- 小程序同层渲染原理剖析
众所周知,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是交由原生客户端渲染的。原生组件作为 Webview 的补充,为小程序带来了更丰富的特性和更高的性能,但同时由于脱离 Webview 渲染也给开发者带来了不小的困扰。在小程序引入「同层渲染」之前,原生组件的层级总是最高,不受 [代码]z-index[代码] 属性的控制,无法与 [代码]view[代码]、[代码]image[代码] 等内置组件相互覆盖, [代码]cover-view[代码] 和 [代码]cover-image[代码] 组件的出现一定程度上缓解了覆盖的问题,同时为了让原生组件能被嵌套在 [代码]swiper[代码]、[代码]scroll-view[代码] 等容器内,小程序在过去也推出了一些临时的解决方案。但随着小程序生态的发展,开发者对原生组件的使用场景不断扩大,原生组件的这些问题也日趋显现,为了彻底解决原生组件带来的种种限制,我们对小程序原生组件进行了一次重构,引入了「同层渲染」。 相信已经有不少开发者已经在日常的小程序开发中使用了「同层渲染」的原生组件,那么究竟什么是「同层渲染」?它背后的实现原理是怎样的?它是解决原生组件限制的银弹吗?本文将会为你一一解答这些问题。 什么是「同层渲染」? 首先我们先来了解一下小程序原生组件的渲染原理。我们知道,小程序的内容大多是渲染在 WebView 上的,如果把 WebView 看成单独的一层,那么由系统自带的这些原生组件则位于另一个更高的层级。两个层级是完全独立的,因此无法简单地通过使用 [代码]z-index[代码] 控制原生组件和非原生组件之间的相对层级。正如下图所示,非原生组件位于 WebView 层,而原生组件及 [代码]cover-view[代码] 与 [代码]cover-image[代码] 则位于另一个较高的层级: [图片] 那么「同层渲染」顾名思义则是指通过一定的技术手段把原生组件直接渲染到 WebView 层级上,此时「原生组件层」已经不存在,原生组件此时已被直接挂载到 WebView 节点上。你几乎可以像使用非原生组件一样去使用「同层渲染」的原生组件,比如使用 [代码]view[代码]、[代码]image[代码] 覆盖原生组件、使用 [代码]z-index[代码] 指定原生组件的层级、把原生组件放置在 [代码]scroll-view[代码]、[代码]swiper[代码]、[代码]movable-view[代码] 等容器内,通过 [代码]WXSS[代码] 设置原生组件的样式等等。启用「同层渲染」之后的界面层级如下图所示: [图片] 「同层渲染」原理 你一定也想知道「同层渲染」背后究竟采用了什么技术。只有真正理解了「同层渲染」背后的机制,才能更高效地使用好这项能力。实际上,小程序的同层渲染在 iOS 和 Android 平台下的实现不同,因此下面分成两部分来分别介绍两个平台的实现方案。 iOS 端 小程序在 iOS 端使用 WKWebView 进行渲染的,WKWebView 在内部采用的是分层的方式进行渲染,它会将 WebKit 内核生成的 Compositing Layer(合成层)渲染成 iOS 上的一个 WKCompositingView,这是一个客户端原生的 View,不过可惜的是,内核一般会将多个 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系。不过我们发现,当把一个 DOM 节点的 CSS 属性设置为 [代码]overflow: scroll[代码] (低版本需同时设置 [代码]-webkit-overflow-scrolling: touch[代码])之后,WKWebView 会为其生成一个 [代码]WKChildScrollView[代码],与 DOM 节点存在映射关系,这是一个原生的 [代码]UIScrollView[代码] 的子类,也就是说 WebView 里的滚动实际上是由真正的原生滚动组件来承载的。WKWebView 这么做是为了可以让 iOS 上的 WebView 滚动有更流畅的体验。虽说 [代码]WKChildScrollView[代码] 也是原生组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,因此你可以直接使用 WXSS 控制层级而不必担心遮挡的问题。 小程序 iOS 端的「同层渲染」也正是基于 [代码]WKChildScrollView[代码] 实现的,原生组件在 attached 之后会直接挂载到预先创建好的 [代码]WKChildScrollView[代码] 容器下,大致的流程如下: 创建一个 DOM 节点并设置其 CSS 属性为 [代码]overflow: scroll[代码] 且 [代码]-webkit-overflow-scrolling: touch[代码]; 通知客户端查找到该 DOM 节点对应的原生 [代码]WKChildScrollView[代码] 组件; 将原生组件挂载到该 [代码]WKChildScrollView[代码] 节点上作为其子 View。 [图片] 通过上述流程,小程序的原生组件就被插入到 [代码]WKChildScrollView[代码] 了,也即是在 [代码]步骤1[代码] 创建的那个 DOM 节点对应的原生 ScrollView 的子节点。此时,修改这个 DOM 节点的样式属性同样也会应用到原生组件上。因此,「同层渲染」的原生组件与普通的内置组件表现并无二致。 Android 端 小程序在 Android 端采用 chromium 作为 WebView 渲染层,与 iOS 不同的是,Android 端的 WebView 是单独进行渲染而不会在客户端生成类似 iOS 那样的 Compositing View (合成层),经渲染后的 WebView 是一个完整的视图,因此需要采用其他的方案来实现「同层渲染」。经过我们的调研发现,chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,主要用来解析和描述embed 标签。Android 端的同层渲染就是基于 [代码]embed[代码] 标签结合 chromium 内核扩展来实现的。 [图片] Android 端「同层渲染」的大致流程如下: WebView 侧创建一个 [代码]embed[代码] DOM 节点并指定组件类型; chromium 内核会创建一个 [代码]WebPlugin[代码] 实例,并生成一个 [代码]RenderLayer[代码]; Android 客户端初始化一个对应的原生组件; Android 客户端将原生组件的画面绘制到步骤2创建的 [代码]RenderLayer[代码] 所绑定的 [代码]SurfaceTexture[代码] 上; 通知 chromium 内核渲染该 [代码]RenderLayer[代码]; chromium 渲染该 [代码]embed[代码] 节点并上屏。 [图片] 这样就实现了把一个原生组件渲染到 WebView 上,这个流程相当于给 WebView 添加了一个外置的插件,如果你有留意 Chrome 浏览器上的 pdf 预览,会发现实际上它也是基于 [代码]<embed />[代码] 标签实现的。 这种方式可以用于 map、video、canvas、camera 等原生组件的渲染,对于 input 和 textarea,采用的方案是直接对 chromium 的组件进行扩展,来支持一些 WebView 本身不具备的能力。 对比 iOS 端的实现,Android 端的「同层渲染」真正将原生组件视图加到了 WebView 的渲染流程中且 embed 节点是真正的 DOM 节点,理论上可以将任意 WXSS 属性作用在该节点上。Android 端相对来说是更加彻底的「同层渲染」,但相应的重构成本也会更高一些。 「同层渲染」 Tips 通过上文我们已经了解了「同层渲染」在 iOS 和 Android 端的实现原理。Android 端的「同层渲染」是基于 chromium 内核开发的扩展,可以看成是 webview 的一项能力,而 iOS 端则需要在使用过程中稍加注意。以下列出了若干注意事项,可以帮助你避免踩坑: Tips 1. 不是所有情况均会启用「同层渲染」 需要注意的是,原生组件的「同层渲染」能力可能会在特定情况下失效,一方面你需要在开发时稍加注意,另一方面同层渲染失败会触发 [代码]bindrendererror[代码] 事件,可在必要时根据该回调做好 UI 的 fallback。根据我们的统计,目前同层失败率很低,也不需要太过于担心。 对 Android 端来说,如果用户的设备没有微信自研的 [代码]chromium[代码] 内核,则会无法切换至「同层渲染」,此时会在组件初始化阶段触发 [代码]bindrendererror[代码]。而 iOS 端的情况会稍复杂一些:如果在基础库创建同层节点时,节点发生了 WXSS 变化从而引起 WebKit 内核重排,此时可能会出现同层失败的现象。解决方法:应尽量避免在原生组件上频繁修改节点的 WXSS 属性,尤其要尽量避免修改节点的 [代码]position[代码] 属性。如需对原生组件进行变换,强烈推荐使用 [代码]transform[代码] 而非修改节点的 [代码]position[代码] 属性。 Tips 2. iOS 「同层渲染」与 WebView 渲染稍有区别 上文我们已经了解了 iOS 端同层渲染的原理,实际上,WebKit 内核并不感知原生组件的存在,因此并非所有的 WXSS 属性都可以在原生组件上生效。一般来说,定位 (position / margin / padding) 、尺寸 (width / height) 、transform (scale / rotate / translate) 以及层级 (z-index) 相关的属性均可生效,在原生组件外部的属性 (如 shadow、border) 一般也会生效。但如需对组件做裁剪则可能会失败,例如:[代码]border-radius[代码] 属性应用在父节点不会产生圆角效果。 Tips 3. 「同层渲染」的事件机制 启用了「同层渲染」之后的原生组件相比于之前的区别是原生组件上的事件也会冒泡,意味着,一个原生组件或原生组件的子节点上的事件也会冒泡到其父节点上并触发父节点的事件监听,通常可以使用 [代码]catch[代码] 来阻止原生组件的事件冒泡。 Tips 4. 只有子节点才会进入全屏 有别于非同层渲染的原生组件,像 [代码]video[代码] 和 [代码]live-player[代码] 这类组件进入全屏时,只有其子节点会被显示。 [图片] 总结 阅读本文之后,相信你已经对小程序原生组件的「同层渲染」有了更深入的理解。同层渲染不仅解决了原生组件的层级问题,同时也让原生组件有了更丰富的展示和交互的能力。下表列出的原生组件都已经支持了「同层渲染」,其他组件( textarea、camera、webgl 及 input)也会在近期逐步上线。现在你就可以试试用「同层渲染」来优化你的小程序了。 支持同层渲染的原生组件 最低版本 video v2.4.0 map v2.7.0 canvas 2d(新接口) v2.9.0 live-player v2.9.1 live-pusher v2.9.1
2019-11-21 - 小程序没有 DOM 接口,原因竟然是……?
拥有丰富的 Web 前端开发经验的工程师小赵今天刚刚来到新的部门,开始从事他之前没有接触过的微信小程序开发。在上手的第一天,他就向同办公室的小程序老手老李请教了自己的问题。 小赵:翻了一圈文档,小程序好像并不提供 DOM 接口?我还以为可以像之前一样用我喜欢的前端框架来做开发呢。老李,你说小程序为什么不给我们提供 DOM 接口呀。 老李:要提供 DOM 接口也没那么容易。你知道小程序的双线程模型吗?(小赵漏出了疑惑的表情)小程序是基于 Web 技术的,这你应该知道,但小程序和普通的移动端网页也不一样。你做了很多前端项目了,应该知道在浏览器里,UI 渲染和 JavaScript 逻辑都是在一个线程中执行的? 小赵:这我知道,在同一个线程中,UI 渲染和 JavaScript 逻辑交替执行,JavaScript 也可以通过 DOM 接口来对渲染进行控制。 老李:小程序使用的是一种两个线程并行执行的模式,叫做双线程模型。像我画的这样,两个线程合力完成小程序的渲染:一个线程专门负责渲染工作,我们一般称之为渲染层;而另外有一个线程执行我们的逻辑代码,我们一般叫做逻辑层。这两个线程同时运行,并通过微信客户端来交换数据。在小程序运行的时候,逻辑层执行我们编写的逻辑,将数据通过 setData 发送到渲染层;而渲染层解析我们的 WXML 和 WXSS,并结合数据渲染出页面。一方面,每个页面对应一个 WebView 渲染层,对于用户来说更加有页面的感觉,体验更好,而且也可以避免单个 WebView 的负担太重;另一方面,将小程序代码运行在独立的线程中的模式有更好的安全表现,允许有像 open-data 这样的组件可以在确保用户隐私的前提下让我们展示用户数据。 [图片] 小赵:怪不得所有和页面有关的改动都只能通过 setData 来完成。但是用两个线程来渲染我们平时用单线程来渲染的 Web 页面,会不会有些「浪费」?而且每一个页面有一个对应的渲染层,那页面变多的时候,岂不是会有很大的开销? 老李: 并不浪费,因为界面的渲染和后台的逻辑处理可以在同一时间运行了,这使得小程序整体的响应速度更快了。而在小程序的运行过程中,逻辑层需要常驻,但渲染层是可以回收的。实际上,当页面栈的层数比较高的时候,栈底页面的渲染层是会被慢慢回收的。 小赵: 原来如此。这么说的话,实际的 DOM 树是存在于渲染层的,逻辑层并不存在,所以逻辑层才没有任何的 DOM 接口,我明白了。但是……既然可以实现像 setData 这样的接口,为什么不能直接把 DOM 接口也代理到逻辑层呢?我觉得小程序可以做一个封装,让我们在逻辑层调用 DOM 接口,在渲染层调用接口后再把结果返回给我们呀。 老李:从理论上来说确实是可以的。但是线程之间的通信是需要时间的呀。将调用发送到渲染层,再将 DOM 调用结果发送回来,这中间由于线程通信发生的时间损耗可能会比这个接口本身需要的时间要多得多。如果以此为基础使用基于 DOM 接口的前端框架,大量的 DOM 调用可能会非常缓慢,让这个设计失去意义。 在实际测试中,如果每次 DOM 调用都进行一次线程通信,耗时大约是同等节点规模直接在渲染层调用的百倍以上;如果忽略通信需要的时间,一个实现良好的基于 DOM 代理的框架可以近似地看成一个动态模板的框架,而动态模板和静态模板相比要慢至少 50% 小赵:原来如此,线程通信的时间确实是我没有考虑到的问题。那现在的小程序框架中难道不存在这个问题吗? 老李: 在现在的小程序框架中,这个问题也是存在的,这也是现在的框架基于静态模板渲染的原因。静态模板可以在运行前就做好打包,直接注入到渲染层,省去线程传输的时间。在运行时,逻辑层只和渲染层进行最少的、必要的数据交换:也就是渲染用的数据,或者说 data 。另一方面,静态模板让两个线程都在启动时就拥有模板相关的所有数据,所以框架也充分利用了这一点,进行了很多优化。 小赵: 怪不得我在文档里发现很多和 setData 有关的性能提示,都提醒尽量减少设置不必要的数据,现在总算是知道为什么了。但是具体到实际开发里的时候,还是总觉得很难每次只设置需要的数据啊,像对象里或者数组里的数据怎么办呢? 老李: 如果只改变了对象里或者数组里的一部分数据,可以通过类似 array[2].message , a.b.c.d 这样的 数据路径 来进行「精准设置」。另外,现在自定义组件也支持 纯数据字段 了,只要在自定义组件的选项中设置好名为 pureDataPattern 的正则表达式, data 中匹配这个正则的字段将成为纯数据字段,例如,你可以用 /^_/ 来指定所有 开头的数据字段为纯数据字段。所有纯数据字段仅仅被记录在逻辑层的 this.data 中,而不会被发送到渲染层,也不参与任何界面渲染过程,节省了传输的时间,这样有助于提升页面更新性能。 小赵:小程序还有这样的功能,受教了。不过说来说去,我还是想在小程序里用我顺手的框架来开发,毕竟这样事半功倍嘛。我在网上搜索了一下,发现现在有很多支持用 Web 框架做小程序开发的框架,但好像都是将模板编译成 WXML,最终由小程序来做渲染,但这样的方法好像兼容性也不是很好。我在想,我们能不能在逻辑层仿造一套 DOM 接口,然后在运行时将 DOM 调用适配成小程序调用? 老李: 你的这个脑洞有一些意思。在逻辑层仿造一套 DOM 接口,直接维护一棵 DOM 树,这当然没问题。但是没有代理 DOM 接口,逻辑层的 DOM 树没法反映到渲染层,因为渲染层具体会出现什么样的组件,是运行时才能知道的,这不就没法生成静态模板了? 小赵:静态模板确实是没法生成了,但我看到小程序的框架支持自定义组件,我是不是可以做一个通用的自定义组件,让它根据传入的参数不同,变成不同的小程序内置组件。而且自定义组件还支持在自己的模板中引用自己,那么我只需要一个这个通用组件,然后从逻辑层用代码去控制当前组件应该渲染成什么内置组件,再根据它是否有子节点去递归引用自己进行渲染就可以了。你看这样可行吗? [图片] 老李: 这样的做法确实可行,而且微信官方已经按照这个思路推出小程序和 Web 端同构的解决方案 Kbone 了。Kbone 的原理就像你刚才说的那样,它提供一个 Webpack 插件,将项目编译成小程序项目;同时提供两个 npm 包,分别提供 DOM 接口模拟和你说的那个通用的自定义组件作为运行时依赖。要不你赶紧试试? 小赵:还有这么好的事,那我终于可以用我喜欢的框架开发小程序了!这么好的框架,为什么不直接内置到小程序的基础库里呀? 老李: 因为这样的功能完全可以用现在已有的基础库功能实现出来呀。Kbone 现在是 npm 包的形式,使得它的功能、问题修复可以随着自己的版本来发布,不需要依赖于基础库的更新和覆盖率,不是挺好的吗? 小赵: 好是好,但我担心的是代码包大小限制的问题。除了我们已经写好的业务逻辑之外,现在还得加上 Kbone,会不会装不下呀? 老李: 原来你是担心这个呀,放心,Kbone 现在已经可以在 扩展库 里一键搞定啦。扩展库是帮我们解决依赖的全新功能,只要在配置项中指定 Kbone 扩展库,就相当于引入了 Kbone 相关的最新版本的 npm 包,这样就不占用小程序的代码包体积了,快试试吧! 小赵:哇,那可太爽了,马上就搞起! 最后 如果你对 Kbone 感兴趣或者有相关问题需要咨询, 欢迎加入 Kbone 技术交流 QQ 群:926335938
2020-01-14