干货:助你理解 React 工作的机制——实现一个简单的自定义渲染器

1,326 阅读11分钟

如想看demo,请直接跳到最后

React 的定义是什么? - 用于构建用户界面的 Javascript 库。

可以看到 React 并没有限定,用于构建什么的用户界面,而我们在实际的使用中:使用 React 来编写 H5、PC 的 web 页面、使用 React Native 来创建移动端的原生应用、使用 Ray 来编写智能小程序的应用。

image.png

state 加上 JSX 生成了虚拟 dom,可以理解为:这个虚拟 dom 是 React 用来表示 UI 的一套数据结构,render 则决定了该虚拟 dom 最终如何渲染在目标环境中。所以,从理论上来说,我们可以使用 Javascript 加上 React 编写运行于任意平台上的界面。

今天我想要介绍的内容主题就是:自定义一个 React render,能够直接使用 React 编写运行于 canvas 中的 UI。

React 的虚拟 dom 含义,React 对虚拟 dom 有哪些操作?

要实现一个自定义 render,首先需要对 Fiber 有一个了解。本段将介绍大致介绍 React 从更新到渲染出界面要经历哪些步骤、以及 React 的虚拟 dom —— Fiber 的定义,然后以 react-dom 为例子,介绍在 React 更新的过程中将要对 Fiber 做出哪些操作,使之最终可以渲染到浏览器的屏幕上。

以下讲解以 React 18 版本为例子

import ReactDom from 'react-dom'
import App from './App'

function App() {
  return (
    <div>
      <ul>
        <li><span>1</span></li>
        <li>2</li>
    	</ul>
    </div>
  )
}
const root = ReactDOM.render(
  <App />,
	document.getElementById('root')
);

image.png Fiber 节点上有三个属性用来描述虚拟 dom 的关系:

  • child 指向当前 Fiber 节点的第一个子节点
  • sibling 指向当前 Fiber 节点的兄弟节点
  • reutrn 指向当前 Fiber 节点的父节点

React 的组件在初次渲染的时候,会生成 Fiber,这个阶段我们先称之为 reconciler。如上图所示,React组件更新是一个深度优先搜索的过程,从组件 App(这是一个 FunctionComponnet,react 在更新的过程中,对于不同类型的组件处理方式是不同的) 开始,创建 App 的 Fiber 然后继续对 App 的子组件进行类似操作,一直到文本节点 span(HostComponent)。

当前操作的组件的子组件为 null 后,react 会对当前节点执行 completeWork的工作

completeWork 第一步

首先,对 span 这个节点执行 completeWork 操作,其类型为 HostComponent,因为该组件是初次渲染,还未生成 dom 实例,所以调用了 createInstance方法生成 dom 实例,这里的 createInsrance则是 react-dom render 实现的,生成目标平台实例的方法。

有没有注意到 span 标签中的文本 1 并没有生成对应的 Fiber 节点,这是 react-dom render 做的一个优化,它认为对于这种字符串类型的子节点不需要生成 Fiber,直接使用 domInstance.textContext直接设置就可以了。在 react 的组件更新阶段这处的判断:

image.png react-dom render 中对应的方法的实现:

export function shouldSetTextContent(type: string, props: Props): boolean {
  return (
    type === 'textarea' ||
    type === 'noscript' ||
    typeof props.children === 'string' ||
    typeof props.children === 'number' ||
    (typeof props.dangerouslySetInnerHTML === 'object' &&
      props.dangerouslySetInnerHTML !== null &&
      props.dangerouslySetInnerHTML.__html != null)
  );
}

然后在 completeWork 阶段调用了 finalizeInitialChildren方法。该方法也是 react-dom render 实现的,在这个方法的实现中给当前 dom 节点设置了 textContent。

completeWork 第二步

到了这里已经完成了更新阶段对于 span节点的处理,接下来 React 会处理 completeWork.return也就是其父节点 li。

该节点也是一个 HostComponent,同样的初次渲染会调用 createInstance以生成对应的 dom 实例,接下来,对于初次渲染的 HostComponent,React 还会做一次 appendAllChildren的操作,其目的是将当前 HostComponent 节点的最近的子 HostComponent 节点(注意,这里可能是子节点,可能也是孙子节点)的 dom 实例插入到当前 dom 节点中,这里调用了 react-dom render 的 appendInitialChild方法:

export function appendInitialChild(
  parentInstance: Instance,
  child: Instance | TextInstance,
): void {
  parentInstance.appendChild(child);
}

到这里也完成了更新阶段对于该 Fiber 节点的处理。

接下来的这一步跟刚才有所不同,因为当前节点是存在一个兄弟节点的,而此时这个兄弟节点其实是还没有经过 reconciler 的,react 会对当前节点兄弟节点进行 reconciler 以生成 Fiber 节点,还是深度优先搜索,到了末尾后又执行 completeWork。循环这个步骤,直到回到最顶层的 root 节点。

整个过程前后步骤如图所示(注意:此图结构跟一开始代码中的不一致,懒得重新画图了,将就一下):

image.png

下一步就是到了 React 的 commit 阶段了。

commit 阶段

到了这个阶段,react 的 Fiber 树已经构建成功。对于初次渲染的 HostComponent 类型的节点,也已经生成了对应的 dom 实例,但是此时的 dom 树还没有真正渲染到屏幕上,react 需要将它插入到 container 容器中,对应调用的方法是:appendChildToContainer这个方法同样有 react-dom render 实现

刚刚所讲的流程只是 React 初次渲染的逻辑,当发起了一次更新其中有些节点或许会被删除,此时删除 dom 节点的操作实在 commit 阶段进行的,相应调用的方法是:removeChild

对于 React 渲染阶段的讲解到此告一段落,只是讲了一个大体的过程,实际上 react-dom render 还需要实现很多的操作,比如:对于事件系统的支持、React 的调度需要用到的相关方法的支持 等...

总结一下:React 定义了虚拟 dom,以及更新虚拟 dom 的机制,且 React 将这部分逻辑和 render 的逻辑解耦了,且提供了相关的 API 给开发者。我们只需要实现 React 更新工作中各个不同时机在目标平台要执行的操作,就能实现将 React 所构建的 UI 运行于目标平台中。

react-reconciler

这就是 React 提供的用于创建自定义渲染器的包:

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

const HostConfig = {
  // You'll need to implement some methods here.
  // See below for more information and examples.
  supportsMutation: true, // 突变模式,一般情况下无脑选这个就行了
};

const MyRenderer = Reconciler(HostConfig);

const RendererPublicAPI = {
  render(element, container, callback) {
    // Call MyRenderer.updateContainer() to schedule changes on the roots.
    // See ReactDOM, React Native, or React ART for practical examples.
  }
};

module.exports = RendererPublicAPI;

实现一个 canvas render

本节将会介绍如何实现一个 canvas render,使其能够在 canvas 渲染出 React 定义的 UI。为了简化 canvas 相关的操作,直接借助 fabric.js 去操作 canvas。

yarn add fabric

别担心,虽然 fabric.js 用法很丰富,但在本 demo 中我们只需要掌握最简单的例子就行了:

var canvas = new fabric.Canvas("canvas",{backgroundColor:"grey"});
			var rect = new fabric.Rect({ // 创建矩形
	      left:100,
	      top:100,
	      fill:"red",
	      width:20,
	      height:20,
	    });
	    canvas.add(rect);//在画布对象上添加这个矩形

	    var rect1 = new fabric.Rect({
	      left:98,
	      top:98,
	      fill:"blue",
	      width:20,
	      height:20,
	    });
	    canvas.add(rect1);//在画布对象上添加这个矩形


	    setTimeout(() => {
	    	rect1.set({
		    	fill:"green",
		    })
		    canvas.renderAll() // 重新渲染 canvas
	    	canvas.remove(rect); // 删除对象
	    }, 2000)

实现 render 方法

建议使用 create-react-app 快速创建一个 React 应用

首先创建一个 canvas-render 的文件夹,新建一个文件,内容如下:

// canvas-render/index.ts
import ReactReconciler from "react-reconciler";

const HostConfig = {
  
};
const ReactReconcilerInstance = ReactReconciler(hostConfig);
const canvasRender = {
  render(){
    
  }
}
export default canvasRender

React 程序的入口处:

// index.tsx
import App from './App.tsx';

const canvasDom = document.getElementById('canvas-root') as HTMLCanvasElement;
canvasDom.width = document.documentElement.clientWidth;
canvasDom.height = document.documentElement.clientHeight;

CanvasRender.render(<App />, canvasDom)
// App.tsx
import React, { useEffect } from 'react';
import logo from './logo.svg';
import './App.css';
import { Circular } from './canvas-render/components'

function App() {
  const [uiInfo, setUiInfo] = React.useState({
    text: '开始',
    fillColor: '#ff6700',
    textColor: '#fff',
  })
  useEffect(() => {
    setTimeout(() => {
      setUiInfo({
        text: '改变文字',
        fillColor: '#999922',
        textColor: '#fff',
      })
    }, 2000)
  }, [])
  return (
    <>
      <Circular textColor={uiInfo.textColor} x={100} y={100} r={50} fillColor={uiInfo.fillColor}>{uiInfo.text}</Circular>
      <Child1 />
    </>
  )
}
function Child1() {

  const [color, setColor] = React.useState('#ff2300')
  const [place, setPlace] = React.useState({y: 50})
  const [state, setState] = React.useState([1, 2, 3, 4, 5]);

  React.useEffect(() => {
    setTimeout(() => {
      setColor('blue');
      setPlace({
        y: 200,
      })
      setState([5, 2, 1, 3])
    }, 3000);
  }, [])

  return (
    <>
      {
        state.map((item, idx) => <Circular textSize={12} textColor='#fff' fillColor={color} key={item} y={place.y} x={30 * (idx + 1)}>{String(item)}</Circular>)
      }
    </>
  );
}

export default App;

接下来我们来完善这个 render 方法,总的来说,这个 render 方法里面要完成两件事情:

  1. 要生成 React 的 FiberRoot 以及 rootFiber
  2. 开始更新调度
// canvas-render/index.ts
import ReactReconciler from "react-reconciler";
import { fabric } from  'fabric';

const HostConfig = {
  
};
const ReactReconcilerInstance = ReactReconciler(hostConfig);
const canvasRender = {
  render(reactElement: any, canvasElement: HTMLCanvasElement) {
    const ctx: fabric.Canvas = new fabric.Canvas(canvasElement, { 
      backgroundColor: 'gray',
      centeredRotation: true,
      centeredScaling: true,
    })
    // @ts-ignore
    ctx._dom = canvasElement;
    let root;
    // @ts-ignore
    if (!ctx._rootContainer) {
      root = ReactReconcilerInstance.createContainer(
        ctx,
        0,
        null,
        false,
        false,
        '',
        () => {},
        null,
      );
      // @ts-ignore
      ctx._rootContainer = root;
    }
    // 开始调度更新
    return ReactReconcilerInstance.updateContainer(reactElement, root);
  },
}
export default canvasRender

实现 createInstance

此时将程序运行起来,会看到以下几个报错:

image.png

image.png

先无脑在 hostConfig 里边将这些方法补上(想要知道每个方法干啥用的可以先看一下 react-reconciler 的 README):

import { fabric } from  'fabric';

export type Container = fabric.Canvas;

type Circle = fabric.Circle & { _todoInsertChildText?: fabric.Text }

type Instance = Circle

let currentContainer: Container;

let currentContainer;
const HostConfig = {
  getRootHostContext(rootContainer: Container) {
    // 使用一个变量保存一下 container,会用到
    // todo 这里用变量保存 rootContainer 不太好,可以探究更好的做法
    // 但是目前没看到 react-reconciler 这个包有提供获取当前正在更新中的 root 的方法
    currentContainer = rootContainer;
    return null;
  },
  prepareForCommit(containerInfo: Container) {
    return null;
  },
  getChildHostContext(parentHostContext: any) {
    return parentHostContext;
  },
  clearContainer() {

  },
  shouldSetTextContent() {
    // 这里代表需要甚至文本节点,返回 true,如果还记得上文介绍,这里代表了针对 string 类型
    // 不会生成 Fiber 节点
    return true;
  },
  resetAfterCommit(containerInfo: Container) {
    // 在 commit 阶段后执行一次 renderAll 更新 canvas
  	containerInfo.renderAll();
  },
};

设置好以上方法后,接下来会报错:

image.png

这是一个我们要实现的比较关键的方法了,回想上文的内容,在 react-dom 中我们需要在 createInstance中生成 dom 实例。现在我们则需要创建一个 fabric 的实例对象

import CircularInstance from './CircularInstance';


const HostConfig = {
  // ...
  createInstance(type: any, newProps: Record<string, any>, containerInfo: Container) {
   	let instance;
    switch(type) {
      case 'circular':
        instance = CircularInstance(newProps as any, containerInfo);
        break;
      default:
        instance = null;  
    }
    // 这里返回的 instance 会被放置到 Fiber.stateNode 属性上
    return instance
  },
};
// CircularInstance.ts
import type { Container } from '../../hostconfig';
import { fabric } from 'fabric';

export interface CircularProps {
  x: number;
  y: number;
  r?: number; // 半径
  fillColor?: string;
  children?: string;
  textColor?: string;
  textSize?: number;
}

const defaultValue = {
  x: 0,
  y: 0,
  r: 10,
  fillColor: '#000000',
}

export default function Circular(props: CircularProps, container: Container) {
  const {
    x = defaultValue.x,
    y = defaultValue.y,
    r = defaultValue.r,
    fillColor = defaultValue.fillColor,
  } = props;
  const instance = new fabric.Circle({
    radius: r,
    fill: fillColor,
    left: x,
    top: y,
    originX: 'center',
    originY: 'center',
    selectable: false,
  })
  return instance;
}

// 更新
export function updateCircular(instance: fabric.Circle, props: CircularProps) {
  const {
    x = defaultValue.x,
    y = defaultValue.y,
    r = defaultValue.r,
    fillColor = defaultValue.fillColor,
  } = props;
  instance.set({
    radius: r,
    fill: fillColor,
    left: x,
    top: y,
    selectable: false,
  })
}

实现 finalizeInitialChildren

接下来需要实现,设置文本的操作。

在刚才的实现中,我们设置了 shouldSetTextContent方法返回 true,这代表了对于 string 类型的节点,React 是不会生成 Fiber 节点的,所以我们需要 HostComponent 在 completeWork 时就把其 string 类型的 children 给处理掉,相对应的是需要实现 finalizeInitialChildren 方法。

import TextInstance, { updateText } from './components/Text/text';


const HostConfig = {
  // ...
  finalizeInitialChildren(instance: any, type: any, props: any, rootContainer: Container, hostContext: any) {
    // 在这里设置文本
    if (props.children && typeof  props.children === 'string') {
      instance._todoInsertChildText = TextInstance(props.children, {
        parentLeft: props.x,
        parentTop: props.y,
        fontSize: props.textSize || 12,
        textColor: props.textColor
      });
    }
    return false
  },
};
// text.ts
import { fabric } from 'fabric'

type textProps = {
  parentTop: number;
  parentLeft: number;
  fontSize: number;
  textColor?: string
}

export default function TextInstance(text: string, props: textProps) {
  const { parentTop, parentLeft, fontSize = 12, textColor } = props;
  const instance = new fabric.Text(text, {
    top: parentTop,
    left: parentLeft,
    fontSize,
    textAlign: 'center',
    originX: 'center',
    originY: 'center',
    fill: textColor || '#000',
    selectable: false,
  });
  return instance;
}

export function updateText(instance: fabric.Text, props: textProps, text: string) {
  const { parentTop, parentLeft, fontSize = 12, textColor } = props;
  instance.set({
    top: parentTop,
    left: parentLeft,
    fontSize,
    fill: textColor,
    selectable: false,
  })
  instance.text = text;
}

这里我们先把他保存到 instance 的 _todoInsertChildText属性上,等到 commit 阶段再去处理,因为我们所使用的 fabric 是没有给对象添加子节点这种操作的。

实现 commit 阶段需要调用的方法

接下来,我们就需要完成 commit 阶段需要调用的方法,这里我直接贴上代码,在注释中说明为何要这样实现。

function appendAllChildText(container: Container, instance: any) {
  // 如果当前节点有文本节点也要添加到 canvas 中
  if (instance._todoInsertChildText) {
    container.add(instance._todoInsertChildText)
  }
}

function updateAllChildText(instance: any, nextProps: any) {
  // 如果当前节点有文本节点也要进行更新
  if (instance._todoInsertChildText) {
    updateText(instance._todoInsertChildText, {
      parentLeft: nextProps.x,
      parentTop: nextProps.y,
      fontSize: nextProps.textSize || 12,
      textColor: nextProps.textColor || "#000",
    }, nextProps.children)
  }
}

function removeAllChildText(container: Container, instance: Instance) {
  // 如果当前节点有文本节点也要进行删除
  if (instance._todoInsertChildText) {
    container.remove(instance._todoInsertChildText)
  }
}

const HostConfig = {
  /**
   * 从 Container 中删除一个节点 该方法执行在 commit 阶段
   * 当组件被卸载的时候 需要调用此方法删除 container 中的对应 fabric 实例
   */
  removeChildFromContainer(container: Container, child: Instance) {
    removeAllChildText(container, child)
    container.remove(child)
  },

  /**
   * 添加一个节点到 Container 中,该方法执行在 commit 阶段
   * 组件初次渲染的时候,需要调用此方法将 fabric 实例添加到 container 中
   */
  appendChildToContainer(container: Container, child: any) {
    container.add(child);
    appendAllChildText(container, child);
  },

  appendInitialChild(parentInstance: any, child: any) {
    // 这里暂时用不上,因为目前我们所有节点的父节点都是 Container
    // 所以目前只用实现 appendChildToContainer 就可以了
  },

  prepareUpdate(instance: any, type: any, oldProps: any, newProps: any) {
    return newProps
  },

  /**
   * 更新节点时需要用到 在 commit 阶段执行
   * 当前组件需要更新的时候,需要调用此方法更新 container 中的 fabric 实例
   */
  commitUpdate(instance: any, updatePayload: any, type: any, prevProps: any, nextProps: any, internalHandle: any) {
    // 更新节点
    updateCircular(instance, nextProps)
    // 更新文本节点
    updateAllChildText(instance, nextProps);
  },

  createTextInstance(text: any, rootContainer: Container, hostContext: any, internalHandle: any) { 
    // 因为 shouldSetTextContent 返回 true React 不会生成 HostText 类型的 Fiber
    // 所以这里用不上 不用实现
  },

  detachDeletedInstance() {
    // 不实现此方法会报错 todo 看源码搞清这个方法作用
    // 先写这儿
  }
}

至此,我们的代码应该就能够顺利跑起来了。

yulan.gif

最后

至此,我们就已经实现了一个超简单的 canvas render。其实还有一个重要的功能没有完成,那就是支持事件系统。。。额,支持事件系统就放到下一次再去做吧,届时在水一篇文章。

当然最重要的 demo 地址也是有的:

github.com/Chechengyi/…

写此实践的初衷是为了自己能够更好了理解 React 工作的机制,至此我的初衷已经完成了,希望此文能对别人也有所帮助。

可能文中的一些点,我的理解也是错的,那请各位直接给我指出。老规矩,如果这篇文章对你有用的话,请不要吝啬您的:赞、赞美、start、等。。。