Unity WebGL与JavaScript通信实战:SendMessage与jslib双向调用指南

📅 发布时间:2026/10/17 18:58:17
Unity WebGL与JavaScript通信实战:SendMessage与jslib双向调用指南
做Unity WebGL项目只要涉及网页交互基本都会撞上同一个问题JavaScript和Unity之间的通信怎么打通。我在两个完整的WebGL项目里实际验证过这套方案整体做下来把通信链路理顺之后确实能节省大量联调时间。这篇不整复杂概念把最容易上手的调用方法直接摆出来包含完整的实操步骤和踩坑记录适合第一次碰Unity WebGL、被通讯问题卡住的开发者参考。把通信链路理顺之后后面再接入什么功能都不会慌网页按钮控制Unity场景里的物体、Unity事件通知网页更新界面哪怕是做后期交互密集的H5游戏这套思路也完全撑得住。1. 整体通信机制与原理解读1.1 Unity WebGL导出后的产物长什么样先搞清楚一件基础事Unity导出WebGL之后游戏并不是以一个“原生应用”的形式存在的而是一整套网页资源。导出目录里通常会有三个核心部分——index.html作为页面入口Build文件夹里放着游戏的.wasm二进制模块、.framework.js和.loader.js这些引导脚本另外还有一个TemplateData文件夹负责加载模板与样式。理解这个结构很重要因为通信代码的落点就分散在这些文件上JavaScript侧的调用逻辑要写在index.html或者外部的.js文件里而Unity侧的接收逻辑要写在C#脚本里编译进.wasm。还有一类特殊的.jslib文件放在Unity工程里专门用来打通“Unity调用JS”这条反向通道。另一个关键点WebGL的Unity运行时本质是一个“在浏览器里跑起来的虚拟机”它和外部HTML页面的JS代码并不共享内存空间。所以哪怕你在Unity里写了public方法也不能直接从一个script标签里访问必须经过Unity暴露出来的通信API也就是官方提供的SendMessage和.jslib机制。理解这一层后面所有代码就都有了解释。1.2 双向通信的两条主链路我把整个通信拆成两条链路来理解在开发时只需要分清方向代码就不会乱链路AJavaScript → Unity。网页端点击按钮、填表单、上报地理信息之后通过unityInstance.SendMessage(对象名, 方法名, 参数)向Unity场景里的指定MonoBehaviour发送指令。参数支持字符串、数字、布尔值复杂数据用JSON字符串承载。链路BUnity → JavaScript。Unity场景里发生状态变化、动画播放完毕、某个任务完成时通过.jslib插件文件中注册的函数间接调用到浏览器全局环境里的window.xxx方法从而实现网页元素的更新。用一个生活化的类比链路A就像你往一个房间里递纸条纸条上写着要找的人和要办的事链路B则像房间里的人在墙上插了一部内线电话拨出去就能让前台做点事情。SendMessage是那个递纸条的人jslib里声明的函数是那部电话的号码薄。不少初学者会在Unity侧直接挂一个UnitySendMessage用C#原生方式去映射JS但这个思路在WebGL下是走不通的因为Unity的C#代码编译后运行在WebAssembly沙箱里不能直接碰DOM。所以必须接受这个事实所有网页端交互都要通过上述两条链路绕一圈。1.3 为什么老的ExternalCall别再用很多老教程里还会出现Application.ExternalCall(jsFunc, param)这段历史代码我也一度图省事用这个后来才踩了它的坑。这个接口在Unity 2019.2之后被标记为弃用在2021版本里基本已经失效了。原因是它的实现方式是通过在Emscripten编译时注入一段eval逻辑来实现的性能不稳而且在开启代码裁剪、压缩等优化选项后经常出现函数找不到的问题排查起来非常头疼。现在官方建议的统一方案就是.jslib插件加SendMessage。做新项目时不要犹豫直接这两个API起步避免老接口不可预知的行为造成线上问题。我也整理了一个简单对比表通信方案方向稳定度适用场景SendMessageJS → Unity稳定官方推荐按钮、网络事件、页面逻辑主动控制Unityjslib DllImportUnity → JS稳定官方唯一推荐Unity事件通知JS、获取浏览器信息Application.ExternalCallUnity → JS弃用不稳定旧工程维护时能不用就不用选型上不要“听起来能用”就跑尽量走官方长期维护的API后面Unity升级版本也省心。2. 环境准备与首次构建2.1 工程搭建与WebGL构建设置准备阶段不难但容易漏。Unity版本建议优先2019.4以上的长期支持版本至少2021或2022也完全没问题。打开Unity Hub安装编辑器时记得勾选WebGL Build Support模块否则构建选项里根本没有WebGL平台。这个模块下载量不小如果你的网速一般建议计划好时间。新建工程时选3D或2D模板都行通信这套东西和模板无关。进入File → Build Settings切换到WebGL平台首次切换时Unity会导入整套WebGL工具链耗时几分钟。这步完成后有两处设置建议动手改Player Settings → Publishing Settings → Compression Format初次建议选Disabled。选Brotli虽然体积能压缩得很小但部署服务器或本地调试时会多出一堆498/404的坑详细原因我在第五部分单独讲。Player Settings → Other Settings → Strip Engine Code如果开了代码裁剪C#方法名在运行时可能被优化掉SendMessage就找不到目标方法。建议先关闭等业务稳定了再考虑裁剪。项目里挂脚本的对象名也要提前固定。我在项目里习惯在场景中创建一个空物体命名为GameBridge挂上所有接收JS消息的脚本组件。这个命名必须和JS侧SendMessage的第一个参数完全一致大小写都要对上一旦改了场景里的对象名后面的调用会直接失效。2.2 构建产物结构与本地预览方式配置好后点击Build按钮Unity会生成一个包含index.html、Build、TemplateData的完整文件夹这个文件夹理论上可以直接扔到任意静态服务器上跑起来。但这里有个新手必踩的坑千万不要直接双击index.html在桌面浏览器里打开。WebGL游戏在file://协议下无法正常工作浏览器会按同源策略拦截WebAssembly的加载请求控制台里会出现Cross-Origin Request Blocked之类的报错。正确做法是起一个本地静态服务器我经常用这三种方式# Python 3 自带模块 python -m http.server 8000 # Node.js 全局安装后的静态服务 npx serve . # 或者使用 VsCode 插件 Live Server启动后在浏览器访问http://localhost:8000双击对应端口下的index.html就能看到Unity加载画面。很多联调问题之所以反复出现就是环境没跑对所以这一步值得花两分钟确认好。构建产物里的Build目录会拆成.wasm、.js、.data等文件.data文件在后台线程预加载如果Unity用了Addressables还会动态去请求AssetBundle这都属于后续话题但在通信联调前至少先确保页面能正常打出Unity的默认画面。3. JS调用UnitySendMessage从入门到跑通3.1 C#侧先开一个口子接收方法怎么写通信的第一步是在Unity侧准备一个可被外部调用的方法。我用一个实战案例来演示网页端收到表单提交之后通知Unity让场景里的角色移动。打开Unity工程在场景里创建一个空物体并命名为GameBridge挂上一个C#脚本GameBridge.csusing UnityEngine; public class GameBridge : MonoBehaviour { // 此方法会被JS侧通过 SendMessage 调用 public void MovePlayer(string direction) { Debug.Log(收到JS指令方向 direction); // 这里写你实际的业务逻辑 // 比如找到角色控制器调用它的移动方法 } }注意事项如下方法必须是public私有方法SendMessage无法命中。方法名必须和JS侧完全一致区别大小写MovePlayer写成moveplayer就会静默失败。这个脚本挂载的物体名和SendMessage第一个参数一致这里就是GameBridge。移动逻辑简单演示的话可以在场景放一个Cube挂上一段角色移动脚本然后在MovePlayer里根据direction去转方向这是测试通信是否打通最快的方式。3.2 JS侧获取Unity实例并调用JS侧代码的核心是要拿到Unity实例对象。新版Unity默认生成的页面代码使用的是createUnityInstance异步函数我一般会在index.html里写这样一段var unityInstance null; function onUnityReady(instance) { unityInstance instance; console.log(Unity实例已就绪); // 可以在这里把积压的消息发出去 } // 注意这里的 createUnityInstance 来自 Build 目录下的加载脚本实际项目中通常在 // Unity 生成的 config 对象里动态加载这里只展示通信相关代码 createUnityInstance(document.querySelector(#unity-canvas), config, onProgress) .then(onUnityReady) .catch(function (error) { console.error(Unity 加载失败, error); }); // 网页按钮事件点击后给 Unity 发消息 function notifyUnityMove(direction) { if (!unityInstance) { console.warn(Unity还未就绪消息被丢弃); return; } unityInstance.SendMessage(GameBridge, MovePlayer, direction); }SendMessage三个参数分别是对象名、方法名、参数字符串。注意direction传的是up、down这样的字符串Unity侧的C#方法签名是void MovePlayer(string direction)两边正好匹配。如果页面需要主动轮询Unity状态或者等Unity加载完毕再开放按钮就在onUnityReady回调里把按钮的disabled去掉这是比较稳妥的交互逻辑。3.3 参数类型对照与JSON传参SendMessage的第三个参数比较“霸道”只支持单个参数类型限定于数字、字符串、布尔值。了解这个特性的最好办法是看下面的映射关系JS传入类型C#接收参数类型说明hellostring最常用推荐所有参数都用字符串传递42int/float如果方法参数是floatJS侧传数字会转成浮点true/falsebool支持JS对象不支持必须先JSON.stringify转成字符串这意味着你想一次性传多个字段必须先把数据序列化成JSON字符串var task { id: 1001, playerId: 7, targetX: 10.5, targetY: -3.2 }; unityInstance.SendMessage(GameBridge, OnReceiveTask, JSON.stringify(task));C#侧接收后反向解析using UnityEngine; public class GameBridge : MonoBehaviour { [System.Serializable] public class TaskData { public int id; public int playerId; public float targetX; public float targetY; } public void OnReceiveTask(string json) { var task JsonUtility.FromJsonTaskData(json); Debug.Log($收到任务{task.id}目标坐标{task.targetX}, {task.targetY}); } }用JsonUtility时有个小坑它的字段绑定是严格类型匹配的JS侧对象里的字段名必须和C#类字段名完全一致。不想被这个绑定逻辑搞头疼的话可以直接换成LitJson或者Newtonsoft.Json它们处理复杂嵌套结构更灵活。3.4 调用时机问题等Unity就绪通信联调中最常见的一个问题页面已经加载完了但用户点击按钮时unityInstance还是null。原因很简单Unity的WebGL运行时加载需要几秒甚至更久而HTML的script标签是在文档解析阶段就执行的如果你的按钮事件挂在window.onload里很可能Unity还没初始化完。解决办法有两种。第一种也是最直接的把发消息的动作统一放在onUnityReady回调之后或者在按钮点击事件里加判断function safeSend() { if (!unityInstance) { console.warn(Unity尚未就绪); return; } unityInstance.SendMessage(GameBridge, MovePlayer, up); }第二种更可靠的做法是做一个简单的待发送队列。页面早期产生的消息先存起来一旦Unity就绪按顺序补发var pendingMessages []; function sendWhenReady(objName, methodName, param) { if (unityInstance) { unityInstance.SendMessage(objName, methodName, param); } else { pendingMessages.push([objName, methodName, param]); } } function onUnityReady(instance) { unityInstance instance; pendingMessages.forEach(function (msg) { unityInstance.SendMessage(msg[0], msg[1], msg[2]); }); pendingMessages []; }这个队列的技巧看着简单但在正式项目里价值很大尤其是页面秒开、点击发生在Unity加载完成前的情况能避免一大波“丢消息”的线上问题。4. Unity调用JSjslib插件的完整带入4.1 创建.jslib插件文件Unity官方现在推荐Unity调用JS的方式是.jslib插件。在Unity工程Assets目录下新建一个文件夹Plugins/WebGL然后在这个文件夹里创建一个文本文件命名为比如unity-to-js.jslib。文件内容不能直接写成JavaScript因为它会被Unity的编译工具链解析并合并到framework.js里。基础模板长这样mergeInto(LibraryManager.library, { NotifyPage: function (message) { // 这里的 message 是一个 C 内存指针要用 UTF8ToString 解码 var str UTF8ToString(message); // 调用页面全局函数 if (window.unityToPageCallback) { window.unityToPageCallback(str); } } });mergeInto是Emscripten模块系统提供的方法作用是把我们定义的这些函数注册进Unity的内部库让C#侧通过DllImport(__Internal)能引用到它们。之所以叫“__Internal”是因为这些函数只存在于Unity内部编译生成的JS代码里并不是页面直接可见的普通函数。4.2 C#声明外部函数并调用C#侧的调用方式看起来很像调用一个原生DLL只不过这里的“DLL”内部名是固定的__Internalusing System.Runtime.InteropServices; using UnityEngine; public class GameBridge : MonoBehaviour { // 声明外部函数注意 DLL 名称必须是 __Internal [DllImport(__Internal)] private static extern void NotifyPage(string message); // 供场景内事件或按钮调用的公开方法 public void SendToPage(string message) { NotifyPage(message); } // 示例鼠标点击挂载此脚本的物体时触发 void OnMouseDown() { SendToPage(Unity 场景发生了点击事件); } }这就是完整的调用链C#方法的SendToPage被调用 → 通过DllImport(__Internal)跳到.jslib里定义的NotifyPage函数 → 在JS侧拿到字符串指针并解码 → 调用页面上的window.unityToPageCallback。4.3 字符串指针与返回值处理这里有个很容易迷惑的点C#侧传给.jslib函数的string到了JS侧并不是一个直接的JS字符串而是一个指向堆内存的指针。所以所有C#传入的字符串在jslib函数内部都要先经过UTF8ToString(指针)转换才能变成真正可用的JS字符串。这一点我在第一次写jslib时直接踩了坑直接拿指针去拼接字符串输出了一堆乱码。如果需要在Unity → JS方向传返回值比如让JS侧算完一个数据回传给C#标准做法是在jslib函数里分配一块内存把字符串内容写进去返回指针mergeInto(LibraryManager.library, { GetPageData: function () { var str 从JS返回的数据; // 分配能容纳字符串的堆内存 var buffer _malloc(str.length 1); // 把字符串写入内存 writeAsciiToMemory(str, buffer); return buffer; } });C#侧接收返回值时函数声明写成返回string即可Unity运行时会自动完成指针到字符串的转换。但说实话这种返回值的写法在项目里能少用就少用因为每次都要手动管内存写多了容易泄漏。更推荐的方向是Unity向JS发一个消息JS侧处理后通过SendMessage再回传结果走异步回调而不是同步返回值。4.4 实例演示Unity事件通知JS更新页面串一个完整案例把第4节的几个点全用上。假设场景里放了一个Cube挂在GameBridge上的C#脚本通过鼠标点击通知网页端更新一段文字。HTML页面的核心脚本// 页面全局回调Unity 通过 jslib 调用 window.unityToPageCallback function (msg) { document.getElementById(status).textContent 来自Unity的消息 msg; }; // 页面按钮反过来通知 Unity function notifyUnity() { if (unityInstance) { unityInstance.SendMessage(GameBridge, MovePlayer, up); } }Unity侧完整实操using System.Runtime.InteropServices; using UnityEngine; public class GameBridge : MonoBehaviour { [DllImport(__Internal)] private static extern void NotifyPage(string message); public void MovePlayer(string direction) { // 角色移动逻辑... } void OnMouseDown() { NotifyPage(Cube 被点击了当前时间 Time.time); } }测试步骤就三步构建WebGL → 启动本地静态服务器打开页面 → 点击浏览器里的Cube页面上的status元素就会同步显示文字。这个案例的核心价值在于C#侧和JS侧的事件前后一一对应逻辑清晰是练习通信机制最直观的模板。5. 实际项目中的避坑与排查5.1 坑点击没反应SendMessage找不到对象这种问题在联调初期占了我排查量的一半。按钮点了浏览器控制台也不报错Unity日志里也没有任何输出其实多数情况下是SendMessage静默失败了。我总结了一套快速自查的顺序检查对象名SendMessage(GameBridge, ...)里的GameBridge必须和场景里的GameObject名称完全一致。注意Unity场景对象默认可能叫GameBridge(Clone)带Clone后缀的话调用就会失败需要在运行时动态改名。检查方法名大小写和拼写必须完全匹配Unity的SendMessage不做容错。检查方法访问级别必须是public。private方法即使挂在目标对象上也调不到。检查脚本是否挂上目标物体上必须实际挂了GameBridge.cs这个组件光有同名类定义没用。检查Unity是否已就绪unityInstance为null时调用不会产生任何效果结合3.4节的待发送队列可以规避。大多数“点了没反应”的问题按这个顺序走一遍都能定位。5.2 坑中文乱码与字符串转义传递中文字符串时乱码主要有两个来源。第一个来源是字符编码不一致。Unity编译时如果用默认的UTF-8JS侧也统一用UTF-8一般不会有太大问题但如果页面本身是GB2312编码和Unity侧交互就会出现乱码。解决办法是统一编码页面文件、静态服务器都尽量明确声明charsetutf-8。第二个坑更容易被忽略传JSON字符串时包含引号、反斜杠等特殊字符在层层传递中没处理好导致另一半解析失败。比如先JSON.stringify又在拼接HTML模板时转了义三层嵌套后数据就变形了。我现在的习惯是只要超过两个字段的通信一律用队列参数结构JSON序列化并且在发送前先console.log打印一遍确认格式再到C#侧解析时打日志对比哪一层少数据一目了然。5.3 坑Brotli压缩导致发布到服务器后加载失败压缩格式选Brotli对体积优化确实显著但代价是服务器得支持对应的HTTP响应头。本地用python -m http.server时它不处理.br文件的解压UnityLoader请求.data.br时服务器会当成普通二进制返回从而加载失败。部署到一些带缓存CDN或企业内网服务器时同样会遇到各种各样稀奇古怪的状态码。我的建议是发布初期一律选Disabled压缩模式先让整个链路稳定跑通再在接近上线的阶段换上Gzip或Brotli并且上线前在真实服务器上做一次完整回归测试。压缩格式属于部署侧优化不应该用来给通信调试添堵。5.4 坑本地直接双击index.html打不开这个踩坑点我在2.2节已经提过篇幅有限这里再强调一个容易误判的细节双击打开时Unity加载画面有可能闪一下再变白屏控制台报错也可能被浏览器拦截折叠很多人会误以为是Unity代码的问题其实根源就是file://协议下的CORS限制。排查方法很简单。在浏览器地址栏输入file:///你的路径/Build/xxx.wasm如果这个文件能正常访问但页面就是起不来大概率就是CORS。换成本地服务器后一切正常就可以放心继续调试了。这属于环境问题不是通信代码问题但新手最难区分的就是这一点。6. 进阶把通信做成稳定可维护的桥接层6.1 统一的JSON数据协议项目大了之后如果还靠零散的SendMessage(GameBridge, MovePlayer, up)满天飞代码会变成一团乱麻。我通常会在通信最初期就约定一个简单的消息协议。统一入口长这样using UnityEngine; public class GameBridge : MonoBehaviour { [System.Serializable] public class MessageData { public string cmd; public string data; } public void OnReceiveMessage(string json) { var msg JsonUtility.FromJsonMessageData(json); switch (msg.cmd) { case move: MovePlayer(msg.data); break; case openPanel: OpenPanel(msg.data); break; case updateScore: UpdateScore(msg.data); break; default: Debug.LogWarning(未知指令: msg.cmd); break; } } }Web端调用时所有消息都走同一个方法function postToUnity(cmd, data) { var message { cmd: cmd, data: data }; unityInstance.SendMessage(GameBridge, OnReceiveMessage, JSON.stringify(message)); } // 例子 postToUnity(move, { direction: left, speed: 5 }); postToUnity(updateScore, { score: 100 });这样Unity侧不需要为每种操作单独开public方法JS侧也不需要记住一长串方法名前后端只要维护一份指令字典即可。新增一个指令时只要在C#的switch分支和JS侧的调用处各加一行管理成本极低。6.2 消息队列做时序缓冲最后再分享一个小技巧。在真实项目中Unity场景的加载速度和网络环境、设备性能强相关JS侧可能很早就接收到了用户操作但Unity实例还在加载过程。如果此时直接调用SendMessage消息会直接丢失体验很差。我之前做的某个跨平台活动页里就专门做了一层缓冲。原理特别简单用一个数组暂存所有消息Unity就绪后再按顺序flush出去这个思路我在3.4节的代码示例里已经贴过了。进阶一点的版本可以把消息加上时间戳和序列号防止重复消息多出额外业务开销这都看业务复杂度但基础队列已经能解决95%的时序问题。反向方向也值得注意Unity调用JS时如果JS侧的回调函数还没在window上挂载比如加载脚本顺序不对jslib调用会报未定义。我的习惯是jslib内部先判断typeof window.xxx function再调用宁可丢弃这次通知也别让整个Unity线程因为异常中断。我个人实际碰到的最大坑其实不在于API本身而在于调用时机和字符串处理。如果一开始就把通信封装成一个桥接层后面不管做活动页还是复杂交互都能省很多事。这套方法我用了挺久稳定性可以放心关键是按上面的步骤把环境、时机、编码三个基础条件都处理干净通信基本不会再给你添乱。