共计 3607 个字符,预计需要花费 10 分钟才能阅读完成。
一、简介
前段时间看到一个用 33 行代码 就实现了一个非常基本的 react 代码。感觉还是蛮有趣的,代码如下:
其主要实现了两大功能:
① 生成虚拟 DOM;
② 根据虚拟 DOM 渲染出真实的 DOM;
无注释版:https://github.com/leontrolsk…
有注释版:https://github.com/leontrolsk…
二、代码分析
虽然代码总共才 33 行,但是写的非常简洁,可能不是一下就能看懂,我对此细细研读了一番,用更加明了的方式重新写了一下。主要就是对外暴露了 React.createElement() 和React.render()两个方法。
① 实现代码基本框架
// 定义一个 React 类并对外暴露
class React {
// 负责创建虚拟 DOM
static createElement(...args) { }
// 负责将虚拟 DOM 渲染成真实的 DOM
static render(parent, v) {}}
export default React;
② 实现 React.createElement()方法
createElement() 方法主要就是解析传递过来的参数,然后解析出 标签名 、 类名数组 、 属性对象 、 子节点数组。
- 解析标签名 : 第一个参数为字符串中包含标签名 ,这个参数支持点的形式,如 div.redColor.bluebg,所以需要以点号进行分割成数组[“div”, “redColor”, “bluebg”], 将数组的第一个元素作为标签名 , 剩余的元素都作为元素的类名。
- 解析元素的属性对象 : 通常传入的第二个参数对象为元素的属性对象, 但是也可以不传 或直接传入元素的子节点,所以需要对第二个参数进行判断,看一下是否是属性对象。判断的依据就是,如果第二个参数是 null、string、number、array、vnode,那么就不是属性对象,而是子节点。
class React {
// 判断能不能渲染,不能渲染则为属性对象
static isRenderable(v) {return v === null || ['string', 'number'].includes(typeof v) || v.__m || Array.isArray(v);
}
}
- 解析子节点 : 解析完标签名和属性对象后, 剩下的参数都是子节点 ,只不过参数有可能是一个数组,所以需要对其进行判断,如果是 数组则需要遍历数组 ,然后再 递归将其添加到 children 数组中。
class React {
// 负责创建虚拟 DOM
static createElement(...args) {// ① 将 attrs 初始化为一个空对象{},并解构出第一个参数 head
let attrs = {};
let [head, ...tail] = args;
// ② 获取标签名和第一个参数中传递的类名
let [tag, ...classes] = head.split('.');
// ③ 获取传入的 attrs 对象,首先判断 tail 中的第一部分能不能渲染,如果不能渲染,说明是 attrs 对象,否则为子节点
if (tail.length && !this.isRenderable(tail[0])) { // 如果传入的第二个参数不能被渲染,那么第二个参数就是 attrs 对象
[attrs, ...tail] = tail; // 取出 attrs 对象
}
// ④ 解析 attrs 对象中包含的类名
if (attrs.class) { // 如果 attrs 属性对象中有 class 属性,那么将其中的类名放到 classes 中
classes = [...classes, ...attrs.class];
}
// ⑤ 移除 attrs 对象中的 class 属性
attrs = {...attrs};
delete attrs.class;
// ⑥ 初始化 children 为空数组[]
const children = [];
// ⑦ 定义一个 addChildren()方法,将所有子节点加入到 children 数组中
const addChildren = (v) => {if (v === null) { // 如果是 null 则直接返回,不将其加入到 children 数组中
return;
}
if (Array.isArray(v)) {// 如果是数组则遍历数组并调用 addChildren()
v.map(addChildren);
} else {children.push(v); // 非数组则直接将其添加到 children 数组中
}
}
addChildren(tail);
// ⑧ 返回虚拟 DOM 对象
return {
__m: true, // 是否是虚拟节点
tag: tag || 'div',
attrs, classes,
children
};
}
}
③ 实现 React.render()方法
React.render() 方法主要传入一个真实的父节点和对应的虚拟节点,将真实父节点当做旧节点 , 将虚拟节点作为新节点 ,然后对新旧节点进行比较,没有则创建,有则更新,同时更新属性,然后进行 递归比较其子节点 , 直到没有子节点为止。
class React {
// 进行新旧节点的比较并渲染出真实 DOM
static render(parent, v) {
// ① 取出真实节点下的所有真实子节点
const olds = parent.childNodes || [];
// ② 取出虚拟节点下的 children 属性对应的虚拟子节点
const news = v.children || [];
// ③ 移除超出的元素(如果旧的节点比新的节点多)
for (const _ of Array(Math.max(0, olds.length - news.length))) {parent.removeChild(parent.lastChild);
}
// ④ 遍历所有虚拟子节点,没有子节点了则会结束 render
news.length > 0 && news.forEach((child, i) => {
// 如果旧子节点不存在则创建一个
let el = olds[i] || this.makeEl(child);
if (!olds[i]) { // 如果旧节点不存在,则直接加入
parent.appendChild(el);
}
// 判断新旧节点是否匹配
const mismatch = (el.tagName || '') !== (child.tag ||'').toUpperCase();
if (mismatch) { // 如果不匹配,则创建出新节点,并且用新节点去替换掉旧节点
(el = this.makeEl(child)) && parent.replaceChild(el, olds[i]);
}
// 更新节点属性
this.update(el, child);
// 递归更新子节点
this.render(el, child);
})
}
static makeEl(vnode) {if (vnode.__m) { // 如果是元素节点
return document.createElement(vnode.tag);
} else { // 如果是文本节点
return document.createTextNode(vnode);
}
}
}
④ 实现 update()方法
主要就是传入真实节点和虚拟节点,然后遍历虚拟节点的 classes 类名数组,将新的类名添加到真实节点上 ,遍历真实节点上的所有 classList,然后 移除虚拟 DOM 上不再使用的类名 。遍历虚拟 DOM 节点上的 attrs 属性对象,并 将新的属性添加到真实节点上 ,遍历真实 DOM 节点上的 attributes, 移除虚拟 DOM 上不再使用的属性。
class React {static update(el, v) {if (!v.__m) { // 如果是文本节点
return el.data === `${v}` || (el.data = v);
}
// 遍历虚拟节点上所有的 class,并添加到 classList 中
for (const name of v.classes) {if (!el.classList.contains(name)) {el.classList.add(name);
}
}
// 遍历真实节点的 classList,移除虚拟节点没有的 class
for (const name of el.classList) {if (!v.classes.includes(name)) {el.classList.remove(name);
}
}
// 遍历虚拟节点的 attrs 属性对象
for (const name of Object.keys(v.attrs)) {if (el[name] !== v.attrs[name]) { // 如果发生变化,则更新
el[name] = v.attrs[name]; // 更新
}
}
// 遍历真实节点的 attributes,移除虚拟节点上不存在的 attrs
for (const {name} of el.attributes) {if (!Object.keys(v.attrs).includes(name) && name !== 'class') {el.removeAttribute(name);
}
}
}
}