React 源碼解讀之 Custom Renderer

2021-02-14 前端遊
引言

從React的渲染流程[1]我們知道,JSX 會先轉為一顆 Fiber Tree,然後通過 Renderer 渲染成頁面。

對於 Web 平臺,這個 Renderer 就是 react-dom,對於 Native 平臺,這個 Renderer 就是 react-native。

當然,我們也可以創建我們自己的 Renderer,將 React 應用渲染到其他目標平臺,比如本文中的 Canvas:

下面就來剖析下 Canvas Renderer 的實現方式。

Canvas Renderer組件

如圖,我們的 Canvas Renderer 包括 Stage,Rect,Circle,Text 這些組件,其中將他們一些公共的特徵抽離成了一個父類 Layer。

不需要 React,現在的 Canvas Renderer 已經可以渲染出內容了,比如:

const renderDom = document.getElementById('demo')

const stage = new Stage({
  renderDom,
  width: 500,
  height: 300,
  style: {border: '1px solid gray'},
})
const rect = new Rect({x: 50, y: 50, width: 100, height: 100, color: 'red'})
const circle = new Circle({x: 50, y: 50, radius: 20, color: 'green'})
const text = new Text({
  content: '我是一個 Demo',
  fillStyle: 'blue',
  x: 100,
  y: 30,
  font: '20px serif',
})
rect.appendChild(circle)
stage.appendChild(text)
stage.appendChild(rect)
stage.render()

Canvas Renderer 實現方式

我們通過引言中第一個 Demo 來分析 Canvas Renderer 的實現方式:

// Demo1.jsx
import {useEffect, useState} from 'react'

const R = 20
const W = 100
const H = 100

function Demo1() {
  const [x, setX] = useState(R)
  const [y, setY] = useState(R)
  useEffect(() => {
    setTimeout(() => {
      if (y === R && x < W - R) {
        setX(x + 1)
      } else if (x === W - R && y < H - R) {
        setY(y + 1)
      } else if (y === H - R && x > R) {
        setX(x - 1)
      } else {
        setY(y - 1)
      }
    }, 10)
  }, [x, y])
  return (
    <>
      <text x={10} y={20} content='DEMO1' font='18px serif' fillStyle='black' />
      <rect x={50} y={50} width={W} height={H} color='blue'>
        <circle x={x} y={y} radius={R} color='red'>
          <rect x={-10} y={-10} width={20} height={20} color='green' />
        </circle>
      </rect>
    </>
  )
}

export default Demo1

// index.js
import CanvasRenderer from './CanvasRenderer'
import Demo1 from './Demo1'

CanvasRenderer.render(<Demo1 />, document.getElementById('demo1'), {
  width: 400,
  height: 200,
  style: {
    backgroundColor: 'white',
    border: '1px solid gray',
  },
})

Demo1 是一個函數組件,返回了 text、rect、 circle 這些標籤,並通過一個 setInterval 定時器實現了一個簡單的動畫。接下來看看 CanvasRenderer.render 函數做了啥:

const reconcilerInstance = Reconciler(HostConfig)
const CanvasRenderer = {
  render(element, renderDom, {width, height, style}, callback) {
    const stage = new Stage({renderDom, width, height, style})
    const isAsync = false // Disables async rendering
    const container = reconcilerInstance.createContainer(stage, isAsync) // Creates root fiber node.
    const parentComponent = null // Since there is no parent (since this is the root fiber). We set parentComponent to null.

    reconcilerInstance.updateContainer(
      element,
      container,
      parentComponent,
      callback
    ) // Start reconcilation and render the result
  },
}

該函數主要是創建了一個 Stage 對象作為 Reconciler 對象 reconcilerInstance 的 container,最後調用 reconcilerInstance.updateContainer() 將 Demo1 組件通過 Canvas Renderer 進行渲染。

我們知道 Reconciler 在 React 渲染流程中充當著非常重要的作用,它會計算出哪些組件需要更新,並會將需要更新的信息提交給 Renderer 來處理,而將 Reconciler 和 Renderer 連接起來的秘訣就在 HostConfig 之中:

const HostConfig = {
  supportsMutation: true,
  // 通過 FiberNode 創建 instance,會保存在 FiberNode 的 stateNode 屬性上
  createInstance(
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
    workInProgress
  ) {
    let element
    switch (type) {
      case 'rect':
        element = new Rect(newProps)
        break
      case 'circle':
        element = new Circle(newProps)
        break
      case 'text':
        element = new Text(newProps)
        break
      default:
        break
    }
    return element
  },

  /* 操作子組件相關 */
  appendInitialChild(parent, child) {
    parent.appendChild(child)
  },
  appendChildToContainer(parent, child) {
    parent.appendChild(child)
  },
  appendChild(parent, child) {
    parent.appendChild(child)
  },
  insertBefore(parent, child, beforeChild) {
    parent.insertBefore(child, beforeChild)
  },
  removeChild(parent, child) {
    parent.removeChild(child)
  },

  /* 組件屬性發生變化時會調用該方法 */
  commitUpdate(
    instance,
    updatePayload,
    type,
    oldProps,
    newProps,
    finishedWork
  ) {
    instance.update(newProps)
  },

  // react 流程結束後,調用此方法,我們可以在這裡觸發我們的渲染器重新渲染
  // 此處參考 remax:https://github.com/remaxjs/remax/blob/80606f640b08c79b9fc61d52a03355f0282c5e14/packages/remax-runtime/src/hostConfig/index.ts#L63
  resetAfterCommit(container) {
    container.render()
  },
  getRootHostContext(nextRootInstance) {
    const rootContext = {}
    return rootContext
  },
  getChildHostContext(parentContext, fiberType, rootInstance) {
    const context = {}
    return context
  },
  prepareForCommit(rootContainerInstance) {
    return null
  },
  prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext
  ) {
    return {}
  },
  // 暫時不需要實現的接口
  finalizeInitialChildren() {},
  appendAllChildren(...args) {},
  commitTextUpdate(textInstance, oldText, newText) {},
  removeChildFromContainer(container, child) {},
  commitMount(domElement, type, newProps, fiberNode){},
  clearContainer(...args) {},
  createTextInstance(
    newText,
    rootContainerInstance,
    currentHostContext,
    workInProgress
  ) {},
  shouldSetTextContent(...args) {},
}

HostConfig 中是我們的 Canvas Renderer 需要實現的一些接口,這裡來說明一下:

supportsMutation

當前渲染器是否支持修改節點,毫無疑問這裡必須是 true。

createInstance

該函數會在通過 FiberNode 創建宿主相關的元素時進行調用,返回的元素會保存在 FiberNode 的 stateNode 屬性上,參考React的渲染流程。

對於 Canvas Renderer 來說,這裡會根據 type 值創建出不同的組件。

appendInitialChild、appendChild、appendChildToContainer、insertBefore

這幾個接口都涉及到元素的插入操作,前三個是把元素插到最後面,其中 appendInitialChild 在首次渲染時調用,appendChild 在更新的時候調用,而 appendChildToContainer 則在把元素插入到 container 時使用。

對於 Canvas Renderer 來說,這些接口中均調用 parent.appendChild(child) 即可:

  appendChild(child) {
    this.__children.push(child)
    child.parent = this
  }

而 insertBefore 則是把元素插入到某個元素前面,同樣,Canvas Renderer 也有對應的實現:

  insertBefore(child, beforeChild) {
    for (let i = 0; i < this.__children.length; i++) {
      if (this.__children[i] === beforeChild) {
        this.__children.splice(i, 0, child)
        child.parent = this
        break
      }
    }
  }

commitUpdate

當組件屬性發生變化的時候會調用該函數,Canvas Renderer 對應的實現方法也比較簡單,即更新 instance 的屬性即可:

  update(props) {
    Object.keys(props).forEach((k) => {
      this[k] = props[k]
    })
  }

resetAfterCommit

在React 源碼解讀之首次渲染流程[1]這篇文章中已闡明 React 的每次更新過程包括 Render 和 Commit 兩大階段。

其中 Render 階段會計算出 Effect 鍊表供 Commit 階段處理,而 resetAfterCommit 這個函數就是在 Commit 階段執行完 commitMutationEffects 函數後進行調用,此時所有對元素的更新操作已處理完畢,所以這裡是一個適合 Canvas Renderer 調用 container.render() 進行重新渲染的地方。

該函數中首先清空了整個畫布,然後依次調用子組件的 render 方法:

// Stage.js
  render() {
    this.context.clearRect(0, 0, this.width, this.height)
    this.renderChildren()
  }
// Layer.js
  renderChildren() {
    for (let child of this.__children) {
      child.render()
    }
  }
// Rect.js
  render() {
    const {x, y, stage} = this.resolvePosAndStage()
    if (!stage) return
    stage.context.beginPath()
    stage.context.rect(x, y, this.width, this.height)
    stage.context.strokeStyle = this.color
    stage.context.stroke()
    this.renderChildren()
  }
// Circle.js
  render() {
    const {x, y, stage} = this.resolvePosAndStage()
    if (!stage) return
    stage.context.beginPath()
    stage.context.arc(x, y, this.radius, 0, 2 * Math.PI, true)
    if (this.fill) {
      stage.context.fillStyle = this.color
      stage.context.fill()
    } else {
      stage.context.strokeStyle = this.color
      stage.context.stroke()
    }
    this.renderChildren()
  }
// Text.js
  render() {
    const {x, y, stage} = this.resolvePosAndStage()
    if (!stage) return
    stage.context.font = this.font
    stage.context.fillStyle = this.fillStyle
    stage.context.fillText(this.content, x, y)
  }

值得一提的是,Remax[2] 也是在這裡觸發了小程序的更新。

至此,我們的 Canvas Renderer 的核心實現原理就分析完了,更多內容及 Demo 詳見源碼[3]。

參考資料[1]

React 源碼解讀之首次渲染流程: https://mp.weixin.qq.com/s?__biz=MzIwOTM2ODM1OQ==&mid=2247483729&idx=1&sn=b8001469891e097f20db16bd481fe253&chksm=9775a519a0022c0f6563a856a92c1522c9a0269687569d1b0340605df9db6433abc6bdb29119&token=2040200027&lang=zh_CN#rd

[2]

Remax: https://remaxjs.org/

[3]

react-canvas-renderer: https://github.com/ParadeTo/react-canvas-renderer

相關焦點

  • React16源碼解讀:揭秘ReactDOM.render
    同時我們也了解到,通過使用 Babel 預置工具包@babel/preset-react可以將類組件中render方法的返回值和函數定義組件中的返回值轉換成使用React.createElement方法包裝而成的多層嵌套結構,並基於源碼逐行分析了React.createElement方法背後的實現過程和ReactElement構造函數的成員結構,最後根據分析結果總結出了幾道面試中可能會碰到或者自己以前遇到過的面試考點
  • 為什麼我說你應該學React源碼?
    通過閱讀源碼,你不僅能在日常工作和面試中受益,還能從中吸收優秀的解決問題的思路以及培養「造輪子」的能力,還會學習到怎麼寫出規範又好維護的代碼。不過React源碼量級很大,本身有很大的難度,很多人都因此被勸退。
  • 手寫React-Router源碼,深入理解其原理
    BrowserRouter源碼 我們代碼裡面最外層的就是BrowserRouter,我們先去看看他的源碼幹了啥,地址傳送門:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.js
  • React 源碼解讀之Hooks
    說明:本文結論均基於 React 16.13.1 得出,若有出入請參考對應版本源碼題目老規矩,在進入正題前,先做個題目:下面的組件能按照期望工作嗎(每隔一秒數字增加 1)?Why?在 React 渲染流程的 commit 階段會遍歷這些 updateQueue.lastEffect 下的所有 Effect 進行處理(詳見 React 源碼解讀之一首次渲染流程):...
  • React 測試入門教程
    $ git clone https://github.com/ruanyf/react-testing-demo.git$ cd react-testing-demo然後,打開 http://127.0.0.1:8080/,你會看到一個 Todo 應用。
  • React源碼分析與實現(一):組件的初始化與渲染
    React源碼分析與實現(一):組件的初始化與渲染原文連結地址:https://github.com/Nealyang 前言戰戰兢兢寫下開篇…也感謝小蘑菇大神以及網上各路大神的博客資料參考~閱讀源碼的方式有很多種,廣度優先法、調用棧調試法等等,此系列文章,採用基線法,顧名思義,就是以低版本為基線,逐漸了解源碼的演進過程和思路。
  • React 源碼分析(1):調用ReactDOM.render後發生了什麼
    本系列文章將分析 React 15-stable的部分源碼, 包括組件初始渲染的過程、組件更新的過程等. 這篇文章先介紹組件初始渲染的過程的幾個重要概念, 包括大致過程、創建元素、實例化組件、事務、批量更新策略等.
  • 快速在你的vue/react應用中實現ssr(服務端渲染)
    >使用node+vue-server-renderer實現vue項目的服務端渲染使用node+React renderToStaticMarkup實現react項目的服務端渲染傳統網站通過模板引擎來實現ssr(比如ejs, jade, pug等)
  • React源碼解析,實現一個React
    本文希望通過參考 React 源碼,依葫蘆畫瓢地完成React的雛形。來幫助理解其內部的實現原理,知其然更要知其所以然。_reactInternalInstance.updateComponent(null, newState) 這個函數。而 this._reactInternalInstance指向CompositeComponent,困此更新邏輯交回CompositeComponent.updateComponent()來完成。
  • 【React源碼筆記】setState原理解析
    帶著這麼多的疑問,因為剛來需求也不多,對setState這一塊比較好奇,那我就默默clone了react源碼。今天從這四個有趣的問題入手,用setState跟大家深入探討state的更新機制,一睹setState的芳容。源碼地址入口(本次探討是基於React 16.7.0版本,React 16.8後加入了Hook)。
  • 「源碼解析 」這一次徹底弄懂react-router路由原理
    寫在前面:為什麼要學習react-router底層源碼? 為什麼要弄明白整個路由流程?
  • 程式設計師是如何閱讀源碼的
    React 源碼目錄解構一般這時候會開始在網上搜文章,如何調試 React 源碼。但是這種大型項目的構建流程較為複雜,如果只是想簡單了解源碼,不需要去了解這些複雜的東西。這裡教大家一個簡單的方案,直接到 CDN 上下載官方編譯好了的開發版源碼(https://cdn.jsdelivr.net/npm/react@17.0.1/umd/react.development.js),中間的版本號可以替換成任何想看的版本。
  • React Status 中文周刊 #26 - Aleph:基於 Deno 的 React 框架
    原文連結:https://css-tricks.com/3-approaches-to-integrate-react-with-custom-elements/原文連結:https://www.netlify.com/blog/2020/12/05/building-a-custom-react-media-query-hook-for-more-responsive-apps
  • 【前端技術】react渲染 - 流程概述
    實際上jsx 是來源於一個前端框架 react。在react中除了我們了解的jsx,那麼jsx在react的渲染過程是哪個環節生效,以及渲染過程經歷了哪些步驟。本文會基於這些點進行概述。1.本文的react.render樹狀圖.xmind,此為作者查看/調試react的渲染源碼時做的結構筆記。
  • React系列六 - 父子組件通信
    那麼這個組件就會變成非常的臃腫和難以維護;所以組件化的核心思想應該是對組件進行拆分,拆分成一個個小的組件;再將這些組件組合嵌套在一起,最終形成我們的應用程式;我們來分析一下下面代碼的嵌套邏輯:import React, { Component } from 'react
  • 我一定把 React-Router 的真諦給你整地明明白白的
    BrowserRouter源碼我們代碼裡面最外層的就是BrowserRouter,我們先去看看他的源碼幹了啥,地址傳送門:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.js
  • React 16.8 之 React Hook
    有了 react hook 就不用寫class(類)了,組件都可以用function來寫,不用再寫生命周期鉤子,最關鍵的,不用再面對this了!State Hook 解決存儲持久化的方案我所知道的可以通過js存儲持久化狀態的方法有: class類、全局變量、DOM、閉包通過學習react hook源碼解析了解到是用閉包實現的~function useState(initialState
  • react源碼分析之-setState是異步還是同步?
    接下來我們從源碼進行分析。源碼分析1、setState入口函數//ReactComponent.jsReactComponent.prototype.setState = function (partialState, callback) { !
  • React Hooks 原理與最佳實踐
    Custom Hooks對於 react 來說,在函數組件中使用 state 固然有一些價值,但最有價值的還是可以編寫通用 custom hooks 的能力。想像一下,一個單純不依賴 UI 的業務邏輯 hook,我們開箱即用。不僅可以在不同的項目中復用,甚至還可以跨平臺使用,react、react native、react vr 等等。