Mini React 之渲染器

3,258 阅读5分钟

在上一篇文章中,我们实现了如何将jsx编译成React.createElement(...),执行后得到虚拟 DOM。这一章我们循序渐进的实现一个基础的渲染器。简单说渲染器就是将 虚拟 DOM 渲染成特定平台下真实 DOM 的工具(本质是一个函数,通常叫 render)。渲染器的工作流程分为两个阶段:mountrerender,第一次渲染,由于没有旧的 VNode,直接将新的 VNode 创建成真实的 DOM挂载到指定的容器内,这个过程叫做 mount; 下一次再渲染的时候会生成新的 VNode,则会使用新的 VNode 与上一次生成的旧 VNode 进行对比,以最少的性能开销完成对真实 DOM 的更新,这个过程就叫 “打补丁”,也就是diff的部分。


渲染器是React最核心最终要的功能,因为它除了将虚拟 DOM渲染成真实DOM只外,还承担着以下重要角色:

  • 控制组件生命周期钩子的调用: 在整个渲染周期中包含了大量的 DOM 操作、组件的挂载、卸载,控制着组件的生命周期钩子调用的时机。
  • 包含最核心的 Diff 算法: Diff 算法是渲染器的核心特性之一,正是由于 Diff 算法的存在才使得 Virtual DOM 如此成功。
  • 与特定的渲染有直接关系: React的RefContextMemoPortal等组件的挂载是跟渲染器有密不可分的关系,他们需要要在适当的时机处理,一些需要在真实DOM存在之后才能执行的操作(如 ref)也应该在这个时候进行的。
  • 自定义渲染器: 自定义渲染器的本质就是把特定平台操作“DOM”的方法从核心算法中抽离,并提供可配置的方案。

2. 实现基础渲染器

2.1 渲染标签和文本

通常渲染器接收两个参数,第一个参数是将要被渲染的 VNode 对象,第二个参数是一个用来承载内容的容器(container),通常叫挂载点。接下来我们新建一个文件src/runtime-dom.js,主要负责将虚拟dom渲染成真实dom。如下代码所示:

src/runtime-dom.js

import { REACT_TEXT } from "./element";

function render(vdom, container) {
  mount(vdom, container);
}
function mount(vdom, container) {
  let newDOM = createDOM(vdom);
  container.appendChild(newDOM);
}

function createDOM(vdom) {
  let dom;//真实DOM元素
  let { type } = vdom;
  if (type === REACT_TEXT) {
    dom = document.createTextNode(props);//props是个字符串,不是一个DOM节点
  } else {
    dom = document.createElement(type);
  }
  return dom;
}

const ReactDOM = {
  render
}
export default ReactDOM;

上面的代码实现了2个函数,render函数就是我们的渲染器(渲染器后续还要实现异步渲染逻辑),然后render里面调了mount函数里面调createDOM方法将vnode生成成真实dom,得到真实dom追加到container容器之中,最后将render函数导出。

createDOM函数,函数里面实现了标签节点和文本节点的创建。声明一个dom变量用于存放真实dom,取vdom的类型(type),判断如果是REACT_TEXT说明是文本节点,创建文本节点并赋值,否则vnode是HTML标签,根据type创建对应标签即可。

现在我们验证一下这个渲染器,在mian.js里面导入ReactDom,调用render函数渲染之前的jsx,看看渲染结果:

mian.js

import React from "./src/index";
import ReactDOM from "./src/react-dom"

const jsxELe = (
  <h1 className="title" style={{ color: "red" }}>
    h1的文本内容
    <div onClick={() => alert("hi~")}>
      <p>p的文本内容</p>
    </div>
  </h1>
);

ReactDOM.render(jsxELe, document.getElementById('root'))

得到下面的结果:

render1

我们看到成功render了,但是只渲染了一个h1标签,子节点并没有渲染出来。

2.2 渲染属性和子节点

上面只渲染了最外层标签和文本,还未对节点属性和子节点进行处理,下面我们来改造一下createDOM这个函数,增加相关处理逻辑:

function createDOM(vdom) {
  let { type, props } = vdom;
  let dom;//真实DOM元素
  if (type === REACT_TEXT) {
    dom = document.createTextNode(props);//props是个字符串,不是一个DOM节点
  } else {
    dom = document.createElement(type);
  }
  // 如果存在属性,则需要添加属性
  if (props) {
    //更新属性 DOM 属性
    updateProps(dom, props);
    // 只有一个子节点的情况
    if (typeof props.children === 'object' && props.children.type) {
      mount(props.children, dom)
    // 有多个子节点的情况, 需要递归挂载子节点
    } else if (Array.isArray(props.children)) {
      reconcileChildren(props.children, dom);
    }
  }
  return dom;
}

上面的代码增加了对props的处理,在上一章实现jsx里面,我们把子节点赋值到虚拟dom的propschildren属性中,所以处理及节点属性也包含了子节点的挂载。增加了updateProps属性更新函数和reconcileChildren递归创建子元素这两个函数。代码如下

function reconcileChildren(children, parentDOM) {
  for (let i = 0; i < children.length; i++) {
    mount(children[i], parentDOM) // 递归挂载
  }
}
function updateProps(dom, newProps = {}) {
  for (let key in newProps) {
    if (key === 'children') {
      // 如果是`children`属性就跳过,因为外面已经做过处理了
      continue;
    } else if (key === 'style') {
      // 如果是style属性就需要特殊处理一下
      let styleObj = newProps[key];
      for (let attr in styleObj) {
        dom.style[attr] = styleObj[attr];
      }
    } else {
      // 虚拟DOM属性一般来刚好和dom的属性相同的,都是小驼峰命名(className),直接赋值即可 
      dom[key] = newProps[key];
    }
  }
}

reconcileChildren函数就是简单的递归创建子元素,我们着重的说一下updateProps这个函数的逻辑:这里要对style属性进行特殊处理一下,因为style比较特殊,它的命令是代码是element.style.xx = xx,所以我们取出style对象,将它混入到之前的style对象上。然后其他属性,jsx特意设计成跟原声dom属性相同,所以直接赋值就行。

完美~ 终于渲染出来了:

render1

2.3 简单事件绑定

别高兴的太早,我们在子元素上注册了onClick事件,这时点击该元素没有起作用,因为我们还没有对事件进行处理。

<div onClick={() => alert("hi~")}>
  <p>p的文本内容</p>
</div>  

下面我们来对事件进行简单的注册处理:

function updateProps(dom, newProps = {}) {
  for (let key in newProps) {
    if (key === 'children') {
      // 如果是`children`属性就跳过,因为外面已经做过处理了
      continue;
    } else if (key === 'style') {
      // 如果是style属性就需要特殊处理一下
      let styleObj = newProps[key];
      for (let attr in styleObj) {
        dom.style[attr] = styleObj[attr];
      }
    } else if (/^on[A-Z].*/.test(key)) { // 暂时的事件处理 事件的key一般是onXxx
      dom[key.toLowerCase()] = newProps[key];
    } else {
      // 虚拟DOM属性一般来刚好和dom的属性相同的,都是小驼峰命名(className),直接赋值即可 
      dom[key] = newProps[key];
    }
  }
}

我们来看一下结果,事件也能正常注册了:

render3

在虚拟dom里面,事件的key一般是onXxx所以我们直接把on的属性当成事件处理即可。但是像dom[key.toLowerCase()] = newProps[key]这么直接处理肯定是不行的,我们都知道React采用的是合成事件而非原生事件,后续说到合成事件的时候再进行优化。

3. 总结

我们首先介绍了渲染器的本质和作用,渲染器其实就是一个函数,目的是为了将虚拟dom渲染成真实dom并挂载到指定的节点上。然后我们说了渲染工作的两种状态,首次渲染(render)和重新渲染(rerender),render的时候是递归创建所有的真实dom,rerender的时候是diff新旧vnode对真实dom进行最小化更新。然后阐述了渲染器的一些重要意义,例如实现组件生命周期,自定义渲染,实现memocontext,等特殊优化及渲染等。后面开始实现基本的文本和标签渲染,还有属性绑定和事件绑定,这样一个最简单最基础的渲染器就完成了。

下一章我们来聊一下React另一个核心特性:组件化。