vue3响应式原理初探
为什么要使用proxy取代defineProperty
原因1:defineproperty无法检测到原本不存在的属性。打个🌰
js
new Vue({
data(){
return {
name:'wxs',
age:25
}
}
})
在vue2中,底层会通过definproperty来响应式data返回的对象,也就是{name:'wxs',age:25}
,具体的响应式监听如下。
js
const obj = {
name:'wxs',
age:25
}
Object.entries(obj).forEach(([key,value]) => {
Object.defineProperty(obj,key,{
get(){
return value
},
set(newValue){
console.log(`监听到属性${key}改变`)
value = newValue
}
})
})
obj.name = 11
obj.age = 22
obj.ak47 = 'ak47'
看看控制台输出
可以看出,由于初始化中并没有初始化ak47,所以在vue2中对未初始化的对象key值的修改是无法监听到的。
再来看看es6新特性proxy的优越性
js
const obj = {
name:'wxs',
age:25
}
const prxoyTarget = new Proxy(obj,{
get(target,key){
return target.key
},
set(target,key,value){
console.log(`监听到${key}需要改成${value}`)
target[key] = value
}
})
prxoyTarget.name = 11
prxoyTarget.age = 22
prxoyTarget.ak47 = 'ak47'
所以回答这个问题的思路就很清晰了
1、proxy可以完成对未初始化对象key的代理监听,而definproperty不可以,相比较之下,proxy更加优越。
2、由vue2的响应式原理可以看出,vue底层需要对vue实例的返回的每一个key进行get和set操作,无论这个值有没有被用到。所以在vue中定义的data属性越多,那么初始化开销就会越大。而proxy是一个惰性的操作,它只会在用到这个key的时候才会执行get,改值的时候才会执行set。所以在vue3中实现响应式的性能实际上要比vue2实现响应式性能要好。
使用proxy如何完成依赖收集呢?
首先看到vue3官网中给出的初始化一个vue实例的基础用法。
因为我们需要响应式一个对象,所以我们使用reactive api。
js
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<div id="app">{{ message }}</div>
<script>
const { createApp, reactive, toRefs } = Vue
createApp({
setup() {
const data = reactive({
name: 'wxs'
})
return data
}
}).mount('#app')
</script>
由这个简单的实例可得知,vue对象内部首先暴露了一个createApp函数用于新建vue实例,那么大致写出Vue内部的执行逻辑,注意看以下代码,重点部分都有注释讲解
js
const Vue = {
// 从官网示例可得,从vue中解构出了createApp函数,那么其内部肯定有createApp的定义
createApp(options){
// createApp实现细节后文补充
return {
// 在mount中执行初次挂载
mount(domSelector){
// 在vue3的源码中也会在mount中执行挂载任务,但是由于此文主要讲解的是vue3的响应式原理,那么AST解析和diff比较就略过,直接显示setup的返回值即可(也就是模拟vue的模版内容为 <div>{{ name }}</div>)。
document.querySelector(domSelector).innerHTML = '页面渲染'
}
}
},
// 响应式代理,在前一小节有介绍,此节不做赘述。
reactive(target){
return new Proxy(target,{
get(target,key){
// 代理对象的get方法
return target[key]
},
set(target,key,newValue){
// 代理对象的set方法
target[key] = newValue
}
})
}
}
将上述代码执行之后,浏览器应该会出现如下页面
但是很明显,我们想要将响应式数据呈现在页面上。比如代码中定义到的name。那么我们可以将createApp做如下改写。具体思路是拿出setUp的返回值。然后将返回值渲染到页面。
js
createApp(options) {
let dataSource = null
if (options.setup) {
dataSource = options.setup()
}
return {
// 在mount中执行初次挂载
mount(domSelector) {
document.querySelector(domSelector).innerHTML = dataSource.name
}
}
},
这样就将响应式数据和template模版之间构建了联系。(但是千万不要被这个简化的代码所迷惑,实际上vue底层会执行diff算法来比较最小化更新之后在渲染页面。因为此文是介绍vue3的响应式原理,所以此过程略过)
完成createApp的补充之后,实际上我们已经完成了vue的初次渲染工作。那么剩下的就是需要完成vue组件的更新工作了(其实也就是说,在响应式数据更新的时候,重新执行一下mount里的代码完成页面刷新)
理清思路之后,稍微重构一下mount函数
js
mount(domSelector) {
const update = () => document.querySelector(domSelector).innerHTML = dataSource.name
effect(update)
}
实际上,vue3是通过effect函数来将更新函数和响应式数据来建立链接。所谓的建立链接,也就是通过effect执行的函数中如果包含了响应式对象,如果响应式对象发生改变,函数就会重新执行。
来看看effect函数的实现细节。
js
let currentCallback = null
const effect = (callback) => {
currentCallback = callback
callback()
currentCallback = null
}
有同学可能会问了,先存一下callback,然后执行一下更新函数,再置空,那么这个赋值不就是脱了裤子放屁-----多此一举吗?那么看看这两行中间夹缝生存的的这一行神奇的代码callback()
。执行一下传入的callback,那么也就是update函数。如果执行update函数必然会触发dataSource.name的get方法。那么我们就可以在这里完成依赖收集。将代理对象和key和更新函数之间建立如下的链接关系。
js
// 在get里面触发依赖收集
get(target, key) {
track(target,key)
// 代理对象的get方法
return target[key]
},
js
// 从上图出发,定义的track函数
const track = (target,key) => {
let depMap = reactiveMap.get(target)
if(!depMap){
depMap = new Map()
reactiveMap.set(target,depMap)
}
let watcherSet = depMap.get(key)
if(!watcherSet){
watcherMap = new Set();
watcherSet.add(currentCallback)
depMap.set(key,watcherSet)
}
}
js
// 依赖收集完成,定义触发函数 触发函数其实就是track函数的逆运算,把track存起来的东西全部取出来
const trigger = (target, key) => {
reactiveMap.get(target).get(key).forEach(cb => cb())
}
最后将trigger写到set里面。
js
set(target, key, newValue) {
// 代理对象的set方法
target[key] = newValue
trigger(target, key)
}
大功告成,可以直接改变对象值看看效果。
js
createApp({
setup() {
const data = reactive({
name: 'wxs'
})
setTimeout(() => {
data.name = '456'
}, 1000)
return data
}
}).mount('#app')
老规矩 贴上全部代码
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- import CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<style>
.pagination-container {
position: sticky;
bottom: 0;
z-index: 100;
}
#box {
width: 100%;
height: 50%;
background: black;
}
</style>
</head>
<body>
<div id="app">{{name}}</div>
</body>
<!-- <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> -->
<script>
let currentCallback = null
const effect = (callback) => {
currentCallback = callback
callback()
currentCallback = null
}
let reactiveMap = new Map();
const track = (target, key) => {
let depMap = reactiveMap.get(target)
if (!depMap) {
depMap = new Map()
reactiveMap.set(target, depMap)
}
let watcherSet = depMap.get(key)
if (!watcherSet) {
watcherSet = [];
watcherSet.push(currentCallback)
depMap.set(key, watcherSet)
}
}
const trigger = (target, key) => {
reactiveMap.get(target).get(key).forEach(cb => cb())
}
const Vue = {
createApp(options) {
let dataSource = null
if (options.setup) {
dataSource = options.setup()
}
return {
// 在mount中执行初次挂载
mount(domSelector) {
const update = () => document.querySelector(domSelector).innerHTML = dataSource.name
effect(update)
}
}
},
reactive(target) {
return new Proxy(target, {
get(target, key) {
track(target, key)
// 代理对象的get方法
return target[key]
},
set(target, key, newValue) {
// 代理对象的set方法
target[key] = newValue
trigger(target, key)
}
})
}
}
const { createApp, reactive } = Vue
createApp({
setup() {
const data = reactive({
name: 'wxs'
})
setTimeout(() => {
data.name = '456'
}, 1000)
return data
}
}).mount('#app')
</script>
</html>
各位看官稍安勿躁,全部代码算上css才100行,当然,vue内部实现肯定比我这个要复杂,比如它内部存更新函数是用的set等等,但是对于响应式原理而言,我们只需要拿出最精华的部分即可。第一遍看不懂没关系,我看视频也是看了七八遍才看懂,各位coder们一定要有耐心,拿下vue3响应式!