前言
微信小程序孱弱的原生开发体验,注定会出现小程序开发框架,来增强开发体验。面对小程序平台碎片化的现状,注定会由框架肩负跨平台移植挑战。开源社区的框架大致分为两类:compile time
和 runtime
。前者以选定 DSL
为基准,通过转译,生成完全符合小程序标准的代码,本质上是对底层 DSL
的模拟,不可避免的出现 BUG
难以追踪,开发限制过多的问题。runtime
模式不模拟、不魔改底层 DSL
, 框架通过适配层,实现自定义渲染器,尽可能保留底层DSL
的灵活性。本文介绍的则是 runtime
模式的 Remax
,笔者个人理解,如有纰漏,请评论指出。
渲染 VNode
狭义上来说,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"}}
<div {{#each props}} {{@key}}="{{this}}" {{/each}}>
{{#each children as |node|}}
{{> template node}}
{{/each}}
</div>
{{~/inline}}
{{#* inline "p"}}
<p {{#each props}} {{@key}}="{{this}}" {{/each}}>
{{#each children as |node|}}
{{> template node}}
{{/each}}
</p>
{{~/inline}}
{{#* inline "ul"}}
<ul {{#each props}} {{@key}}="{{this}}" {{/each}}>
{{#each children as |node|}}
{{> template node}}
{{/each}}
</ul>
{{~/inline}}
{{#* inline "li"}}
<li {{#each props}} {{@key}}="{{this}}" {{/each}}>
{{#each children as |node|}}
{{> template node}}
{{/each}}
</li>
{{~/inline}}
{{#* inline "img"}}
<img {{#each props}} {{@key}}="{{this}}" {{/each}} />
{{~/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"}}
<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}}
</view>
{{~/inline}}
{{#* inline "image"}}
<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}}
全量绑定属性,渲染结果如下:
<view
class="ant-list-items"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
<view
class=""
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
<view
class="ant-comment"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
<view
class="ant-comment-inner"
hover-class=""
hover-stop-propagation=""
hover-start-time=""
hover-stay-time=""
>
</view>
</view>
</view>
</view>
渲染结果存在大量空值,基于字符串的模板引擎影响不大,但是小程序环境下对应的组件对空值是否兼容,需要实际测定,而且大量空值,对小程序本身的性能也是挑战,是否存在更精细一点的操作方式,在不能对 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"}}
<view>
{{#each children as |node|}}
{{> template node}}
{{/each}}
</view>
{{~/inline}}
{{#* inline "view_class"}}
<view class="{{props.[class]}}">
{{#each children as |node|}}
{{> template node}}
{{/each}}
</view>
{{~/inline}}
{{#* inline "view_class_hover-class"}}
<view class="{{props.[class]}}" hover-class="{{props.[hover-class]}}">
{{#each children as |node|}}
{{> template node}}
{{/each}}
</view>
{{~/inline}}
{{#* inline "image_src"}}
<image src="{{props.[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 (
<View className={styles.app}>
<View className={styles.header}>
<Image
src="https://gw.alipayobjects.com/mdn/rms_b5fcc5/afts/img/A*OGyZSI087zkAAAAAAAAAAABkARQnAQ"
className={styles.logo}
alt="logo"
/>
<View className={styles.text}>
编辑 <Text className={styles.path}>src/pages/index/index.js</Text>开始
</View>
</View>
</View>
);
};
隐式绑定尽量减少使用,举例如下:
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 (
<View {...parentViewProps}>
<Image {...imageProps} />
<View className={styles.text}>
编辑 <Text className={styles.path}>src/pages/index/index.js</Text>开始
</View>
</View>
);
};
此处案例对静态转译的挑战并不算大,但更为复杂的场景很可能出现难以预料的问题,需要谨慎使用。源代码扫描,使用 babel
工具链,案例扫描结果如下:
// 方案 1
{
View: Set { 'class' },
Image: Set { 'src', 'class', 'alt' },
Text: Set { 'class' }
}
// 方案 3
Set { 'View_class', 'Image_alt_class_src', 'Text_class' }
本案例中,无论哪种方案,对生成小程序模板的体积都不会有太大影响,最终生成的模板不再赘述,有兴趣可以自行实现。基于静态模板引擎 handlebars
, 仅作为演示,通过可行性验证,最终仍然需要在小程序环境下验证,简单粗暴的小程序模板移植如下:
<template is="{{vnode.type}}" data="{{vnode}}" />
<template name="view">
<view class="{{vnode.props.class}}">
<block wx:for="{{vnode.children}}" wx:key="id">
<template is="{{item.type}}" data="{{vnode: item}}" />
</block>
</view>
</template>
<template name="image">
<image class="{{vnode.props.class}}" src="{{vnode.props.src}}"></image>
</template>
<template name="text">
<text class="{{vnode.props.class}}">
{{vnode.children[0]}}
</text>
</template>
然而在山的那边,依然是山,页面并不如预想般呈现,控制台提示如下:
蓦然回首,才发现小程序模板不支持递归。。。
事已至此,模板引擎必须要支持递归,小程序 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;
},
};
<wxs module="h">
<!-- 代码见上 -->
</wxs>
<!-- 起始 template -->
<template is="{{vnode.type}}" data="{{vnode: vnode, ancestor: vnode.type}}" />
<template name="view">
<view class="{{vnode.props.class}}">
<block wx:for="{{vnode.children}}" wx:key="id">
<template is="{{h.tid(item.type, ancestor)}}" data="{{vnode: item, ancestor: ancestor + ',view'}}" />
</block>
</view>
</template>
<template name="view_1">
<view class="{{vnode.props.class}}">
<block wx:for="{{vnode.children}}" wx:key="id">
<template is="{{h.tid(item.type, ancestor)}}" data="{{vnode: item, ancestor: ancestor + ',view'}}" />
</block>
</view>
</template>
<template name="view_2">
<view class="{{vnode.props.class}}">
<block wx:for="{{vnode.children}}" wx:key="id">
<template is="{{h.tid(item.type, ancestor)}}" data="{{vnode: item, ancestor: ancestor + ',view'}}" />
</block>
</view>
</template>
<template name="image">
<image class="{{vnode.props.class}}" src="{{vnode.props.src}}"></image>
</template>
<template name="text">
<text class="{{vnode.props.class}}">
{{vnode.children}}
</text>
</template>
大功告成,实际构建过程中,不同元素嵌套深度未知,因此相当程度都是处理 template
生成与优化,react reconciler
部分相对不那么难以理解。
Remax 渲染器
前述以 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 迷思
remax
的实现机制不同,必然与转译型框架存在较大差异,笔者理解如下:
remax
可以无缝使用redux
、mobx
状态管理库,不需要额外的封装层remax
性能相对静态模板存在损耗,是否影响正常应用,需具体情况分析App
、Page
层级的事件监听,通过代理方式向React
应用中组件分发- 小程序为多页面形态,
remax
目前模拟多页面形态 (🤔不🤔确🤔定🤔)