在上一篇文章中,我们实现了如何将jsx编译成React.createElement(...)
,执行后得到虚拟 DOM
。这一章我们循序渐进的实现一个基础的渲染器。简单说渲染器就是将 虚拟 DOM
渲染成特定平台下真实 DOM
的工具(本质是一个函数,通常叫 render
)。渲染器的工作流程分为两个阶段:mount
和 rerender
,第一次渲染,由于没有旧的 VNode
,直接将新的 VNode
创建成真实的 DOM
挂载到指定的容器内,这个过程叫做 mount
; 下一次再渲染的时候会生成新的 VNode
,则会使用新的 VNode
与上一次生成的旧 VNode
进行对比,以最少的性能开销完成对真实 DOM
的更新,这个过程就叫 “打补丁”,也就是diff的部分。
渲染器是React最核心最终要的功能,因为它除了将虚拟 DOM
渲染成真实DOM只外,还承担着以下重要角色:
- 控制组件生命周期钩子的调用: 在整个渲染周期中包含了大量的 DOM 操作、组件的挂载、卸载,控制着组件的生命周期钩子调用的时机。
- 包含最核心的 Diff 算法: Diff 算法是渲染器的核心特性之一,正是由于 Diff 算法的存在才使得 Virtual DOM 如此成功。
- 与特定的渲染有直接关系: React的
Ref
、Context
、Memo
、Portal
等组件的挂载是跟渲染器有密不可分的关系,他们需要要在适当的时机处理,一些需要在真实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'))
得到下面的结果:
我们看到成功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的props
的children
属性中,所以处理及节点属性也包含了子节点的挂载。增加了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属性相同,所以直接赋值就行。
完美~ 终于渲染出来了:
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];
}
}
}
我们来看一下结果,事件也能正常注册了:
在虚拟dom里面,事件的key
一般是onXxx
所以我们直接把on
的属性当成事件处理即可。但是像dom[key.toLowerCase()] = newProps[key]
这么直接处理肯定是不行的,我们都知道React采用的是合成事件而非原生事件,后续说到合成事件的时候再进行优化。
3. 总结
我们首先介绍了渲染器的本质和作用,渲染器其实就是一个函数,目的是为了将虚拟dom渲染成真实dom并挂载到指定的节点上。然后我们说了渲染工作的两种状态,首次渲染(render)和重新渲染(rerender),render的时候是递归创建所有的真实dom,rerender的时候是diff新旧vnode对真实dom进行最小化更新。然后阐述了渲染器的一些重要意义,例如实现组件生命周期,自定义渲染,实现memo
,context
,等特殊优化及渲染等。后面开始实现基本的文本和标签渲染,还有属性绑定和事件绑定,这样一个最简单最基础的渲染器就完成了。
下一章我们来聊一下React另一个核心特性:组件化。