Well in Time
製作 Custom React renderer - 用 React 寫 ppt
02-26-202016 Min Read

『願你餘生所有的珍惜都不用靠失去來懂得。』

前言

最近因為武漢肺炎的關係,公司展開了不知得持續多久的 Work From Home 政策,雖然團隊裡有些人不太喜歡,覺得這樣溝通與開會效率比不上在辦公室面對面,但我個人是還蠻習慣的,並且,這節省下來的通勤時間,剛好讓我可以多看一些影片跟玩玩 side project。這次在補帶 React Conf 2019 的影片時,看到前 React team 的 EM, Sophie Alpert 介紹 Building a Custom React Renderer,加上 side project 用上了 ink 這個能用 React 撰寫 command line 工具的套件,讓我決定透過實際操作來了解何謂 Custom React Renderer,以及該如何打造。除了 Sophie Alpert 的影片外,基本上是參考 @nitin42 的這份教學,以 officegen 作為 React Component 的 host environment,讓我們能用撰寫 React 的方式來製作 pptx 投影片!

React Reconciliation 與 Renderer

React 的架構中有兩個主要的重心,reconciliationrenderingreconciliation 是 React 用來找出狀態改變前後,其 UI 樹狀結構差異的演算法,並決定哪一部分的節點是需要被更動的,也就是市面上流傳所謂 Virtual DOM 的核心概念。但實際上所謂 Virtual DOM 這個名詞有點誤用,因為在 reconciliation 的演算法與定義中,完全不涉及 DOMDOM 只是 React reconciliation 可以套用的其中一個 Host Environmentrendering 的過程會依據 reconiliation 的結果,搭配所在的 Host Environment 來渲染出相對應的畫面,這就是 Renderer 所負責的。例如 React-Native 就是 Host Environment 為 iOS、Android 平台的一種 Renderer,當然 react-dom 也是。

這個架構老早就存在於 React 的核心中,當初 Fiber 架構就是在改善 reconciliation 的實作方式(當然也有影響 renderer 的實作),也有許多文章在探討與說明。

只是較少為人知的是,Rect 其實有一個 react-reconciler 的套件可以使用,幫你處理好 reconciliation 的部分,提供一些介面讓你根據想要的 host environment 實作 rendering,而這就是為什麼有人能客製化各種 renderer,讓大家能用 React 撰寫 VR、Command Line 或是 等等(可參考此 awesome list)。

關於 Fiber 架構的觀念介紹,推薦大家去看 Andrew Clark文章,雖然是很久之前寫的,但我覺得觀念闡述的很清晰易懂。

簡單來說,所謂的 fiber 是在 reconciliation 中的一個工作單位,一個 fiber 是一個 JavaScript object,包含著一個 Component 的資訊,其輸入與輸出,在接下來的實作中,我們會利用 react-reconciler 與 fiber 所提供的 Component 資訊來實作一個客製化 PPTXRenderer。

最終的結果

先看一下最終的成品功能,這樣在說明後面的實作時,應該會比較有感受。

這次範例中所客製化的 PPTXRenderer 可以讓我們使用兩個 Component:<Slide><Text> 來產生投影片。

例如在 App.js 中這樣寫:

import React, { Component } from 'react';
import { Text, Slide, render } from '../src';
const App = () => (
  <React.Fragment>
    <Slide>
      <Text>Slide 1 😁 😁</Text>
    </Slide>
    <Slide>
      <Text>Slide 2 😍 😍</Text>
    </Slide>
  </React.Fragment>
);
render(<App />, `${__dirname}/text.pptx`);

會產生這樣的投影片:

demo

TL;DR 篇幅有點長,很多程式碼,若不想看文章,可以直接參考程式碼 reapptx

Custom Renderer 基本結構

react-reconciler 提供的函式可以接受一個 host config object,並回傳 renderer instance。其中 host config object 是我們用來定義與實作在 renderer 的 lifecycle 中所需要的 method,包含 updateappend childrenremove children 等等,這邊所處理的通通都是 host environment 底下的 components,其餘 non-host 的 components 都會由 React 負責管理。

先看看 react-reconciler Readme 內提供的範例:

import Reconciler from "react-reconciler"

const HostConfig = {
  // You'll need to implement some methods here.
};

const MyRenderer = Reconciler(HostConfig);

const RendererPublicAPI = {
  render(element, container, callback) {
    const MyRendererContainer = MyRenderer.createContainer(container, false);
    // Call MyRenderer.updateContainer() to schedule changes on the roots.
  }
};

module.exports = RendererPublicAPI;

還記得你一般開發 react app 時,都會呼叫 ReactDOM.render 來將你的 root component 掛載到一個 div 上頭嗎?上述程式碼中所 export 的 RenderPublicAPI.render 就等同於 ReactDOM.render

而在 render 函式中,由 HostConfigreact-conciler 所建構的 custom renderer 就可以將 React component 應用在不同的 host environment 中。

至於如何實作 HostConfig,這邊有完整的 method 列表,你也可以參考 react-domreact-native 的 HostConfig

不過從列表中洋洋灑灑一堆 interface,到底哪些是重要的呢?

我們可以利用一個方式來測試,先把原本使用 react-dom 的 renderer 換成你自己的:

import React from "react";
+// import ReactDOM from "react-dom";
+import MyRenderer from "./MyRenderer";

import App from "./App";

const rootElement = document.getElementById("root");
-ReactDOM.render(
+MyRenderer.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  rootElement
);

而在 MyRenderer.js 中,將各個函式一個一個放上去,並加上 log,直到你的頁面沒有出現 error:

import Reconciler from "react-reconciler";

const HostConfig = {
  appendInitialChild(...args) {
    console.log("appendInitialChild", ...args);
  },

  createInstance(...args) {
    console.log("createInstance", ...args);
  },

  createTextInstance(...args) {
    console.log("createTextInstance", ...args);
  },
  // ... 依此類推將各種 method 都放上去,並加上 log
  // ...
};

const MyRenderer = Reconciler(HostConfig);

const RendererPublicAPI = {
  render(element, container) {
    // render function 中的內容則參考 react-conciler 的 readme
    // 將 renderer 的 container 創建出來,並將傳入的 element update 上去。
    const MyRendererContainer = MyRenderer.createContainer(container);
    MyRenderer.updateContainer(element, MyRendererContainer, null);
  }
};

export default RendererPublicAPI;

這時你的頁面應該會是空白的,因為你所有 renderer 的 method 都尚未實作。但如此一來就能觀察出在你的 <App /> component 中,renderer 會調用哪些函數、執行順序為何、個別的參數是什麼。

這邊給大家一個 codesandbox 的例子把玩:

實際的 Host Config

初步了解 Renderer 的結構與需要填入的 Host Config 後,我們可以來實作需要的 method:

import Reconciler from 'react-reconciler';

const hostConfig = {
	appendInitialChild(parentInstance, child) {
    if (parentInstance.appendChild) {
      parentInstance.appendChild(child);
    }
  },

  createInstance(
    type,
    props,
    rootContainerInstance,
    hostContext,
    internalInstanceHandle
  ) {
    const COMPONENTS = {
      ROOT: () => new PPTXDocument(),
      TEXT: () => new Text(rootContainerInstance, props),
      SLIDE: () => new Slide(rootContainerInstance, props),
      default: undefined
    };

    return COMPONENTS[type]() || COMPONENTS.default;
  },

  getRootHostContext(instance) {
    return {}; 
  },

  getChildHostContext(parentHostContext, type, rootContainerInstance) {
    return {};
  },

  shouldSetTextContent(type, props) {
    return false;
  },

  createTextInstance(
    text,
    rootContainerInstance,
    hostContext,
    internalInstanceHandle
  ) {
    return text;
  },

  finalizeInitialChildren(
    parentInstance,
    type,
    props,
    rootContainerInstance,
    hostContext
  ) {
    return false;
  },

  getPublicInstance(inst) {
    return inst;
  },

  prepareForCommit(rootcontainerInfo) { /* noop */ },

  resetAfterCommit(rootcontainerInfo) { /* noop */ },

  appendChildToContainer(container, child) { /* noop */ },

  removeChildFromContainer(container, child) { /* noop */ },

  now: () => {},

  supportsMutation: true
};
const PPTXRenderer = Reconciler(hostConfig);

有許多 function 是必要但我們的範例中用不著的,所以留空,不過我還是一個一個說明他們各自的功能為何。

  • appendInitialChild(parentInstance, child)

當 renderer 在繪製 component 的時候,會透過此函式將該 component 的 child component append 上去,所以在這個函式中,你必須實作如何將你想要 render 的 component child 加到其 parent component 上頭。以我們的範例為例,是會在每個 component 都實作一個 appendChild 函式,在這邊我們就只需要執行 parentInstance.appendChild(child); 即可。

  • createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle )

看名字就知道是在創建 Component instance 的函式,會傳入當前節點的 type、該節點的 props、根節點的實例、host 環境的 context,以及一個叫做 internalInstanceHandle 的物件。

其他參數都很好懂,這個 internalInstanceHandle 其實就是對應此節點的 fiber。我們前面有說過,fiber 代表的是整個 reconciler 過程中的一個工作單位,而每個 component 都有對應的兩種 fiber,分別是已經完成工作,可以render 的 flushed fiber 跟尚未處理完的 work in progress fiber,fiber 中包含許多 component 的資訊。基本上這個範例中,前面兩個參數就足夠了。想了解更多 fiber 的內容請參考 react-fiber-architecture

在這個範例中,我們只需要透過傳入的 type 來決定我們要對應產生哪個 component 的實例,後面會在說明每個 component 的實作:

const COMPONENTS = {
  ROOT: () => new PPTXDocument(),
  TEXT: () => new Text(rootContainerInstance, props),
  SLIDE: () => new Slide(rootContainerInstance, props),
  default: undefined,
};
	
return COMPONENTS[type]() || COMPONENTS.default;
  • getRootHostContext(instance)

這個函式讓你能夠與 Host Config 中的其他 method 共享 context。基本上會傳入 root component instance 當參數。 在這範例中,並沒有需要 share 任何 context,所以回傳個空物件即可。

  • getChildHostContext(parentHostContext, type, rootContainerInstance)

與上一個函式雷同,讓你能夠分享 context 給當下節點的 children,也能取得 parent 的 context。我們一樣不需要用到,所以回傳空物件。

  • shouldSetTextContent(type, props)

就我的理解,這個函數的目的可以簡單說是讓你有機會判斷是否要將 traversal 停止在當前節點。通常我們的 leaf node 都會是 text node,若在此函式內你回傳 true,則 reconciler 會停止繼續往下 traverse,他會停止在這層,然後接著呼叫 createInstance 去創建實例。

若是設為 false,reconciler 澤會繼續遞迴下去,直到此函式回傳 true,或是真的達到了 leaf text node。若是達到 leaf text node,就會呼叫另個函式 - createTextInstance

以我們的範例來說,我們不像 react-dom 需要考慮 textarea 或是 dangerouslySetInnerHTML 等情況,都直接回傳 false 即可。

  • createTextInstance(text, rootContainerInstance, hostContext, internalInstanceHandle)

顧名思義就是創建 Text instance,這個 Text 指的是你在 component 中直接撰寫的 string 部分,例如:

<Text> Taiwan No.1 </Text>

Taiwan No.1 就是這邊要處理的 Text instance。在 react-dom 中就是要創建一個 textNode,而在這邊我們直接回傳 Text 本身,讓他的父節點 <Text> 來處理。 除了第一個參數 text 外,ㄧ樣會有 root component instance、host context 跟 internalInstanceHandle(fiber),ㄧ樣我們只會用到第一個參數 text。

  • finalizeInitialChildren( parentInstance, type, props, rootContainerInstance, hostContext )

這個函式主要目的在於告訴 reconciler 需不需要在當前的 component 上呼叫 commitMount(),也就是需不需要等到所有 element 都被 rendered 以後才執行某些事情。例如 input elements 的 autofocus,就需要等 component mount 以後才能被呼叫。 在我們的範例中,不需要做這些事,所以就回傳 false 即可。若是你回傳 true,那就必須也實作 commitMount

  • getPublicInstance(ins)

只是個公開介面讓你能取得 instance。

  • prepareForCommit(rootcontainerInfo)

當你的節點實例都生成後,即將掛載到根節點時,可以在這個函式內進行一些準備工作,例如統計需要 autofucs 的節點等等,以我們的範例來說不需要做任何事,留空。

  • resetAfterCommit(rootcontainerInfo)

當 reconciliation 結束,inmemory tree 都掛載到 host root element 時,我們可以利用這個函式執行任何後續動作,像是回覆一些 event 狀態等等。

  • appendChildToContainer(container, child)
  • removeChildFromContainer(container, child)
  • supportsMutation

這幾個可以一起看,supportsMutation 代表的是你的 host environment 支不支援一些可以更改結構的 API,像是 DOM 內的 appendChild,若有則回傳 true,並實作 appendChildToContainerremoveChildFromContainer,讓 renderer 知道當 host element 執行 mutative api 時該如何處理。 在我們的範例中我們不需要用到這些,但因為這應該是蠻容易用到的,所以我設為 true 並在這邊說明一下。

  • now()

host config 內最後一個函式 now(),是 reconciler 用來計算當前時間的,我們可以留空,或是提供 Date.now

對應 Custom Renderer 的 component

終於走完一遍 host config,接下來可以看看我們的 component 該怎麼實作。

在我們的 createInstance() 中,我們根據傳入的 fiber type 來決定要實例化哪個 component:

createInstance(type, props, ..args) {
  const COMPONENTS = {
    ROOT: () => new PPTXDocument(),
    TEXT: () => new Text(rootContainerInstance, props),
    SLIDE: () => new Slide(rootContainerInstance, props),
    default: undefined
  };

  return COMPONENTS[type]() || COMPONENTS.default;
},

Root Component - PPTXDocument

Root component 在 react-dom 內可以說是 document 物件,而在我們的 PPTXRenderer 中,該角色就是 new officegen('pptx') 物件:

class PPTXDocument {
  constructor() {
    this.pptx = officegen('pptx');
  }
}

而此物件會被當成 rootContainerInstance 被傳到其他 host config 的函式中。

Slide Component

class Slide {
  constructor(root, props) {
    this.root = root;
    this.props = props;
    this.slideInstance = this.root.pptx.makeNewSlide();
  }
  appendChild(child) {
    // 依據不同 Host environment 來決定要如何實作
    // 在 react-dom 中,可能就是 document.appendChild(child)
    if (child.type === 'TEXT') {
      // render the text node
      this.slideInstance.addText(child.content);
    }
  }
}

Slide component 會取得 root instance,並呼叫 makeNewSlide() 來創建 slide(這是屬於 officegen 的 API)。

然後我們需要實作 appendChild(child),因為我們希望能透過以下的方式來創建 slides:

<Slide>
  <Text>Slide 1 😁</Text>
</Slide>

能接收一個 <Text> component 當子節點來 render Text 到 slide 上,我們用 officegen 提供的 addText 來將 child.content 繪製上去。此函式會被 appendInitialChild 呼叫。

Text Component

class Text {
  constructor(root, props) {
    this.type = 'TEXT';
    this.content = '';
  }
  appendChild(child) {
    if (typeof child === 'string') {
      this.content = child;
    }
  }
}

基本結構一樣,只是在 appendChild 中,我們不用 append 任何 child,反之,我們需要將 text component 收到的 text child 存入一個 content 變數,讓其 parent(slide component) 可以接收到。

注意事項

雖然我們在這邊都創建了 SlideText component,但是在真正使用在 jsx 裡面時(也就是 App.js)是不能直接 import 這邊的 component 來使用的,我們可以另外創建一個 string alias 給 App.js 使用:

// Aliases for createInstance
const Text = 'TEXT';
const Slide = 'SLIDE';

const App = () => (
  <Slide>
    <Text>Slide 1 😁 😁</Text>
  </Slide>
);

我們上述所撰寫的 Component 會在 reconciler 的 createInstance 中依據這邊的 alias 來創建實例。

Render function

最後我們要實作 render function 來真正取代一般的 ReactDOM.render

// render component
async function render(component, filePath) {
  // 創建 Root component instance 當整個 react tree 的 root。 
  const container = new PPTXDocument();
  // 呼叫 create container,該函式會回傳一個 flushed fiber(完成工作的 fiber,代表可以 render)
  const node = PPTXRenderer.createContainer(container);
  // 接著透過呼叫 updateContainer 來設定一個從根節點開始的 update,更新整個樹。
  PPTXRenderer.updateContainer(component, node, null);
  // Officegen generates a output stream and not a file
  const stream = fs.createWriteStream(filePath);
  await new Promise((resolve, reject) => {
    // Generate a pptx document
    container.pptx.generate(stream, Events(filePath, resolve, reject));
  );
}

記得我們一般在呼叫 ReactDOM.render 時,都會傳入兩個參數嗎?一個是我們的 root component,一個就是要掛載的 dom element,而在我們的範例中,我們一樣傳入 root component,但第二參數給予的是要產生的 ppt 的路徑,而非要掛載的 element,因為我們是要將 react component 寫入 pptx 檔案。

最後在回傳的 Promise function 中,我們呼叫 officegengenerate 函式來將我們在前面 host config 的 lifecycle method 中所附加到根節點的內容(appendChild 的部分)寫入檔案。

這樣就完成了我們的 Custom Renderer!

完整程式碼在此 -> reapptx

補充說明 - update

這次的範例裡面並沒有需要更新 Component 狀態,如果你需要實作一個能處理 state update 的 custom renderer,在你的 host config 中,除了 appendChildToContainer()removeChildFromContainer() 外,還需要實作 prepareUpdate()commitUpdate()

  • prepareUpdate(instance, type, oldProps, newProps, rootContainerInstance, hostContext)

從他傳入的參數就可以看出,你可以藉由 oldPropsnewProps 的比較來決定是否要進行更新,若不需要就回傳 null,要的話就回傳要更新的 payload。

  • commitUpdate(instance, updatePayload, type, oldProps, newProps, internalInstanceHandle)

這個函式就是負責最後將要 prepareUpdate 回傳的 update payload 套用到實際 instance 上。

關於更新的實際例子,可以參考 react 渲染器了解一下 這篇文章,有實際的例子與詳細程式碼講解。

結論

一不小心洋洋灑灑紀錄了一堆,但透過製作 custom react renderer,一步步把整個流程與其中用到的函式都釐清用途後,對於 react 在進行 reconciliation 與 rendering 的流程多了不少了解,也不再對 React 為什麼能套用在這麼多不同的環境中感到神秘了,算是很不錯的收穫!有耐心看完的讀者若發現錯誤或是不清楚的地方,歡迎留言告知指教。

資料來源

  1. Building a Custom React Renderer
  2. Making-a-custom-React-renderer
  3. ⚛️👆Beginners guide to React Renderers. How to build your own renderer from scratch?
  4. react 渲染器了解一下?
  5. facebook/react-reconciler
  6. awesome-react-renderer
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 09-08-2024