ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript互操作实战:浏览器、iOS原生与服务端调用全解析

JavaScript互操作实战:浏览器、iOS原生与服务端调用全解析 做过前端和客户端混合开发的朋友应该都遇到过这个经典场景App里嵌了一个H5页面页面上有个“扫码”按钮点击之后要唤起App原生的相机能力。你在前端写JavaScript写了大半发现对面是Objective-C的API数据不知道怎么送过去或者反过来原生页面想要调用网页里的某个JS函数又不知道从哪里入手。这还没完到了服务端C#后端想复用一段前端工程师写的JS算法也常常卡在“怎么执行JS”这一步上。这些问题背后其实是同一个核心概念JavaScript的互操作性。JavaScript语言本身只定义了语法、变量、函数这些基础规则真正让它能干活的是它运行的宿主环境以及和外部系统协作的能力。这篇博文我会从三个最主要的互操作场景展开浏览器内的DOM与BOM操作、iOS原生环境里OC与JS的互相调用、服务端场景下C#与JS的互相调用最后再分享一些互操作问题的高频排查经验。适合正在做混合开发、前端工程化或者需要在服务端复用 JS 逻辑的开发者参考。1. 互操作性到底是什么JS从来不是一个人在战斗1.1 JavaScript只是语言内核能力全部来自外部宿主大部分人刚开始学JavaScript的时候都有过一个错觉以为document、window、console、alert这些就是JavaScript语言的一部分。我第一次学的时候也这么想直到看完ECMAScript规范才明白语言本身只规定了数据类型、运算符、流程控制、函数、对象、异常处理这些东西连setTimeout都不属于JavaScript语言内置能力。真正让JavaScript“能干活”的是它所运行的宿主环境。浏览器提供了document让你操作网页文档提供了window让你访问浏览器窗口信息提供了fetch让你发起网络请求Node.js环境则提供的是require、process、Buffer这些完全不同的全局对象。这些都不是语言规范里定义的而是宿主环境通过一套约定好的接口暴露给JS的。这就是互操作性的第一层含义JavaScript必须调用宿主环境提供的API才能完成实际任务。你写document.querySelector本质上是在和浏览器的内部实现互操作。理解这一点很关键因为一旦换一个宿主环境比如跑到iOS的JavaScriptCore里document就不存在了代码一执行就是ReferenceError。再比如PDF文档里也能嵌入JavaScript脚本Acrobat会提供app对象和它的trustedFunction等方法给JS调用公众号图文里也经常遇到javascript:;这种伪协议那其实是浏览器环境下JS和页面行为互操作的一种历史产物。很多人跨平台运行JS跑不通根源往往不是语法问题而是没意识到自己依赖了某个特定宿主的独有API。1.2 互操作的三个典型层级先定位再发力根据我自己的项目经验日常开发和“JavaScript互操作”相关的工作基本可以分成三个层级。每个层级的调用方式、错误传递机制、类型系统都不一样网上搜出来的代码是否适用取决于你判断自己处在哪个层级。互操作层级另一方是谁典型技术典型场景浏览器环境内DOM、BOM、Web APIquerySelector、dispatchEvent、fetch页面交互、H5功能开发、自动化脚本原生宿主环境Objective-C、Swift、Java/KotlinJavaScriptCore、WKWebView、JNIApp内嵌H5、原生与Web桥接服务端与其他语言C#、Python、Java等Jint、ClearScript、PyExecJS服务端复用JS规则或算法逻辑记得先记住一句话JavaScript和谁打交道就要遵守谁家的接口约定。浏览器里的互操作走的是DOM事件模型原生App里的互操作走的是运行时上下文和消息桥服务端跨语言互操作走的是进程内脚本引擎加参数序列化。我见过太多人一上来就搜“JS怎么调原生”“C#怎么执行JS”其实第一步应该先搞清楚自己到底属于哪一层再去选对应的技术方案否则抄来的代码大概率南辕北辙。2. 浏览器环境内的互操作实操控制DOM、模拟事件、适配移动端2.1 用querySelector定位元素用dispatchEvent派发事件先聊最近经常被搜到的一条代码javascript:document.querySelector(video).dispatchEvent(new Event(ended))。这一段看起来奇怪其实是一个非常典型的“浏览器环境互操作”动作先通过querySelector拿到页面里的video元素再向它派发一个ended事件让监听这个事件播放器逻辑误以为视频已经播放结束。为什么有人要这么干我遇到过的真实场景是一些视频网站的自定义播放器没有暴露“强制结束播放”的API但在特定时机比如手动切换清晰度之后、用户跳转剧集之后你需要让播放器内部的后续流程跑起来。这时候最省事的办法就是直接派发一个ended事件播放器内的监听函数收到之后就会执行加载下一集、上报播放记录这些逻辑。不过有几个细节必须注意。第一new Event(ended)构造的是普通Event对象只带type属性不带额外数据。如果你需要在事件里传递自定义信息应该用new CustomEvent(ended, { detail: {...} })。第二dispatchEvent是同步派发的事件派发之后页面上对应的监听器会立刻执行如果监听逻辑很重会阻塞当前JS线程。第三一定要确保事件监听器已经注册完成再派发否则事件发出去没人接收白忙一场。给你一个完整可参考的写法const video document.querySelector(video); if (video) { video.addEventListener(ended, () { console.log(播放结束准备切换下一集); loadNextVideo(); }); } // 在需要强制结束播放的时机调用 video.dispatchEvent(new CustomEvent(ended, { detail: { reason: manual_switch } }));实际项目中我建议把这类操作封装成一个小工具函数比如forceEndVideo()而不是把document.querySelector散落在各个业务代码里。等哪天页面结构变了你只需要改工具函数一处即可。这条经验适用于所有浏览器互操作代码别嫌封装麻烦后面维护会省很多事。2.2 input模拟输入只改value还不够要触发框架的更新机制浏览器互操作里另一类高频需求是模拟输入。自动化测试脚本往输入框填值、表单自动填充、辅助工具帮用户代填信息都会用到。最简单的做法是给input元素赋值再手动触发input事件const input document.querySelector(#username); input.value testuser; input.dispatchEvent(new Event(input, { bubbles: true }));但这里有一个很多朋友踩过的大坑如果你用的是Vue或React这类框架或者页面对input做了受控处理直接给value赋值往往没用。表面上看输入框里的值变了框架内部维护的绑定数据其实没变一提交表单还是拿到空值或者旧值。原因是框架重写了HTMLInputElement原型上value属性的setter用来自动拦截赋值并同步内部状态。绕过它的办法是拿到原始的原型setter来赋值再触发事件const setter Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, value ).set; setter.call(input, testuser); input.dispatchEvent(new Event(input, { bubbles: true }));这里简单解释一下原理。Object.getOwnPropertyDescriptor取到的是HTMLInputElement原型上value属性的set函数也就是浏览器原生实现、还没有被框架改写过的版本。用它设置值框架监听不到“赋值动作被劫持”的问题再触发一次input事件框架的事件监听器收到通知后就会同步内部状态。这样数据链路才真正打通。实测过很多次这个方法是目前跨Vue和React都有效的方案。2.3 BOM互操作和移动端H5的特殊处理除了DOM浏览器互操作的另一大半是BOM也就是浏览器对象模型。window、navigator、location、history这些对象都是JS和浏览器环境协作的桥梁。常见的比如通过navigator.userAgent判断设备类型通过location.href做页面跳转通过history.pushState修改地址栏而不刷新页面。这些API虽然每天都在用但本质上都是JS在调用浏览器宿主提供的系统级能力。移动端H5开发里还有一个很典型的系统能力互操作需求就是图片手势缩放。iOS的Safari原生支持gesturestart、gesturechange、gestureend三个手势事件可以拿到scale值配合CSS transform实现图片放大缩小let scale 1; let initialScale 1; img.addEventListener(gesturestart, (e) { e.preventDefault(); initialScale scale; }); img.addEventListener(gesturechange, (e) { e.preventDefault(); scale initialScale * e.scale; img.style.transform scale(${scale}); });但注意这三个手势事件基本只有iOS Safari支持Android浏览器没有这套事件。Android端需要自己用touch事件配合多点触摸距离计算缩放比例或者直接引入hammer.js这类手势库。写代码前最好做一次能力检测比如用ongesturestart in window判断支持程度再决定走哪条分支。这就是典型的浏览器环境差异同一个JS逻辑在不同宿主环境里的互操作接口完全不同不能用一套代码通吃所有平台。3. 原生App里OC与JavaScript互相调用的完整方案3.1 为什么原生App一定要给JS留一个位置浏览器里的互操作先放一边近几年最热的互操作场景其实是移动端原生App和JS的配合。我做过不少iOS项目几乎每个项目里都有H5页面的身影原因很现实纯原生迭代速度太慢发一个版本要等审核而运营活动页、公告页、会员规则这类高频变化的页面用H5改完直接发版效率高太多了。再加上跨端复用的需求一套H5代码iOS和Android都能用原生只需要提供一个容器。所以“OC和JavaScript互相调用”才会成为高频搜索词。它本质上是两件事一是在原生环境里创建JS运行环境并执行JS代码二是在JS代码里调用原生暴露出来的方法。iOS开发里最常见的有两条技术路线JavaScriptCore框架和WKWebView的消息桥机制。下面分别展开。3.2 JavaScriptCore用JSContext执行脚本用Block与JSExport暴露原生能力JavaScriptCore是苹果从WebKit里拆出来的JS引擎框架OC和Swift都能直接使用。核心类是JSContext可以理解为一个独立的JavaScript运行上下文类似于浏览器的全局window对象。最基本的用法是直接在context里执行JS代码并取回结果JSContext *context [[JSContext alloc] init]; JSValue *result [context evaluateScript:1 2]; NSLog(%, [result toNumber]); // 输出3只执行简单计算没有意义互操作关键在于把OC能力暴露给JS。在JavaScriptCore里给JS传一个OC的blockJS那边就可以直接当成函数来调用context[nativeLog] ^(NSString *message) { NSLog(JS调用了原生方法: %, message); }; [context evaluateScript:nativeLog(hello from js)];反过来JS里定义的函数OC也可以通过JSValue调用。先把函数取出来再用callWithArguments调用支持传参数和取值[context evaluateScript:function multiply(a, b) { return a * b; }]; JSValue *multiplyFn context[multiply]; JSValue *result [multiplyFn callWithArguments:[3, 4]]; NSLog(%, [result toNumber]); // 输出12用block做互操作非常灵活但项目大了写法容易乱。更规范的姿势是定义协议让OC对象符合JSExport协议这样JS侧可以直接访问原生对象的方法和属性protocol NativeBridgeProtocol JSExport - (NSString *)getUserToken; - (void)saveData:(NSString *)data; end interface NativeBridge : NSObject NativeBridgeProtocol end创建实例后放进contextJS里就能用bridge.getUserToken()这种直观的方式调用原生方法比一堆散落的block可读性好得多也方便统一管理桥接接口。3.3 WKWebView消息桥JS侧postMessage原生侧实现HandlerJavaScriptCore更适合把JS当脚本引擎来用不一定要渲染完整页面。但实际App项目里更多场景是嵌套一个完整的H5页面这时主流方案是WKWebView。在WKWebView里JS和OC互操作走的是消息桥机制。JS调用OC的标准姿势是调用window.webkit.messageHandlers下面的方法window.webkit.messageHandlers.scan.postMessage({ type: scan, page: home });原生这边要创建一个名为scan的消息处理器实现WKScriptMessageHandler协议WKUserContentController *userContentController [[WKUserContentController alloc] init]; [userContentController addScriptMessageHandler:self name:scan]; WKWebViewConfiguration *config [[WKWebViewConfiguration alloc] init]; config.userContentController userContentController; WKWebView *webView [[WKWebView alloc] initWithFrame:self.view.frame configuration:config]; // 实现协议方法 - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name isEqualToString:scan]) { NSDictionary *body message.body; // postMessage传过来的对象 [self startScanWithType:body[type]]; } }反过来原生调用JS直接用evaluateJavaScript执行一段脚本就行[webView evaluateJavaScript:document.querySelector(#result).textContent 扫描成功 completionHandler:^(id result, NSError *error) { if (error) { NSLog(执行JS出错: %, error); } }];消息桥方案有一个核心限制必须牢记postMessage传的数据会被序列化只能传JSON能表达的类型。函数、Date对象这类东西过不去需要先转成字符串或者数字。我见过不少人在JS侧传了一个含函数的对象结果原生收到全是null排查半天才发现是序列化限制。3.4 参数传递、类型转换和调用时序这三个坑一个都别踩OC和JS互相调用的坑主要集中在参数、类型、时序三方面我挨个说。第一个坑是类型转换。JSValue到OC类型的映射表很完整JS里的number到了OC可能是NSNumber也可能是int、double、BOOL具体取决于你调用toInt32还是toDouble还是toBool选错轻则精度损失重则逻辑错误。还有一个特别容易踩的精度问题JS里超过Number.MAX_SAFE_INTEGER也就是2的53次方减1的大整数在OC和JS之间来回传会丢精度。后端返回的雪花ID如果直接拿NSNumber传两边数据会对不上。正确做法是统一转成字符串传递需要计算时再转回数字类型。第二个坑是调用时序。JS调用原生后原生做了耗时操作再回调JS这时候必须确认WKWebView页面已经加载完成。我遇到过在页面还没加载完的时候就调用evaluateJavaScript结果什么都没执行的情况。WebView加载过程中有些全局对象还没挂载你注入的桥接方法也没生效。稳妥做法是在didFinishNavigation回调之后再去执行JS调用并且对高频调用做节流控制。第三个坑是内存管理。OC的block里如果强引用了JSValue或者JSContext而JSValue反过来又引用了OC对象就可能形成循环引用导致内存泄漏。之前有个项目里明明只是简单弹窗内存却持续上涨查到最后就是桥接block里全局持有JSContext导致的。用block暴露原生方法时捕获的对象一律用弱引用用完记得清理。4. 服务端和其他语言中的JS互操作C#与JavaScript怎么打交道4.1 ASP.NET网页里的老牌互操作注册脚本与回传机制把场景切到服务端。做过ASP.NET Web Forms项目的朋友一定经历过aspx页面和后台aspx.cs之间来回传数据的日子。C#代码不能直接操作浏览器里的JS变量反过来JS也不能直接调用C#方法但页面开发又必须在两端之间交换数据怎么办传统方案是C#向页面注册脚本。比如在Page_Load里用ClientScript.RegisterStartupScript把一段JS字符串注入到页面底部string script alert(数据保存成功);; ClientScript.RegisterStartupScript(this.GetType(), saveMsg, script, true);这个方案本质上就是C#和JS之间单向互操作服务端把JS代码文本交给浏览器执行。C#没有“实时调用”页面里的JS函数而是通过页面生命周期和HTTP请求协作。理解了这一点以后再有人问“aspx.cs怎么调用JS函数”就有答案了不是直连是注册脚本等页面加载到对应阶段再执行。反向需求也就是页面JS想调用C#方法在Web Forms里最常见的做法是用__doPostBack机制。JS里触发一个回传__doPostBack(btnExport, );C#端在对应服务器事件里处理即可protected void btnExport_Click(object sender, EventArgs e) { // 执行导出逻辑 }这套方案虽然老但思想很有代表性跨语言跨环境的互操作要么通过中间格式HTML、HTTP参数要么通过框架约定的机制去完成。理解这个思想比记住具体API更能应对技术变迁。4.2 在C#进程内执行JSJint和ClearScript实战上一节说的注册脚本、回传本质上还停留在“网页层面”的互操作。另一种更硬核的需求是服务端程序要在自己进程内直接执行JS代码。典型场景是业务规则引擎是用JS写的加解密签名算法是前端工程师写的你想复用npm生态里的某个纯JS算法但又不想为了它单独去部署一个Node.js服务。这时候可以用Jint或者ClearScript在C#进程里内嵌一个JS引擎直接执行JS函数。Jint的用法很简洁。假设前端给了一个计算积分的JS函数// rules.js function calculateScore(orders) { let total 0; for (let order of orders) { total order.amount * order.rate; } return Math.floor(total); }C#侧加载文件并调用函数using Jint; var engine new Engine(); engine.Execute(File.ReadAllText(rules.js)); var orders new[] { new { amount 100, rate 1.5 }, new { amount 200, rate 0.8 } }; var result engine.Invoke(calculateScore, orders); Console.WriteLine(result.ToObjectint()); // 输出230Jint会自动把C#对象转换成JS对象函数执行结果再转换回来用起来很顺畅。不过有几个关键点必须注意。第一服务端执行JS有安全风险。如果执行的JS是用户提交的内容等于你把脚本执行能力暴露给了外部恶意代码可以死循环占满CPU甚至尝试访问宿主资源。Jint默认在受限环境里跑还算安全但依然建议加超时控制、限制可用API绝不要直接执行用户输入。第二Jint对ES标准的支持有上限。新版Jint已经支持大部分ES2020特性但如果JS代码用了很新的语法或者依赖DOM、window这些浏览器环境Jint根本跑不起来。选型之前务必确认你要执行的JS语法范围。第三CLR类型和JS类型映射也有坑。C#的decimal在JS里会变成number超过安全范围的整数照样丢精度。传对象时C#的匿名类型、字典、POD对象的属性访问在JS里用法不同建议统一用字典或者JSON字符串做参数传递减少类型歧义。4.3 用JSON统一跨语言的数据交换跨语言互操作里面有一个最通用的中间人就是JSON。前端的JS对象要变成C#能读的字典C#的对象要变成JS能读的结构靠的基本都是JSON序列化和反序列化。我的习惯是凡是跨语言的函数调用参数一律先用序列化工具转成JSON字符串传过去对面收到后再解析。宁可多一次序列化也不要依赖两边的自动类型映射因为自动映射在互操作边界处最容易出诡异问题。还要提醒两个细节。第一JSON里没有undefinedJS的undefined值在序列化时会丢失甚至变成null。第二JSON里没有Date类型日期要么传字符串要么传时间戳双方提前约定格式。互操作的边界地带最忌暧昧的数据格式把字段类型、取值规则、异常值处理都定清楚能省掉大量联调扯皮的时间。5. 常见互操作问题与排查技巧实录5.1 JavaScript运行时报错怎么排查先分清错误类型互操作出问题一大半会以JS运行时报错的形式暴露出来。建议先建立错误分类的心智模型ReferenceError是变量或者环境对象不存在典型的是在JavaScriptCore里用了documentTypeError是类型不对把undefined当函数调用就是它SyntaxError是语法解析失败多半是你动态拼出来的JS代码写错了RangeError是数值越界比如递归过深栈溢出。定位时养成看堆栈的习惯。浏览器控制台里错误信息下面会带完整调用链顺着文件行号找问题。原生App执行JS报错时evaluateJavaScript的completionHandler里的error参数会带着错误信息别忽略。JSContext环境可以设置exceptionHandler把所有异常统一打印这样排查“代码没反应但不知道错在哪”的问题会轻松得多。5.2 关于javascript:void(0)和javascript:;的一些陈旧误解搜索引擎里高频出现“javascript:void(o)怎么解决谷歌浏览器”的问题估计不少朋友是在复制公众号内容、或者处理老项目代码时遇到的。这里需要讲清楚三件事javascript:void(0)是什么、为什么有人这么写、为什么现在成了麻烦。javascript:void(0)是JavaScript伪协议。放在a标签的href里意思是点击链接时执行void(0)表达式返回undefined页面不会跳转。javascript:;同理。很久以前人们常把这种写法当作阻止a标签跳转的手段比如早期微博、公众号图文里的“复制链接”“点赞”按钮href就写成javascript:;实际点击行为由绑定的事件处理。但这种写法在现在的浏览器里越来越不友好。第一现代浏览器对地址栏和书签里的javascript:协议做了很多限制直接在地址栏粘贴javascript:开头的代码不会执行甚至会被过滤这就是很多人“怎么解决都解决不了”的原因。第二a标签语义是超链接把href写成伪协议既破坏语义、又影响无障碍访问搜索引擎爬虫也会困惑。第三它会污染复制行为比如在某些页面复制内容时复制出来变成javascript:;就是链接伪协议带来的典型副作用。正确做法并不复杂。需要按钮行为就老老实实用button元素如果一定要用a标签给它一个正常的href然后在点击事件里调用preventDefault()阻止跳转document.querySelector(#copyLink).addEventListener(click, function(e) { e.preventDefault(); copyToClipboard(); });这样没有跳转副作用语义清晰功能正常也不会有伪协议的兼容性隐患。别再往新代码里写javascript:void(0)了这个写法留给历史项目就好。5.3 跨语言互操作的系统性排查顺序最后把互操作问题的排查思路做一个系统化总结我建议按这个顺序去查环境确认、参数格式、调用时序、错误传递。第一步先确认调用发生的环境。这段JS是在浏览器里跑在iOS WebView里跑还是在服务端JS引擎里跑不同环境下全局对象和可用API完全不同这是最基础的定位也是新手最容易栽的地方。第二步查参数格式。互操作最常见的问题就是数据过了边界之后变了样。JS传的字符串“123”OC收到的却是NSNumberC#传过去的DateTimeJS那边拿到了字符串。在两边入口打日志把原始参数和收到参数都打印出来对比问题就一目了然了。第三步看调用时序。是不是页面没加载完就调原生桥接对象还没注入就去调用了原生异步操作没返回JS已经在干等结果了这些靠断点和日志时间戳基本都能查出来。第四步看错误传递。JS抛异常OC能不能拿到OC回调JSJS那边有没有接住很多跨语言错误难查是因为错误信息根本没有跨边界传过来。我的经验是给两边统一建一个日志桥JS里所有异常都通过桥接方法上报到原生原生侧统一打印整个互操作链路的日志放在一起看排查效率至少翻一倍。5.4 几个容易踩的互操作暗坑再补几个我实际踩过的暗坑。第一个是重复注册。WKWebView的addScriptMessageHandler如果执行多次JS端调用一次postMessage原生端可能会收到多次回调。所以注册动作要做幂等处理每次创建WebView之前先removeScriptMessageHandler再添加新的。第二个是桥接方法的性能损耗。WebView的postMessage通信是异步且经过序列化的高频调用有明显延迟一笔一笔传会卡到怀疑人生。批量传数据一次传一个对象让原生侧整体解析比一个字段一个字段反复postMessage高效得多。第三个是JS执行阻塞主线程。evaluateJavaScript虽然跑在WebView进程里但复杂脚本依然会影响UI渲染。有次向页面一次性注入上万行DOM操作代码页面直接卡住十几秒。把大任务拆成小批次或者用异步延时执行体验会好很多。做互操作开发这些年我个人最大的体会是JavaScript的互操作问题本质上是边界问题。谁在调用谁、参数用什么格式过边界、错误怎么跨边界传递、生命周期怎么对齐把这四个问题想清楚不管是浏览器DOM、OC与JS、C#与JS还是以后遇到小程序容器、桌面端脚本引擎思路都是一脉相承的。我建议新手先别急着研究各种桥接框架先在浏览器控制台里把querySelector、dispatchEvent、input事件模拟这些基本功练熟把类型转换和事件派发的逻辑理顺再去看原生的JavaScriptCore、WKWebView消息桥方案会顺畅很多。互操作的坑永远踩不完但只要边界清楚了你至少知道该往哪个方向排查这就够用了。
返回列表