一、简介
前段时间看到一个用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); } } }}