1

探索小程序底层架构原理 - 前端南玖

 1 year ago
source link: https://www.cnblogs.com/songyao666/p/16981591.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

双线程架构

在这之前,我们先来思考一个问题,小程序在架构上为什么会选择双线程?

为什么是双线程?

加载及渲染性能

小程序的设计之初就是要求快速,这里的快指的是加载以及渲染。

目前主流的渲染方式有以下3种:

  • Web技术渲染
  • Native技术渲染
  • Hybrid技术渲染(同时使用了webview和原生来渲染)

从小程序的定位来讲,它就不可能用纯原生技术来进行开发,因为那样它的编译以及发版都得跟随微信,所以需要像Web技术那样,有一份随时可更新的资源包放在远程,通过下载到本地,动态执行后即可渲染出界面。

但如果用纯web技术来开发的话,会有一个很致命的缺点那就是在 Web 技术中,UI渲染跟 JavaScript 的脚本执行都在一个单线程中执行,这就容易导致一些逻辑任务抢占UI渲染的资源,这也就跟设计之初要求的相违背了。

因此微信小程序选择了Hybrid 技术,界面主要由成熟的 Web 技术渲染,辅之以大量的接口提供丰富的客户端原生能力。同时,每个小程序页面都是用不同的WebView去渲染,这样可以提供更好的交互体验,更贴近原生体验,也避免了单个WebView的任务过于繁重。

微信小程序是以webview渲染为主,原生渲染为辅的混合渲染方式

由于web技术的灵活开放特点,如果基于纯web技术来渲染小程序的话,势必会存在一些不可控因素和安全风险。

为了解决安全管控的问题,小程序从设计上就阻止了开发者去使用一些浏览器提供的开放性api,比如说跳转页面、操作DOM等等。如果把这些东西一个一个地去加入到黑名单,那么势必会陷入一个非常糟糕的循环,因为浏览器的接口也非常丰富,那么就很容易遗漏一些危险的接口,而且就算是禁用掉了所有的接口,也防不住浏览器内核的下次更新。

所以要彻底解决这个问题,必须提供一个沙箱环境来运行开发者的JavaScript 代码。这个沙箱环境只提供纯 JavaScript 的解释执行环境,没有任何浏览器相关接口。那么像HTML5中的ServiceWorkerWebWorker特性就符合这样的条件,这两者都是启用另一线程来执行 javaScript

这就是小程序双线程模型的由来:

  • 渲染层: 界面渲染相关的任务全都在 WebView 线程里执行,通过逻辑层代码去控制渲染哪些界面。一个小程序存在多个界面,所以渲染层存在多个 WebView。

  • 逻辑层: 创建一个单独的线程去执行 JavaScript,在这个环境下执行的都是有关小程序业务逻辑的代码。

双线程模型

小程序的架构模型有别与传统web单线程架构,小程序为双线程架构。

微信小程序的渲染层与逻辑层分别由两个线程管理,渲染层的界面使用 webview 进行渲染;逻辑层采用 JSCore运行JavaScript代码。

小程序双线程.png

webview渲染线程

如何找到渲染层?

  1. 我们可以通过调试微信开发者工具:微信开发者工具 ->调试 ->调试微信开发者工具
wx-1.png
  1. 然后我们会再看到一个调试界面,看起来跟我们平时用的浏览器调试界面几乎一摸一样
wx-2.png

但这并不是小程序的渲染层,而是开发者工具的结构。但我们在里面可以发现有一些webview标签,在第一个webview上的src属性看着是不是有点眼熟,没猜错的话它就是我们当前小程序打开页面的路径。所以这个webview才是小程序真正的渲染层。这里你会发现它里面并不只有一个webview,其实里面包含着视图层的webview业务逻辑层webview开发者工具的webview

开发者工具的逻辑层跑在webview中主要是为了模拟真机上的双线程

  1. 打开渲染层一探究竟

通过showdevTools方法来打开调试此webview界面的调试器

document.querySelectorAll('webview')[0].showDevTools(true)
wx-3.png

这里我们看到的才真正是小程序的渲染层,也就是小程序代码编译后的样子,我们会发现这里的标签都与我们开发时写的不一样,都统一加了wx-前缀。了解过webComponent的同学相信一眼就能看出他们非常相似,但小程序并没有直接使用webComponent,而是自行搭建了一套组件系统Exparser

Exparser的组件模型与WebComponents标准中的Shadow DOM高度相似。Exparser会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的Shadow DOM实现。

为什么不直接使用webComponent,而是选择自行搭建一套组件系统?

点击查看 - 管控与安全:web技术可以通过脚本获取修改页面敏感内容或者随意跳转其它页面
- 能力有限:会限制小程序的表现形式
- 标签众多:增加理解成本

JSCore逻辑线程

逻辑层我们直接在小程序开发者工具的调试器中输入document就能看到

wx-逻辑线程.png

小程序将所有业务代码置于同一个线程中运行,在小程序开发者工具中逻辑线程同样是跑在一个webview中;webview中的appservice.html除了引入业务代码js之外,还有后台服务内嵌的一些基础功能代码。

了解完小程序的双线程架构,我们再来看一下小程序的代码是如何编译运行的,微信开者工具模拟器运行的代码是经过本地预处理、本地编译,而微信客户端运行的代码是额外经过服务器编译的。这里我们还是以微信开发者工具为例来探索一番。

在开发者工具输入openVendor(),会帮我们打开微信开发者工具的WeappVendor文件夹

wx-4.png

在这里我们我们会看到一些wxvpkg文件,这是小程序的各个版本的基础库文件,还有两个值得我们注意的文件:wccwcsc,这两个文件是小程序的编译器,分别用来编译wxmlwxss文件。

编译wxml

这里我们可以将开发者工具中的wcc编译器拷贝一份出来,尝试去用它编译一下wxml文件,看看最后的产物是什么?

wx-5.png

我们在终端执行一下以下命令

./wcc -b index.wxml >> wxml_output.js

然后它会在当前目录下生成一个wxml_output.js文件,文件中有一个非常重要的方法$gwx,该方法会返回一个函数。该函数的具体作用我们可以尝试执行一下看看结果。

我们打开渲染层webview搜索一下该方法(为了方便查看,这里会用个小项目来演示)

wx-6.png

从这里我们可以看到该方法会传入一个小程序页面的路径,返回的依然是一个函数

var decodeName = decodeURI("./index/index.wxml")
var generateFunc = $gwx(decodeName)

我们尝试按这里流程执行一下$gwx返回的函数,看看返回的内容是什么?

<!--compiler-test/index.wxml-->
<view class="qd_container" >
  <text name="title">wxml编译</text>
  <view >{{ name }}</view>
</view>
const func = $gwx(decodeURI('index.wxml'))
console.log(func())
wx-7.png

没错,这个函数正是用来生成Virtual DOM

思考:为什么$gwx不直接生成Virtual DOM

点击查看 - 双线程,需要动态注入数据

编译wxss

我们同样可以用微信开发者工具中的wcsc来编译一下wxss文件。

(大家认为这里应该是会生成css文件还是js文件呢?)

我们在终端执行一下以下命令来编译wxss文件

./wcsc -js index.wxss >> wxss_output.js
wx-8.png

相比之前的wcc编译wxml文件来说,这次的编译相对来说比较简单,它主要完成了以下内容:

  • rpx单位的换算,转换成px
  • 提供setCssToHead方法将转换好的css添加到head中

rpx动态适配

小程序提供rpx单位来适配各种尺寸的设备

wx-9.png
/*index.wxss */
.qd_container {
  width: 100rpx;
  background: skyblue;
  border: 1rpx solid salmon;
}
.qd_reader {
  font-size: 20rpx;
  color: #191919;
  font-weight: 400;
}

经过编译之后会生成setCssToHead方法并执行

setCssToHead([".",[1],"qd_container { width: ",[0,100],"; background: skyblue; border: ",[0,1]," solid salmon; }\n.",[1],"qd_reader { font-size: ",[0,20],"; color: #191919; font-weight: 400; }\n",])( typeof __wxAppSuffixCode__ == "undefined"? undefined : __wxAppSuffixCode__ );

里面会调用transformRPX方法将rpx转成px

var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) {
if ( number === 0 ) return 0;
number = number / BASE_DEVICE_WIDTH * ( newDeviceWidth || deviceWidth );
number = Math.floor(number + eps);
if (number === 0) {
if (deviceDPR === 1 || !isIOS) {
return 1;
} else {
return 0.5;
}
}
return number;
}
// 主要公式
number = number / BASE_DEVICE_WIDTH * (newDeviceWidth || deviceWidth);
number = Math.floor(number + eps);  //为了精确
// rpx值 / 基础设备宽750 * 真实设备宽

上面了解完wxmlwxss的编译过程,我们再来整体了解一下页面的渲染流程。

先来了解渲染层模版

从上面的渲染层webview我们可以找到这两个webview

wx-10.png

第一个index/indexwebview我们上面说了它就是对应我们的小程序的渲染层,也就是真正的小程序页面。

那么下面这个instanceframe.html是什么呢?

这个webview其实是小程序渲染模版,打开查看一番

wx-11.png

它其实就是提前注入了一些页面所需要的公共文件,以及红框内的一些页面独立的文件占位符,这些占位符会等小程序对应页面文件编译完成后注入进来。

如何保证代码的注入是在渲染层webview的初始化之后执行?

在刚刚渲染模版webview的下方有这样一段脚本:

if (document.readyState === 'complete') {
    alert("DOCUMENT_READY")
  } else {
    const fn = () => {
      alert("DOCUMENT_READY")
      window.removeEventListener('load', fn)
    }
    window.addEventListener('load', fn)
  }

很明显,这里在页面初始化完成后,通过alert来进行通知。此时的native/nw.js会拦截这个alert,从而知道此时的webview已经初始化完成。

整体渲染流程

了解了上面这个重要过程,我们就可以将整个流程串联起来了

  1. 打开小程序,创建视图层页的webview时,此时会初始化渲染层webview,并且会将该web view地址设置为instanceframe.html,也就是我们的渲染层模版
  2. 然后进入页面/index/index,等instanceframewebview初始化完成,会将页面index/index编译好的代码注入进来并执行
// 将webview src路径修改为页面路径
history.pushState('', '', 'http://127.0.0.1:26444/__pageframe__/index/index')

/*
... 
这里还有一些 wx config及wxss编译后的代码
*/

// 这里是
var decodeName = decodeURI("./index/index.wxml")
var generateFunc = $gwx(decodeName)
if (decodeName === './__wx__/functional-page.wxml') {
  generateFunc = function () {
    return {
      tag: 'wx-page',
      children: [],
    }
  }
}
if (generateFunc) {
  var CE = (typeof __global === 'object') ? (window.CustomEvent || __global.CustomEvent) : window.CustomEvent;
  document.dispatchEvent(new CE("generateFuncReady", {
    detail: {
      generateFunc: generateFunc
    }
  }))
  __global.timing.addPoint('PAGEFRAME_GENERATE_FUNC_READY', Date.now())
} else {
  document.body.innerText = decodeName + " not found"
  console.error(decodeName + " not found")
}
  1. 此时通过history.pushState方法修改webview的src但是webview并不会发送页面请求,并且将调用$gwx为生成一个generateFun方法,前面我们了解到该方法是用来生成虚拟dom的
  2. 然后会判断该方法存在时,通过document.dispatchEvent 派发发自定义事件generateFuncReady 将generateFunc当作参数传递给底层渲染库
  3. 然后在底层渲染库WAWebview.js中会监听自定义事件generateFuncReady ,然后通过 WeixinJSBridge 通知 JS 逻辑层视图已经准备好()
wx-12.png
  1. 最后 JS 逻辑层将数据给 Webview 渲染层,WAWebview.js在通过virtual dom生成真实dom过程中,它会挂载到页面的document.body上,至此一个页面的渲染流程就结束了

小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。

在架构上,WebView 和 JS Core 都是独立的模块,并不具备数据直接共享的通道。所以在更新数据时必须调用setData来通知渲染层做更新。

setData

  • 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;
  • 将 data 从逻辑层传输到视图层;
  • 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新。

这里第二步由于WebView 和 JS Core 都是独立的模块,数据传输是通过 evaluateJavascript 实现的,还会有额外 JS 脚本解析和执行的耗时因此数据到达渲染层是异步的。

因此切记

  • 不要频繁的去setData
  • 不要每次 setData 都传递大量新数据(单次stringify后不超过256kb)
  • 不要对后台态页面进行setData,会抢占正在执行的前台页面的资源

与Vue对比(再来看看Vue)

整体来讲,小程序身上或多或少都有着vue的影子...(模版文件,data,指令,虚拟dom,生命周期等)

但在数据更新这里,小程序却与Vue表现的截然不同。

1.页面更新DOM是同步的还是异步的?

2.既然更新DOM是个同步的过程,为什么Vue中还会有nextTick钩子?

mounted() {
  this.name = '前端南玖'
  console.log('sync',this.$refs.title.innerText) // 旧文案
  // 新文案
  Promise.resolve().then(() => {
    console.log('微任务',this.$refs.title.innerText)
  })
  setTimeout(() => {
    console.log('宏任务',this.$refs.title.innerText)
  }, 0)
  this.$nextTick(() => {
    console.log('nextTick',this.$refs.title.innerText)
  })
}
nexttick5.png

这里推荐阅读这篇了解更多:Vue异步更新机制以及$nextTick原理

然而小程序却没有这个队列概念,频繁调用,视图会一直更新,阻塞用户交互,引发性能问题。

而Vue 每次赋值操作并不会直接更新视图,而是缓存到一个数据更新队列中,异步更新,再触发渲染,在同一个tick内多次赋值,也只会渲染一次。

原文首发地址点这里,欢迎大家关注公众号 「前端南玖」,如果你想进前端交流群一起学习,请点这里

我是南玖,我们下期见!!!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK