阅读 1948

Remax 原理浅析

前言

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

暂且抛开复杂的更新逻辑及组件,优先考虑如何在类浏览器环境中静态渲染 VNodeJSX 函数简化如下:

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;
}
复制代码

实现使用递归,便可完成从 VNodeDOM 的还原。如果目标环境限制繁多,不支持直接创建 DOM,仅支持模板渲染,该如何处理?

此处以 handlerbars 举例说明,handlerbars 中实现递归,需要通过声明 Partial 作为复用 templateChildren 遍历中调用 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 partialJSX 函数调整如下:

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

  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 (
    <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 渲染为原生 UIreact-three-fiber 负责将 vnode 渲染为三维图形,那么 Remax 负责将 vnode 渲染为什么,才能完成 vnode 到小程序的临门一脚?

小程序的结构简化如下:

逻辑层与视图层分离,视图层模板已经解决 vnode tree 到小程序 element 的转化,还得解决数据传递的问题。小程序自带数据管理机制,通过 setData 触发更新,React 自带状态管理机制,通过 setState 触发更新,需要框架层面进行协调,将生成的 virtual tree 转化为小程序的数据,实质上,Remax 实现的渲染器就是将 vnode 渲染为vnode

作为框架,Remax 当然屏蔽不少细节,如若有兴趣,建议参照 react-reconciler 了解详情。

Remax 迷思

remax 的实现机制不同,必然与转译型框架存在较大差异,笔者理解如下:

  • remax 可以无缝使用 reduxmobx 状态管理库,不需要额外的封装层
  • remax 性能相对静态模板存在损耗,是否影响正常应用,需具体情况分析
  • AppPage 层级的事件监听,通过代理方式向 React 应用中组件分发
  • 小程序为多页面形态,remax 目前模拟多页面形态 (🤔不🤔确🤔定🤔)

参考链接

文章分类
前端
文章标签