berwin / Blog

记录成长的过程
MIT License
4.16k stars 316 forks source link

小程序底层实现原理及一些思考(2) #49

Open berwin opened 4 years ago

berwin commented 4 years ago

小程序底层实现原理及一些思考(2)

写在前面的话,最近频繁出现有人盗版我的原创文章、盗版我出版的书、抄袭我的文章、根据我的文章又写了一篇新的文章然后说是自己的研究成果、根据我的文章写了一个开源项目然后说是自己原创、根据我出版的书二次改写并开源到Github获得好几千Star等情况。

我愿意写硬核知识分享给大家是给大家学习与成长用的,也仅限于学习与学术交流使用。

我不希望我的文章以任何形式被转载、被二次改写、被盗版、被抄袭、或者根据我的文章开发了一个小程序框架开源出去骗star等侵占我版权的行为,一经发现必追究责任。

如果无法控制盗版行为,我将考虑关闭博客,开启白名单模式,只有白名单内的人可以阅读我的文章。为了帮助更多的人学习与进步,请大家拒绝盗版!

去年九月份写了篇文章 《小程序底层实现原理及一些思考》,讲述了我实现探索小程序的过程及一些思考,并揭露了一个事实:小程序是基于Web技术实现的

兜兜转转尝试了很多方案,但不同的方案均存在一些问题,我试图找到完美的解决方案,功夫不负有心人,最终找到了。

在前文的最后,我提到最终还是要回到双线程,双线程才是正确的方向,现在经过验证这个方向非常正确。

但如何基于双线程实现小程序前文只是略微提及,因为当时也只是一个初步的设想,并没有实现出来。

为什么是双线程

当反复尝试了很多方案后,我开始重新思考,到底什么是小程序。

思考后得到的结论是,站在产品角度思考技术,小程序有两个特点:

  1. 免安装
  2. 具备通过宿主APP访问OS的能力

小程序的定义和特点有很多,但我认为最根本的特点是以上两点

站在技术角度思考,小程序至少要保证四点:安全稳定性能简单

如果只是站在产品角度思考如何实现小程序其实非常简单。

基于宿主环境的不同可能实现方式不同

如果宿主环境是浏览器,任何一个网页都具备 “免安装” 的特点,浏览器只需提供一些拥有OS能力的API,这就是小程序了。

这和PWA区别不大,或许这也是很多人会拿小程序和PWA进行对比的原因。

如果宿主环境是一个超级APP(iOS或Android),任何一个WebView都具备免 “免安装” 的特点,超级APP只需提供一些拥有OS能力的API给WebView里面的代码用就可以了,套路都是一样的。

站在技术角度思考,为了保证安全性与稳定性问题,单线程这个方向就要完全放弃,这也是为什么上一篇文章的最后提到“双线程”是正确的方向。

事实上,双线程并不够,如果允许多个小程序同时运行,那么双线程无法解决稳定性的问题,正确的做法是使用 “多线程” ,将不同小程序的代码在不同的线程下执行。

如果同一时间只执行一个小程序,那么双线程(UI线程与逻辑线程)就可以满足“安全性”与“稳定性”的需求。但同时运行多个小程序,则需要多个逻辑线程同时执行不同小程序的逻辑,以及多个UI线程同时渲染不同小程序的UI。

多线程

多线程不只是解决多个小程序并存的问题,每个小程序还需要有自己的多个UI线程。

也就是说要完全抛弃SPA单页应用,小程序应该做成多页应用,而不是单页应用。

之所以采用多UI线程的原因是单页应用很难模拟原生应用切换页面的体验。单页应用在从A页面跳转到B页面时,其实画布还是那块画布,只是把A页面的内容擦掉把B页面的UI画上去了。这种原理在很多场景下很尴尬,举个例子:用户开发一款新闻信息流小程序,它有两个页面,A页面是新闻信息流,B页面是新闻详情页。那么当用户往下滑了很久后发现一个感兴趣的新闻,点击跳转到详情页,看完之后想回到A页面刚才的位置继续浏览。单页应用由于把A页面给擦掉了,所以这种场景下当从B回到A时,会发现A又重新刷新了一遍,体验非常糟糕。

采用多UI线程则可以解决这个问题,实现多UI线程并不复杂,如果宿主环境是浏览器,则可以在页面中使用多个iframe叠在一起,每当跳转页面时,创建一个新的iframe盖在最上面,当回退时,把最上层的iframe删除即可。如果宿主环境是手机上的超级APP,则把iframe改成WebView,套路都是一样的。

多线程

双线程的关键在于逻辑线程要尽可能的 “轻” ,并且要尽量 减少 线程间通信的 频率 ,曾经在双线程上失败主要在于逻辑线程非常重,并且线程间通信非常频繁,上一篇文章有提到。

多线程架构

为了做到让逻辑线程尽可能的 “轻” ,且 减少 逻辑线程与UI线程的通信 频率 ,那么逻辑线程的职责应该设计成只做两件事:

  1. 执行用户的函数(生命周期、方法等)
  2. 收发状态数据

执行用户的函数是为了让开发者有能力修改状态数据,状态数据只在组件初始化以及开发者修改状态数据后发送至UI线程。其余全部逻辑均在UI线程下完成。

通过这样的方案达到了让逻辑线程尽可能的 “轻” (只是执行开发者提供的函数而已),且 减少 逻辑线程与UI线程的通信 频率 (只在初始化及状态数据发生变化时产生通信)目的。

之所以把架构设计成这样是因为如果不这样,则会有两个致命问题:“性能”和“原生能力受限”。

上一篇文章《小程序底层实现原理及一些思考》有提到另一种方案(及逻辑线程重而UI线程轻)的问题,这里简单回顾一下这个失败的方案:

UI线程只负责接收指令(创建元素、修改元素、插入元素、路由跳转、事件绑定等一些基础指令),剩余的一切都有逻辑线程执行(用开发者提供的状态数据计算应该发出哪些指令)。这个方案有两个致命问题:“性能”和“原生能力受限”。

多线程架构

因为逻辑线程经过计算后,若想渲染一个完整的UI页面,需要发送大量的绘制指令(创建元素、修改元素、插入元素、修改属性等),指令在线程间传输是异步的,也就导致指令信号的传输间有间隔,由于指令很小,导致间隔的时间比指令的时间还长,如下图所示:

指令传输性能图

黄色的JavaScript执行方块是指令的执行,而两个指令之间有很多空闲的时间,因为执行信号的传递需要时间。所以背后的执行过程是,接受一个指令绘制一下,然后等一会又接收一个新的指令然后再绘制下,当指令数量庞大的时候,性能问题非常明显,绘制一整个UI界面非常慢。

所以让逻辑线程尽可能的 “轻” ,且将线程间传输的“指令”改为“数据”来 减少 逻辑线程与UI线程的通信 频率 是非常重要的。

通讯模块

谈完了整体架构的设计和考虑,再谈谈通讯模块,顾名思义,通信模块的职责是负责不同线程之间的通信功能。

在多线程架构下,通讯模块至关重要,但通讯模块的实现并不复杂,底层依赖宿主环境提供的消息通道。例如:Master层控制iframe时底层可以使用postMessageAPI。

有一点需要注意:通信模块在通讯时,为了将信号准确地发送到指定位置,需要根据频道号发送,频道号的规范应该设置为[mid]_[pid]_[cid]

之所以将信号规范成这样,是为了同时执行多个小程序时,信号依然可以准确地找到指定位置。

另一个需要注意的地方是,官方组件的逻辑执行环境和开发者的组件逻辑执行环境不在一个地方, 通信模块需要负责分辨并将信号发送到指定位置

通信的逻辑大致如下:

官方组件与开发者组件通信的不同之处

因为Web Worker是一个沙箱环境,能力受限,所以将用户的逻辑代码放在沙箱中执行,而官方组件的逻辑部分则需要放在拥有全部能力的UI层执行。

所以通讯模块需要分辨出信号应该发到沙箱内还是发送到UI层。

渲染流程

小程序的渲染流程本质上和目前市面上比较流行的前端框架(Vue.js、React)没有本质区别,小程序会让开发者写一份模板用来渲染,然后再写一份JS用于控制小程序的逻辑和更新数据。这和Vue.js和React是一样的。

UI = render(state)

更具象化一点:

UI = template(data)

在小程序中,模板是现成的,开发者写的模板框架是可以直接拿到的,而数据是在整个生命周期开始时,从逻辑层会不断的将最新的数据传输到UI层。

所以开发者编写的模板是直接在UI层加载进去,而开发者编写的JS,则加载到沙箱中执行。在整个项目的生命周期初始化时,会将数据传输到UI层,UI层拿到数据后,结合模板进行渲染,这就是首次渲染。

后面每当开发者通过JS更新状态时,均将状态传输到UI层做一次渲染。

当然,这里面会做一些优化以达到每次数据更新只修改和这次数据更新有关联的那部分UI内容,以提高性能。若想实现这一点并不难,可以使用VirtualDOM,也可以像Vue.js 1.0一样通过最细粒度的Watcher监测每一个DOM标签所绑定的数据是否有更新。

渲染流程图

路由

前面讲多线程时,提到小程序应该完全抛弃 SPA单页应用 做成多页应用,因为多页应用可以保留前一个页面的状态。

多线程

所以路由的内部是基于多页应用的的架构实现的,基于这个原理路由其实并不复杂。

首先,触发路由的行为可以是从UI层发出,也可以是从沙箱中发出。在UI层发出的信号可能是用户点了回退按钮,或者某种回退上一页的手势,信号由宿主环境发出。在沙箱中发出的信号是开发者通过官方提供的API发出的信号。

那么无论是UI层“用户”发出的信号,还是沙箱中“开发者”发出的信号,该信号都应该发送到Master层,由Master层统一控制路由。

路由信号有两种行为:前进、后退。

前进信号: 如果宿主环境是浏览器而承载UI页面的是iframe,那么前进信号对应的行为是创建一个新的iframe盖在前一个页面的上面,并初始化新页面的生命周期。其他宿主环境和承载UI页面的容器原理与纯Web方案一致。

后退信号: 后退信号对应的行为是从页面栈的栈顶开始删除页面(承载UI界面的载体,如:iframe、WebView等),官方提供给开发者的API可以通过参数设置回退几层,对应的行为是删除几个页面,最多删除 stack.length - 1个(最多回退到首页)。

创建新路的流程图

上图给出了当开发者调用API打开新页面时,路由的内部流程图。沙箱中发出信号到Master,Master接收信号后创建iframe推入页面栈,页面在创建的时候会同时把基础JS库带进去,页面创建后JS基础库会立刻执行初始化操作,初始化完毕后会发送一个信号通知Master页面已创建并初始化完毕,随后Master会发送信号到沙箱中。之所以这样设计流程是因为有两个目的:

  1. 需要通知开发者页面已经创建成功。
  2. 在沙箱中创建新页面的“根组件”,并正式开启新页面的生命周期与渲染的流程。

生命周期

生命周期可以窥探到内部运行的时序,在多线程架构下,由于渲染层和逻辑层是分开执行的,所以生命周期需要依赖通讯模块传递信号来控制小程序的不同阶段。

小程序的入口是Master,Master既不属于UI层,也不属于沙箱,它是凌驾于所有的一个上帝视角。在宿主环境是浏览器的情况下,Master为 index.js,它是整个程序的入口。

index.js中除了做一些初始化工作,最重要的是它需要开启沙箱环境(比如:Web Worker),沙箱环境开启之后,也会做一些环境的初始化工作。相当于从index.js入口启动小程序后,Master和沙箱环境的初始化工作是第一步。

当沙箱环境初始化完毕后,它需要向Master发送一个信号,通知Master沙箱环境已经准备就绪。这时候Master会根据开发者设置的配置,创建小程序的第一个页面,也就是小程序的首页。

配置信息中包含了小程序的路由信息,当然也包括哪个是小程序的首页以及对应的组件。

当第一个页面被推到页面栈后,该页面开始进行初始化工作,这个时候UI层可以拿到该页面的组件树以及每个组件对应的模板等信息,页面会从根组件开始初始化,在UI层组件初始化的过程中,UI层的组件会发送信号到Master,通知Master组件初始化完毕,Master收到信号后需要发送一个信号到沙箱中,沙箱接收到信号后需要在沙箱环境中创建一个对应的组件用来执行开发者的JS逻辑。也就是说,同一个组件其实是被拆成两部分,分别在UI层中初始化,然后在沙箱中再初始化,在UI层中组件负责渲染以及处理UI事件等事情,而在沙箱中的组件主要负责调用开发者定义的函数,以及提供开发者一些API修改组件的状态,整个流程如下图所示:

生命周期流程图-创建逻辑层组件

逻辑层组件被初始化的过程中,会触发两个生命周期函数:“beforeCreate”和“created”。

当逻辑层的组件初始化完毕后,会发送一个信号到渲染层的组件中,通知渲染层的组件逻辑层组件这边已经初始化完毕,并且会将组件的状态信息发送到渲染层组件,渲染层组件收到信号后,就可以拿着数据去做首次渲染操作,如下图所示:

生命周期流程图-全部流程

当首次渲染完成后,渲染层组件会发送一个信号到逻辑层组件中,逻辑层组件收到信号后触发生命周期“onReady”通知开发者已经首次渲染完毕。

后面每当开发者调用 setData 修改数据时,逻辑层组件都会将最新的数据发送到渲染层对应的组件中,该组件会用最新的状态重新走一遍渲染流程。

如果这期间用户点击了组件中的某个绑定了事件的元素,那么UI层组件会发送信号到逻辑层中对应的组件,并将一些事件信息一同发过去,逻辑层组件收到信号后调用开发者绑定的函数并将事件信息通过参数传递给开发者,整个流程结束。

底层技术的应用场景

前面介绍了很多底层技术原理,这套技术虽然是基于“Web环境下的小程序”发明出来的,但小程序只是这套技术的众多应用场景里的其中一个,这套技术可以支撑的应用场景还有很多。

WebIDE插件系统

vscode

WebIDE的插件系统和小程序有相同的特性,IDE插件也需要同时满足:安全稳定性能简单

区别在于,小程序的场景是在一个“环境”下只能执行一个小程序,而IDE需要在相同的“环境”下要求N个插件同时执行。

这里说的“环境”指的是:以Web环境为例,通常页面的入口文件是 index.html 那么一个环境只运行一个小程序指的是这个 index.html 只执行与渲染一个小程序,也可以理解为浏览器一个Tab标签为一个小程序容器。

一个环境运行多个插件可以理解为 index.html 里同时运行大量的插件,也就是说,相同的页面下,同时运行大量的插件,这些插件在同一个页面下互相隔离运行,在这种场景下“稳定性”尤为重要,某个插件死循环或者有其他问题,整个页面不应该受到影响。而且因为在同一个页面下执行,那么插件A不应该有权限访问和修改插件B的UI也是非常重要的一个特性。

综上,本文介绍的技术原理也同样适用于WebIDE的插件体系。

Figma插件系统

Figma的插件系统与本文将的技术体系有异异曲同工之妙,本文介绍的技术原理同样适用于Figma插件系统这样的需求。

App Store

如果将一个网页当做上网的入口,或者当做操作系统,并将在这个网页中运行的小程序当做应用程序。那么使用这套技术,可以让这个网页同时运行大量的,各种类型的应用程序,从而实现将网页变成操作系统的能力。

其他场景

这套技术的适用范围远不止上面提到的案例,任何需要第三方开发者参与并最终在自己的平台上运行程序的场景,都可以使用这套技术来实现。

总结

本文详细讨论了基于Web技术实现小程序所涉及到的方方面面,并介绍了如何让基于Web技术的小程序拥有安全稳定性能简单等四个基本特性。

同时在本文的最后也提到了该技术虽然诞生于小程序这个场景,但底层技术并不只局限于小程序,在其他场景下也有很广泛的应用空间。

插播一条广告,团队之前只进P7,目前开放了几个P6+的HC,机会难得,有想法和我一起搞天猫双十一帮全国女生剁手的同学可以来一封简历:jiuwu.lbw@alibaba-inc.com

sunnynudt commented 4 years ago

大佬,请教几个问题哈。

  1. 您目前阐述的原理,是不是目前市面上的小程序的底层实现都和你说的类似?包括微信小程序(这个应用最广泛)?
  2. 针对具体技术细节,有个地方不是很清晰。比如在微信小程序的某个JS文件修改了一个状态A,那么在存在多个webview线程的情况下,如何定位到某个webview线程的某个组件根据状态A进行重新渲染呢?有一点需要注意:通信模块在通讯时,为了将信号准确地发送到指定位置,需要根据频道号发送,频道号的规范应该设置为[mid]_[pid]_[cid]。是根据这原理吗?
  3. 针对微信小程序,逻辑线程如何通知到native,native如果再通知webview线程呢?大佬有时间详细讲一讲吗?或者提供一些文档也可以。目前,没有找到更好的解释。你这两篇文章质量很高,解决了我很多疑惑。感恩!也买了您的Vue.js书籍表达了支持!
caozhong1996 commented 4 years ago

我很喜欢您的博客,也买过您出的书,如果大佬开启博客的白名单,可以把买过书的人拉进去吗?保证不外传。

liang520 commented 4 years ago

微信小程序的实现,在逻辑层也维护了一套虚拟dom,比如调用createSelectorQuery获取节点属性的时候,并不会去渲染层获取,逻辑层直接处理,这应该不是最主要的原因,为什么维护一套虚拟dom在逻辑层还在研究中

wstcyx commented 3 years ago

微信小程序 现在应该也是多线程的

wizardpisces commented 3 years ago

文章写的可以,有个点可以讨论下:"之所以采用多UI线程的原因是单页应用很难模拟原生应用切换页面的体验",这个结论有待商榷吧,单页面也可以做出多页面的切换效果,组件缓存后就没得刷新这个说法了;核心原因可能不是这个吧,不知道博主有没有新的见解

MrLeihe commented 3 years ago

文章写的可以,有个点可以讨论下:"之所以采用多UI线程的原因是单页应用很难模拟原生应用切换页面的体验",这个结论有待商榷吧,单页面也可以做出多页面的切换效果,组件缓存后就没得刷新这个说法了;核心原因可能不是这个吧,不知道博主有没有新的见解

组件再怎么缓存也达不到多页切换的流畅度

delenzhang commented 2 years ago

文章写的可以,有个点可以讨论下:"之所以采用多UI线程的原因是单页应用很难模拟原生应用切换页面的体验",这个结论有待商榷吧,单页面也可以做出多页面的切换效果,组件缓存后就没得刷新这个说法了;核心原因可能不是这个吧,不知道博主有没有新的见解

组件再怎么缓存也达不到多页切换的流畅度

自定义路由方式,使用absulute 的 div 作为 webview 实现多个页面,缓存dom的方式 缓存每个页面的内容,单页面模拟原生的push 和 pop 动画,以及实现App的回退的中间态滑动动画,可以实现,但是体验相同机器比较 总是逊色于原生交互。最主要的是 安全和管控

zhhwdev commented 9 months ago

多UI线程是不是就是每个页面使用单独的webview,感觉这样内存会爆掉。。

zhhwdev commented 9 months ago

文章中分享的,逻辑层运行在web worker中,我分析了飞书的方案,他们的逻辑层好像运行在jscore中,这种区别是基于什么考虑?