微信小程序孱弱的原生开发体验,注定会出现小程序开发框架,来增强开发体验。面对小程序平台碎片化的现状,注定会由框架肩负跨平台移植挑战。开源社区的框架大致分为两类: compile time 和 runtime 。前者以选定 DSL 为基准,通过转译,生成完全符合小程序标准的代码,本质上是对底层 DSL 的模拟,不可避免的出现 BUG 难以追踪,开发限制过多的问题。 runtime 模式不模拟、不魔改底层 DSL , 框架通过适配层,实现自定义渲染器,尽可能保留底层 DSL 的灵活性。本文介绍的则是 runtime 模式的 Remax ,笔者个人理解,如有纰漏,请评论指出。
狭义上来说, Virtual DOM 就是特定数据结构的 JavaScript 对象,通过映射机制,能将 JavaScript 对象转化为真实 DOM 节点,过程称之为 渲染 ,实例称之为 渲染器 ,宿主环境下渲染 VNode ,并保持数据同步,便是 Virtual DOM 库的核心所在。
简单的 VNode 如下:
{
"type": "ul",
"props": {
"class": "ant-list-items"
},
"children": ["I'm happy when unhappy!"]
}
源代码到 VNode 需要使用 babel 进行编译,需要 runtime 提供 JSX 函数,函数运行时生成 Virtual Node, 面向不同的宿主环境, VNode 结构不尽相同。
暂且抛开复杂的更新逻辑及组件,优先考虑如何在类浏览器环境中静态渲染 VNode , JSX 函数简化如下:
function h(type, props, ...children) {
return {
type,
children,
props: props || {},
};
}
浏览器环境下, JavaScript 可以直接创建 DOM 节点,渲染代码如下:
function render(vnode) {
if (typeof vnode === "string") {
return document.createTextNode(vnode);
}
const props = Object.entries(vnode.props);
const element = document.createElement(vnode.type);
for (const [key, value] of props) {
element.setAttribute(key, value);
}
vnode.children.forEach((node) => {
element.appendChild(render(node));
});
return element;
}
实现使用递归,便可完成从 VNode 到 DOM 的还原。如果目标环境限制繁多,不支持直接创建 DOM ,仅支持模板渲染,该如何处理?
此处以 handlerbars 举例说明, handlerbars 中实现递归,需要通过声明 Partial 作为复用 template , Children 遍历中调用 Partial 即可,特别注意,模板引擎表达式计算能力受限,逻辑派生属性预先计算, JSX 函数调整如下:
function h(type, props, ...children) {
return {
type,
children,
props: props = {},
// 自闭合元素,模板引擎使用
isVoidElement: htmlVoidElements.includes(type),
};
}
实现如下:
{{!-- vtree 透传 --}}
{{> template this}}
{{#* inline "template"}}
{{!-- HTMLElement --}}
{{#if type}}
{{!-- 自闭合 tag 无 children --}}
{{!-- 暂不考虑非键值对 property --}}
{{#if isVoidElement}}
<{{type}} {{#each props}} {{@key}}="{{this}}" {{/each}}/>
{{else}}
<{{type}} {{#each props}} {{@key}}="{{this}}" {{/each}}>
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{type}}>
{{/if}}
{{!-- TextNode --}}
{{else}}
{{this}}
{{/if}}
{{/inline}}
相比直接创建 DOM ,模板引擎实现要繁琐很多,受限于环境原因不得已而为之。如果宿主环境更加苛刻, type 不能动态填充,仅支持动态链接 Dynamic Partials ,又该如何处理。。。
案例使用 VNode 详见 gist.github.com/huang-xiao-…
不能动态填充 type ,那么只能预定义包含 type 在内的 Partial ,根据 type 选择预定义的 Partial ,曲线救国,如果 Element 集合有限,则完全可以使用预定义的方式,实现如下:
{{!-- vtree 透传 --}}
{{> template this}}
{{#* inline "template"}}
{{!-- HTMLElement --}}
{{#if type}}
{{!-- Dynamic Partial --}}
{{> (lookup . 'type') }}
{{!-- TextNode --}}
{{else}}
{{this}}
{{/if}}
{{/inline}}
{{!-- node 透传 --}}
{{!-- 暂不考虑非键值对 property --}}
{{#* inline "div"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "p"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "ul"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "li"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "img"}}
{{~/inline}}
限定环境下渲染完成,预定义模式也导致模板快速膨胀。如果宿主环境更加苛刻, property 必须显式逐一绑定,不能使用遍历,不能进行逻辑判定,又该如何处理。。。
简单粗暴的方案,就是对 DOM 节点的所有属性进行全量绑定。浏览器环境下 property 繁杂,可行性无限趋近于 0 ,小程序环境下,元素类型也相对较少, element property 相对简化,全量绑定可以接受。
view 文档缩略如下:
非常简易的模板实现如下:
{{!-- vtree 透传 --}}
{{> template this}}
{{!-- 暂不考虑非键值对 property --}}
{{#* inline "template"}}
{{!-- HTMLElement --}}
{{#if type}}
{{!-- Dynamic Partial --}}
{{> (lookup . 'type') }}
{{!-- TextNode --}}
{{else}}
{{this}}
{{/if}}
{{/inline}}
{{!-- node 透传 --}}
{{#* inline "view"}}
class="{{props.[class]}}"
hover-class="{{props.[hover-class]}}"
hover-stop-propagation="{{props.[hover-stop-propagation]}}"
hover-start-time="{{props.[hover-start-time]}}"
hover-stay-time="{{props.[hover-stay-time]}}"
>
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "image"}}
src="{{props.[src]}}"
mode="{{props.[mode]}}"
webp="{{props.[webp]}}"
lazy-load="{{props.[lazy-load]}}"
show-menu-by-longpress="{{props.[show-menu-by-longpress]}}"
binderror="{{props.[binderror]}}"
bindload="{{props.[bindload]}}"
class="{{props.[class]}}"
/>
{{~/inline}}
全量绑定属性,渲染结果如下:
class="ant-list-items"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
class=""
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
class="ant-comment"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
class="ant-comment-inner"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
渲染结果存在大量空值,基于字符串的模板引擎影响不大,但是小程序环境下对应的组件对空值是否兼容,需要实际测定,而且大量空值,对小程序本身的性能也是挑战,是否存在更精细一点的操作方式,在不能对 property 进行任何逻辑判断的限制下,精简非必要绑定?
参照之前的 preset element partial 穷举方式,对 property 可能的组合穷举,以对应的 hash 值作为 partial name ,生成 VNode 时对 props 进行哈希计算,用以引用 preset property partial , JSX 函数调整如下:
function h(type, props, ...children) {
return {
type,
children,
props: props || {},
// props 哈希计算
hash:
props == null ? type : `${type}_${Object.keys(props).sort().join("_")}`,
};
}
模板实现如下:
{{!-- vtree 透传 --}}
{{> template this}}
{{!-- 暂不考虑非键值对 property --}}
{{#* inline "template"}}
{{!-- HTMLElement --}}
{{#if type}}
{{!-- Dynamic Partial --}}
{{> (lookup . 'hash') }}
{{!-- TextNode --}}
{{else}}
{{this}}
{{/if}}
{{/inline}}
{{#* inline "view"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "view_class"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "view_class_hover-class"}}
{{#each children as |node|}}
{{> template node}}
{{/each}}
{{~/inline}}
{{#* inline "image_src"}}
{{~/inline}}
问题随之而来, property 较少的元素可以穷举, property 较多的元素组合数直接原地爆炸,方案还是不具备实际意义,需要继续优化。
如果预先对源码进行扫描,判断源码中声明过的属性,便能剔除大量未引用 property ,以此作为优化调整基准,可选方案如下:
方案 2 首先排除,其声明的模板数量必定大于等于方案 3,那么只剩下方案 1 与方案 3 的 battle ,本质上来说,预扫描方案就是进入宿主环境前,精简 preset template 集合,有一些静态转译的味道。一旦接触到静态转译,开发过程中必定需要作出一定的妥协,尽量降低转译的难度,显式绑定最为推荐,举例如下:
import * as React from 'react';
import { View, Text, Image } from 'remax/wechat';
import styles from './index.module.css';
export default () => {
return (
src="https://gw.alipayobjects.com/mdn/rms_b5fcc5/afts/img/A*OGyZSI087zkAAAAAAAAAAABkARQnAQ"
className={styles.logo}
alt="logo"
/>
编辑 src/pages/index/index.js 开始
);
};
隐式绑定尽量减少使用,举例如下:
import * as React from 'react';
import { View, Text, Image } from 'remax/wechat';
import styles from './index.module.css';
export default () => {
const parentViewProps = {
className: styles.header
};
const imageProps = {
src: "https://gw.alipayobjects.com/mdn/rms_b5fcc5/afts/img/A*OGyZSI087zkAAAAAAAAAAABkARQnAQ",
className: styles.logo,
alt: "logo"
}
return (
编辑 src/pages/index/index.js 开始
);
};
此处案例对静态转译的挑战并不算大,但更为复杂的场景很可能出现难以预料的问题,需要谨慎使用。源代码扫描,使用 babel 工具链,案例扫描结果如下:
// 方案 1
{
View: Set { 'class' },
Image: Set { 'src', 'class', 'alt' },
Text: Set { 'class' }
}
// 方案 3
Set { 'View_class', 'Image_alt_class_src', 'Text_class' }
本案例中,无论哪种方案,对生成小程序模板的体积都不会有太大影响,最终生成的模板不再赘述,有兴趣可以自行实现。基于静态模板引擎 handlebars , 仅作为演示,通过可行性验证,最终仍然需要在小程序环境下验证,简单粗暴的小程序模板移植如下:
{{vnode.children[0]}}
然而在山的那边,依然是山,页面并不如预想般呈现,控制台提示如下:
蓦然回首,才发现小程序模板不支持递归。。。
事已至此,模板引擎必须要支持递归,小程序 template 又不支持递归,那就只能通过模拟的方式来支持递归,大悟-影分身之术:将元素 template 重复声明,添加递增序号用以区分,具体实现上需要脚本支持,实现如下:
module.exports = {
tid: function (type, ancestor) {
var items = ancestor.split(",");
var depth = 0;
for (var i = 0; i < items.length; i++) {
if (type === items[i]) {
depth = depth + 1;
}
}
return depth === 0 ? type : type + "_" + depth;
},
};
{{vnode.children}}
大功告成,实际构建过程中,不同元素嵌套深度未知,因此相当程度都是处理 template 生成与优化, react reconciler 部分相对不那么难以理解。
前述以 handlebars 举例的大幅篇章,增设的种种宿主环境限制,便是微信小程序的种种无可奈何,也是 Remax 核心所在。 Remax 底层选用 React ,而 React 自身是平台无关,通过渲染器来支持跨平台。 react-dom 负责将 vnode 渲染为真实 DOM 节点, react-native 负责将 vnode 渲染为原生 UI , react-three-fiber 负责将 vnode 渲染为三维图形,那么 Remax 负责将 vnode 渲染为什么,才能完成 vnode 到小程序的临门一脚?
小程序的结构简化如下:
逻辑层与视图层分离,视图层模板已经解决 vnode tree 到小程序 element 的转化,还得解决数据传递的问题。小程序自带数据管理机制,通过 setData 触发更新, React 自带状态管理机制,通过 setState 触发更新,需要框架层面进行协调,将生成的 virtual tree 转化为小程序的数据,实质上, Remax 实现的渲染器就是将 vnode 渲染为 vnode 。
作为框架, Remax 当然屏蔽不少细节,如若有兴趣,建议参照 react-reconciler 了解详情。
remax 的实现机制不同,必然与转译型框架存在较大差异,笔者理解如下: