本身寫個React渲染器: 以 Remax 爲例(用React寫小程序)

上個月螞蟻金服前端發佈了一個新的框架 Remax, 口號是使用真正的、完整的 React 來開發小程序.html

對於本來的 React 開發者來講 'Learn once, write anywhere' , 和 ReactNative 開發體驗差很少,而對於小程序來講則是全新的開發體驗前端

Taro號稱是‘類React’的開發方案,可是它是使用靜態編譯的方式實現,邊柳 在它的 《Remax - 使用真正的 React 構建小程序》文章中也提到了這一點:node

所謂靜態編譯,就是使用工具把代碼語法分析一遍,把其中的 JSX 部分和邏輯部分抽取出來,分別生成小程序的模板和 Page 定義。react

這種方案實現起來比較複雜,且運行時並無 React 存在。git


相比而言,Remax 的解決方案就簡單不少,它不過就是新的React渲染器.github


由於 Remax 剛發佈不久,核心代碼比較簡單,感興趣的能夠去 github 觀摩貢獻
能夠經過 CodeSandbox 遊樂場試玩自定義Renderer: Edit react-custom-renderer
文章看起來比較長,好戲在後頭,一步一步來 🦖算法


文章大綱小程序


關於React的一些基本概念

建立一個 React 自定義渲染器,你須要對React渲染的基本原理有必定的瞭解。因此在深刻閱讀本文以前,先要確保你可以理解如下幾個基本概念:微信小程序

1. Element微信

咱們能夠經過 JSX 或者 React.createElement 來建立 Element,用來描述咱們要建立的視圖節點。好比:

<button class='button button-blue'>
  <b>
    OK!
  </b>
</button>
複製代碼

JSX 會被轉義譯爲:

React.createElement(
  "button",
  { class: 'button button-blue' },
  React.createElement("b", null, "OK!")
)
複製代碼

React.createElement 最終構建出相似這樣的對象:

{
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}
複製代碼

也就是說 Element 就是一個普通的對象,描述用戶建立的節點類型、props 以及 children。這些 Elements 組合成樹,描述用戶視圖


2. Component

能夠認爲是 Element 的類型,它有兩種類型:

  • Host Component: 宿主組件,這是由渲染的平臺提供的‘內置’組件,例如ReactDOM 平臺下面的 DOM 節點,如 divspan... 這些組件類型爲字符串

  • Composite Component: 複合組件,這是一種用戶自定義的組件封裝單位。一般包含自定義的邏輯、狀態以及輸出 Element 樹。複合類型能夠爲類或函數

const DeleteAccount = () => (
  <div> <p>Are you sure?</p> <DangerButton>Yep</DangerButton> <Button color='blue'>Cancel</Button> </div>
);
複製代碼

3. Instance

當 React 開始渲染一個 Element 時,會根據組件類型爲它建立一個‘實例’,例如類組件,會調用new操做符實例化。這個實例會一直引用,直到 Element 從 Element Tree 中被移除。

首次渲染: React 會實例化一個 MyButton 實例,調用掛載相關的生命週期方法,並執行 render 方法,遞歸渲染下級

render(<MyButton>foo</MyButton>, container)
複製代碼

更新: 由於組件類型沒有變化,React 不會再實例化,這個屬於‘節點更新’,React 會執行更新相關的生命週期方法,如shouldComponentUpdate。若是須要更新則再次執行render方法

render(<MyButton>bar</MyButton>, container)
複製代碼

卸載: 組件類型不同了, 原有的 MyButton 被替換. MyButton 的實例將要被銷燬,React 會執行卸載相關的生命週期方法,如componentWillUnmount

render(<button>bar</button>, container)
複製代碼

4. Reconciler & Renderer

ReconcilerRenderer 的關係能夠經過下圖縷清楚.

Reconciler 的職責是維護 VirtualDOM 樹,內部實現了 Diff/Fiber 算法,決定何時更新、以及要更新什麼

Renderer 負責具體平臺的渲染工做,它會提供宿主組件、處理事件等等。例如ReactDOM就是一個渲染器,負責DOM節點的渲染和DOM事件處理。



5. Fiber 的兩個階段 React 使用了 Fiber 架構以後,更新過程被分爲兩個階段(Phase)

  • 協調階段(Reconciliation Phase) 這個階段 React 會找出須要更新的節點。這個階段是能夠被打斷的,好比有優先級更高的事件要處理時。
  • 提交階段(Commit Phase) 將上一個階段計算出來的須要處理的**反作用(Effects)**一次性執行了。這個階段必須同步執行,不能被打斷

若是按照render爲界,能夠將生命週期函數按照兩個階段進行劃分:

  • 協調階段
    • constructor
    • componentWillMount 廢棄
    • componentWillReceiveProps 廢棄
    • static getDerivedStateFromProps
    • shouldComponentUpdate
    • componentWillUpdate 廢棄
    • render
    • getSnapshotBeforeUpdate()
  • 提交階段
    • componentDidMount
    • componentDidUpdate
    • componentWillUnmount

沒理解?那麼下文讀起來對你可能比較吃力,建議閱讀一些關於React基本原理的相關文章。


就目前而言,React 大部分核心的工做已經在 Reconciler 中完成,好在 React 的架構和模塊劃分還比較清晰,React官方也暴露了一些庫,這極大簡化了咱們開發 Renderer 的難度。開始吧!


自定義React渲染器

React官方暴露了一些庫供開發者來擴展自定義渲染器:

  • react-reconciler - 這就是 React 的協調器, React 的核心所在。咱們主要經過它來開發渲染器。
  • scheduler - 合做調度器的一些 API 。本文不會用到

須要注意的是,這些包仍是實驗性的,API可能不太穩定。另外,沒有詳細的文檔,你須要查看源代碼或者其餘渲染器實現;本文以及擴展閱讀中的文章也是很好的學習資料。


建立一個自定義渲染器只需兩步:

第一步: 實現宿主配置,這是react-reconciler要求宿主提供的一些適配器方法和配置項。這些配置項定義瞭如何建立節點實例、構建節點樹、提交和更新等操做。下文會詳細介紹這些配置項

const Reconciler = require('react-reconciler');

const HostConfig = {
  // ... 實現適配器方法和配置項
};
複製代碼

第二步:實現渲染函數,相似於ReactDOM.render() 方法

// 建立Reconciler實例, 並將HostConfig傳遞給Reconciler
const MyRenderer = Reconciler(HostConfig);

/** * 假設和ReactDOM同樣,接收三個參數 * render(<MyComponent />, container, () => console.log('rendered')) */
export function render(element, container, callback) {
  // 建立根容器
  if (!container._rootContainer) {
    container._rootContainer = ReactReconcilerInst.createContainer(container, false);
  }

  // 更新根容器
  return ReactReconcilerInst.updateContainer(element, container._rootContainer, null, callback);
}
複製代碼

容器既是 React 組件樹掛載的目標(例如 ReactDOM 咱們一般會掛載到 #root 元素,#root 就是一個容器)、也是組件樹的 根Fiber節點(FiberRoot)。根節點是整個組件樹的入口,它將會被 Reconciler 用來保存一些信息,以及管理全部節點的更新和渲染。

關於 Fiber 架構的一些細節能夠看這些文章:


HostConfig 渲染器適配

HostConfig 支持很是多的參數,完整列表能夠看這裏. 下面是一些自定義渲染器必須提供的參數:

interface HostConfig {
  /** * 用於分享一些上下文信息 */
  // 獲取根容器的上下文信息, 只在根節點調用一次
  getRootHostContext(rootContainerInstance: Container): HostContext;
  // 獲取子節點的上下文信息, 每遍歷一個節點都會調用一次
  getChildHostContext(parentHostContext: HostContext, type: Type, rootContainerInstance: Container): HostContext;


  /** * 節點實例的建立 */
  // 普通節點實例建立,例如DOM的Element類型
  createInstance(type: Type, props: Props, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: OpaqueHandle,): Instance;
  // 文本節點的建立,例如DOM的Text類型
  createTextInstance(text: string, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: OpaqueHandle): TextInstance;
  // 決定是否要處理子節點/子文本節點. 若是不想建立則返回true. 例如ReactDOM中使用dangerouslySetInnerHTML, 這時候子節點會被忽略
  shouldSetTextContent(type: Type, props: Props): boolean;

  /** * 節點樹構建 */
  // 若是節點在*未掛載*狀態下,會調用這個來添加子節點
  appendInitialChild(parentInstance: Instance, child: Instance | TextInstance): void;
  // **下面都是反作用(Effect),在’提交‘階段被執行**
  // 添加子節點
  appendChild?(parentInstance: Instance, child: Instance | TextInstance): void;
  // 添加子節點到容器節點(根節點)
  appendChildToContainer?(container: Container, child: Instance | TextInstance): void;
  // 插入子節點
  insertBefore?(parentInstance: Instance, child: Instance | TextInstance, beforeChild: Instance | TextInstance): void;
  // 插入子節點到容器節點(根節點)
  insertInContainerBefore?(container: Container, child: Instance | TextInstance, beforeChild: Instance | TextInstance,): void;
  // 刪除子節點
  removeChild?(parentInstance: Instance, child: Instance | TextInstance): void;
  // 從容器節點(根節點)中移除子節點
  removeChildFromContainer?(container: Container, child: Instance | TextInstance): void;

  /** * 節點掛載 */
  // 在完成全部子節點初始化時(全部子節點都appendInitialChild完畢)時被調用, 若是返回true,則commitMount將會被觸發
  // ReactDOM經過這個屬性和commitMount配置實現表單元素的autofocus功能
  finalizeInitialChildren(parentInstance: Instance, type: Type, props: Props, rootContainerInstance: Container, hostContext: HostContext): boolean;
  // 和finalizeInitialChildren配合使用,commitRoot會在’提交‘完成後(resetAfterCommit)執行, 也就是說組件樹渲染完畢後執行
  commitMount?(instance: Instance, type: Type, newProps: Props, internalInstanceHandle: OpaqueHandle): void;

  /** * 節點更新 */
  // 準備節點更新. 若是返回空則表示不更新,這時候commitUpdate則不會被調用
  prepareUpdate(instance: Instance, type: Type, oldProps: Props, newProps: Props, rootContainerInstance: Container, hostContext: HostContext,): null | UpdatePayload;
  // **下面都是反作用(Effect),在’提交‘階段被執行**
  // 文本節點提交
  commitTextUpdate?(textInstance: TextInstance, oldText: string, newText: string): void;
  // 普通節點提交
  commitUpdate?(instance: Instance, updatePayload: UpdatePayload, type: Type, oldProps: Props, newProps: Props, internalInstanceHandle: OpaqueHandle): void;
  // 重置普通節點文本內容, 這個須要和shouldSetTextContent(返回true時)配合使用,
  resetTextContent?(instance: Instance): void;

  /** * 提交 */
  // 開始’提交‘以前被調用,好比這裏能夠保存一些狀態,在’提交‘完成後恢復狀態。好比ReactDOM會保存當前元素的焦點狀態,在提交後恢復
  // 執行完prepareForCommit,就會開始執行Effects(節點更新)
  prepareForCommit(containerInfo: Container): void;
  // 和prepareForCommit對應,在提交完成後被執行
  resetAfterCommit(containerInfo: Container): void;


  /** * 調度 */
  // 這個函數將被Reconciler用來計算當前時間, 好比計算任務剩餘時間 
  // ReactDOM中會優先使用Performance.now, 普通場景用Date.now便可
  now(): number;
  // 自定義計時器
  setTimeout(handler: (...args: any[]) => void, timeout: number): TimeoutHandle | NoTimeout;
  // 取消計時器
  clearTimeout(handle: TimeoutHandle | NoTimeout): void;
  // 表示一個空的計時器,見👆clearTimeout的簽名
  noTimeout: NoTimeout;

  // ? 功能未知
  shouldDeprioritizeSubtree(type: Type, props: Props): boolean;
  // 廢棄
  scheduleDeferredCallback(callback: () => any, options?: { timeout: number }): any;
  // 廢棄
  cancelDeferredCallback(callbackID: any): void;


  /** * 功能開啓 */
  // 開啓節點修改,通常渲染器都會開啓,否則沒法更新節點
  supportsMutation: boolean;
  // 開啓持久化 ?
  supportsPersistence: boolean;
  // 開啓hydrate,通常用於服務端渲染
  supportsHydration: boolean;

  /** * 雜項 */
  // 獲取可公開的節點實例,即你願意暴露給用戶的節點信息,用戶經過ref能夠獲取到這個對象。通常自定義渲染器原樣返回便可, 除非你想有選擇地給用戶暴露信息
  getPublicInstance(instance: Instance | TextInstance): PublicInstance;

  // ... 還有不少參數,因爲通常渲染器不會用到,暫時不講了
}
複製代碼

若是按照Fiber的兩個階段來劃分的話,接口分類是這樣的:

協調階段 開始提交 提交階段 提交完成
createInstance prepareCommit appendChild resetAfterCommit
createTextInstance appendChildToContainer commitMount
shouldSetTextContent insertBefore
appendInitialChild insertInContainerBefore
finalizeInitialChildren removeChild
prepareUpdate removeChildFromContainer
commitTextUpdate
commitUpdate
resetTextContent

經過上面接口定義能夠知道 HostConfig 配置比較豐富,涉及節點操做、掛載、更新、調度、以及各類生命週期鉤子, 能夠控制渲染器的各類行爲.

看得有點蒙圈?不要緊, 你暫時沒有必要了解全部的參數,下面會一點一點展開解釋這些功能。你能夠最後再回來看這裏。


宿主組件

React中有兩種組件類型,一種是宿主組件(Host Component), 另外一種是複合組件(CompositeComponent). 宿主組件是平臺提供的,例如 ReactDOM 平臺提供了 divspanh1... 等組件. 這些組件一般是字符串類型,直接渲染爲平臺下面的視圖節點。

複合組件,也稱爲自定義組件,用於組合其餘複合組件宿主組件,一般是類或函數。

渲染器不須要關心複合組件的處理, Reconciler 交給渲染器的是一顆宿主組件樹

固然在 Remax 中,也定義了不少小程序特定的宿主組件,好比咱們能夠這樣子使用它們:

function MyComp() {
  return <view><text>hello world</text></view>
}
複製代碼

Reconciler 會調用 HostConfigcreateInstancecreateTextInstance 來建立宿主組件的實例,因此自定義渲染器必須實現這兩個方法. 看看 Remax 是怎麼作的:

const HostConfig = {
  // 建立宿主組件實例
  createInstance(type: string, newProps: any, container: Container) {
    const id = generate();
    // 預處理props, remax會對事件類型Props進行一些特殊處理
    const props = processProps(newProps, container, id);
    return new VNode({
      id,
      type,
      props,
      container,
    });
  },

  // 建立宿主組件文本節點實例
  createTextInstance(text: string, container: Container) {
    const id = generate();
    const node = new VNode({
      id,
      type: TYPE_TEXT,
      props: null,
      container,
    });
    node.text = text;
    return node;
  },

  // 判斷是否須要處理子節點。若是返回true則不建立,整個下級組件樹都會被忽略。
  // 有一些場景是不須要建立文本節點的,而是由父節點內部消化。
  // 舉個例子,在ReactDOM中,若是某個節點設置了dangerouslySetInnerHTML,那麼它的children應該被忽略,
  // 這時候 shouldSetTextContent則應該返回true
  shouldSetTextContent(type, nextProps) {
    return false
  }
}

複製代碼

在 ReactDOM 中上面兩個方法分別會經過 document.createElementdocument.createTextNode 來建立宿主組件(即DOM節點)。


上面是微信小程序的架構圖(圖片來源: 一塊兒脫去小程序的外套 - 微信小程序架構解析)。

由於小程序隔離了渲染進程邏輯進程Remax 是跑在邏輯進程上的,在邏輯進程中沒法進行實際的渲染, 只能經過setData方式將更新指令傳遞給渲染進程後,再進行解析渲染

因此Remax選擇在邏輯進程中先構成一顆鏡像樹(Mirror Tree), 而後再同步到渲染進程中,以下圖:


上面的 VNode 就是鏡像樹中的虛擬節點,主要用於保存一些節點信息,不作任何特殊處理, 它的結構以下:

export default class VNode {
  id: number;                  // 惟一的節點id
  container: Container;
  children: VNode[];           // 子節點
  mounted = false;             // 節點是否已經掛載
  type: string | symbol;       // 節點的類型
  props?: any;                 // 節點的props
  parent: VNode | null = null; // 父節點引用
  text?: string;               // 若是是文本節點,這裏保存文本內容
  path(): Path                 // 節點的路徑. 同步到渲染進程後,經過path恢復到樹中
  // 子節點操做
  appendChild(node: VNode, immediately: boolean)
  removeChild(node: VNode, immediately: boolean)
  insertBefore(newNode: VNode, referenceNode: VNode, immediately: boolean)

  update()                     // 觸發同步到渲染進程
  toJSON(): string
}
複製代碼

VNode 的完整代碼能夠看這裏


鏡像樹的構建和操做

要構建出完整的節點樹須要實現HostConfigappendChildinsertBeforeremoveChild 等方法, 以下, 這些方法都比較容易理解,因此不須要過多解釋。

const HostConfig = {
  // ...

  // 支持節點修改
  // 有些靜態渲染的場景,例如渲染爲pdf文檔,這時候能夠關閉
  // 當關閉時,只須要實現appendInitiaChild
  supportsMutation: true,

  // 用於初始化(首次)時添加子節點
  appendInitialChild: (parent: VNode, child: VNode) => {
    parent.appendChild(child, false);
  },

  // 添加子節點
  appendChild(parent: VNode, child: VNode) {
    parent.appendChild(child, false);
  },

  // 插入子節點
  insertBefore(parent: VNode, child: VNode, beforeChild: VNode) {
    parent.insertBefore(child, beforeChild, false);
  },

  // 刪除節點
  removeChild(parent: VNode, child: VNode) {
    parent.removeChild(child, false);
  },

  // 添加節點到容器節點,通常狀況咱們不須要和appendChild特殊區分
  appendChildToContainer(container: any, child: VNode) {
    container.appendChild(child);
    child.mounted = true;
  },

  // 插入節點到容器節點
  insertInContainerBefore(container: any, child: VNode, beforeChild: VNode) {
    container.insertBefore(child, beforeChild);
  },

  // 從容器節點移除節點
  removeChildFromContainer(container: any, child: VNode) {
    container.removeChild(child);
  },
}
複製代碼

節點更新

上一節講的是樹結構層面的更新,當節點屬性變更或者文本內容變更時,也須要進行更新。咱們能夠經過下列 HostConfig 配置來處理這類更新:

const HostConfig = {
  /** * 更新相關 */
  // 能夠在這裏比對props,若是props沒有變化則不進行更新,這和React組件的shouldComponentUpdate差很少
  // **返回’空‘則表示不更新該節點, 這時候commitUpdate則不會被調用**
  prepareUpdate(node: VNode, type: string, oldProps: any, newProps: any) {
    oldProps = processProps(oldProps, node.container, node.id);
    newProps = processProps(newProps, node.container, node.id);
    if (!shallowequal(newProps, oldProps)) {
      return true;
    }
    return null;
  },

  // 進行節點更新
  commitUpdate(
    node: VNode,
    updatePayload: any,
    type: string,
    oldProps: any,
    newProps: any
  ) {
    node.props = processProps(newProps, node.container, node.id);
    node.update();
  },

  // 進行文本節點更新
  commitTextUpdate(node: VNode, oldText: string, newText: string) {
    if (oldText !== newText) {
      node.text = newText;
      // 更新節點
      node.update();
    }
  },
}
複製代碼

Ok, 這個也比較好理解。 對於普通節點更新,Reconciler 會先調用 prepareUpdate, 肯定是否要更新,若是返回非空數據,Reconciler 就會將節點放入 Effects 鏈中,在提交階段調用 commitUpdate 來執行更新。 文本節點更新則直接調用 commitTextUpdate,不在話下.


反作用提交

React 的更新的兩個階段這個概念很是重要,這個也體如今HostConfig上:

const HostConfig = {
  // Reconciler說,我要開始提交了,你提交前要作什麼,就在這作吧
  // 好比ReactDOM會在這裏保存當前DOM文檔的選中狀態和焦點狀態, 以及禁用事件處理。由於DOM更新可能會破壞這些狀態
  prepareForCommit: () => {},

  // Reconciler說,我已經提交完了
  // ReactDOM會在這裏恢復提交前的DOM文檔的選中狀態和焦點狀態
  resetAfterCommit: () => {},




  // 在協調階段,當一個節點完成'建立'後調用。若是有子節點,則在全部子節點appendInitialChild完成後調用
  // 返回一個boolean值表示’完成提交‘後是否要調用commitMount. 通俗講就是告訴Reconciler,當前節點完成’掛載‘後要執行某些東西
  // ReactDOM會使用這個鉤子來處理帶有autofoucs屬性的節點,在commitMount中實現自動獲取焦點
  finalizeInitialChildren: () => false,

  // 和finalizeInitialChildren配合使用,若是前者返回true,在Reconciler完成提交後,對應節點的commitMount會被執行
  commitMount: () => {},
}
複製代碼

將上文講到的全部鉤子都聚合起來,按照更新的階段和應用的目標(target)進行劃分,它們的分佈是這樣的:


那麼對於 Remax 來講, 何時應該將'更新'提交到渲染進程呢?答案是上圖全部在提交階段的方法被調用時。

提交階段原意就是用於執行各類反作用的,例如視圖更新、遠程方法請求、訂閱... 因此 Remax 也會在這個階段收集更新指令,在下一個循環推送給渲染進程。


HostConfig執行流程總結

回顧一下自定義渲染器各類方法調用的流程, 首先看一下掛載的流程:

假設咱們的組件結構以下:

const container = new Container()
const MyComp = () => {
  return (
    <div> <span>hello world</span> </div>
  )
}

render(
  <div className="root"> <MyComp /> <span>--custom renderer</span> </div>,
  container,
  () => {
    console.log("rendered")
  },
)
複製代碼

React 組件樹的結構以下(左圖),但對於渲染器來講,樹結構是右圖。 自定義組件是React 層級的東西,渲染器只須要關心最終須要渲染的視圖結構, 換句話說渲染器只關心宿主組件:


掛載會經歷如下流程:

經過上面的流程圖,能夠很清晰看到每一個鉤子的調用時機。


同理,咱們再來看一下節點更新時的流程. 咱們稍微改造一下上面的程序,讓它定時觸發更新:

const MyComp = () => {
  const [count, setCount] = useState(1)
  const isEven = count % 2 === 0
  useEffect(() => {
    const timer = setInterval(() => {
      // 遞增計數器
      setCount(c => c + 1)
    }, 10000)

    return () => clearInterval(timer)
  }, [])

  return (
    <div className="mycomp" style={{ color: isEven ? "red" : "blue" }}> {isEven ? <div>even</div> : null} <span className="foo">hello world {count}</span> </div>
  )
}
複製代碼

下面是更新的流程:


MyCompcount 由1變爲2時,MyComp 會被從新渲染,這時候新增了一個div 節點(紅色虛框), 另外 hello world 1 也變成了 hello world 2

新增的 div 節點建立流程和掛載時同樣,只不過它不會當即插入到父節點中,而是先放到Effect鏈表中,在提交階段統一執行。

同理hello world {count}文本節點的更新、以及其餘節點的 Props 更新都是放到Effect鏈表中,最後時刻才更新提交. 如上圖的 insertBeforecommitTextUpdatecommitUpdate.

另一個比較重要的是 prepareUpdate 鉤子,你能夠在這裏告訴 Reconciler,節點是否須要更新,若是須要更新則返回非空值,這樣 commitUpdate 纔會被觸發。


同步到渲染進程

React 自定義渲染器差很少就這樣了,接下來就是平臺相關的事情了。 Remax 目前的作法是在觸發更新後,經過小程序 Page 對象的 setData 方法將更新指令傳遞給渲染進程; 渲染進程側再經過 WXS 機制,將更新指令恢復到樹中; 最後再經過模板機制,將樹遞歸渲染出來。

總體的架構以下:


先來看看邏輯進程側是如何推送更新指令的:

// 在根容器上管理更新
export default class Container {
  // ...
  // 觸發更新
  requestUpdate(
    path: Path,
    start: number,
    deleteCount: number,
    immediately: boolean,
    ...items: RawNode[]
  ) {
    const update: SpliceUpdate = {
      path, // 更新節點的樹路徑
      start, // 更新節點在children中的索引
      deleteCount,
      items, // 當前節點的信息
    };
    if (immediately) {
      this.updateQueue.push(update);
      this.applyUpdate();
    } else {
      // 放入更新隊列,延時收集更新指令
      if (this.updateQueue.length === 0) {
        setTimeout(() => this.applyUpdate());
      }
      this.updateQueue.push(update);
    }
  }

  applyUpdate() {
    const action = {
      type: 'splice',
      payload: this.updateQueue.map(update => ({
        path: stringPath(update.path),
        start: update.start,
        deleteCount: update.deleteCount,
        item: update.items[0],
      })),
    };

    // 經過setData通知渲染進程
    this.context.setData({ action });
    this.updateQueue = [];
  }
}
複製代碼

邏輯仍是比較清楚的,即將須要更新的節點(包含節點路徑、節點信息)推入更新隊列,而後觸發 setData 通知到渲染進程


渲染進程側,則須要經過 WXS 機制,相對應地將更新指令恢復到渲染樹中:

// 渲染樹
var tree = {
  root: {
    children: [],
  },
};

// 將指令應用到渲染樹
function reduce(action) {
  switch (action.type) {
    case 'splice':
      for (var i = 0; i < action.payload.length; i += 1) {
        var value = get(tree, action.payload[i].path);
        if (action.payload[i].item) {
          value.splice(
            action.payload[i].start,
            action.payload[i].deleteCount,
            action.payload[i].item
          );
        } else {
          value.splice(action.payload[i].start, action.payload[i].deleteCount);
        }
        set(tree, action.payload[i].path, value);
      }
      return tree;
    default:
      return tree;
  }
}
複製代碼

OK, 接着開始渲染, Remax 採用了模板的形式進行渲染:

<wxs src="../../helper.wxs" module="helper" />
<import src="../../base.wxml"/>
<template is="REMAX_TPL" data="{{tree: helper.reduce(action)}}" />
複製代碼

Remax 爲每一個組件類型都生成了一個template,動態'遞歸'渲染整顆樹:

<template name="REMAX_TPL">
  <block wx:for="{{tree.root.children}}" wx:key="{{id}}">
    <template is="REMAX_TPL_1_CONTAINER" data="{{i: item}}" />
  </block>
</template>

<wxs module="_h">
  module.exports = {
  v: function(value) {
  return value !== undefined ? value : '';
  }
  };
</wxs>


<% for (var i = 1; i <= depth; i++) { %>
<%var id = i; %>

<% for (let component of components) { %>
<%- include('./component.ejs', {
        props: component.props,
        id: component.id,
        templateId: id,
      }) %>
<% } %>
<template name="REMAX_TPL_<%=id%>_plain-text" data="{{i: i}}">
  <block>{{i.text}}</block>
</template>

<template name="REMAX_TPL_<%=id%>_CONTAINER" data="{{i: i}}">
  <template is="{{'REMAX_TPL_<%=id%>_' + i.type}}" data="{{i: i}}" />
</template>
<% } %>
複製代碼

限於小程序的渲染機制,如下因素可能會影響渲染的性能:

  • 進程IPC。更新指令經過IPC通知到渲染進程,頻繁更新可能會影響性能. ReactNative 中涉及到 Native 和 JS引擎之間的通訊,也是存在這個問題的。 因此小程序纔有了 WXS 這類方案,用來處理複雜的視圖交互問題,好比動畫。將來 Remax 也須要考慮這個問題
  • Reconciler這一層已經進行了 Diff,到渲染進程可能須要重複再作一遍?
  • 基於模板的方案,局部更新是否會致使頁面級別從新渲染?和小程序原生的自定義組件相比性能如何?

總結

本文以 Remax 爲例,科普一個 React 自定義渲染器是如何運做的。對於 Remax,目前還處於開發階段,不少功能還不完善。至於性能如何,筆者還很差作評論,能夠看官方給出的初步基準測試。有能力的同窗,能夠參與代碼貢獻或者 Issue 討論。

最後謝謝邊柳對本文審校和建議。


擴展閱讀


相關文章
相關標籤/搜索