Dian 团队周日前端技术分享:Vue 框架概述、深入 Vue 响应式原理之变化侦测、 Vue 3.0 特性初探
一、Vue 简介
什么是 Vue:Vue 是一套用于构建用户界面的渐进式框架。
Vue 和 React 有什么不同?
官网上有详细讲解,这里只作一个归纳:
- Vue 自带渲染性能优化 —— 在 Vue 应用中,组件的依赖在渲染过程中自动追踪,系统能精确知晓哪个组件确实需要被重渲染;而在 React 应用中,当某个组件的状态发生变化时,它会以该组件为根,重新渲染整个组件子树,这就需要开发者手动进行性能优化来避免不必要的子组件重渲染。
- Vue对视图类的组件更加友好 —— React 依靠 JSX(用 XML 语法编写 JavaScript 的一种语法糖) 来实现组件的渲染功能,其优势为开发者可以使用完整的编程语言 JavaScript 功能来构建你的视图页面,因此在偏向于逻辑的组件中更推荐使用 JSX;而 Vue 依靠基于 HTML 的模板 (Template) 来实现渲染,对于习惯 HTML 的开发者更友好,并且通过 DSL (如 v-on 等各种修饰符) 简化了代码的逻辑部分,因此更适用于视图表现类的组件。
- 更便捷的 CSS 作用域管理 —— React 通过 CSS-in-JS 的方案 (如styled-components 和 emotion) 或者将组件分布在多个文件上 (如 CSS Modules) 实现;而 Vue 通过单文件组件中的
<style scoped>
标签就可以为组件内的 CSS 指定作用域。
补充说明:emotion、styled-components 和 CSS Modules
emotion:
styled-Components:
CSS-Modules:
1 | // App.js |
编译后:
综上,React 充分发挥了 JS 的灵活性和复杂性,拥有庞大的生态系统,但同时也带来前期学习曲线陡峭、对开发者不够友好的问题;Vue 能够在内部处理许多繁杂的问题 (比如上面提到的性能优化和 CSS 局部作用域),前期学习曲线更平缓,但是提供便捷的同时也带来了一些限制。所以在以数据展示为主的项目 (如烟草系统) 中更适合用 Vue 框架,而在业务逻辑更多、要求更高的项目 (如考试系统) 中更适合用 React 框架 (这是我猜的,可能当时选择这两个框架也有别的原因)。
二、Vue 2.6.11 源码解析 —— 深入响应式原理之变化侦测
什么是变化侦测?
特性:数据驱动视图——输入 state(数据),输出 UI(视图);UI 随 state 同步变化
1 | UI = render(state) |
不同的框架有不同的变化侦测机制:Angular 通过脏值检查流程;React 通过对比虚拟 DOM;Vue 的变化侦测机制后面会提到:
1. Object 的变化侦测
1.1 使 Object 的属性可观测 —— Object.defineProperty 方法
作用:直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。通过自定义 Setters 和 Getters 实现对属性变化的追踪。
实例:
1 | let car = {} |
通过 Object.defineProperty 方法,每次对 car 这个对象中的 ‘price’ 属性进行的读写操作都能够被追踪到。在实际应用当中,这里的 car 就等同于传给 Vue 实例的 data 对象。下面来看源码 (注释有 1.1.2 、1.2.1 的地方可以忽略,之后会提到):
1 | // 源码位置:src/core/observer/index.js |
应用:定义属性可观测的对象
1 | let car = new Observer({ |
流程:
- Observer 实例将 value 属性与传入的 car 对象相关联
- def 函数给传入的 car 对象增加一个名为 “__ ob __ “ 的属性,这个属性的值为接收 car 对象的 Observer 实例本身。因此,当 car 获得 “__ ob __ “ 属性的同时,Observer 实例的 value 属性对应的对象也会获得 属性。简言之,def 函数通过将实例的属性与实例本身相关联,它的 “__ ob __ “ 属性变成了一个无限层 “__ ob __” 属性相嵌套的实例。上图:
- 对 car 对象的除了 “__ ob __ “ 以外的每一个属性执行 defineReactive 函数,自定义它们的 getter 和 setter 以实现追踪。若这个属性对应的值本身也是一个对象,则对这个对象再次执行流程 1~3 的步骤 (相当于再对这个对象进行和 “car” 相同的操作)。
注意:
- 对象直接进行赋值进行的是浅拷贝,指向的是同一块内存区域,因此二者会同步变化。
- 添加的 “__ ob __ “ 属性是不可遍历的,在 walk 函数中不会被 Object.keys 遍历到。
- 当一个对象被传入到 Observer 中,这个对象的所有属性 (包括子属性) 都会被观测到,这个对象就变成了 可观测的、响应式的。
补充说明:
数组与对象的浅拷贝和深拷贝:
- 浅拷贝 —— 两个变量(栈内存)指向同一片堆内存,一个发生变化的时候,另一个也会同步变化。
- 深拷贝 —— 两个变量(栈内存)指向两片互不影响的堆内存。
数组与对象的常见深拷贝方式:
数组:
1
2
3
4
5
6
7
8// 当数组里面的值是基本数据类型,比如String,Number,Boolean时,属于深拷贝
// 当数组里面的值是引用数据类型,比如Object,Array时,属于浅拷贝
// 法一:利用 concat
var arr2 = arr1.concat();
// 法二:利用 slice
var arr2 = arr1.slice();对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 法一:通过 JSON 对象
// 缺点:无法拷贝函数,会变成 undefined
function deepClone2(obj) {
var _obj = JSON.stringify(obj),
objClone = JSON.parse(_obj);
return objClone;
}
// 法二:通过 Object.assign 方法将 obj 赋值给空对象
// 缺点:仅对基本数据类型深拷贝
var obj1 = Object.assign({}, obj);
// 法三:lodash 函数库实现深拷贝
// 缺点:拷贝复杂的对象可能会报错,如拷贝 Vue 实例。一般复杂的对象都会内置拷贝方法,比如 Vue.extend()。
let result = _.cloneDeep(test)详情可参考:js浅拷贝与深拷贝的区别和实现方式 https://www.jianshu.com/p/1c142ec2ca45
疑问:
Q:添加属性 “__ ob __ “ 的作用是什么 ?
A:标记这个对象已经是可观测的,也便于直接从 value 的 __ ob __ 属性中读取到 value 对应的 Observer 实例,这个在依赖收集中会用到。
1.2 依赖收集(Deps) —— 哪个视图”依赖”了这个数据 ?
怎么收集? —— 在 getter 中收集依赖,在 setter 中通知依赖更新。
收集到哪?—— 为每一个数据都建立一个依赖管理器: Dep 类。
谁来依赖? —— 由 Watcher 类完成数据和视图之间的信息传递。
1.2.1 依赖管理器源码部分:
1 | // 源码位置:src/core/observer/dep.js |
修改 getter 和 setter,让这两个过程中执行收集依赖和更新依赖。即在上一小节的 defineReactive 函数中添加上注释有 (1.1.2) 的代码。
1.2.2 Watcher 类
谁用到了数据,谁就是依赖,我们就为谁创建一个Watcher实例。在之后数据变化时,我们不直接去通知依赖更新,而是通知依赖对应的 Watcher 实例,由 Watcher 实例去通知真正的视图。
源码:
1 | export default class Watcher { |
流程:
Q1:为什么要单独定义 this.getter ?
Q2:Watcher 实例怎么使用 ?
Q3:这种实现对象可观测的方法有什么缺陷 ?
A3:这个方法仅仅只能观测到 object 数据的读写操作,当我们向 object 数据里添加一对新的 key/value 或删除一对已有的 key/value 时,它是无法观测到的。因此 Vue 增加了两个全局 API:Vue.set 和 Vue.delete。
2. Array 的变化侦测
- 为什么 Object 和 Array 的变化侦测会有不同? —— Array 无法使用 Object.defineProperty;
- Array 和 Object 的观测方式有什么异同?
异:Object 的变化是通过 setter 来追踪的,而 Array 型数据没有 setter;
同:Array 型数据还是在 getter 中收集依赖。
简言之:Array 和 Object 的依赖收集(getter)方式相同,变化侦测(setter)方式不同。
Q1:为什么 Array 也可以有 getter ?
A1:因为在开发的时候,组件中的 data 都被包装在一个 Object 中,每次获取其中的 Array 数据时自然就会触发 Array 数据的 getter;这个 getter 是 Object 赋予它的,而 Array 本身是无法设定 getter 和setter 的。
1 | data(){ |
那么,Array 型数据怎么进行变化追踪? —— 重写 JS 中的数组操作方法,例如:
1 | // 重写 push 方法 |
2.1 数组方法的拦截器
当数组实例使用操作数组方法时,拦截 Array.prototype 上的原生方法,转而使用拦截器中重写的方法。
1 | // 源码位置:/src/core/observer/array.js |
执行完上面这段代码之后,arrayMethods 变成:
那么,下面就是将拦截器挂载到数组实例与 Array.prototype 之间,使之能够生效。代码部分见 1.1.1 节的 Observer 实例中注释有 (1.2.1) 的位置。
代码中首先判断了浏览器是否支持 __ proto __ ,如果支持,则调用 protoAugment 函数把 value.__ proto __ = arrayMethods;如果不支持,则调用 copyAugment 函数把拦截器中重写的7个方法循环加入到 value 上。实验:对一个数组分别进行 protoAugment 和 copyAugment 操作(方便起见省略了添加 __ ob __ 的步骤):
1 | // 试验代码 |
运行结果:
2.2 数组的依赖收集
回顾:
- 由于组件的 data 在实际使用时是一个对象的形式,data 对象本身就是一个 Observer 类,因此 data 中的对象和数组都会具有 getter 和 setter。
- 不同之处在于,Observer 类会通过 walk 方法给对象中的每一个属性都添加 getter 和 setter,而数组是通过改写 Array 原型的七个会改变原数组的方法来实现对数组变化的追踪。
- 在依赖收集方面,对象会通过 walk 方法给其中的每一个属性都添加一个 Dep 对象,通过这个 Dep 对象记录和通知这些属性的依赖;数组并不会给其中的每个元素都添加这个对象,它的 getter 和 setter 都是在 Observer 类中完成的,因此依赖收集也在 Observer 类中完成。
下面就是源码如何能够在 getter 中访问到 Observer 类中的依赖管理器的方法:
1 | // 这里的 defineReactive 和前一节的在源码中是同一个函数,只不过这里把它的逻辑拆开来讲 |
2.3 数组的依赖通知
数组的依赖通知在拦截器里完成。与依赖收集相同,首先是访问到 value 对应的 Observer 实例,然后获取到 Observer 实例上的依赖管理器 dep,再调用其 dep.notify() 方法即可。
下面修改 /src/core/observer/array.js 部分的代码:
1 | // 源码位置:/src/core/observer/array.js |
2.4 深度侦测
目前为止,我们已经做到了侦测数组怎样的变化?
我们通过修改数组原型的七个方法,已经能够侦测到数组自身的变化。
什么是深度侦测?
深度侦测就是不但要侦测数据自身的变化,还要侦测数据中所有子数据的变化。举例:当数组元素是一个对象,且这个对象的某个属性发生了变化,也应被侦测到。
源码实现在 1.1.1 节,这里把核心逻辑单独拿出来:
1 | export class Observer { |
2.5 数组新增元素的侦测
目前已经实现的侦测:
当我们向数组中新增元素(如 push 方法),可以侦测到数组的变化。但是如果这个新增的元素发生变化,还无法被侦测到。
解决办法:改写 Array 原型的七个方法当中的能够向数组新增元素的三个方法:push、unshift、splice,在这三个方法中拿到新增的元素,并调用 observe 函数将其转化。
源码:
1 | // 源码位置:/src/core/observer/array.js |
2.6 不足之处
拦截器只能够对 Array 原型的方法进行拦截,并不能干涉用户对数组索引的操作,或者用户直接把 0 赋值给数组的 length 属性来清空数组也是无法被拦截到的。为了解决这个问题,Vue 增加了两个全局的 API :Vue.set 和 Vue.delete。
1 | // “利用索引直接设置一个数组项” 和 “修改数组长度” 都不是响应性的 |
三、浅析 Vue 3.0
1. Composition API
1.1 为什么我们需要 Hooks (钩子)
继 React 推出 React Hooks 之后,Vue 3.0 也推出了与之十分类似的 Composition API,这两者都表明一点:基于函数的逻辑复用机制已经逐渐成为主流。
Hooks 还没有出现的时候,组件是视图、状态(数据)、业务逻辑的集合体,且业务逻辑分散在组件的各个方法之中,容易导致重复逻辑或关联逻辑。由于状态和业务逻辑的强耦合,将业务逻辑从组件中分离出来以实现复用就变得十分困难,因此大型组件很难拆分和重构,也很难测试。
Hooks 带来了什么?
Hooks 让函数也可以拥有自己的状态,且在复用的时候这些状态互不影响。 通过将有状态逻辑封装进自定义的 Hooks,开发者可以将组件写成纯函数的形式,只在需要外部功能和副作用时用这些钩子把外部代码”钩”进来。因此,Hooks 的产生让开发者能够在保持状态和视图关联性的基础上,将两者在代码层面完全分开:用自定义的钩子来处理业务逻辑并相应的改变状态、然后让只负责根据状态渲染视图的组件调用这个钩子。通过这种方式我们可以将有状态的业务逻辑从组件中分离出来,加强了逻辑代码的复用性,同时也提高了代码的可读性。
举个例子:
1 | import React, { useState, useEffect } from "react"; |
1.2 Composition API 概览
创建组件: setup()
执行时机:在 beforeCreate 之后、created 之前执行
接受参数:props (组件允许外界传递过来的参数)、context (上下文对象,相当于 this)
注:在 setup() 函数中无法访问到 this
返回值:响应式数据 (状态)、方法 (相当于 methods,但只需要像普通函数这样定义)、元素或组件的引用(<Component ref="xxx">
)响应式数据:ref/reactive
两者在功能上没有不同,官方提供两种创建响应式数据的方法只是为了适应不同的编程习惯。
注:访问 ref 的值必须要用 xxx.value 的方式1
2
3
4
5
6
7
8
9
10
11// 风格一:ref
const x = ref(0)
const y = ref(0)
const isDisplay = ref(false)
// 风格二:reactive
const state = reactive({
x: 0,
y: 0,
isDisplay: false
})isRef() :判断某个值是否为 ref() 创建出来的对象
toRefs() :将 reactive() 创建出来的响应式对象转换为普通的对象,但它的每个属性节点都是 ref() 类型的响应式数据;
toRefs() 是一个很重要的方法,主要用于在 setup() 返回值的时候,将 reactive 对象转化为 ef 型的数据,以防出现相应丢失的状况;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
30function useMousePostion() {
const pos = reactive({
x: 0,
y: 0
})
onMount(() => {
// Add Event Listener
})
return pos
}
// comsuming component
export default {
setup() {
const { x, y } = useMousePosition()
// 响应丢失
return { x, y }
// 响应丢失
return { ...useMousePositon() }
// It's work
return {
pos: useMousePosition()
}
}
}解决办法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24function useMousePostion() {
const pos = reactive({
x: 0,
y: 0
})
onMount(() => {
// Add Event Listener
})
return toRefs(pos) // 转化成 ref 型数据以避免相应丢失
}
// comsuming component
export default {
setup() {
// Work!
const { x, y } = useMousePosition()
return { x, y }
// Work
return { ...useMousePositon() }
}
}computed():和 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
30
31/******************** 一、创建只读的计算属性 *********************/
// 创建一个 ref 响应式数据
const count = ref(1)
// 根据 count 的值,创建一个响应式的计算属性 plusOne
// 它会根据依赖的 ref 自动计算并返回一个新的 ref
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 输出 2
plusOne.value++ // error
/******************** 二、创建可读写的计算属性 ********************/
// 创建一个 ref 响应式数据
const count = ref(1)
// 创建一个 computed 计算属性
const plusOne = computed({
// 取值函数
get: () => count.value + 1,
// 赋值函数
set: val => {
count.value = val - 1
}
})
// 为计算属性赋值的操作,会触发 set 函数
plusOne.value = 9
// 触发 set 函数后,count 的值会被更新
console.log(count.value) // 输出 8watch():和 Vue 中的监视器大致相同
注1:watch() 函数会返回一个停止自己继续监视的函数 stop() ;
注2:watch() 的回调函数会提供 onCleanup(() => {}) 来清除无效的异步任务,在 watch 被重复执行或被手动 stop 的时候触发;
注3:监视 ref 类型和 reactive 类型的写法不同:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 监视 reactive 型数据的变化
const state = reactive({ count: 0 })
watch(
() => state.count,
(count, prevCount) => {
/* ... */
}
)
// 监视 ref 型数据的变化
const count = ref(0)
watch(count, (count, prevCount) => {
/* ... */
})LifeCycle Hooks (只能在 setup() 函数中使用)
1
2
3
4
5
6
7
8
9
10// Vue 2.x 的生命周期函数与新版 Composition API 之间的映射关系
beforeCreate -> use setup()
created -> use setup()
beforeMount -> onBeforeMount
mounted -> onMounted
beforeUpdate -> onBeforeUpdate
updated -> onUpdated
beforeDestroy -> onBeforeUnmount
destroyed -> onUnmounted
errorCaptured -> onErrorCaptured父子组件传值:
父传子:provide(‘要共享的数据名称’, 被共享的数据)
子组件接收:inject(‘接收的数据名称’)元素和组件的引用:
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
28template>
<div>
<h3 ref="h3Ref">TemplateRefOne</h3>
</div>
</template>
<script>
import { ref, onMounted } from '@vue/composition-api'
export default {
setup() {
// 创建一个 DOM 引用
const h3Ref = ref(null)
// 在 DOM 首次加载完毕之后,才能获取到元素的引用
onMounted(() => {
// 为 dom 元素设置字体颜色
// h3Ref.value 是原生DOM对象
h3Ref.value.style.color = 'red'
})
// 把创建的引用 return 出去
return {
h3Ref
}
}
}
</script>
1.3 对比 React Hooks 和 Composition API
两者都提供了函数式组件的完整的构建方法,也允许用户自定义钩子函数。两者除了提供的功能不尽相同,也存在许多细微的差距 (如React hooks 会在组件每次渲染时候运行,而 Vue setup() 只在组件创建时运行一次)。因为 Vue 3.0 还没有正式发布,对这些差异不作一一列举,详细可以看这篇博文:
[译] 对比 React Hooks 和 Vue Composition API:https://blog.csdn.net/tonylua/article/details/103311090/
2. 变化侦测 —— Proxy 初探
回顾:Vue 2.x 中使用 Object.defineProperty 进行变化侦测的限制
- 无法监听 Object 型数据一对 key/value 的添加和删除;
- 无法监听 Array 型数据的索引操作和长度的变更;
- 对 Map、Set、WeakMap 和 WeakSet 无法提供支持;
在 Vue 3.0,官方放弃了Object.defineProperty,而选择了使用更快的原生 Proxy 来实现变化侦测。
2.1 什么是 Proxy ?
MDN:Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)
换言之,Proxy 给对象提供了一层包装,可以拦截外界的某些操作并改写这个操作的默认行为。
举个例子:
1 | let obj = { |
2.2 Proxy 相比于 Object.defineProperty 的优势
- Proxy 可以直接监听对象而非属性
- Proxy 可以直接监听数组的变化
- Proxy 有 13 种拦截方法,功能更强大
下面列举 13 种拦截方法中比较主要的几种:
可代理操作 | 功能 |
---|---|
get | 用于拦截对象的读取属性操作 |
set | 用于拦截设置属性值的操作 |
apply | 用于拦截函数的调用 |
construct | 用于拦截 new 操作符 |
has | 用于拦截判断对象是否有某个属性的操作 (如 in) |
2.3 Proxy 和 Reflect 实现数据响应式
什么是 Reflect?
MDN:Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。
换言之,Reflect 提供被 Proxy 拦截的操作对应的 13 种默认行为。一般在 Proxy 拦截操作之后,先添加开发者自定义的操作,然后再调用 Reflect 中相应的默认行为。
举个例子:
1 | let proxy = new Proxy(obj, { |
以上就是 Vue 3.0 在数据侦测方面所作改进的一个简单的概述,具体的源码等到正式发布的时候再来研究 (毕竟 Vue 2.x 我还没研究完)。
四、对提问的补充
Q:为什么说 Vue 的响应式更新精确到组件级别?Vue 和 React 更新的粒度有什么区别?
A:举例来说 这样的一个组件:
1 | <template> |
我们在触发 this.msg = ‘Hello, Changed~’的时候,会触发组件的更新,视图的重新渲染。React 在类似的场景下是自顶向下的进行递归更新的,即更新这个 msg 的同时也顺手把 <ChildComponent />
这个组件给更新了。如果 <template>
里边嵌套了十层子组件,也会把这十层子组件一起更新。但是 Vue 当中, <ChildComponent />
这个组件其实是不会重新渲染的。一句话来说,Vue 的组件更新确实是精确到组件本身的。
详细原理请见这里:https://juejin.im/post/5e854a32518825736c5b807f?tdsourcetag=s_pcqq_aiomsg
(非常感谢 py 学长的提问)
五、总结
总的来说,我看了这些的感受是:
- Vue 带给开发者的主要是简洁、便利的编程体验,它支持将HTML 模板、CSS 样式和 JS 逻辑分离开来,并且内置了许多方便的功能。个人认为 Vue 更适用于展示类的、中小型的项目,能够让开发者脱离性能优化等琐碎的问题,将注意力集中在视图和业务逻辑上来。而 React 则完全相反,它通过把 HTML、CSS 写进 JS 代码当中,将 JS 的灵活性赋予了 HTML、CSS,从而给了开发者更大的发挥空间。但与此同时,写好一个 React 框架的项目对开发者的要求也更高。
- Hooks 是大势所趋,它在极大程度地保留 class 组件的特性的前提下,让有状态逻辑的复用成为了很轻松的一件事。个人认为未来很可能会取代最初的 class 型组件的写法。
- Vue 3.0 除了侦测机制性能上的优化外,对 React Hooks 的借鉴也增加了 Vue 项目的灵活性,但整体来看也没有像 React 一样向着 HTML in JS、CSS in JS 的方向走。期待 Vue 3.0 正式发布后的表现。
六、参考文献
1. Vue 简介
Vue 对比其他框架:https://cn.vuejs.org/v2/guide/comparison.html
为什么说 Vue 的响应式更新精确到组件级别?(原理深度解析):https://juejin.im/post/5e854a32518825736c5b807f?tdsourcetag=s_pcqq_aiomsg
2. Vue 2.6.11 源码解析 —— 深入响应式原理
Vue 2.6.11 源码 —— https://github.com/vuejs/vue/tree/v2.6.11
Vue 源码系列 - Vue 中文社区:https://vue-js.com/learn-vue/start/
知乎专栏 —— “源” 来如此:https://zhuanlan.zhihu.com/c_1162017153112363008
Vue 源码 —— 深入响应式原理:https://www.jianshu.com/p/82ea96b6fdeb
3. 浅析 Vue 3.0
Vue3.0 抢先体验新特性:https://www.icode9.com/content-4-684488.html
破解vue3.x新特性 - 洞见未来:http://www.liulongbin.top:8085/#/
Vue composition API 吃螃蟹指南:https://www.jianshu.com/p/687a393e788a
对比 React Hooks 和 Vue Composition API:https://www.jianshu.com/p/521a51e94d65
React Hooks 入门教程:http://www.ruanyifeng.com/blog/2019/09/react-hooks.html
[译] 对比 React Hooks 和 Vue Composition API:https://blog.csdn.net/tonylua/article/details/103311090/
初探 Vue3.0 中的一大亮点——Proxy:https://www.jianshu.com/p/2a8ec76e0090