From 0170dc6e9c8c79eb9017f89733a1b3f79ad669e1 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Tue, 16 Dec 2025 11:17:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor-core):=20=E6=B7=BB=E5=8A=A0=20UserC?= =?UTF-8?q?odeService=20=E5=B0=B1=E7=BB=AA=E4=BF=A1=E5=8F=B7=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 waitForReady()/signalReady() API - 支持等待用户脚本编译完成 - 解决场景加载时组件未注册的时序问题 --- .../src/Services/UserCode/IUserCodeService.ts | 43 +++- .../src/Services/UserCode/UserCodeService.ts | 210 +++++++++--------- .../src/Services/UserCode/index.ts | 15 +- 3 files changed, 150 insertions(+), 118 deletions(-) diff --git a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts index 9f60c181..f5790bf4 100644 --- a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts @@ -71,16 +71,14 @@ export interface UserCodeCompileOptions { sourceMap?: boolean; /** Whether to minify output | 是否压缩输出 */ minify?: boolean; - /** Output format | 输出格式 */ + /** Output format (default: 'esm') | 输出格式(默认:'esm')*/ format?: 'esm' | 'iife'; /** - * SDK modules for shim generation. - * 用于生成 shim 的 SDK 模块列表。 + * SDK modules information (reserved for future use). + * SDK 模块信息(保留供将来使用)。 * - * If provided, shims will be created for these modules. - * Typically obtained from RuntimeResolver.getAvailableModules(). - * 如果提供,将为这些模块创建 shim。 - * 通常从 RuntimeResolver.getAvailableModules() 获取。 + * Currently SDK is handled via external dependencies and global variable. + * 当前 SDK 通过外部依赖和全局变量处理。 */ sdkModules?: SDKModuleInfo[]; } @@ -382,6 +380,37 @@ export interface IUserCodeService { * 检查是否正在监视。 */ isWatching(): boolean; + + /** + * Wait for user code to be ready (compiled and loaded). + * 等待用户代码准备就绪(已编译并加载)。 + * + * This method is used to synchronize scene loading with user code compilation. + * Call this before loading a scene to ensure user components are registered. + * 此方法用于同步场景加载与用户代码编译。 + * 在加载场景之前调用此方法以确保用户组件已注册。 + * + * @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise + */ + waitForReady(): Promise; + + /** + * Signal that user code is ready. + * 发出用户代码就绪信号。 + * + * Called after user code compilation and registration is complete. + * 在用户代码编译和注册完成后调用。 + */ + signalReady(): void; + + /** + * Reset the ready state (for project switching). + * 重置就绪状态(用于项目切换)。 + * + * Called when opening a new project to reset the ready promise. + * 打开新项目时调用以重置就绪 Promise。 + */ + resetReady(): void; } import { EditorConfig } from '../../Config'; diff --git a/packages/editor-core/src/Services/UserCode/UserCodeService.ts b/packages/editor-core/src/Services/UserCode/UserCodeService.ts index 3f3d59a0..09ad8b2d 100644 --- a/packages/editor-core/src/Services/UserCode/UserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/UserCodeService.ts @@ -11,7 +11,7 @@ import { Injectable, createLogger, PlatformDetector, - ComponentRegistry as CoreComponentRegistry, + GlobalComponentRegistry as CoreComponentRegistry, COMPONENT_TYPE_NAME, SYSTEM_TYPE_NAME } from '@esengine/ecs-framework'; @@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService { */ private _hotReloadCoordinator: HotReloadCoordinator; + /** + * 就绪状态 Promise + * Ready state promise + */ + private _readyPromise: Promise; + private _readyResolve: (() => void) | undefined; + constructor(fileSystem: IFileSystem) { this._fileSystem = fileSystem; this._hotReloadCoordinator = new HotReloadCoordinator(); + this._readyPromise = this._createReadyPromise(); + } + + /** + * Create a new ready promise. + * 创建新的就绪 Promise。 + */ + private _createReadyPromise(): Promise { + return new Promise(resolve => { + this._readyResolve = resolve; + }); } /** @@ -190,28 +208,20 @@ export class UserCodeService implements IService, IUserCodeService { const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`; await this._fileSystem.writeFile(entryPath, entryContent); - // Create shim files for framework dependencies | 创建框架依赖的 shim 文件 - // Returns mapping from package name to shim path - // 返回包名到 shim 路径的映射 - const alias = await this._createDependencyShims(outputDir, options.sdkModules); - - // Determine global name for IIFE output | 确定 IIFE 输出的全局名称 - const globalName = options.target === UserCodeTarget.Runtime - ? EditorConfig.globals.userRuntimeExports - : EditorConfig.globals.userEditorExports; + // Get external dependencies | 获取外部依赖 + // SDK marked as external, resolved from global variable at runtime + // SDK 标记为外部依赖,运行时从全局变量解析 + const external = this._getExternalDependencies(options.target, options.sdkModules); // Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译 - // Use IIFE format to avoid ES module import issues in Tauri - // 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题 + // Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载 const compileResult = await this._runEsbuild({ entryPath, outputPath, - format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri - globalName, + format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import() sourceMap: options.sourceMap ?? true, minify: options.minify ?? false, - external: [], // Don't use external, use alias instead | 不使用 external,使用 alias - alias, + external, projectRoot: options.projectPath }); @@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService { * Load compiled user code module. * 加载编译后的用户代码模块。 * + * Uses Blob URL for ESM dynamic import in Tauri environment. + * 在 Tauri 环境中使用 Blob URL 进行 ESM 动态导入。 + * + * Note: Browser's import() only supports http://, https://, and blob:// protocols. + * Custom protocols like project:// are not supported for ESM imports. + * 注意:浏览器的 import() 只支持 http://、https:// 和 blob:// 协议。 + * 自定义协议如 project:// 不支持 ESM 导入。 + * * @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径 * @param target - Target environment | 目标环境 * @returns Loaded module | 加载的模块 @@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService { let moduleExports: Record; if (PlatformDetector.isTauriEnvironment()) { - // In Tauri, read file content and execute via script tag - // 在 Tauri 中,读取文件内容并通过 script 标签执行 - // This avoids CORS and module resolution issues - // 这避免了 CORS 和模块解析问题 + // Read file content via Tauri and load via Blob URL + // 通过 Tauri 读取文件内容并通过 Blob URL 加载 + // Browser's import() doesn't support custom protocols like project:// + // 浏览器的 import() 不支持自定义协议如 project:// const { invoke } = await import('@tauri-apps/api/core'); const content = await invoke('read_file_content', { path: modulePath }); - logger.debug(`Loading module via script injection`, { originalPath: modulePath }); + logger.debug(`Loading ESM module via Blob URL`, { + path: modulePath, + contentLength: content.length + }); - // Execute module code and capture exports | 执行模块代码并捕获导出 - moduleExports = await this._executeModuleCode(content, target); + // Load ESM via Blob URL | 通过 Blob URL 加载 ESM + moduleExports = await this._loadESMFromContent(content); } else { // Fallback to file:// for non-Tauri environments // 非 Tauri 环境使用 file:// @@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService { return this._watching; } + /** + * Wait for user code to be ready (compiled and loaded). + * 等待用户代码准备就绪(已编译并加载)。 + * + * @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise + */ + waitForReady(): Promise { + return this._readyPromise; + } + + /** + * Signal that user code is ready. + * 发出用户代码就绪信号。 + */ + signalReady(): void { + if (this._readyResolve) { + this._readyResolve(); + this._readyResolve = undefined; + } + } + + /** + * Reset the ready state (for project switching). + * 重置就绪状态(用于项目切换)。 + */ + resetReady(): void { + this._readyPromise = this._createReadyPromise(); + } + /** * Dispose service resources. * 释放服务资源。 @@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService { return lines.join('\n'); } - /** - * Create shim file that maps SDK global variable to module import. - * 创建将 SDK 全局变量映射到模块导入的 shim 文件。 - * - * This is used for IIFE format to resolve external dependencies. - * Creates a single shim for @esengine/sdk. - * 这用于 IIFE 格式解析外部依赖。 - * 只创建一个 @esengine/sdk 的 shim。 - * - * @param outputDir - Output directory | 输出目录 - * @param _sdkModules - Deprecated, not used | 已废弃,不再使用 - * @returns Mapping from package name to shim path | 包名到 shim 路径的映射 - */ - private async _createDependencyShims( - outputDir: string, - _sdkModules?: SDKModuleInfo[] - ): Promise> { - const sep = outputDir.includes('\\') ? '\\' : '/'; - const sdkGlobalName = EditorConfig.globals.sdk; - - // Create single SDK shim - // 创建单一 SDK shim - const shimPath = `${outputDir}${sep}_shim_sdk.js`; - const shimContent = `// Shim for @esengine/sdk -// Maps to window.${sdkGlobalName} -// User code imports from '@esengine/sdk' will use this shim -module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {}; -`; - await this._fileSystem.writeFile(shimPath, shimContent); - const normalizedPath = shimPath.replace(/\\/g, '/'); - - logger.info('Created SDK shim', { path: normalizedPath }); - - return { - '@esengine/sdk': normalizedPath - }; - } - /** * Get external dependencies that should not be bundled. * 获取不应打包的外部依赖。 @@ -1122,16 +1134,24 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { * * Uses Tauri command to invoke esbuild CLI. * 使用 Tauri 命令调用 esbuild CLI。 + * + * @param options - Compilation options | 编译选项 + * @returns Compilation result | 编译结果 */ private async _runEsbuild(options: { + /** Entry file path | 入口文件路径 */ entryPath: string; + /** Output file path | 输出文件路径 */ outputPath: string; + /** Output format (ESM for dynamic import) | 输出格式(ESM 用于动态导入)*/ format: 'esm' | 'iife'; - globalName?: string; + /** Generate source maps | 生成源码映射 */ sourceMap: boolean; + /** Minify output | 压缩输出 */ minify: boolean; + /** External dependencies (not bundled) | 外部依赖(不打包)*/ external: string[]; - alias?: Record; + /** Project root for resolving paths | 项目根路径用于解析路径 */ projectRoot: string; }): Promise<{ success: boolean; errors: CompileError[] }> { try { @@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { entry: options.entryPath, output: options.outputPath, format: options.format, - aliasCount: options.alias ? Object.keys(options.alias).length : 0 + external: options.external }); - if (options.alias) { - logger.debug('esbuild alias mappings:', options.alias); - } - // Use Tauri command | 使用 Tauri 命令 const { invoke } = await import('@tauri-apps/api/core'); @@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { entryPath: options.entryPath, outputPath: options.outputPath, format: options.format, - globalName: options.globalName, sourceMap: options.sourceMap, minify: options.minify, external: options.external, - alias: options.alias, projectRoot: options.projectRoot } }); @@ -1206,52 +1220,30 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { } /** - * Execute compiled module code and return exports. - * 执行编译后的模块代码并返回导出。 + * Load ESM module from JavaScript content string. + * 从 JavaScript 内容字符串加载 ESM 模块。 * - * The code should be in IIFE format that sets a global variable. - * 代码应该是设置全局变量的 IIFE 格式。 + * Uses Blob URL to enable dynamic import() of ESM content. + * 使用 Blob URL 实现 ESM 内容的动态 import()。 * - * @param code - Compiled JavaScript code | 编译后的 JavaScript 代码 - * @param target - Target environment | 目标环境 + * @param content - JavaScript module content (ESM format) | JavaScript 模块内容(ESM 格式) * @returns Module exports | 模块导出 */ - private async _executeModuleCode( - code: string, - target: UserCodeTarget - ): Promise> { - // Determine global name based on target | 根据目标确定全局名称 - const globalName = target === UserCodeTarget.Runtime - ? EditorConfig.globals.userRuntimeExports - : EditorConfig.globals.userEditorExports; - - // Clear any previous exports | 清除之前的导出 - (window as any)[globalName] = undefined; + private async _loadESMFromContent(content: string): Promise> { + // Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL + const blob = new Blob([content], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); try { - // esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); - // When executed via new Function(), var declarations stay in function scope - // We need to replace "var globalName" with "window.globalName" to expose it - // esbuild 生成: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); - // 通过 new Function() 执行时,var 声明在函数作用域内 - // 需要替换 "var globalName" 为 "window.globalName" 以暴露到全局 - const modifiedCode = code.replace( - new RegExp(`^"use strict";\\s*var ${globalName}`, 'm'), - `"use strict";\nwindow.${globalName}` - ); + // Dynamic import the ESM module | 动态导入 ESM 模块 + const moduleExports = await import(/* @vite-ignore */ blobUrl); - // Execute the IIFE code | 执行 IIFE 代码 - // eslint-disable-next-line no-new-func - const executeScript = new Function(modifiedCode); - executeScript(); - - // Get exports from global | 从全局获取导出 - const exports = (window as any)[globalName] || {}; - - return exports; - } catch (error) { - logger.error('Failed to execute user code | 执行用户代码失败:', error); - throw error; + // Return all exports | 返回所有导出 + return { ...moduleExports }; + } finally { + // Always revoke Blob URL to prevent memory leaks + // 始终撤销 Blob URL 以防止内存泄漏 + URL.revokeObjectURL(blobUrl); } } diff --git a/packages/editor-core/src/Services/UserCode/index.ts b/packages/editor-core/src/Services/UserCode/index.ts index 183c4ec3..bc0b0a0a 100644 --- a/packages/editor-core/src/Services/UserCode/index.ts +++ b/packages/editor-core/src/Services/UserCode/index.ts @@ -43,9 +43,10 @@ * ↓ * [UserCodeService.scan()] - Discovers all scripts * ↓ - * [UserCodeService.compile()] - Compiles to JS using esbuild + * [UserCodeService.compile()] - Compiles to ESM using esbuild + * (@esengine/sdk marked as external) * ↓ - * [UserCodeService.load()] - Loads compiled module + * [UserCodeService.load()] - Loads via project:// protocol + import() * ↓ * [registerComponents()] - Registers with ECS runtime * [registerEditorExtensions()] - Registers inspectors/gizmos @@ -53,6 +54,16 @@ * [UserCodeService.watch()] - Hot reload on file changes * ``` * + * # Architecture | 架构 + * + * - **Compilation**: ESM format with `external: ['@esengine/sdk']` + * - **Loading**: Reads file via Tauri, loads via Blob URL + import() + * - **Runtime**: SDK accessed via `window.__ESENGINE_SDK__` global + * - **Hot Reload**: File watching via Rust backend + Tauri events + * + * Note: Browser's import() only supports http/https/blob protocols. + * Custom protocols like project:// are not supported for ESM imports. + * * # Example User Component | 用户组件示例 * * ```typescript