我报名参加金石方案1期挑战——瓜分10万奖池,这是我的第2篇文章,点击检查活动详情
hey! 我是小黄瓜。不定期更新,期待重视➕ 点赞,一同生长~
写在前面
本文的目标是完成一个基本的 vue3
的虚拟DOM的节点烘托与更新,包含最根底的状况的处理,本文是系列文章,本系列已全面运用vue3组合式语法,假如你对 vue3
根底语法及呼应式相关逻辑还不了解,那么请移步:
超详细整理vue3根底知识
狂肝半个月!1.3万字深度剖析vue3呼应式(附脑图)
手写mini-vue3第三弹!万字完成烘托器初次烘托流程
大结局!完成vue3模版编译功用
本文仅仅整个vue3烘托器的下篇内容,包含组件及节点的更新,更新优化,diff算法的内容。
食用提示!必看
看本篇之前必须要看上一篇初次烘托!!!
因为整个烘托过程中的函数完成以及流程过长,有许多函数的完成内容在相关的章节并不会悉数展示,而且存在大量的伪代码,相关章节只会重视当时功用代码的显现和完成。
可是!为了便于了解,我在github
上上传了每章节的详细完成。(请把交心打在评论区 )把每一章节的完成都存放在了独自的文件夹:
只运用了单纯的html
和js
来完成功用,只需求在index.html
中替换相关章节的文件路径(替换以下三个文件),在浏览器中翻开,就能够自行调试和检查代码。见下图:
地址在这儿,欢迎star!
vue3-analysis(component)
mini-vue3的正式项目地址在这儿!现在只完成了呼应式和烘托器部分!
k-vue
欢迎star!
本文你将学到
- 更新
element
- 更新
component
-
diff
算法 - 最长递加子序列应用
- 完成
nextTick
一. 更新element流程建立
在完成更新之前,有必要先来想一下,到底怎样算是更新?怎样算是触发更新的操作?在上一篇文章中,咱们只完成了初度烘托,也便是翻开页面默许烘托出来的内容,不依靠动作去触发的部分。在这一部分,咱们首要履行了setup
函数,把它当作数据源来初始化render
函数进行烘托,依据vnode
的特点值来顺次生成DOM节点,DOM特点,递归烘托子节点。最终将整个vnode
烘托到页面上。也便是说现在完成的烘托仅仅纯静态的。
所谓更新便是在用户进行手动触发时,当数据产生改动时,DOM节点产生时对页面上的DOM元素进行修正或许增加或许删去的操作。运用原生的DOM的API来操作DOM时十分的简单,无论你怎样产生改动,我直接拼接DOM字符串,运用innerHTML
怼上去就完事了。可是在vue
这种老练的框架处理这种问题时,需求考虑的问题比单纯的完成要多的多。比如最直观的问题,假如咱们的DOM层级比较深,整个页面结构十分杂乱,直接运用拼接烘托的方法并不高明,这意味着无论我做了多么小的改动,都会将页面从头烘托一遍,这样带来的功用损耗将是十分惊人的。那么怎样进行优化就将会是整个更新DOM过程中十分重要的工作。本文的后半部分的一大核心问题也便是处理更新的优化部分。
说了这么多,还有一个最核心的问题没有处理,怎样能知道数据更新了呢?关于更新时优化这都是后话了,首要咱们得知道数据啥时分更新了才行,否则啥都白扯。更新的核心其实便是当数据产生改动时,从头履行render
函数进行比照差异,然后烘托到页面上。等等,这句话怎样这么耳熟?这不便是呼应式的完成逻辑吗!(对呼应式不太熟悉的老铁们请先学习一下呼应式部分)特别是咱们的数据都是以ref
或许reactive
函数进行包裹的,那么只需求将从头履行render
函数部分逻辑放在effect
函数内就能够了。这样就能够达到数据与更新DOM的逻辑想绑定,数据更新,视图更新的功用了。
在此之前,仍是先来完成一个比如:
const App = {
setup() {
let value = reactive({
count: 0
})
// 点击按钮count+1
const changeCount = ()=>{
value.count = value.count + 1
}
return {
value,
changeCount
}
},
render() {
return h('div', { id: 'root' }, [
h('p', {}, 'count:' + this.value.count),
h('button', { onClick: this.changeCount, }, 'change')
])
}
}
上面的比如中咱们完成了一个按钮,点击按钮呼应式数据value.count
将会加一,相应的,页面中也会触发DOM更新的相关逻辑。
咱们在初始化component
实例的时分初始化特点isMounted
,用于标识是否处于更新状况
const createComponentInstance = function (vnode, parent) { // 修正
const component = {
vnode,
type: vnode.type,
props: {},
setupState: {},
provides: parent ? parent.provides : {},
parent: parent ? parent : {},
// 是否初次烘托?
isMounted: false, // 新增
subTree: {},
slots: {},
emit: () => {}
}
component.emit = emit.bind(null, component)
return component
}
接下来找到履行render
函数的当地,绑定呼应式数据的履行函数effect
:
const setupRenderEffect = function (instance, vnode, container) {
effect(()=>{
// 依据isMounted来判别现在处于更新/初始化?
if(!instance.isMounted) {
console.log("init");
const { proxy } = instance;
const subTree = (instance.subTree = instance.render.call(proxy));
patch(null, subTree, container, instance);
vnode.el = subTree.el;
instance.isMounted = true;
} else {
console.log("update");
const { proxy } = instance
// render函数履行成果
const subTree = instance.render.call(proxy)
const prevSubTree = instance.subTree
// 更新subTree
instance.subTree = subTree;
// 传入patch
patch(prevSubTree, subTree, container, instance)
}
})
}
在履行setupRenderEffect
函数时将会履行那几件工作呢,首要运用component
中的subTree
来保存上次的履行成果,用于调用patch
函数时,当作旧的vnode
参加比照操作,最终需求将新的vnode
来更新特点subTree
。
因为咱们的patch
函数参数又产生了变更,加入了上一次更新的vnode
,所以需求再次对一切调用patch
函数的当地进行参数修正:
render
:
const render = function (vnode, container) {
// 初次烘托,第一个参数传递null
patch(null, vnode, container, null) // 修正
}
patch
:
// n2 代表当时处理的vnode
const patch = function (n1, n2, container, parentComponent) { // 修正
const { type, shapeFlag } = n2 // 修正
switch(type) {
case Fragment:
processFragment(n1, n2, container, parentComponent); // 修正
break
case Text:
processText(n1, n2, container); // 修正
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, parentComponent) // 修正
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container, parentComponent) // 修正
}
break
}
}
processFragment
:
const processFragment = function(n1, n2, container, parentComponent) { // 修正
mountChildren(n2.children, container, parentComponent) // 修正
}
processText
:
const processText = function(n1, n2, container) { // 修正
const { children } = n2 // 修正
const textVNode = (n2.el = document.createTextNode(children)) // 修正
container.append(textVNode)
}
mountChildren
:
const mountChildren = function (children, container, parentComponent) {
children.forEach(v => {
// 初次烘托无需传递第一个参数
patch(null, v, container, parentComponent) // 修正
})
}
因为在处理element
的时分初始化的烘托和更新是不同的,所以在这个函数中需求进行别离处理,假如没有传递n1
,则阐明是初次烘托,反之则是更新。
const processElement = function (n1, n2, container, parentComponent) { // 修正
if(!n1) { // 修正
mountElement(n2, container, parentComponent) // 修正
} else { // 修正
patchElement(n1, n2, container) // 修正
}
}
在patchElement
中打印一下更新时的vnode
:
const patchElement = function(n1, n2, container) {
console.log(n1);
console.log(n2);
}
能够看到依据上面的比如,点击按钮后,输出了两个vnode
,里边的children
是不同的,这正是需求更新的内容。
二. 更新element 的props
更新props
,也便是更新DOM特点,其实首要便是来过那种状况,旧的vnode
与新的vnode
不共同,直接设置新的props
,假如新的props
中没有旧的props
,直接进行删去操作。
接下来仍是国际惯例,首要完成一个案例,用需求来驱动功用完成:
const App = {
setup() {
let props = reactive({
foo: 'foo',
bar: 'bar'
})
const changeProps1 = () => {
props.foo = 'new-foo'
}
const changeProps2 = () => {
props.foo = undefined
}
return {
props,
changeProps1,
changeProps2,
}
},
render() {
return h('div', { id: 'root', ...this.props }, [
h("button", { onClick: this.changeProps1 }, "修正"),
h("button", { onClick: this.changeProps2 }, "删去"),
h("button", { onClick: this.changeProps3 }, "增加"),
])
}
}
咱们在div
中设置了特点foo
和特点bar
,而且界说了两个按钮,别离对特点进行修正和删去操作。
关于props
的处理,咱们是在mountElement
函数中进行的,因为处理特点的过程还需求在别的当地进行运用,所以需求将本来存在于mountElement
函数中的逻辑抽离出来,用于处理更新props
目标。
const mountElement = function (vnode, container, parentComponent) {
// 省掉...
// props
for (const key in props) {
const prop = props[key]
// 初次烘托只需求传递更新后的特点值
mountProps(el, key, null, prop) // 修正
}
container.append(el)
}
// 修正
// mountProps第三个参数为更新前的props的key值,第四个参数nextVal为更新后
const mountProps = function(el, key, prevVal, nextVal) {
const isOn = key => /^on[A-Z]/.test(key)
// 运用on进行绑定事情
if(isOn(key)) {
const event = key.slice(2).toLowerCase()
el.addEventListener(event, nextVal)
} else {
// 增加判别,假如新的props为null或许undefined。那么依据key来删去DOM特点
if(nextVal === undefined || nextVal === null) {
el.removeAttribute(key)
} else {
el.setAttribute(key, nextVal)
}
}
}
抽离出来mountProps
函数,首要负责处理props
目标生成DOM特点,增加判别当时特点是否存在,假如不存在直接依据key
来删去DOM特点。
接下来在patchElement
处理更新:
const EMPTY_OBJ = {}
const patchElement = function(n1, n2, container) {
// 取出新旧vnode中的props
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
// 更新el
const el = (n2.el = n1.el)
// 调用patchProps找出差异
patchProps(el, oldProps, newProps)
}
关于为啥要创立一个空目标,然后作为新旧props
的默许值,其实意图是让新旧的props
在运用默许值目标的时分能够运用同一个引证,便于在下文中运用==来判别新旧目标的值。
const patchProps = function(el, oldProps, newProps) {
// 两个目标不相等时,才会进行比照
if(oldProps !== newProps) {
// 循环新的props
for(let key in newProps) {
// 依据新的props中的key别离在新旧props中取值
const prevProp = oldProps[key]
const nextProp = newProps[key]
// 不想等则进行更新
if(prevProp !== nextProp) {
mountProps(el, key, prevProp, nextProp)
}
}
// 判别是否存在需求删去的特点
if(oldProps !== EMPTY_OBJ) {
for(let key in oldProps) {
if(!(key in newProps)) {
mountProps(el, key, oldProps[key], null)
}
}
}
}
}
在进行比照的时分,首要遍历新的props
目标,取一切的key
值在新旧的props
中进行查找,假如不相等,直接调用mountProps
函数进行更新。接下来处理旧的props
中存在而心的props
节点不存在的状况,遍历旧props
判别是否存在于新props
中,不存在直接调用mountProps
函数进行删去。
初次烘托:
点击修正,修正foo
特点后:
foo
特点的值更新为new-foo
点击删去,将foo
特点删去后:
页面中foo
特点现已被删去。
三. 更新 children
在处理完props
的更新后,接下来开端着手处理children
的更新,因为咱们生成vnode
时只支持字符串和数组的方法来创立子节点,字符串代表文本节点,数组代表子节点,所以在处理更新时也就只存在四种更新状况:
- Array -> String
- String -> String
- String -> Array
- Array -> Array
因为Array -> Array
较为杂乱,咱们先来处理与文本节点相关的更新操作。
依旧是先来写一个:
// ex1 Array -> String
const prevChild = [h("div", {}, "A"), h("div", {}, "B")];
const nextChild = "newChildren";
const App = {
setup() {
// 界说呼应式数据
const isChange = reactive({
value: false
})
// 挂载到大局目标window上
window.isChange = isChange;
return {
isChange
}
},
render() {
let self = this
// isChange.value的值产生改动,更新children
return self.isChange.value === true ?
h('div', {}, nextChild) :
h('div', {}, prevChild)
}
}
创立呼应式数据isChange.value
,当该数据产生改动时,更改children
,把isChange
挂载到window
上的原因是,能够在控制台经过打印直接运用window.isChange.value = true
来更该数据,触发更新。
处理children
更新的逻辑仍是在patchElement
函数中触发:
const patchElement = function(n1, n2, container, parentComponent) {
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
const el = (n2.el = n1.el)
// children update
patchChildren(n1, n2, el, parentComponent) // 修正
// props update
patchProps(el, oldProps, newProps)
}
那么当存在子节点时,更新为文本节点怎样来处理呢?答案是将之前的子节点悉数删去,然后从头设置文本节点。
const patchChildren = function(n1, n2, container, parentComponent) {
// 取出新旧节点的shapeFlag
const prevShapFlag = n1.shapeFlag
const c1 = n1.children
const c2 = n2.children
const { shapeFlag } = n2
// 当新的vnode的children为string类型时
if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 之前children为array
if(prevShapFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(n1.children)
}
// 判别新旧children是否共同?
if(c1 !== c2) {
setElementText(container, c2)
}
}
}
首要取出新旧vnode
的shapeFlag
用于子节点的类型判别,假如更新类型为Array->String
,那么会删去本来的节点,然后设置新的文本节点。
删去子节点的操作长这样:
const unmountChildren = function(children) {
// 循环children
for(let i = 0; i < children.length; i++) {
// 找到el
let el = children[i].el
// 根绝DOM节点找到其父节点
let parent = el.parentNode
// 删去
if(parent) parent.removeChild(el)
}
}
循环查找每个子节点的el
特点,也便是节点的真实DOM节点,依据parentNode
获取父节点,然后进行删去。
最终设置文本节点:
const setElementText = function(el, text) {
el.textContent = text
}
其实上面的代码也现已将String->String
完成了,因为进入旧的children
为String
这个逻辑判别后,假如新的children
也为String
而且不相等,那么就会触发setElementText
函数,更新文本节点。
将上面比如中的新旧vnode
替换成:
// ex3 Text -> Text
const prevChild = "oldChildren"
const nextChild = "newChildren"
页面也会成功更新。
最终Text -> Array
的逻辑也很简单,运用setElementText
函数清空文本节点,然后运用mountChildren
来从头烘托children
。
const patchChildren = function(n1, n2, container, parentComponent) {
const prevShapFlag = n1.shapeFlag
const c1 = n1.children
const c2 = n2.children
const { shapeFlag } = n2
if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if(prevShapFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(n1.children)
}
if(c1 !== c2) {
setElementText(container, c2)
}
} else {
// 旧children为string
if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(container, "")
mountChildren(c2, container, parentComponent)
}
}
}
从头设置新旧vnode
:
// ex2 Text -> Array
const prevChild = "oldChildren"
const nextChild = [h("div", {}, "A"), h("div", {}, "B")];
页面也现已成功进行更新。
四. 更新children – 两头比照diff算法
上文中咱们现已处理了和children
为string
类型相关的更新操作。现在只剩下了重头戏,也便是节点更新时需求优化的要点。便是Array -> Array
这种状况,因为在vnode
的更新操作中,节点的层级通常是十分多的,不行能直接将一切的旧节点悉数删去,然后从头将新的vnode
烘托出来。所以diff算法就出现了,旨在最小化的变更DOM。
相同咱们也会将节点的比照划分为几种状况。首要处理的是需求增加或许删去的状况。
增加指的是新旧的vnode
的差异只存在于新的vnode
在后边增加了几个子节点,除此之外其他的节点悉数共同,比如咱们以ABC当作节点为例:
此次更新增加了DE节点,除此之外和旧的vnode
共同。
此次变更,新的vnode
在前面增加了AB子节点,除此之外其他的节点与之前共同。
这种更新其实逻辑仍是比较简单的,无非便是找到新增加的节点,然后创立DOM就完事了,可是怎样去找这就又是一门学问了,前面现已说过了,为了寻求功用上满足的好,咱们不行能去每一个节点都拿往来不断新旧循环比照,这样的价值太大了,不如有很小的改动,就要循环整个vnode
,不免有点太蠢了。那么怎样能够高效的找到增加节点开端的方位呢?
其实最首要的意图便是要确认增加的节点规模,咱们能够运用双端指针。
界说指针i,它的作用是从开端节点开端向后进行比照,假如新旧节点在i方位相同,那么i++,往前走一步,即new[i] === old[i]
,这儿咱们运用new
和old
来表明新旧节点。
界说指针e1
和e2
,e1
代表指向旧的vnode
最终一个子节点,而e2
代表指向新的vnode
最终一个子节点,也便是别离指向新旧vnode
的末端。然后顺次进行比照,假如相同,则撤退一步,即old[e1] === new[e2]
。
假如i指针暂停,那么代表找到了不同节点的开端方位,而e1
和e2
指针的暂停,则表明找到了新旧vnode
不同节点的完毕方位。
依据上面的比如,咱们找到了新旧节点差异的开端D和完毕E。
这中心还有一个关键的问题,怎样判别两个节点是相同的?
答案是:现在的做法是运用特点key
和type
。看见没,这便是日常开发中在for
遍历循环的时分写key
的重要性,真的是实打实的提升功用啊。
接下来就到了完成代码的环节了,在此之前,仍是先写一个小比如:
// 界说新旧children
const prevChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
];
const nextChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
];
const App = {
setup() {
// 设置呼应式数据
const isChange = reactive({
value: false
})
window.isChange = isChange;
return {
isChange
}
},
render() {
let self = this
// 呼应式数据改动时,更新children
return self.isChange.value === true ?
h('div', {}, nextChild) :
h('div', {}, prevChild)
}
}
更新的逻辑仍是在patchChildren
中来完成,上文中咱们现已完成了和文本节点相关的逻辑:
const patchChildren = function(n1, n2, container, parentComponent, anchor) { // 修正
// 省掉...
if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 省掉...
} else {
// 当时children为array
if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(container, "")
mountChildren(c2, container, parentComponent, anchor) // 修正
} else {
// Array -> Array
// diff children
patchKeyedChildren(c1, c2, container, parentComponent, anchor) // 修正
}
}
}
patchKeyedChildren
函数便是咱们处理更新的战场。
第一步,首要依据指针确认开端/完毕方位:
function patchKeyedChildren(
c1,
c2,
container,
parentComponent
) {
const l2 = c2.length;
// 界说前指针i
let i = 0;
// 界说后指针e1,e2
// 别离指向新旧节点的尾部
let e1 = c1.length - 1;
let e2 = l2 - 1;
// 判别是否为相同节点
function isSomeVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
// 移动i
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
// 假如为相同节点,则递归调用patch,因为此子节点纷歧定为最终的文本节点
if (isSomeVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
i++;
}
// e1, e2前移
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSomeVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
e1--;
e2--;
}
}
当i和e1,e2都移动完毕后,此刻三个指针的方位别离为:
i === 3
e1 === 2
e2 === 4
这三个指针的方位能够阐明许多问题,能够反映新旧vnode
的差异到底是什么样的状况:
- 当i指针大于e1指针,小于等于e2指针时,代表需求创立新节点,此刻从e1指针后边的方位开端创立新节点即可,完毕方位坐落e2。
- 当i指针小于e1指针而且大于e2指针时,代表需求删去多余的旧节点。
- 剩下的状况代表差异方位不在开端或许完毕方位,而在中心方位(此状况下文完成)。
拿我上面的来看:
// 旧vnode
const prevChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
];
// 新vnode
const nextChild = [
h("p", { key: "D" }, "D"),
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
];
// 指针方位别离为
i === 0
e1 === -1
e2 === 0
// 旧vnode
const prevChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
];
// 新vnode
const nextChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
];
// 指针方位别离为
i === 3
e1 === 2
e2 === 4
全都契合咱们第一种状况。
完成前两种状况:
function patchKeyedChildren(
c1,
c2,
container,
parentComponent,
parentAnchor
) {
const l2 = c2.length;
let i = 0;
let e1 = c1.length - 1;
let e2 = l2 - 1;
function isSomeVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSomeVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
i++;
}
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSomeVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
e1--;
e2--;
}
if (i > e1) {
if (i <= e2) {
// 获取刺进DOM节点的方位
const nextPos = e2 + 1;
// 此刺进方位的核算首要针对坐落vnode前端增加节点的状况
// 在vnode后端增加节点直接传入null,作用等同于append
const anchor = nextPos < l2 ? c2[nextPos].el : null;
while (i <= e2) {
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
} else if (i > e2) {
while (i <= e1) {
remove(c1[i].el);
i++;
}
} else {
// 中心比照
}
}
// 依据父节点删去
const remove = function(child) {
const parent = child.parentNode
if(parent) parent.removeChild(child)
}
还记得之前咱们在mountElement
函数中挂载DOM时,是直接运用的append
,现在来看是不契合现在的要求的,试想,假如咱们在vnode
的前端增加节点,你还能都给我加到末尾吗?明显是不合理的。
所以咱们需求记载一个坐标,用于DOM的insert
刺进节点,依据上面咱们指针的移动规则,这个坐标记载e2
的下一位即可,因为咱们要在e2
的方位增加节点,而insertBefore
是增加到指定节点之前,所以咱们要记载e2
指针的下一个方位。
先修正mountElement
函数的挂载方法:
const mountElement = function (vnode, container, parentComponent, anchor) { // 修正
// 省掉...
// props
for (const key in props) {
const prop = props[key]
mountProps(el, key, null, prop)
}
// container.append(el)
insert(container, el, anchor) // 修正
}
// 刺进
const insert = function(parent, child, anchor) {
parent.insertBefore(child, anchor || null)
}
因为咱们有增肌了参数anchor
,而patch
函数和mountElement
函数并不直接有调用关系,所以…又是一轮大规模的参数传递…
render
函数:
const render = function (vnode, container) {
// 初始化烘托不需求传递坐标
patch(null, vnode, container, null, null) // 修正
}
patch
函数:
const patch = function (n1, n2, container, parentComponent, anchor) { // 修正
const { type, shapeFlag } = n2
switch(type) {
case Fragment:
processFragment(n1, n2, container, parentComponent, anchor); // 修正
break
case Text:
processText(n1, n2, container);
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, parentComponent, anchor) // 修正
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container, parentComponent, anchor) // 修正
}
break
}
}
processFragment
函数:
const processFragment = function(n1, n2, container, parentComponent, anchor) { // 修正
mountChildren(n2.children, container, parentComponent, anchor) // 修正
}
processElement
函数:
const processElement = function (n1, n2, container, parentComponent, anchor) { // 修正
if(!n1) {
mountElement(n2, container, parentComponent, anchor) // 修正
} else {
patchElement(n1, n2, container, parentComponent, anchor) // 修正
}
}
patchElement
函数:
const patchElement = function(n1, n2, container, parentComponent, anchor) { // 修正
// 省掉...
// children update
patchChildren(n1, n2, el, parentComponent, anchor) // 修正
// props update
patchProps(el, oldProps, newProps)
}
patchChildren
函数:
const patchChildren = function(n1, n2, container, parentComponent, anchor) { // 修正
// 省掉...
if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 省掉...
} else {
// 当时children为array
if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(container, "")
mountChildren(c2, container, parentComponent, anchor) // 修正
} else {
// Array -> Array
// diff children
patchKeyedChildren(c1, c2, container, parentComponent, anchor) // 修正
}
}
}
mountElement
函数:
const mountElement = function (vnode, container, parentComponent, anchor) { // 修正
// 省掉...
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
el.textContent = children
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el, parentComponent, anchor) // 修正
}
// props
for (const key in props) {
const prop = props[key]
mountProps(el, key, null, prop)
}
// container.append(el)
insert(container, el, anchor) // 修正
}
mountChildren
函数:
const mountChildren = function (children, container, parentComponent,anchor) { // 修正
children.forEach(v => {
patch(null, v, container, parentComponent, anchor) // 修正
})
}
processComponent
函数:
const processComponent = function (n1, n2, container, parentComponent, anchor) { // 修正
mountComponent(n2, container, parentComponent, anchor) // 修正
}
mountComponent
函数:
const mountComponent = function (vnode, container, parentComponent, anchor) { // 修正
// 创立组件实例
const instance = createComponentInstance(vnode, parentComponent)
setupComponent(instance)
setupRenderEffect(instance, vnode, container, anchor) // 修正
}
setupRenderEffect
函数:
const setupRenderEffect = function (instance, vnode, container, anchor) { // 修正
effect(()=>{
if(!instance.isMounted) {
// 省掉...
patch(null, subTree, container, instance, anchor); // 修正
vnode.el = subTree.el;
instance.isMounted = true;
} else {
// 省掉...
instance.subTree = subTree;
patch(prevSubTree, subTree, container, instance, anchor) // 修正
}
})
}
完整代码见:github.com/konvyi/vue3…
五. 更新children – 中心比照(修正/删去)
处理完两头的节点,再看看一下中心节点,这种状况是指差异的部分坐落中心。
处理中心节点的差异也有三种状况:
- 删去
- 新建
- 修正
- 复用
咱们先来看一下删去节点和修该节点的状况。
删去节点是指节点存在于旧的vnode
中,而不存在新的vnode
中,修正是指节点是相同的(具有相同的key
,而修正节点的props
或许children
)。
例如:
//旧
const prevChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "Z" }, "Z"),
h("p", { key: "C", id: "c-prev" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
// 新
const nextChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C", id:"c-next" }, "C"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
在上面这个比如中,咱们删去了Z和D节点,修正了C节点的id
特点。两头的节点是相同的。
在patchKeyedChildren
这个函数中,咱们现已处理完了双端比照:
function patchKeyedChildren(
c1,
c2,
container,
parentComponent,
parentAnchor
) {
// 省掉...
if (i > e1) {
// 省掉...
} else if (i > e2) {
// 省掉...
} else {
// 既不大于e1,也不大于e2,阐明差异的开端方位坐落中心
// 中心比照
let s1 = i
let s2 = i
// 核算需求处理的长度
const toBePatched = e2 - s2 + 1
// 当时处理的方位
let patched = 0
// map映射,用于保存新vnode中的节点方位
const keyToNewIndexMap = new Map()
// 保存
for(let i = s2; i <= e2; i++) {
let nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key, i)
}
// 遍历旧vnode
for(let i = s1; i <= e1; i++) {
const prevChild = c1[i]
// 优化,假如patched的值大于需求处理的长度
// 代表旧的剩下的需求删去
if(patched >= toBePatched) {
remove(prevChild.el)
continue
}
let newIndex
// 首要判别是否在map映射中查找到key值,
// 假如没有查找到则只能遍历一切新vnode查找
if(prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
for(let j = s2; j <= e2; j++) {
if(isSomeVNodeType(prevChild, c2[j])) {
newIndex = j
break
}
}
}
// 没有查找到下标,直接删去旧节点
if(newIndex === undefined) {
remove(prevChild.el)
} else {
// 查找到则进一步递归比照,patched++
patch(prevChild, c2[newIndex], container, parentComponent, null)
patched++
}
}
}
}
删去的思路只要是验证旧的vnode
节点是否坐落新的vnode
之中,两种方法来验证,一是运用key
与新节点的下标进行映射,当便当旧节点时,运用旧节点的key
进行查找,假如旧节点中没有key
,则是能遍历一切新节点进行寻找。(再一次印证了key
的重要性)至于节点中props
与children
的更新,直接走patch
函数中的更新流程的即可。
接下来就瓜熟蒂落了,能够查找到就继续调用patch
进行深层比照,没有查找到则阐明在新的vnode
中该节点现已不存在,直接删去。
六. 更新children – 中心比照(增加/移动)
可能有许多人难以了解标题的内容,增加节点能够了解,可是移动是怎样一回事?
要答复这个问题,仍是先看一下这个:
const prevChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
h("p", { key: "Z" }, "Z"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
const nextChild = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "D" }, "D"),
h("p", { key: "C" }, "C"),
h("p", { key: "Y" }, "Y"),
h("p", { key: "E" }, "E"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
const App = {
setup() {
const isChange = reactive({
value: false
})
window.isChange = isChange;
return {
isChange
}
},
render() {
let self = this
return self.isChange.value === true ?
h('div', {}, nextChild) :
h('div', {}, prevChild)
}
}
这个比如其实和之前的更新思路是共同的(开端和完毕方位的节点未产生改动),只不过产生改动的节点方位产生了改动:
// 旧的vnode
h("p", { key: "C" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
h("p", { key: "Z" }, "Z"),
// 新的vnode
h("p", { key: "D" }, "D"),
h("p", { key: "C" }, "C"),
h("p", { key: "Y" }, "Y"),
h("p", { key: "E" }, "E"),
调查上面的节点可知,Z节点为需求删去的节点,Y节点是需求创立的节点,而C,D,E节点都是未产生改动的,假如咱们仅仅单纯的将一切开端与完毕节点不同的一段vnode
悉数删去重建,不免有点太浪费了,怎样高效的对一切现已创立的节点进行运用呢?其实这就答复了上面的问题,移动。
如图所示,其实刨开Z的删去和Y的新增,只需求将D移动至开端就能够了,最小化的修正了咱们的DOM。
function patchKeyedChildren(
c1,
c2,
container,
parentComponent,
parentAnchor
) {
// 省掉...
if (i > e1) {
// 省掉...
} else if (i > e2) {
// 省掉...
} else {
// 中心比照
let s1 = i
let s2 = i
const toBePatched = e2 - s2 + 1
let patched = 0
const keyToNewIndexMap = new Map()
// 用于保存需求处理的节点下标
const newIndexToOldIndexMap = new Array(toBePatched) // 新增
// 数组补0
for (let i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 新增
for (let i = s2; i <= e2; i++) {
let nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key, i)
}
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
remove(prevChild.el)
continue
}
let newIndex
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
for (let j = s2; j <= e2; j++) {
if (isSomeVNodeType(prevChild, c2[j])) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
remove(prevChild.el)
} else {
// 依据新vnode中的方位来保存旧vnode节点的下标
newIndexToOldIndexMap[newIndex - s2] = i + 1 // 新增
patch(prevChild, c2[newIndex], container, parentComponent, null)
patched++
}
}
// 获取最长递加子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap) // 新增
let j = increasingNewIndexSequence.length - 1
for (let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = i + s2
const nextChild = c2[nextIndex]
// 刺进节点,记载锚点
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null
// 假如在newIndexToOldIndexMap为0,则阐明未找到newIndex,为新增节点
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor)
} else {
if (j < 0 || increasingNewIndexSequence[j] !== i) {
insert(container, nextChild.el, anchor)
} else {
j--
}
}
}
}
}
首要界说一个数组用来存储需求更新的节点下标,初始化时运用0进行填充,假如能够获取到newIndex
的话,阐明该节点在新旧的vnode
中同时存在,咱们会对这种节点的下标进行填充,而假如是新增节点的话,咱们并不能处理到数组中为0的值,也便是该方位不会被填充,仍然为0,这也是下文中咱们判别新增节点与移动节点的依据。
接下来就到了移动节点的操作,这需求咱们筛选出到底不需求移动的是哪些?这需求一个重要的辅佐函数-最长递加子序列。
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
关于这个函数是怎样完成的,因为本篇篇幅有点太长了,我预备在vue3系列完毕之后独自写一篇文章,这儿其实对了解整个更新过程影响不是特别大,只需求知道这个函数能够将记载数组中整个递加的数字的下标,就拿咱们上文中的比如来说:
// 记载完下标后
newIndexToOldIndexMap = [4, 3, 0, 5]
// 这也就对应了
[D, C, Y, E]
[4, 3, 0, 5]
// Y为0,代表新增
// 其他别离代表在旧的vnode中的下标方位
经过处理之后:
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
increasingNewIndexSequence = [1, 3]
代表节点C,E是无需改变的节点。
最终倒序移动需求移动的节点D就能够了。
七. 完成组件更新
以上的更新都是属于element
的更新,那么componet
(组件)怎样进行更新呢?
比如有以下:
const App = {
setup() {
// 界说呼应式数据
let count = reactive({
value: 'pino'
})
// 修正count.value
let changeCount = function() {
count.value = 'new-pino'
}
return {
count,
changeCount
}
},
render() {
return h('div', {}, [
h('p', {}, 'App'),
// 子组件,设置props
h(Child, { count: this.count.value }),
h('button', { onClick: this.changeCount }, 'change')
])
}
}
const Child = {
setup() {},
render() {
// 运用this.$props运用props数据
return h('div', {}, `Child->props:${this.$props.count}`)
}
}
在组件App
中,咱们设置了呼应式数据count
并将其作为props
传递给子组件Child
,当点击按钮change
时分。改动count
的值,那么在子组件Child
中的值也应该产生改动。这便是咱们完成的作用。
依据上面的剖析不难看出,其实首要更新的便是组件的props
特点。
首要为获取数据时增加props
拦截:(此为上文中的内容,拜见上一篇初次烘托的文章)
const publicPropertiesMap = {
$el: i => i.vnode.el,
$slots: i => i.slots,
// 增加$props拦截,经过$props来访问props数据
$props: i => i.props // 增加
}
在component
实例中增加next
特点,用于保存最新更新的vnode
:
const component = {
vnode,
type: vnode.type,
next: null, // 新增
props: {},
setupState: {},
provides: parent ? parent.provides : {},
parent: parent ? parent : {},
isMounted: false,
subTree: {},
slots: {},
emit: () => {}
}
在创立vnode
时增加component
特点用于保存component
特点:
const vnode = {
type,
props,
children,
component: null, // 增加
key: props && props.key,
shapeFlag: getShapeFlag(type),
el: null,
}
在processComponent
函数中判别是否存在n1
,假如存在的话则阐明为更新操作,需求处理更新的逻辑,假如没有n1
则阐明是初次烘托:
const processComponent = function (n1, n2, container, parentComponent, anchor) {
if(!n1) { // 修正
mountComponent(n2, container, parentComponent, anchor) // 修正
} else { // 修正
// 处理更新
updateComponent(n1, n2) // 修正
} // 修正
}
在初次烘托时保存component
实例:
const mountComponent = function (vnode, container, parentComponent, anchor) {
// 创立组件实例
const instance = (vnode.component = createComponentInstance(vnode, parentComponent)) // 修正
setupComponent(instance)
setupRenderEffect(instance, vnode, container, anchor)
}
updateComponent
函数处理组件更新逻辑:
const updateComponent = function(n1, n2) {
// 获取component实例
const instance = (n2.component = n1.component)
// 判别是否需求更新?
if(shouldUpdateComponent(n1, n2)) {
// 保存最新vnode
instance.next = n2;
// 更新DOM
instance.update();
} else {
// 不需求更新则复用el
// 将n2设置为实例的vnode特点(更新vnode)
n2.el = n1.el;
instance.vnode = n2;
}
}
此刻的n1
和n2
(新旧vnode):
此刻props
中的特点count
现已产生了改动。
整个更新函数还有两个关键点,怎样判别是需求更新?怎样进行更新?
判别更新便是将新的props
目标进行遍历,再与旧的props
进行比照,假如不相同则需求更新。
const shouldUpdateComponent = function(prevVNode, nextVNode) {
const { props: prevProps } = prevVNode;
const { props: nextProps } = nextVNode;
// 遍历新的props
for (const key in nextProps) {
// 假如有不同的特点,回来true(需求更新)
if (nextProps[key] !== prevProps[key]) {
return true;
}
}
return false;
}
判别是完成了,那么怎样更新呢,因为更新props
不行能仅仅把实例中的特点更改了就完事了,还需求再对页面中的作用进行更新,因为最终是要显现在页面上的。
能够想一下咱们在element
的更新中是怎样更新页面的呢?是用effect
进行绑定函数的方法,监听到数据的改动,再次履行烘托函数履行render
函数的。那么咱们的component
的更新是不是也能够借用呼应式呢?
答案当然是能够的,咱们在初次烘托的时分将effect
函数进行保存,在需求更新的时分调用就能够了。还记得在呼应式那一节中effect
函数的回来值是啥吗,调用effect
函数的回来值还能够履行依赖函数,这也就完成了更新页面的功用。(不熟悉呼应式能够拜见呼应式那一部分的文章)
const setupRenderEffect = function (instance, vnode, container, anchor) {
// 保存effect函数
instance.update = effect(() => { // 修正
if (!instance.isMounted) {
console.log("init");
const { proxy } = instance;
const subTree = (instance.subTree = instance.render.call(proxy));
patch(null, subTree, container, instance, anchor);
vnode.el = subTree.el;
instance.isMounted = true;
} else {
console.log("update");
const { next, vnode } = instance // 增加
if(next) { // 增加
next.el = vnode.el // 增加
// 在履行更新页面之前首要要先更新component实例中的各个特点
updateComponentPreRender(instance, next) // 增加
} // 增加
const { proxy } = instance
const subTree = instance.render.call(proxy)
const prevSubTree = instance.subTree
instance.subTree = subTree;
patch(prevSubTree, subTree, container, instance, anchor)
}
})
}
const updateComponentPreRender = function(instance, nextVNode) {
instance.vnode = nextVNode
instance.next = null
instance.props = nextVNode.props
}
八. 完成nextTick功用
尽管更新功用现已完成的差不多了,可是其实在履行的时机上仍是有很大的问题,比如咱们有以下的:
const App = {
setup() {
// 界说呼应式数据count
let count = reactive({
value: 0
})
//
let changeCount = function() {
for(let i = 0; i < 10; i++) {
count.value = count.value + 1
}
}
return {
count,
changeCount
}
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count.value}`),
h('button', { onClick: this.changeCount }, 'update')
])
}
}
在这个比如中,咱们经过点击按钮update
来触发changeCount
函数,changeCount
函数中循环为count
增加10次。
咱们在更新逻辑时打印”update”,发现更新逻辑竟然履行了10次,也便是出发了10次更新DOM的操作,哪怕产生的改动仅仅仅仅把呼应式数据加一!
这显然是十分离谱的,那么怎样能够削减更新呢,以上面的比如为例,其实咱们只在循环完毕后履行一次更新DOM的操作就能够了。那么其实处理的方案也很简单,把更新操作放到微使命行列就能够了嘛。微使命是等到一切的同步使命悉数履行完毕后才会履行,那么等到微使命里边的更新操作开端履行时,咱们的循环当然现已履行完毕了,所以更新操作只会履行一次。
可是假如故事到这儿就完毕其实也挺美好的,可是工作往往都不会一帆风顺,假如像咱们上面的主意完成的话,那么又会出现一个新的问题,看下边的:
const App = {
setup() {
let count = reactive({
value: 0
})
let changeCount = function() {
for(let i = 0; i < 10; i++) {
count.value = count.value + 1
}
// 获取当时component实例
const instance = getCurrentInstance()
console.log(instance, 'instance');
}
return {
count,
changeCount
}
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count.value}`),
h('button', { onClick: this.changeCount }, 'update')
])
}
}
假如咱们在for循环后边直接获取最新的component
实例,因为咱们的DOM更新操作放到了微使命里边,那么获取的当时实例天然不会是最新的,可是咱们在呼应式数据改动之后获取实例本意肯定是想获取最新的component
实例,可是因为咱们更新DOM操作的延后,导致正常的功用会受到影响。
那么怎样处理呢?
还记得nextTick
吗?
this.$nextTick(() => {
console.log(getCurrentInstance())
})
在nextTick
里边能够获取当最新的DOM。
nextTick
的完成也很直接,既然你把更新DOM的操作放到了微使命里,那么我也把nextTick
里边的履行逻辑也放在微使命里边不就能够了。
咱们在每次履行instance.update
函数时,运用queueJobs
函数进行处理:
const setupRenderEffect = function (instance, vnode, container, anchor) {
instance.update = effect(() => {
// 省掉...
}, {
// 运用scheduler函数进行包裹
// 履行时会履行此函数
scheduler() {
queueJobs(instance.update);
}
})
}
这儿运用effect
函数的第二个参数进行处理,不熟悉的话需求熟悉一下呼应式的部分。
接下来完成queueJobs
函数:
// 初始化履行栈
const queue = []
// 界说一个成功的promise状况
const p = Promise.resolve()
// 界说是否增加使命的状况
let isFlushPending = false
const queueJobs = job => {
// 假如使命未被增加到行列中
if(!queue.includes(job)) {
queue.push(job)
}
queueFlush()
}
queueFlush
函数首要用于判别是否增加至微使命:
const queueFlush = () => {
// 假如当时处于true,则直接回来
if(isFlushPending) return
isFlushPending = true
// 调用nextTick函数将flushJobs增加至微使命行列
nextTick(flushJobs)
}
// 取出一切行列中的使命履行
const flushJobs = () => {
isFlushPending = false
let job
while((job = queue.shift())) {
job && job()
}
}
const nextTick = fn => {
// 运用Promise.then
return fn ? p.then(fn) : p
}
其实中心思想便是在创立完一次使命增加至微使命行列之后,后续的履行都仅仅将函数增加到queue
行列中。当履行微使命后isFlushPending
开关变为false
,之后能够再次增加使命到微使命中。
而直接履行nextTick
函数则会自定创立一个使命到微使命中:
const App = {
setup() {
let count = reactive({
value: 0
})
let changeCount = function() {
for(let i = 0; i < 10; i++) {
count.value = count.value + 1
}
// 在nextTick函数中调用
nextTick(()=>{
const instance = getCurrentInstance()
})
}
return {
count,
changeCount
}
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count.value}`),
h('button', { onClick: this.changeCount }, 'update')
])
}
}
写在最终⛳
未来可能会更新完成mini-vue3
和javascript
根底知识系列,希望能一向坚持下去,期待多多点赞,一同前进!