Vue2
Vue的学习路线
原生开发
通过script标签引入vue.js,src属性通常是http链接,或者下载到本地的vue.js文件的路径。
1 | <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> |
如果是http链接,当浏览器加载这个脚本,会发送一个get请求获取并执行vue的js代码。
引入vue.js后,Vue这个构造函数成为全局变量,挂载到window对象上,然后我们在页面的script标签中写些代码,创建一个vue实例,传入一个配置对象
1 | const app = new Vue({ |
此时我们还未引入组件的概念,但是我们已经能够学习vue的大部分知识点了。包括模板语法,数据绑定,数据代理如何实现,vue的常用指令,计算属性,数据监听,vue的生命周期等等。
非单文件组件
什么组件?组件化开发有什么好处?
在vue中,组件就是能实现局部功能的html,css,js代码的集合,组件化开发有利于代码复用,提高开发效率,同时把功能上密切相关的html,css,js代码放到一起,依赖关系明确,易于维护。
vue的组件可分为单文件组件和非单文件组件
非单文件组件,就是通过Vue.extend({}),返回一个VueComponent构造函数,这个构造函数被用来创建组件实例,依赖的配置对象就是Vue.extend({})传入的对象,这个配置对象的结构,和new Vue()传入的配置对象的结构几乎一致。
存在如下关系,即Vuecomponent是Vue的子类。
1 | 组件实例._proto_ = VueComponent.prototype |

非单文件组件使用
1 | <div id="app" :name="str"> |
1 | // 定义一个school组件 |
单文件组件
单文件组件,就是我们熟知的.vue文件,单文件组件解决了非单文件组件无法复用css代码的问题,我们开发过程中使用的最多的组件也是单文件组件。显然,.vue文件是vue团队开发的文件,无法在浏览器上运行,所以我们需要借助模块化打包工具比如webpack,来处理这个文件,webpack又是基于nodejs的,nodejs是使用模块化开发的。这样vue的开发就过渡到了基于nodejs+webpack的模块化开发,为了简化模块化开发过程中webpack的配置,vue团队就开发了vue-cli,即vue的脚手架
单文件组件的大致结构如下:
1 | <template> |
其中export default {}由export default Vue.extend({})简化而来的,组件注册的时候会自动处理:如果发现注册的组件是一个对象,而不是一个VueComponet构造函数 ,则使用Vue.extend包裹,否则直接注册。
组件之间通过嵌套确定层级关系,所有其他组件都在根组件App.vue内,根组件直接嵌入index.html文件,这一嵌入操作是在main.js中实现的,组件化开发后,不需要直接在html页面中写结构,内容被分解为一个一个vue组件中的模板。
import一个vue组件发生了什么
import一个App组件,得到的到底是个啥?打印出来可以看到,返回的App就是一个包含多个属性的,组件配置JS对象,它是通过使用vue-loader编译转换vue文件得到的,包含了组件的所有信息。

其实无论vue2,还是vue3,导入一个vue文件都会被构建工具(webpack或者vite)处理,得到一个js对象,这个js对象通常都包含render函数,是将vue文件中的模板编译得到的,也就是说开发阶段就把模板编译完毕了,生产阶段不需要编译。至于样式部分(<style>),部分会被单独提取处理,不会包含在导出的组件配置对象中,通常会被提取到CSS文件中。
这个组件配置对象,最终又会被Vue.extend处理(Vue2),得到组件的构造函数。
render函数
你们有没有思考过这个问题,render函数是如何得到的,调用render函数到底做了什么?
1 | <template> |
上述父组件模板template,最终会被编译为如下渲染函数render(在$mount方法中,在后文有介绍):
1 | function render(createElement) { |
可以看出渲染函数中,有许多createElement函数。
在创建组件根实例的时候,使用的render函数中,也用到了createElement函数,就是下面的h,不过这个render函数相当于是用户自定义的,而不是模板解析后得到的,结构非常简单。
1 | import Vue from "vue"; |
我们将这个对象,传入render方法,最终交给h方法来调用,h方法就是createElement方法。
createElement
createElement常常出现在渲染函数render中,所以createElement方法到底是个什么玩意?
createElement 是 Vue 中用于创建虚拟节点(VNode)的核心函数。
createElement 根据 tag 的不同,调用不同的方法生成 VNode
如果tag 是HTML内置的标签:直接调用**new VNode()**方法创建VNode
如果tag是已注册的组件标签,则拿到对应的组件构造器Ctor,虽然通常情况是一个组件的配置对象,然后再调用createComponent方法
1 | if ((Ctor = resolveAsset(context.$options, 'components', tag))) { |
如果tag是一个js对象(组件配置对象),比如导入App.vue得到的App,则直接调用createComponent方法
createComponent的作用是什么,如何处理类似App这样的组件js对象?
createComponent
createComponent 是用于创建组件类型VNode,它最终也会调用new VNode()返回据组件的VNode。
1 | export function createComponent(Ctor, data, context, children, tag) { |
createComponent 在这里主要做了2件事:
把传入的组件配置对象,使用
Vue.extend构造成VueComponent构造函数实例化
VNode并返回,同时我们创建好的组件的构造函数也会被传入。
不过createComponent创建的,不是真正的组件的vnode,而是占位的vnode,这个vnode不描述任何的真实dom结构,它告诉Vue这里要插入一个组件,包含了创建组件所需的所有信息。组件真正的、描述其内部模板结构的 VNode,是在组件实例化后,通过 render() 函数生成的 vm._vnode
Vue.extend()
这个方法我们在前面学习过,我们使用它来创建非单文件组件,返回一个组件的构造函数,下面是简要的源码
1 | Vue.extend = function (options) { |
Vue.extend()的主要作用就是:根据传入的组件配置对象,创建一个VueComponent构造函数并返回
这里的 opts和 Sub.options是不同的,Sub.options指的是组件定义时的静态选项,比如组件的data、methods;而opts是组件实例化时传入的参数,比如父组件传递的 props 数据,父组件实例。_init方法会将两者合并,形成最终的实例选项 vm.$options。
从上述代码中我们可以看出,不同组件对应的VueComponent构造函数,它们的功能或者说函数体都是一样的(都是调用_init方法)区别就在于挂载的options不同。
调用VueComponent构造函数,就开始了组件实例的创建和初始化流程。
具体来说,子组件的 VNode 包含了一个特殊的 componentOptions 属性,其中包含了子组件的构造函数,以及其他相关信息如 propsData, children 等。
那这个构造函数具体是何时被调用呢?
调用父组件的render函数的时候,就会调用这个构造函数,开启子组件实例的创建吗?并不会!父组件调用render的时候,只会创建子组件的构造函数,创建子组件的VNode(占位Vnode),但是不会调用子组件的构造函数。
只有当父组件调用_update方法,将虚拟DOM递归的转化成真实DOM的时候(在createEle方法中),才会调用子组件的构造函数,创建子组件实例。
总结

说说vue2响应式是如何实现的
是什么
响应式指的是,当更改响应式数据时,视图会随即自动更新,但是响应式是如何实现的呢?
object.defineProperty
在vue2中,使用object.defineProperty来监听对象属性的变化:访问对象的属性会触发getter,修改对象的属性会触发setter,但是这还不足以实现响应式,我们还需要在gette中收集依赖,在setter中通知依赖更新。
问题是什么是依赖?依赖存储在哪儿?
依赖是啥:watcher
依赖就是我们数据变化后,需要通知的对象,就是使用了我们数据的对象,而使用一个数据的地方有很多,比如模板中和用户定义的watch。
在vue2中,依赖有个好听的名字:Watcher。在vue2中,依赖被抽象为一个Watcher类。创建Watcher实例,在构造函数内部就会触发get方法,然后这个Watcher就会被dep收集为依赖。
依赖收集:dep
在vue2中,为响应式对象的每个属性,都创建了一个Dep实例,用来管理属性的依赖,属性的依赖存储在dep.subs数组中,同时这个dep实例还有通知依赖更新的方法。
存在的问题
在vue2中,由于响应式的实现是基于object.defineProperty,所以无法监听到对象属性的添加和删除,也无法监听到通过数组方法对数组的修改,无法监听到通过数组下标对数组的修改。为了解决这个问题,在vue2中设计了$set和$delete这2个方法。
$set可以在目标对象上响应式的添加属性,并通知目标对象的依赖进行更新,$delete可以在删除目标对象的属性后,通知目标对象的依赖进行更新。
除此以外,在vue2中还重写了能修改数组的那7个方法。重写的数组方法,它们不但能实现原有的功能,还能在数组被修改的时候通知数组的依赖更新,新增的数据也会被添加响应式。
defineReactive
defineReactive在vue2中被用来响应式的添加一个属性
1 | const obj = { name: 'tom', age: 22 } |
defineReactive方法内部到底做了些什么呢?其实它主要就做了如下工作:
调用observe方法,给value递归添加响应式
为每个属性(key)都创建一个Dep实例,用来管理这个响应式数据的依赖,这个Dep实例存储在闭包中,无法直接通过这个属性访问到。
劫持属性,这是通过
Object.defineProperty实现的。触发getter的时候,调用dep.depend(),进行依赖收集,触发setter的时候,调用dep.notify,通知依赖更新。
observe
1 | function observe(value) { |
observe方法的实现思路很简单:如果value不是对象则直接返回,如果是响应式对象,直接返回它的observer实例。如果是普通对象,调用 new Observer 将其转化成响应式的,然后返回其observer实例。
Observer
observer类是vue2中给一个对象添加响应式的核心类
1 | const arrayMethods = Object.create(Array.prototype);//一个空对象,这个对象的原型是数组的原型 |
在Observer类的构造器中,创建了Dep实例,这个 Dep 实例主要是为了处理数组类型的对象,或对象本身作为一个整体被访问的情况。
当调用observe方法给数组添加响应式的时候:
- 会使用一个拦截器覆盖数组的原型,这个拦截器对象上有7个重写的数组方法,它们不但能实现原有的功能,还能在数组被修改的时候,通知数组的依赖更新。
- 然后再遍历数组中的所有元素,调用observe方法给它们添加响应式。
Dep
在前面的内容中,我们发现在Observer类的构造函数中,还有defineReative函数中,都使用到了Dep类,调用了主要Dep实例的dep.depend,dep.notify()方法,那它到底有什么作用呢?
1 | let uid = 0 // 用于生成 Dep 实例的唯一 ID |
1 | // 全局静态变量初始赋值为 null,表示当前没有活跃的 Watcher |
Dep.target
Dep.target表示当前活跃的(正在收集依赖的)Watcher实例,同时只能有一个
subs
每个dep实例都有唯一的id和subs(订阅者数组,Watcher数组)
dep.depend
当响应式数据的getter被触发后,这个方法就会被调用,但是这个方法其实是在调用Dep.target.addDep(this)方法,调用Watcher实例的方法,其中传入的this就是dep实例。总的来说,dep.depend() 方法,通过利用全局变量 Dep.target,在数据读取时建立了 Dep和 Watcher之间的双向关联。具体的来说,是Watcher先进行依赖收集,然后dep再收集它的订阅者(Dep.target,当前正在收集依赖的Watcher)
dep.notify
当响应式数据改变的时候,调用这个方法通知订阅者更新,遍历并调用每个订阅者(Watcher)的 update 方法
Watcher
是什么
Watcher就是我们所说的依赖,大致源码:
1 | class Watcher { |
构造函数
接收几个参数:
vm: 当前 Vue 实例,充当数据源expOrFn:要监听的内容,可以是一个字符串'a.b.c'或一个函数(计算属性的时候)cb: 数据变化时要执行的回调函数options: 可选配置项(如 deep、lazy、sync)isRenderWatcher: 是否是渲染 watcher(也就是用来更新视图的那个)
vm._watchers,是一个数组,存放这个组件中所有的 watcher:
- 渲染 watcher(1个)
- 用户通过
$watch添加的 watcher(多个) - computed 属性对应的 watcher(多个)
getter
getter函数的作用就是访问响应式数据,触发依赖收集(dep.depend),并返回一个值value
1 | if (typeof expOrFn === 'function') { |
当expOrFn是函数的时候,比如:
1 | new Watcher(vm, function () { |
那么 this.getter 就是这个函数本身:
1 | this.getter = function () { |
当expOrFn是字符串:
1 | new Watcher(vm, 'a.b.c', cb) |
Vue 内部会调用 parsePath('a.b.c') 把它转换成一个函数:
1 | this.getter = function () { |
不管expOrFn是函数还是字符串路径,最后都会变成一个能从vm取值并返回的函数,这就是 getter
get
1 | this.value = this.lazy ? undefined : this.get() |
如果不是 lazy watcher(不是计算属性Watcher),创建Watcher的时候就立即执行一次 get() 方法,收集依赖并保存初始值。
创建除了计算属性Watcher以外的所有Watcher时,都会调用立即get函数,其实get方法不仅会再构造函数中被调用,还会在run方法中被调用。get函数到底是什么,它做了什么事情?
1 | // Dep类中的代码 |
1 | get () { |
分析上述代码,我们可以得知get方法无非就做了这么几件事:
- 调用
pushTarget(this),把当前Watcher(调用get方法的Watcher)设置为target( Dep.target = this) - 调用getter方法重新获得value触发依赖收集,
Dep.target就会被deps收集到subs里 - 所有依赖收集结束后,调用
popTarget()修改Dep.target,结束依赖收集。
简而言之,get能确保当前Watcher的所有依赖,都能收集到这个Watcher,同时还会返回最新的值
update
当响应式数据修改,会触发setter,调用dep.notify方法通知所有Watcher更新,遍历调用Watcher的update方法,那么这个方法做了什么?
1 | update () { |
run
在update方法中,调用run() 执行的是真正的更新过程。
1 | run() { |
从上面的代码中可得知,run方法无非就做了这么几件事:调用get更新当前值(this.value),这意味着会调用getter;如果满足条件还会执行回调函数cb。其实watcher的run方法就相当于vue3中effect的调度器。
区别于Vue3
其实在vue3中,副作用函数effect就相当于vue2中watcher的get方法。为什么呢?因为vue3中通过effect注册的effectFn,其内部也会将这个effectFn设置到全局的位置:activeEffect,然后调用getter方法,触发副作用收集,然后修改activeEffect,这简直就是watcher的get方法。
创建一个watcher,就相当于在vue3中注册了一个副作用函数。
Watcher分类
每个Watcher都有value,getter,get,cb,update,run属性。Watcher又可分为三类,渲染Watcher,计算属性 Watcher,用户自定义Watcher。为什么Watcher只有这3类,因为响应式数据,通常只在,模板,计算属性,watch中被使用。
这三类Watcher的value,getter和cb分别是什么情况呢?首先我们可以看看这三类Watcher是在哪里,如何被创建的。
渲染Watcher
渲染 Watcher在mountComponent(后面会介绍)方法中被创建
1 | Vue.prototype.$mount = function(el){ |
可以看到第5个参数 为 true /* isRenderWatcher */; 说明是渲染watcher;updateComponent 是第二个参数,那么它应该是 expOrFn,最终会赋值给this.getter;cb是noop,就是空回调函数的意思。对于渲染Watcher来说,没有实际的回调函数cb
既然**updateComponent是渲染Watcher的getter,那它是如何触发依赖收集的**?返回值又是什么?
_render
我们先来看看updateComponent中调用的_render方法:
1 | Vue.prototype._render = function () { |
这个方法主要做了这么几件事:
- 从
vm.$options中拿到准备好的render函数 - 调用render函数并返回得到的VNode
补充:
vm.$createElement就是我们常说的createElement方法。render方法其实就是使用createElement来创建VNode
render方法中会访问响应式数据(如 this.age)
1 | function render(createElement) { |
调用render方法 → 响应式数据被访问 → 触发 getter → 收集依赖,因此updateComponent是一个规范的getter,它确实能触发依赖收集
_update
调用完render方法得到VNode后,还会调用_update方法,将虚拟DOM转化成真实DOM。
1 | Vue.prototype._update = function (vnode) { |
其中vm.__patch__就是我们常说的patch方法,在后面的diff算法中有介绍。
从中可以看出update方法并没有返回值(或者说返回值是undefined),这就意味着updateComponent方法没有返回值,这就意味着渲染Watcher的getter没有返回值,这就意味着渲染Watcher的value始终是undefined,也就是说,渲染Watcher的value不重要
总结
如果响应式数据在模板中被使用,当创建渲染Watcher的时候,会在构造函数中调用this.get方法:
this.get内部会调用pushTarget(this),将渲染Watcher设置为target(将渲染Watcher赋给Dep.target)- 再调用
this.getter方法,也就是updateComponent方法。 - updateComponent方法内部会调用render方法,访问响应式数据,触发getter
- 调用
dep.depend()方法,其在内部调用Dep.target.addDep(this)方法,让渲染Watcher订阅dep,addDep方法内部调用dep.addSub(this),让dep收集渲染Watcher到subs。 - 调用完render方法,收集好依赖,返回虚拟DOM后,
updateComponent还会调用update方法,将虚拟DOM转化成真实DOM
render方法只在this.get中被调用,而渲染Watcher调用get方法的时候,就会将渲染Watcher赋给Dep.target,所以调用render方法的时候,deps收集的Watcher一定是渲染Watcher
计算属性Watcher
在initComputed中已介绍,不赘述
用户自定义Watcher
在initWatch中已介绍,这里再补充几点:
用户自定义Watcher的user属性为true
用户自定义Watcher区别于前面2类Watcher,它有自己的cb,是用户定义的,当自定义Watcher的依赖更新后,用户自定义的Watcher就会调用update->run方法,然后在run方法中调用cb
我们自定义Watch的时候还能拿到新旧值,这说明对于用户自定义Watcher,它的value也是有意义的。
对比
| Watcher 类型 | update() 是否调用 run()? | 是否有回调 cb? | 如何处理更新 | 何时被创建 |
|---|---|---|---|---|
| 渲染 Watcher | 是 | 否(内部机制处理更新) | 放入队列,最终调用 run() 更新视图 | 在mountCompnent方法中 |
| 用户 Watcher(watch 选项) | 是 | 是 | 放入队列,调用 run() 执行回调 | 在initWatch的时候 |
| 计算属性 Watcher | 否 | 否 | 仅标记为 dirty = true,下次访问时重新计算 | 在initComputed的时候 |
给对象添加属性视图不刷新
由于我们在vue2中是基于Object.defineProperty()来实现的响应式的,所以添加或者删除对象的属性,或者通过数组方法修改数组,无法被vue监听到。Vue对此也提供了解决的方案。
对于数组,编写拦截器对象,重写数组原型上那7能修改数组的方法:pop,push,shift,unshift,sort,reverse,splice,然后使用拦截器,覆盖掉哪些需要添加响应式的数组的原型。之后调用该数组的那几个方法,使用的就不是数组原型上的方法,而是重写的方法,在重写的方法中,通知数组的依赖进行更新。
1 | function def(obj, key, value, enumerable) { |
对于对象,Vue提供了vm.$set和vm.$delete方法来解决这个问题,关于二者已经介绍过。
说说new Vue()后发生了什么
我们都听过知其然知其所以然这句话,那么不知道大家是否思考过new Vue()这个过程中究竟做了些什么?
vue构造函数
首先找到vue的构造函数
1 | //源码位置:src\core\instance\index.js |
options是用户传递入的配置对象,包含data、methods等常用属性(至少创建根实例的时候是这样的)。
vue构建函数调用了_init方法,并传入了options,所以我们关注的核心就是_init方法:
_init
1 | //位置:src\core\instance\init.js |
在调用beforeCreate之前,主要做一些数据初始化的工作:
initEvents是用来初始化事件的,初始化的是父组件给子组件添加的事件。本质是通过vm.$on来给子组件实例添加事件监听,被注册的事件会存储在vm._events对象中,后续移除事件监听则是通过vm.$off,这两个api我们都介绍过。子组件模板内添加的事件,至少需要在模板解析后才能开始初始化。
callhook的作用是触发用户设置的生命周期钩子
initInjections和initProvide在后文传值方式中有介绍,initInjections 在 initState 之前执行,所以在data或者props中可以使用this访问inject中的数据。initProvide 在最后执行,所以它能使用data中的数据。
initState
1 | //源码位置:src\core\instance\state.js |
分析后发现,initState方法依次,统一初始化了props/methods/data/computed/watch,说明在created的时候,这些东西都准备好了,或者说初始化工作都完成了。
initProps
1 | function initProps(vm, propsOptions) { |
propsOptions = vm.$options.props,是props配置对象,是我们自定义的,比如
1 | props:{ |
vm.$options.propsData,是组件接收到的,父组件传递过来的值。vm._props的值起初是一个普通的空对象,后来被响应式的添加了属性,最后它的属性会被代理到vm上(如果vm上不存在同名属性的话)。
initProps无非就是做了这么几件事:
- 创建一个空对象,赋给
vm._props - 使用
propsOptions校验propsData,并拿到一个一个的值value - 调用
defineReactive(props, key, value),将一个一个的值响应式地代理到vm._props上,区别于inject直接把key,value响应式的添加到vm上。 - 如果
props中的某个key在vm上不存在,则调用proxy(vm, '_props', key),将其代理到vm上,然后就能直接通过vm访问vm._props上的值。
initMethods
简要源码如下:
1 | function initMethods(vm, methods) { |
实现思路如下:
- 遍历methods中的所有key,如果值不是一个函数则报错
- 如果methods中的key和props中的key重复,也报错
- 如果methods中的key已经在vm中存在了,且是以
$或者_开头的,则也报错 - 如果不存在上述问题,使用bind修改方法中this的指向,将修改后的方法挂载到vm上,这意味着,无论如何调用组件中配置的方法,其内部的this指向始终是组件实例。
initData
1 | function initData(vm) { |
vm.$options.data是一个函数,则调用这个函数,赋值给vm._data,如果是个对象,则直接赋值给vm._data,然后使用observe方法将vm._data转化成响应式的数据。最后再考虑将vm._data中的数据代理到vm上。
props和method在data之前就被初始化了,所以data中的属性,不能与props和methods中的属性重复;之所以要防止重复,因为它们都会被代理到vm上(是的,包括props中的数据),都是直接通过this来访问,重复了就会产生冲突。
同时我们也可以发现,props中的数据的优先级,是高于data中的数据的,对于data中的key,只有在props中不存在相同的key的时候,才能代理到vm上。对于其他情况,data中的key都可以直接代理到vm上,即便出现了覆盖的情况。
对比通过props和inject添加的响应式数据
inject是使用defineReactive将注入的属性,直接响应式的添加到vm上的(不是深度响应),所以压根不需要考虑代理。
1 | export function initInjections(vm: Component) { |
props和inject初始化的方式本质都是一样的,都是defineReactive+关闭深度响应式,只不过添加的位置不同,props是响应式添加到vm._props然后再代理到vm,也就是说,在vue组件中,初始化父组件或者祖先组件传递过来的值,都只会添加浅层响应式
proxy
vue的数据代理核心在于proxy方法,我们来看看它做了什么。
1 | function proxy(target, sourceKey, key) { |
再次之后,访问target.key返回的就是target.sourceKey.key,说到底还是从target上面取数据,只不过简化了访问的路径。
initComputed
计算属性的配置方式有2种。
1 | new Vue({ |
initComputed的简要源码:
1 | function initComputed (vm: Component, computed: Object) { |
initComputed的任务就是,确定每个计算属性的getter,为每个计算属性创建watcher,并存储在vm._computedWatchers,然后调用defineComputed方法。
遍历每个计算属性,在给每个计算属性创建Watcher之前,先确定每个计算属性的getter,这一点非常简单。拿到每个计算属性的value,如果这个value是一个函数,则直接把它作为计算属性Watcher的getter,否则把value.get作为getter,无论如何,得到的getter的格式形如:
1 | function() { |
可以看出,计算属性的getter的主要作用是求值,由于访问了响应式属性还会触发依赖收集,作为Watche的getter非常合适。
确定好getter之后,为每个计算属性创建一个Watcher,并存储在vm._computedWatchers[key]中,从代码中可以看出,计算属性Watcher也没有实际的回调函数cb,它的cb是一个空函数(noop),说明计算属性Watcher的cb不重要。
特殊标志
对于计算属性(computed) 来说,它对应的 Watcher 有两个特殊标志:
lazy:表示是否延迟求值(即在构造函数中不立即调用
get()方法获取值)dirty:表示当前值是否是“脏”的(需要重新计算)
因为计算属性Watcher的lazy属性为true,这就是意味着,创建计算属性Watcher的时候,并不会立即调用
this.get方法取值,也就不会触发收集依赖。只有在模板或其他地方访问它的时候才会真正去求值1
2
3constructor (){
this.value = this.lazy? undefined: this.get()
}
创建完计算属性的Watcher后,调用defineComputed方法
defineComputed
我们再来看看defineComputed到底做了什么
1 | // 辅助变量和函数 |
可以看出,defineComputed的最终目的是,使用Object.defineProperty,把计算属性代理到vm上方便访问,但是在这之前,还需要确定计算属性的get和set:
- 计算属性的
get等于createComputedGetter(key)的返回值 - 计算属性的
set等于userDef.set,如果没有配置set,则set为noop,空函数。
createComputedGetter
createComputedGetter这个函数的命名意思非常明确,它的作用就是用来创建计算属性的getter的,它的返回值是一个函数。
我们来看看createComputedGetter是如何构造计算属性的get的
1 | // 创建 computed 属性的 getter 函数 |
createComputedGetter会立即返回一个具名函数computedGetter,意思就是计算属性的getter,在computedGetter 函数的内部,或者说计算属性get的内部,是这样工作的:
先在
this._computedWatchers中查找当前key(计算属性)是否存在对应的watcher,不存在直接退出找到计算属性的Watcher后,根据
Watcher.dirty属性判断计算属性是否是脏的,如果是,重新计算计算属性的值并修改Watcher.dirty为false。如果有Watcher正在收集依赖,则调用
watcher.depend()方法(watcher收集watcher吗…毕竟计算属性也算是数据)最后,返回
watcher.value。这就说明,计算属性的getter返回的值,本质就是对应的计算属性watcher.value确定计算属性的set就没那么麻烦了,如果自己定义了set,直接就当作计算属性的set。
在computedGetter方法中,还调用了watcher.depend(),对于在模板中使用的计算属性,它会让渲染 Watcher 去订阅计算属性所依赖的底层数据的 dep,而计算属性 Watcher 本身也是这些 dep 的订阅者。因此,这些 dep 的 subs 列表中会同时包含计算属性 Watcher 和渲染 Watcher。
这么做的效果就是,如果某个计算属性在模板中使用了,当其某个依赖的改变后,会同时通知计算属性Watcher和渲染Watcher更新;
普通响应式数据通过 defineReactive 定义,其 getter 会:
- 返回闭包中的 value
- 触发依赖收集(将当前 Dep.target 加入自身的 dep)
而计算属性不同,它是通过 Object.defineProperty 直接挂到 vm 上的,没有自己的 dep,其 getter 返回的是对应 计算 Watcher 的 value,如果 Watcher 是脏的(dirty: true),会重新求值,并在此过程中由计算 Watcher 收集它所依赖的响应式数据(如 data 中的属性),这个依赖收集发生在 计算 Watcher 和底层数据之间,与计算属性本身无关
此外,计算属性的 setter 是用户自定义的,不会自动触发依赖通知——因为它不是响应式系统通过defineReactive管理的,自然也没有“变更后通知订阅者”的机制。
变动
这种设计看起来完美,但是还存在问题,如果计算属性依赖的状态改变了,但实际的值没有改变,渲染Watcher还是会被通知去更新。
解决的办法就是,计算属性依赖的状态,不再收集渲染Watcher为依赖。对于计算属性Watcher,实例化的时候还会有如下初始化操作:
1 | this.dep = new Dep |
哈哈,在Watcher中创建dep,完美解决了计算属性没有自己dep的问题,后续计算属性的值真正改变了的时候,计算属性Wacther再通知渲染Watcher更新
initWatch
watch常用的注册方式
1 | watch:{ |
initWatch的实现思路并不复杂
1 | // 简化版本 |
initWatch接收2个参数,vm是组件实例,watch是用户设置的watch对象。使用for in 遍历watch对象,其中的每个key就是路径字符串,为每个key创建Watcher
1 | function createWatcher(vm, expOrFn, handler) { |
在createWatcher中,确定好handler和options后就可以,调用vm.$watch,创建用户自定义Watcher了。
vm.$mount
在本文中介绍过了,不赘述
Vue的实例方法
vm.$set
1 | vm.$set(target, key, value) |
用法:设置对象的属性,如果对象是响应式的,确保属性被创建后也是响应式的,同时触发视图更新。这个方法被用来解决在vue中给对象添加属性无法被监听到的问题。
实现原理:
1 | function set(target, key, value) { |
实现思路:
如果target是数组,使用splice方法来添加元素
如果target是对象,且想要添加的key已经存在,则直接设置key-value即可。
如果target是对象,且添加的是一个新的key,先判断target是不是响应式对象,如果不是,则直接设置key-value即可;如果是
通过
defineReactive方法将key,value响应式的添加到target上,并通知target的依赖进行更新。
vm.$delete
1 | vm.$delete(target, key) |
用法:删除对象的属性。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开vue.js不能检测到属性被删除的限制。
实现原理:
1 | function delete(target, key) { |
实现思路:
- 同样的,先考虑target是数组的情况,使用splice方法来删除对应的属性
- 再考虑
target是对象的情况,如果target不是响应式的,直接删除这个属性即可;如果target是响应式的,删除属性后,还要通知target的依赖更新
vm.$watch
1 | //第一个参数可以是路径字符串也可以是函数 |
返回值是unwatch函数,用于解除事件监听。
1 | const unwatch = vm.$watch('a.b.c', function(newVal, oldVal){ |
实现原理:
1 | function $watch(expOrFn, cb, options) { |
initWatch是对vm.$watch的封装,vm.$watch其实是对new Watcher的一种封装,Watcher的原理在前面介绍过。如果options中还配置了deep:true,在创建对应的Watcher实例的时候,实例的deep属性会被标记为true,然后在get方法中,不仅会调用getter方法获取到value,还会递归访问value的子值。
1 | get(){ |
比如一个watch监听的是vm上的user属性,如果user的值是个对象且开启了深度监听,则vm.user对象中的所有属性(不只是第一层)也会被监听。
vm.$on
1 | vm.$on(event, cb) |
用法:监听当前实例上的自定义事件,事件可由vm.$emit触发,下面介绍简要的实现原理:
1 | const obj = {} |
vm.$off
1 | vm.$off([event, callback]) |
用法:移除实例上的自定义事件监听器。
- 如果没有提供任何参数,则移除实例上的所有事件监听器
- 如果只提供了事件,则移除该事件的所有的监听器
- 如果同时提供了事件和回调,则只移除这个回调的监听器
简要实现原理如下:
1 | obj.__proto__.$off = function (event, cb) { |
vm.$once
1 | vm.$once(event, callback) |
用法:监听一个自定义事件,但是只触发一次,在第一次触发之后移除监听器
实现原理:
1 | obj.__proto__.$once = function (event, cb) { |
vm.$emit
1 | vm.$emit(event,[...args]) |
用法:触发当前实例上的自定义事件,附加的参数都会传入对应的回调函数。
1 | obj.__proto__.$emit = function (event, ...args) { |
vm.$forceUpdate
vm.$forceUpdate的作用是强迫vue.js实例重新渲染,注意它仅仅影响实例本身和插入插槽的子组件,而不是所有子组件。
1 | Vue.prototype.$forceUpdate = function(){ |
简单来说,vm.$forceUpdate的作用就是手动调用渲染Watcher的update方法
vm.$destroy
vm.$destroy的作用是完全销毁一个实例,它会清理该实例和其他实例的连接,并解绑其全部指令和监听器,同时会触发beforeDestory和destroyed这2个钩子函数。
1 | Vue.prototype.$destroy = function(){ |
vm.$mount
这个方法通常不需要我们手动调用,因为如果在实例化的时候设置了el选项,会自动把vue.js实例挂载到对应的dom元素上,其实内部使用的就是这个方法。如果vue.js实例在实例化的时候,没有el选项,则它处于未挂载的状态,没有与html文件关联,我们可以手动调用vm.$mount方法将vue.js实例挂载到dom上。
1 | const MyComponent = Vue.extend({ |
vue.js其实有很多不同的构建版本,在不同的构建版本中,vm.$mount的表现都不一样,主要区别体现在完整版(vue.js)和运行时版本(vue.runtime.js),这二者的差异在于,完整版中有编译器,可以编译模板为渲染函数,而运行时版本中没有编译器。
在完整版的vue.js中,vm.$mount会先检查渲染函数render是否存在,如果没有,立即进行编译过程,将模板编译成渲染函数;而在运行时版本中,由于没有编译器,它会默认实例上已近存在渲染函数,如果不存在,为了防止报错,会将创建空注释结点的函数,作为渲染函数。
下面介绍完整版vm.$mount的代码
1 | const mount = Vue.prototype.$mount |
在上面代码中,我们将vue原型上的$mount方法,保存在mount中,以便后续使用。然后就把vue原型上的$mount方法给覆盖了。新方法会调用原始的方法,这种做法叫做函数劫持。
通过函数劫持,可以在原始功能上新增其他功能,上述代码中,mount方法就是vm.$mount方法的核心功能,我们把它保存下来了。在完整版中,需要在mount功能的基础上添加模板编译的功能
1 | const mount = Vue.prototype.$mount |
1 | function query(el){ |
经过query的处理,确保el是一个dom
编译器的工作流程:
检查是否存在render函数,如果存在,则直接调用mount方法,也就是执行mount的核心功能。
如果没有render函数,则将模板编译成render函数:
需要先拿到模板:
判断配置对象中是否有
template属性,我们期望是一个html字符串,但如果这个字符串是id选择器,我们则获取对应的html结构作为模板字符串(idToTemplate)。实际还可能是dom对象,我们把这个dom对象的
innerHTML作为模板如果既没有
render也没有template,那就将el的全部结构作为template(包括了元素本身以及其内部的所有 HTML 内容)无论如何,最终
template属性的值是一个html字符串最后进行模板编译,得到
render函数,挂载到options上
接下来介绍在运行时版本的vue.js中,vm.$mount的工作原理,也就是vm.$mount的核心功能,前面介绍过,存储在mount方法中,其实就是mountComponent方法。
1 | export function mountComponent (vm, el) { |
mountComponent做了这么几件事
- 先判断
vm.$options.render是否存在,如果不存在的话就让它等于createEmptyVNode。 - 执行
beforeMount钩子 - 准备好
updateComponent,也就是渲染Watcher的getter,创建渲染Watcher,创建虚拟DOM并转化成真实DOM挂载。 - 执行
mounted钩子
从上述代码中可以看出,在beforeMount钩子被调用的时候,模板已经编译完毕,render函数已经准备好了,不过还没有被调用。
在创建渲染Watcher的时候,在构造函数中,updateComponent会立即执行,也就是说会调用_render函数;再调用_update方法将虚拟DOM转化成真实DOM再挂载
关于patch的介绍,参考DIFF算法部分
Vue的全局方法
Vue.nextTick
用法如下:
1 | Vue.nextTick( [callback, context] ) |
用法:传入的回调函数会在下一dom更新之后延迟执行,修改数据后立即使用这个方法,在回调函数中能拿到最新的dom。
源码:
1 | import { nextTick } from '../utils/index' |
其中的nextTick方法就是后面介绍过的nextTick方法,无论是Vue.nextTick方法还是vm.$nextTick方法,都是同一个方法,都是nextTick方法,只是挂载的位置不同。
Vue.set
其用法如下:
1 | Vue.set( target, key, value) |
用法:设置对象的属性,如果对象是响应式的,确保属性被创建后也是响应式的,同时触发视图更新。这个方法被用来解决在vue中给对象添加属性,无法被监听到的问题。
Vue.set和vm.$set的实现原理相同:
1 | import { set } from "../observer/index" |
都是同一个set方法,只是挂载的位置不同。
Vue.delete
其用法如下:
1 | Vue.delete( target, key) |
用法:删除对象的属性。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开vue.js不能检测到属性被删除的限制。
同理,Vue.delete与vm.$delete的实现原理相同,都是同一个delete方法只是挂载的位置不同。
Vue组件通信的方式有哪些
vue中,每个组件之间的都有独自的作用域,组件间的数据是无法共享的,但实际开发工作中我们常常需要让组件之间共享数据,这也是组件通信的目的,要让它们互相之间能进行通讯,这样才能构成一个有机的完整系统。
组件间通信的分类:父子组件之间的通信,兄弟组件之间的通信,祖孙与后代组件之间的通信。一些常用实践:
父组件如何拿到子组件中数据:
- 父组件捕获子组件实例:子组件将数据暴露出来,然后父组件捕获子组件实例(父组件主动去拿)
- 子组件调用父组件的方法:父组件将一个方法传递给子组件,子组件调用这个方法的同时, 将自己的数据传递给父组件(子组件主动传递,其实就是emit)
子组件如何拿到父组件的数据:
- 父组件通过provide提供数据,子组件通过inject注入数据
- 父组件通过props传递数据,子组件通过props接收数据
- 捕获子组件,然后调用子组件的方法,从而将父组件的数据,传递给子组件,这么做的好处是动态传值,而不是一开始就要传值。
兄弟组件间如何传值
通过状态管理工具
使用事件总线
在父组件中捕获子组件A,然后传递给子组件B,然后子组件B就能使用子组件A中暴露的方法或者数据 (少用)
props和emit
基本语法
适用场景:父组件传递数据给子组件,即父子组件之间的通信
父组件通过给子组件标签添加属性,来传递值,子组件设置props属性,接收父组件传递过来的参数,同时还能限制父组件传递过来的数据的类型,还能设置默认值。
1 | <Children name="jack" age=18 /> |
1 | //Children.vue |
注意:
- props中的数据是父组件的,子组件不能直接修改,遵循”谁的数据谁来维护”的原则。
- 子组件标签的所有属性中,未被子组件接收(props中未声明)的数据,也能在
this.$attr,即组件实例的属性中拿到,因为未被接受的属性,就会被当作组件自身的普通属性。
深入理解
再问大家一个问题,为什么父组件中的数据更新,子组件中通过props接收的数据也会随之改变,子组件视图也会更新?
父组件的模板,在模板编译的时候,会被解析成一个render函数,这一点我们在前面已经介绍过了,举例说明
1 | function render(createElement) { |
调用父组件的模板render函数时,访问了父组件实例的age属性,赋值给子组件的props.age,这个过程中触发age属性的getter,于是收集父组件自身的render函数为依赖(就是渲染Watcher)
调用父组件的render函数,遇到子组件标签时,会调用createElement方法,根据子组件的配置,创建子组件的VNode(这部分内容前面介绍过) 。在将子组件VNode转化成真实DOM的时候,会调用子组件构造函数,创建子组件实例,后续就会会调用initProps方法。
1 | // 传入的第二个参数,是子组件中的props属性的值(props配置对象) |
父组件传递了,且子组件通过props接收的数据,会被存储在vm.$options.propsData,子组件初始化的时候(调用initProps的时候),会将通过props接收的数据,响应式的添加到vm._props,并代理到vm上,缩短访问路径。
上述例子中,父组件传递给子组件的值,只不过是this.age,是一个普通数据类型,压根不是响应式数据,这种传递会导致响应式丢失,触发getter的位置,也是在父组件渲染函数内,子组件渲染Watcher压根就没被age属性收集为依赖,后续是子组件自己把age属性响应式的添加到vm._props
既然在父组件的age属性,并没有收集子组件Watcher为订阅者,为什么父组件更新age属性,子组件视图也会更新呢?
当父组件中的
age属性改变,会触发对应的setter,然后通知依赖更新,其中的依赖就包括父组件渲染Watcher父组件渲染Watcher最终会调用
run方法,这个方法会调用render函数,创建新的组件vnode(props的值是新的)在
patchVnode阶段,更新组件实例,修改子组件的_props属性由于
this._props是响应式的,所以会自动触发子组件视图更新。
简单的来说,父组件修改传递给子组件的数据,子组件视图也会更新,**是因为父组件内部重新调用了render方法,创建了新的组件Vnode,然后在patchVnode的时候,会更新组件实例的_props属性(this._props)**。
emit
适用场景:子组件传递数据给父组件(父子组件通信)
子组件通过$emit触发自定义事件,$emit第一个参数为自定义的事件名,第二个参数为传递给父组件的数值
父组件在子组件上绑定事件监听,通过传入的回调函数拿到子组件的传过来的值。
1 | //Children.vue |
1 | //Father.vue |
要注意的是,给组件添加的事件监听是自定义事件,因为组件标签不是原生标签,无法添加原生事件监听,也就没有原生事件对象,所以传递给回调函数的,是子组件传递过来的值,而不是原生dom事件。
provide与inject
基本语法
跨层级传递数据,传递方向是单向的,只能顶层向底层传递。
在祖先组件定义provide属性,返回传递的值,在后代组件通过inject,接收祖先组件传递过来的值
1 | export default { |
1 | //常见写法 |
深入理解
如果父组件通过Provide传递的是一个基本数据类型,在子组件内接收了,后续即便父组件修改这个基本数据类型,子组件也不会更新,为什么?但如果父组件通过Provide传递的是一个对象,这一情况就完全不同?
我们先看看initProvide的源码:
1 | export function initProvide(vm: Component) { |
可以看出initProvide的源码非常简单:
- 如果组件定义了Provide属性,且值是一个函数,则使用
call(vm)调用这个函数,确保this指向准确。 - 最后将函数调用的返回值,其实也就是一个对象,赋给
vm._provided属性。
再看看initInjections的源码
1 | export function initInjections(vm) { |
步骤分析:
创建一个空对象result,遍历inject配置对象中的每个属性,从当前组件实例开始:
检查这个组件实例是否提供了对应的值,如果提供了,则把这个值取出来,存到result中
1
result[key] = source._provided[provideKey]
如果没有找到,则继续去下一个祖先元素中查找,类似原型链查找
如果在所有祖先元素中都没有找到这个provideKey,则检查
inject[key]中是否提供默认值,如果提供了则使用默认值(是用户在inject中配置的),存到result中,如果默认值都没提供,则直接报错
- 将注入的值响应式的添加到组件vm上:使用
defineReactive(vm, key, value), 将result上的所有属性都代理到vm上并添加为响应式,但 不进行深度响应式处理(也就是在defineReactive中,调用observe方法会直接返回)。
ok,分析完源码后,我们来尝试解决开始提到的问题
所以说,即便父组件Provide的值被修改了
1 | provide() { |
比如this.color修改了 ,也并不会重新Provide这个值,也就是说provide是一次性的,所以如果父组件通过Provide传递的是一个基本数据类型(比如this.color),在子组件内接收了,后续即便父组件修改这个基本数据类型,在子组件实例vm上的color也不会改变。
但是如果父组件通过Provide传递的,是一个对象,由于在vue中响应式是递归添加的,所以这个对象是个响应式对象,而且由于传递的是一个引用,其实父子组件是共用这个响应式对象的,如果子组件中在模板中使用了这个对象,则子组件的渲染Watcher会被它收集为依赖,这样即便在父组件内修改这个对象,在子组件的视图也会更新。
与props的比较
写法相同
在vue2中,inject的写法和props的写法完全相同
1 | //数组写法 |
原始值相同
父组件给子组件通过props传递的数据,就是vm.$options.propsData,本身也不是个响应式对象,父组件provide的数据(vm._provided),也不是一个响应式的对象
1 | createElement('Child', { // 子组件 Child,绑定 props.age |
1 | provide() { |
它们本身都类似
1 | { |
也就是父组件从自己身上取值,然后存到一个普通对象身上,在这个取值的过程,其实是会丢失响应式的,但如果取出的是一个对象,比如userInfo,由于在vue中响应式是递归添加的,所以这个对象userInfo还是个响应式对象。
通过props传值的时候,由于是在模板中使用响应式数据,父组件的响应式数据,会收集父组件的渲染Watcher为订阅者,但是通过provide传值,真的就只是传了一个值,没有Watcher在收集依赖
后续变化不同
通过props传递的即便是一个基本数据类型,在父组件中修改了,子组件视图也会更新;因为会调用父组件的render方法,创建新的组件VNode,然后再patchVNode阶段,更新组件实例的_props,由于_props是响应式的,所以能触发子组件视图更新。
但是如果通过provide传递一个基本数据类型,在父组件中修改了,子组件视图也不会更新,因为父组件不会重新provide值,子组件也不会重新inject,provide和inject都是一次性的。
ref和$parent
在 Vue 2 中,this.$refs 是一个对象,它包含了所有通过 ref 属性注册的DOM 元素或组件实例。可以使用 this.$refs 来直接访问这些dom元素或组件实例,从而进行操作,如获取DOM节点、调用子组件实例的方法,获取数据等。
注意:this.$refs 只能在父组件中,用来引用通过 ref 属性标记的子组件或 DOM 元素
1 | <Children ref="foo" /> |
同时,子组件也可通过this.$parent拿到父组件实例
事件总线
使用场景:兄弟组件传值
通过共同祖辈$parent或者$root搭建通信
兄弟组件
1 | this.$parent.$on('add',this.add) |
另一个兄弟组件
1 | this.$parent.$emit('add',1 ) |
本质就是要找到一个两个兄弟组件都能访问到的vue实例,A组件在这个实例上注册事件监听,B组件触发这个事件并传入值,就能把值传递给A组件,依次类推,就能实现兄弟组件间相互传值。本质和emit是一样的(父组件在子组件实例上添加事件监听,子组件通过自己的实例this调用emit方法)。这个vue实例的作用好像连接这两个组件的管道,通过这个Vue实例来通信。
状态管理工具
比如Vuex,此处不介绍。
总结
父子组件之间传值就使用props和emit。父组件通过在子组件标签添加属性来给子组件传值,子组件通过props接收父组件传递过来的值;父组件给子组件添加自定义事件监听,然后在子组件内部,通过emit触发父组件注册的自定义事件,并传入值,将子组件内部的值传递给父组件(本质还是父组件通过方法拿到了子组件的值,不过是子组件主动传递的)。
父组件还可以通过ref捕获子组件实例,拿到子组件中的数据,同时还能调用子组件的方法给子组件传值。子组件也可以通过$parent属性拿到父祖件实例,从而拿到父组件中的数据。
祖先组件将值传递给后代组件,就使用provide和inject。祖先组件通过provide提供值,后代组件通过inject注入值。
兄弟组件之间传值就可以使用事件总线,找到或者创建一个兄弟组件都能访问到的vue实例,在A组件中给这个vue实例注册事件监听,在B组件中触发这个注册的事件并传入值,就能将B组件中的数据传入A组件。
如果某个数据需要在多个组件中使用,可以选择状态管理工具
说说Vue的生命周期
定义
讲解思路:先介绍是什么,再介绍有哪些阶段,再引入勾子函数,再详细介绍各个阶段
vue的生命周期,指的是vue实例从创建到销毁的过程,可分为五个阶段:初始化阶段,模板编译阶段,挂载阶段,更新阶段,卸载阶段。在vue生命周期的不同阶段,还会执行一系列的钩子函数,这些钩子函数就叫做生命周期函数。
初始化阶段,指的是从new Vue()到created之间的阶段。这个阶段主要负责在vue实例上初始化一些属性和事件,给数据添加响应式就发生在这个阶段。
模板编译阶段,指的是在created钩子和beforeMount钩子之间的阶段,这个阶段主要负责将模板编译成渲染函数,但是在运行时版本中,模板已经编译好了,所以不存在这个阶段。
挂载阶段,指的是在beforeMount钩子函数和mounted钩子函数之间的阶段,在这个阶段会创建渲染watcher,挂载组件。
模板更新阶段,指的是beforeUpdate钩子函数和updated钩子函数之间的阶段,这个阶段会创建新的vnode并更新模板
卸载阶段,当调用vm.$destory方法后,就会进入组件的卸载阶段。
Vue2中的生命周期函数
beforeCreate:vue实例刚被创建,能拿到this,部分初始化工作完成,但是数据代理还未开始(未调用initState方法),此时无法通过this方法使用data和methods等
created: 此时几乎所有配置属性比如inject,data,method,computed,props,watch,provide都初始化完成,但是模板解析(是为了得到render函数,render函数是用来创建虚拟dom的)还未开始(未调用vm.$mount方法),页面展示的是未经vue编译的dom。
beforeMount:template模板已经解析结束,render函数创建完毕,但是render函数还未调用,还没生成虚拟dom,此时展示的还是旧的页面(未经编译的页面)
mounted:此时render函数已经被调用,而且虚拟 DOM 已转换为真实 DOM,挂载到页面上,此时对DOM的操作是有效的。
beforeUpdate:此时数据是新的,页面展示的内容是旧的,因为vue视图是异步更新的,关于异步更新这一点,可以参考后文《说说你对nextTick的理解》
updated: 此时新旧虚拟dom比较完毕,页面已更新。
beforeDestroy:当执行beforeDestroy的钩子的时候,Vue实例就已经从运行阶段进入销毁阶段,但还未真正执行销毁的过程,身上所有的data和methods,以及指令等,都处于可用状态。
destroyed: 完全销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器;
对于vue3中的生命周期的介绍,参考《vue》一文。
深入理解
对于存在子组件的情况,为什么先执行父组件的created钩子,再执行子组件的created,mounted钩子,最后再执行父组件的mounted钩子
在创建父组件实例的时候,先进行初始化操作(初始化props,methods,watch,计算属性,给数据添加响应式)当初始化完毕就触发父组件的created钩子,然后进行挂载操作,创建父组件的vnode,调用patch方法递归的将父组件的vnode转化成真实dom。在这个过程中遇到了子组件的vnode,就开始创建子组件实例,开启子组件的生命周期,执行初始化操作,触发子组件的created钩子,执行dom挂载操作,执行子组件的mounted钩子。等到父组件中的所有dom都挂载完毕,再执行父组件的mounted钩子
说说你对vue双向绑定的理解
双向绑定,是数据变化,触发视图更新,视图更新,触发数据变化,其实就是v-model的功能,因此如果要问双向绑定的原理,思路应该是如何实现这个v-mdoel。
给input的标签使用v-model,就相当于动态绑定了value属性,并且添加了input事件监听。当用户在input框中输入文字,就会触发input事件,然后在事件回调中,就会使用input.value来修改动态绑定的响应式数据。当动态绑定的响应式数据被修改后,由于这个数据在模板中被使用了,所以会触发模板更新,重新给input的value属性传值。
1 | <!-- 使用 v-model --> |
说说你对slot的理解?
slot的作用就是用来自定义组件内部的结构,slot可以分为以下三种:默认插槽,具名插槽,作用域插槽
默认插槽
在子组件中使用<slot>标签,就表示这部分结构可以被自定义,slot标签中可以书写结构充当默认结构。父组件在使用的时候,直接在子组件的标签内写入内容即可。
子组件Child.vue,使用slot标签占位,标签体内的结构是默认结构
1 | <template> |
父组件向子组件传递结构,只需要在子组件标签体内写结构就好了
1 | <Child> |
具名插槽
默认插槽形如
1 | <slot> |
当我们给slot标签添加name属性,给每个插槽命名,默认插槽就变成了具名插槽,也就是有名字的插槽
当我们需要在子组件内部的多个位置使用插槽的时候,为了把各个插槽区别开,就需要给每个插槽取名。
同时父组件传入自定义结构的时候,也要指明是传递给哪个插槽的,形象的来说,就是子组件挖了多个坑,然后父组件来这些填坑,需要把具体的结构填到具体的哪个坑。
子组件Child.vue
1 | <template> |
父组件
1 | <child> |
template标签是用来分割,包裹自定义结构的。v-slot属性用来指定,这部分结构用来替换哪个插槽,所以v-slot指令是放在template标签上的,要注意的是,如果想要将某部分结构传递给指定的插槽xxx,因该使用v-slot:xxx,而不是v-slot='xxx'
v-slot:default可以简化为#default,v-slot:content可以简化成#content
作用域插槽
作用域插槽,其实指的是通过插槽传值。子组件在slot标签上绑定属性,将子组件的信息传给父组件使用,所有绑定的属性(除了name属性),都会被收集成一个对象,被父组件的v-slot属性接收。
子组件Child.vue
1 | <template> |
父组件
1 | <child> |
可以通过解构获取v-slot={ user },还可以重命名v-slot="{user: newName}"和定义默认值v-slot="{user = '默认值'}"
所在slot中也存在’’双向数据传递’’,父组件给子组件传递页面结构,子组件给父组件传递子组件的数据。
你有写过自定义指令吗?
什么是指令
我们看到的v-开头的行内属性,都是指令,不同的指令可以完成或实现不同的功能。除了核心功能默认内置的指令 (v-model 和 v-show),Vue 也允许注册自定义指令
指令使用的几种方式:
1 | //会实例化一个指令,但这个指令没有参数 |
注意:指令中传入的都是表达式,无论是不是自定义指令,比如
v-bind:name = 'tom',传入的是tom这个变量的值,而不是tom字符串,除非写成"'tom'",传入的才是字符串。
关于自定义指令,我们关心的就是三大方面,自定义指令的定义,自定义指令的注册,自定义指令的使用。
自定义指令的使用方式和内置指令相同,我们不再研究,其中的难点就是定义自定义指令部分。
定义自定义指令
自定义指令本质就是一个包含特定钩子函数的js对象
在vue2中,这些常见的钩子函数包括:
bind(el, binding)
只调用一次,指令第一次绑定到元素时调用,在这里可以进行一次性的初始化设置,el是指令绑定的dom元素。
unbind()
只调用一次,指令与元素解绑时调用
inserted(el, binding, vnode, oldVnode)
el插入document中时触发,inserted 保证 el 已在 document 中
update(el, binding, vnode, oldVnode)
所在组件的 VNode 更新时调用,但指令绑定的值未变化时也可能触发
注意:上述钩子函数在vue3中并不都有效,vue3中的自定义指令钩子函数和生命周期函数一致,包括:created,beforeMount,mounted,beforeUpdate,updated,beforeUnmount,unmounted。
具体见官方文档,https://cn.vuejs.org/guide/reusability/custom-directives#directive-hooks
所有的钩子函数的参数都有以下:
el:指令所绑定的元素,可以用来直接操作 DOM,省去了手动捕获dom的步骤
binding:
一个对象,包含以下property
name:指令名,不包括v-前缀。value:传入指令的表达式的值,例如:v-my-directive="1 + 1"中,绑定值为2。oldValue:指令绑定的前一个值,仅在update和componentUpdated钩子中可用。无论值是否改变都可用。expression:字符串形式的指令表达式。例如v-my-directive="1 + 1"中,表达式为"1 + 1",又比如v-for="(value, key, index) in obj",传入的表达式为"(value, key, index) in obj"arg:传给指令的参数,可选。例如v-my-directive:foo中,参数为"foo",又比如v-bind:class = "['box']"的参数为class,为什么是arg不是args,因为传递给指令的参数只能有一个,而修饰符却可以有多个。modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar中,修饰符对象为{ foo: true, bar: true }
vnode:Vue 编译生成的虚拟节点
oldVnode:上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用
注册自定义指令
注册一个自定义指令有全局注册与局部注册两种方式。
全局注册
全局注册主要是通过Vue.directive方法进行注册
Vue.directive第一个参数是指令的名字(不需要写上v-前缀),第二个参数可以是对象数据,也可以是一个指令函数
1 | //全局注册一个自定义指令 `v-focus` |
在vue3中的语法如下:
1 | // main.js |
局部注册
通过在组件配置对象中设置directives属性
1 | directives: { |
然后就可以在模板中使用指令
1 | <input v-focus /> |
在vue3中,局部注册的语法就不同了。如果混合使用选项式api,就可以像vue2一样借助**directives属性解决,如果使用的是setup语法糖写法,就需要在setup函数中使用小驼峰来局部注册指令**。
1 | <!-- MyComponent.vue --> |
给指令命名的方式要遵循小驼峰的方式,而且必须以v开头,比如vMyDirective,然后在模板中使用就必须写成v-my-directive。
应用场景
给已经注册了的点击事件添加节流,核心思路是使用事件捕获触发加event.stopImmediatePropagation,来拦截默认冒泡触发的事件监听。
1 | // 1.设置v-throttle自定义指令,但是只能 |
Vue中组件和插件有什么区别
组件是什么
在vue中,组件一般指的是单文件组件,就是能实现部分功能的html,css,js代码的集合。优点是能降低整个系统的耦合度,提高代码的可维护性和可复用性。
插件是什么
vue插件通常就是一个实现了 install 方法的对象,install方法的第一个参数就是vue构造函数。Vue插件基于Vue构造函数来为Vue添加全局功能,如通过Vue.component来注册一些全局的组件(比如vue-router插件全局注册router-link组件)
1 | //这个方法的第一个参数是 `Vue` 构造函数,第二个参数是一个可选的选项对象(options)。 |
插件注册
插件的注册通过Vue.use()的方式进行注册,第一个参数为插件对象,第二个参数是可选择的配置项
1 | Vue.use = function(plugin, options){ |
值得注意的是,注册插件的时候,需要在调用 new Vue() 启动应用之前完成,这就是为什么main.js文件通常先注册插件,再挂载dom。
1 | import { createApp } from 'vue' |
Vue.use会自动阻止多次注册相同插件,只会注册一次。
v-if和v-for的优先级是什么
在vue2中
在vue2中,v-for的优先级高于v-if,也就是说会遍历所有元素,然后再通过v-if判断是否是要渲染,即使某些项最终不满足 v-if 条件,v-for 仍会遍历这些项,
1 | <ul> |
这个例子中,Vue 2 首先遍历 items 数组(通过 v-for),然后对每个项应用 v-if 来决定是否渲染该项。如果90%的数据其实不需要展示,就会带来没有必要的遍历开销。
在vue3中
而在vue3中,v-if的优先级高于v-for,所以在vue3中,上述代码会报错,会提示item未被定义;这也意味着在vue3中,无法根据某个对象的属性,使用v-if来控制渲染。在vue3中这么设计的目的也是希望用户不要将v-if和v-for卸载同一个标签中。
总结
其实最推荐的做法是,只遍历并渲染需要渲染的数据,不在同一个元素上使用v-if和v-for,这就需要我们提前过滤元素。
v-if和v-show如何理解
共同点
二者都是用来控制页面中元素的显示与隐藏,当表达式值为false的时候,都不会占据页面的位置。
区别
v-show本质是通过切换css样式,来实现元素的显示与隐藏,令display:none让元素隐藏,dom元素还存在。
v-if本质则是通过控制dom元素的创建与删除,来实现元素的显示与隐藏,因为v-if直接操作dom,所以v-if有更高的性能消耗(会触发回流)。
v-if才是真正的条件渲染,v-show的值为false的元素,也会被创建,还是会出现在文档中,只是变得不可见且不占据位置。
说说你对nextTick的理解
在vue中,虽然是数据驱动视图更新,但是数据改变(同步改变),vue异步操作dom来更新视图;而传入nextTick的回调函数,能确保在DOM更新之后再被执行,所以nextTick回调函数中能访问到最新的DOM。
使用方法
Vue.nextTick(()=>{})或者this.$nextTick(()=>{}),二者的区别在于后者的回调函数中会自动绑定组件实例。
1 | <div id="app"> {{ message }} </div> |
1 | //使用回调函数 |
如果调用nextTick的时候,没有传入回调函数,则会返回一个Promise对象,这个Promise对象的状态在DOM更新后改变
1 | //使用async/await |
底层实现
1 | const callbacks = [] // 存放传入nextTick的回调函数 |
callbacks新增回调函数后,又执行了timerFunc函数,那么这个timerFunc函数是做什么用的呢,我们继续来看代码:
1 | export let isUsingMicroTask = false //判断是否使用的是微任务 |
上述代码描述了timerFunc的是如何被定义的,做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.then、MutationObserver(代码中已删除)和setImmediate,上述三个都不支持最后使用setTimeout。前两者将清空callbacks的任务放入微任务队列,后两者将清空callbacks的任务放入宏任务队列
通过四个判断可以确保,无论在何种浏览器条件下,都能定义出最合适timerFunc。而且四种情况下定义的timerFunc,效果都是,将flushCallbacks放入微任务(或者宏任务)队列。
timerFunc不顾一切的要把flushCallbacks放入微任务或者宏任务中去执行,它究竟是何方神圣呢?让我们来一睹它的真容:
1 | function flushCallbacks () { |
来以为有多复杂的flushCallbacks,居然不过短短的几行。它所做的事情也非常的简单:
把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍。
为什么要拷贝一遍呢?因为防止遍历的过程中,执行callbacks中的任务的时候,又有新的任务被放到callbacks,然后在本次事件循环中就把本该在下一个事件循环中执行的任务执行掉了!
简单的来说,它的作用仅仅是用来执行callbacks中的所有回调函数,也就是说,callbacks中的任务,会在微任务阶段(或者宏任务)被执行。
如何确保此时DOM是最新的?
经过上面的介绍我们知道,传入nextTick的回调函数,通常会在微任务阶段被依次执行,那又是如何确保nextTick中的回调函数访问到的DOM是最新的DOM呢?我们知道,响应式数据如果被修改了就会触发setter通知依赖更新,如果这个数据在模板中使用了,则会通知模板更新,也就是会调用渲染Watcher的update方法,我们先看看Watcher的update方法:
1 | update () { |
由此可知,渲染watcher更新会走queueWatcher(this)的逻辑,那queueWatcher(this)到底做了什么?
1 | const queue = [] |
分析上述代码可知,调用渲染Watcher的update方法,会将渲染Watcher放入一个异步更新队列queue,然后清空这个异步更新队列的任务flushSchedulerQueue,会通过nextTick放入callbacks中,而callbacks中还存储了我们调用nextTick传入的回调,而callbacks中的回调函数,通常会在微任务阶段依次执行。
简单的来说,当我们修改一个在模板中使用了的响应式数据,渲染watcher会被放入异步更新队列,然后清空这个异步更新队列的方法会通过nextTick放入callbacks,而我们手动调用nextTick传入的回调函数,也会被加入这个callbacks队列。由于我们先手动修改响应式数据,再手动调用nextTick,所以清空异步更新队列的任务会先放入callbacks队列。后续在微任务阶段,先进行DOM更新操作,在执行我们传入nextTick的回调函数。

什么是虚拟DOM,什么是VNode
操作真实dom
这部分内容主要参考js中的事件循环,可参考本博客内的《javascript》一文
在原生 JavaScript 的事件循环中,多次 DOM 操作会 立即修改内存中的 DOM 树,但浏览器通过 批量更新,合并机制, 延迟视图渲染至事件循环末尾。
1 | // 同一事件循环中多次修改同一元素的样式 |
浏览器会将这三次样式修改,合并为一次渲染流程,而非逐次触发三次重排,所以不会看到样式闪烁,因为只渲染了一次。
虽然减少了渲染次数,但每次 DOM 操作仍会 立即修改内存中的 DOM 树,频繁操作可能导致主线程阻塞(比如触发了重排重绘,创建了DOM),所以在Vue等框架中,使用虚拟DOM和diff算法,来减少操作真实DOM的次数。
虚拟DOM(树)与VNode
虚拟DOM,也叫虚拟DOM树,本质就是一个用来描述真实DOM树的js对象,是对真实DOM树的高度抽象。
1 | <div id="app"> |
将上面的HTML模版抽象成虚拟DOM树:
1 | { |
操作虚拟 DOM 的速度,比直接操作真实 DOM 快 10-100 倍。
VNode
虚拟DOM树本身是一个js对象,是对真实DOM树的高度抽象;而VNode是虚拟DOM树上的结点,是对真实DOM结点的抽象,它描述了应该怎样去创建真实的DOM结点。
在vue中,VNode其实就是就是一个VNode类创造出来的实例,这个vnode上有许多属性比如key,text,tag,elm,isComment,children等。
vnode有很多种类型,比如注释结点,文本结点,元素结点,组件结点。
其中注释结点只有2个有效属性text和isComment,其余属性全是默认的undefined或者null。文本结点只有一个text属性。
将vnode转化成dom,挂载到哪儿?
VNode 转化为真实 DOM 后,会被插入到其父 VNode 对应的真实 DOM 元素(parentElm)中,并且通常插入在参考节点(refElm)之前。如果是根组件,最终会挂载到你调用 $mount(el) 时指定的容器上。
看看createElm做了什么
1 | function createElm (vnode, parentElm, refElm) { |
首次调用createElm需要传入父Vnode的DOM,从上述代码中我们可以理解,递归的将虚拟DOM转化成真实DOM是什么意思,简单的来说DOM的创建是从上至下的。
如何比较新旧虚拟DOM树
diff算法
在vue中,我们使用diff算法来进行新旧虚拟dom树的比较。

特点:
- 从根结点开始比较,然后再对比子节点
- 比较只会在同层级进行, 不会跨层级比较
- 在比较同层级子节点的过程中,从两边向中间循环比较
patch
patch方法是diff算法的开端,patch方法只会被调用一次,传入的是整个组件的vnode和oldVnode
1 | //简化后的patch |
1 | function sameVnode (a, b) { |
调用
patch方法,传入新旧虚拟结点(oldVnode, vnode)1.没有新节点:vnode = undefined,说明旧的结点该被删除了,移除旧的dom;
2.没有旧节点:oldVnode = undefined,说明是页面刚开始初始化的时候,此时,根本不需要比较了。直接使用新的vnode来创建dom。
如果oldVnode, vnode都存在,则调用
sameVNode方法,从key,tag等方面判断是否属于同一结点- 3.如果返回true,表明结点可复用,则进一步调用
patchVNode方法,给dom打补丁; - 4.如果
sameVNode返回false,说明旧的dom不可复用,直接使用新的vnode创建新的dom,插入到旧的dom旁边(左边或者右边),然后移除旧的dom。
- 3.如果返回true,表明结点可复用,则进一步调用
patchVNode
patchVnode方法中执行的是真正的更新操作
1 | function patchVnode(oldVnode, vnode) { |
进一步比较oldVnode, vnode,如果oldVnode === vnode,也就是说新旧虚拟结点完全相同,则直接return,什么也不做。
如果新旧虚拟结点不同,则让vnode引用oldVnode的dom。
先对dom的属性打补丁,更新真实dom的各个属性,确保dom属性和vnode属性一致。
再对子元素打补丁:
如果vnode和oldVnode都是元素结点:
- 如果都有子元素,则调用
updateChildren方法对比更新子元素,这涉及到diff算法的核心部分。 - 如果vnode有子节点,而oldVnode没有,那么不用比较了,直接新建全部子节点,插入父节点中。
- 如果oldVnode有子节点,而vnode没有,说明更新后的页面,子节点全部都不见了,那么要做的,就是把所有旧的子节点删除(也就是直接把
DOM删除)。
如果vnode和oldVnode都是文本或者注释结点:则用vnode的文本更新旧dom的文本
对于给属性打补丁,每个的update函数都类似,拿updateAttrs()举例看看:
1 | function updateAttrs(oldVnode, vnode) { |
总结一下上述代码主要流程:
遍历vnode(新的vnode)属性,如果和oldVnode不一样,就调用
setAttribute()修改;遍历oldVnode属性,如果不在vnode属性中,就调用
removeAttribute()删除确保新的dom属性和vnode属性相同
updateChildren
暴力搜索法
从左到右遍历newChildren,对于newChildren中的每个vnode,都使用遍历的方式在oldChildren中查找是否存在相同的结点。
首尾指针法:给newChildren和oldChildren,都添加首尾指针
- 先进行头头比较,判断2个头指针指向的vnode,是否是sameVnode,如果是,只需进行
patchVNode,然后将2个头指针右移。 - 如果不是,再检查2个尾指针指向的vnode,是否是sameVnode,如果是,只需进行
pathVnode,然后将2个尾指针左移。 - 如果不是,再检查
newEndVnode和oldStartVnode是否相同,如果相同,在patchVnode之后,将oldStartVnode对应的dom结点移动到oldEndVnode对应的dom结点之后,然后再移动指针 - 如果不同,再检查
newStartVnode和oldEndVnode是否相同,如果相同,在patchVnode之后,将oldEndVnode对应的dom结点移动到oldStartVnode对应的dom结点之前 - 如果通过头头,尾尾,尾头,头尾的检查,都没有找到相同的vnode,则使用key来查找
newStartVnode对应的oldVnode。
当任意一个头指针大于它的尾指针,退出循环,循环结束时,添加或者删除多余dom
1 | function updateChildren(parentElm, oldCh, newCh) { |
总结
在vue中。使用diff算法,进行组件新旧虚拟DOM的比较,diff算法从虚拟DOM的根结点开始,自上而下进行比较,而且只在同层级结点间进行比较。具体的来说,调用patch方法,传入组件的oldVnode和newVnode,正式开启diff算法流程。
如果oldVnode不存在,说明是首次挂载,直接根据new Vnode创建新的结点并挂载即可;如果newVnode不存在,说明进行的是组件的卸载,直接移除件的dom。如果组件的oldVnode和newVnode都存在,则调用sameVnode方法,判断是否是同一结点,如果不是,说明DOM不可复用,直接根据newVnode创建新的dom,挂载的旧的dom旁边,然后移除旧的dom。如果是同一结点,则调用patchVnode方法给dom打补丁。
在pathVnode方法中,先让newVnode引用oldVnode的dom,然后根据newVnode,给旧的dom属性打补丁,然后再比较子元素,给子元素打补丁。使用双指针法,从两边往中间循环比较新旧子元素,如果是sameVnode,递归调用patchVnode给dom打补丁。
当任意一个头指针大于其对应的尾指针,循环结束,批量添加或者删除结点。

参考资料:
- 面试官:你了解vue的diff算法吗?说说看 | web前端面试 - 面试官系列
- javascript - Vue源码解析:虚拟dom比较原理 - 个人文章 - SegmentFault 思否
- 6分钟彻底掌握vue的diff算法,前端面试不再怕!_哔哩哔哩_bilibili
说说你对vue中key的理解
key是给每一个vnode的唯一id,是sameVnode方法的重要判断依据,在diff过程中,根据key值,可以更准确的找到匹配的新旧vnode,从而优化diff算法,提高dom的复用率。如果不设置key,那key值默认就都是undefined,只要tag相同,就会被认为是相同的vnode。
详细可参考:030_尚硅谷Vue技术_key作用与原理_哔哩哔哩_bilibili
说说你对keep-alive的理解
keep-alive是vue中的内置组件,包裹动态组件(router-view)时,会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM。
被缓存的组件会额外多出两个生命周期activated和deactivated
keep-alive可以使用一些属性,来更精细的控制组件缓存。
include- 字符串或正则表达式或者一个数组。只有名称匹配的组件会被缓存exclude- 字符串或正则表达式或者一个数组。任何名称匹配的组件都不会被缓存max- 数字:最多可以缓存多少个组件实例,超出这个数字之后,则删除第一个被缓存的组件,由此可以推测存在一个缓存队列,先入先出。
1 | <keep-alive include="a,b"> |
组件名称匹配,组件名称,指的到底是什么呢?
匹配首先检查组件自身的 name 选项,如果 name 选项不可用,则匹配它的局部注册名称 (父组件 components 选项的键值),匿名组件不能被匹配。
组件被缓存了,如何获取数据呢?
借助beforeRouteEnter这个组件内的导航守卫,或者activated生命周期函数
1 | // 这个守卫不能写在组件中,因为组件中执行了这个代码,说明路由已经进入这个组件了,自相矛盾了,只能写在路由对象中 |
1 | activated(){ |
面试官:说说你对keep-alive的理解是什么? | web前端面试 - 面试官系列这篇文章中还讲解了keep-alive的实现原理,看起来还是挺复杂的
vue3中的keep-alive的语法不同于vue2
基础用法,默认缓存所有页面:
1 | <router-view v-slot="{ Component }"> |
- Component就是当前的路由组件,是
router-view组件传递过来的 <component>是 Vue3 的内置组件,配合:is属性实现动态组件切换,Vue 会根据:is的值动态渲染对应的组件- keep-alive包裹的不再是router-view而是具体的组件
精确控制具体哪些组件缓存,因为再vue3中使用组件已经不再需要注册,也不需要给组件命名,所以我们控制组件缓存的依据,变成了路由组件的路由对象,而不是组件的名称。同时,我们不再通过给keep-alive标签添加属性,来控制哪些组件该被缓存,缓存多少组件,转变为借助v-if,如果某个组件应该被缓存,那么他就会被keep-alive标签包裹。
简单的来说在vue3中,我们通过路由组件对应的路由对象和v-if指令,来控制组件的缓存。
在路由对象中添加meta属性
1 | { |
在模板中的写法:
1 | <template> |
或者:
1 | <router-view v-slot="{ Component, route }"> |
但是就到此为止的话,切换页面的时候会报错:vue3 TypeError: parentComponent.ctx.deactivate is not a function 报错
网上提供的解决方案就是给每个component提供一个key。
1 | <router-view v-slot="{ Component, route }"> |
详细可参考:vue3中使用keep-alive目的:掘金
SPA
什么是SPA,和MPA有什么区别?
SPA指的是只有一个页面的web应用程序,MPA(多页面应用程序)指的是有多个页面的web应用程序。
SPA通过js操作dom来局部更新页面内容,刷新速度更快,用户体验更好;而MPA是通过页面切换,来实现整页的刷新,整页刷新就需加载整个页面所有资源,并重新渲染页面,所以速度慢;
SPA缺点是不利于搜索引擎优化(SEO),因为搜索引擎爬虫,无法爬取动态生成的内容,而且首屏加载速度较慢,因为一开始就要加载所有的资源,当然这些问题都是可以解决的。
如何实现SPA
SPA是通过hash路由或者history路由实现的,问如何实现SPA,其实就是在询问这两种路由是如何实现,关于这一点,可以参考后文。
服务端渲染SSR
首先我们思考一个问题,为什么需要提高SPA的seo?传统web开发,一般就是多页面应用程序,每个页面的html结构都在服务端拼接好,所以没有SEO问题,而SPA的页面内容通过 Js 动态渲染,初始 HTML 通常是空壳(如 <div id="root"></div>),真实内容由 JS 后续填充。传统搜索引擎爬虫(如早期 Googlebot)可能无法执行 JavaScript,无法爬取动态生成的内容。
那如何解决SPA的SEO问题呢?答案是使用服务端渲染
服务端渲染(SSR),指的是服务端,根据请求的URL,动态拼接HTML结构,然后返回给客户端浏览器,再由浏览器负责页面的激活工作,包括给DOM添加事件监听,建立vnode和真实DOM结点之间的联系。比如:
1 | 用户访问 `https://example.com/user/123` |
需要注意的是,服务端渲染,只会在首次加载页面或者刷新页面的时候执行一次(切换前端路由并不会触发服务端渲染)
使用服务端渲染,返回的页面,就已经包含了一定的页面结构,能够被搜索引擎爬虫爬取。除了能提高SPA的SEO,使用服务端渲染还能提高首屏的加载速度,因为不需要浏览器执行js来拼接html。
简单实现的代码如下:
1 |
|
1 | // 因为是在服务端运行的代码,所以使用的是cjs语法 |
上述代码只实现了根据请求url动态拼接html结构,但是没有涉及到从url中提取出前端路由,然后渲染对应组件的逻辑,而且返回的html文件中不包含激活客户端的js代码,正常开发下是需要包含激活代码的。
hash路由和history路由

哈希路由(Hash-based Routing)和History路由(History API-based Routing)是前端路由的两种常见实现方式,它们用于在单页面应用程序 (SPA) 中模拟多页面体验,而无需重新加载整个页面。
hash路由
是什么
前端路由被放到url的hash部分,即url中#后面的部分。哈希值改变也不会触发页面重新加载,但是会产生历史记录。
浏览器不会将哈希值发送到服务器,因此无论哈希值如何变化,刷新页面,服务器只会返回同一个初始 HTML 文件。
优缺点
不需要服务器配置支持,因为哈希值不会被发送给服务器。
兼容性好,几乎所有浏览器都支持哈希变化事件。
URL 中包含显眼的 # 符号,可能影响美观。
前端路由部分十分明确,方便部署,可以部署在服务器的任何位置。
如何做

可以直接设置 window.location.hash 属性来改变 URL 中的哈希部分,改变 window.location.hash 不会触发页面刷新,但它会添加一个新的历史记录条目。前端 JavaScript 监听 hashchange 事件来检测哈希的变化,并根据新的哈希值更新页面内容。
1 | class Router { |
history路由
是什么
使用标准的路径形式,例如 http://example.com/page1,前端路由被放到url中的path部分
优缺点
没有显眼的#号,更为美观
非常适合用来做服务端渲染:使用History 路由的项目,前端路由混合在url中的path部分,这意味着前端路由能发送到后端服务器。而服务端渲染SSR,要根据请求的 URL,使用 Node.js 动态生成对应的 HTML 内容。
存在404问题,因为前端路由会被当作资源路径,发送到后端,而后端并未做对应配置。
history路由的项目一般部署在服务器根目录,域名后面的路径就是前端路径,否则需要在前端路由库(比如VueRouter)中添加基础路径,确保浏览器能从url中提取出前端路径。
1 | const router = new VueRouter({ |
例如,如果用户的 URL 是 http://example.com/app/user/profile,那么前端路由库会将 /user/profile 视为实际的路由路径,而 /app/ 则被视为基础路径。
如何做
使用 HTML5 的 History API (history.pushState() 和 history.replaceState()) 来修改 URL,而不会触发页面刷新。其中history.pushState()会产生新的历史记录而history.replaceState()不会。
要注意的是调用这两个api修改URL,并不会触发任何的事件,所以在vue的router中,基于这2个方法实现了router.push和router.replace方法,不仅仅能实现修改当前URL的功能,还会在URL被修改后,切换对应的路由组件。
history.pushState(state, title, url)
作用:向浏览器的历史栈中添加一个新的记录,历史栈长度+1,并更新当前 URL,但不重新加载页面。
参数
state: 一个对象,用于存储与该状态相关联的数据
也可以通过history.state属性访问。
title:通常被忽略或设为空字符串(大多数浏览器不支持)。
url:新的 URL,可以是相对路径或绝对路径,但不能改变域名,否则会报错。

history.replaceState(state, title, url)
作用:替换当前的历史记录条目,而不是添加新的条目。它同样更新当前 URL但不刷新页面。
参数与 pushState 相同。
最终代码实现(只实现了pushState)
1 | class Router { |
区别
这两种路由方式总共有5个区别
表面上不同
hash路由中存在#号,而history路由中不存在
前端路由的位置不同
hash路由中的前端路由,位于URL中的哈希部分;history路由中的前端路由,被放到url中的path部分
实现方式不同
hash路由通过修改location.hash来切换前端路由,然后通过监听window的hashchange事件来切换不同的组件。
history路由通过history api来修改前端路由,并根据修改后的前端路由切换对应的组件。
404问题
使用hash路由的项目一般不会出现404,而使用history路由的项目,除非和后端配合了,否则存在404的问题。
SSR
history路由的项目非常适合做服务端渲染,因为前端路由包含在请求的URL中,后端服务器需要根据请求URL中的前端路由,来动态拼接HTML结构。
如何提高首屏加载速度?
首屏加载时间,指的是浏览器从响应用户输入网址,到首屏内容渲染完成的时间,此时整个网页不一定要全部渲染完成,但需要展示当前视窗需要的内容。
首屏加载慢的原因无非2个方面,网络加载慢,或者资源体积太大,对于网络问题,可以使用cdn等方法来解决,但是不属于前端的范畴。
路由懒加载
对于非首屏组件,使用路由懒加载,当需要访问这些组件的时候,再加载对应的资源。路由懒加载本质就是异步加载js,css文件,或者说按需加载js,css文件。
开发单页面应用程序时,只有一个html页面,打包后也只有一个index.html页面,其他所谓的页面,都是通过 JavaScript 动态地修改DOM来实现的。开发过程中,一个页面对应一个或者多个组件,在打包后,每个组件都会转化成对应的css,js代码,其中的js代码不光包括业务逻辑,也负责修改dom,构建页面。
如果使用路由懒加载,我们可以观察到,打包后的js,css文件数量变多了,每个文件的体积也变小了。这是动态导入import触发的代码分割,Webpack / Vite 等构建工具,会将每个懒加载的组件打包成一个独立的 chunk(代码块)。这样,index.html引入的主包体积也会变小(js,css文件)
所以使用路由懒加载,就一定比不使用懒加载好吗,对哪些组件使用懒加载比较好?
访问使用了路由懒加载的组件,需要发送额外的请求获取js和css文件,其实是有请求开销的。
推荐使用懒加载的组件:非首屏路由组件,低频访问页面,按角色权限隔离的页面(如:管理员后台)
不推荐使用懒加载的组件:首屏必现的路由组件(避免运行时加载延迟),用户几乎肯定会访问的页面(避免运行时加载延迟),组件体积非常小( 分割 chunk 的 HTTP 开销可能大于收益)
参考资料:Vue Webpack 打包优化——路由懒加载(按需加载)原理讲解及使用方法说明- 掘金
压缩文件
这一点是显而易见的,压缩静态资源的大小,我们加载这些资源的时间就变少了,从而提高了首屏加载速度。在实际开发过程中,这个功能通常是由webpack等模块化打包工具自动实现的。
缓存静态资源
对于已经请求过的资源,再次请求直接使用缓存,而不用发送实际的请求,这样加载速度就快了
从上述分析我们知道了使用缓存的好处,但是我们如何设置缓存呢?浏览器对于html,js,css和图片等文件是有一套默认的缓存规则,还是说由服务器设置响应头来指定这些文件的缓存规则?
直接说结论:浏览器本身没有对 HTML、CSS、JS、图片等文件的“默认缓存规则”。所有的缓存行为都由服务器通过设置 HTTP 响应头(Response Headers)来控制。浏览器只是遵循这些规则,不会自行决定如何缓存。
常见的缓存控制头:
- Cache-Control:最主要的缓存控制字段(HTTP/1.1)
- Expires 指定缓存过期的绝对时间(HTTP/1.0,已被 Cache-Control 取代)
- ETag / Last-Modified 用于协商缓存,验证资源是否更新
假设你访问 https://example.com/app.js,然后服务端对响应头做了如下配置
1 | //设置强缓存 |
将 app.js 缓存 1 年,一年内再次请求,直接使用缓存,不发请求到服务器
1 | //设置协商缓存 |
下次请求时,带上 If-None-Match: "abc123",服务器检查资源是否变化:如果没变 → 返回 304 Not Modified,不返回内容
,如果变了 → 返回 200 和新内容。
1 | HTTP/1.1 200 OK |
defer和async属性
给script标签添加defer或者async属性,能让加载js文件的时候,不阻塞dom树的构建,有利于提高页面的渲染速度。
http请求
从http请求的角度优化,就是减少http请求的次数,主要思想就是:合并体积小的文件
将多个体积较小的css或者js文件,合并为单个文件(如
bundle.css),减少请求次数。使用雪碧图(或者说精灵图),合并小图片,减少请求小图片的次数,。
或者:内联文件,比如将首屏关键的css插入到html页面,或者将图片转化成base64格式,内联在js文件。
还有一种做法是,通过将同一个页面下的资源分布到不同的域名下,提高并发连接数。
如何做前端性能优化
前端性能指标
DCL
即DOMContentLoaded,表示从页面首次加载,到HTML 文档被完全加载和解析完成 ,所用的时间,不等待样式表、图等外部资源加载完成。
L
即Load,表示从页面首次加载,到整个页面及所有依赖资源(如图片、CSS、字体、iframe 等)全部加载完成 所花的时间
但由于现代通常是单页面应用程序,初始返回的HTML文件通常是个空壳子,DCL 虽早,但用户看到的是空白,DCL 无法反映真实首屏内容出现时间。所以这两个性能指标,现在不常用来表示首屏加载性能了,但Load在含大量图片的页面中仍有意义
FP
表示从页面首次加载,到首次渲染(渲染出第一个像素点,视觉发生变化)所花时间,即白屏时间
FCP
表示从页面首次加载,到首次DOM内容(比如文字,图片)渲染出来,所花的时间,FCP触发的时间点一定在FP之后
LCP
最大内容绘制所花的时间,表示从页面首次加载,到最大DOM内容(比如,一张很大的图片)渲染出来,所花的时间,可以在控制台查看最大的DOM内容。
vue专属优化
- 使用
keep-alive缓存组件:会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM。关键是keep-alive的原理是什么? - 使用路由懒加载,本质是按需加载css,js文件
- 给dom添加key值。提高新旧vnode匹配的准确性,有利于diff算法复用dom。
打包工具优化
- 压缩css,js文件。使用打包工具比如webpack,压缩css,js文件(删除注释,空格,合并多个文件)
- 文件合并,文件拆分。合并体积较小的文件,分割体积过大的文件
- 将体积较小的图片转化成base64格式,内联到js文件中。
tree-shaking。使用tree-shaking移除未使用的代码,减少最终打包后的文件体积。- 将第三方库代码分割为单独的文件,有利于充分利用缓存
http请求优化
减少http请求的次数:核心思想是合并文件
- 将多个体积较小的css或者js文件,合并为单个文件(如
bundle.css),减少请求次数。 - 使用雪碧图(或者说精灵图),减少请求小图片的次数
增加连接
将同一个页面下的资源分布到不同的域名下,从而建立多个连接
升级http协议版本
使用http2,支持并发请求,多路复用
缓存优化
缓存静态资源。在构建过程中,为静态资源文件名添加内容哈希值(例如 app.a1b2c3d4.js),这样每次更新文件时都会生成一个新的URL,浏览器会认为这是一个全新的资源而重新下载它,而不是使用缓存,这是也是打包工具会帮忙做的事情。
JS代码优化
- 使用事件代理,减少内存占用
- 对于高频触发的事件,使用防抖或者节流
- 使用图片懒加载
- 使用web-worker,避免js计算阻塞主线程
部署vue项目遇到过什么问题
404问题
HTTP 404 错误意味着链接指向的资源不存在,问题在于为什么不存在?且为什么只有history模式下会出现这个问题,而hash模式下不会有?
history模式,刷新页面,前端路由部分会被当作请求URL的一部分发送给服务器,然而服务器并没有相关配置,所以响应404。
而hash模式,前端路由在URL的#后面,不会被当作请求URL的一部分。要解决使用history路由的项目,刷新页面出现的404问题,必须和后端沟通,当请求的页面不存在时,返回index.html,把页面控制权全交给前端路由。
但是这样有个问题,就是后端服务器不会再响应404错误了,当找不到请求的资源总是会返回index.html,即便请求的资源在前后端中都不存在(即把页面控制权交给前端路由,也没有对应的页面),所以为了避免这种情况,应该在 Vue应用里面覆盖所有的路由情况,最后给出一个 404 页面(虽然说是404页面,但是响应状态码是200,因为返回了index.html),简单的来说404页面需要前端来设计。
直接打开页面空白问题
直接打开页面,页面空白本质就是因为js文件加载失败
因为我们开发的是单页面应用程序,需要借助js操作dom来更新页面,而本身的html文件中并没有任何结构,所以如果js文件加载失败,页面就不会有任何结构,所以显示空白。
那为什么js文件会加载失败呢,原因分为两种,一种是加载js文件的路径错误,这通常出现在使用绝对资源路径的情况(使用history路由),为了得到最终的路径还会和盘符(C:或者D:)拼接,所以找不到资源。
还有一种是请求资源的时候跨域了,为什么会跨域了,我们加载的不是本地的js文件文件吗?确实,加载本地资源出现跨域,导致资源加载失败的问题,只会出现在vue3项目中,而vue2项目中不会有这个问题,为什么呢?vue3默认使用vite构建工具,打包后会生成基于esm的代码,浏览器在file://协议下加载esm时,会触更严格的跨域安全策略,导致本地的css,js文件也被视为跨域资源,所以资源加载失败
1 | <!--可以观察到这个模块的type='module',这意味着这个js文件内使用了esm语法(比如import),这个js文件成为了esm--> |
而vue2项目通常使用webpack打包,生成的代码通常以传统脚本的形式加载,此时浏览器对file://协议的跨域闲置比较宽松。
你的项目是如何做权限管理的?
后端权限控制
权限管理主要由后端来进行,在后端创建不同的角色,角色不同,拥有的权限也不同,权限包括:
- 访问某个页面的权限
- 操作某个按钮的权限
- 调用某个接口的权限
将角色分配给某个用户,这样用户就具备了权限
前端登录
前端登录后台管理系统,拿到token,存储在cookie或者sessionStorage中,用来标识用户的登录状态
然后发送请求获取用户的权限列表,将这个权限列表,存储vuex或者pinia中,用来动态渲染菜单选项,动态拼接路由表
登录成功且路由表拼接好后,跳转到首页
全局前置守卫
配置全局前置守卫,每次页面跳转,都校验用户的登录状态,如果未登录,拦截到登录页面,如果登录了,则判断权限列表是否为空,如果为空,则发送请求获取权限列表,再动态拼接路由表,路由表拼接好后,再放行路由跳转。
按钮权限控制
- 将用户在每个页面下的具有的权限,存储在页面对应的路由对象route中
- 给页面中需要进行权限控制的按钮,添加自定义指令,指定这个按钮需要的权限
- 在自定义指令的内部,拿到当前页面的路由对象,取出权限列表,判断用户是否具有操作这个按钮权限,如果没有,则禁用按钮
请求权限控制
- 设置请求拦截器,根据请求的方法,匹配对应的权限:GET就是查询,PUT就是更新,POST就是添加,DELETE就是删除
- 拿到当前页面的路由对象,取出权限列表,判断用户是否具有发送这个请求的权限,如果没有,则拦截请求,返回一个拒绝状态的Promise
