從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