本文首发于:一个例子看懂Vue响应式原理 -Aiysosis Blog(aiysosis.ink)
本文主要参照于Vue Day 2019 by Alicante Frontend论坛上Rubén Valseca带来的演示。
视频地址: How does Vue.js reactivity work? Implementing a reactive system - Rubén Valseca - Vue Day 2019 - YouTube。
要进行vue源码的学习,最好首先了解vue的主要运行原理和模块之间的关系,这样在阅读源码的时候才可以更好地理解,本文将从一个简单的例子开始,渐进式地实现响应性。
在餐馆吃饭时,餐费可能由若干个部分组成,这里我们假设餐费(total)等于菜品价格(dinnerPrice)加上小费(tip),我们希望开发一个餐费计算器,可以根据已有的菜品价格和消费自动更新总的餐费。
首先编写html文件的基本结构。我们仿照vue的结构,在body内部放入一个id为app的元素作为我们的全局挂载点。
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dinner Costtitle>
head>
<body>
<div id="app">div>
body>
<script src="./vue.js">script>
<style>
html,body{
height: 100%;
margin: 0;
}
#app{
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
table{
background-color: whitesmoke;
font-size: 17px;
}
th,td {
padding: 12px 15px;
}
style>
html>
接下来编写javascript文件,先完成最基本的逻辑,只需要这些:
function paint(){
document.getElementById("app").innerHTML = `
Item Cost
Price ${dinnerPrice}$
Tip ${tip}$
Total ${total}$
`
}
//data
let dinnerPrice = 100;
let tip = 20;
let total = 0;
function calcTotal(){
total = dinnerPrice + tip;
}
calcTotal();
paint();
非常简单的逻辑,放到浏览器中跑一下:
我们成功渲染了一个简单的表格,同时我们正确地计算了总费用。当然,现在的代码是没有任何响应性的,更改dinnerPrice或者tip就会露馅。
接下来,我们将逐步实现响应性。
之后的代码中,一些不变的代码块(如html和paint)代码将被省略。
响应性的关键在于“响应”,即我们需要监控部分变量的行为,在目标变量改变时进行相应操作。利用Proxy - JavaScript | MDN (mozilla.org),我们可以拦截被监控对象的行为并自定义相应操作。
proxy代理的目标是对象,所以我们的第一步是将定义的变量放入对象。
const dataObj = {
dinnerPrice : 100,
tip : 20,
total : 0,
}
接下来创建代理,结合情景,我们需要在dinnerPrice和tip改变的时候重新计算total,“价格改变”这个事件我们可以用set来拦截,同时将之前变量的直接调用改为通过代理对象调用。
function paint() {
//通过代理对象调用
document.getElementById("app").innerHTML = `
Item Cost
Price ${data.dinnerPrice}$
Tip ${data.tip}$
Total ${data.total}$
`;
}
const dataObj = {
dinnerPrice: 100,
tip: 20,
total: 0,
};
const data = new Proxy(dataObj, {
set(dataObj, key, val) {
//一定要先执行赋值再触发响应,否则计算函数还是参照原值
let res = Reflect.set(dataObj, key, val);
if (key !== "total") {
//只拦截dinnerPrice和tip
calcTotal(); //重新计算
}
return res;
},
});
function calcTotal() {
data.total = data.dinnerPrice + data.tip;//通过代理对象调用
}
calcTotal();
paint();
实际运行,在浏览器的控制台中尝试改变data中的变量,重新渲染,观察值:
可以看到,当我们修改dinnerPrice,然后手动调用paint函数,页面的数据就正常得到了更新。这样就完成了最最基本的响应式了。当然了,只是这样还是远远不够的,因为到目前为止的响应都是我们手动编码写出来的,这样的编码方式复杂且复用性低。
为了描述变量之间的响应关系,我们引入依赖的概念,如果变量a更新时变量b需要同步更新,那么就说变量b依赖于变量a。应用在这里的情景,即total依赖于dinnerPrice,且total依赖于tip。
现阶段我们的目标,就是实现依赖的管理。首先整理思路,可以预先将处理函数收集起来,并且和被依赖的变量所绑定,在被依赖的变量改变时,运行处理函数。所以我们创建一个depends对象,depends的key与dataObj的key一一对应,depends的value是处理函数构成的数组,如下方代码所示:
const dataObj = {
dinnerPrice : 100,
tip : 40,
total : 0,
}
const depends = {
dinnerPrice:[calcTotal],
tip:[calcTotal],
}
在set函数中,我们需要检查被依赖变量是否在depends中,如果有,就说明其被依赖了,依次执行其处理函数即可。这个过程就是依赖的触发。
const data = new Proxy(dataObj,{
set(dataObj,key,val){
Reflect.set(dataObj,key,val);
if(depends[key]){
depends[key].forEach(fn => fn());
}
paint();
}
})
更进一步,实际上,如果观察calcTotal函数不难发现,依赖的关系是已经蕴含在处理函数中的。那么,我们能不能通过代码来自动形成depends对象呢?这就是接下来我们要干的。
整理以下思路,可以发现,被依赖的变量在处理函数中都会被读取值。那么,我们只要在proxy的get中进行依赖的收集可以了。如上方所说,我们需要提前收集处理函数,而目前在get中是拿不到这个处理函数的,所以创建一个全局变量叫做watchingFunc,专门用来记录处理函数。
const dataObj = {
dinnerPrice: 100,
tip: 20,
total: 0,
};
let watchingFunc = null;//记录处理函数
const depends = {};
const data = new Proxy(dataObj, {
get(dataObj, key) {
//依赖只需要收集一次
if (watchingFunc) {
//key不存在则添加并初始化
if (!depends[key]) depends[key] = [];
depends[key].push(watchingFunc);
}
return Reflect.get(dataObj, key);
},
set(dataObj, key, val) {
let res = Reflect.set(dataObj, key, val);
if (depends[key]) {
depends[key].forEach((fn) => fn());
}
return res;
},
});
function calcTotal() {
data.total = data.dinnerPrice + data.tip;
}
watchingFunc = calcTotal;
calcTotal();
watchingFunc = null;//为了实现依赖只收集一次
paint();
上述代码中有一个重要的点,即依赖只需要收集一次。
watchingFunc = calcTotal;
calcTotal();
watchingFunc = null;//为了实现依赖只收集一次
如果我们不在这里把watchingFunc设置为null,那么每次读取dinnerPrice或者tip都会往depends的对应项中插入一次处理函数,那么同一个处理函数就会被反复运行多次。
这是我们目前的js代码。
function paint() {/*...*/}
const dataObj = {
dinnerPrice: 100,
tip: 20,
total: 0,
};
let watchingFunc = null;
const depends = {};
const data = new Proxy(dataObj, {
get(dataObj, key) {
if (watchingFunc) {
if (!depends[key]) depends[key] = [];
depends[key].push(watchingFunc);
}
return Reflect.get(dataObj, key);
},
set(dataObj, key, val) {
let res = Reflect.set(dataObj, key, val);
if (depends[key]) {
depends[key].forEach((fn) => fn());
}
return res;
},
});
function calcTotal() {/*...*/}
watchingFunc = calcTotal;
calcTotal();
watchingFunc = null;
paint();
看起来…还不错?但我们还可以做得更好。上面的代码中一些关键的逻辑是可以复用的,我们完全可以将它们提取出来,方便之后的复用。
首先是依赖的触发,我们做的只是在dataObj上面做了一层代理的包装,那么我们为什么不把这里的包装逻辑封装成一个函数呢?不如就叫这个函数为reactive吧,它接受一个对象,返回代理对象,而depends我们可以用闭包存起来。
function reactive(dataObj){
const depends = {};//通过闭包存储
return new Proxy(dataObj, {
get(dataObj, key) {
if (watchingFunc) {
if (!depends[key]) depends[key] = [];
depends[key].push(watchingFunc);
}
return Reflect.get(dataObj, key);
},
set(dataObj, key, val) {
let res = Reflect.set(dataObj, key, val);
if (depends[key]) {
depends[key].forEach((fn) => fn());
}
return res;
},
});
}
依赖收集,只需要对处理函数进行操作,那么不如也包装成一个函数,就叫它effect吧。
let watchingFunc = null;//全局变量
function effect(fn) {
watchingFunc = fn;
fn();
watchingFunc = null;
}
有了这些函数,我们的剩余部分逻辑就变成了这样:
//this is reacvitity core
let watchingFunc = null;//全局变量
function paint() {/*...*/};
function reactive(dataObj){/*...*/};
function effect(fn) {/*...*/}
//this is our app
const data = reactive({
dinnerPrice: 100,
tip: 20,
total: 0,
});
effect(() => {
data.total = data.dinnerPrice + data.tip;
});
paint();
这简直太爽了,就像穿着新内裤迎接新年的早晨一样()。
上面的代码还有一点不足,就是paint函数在更改数据之后仍然需要我们手动触发。先给paint函数改个名吧,改成render函数,听起来似乎更炫酷了。
function render() {
document.getElementById("app").innerHTML = `
Item Cost
Price ${data.dinnerPrice}$
Tip ${data.tip}$
Total ${data.total}$
`;
}
分析一下,render函数会在什么时候被调用?答案很简单,即第一次初始化时以及变量的值发生改变的时候,我们上面的effect函数好像正好切合了这个功能?
//this is reacvitity core
let watchingFunc = null;//全局变量
function render() {/*...*/};
function reactive(dataObj){/*...*/};
function effect(fn) {/*...*/}
//this is our app
const data = reactive({
dinnerPrice: 100,
tip: 20,
total: 0,
});
effect(() => {
data.total = data.dinnerPrice + data.tip;
});
effect(render);//<---------------------看这里
在浏览器中调试,当我们改变tip的值时页面立即得到更新。
至此,预期的功能全部实现,我们通过一个小小的例子了解的vue运行的底层原理,这实在是令人兴奋的开局。当然,这只是很小的一步,即使是在最后的代码中仍然还有很多缺陷,比如如果我们尝试直接修改total的值(这是应该被阻止的),一样可以修改成功,这里就涉及到了readonly的判断问题。
后续文章计划以mini-vue作为主要学习对象(虽然不知道会不会鸽,因为本人也才刚开始学),进一步探索vue的底层原理,这次的分享就到此为止啦~。