--- tags: - UE - Puerts - EasyEditorPlugin - 改造方案 - TypeScript - 架构 created: 2026-05-31 --- # 改造方案:将业务 TS 代码嵌入插件 ## 需求背景 目前 EasyEditorPlugin 将业务 TypeScript 代码放在**项目的 `Content/EasyEditorScripts/` 目录**中,插件本身只是一个薄桥接层。我们希望能够: 1. **业务 TS 代码跟随插件分发**(写在插件的 Content/Resources 目录中) 2. **项目 C++ 代码直接调用插件中的 TS 函数**(类型安全,有返回值) 3. **项目代码只需最少量的调用**(LoadModule + 少量 API 调用) ## 可行性评估 | 需求 | 可行性 | 改造量 | |------|--------|--------| | TS 代码嵌入插件 Content | ✅ 可行,自定义 IJSModuleLoader | 小(~50 行 C++) | | C++ 调用插件 TS 函数 | ✅ 可行,暴露 FJsObject 获取 API | 中(~100 行 C++) | | TS 代码热重载 | ✅ 已有基础设施,仅需调整路径 | 无需改动 | | 运行时(非 Editor)支持 | ✅ 可行,但需创建新 Runtime 模块 | 大(架构变更) | ## 方案一:改造 EasyEditorPlugin(编辑器工具场景) 适用场景:**编辑器工具扩展**,业务代码是编辑器工具逻辑(如资产处理、关卡自动化等),不需要在打包后的游戏中运行。 ### 改造点 1:自定义 IJSModuleLoader,从插件 Content 加载 #### 当前代码 ```cpp // EasyEditorPlugin.cpp InitJsEnv() JsEnv = MakeShared( std::make_shared(TEXT("EasyEditorScripts")), ...); ``` **问题**:`DefaultJSModuleLoader` 只在项目 Content 目录搜索,不会搜索插件目录。 #### 改造后 新建 `PluginJSModuleLoader.h`: ```cpp #pragma once #include "JSModuleLoader.h" #include "Misc/Paths.h" class FPluginJSModuleLoader : public PUERTS_NAMESPACE::IJSModuleLoader { public: explicit FPluginJSModuleLoader(const FString& InPluginContentSubDir) : ScriptRoot(InPluginContentSubDir) { // 获取插件 Content 目录 // EasyEditorPlugin 中: FPaths::ProjectPluginsDir() / "EasyEditorPlugin/Content" PluginContentDir = DeterminePluginContentDir(); } virtual bool Search(const FString& RequiredDir, const FString& RequiredModule, FString& Path, FString& AbsolutePath) override { // 搜索顺序: // 1. 先在插件的 Content/ScriptRoot/ 下搜索 // 2. 再走默认的 DefaultJSModuleLoader 逻辑(项目 Content、node_modules 等) // 插件目录搜索 FString PluginDir = PluginContentDir / ScriptRoot; if (SearchModuleInDir(PluginDir, RequiredModule, Path, AbsolutePath)) return true; // 回退到默认搜索逻辑 return DefaultLoader.Search(RequiredDir, RequiredModule, Path, AbsolutePath); } virtual bool Load(const FString& Path, TArray& Content) override { return DefaultLoader.Load(Path, Content); } virtual FString& GetScriptRoot() override { return ScriptRoot; } static FString DeterminePluginContentDir() { // 获取当前插件自身的 Content 目录 // 方案 A:通过 IPluginManager 查询 TSharedPtr Plugin = IPluginManager::Get() .FindPlugin(TEXT("EasyEditorPlugin")); if (Plugin.IsValid()) { return Plugin->GetContentDir(); } // 方案 B:手动拼路径(兼容旧版本) return FPaths::ProjectPluginsDir() / TEXT("EasyEditorPlugin/Content"); } private: FString ScriptRoot; FString PluginContentDir; PUERTS_NAMESPACE::DefaultJSModuleLoader DefaultLoader; bool SearchModuleInDir(const FString& Dir, const FString& RequiredModule, FString& Path, FString& AbsolutePath) { // 尝试扩展名: .js, .mjs, .cjs, .mbc, .cbc, /index.js, package.json static const TArray Extensions = { TEXT(".js"), TEXT(".mjs"), TEXT(".cjs"), TEXT(".mbc"), TEXT(".cbc") }; for (const auto& Ext : Extensions) { FString TestPath = Dir / RequiredModule + Ext; if (FPaths::FileExists(TestPath)) { Path = RequiredModule + Ext; AbsolutePath = FPaths::ConvertRelativePathToFull(TestPath); return true; } } // 尝试目录 + index.js FString IndexPath = Dir / RequiredModule / TEXT("index.js"); if (FPaths::FileExists(IndexPath)) { Path = RequiredModule / TEXT("index.js"); AbsolutePath = FPaths::ConvertRelativePathToFull(IndexPath); return true; } return false; } }; ``` #### 修改 InitJsEnv() ```cpp void FEasyEditorPluginModule::InitJsEnv() { SourceFileWatcher = MakeShared( [this](const FString& InPath) { /* 同上 */ }); // 使用自定义 Loader,从插件 Content/BusinessScripts/ 加载 JsEnv = MakeShared( std::make_shared(TEXT("BusinessScripts")), std::make_shared(), -1, [this](const FString& InPath) { /* 同上 */ }); } ``` #### 插件目录结构变为 ``` EasyEditorPlugin/ ├── Content/ │ └── BusinessScripts/ ← TS 业务代码(随插件分发) │ ├── Main.ts ← 入口 │ ├── commands/ │ │ ├── AssetCommands.ts │ │ └── LevelCommands.ts │ ├── ui/ │ │ ├── MenuSetup.ts │ │ └── DetailPanel.ts │ └── utils/ │ └── helpers.ts ├── Source/ │ └── ... └── EasyEditorPlugin.uplugin ``` --- ### 改造点 2:暴露 JsEnv,支持 C++ 类型安全调用 TS #### 问题 ```cpp class FEasyEditorPluginModule { private: TSharedPtr JsEnv; // ← 外部无法访问 }; ``` 外部 C++ 只能通过 `Eval` 执行 JS 字符串,无类型安全、无返回值。 #### 改造:公开 JsEnv 访问和 FJsObject 获取 在 `EasyEditorPlugin.h` 中新增: ```cpp class FEasyEditorPluginModule : public IModuleInterface { public: // ===== 新增 API ===== /** * 从 JS 全局作用域获取一个函数/对象,返回 FJsObject 用于类型安全调用 * 示例: * auto& Mod = FModuleManager::LoadModuleChecked("EasyEditorPlugin"); * FJsObject MyFunc = Mod.GetJsExport("myBusinessFunction"); * int32 Result = MyFunc.Func(42, TEXT("hello")); */ FJsObject GetJsExport(const FString& Name) const; /** * 从 JS 模块(已 require 的)中获取一个导出 * 示例: * FJsObject Handler = Mod.GetJsModuleExport("commands/AssetCommands", "handleAsset"); * Handler.Action(TEXT("/Game/MyAsset")); */ FJsObject GetJsModuleExport(const FString& ModuleName, const FString& ExportName) const; /** * 获取底层的 IJsEnv 接口(高级用法) */ TSharedPtr GetJsEnv() const { return JsEnv; } // ===== 原有 API ===== std::function OnJsEnvPreReload; std::function Eval; FSimpleMulticastDelegate OnJsEnvCleanup; TSparseArray> TsConsoleCommands; private: // ... }; ``` #### 实现(`EasyEditorPlugin.cpp`) ```cpp FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const { if (!JsEnv.IsValid()) { UE_LOG(Puerts, Error, TEXT("JsEnv is not valid when getting export '%s'"), *Name); return {}; } // 通过 Eval 机制获取全局变量 // 实际实现需要利用 Puerts 的内部 API // 方案见下方"实现细节" } FJsObject FEasyEditorPluginModule::GetJsModuleExport( const FString& ModuleName, const FString& ExportName) const { if (!JsEnv.IsValid()) { return {}; } // 需要从模块缓存中查找 // 方案见下方"实现细节" } ``` #### 实现细节:如何从 JsEnv 中获取 JS 对象 **核心问题**:`FJsEnv` 的 `FJsEnvImpl` 成员是私有的,没有公开的 "通过名称获取 JS 变量" API。需要通过以下方式实现: **方案 A:通过 `puerts::FJsEnv` 包装器暴露新方法** 需要在 Puerts 的 `FJsEnv` 中新增一个方法: ```cpp // 在 JsEnv.h 中新增 class JSENV_API FJsEnv { public: // ... 现有 API ... /** * 获取全局作用域中的一个值 * @param Name 全局变量/函数名 * @return 包装了该 JS 值的 FJsObject,如果不存在或类型不对则返回空 */ FJsObject GetGlobalValue(const FString& Name); }; ``` 实现(在 `JsEnvImpl.cpp` 中): ```cpp FJsObject FJsEnv::GetGlobalValue(const FString& Name) { if (!GameScript) return {}; auto Impl = static_cast(GameScript.get()); auto Isolate = Impl->MainIsolate; auto Context = Impl->DefaultContext.Get(Isolate); #ifdef THREAD_SAFE v8::Locker Locker(Isolate); #endif v8::Isolate::Scope IsolateScope(Isolate); v8::HandleScope HandleScope(Isolate); v8::Context::Scope ContextScope(Context); auto Global = Context->Global(); auto V8Name = FV8Utils::ToV8String(Isolate, Name); auto MaybeValue = Global->Get(Context, V8Name); v8::Local Val; if (MaybeValue.ToLocal(&Val) && Val->IsObject()) { return FJsObject(Context, Val.As()); } return {}; } ``` 但这种方式需要 **修改 Puerts 源码**(在 `JsEnv.h` 和 `JsEnvImpl.cpp` 中增加代码),对于已经安装的 Puerts 插件不太方便。 **方案 B:在 EasyEditorPlugin 中注入 JS 辅助函数(推荐,无需修改 Puerts)** 思路:在 `InitJsEnv()` 时,向 JS 全局注入一个 `__eep_getExport` 函数,同时在 C++ 中存储引用。 ```cpp // EasyEditorPlugin.cpp // 存储注入的 JS 辅助函数 static FJsObject G_GetExportFunc; void FEasyEditorPluginModule::InitJsEnv() { // ... 创建 JsEnv ... JsEnv->Start(TEXT("Main")); // Main.ts 执行后,获取注入的辅助函数引用 // Main.ts 中需要暴露: // global.__eep_getExport = (name) => { ... return eval(name); } // global.__eep_getModuleExport = (moduleName, exportName) => { ... } } ``` 但这形成了一个鸡生蛋蛋生鸡的问题 —— 在 Main.ts 执行前无法获取引用。 **方案 C:使用 Puerts 的 JIT stub 机制(最务实)** 在 EasyEditorPlugin 的绑定代码中注册可被 `require()` 调用的 C++ 函数: ```cpp // 在 EasyEditorPlugin.cpp 的 AutoRegisterForEEP 中新增绑定: struct FEasyEditorBridge { static puerts::Function GetGlobalFunction; static FJsObject GetGlobalObject; // 注册到 Puerts binding }; ``` 然后在 Main.ts 中: ```typescript // 将需要暴露给 C++ 的函数注册到 global 对象 import { MyBusinessLogic } from './business/BusinessLogic'; (global as any).__exports = { processAsset: MyBusinessLogic.processAsset, validateLevel: MyBusinessLogic.validateLevel, // ... }; ``` C++ 侧通过 Eval 获取: ```cpp FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const { // 利用 Eval 机制代理 // 实际方案:在 V8 中执行 "__exports[Name]",捕获结果 // 但当前 Eval 是 void 返回的... // 需要改造 Eval 为有返回值的版本,见下文 } ``` **方案 D:改造 Eval 为有返回值的版本(最小侵入)** ```cpp // 在 EasyEditorPlugin.h 中 class FEasyEditorPluginModule { public: // 替代原来的 void Eval,使用可返回值的版本 std::function EvalWithReturn; }; ``` 在 `InitJsEnv()` 中注册对应的 C++ 绑定: ```cpp // 利用 Puerts 的 FV8Utils 实现有返回值的 Eval // 在 C++ 侧: 持有 v8::Isolate 和 v8::Context,直接 eval 并封装结果 ``` **方案 E:最简单且实用的方案 —— 通过 Console 命令 + 注册回调** 不暴露 JsEnv,而是让项目 C++ 注册一个回调,TS 通过 `EasyEditorPlugin.AddConsoleCommand` 或其他绑定来调用它: ```cpp // 项目 C++ 代码中使用 EasyEditorPlugin void FMyProjectModule::StartupModule() { auto& EEP = FModuleManager::LoadModuleChecked("EasyEditorPlugin"); // 在 JS 环境就绪后注册 EEP.OnJsEnvCreated.AddLambda([this]() { // 告诉 TS 侧:C++ 准备好了 EEP.Eval(TEXT("if (global.__onCppReady) global.__onCppReady()")); }); } ``` 但这种模式是 **TS 调用 C++**,而非用户需求的 **C++ 调用 TS**。 --- ### 推荐的 C++ → TS 调用方案总结 | 方案 | 侵入性 | 类型安全 | 需要修改 Puerts | 推荐度 | |------|--------|---------|----------------|--------| | A: 扩展 FJsEnv::GetGlobalValue | 低 | ✅ | ❌ 需要 | ⭐⭐⭐ | | B: 注入 JS 辅助函数 | 中 | ✅ | 不需要 | ⭐⭐ | | C: require() 注册 | 中 | ✅ | 不需要 | ⭐⭐ | | D: Eval 改造成有返回值 | 低 | ❌ 需手动转换 | 不需要 | ⭐⭐ | | E: 回调注册 | 低 | ❌ | 不需要 | ⭐(反向模式) | **最佳实践:方案 C(PUERTS_MODULE addon)+ 方案 A(扩展 FJsEnv)** 在实际项目中,建议**对 Puerts 做少量扩展**(fork 或 patch),因为 Puerts 本身设计就预留了扩展点。 --- ## 方案二:基于 Puerts 创建自己的 Runtime 插件(运行时代码场景) 如果业务代码需要在**打包后的游戏**中运行(Gameplay 逻辑、AI、战斗系统等),EasyEditorPlugin 的 Editor-only 限制是致命的。 此时正确的做法是:**直接基于 Puerts 创建自己的 Runtime 插件**。 ### 插件骨架 ``` MyBusinessPlugin/ ├── MyBusinessPlugin.uplugin ├── Content/ │ └── BusinessScripts/ ← TS 业务代码 │ ├── Main.ts ← 入口 │ ├── combat/ │ ├── ai/ │ └── ui/ └── Source/ └── MyBusinessPlugin/ ├── MyBusinessPlugin.Build.cs ├── Public/ │ └── MyBusinessPluginModule.h │ └── BusinessAPI.h ← 公开给项目 C++ 的 API └── Private/ ├── MyBusinessPluginModule.cpp └── MyBinding.cpp ← PUERTS_MODULE 注册 ``` ### .uplugin ```json { "FriendlyName": "MyBusinessPlugin", "Modules": [{ "Name": "MyBusinessPlugin", "Type": "Runtime", // ← Runtime!可在打包游戏中运行 "LoadingPhase": "Default" }], "Plugins": [{ "Name": "Puerts", "Enabled": true }] } ``` ### Build.cs ```csharp PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "JsEnv" // ← 依赖 Puerts 的 JsEnv }); ``` ### `MyBusinessPluginModule.h` ```cpp #pragma once #include "CoreMinimal.h" #include "Modules/ModuleManager.h" #include "JsEnv.h" #include "JsObject.h" class FMyBusinessPluginModule : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; // 公开给项目 C++ 的 API FJsObject GetExport(const FString& Name) const; TSharedPtr GetJsEnv() const { return JsEnv; } private: TSharedPtr JsEnv; void InitJsEnv(); void UnInitJsEnv(); }; ``` ### `MyBusinessPluginModule.cpp` ```cpp #include "MyBusinessPluginModule.h" #include "JSModuleLoader.h" #include "Misc/Paths.h" #include "Interfaces/IPluginManager.h" void FMyBusinessPluginModule::StartupModule() { InitJsEnv(); } void FMyBusinessPluginModule::ShutdownModule() { UnInitJsEnv(); } void FMyBusinessPluginModule::InitJsEnv() { // 获取插件 Content 目录 TSharedPtr Plugin = IPluginManager::Get() .FindPlugin(TEXT("MyBusinessPlugin")); FString PluginContent = Plugin.IsValid() ? Plugin->GetContentDir() : FPaths::ProjectPluginsDir() / TEXT("MyBusinessPlugin/Content"); // 创建自定义 ModuleLoader,从插件 Content/BusinessScripts/ 加载 auto Loader = MakeShared( PluginContent / TEXT("BusinessScripts")); JsEnv = MakeShared( Loader, MakeShared(), -1, // debugPort, -1 = 禁用调试 nullptr, FString(), nullptr, nullptr); // 启动业务代码入口 JsEnv->Start(TEXT("Main")); } void FMyBusinessPluginModule::UnInitJsEnv() { JsEnv.Reset(); } FJsObject FMyBusinessPluginModule::GetExport(const FString& Name) const { // 实现同方案一的讨论 // 推荐方式: 对 Puerts 做 patch,给 FJsEnv 加 GetGlobalValue } ``` ### `BusinessAPI.h` — 给项目 C++ 的调用接口 ```cpp #pragma once #include "CoreMinimal.h" #include "JsObject.h" namespace BusinessAPI { // 获取插件的 JsEnv TSharedPtr GetBusinessJsEnv(); // 获取 JS 导出函数 PUERTS_NAMESPACE::FJsObject GetExport(const FString& Name); // 便捷调用封装 inline int32 ValidateLevel(const FString& LevelPath) { auto Func = GetExport(TEXT("validateLevel")); if ( /* Func is valid */ ) { return Func.Func(LevelPath); } return -1; } } ``` ### 项目 C++ 调用方式 ```cpp // 项目 C++ 代码 —— 极简调用 void AMyGameMode::BeginPlay() { Super::BeginPlay(); // 加载插件(自动启动 TS 业务逻辑) FModuleManager::Get().LoadModule("MyBusinessPlugin"); // 调用 TS 函数 auto Func = BusinessAPI::GetExport(TEXT("onGameStart")); Func.Action(this); // 传递 UObject,自动转换为 TS 侧 UE 类型 } ``` --- ## 热重载支持 无论方案一还是方案二,热重载都是可用的。只需确保 `FSourceFileWatcher` 监听的是正确的目录: ```cpp // 插件 Content 目录下的 BusinessScripts/ FString WatchDir = PluginContentDir / TEXT("BusinessScripts"); SourceFileWatcher = MakeShared( [this](const FString& InPath) { if (JsEnv.IsValid()) { TArray Source; if (FFileHelper::LoadFileToArray(Source, *InPath)) { JsEnv->ReloadSource(InPath, puerts::PString((const char*)Source.GetData(), Source.Num())); } } }); ``` --- ## 改造清单 ### 最小改造(编辑器工具,方案一) | 步骤 | 文件 | 改动 | |------|------|------| | 1 | 新建 `PluginJSModuleLoader.h` | 自定义模块加载器,从插件 Content 搜索 | | 2 | `EasyEditorPlugin.cpp:InitJsEnv()` | 切换为 `FPluginJSModuleLoader` | | 3 | `EasyEditorPlugin.h` | 添加 `GetJsEnv()` 和 `GetJsExport()` | | 4 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 | | 5 | (可选) Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 | ### 完整改造(Runtime 支持,方案二) | 步骤 | 文件 | 改动 | |------|------|------| | 1 | 创建新插件 `MyBusinessPlugin` | `.uplugin`, `Build.cs`, 目录结构 | | 2 | `PluginJSModuleLoader.h`(复用) | 自定义模块加载器 | | 3 | `MyBusinessPluginModule.h/cpp` | 模块实现,参考 EasyEditorPlugin | | 4 | `BusinessAPI.h` | 公开 C++ 调用 API | | 5 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 | | 6 | Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 | | 7 | 项目 `.uproject` | 添加插件依赖 | --- ## 注意事项 1. **线程安全**:FJsObject 在 `THREAD_SAFE` 模式下自动加锁,但建议所有 TS 调用都在 GameThread 进行 2. **JsEnv 生命周期**:确保在 `ShutdownModule()` 中正确销毁 JsEnv,避免 V8 isolate 泄漏 3. **GC 联动**:参考 EasyEditorPlugin 的地图卸载时 GC 处理,避免 "GC Memory Leak" 断言 4. **模块解析**:自定义 `IJSModuleLoader` 需要正确实现 `Search` 和 `Load` 两个方法 5. **Puerts 版本**:不同 Puerts 版本的 V8 API 可能不同(V8 8.x vs 11.x),注意 `V8_MAJOR_VERSION` 条件编译