React:实现一个自定义的渲染器
实现一个自定义的 react-dom renderer.
Published by xiaoliublog@gmail.com at 09/12/2021.

一、目标

实现如下组件的渲染

import React, { useState } from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  const [showLogo, setShowLogo] = useState(true);
  const [color, setColor] = useState('red');

  return (
    <div className="App">
      <header className="App-header">
        {showLogo && <img src={logo} className="App-logo" alt="logo" />}
        <button onclick={() => setShowLogo((show) => !show)}>{`${
          showLogo ? 'Hide' : 'Show'
        } Logo`}</button>
        <p
          textColor={color}
          onmouseenter={() => setColor('green')}
          onmouseout={() => setColor('red')}
        >
          Edit <code>src/App.js</code> and save to reload. 1
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

你可以在这里测试 Open in stackblitz 或者新建一个CRA项目和下面的教程一起做,这样你会知道每一步发生了什么。

二、原理及学习资源

学习资源

  1. ReactConf 2019
  2. react-reconciler Readme

原理

网上太多文章了,这里就只简单说一下。

当我们新建了一个CRA项目,你会发现项目会安装如下两个包 reactreact-dom。 react 这个包并不包含与 dom 操作相关的代码,只负责上层的数据与vdom, 而 vdom 具体是怎么在浏览器或者其他平台渲染的,是由 react-domreact-native 来完成的。

那么reactreact-dom 是如何“交流”的呢? 这里就用到了 react-reconciler,可以把它看作一个“接口”, react-dom/react-native 都是通过实现react-reconciler中的方法来将vdom中的节点渲染到对应平台中的。

三、实现

替换 react-dom

在 CAR 创建的项目入口文件中,我们可以发现入口文件中总会调用这样一段代码将我们的应用渲染到网页上。

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

我们要做的便是实现我们自己的一个 render 方法。 我们创建一个文件CustomDOM.js 观察上面的react-dom 的方法,需要一个render方法,这个方法有两个参数:

  • 第一个参数是我们要渲染的应用<App />
  • 第二个是挂载的dom节点
// src/CustomDOM.js

const CustomDOM = {
  render(element, rootContainer) {
  },
};

export default CustomDOM;

然后在入口文件中替换 react-dom

// src/index.js

import React from 'react';
// import ReactDOM from 'react-dom';
import CustomDOM from './CustomDOM';
import './index.css';
import App from './App';

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

实现基本的方法

在替换了render方法后,此时我们的应用是一片空白的,因为我们没有实现其中的方法。

根据react-reconciler Readme 我们可以写成如下的一段样板代码。

import ReactReconciler from 'react-reconciler';

// 我们后面的要做的工作都在这个 HostConfig 上
// 我们需要实现其中的方法,来将 vdom 渲染到对应的平台上。我们这里实现的是最简单的一个,在浏览器上。
const HostConfig = {};

const CustomReconciler = ReactReconciler(HostConfig);

const CustomDOM = {
  render(element, rootContainer) {
    const container = CustomReconciler.createContainer(
      rootContainer,
      false,
      false
    );
    CustomReconciler.updateContainer(element, container, null);
  },
};

export default CustomDOM;

此时,我们的应用不是空白的了,它会报错,但这是正常的,因为我们的HostConfig还是空的呢。

根据报错,我们实现其中的一些让应用运行起来所必须的方法。

// 这里的配置可以让我们的应用不再报错,
// 并且可以渲染出一些基本的节点,如文字、按钮等,但是没有样式,链接,图片等等属性
const HostConfig = {
  // 支持两种模式,Mutation or Persistence
  // Mutation:当你要支持的平台类似 DOM 有类似 appendChild removeChild 的方法时,说明可以修改渲染树的节点,则使用这种方式。
  // Persistence:当你要支持的平台不支持修改时,则使用这种模式。新的 react-native 使用的就是这种模式。
  supportsMutation: true,
  // supportsPersistence: true,

  // getRootHostContext getChildHostContext 可以返回一个自定义对象,这个对象可以在 createInstance 函数中获取到。
  // 常见的使用场景,如让 createInstance 函数知道自己处于 html 还是 svg 中。
  // 运行机制:
  // <div> 
  //    <p> <svg> <g /> </svg> </p>
  // </div>
  //             
  // div#root   getRootHostContext() => {ctx: 'html'}
  //    ↓                                           \\
  //    p       getChildHostContext() => 无变化,返回 parentHostContext {ctx: 'html'} 
  //    ↓
  //   svg      getChildHostContext() => type 为 svg,我们这里返回 {ctx: 'svg'} 
  //    ↓       createInstance 方法的第四个参数 hostContext 为上一步 getChildHostContext 方法返回的结果 {ctx: 'svg'} 
  //    g       
  getRootHostContext(rootContainer) {
    return null;
  },
  getChildHostContext(parentHostContext, type, rootContainer) {
    return parentHostContext;
  },

  // 是否可以直接设置节点的文字,dom 里面可以直接修改 textContent
  // 返回 true, 需要实现 resetTextContent 方法,一般用于性能优化
  // 返回 false,不需要额外操作
  // 我们这里方便起见,只返回 false
  shouldSetTextContent() {
    return false;
  },
  prepareForCommit() {
    return null;
  },
  clearContainer(container) {},
  resetAfterCommit() {},

  // 创建一个dom节点
  createInstance(type, props, rootContainer, hostContext, internalHandle) {
    const el = document.createElement(type);
    return el;
  },
  // 创建一个文字节点
  createTextInstance(text) {
    return document.createTextNode(text);
  },

  finalizeInitialChildren() {},


  // 将上面创建的节点添加到dom树中
  appendInitialChild(parent, child) {
    parent.appendChild(child);
  },
  appendChildToContainer(container, child) {
    container.appendChild(child);
  },
  appendChild(parent, child) {
    parent.appendChild(child);
  },
  appendAllChildren(parent, ...childs) {},
}

此时页面有内容,但是没有样式,因为createInstance方法里面我们为创建的节点添加属性。

img-alt

这里简单起见,我们只添加我们的代码中用到的属性。修改createInstance实现如下:

  // ...
  createInstance(type, props, rootContainer, hostContext, internalHandle) {
    const el = document.createElement(type);
    ['className', 'href', 'rel', 'src'].forEach((k) => {
      if (props[k]) el[k] = props[k];
    });
    ['click', 'mouseenter', 'mouseout'].forEach((eventName) => {
      if (props[`on${eventName}`]) {
        el.addEventListener(eventName, props[`on${eventName}`]);
      }
    });
    // 这个属性是我们添加的一个自定义属性, 这里我们需要处理一下,让它正确的被处理
    // <p textColor={color} ... />
    // 按照这里的思路,你可以添加更多自定义的属性,然后手动处理逻辑
    if (props.textColor) {
      el.style.color = props.textColor;
    }
    return el;
  },
  // ...

OK, 到这里,我们的CustomDOM就可以正确的渲染出我们的页面了。

但是当我们点击 “Hide Logo”,试图隐藏图标的时候,给我们报错 removeChildFromContainer is not a function 观察我们上面实现的HostConfig,只实现了create*``append*相关的方法,我们再补充一下其他的方法

const HostConfig = {
  //...

  // 将节点从dom树移除
  removeChild(parent, child) {
    parent.removeChild(child);
  },
  removeChildFromContainer(container, child) {
    container.removeChild(child);
  },

  // 在dom树中插入节点
  insertInContainerBefore(container, child, beforeChild) {
    container.insertBefore(child, beforeChild);
  },
  insertBefore(parent, child, beforeChild) {
    parent.insertBefore(child, beforeChild);
  },
  // 你可以在这个方法中比较旧的props和新的props,准备好下一次更新时所需要的数据,可以让commitUpdate更快的执行(更新dom)
  // ⚠️注意:不要在这个方法中去修改dom节点,这个函数的返回值会传给`commitUpdate`,
  //        请在 `commitUpdate` 方法中去做这些更改
  prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainer,
    hostContext
  ) {},
  // updatePayload 是 prepareUpdate() 中的返回值。
  commitUpdate(
    instance,
    updatePayload,
    type,
    prevProps,
    nextProps,
    internalHandle
  ) {},
  // 文字节点的更新方法
  commitTextUpdate(instance, oldText, newText) {},
}

至此,基本的renderer就完成了。

实现节点的更改

经过上面的实现后我们终于可以点击按钮隐藏我们的Logo了。但是我们发现按钮的文字没有改变。

更新节点主要通过两个方法 commitUpdate commitTextUpdate。 我们这里要更改文字节点只需要实现commitTextUpdate这个方法。

  // 有同学可能会问,这里怎么不需要 prepareUpdate 来做diff呢?
  // text 节点只需要对比新旧字符串是否相同,开销较小。
  commitTextUpdate(instance, oldText, newText) {
    if (oldText !== newText) {
      instance.textContent = newText;
    }
  }

commitUpdateprepareUpdate 方法,我们放在下面的自定义属性里面来讲。

实现自定义属性

既然要做一个自定义的渲染器,那怎么能不加一点自定义的东西呢?有的同学可能已经注意到了开头的代码中的 textColor,这个并不是原生的属性,是我们自己加上去的, 想让它生效,自然是需要特殊处理的。总共有两个阶段我们需要处理,

  1. 创建阶段
  2. 更新阶段

首先是在创建节点时,我们需要去应用我们自定义的属性。

  // ...
  createInstance(type, props, rootContainer, hostContext, internalHandle) {
    const el = document.createElement(type);
    // ...
    // 这个属性是我们添加的一个自定义属性, 这里我们需要处理一下,让它正确的被处理
    // <p textColor={color} ... />
    // 按照这里的思路,你可以添加更多自定义的属性,然后手动处理逻辑
    if (props.textColor) {
      el.style.color = props.textColor;
    }
    return el;
  },
  // ...

此时,文字初始的颜色就是红色了,但是鼠标移上去颜色并不会变。

其次就是更新节点时

  // ...
  prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainer,
    hostContext
  ) {
    // 在这里做 diff,并将需要更新的属性返回,交给 commitUpdate来处理。 
    let updatePayload = {};
    if (newProps.textColor !== oldProps.textColor) {
      updatePayload['textColor'] = newProps.textColor
    }
    return updatePayload;
  },
  commitUpdate(
    instance,
    updatePayload,
    type,
    prevProps,
    nextProps,
    internalHandle
  ) {
    // 这里根据 prepareUpdate 处理的结果,更新节点。
    if (updatePayload) {
      if (updatePayload.textColor) {
        instance.style.color = updatePayload.textColor;
      }
    }
  },
  // ...

这时候,鼠标移上去就可以变为绿色,移出时又会变为红色,符合我们代码的预期。

至此,我们的自定义 React Renderer 就完成了。

img-alt

总结

本文主要介绍了

  1. react-reconciler 的作用 -- 作为和react和宿主通信的桥梁,将vdom渲染为真实的UI节点。
  2. react-reconciler 的 HostConfig 中主要的方法
  3. 需要注意的是,react-reconciler 的 api 并不稳定,在你写的时候需要去看官方的readme,甚至去看源码。但是基本的原理应该变化不大。

⚠️本文只是起到了入门和介绍的作用,本帖实现的自定义 renderer 也有很多需要完善的地方,不可用在生产环境。

如果你发现了本文中的错误,欢迎批评指正,以免误导他人~

豫ICP备17010879号