这篇文章将介绍如何实现一个迷你版的 React,我将这个库称为 Mini React,我将 Mini react 的源码对应它实现的能力分为了 3 部分。在 React 中有两种类型的自定义组件,分别是函数组件和类组件,在 Mini React 中只实现类组件。
这篇文章的内容如下:
- Babel 与 JSX
- 实现 createElement 和 renderDom 方法让组件渲染在界面上
- setState 让组件具备交互性
- 虚拟 DOM 以及虚拟 DOM 的 diff 算法
- Mini React 运行流程图
Babel 与 JSX
在 React 的官网中推荐使用 JSX 描述 UI,我们可以将 JSX 当作模版语言。但是 JSX 不是合法的 html 也不是合法的 Javascript 语法。在 React 官网上还提到 JSX 最终会被转化为 JS 函数调用并且在使用 JSX 的作用域中要能够访问到 React。那么是谁将 JSX 转化成 JS 函数调用的呢?为什么在使用 JSX 的作用域中要能够访问到 React呢?
@babel/plugin-transform-react-jsx 将 JSX 转化成了 JS 函数调用,它是 Babel 的一个插件,这个插件的作用是遍历每个 JSX 节点并将它们转换为函数调用。
被转换成
从上图可以看出 JSX 被转换成了 React.createElement 函数调用的形式,这就是在使用 JSX 的作用域中要能够访问到 React 的原因。@babel/plugin-transform-react-jsx 将 JSX 转换成 React.createElement 函数调用这是它的默认行为,我们通过修改它的配置来改变函数名,配置如下:
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: [
["@babel/plugin-transform-react-jsx", {
pragma: 'createElement'
}]
],
}
}
}]
}
将 @babel/plugin-transform-react-jsx 的 pragma 参数改成 createElement,这使得 JSX 被转化为 createElement 函数调用。这时我们需要在使用 JSX 的作用域中能够访问到 createElement 函数。下面我们开始实现 createElement。
实现 createElement 和 renderDom 方法让 Mini React 组件渲染在界面上
经过分析可知 createElement 的第一个参数表示被创建的组件,它可能是字符串也可能是一个自定义组件,第二个参数是被创建组件的属性,它可以是 null,剩余的参数是被创建组件的子组件。createElement 的声明如下:
function createElement(type: string | ConstructorOf<Component>, attrs: {[attr: string]: any}, ...children: (string | Component | ElementNode)[]): Component | ElementNode;
在 DOM 中有两种常见的节点,分别是元素节点和文本节点,在 Mini React 中我们除了要实现这两种浏览器内置的 DOM 节点类型之外还要实现一个自定义组件类型。
文本组件
文本组件的实现非常简单,它只需要文件组件对应的内容,代码如下:
class TextNode {
root: Text
constructor(content: string) {
this.root = document.createTextNode(content)
}
}
元素组件
对于元素组件,我们需要根据元素类型(这里的元素类型指标签名)创建出元素组件,并且元素组件要有设置属性和添加子组件的方法,代码如下:
class ElementNode {
root: HTMLElement
constructor(type: string) {
this.root = document.createElement(type)
}
setAttribute(name: string, value: any) {
... something
this.root.setAttribute(name,value);
}
appendChild(component: TextNode | ElementNode | Component) {
this.root.appendChild(component.root)
}
}
自定义组件类型
通过 createElement 的类型声明可知,自定义组件类型也有属性和子元素,但是自定义组件没有浏览器内置的 API,我们需要实现这些 API,使用过 React 的同学都知道 React 的类组件必须有 render 方法,所以在 Mini React 中也要求自定义组件必须有 render 方法,代码如下:
abstract class Component {
props: {[attr: string]: any}
_root?: HTMLElement
children: (TextNode | ElementNode | Component)[]
abstract render() : ElementNode | Component
constructor() {
this.props = Object.create(null)
this.children = []
}
setAttribute(name: string, value: any) {
this.props[name] = value
}
appendChild(component: TextNode | ElementNode | Component) {
this.children.push(component)
}
get root(): HTMLElement {
if(!this._root) {
this._root = this.render().root
}
return this._root;
}
}
这里的 get root 会导致一个递归调用,一直到 render 方法返回的是一个 ElementNode 类型为止。实现了这三个类型之后,我们开始实现 createElement,在 createElement 函数内部根据 type 的类型创建出不同的组件,然后调用这些组件的方法,代码如下:
function createElement(type: string | ConstructorOf<Component>, attrs: {[attr: string]: any}, ...children: (string | Component | ElementNode)[]): Component | ElementNode {
let component: Component | ElementNode;
if (typeof type === 'string') {
component = new ElementNode(type)
} else {
component = new type()
}
for (const attrName in attrs) {
component.setAttribute(attrName, attrs[attrName])
}
function insertChildren(children: any[]) {
for (let child of children) {
let childComponent: Component | ElementNode | TextNode
if (child === null) {
continue;
}
if (typeof child === 'string') {
childComponent = new TextNode(child)
} else {
childComponent = child
}
if (Array.isArray(child)) {
insertChildren(child)
} else {
component.appendChild(childComponent)
}
}
}
insertChildren(children)
return component
}
到目前为止我们已经实现了 createElement 方法,但是只有 createElement 还不够,我们还缺少将组件渲染到浏览器 DOM 树的方法,在这里将它命名为 renderDom,renderDom 参照 ReactDOM.render 的用法,它的实现很简单:
function renderDom (component: Component | ElementNode, parent: HTMLElement) {
parent.appendChild(component.root);
}
在目前为止我们已经可以将一些简单的组件显示到界面上了,但是这些组件还不能根据用户的交互而更新。在 react 中,组件的 props 或者 state 发生了变化就会触发组件的重新渲染,但是组件不能改变它的 props,它只能改变它的 state,自定义组件的 setState 方法用于去更新 state。接下来我们就开始实现 setState 方法。
实现 setState 让组件具备交互性
首先我们先在 Component 抽象类中增加 state 属性,setState 方法,rerender 方法。setState 用于修改 state,rerender 使用新的 state 重新渲染组件。
abstract class Component {
...
state: {[attr: string]: any}
constructor() {
...
this.state = null;
}
...
setState(newState: {[attr: string]: any}) {
// todo
}
rerender() {
// todo
}
}
setState
我们先实现 setState,setState 将新的 state 与旧的 state 进行合并得到一个最终的 state。
setState(newState: {[attr: string]: any}) {
if(this.state === null || typeof this.state !== 'object') {
this.state = newState;
this.rerender()
return ;
}
const merge = (oldState: {[attr: string]: any}, newState: {[attr: string]: any}) => {
for (const key in newState) {
if(oldState[key] !== null && typeof oldState[key] === 'object') {
merge(oldState[key], newState[key])
} else {
oldState[key] = newState[key]
}
}
}
merge(this.state, newState)
this.rerender()
}
rerender
我们先回头看一下前面的代码是如何将组件渲染在界面上的,在前面我们使用了 appendChild API 将子元素添加到父元素中,但是当我们需要根据新的 state 重新渲染界面时,appendChild API 就不满足需求了,我们需要使用更精细化操作 dom 的 API,在这里,我使用 HTML5 中的 Range API 来更新界面上的 DOM。
回到重新渲染这个话题上,在 state 发生变化之后,我们是在原来的 range 上进行增删改,所有在初始渲染上,我们需要将最开始的 range 保存下来,然后在重新渲染的时候再使用这个 range。我们要将所有使用 (node as HTMLElement).appendChild 的地方全部替换成 Range。我们首先改造 renderDom。
function renderDom(component: Component | ElementNode, parent: HTMLElement) {
const range = document.createRange()
range.setStart(parent,0)
range.setEnd(parent,parent.childNodes.length)
range.deleteContents()
component[RENDER_TO_DOM](range)
}
接下来实现上面三种组件类型的[RENDER_TO_DOM]方法,总的来说[RENDER_TO_DOM]方法就是将组件的 root 插入到 range 中,TextNode 和 ElementNode 的 [RENDER_TO_DOM] 是一样的,如下:
[RENDER_TO_DOM](range: Range) {
range.deleteContents()
range.insertNode(this.root)
}
自定义组件(即:Component)的[RENDER_TO_DOM]与其他的两种类型的[RENDER_TO_DOM]有所不同,这源于 Component 的真实 DOM 树是从它的 render 方法中返回的,并且 Component.render 返回的可能依然是一个 Component 而非 ElementNode,但是 range 中只能插入真实的 DOM,在前一个章节,当我们访问 Component.root 时会触发递归调用,Component.root 的值等于 Component.render 返回值的 root。现在的Component[RENDER_TO_DOM]类似, Component[RENDER_TO_DOM] 实际上调用的是 Component.render()[RENDER_TO_DOM],这里也有触发递归调用,代码如下:
[RENDER_TO_DOM](range: Range) {
// 将 range 保存下来,供 rerender 的时候使用
this._range = range
// 这里会导致一个递归调用,一直到 render 方法返回的是一个 ElementNode 类型为止
this.render()[RENDER_TO_DOM](range)
}
现在三种组件类型的[RENDER_TO_DOM]方法已经实现了,我们还需要改造 ElementNode.appendChild 方法,因为在这个方法我们依然使用的是 (node as HTMLElement).appendChild, 我们要将它改成 Range API,如下:
appendChild(component: Component | ElementNode | TextNode) {
const range: Range = document.createRange()
// 因为是在节点的最后添加子元素,所以将 range 移动到末尾
range.setStart(this.root, this.root.childNodes.length)
range.setEnd(this.root,this.root.childNodes.length)
component[RENDER_TO_DOM](range)
}
最后实现 Component.rerender,它只是使用 _range 重新调用一次Component[RENDER_TO_DOM],如下:
rerender() {
this._range.deleteContents()
this[RENDER_TO_DOM](this._range)
}
创建虚拟 DOM 以及虚拟 DOM 的 diff 算法
在现在为止 Mini React 已经实现了重新渲染,但是我们一直操作的是真实 DOM 并且更新范围非常大。接下来我们要实现虚拟 DOM 并且减少更新范围。
vNode(即:虚拟 DOM )和 vNode 的 diff 算法主要是为了优化重新渲染的性能,在重新渲染的时候会将新旧 vNode 进行对比,然后只将有变更的 vNode 反映到真实的 DOM 上。既然要对比新旧vNode,那么我们就要将旧的 vNode 保存下来供重新渲染的时候使用。我们给每一个组件类型都增加 vdom 属性,vdom 用于保存 vNode。
diff 算法有很多种类型,在 Mini React 中使用的是一种非常简单的 diff 算法,它的思想如下:
- 如果两个新旧 vNode 的 type 不同就认为它们是不同的 Node,然后 diff 算法终止
- 如果两个文本 vNode 的内容不同就认为它们是不同的 Node,然后 diff 算法终止
- 如果两个 vNode 的 props 中相同的属性不相同就认为它们是不同的 Node,然后 diff 算法终止
- 如果新的 vNode.props 的数量小于旧的 vNode.props 的数量就认为它们是不同的 Node,然后 diff 算法终止
- 如果经过前面 4 条规则之后确定两个 vNode 是一样的 Node,就遍历新旧 vNode 的子 vNode,并且使用 1-4 条中的规则去对比子 vNode
如果认为新旧 vNode 是不同的 Node,那么就销毁整个旧的 vNode 对应的 DOM 树并且使用新的 vNode 重新渲染 DOM 树。
对于 TextNode 而言,它有一个 type 属性 (表示这是一个文本 vNode,type 为固定的值),content 属性(文本 vNode 的内容),_range 属性(文本 vNode 的插入范围),vdom 属性(返回文本 vNode 自身)还有一个 [RENDER_TO_DOM]方法,这个方法用于将 vNode 渲染到界面上。
class TextNode {
type: string;
content: string;
_range: Range;
constructor(content: string) {
this.type = '#text';
this.content = content;
this._range = null
}
get vdom(): TextNode {
return this;
}
[RENDER_TO_DOM](range: Range) {
const root = document.createTextNode(this.content)
range.deleteContents()
this._range = range;
range.insertNode(root)
}
}
与前面的 TextNode 相比,新的 TextNode 去掉了一些属性也新增了一些属性,需要注意的是去掉了 root 属性,这是因为我们创建的是一个 vNode,root 是一个真实的 DOM Node, 它不应该保存在 vNode 中,它只能在 vNode render to dom 的时候被创建并作为临时使用。
ElementNode 在 render to dom 之前也不能有任何关于真实 DOM 的操作,所以我们要修改 ElementNode.setAttribute 和 ElementNode.appendChild,我们要把真实的 DOM 操作放在 [RENDER_TO_DOM]中。简化代码如下:
class ElementNode {
... dosomething
setAttribute(name: string,value: any) {
this.props[name] = value
}
appendChild(component: Component | ElementNode | TextNode) {
this.children.push(component)
}
[RENDER_TO_DOM](range: Range) {
const root = document.createElement(this.type);
range.deleteContents()
for (const name in this.props) {
const value = this.props[name]
... dosomething
root.setAttribute(name,value);
}
if (!this.vChildren) {
this.vChildren = this.children.map(child => child.vdom);
}
for (const child of this.vChildren) {
const childRange = document.createRange()
childRange.setStart(root, root.childNodes.length)
childRange.setEnd(root, root.childNodes.length)
child[RENDER_TO_DOM](childRange)
}
this._range = range;
range.insertNode(root)
}
}
我们将 setAttribute 和 appendChild 修改成只是往 vNode 上增加属性,在[RENDER_TO_DOM]中才操作真实的 DOM。现在我将 ElementNode 和 TextNode 改造的差不多了,接下来轮到改造 Component 了。回到 vdom 这个属性上,vdom 保存是虚拟 DOM (即:vNode),对于 ElementNode 和 TextNode 而言它的 vdom 是它的实例本身,但是 Component.vdom 不再是 Component 的实例本身了,Component.vdom 等于 Component.render().vdom,这是因为Component 的渲染结果取决于从 render 方法的返回值。在前面我们提到需要将旧的 vNode 保存下来,在这里我将它保存到 _vdom 中。
class Component {
... do something
get vdom(): ElementNode {
// 这里会触发递归调用,一直到 render 返回值是非 Component 结束
const vdom = this.render().vdom
// 将本次用于渲染的 vNode 保存下来
if (!this._vdom) {
this._vdom = vdom
}
return vdom
}
[RENDER_TO_DOM](range: Range) {
this._range = range
this.vdom[RENDER_TO_DOM](range)
}
}
在前面为了实现用户界面更新我们定义了 rerender 方法,但是这个方法的弊端是:不管怎么修改 Component.state 的值,当重新渲染的时候 Component.range 对应的整个 DOM 树都会摧毁重建。我们现在要做的是利用 diff 算法对比新旧 vNode 的变更,减少真实 DOM 的更新范围。现在给 Component 增加一个 update 方法,这个方法用于对比新旧 vNode,然后调用新的vNode[RENDER_TO_DOM]去更新界面,代码如下:
class Component {
... do something
update() {
// 利用前面提到的 diff 算法规则比较两个 vNode 是否是相同的
function isSameNode(oldNode: TextNode | ElementNode, newNode: TextNode | ElementNode): boolean {
... dosomething
}
const update = (oldNode: TextNode | ElementNode, newNode: TextNode | ElementNode) => {
if (!isSameNode(oldNode, newNode)) {
// 如果不是相同的 vNode,则重建整个新 vNode 对应的 DOM 树
newNode[RENDER_TO_DOM](oldNode._range)
return;
}
newNode._range = oldNode._range
... dosomething
// 得到子 vNode
const newChildren = newNode.vChildren;
const oldChildren = oldNode.vChildren;
// 最后一个 node 所在的 range
let tailRange = oldChildren[oldChildren.length - 1]._range
for (let i = 0; i < newChildren.length; i++) {
let newChild = newChildren[i];
let oldChild = oldChildren[i];
if (i < oldChildren.length) {
update(oldChild, newChild)
}
// 如果 newChildren 的长度大于 oldChildren 的长度,将新的 node 插入到末尾
else {
let range = document.createRange()
// 将 range 移动到末尾
range.setStart(tailRange.endContainer, tailRange.endOffset);
range.setEnd(tailRange.endContainer, tailRange.endOffset);
newChild[RENDER_TO_DOM](range)
tailRange = range;
}
}
}
const vdom = this.vdom;
update(this._vdom,vdom);
// 保存本次更新的 vdom
this._vdom = vdom;
}
}
现在删除 rerender 方法,并且将 setState 方法中调用 rerender 的地方改成调用 update。现在 ini React 可以支持局部更新了。