Remax 原理浅析

2020-04-21     sandag

微信小程序孱弱的原生开发体验,注定会出现小程序开发框架,来增强开发体验。面对小程序平台碎片化的现状,注定会由框架肩负跨平台移植挑战。开源社区的框架大致分为两类: 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}}

{{/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 ,以此作为优化调整基准,可选方案如下:

    1. 同一元素所有扫描出的属性绑定作为全集,使用全量逐一绑定的方式,需要处理空值兼容;
    2. 同一元素所有扫描出的属性绑定作为全集,基于组合穷举,声明不同复用模板;
    3. 同一元素不同的使用场景,声明不同复用模板;

    方案 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 , 仅作为演示,通过可行性验证,最终仍然需要在小程序环境下验证,简单粗暴的小程序模板移植如下: