Vue探究

本文最后更新于:8 个月前

Vue探究

一、Vue3新特性

1、源码优化

  • 基于TypeScript编写,提供了更好的类型检查
  • 仓库使用了monorepo模式(多包单仓库),将不同的模块拆分到packages目录下不同的子目录中。好处是:更易阅读、理解源码模块;一些库(如reactivity响应式库)可以独立于vue单独使用
  • 移除了一些不常用的API,如:

2、编译优化

  • 引入tree-shaking,将无用模块删除,仅打包需要的模块,减小了打包体积(得益于ES6的importexport语法,可以在静态编译阶段获取到模块的依赖关系,删去无用模块)
  • diff算法优化,增加了静态标记,只标记动态节点,diff比较的时候只比较动态节点
  • 静态提升,对不参与更新的元素做静态提升,保存只渲染一次,后面直接复用,避免重复创建节点
  • 事件监听缓存
  • SSR优化

3、性能优化

  • 数据劫持优化,使用Proxy代替Object.defineProperty,后者具有多个缺陷:不能检测对象属性的添加和删除、不能监听数组对象、对象层级太深时占用大量内存(Vue3的优化方式是在getter中递归响应式,真正访问到的内部对象才做响应式,而不是所有内部对象都做)

4、语法API优化

  • Composition(组合式)API,克服了 Options(选项式)API 碎片化的缺点(同一个功能逻辑散乱分布在多个选项中,不利于阅读和维护),优化了逻辑组织。
  • 优化了逻辑复用

二、Tree Shaking

是什么

Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术。简单来讲,就是在保持代码运行结果不变的前提下,去除无用的代码

Vue2中,无论我们使用什么功能,它们最终都会出现在生产代码中。主要原因是Vue实例在项目中是单例的,捆绑程序无法检测到该对象的哪些属性在代码中被使用到

1
2
3
import Vue from 'vue'

Vue.nextTick(() => {})

Vue3源码引入tree shaking特性,将全局 API 进行分块。如果您不使用其某些功能,它们将不会包含在您的基础包中

1
2
3
import { nextTick, observable } from 'vue'

nextTick(() => {})

怎么做

Tree shaking是基于ES6模板语法(importexports),主要是借助ES6模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量

Tree shaking无非就是做了两件事:

  • 编译阶段利用ES6 Module判断哪些模块已经加载
  • 判断那些模块和变量未被使用或者引用,进而删除对应代码

三、响应式原理

整体思路是数据劫持+观察者模式。什么是“观察者模式”?

1、观察者模式

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。先看一段代码:

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 Observerd {
constructor(name) {
this.name = name
this.state = '走路'
this.observers = []
}
setObserver(observer) {
this.observers.push(observer)
}
setState(state) {
this.state = state
this.observers.forEach(observer => observer.update(this))
// 箭头函数的this只想该函数所在作用域指向的对象,在这里就是setState函数的作用域指向的对象,也类本身
}
}

class Observer {
constructor() {
}
update(observerd) {
console.log(observerd.name + '正在' + observerd.state)
}
}

var client1 = new Observer()
var client2 = new Observer()
var server = new Observerd('小明')
server.setObserver(client1)
server.setObserver(client2)
// server.setState('跑步')

【优点】:降低耦合、目标和观察者之间建立了一套触发机制

【缺点】:目标和观察者之间如果有循环依赖,会循环调用导致程序崩溃;当观察者很多时,通知发布会占用很多时间,降低程序效率

参考文章:一文彻底搞懂观察者模式(Observer Pattern) - SegmentFault 思否

2、发布订阅模式(拓展)

Publisher-Subscriber-Mode

Vue 中的 $on$emit 方法。使用的就是发布-订阅模式。Node.js EventEmitter 中的 on 和 emit 方法也是发布-订阅者模式。那么什么是发布-订阅者模式?

先看一段简单代码:

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
class EventEmitter {
// 补全代码
constructor(){
this.events = {}
}
on(event,fn){ //订阅者注册事件到调度中心
(this.events[event] || this.events[event] = []).push(fn)
}
once(event,fn){ //只订阅一次,调用一次后删除
let that = this
function once_fn(){
that.off(event,fn)
fn.apply(that,arguments)
}
once_fn.fn = fn
that.on(event,once_fn)
}
off(event,fn){ //取消订阅
var fns = this.events[event]
if(!fns){ return }
if(!fn){ //如果没有传递指定fn,把所有event值都清空
fns = null
}else{
var len = fns.length
while(len--){
if(fns[len]===fn || fns[len].fn===fn){
fns.splice(len,1)
break
}
}
}
}
emit(event){ //触发事件,即“发布”
if(this.events[event]){
this.events[event].forEach(cb=>cb())
}
}
}

//测试
var EE = new EventEmitter
EE.on('click',()=>{console.log('订阅1,onClick')})
EE.once('click',()=>{console.log('订阅2,onceClick')})
EE.on('hover',()=>{console.log('订阅3,onHover')})

// 测试用例
// EE.emit('hover') 订阅3,onHover
// EE.emit('click') 订阅1,onClick 订阅2,onceClick
// EE.emit('click') 订阅1,onClick

【定义】

发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。

订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)到调度中心(Event Channel),当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。

【优缺点】

优点:解耦合

缺点:消耗额外的内存

【观察者模式 vs 发布订阅模式】

img

  • 在观察者模式中,观察者是知道 Subject 的,Subject 一直保持对观察者进行记录。然而,在发布订阅模式中,发布者和订阅者不知道对方的存在。它们只有通过消息代理进行通信。

  • 在发布订阅模式中,组件是松散耦合的,正好和观察者模式相反。

  • 观察者模式大多数时候是同步的,比如当事件触发,Subject 就会去调用观察者的方法。而发布-订阅模式大多数时候是异步的(使用消息队列)。

  • 观察者模式需要在单个应用程序地址空间中实现,而发布-订阅更像交叉应用模式。

3、Vue2响应式模拟

Vue2响应式使用的是Object.defineProperty,给对象的每个key值设置get和set方法

模拟监听对象原理,还有set的原理没有探究,即对为新增加的对象属性设置监听的原理

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
const DEEP = true      		//是否进行深度监听
function updateView() { //验证是否触发视图更新
console.log('视图更新')
}
function defineReactive(target, key, value) { //劫持数据,设置set和get
if (DEEP) {
observe(value) //对value调用observe,若value是对象类型,则进一步监听;若不是对象直接返回
}
Object.defineProperty(target, key, {
get() {
return value
},
set(newVal) {
if (value !== newVal) {
//value通过函数闭包保存
value = newVal
updateView()
}
}
})
}
function observe(target) { //监听对象属性
if (typeof target !== 'object' || target === null) {
return target //不是对象不监听
}
for (let key in target) {
defineReactive(target, key, target[key])
}
}
const data = {
id: 001,
name: 'Q',
info: {
tel: '123456',
email: '123@qq.com'
}
}
observe(data) //监听对象

//测试(DEEP为false):
// data.name = 'Liu' //输出“视图更新”
// data.age = { num: 18 } //没有触发
// data.info.tel = '654321' //没有触发

//测试(DEEP为true):
// data.name = 'Liu' //输出“视图更新”
// data.age = { num: 18 } //没有触发
// data.info.tel = '654321' //输出“视图更新”

模拟监听数组,将为对象属性设置监听的方式运用到数组上是不实际的,因为如果数组有非常多的元素,那么为每一个元素单独设置一个get和set将让程序变得非常臃肿,降低程序性能。所以对数组的监听采取改写数组原型的方式。

具体操作:1、创建一个新对象,继承数组原型(让新对象的原型指向数组原型);2、修改新对象的原型方法(在原来的原型基础上为指定方法增加响应触发机制);3、让需要被监听的数组的原型指向新对象

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
function updateView() {  //验证是否触发视图更新
console.log('视图更新')
}

//创建自定义特殊原型,在原型基础上,为指定方法加入响应触发机制
const arrProperty = Object.create(Array.prototype ) //创建一个原型指向数组原型的对象
const methods = ['push','pop','unshift','shift','splice'] //要加入响应触发机制的方法
methods.forEach(method=>{
arrProperty[method] = function(){
updateView() //加入响应触发机制
Array.prototype[method].call(this,...arguments) //调用原来的方法,实现原有的功能
}
})

//将需要监听的数组的原型执行自定义特殊原型
function observe(target){
if (typeof target !== 'object' || target === null) {
return target //不是对象或者数组,直接返回
}
if(Array.isArray(target)){
target.__proto__ = arrProperty
return
}
for(let key in target){
defineProperty(target,key,target[key])
}
}

function defineProperty(target,key,value){
// 这里一定要通过函数去调用Object.defineProperty方法,原因是需要利用函数闭包去保存value值
observe(value)
Object.defineProperty(target,key,{
get(){
return value
},
set(newVal){
value = newVal
updateView()
}
})
}
const data = {
Cars:['BMW','Audi']
}
observe(data)

//测试:
// data.Cars.push('AE86') //输出“视图更新”

4、Vue3响应式模拟

Vue3响应式弃用了Object.defineProperty,改用Proxy,改善之处:

  • 在于可以直接监听对象的所有key,而不用一个个去给每个key设置
  • 等到触发了对象之后,才进行监听,优化性能(Object.defineProperty在开始时就递归完成所有getter、setter的绑定)
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
function updateView() {  //验证是否触发视图更新
console.log('视图更新')
}
function observe_proxy(data){
const res = new Proxy(data,{
get(target,key,receiver){
// 嵌套代理的实现方式:Proxy 对于嵌套赋值,会触发 get
// 利用这个特性,可以在 get 中做判断,若获取的是 object 则返回 Proxy,而不是返回该值
// 若修改的是嵌套对象,如obj.a.b=2,则obj.a触发get,返回嵌套代理对象,完成嵌套对象代理
if(typeof target[key]==='object'){
return observe_proxy(target[key])
}
// Reflect.get/set 为官方标准写法,直接将 get、set 的参数赋值即可
// 不需要手动写 return target[key]
return Reflect.get(target,key,receiver)
},
set(target,key,value,receiver){
updateView()
return Reflect.set(target,key,value,receiver)
}
})
return res
}
const data = {
id: 001,
name: 'Q',
info: {
tel: '123456',
email: '123@qq.com'
},
cars:['BMW','Audi']
}
const dataProxy = observe_proxy(data)

// 测试:
// dataProxy.id //返回:1
// dataProxy.info.tel = '987654' //输出:视图更新
// dataProxy.cars.push('Benz') //输出:视图更新
// dataProxy.cars = [] //输出:视图更新

对于原对象data的所有操作需要通过Proxy代理dataProxy进行,才能够得到响应式的效果。

【依赖收集】,通过Proxy API对数据变化进行特定响应只是响应式系统的一部分,另外一个重要的步骤是依赖收集,即收集数据变化之后要对哪些地方进行通知。实现依赖收集的方式是绑定副作用函数,具体流程就是,在get中设置key值对应的请求来源,调用set的时候去通知key值对应的所有请求来源。

image.png
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
65
66
67
68
69
70
71
72
73
74
75
const fnList = new WeakMap()
let activeEffect = null

// 收集副作用函数
function track(target, key) {
if (!activeEffect) return
let dataMap = fnList.get(target)
if (!dataMap) {
dataMap = new Map()
fnList.set(target, dataMap)
}
let fnSet = dataMap.get(key)
if (!fnSet) {
fnSet = new Set()
dataMap.set(key, fnSet)
}
fnSet.add(activeEffect)
}

// 执行副作用函数
function trigger(target, key) {
const dataMap = fnList.get(target)
if (!dataMap) return
const effectFns = dataMap.get(key)
effectFns && effectFns.forEach(fn => fn())
}

function observe_proxy(data) {
const res = new Proxy(data, {
get(target, key, receiver) {
// 嵌套代理的实现方式:Proxy 对于嵌套赋值,会触发 get
// 利用这个特性,可以在 get 中做判断,若获取的是 object 则返回 Proxy,而不是返回该值
// 若修改的是嵌套对象,如obj.a.b=2,则obj.a触发get,返回嵌套代理对象,完成嵌套对象代理
if (typeof target[key] === 'object') {
return observe_proxy(target[key])
}
// Reflect.get/set 为官方标准写法,直接将 get、set 的参数赋值即可
// 不需要手动写 return target[key]
const res = Reflect.get(target, key, receiver)
track(data, key)
return res
},
set(target, key, value, receiver) {
let res = Reflect.set(target, key, value, receiver)
trigger(data, key)
return res
}
})
return res
}

function effect(effectFn) {
activeEffect = effectFn
effectFn()
// 由于通过 set 触发副作用函数时也会导致 get 被触发,
// 将 activeEffect 设为 null 避免重复添加副作用函数
activeEffect = null
}

const data = {
id: 001,
name:'Q',
info: {
tel: '123456',
email: '123@qq.com'
},
cars: ['BMW', 'Audi']
}
const dataProxy = observe_proxy(data)
const fn1 = () => { console.log('data.id', dataProxy.id) } //这里获取id是调用了id的get函数,绑定了副作用函数

// 测试
// effect(fn1) // 输出:data.id 1
// dataProxy.id = 002 // 输出:data.id 2
// dataProxy.name = 'Liu' //没监听,不输出

以上代码简单模拟了依赖收集的方式,实际场景中远比这复杂,需要考虑嵌套绑定的情况、需要考虑多个key值对应同一个依赖的情况。为了节省时间、加快进度,这些情况留作后续探究

四、Vue生命周期

img

可以把Vue的生命周期分为3大阶段

1、创建阶段

包括初始化事件和生命周期,初始化data、methods等属性,渲染模板

new Vue()beforeMount前,即new Vue()的过程中依次发生了什么(具体分析阅读源码【Git/vue2源码解读】,已添加解析注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
initLifecycle(vm) // 初始化组件生命周期标志位
initEvents(vm) // 初始化组件事件侦听
initRender(vm) // 初始化渲染方法
callHook(vm, 'beforeCreate', undefined, false /* setContext */) // 触发beforeCreate钩子
initInjections(vm) // resolve injections before data/props 初始化依赖注入内容,在初始化data、props之前
initState(vm) // 依次初始化 props/methods/data/computed/watch(按照初始化顺序排列)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created') // 触发created钩子
...
// 挂载元素
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
...
  1. 首先找到Vue的构造函数,/src/core/instance/index.ts

  2. 根据上一个文件,找到 /src/core/instance/init.ts,从这里可以获得的信息

    • beforeCreate时,还未开始数据初始化,无法访问data等属性
    • 在beforeCreate -> created期间,依次完成 inject依赖注入、数据(props、data等)初始化、provide注入
  3. 为了探究数据初始化的顺序,找到 /src/core/instance/state.ts,从这里可以获得的信息

    • 数据初始化的顺序依次为:props -> [ setup (组合式API)-> ] methods -> data -> computed -> watch

    至此,触发了created钩子,下面为created之后的过程

  4. 挂载实例,$mount方法 是在 /src/platforms/web/runtime-with-compiler.ts 重写的

    在该文件中,扩展$mount的逻辑,调用compileToFunctions方法,

    1).在compileToFunctions方法中,调用parse方法,将将temmplate解析ast tree,

    2).在compileToFunctions方法中,调用generate方法,将上一步获得的ast转换成render函数

    然后调用原来的$mount方法,转到 src\platforms\web\runtime\index.ts,调用 mountComponent 方法渲染组件

    mountComponent 方法在 src\core\instance\lifecycle.ts ,beforeMount钩子是在这里抛出的

参考文章:面试官:Vue实例挂载的过程 | web前端面试 - 面试官系列 (vue3js.cn)

2、运行阶段

mountedbeforeDestory前,包括beforeUpdateupdated

3、销毁阶段

beforeDestorydestoryed

五、双向绑定

什么是双向绑定

  • 把 Model 层绑定到 View 层,数据驱动视图
  • 把 View 层绑定到 Model 层,视图驱动数据

双向绑定的原理

Model -> View,利用Observer观察者模式

  • 数据监听,Vue2用Object.defineProperty 劫持数据对象,Vue3用Proxy代理监听数据对象
  • 依赖收集,观察者模式,在getter中收集依赖它的对象,在setter中通知所有依赖它的对象

View -> Model,监听视图事件,修改Model值

在处理v-model指令时,为当前dom节点添加input(或change)事件,修改Model值


Vue探究
http://timegogo.top/2022/10/19/Vue/Vue探究/
作者
丘智聪
发布于
2022年10月19日
更新于
2023年7月16日
许可协议