Unity WebGL与JS通信实战:SendMessage+jslib双向调用全解析

发布时间:2026/10/17 16:34:07
Unity WebGL与JS通信实战:SendMessage+jslib双向调用全解析
如果你是个前端第一次接到“把Unity WebGL项目嵌进我们的官网还要能跟页面按钮互动”这种需求大概率会在两个地方被卡住一是构建产物怎么加载二是两边到底怎么互相喊话。JavaScript和Unity WebGL的通信方法说难不难说简单也有一堆细坑网上资料要么只讲了老的SendMessage用法要么一上来就丢一整套框架新手看完直接劝退。这篇文章我不讲虚的直接用一套“先跑通再优化”的思路把 JS 调用 Unity、Unity 回调 JS 的完整链路讲清楚。你不用提前懂什么底层原理照着抄就能用同时我会把每一步为什么这么写的逻辑也顺带解释明白。适合正在做Web端3D集成、Unity开发者对接前端、或者纯粹想搞懂这套通信机制的同学相信看完能少走几周的弯路。我一直认为搞技术最怕的不是不会而是被碎片化资料带偏方向。所以下面的内容我会按实际接项目的顺序来组织先想清楚通信模型再配置Unity侧然后打通两条通道最后用完整案例串起来。整个过程以“易上手”为第一优先级但也不会让你只知其然。1. 通信需求从哪来Unity WebGL在网页里的真实形态1.1 浏览器里的Unity不是“游戏”而是一个封闭模块很多第一次接触Unity WebGL的人会下意识以为构建产物里那个.html就是整个游戏页面用 iframe 一嵌就完事。但实际上Unity WebGL 的产物是几件套一个index.html、一个loader.js、一个.wasm、一个.data有时还有一个.framework.js。浏览器加载时loader先创建Unity的运行时实例然后在指定容器里渲染Canvas整个引擎跑在一个独立的内存空间里。这个“独立空间”是理解通信问题的核心前提。JavaScript跑在浏览器环境里Unity跑在WebAssembly虚拟机里两边虽然都在同一个网页中但变量、函数、堆栈完全隔离。你不能在 JS 里直接访问 Unity 的某个 C# 对象的公开字段也没法在 C# 里直接调用 window 上的全局函数。想让两边对话只能走引擎或插件暴露出来的“官方通道”。这就像两个相邻国家各有各的法律和海关想互相贸易必须走指定的口岸而不能直接把卡车开过边境。搞懂了这个模型你再去看后面的 API会发现一切都很顺理成章。1.2 三条通信路径先分清再动手以实际开发中最常用的角度划分JavaScript和Unity之间的通信主要有三种路径路径AJS 主动调用 Unity 里的某个 C# 方法。最典型的是网页按钮触发Unity场景里的物体运动核心API是unityInstance.SendMessage。路径BUnity 主动通知 JS。最典型的是Unity某个动画播完需要网页更新界面显示核心是实现一个 jslib 插件再通过C#调用。路径C请求-响应式回调。JS发起一个请求Unity处理完后再回调JS核心是给请求加一个 ID然后靠一套回调注册表把返回值送回去。路径C本质上是在路径A和路径B之上封装的一层“协议”并不是独立的底层机制。新手建议先扎实掌握A和B因为所有框架、SDK本质上都在封装这两条通道。如果一开始就奔着某个封装好的通信库去学遇到问题反而更难看透。1.3 选型建议别被旧资料带到沟里现在搜索“Unity WebGL JS通信”搜索到的大概率还是Application.ExternalCall的用法。这里明确说一下这个API在旧版本Unity里确实可用但Unity后来已经移除了对它的官方支持新版本构建出的产物里基本找不到对应实现。如果你照着老教程写大概率编译能过但运行时报“函数未定义”白白浪费时间。官方推荐的做法从C#侧调用JS时用jslib插件配合DllImportJS侧调用Unity侧时用SendMessage。这两条通道加在一起足以应付绝大多数项目需求。不要上来就研究什么底层二进制接口互操作那是真正需要自定义复杂编解码时才需要碰的东西。2. 动手前的Unity侧配置这几步不做好后面全白搭2.1 Player Settings 里的关键项在Unity里构建 WebGL 之前先打开 Build Settings 切换到 WebGL 平台然后点 Player Settings 检查几项WebGL 版本建议保持 WebGL 2.0Unity 2020 之后创建的项目默认就是能支持更多图形特性和现代浏览器兼容性也更好。Compression Format本地开发调试时建议先设为 Disabled 或 Brotli。如果你选了 Brotli部署时Web服务器必须能返回对应的Content-Encoding头很多新手本地点开一片空白就是这个原因。Enable Exceptions如果想在浏览器里看到C#抛出的异常堆栈可以在Development Build模式下勾选正式发布时记得关掉否则体积和性能都会受影响。这些设置看似和通信无关但通信调试依赖Unity正常跑起来。我见过太多人因为压缩格式问题页面白屏还以为是JS调Unity的代码写错了排查半天方向完全反了。2.2 模板选择与Unity实例的挂载点Unity WebGL默认构建的HTML页面是把Canvas直接挂到body里的。如果你希望页面其他部分由自己的前端代码控制建议不要直接用默认模板而是建一个自定义HTML模板里面预留一个div容器并让Unity实例渲染进去。模板创建方法在Unity项目里新建Assets/WebGLTemplates/MyTemplate/index.html然后在 Player Settings 的 WebGL Template 里选它。模板中通常会有一段var unityInstance UnityLoader.instantiate(container, build.json)或新版createUnityInstance的代码你需要做的就是把 container 的id指向自己的div。为什么这一步很关键因为unityInstance这个对象是JS操作Unity的入口自定义模板就意味着你能控制这个实例如何被创建、在哪里暴露、以及里面可以随时插入业务需要的初始化逻辑。很多通信时序问题其实都可以在这一层用一段标准初始化代码解决。2.3 一个最小可用的C#接收方法在Unity场景里新建一个空物体挂上一个脚本脚本里写一个public方法。这个方法是给JS预留的“接收端口”。using UnityEngine; public class BridgeReceiver : MonoBehaviour { public void OnWebMessage(string message) { Debug.Log([Unity] receive from JS: message); } }这里要注意三个点方法必须 public否则SendMessage找不到。所在GameObject必须处于激活状态且场景里有且只有一个同名对象不然SendMessage会报“找不到对象”或者产生不确定的调用结果。方法名大小写敏感JavaScript那边写错一个字母都调不到。这个脚本虽然简单但它是整个通信链路的第一步先把这一步跑通后面的复杂封装才有基础。3. 从JavaScript喊话UnitySendMessage是主力通道3.1 准确语法与参数规则JS侧调用Unity方法的标准写法是unityInstance.SendMessage(objectName, methodName, value);objectNameUnity场景里目标GameObject的名字。methodName挂在该对象上的某个组件中的public方法名。value可选要传递的参数支持字符串和数字。在Unity 2021及之后的版本里也支持传递简单对象但为了兼容和稳定我建议统一用字符串或数字。没有参数时第三项可以直接不传例如unityInstance.SendMessage(BridgeReceiver, OnWebMessage);当你传数字时Unity侧可以定义为int或float引擎会尝试做隐式转换。当你传字符串时建议C#侧参数类型固定为string减少类型不匹配的边界问题。3.2 先等Unity就绪再喊话时序问题的三种解法新手最容易踩的坑是页面一加载就去调用SendMessage结果控制台报错“Unity instance is not ready”或者干脆静默失败。原因是Unity实例创建是异步的场景加载也需要时间JS脚本不能假设“页面load完就万事大吉”。解法有三种按推荐程度排序第一种在Unity初始化完成后主动通知JS。C#侧在Start方法里调用jslib插件里的函数告诉JS“我已经准备好了”。这样JS就知道可以开始调用了这个方案最可靠也是我实际项目里最常用的。第二种在JS侧轮询。利用setInterval或requestAnimationFrame不断检查unityInstance是否存在、是否有SendMessage方法等它出现再执行。function waitForUnity(callback) { if (window.unityInstance typeof window.unityInstance.SendMessage function) { callback(); } else { setTimeout(() waitForUnity(callback), 200); } }第三种在自定义模板里拿到unityInstance后把它挂到window上并且设置一个全局标志位。后续所有页面业务代码都只跟window.unityInstance交互避免闭包作用域带来的混乱。我强烈建议直接把第三种和第一种结合起来模板里暴露实例Unity启动后通知JS。这样既保证实例存在又保证场景里的目标对象已经加载完毕。3.3 传字符串、传数字还是传JSON实际项目中只传一个字符串或者数字往往不够用。比如你要把用户在网页上填写的昵称、分数、关卡ID一次传给Unity最干净的方式是传JSON字符串。JavaScript侧这样写const payload JSON.stringify({ playerName: Alice, score: 99, level: 3 }); unityInstance.SendMessage(BridgeReceiver, OnWebMessage, payload);Unity侧用JsonUtility解析[System.Serializable] public class PlayerMessage { public string playerName; public int score; public int level; } public void OnWebMessage(string json) { PlayerMessage msg JsonUtility.FromJsonPlayerMessage(json); Debug.Log(msg.playerName / msg.score / msg.level); }这里有个很重要的坑JsonUtility不支持直接解析JSON数组也不支持Dictionary。如果你想从一个形如[{a:1},{a:2}]的数组里取数据JsonUtility.FromJsonT会失败或者得到空值。解决办法是包一层对象比如{items:[...]}或者干脆换用第三方库想图省事就尽量在通信协议设计上避开顶层数组这种结构。4. 从Unity回传消息给JavaScript避开旧API用jslib插件4.1 为什么新项目别碰 Application.ExternalCall最早Unity WebGL支持从C#直接调用Application.ExternalCall(jsFuncName, param)当年的写法确实方便一行代码就能把消息丢给浏览器。但它有几个硬伤不支持返回值、参数序列化逻辑老旧、在新版Unity里被移出官方支持列表。如果你现在还在用2020以上的Unity费劲写出来的ExternalCall代码很可能构建时能过运行时代码却找不到对应入口。没必要在这个API上恋战。正确姿势是jslib插件。它的本质是允许你在Unity工程里放一份JavaScript文件并把C#方法映射到JS方法等于为Unity和浏览器之间打开一道专用闸门。4.2 从创建到调用手写一个最小jslib在Unity项目的Assets/Plugins/WebGL/目录下新建一个文本文件后缀改成.jslib例如BridgeLib.jslib。内容写mergeInto(LibraryManager.library, { SendToJS: function (str) { var text UTF8ToString(str); if (window.onUnityMessage) { window.onUnityMessage(text); } } });然后在C#脚本里通过DllImport引入这个函数using System.Runtime.InteropServices; using UnityEngine; public class BridgeSender : MonoBehaviour { [DllImport(__Internal)] private static extern void SendToJS(string message); public void SendStatus(string status) { SendToJS(status); } }注意两点jslib里函数名要和C#侧DllImport声明的函数名严格一致包括大小写。UTF8ToString是Unity WebGL运行时提供给jslib的辅助函数用来把C#传出来的字符串指针转成JS字符串。如果不用这个函数你拿到的基本上是乱码或内存地址这就是很多人回传中文乱码的根本原因。构建完成后当Unity侧调用SendToJS(hello)浏览器里就会触发window.onUnityMessage这个全局函数。如果你不放心全局函数是否存在jslib里可以先做判断if (window.onUnityMessage) { window.onUnityMessage(text); } else { console.warn(onUnityMessage not defined); }这种防御式写法在页面组件还没完全初始化时会救你一命。4.3 在jslib里主动操作DOM还是只触发回调一种常见做法是直接在jslib里操作DOM比如更新一个div的文本。如果项目只用Unity一个模块这么写没问题够简单。但如果你的页面还有前端框架比如Vue或React直接在jslib里改DOM会绕过框架的数据流容易导致状态不同步。所以我更推荐jslib只负责“把消息抛出去”具体DOM操作交给页面框架里的回调函数做。也就是说jslib里只调用window.onUnityMessage前端模块注册这个事件在事件里统一处理数据并更新UI。这样分层通信层和业务层是解耦的后面换UI框架也不太会影响Unity侧代码。4.4 中文和特殊字符的编码安全问题中文在跨通道传输时特别容易出问题。如果你在jslib里看到一堆乱码第一反应应该检查是不是没有经过UTF8ToString解码而不是怀疑Unity或浏览器有问题。反过来JS传中文给Unity时SendMessage内部会统一处理编码一般不会出乱码但到了C#侧如果要拼接字符串或做长度判断注意C#和JS对字符串长度的计算方式不同C#里string.Length按UTF-16字符数JS的length在大多数情况下也按UTF-16但如果遇到特殊emoji两边可能对不上。另外如果从网页传入的内容包含用户输入直接拼到HTML里会有XSS风险。我的习惯是Unity回调到JS后更新页面文本一律用textContent或框架的数据绑定不直接把字符串塞进innerHTML。这算是一个容易被忽略的安全习惯。5. 数据交换的实用技巧别让通信变成传话筒5.1 统一数据结构给每条消息起个“名字”通信内容少的时候你说一句“移动”我说一句“好的”怎么都能行。一旦场景复杂调用多了没有协议约束就会乱套。我建议所有JS到Unity的调用都包装成这种结构window.sendToUnity(player.move, { x: 1, y: 2 });内部实现是window.sendToUnity function (event, data) { const msg JSON.stringify({ event: event, data: data || {} }); window.unityInstance.SendMessage(BridgeReceiver, OnWebMessage, msg); };Unity侧收到后先解析event字段分发到不同处理方法再解析data字段。这样Unity侧只需要维护一个入口方法新加功能时不用反复暴露新的C#方法对前端来说调用入口也统一了。本质上就是消息总线模式好处是扩展方便坏处是不适合对性能极端敏感的场景但WebGL通信本身就不是为了高频大流量设计的这点取舍基本没问题。5.2 高频调用时的降频策略有时候你会遇到一秒钟要发几十次状态更新的需求比如在网页上拖动滑块要求Unity实时响应。直接每次change事件都发一条消息会明显感觉卡顿因为Unity处理一条消息的开销比普通JS函数调用大不少。我的做法是给高频事件加一个“节流器”比如滑块input事件触发时不立即发送而是记录目标值用requestAnimationFrame保证每秒最多发一两次。或者直接改成“滑块松开”时发一次最终值配合Unity侧做插值过渡效果反而更顺滑。这里没有统一答案但核心理念是减少消息条数而不是优化单条消息的传输速度。5.3 基于回调ID的请求-响应模式如果你想做“JS问Unity要一个数据Unity拿到后回传”的完整双向交互只在Unity侧处理完直接调用JS还不够因为JS侧可能需要知道这次回传对应的是哪个请求。办法是给请求编个号。let requestId 0; const pendingCallbacks {}; window.requestFromUnity function (action, payload, callback) { const id requestId; pendingCallbacks[id] callback; const msg JSON.stringify({ id: id, action: action, data: payload }); window.unityInstance.SendMessage(BridgeReceiver, OnRequest, msg); }; window.onUnityResponse function (json) { const res JSON.parse(json); if (pendingCallbacks[res.id]) { pendingCallbacks[res.id](res.data); delete pendingCallbacks[res.id]; } };Unity C#侧拿到请求后处理完把结果通过jslib回传带上同一个id。这就形成了一次完整的请求-响应闭环。这套模式在很多实际项目里极其好用比如网页加载存档数据、Unity请求页面的登录态都属于这种场景。6. 完整实战一个按钮控制Unity动画并回传状态的Demo6.1 场景与需求说明我用一个最常见的完整例子把所有代码串起来。需求是网页上有两个按钮点击“开始旋转”时Unity场景里的Cube旋转90度旋转完成后通知网页显示“旋转完成”点击“恢复位置”时Cube回到初始位置网页显示“已复位”。这个Demo虽然简单但覆盖了JS到Unity、Unity到JS两条通道也覆盖了时序处理和加载完成后才注册事件的标准流程。6.2 Unity侧完整代码与jslib插件先在场景里创建一个Cube再创建一个空物体BridgeContainer挂载下面的脚本using UnityEngine; using System.Runtime.InteropServices; public class BridgeContainer : MonoBehaviour { [DllImport(__Internal)] private static extern void NotifyJSRotationDone(string message); public void StartRotation() { StartCoroutine(RotateRoutine()); } public void ResetRotation() { StopAllCoroutines(); transform.rotation Quaternion.identity; NotifyJSRotationDone(reset); } private System.Collections.IEnumerator RotateRoutine() { Quaternion target Quaternion.Euler(0, 90, 0); float duration 1.0f; float elapsed 0f; Quaternion start transform.rotation; while (elapsed duration) { elapsed Time.deltaTime; transform.rotation Quaternion.Slerp(start, target, elapsed / duration); yield return null; } transform.rotation target; NotifyJSRotationDone(finished); } }对应Assets/Plugins/WebGL/BridgeLib.jslibmergeInto(LibraryManager.library, { NotifyJSRotationDone: function (str) { var message UTF8ToString(str); if (window.onUnityNotify) { window.onUnityNotify(message); } else { console.warn(window.onUnityNotify not registered); } } });6.3 JS侧页面代码与联调步骤HTML中先提供一个承载Unity的容器unity-container再把模板构建出来的Unity实例挂载到这里。页面JS核心逻辑如下window.onUnityNotify function (message) { const status document.getElementById(status); if (message finished) { status.textContent 旋转完成; } else if (message reset) { status.textContent 已复位; } }; function startRotate() { if (window.unityInstance) { window.unityInstance.SendMessage(BridgeContainer, StartRotation); } } function resetRotate() { if (window.unityInstance) { window.unityInstance.SendMessage(BridgeContainer, ResetRotation); } }注意window.onUnityNotify要在Unity实例加载完成前就定义好否则Unity启动后立刻回调时找不到函数状态就丢了。这也是我推荐全局函数而不是局部变量的原因全局变量天然规避了作用域时序问题。部署和调试时不要直接用file://协议打开HTMLWebGL的加载器会因跨域限制直接失败。建议在项目根目录跑一个简单的本地静态服务器比如python -m http.server 8080然后访问http://localhost:8080进行联调。如果你用的是Vite或Webpack的前端工程直接把构建产物放到public目录下用开发服务器打开也一样。7. 常见问题与避坑速查表7.1 典型报错对照表现象可能原因解决方法控制台报SendMessage is not a function调用时机太早unityInstance尚未创建完成用waitForUnity或Unity主动通知就绪后再调用SendMessage提示找不到目标对象GameObject名字拼写错误、目标未激活检查场景中对象名称与激活状态C#方法没有执行方法不是public、方法名不匹配确认public修饰符检查大小写Unity调用JS无反应jslib文件路径不对、DllImport没配对、window函数未定义确认Assets/Plugins/WebGL路径检查__Internal加日志中文传到页面乱码jslib里没用UTF8ToString解码所有C#字符串入JS前都要UTF8ToString页面白屏不加载压缩格式配置不对、服务器缺少编码头本地调试先关闭压缩部署时配好Brotli头画面有但按钮点了没反应模板里unityInstance没挂到window上在模板初始化完成后统一window.unityInstance ...7.2 定位问题的一套固定调试流程遇到通信问题我建议按下面的顺序排查而不是根据直觉乱翻代码先在Unity侧入口方法里加Debug.Log确认消息是否真的进了Unity。浏览器控制台也能看到Unity的日志这一步能直接判断问题在JS侧还是Unity侧。如果Unity侧没收到检查JS的SendMessage参数打印一下unityInstance是否存在。如果Unity侧收到了但浏览器没反应检查jslib里的window.onUnityNotify是否存在并在jslib里加一个console.log确认函数被调用。确认都是单次调用没有定时器或帧循环覆盖状态。这套流程只要走一遍90%的问题都能定位出来。剩下10%大概率出在构建缓存上Build一次再测。7.3 几条独家心得做这种跨端通信我的体会是先“约定协议”再“写功能代码”。哪怕项目很小我也建议至少把消息名、数据结构、回调函数名先用一个文档列出来否则前端和Unity两边各写各的联调时一对暗号对不上那才叫痛苦。另一个经验是先跑通最小链路再上复杂功能。我第一次做类似集成时一上来就写完整的数据同步流程结果花了三天排查环境问题后来发现Unity根本还没构建成功。后来每次都是先用一个Hello字符串把两端打通再逐步加代码效率高得多。如果你打算长期维护这类项目建议把通信层单独封装成一个模块比如bridge.js里面集中管理所有的消息格式、等待Unity就绪的逻辑、以及回调注册表。这样即使Unity场景内部实现大改网页侧的接口也能保持稳定算是这类集成项目里最值得做的一笔长期投资。