微信小程序孱弱的原生開發體驗,註定會出現小程序開發框架,來增強開發體驗。面對小程序平台碎片化的現狀,註定會由框架肩負跨平台移植挑戰。開源社區的框架大致分為兩類: 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 的實現機制不同,必然與轉譯型框架存在較大差異,筆者理解如下: