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; sourceMap?: boolean;
/** Whether to minify output | 是否压缩输出 */ /** Whether to minify output | 是否压缩输出 */
minify?: boolean; minify?: boolean;
/** Output format | 输出格式 */ /** Output format (default: 'esm') | 输出格式(默认:'esm'*/
format?: 'esm' | 'iife'; format?: 'esm' | 'iife';
/** /**
* SDK modules for shim generation. * SDK modules information (reserved for future use).
* 用于生成 shim 的 SDK 模块列表 * SDK 模块信息(保留供将来使用)
* *
* If provided, shims will be created for these modules. * Currently SDK is handled via external dependencies and global variable.
* Typically obtained from RuntimeResolver.getAvailableModules(). * 当前 SDK 通过外部依赖和全局变量处理。
* 如果提供,将为这些模块创建 shim。
* 通常从 RuntimeResolver.getAvailableModules() 获取。
*/ */
sdkModules?: SDKModuleInfo[]; sdkModules?: SDKModuleInfo[];
} }
@@ -382,6 +380,37 @@ export interface IUserCodeService {
* 检查是否正在监视。 * 检查是否正在监视。
*/ */
isWatching(): boolean; 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'; import { EditorConfig } from '../../Config';

View File

@@ -11,7 +11,7 @@ import {
Injectable, Injectable,
createLogger, createLogger,
PlatformDetector, PlatformDetector,
ComponentRegistry as CoreComponentRegistry, GlobalComponentRegistry as CoreComponentRegistry,
COMPONENT_TYPE_NAME, COMPONENT_TYPE_NAME,
SYSTEM_TYPE_NAME SYSTEM_TYPE_NAME
} from '@esengine/ecs-framework'; } from '@esengine/ecs-framework';
@@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService {
*/ */
private _hotReloadCoordinator: HotReloadCoordinator; private _hotReloadCoordinator: HotReloadCoordinator;
/**
* 就绪状态 Promise
* Ready state promise
*/
private _readyPromise: Promise<void>;
private _readyResolve: (() => void) | undefined;
constructor(fileSystem: IFileSystem) { constructor(fileSystem: IFileSystem) {
this._fileSystem = fileSystem; this._fileSystem = fileSystem;
this._hotReloadCoordinator = new HotReloadCoordinator(); 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`; const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`;
await this._fileSystem.writeFile(entryPath, entryContent); await this._fileSystem.writeFile(entryPath, entryContent);
// Create shim files for framework dependencies | 创建框架依赖的 shim 文件 // Get external dependencies | 获取外部依赖
// Returns mapping from package name to shim path // SDK marked as external, resolved from global variable at runtime
// 返回包名到 shim 路径的映射 // SDK 标记为外部依赖,运行时从全局变量解析
const alias = await this._createDependencyShims(outputDir, options.sdkModules); const external = this._getExternalDependencies(options.target, options.sdkModules);
// Determine global name for IIFE output | 确定 IIFE 输出的全局名称
const globalName = options.target === UserCodeTarget.Runtime
? EditorConfig.globals.userRuntimeExports
: EditorConfig.globals.userEditorExports;
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译 // Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
// Use IIFE format to avoid ES module import issues in Tauri // Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载
// 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题
const compileResult = await this._runEsbuild({ const compileResult = await this._runEsbuild({
entryPath, entryPath,
outputPath, outputPath,
format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import()
globalName,
sourceMap: options.sourceMap ?? true, sourceMap: options.sourceMap ?? true,
minify: options.minify ?? false, minify: options.minify ?? false,
external: [], // Don't use external, use alias instead | 不使用 external使用 alias external,
alias,
projectRoot: options.projectPath projectRoot: options.projectPath
}); });
@@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService {
* Load compiled user code module. * 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 modulePath - Path to compiled JS file | 编译后的 JS 文件路径
* @param target - Target environment | 目标环境 * @param target - Target environment | 目标环境
* @returns Loaded module | 加载的模块 * @returns Loaded module | 加载的模块
@@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService {
let moduleExports: Record<string, any>; let moduleExports: Record<string, any>;
if (PlatformDetector.isTauriEnvironment()) { if (PlatformDetector.isTauriEnvironment()) {
// In Tauri, read file content and execute via script tag // Read file content via Tauri and load via Blob URL
// Tauri 中,读取文件内容并通过 script 标签执行 // 通过 Tauri 读取文件内容并通过 Blob URL 加载
// This avoids CORS and module resolution issues // Browser's import() doesn't support custom protocols like project://
// 这避免了 CORS 和模块解析问题 // 浏览器的 import() 不支持自定义协议如 project://
const { invoke } = await import('@tauri-apps/api/core'); const { invoke } = await import('@tauri-apps/api/core');
const content = await invoke<string>('read_file_content', { const content = await invoke<string>('read_file_content', {
path: modulePath 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 | 执行模块代码并捕获导出 // Load ESM via Blob URL | 通过 Blob URL 加载 ESM
moduleExports = await this._executeModuleCode(content, target); moduleExports = await this._loadESMFromContent(content);
} else { } else {
// Fallback to file:// for non-Tauri environments // Fallback to file:// for non-Tauri environments
// 非 Tauri 环境使用 file:// // 非 Tauri 环境使用 file://
@@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService {
return this._watching; 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. * Dispose service resources.
* 释放服务资源。 * 释放服务资源。
@@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService {
return lines.join('\n'); 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. * 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. * Uses Tauri command to invoke esbuild CLI.
* 使用 Tauri 命令调用 esbuild CLI。 * 使用 Tauri 命令调用 esbuild CLI。
*
* @param options - Compilation options | 编译选项
* @returns Compilation result | 编译结果
*/ */
private async _runEsbuild(options: { private async _runEsbuild(options: {
/** Entry file path | 入口文件路径 */
entryPath: string; entryPath: string;
/** Output file path | 输出文件路径 */
outputPath: string; outputPath: string;
/** Output format (ESM for dynamic import) | 输出格式ESM 用于动态导入)*/
format: 'esm' | 'iife'; format: 'esm' | 'iife';
globalName?: string; /** Generate source maps | 生成源码映射 */
sourceMap: boolean; sourceMap: boolean;
/** Minify output | 压缩输出 */
minify: boolean; minify: boolean;
/** External dependencies (not bundled) | 外部依赖(不打包)*/
external: string[]; external: string[];
alias?: Record<string, string>; /** Project root for resolving paths | 项目根路径用于解析路径 */
projectRoot: string; projectRoot: string;
}): Promise<{ success: boolean; errors: CompileError[] }> { }): Promise<{ success: boolean; errors: CompileError[] }> {
try { try {
@@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
entry: options.entryPath, entry: options.entryPath,
output: options.outputPath, output: options.outputPath,
format: options.format, 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 命令 // Use Tauri command | 使用 Tauri 命令
const { invoke } = await import('@tauri-apps/api/core'); const { invoke } = await import('@tauri-apps/api/core');
@@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
entryPath: options.entryPath, entryPath: options.entryPath,
outputPath: options.outputPath, outputPath: options.outputPath,
format: options.format, format: options.format,
globalName: options.globalName,
sourceMap: options.sourceMap, sourceMap: options.sourceMap,
minify: options.minify, minify: options.minify,
external: options.external, external: options.external,
alias: options.alias,
projectRoot: options.projectRoot 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. * Uses Blob URL to enable dynamic import() of ESM content.
* 代码应该是设置全局变量的 IIFE 格式 * 使用 Blob URL 实现 ESM 内容的动态 import()
* *
* @param code - Compiled JavaScript code | 编译后的 JavaScript 代码 * @param content - JavaScript module content (ESM format) | JavaScript 模块内容ESM 格式)
* @param target - Target environment | 目标环境
* @returns Module exports | 模块导出 * @returns Module exports | 模块导出
*/ */
private async _executeModuleCode( private async _loadESMFromContent(content: string): Promise<Record<string, any>> {
code: string, // Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL
target: UserCodeTarget const blob = new Blob([content], { type: 'application/javascript' });
): Promise<Record<string, any>> { const blobUrl = URL.createObjectURL(blob);
// 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;
try { try {
// esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); // Dynamic import the ESM module | 动态导入 ESM 模块
// When executed via new Function(), var declarations stay in function scope const moduleExports = await import(/* @vite-ignore */ blobUrl);
// 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}`
);
// Execute the IIFE code | 执行 IIFE 代码 // Return all exports | 返回所有导出
// eslint-disable-next-line no-new-func return { ...moduleExports };
const executeScript = new Function(modifiedCode); } finally {
executeScript(); // Always revoke Blob URL to prevent memory leaks
// 始终撤销 Blob URL 以防止内存泄漏
// Get exports from global | 从全局获取导出 URL.revokeObjectURL(blobUrl);
const exports = (window as any)[globalName] || {};
return exports;
} catch (error) {
logger.error('Failed to execute user code | 执行用户代码失败:', error);
throw error;
} }
} }

View File

@@ -43,9 +43,10 @@
* ↓ * ↓
* [UserCodeService.scan()] - Discovers all scripts * [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 * [registerComponents()] - Registers with ECS runtime
* [registerEditorExtensions()] - Registers inspectors/gizmos * [registerEditorExtensions()] - Registers inspectors/gizmos
@@ -53,6 +54,16 @@
* [UserCodeService.watch()] - Hot reload on file changes * [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 | 用户组件示例 * # Example User Component | 用户组件示例
* *
* ```typescript * ```typescript