feat(editor-core): 添加 UserCodeService 就绪信号机制

- 新增 waitForReady()/signalReady() API
- 支持等待用户脚本编译完成
- 解决场景加载时组件未注册的时序问题
This commit is contained in:
yhh
2025-12-16 11:17:19 +08:00
parent 7834328ae0
commit 0170dc6e9c
3 changed files with 150 additions and 118 deletions

View File

@@ -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<void>;
/**
* 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';

View File

@@ -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<void>;
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<void> {
return new Promise<void>(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<string, any>;
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<string>('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<void> {
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<Record<string, string>> {
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<string, string>;
/** 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<Record<string, any>> {
// 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<Record<string, any>> {
// 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);
}
}

View File

@@ -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