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
2
3
4
5
6
7
8
9
10
11
const app = new Vue({
el: '#app',
data: {
},
computed: {
},
methods: {
},
watch: {
}
})

此时我们还未引入组件的概念,但是我们已经能够学习vue的大部分知识点了。包括模板语法,数据绑定,数据代理如何实现,vue的常用指令,计算属性,数据监听,vue的生命周期等等。

非单文件组件

什么组件?组件化开发有什么好处?

在vue中,组件就是能实现局部功能的html,css,js代码的集合,组件化开发有利于代码复用,提高开发效率,同时把功能上密切相关的html,css,js代码放到一起,依赖关系明确,易于维护。

vue的组件可分为单文件组件非单文件组件

非单文件组件,就是通过Vue.extend({}),返回一个VueComponent构造函数,这个构造函数被用来创建组件实例,依赖的配置对象就是Vue.extend({})传入的对象,这个配置对象的结构,和new Vue()传入的配置对象的结构几乎一致。

存在如下关系,即Vuecomponent是Vue的子类。

1
2
组件实例._proto_ = VueComponent.prototype
VueComponent.prototype._proto_ = Vue.prototype

非单文件组件使用

1
2
3
4
5
6
<div id="app" :name="str">
<div>{{str}}</div>
<div class="box">
<School></School>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义一个school组件
const School = Vue.extend({
template:`<div>{{name}}</div>`,
data(){
return {
name: 'tom'
}
}
})
// 创建vue根实例
const app = new Vue({
el: '#app',
data: {
str: "haha"
},
//组件注册
components:{School}
})

单文件组件

单文件组件,就是我们熟知的.vue文件,单文件组件解决了非单文件组件无法复用css代码的问题,我们开发过程中使用的最多的组件也是单文件组件。显然,.vue文件是vue团队开发的文件,无法在浏览器上运行,所以我们需要借助模块化打包工具比如webpack,来处理这个文件,webpack又是基于nodejs的,nodejs是使用模块化开发的。这样vue的开发就过渡到了基于nodejs+webpack模块化开发,为了简化模块化开发过程中webpack的配置,vue团队就开发了vue-cli,即vue的脚手架

单文件组件的大致结构如下:

1
2
3
4
5
6
7
8
9
10
<template>
<div></div>
</template>

<script>
export default {}
</script>
<style>

</style>

其中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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div>
<!-- 向子组件传递一个基本数据类型 -->
<Child :age="age">
</Child>
<button @click="add">age++</button>
</div>
</template>
<script>
import Child from '@/components/Child.vue';
export default {
data(){
return{
age:1,
}
},
components:{
Child//js对象
},
methods:{
add(){
this.age++
}
}
}
</script>

上述父组件模板template,最终会被编译为如下渲染函数render(在$mount方法中,在后文有介绍):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function render(createElement) {
return createElement(
'div', // 根元素 div
[ // 子节点数组
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age // 传递父组件的 age 属性
}
}),
// 按钮元素,绑定 click 事件
createElement('button', {
on: {
click: () => this.add() // 触发 add方法
}
}, 'age++')//'age++'是标签体内容
]
);
}

可以看出渲染函数中,有许多createElement函数。

在创建组件根实例的时候,使用的render函数中,也用到了createElement函数,就是下面的h,不过这个render函数相当于是用户自定义的,而不是模板解析后得到的,结构非常简单。

1
2
3
4
5
6
import Vue from "vue";
import App from "./App.vue";

const app = new Vue({
render: (h) => h(App), //用户自定义的render函数
}).$mount("#app");

我们将这个对象,传入render方法,最终交给h方法来调用,h方法就是createElement方法。

createElement

createElement常常出现在渲染函数render中,所以createElement方法到底是个什么玩意?

createElement 是 Vue 中用于创建虚拟节点(VNode)的核心函数

createElement 根据 tag 的不同,调用不同的方法生成 VNode

如果tag 是HTML内置的标签:直接调用**new VNode()**方法创建VNode

如果tag是已注册的组件标签,则拿到对应的组件构造器Ctor,虽然通常情况是一个组件的配置对象,然后再调用createComponent方法

1
2
3
4
5
if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
// resolveAsset 函数用于从当前Vue实例的选项中查找名为 'tag' 的组件
// Ctor是组件的构造器,虽然通常情况是一个js对象,还需要在createComponent中进一步处理
vnode = createComponent(Ctor, data, context, children, tag);
}

如果tag是一个js对象(组件配置对象),比如导入App.vue得到的App,则直接调用createComponent方法

createComponent的作用是什么,如何处理类似App这样的组件js对象?

createComponent

createComponent 是用于创建组件类型VNode它最终也会调用new VNode()返回据组件的VNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function createComponent(Ctor, data, context, children, tag) {
if (!Ctor) return;
const baseCtor = context.$options._base; // Vue 构造函数
// 如果是组件选项对象,转为构造函数
if (typeof Ctor === 'object') {
Ctor = baseCtor.extend(Ctor);
}
// 创建组件 VNode
const vnode = new VNode(
`vue-component-${Ctor.cid}`, // 组件唯一标识
data,
undefined, // 组件 VNode 没有子节点
context,
{ Ctor, children } // 组件构造函数和子节点
)
return vnode;
}

createComponent 在这里主要做了2件事:

  • 把传入的组件配置对象,使用Vue.extend构造成 VueComponent 构造函数

  • 实例化 VNode 并返回,同时我们创建好的组件的构造函数也会被传入。

不过createComponent创建的,不是真正的组件的vnode,而是占位的vnode,这个vnode不描述任何的真实dom结构,它告诉Vue这里要插入一个组件,包含了创建组件所需的所有信息。组件真正的、描述其内部模板结构的 VNode,是在组件实例化后,通过 render() 函数生成的 vm._vnode

Vue.extend()

这个方法我们在前面学习过,我们使用它来创建非单文件组件,返回一个组件的构造函数,下面是简要的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vue.extend = function (options) {
const Super = this //拿到Vue构造函数,因为是Vue构造函数调用的extend方法

// 1. 创建子类
const Sub = function VueComponent(opts) {
this._init(opts) //这个init方法是Vue原型上的
}

// 2. 继承原型
Sub.prototype = Object.create(Super.prototype) //创建一个空对象,这个对象的原型是Vue构造函数的原型
Sub.prototype.constructor = Sub // 修正构造函数指向

// 3. 合并选项
Sub.options = mergeOptions(Super.options, options)

return Sub
}

Vue.extend()的主要作用就是:根据传入的组件配置对象,创建一个VueComponent构造函数并返回

这里的 optsSub.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const obj = { name: 'tom', age: 22 }
Object.keys(obj).forEach(key => {
//把值提前取出来
defineReactive(obj, key, obj[key])
})
function defineReactive(obj, key, value) {
observe(value)
//为每个key创建一个Dep实例
const dep = new Dep();
//调用Object.defineProperty方法,劫持属性
Object.defineProperty(obj, key, {
get() {
//触发getter收集依赖
if(Dep.target){
dep.depend()
}
//这里不能写成obj[key],否则会陷入无限递归,即在get中触发了get
//返回的是闭包中的value值(也就是函数第三个参数),也就是说再添加getter之前,先把数据取出来
return value
},
set(val) {
// 修改的是闭包中的value值,并没有直接修改obj中的数据。
// 修改obj[key]也会陷入无限递归,因为再set中触发了set
if(value === val){
return
}
value = val
//通知依赖更新
dep.notify();
}
})
}

defineReactive方法内部到底做了些什么呢?其实它主要就做了如下工作:

  • 调用observe方法,给value递归添加响应式

  • 为每个属性(key)都创建一个Dep实例,用来管理这个响应式数据的依赖,这个Dep实例存储在闭包中,无法直接通过这个属性访问到。

  • 劫持属性,这是通过Object.defineProperty实现的。触发getter的时候,调用dep.depend(),进行依赖收集,触发setter的时候,调用dep.notify,通知依赖更新。

observe

1
2
3
4
5
6
7
8
9
10
11
12
13
function observe(value) {
//不是对象直接返回,基本数据类型没有必要添加响应式
if (!isObject(value)) {
return //直接返回,没有返回值就是返回null
}
//如果一个对象有__ob__属性且属性值是Observer实例,说明这个对象已经是响应式的了,直接返回这个对象的Observer对象
if (value.hasOwnProperty('__ob__') && value.__ob__ instanceof Observer) {
return value.__ob__
} else {
//否则为这个对象创建一个Observer对象,再返回
return new Observer(value)
}
}

observe方法的实现思路很简单:如果value不是对象则直接返回,如果是响应式对象,直接返回它的observer实例。如果是普通对象,调用 new Observer 将其转化成响应式的,然后返回其observer实例。

Observer

observer类是vue2中给一个对象添加响应式的核心类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const arrayMethods = Object.create(Array.prototype);//一个空对象,这个对象的原型是数组的原型
['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'].forEach(key => {
def(arrayMethods, key, function mutator(...args) {
// 这里的this最终指向的是数组(因为是数组调用这些重写的方法),也就是说重写的数组方法内部,还是会调用原始的数组方法
const res = Array.prototype[key].apply(this, args)
// 通知数组依赖更新
this.__ob__.dep.notify()
let inserted
if (['push', 'unshift'].includes(key)) {
inserted = args
} else if (key == 'splice') {
inserted = args.slice(2)
}
this.__ob__.observeArray(inserted) //对于新添加的数据,也需要添加响应式
return res
}, true)
})
class Observer {
constructor(value) {
this.value = value
this.dep = new Dep() //将一个dep挂载到observer实例上
// 把这个observer实例,挂载到这个需要添加响应式的对象身上,方便管理这一整个对象的依赖
def(value, '__ob__', this)
// 如果需要添加响应式的是一个数组
if (Array.isArray(value)) {
if (hasProto) { //如果支持浏览器[[prototype]]
value.__proto__ = arrayMethods //覆盖需要添加响应式的数组的原型,而不是重写Array.prototype上的方法
} else {
//否则,直接将这些重写的方法挂载到数组上
Object.keys(arrayMethods).forEach(key => {
def(value, key, arrayMethods[key], false)
})
}
// 给数组中的所有元素添加响应式
this.observeArray(value)
} else {
this.walk(value)
}
}
walk(value) {
Object.keys(value).forEach(key => {
defineReactive(value, key, value[key])
})
}
//对于数组,其中的所有元素也需要添加响应式
observeArray(arr) {
for (let i = 0; i < arr.length; i++) {
observe(arr[i])
}
}
}

在Observer类的构造器中,创建了Dep实例,这个 Dep 实例主要是为了处理数组类型的对象,或对象本身作为一个整体被访问的情况

当调用observe方法给数组添加响应式的时候:

  • 会使用一个拦截器覆盖数组的原型,这个拦截器对象上有7个重写的数组方法,它们不但能实现原有的功能,还能在数组被修改的时候,通知数组的依赖更新。
  • 然后再遍历数组中的所有元素,调用observe方法给它们添加响应式。

Dep

在前面的内容中,我们发现在Observer类的构造函数中,还有defineReative函数中,都使用到了Dep类,调用了主要Dep实例的dep.dependdep.notify()方法,那它到底有什么作用呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let uid = 0 // 用于生成 Dep 实例的唯一 ID
export default class Dep {
// 静态变量,保存当前正在收集依赖的 Watcher 实例。全局唯一,因为同一时间只能有一个 Watcher 正在收集依赖。
static target: ?Watcher;
id: number;// Dep 实例的唯一标识符
subs: Array<Watcher>;// 订阅者数组,包含所有监听此依赖的 Watcher 对象,可以理解为一个明星有多少粉丝
//构造函数初始化 Dep 实例。
constructor () {
this.id = uid++ // 分配一个唯一的 ID 给每个 Dep 实例
this.subs = [] // 初始化订阅者(Watcher)数组为空
}

//添加一个新的订阅者到 Dep 实例中。sub: 新的订阅者(Watcher 实例)
addSub (sub) {
this.subs.push(sub) // 将新的订阅者添加到订阅者列表中
}
//从 Dep 实例中移除指定的订阅者
removeSub(sub) {
const idx = this.subs.indexOf(sub)
if (idx >= 0) {
this.subs.splice(idx, 1)
}
}
//当某个属性被访问时调用此方法,将当前活跃的 Watcher 添加为该属性的订阅者
depend () {
if (Dep.target) { // 如果存在当前活跃的 Watcher
Dep.target.addDep(this) // 将当前 Dep 添加到该 Watcher 的依赖列表deps中
}
}
//当属性值(或者对象,数组)发生改变时调用此方法,通知所有订阅者进行更新。
notify () {
const subs = this.subs.slice() // 复制一份订阅者列表以避免在遍历过程中修改原列表
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 遍历并调用每个订阅者的 update 方法
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 全局静态变量初始赋值为 null,表示当前没有活跃的 Watcher
Dep.target = null
// 定义一个数组来实现栈结构,用于存储 Watcher 实例
const targetStack = []

//将一个 Watcher 实例压入栈中,并设置为当前活跃的 Watcher。
export function pushTarget (target: ?Watcher) {
targetStack.push(target) // 将 Watcher 实例加入栈顶
Dep.target = target // 设置为当前活跃的 Watcher
}
//从栈中弹出顶部的 Watcher 实例,并恢复上一个活跃的 Watcher。
export function popTarget () {
targetStack.pop() // 移除栈顶元素
Dep.target = targetStack[targetStack.length - 1] // 恢复上一个活跃的 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,在数据读取时建立了 DepWatcher之间的双向关联。具体的来说,是Watcher先进行依赖收集,然后dep再收集它的订阅者Dep.target,当前正在收集依赖的Watcher)

dep.notify

当响应式数据改变的时候,调用这个方法通知订阅者更新,遍历并调用每个订阅者(Watcher)的 update 方法

Watcher

是什么

Watcher就是我们所说的依赖,大致源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Watcher {
constructor(vm, expOrFn, cb, options [, isRenderWatcher]) {
// 存储组件实例vm,有什么作用?
// 1.在getter方法中充当数据源
// 2.在回调函数cb中充当数据源
this.vm = vm
this.deps = [] // 表示watcher监听了哪些数据
if (isRenderWatcher) { // 如果是渲染 watcher,对象赋值给 vm._watcher
vm._watcher = this
}
vm._watchers.push(this)
this.depIds = new Set() //去重,防止重复监听数据(重复收集dep)
//返回一个函数,调用这个函数的时候传入一个对象,就能从这个对象上取特定的值
if (typeof expOrFn == 'function') {
//如果传入的是一个函数,则这个函数就作为getter,比如
//function(){ retrun this.name + this.age }
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.cb = cb
// 如果不是 lazy 的 watcher(如果不是计算属性watcher) 则立即执行 get 成员方法
this.value = this.lazy ? undefined : this.get()
}
get() {
//watcher可以主动订阅数据
//将自身设置为激活状态的watcher
pushTarget(this)
const value = this.getter.call(this.vm, this.vm) //从组件实例上取值,触发依赖收集
popTarget() //收集完依赖后就清空
return value //返回新的值
}
//dep.denpend(this)内部其实是在调用这个方法
addDep(dep) {//收集dep
// watcher(依赖)不重复收集相同的观测数据(dep), 数据(dep)不重复收集依赖(watcher)
if (!this.depIds.has(dep.id)) {
this.depIds.add(dep.id)
this.deps.push(dep)//先让watcher收集dep
dep.addSub(this)//再让dep收集watcher
}
}
update () {
if (this.lazy) {
this.dirty = true // 计算属性标记为脏,下次再重新计算,不做其他处理
} else if (this.sync) {
this.run() // 同步执行更新
} else {
queueWatcher(this) // 将watcher加入异步队列更新(优化性能)
}
}
//将当前watcher从它观测的数据的依赖列表中移除
teardown() {
this.deps.forEach(dep => dep.removeSub(this))
}
}

构造函数

接收几个参数:

  • 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
2
3
4
5
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}

expOrFn是函数的时候,比如:

1
2
3
new Watcher(vm, function () {
return this.a + this.b
}, cb)

那么 this.getter 就是这个函数本身:

1
2
3
this.getter = function () {
return this.a + this.b
}

expOrFn是字符串:

1
new Watcher(vm, 'a.b.c', cb)

Vue 内部会调用 parsePath('a.b.c') 把它转换成一个函数:

1
2
3
this.getter = function () {
// 等价于:return this.a.b.c
}

不管expOrFn是函数还是字符串路径,最后都会变成一个能从vm取值并返回的函数,这就是 getter

get

1
this.value = this.lazy ? undefined : this.get()

如果不是 lazy watcher(不是计算属性Watcher),创建Watcher的时候就立即执行一次 get() 方法,收集依赖并保存初始值。

创建除了计算属性Watcher以外的所有Watcher时,都会调用立即get函数,其实get方法不仅会再构造函数中被调用,还会在run方法中被调用。get函数到底是什么,它做了什么事情?

1
2
3
4
5
6
7
8
9
10
11
12
// Dep类中的代码
// 定义一个数组来实现栈结构,用于存储 Watcher 实例
const targetStack = []
//将一个 Watcher 实例压入栈中,并设置为当前活跃的 Watcher。
export function pushTarget (target: ?Watcher) {
targetStack.push(target) // 将 Watcher 实例加入栈顶
Dep.target = target // 设置为当前活跃的 Watcher
}
export function popTarget () {
targetStack.pop() // 移除栈顶元素
Dep.target = targetStack[targetStack.length - 1] // 恢复上一个活跃的 Watcher
}
1
2
3
4
5
6
7
8
9
10
11
12
get () {
// 设置当前 watcher 为目标(Dep.target,即正在收集依赖的wather)
pushTarget(this)
let value
try {
value = this.getter.call(vm, vm) // 执行 getter,返回一个值,并触发依赖收集(会调用dep.depend方法)
} finally {
if (this.deep) traverse(value) // 递归的触发value所有子值的getter
popTarget() // this.getter可能触发多个依赖收集,所有依赖收集完毕后,再结束依赖收集
}
return value
}

分析上述代码,我们可以得知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
2
3
4
5
6
7
8
9
update () {
if (this.lazy) {
this.dirty = true // 计算属性标记为脏,下次再重新计算,不做其他处理
} else if (this.sync) {
this.run() // 同步执行更新
} else {
queueWatcher(this) // 加入异步队列更新(优化性能)
}
}

run

在update方法中,调用run() 执行的是真正的更新过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
run() {
if (!this.active) return;

const value = this.get(); // 重新求值并收集依赖

// 判断是否需要触发回调
if (value !== this.value || isObject(value) || this.deep) {
const oldValue = this.value;
this.value = value;

const cb = this.cb;
if (this.user) {
invokeWithErrorHandling(cb, this.vm, [value, oldValue], this.vm,
`callback for watcher "${this.expression}"`);
} else {
cb.call(this.vm, value, oldValue);
}
}
}

从上面的代码中可得知,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Vue.prototype.$mount = function(el){
el = el && inBrowser ? query(el) :undefined
return mountComponent(this, el)
}
export function mountComponent (vm, el) {
// 这个传入的 DOM 元素(el)就是组件将来要挂载到的位置。
vm.$el = el
// 如果没有获取解析的render函数,则会抛出警告
if (!vm.$options.render) {
// createEmptyVNode是默认的渲染函数,会返回注释类型的空vnode
vm.$options.render = createEmptyVNode
// 抛出警告
}
// 执行beforeMount钩子
callHook(vm, 'beforeMount')

const updateComponent = () => {
vm._update(vm._render())
}
// 创建渲染Watcher
new Watcher(vm, updateComponent, noop, options, true /* isRenderWatcher */)
callHook(vm, 'mounted')
//最终返回创建好的组件实例
return vm
}

可以看到第5个参数 为 true /* isRenderWatcher */; 说明是渲染watcher;updateComponent 是第二个参数,那么它应该是 expOrFn,最终会赋值给this.gettercbnoop,就是空回调函数的意思。对于渲染Watcher来说,没有实际的回调函数cb

既然**updateComponent是渲染Watcher的getter,那它是如何触发依赖收集的**?返回值又是什么?

_render

我们先来看看updateComponent中调用的_render方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue.prototype._render = function () {
const vm = this
//提取出render函数
const { render } = vm.$options //这里真的是首尾呼应了,渲染函数真的是挂载到options中的,参考前文
let vnode
try {
// 这里执行了 render 函数
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
// 错误处理
}
// 返回虚拟节点
return vnode
}

这个方法主要做了这么几件事:

  • vm.$options中拿到准备好的render函数
  • 调用render函数并返回得到的VNode

补充:vm.$createElement就是我们常说的 createElement 方法。render方法其实就是使用createElement来创建VNode

render方法中会访问响应式数据(如 this.age)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function render(createElement) {
return createElement(
'div', // 根元素 div
[ // 子节点数组
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age // 传递父组件的 age 属性
}
}),
// 按钮元素,绑定 click 事件
createElement('button', {
on: {
click: () => this.add() // 触发 add() 方法
}
}, 'age++')//'age++'是标签体内容
]
);
}

调用render方法 → 响应式数据被访问 → 触发 getter → 收集依赖,因此updateComponent是一个规范的getter,它确实能触发依赖收集

_update

调用完render方法得到VNode后,还会调用_update方法,将虚拟DOM转化成真实DOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue.prototype._update = function (vnode) {
const vm = this
const prevVnode = vm._vnode //oldVnode
//vm._vnode,整个模板对应的 VNode,先更新一下组件的vnode
vm._vnode = vnode

if (!prevVnode) {
// 初次渲染,vm.__patch__就是我们常说的patch方法
vm.$el = vm.__patch__(vm.$el, vnode)
} else {
// 更新阶段
vm.$el = vm.__patch__(prevVnode, 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function def(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
const arrayMethods = Object.create(Array.prototype);//一个空对象,这个对象的原型是数组的原型
['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'].forEach(key => {
def(arrayMethods, key, function mutator(...args) {
// 这里的this最终指向的是数组,也就是在重写的数组方法中调用了原始的数组方法
const res = Array.prototype[key].apply(this, args)
// 通知数组的依赖(依赖的是数组整体)更新
this.__ob__.dep.notify()
let inserted
if (['push', 'unshift'].includes(key)) {
inserted = args
} else if (key == 'splice') {
inserted = args.slice(2)
}
this.__ob__.observeArray(inserted) //对于新添加的数据,也需要添加响应式
return res
}, true)
})

对于对象,Vue提供了vm.$setvm.$delete方法来解决这个问题,关于二者已经介绍过。

说说new Vue()后发生了什么

我们都听过知其然知其所以然这句话,那么不知道大家是否思考过new Vue()这个过程中究竟做了些什么?

vue构造函数

首先找到vue的构造函数

1
2
3
4
5
//源码位置:src\core\instance\index.js
function Vue (options) {
//其他代码
this._init(options)//this指向创建的Vue实例
}

options是用户传递入的配置对象,包含data、methods等常用属性(至少创建根实例的时候是这样的)。

vue构建函数调用了_init方法,并传入了options,所以我们关注的核心就是_init方法:

_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//位置:src\core\instance\init.js
Vue.prototype._init = function (options?: Object) {
//vm = this = vue实例
const vm = this
initLifecycle(vm)
initEvents(vm)// 初始化组件事件侦听
initRender(vm)// 初始化渲染方法

callHook(vm, 'beforeCreate')// 调用生命周期钩子'beforeCreate'

initInjections(vm) // 初始化注入内容,在初始化data、props之前。
initState(vm)// 初始化 props/data/method/watch/methods/computed
initProvide(vm)// 之所以最后初始化Provide,因为Provide引用的数据就是data或者computed等属性中的。

// 调用生命周期钩子'created',此时不光是data,props,method,watch,provide等几乎所有配置属性都完成了初始化的工作
callHook(vm, 'created')

// 挂载元素
// 只有传递了el选项,才会继续后续流程
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}

在调用beforeCreate之前,主要做一些数据初始化的工作:

  • initEvents是用来初始化事件的,初始化的是父组件给子组件添加的事件。本质是通过vm.$on来给子组件实例添加事件监听,被注册的事件会存储在vm._events对象中,后续移除事件监听则是通过vm.$off,这两个api我们都介绍过。

    子组件模板内添加的事件,至少需要在模板解析后才能开始初始化。

  • callhook的作用是触发用户设置的生命周期钩子

initInjectionsinitProvide在后文传值方式中有介绍,initInjectionsinitState 之前执行,所以在data或者props中可以使用this访问inject中的数据。initProvide 在最后执行,所以它能使用data中的数据。

initState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//源码位置:src\core\instance\state.js
//vm是vue实例
export function initState (vm) {
// 初始化组件的watcher列表
vm._watchers = []
const opts = vm.$options
// 初始化props
if (opts.props) initProps(vm, opts.props)
// 初始化methods
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
// 初始化data
initData(vm)
} else {
// 如果没有data,将vm._data设置为空对象,然后给这个空对象添加响应式
observe(vm._data = {}, true /* asRootData */)
}
// 初始化computed
if (opts.computed) initComputed(vm, opts.computed)
// 初始化watch
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}

分析后发现,initState方法依次,统一初始化了props/methods/data/computed/watch,说明在created的时候,这些东西都准备好了,或者说初始化工作都完成了。

initProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function initProps(vm, propsOptions) {
const propsData = vm.$options.propsData || {};
const props = vm._props = {};

// 非根组件时(大部分情况),暂时关闭响应式递归(避免重复观测),即在defineReactive方法中,不对value属性调用observe方法
const shouldToggle = !vm.$parent;
if (!shouldToggle) toggleObserving(false);

for (const key in propsOptions) {
const value = validateProp(key, propsOptions, propsData, vm);
defineReactive(props, key, value);
if (!(key in vm)) proxy(vm, '_props', key); // 代理到 vm.key
}

if (!shouldToggle) toggleObserving(true); // 恢复响应式状态
}

propsOptions = vm.$options.props,是props配置对象,是我们自定义的,比如

1
2
3
4
5
6
7
8
9
10
props:{  
 // 字符串形式
 name:String // 接收的类型参数
 // 对象形式
age:{  
     type:Number// 接收的类型为数值
     defaule:18,  // 默认值为18
     require:true // age属性必须传递
 }
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function initMethods(vm, methods) {
const props = vm.$options.props;

for (const key in methods) {
const method = methods[key];

// 开发环境校验
if (process.env.NODE_ENV !== 'production') {
if (typeof method !== 'function') {
warn(`Method "${key}" is not a function.`);
} else if (props && hasOwn(props, key)) {
warn(`Method "${key}" conflicts with a prop.`);
} else if (vm[key] !== null && vm[key] !== undefined) {
warn(`Method "${key}" conflicts with an existing instance property.`);
}
}

// 核心:绑定 this 到当前实例(非函数则替换为 noop),确保this.method无论如何被调用,内部this指向都正确
vm[key] = typeof method === 'function' ? method.bind(vm) : noop;
}
}

实现思路如下:

  • 遍历methods中的所有key,如果值不是一个函数则报错
  • 如果methods中的key和props中的key重复,也报错
  • 如果methods中的key已经在vm中存在了,且是以$或者_开头的,则也报错
  • 如果不存在上述问题,使用bind修改方法中this的指向,将修改后的方法挂载到vm上,这意味着,无论如何调用组件中配置的方法,其内部的this指向始终是组件实例。

initData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function initData(vm) {
// 获取 data:函数则执行,否则取对象或空对象
let data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {};

// 将 data 转为响应式
observe(data);

// 检查 data 属性是否与 props/methods 冲突,并代理到 vm
const keys = Object.keys(data);
const { props, methods } = vm.$options;

for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i];

if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {
warn(`Method "${key}" is already defined as a data property.`, vm);
}
if (props && hasOwn(props, key)) {
warn(`Data property "${key}" is already declared as a prop. Use prop default instead.`, vm);
}
}

// 代理非保留字段(不以 $ 或 _ 开头)
if (!isReserved(key)) {
proxy(vm, '_data', key);
}
}
}

vm.$options.data是一个函数,则调用这个函数,赋值给vm._data,如果是个对象,则直接赋值给vm._data,然后使用observe方法将vm._data转化成响应式的数据。最后再考虑将vm._data中的数据代理到vm上。

propsmethoddata之前就被初始化了,所以data中的属性,不能与propsmethods中的属性重复;之所以要防止重复,因为它们都会被代理到vm上(是的,包括props中的数据),都是直接通过this来访问,重复了就会产生冲突。

同时我们也可以发现,props中的数据的优先级,是高于data中的数据的,对于data中的key,只有在props中不存在相同的key的时候,才能代理到vm上。对于其他情况,data中的key都可以直接代理到vm上,即便出现了覆盖的情况。

对比通过props和inject添加的响应式数据

inject是使用defineReactive将注入的属性,直接响应式的添加到vm上的(不是深度响应),所以压根不需要考虑代理。

1
2
3
4
5
6
7
8
9
10
11
export function initInjections(vm: Component) {
const result = resolveInject(vm.$options.inject, vm); // 解析 inject 配置,拿到对应的值
if (result) {
// 代码的效果是设置shouldObserve = false,然后调用observe方法的时候,就会直接返回
toggleObserving(false); // 关闭深度响应式观察
Object.keys(result).forEach(key => {
defineReactive(vm, key, result[key]); // 响应式地添加到vm上
});
toggleObserving(true); // 恢复深度响应式观察
}
}

propsinject初始化的方式本质都是一样的,都是defineReactive+关闭深度响应式,只不过添加的位置不同,props是响应式添加到vm._props然后再代理到vm,也就是说,在vue组件中,初始化父组件或者祖先组件传递过来的值,都只会添加浅层响应式

proxy

vue的数据代理核心在于proxy方法,我们来看看它做了什么。

1
2
3
4
5
6
7
8
9
10
11
12
function proxy(target, sourceKey, key) {
Object.defineProperty(target, key, {
get() {
return target[sourceKey][key];
},
set(newValue) {
target[sourceKey][key] = newValue;
},
configurable: true // 允许后续删除或重新定义该属性
});
}
proxy(vm, `_data`, key)

再次之后,访问target.key返回的就是target.sourceKey.key,说到底还是从target上面取数据,只不过简化了访问的路径。

initComputed

计算属性的配置方式有2种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
new Vue({
data() {
return { firstName: 'John', lastName: 'Doe' };
},
computed: {
fullName() {
return this.firstName + ' ' + this.lastName;
},
fullName2: {
get() {
return this.firstName + ' ' + this.lastName;
},
set(newValue) {
//不能直接修改计算属性,尝试修改计算属性,会修改计算属性的依赖
console.log('Setting fullName');
const names = newValue.split(' ');
this.firstName = names[0];
this.lastName = names[1];
}
}
}
});

initComputed的简要源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)

for (const key in computed) {
const userDef = computed[key]
// 如果computed[key]是函数,则直接用来当作计算属性Watcher的getter,否则将computed[key].get作为getter
const getter = typeof userDef === 'function' ? userDef : userDef.get
// 为每一个计算属性,创建一个 computed watcher
watchers[key] = new Watcher(
vm,
getter,
noop,//计算属性Watcher也没有实际的回调函数cb,noop就是一个空函数
{ lazy: true } // 关键点:lazy 表示懒执行,不立即求值
)
// 定义响应式属性,需要避免重名,这里默认不重名
defineComputed(vm, key, userDef)
}
}

initComputed的任务就是,确定每个计算属性的getter,为每个计算属性创建watcher,并存储在vm._computedWatchers,然后调用defineComputed方法。

遍历每个计算属性,在给每个计算属性创建Watcher之前,先确定每个计算属性的getter,这一点非常简单。拿到每个计算属性的value,如果这个value是一个函数,则直接把它作为计算属性Watcher的getter,否则把value.get作为getter,无论如何,得到的getter的格式形如:

1
2
3
function() {
return this.firstName + ' ' + this.lastName;
}

可以看出,计算属性的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
    3
    constructor (){
    this.value = this.lazy? undefined: this.get()
    }

创建完计算属性的Watcher后,调用defineComputed方法

defineComputed

我们再来看看defineComputed到底做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 辅助变量和函数
const sharedPropertyDefinition = {
enumerable: true,//可枚举
configurable: true,//可配置
get: noop,
set: noop
};
function noop () {}

function defineComputed (target, key, userDef) {
const shouldCache = true; // 在 Vue 2 中 computed 默认都支持缓存
const isFunction = typeof userDef === 'function'; // 判断用户定义的是函数,还是对象形式
// 定义 getter
// 如果userDef是函数
if (isFunction) {
//调用createComputedGetter得到计算属性的getter
//如果userDef是函数。说明一定没有定义set
sharedPropertyDefinition.get = createComputedGetter(key);
} else {
//如果不是函数,且userDef存在get属性,则还是调用createComputedGetter得到计算属性的getter
sharedPropertyDefinition.get = userDef.get
? createComputedGetter(key)
: noop;
//如果定义了set,直接赋给sharedPropertyDefinition.set
sharedPropertyDefinition.set = userDef.set || noop;
}

// 使用 Object.defineProperty 把计算属性代理到 vm 上
Object.defineProperty(target, key, sharedPropertyDefinition);
}

可以看出,defineComputed的最终目的是,使用Object.defineProperty,把计算属性代理到vm上方便访问,但是在这之前,还需要确定计算属性的get和set

  • 计算属性的get等于createComputedGetter(key)的返回值
  • 计算属性的set等于userDef.set ,如果没有配置set,则set为noop,空函数。

createComputedGetter

createComputedGetter这个函数的命名意思非常明确,它的作用就是用来创建计算属性的getter的,它的返回值是一个函数

我们来看看createComputedGetter是如何构造计算属性的get

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 创建 computed 属性的 getter 函数
// 只需要传入一个key就行,根据这个key找到对应的计算属性watcher
function createComputedGetter (key) {
//立即返回一个函数
return function computedGetter () {
//先查找是否存在对应的watcher
const watcher = this._computedWatchers && this._computedWatchers[key];
//如果没有找到直接退出
if (!watcher) {
return;
}
// 如果是 dirty 的,就重新求值
if (watcher.dirty) {
// 下面的代码其实是会被封装成evaluate函数
watcher.dirty = false
watcher.value = watcher.get();//在这个过程中,会触发依赖收集,计算属性Watcher会被dep收集为依赖
}
// 收集依赖
if (Dep.target) {
watcher.depend();
}
// 如果不是dirty的,就直接返回缓存的值,返回的值是计算属性watcher存储的值
return watcher.value;
};
}

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
2
3
4
5
6
7
8
9
10
11
12
13
watch:{
a(val, oldVal){
console.log(val, oldVal)
},
c:{
handler:function(val,oldVal){},
deep: true,
immediate: true
},
'e.f': function(val, oldVal){
console.log(val, oldVal)
},
}

initWatch的实现思路并不复杂

1
2
3
4
5
6
7
// 简化版本
function initWatch(vm, watch){
for(const key in watch){
const handler = watch[key]
createWatcher(vm, key, handler)
}
}

initWatch接收2个参数,vm是组件实例,watch是用户设置的watch对象。使用for in 遍历watch对象,其中的每个key就是路径字符串,为每个key创建Watcher

1
2
3
4
5
6
7
8
9
10
11
function createWatcher(vm, expOrFn, handler) {
let options = {};

// 如果 handler 是对象(如 { handler, deep, immediate })
if (typeof handler === 'object') {
options = handler; // 保留所有选项
handler = handler.handler; // 提取真正的回调函数
}

return vm.$watch(expOrFn, handler, options); //handle也可被叫做cb
}

createWatcher中,确定好handleroptions后就可以,调用vm.$watch,创建用户自定义Watcher了。

vm.$mount

在本文中介绍过了,不赘述

Vue的实例方法

vm.$set

1
vm.$set(target, key, value)

用法:设置对象的属性,如果对象是响应式的,确保属性被创建后也是响应式的,同时触发视图更新。这个方法被用来解决在vue中给对象添加属性无法被监听到的问题。

实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function set(target, key, value) {
//先处理数组的情况
if (Array.isArray(target) && typeof key == 'number') {
// 处理数组长度
if (key > target.length - 1) {
target.length = key + 1
}
// 调用数组的splice方法,如果数组是响应式的,对数组的修改会被监听到,然后触发依赖更新
target.splice(key, 1, value)
return value
}
// 处理target是对象的情况
// 如果想要添加的key已经存在,如果target是响应式的,说明这个key已经被监听了
if (target.hasOwnProperty(key)) {
target[key] = value //直接修改这个value
return value
}
//如果是新增属性
const ob = target.__ob__
//如果target不是响应式对象,那就简单了,直接添加这对键值就行
if (!ob) {
target[key] = value
return value
} else {
defineReactive(target, key, value) //将这对键值通过defineReactive响应式的添加到target上
ob.dep.notify() //同时通知target的依赖更新,也就是使用了这个对象的依赖更新
return 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function delete(target, key) {
//考虑数组的情况
if (Array.isArray(target) && typeof key == 'number') {
//因为调用了数组的splice方法,如果数组是响应式的,对数组的修改会被监听到,然后触发数组的依赖更新,而不是被删除的元素的依赖
target.splice(key, 1)
return //不需要返回值
}
const ob = target.__ob__
if (!ob) {
return delete target[key] //直接删除这个属性即可
} else {
delete target[key]
ob.dep.notify() //通知被修改的对象的依赖更新
}
}

实现思路:

  • 同样的,先考虑target是数组的情况,使用splice方法来删除对应的属性
  • 再考虑target是对象的情况,如果target不是响应式的,直接删除这个属性即可;如果target是响应式的,删除属性后,还要通知target的依赖更新

vm.$watch

1
2
//第一个参数可以是路径字符串也可以是函数
vm.$watch(expOrFn, cb, options)

返回值是unwatch函数,用于解除事件监听。

1
2
3
4
const unwatch = vm.$watch('a.b.c', function(newVal, oldVal){
//做点什么
})
unwatch()//取消监听

实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
function $watch(expOrFn, cb, options) {
const vm = this // 调用watch方法的是组件实例
options = options || {}
const watcher = new Watcher(vm, expOrFn, cb, options) //核心代码
if (options.immediate) {
cb.call(vm, watcher.value) //立即执行一次回调函数
}
// 返回unwatch方法
return () => {
watcher.teardown()
}
}

initWatch是对vm.$watch的封装,vm.$watch其实是对new Watcher的一种封装,Watcher的原理在前面介绍过。如果options中还配置了deep:true,在创建对应的Watcher实例的时候,实例的deep属性会被标记为true,然后在get方法中,不仅会调用getter方法获取到value,还会递归访问value的子值

1
2
3
4
5
6
7
8
9
get(){
pushTarget(this)
let value = this.getter.call(vm,vm)
if(this.deep){
traverse(value) //递归触发子值的依赖收集,value的所有子值也收集这个watcher为自己的依赖
}
popTarget()
return value
}

比如一个watch监听的是vm上的user属性,如果user的值是个对象且开启了深度监听,则vm.user对象中的所有属性(不只是第一层)也会被监听。

vm.$on

1
vm.$on(event, cb)

用法:监听当前实例上的自定义事件,事件可由vm.$emit触发,下面介绍简要的实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {}
obj._events = Object.create(null) //创建一个没有原型的空对象
obj.name = 'sanye'
obj.age = '22'
//添加事件监听
obj.__proto__.$on = function (event, cb) {
const vm = this
//只需要把cb存储起来就好了
if (vm._events[event]) {
vm._events[event].push(cb)
} else {
vm._events[event] = []
vm._events[event].push(cb)
}
}

vm.$off

1
vm.$off([event, callback])

用法:移除实例上的自定义事件监听器。

  • 如果没有提供任何参数,则移除实例上的所有事件监听器
  • 如果只提供了事件,则移除该事件的所有的监听器
  • 如果同时提供了事件和回调,则只移除这个回调的监听器

简要实现原理如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
obj.__proto__.$off = function (event, cb) {
const vm = this

if(arguments.length == 0){//如果没有提供任何参数
vm._events = Object.craete(null)
}
if(arguments.length == 1){//如果只提供了一个参数
vm._events[event] = null
}
// 判断数组中是否存在某个元素
// [NaN].includes(NaN); // true (比 indexOf 更好)
if (vm._events[event].includes(cb)) {
const idx = vm._events[event].indexOf(cb)
vm._events[event].splice(idx, 1)
return
}
const idx = vm._events[event].findIndex(c => c.fn == cb) //这部分代码需要结合vm.$once部分查看
if (idx >= 0) {
vm._events[event].splice(idx, 1)
}
}

vm.$once

1
vm.$once(event, callback)

用法:监听一个自定义事件,但是只触发一次,在第一次触发之后移除监听器

实现原理:

1
2
3
4
5
6
7
8
9
10
obj.__proto__.$once = function (event, cb) {
const vm = this
function once(...args) {
vm.$off(event, once)//调用一次回调就移除监听
cb.apply(vm, args)
}
//当用户想要通过$off解除通过once添加的监听,这个步骤是有必要的,因为用户以为自己注册的回调是cb呢,其实是once,所以删除掉
once.fn = cb //存储原回调函数
vm.$on(event, once) //注册的其实是包装了的cb,不是真正的cb
}

vm.$emit

1
vm.$emit(event,[...args])

用法:触发当前实例上的自定义事件,附加的参数都会传入对应的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
obj.__proto__.$emit = function (event, ...args) {
const vm = this
const cbs = vm._events[event] //获取这个事件下注册的所有回调函数,还有可能这个事件其实没注册过
if (cbs) {
//执行所有注册的回调函数,并传入参数
cbs.forEach(cb => {
try {
cb.apply(vm, args)
} catch (err) {
console.log(err)
}
})
}
}

vm.$forceUpdate

vm.$forceUpdate的作用是强迫vue.js实例重新渲染,注意它仅仅影响实例本身和插入插槽的子组件,而不是所有子组件。

1
2
3
4
5
6
Vue.prototype.$forceUpdate = function(){
const vm = this
if(vm_watcher){
vm._watcher.update()
}
}

简单来说,vm.$forceUpdate的作用就是手动调用渲染Watcher的update方法

vm.$destroy

vm.$destroy的作用是完全销毁一个实例,它会清理该实例和其他实例的连接,并解绑其全部指令和监听器,同时会触发beforeDestorydestroyed这2个钩子函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Vue.prototype.$destroy = function(){
const vm = this
//如果正在销毁,则直接返回
if(vm._isBeingDestroyed){
return
}
callHook(vm, 'beforeDestory') //此时还未执行任何实际的销毁工作,只是在通知,将要开始销毁工作了
vm._isBeingDestroyed = true //标记正在销毁实例
const parent = vm.$parent //找到父组件实例
if(parent){
//parent.$children存储了父组件下的所有子组件实例
remove(parent.$children, vm) //从parent.$children这个对象数组中,移除vm这个对象(indexof+splice)
}
if(vm._watcher){
vm._watcher.teardown() //将渲染Watcher从所有它观察的数据的依赖列表中移除
}
let i = vm._watchers.length
while(i--){
vm._watchers[i].teardown() //将所有用户自定义的watcher从所有它观察的数据的依赖列表中移除
}
vm._isDestroyed = true //标记组件已被销毁
vm.__patch__(vm._vnode, null) //就是我们常说的patch方法,会卸载整个组件的dom
callHook(vm, 'destroyed') //触发destroyed钩子,销毁组件的工作几乎完成了
vm.$off() //移除组件实例上的所有事件监听
}

vm.$mount

这个方法通常不需要我们手动调用,因为如果在实例化的时候设置了el选项,会自动把vue.js实例挂载到对应的dom元素上,其实内部使用的就是这个方法。如果vue.js实例在实例化的时候,没有el选项,则它处于未挂载的状态,没有与html文件关联,我们可以手动调用vm.$mount方法将vue.js实例挂载到dom上

1
2
3
4
const MyComponent = Vue.extend({
template: "<div>hello</div>"
})
new MyComponent().$mount('#app') //会替换掉#app

vue.js其实有很多不同的构建版本,在不同的构建版本中,vm.$mount的表现都不一样,主要区别体现在完整版(vue.js)和运行时版本(vue.runtime.js),这二者的差异在于,完整版中有编译器,可以编译模板为渲染函数,而运行时版本中没有编译器。

在完整版的vue.js中,vm.$mount先检查渲染函数render是否存在,如果没有,立即进行编译过程,将模板编译成渲染函数;而在运行时版本中,由于没有编译器,它会默认实例上已近存在渲染函数,如果不存在,为了防止报错,会将创建空注释结点的函数,作为渲染函数。

下面介绍完整版vm.$mount的代码

1
2
3
4
5
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(el){
//做些什么,比如模板编译
return mount.call(this,el)
}

在上面代码中,我们将vue原型上的$mount方法,保存在mount中,以便后续使用。然后就把vue原型上的$mount方法给覆盖了。新方法会调用原始的方法,这种做法叫做函数劫持

通过函数劫持,可以在原始功能上新增其他功能,上述代码中,mount方法就是vm.$mount方法的核心功能,我们把它保存下来了。在完整版中,需要在mount功能的基础上添加模板编译的功能

1
2
3
4
5
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el?: string | Element){
el = el && query(el)// 如果el值为真,捕获它,得到它的dom
return mount.call(this, el)
}
1
2
3
4
5
6
7
8
9
10
11
12
function query(el){
if(typeof el =='string'){
const element = document.querySelector(el)
if(element){
return element
}else{
return document.createElement('div')
}
}else{
return 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function mountComponent (vm, el) {
//这个传入的 DOM 元素(el)就是组件将来要挂载到的位置。
vm.$el = el
// 如果没有获取解析的render函数,则会抛出警告,render是解析模板文件生成的
if (!vm.$options.render) {
//createEmptyVNode是默认的渲染函数,会返回注释类型的空vnode
vm.$options.render = createEmptyVNode
//抛出警告
}
// 执行beforeMount钩子
callHook(vm, 'beforeMount')

const updateComponent = () => {
vm._update(vm._render())
}
// 创建渲染Watcher,开启dom的挂载
new Watcher(vm, updateComponent, noop, true /* isRenderWatcher */)
callHook(vm, 'mounted')
//最终返回创建好的组件实例
return vm
}

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
2
import { nextTick } from '../utils/index'
Vue.nextTick = nextTick

其中的nextTick方法就是后面介绍过的nextTick方法,无论是Vue.nextTick方法还是vm.$nextTick方法,都是同一个方法,都是nextTick方法,只是挂载的位置不同。

Vue.set

其用法如下:

1
Vue.set( target, key, value)

用法:设置对象的属性,如果对象是响应式的,确保属性被创建后也是响应式的,同时触发视图更新。这个方法被用来解决在vue中给对象添加属性,无法被监听到的问题。

Vue.setvm.$set的实现原理相同:

1
2
import { set } from "../observer/index"
Vue.set = set

都是同一个set方法,只是挂载的位置不同。

Vue.delete

其用法如下:

1
Vue.delete( target, key)

用法:删除对象的属性。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开vue.js不能检测到属性被删除的限制。

同理,Vue.deletevm.$delete的实现原理相同,都是同一个delete方法只是挂载的位置不同。

Vue组件通信的方式有哪些

vue中,每个组件之间的都有独自的作用域,组件间的数据是无法共享的,但实际开发工作中我们常常需要让组件之间共享数据,这也是组件通信的目的,要让它们互相之间能进行通讯,这样才能构成一个有机的完整系统。

组件间通信的分类:父子组件之间的通信,兄弟组件之间的通信,祖孙与后代组件之间的通信。一些常用实践:

父组件如何拿到子组件中数据:

  • 父组件捕获子组件实例:子组件将数据暴露出来,然后父组件捕获子组件实例(父组件主动去拿)
  • 子组件调用父组件的方法:父组件将一个方法传递给子组件,子组件调用这个方法的同时, 将自己的数据传递给父组件(子组件主动传递,其实就是emit)

子组件如何拿到父组件的数据:

  • 父组件通过provide提供数据,子组件通过inject注入数据
  • 父组件通过props传递数据,子组件通过props接收数据
  • 捕获子组件,然后调用子组件的方法,从而将父组件的数据,传递给子组件,这么做的好处是动态传值,而不是一开始就要传值。

兄弟组件间如何传值

  • 通过状态管理工具

  • 使用事件总线

  • 在父组件中捕获子组件A,然后传递给子组件B,然后子组件B就能使用子组件A中暴露的方法或者数据 (少用)

props和emit

基本语法

适用场景:父组件传递数据给子组件,即父子组件之间的通信

父组件通过给子组件标签添加属性,来传递值,子组件设置props属性,接收父组件传递过来的参数,同时还能限制父组件传递过来的数据的类型,还能设置默认值。

1
<Children name="jack" age=18 />  
1
2
3
4
5
6
7
8
9
10
11
//Children.vue
props:{
 // 字符串形式
 name:String // 接收的类型参数
 // 对象形式
age:{  
     type:Number// 接收的类型为数值
     defaule:18,  // 默认值为18
     require:true // age属性必须传递
 }
}

注意:

  • props中的数据是父组件的,子组件不能直接修改,遵循”谁的数据谁来维护”的原则
  • 子组件标签的所有属性中,未被子组件接收(props中未声明)的数据,也能在this.$attr,即组件实例的属性中拿到,因为未被接受的属性,就会被当作组件自身的普通属性。

深入理解

再问大家一个问题,为什么父组件中的数据更新,子组件中通过props接收的数据也会随之改变,子组件视图也会更新?

父组件的模板,在模板编译的时候,会被解析成一个render函数,这一点我们在前面已经介绍过了,举例说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function render(createElement) {
return createElement(
'div', // 根元素 div
[ // 子节点数组
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age // 传递父组件的 age 属性
}
}),
// 按钮元素,绑定 click 事件
createElement('button', {
on: {
click: () => this.add('age') // 触发 add('age') 方法
}
}, 'age++')//'age++'是标签体内容
]
);
}

调用父组件的模板render函数时,访问了父组件实例的age属性,赋值给子组件的props.age,这个过程中触发age属性的getter于是收集父组件自身的render函数为依赖(就是渲染Watcher)

调用父组件的render函数,遇到子组件标签时,会调用createElement方法,根据子组件的配置,创建子组件的VNode(这部分内容前面介绍过) 。在将子组件VNode转化成真实DOM的时候,会调用子组件构造函数,创建子组件实例,后续就会会调用initProps方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 传入的第二个参数,是子组件中的props属性的值(props配置对象)
function initProps(vm, propsOptions) {
// 拿到子组件声明并接收到的所有props数据(不包括普通标签属性)
const propsData = vm.$options.propsData || {};
// 创建一个空对象,挂载到组件实例_props属性上
const props = vm._props = {};
// 遍历props配置对象
for (const key in propsOptions) {
// 使用propsOptions 校验 propsData
const value = validateProp(key, propsOptions, propsData, vm);
// 组件接收的数据,会被响应式地添加到vm._props
defineReactive(props, key, value);
// 代理到实例(this),然后就能直接通过this访问
if (!(key in vm)) {
proxy(vm, '_props', key);
}
}
}

父组件传递了,且子组件通过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)**。

参考文章:【Vue原理】Props - 白话版 - 知乎

emit

适用场景:子组件传递数据给父组件(父子组件通信)

子组件通过$emit触发自定义事件,$emit第一个参数为自定义的事件名,第二个参数为传递给父组件的数值

父组件在子组件上绑定事件监听,通过传入的回调函数拿到子组件的传过来的值。

1
2
//Children.vue
this.$emit('add', good)
1
2
//Father.vue
<Children @add="cartAdd" />

要注意的是,给组件添加的事件监听是自定义事件,因为组件标签不是原生标签,无法添加原生事件监听,也就没有原生事件对象,所以传递给回调函数的,是子组件传递过来的值,而不是原生dom事件。

provide与inject

基本语法

跨层级传递数据,传递方向是单向的,只能顶层向底层传递。

在祖先组件定义provide属性,返回传递的值,在后代组件通过inject,接收祖先组件传递过来的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default {
data() {
return {
color: 'red', // 普通类型
userInfo: { name: 'John', age: 30 } // 复杂类型
};
},
provide() {
// 使用data或者computed中的数据
// 这里丢失响应式的原因,和解构响应式对象失去响应式的原因是一样的
return {
color: this.color, // 非响应式,因为this.color的值只是个普通类型
userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
};
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//常见写法
{
// 如果多个祖先组件都提供了同名的属性,那么最接近的祖先组件提供的属性,会被优先使用(就近原则)。
inject: ['color','userInfo'],
}
{
inject: {
color: {
from: 'color',
default: 'blue' // 如果祖先未提供 'color',使用默认值
},
userInfo: {
from: 'userInfo',
default: () => ({ name: 'Guest', id: 0 }) // 默认值是函数时,返回新对象
}
},
}

深入理解

如果父组件通过Provide传递的是一个基本数据类型,在子组件内接收了,后续即便父组件修改这个基本数据类型,子组件也不会更新,为什么?但如果父组件通过Provide传递的是一个对象,这一情况就完全不同?

我们先看看initProvide的源码:

1
2
3
4
5
6
7
8
export function initProvide(vm: Component) {
const provide = vm.$options.provide
if (provide) {
vm._provided = typeof provide === 'function'
? provide.call(vm)//使用vm调用provide函数,确保this指向正确
: provide
}
}

可以看出initProvide的源码非常简单:

  • 如果组件定义了Provide属性,且值是一个函数,则使用call(vm)调用这个函数,确保this指向准确。
  • 最后将函数调用的返回值,其实也就是一个对象,赋给vm._provided属性。

再看看initInjections的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export function initInjections(vm) {
const result = resolveInject(vm.$options.inject, vm);
if (result) {
toggleObserving(false); // 关闭递归响应式
Object.keys(result).forEach(key => {
defineReactive(vm, key, result[key]); // 注入属性变为响应式
});
toggleObserving(true); // 恢复递归响应式
}
}
function resolveInject(inject, vm) {
if (!inject) return;

const result = {};
const keys = Array.isArray(inject) ? inject : Object.keys(inject);

for (const key of keys) {
// 标准化配置
const { from = key, default:def } = (typeof inject === 'object' && inject[key]) || {};

// 向上查找 provide
let source = vm;
while (source) {
if (source._provided?.[from] !== undefined) {
result[key] = source._provided[from]; //从._provided对象中拿一个值
break;
}
source = source.$parent;
}

// 如果在所有祖先元素中都没找到,则使用默认值
if (!source && def !== undefined) {
result[key] = typeof def === 'function' ? def.call(vm) : def;
}
}

return result;
}

步骤分析:

  • 创建一个空对象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
2
3
4
5
6
7
provide() {
//使用data或者computed中的数据
return {
color: this.color, // 非响应式,因为this.color的值只是个普通类型
userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
};
}

比如this.color修改了 ,也并不会重新Provide这个值,也就是说provide是一次性的,所以如果父组件通过Provide传递的是一个基本数据类型(比如this.color),在子组件内接收了,后续即便父组件修改这个基本数据类型,在子组件实例vm上的color也不会改变。

但是如果父组件通过Provide传递的,是一个对象,由于在vue中响应式是递归添加的,所以这个对象是个响应式对象,而且由于传递的是一个引用,其实父子组件是共用这个响应式对象的,如果子组件中在模板中使用了这个对象,则子组件的渲染Watcher会被它收集为依赖,这样即便在父组件内修改这个对象,在子组件的视图也会更新。

与props的比较

写法相同

在vue2中,inject的写法和props的写法完全相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//数组写法
props: ['name'] inject: ['theme']

//对象写法
props: {
name: String
}
inject: {
theme: String
}

//高级写法
props: {
age: {
type: Number,
default: 18,
required: false
}
}
inject: {
user: {
type: Object,
default: () => ({ name: 'Guest' }),
required: true
}
}

原始值相同

父组件给子组件通过props传递的数据,就是vm.$options.propsData,本身也不是个响应式对象,父组件provide的数据(vm._provided),也不是一个响应式的对象

1
2
3
4
5
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age
}//这部数据就是propsData
}),
1
2
3
4
5
6
7
provide() {
//使用data或者computed中的数据
return {
color: this.color, // 非响应式,因为this.color的值只是个普通类型
userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
};//返回的对象就是vm._provided,也就是提供的对象
}

它们本身都类似

1
2
3
4
{
color: this.color,
userInfo: this.userInfo,
};

也就是父组件从自己身上取值,然后存到一个普通对象身上,在这个取值的过程,其实是会丢失响应式的,但如果取出的是一个对象,比如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
2
<Children ref="foo" />  
this.$refs.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

beforeMounttemplate模板已经解析结束,render函数创建完毕,但是render函数还未调用,还没生成虚拟dom,此时展示的还是旧的页面(未经编译的页面)

mounted:此时render函数已经被调用,而且虚拟 DOM 已转换为真实 DOM,挂载到页面上,此时对DOM的操作是有效的。

beforeUpdate:此时数据是新的,页面展示的内容是旧的,因为vue视图是异步更新的,关于异步更新这一点,可以参考后文《说说你对nextTick的理解》

updated: 此时新旧虚拟dom比较完毕,页面已更新。

beforeDestroy:当执行beforeDestroy的钩子的时候,Vue实例就已经从运行阶段进入销毁阶段,但还未真正执行销毁的过程,身上所有的data和methods,以及指令等,都处于可用状态。

destroyed: 完全销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器;

对于vue3中的生命周期的介绍,参考《vue》一文。

深入理解

对于存在子组件的情况,为什么先执行父组件的created钩子,再执行子组件的createdmounted钩子,最后再执行父组件的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
2
3
4
5
<!-- 使用 v-model -->
<input v-model="message" />

<!-- 编译后的等效代码 -->
<input :value="message" @input="e => {message = e.target.value}" />

说说你对slot的理解?

slot的作用就是用来自定义组件内部的结构slot可以分为以下三种:默认插槽,具名插槽,作用域插槽

默认插槽

在子组件中使用<slot>标签,就表示这部分结构可以被自定义,slot标签中可以书写结构充当默认结构。父组件在使用的时候,直接在子组件的标签内写入内容即可。

子组件Child.vue,使用slot标签占位,标签体内的结构是默认结构

1
2
3
4
5
<template>
<slot>
<p>插槽后备的内容</p>
</slot>
</template>

父组件向子组件传递结构,只需要在子组件标签体内写结构就好了

1
2
3
<Child>
<div>默认插槽</div>
</Child>

具名插槽

默认插槽形如

1
2
3
<slot>
<p>插槽后备的内容</p>
</slot>

当我们给slot标签添加name属性,给每个插槽命名,默认插槽就变成了具名插槽,也就是有名字的插槽

当我们需要在子组件内部的多个位置使用插槽的时候,为了把各个插槽区别开,就需要给每个插槽取名。

同时父组件传入自定义结构的时候,也要指明是传递给哪个插槽的,形象的来说,就是子组件挖了多个坑,然后父组件来这些填坑,需要把具体的结构填到具体的哪个坑。

子组件Child.vue

1
2
3
4
<template>
<slot>插槽后备的内容</slot>
<slot name="content">插槽后备的内容</slot>
</template>

父组件

1
2
3
4
5
<child>
<template v-slot:default>具名插槽</template>
<!-- 具名插槽⽤插槽名做参数 -->
<template v-slot:content>内容...</template>
</child>

template标签是用来分割,包裹自定义结构的。v-slot属性用来指定,这部分结构用来替换哪个插槽,所以v-slot指令是放在template标签上的,要注意的是,如果想要将某部分结构传递给指定的插槽xxx,因该使用v-slot:xxx,而不是v-slot='xxx'

v-slot:default可以简化为#defaultv-slot:content可以简化成#content

作用域插槽

作用域插槽,其实指的是通过插槽传值。子组件slot标签上绑定属性,将子组件的信息传给父组件使用,所有绑定的属性(除了name属性),都会被收集成一个对象,被父组件的v-slot属性接收

子组件Child.vue

1
2
3
4
5
<template> 
<slot name="footer" testProps="子组件的值">
<h3>没传footer插槽</h3>
</slot>
</template>

父组件

1
2
3
4
5
6
7
8
9
<child> 
<!-- 把v-slot的值指定为作⽤域上下⽂对象 -->
<template v-slot:footer="slotProps">
来⾃⼦组件数据:{{slotProps.testProps}}
</template>
<template #footer="slotProps">
来⾃⼦组件数据:{{slotProps.testProps}}
</template>
</child>

可以通过解构获取v-slot={ user },还可以重命名v-slot="{user: newName}"和定义默认值v-slot="{user = '默认值'}"

所在slot中也存在’’双向数据传递’’,父组件给子组件传递页面结构,子组件给父组件传递子组件的数据。

你有写过自定义指令吗?

什么是指令

我们看到的v-开头的行内属性,都是指令,不同的指令可以完成或实现不同的功能。除了核心功能默认内置的指令 (v-modelv-show),Vue 也允许注册自定义指令

指令使用的几种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//会实例化一个指令,但这个指令没有参数 
v-xxx

//将值传到指令中
v-xxx="value"

//将字符串传入到指令中,如v-html="'<p>内容</p>'"
v-xxx="'string'"

//传参数(arg),如v-bind:class="className"
v-xxx:arg="value"

//使用修饰符(modifier)
v-xxx:arg.modifier="value"

注意:指令中传入的都是表达式,无论是不是自定义指令,比如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:指令绑定的前一个值,仅在 updatecomponentUpdated 钩子中可用。无论值是否改变都可用。
  • 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 }

vnodeVue 编译生成的虚拟节点

oldVnode:上一个虚拟节点,仅在 updatecomponentUpdated 钩子中可用

注册自定义指令

注册一个自定义指令有全局注册与局部注册两种方式。

全局注册

全局注册主要是通过Vue.directive方法进行注册

Vue.directive第一个参数是指令的名字(不需要写上v-前缀),第二个参数可以是对象数据,也可以是一个指令函数

1
2
3
4
5
6
7
8
//全局注册一个自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus() // 页面加载完成之后自动让输入框获取到焦点的小功能
}
})

在vue3中的语法如下:

1
2
3
4
5
6
7
8
9
10
11
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)

// 全局注册一个指令
app.directive('focus', {
mounted(el) {
el.focus()
}
})

局部注册

通过在组件配置对象中设置directives属性

1
2
3
4
5
6
7
8
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus() // 页面加载完成之后自动让输入框获取到焦点的小功能
}
}
}

然后就可以在模板中使用指令

1
<input v-focus />

在vue3中,局部注册的语法就不同了。如果混合使用选项式api,就可以像vue2一样借助**directives属性解决,如果使用的是setup语法糖写法,就需要在setup函数中使用小驼峰来局部注册指令**。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- MyComponent.vue -->
<script setup>
const vFocus = {
mounted(el, binding) {
el.focus()
}
}
</script>

<template>
<input v-foucs></div>
</template>

给指令命名的方式要遵循小驼峰的方式,而且必须以v开头,比如vMyDirective,然后在模板中使用就必须写成v-my-directive

应用场景

给已经注册了的点击事件添加节流,核心思路是使用事件捕获触发event.stopImmediatePropagation,来拦截默认冒泡触发的事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1.设置v-throttle自定义指令,但是只能
Vue.directive('throttle', {
bind: (el, binding) => {
const throttleTime = binding.value || 2000; // 拿到节流时间,默认2s
let timer;
//el是绑定指令的元素
el.addEventListener('click', event => {
// 如果在前throttleTime的时间内已经点击过了
if (timer) {
// 它不仅会阻止事件继续沿 DOM 树传播,还会阻止在同一阶段内其他监听器的执行,包括目标阶段的监听器。
event && event.stopImmediatePropagation();
return
}
//开启定时器,占用临界资源
timer = setTimeout(() => {
//一定时间后,释放资源
timer = null;
}, throttleTime);
//同时绑定的另一个监听器也被调用,触发sayHello函数,因为事件流没有被阻止传播
}, true);//捕获触发,触发的顺序在冒泡触发之前
},
});
// 2.为button标签设置v-throttle自定义指令
<button @click="sayHello" v-throttle>提交</button>

Vue中组件和插件有什么区别

组件是什么

在vue中,组件一般指的是单文件组件,就是能实现部分功能的html,css,js代码的集合。优点是能降低整个系统的耦合度,提高代码的可维护性和可复用性。

插件是什么

vue插件通常就是一个实现了 install 方法的对象,install方法的第一个参数就是vue构造函数。Vue插件基于Vue构造函数来为Vue添加全局功能,如通过Vue.component来注册一些全局的组件(比如vue-router插件全局注册router-link组件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//这个方法的第一个参数是 `Vue` 构造函数,第二个参数是一个可选的选项对象(options)。
myPlugin.install = function (Vue, options) {
// 1. 添加全局方法或 property
Vue.myGlobalMethod = function () {}
// 2. 添加全局资源
Vue.directive('my-directive', {
bind (el, binding, vnode, oldVnode) {}
})
// 3. 注入组件选项
Vue.mixin({
created: function () {
}
})
// 4. 添加实例方法
Vue.prototype.$myMethod = function (methodOptions) {}
}

插件注册

插件的注册通过Vue.use()的方式进行注册,第一个参数为插件对象,第二个参数是可选择的配置项

1
2
3
4
5
Vue.use = function(plugin, options){
// this指向Vue构造函数
// 在use方法内部,会调用插件的install方法
plugin.install(this, options)
}

值得注意的是,注册插件的时候,需要在调用 new Vue() 启动应用之前完成,这就是为什么main.js文件通常先注册插件,再挂载dom。

1
2
3
4
5
6
7
8
9
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

Vue.use会自动阻止多次注册相同插件,只会注册一次。

v-if和v-for的优先级是什么

在vue2中

在vue2中,v-for的优先级高于v-if,也就是说会遍历所有元素,然后再通过v-if判断是否是要渲染,即使某些项最终不满足 v-if 条件,v-for 仍会遍历这些项,

1
2
3
4
5
<ul>
<li v-for="item in items" v-if="item.isVisible" :key="item.id">
{{ item.name }}
</li>
</ul>

这个例子中,Vue 2 首先遍历 items 数组(通过 v-for),然后对每个项应用 v-if 来决定是否渲染该项。如果90%的数据其实不需要展示,就会带来没有必要的遍历开销

在vue3中

而在vue3中,v-if的优先级高于v-for,所以在vue3中,上述代码会报错,会提示item未被定义;这也意味着在vue3中,无法根据某个对象的属性,使用v-if来控制渲染。在vue3中这么设计的目的也是希望用户不要将v-ifv-for卸载同一个标签中。

总结

其实最推荐的做法是,只遍历并渲染需要渲染的数据,不在同一个元素上使用v-ifv-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
2
3
4
5
6
7
8
<div id="app"> {{ message }} </div>

const vm = new Vue({
el: '#app',
data: {
message: '原始值'
}
})
1
2
3
4
5
6
//使用回调函数
this.message = '修改后的值'
console.log(this.$el.textContent) //'原始的值'
this.$nextTick(function () {
console.log(this.$el.textContent) //'修改后的值'
})

如果调用nextTick的时候,没有传入回调函数,则会返回一个Promise对象,这个Promise对象的状态在DOM更新后改变

1
2
3
4
5
//使用async/await
this.message = '修改后的值'
console.log(this.$el.textContent) //'原始的值'
await this.$nextTick()//此时没有传入回调函数
console.log(this.$el.textContent) //'修改后的值'

底层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const callbacks = []  // 存放传入nextTick的回调函数
let pending = false // 控制timerFunc的调用频率
let timerFunc // 后续会被定义
let _resolve
export function nextTick (cb?: Function, ctx?: Object) {
//将传入的回调函数,放入callbacks中,这个过程是同步发生的,但是callbacks中的函数在微任务阶段才会被执行
//无论是否传入cb,都会push一个cb,只不过逻辑不同
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)//ctx通常是传入的组件实例
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx) //改变返回的promise对象的状态
}
})
if (!pending) {
//如果是首次调用nextTick,还会调用一次timerFunc
//pending = true的意义是如果再次调用nextTick,不再调用timerFunc
//这意味着即便多次同步调用nextTick,只会在第一次调用的时候,将清空callback的任务,放入者微任务(或者宏任务)队列
pending = true
timerFunc()//效果是将flushCallbacks放入微任务(或者宏任务)队列
}
// 如果没传入回调函数,且当前浏览器支持promise对象,则返回一个Promise对象
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
//将能改变返回的promise对象状态的resolve传递给_resolve
//_resolve被调用的时候,就是callbacks被清空的时候,就是dom更新的时候
_resolve = resolve
})
}
}

callbacks新增回调函数后,又执行了timerFunc函数,那么这个timerFunc函数是做什么用的呢,我们继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export let isUsingMicroTask = false //判断是否使用的是微任务
//判断1:是否原生支持Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
//如果支持promise,则通过promise的方式,将清空callbacks队列的任务,放入微任务队列
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
//判断3:是否原生支持setImmediate
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
//判断4:上面都不行,直接用setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}

上述代码描述了timerFunc是如何被定义的,做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.thenMutationObserver(代码中已删除)setImmediate,上述三个都不支持最后使用setTimeout前两者将清空callbacks的任务放入微任务队列,后两者将清空callbacks的任务放入宏任务队列

通过四个判断可以确保,无论在何种浏览器条件下,都能定义出最合适timerFunc。而且四种情况下定义的timerFunc效果都是,将flushCallbacks放入微任务(或者宏任务)队列

timerFunc不顾一切的要把flushCallbacks放入微任务或者宏任务中去执行,它究竟是何方神圣呢?让我们来一睹它的真容:

1
2
3
4
5
6
7
8
9
10
function flushCallbacks () {
//释放pending,确保下次事件循环同步调用nextTick的时候,能触发timerFunc
pending = false
//浅拷贝,防止执行callback的过程中引入新的callback
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

来以为有多复杂的flushCallbacks,居然不过短短的几行。它所做的事情也非常的简单:

把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍。

为什么要拷贝一遍呢?因为防止遍历的过程中,执行callbacks中的任务的时候,又有新的任务被放到callbacks,然后在本次事件循环中就把本该在下一个事件循环中执行的任务执行掉了!

简单的来说,它的作用仅仅是用来执行callbacks中的所有回调函数,也就是说,callbacks中的任务,会在微任务阶段(或者宏任务)被执行。

如何确保此时DOM是最新的?

经过上面的介绍我们知道,传入nextTick的回调函数,通常会在微任务阶段被依次执行,那又是如何确保nextTick中的回调函数访问到的DOM是最新的DOM呢?我们知道,响应式数据如果被修改了就会触发setter通知依赖更新,如果这个数据在模板中使用了,则会通知模板更新,也就是会调用渲染Watcher的update方法,我们先看看Watcher的update方法:

1
2
3
4
5
6
7
8
9
10
11
update () {
if (this.computed) {
// computed watcher 逻辑
} else if (this.sync) {
// 同步 watcher,立即执行
this.run()
} else {
// 默认情况:异步更新
queueWatcher(this)
}
}

由此可知,渲染watcher更新会走queueWatcher(this)的逻辑,那queueWatcher(this)到底做了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const queue = []
let waiting = false

function queueWatcher (watcher) {
const id = watcher.id //watcher也有id
if (queue.indexOf(id) === -1) {//进行去重
queue.push(watcher)
}
if (!waiting) {
waiting = true
//flushSchedulerQueue的作用是遍历异步更新队列queue中的所有watcher,执行它们的run方法
nextTick(flushSchedulerQueue)
}
}

分析上述代码可知,调用渲染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
2
3
4
// 同一事件循环中多次修改同一元素的样式
element.style.width = "100px";
element.style.height = "200px";
element.style.backgroundColor = "red";

浏览器会将这三次样式修改,合并为一次渲染流程,而非逐次触发三次重排,所以不会看到样式闪烁,因为只渲染了一次。

虽然减少了渲染次数,但每次 DOM 操作仍会 立即修改内存中的 DOM 树,频繁操作可能导致主线程阻塞(比如触发了重排重绘,创建了DOM),所以在Vue等框架中,使用虚拟DOM和diff算法,来减少操作真实DOM的次数

虚拟DOM(树)与VNode

虚拟DOM,也叫虚拟DOM,本质就是一个用来描述真实DOM树的js对象,是对真实DOM树的高度抽象

1
2
3
<div id="app">
<p class="text">hello world!!!</p>
</div>

将上面的HTML模版抽象成虚拟DOM树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
tag: 'div',
data: {
id: 'app'
},
chidren: [
{
tag: 'p',
data: {
className: 'text'
},
chidren: [
'hello world!!!'
]
}
]
}

操作虚拟 DOM 的速度,比直接操作真实 DOM 快 10-100 倍

VNode

虚拟DOM树本身是一个js对象,是对真实DOM树的高度抽象;而VNode是虚拟DOM树上的结点,是对真实DOM结点的抽象,它描述了应该怎样去创建真实的DOM结点。

在vue中,VNode其实就是就是一个VNode类创造出来的实例,这个vnode上有许多属性比如key,text,tag,elm,isComment,children等。

vnode有很多种类型,比如注释结点,文本结点,元素结点,组件结点

其中注释结点只有2个有效属性textisComment,其余属性全是默认的undefined或者null。文本结点只有一个text属性。

将vnode转化成dom,挂载到哪儿?

VNode 转化为真实 DOM 后,会被插入到其父 VNode 对应的真实 DOM 元素(parentElm)中,并且通常插入在参考节点(refElm)之前。如果是根组件,最终会挂载到你调用 $mount(el) 时指定的容器上。

看看createElm做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function createElm (vnode, parentElm, refElm) {
const { tag, children, text } = vnode
if (tag) {
// 创建真实 DOM
vnode.elm = nodeOps.createElement(tag)
// 递归创建子元素,如果没有子元素则直接跳过,直接插入到父元素
if (children && children.length > 0) {
for (let child of children) {
if (child) {
// 子节点的 parentElm 是当前节点的 .elm,第三个参数是null说明插入子元素没有参考元素
createElm(child, vnode.elm, null);
}
}
}
// 插入到父元素中(如果 parentElm 存在)
if (parentElm) {
insert(parentElm, vnode.elm, refElm)
}
}
//省略了对文本结点和注释结点的创建
}
function insert (parent, elm, ref) {
if (parent) {
if (ref) {
parent.insertBefore(elm, ref)
} else {
parent.appendChild(elm)
}
}
}

首次调用createElm需要传入父Vnode的DOM,从上述代码中我们可以理解,递归的将虚拟DOM转化成真实DOM是什么意思,简单的来说DOM的创建是从上至下的。

如何比较新旧虚拟DOM树

diff算法

在vue中,我们使用diff算法来进行新旧虚拟dom树的比较。

特点:

  • 根结点开始比较,然后再对比子节点
  • 比较只会在同层级进行, 不会跨层级比较
  • 在比较同层级子节点的过程中,从两边向中间循环比较

patch

patch方法是diff算法的开端,patch方法只会被调用一次,传入的是整个组件的vnode和oldVnode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//简化后的patch
function patch (oldVnode, vnode) {
var elm, parent;
//isUndef的作用是判断一个数据是不是undefined或者null,isDef的作用相反
if (isUndef(vnode) && isDef(oldVnode)) { // 没有新vnode,但是有旧的vnode,直接执行destory钩子函数,卸载组件
invokeDestroyHook(oldVnode)
return
}
if (isUndef(oldVnode)) {
createElm(vnode) // 没有旧节点,说明是初次渲染,直接用新节点生成dom元素
}
if (sameVnode(oldVnode, vnode)) {
// 相似就去打补丁(增删改)
patchVnode(oldVnode, vnode);
} else {
// 不相似就整个覆盖,创建新的dom,移除旧的dom
elm = oldVnode.elm; //获取旧的dom
parent = api.parentNode(elm); //找到旧的dom的父元素
createElm(vnode); //使用新的vnode创建dom,创建的dom可通过vnode.elm拿到
if (parent !== null) {
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));//插入新的dom,到旧dom的下一个dom前
parent.removeChild(elm)//从父元素中移除旧的dom
}
}
return vnode.elm;
}
1
2
function sameVnode (a, b) {
return a.key === b.key && a.tag === b.tag;
  • 调用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。

patchVNode

patchVnode方法中执行的是真正的更新操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function patchVnode(oldVnode, vnode) {
// 1. 新旧节点相同,直接跳过
if (oldVnode === vnode) return;

// 2. 复用真实 DOM 元素
const elm = vnode.elm = oldVnode.elm;

const oldCh = oldVnode.children;
const ch = vnode.children;

// 3. 更新节点自身属性(class/style/props/events 等)
if (vnode.data) {
updateAttrs(oldVnode, vnode);
updateClass(oldVnode, vnode);
updateEvents(oldVnode, vnode); // 合并事件更新
updateProps(oldVnode, vnode);
updateStyle(oldVnode, vnode);
}

// 4. 处理子节点
if (vnode.text == null) {
// 情况1: 新旧都有子节点 → diff 子树
if (oldCh != null && ch != null) {
if (oldCh !== ch) {
updateChildren(elm, oldCh, ch);
}
}
// 情况2: 只有新节点有子 → 添加
else if (ch != null) {
if (oldVnode.text != null) setTextContent(elm, '');
addVnodes(elm, ch);
}
// 情况3: 只有旧节点有子 → 删除
else if (oldCh != null) {
removeVnodes(elm, oldCh);
}
}
// 5. 处理文本节点
else if (oldVnode.text !== vnode.text) {
// 直接更新文本结点的内容
setTextContent(elm, vnode.text);
}
}

进一步比较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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function updateAttrs(oldVnode, vnode) {
const elm = vnode.elm;
const oldAttrs = oldVnode.data.attrs || {};
const newAttrs = vnode.data.attrs || {};

// 更新或添加属性
for (const key in newAttrs) {
const cur = newAttrs[key];
const old = oldAttrs[key];
if (cur !== old) {
if (booleanAttrsDict[key] && cur == null) {
elm.removeAttribute(key);
} else {
elm.setAttribute(key, cur);
}
}
}

// 删除已移除的属性
for (const key in oldAttrs) {
if (!(key in newAttrs)) {
elm.removeAttribute(key);
}
}
}

总结一下上述代码主要流程:

  • 遍历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个尾指针左移。
  • 如果不是,再检查newEndVnodeoldStartVnode是否相同,如果相同,在patchVnode之后,将oldStartVnode对应的dom结点移动到oldEndVnode对应的dom结点之后,然后再移动指针
  • 如果不同,再检查newStartVnodeoldEndVnode是否相同,如果相同,在patchVnode之后,将oldEndVnode对应的dom结点移动到oldStartVnode对应的dom结点之前
  • 如果通过头头,尾尾,尾头,头尾的检查,都没有找到相同的vnode,则使用key来查找newStartVnode对应的oldVnode

当任意一个头指针大于它的尾指针,退出循环,循环结束时,添加或者删除多余dom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0;
let oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;

let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];

let oldKeyToIdx, idxInOld, elmToMove;

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 建立 key -> index 映射(懒初始化)
if (oldKeyToIdx == null) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];

if (idxInOld == null) {
// 新节点,创建并插入
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
// 复用旧节点
elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined; // 标记已移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}
}
}

// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
const before = newCh[newEndIdx + 1]?.elm || null;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}

总结

在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中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

被缓存的组件会额外多出两个生命周期activateddeactivated

keep-alive可以使用一些属性,来更精细的控制组件缓存。

  • include - 字符串或正则表达式或者一个数组。只有名称匹配的组件被缓存
  • exclude - 字符串或正则表达式或者一个数组。任何名称匹配的组件都不会被缓存
  • max - 数字:最多可以缓存多少个组件实例,超出这个数字之后,则删除第一个被缓存的组件,由此可以推测存在一个缓存队列,先入先出。
1
2
3
4
5
6
7
8
9
10
11
12
13
<keep-alive include="a,b">
<component :is="view"></component>
</keep-alive>

<!-- 正则表达式 (使用 `v-bind`,动态绑定,表示传入的是正则表达式,而不是字符串) -->
<keep-alive :include="/a|b/">
<component :is="view"></component>
</keep-alive>

<!-- 数组 (使用 `v-bind,动态绑定,表示传入的是表达式`) -->
<keep-alive :include="['a', 'b']">
<component :is="view"></component>
</keep-alive>

组件名称匹配,组件名称,指的到底是什么呢?

匹配首先检查组件自身的 name 选项,如果 name 选项不可用,则匹配它的局部注册名称 (父组件 components 选项的键值),匿名组件不能被匹配。

组件被缓存了,如何获取数据呢?

借助beforeRouteEnter这个组件内的导航守卫,或者activated生命周期函数

1
2
3
4
5
6
7
8
// 这个守卫不能写在组件中,因为组件中执行了这个代码,说明路由已经进入这个组件了,自相矛盾了,只能写在路由对象中
beforeRouteEnter(to, from, next){
next(vm=>{
console.log(vm)
// 每次进入路由执行
vm.getData() // 获取数据
})
},
1
2
3
activated(){
this.getData() // 获取数据
},

面试官:说说你对keep-alive的理解是什么? | web前端面试 - 面试官系列这篇文章中还讲解了keep-alive的实现原理,看起来还是挺复杂的

vue3中的keep-alive的语法不同于vue2

基础用法,默认缓存所有页面:

1
2
3
4
5
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
  • Component就是当前的路由组件,是router-view组件传递过来的
  • <component>是 Vue3 的内置组件,配合 :is 属性实现动态组件切换,Vue 会根据:is的值动态渲染对应的组件
  • keep-alive包裹的不再是router-view而是具体的组件

精确控制具体哪些组件缓存,因为再vue3中使用组件已经不再需要注册,也不需要给组件命名,所以我们控制组件缓存的依据,变成了路由组件的路由对象,而不是组件的名称。同时,我们不再通过给keep-alive标签添加属性,来控制哪些组件该被缓存,缓存多少组件,转变为借助v-if,如果某个组件应该被缓存,那么他就会被keep-alive标签包裹。

简单的来说在vue3中,我们通过路由组件对应的路由对象和v-if指令,来控制组件的缓存

在路由对象中添加meta属性

1
2
3
4
5
6
7
8
{
path: "/keepAliveTest",
name: "keepAliveTest",
meta: {
keepAlive: true //设置页面是否需要使用缓存
},
component: () => import("@/views/keepAliveTest/index.vue")
},

在模板中的写法:

1
2
3
4
5
6
7
8
<template>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" v-if="$route.meta.keepAlive"/>
</keep-alive>
<component :is="Component" v-if="!$route.meta.keepAlive"></component>
</router-view>
</template>

或者:

1
2
3
4
5
6
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" v-if="route.meta.keepAlive"></component>
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive"></component>
</router-view>

但是就到此为止的话,切换页面的时候会报错vue3 TypeError: parentComponent.ctx.deactivate is not a function 报错

网上提供的解决方案就是给每个component提供一个key。

1
2
3
4
5
6
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="route.name" v-if="route.meta.isKeepAlive"></component>
</keep-alive>
<component :is="Component" :key="route.name" v-if="!route.meta.isKeepAlive"></component>
</router-view>

详细可参考: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
2
3
4
5
6
用户访问 `https://example.com/user/123`
请求到达服务器
服务器用 Vue 渲染出该页面的 HTML
返回完整的 HTML + JS + CSS
浏览器解析 HTML,构建 DOM 和 CSSOM,立即显示内容
同时加载 JS 文件,进行“客户端激活”(Client-side Hydration)

需要注意的是,服务端渲染,只会在首次加载页面或者刷新页面的时候执行一次(切换前端路由并不会触发服务端渲染)

使用服务端渲染,返回的页面,就已经包含了一定的页面结构,能够被搜索引擎爬虫爬取。除了能提高SPA的SEO,使用服务端渲染还能提高首屏的加载速度,因为不需要浏览器执行js来拼接html。

简单实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{title}}</title>
{{{metas}}}
</head>
<body
<!--下面的注释最终会被渲染好的模板内容替代注意不能有空格-->
<!--vue-ssr-outlet-->
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 因为是在服务端运行的代码,所以使用的是cjs语法
const express = require('express')
const app = express()
const Vue = require('vue')
const vueServerRenderer = require('vue-server-renderer') // vue在服务端渲染方面有一个专门的包
const fs = require('fs')
// 以utf-8的格式,同步读取模板html文件,返回一个string
const template = fs.readFileSync('./index.html','utf-8')

// 根据传入的html模板,创建一个renderer,渲染好的模板,会被放入html模板的指定位置
const renderer = vueServerRenderer.createRenderer({template})
app.get('*', (req, res)=>{
const vue = new Vue({
data:{
url:req.url
},
template:"<div>{{url}}</div>"
})
// 更多html模板配置参数,更像是data
const context = {
title:'Vue SSR',
metas:`<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">`
}
//调用renderer对象的renderToString方法
//第一个参数传入vue实例,第二个参数传入模板html文件的更多配置参数,第三个参数传入一个渲染成功后触发的回调函数
//回调函数的第一个参数是一个错误对象,第二个参数才是渲染好后的html字符串
renderer.renderToString(vue, context ,(err,html)=>{
if(err){
//链式调用
res.status(500).end('服务端错误')
}else{
res.end(html)
}
})
})
app.listen('8080',()=>{
console.log('服务器开启成功')
})

上述代码只实现了根据请求url动态拼接html结构,但是没有涉及到从url中提取出前端路由,然后渲染对应组件的逻辑,而且返回的html文件中不包含激活客户端的js代码,正常开发下是需要包含激活代码的。

hash路由和history路由

哈希路由(Hash-based Routing)和History路由(History API-based Routing)是前端路由的两种常见实现方式,它们用于在单页面应用程序 (SPA) 中模拟多页面体验,而无需重新加载整个页面。

hash路由

是什么

前端路由被放到urlhash部分,即url中#后面的部分。哈希值改变也不会触发页面重新加载,但是会产生历史记录。

浏览器不会将哈希值发送到服务器,因此无论哈希值如何变化,刷新页面,服务器只会返回同一个初始 HTML 文件。

优缺点

不需要服务器配置支持,因为哈希值不会被发送给服务器。

兼容性好,几乎所有浏览器都支持哈希变化事件。

URL 中包含显眼的 # 符号,可能影响美观。

前端路由部分十分明确,方便部署,可以部署在服务器的任何位置。

如何做

可以直接设置 window.location.hash 属性来改变 URL 中的哈希部分,改变 window.location.hash 不会触发页面刷新,但它会添加一个新的历史记录条目。前端 JavaScript 监听 hashchange 事件来检测哈希的变化,并根据新的哈希值更新页面内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Router {
constructor() {
//存储路由
this.routes = {}
//页面初次加载的时候,获取当前路由,根据当前路由执行对应的回调函数
window.addEventListener('load', () => {
//如果hash值为"" ,则修改hash值为 '/'
const currentHash = location.hash.slice(1)
if (!currentHash) {
this.push('/')
} else {//否则直接执行对应的回调函数
this.callback()
}
})
window.addEventListener('hashchange', () => {
//调用对应的回调函数
this.callback()
})
}
//用来根据当前hash路由,执行对应的回调函数
callback() {
const path = location.hash.slice(1)//获取到当前的前端路由
const callbackFunc = this.routes[path]
if (callbackFunc) {
callbackFunc()
}
}
//用来注册路由,对应的回调函数
route(path, callback) {
this.routes[path] = callback
}
//修改当前页面的hash值,模拟路由跳转,这一操作会触发hashchange,然后就会执行对应的回调函数
push(path) {
location.hash = path //修改location.hash
}
}
// 将创建的实例挂载到window上,成为全局变量
window.miniRouter = new Router();
// 注册路由
miniRouter.route('/', () => console.log('page1'))
miniRouter.route('/page2', () => console.log('page2'))
// 模拟导航操作
miniRouter.push('/'); // 应该输出 'page1'
miniRouter.push('/page2'); // 应该输出 'page2'

history路由

是什么

使用标准的路径形式,例如 http://example.com/page1,前端路由被放到url中的path部分

优缺点

没有显眼的#号,更为美观

非常适合用来做服务端渲染:使用History 路由的项目,前端路由混合在url中的path部分,这意味着前端路由能发送到后端服务器。而服务端渲染SSR,要根据请求的 URL,使用 Node.js 动态生成对应的 HTML 内容。

存在404问题,因为前端路由会被当作资源路径,发送到后端,而后端并未做对应配置。

history路由的项目一般部署在服务器根目录,域名后面的路径就是前端路径,否则需要在前端路由库(比如VueRouter)中添加基础路径,确保浏览器能从url中提取出前端路径。

1
2
3
4
5
6
7
const router = new VueRouter({
mode: 'history',
base: '/app/', // 设置基础路径
routes: [
// 你的路由配置
]
});

例如,如果用户的 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.pushrouter.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Router {
constructor() {
//存储路由
this.routes = {};
//页面初次加载的时候,修改路径为 '/',并触发对应的事件回调
window.addEventListener('load', () => {
history.replaceState({ path: '/' }, null, '/');
this.routes['/'] && this.routes['/']();
})
}
//用来注册路由
route(path, callback) {
this.routes[path] = callback;
}
//用来修改路由
push(path) {
history.pushState({ path }, null, path);
//修改之后立马调用对应的回调函数
this.routes[path] && this.routes[path]();
}
}

// 使用 Router
window.miniRouter = new Router();
//注册路由
miniRouter.route('/', () => console.log('首页'));
miniRouter.route('/page2', () => console.log('page2'));

// 跳转
miniRouter.push('/page2'); // 输出 'page2'

区别

这两种路由方式总共有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来实现的。开发过程中,一个页面对应一个或者多个组件,在打包后,每个组件都会转化成对应的cssjs代码,其中的js代码不光包括业务逻辑,也负责修改dom,构建页面。

如果使用路由懒加载,我们可以观察到,打包后的js,css文件数量变多了,每个文件的体积也变小了。这是动态导入import触发的代码分割,Webpack / Vite 等构建工具,会将每个懒加载的组件打包成一个独立的 chunk(代码块)。这样,index.html引入的主包体积也会变小(jscss文件)

所以使用路由懒加载,就一定比不使用懒加载好吗,对哪些组件使用懒加载比较好?

访问使用了路由懒加载的组件,需要发送额外的请求获取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
2
3
4
//设置强缓存
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: max-age=31536000 # 缓存一年

app.js 缓存 1 年,一年内再次请求,直接使用缓存,不发请求到服务器

1
2
3
4
5
//设置协商缓存
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
ETag: "abc123"

下次请求时,带上 If-None-Match: "abc123",服务器检查资源是否变化:如果没变 → 返回 304 Not Modified,不返回内容
,如果变了 → 返回 200 和新内容。

1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/html
//没有 Cache-Control
Cache-Control: no-cache //或者禁用缓存

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
2
<!--可以观察到这个模块的type='module',这意味着这个js文件内使用了esm语法(比如import),这个js文件成为了esm-->
<script type="module" crossorigin src="/assets/index-RPTkaswq.js"></script>

而vue2项目通常使用webpack打包,生成的代码通常以传统脚本的形式加载,此时浏览器对file://协议的跨域闲置比较宽松。

你的项目是如何做权限管理的?

后端权限控制

权限管理主要由后端来进行,在后端创建不同的角色,角色不同,拥有的权限也不同,权限包括:

  • 访问某个页面的权限
  • 操作某个按钮的权限
  • 调用某个接口的权限

将角色分配给某个用户,这样用户就具备了权限

前端登录

前端登录后台管理系统,拿到token,存储在cookie或者sessionStorage中,用来标识用户的登录状态

然后发送请求获取用户的权限列表,将这个权限列表,存储vuex或者pinia中,用来动态渲染菜单选项,动态拼接路由表

登录成功且路由表拼接好后,跳转到首页

全局前置守卫

配置全局前置守卫,每次页面跳转,都校验用户的登录状态,如果未登录,拦截到登录页面,如果登录了,则判断权限列表是否为空,如果为空,则发送请求获取权限列表,再动态拼接路由表,路由表拼接好后,再放行路由跳转。

按钮权限控制

  • 将用户在每个页面下的具有的权限,存储在页面对应的路由对象route
  • 给页面中需要进行权限控制的按钮,添加自定义指令,指定这个按钮需要的权限
  • 在自定义指令的内部,拿到当前页面的路由对象,取出权限列表,判断用户是否具有操作这个按钮权限,如果没有,则禁用按钮

请求权限控制

  • 设置请求拦截器,根据请求的方法,匹配对应的权限:GET就是查询,PUT就是更新,POST就是添加,DELETE就是删除
  • 拿到当前页面的路由对象,取出权限列表,判断用户是否具有发送这个请求的权限,如果没有,则拦截请求,返回一个拒绝状态的Promise