原理分析
Vue原理
虚拟DOM
面试题:请你阐述一下对
vue
虚拟dom
的理解
- 什么是虚拟DOM?
虚拟DOM本质上就是一个普通的JS对象,用于描述视图的界面结构
在vue
中,每个组件都有一个render
函数,每个render
函数都会返回一个虚拟DOM树,这也就意味着每个组件都对应一棵虚拟DOM树.
没有
render
函数,就找template
,没有template
就找el
,把el.outerHTML
作为template
(就是一个字符串),然后将template
编译成render函数.如果有render函数,就直接使用
render
函数,每一个render
函数都返回一个虚拟DOM(JS对象);
1 | new Vue({ |
- 为什么需要虚拟DOM
在vue
中,渲染视图会调用render
函数,这种渲染不仅发生在组件创建时,同时发生在视图依赖的数据更新时.如果在渲染时,直接使用真实DOM,由于真实DOM的创建,更新,插入等操作会带来大量的性能损耗,从而就极大的降低渲染效率.
因此,vue在渲染时,会使用虚拟DOM来代替真实DOM,主要为了解决渲染效率的问题.
- 虚拟DOM是如何转换为真实DOM的?
在一个组件实例首次被渲染时,它先生成虚拟DOM树,然后根据虚拟DOM树创建真实DOM,并把真实的DOM挂载到页面中合适的位置,此时,每个虚拟DOM便会对应一个真实的DOM.
如果一个组件受响应式数据变化的影响,需要重新渲染时,它仍然会重新调用render
函数,创建出一个新的虚拟DOM树,用新树和旧树对比,通过对比,vue
会找到最小更新量,然后更新必要的真实DOM节点.
这样一来,就保证了对真实DOM达到最小的改动.
- 模板和虚拟DOM的关系
vue
中有一个compile
模块,它主要负责将模板转换为render
函数,而render
函数调用后将得到虚拟DOM.
编译的过程分为两步:
- 将模板字符串转换为AST
- 将AST转换为render函数
如果使用传统的引入方式,则编译时间发生在组件第一次加载时,这称之为运行时编译.
如果是在vue-cli
的默认配置下,编译发生在打包时,这称之为模板预编译.
编译是一个极其消耗性能的操作,预编译可以有效的提高运行时的性能,而且,由于运行的时候已不需要编译,vue-cli
在打包时会排除掉vue
中的compile
模块,以减少打包体积.
模板的存在,仅仅是为了让开发人员更加方便的书写界面代码
vue最终运行的时候,需要的是render函数,而不是模板,因此,模板中的各种语法,在虚拟DOM中都是不存在的,他们都会变成虚拟DOM的配置
案例: 自动生成目录
数据响应式原理
面试题: 请阐述vue2响应式原理
数据响应式的最终目标,是当对象本身或对象属性发生变化时,将会运行一些函数,最常见的就是render
函数.
在具体实现上,vue用到了几个核心部件
- Observer
- Dep
- Watcher
- Scheduler
Observer
Observer要实现的目标非常简单,就是把一个普通的对象转换为响应式对象.为了实现这一点,Observer把对象的每个属性通过Object.defineProperty
转换为带有getter
和setter
的属性,这样一来,当访问或设置属性时,vue就有机会做一些别的事情.
Observer是vue内部的构造器,我们可以通过Vue提供的静态方法Vue.observable(object)
间接的使用该功能.
在组件生命周期中,这件事发生在beforeCreate
之后,created
之前.具体实现上,它会递归遍历对象的所有属性,以完成深度的属性转换.
由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态添加或删除的属性,因此vue提供了$set
和$delete
两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性.
对于数组,vue会更改它的隐式原型,之所以这样做,是因为vue需要监听那些可能改变数组内容的方法.
总之,Observer的目标就是让一个对象,它属性的读取,赋值,内部数组的变化都要能够被vue感知到.
Dep
这里有两个问题没解决,就是读取属性时要做什么事,而属性变化时要做什么事,这个问题要依靠Dep来解决.
Dep的含义是Dependency,表示依赖的意思.
Vue会为响应式对象中的每个属性,对象本身,数组本身创建一个Dep实例,每个Dep实例都有能力做以下两件事:
- 记录依赖: 是谁在用我
- 派发更新: 我变了,我要通知到那些要用到我的人.
当读取响应式对象的某个属性时,它会进行依赖收集:有人用到了我
当改变某个属性时,它会派发更新:哪些用我的人听好了,我变了.
Watcher
这里又出现一个问题,就是Dep是如何知道是谁在用我?
要解决这个问题,需要依靠另一个东西,就是Watcher
当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道那个函数在用自己
因此,vue通过一种巧妙的办法来解决这个问题.
我们不要直接执行函数,而是把函数交给一个叫做watcher的东西去执行,watcher是一个对象,每个这样的函数执行时都应该创建一个watcher,通过watcher去执行.
watcher会设置一个全局变量,让全局变量记录当前负责执行的watcher等于自己,然后再去执行函数,在函数执行的过程中,如果发生了依赖记录dep.depend()
,那么Dep就会把这个全局变量记录下来,表示:有一个watcher用到了我这个属性.
当Dep进行派发更新时,他会通知之前记录的所有watcher,我变了.
每个vue组件实例,都至少对应一个watcher,该watcher中记录了该组件的render
函数
watcher首先会把render函数运行一次以收集依赖,于是那些在render中用到的响应式数据就会记录这个watcher
当数据变化时,dep就会通知该watcher,而watcher将重新运行render函数,从而让界面重新渲染同时重新记录当前依赖.
Scheduler
现在还剩下最后一个问题,就是Dep通知了Watcher之后,如果watcher执行重新运行对应的函数,就有可能会导致函数频繁运行,从而导致效率低下
事项,如果一个交给watcher的函数,它用到了属性a,b,c,d,那么a,b,c,d属性都会记录依赖,于是下面的代码将触发4次更新.
1 | state.a = "new data"; // 某些watcher想运行了,给调度器 |
这样显然是不合适的.因此,watcher收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫做调度器的东西
调度器维护一个执行队列,该队列同一个watcher仅会存在一次,队列中的watcher不是立即执行,它会通过一个叫做nextTick
的工具方法,把这些需要执行的watchher
放入到事件循环的微队列中,nextTick
的具体做法是通过Promise
完成的.
nextTick通过
this.$nextTick
暴露给开发者.
也就是说,当响应式数据变化时,render函数的执行是异步的,并且在微队列中.
总体流程
diff算法
面试题: 请阐述vue的diff算法
参考回答:
当组件创建和更新时,vue均会执行内部的
update
函数,该函数使用render函数生成的虚拟dom树,将新旧两棵树进行对比,找到差异点,最终更新到真实dom.对比差异的过程叫diff,vue内部通过一个叫
patch
的函数完成该过程在对比时,vue采用深度优先,同层比较的方式进行对比.
在判断两个节点是否相同时,vue是通过虚拟节点的key和tag来进行判断的.
具体来说,首先对根节点进行对比,如果相同,则将旧节点关联的真实dom挂到新节点上,然后根据需要更新真实dom,然后再对比其子节点数组;如果不同,则按照新节点的信息递归创建所有真实DOM,同时挂到对应虚拟节点上,然后移除掉旧的DOM.
在对比其子节点数组时,vue对每个子节点数组使用了两个指针,分别指向头尾,然后不断地向中间靠拢来进行对比,这样做的目的是尽量复用真实的DOM,尽量少的销毁和创建真实DOM.如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实的DOM到合适的位置.
这样一直递归遍历下去,直到整棵树完成对比.
- diff时机
当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事
- 运行
_render
生成一棵新的虚拟dom树(vnode tree) - 运行
_update
,传入虚拟dom树的根节点,对新旧两棵树进行对比,最终完成对真实dom的更新
核心代码如下
1 | // vue构造函数 |
diff就发生在_update
函数的运行过程中.
_update
函数在干什么?
_update
函数接收到一个vnode
参数,这就是新生成的虚拟dom树.
同时,_update
函数通过当前组件的_vnode
属性,拿到旧的虚拟dom树
_update
函数首先会给组件的_vnode
属性重新赋值,让它指向新树.
然后回判断旧树是否存在:
不存在:说明这是第一次加载组件,于是通过内部的
patch
函数,直接遍历新树,为每个节点生成真实DOM,挂载到每个节点的elm
属性上.存在,说明之前已经渲染过该组件,于是通过内部的patch函数,对新旧两棵树进行对比,以达到下面两个目标:
- 完成对所有真实DOM的最小化处理
- 让新树的节点对象到合适的真实DOM
- path函数的对比流程
术语解释:
相同: 是指两个虚拟节点的标签类型,key值均相同,但input元素还要看type属性
新建元素: 是指根据一个虚拟节点提供的信息,创建一个真实dom元素,同时挂载到虚拟节点的
elm
属性上.销毁元素:是指
vnode.elm.remove()
更新: 是指对两个虚拟节点进行对比更新,它仅发生在两个虚拟节点相同的情况下.
对比子节点: 是指对两个虚拟节点的子节点进行对比,具体过程稍后描述.
详细流程
(1)根节点比较
patch函数首先对根节点进行比较
如果两个节点:
- 相同: 进入更新流程
- 将旧节点的真实dom赋值到新节点:
newVnode.elm = oldVnode.elm
- 对比新节点和旧节点的属性,又变化的更新到真实dom中,
- 当前两个节点处理完毕,开始对比子节点
- 将旧节点的真实dom赋值到新节点:
- 不相同
- 新节点递归新建元素
- 旧节点销毁元素
(2). 对比子节点
在对比子节点时,vue的一切出发点,都是为了:
- 尽量啥也别做
- 不行的话,尽量改动元素属性
- 还不行的话,尽量移动元素,而不是删除和创建元素
- 还不行的话,删除和创建元素.
性能优化
- 打包体积优化
- 运行时优化
使用key
对于通过循环生成的列表,应该给每一个列表项一个稳定且唯一的key
,这有利于在列表变动时,尽量少的删除,新增,改动元素.
使用冻结的对象
冻结的对象不会被响应化.
使用函数式组件
没有自己的data和生命周期函数.
使用计算属性
如果模板中某个数据会使用多次,并且该数据是通过计算得到的,使用计算属性以缓存他们.
非实时绑定的表单项
当使用v-model
绑定一个表单项时,当用户改变表单项的状态时,也会随之改变数据,从而导致vue发生重新渲染(renderer
),这会带来一些性能的开销.
特别是当用户改变表单项时,页面会有一些动画正在进行中,由于用于只有一个渲染主线程,所以可能会导致动画出现卡顿.
我们可以通过使用lazy
或不使用v-model
的方式解决该问题,但要注意,这样可能导致在某一个时间段内数据和表单项的值是不一致的.
保持对象引用稳定
在绝大部分情况下,vue
触发render
的时机是其依赖的数据发生变化
若数据没有发生变化,哪怕给数据重新赋值了,vue也是不会做出任何处理的
下面是vue
判断数据没有变化的源码
1 | // value为旧值,newVal为新值 |
因此,如果需要,只要能保证组件的依赖数据不发生变化,组件就不会重新渲染.
对于原始数据类型,保持其值不变即可.
对于对象类型,保持其引用不变即可.
从另一方面来说,由于可以通过保持属性引用稳定来避免子组件的重新渲染,那么我们应该细分组件来避免多余的渲染.
使用v-show代替v-if
对于频繁切换显示状态的元素,使用v-show可以保证虚拟dom树的稳定,避免频繁新增和删除元素,特别是对于那些内部包含大量dom元素的节点,这一点极其重要
关键字: 频繁切换显示,内部包含大量dom元素
使用延迟装载(defer)
首页白屏时间主要受到两个因素的影响:
- 打包体积过大:巨型包需要消耗大量的传输时间,导致js传输完成前页面只有一个div,没有可显示的内容.
- 需要立即渲染的内容太多:JS传输完成后,浏览器开始执行JS构造页面.但可能一开始渲染的组件太多,导致JS执行时间很长,而且执行完成后浏览器要渲染的元素过多,从而导致白屏.
打包体积过大需要自行优化打包体积.
一个可行的办法就是延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来.
延迟装载是一个思路,本质上是利用
requestAnimationFrame
事件分批渲染内容,它的具体实现多种多样.
使用keep-alive
请阐述keep-alive组件的原理和作用
keep-alive组件是vue的内置组件,用于缓存内部组件实例.这样做的目的在于,keep-alive内部的组件切换时,不用重新创建组件实例,而是直接使用缓存中的示例,一方面能够避免创建组件带来的开销,另一方面可以保留组件的状态.
keep-alive
具有include
和exclude
属性,通过他们可以控制哪些组件进入缓存.另外它还提供了max
属性,通过它可以设置最大缓存数,当缓存的实例超过max时,会移除最久没有使用的组件缓存.
受keep-alive
影响,其内部所嵌套的组件都具有两个生命周期钩子函数,分别是actived
和deactived
,他们分别在组件激活和失活时触发.第一次actived
触发是在mounted
之后
在具体的实现上,keep-alive
在内部维护了一个key
数组和一个缓存对象.
1 | // keep-alive 内部的生命周期函数 |
key
数组记录目前缓存的组件key
值,如果组件没有指定key值,则会为其自动生成一个唯一的key值.
cache对象以key值为键,vnode为值,用于缓存组件对应的虚拟DOM
在keep-alive
的渲染函数中,其基本逻辑是判断当前渲染的vnode是否有对应的缓存,如果有,从缓存中读取到对应的组件实例,如果没有,则将其缓存.
当缓存数量超过max值时,keep-alive会移除key数组的第一个元素
1 | render() { |
长列表优化
Uniapp原理
Uniapp是由DCloud推出的基于Vue.js
的跨平台前端开发框架,能够通过一套代码实现多端(iOS, Android, H5, 小程序, 快应用等)运行.其核心实现方式主要依赖于编译时转换和运行时适配,结合Native渲染和Web渲染模式,最终达到跨平台的效果.
1. Uniapp的核心架构
Uniapp主要由以下几个核心部分组成:
编译器(Compiler)
负责将Vue代码转换成各个平台可运行的代码,如微信小程序WXML, 支付宝小程序AXML,H5 HTML.
运行时(Runtime)
负责在不同平台上运行,并提供API适配及组件适配,以确保一套代码可以兼容多个平台.
渲染层(Rendering Layer)
视平台选择WebView或Native组件渲染,在H5端使用DOM,在小程序端使用小程序的自定义组件,在App端使用Native组件
通信桥(Bridge)
在JS层(业务逻辑)和Native层(平台能力)之间建立通信桥,使得JS可以调用平台原生能力,如相机,文件存储等.