写点什么

越来越受欢迎的 Vue 想学么,90 后小姐姐今儿来教你

发布于: 2021 年 03 月 09 日

摘要:Vue 的相关技术原理成为了前端岗位面试中的必考知识点,掌握 Vue 对于前端工程师来说更像是一门“必修课”。


本文原作者为尹婷,擅长前端组件库研发和微信机器人。


我们发现, Vue 越来越受欢迎了。


不管是 BAT 大厂,还是创业公司,Vue 都被广泛的应用。对比 Angular 和 React,三者都是非常优秀的前端框架,但从 GitHub 上来看,Vue 已经达到了 170 万的 Star。Vue 的相关技术原理也成为了前端岗位面试中的必考知识点,掌握 Vue 对于前端工程师来说更像是一门“必修课”。为此,华为云社区邀请了 90 后前端开发工程师尹婷带来了《Vue3.0 新特性介绍以及搭建一个 vue 组件库》的分享。


了解 Vue3.0 先从六大特性说起


Vue.js 是一个 JavaScriptMVVM 库,是一套构建用户界面的渐进式框架。在 2019 年 10 月 05 日凌晨,Vue3 的源代码 alpha。目前已经发布正式版,作者表示, Vue 3.0 具有六大特性:Tree Shaking;Composition;Fragment;Teleport;Suspense;渲染 Performance。渲染 Performance 主要是框架内部的性能优化,相对比较底层,本文会主要为大家介绍前四个特性的解读。


Tree Shaking


大多数编译器都会为我们的代码进行一个死代码的去除工作。首先我们要了解一下,什么是死代码呢?


以下几个特性的代码,我们把它称之为死代码:代码不会被执行,不可到达;代码执行的结果不会被用到;代码只会影响死变量(只写不读)。比如我们给一个变量赋值,但是并没有去用这个变量,那么这就是一个死变量。这就是在我们定义阶段会把它去除的一部分,比如说 roll up 消除死代码的工作。



如上图示例,左边是开发的源码提供的两个函数,但最终只用到了 baz 函数。在最后打包的时候,会把 foo 函数去除掉,只把 baz 这个函数打包进浏览器里面运行。Tree Shaking 是消除死代码的一种方式,更关注于无用模块的消除,消除那些引用了但并没有被使用的模块。


左边这块代码,export 有两个函数,一个是 post,一个是 get,但是在我们生产里边真正使用到只有 post。那么 rollup 在打包之后,就会直接消除掉 get 的函数,然后只把 post 的函数打包进入我们的生产里。除了 rollup 支持这个特性外,webpack 也支持。


接下来,我们看一下 VUE3.0 对 Tree Shaking 的支持都做了哪些事情?



首先以 VUE2 和 VUE3 对 nextTick 的使用进行对比:VUE2 把 nextTick 挂载到 VUE 实例上的一个 global API 式;VUE3 先把 nextTick 模块剔除,在要使用的时候,再把这个模块引入。

通过这个对比,我们可以看到使用 VUE2 的时候,即使没有 nextTick 或者其他方法,但由于它是一个 GLOBA API,它一定会被挂载到一个实例上,最后打包生产代码的时候,会把这个函数给打包进去,这一段代码进而也会影响到文件体积。在 VUE3.0 如果不需要这个模块的话,最后打包的这个文件里边就不会有这一块代码。通过这种方式就减少了最后生产代码的体积。


当然,不只是 nextTick,在 VUE3.0 内部也做了其他很多 tree-shaking。例如:如果不使用 keep-alive 组件或 v-show 指令,它会少引入很多跟 keep-alive 或者 v-show 不相关的包。



上图为 Vue2.0 的这段代码,左边是引入 utils 函数,然后把这个函数指为 mixins。这一段代码是在 Vue2 里边是最常用到的,但这段代码是有问题的。


如果对这个项目不熟悉,第一次看到这个代码的时候,由于不知道这个 utils 里边有哪些属性和方法,也就是说这个 mixins 对于开发者就是个黑盒。很容易遇到一种场景:在开发组件初期,应用了 mixins 的一个方法,现在不需要使用该方法了,在删除的过程发现不知道其他的地方是否引用过 mixins 其他的属性和方法。


Composition



如果使用的是 Vue3.0 的 Composition,该怎么规避这个问题呢?如上图所示,假设它是一个组件实例,我们使用 useMouse 函数并返回了 X 和 Y 两个变量。从左边代码可以看到 useMouse 函数就是根,它监听了鼠标的移动事件之后,返回了鼠标的 XY 坐标。通过这种方式来组织代码,就可以很明确的知道这个函数返回的变量和改变的值。



接下来我们再看一个 Composition 的例子:左边是在 Vue2 中最常用的一段代码,首先在 data 里边声明 first name 和 last name,然后在回帖的时候去请求接口,拿到接口返回到值,在 computed 之后获取他的 full Name。那么,这段代码的问题是什么呢?


这里的 computed,因为我们不知道返回的 full Name 的逻辑是什么。在获取了 data 之后,是希望通过 data 的返回值来拿到它的 first name 和 last name,然后来获取它的 full name。但是这一段代码的逻辑在获取接口之后就已经断掉,这就是 Vue2.0 设计不合理的一个地方,导致我们的逻辑是分裂派的,分裂在个配置下。那么,如果用 Composition 的话,怎么样实现呢?


请求接口之后,直接拿到它的返回数据,然后把这个返回数据的值赋给 computed 函数里,这里就可以拿到 full Name。通过这段代码可以看到,逻辑是更加的聚合了。



如何做到使用 useMouse 函数,里边的变量也是可响应的。在 Vue 3.0 中提供了两个函数:reactive 和 ref。reactive 可以传一个对象进去,然后这个函数返回之后的 state,是可响应的;ref 是直接传一个值进去,然后返回到看法对象,它也是可响应的。如果我们在 setup 函数里边返回一个可响应值的对象,是可以在字符串模板渲染的时候使用。比如,有时候我们直接在修改 data 的时候,视图也会相应的改变。


Vue2 中,一般会采用 mixins 来复用逻辑代码,但存在一些问题:例如代码来源不清晰、方法属性等冲突。基于此,在 vue3 中引入了 Composition API(组合 API),使用纯函数分隔复用代码,和 React 中的 hooks 的概念很相似。


Composition 的优点是暴露给模板的属性来源清晰,它是从函数返回的;第二,可以进行逻辑重用;第三,返回值可以被任意的命名,不存在秘密空间的冲突;第四,没有创建额外的组件实力带来的性能损耗。


以前我们如果想要获取一个响应式的 data,我们必须要把这个 data 放在 component 里边,然后在 data 里边进行声明,这样的话才能使这个对象是可响应的,现在可直接使用 reactive 和 ref 函数就可以使被保变成可响应的。


Fragment



在书写 vue2 时,由于组件必须只有一个根节点,很多时候会添加一些没有意义的节点用于包裹。Fragment 组件就是用于解决这个问题的(这和 React 中的 Fragment 组件是一样的)。


Fragment 其实就是在 Vue2 的一个组间里边,它的 template 必须要有一个根的 DIV 把它包住,然后再写里边的 you。在 Vue3,我们就不需要这个根的 DIV 来把这个组件包住了。上图就是 2 和 3 的对比。


Teleport



Teleport 其实就是 React 中的 Portal。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。Teleport 提供一个 Teleport 的组件,会指定一个目标的元素,比如说这里指定的是 body,然后 Teleport 任何的内容都会渲染到这个目标元素中,也就是说下面的这一部分 Teleport 代码,它会直接渲染到 body。


那么关于 Teleport 应用的位置,我们可以为大家举个例子来说明一下。比如说我们在做组件的时候,经常会实现一个 dialog。dialog 的背景是一个黑的铺满全屏 DIV,我们对它的布局是 position: absolute。如果父级元素是 relative 布局,我们的这个背景层就会受它的父元素的影响。那么此时,如果用 Teleport 直接把父组件定为 body,这样它就不会再受到副组件元素样式的影响,就可以确认一个我们想要的黑色背景画。


下面我写一下 react 和 vue 的 diff 算法的比对,我是一边写代码,一边写文章,整理一下思路。注:这里只讨论 tag 属性相同并且多个 children 的情况,不相同的 tag 直接替换,删除,这没啥好写的。


用这个例子来说明:




简单 diff,把原有的删掉,把更新后的插入。



变化前后的标签都是 li,所以只用比对 vnodeData 和 children 即可,复用原有的 DOM。


先只从这个例子出发,我只用遍历旧的 vnode,然后把旧的 vnode 和新的 vnode patch 就行。



这样就省掉移除和新增 dom 的开销,现在的问题是,我的例子刚好是新旧 vnode 数量一样,如果不一样就有问题,示例改成这样:



实现思路改成:先看看是旧的长度长,还是新的长,如果旧的长,我就遍历新的,然后把多出来的旧节点删掉,如果新的长,我就遍历旧的,然后多出来的新 vnode 加上。



仍然有可优化的空间,还是下面这幅图:



通过我们上面的 diff 算法,实现的过程会比对 preve vnode 和 next vnode,标签相同,则只用比对 vnodedata 和 children。发现


 标签的子节点(文本节点 a,b,c)不同,于是分别删除文本节点 a,b,c,然后重新生成新的文本节点 c,b,a。但是实际上这几个


 只是位置不同,那优化的方案就是复用已经生成的 dom,把它移动到正确的位置。


怎么移动?我们使用 key 来将新旧 vnode 做一次映射。


首先我们找到可以复用的 vnode,可以做两次遍历,外层遍历 next vnode,内层遍历 prev vnode



如果 next vnode 和 prev vnode 只是位置移动,vnodedata 和 children 没有任何变动,调用 patchVnode 之后不会有任何 dom 操作。

接下来只需要把这个 key 相同的 vnode 移动到正确的位置即可。我们的问题变成了怎么移动。


首先需要知道两个事情:


· 每一个 prev vnode 都引用了一个真实 dom 节点,每个 next vnode 这个时候都没有真实 dom 节点。


· 调用 patchVnode 的时候会把 prevVnode 引用的真实 Dom 的引用赋值给 nextVnode,就像这样:



还是拿上面的例子,外层遍历 next vnode,遍历第一个元素的时候, 第一个 vnode 是 li©,然后去 prev vnode 里找,在最后一个节点找到了,这里外层是第一个元素,不做任何移动的操作,我们记录一下这个 vnode 在 prevVnode 中的索引位置 lastIndex,接下来在遍历的时候,如果 j<lastIndex,说明原本 prevVnode 在前面的元素,在 nextVnode 中变到了后面来了,那么我们就把 prevVnode[j]放到 nextVnode[i-1]的后面。


这里多说一句,dom 操作的 api 里,只有 insertBefore(),没有 insertAfter()。也就是说只有把某个 dom 插入到某个元素前面这个方法,没有插入到某个元素后面这个方法,所以我们只能用 insertBefore()。那么思路就变成了,当 j<lastIndex 的时候,把 prevChildren[j]插入到 nextVnode[i-1]的真实 dom 的后面元素的前面。


当 j>=lastIndex 的时候,说明这个顺序是正确的的,不用移动,然后把 lastIndex = j;

也就是说,只把 prevVnode 中后面的元素往前移动,原本顺序是正确的就不变。

现在我们的 diff 的代码变成了这样:



同样的问题,如果新旧 vnode 的元素数量一样,那就已经可以工作了。接下来要做的就是新增节点和删除节点。


首先是新增节点,整个框架中将 vnode 挂载到真实 dom 上都调用 patch 函数,patch 里调用 createElm 来生成真实 dom。按照上面的实现,如果 nextVnode 中有一个节点是 prevVnode 中没有的,就有问题:



在 prevVnode 中找不到 li(d),那我们需要调用 createElm 挂在这个新的节点,因为这里的节点需要超入到 li(b)和 li©之间,所以需要用 insertBefore()。在每次遍历 nextVnode 的时候用一个变量 find=false 表示是否能够在 prevVnode 中找到节点,如果找到了就 find=true。如果内层遍历后 find 是 false,那说明这是一个新的节点。



我们的 createElm 函数需要判断一下第四个参数,如果没有就是用 appendChild 直接把元素放到父节点的最后,如果有第四个参数,则需要调用 insertBefore 来插入到正确的位置。


接下来要做的是删除 prevVnode 多余节点:



在 nextVnode 中已经没有 li(d)了,我们需要在执行完上面所讲的所有流程后在遍历一次 prevVnode,然后拿到 nextVnode 里去找,如果找不到相同 key 的节点,那就说明这个节点已经被删除了,我们直接用 removeChild 方法删除 Dom。



完整的代码:https://github.com/TingYinHelen/tempo/blob/main/src/platforms/web/patch.js在 react-diff 分支(目前有可能代码仓库还没有开源,等我实现更完善的时候会开源出来,项目结构可能有变化,看 tempo 仓库就行)


这里我的代码实现的 diff 算法很明显看出来时间复杂度是 O(n2)。那么这里在算法上依然又可以优化的空间,这里我把 nextChildren 和 prevChildren 都设计成了数组的类型,这里可以把 nextChildren、prevChildren 设计成对象类型,用户传入的 key 作为对象的 key,把 vnode 作为对象的 value,这样就可以只循环 nextChildren,然后通过 prevChildren[key]的方式找到 prevChidren 中可复用的 dom。这样就可以把时间复杂度降到 O(n)。


以上就是 react 的 diff 算法的实现。


vue 的 diff 算法


先说一下上面代码的问题,举个例子,下面这个情况:



如果按照 react 的方法,整个过程会移动 2 次:

li©是第一个节点,不需要移动,lastIndex=2

li(b), j=1, j<lastIndex, 移动到 li©后面 (第 1 次移动)

li(a), j=0, j<lastIndex, 移动到 li(b)后面 (第 2 次移动)


但是通过肉眼来看,其实只用把 li©移动到第一个就行,只需要移动 1 一次。

于是 vue2 这么来设计的:



首先找到四个节点 vnode:prev 的第一个,next 的第一个,prev 的最后一个,next 的最后一个,然后分别把这四个节点作比对:1. 把 prev 的第一个节点和 next 的第一个比对;2. 把 prev 的最后一个和 next 的最后一个比对;3.prev 的第一个和 next 的最后一个;4. next 的第一个和 prev 的最后一个。如果找到相同 key 的 vnode,就做移动,移动后把前面的指针往后移动,后面的指针往前移动,直到前后的指针重合,如果 key 不相同就只 patch 更新 vnodedata 和 children。下面来走一下流程:


  1. li(a)和 li(b),key 不同,只 patch,不移动

  2. li(d)和 li©,key 不同,只 patch,不移动

  3. li(a)和 li©,key 不同,只 patch,不移动

  4. li(d)和 li(d),key 相同,先 patch,需要移动移动,移动的方法就是把 prev 的 li(d)移动到 li(a)的前面。然后移动指针,因为 prev 的最后一个做了移动,所以把 prev 的指向后面的指针往前移动一个,因为 next 的第一个 vnode 已经找到了对应的 dom,所以 next 的前面的指针往后移动一个。


现在比对的图变成了下面这样:



这个时候的真实 DOM:



继续比对


  1. li(a)和 li(b),key 不同,只 patch,不移动。

  2. li©和 li©,相同相同,先 patch,因为 next 的最后一个元素也刚好是 prev 的最后一个,所以不移动,prev 和 next 都往前移动指针。


这个时候真实 DOM:



现在最新的比对图:



继续比对


  1. li(a)和 li(b),key 不同,只 patch,不移动。

  2. li(b)和 li(a),key 不同,只 patch,不移动。

  3. li(a) 和 li (a),key 相同,patch,把 prev 的 li(a)移动到 next 的后面指针的元素的后面。


真实的 DOM 变成了这样:



比对的图变成这样:



继续比对:

li(b)和 li(b)的 key 相同,patch,都是前指针相同所以不移动,移动指针

这个时候前指针就在后指针后面了,这个比对就结束了。



这就完成了常规的比对,还有不常规的,如下图:



经过 1,2,3,4 次比对后发现,没有相同的 key 值能够移动。


这种情况我们没有办法,只有用老办法,用 newStartIndex 的 key 拿去依次到 prev 里的 vnode,直到找到相同 key 值的老的 vnode,先 patch,然后获取真实 dom 移动到正确的位置(放到 oldStartIndex 前面),然后在 prevChildren 中把移动过后的 vnode 设置为 undefined,在下次指针移动到这里的时候直接跳过,并且 next 的 start 指针向右移动。


function updateChildren (elm, prevChildren, nextChildren) {  let oldStartIndex = 0;  let oldEndIndex = prevChildren.length - 1;  let newStartIndex = 0;  let newEndIndex = nextChildren.length - 1;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { let oldStartVnode = prevChildren[oldStartIndex]; let oldEndVnode = prevChildren[oldEndIndex]; let newStartVnode = nextChildren[newStartIndex]; let newEndVnode = nextChildren[newEndIndex];
if (oldStartVnode === undefined) { oldStartVnode = prevChildren[++oldStartIndex]; } if (oldEndVnode === undefined) { oldEndVnode = prevChildren[--oldEndIndex]; }
if (oldStartVnode.key === newStartVnode.key) { patchVnode(newStartVnode, oldStartVnode); oldStartIndex++; newStartIndex++; } else if (oldEndVnode.key === newEndVnode.key) { patchVnode(newEndVnode, oldEndVnode); oldEndIndex--; newEndIndex--; } else if (oldStartVnode.key === newEndVnode.key) { patchVnode(newEndVnode, oldStartVnode); elm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling); newEndIndex--; oldStartIndex++; } else if (oldEndVnode.key === newStartVnode.key) { patchVnode(newStartVnode, oldEndVnode); elm.insertBefore(oldEndVnode.elm, oldStartVnode.elm); oldEndIndex--; newStartIndex++; } else { const idxInOld = prevChildren.findIndex(child => child.key === newStartVnode.key); if (idxInOld >= 0) { elm.insertBefore(prevChildren[idxInOld].elm, oldStartVnode.elm); prevChildren[idxInOld] = undefined; newStartIndex++; } } }}
复制代码


接下来就是新增节点:



这种排列方法,按照上面的方法,经过 1,2,3,4 比对后找不到相同 key,然后然后用 newStartIndex 到老的 vnode 中去找,仍然找不着,这个时候说明是一个新节点,把它插入到 oldStartIndex 前面



最后是删除节点,我把他作为课后作业,同学可以自己实现最后的删除的算法。


完整代码在 https://github.com/TingYinHelen/ tempo 的 vue 分支。


PS.本文部分内容参考自《比对一下 react,vue2.x,vue3.x 的 diff 算法》。


本文分享自华为云社区《90 后小姐姐带你了解 Vue3.0 新特性》,原文作者:技术火炬手。


点击关注,第一时间了解华为云新鲜技术~


发布于: 2021 年 03 月 09 日阅读数: 15
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
越来越受欢迎的Vue想学么,90后小姐姐今儿来教你