我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
前言
最近在掘金上学习了一本小册——《微信小程序底层框架实现原理》,加上以前做微信小程序的经验,结合自己的工作经历,深有感触,借此机会和大家分享一下学习工作心得。
2017 年 1 月微信小程序正式发布 。
我从2018年接触学习前端时,曾仿写过一个性格评测类小程序demo,后来实习期间,完成了部门首个真正意义上小程序。做毕业设计时,结合微信小程序云开发能力,做了一个问答小程序(类似百度知道,360问答)。后来,做过一个大学信息资讯类小程序。正式工作之后,做过的小程序就很多了,借款类小程序,购物类小程序,消费类小程序,导流类小程序。
为什么要掌握小程序
有招聘需求,现在部分团队会有专门招聘小程序开发工程师,toC的产品招聘前端一般也会要求掌握微信小程序,有相关小程序开发经验。
于开发者
- 目前开发的项目是小程序
- 想自己独立开发一个小程序
- 多掌握一门技术是好的
于企业
- app 开发迭代成本较高,对于新业务小程序可以快速试错,探索渠道
- web端的生态不完整,收益小(游戏,抖音小程序,虎牙小程序,支付宝小程序)
双线程架构
小程序与传统web单线程架构相比,是双线程架构。
渲染层和逻辑层由两个线程管理,逻辑层采用JSCore运行js代码,渲染层使用 webview 进行渲染。小程序有多个页面,所以渲染层存在多个webview。
两个线程之间由Native 层之间统一处理,无论是线程之间的通信,还是数据的传递,网络请求都是由Native层做转发。
此处提到的小程序都特指微信小程序
渲染一个hello world页面
``` // index.wxml
// index.js Page({ onLoad: function () { this.setData({ msg: 'Hello World' }) } }) ```
- 渲染层和数据相关。
- 逻辑层负责产生、处理数据。
- 逻辑层通过 Page 实例的 setData 方法传递数据到渲染层。
数据驱动
WXML可以先转成JS对象,然后再渲染出真正的Dom树,回到“Hello World”那个例子,我们可以看到转换的过程
通过setData把msg数据从“Hello World”变成“Goodbye”,产生的JS对象对应的节点就会发生变化,此时可以对比前后两个JS对象得到变化的部分,然后把这个差异应用到原来的Dom树上,从而达到更新UI的目的,这就是“数据驱动”。
这一点和vue其实是一致的
既然小程序是基于双线程模型,那就意味着任何数据传递都是线程间的通信,也就是都会有一定的延时。
一切都是异步。
快速渲染设计原理
小程序采用多个webview渲染,更加接近原生App的用户体验。
如果为单页面应用,单独打开一个页面,需要先卸载当前页面结构,并重新渲染。
多页面应用,新页面直接滑动出来并且覆盖在旧页面上即可。这样用户体验非常好。
数量限制
页面得载入是通过创建并插入webview 来实现的。
微信小程序做了限制,在微信小程序中打开的页面不能超过10个,达到10个页面后,就不能再打开新的页面。
所以我们在开发中,要避免路由嵌套太深。
PageFrame
我们在写小程序页面时,并不关心webview,只需要写页面ui和逻辑即可。
我们通过调试微信开发工具,可以看到,有两个webview。
一个加载的的是当前页面,加载地址和当前页面路径一致。
一个是instanceframe.html。
微信小程序在初始化的时候,除了渲染首页之后,会帮我们提前额外的预加载一个webview,微信起名为instanceframe.html,用来新渲染webview的模板。
我们通过微信开发者工具打开调试,打开这个 instanceframe.html
document.getElementsByTagName('webview')[1].showDevTools(true, null)
下图是pageframe/instanceframe.html的模板
pageFrame的html结构中注入的js资源
- ./dev/wxconfig.js
小程序默认总配置项,包括用户自定义与系统默认的整合结果。在控制台输入__wxConfig可以看出打印结果
- ./dev/devtoolsconfig.js
小程序开发者配置,包括navigationBarHeight,标题栏的高度,状态栏高度,等等,控制台输入__devtoolsconfig可以看到其对应的信息
- ./dev/deviceinfo.js
设备信息,包含尺寸/像素点pixelRatio
- dev/jsdebug.js
debug工具
- ./dev/WAWebview.js
渲染层底层基础库
- ./dev/hls.js
优秀的视频流处理工具
- ./dev/WARemoteDebug.js
底层基础库调试工具
注释占位符, 整个页面的json wxss wxml编译之后都存储在这里,当前是一个预设的html模版,所以是空的
wxappcode.js
我们按同样的调试方法,去找到首页的wxappcode.js结构,简单说明下
var decodeJsonPathName = decodeURI("pages/index/index") __wxAppCode__[decodeJsonPathName + ".json"]={"usingComponents":{}} var decodeWxmlPathName = decodeURI("pages/index/index") __wxAppCode__[decodeWxmlPathName + ".wxml"]=$gwx("./" + decodeWxmlPathName + ".wxml") var decodeWxssPathName = decodeURI("pages/index/index") __wxAppCode__[decodeWxssPathName + ".wxss"]=((window.eval || __global.__hackEval)('setCssToHead([\x22.\x22,[1],\x22test{ height: calc(\x22,[0,100],\x22-2px); ;wxcs_style_height : calc(100rpx-2px); width: \x22,[0,200],\x22; ;wxcs_style_width : 200rpx; ;wxcs_originclass: .test;;wxcs_fileinfo: ./pages/index/index.wxss 2 1; }\n\x22,],undefined,{path:\x22./pages/index/index.wxss\x22})')); window.__mainPageFrameReady__ && window.__mainPageFrameReady__()
文件包含了所有文件的编译路径
主要几个重要的函数和属性有
- decodeJsonPathName
- .json配置
- .wxml编译后的$gwx函数。
- .wxss编译后的eval函数。
后两个函数我们会在后文展开分析。
当小程序需要打开某个页面的时候,只需要提取页面的者几个属性,注入到预加载的html模版中就可以快速生成一个新的webview
快速启动
在视图层内,每个页面都是一个webiew,当小程序启动时只有首页一个webview
执行wx.navigateTo新开一个页面的时候,就会创建一个新的webview并插入到视图层
wx.navigateBack则为销毁webview
小程序每个视图层页面内容都是通过pageframe.html模板来生成的。
- 首页启动时,即第一次通过pageframe.html生成内容后,后台服务会缓存pageframe.html模板首次生成的html内容
- 非首次新打开页面时,页面请求的pageframe.html内容直接走后台缓存
- 非首次新打开页面时,pageframe.html页面引入的外链js资源走本地缓存
这样在后续新打开页面时,都会走缓存的pageframe的内容,避免重复生成,快速打开一个新页面。
首次打开新页面
- 启动一个webview,src为空地址http://127.0.0.1:${global.proxyPort}/aboutblank?${c}
- webview 初始化完毕后,设置地址src 为pageframe.html,开始加载注入的预设样式和预设js 代码
- pageframe.html在dom ready之后,触发注入并执行具体页面的相关代码
下图代码中可以看到dom加载完毕之后,触发alert 通知
- 此时通过history.pushState方法修改webview的src但是webview并不会发送页面请求。
- 因此webview 路径变化为
- - http://127.0.0.1:${global.proxyPort}/aboutblank?${c} - http://127.0.0.1::63444/pageframe/instanceframe.html - http://127.0.0.1:63444/pageframe/pages/index/index
- 正好对应webview 加载过程
wxml 设计思路
网页编程一般采用的是HTML + CSS + JS的组合,其中 HTML 是用来描述当前这个页面的结构,CSS 用来描述页面的样子,JS 通常是用来处理这个页面和用户的交互。
同样道理,在小程序中也有同样的角色,其中 WXML 充当的就是类似 HTML 的角色。
小程序自行搭建了组件组织框架Exparser框架
Exparser的组件模型与WebComponents标准中的ShadowDOM高度相似
如下代码,我们定义在wxml中
<!--index.wxml--> <view class="container"> Weixin <text style="position:relative;">文本</text> </view> <button bindtap="test">按钮</button>
Exparser框架会将上述结构转换为下面这个样子
<wx-view exparser:info-class-prefix="" exparser:info-component-id="2" class="container"> Weixin <wx-text exparser:info-class-prefix="" exparser:info-component-id="3" style="position:relative;"> <span style="display:none;">文本</span> <span>文本</span></wx-text> </wx-view> <wx-button exparser:info-class-prefix="" exparser:info-component-id="4" exparser:info-attr-bindtap="test" role="button" aria-disabled="false"> 按钮 </wx-button>
这样看的话是不是和WebComponents一样了,但是小程序并没有直接使用WebComponents,而是自行搭建了组件框架Exparser。
WebComponents
Web Components 是一个浏览器原生支持的组件化方案,允许你创建新的自定义、可封装、可重用的HTML 标记。不用加载任何外部模块,直接就可以在浏览器中跑。
如下代码, 标签就是自定义组件的标签了,它不属于html语义化标签中的任何一个,是自定义的。
```html
21312
```
WebComponent主要就是三个规范:
- Custom Elements规范
可以创建一个自定义标签。根据规范,自定义元素的名称必须包含连词线”-“,用与区别原生的 HTML 元素。
可以指定多个不同的回调函数,它们将会在元素的不同生命时期被调用。
- templates 规范
提供了<template>
标签,可以在它里面使用HTML定义DOM结构。
- Shadow DOM规范
下图中,看一下右侧的HTML结构,我们可以展开 标记看到里面的结构。是不是有种白封装了的感觉。如果只有这样的效果的话,跟模板引擎渲染组件的效果是一样的。所以我们不希望用户能够看到 的内部代码,WebComponent 允许内部代码隐藏起来,这叫做 Shadow DOM,即这部分 DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部。
ShadowDOM
首先实例化一个根节点,挂载到宿主上,这里的宿主是this。上面说过,this指向user-card。
然后我们把创建的DOM结构,或者<template>
结构挂载到影子根上即可。看一下HTML结构展示。
var shadow = this.attachShadow({ mode:'closed'}); shadow.appendChild(content)
内置的控件元素不能成为宿主,比如:img、button、input、textarea、select、radio、checkbox,video等等,因为他们已经是 #shadow-root
如果愿意的话,我们可以调试他们的shadow,看看这些标签的真实结构
Exparser框架原理
Exparser是微信小程序的组件组织框架,内置在小程序基础库中,为小程序提供各种各样的组件支撑。
内置组件和自定义组件都有Exparser组织管理。
Exparser的组件模型与WebComponents标准中的Shadow DOM高度相似。
Exparser会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的Shadow DOM实现。Exparser的主要特点包括以下几点:
- 基于Shadow DOM模型:模型上与WebComponents的ShadowDOM高度相似,但不依赖浏览器的原生支持,也没有其他依赖库;实现时,还针对性地增加了其他API以支持小程序组件编程。
- 可在纯JS环境中运行:这意味着逻辑层也具有一定的组件树组织能力。
- 高效轻量:性能表现好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小。
小程序中,所有节点树相关的操作都依赖于Exparser,包括WXML到页面最终节点树的构建和自定义组件特性等。
原生组件
小程序中的部分组件是由客户端创建的原生组件,并不完全在Exparser的渲染体系下,这些组件有:
- camera
- canvas
- input(仅在 focus 时表现为原生组件)
- live-player
- live-pusher
- map
- textarea
- video
引入原生组件主要有3个好处:
- 扩展Web的能力。比如像输入框组件(input, textarea)有更好地控制键盘的能力。
- 体验更好,同时也减轻WebView的渲染工作。比如像地图组件(map)这类较复杂的组件,其渲染工作不占用WebView线程,而交给更高效的客户端原生处理。
- 绕过setData、数据通信和重渲染流程,使渲染性能更好。比如像画布组件(canvas)可直接用一套丰富的绘图接口进行绘制。
特殊场景
如果业务场景为手势识别之类的,监听事件不断的触发,数据不断的改变。
这样的业务场景中,我们可以想像,如果坐标值不断改变的话,在逻辑与视图分开的双线程架构中,线程与线程之间的通讯是非常频繁的,会有很大的性能问题。
所以我们可以看到微信开放了一个标记 ,可以在渲染层写部分js逻辑。这样话就可以在渲染层单独处理频繁改变的数据,就避免了线程与线程之间频繁通讯导致的性能和延时问题。
优势
WXML模版语法经过转换之后,会已自定义元素的形式来渲染。这里会有个疑问🤔️,为什么不用HTML语法和WebComponents来实现渲染,而是选择自定义?
- 管控与安全:web技术可以通过脚本获取修改页面敏感内容或者随意跳转其它页面
- 能力有限:会限制小程序的表现形式
- 标签众多:增加理解成本
wxss 设计思路
WXSS 具有 CSS的大部分特性。同时为了更适合开发微信小程序,WXSS 对 CSS 进行了扩充以及修改。通俗的可以理解成基于CSS改了点东西,又加了点东西。
与 CSS 相比,WXSS 扩展的特性有:
- 尺寸单位
rpx(responsive pixel) : 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。
| 设备 | rpx换算px (屏幕宽度/750) | px换算rpx (750/屏幕宽度) | | ------------ | ---------------------- | ---------------------- | | iPhone5 | 1rpx = 0.42px | 1px = 2.34rpx | | iPhone6 | 1rpx = 0.5px | 1px = 2rpx | | iPhone6 Plus | 1rpx = 0.552px | 1px = 1.81rpx |
- 样式导入\ 使用 @import语句可以导入外联样式表, @import后跟需要导入的外联样式表的相对路径,用;表示语句结束。
编译
css /**index.wxss**/ .test{ height: calc(100rpx-2px); width: 200rpx; }
如上我们定义的index.wxss,会被编译成js,注入webview
我们把编译后的js分成三部分,展开分析。
第一部分用于获取一套基本设备信息,包含设备高度、设备宽度、物理像素与CSS像素比例、设备方向。
js /*********/ /*第一部分*/ /*设备信息*/ /*********/ var BASE_DEVICE_WIDTH = 750;// 基础设备宽度750 var isIOS=navigator.userAgent.match("iPhone"); // 是否ipheone 机型 var deviceWidth = window.screen.width || 375; // 设备宽度 默认375 var deviceDPR = window.devicePixelRatio || 2; // 获取物理像素与css像素比例 默认2 var checkDeviceWidth = window.__checkDeviceWidth__ || function() { var newDeviceWidth = window.screen.width || 375 // 初始化设备宽度 var newDeviceDPR = window.devicePixelRatio || 2 // 初始化设备 像素比例 var newDeviceHeight = window.screen.height || 375 // 初始化设备高度 // 判断屏幕方向 landscape 为横向,如果是横向 高度值给宽度 if (window.screen.orientation && /^landscape/.test(window.screen.orientation.type || '')) newDeviceWidth = newDeviceHeight // 更新设备信息 if (newDeviceWidth !== deviceWidth || newDeviceDPR !== deviceDPR) { deviceWidth = newDeviceWidth deviceDPR = newDeviceDPR } } // 检查设备信息 checkDeviceWidth()
第二部分:转化rpx
核心就是:下面两句,做了一个精度收拢
number = number / BASEDEVICEWIDTH * (newDeviceWidth || deviceWidth);
number = Math . floor (number + eps);
js /*********/ /*第二部分*/ /*转化rpx*/ /*********/ var eps = 1e-4;//0.0001 var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) { // 如果0 返回 0 0rpx = 0px if ( number === 0 ) return 0; // px = rpx值 / 基础设备宽度750 * 设备宽度 number = number / BASE_DEVICE_WIDTH * ( newDeviceWidth || deviceWidth ); // 返回小于等于 number + 0.0001的大整数,用户收拢精度 number = Math.floor(number + eps); if (number === 0) {// 如果number == 0,说明输入为1rpx if (deviceDPR === 1 || !isIOS) {// 非IOS 或者 像素比为1,返回1 return 1; } else { return 0.5; } } return number; }
第三部分主要是 setCssToHead 顾名思义
js /*********/ /*第三部分*/ /*setCssToHead*/ /*********/ window.__rpxRecalculatingFuncs__ = window.__rpxRecalculatingFuncs__ || []; var __COMMON_STYLESHEETS__ = __COMMON_STYLESHEETS__ || {} % s var setCssToHead = function(file, _xcInvalid, info) { var Ca = {}; var css_id; var info = info || {}; var _C = __COMMON_STYLESHEETS__ function makeup(file, opt) { var _n = typeof(file) === "string"; if (_n && Ca.hasOwnProperty(file)) return ""; if (_n) Ca[file] = 1; var ex = _n ? _C[file] : file; var res = ""; for (var i = ex.length - 1; i >= 0; i--) { var content = ex[i]; if (typeof(content) === "object") { var op = content[0]; if (op == 0) res = transformRPX(content[1], opt.deviceWidth) + "px" + res; else if (op == 1) res = opt.suffix + res; else if (op == 2) res = makeup(content[1], opt) + res; } else res = content + res } return res; } var styleSheetManager = window.__styleSheetManager2__ var rewritor = function(suffix, opt, style) { opt = opt || {}; suffix = suffix || ""; opt.suffix = suffix; if (opt.allowIllegalSelector != undefined && _xcInvalid != undefined) { if (opt.allowIllegalSelector) console.warn("For developer:" + _xcInvalid); else { console.error(_xcInvalid); } } Ca = {}; css = makeup(file, opt); if (styleSheetManager) { var key = (info.path || Math.random()) + ':' + suffix if (!style) { styleSheetManager.addItem(key, info.path); window.__rpxRecalculatingFuncs__.push(function(size) { opt.deviceWidth = size.width; rewritor(suffix, opt, true); }); } styleSheetManager.setCss(key, css); return; } if (!style) { var head = document.head || document.getElementsByTagName('head')[0]; style = document.createElement('style'); style.type = 'text/css'; style.setAttribute("wxss:path", info.path); head.appendChild(style); window.__rpxRecalculatingFuncs__.push(function(size) { opt.deviceWidth = size.width; rewritor(suffix, opt, style); }); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { if (style.childNodes.length == 0) style.appendChild(document.createTextNode(css)); else style.childNodes[0].nodeValue = css; } } return rewritor; } setCssToHead([".", [1], "test{ height: calc(", [0, 100], "-2px); width: ", [0, 200], "; }\n", ])(typeof __wxAppSuffixCode__ == "undefined" ? undefined: __wxAppSuffixCode__);
setCssToHead 传的参数 是我们定义的wxcss,变成了结构化数据,方便遍历处理
index.wxss中写rpx单位的属性都变成了区间的样子[0, 100]、[0, 200]。其他单位并没有转换。这样的话就可以方便的识别哪里写了rpx单位
[".", [1], "test{ height: calc(", [0, 100], "-2px); width: ", [0, 200], "; }\n", ]
注入
在渲染层的一个的<script>
标签中,有很长的一串字符串,并且用eval方法执行。如果你仔细看的话,还是可以勉强分辨出,这个字符串正是我们前面编译出来的js转换成的。
这样就可以得知,编译后的代码是通过eval方法注入执行的。这样的话完成了WXSS的一整套流程。
同时我们也可以看到,是在修改pageFrame 的路径之后,初始化小程序样式配置文件之后,才开始注入样式文件
Virtual Dom 渲染流程
微信开发者工具和微信客户端都无法直接运行小程序的源码,因此我们需要对小程序的源码进行编译。
代码编译过程包括本地预处理、本地编译和服务器编译。
为了快速预览,微信开发者工具模拟器运行的代码只经过本地预处理、本地编译,没有服务器编译过程,而微信客户端运行的代码是额外经过服务器编译的。
编译
<!--index.wxml--> <view class="container"> Weixin <text style="position:relative;" >文本</text> </view> <button bindtap="test">按钮</button>
如上面这段简单的wxml文件,经过编译之后,被编译成了 1500 多行
全部代码都被包裹在$gwx函数中,编译后的WXML文件,以js的形式插入到了渲染层的