一个例子看懂Vue响应式原理

一个例子看懂Vue响应式原理

本文首发于:一个例子看懂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文件,先完成最基本的逻辑,只需要这些:

  • 一个根据数据修改dom的函数,这里我们叫它paint
  • 一个计算函数,根据菜品费用和小费计算餐费。
function paint(){
    document.getElementById("app").innerHTML = `
    
ItemCost
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

响应性的关键在于“响应”,即我们需要监控部分变量的行为,在目标变量改变时进行相应操作。利用Proxy - JavaScript | MDN (mozilla.org),我们可以拦截被监控对象的行为并自定义相应操作。

proxy代理的目标是对象,所以我们的第一步是将定义的变量放入对象。

const dataObj = {
    dinnerPrice : 100,
    tip : 20,
    total : 0,
}

接下来创建代理,结合情景,我们需要在dinnerPrice和tip改变的时候重新计算total,“价格改变”这个事件我们可以用set来拦截,同时将之前变量的直接调用改为通过代理对象调用

function paint() {
  //通过代理对象调用
  document.getElementById("app").innerHTML = `
    
ItemCost
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 = `
    
ItemCost
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的底层原理,这次的分享就到此为止啦~。

你可能感兴趣的:(vue.js,javascript,前端)