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 , 僅作為演示,通過可行性驗證,最終仍然需要在小程序環境下驗證,簡單粗暴的小程序模板移植如下: