微信小程序孱弱的原生開發體驗,註定會出現小程序開發框架,來增強開發體驗。面對小程序平台碎片化的現狀,註定會由框架肩負跨平台移植挑戰。開源社區的框架大致分為兩類: 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"}}
{{#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 部分相對不那麼難以理解。
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 目前模擬多頁面形態 ( 不 確 定 )