前置概念
虚拟 dom
vdom,虚拟 dom 就是用来表示真实的 dom 元素的属性或者特点的一套数据结构,和真实 dom 一样具有树形结构,具有许多树形节点 vnode
可以简要的表示
const vnode = { type: "h1", children: [ { type: "h2", children: "我是h2", }, ],};渲染器
渲染器的基本作用就是把虚拟 dom 渲染为平台上面的真实 元素,浏览器上就是真实的 dom 元素。
挂载
mounted,意思就是渲染器读取解析 vnode/vdom 属性之后,使用真实的 dom 形式并且表现在页面上/或者是具体的页面某个位置上
实现渲染器
简单的渲染原理
renderer: 渲染函数
就是通过 innerHTML 的形式将第一个参数 domString 插入到对应容器当中
function renderer(domString, container) { container.innerHTML = domString;}
renderer("<h1>vue3 renderer</h1>", document.getElementById("app"));[Image missing: /Users/leo/Library/Application%20Support/marktext/images/2022-12-03-20-32-21-image.png]
结合 reactivity
引入 vue3 的 reactivity 包,他会在全局暴露一个 VueReactivity 变量
<script src="https://cdn.jsdelivr.net/npm/@vue/reactivity@3.2.45/dist/reactivity.global.min.js"></script><script type="module"> import { renderer } from "./render.js";
const { effect, ref } = VueReactivity;
const count = ref(1);
effect(() => { renderer( `<h1>vue3 renderer times: ${count.value}</h1>`, document.getElementById("app") ); });
setTimeout(() => { count.value++; }, 2000);</script>通过引入 vue3 的 reactivity,我们能够实现一个动态渲染的基本逻辑
自定义渲染器
vue3 中的渲染器,是设计为通用可配置的,即可实现渲染到任意目标的平台上,我们目前说的目标平台,先指定浏览器;后续可以将一些可抽象的 API 抽离,使得渲染器的核心不依赖与浏览器的 api
这也就是 vue 的核心之一,将相关浏览器的 api 封装到了runtime-dom的一个包,提供了很多针对浏览器的 dom api,属性以及事件处理
工厂函数
首先我们创建一个 createRenderer 的工厂函数用于创建一个渲染器, 并且抛出许多的方法
/** 创建一个渲染器 */function createRenderer() { const render = (vnode, container) => { // ... };
// 后续会有各种方法
return { render };}之后进行相关的渲染调用
const renderer = createRenderer();renderer.render(vNode, document.getElementById("app"));加入更新的概念
第一次调用
renderer.render(vNode, document.getElementById("app"));第二次调用渲染的时候还是在同一个 container 上调用的
renderer.render(newVNode, document.getElementById("app"));由于首次的渲染已经将对应的 dom 渲染到了 container 内部了,所以再次调用 render 函数的时候,渲染一个新的虚拟 dom,就单单是做一个简单的挂载的动作了,而是要进行更新对比,找出变动的节点,这个过程就叫做 - 打补丁(更新)
因此我们可以相关的改造一下代码:
引入一个更新的概念处理的逻辑
/** 创建一个渲染器 */function createRenderer() { const render = (vnode, container) => { if (vnode) { // 新的node存在的情况,将其旧的vnode一起传递给patch函数进行补丁的更新 patch(container._vnode, vnode, container); } else { if (container._vnode) { // 旧的vnode存在,新的vnode不存在的情况,说明是一个 unmount(卸载)的操作 // 这里只需要将container内dom清空就可以, 目前暂时这样清空 container.innerHTML = ""; } } // 每一次都需要保存上一次的vnode,存储在container下 container._vnode = vnode; // 暂时使用这段代码清空,后续会完善 };
return { render };}patch 简单实现
其中 patch 的函数,是最最重要的一个渲染器的核心,主要是做初始化和相关的 diff 操作
目前进行简单实现,后续着重说
/** 更新对比 */function patch(n1, n2, container) { // 如果不存在 if (!n1) { mountElement(n2, container); } else { // n1存在的情况下面,我们进行更新(打补丁) }}-
n1: 老的 vnode
-
n2: 新的 vnode
-
container: 容器
如果不存在旧的 vnode 的情况下,说明只是需要进行元素的挂载即可
实现 mountElement 挂载
/** 挂载 */function mountElement(vnode, container) { // 创建dom元素 const el = document.createElement(vnode.type); //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 el.textContent = vnode.children; } // 将元素添加到容器中 container.appendChild(el);}通过 vnode 的 type 标签名称创建一个新的 dom 元素,接着处理 children,如果是字符串的类型,说明是文本的 child 节点,直接设置 textContent 就可以了,之后 appendChild 插入容器当中
配置项形式抽离
因为我们要设计一个相当于是不依赖于平台的一个通用渲染器,所以,需要将上述所用到的所有依赖于浏览器的 api 都给抽离出来,实现独立封装的配置项
例如,我们抽离mountElement函数使用到的一些浏览器方法
/** 浏览器端的相关api */const BROWSER_APIS = { // 用于创建元素 createElement(tag) { return document.createElement(tag); },
/** 用于设置元素的文本节点 */ setElementText(el, text) { el.textContent = text; },
/** 给特定的parent下添加指定的元素 */ insert(el, parent, anchor = null) { parent.insertBefore(el, anchor); },};
export default BROWSER_APIS;之后我们改造createRenderer函数
将相关的 api 以 options 的形式传入
/** 创建一个渲染器 */function createRenderer(options) { const { createElement, insert, setElementText } = options;
/** 挂载 */ function mountElement(vnode, container) { // ... }
/** 更新对比 */ function patch(n1, n2, container) { // ... }
/** 渲染方法 */ const render = (vnode, container) => { // ... };
return { render };}
export { createRenderer };之后我们就可以通过传递进来的可配置的 apis 去实现相关的渲染器操作
例如,我们可以改造mountElement并且使用到相关的 apis
/** 挂载 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); // ➕ //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(e, vnode.children); // ➕ } // 将元素添加到容器中 insert(el, container); // ➕}通过了以上配置之后,渲染器将不仅仅可以在浏览器端进行使用,我们也可以根据不同的平台,传入不同的自定义的相关 api 参数
挂载和更新
子节点挂载
上述我们只考虑到了一个 vnode 的 children 为 string 的情况下的挂载,使用 setElementText 对元素进行挂载,但是 children 可能存在多个 vnode 非 string 类型的情况
例如:以下的 vnode,有两个子节点
const vNode = { type: "div", children: [ { type: "p", children: "111", }, { type: "p", children: "222", }, ],};因此需要改造挂载函数,使其具有挂载子节点的能力。这里加入一层 children 节点的判断
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(el, vnode.children); } else if (Array.isArray(vnode.children)) { // ➕ // 如果children是数组,则便遍历每一个字节点,然后调用patch的方法 vnode.children.forEach((child) => { patch(null, child, el); }); } // 将元素添加到容器中 insert(el, container);}挂载节点的属性
一个元素可以用多个属性来进行描述,当然映射到虚拟 dom 上的话,用一个 props 的属性进行表示。
const vNode = { type: "div", props: { id: "foo", }, children: [ { type: "p", children: "111", }, { type: "p", children: "222", }, ],};因此我们在挂载元素的时候,也需要将这些属性值渲染到对应的元素上面
改造挂载函数
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(el, vnode.children); } else if (Array.isArray(vnode.children)) { // 如果children是数组,则便遍历每一个字节点,然后调用patch的方法 vnode.children.forEach((child) => { patch(null, child, el); }); }
// 处理props, 存在的情况下进行处理 if (vnode.props) { // ➕ // 遍历 for (const key in vnode.props) { if (key in el) { const prop = vnode.props[key]; // 调用setAttribute将属性设置到元素上 el.setAttribute(key, prop); // 🌟 } } }
// 将元素添加到容器中 insert(el, container);}在标记“🌟“的地方,也可以使用 el[key] = vnode.props[key],但是由于这都是属于直接操作 dom 对象的行为,所以都会存在缺陷,因此,我们需要想办法如何的正确设置元素的属性
🌟HTML attribute 和 DOM properties 区别
HTML attribute 指的就是定义在 HMTL 标签上的属性
<input id="my-input" type="text" value="foo" />document 文档解析之后,会生成一个与之相符的 dom 元素对象,这个对象上面包含了很多的属性
[Image missing: /Users/leo/Library/Application%20Support/marktext/images/2022-12-05-00-37-36-image.png]
这些就是所谓的 dom property。
两者的区分大致如下
-
很多的 HTMl attribute 在 DOM 对象上面都有与之同名的 DOM properties,但是命名规则却不一样
例如:HTML attribute 的 class 对应的 dom property 就是 className
-
两者存在关联:例如上述设置了 HTML attribute 的 id 为‘foo’,那么对应的 DOM properties 当中存在相同属性名称为 id 也为 foo,两者可以当作直接映射的关系
-
并不是都存在直接直接映射关系:例如 value 属性,上述 input 设置了 value 值,但是在 DOM properties 对应的值不仅仅是 value,还有 defaultValue 值;如果后续在 input 框中输入了其他的 value 值 bar
[Image missing: /Users/leo/Library/Application%20Support/marktext/images/2022-12-05-00-50-37-image.png]
然后我们再去读取其相关的 HTML Attribute 和 DOM properties 会发现
[Image missing: /Users/leo/Library/Application%20Support/marktext/images/2022-12-05-00-53-03-image.png]
其实 HTML Attribute 只是做了一个初始值的赋值,但是却是对其他的 DOM property 也有相关影响
-
非法属性值会被浏览器校验处理掉:例如我们在 input 的 HTML attribute 上面设置了一个 type 等于一个‘foo‘的属性,那么会被浏览器自动处理掉。最终我们读取 DOM property 的时候,是被矫正之后的值 ‘text’
HTMl Atrribute 的作用就是设置与之对应的 DOM properties 的初始值
所以按照上述的 HTML attribute 和 DOM props 的区别,我们可以改造相关的 mountElement 的操作
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); console.log(el); //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(el, vnode.children); } else if (Array.isArray(vnode.children)) { // 如果children是数组,则便遍历每一个字节点,然后调用patch的方法 vnode.children.forEach((child) => { patch(null, child, el); }); }
// 处理props, 存在的情况下进行处理 if (vnode.props) { // 遍历 for (const key in vnode.props) { const value = vnode.props[key]; // 获取props对应key的value // dom properties存在的情况 if (key in el) { el[key] = value; } else { // 设置的属性没有对应的DOM properties的情况,调用setAttribute将属性设置到元素上 el.setAttribute(key, value); } } }
// 将元素添加到容器中 insert(el, container);}正确的设置节点的属性
浏览器会自动为我们解析 HTML 文件中的 dom 元素,以及相关的 props 的属性设置操作。
但是在 vue 中,因为用到了自身的模板文件,所以在解析相关的节点的时候需要自身处理这些属性的挂载操作
布尔类型属性处理
例如下面这段 html 代码:
<button disabled />在浏览器中会将其属性 disabled 自动矫正为 disabled=true
在目前的我们渲染器中,类似与等价于如下 vnode
const vNode = { type: "button", props: { disabled: "", },};最终在挂载的时候,会调用设置方法将空字符串 设置到 dom 属性上面。
类似于:el.disabled=''
但是由于浏览器的自动矫正功能,会将我们的空字符串,自动矫正为 false,这就不符合用户的本意了。
因此我们要对这种 DOM attribute 布尔类型的属性,在赋值的时候加入一层判断
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 // ... 省略代码
// 处理props, 存在的情况下进行处理 if (vnode.props) { // 遍历 for (const key in vnode.props) { const value = vnode.props[key]; // 获取props对应key的value // dom properties存在的情况 if (key in el) { // 获取这个DOM properties元素的属性类型 const type = typeof el[key]; // ➕ // 如果原生属性类型为布尔类型,并且value是空的字符串的话,给他值矫正为true if (type === "boolean" && value === "") { // ➕ el[key] = true; } else { // 其他情况 el[key] = value; } } else { // 设置的属性没有对应的DOM properties的情况,调用setAttribute将属性设置到元素上 el.setAttribute(key, value); } } }
// 将元素添加到容器中 insert(el, container);}只读类型属性处理
DOM properties 当中还存在很多只读的属性,例如:form
如下例子:
<form id="form1"></form><input form="form1" />类似 form 这种 DOM properties 在所有的 form 控件上都是,只读的属性的,我们只能通过 setAttribute来设置他的属性,所以这时候还得要修改我们的逻辑,判断当前的属性在浏览器中是否是只读的,如果是匹配上这种只读的情况的属性,使用setAttribute来进行赋值
// 判断是否应该作为DOM properties的设置function shouldSetAsProps(el, key, value) { // 对特殊只读属性的处理 if (key === "form" && el.tagName === "INPUT") return false;
return key in el;}
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 // ... 省略代码
// 处理props, 存在的情况下进行处理 if (vnode.props) { // 遍历 for (const key in vnode.props) { const value = vnode.props[key]; // 获取props对应key的value // dom properties存在的情况 if (shouldSetAsProps(el, key, value)) { // 获取这个DOM properties元素的属性类型 const type = typeof el[key]; // 如果原生属性类型为布尔类型,并且value是空的字符串的话,给他值矫正为true if (type === "boolean" && value === "") { el[key] = true; } else { el[key] = value; } } else { // 设置的属性没有对应的DOM properties的情况,调用setAttribute将属性设置到元素上 el.setAttribute(key, value); } } }
// 将元素添加到容器中 insert(el, container);}提取平台无关代码
我们将遍历 props 时候的 判断设值的代码,提取到 BROWSE_APIS 当中,作为浏览器端的方法,取名为patchProps
/** 将属性设置相关的操作封装到patchProps的函数中,并作为渲染器选项传递 */ patchProps(el, key, preValue, nextValue) { // 判断是否应该作为DOM properties的设置 function shouldSetAsProps(el, key, value) { // 对特殊只读属性的处理 if (key === "form" && el.tagName === "INPUT") return false; // ...还有更多特殊处理情况todo
return Object.hasOwnProperty.call(vnode.props, key); }
// dom properties存在的情况 if (shouldSetAsProps(el, key, nextValue)) { // 获取这个DOM properties元素的属性类型 const type = typeof el[key]; // 如果原生属性类型为布尔类型,并且value是空的字符串的话,给他值矫正为true if (type === "boolean" && nextValue === "") { el[key] = true; } else { el[key] = nextValue; } } else { // 设置的属性没有对应的DOM properties的情况,调用setAttribute将属性设置到元素上 el.setAttribute(key, nextValue); } },最终在 mountElement 中就这样调用
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); console.log(el); //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(el, vnode.children); } else if (Array.isArray(vnode.children)) { // 如果children是数组,则便遍历每一个字节点,然后调用patch的方法 vnode.children.forEach((child) => { patch(null, child, el); }); }
// 处理props, 存在的情况下进行处理 if (vnode.props) { // 遍历 for (const key in vnode.props) { patchProps(el, key, null, vnode.props[key]); // ➕ } }
// 将元素添加到容器中 insert(el, container);}class 和 style 的处理
在 vue 中,我们可以用 3 种方式去传递一个 class 的值
字符串
<p class="foo bar"></p>对象
<p :class="{ foo:true, bar:false }"></p>数组
<p :class="['foo bar', {foo:true, bar:false }]"></p>无论是何种结构,我们最终转化到 vNode 上面都是要成为一个字符串的
const vNode = { type: "p", props: { class: "foo bar", },};因此在生成 vnode 的过程,我们需要调用 normalizeClass 的方法去转换 class
const vNode = { type: "p", props: { class: normalizeClass([ "foo", { foo: false, bar: true, }, ]), },};实现 normalizeClass
normalizeClass
/** 格式化class */function normalizeClass(value) { let res = ""; if (isString(value)) { res = value; } else if (isArray(value)) { // 类似数组 join(' ') for (let i = 0; i < value.length; i++) { const normalized = normalizeClass(value[i]); if (normalized) { res += normalized + " "; } } } else if (isObject(value)) { for (const name in value) { if (value[name]) { res += name + " "; } } } return res.trim();}设置 class
设置 dom class 属性的方式有多种,setAttribute, el.className, el.classList
其中el.className的性能最好
因此我们改之我们的patchProps方法
/** 将属性设置相关的操作封装到patchProps的函数中,并作为渲染器选项传递 */ patchProps(el, key, preValue, nextValue) { // 判断是否应该作为DOM properties的设置 function shouldSetAsProps(el, key, value) { // ... }
if (key === "class") { // ➕ el.className = nextValue || ""; // ➕ } else if (shouldSetAsProps(el, key, nextValue)) { // ... } else { // ... } },当然 style 也是类似的处理方案,只不过在 vue 中调用的是normalizeStyle的方法
卸载操作
上述说到,我们在 render 函数中使用了 container.innerHTML = ''的方式去清空卸载;
/** 渲染方法 */const render = (vnode, container) => { if (vnode) { // 新的node存在的情况,将其旧的vnode一起传递给patch函数进行补丁的更新 patch(container._vnode, vnode, container); } else { if (container._vnode) { // 旧的vnode存在,新的vnode不存在的情况,说明是一个 unmount(卸载)的操作 // 这里只需要将container内dom清空就可以 container.innerHTML = ""; // ✨ } } // 每一次都需要保存上一次的vnode,存储在container下 container._vnode = vnode;};这么做不严谨,原因如下:
- 容器的内容可能是某个组件或者多个渲染的,在卸载的时候应该去触发组件相关的卸载生命周期函数
- 自定义指令,没办法正确执行
- dom 身上绑定的一些事件,不会进行移除
正确的卸载方式:
-
根据 vnode 对象获取其关联的真实 dom 元素
-
使用原生的 dom 移除操作将其移除
建立 dom 与 vnode 联系
要想根据 vnode 对象获取其关联的真实 dom 元素,首先必须要先建立联系
在挂载阶段我们利用 createElement创建了真实的 dom,之后将其绑定在 vnode 上,这样就可以将 vNode 和真实 dom 之间的联系建立起来
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); console.log(el); vnode.el = el; // 将其创建出来的dom添加到vnode上,建立联系 ➕ //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // ... } else if (Array.isArray(vnode.children)) { // ... }
// 处理props, 存在的情况下进行处理 if (vnode.props) { // ... }
// 将元素添加到容器中 insert(el, container);}改造 render 函数
/** 渲染方法 */const render = (vnode, container) => { if (vnode) { // 新的node存在的情况,将其旧的vnode一起传递给patch函数进行补丁的更新 patch(container._vnode, vnode, container); } else { if (container._vnode) { // 旧的vnode存在,新的vnode不存在的情况,说明是一个 unmount(卸载)的操作 // 根据vnode获取要卸载的真实dom元素 const el = container._vnode.el; // ➕ // 获取el的父级元素 const parent = el.parentNode; // ➕ // 调用removeChild删除元素 if (parent) parent.removeChild(el); // ➕ } } // 每一次都需要保存上一次的vnode,存储在container下 container._vnode = vnode;};其中的 container._vnode 代表的就是旧的 vnode(即将要被卸载的),由于我们之前绑定上了相关的 dom 在 vnode 上,就可以调用其父级的移除元素的操作
封装 unmount
将上述的卸载操作封装成一个 unmount 的函数,方便后续功能增加
unmount
/** 卸载操作 */function unmount(vnode) { // 根据vnode获取要卸载的真实dom元素 // 获取el的父级元素 const parent = vnode.el.parentNode; if (parent) { parent.removeChild(vnode.el); }}render
const render = (vnode, container) => { if (vnode) { // 新的node存在的情况,将其旧的vnode一起传递给patch函数进行补丁的更新 patch(container._vnode, vnode, container); } else { if (container._vnode) { // 旧的vnode存在,新的vnode不存在的情况,说明是一个 unmount(卸载)的操作 unmount(container._vnode); // ➕ } } // 每一次都需要保存上一次的vnode,存储在container下 container._vnode = vnode;};区分 vnode 类型
在 render 函数中,vnode 存在的情况下,会将老的 vnode 和新的 vnode 都传递给 patch 函数去做一个更新,当然是我们的 node 的类型都是相同的情况下,我们才有去做比较的意义。
比如:
const vnode = { type: "p" }; //第一次渲染:const vnode = { type: "input" }; // 第二次渲染这种情况就没有对比更新的一个必要了。
这种情况下:
-
先去卸载 p 元素
-
再将 input 挂载到容器中
因此我们需要改造 patch 的代码
/** 更新对比, 并且做挂载相关的功能 */function patch(n1, n2, container) { // n1老节点存在,对比n1和n2的类型 if (n1 && n1.type !== n2.type) { // ➕ // 如果新旧vnode的类型不同,则直接将旧的vnode卸载 unmount(n1); // ➕ n1 = null; // ➕ } // 如果不存在 if (!n1) { mountElement(n2, container); } else { // n1存在的情况下面,我们进行更新(打补丁) }}这种情况都是 vnode 为普通标签元素的类型情况下,我们也可以稍微改造一些对组件类型等等的支持
/** 更新对比, 并且做挂载相关的功能 */function patch(n1, n2, container) { // n1老节点存在,对比n1和n2的类型 if (n1 && n1.type !== n2.type) { // 如果新旧vnode的类型不同,则直接将旧的vnode卸载 unmount(n1); n1 = null; }
const { type } = n2;
if (typeof type === "string") { // 说明是普通的标签元素 if (!n1) { // 如果不存在, 就进行挂载 mountElement(n2, container); } else { // n1存在的情况下面,我们进行更新(打补丁) patchElement(n1, n2); } } else if (typeof type === "object") { // 组件 } else { // 其他 }}事件处理
首先我们要在 vnode 中去描述事件,假定一个规则,以字符串 on 开头的都视作事件
const vnode = { type: "p", props: { onClick: () => {}, }, children: "text",};绑定事件和更新
事件绑定和更新我们就需要在 patchProps 函数中做相关的处理
/** 将属性设置相关的操作封装到patchProps的函数中,并作为渲染器选项传递 */ patchProps(el, key, preValue, nextValue) { // 匹配事件,以on开头 if (/^on/.test(key)) { // ➕ // 根据属性名称得到对应的事件名称 const name = key.slice(2).toLowerCase(); // ➕ // 移除上一次绑定的事件处理函数 prevValue && el.removeEventListener(name, prevValue); // ➕ // 绑定事件, nextvalue为事件函数 el.addEventListener(name, nextValue); // ➕ } else if (key === "class") { // ... } else if (shouldSetAsProps(el, key, nextValue)) { // ... } else { // ... } }按如上的逻辑,就是相当于每次更新都要去移除之前绑定的函数,然后再对新的值重新进行监听。但是这么做性能并不是最优的。
我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为 invoker.value,后续更新值的时候,我们只需要更新 invoker.value 值就可以
/** 将属性设置相关的操作封装到patchProps的函数中,并作为渲染器选项传递 */function patchProps(el, key, prevValue, nextValue) { // 匹配事件,以on开头 if (/^on/.test(key)) { // 定义el.vei为一个对象,存在事件名称到事件处理函数的映射 const invokers = el._vei || (el._vei = {});
// 获取该元素伪造的事件处理函数, 根据key let invoker = invokers[key]; // 根据属性名称得到对应的事件名称 const name = key.slice(2).toLowerCase();
if (nextValue) { if (!invoker) { // 如果没有invoker,为首次监听,则去伪造一个缓存到el.vei中 invoker = el._vei[key] = (e) => { // 如果invoker是数组的情况,需要遍历执行 if (Array.isArray(invoker.value)) { invoker.value.forEach((fn) => fn(e)); } else { // 这里才是处理真正的事件函数 invoker.value(e); } }; // 赋值事件处理函数到invoker的value上 invoker.value = nextValue; // 绑定invoker el.addEventListener(name, invoker); } else { // 存在invoker说明是更新,只需要更新invoker.value值就行 invoker.value = nextValue; } } else if (invoker) { // 新的事件函数不存在,需要销毁invoker el.removeEventListener(name, invoker); } } else if (key === "class") { // ... } else if (shouldSetAsProps(el, key, nextValue)) { // .... } else { // ... }}-
invokers:存事件名称与对应函数的映射
-
el._vei:vue event invoker,在 el 上缓存 invoker
更新属性以及子节点
更新必定涉及到整个 vnode 上面的属性的变化,包括节点的属性以及节点的子节点的变化
元素的挂载是由 mountElement 触发的
/** 挂载函数调用 */function mountElement(vnode, container) { // 创建dom元素 const el = createElement(vnode.type); console.log(el); vnode.el = el; // 将其创建出来的dom添加到vnode上,建立联系 //处理子节点, 如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === "string") { // 只需要设置元素的textContent的属性即可 setElementText(el, vnode.children); } else if (Array.isArray(vnode.children)) { // 如果children是数组,则便遍历每一个字节点,然后调用patch的方法 vnode.children.forEach((child) => { patch(null, child, el); }); }
// 处理props, 存在的情况下进行处理 if (vnode.props) { // 遍历 for (const key in vnode.props) { patchProps(el, key, null, vnode.props[key]); } }
// 将元素添加到容器中 insert(el, container);}在挂载子节点的时候,首先有两种的类型的区分
-
字符串:具有文本的子节点
-
数组:多个子节点
总的来说,子节点分为 3 种类型:
-
没有子节点的情况
vnode = {type: "div",children: null,}; -
字符串的情况
vnode = {type: "div",children: "222",}; -
数组的情况
vnode = {type: "div",children: ["111", { type: "p" }],};
对应到更新的时候,我们对应的新旧节点都分别是 3 种情况
[Image missing: 2022-12-09-22-45-02-image.png]
接下来我们去实现 patchElement函数
-
n1: 旧 vnode
-
n2: 新 vnode
实现步骤
-
首先我们去更新他们的 props 的变化,调用之前封装好的 patchProps 函数做更新变化
-
之后去更新他们的 children,这里要对以上 9 种的情况进行覆盖。
实现 props 变化更新
实现 props 的更新
-
从新的 props 参数中找出旧的 props 中与之对应的 key value,调用 pacthProps 方法对 dom 元素进行对比更新
-
从旧的 props 中找出不存在新 props 中的属性,调用 pacthProps 的方法进行 dom 属性的卸载
/** 更新子节点 */function pacthElement(n1, n2) { n2.el = n1.el; const el = n2.el; const { props: oldProps } = n1; const { props: newProps } = n2; // step 1 更新props for (const key in newProps) { if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]); } } for (const key in oldProps) { if (!(key in newProps)) { patchProps(el, key, oldProps[key], null); } }
// step 2:更新children pacthChildren(n1, n2, el);}顺手改造 patchProps 函数
我们依据 patchProps 的各个分支,去相对应封装我们的更新方法
最终的改造如下
// 比对props做更新const patchProp = (el, key, prevValue, nextValue) => { if (key === "class") { // class的处理 patchClass(el, nextValue); } else if (key === "style") { // style的处理 patchStyle(el, prevValue, nextValue); } else if (/^on[^a-z]/.test(key)) { // 事件的处理 patchEvent(el, key, nextValue); } else { // 其他属性的处理 patchAttr(el, key, nextValue); }};操作 class 更新
/** 比对class属性 */function patchClass(el, value) { // 根据最新值设置类名 if (value == null) { el.removeAttribute("class"); } else { el.className = value; }}操作样式的更新
/** 比对class属性 */function patchStyle(el, prev, next) { // 更新style const style = el.style; for (const key in next) { // 用最新的直接覆盖 style[key] = next[key]; } if (prev) { for (const key in prev) { // 老的有新的没有删除 if (next[key] == null) { style[key] = null; } } }}操作事件的更新
/** 创建一个invoker */function createInvoker(initialValue) { const invoker = (e) => invoker.value(e); invoker.value = initialValue; return invoker;}/** 比对事件更新 */function patchEvent(el, rawName, nextValue) { // 更新事件 const invokers = el._vei || (el._vei = {}); const exisitingInvoker = invokers[rawName]; // 是否缓存过
if (nextValue && exisitingInvoker) { exisitingInvoker.value = nextValue; } else { const name = rawName.slice(2).toLowerCase(); // 转化事件是小写的 if (nextValue) { // 缓存函数 const invoker = (invokers[rawName] = createInvoker(nextValue)); el.addEventListener(name, invoker); } else if (exisitingInvoker) { el.removeEventListener(name, exisitingInvoker); invokers[rawName] = undefined; } }}操作属性的更新
/** 比对dom properties或者是html attributes */function patchAttr(el, key, value) { // 更新属性 if (value == null) { // 如果值不存在,说明是卸载props的操作 el.removeAttribute(key); } else { if (shouldSetAsProps(el, key, nextValue)) { // dom properties存在的情况 // 获取这个DOM properties元素的属性类型 const type = typeof el[key]; // 如果原生属性类型为布尔类型,并且value是空的字符串的话,给他值矫正为true if (type === "boolean" && nextValue === "") { el[key] = true; } else { el[key] = nextValue; } } else { el.setAttribute(key, value); } } }更新 children 节点
对照以上 3*3 的节点情况
1. 新子节点是文本节点
/** 更新children */function pacthChildren(n1, n2, container) { // 判断新子节点的类型是否是文本节点 if (typeof n2.children === "string") { // 当新节点为文本节点的时候,如果旧节点是一组子的节点,我们需要逐个去卸载,其他情况啥也不做 if (Array.isArray(n1.children)) { n1.children.forEach((node) => unmount(node)); } // 最后设置新的节点内容 setElementText(container, n2.children); }}2. 新子节点是一组
/** 更新children */function pacthChildren(n1, n2, container) { // 判断新子节点的类型是否是文本节点 if (typeof n2.children === "string") { // ... } else if (Array.isArray(n2.children)) { // 当新的子节点是一组 // 我们判断旧的子节点是否也是一组 if (Array.isArray(n1.children)) { // diff算法 todo } else { // 到这里,存在两种情况,要么是文本节点要么无 // 什么情况下都去清空,然后将一组新的子节点添加进来 setElementText(container, ""); n2.children.forEach((node) => patch(null, node, container)); } }}diff 算法,目前先用傻瓜逻辑实现,全部卸载然后再全部挂载
n1.children.forEach((node) => unmount(node));n2.children.forEach((node) => patch(null, node, container));3. 新子节点啥也没有
/** 更新children */function pacthChildren(n1, n2, container) { // 判断新子节点的类型是否是文本节点 if (typeof n2.children === "string") { // 当新节点为文本节点的时候,如果旧节点是一组子的节点,我们需要逐个去卸载,其他情况啥也不做 if (Array.isArray(n1.children)) { n1.children.forEach((node) => unmount(node)); } // 最后设置新的节点内容 setElementText(container, n2.children); } else if (Array.isArray(n2.children)) { // .... } else { // 到这里,说明新的子节点不存在 // 如果旧的节点是一组子节点,只需要逐个卸载就可以 if (Array.isArray(n1.children)) { n1.children.forEach((node) => unmount(node)); } else if (typeof n1.children === "string") { // 旧节点是文本,清空 setElementText(container, ""); } // 其他情况不用管 }}特殊节点类型
一般我们用 vnode 描述节点,都是用 type 去描述节点类型
但是 像 文本节点 注释节点 以及 vue3 的 fragment,都较为特殊
<Fragment> <!-- 注释节点 --> 文本节点</Fragment>文本节点和注释节点处理
这两者对于普通标签节点来说,不具备标签名称,所以需要框架认为的去创造一些唯一的标识,并且将其作为注释节点和文本节点的 type
描述文本和注释节点:
// 加入人为的文本节点标识const Text = Symbol();const newVnode = { type: Text, children: "文本节点",};// 加入人为的注释节点标识const Comment = Symbol();const newVnode = { type: Comment, children: "注释的节点",};加入两者之后,我们需要在 patch 中去做相关的改造
/** 更新对比, 并且做挂载相关的功能 */ function patch(n1, n2, container) { // n1老节点存在,对比n1和n2的类型 if (n1 && n1.type !== n2.type) { // 如果新旧vnode的类型不同,则直接将旧的vnode卸载 unmount(n1); n1 = null; }
const { type } = n2;
if (typeof type === "string") { // .... } else if (typeof type === "object") { // 组件 } else if (type === Text) { // 文本标签 if (!n1) { // 使用原生createTextNode去创建文本节点 const el = (n2.el = document.createTextNode(n2.children)); // 将文本节点插入到容器中 insert(el, container); } else { // 如果旧的vnode存在,只需要使用心得文本节点的文本内容更新旧文本节点就可以了 const el = (n2.el = n1.el); if (n2.children !== n1.children) { el.nodeValue = n2.children; } } }当然上述用到了相关浏览器的 api 我们需要给他提取出来document.createTextNode以及el.nodeValue
/** 浏览器端的相关api */const BROWSER_APIS = { // 用于创建元素 createElement(tag) { // ... },
/** 用于设置元素的文本节点 */ setElementText(el, text) { // ... },
/** 给特定的parent下添加指定的元素 */ insert(el, parent, anchor = null) { // ... },
/** 创建文本节点 */ createText(text) { // ➕ return document.createTextNode(text); },
/** 设置文本值 */ setText(el, text) { // ➕ el.nodeValue = text; },
/** 将属性设置相关的操作封装到patchProps的函数中,并作为渲染器选项传递 */ patchProps(el, key, prevValue, nextValue) { // ... },};处理注释的节点和其类似,调用原生的 document.createComment函数来创建即可
Fragment
Fragment 和 tex 以及注释类似,type 也需要人为加入标识
const Fragment = Symbol();在项目中经常会有类似这样的 vNode,
const vNode = { type: "ul", children: [ { type: Fragment, children: [ { type: "li", children: "1", }, { type: "li", children: "2", }, ], }, ],};由于 Fragment 本身是不属于一个真正的节点的,所以在做渲染的时候,我们只需要渲染它的子节点就可以了
因此我们改造 patch 函数
/** 更新对比, 并且做挂载相关的功能 */function patch(n1, n2, container) { // n1老节点存在,对比n1和n2的类型 if (n1 && n1.type !== n2.type) { // 如果新旧vnode的类型不同,则直接将旧的vnode卸载 unmount(n1); n1 = null; }
const { type } = n2;
if (typeof type === "string") { // ... } else if (typeof type === "object") { // 组件 } else if (type === Text) { // ... } else if (type === Fragment) { // 如果是 片段节点 if (!n1) { // 如果不存在旧节点的话,只需要将Fragment的children逐个挂载就可以 n2.children.forEach((node) => patch(null, node, container)); } else { // 如果旧的vnode存在的话, 则只需要更新fragment的children就可以 pacthChildren(n1, n2, container); } }}并且对于我们在卸载的时候,也要做相关的处理
/** 卸载操作 */function unmount(vnode) { // 在卸载的时候,如果是卸载的vnode类型为Fragment, 则需要卸载他的children if (vnode.type === Fragment) { vnode.children.forEach((node) => unmount(node)); return; } // 根据vnode获取要卸载的真实dom元素 // 获取el的父级元素 const parent = vnode.el.parentNode; if (parent) { parent.removeChild(vnode.el); }}后面再说 diff
diff 算法
// todo…