两月以后,看着电脑,我回想起接到通知说要开发小程序引擎的那个下午。当时的我以为,这个小程序和其他小程序都不一样,因为它是个假的,其实是个网页。两月之后,我才发现,“噢~原来大家都是这么做的啊”。
最近一直在做小程序的底层实现,过程中磕磕绊绊也多次进行架构方向上的转型,趁着周末抽空写一篇文章记录一下开发过程中遇到的问题和一些思考与决策。
本篇文章更多的是在描述架构与技术方向层面的思考和决策,不会过多介绍具体某个问题是如何解决的,因为细节实在太多。
当时的我将我们的小程序定位成一个SPA,因为我们的小程序的宿主环境是浏览器。
它只是看起来像小程序(因为这个窗口没有地址栏什么的),但其实包括UI渲染和事件交互在内的绝大部分功能都是基于Web技术,虽然会提供Native和OS的一些能力与API,但本质上其实是个网页。又考虑到目前很多人使用第三方工具用Vue或React写小程序,我就在思考:“反正本质上就是一个网页,那为什么不原生内置Vue让用户直接用Vue的语法写小程序呢?”。
所以当时定了一个基本方向:让开发者使用Vue开发我们的小程序,开发体验完全与Web保持一致。
虽然开发体验与Web保持一致,但是Web技术实在是太开放了,开发者可以为所欲为。这种情况在小程序中是不允许的,不允许使用<iframe>
、不允许<a>
直接外跳到其他在线网页、不允许开发者触碰DOM、不允许使用某些未知的危险API等。
所以遇到的第一个问题是如何禁止用户在Vue的模板中使用iframe
或a
或其他不允许使用的东西。
若想做到这一点就不得不对Vue的渲染层进行一个托管与改造。
对Vue进行改造通常有两种方案:
- 使用类似
polyfill
的手法覆盖一些Vue原生提供的API - Fork一个Vue出来自己改
第一个方案能力有限,有一些Vue内部的逻辑没有办法通过polyfill
的形式更改。第二种方案的缺陷是如果我只想修改Vue中的某一块逻辑,其他我不修改的部分如果有Bug,Vue官方更新了版本我没有办法同步。
这两种方案都有缺陷和不足,所以我没有使用这两个其中的任何一个,我使用了另一个方案,我觉得应该是目前为止最好的一种方案。
我简单介绍一下这种方案:
- 把Vue.js装到
node_modules
里 - 项目里使用webpack,并设置上别名
- 把自己想改造的那部分代码copy到自己的项目目录中进行修改
因为我需要对渲染层进行改造,所以我需要重设web
这个别名,如下:
const path = require('path')
module.exports = {
'vue$': path.resolve(__dirname, '../src/web/entry-runtime-with-compiler'),
compiler: 'vue/src/compiler',
core: 'vue/src/core',
shared: 'vue/src/shared',
web: path.resolve(__dirname, '../src/web'),
weex: 'vue/src/platforms/weex',
server: 'vue/src/server',
sfc: 'vue/src/sfc'
}
大致原理是:如果import
了一个不需要改造的,那其实是import
了node_modules
里的原始Vue的文件,如果是import
了需要改造的,那其实import
的是我的目录,文件也是我修改后的文件。
对这方面技术有兴趣可以留言详细讨论,由于不是本文重点,不再展开。
Vue通过一系列计算之后最终产出的结果是一些指令,比如创建一个DOM元素,移除一个DOM元素,插入到某个位置等。
所以当时的做法就简单在创建DOM元素时,用tagName
校验是否在黑名单中,如果在黑名单中就触发警告并怎么怎么样。
但其实这种做法只能是:防君子不防小人。
项目做到这里遇到一个问题是不论怎样,都没有办法防止开发者做一些我们想禁用的功能。因为是一个网页,开发者可以执行JS,可以操作DOM,可以操作BOM,可以做一切事情。
所以我们开始考虑将用户的代码放到一个绝对安全的沙箱中去执行。
在Web技术下,可以将用户的代码放到Web Worker中去执行,也可以放到一个隐藏的iframe中去执行,或者宿主环境提供一个环境。无论怎样,目的都是相同的,就是把用户的代码放到一个绝对安全的沙箱中执行。
由于开发者是基于Vue.js开发,用户的代码是没有办法单独放到沙箱中去执行的,所以我把Vue也放到沙箱中去执行。
这个时候技术架构和技术方向被调整成了Master-Slave的双线程模式。
沙箱中的代码我称为Master,它通过一系列计算,最终输出一些指令:创建元素、修改元素、插入元素、路由跳转、事件绑定等一些基础指令。这些指令从沙箱中通过线程间的消息机制传递到网页中,这个网页有一个模块叫做Slave,它负责监听Master发过来的指令并根据指令做指定操作。
把Vue放到Web Worker中执行需要解决非常多的问题,比如:原本Vue直接对DOM的操作需要转换成向另一个线程发送指令,还有事件绑定问题,事件的Event对象问题,事件修饰符(event.preventDefault
)问题,路由控制(双向的)问题,表单元素的双向绑定问题、ref
问题等。因为线程间的消息传递只能传递字符串,所以很多东西就会变得非常麻烦。
不过这些具体的技术问题都是比较容易解决的,比较难的问题是两个:“性能”和“原生能力受限”。
在这种架构下,当页面有大范围UI变化时(例如首次渲染),Logic线程需要往UI线程发送大量的指令,包括:创建DOM,插入DOM,绑定事件等,每条指令都是一个单独的跨线程的消息通信,当消息数量庞大时,性能问题就会暴露出来,而且非常明显。
如果去Chrome DevTools的Performance面板看,会发现UI线程其实很闲,但是渲染的就是很慢。因为消息传递的代价,而且每次encode与decode也都需要代价,我自己写DEMO时没发现问题,但是投放到生产环境下去渲染一个真实的组件时,就会发现性能问题非常明显。
这个架构下虽然有性能问题,但以我的能力想解决这个问题也并不是太困难。另一个问题(原生能力受限)是这个架构下永远都无法解决的一个问题。
为了安全,需要把用户的代码放到沙箱中去执行,但因为想让用户使用Vue开发,所以Vue也得放到沙箱中,这就导致了一个无法解决的问题是:我们官方提供的原生组件,也会受限。
道理很简单,假设用户在模板中使用了一个官方原生提供的组件,那么这个组件一定是需要提前注册到Vue中的,那就代表,我们官方提供的组件,也得放在沙箱中,所以把我们自己也给限制住了。
导致我们官方想提供视频组件,音频组件就很困难,因为我们官方组件也不能碰DOM和BOM,就更不用说提供其他Native能力的组件了。
当然这个问题在不改架构的情况下还是有办法强行解决的,解决方案是这样的:
官方组件先在沙箱中注册一个Vue的组件,然后这个组件不去实现具体的功能,只是接收用户传递的数据,然后把数据传递给UI线程,然后UI线程有一个这个组件对应的真实的组件去做具体的功能并最终渲染到UI上。
每一个组件都需要这样做,这对开发组件的同学非常不友好,他们需要理解这个小程序底层的架构是如何运行的,而且还会增加很多工作量。
这不是我想要的,我希望的是不管我底层是单线程还是双线程,对上层开发是无感知的。而且因为Vue.js是在沙箱中做各种操作,也不确定未来会不会有什么需求是无法满足的,技术风险太高了。
到了这一步,我已经慢慢的开始感受到,Vue已经成为瓶颈,它限制了我。
不只是因为技术原因,也因为一些其他原因,比如风险太高,时间紧,最终决定先将方案切换回单线程,这样至少说可以保证这个项目不用延期。然后另一边再去慢慢调研并研究出一个技术方案可以解决之前遇到的所有问题。
似乎又回到了起点,因为单线程有单线程的问题,那就是Web技术太开放了,我们无法做到“安全”和对开发者进行“管控”。
不过我们还是在单线程模式下找到了可以提高一定安全性的方案,方案是通过ShadowDOM的Close模式把Body锁住。这样开发者自己的代码是无法操作DOM的,因为被我锁住了,但是开发者的JavaScript是自由的。
在这个架构下,开发者可以操作DOM,可以操作BOM,可以操作Vue.js,什么都可以干,但它无法直接操作被我锁住的ShadowDOM,想操作这个ShadowDOM,必须通过合法的途径操作,而这个ShadowDOM才是小程序用于展示的主要的窗口。
然后对于BOM上的某些危险API,会被提前禁用掉。
这个方案似乎解决了所有问题,但还是为未来留下了一点隐患,只要开发者的JavaScript是自由的,你就永远无法知道他会用他的JavaScript做什么。对于某些未知的漏洞,可能非常危险,这为日后留下了一个风险,事情会变成一场官方和开发者之间持久的攻防战。
无论怎样,这个方案解决了目前遇到的所有问题,这也为我留下了非常多的时间去研究真正的小程序应该怎样做。
最终,我发现双线程才是正确的方向,只有把用户的代码放到沙箱里执行才能真正的做到:“安全”和“管控”。
不过这一次我决定不再使用Vue.js,我需要开发一个全新的框架来支持双线程这种模式。上一次的双线程之所以会失败,主要原因是上一次是UI线程比较轻,而Logic线程比较重,用户的代码,Vue.js,官方的组件都跑在Logic线程下,而这个线程只是一个JS运行时,所以我们的原生能力会受到限制。
而这一次我决定让UI重一点,Logic轻一点。只把用户的代码和框架的一部分下放到Logic线程,大部分操作和原生组件都放在UI线程下执行。
Worker线程只是做一些计算然后把数据传递到UI线程,然后大部分工作都在UI下面执行,并且官方的组件在UI这边执行。
这样可以解决之前遇到的两个问题:性能和原生能力受限。
因为线程之间的消息通道只传递数据,而数据不会像绘制UI指令数量那么多,可以说根本不在一个数量级,性能问题解决了,而且不光解决了性能问题,还顺带着提升了性能,因为无论用户代码写的执行效率再怎么低,都不会卡死UI线程。
原生能力受限的问题也解决了,因为官方提供的组件根本不在这个线程下运行,安全和管控的问题也解决了。
之前我一直以为其他小程序是Native渲染的,而我是基于Web技术实现的,但偶然有一次看到一些资料,才发现,原来大家都是基于Web技术实现的小程序,而实现方式也大致相同,遇到的问题也都一样。
可能唯一的区别是,我不需要多个webview,我只需要一个网页就够了,所以我可以把Logic线程的代码放到Web Worker里,而其他小程序是多个webview,所以他们不能用Web Worker,不过没有本质区别。
前一段时间各种小程序多端框架满天飞,准确的说,这些框架都是“翻译”器,就我个人觉得,以翻译为基础在不同小程序之间进行跨端,只能算是一种临时性技术方案。
我觉得真正的终极技术方案,有两种:
- 从渲染引擎层面抹平平台间的差异,例如:Flutter
- 各个小程序开发商共同制定一些标准和规范,例如:浏览器
第一种,使用小程序提供的canvas的一些API实现一个渲染引擎,然后在渲染引擎上实现一些布局引擎,在此基础上提供的框架和其他能力都是统一的,不同平台之间只需要实现不同的渲染引擎即可。不过我不确定小程序提供的canvas能不能做到这一点,不过Web浏览器提供的canvas可以做到,像SpriteJS就做到了。
第二种,各大小程序厂商共同制定一套标准,按照标准实现各自的API,这种情况是比较好的,而且也不是完全没有可能。最近各大小程序厂商已经在W3C起草了小程序白皮书。
我捡重要的列一下白皮书中的内容:
- 标准化小程序包(就是说一份小程序代码,可以在各大小程序平台解析,使用统一的
.ma
后缀的文件) - 标准化小程序页面的URI Scheme(就是说定义一份协议,然后同一个URI地址可以在不同的小程序平台打开相同的页面)
- 标准化小程序Widgets
仔细看到这里的读者应该会对我开发小程序的整个过程和一些决策有一个大致的了解。
大家对小程序的底层实现都是使用双线程模型,大家对外宣称都会说是为了:
- 方便多个页面之间数据共享和交互
- 为native开发者提供更好的编码体验
- 为了性能(防止用户的JS执行卡住UI线程)
- 其他好处
但其实真正的原因其实是:“安全”和“管控”,其他原因都是附加上去的。
因为Web技术是非常开放的,JavaScript可以做任何事。但在小程序这个场景下,它不会给开发者那么高的权限:
- 不允许开发者把页面跳转到其他在线网页
- 不允许开发者直接访问DOM
- 不允许开发者随意使用window上的某些未知的可能有危险的API
当然,想解决这些问题不一定非要使用双线程模型,但双线程模型无疑是最合适的技术方案。
声明,本文仅限学习使用,最终会使用什么方案目前还无法明确表示。技术因素以及非技术因素都会影响最终技术方案的决策。