天天看點

史上最貼心React渲染器開發輔導

史上最貼心React渲染器開發輔導

這個故事要從幾年前,

React

react-dom

離婚說起,先插一嘴,第三者不是張三(純博人眼球)。

React

剛流行起來時,并沒有

react-dom

,突然有一天,

React

發達了,想學時間管理,然後閱人無數,就這樣

React

成了王,而

react-dom

與之分開淪為了妾,

React

可謂妻妾成群,我們随便舉幾個例子:

React-Native

Remax

等。為啥

React

如此無情?我攤牌了,編不下去了,就說好好寫文章他不香嗎?

正兒八經的,我們開始!

相信大家對于跨端這個概念不陌生,什麼是跨端?就是讓你感覺寫一套代碼可以做幾個人的事,比如,我用

React

可以寫Web 、可以寫小程式 、可以寫原生應用,這樣能極大降低成本,但其實,你的工作交給

React

去做了,我們可以對應一下:

•web:react-dom•小程式:remax•ios、android:react-native

這樣一捋是不是清晰了?我們再看一張圖

史上最貼心React渲染器開發輔導

到這裡,你是否明白了當初

React

react-dom

分包的用意了?

React

這個包本身代碼量很少,他隻做了規範和api定義,平台相關内容放在了與宿主相關的包,不同環境有對應的包面對,最終展現給使用者的是單單用

React

就把很多事兒做了。

那按這樣說,我們是不是也可以定義自己的React渲染器?當然可以,不然跟着這篇文章走,學完就會,會了還想學。

建立React項目

首先使用React腳手架建立一個demo項目

安裝腳手架

npm i -g create-react-app

建立項目

create-react-app react-custom-renderer

運作項目

yarn start

現在我們可以在vs code中進行編碼了

修改

App.js

檔案源碼

import React from "react";
import "./App.css";

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  handleClick = () => {
    this.setState(({ count }) => ({ count: count + 1 }));
  };

  render() {
    const { handleClick } = this;
    const { count } = this.state;
    return (
      <div className="App">
        <header className="App-header" onClick={handleClick}>
          <span>{count}</span>
        </header>
      </div>
    );
  }
}

export default App;
           

複制

打開浏覽器,可以看到頁面,我們點選頁面試試,數字會逐漸增加。

史上最貼心React渲染器開發輔導

到這裡,簡單的

React

項目建立成功,接下來我們準備自定義渲染器。

初識渲染器

打開

src/index.js

,不出意外,一應該看到了這行代碼:

import ReactDOM from 'react-dom';           

複制

還有這行

ReactDOM.render(
  <App />,
  document.getElementById('root')
);           

複制

現在我們要使用自己的代碼替換掉

react-dom

,建立

MyRenderer.js

,然後修改

index.js

中的内容

import MyRenderer from './MyRenderer'

MyRenderer.render(
  <App />,
  document.getElementById('root')
)           

複制

然後打開浏覽器,會看到報錯資訊,我們按照報錯資訊提示,完善

MyRenderer.js

的内容。首先檔案中最基本的結構如下

import ReactReconciler from "react-reconciler";

const rootHostContext = {};
const childHostContext = {};

const hostConfig = {
  getRootHostContext: () => {
    return rootHostContext;
  },
  getChildHostContext: () => {
    return childHostContext;
  },
};

const ReactReconcilerInst = ReactReconciler(hostConfig);
export default {
  render: (reactElement, domElement, callback) => {
    if (!domElement._rootContainer) {
      domElement._rootContainer = ReactReconcilerInst.createContainer(
        domElement,
        false
      );
    }
    return ReactReconcilerInst.updateContainer(
      reactElement,
      domElement._rootContainer,
      null,
      callback
    );
  },
};
           

複制

react-reconciler

源碼我們曾講解過,我們可以把它當做一個排程器,負責建立與更新,而後在

scheduler

中進行排程,我們導出一個對象,其中有一個方法

render

,參數與

react-dom

的render方法一緻,這裡需要判斷一下,如果傳入的dom元素是根容器,則為建立操作,否則是更新的操作,建立操作調用

react-reconciler

執行個體的

createContainer

方法,更新操作調用

react-reconciler

執行個體的

updateContainer

方法。我們再來看到更為重要的概念——hostConfig。

Host宿主相關配置

Host——東家、宿主,見名知意,HostConfig是對于宿主相關的配置,這裡所說的宿主就是運作環境,是web、小程式、還是原生APP。有了這個配置,

react-reconciler

在進行排程後,便能根據宿主環境,促成UI界面更新。

我們繼續來到浏覽器,跟随報錯資訊,完善我們hostConfig的内容,我将其中核心的方法列舉如下,供大家參考學習。

•getRootHostContext•getChildHostContext•shouldSetTextContent•prepareForCommit•resetAfterCommit•createTextInstance•createInstance•appendInitialChild•appendChild•finalizeInitialChildren•appendChildToContainer•prepareUpdate•commitUpdate•commitTextUpdate•removeChild

看到這些方法不禁聯想到

DOM

相關操作方法,都是語義化命名,這裡不贅述各個方法的實際含義,一下我們修改相關方法,重新讓項目跑起來,以助于大家了解渲染器的工作原理。

定義hostConfig

以上方法中,我們重點了解一下

createInstance

commitUpdate

, 其他方法我在最後通過代碼片段展示出來,供大家參考。(注:相關實作可能與實際使用有較大的差别,僅供借鑒學習)

createInstance

方法參數

•type•newProps•rootContainerInstance•_currentHostContext•workInProgress

傳回值

根據傳入type,建立dom元素,并處理props等,最終傳回這個dom元素。本例我們隻考慮一下幾個props

•children•onClick•className•style•其他

代碼實作

const hostConfig = {
  createInstance: (
    type,
    newProps,
    rootContainerInstance,
    _currentHostContext,
    workInProgress
  ) => {
    const domElement = document.createElement(type);
    Object.keys(newProps).forEach((propName) => {
      const propValue = newProps[propName];
      if (propName === "children") {
        if (typeof propValue === "string" || typeof propValue === "number") {
          domElement.textContent = propValue;
        }
      } else if (propName === "onClick") {
        domElement.addEventListener("click", propValue);
      } else if (propName === "className") {
        domElement.setAttribute("class", propValue);
      } else if (propName === "style") {
        const propValue = newProps[propName];
        const propValueKeys = Object.keys(propValue)
        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')
        domElement.setAttribute(propName, propValueStr);
      } else {
        const propValue = newProps[propName];
        domElement.setAttribute(propName, propValue);
      }
    });
    return domElement;
  },
}           

複制

是不是很眼熟?誰說原生JavaScript不重要,我們可以看到在架構的内部,還是需要使用原生JavaScript去操作DOM,相關操作我們就不深入了。

commitUpdate

更新來自于哪裡?很容易想到

setState

,當然還有

forceUpdate

,比如老生常談的問題:兄嘚,

setState

是同步還是異步啊?啥時候同步啊?這就涉及到

fiber

的内容了,其實排程是通過計算的

expirationTime

來确定的,将一定間隔内收到的更新請求入隊并貼上相同時間,想想,如果其他條件都一樣的情況下,那這幾次更新都會等到同一個時間被執行,看似異步,實則将優先權讓給了更需要的任務。

小小拓展了一下,我們回來,更新來自于

setState

forceUpdate

,更新在經過系列排程之後,最終會送出更新,這個操作就是在 commitUpdate方法完成。

方法參數

•domElement•updatePayload•type•oldProps•newProps

這裡的操作其實與上面介紹的createInstance有類似之處,不同點在于,上面的方法需要建立執行個體,而此處更新操作是将已經建立好的執行個體進行更新,比如内容的更新,屬性的更新等。

代碼實作

const hostConfig = {
  commitUpdate(domElement, updatePayload, type, oldProps, newProps) {
    Object.keys(newProps).forEach((propName) => {
      const propValue = newProps[propName];
      if (propName === "children") {
        if (typeof propValue === "string" || typeof propValue === "number") {
          domElement.textContent = propValue;
        }
        // TODO 還要考慮數組的情況
      } else if (propName === "style") {
        const propValue = newProps[propName];
        const propValueKeys = Object.keys(propValue)
        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')
        domElement.setAttribute(propName, propValueStr);
      } else {
        const propValue = newProps[propName];
        domElement.setAttribute(propName, propValue);
      }
    });
  },
}           

複制

兩個主要的方法介紹完了,你現在隐隐約約感受到了

react

跨平台的魅力了嗎?我們可以想象一下,假設

MyRenderer.render

方法傳入的第二個參數不是

DOM

對象,而是其他平台的

GUI

對象,那是不是在 createInstance 和 commitUpdate 方法中使用對應的GUI建立與更新api就可以了呢?沒錯!

完整配置

const hostConfig = {
  getRootHostContext: () => {
    return rootHostContext;
  },
  getChildHostContext: () => {
    return childHostContext;
  },
  shouldSetTextContent: (type, props) => {
    return (
      typeof props.children === "string" || typeof props.children === "number"
    );
  },
  prepareForCommit: () => {},
  resetAfterCommit: () => {},
  createTextInstance: (text) => {
    return document.createTextNode(text);
  },
  createInstance: (
    type,
    newProps,
    rootContainerInstance,
    _currentHostContext,
    workInProgress
  ) => {
    const domElement = document.createElement(type);
    Object.keys(newProps).forEach((propName) => {
      const propValue = newProps[propName];
      if (propName === "children") {
        if (typeof propValue === "string" || typeof propValue === "number") {
          domElement.textContent = propValue;
        }
      } else if (propName === "onClick") {
        domElement.addEventListener("click", propValue);
      } else if (propName === "className") {
        domElement.setAttribute("class", propValue);
      } else if (propName === "style") {
        const propValue = newProps[propName];
        const propValueKeys = Object.keys(propValue)
        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')
        domElement.setAttribute(propName, propValueStr);
      } else {
        const propValue = newProps[propName];
        domElement.setAttribute(propName, propValue);
      }
    });
    return domElement;
  },
  appendInitialChild: (parent, child) => {
    parent.appendChild(child);
  },
  appendChild(parent, child) {
    parent.appendChild(child);
  },
  finalizeInitialChildren: (domElement, type, props) => {},
  supportsMutation: true,
  appendChildToContainer: (parent, child) => {
    parent.appendChild(child);
  },
  prepareUpdate(domElement, oldProps, newProps) {
    return true;
  },
  commitUpdate(domElement, updatePayload, type, oldProps, newProps) {
    Object.keys(newProps).forEach((propName) => {
      const propValue = newProps[propName];
      if (propName === "children") {
        if (typeof propValue === "string" || typeof propValue === "number") {
          domElement.textContent = propValue;
        }
        // TODO 還要考慮數組的情況
      } else if (propName === "style") {
        const propValue = newProps[propName];
        const propValueKeys = Object.keys(propValue)
        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')
        domElement.setAttribute(propName, propValueStr);
      } else {
        const propValue = newProps[propName];
        domElement.setAttribute(propName, propValue);
      }
    });
  },
  commitTextUpdate(textInstance, oldText, newText) {
    textInstance.text = newText;
  },
  removeChild(parentInstance, child) {
    parentInstance.removeChild(child);
  },
};           

複制

來到浏覽器,正常工作了,點選頁面,計數增加。

以上就是本節的所有内容了,看罷你都明白了嗎?如果想看其他架構原理,歡迎留言評論

•微信公衆号 《JavaScript全棧》•掘金 《合一大師》•Bilibili 《合一大師》•微信:zxhy-heart

我是合一,英雄再會。