[干货] 与你一同揭开 Vue 响应式的面纱

904 阅读10分钟

揭开响应式的面纱

Vue2 vs Vue3

技术同历史车轮一同向前且不断创新,如尤大所说,Vue3 从另一种层面也是还清了 Vue2 所欠下的一些技术债。从事前端的小伙伴不出意外对 Vue 的响应式都有所了解,毕竟这个 Vue 的核心竞争力。可能存在的问题就是对响应式实现的原理的认知不同,为此,笔者借此文与你一同揭开响应式的面纱。(本文通过图文、代码的方式来理解响应式原理,阅读时长15~25分钟,如文章中说明有误,请不吝赐教~~~)

本文将围绕以下几个问题展开

  1. Vue2 和 Vue3 实现响应式的 api 优劣对比?
  2. 如何简单实现 Vue2 响应式?
  3. 使用 Object.defineProperty 对 data 中的数据进行遍历劫持?
  4. 实现 发布-订阅 模式,收集依赖和触发依赖?
  5. 数组如何实现响应式?以及其他复杂数据类型能否劫持?
  6. 如何简单实现 Vue3 响应式?
  7. 复杂数据类型,使用 Proxy 代理对象?
  8. ref 和 reactive 的原理区别?
  9. 如何监听数组?以及其他复杂数据类型?

安于现状和改变,则何如?

响应式

响应式: 响应式机制的主要功能就是,可以把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新。按照 MVVM 模型来说就是 view 层的数据发生改变,model 层的数据也会同步发生改变;

翻开 Vue3 源码 历史 一查,满篇都写着四个字是‘权衡艺术’。Vue3 从性能的角度,官方统计的数据 Performance 性能比 Vue 2.x 快 1.2~2 倍,其中重大的一部分贡献就来自于响应式的重构 refactor。Vue2 已经很成功了(使用人数、github star),但是从技术上的层面剖析,依旧不太完满。那为什么要重构呢?笔者认为可能是艺术家的小骄傲吧!废话不多说,咱们根据开头提出的问题,逐一击破~~~

Vue2 和 Vue3 实现响应式的 api 优劣对比?

  • defineProperty 是 ES5 提供的方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象,在 Vue2 中,通过劫持对象的 getter 和 setter,实现响应式。操作对象是原始数据,兼容性强。缺点就是只能监听已存在的对象,需要对每个属性进行遍历监听,如果嵌套对象,需要深层监听,会造成性能问题,Vue2 也提供了$set$delete新增和删除新属性的 Api,解决 Object.defineProperty 的缺陷。
    • Proxy 是 ES6 提供的构造函数,在 Vue3 中,通过代理对象,实现响应式。操作对象是新对象,并且多达13中拦截方式。Proxy 可以拦截属性的访问、赋值、删除等操作,不需要初始化的时候遍历所有属性,另外有多层属性嵌套的话,只有访问某个属性的时候,才会递归处理下一级的属性。可以监听动态新增的属性,可以监听删除的属性 ,可以监听数组的索引和 length 属性。
  • Proxy 创建的实例可以用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程
  • Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
  • 代理也是最符合也是最能体现 Vue 响应式核心观点的名词

去做你说的不可能

一千个读者心中就有一千个哈姆雷特。对于 Vue 的核心响应式来说,每个 前端er 说辞都十分接近,但是实现方法去不尽相同。根据费曼学习法指导,学习需明确目标、自我理解、化整为零、总结提炼。所以,为了更好的检验自己学习成果,动手实现一个最简模型,这个模型包含核心思想和核心方法。因此,咱们朝着实现一个最简模型目标,策马奔腾向前~~~

最简实现 Vue2 响应式

任务拆分

  • 实现对数据劫持
    • 使用 Object.defineProperty
  • 实现发布订阅模式
    • 数据发生改变,更新视图
    • 视图发生改变,更新数据

图解

代码

<!-- index.html 示例 demo-->
<div id="app">
    <span>姓名 {{name}}</span>
    <input type="text" v-model="name" />
</div>
<script>
const vm = new Vue({
    el: "#app",
    data: {
        name: "zs",
    },
});
</script>

使用 Object.defineProperty 对 data 中的数据进行遍历劫持

// Vue.js
class Vue {
  constructor(obj_instance) {
    this.$data = obj_instance.data;
    Observer(this.$data);
  }
}

function Observer(data_instance) {
  // 递归的出口
  if (!data_instance || typeof data_instance !== "object") return;
  Object.keys(data_instance).forEach((key) => {
    defineRective(data_instance, key, data_instance[key]);
  });
}

function defineRective(data_instance, key, value) {
  Observer(value); // 递归 -- 子属性数据劫持
  Object.defineProperty(data_instance, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log(`访问了属性:${key} -> 值:${value}`);
      return value;
    },
    set(newValue) {
      console.log(`设置了属性:${key} -> 值:${value}`);
      value = newValue;
    },
  });
}

以上的代码简单的实现了对 data 的进行劫持,在控制台 get 或 set 相应属性就能看到 vm 实例中的 data 会发生相应的改变。

根据任务拆解,接下来就是给 view 层和 model 层搭上一座桥梁。简单实现一个 compiler 解析器,替换界面上的插值表达式,以实现双向数据绑定。

实现发布-订阅模式,收集依赖和触发依赖

在实现发布-订阅模式之前,先实现一个简易版的 HTML 模板解析器,用来替换界面上的插值表达式,利用虚拟的节点对象作为缓冲区,修改完 dom 后再将 Vue 数据应用,最后再渲染页面。

// HTML模板解析器 替换界面上的插值表达式
// 获取页面元素 -> 放入临时内存区域(批量修改 dom)-> 应用 Vue 数据 -> 渲染页面
function Compile(element, vm) {
  vm.$el = document.querySelector(element);
  const fragment = document.createDocumentFragment();
  let child;
  // 一个一个添加进去
  while ((child = vm.$el.firstChild)) {
    fragment.append(child);
  }
  fragment_complie(fragment, vm);
  vm.$el.appendChild(fragment);
}

// 替换文档
function fragment_complie(node, vm) {
  // input node.nodeType === 1 h1、span 也是 1
  // text node.nodeType === 3
  // 替换 插值表达式 -> 文本节点
  if (node.nodeType === 3) {
    parseInterpolation(node, vm);
    return;
  }
  if (node.nodeType === 1 && node.nodeName === "INPUT") {
    parseInput(node, vm);
    return;
  }
  // 循环遍历
  node.childNodes.forEach((child) => fragment_complie(child, vm));
}

function parseInterpolation(node, vm) {
  const pattern = /\{\{\s*(\S+)\s*\}\}/;
  const xxx = node.nodeValue;
  const result_regex = pattern.exec(node.nodeValue);
  if (result_regex) {
    const arr = result_regex[1].split(".");
    // 链式调用属性 vm.$data.other.age
    const value = arr.reduce((total, current) => total[current], vm.$data);
    node.nodeValue = xxx.replace(pattern, value);
  }
}

function parseInput(node, vm) {
  const attr = Array.from(node.attributes);
  attr.forEach((i) => {
    if (i.nodeName === "v-model") {
      const value = i.nodeValue
        .split(".")
        .reduce((total, current) => total[current], vm.$data);
      console.log("node ===", node);
      console.log("value ===", value);
      node.value = value;
      node.addEventListener("input", (e) => {
        // ['other','age']
        const arr1 = i.nodeValue.split(".");
        // ['other']
        const arr2 = arr1.slice(0, arr1.length - 1);
        // vm.$data.other
        const final = arr2.reduce((total, current) => total[current], vm.$data);
        // arr1.length - 1 当前的这个 总是最后一个
        final[arr1[arr1.length - 1]] = e.target.value;
      });
    }
  });
}

以上是实现了根据 nodeType 替换插值表达式和为使用了 v-model 的 input 添加事件监听函数。到此为止,搭建桥梁的材料都已经备齐,就是开始搭建了~~~

发布订阅模式

发布者有内容更新的时候就通知到相应的订阅者。例如在学生定购牛奶,学生就是订阅者,牛奶批发商就是发布者,牛奶批发商需将每日新鲜的牛奶送到学生手上。

// 链式调用公共函数
function getValueToChain(value, header) {
  return value.split(".").reduce((total, current) => total[current], header);
}

发布者要素

  • 维护订阅者数组
    • 这是一个收集依赖的过程
  • 通知订阅者更新
    • 这是一个触发依赖的过程
// 发布者 - 收集和通知订阅者
class Dependency {
  constructor() {
    // 订阅者数组
    this.subscribers = [];
  }
  // 添加订阅者
  addSub(sub) {
    this.subscribers.push(sub);
  }
  // 通知订阅者
  notify() {
    // 遍历数组 更新依赖
    this.subscribers.forEach((sub) => sub.update());
  }
}

订阅者要素

  • 接受自定义的更新函数
  • 创建订阅者的时候,需要通知发布者收集
// 订阅者
class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm; // Vue 实例
    this.key = key; // 需要更新的属性
    this.callback = callback; // 如何更新的函数 支持自定义
    // 临时属性 给类添加一个临时属性 
    Dependency.temp = this;
    // 触发当前 key 的 getter 不需要保存
    getValueToChain(key, vm.$data);
    Dependency.temp = null;
  }
  update() {
    const value = getValueToChain(this.key, this.vm.$data);
    this.callback(value);
  }
}

生成了发布者和订阅者之后,就需要将这两个类插入到之前写的代码里面去。(以下代码省略部分内容)

  • 在数据劫持的时候,需要在 get 的时候收集依赖,在 set 的时候触发依赖
  • 在模板解析的时候,需要在处理相应节点的时候,增加订阅者
function Observer(data_instance) {
  if (!data_instance || typeof data_instance !== "object") return;
  // === 新增 ===
  const dependency = new Dependency();
  Object.keys(data_instance).forEach((key) => {
    defineRective(data_instance, key, data_instance[key], dependency);
  });
}
function defineRective(data_instance, key, value, dependency) {
  Object.defineProperty(data_instance, key, {
    enumerable: true,
    configurable: true,
    get() {
      // === 新增 ===
      // 订阅者加入依赖实例的数组
      Dependency.temp && dependency.addSub(Dependency.temp);
      return value;
    },
    set(newValue) {
      value = newValue;
      // === 新增 ===
      dependency.notify();
      // 新属性需重新监听
      Observer(newValue);
    },
  });
}
function parseInterpolation(node, vm) {
  if (result_regex) {
    const arr = result_regex[1].split(".");
    const value = arr.reduce((total, current) => total[current], vm.$data);
    node.nodeValue = xxx.replace(pattern, value);
    // === 新增 ===
    // 创建订阅者
    new Watcher(vm, result_regex[1], (newValue) => {
      node.nodeValue = xxx.replace(pattern, newValue);
    });
  }
}
function parseInput(node, vm) {
  attr.forEach((i) => {
    if (i.nodeName === "v-model") {
      // === 新增 ===
      // 创建订阅者
      new Watcher(vm, i.nodeValue, (newValue) => {
        node.value = newValue;
      });
    }
  });
}

将发布订阅模式加到代码后,再去修改 index.html 页面的值就能看到数据已经实现响应式了~~~

数组如何实现响应式?以及其他复杂数据类型能否劫持?

对于数组,因为 Object.defineProperty 的缺陷(无法检测数组/对象的新增、无法检测通过索引改变数组的操作),对于数组已存在的索引,Vue 没有去做检测,总的来说是因为性能和用户体验收益不成正比,尤大给出的解释如下图。官方文档给了 Object.defineProperty 缺陷的解释 ——> 传送门

对于对象而言,每一次的数据变更都会对对象的属性进行一次枚举,一般对象本身的属性数量有限,所以对于遍历枚举等方式产生的性能损耗可以忽略不计,但是对于数组而言呢?数组包含的元素量是可能达到成千上万,假设对于每一次数组元素的更新都触发了枚举/遍历,其带来的性能损耗将与获得的用户体验不成正比,故 Vue2 无法检测数组的变动。

所以,通过下标修改数组的情况,Vue2 是无法被响应式追踪的。又因 Object.defineProperty 也监听不了数组 length 发生变化的情况(push、pop、shift 等对数组进行操作的方法),Vue2 就重写了原型上的 7个方法再进行监听(直接通过下标修改的情况不多,但是通过 length 修改的情况却很常见)。新增和删除也提供了 setset 和 delete。 另外,Vue2 也无法监听其它复杂数据类型,如 Map、Set 等。

最简实现 Vue3 响应式

Vue3 是通过 monorepo 的方式管理包,即可以看成是多个仓库集成。其中响应式 reactivity 模块是可以单独抽离在其它三方框架使用。

源码中 reactive 模块下有一个 test 文件夹,包含着相应 api 的测试用例,所以我们 Vue3 响应式 demo 通过 TDD 测试驱动开发的模式编写。

编写一个单测

// 使用 jest
import { reactive } from "../reactive";

// describe 是描述一个单测
describe('effect', () => {
  it('reactive ', () => {
    // reactive 核心
    // get 收集依赖
    // set 触发依赖
    const user = reactive({
      age: 10
    })
    user.age++  // user.age = user.age + 1
    // 预计 user.age 的值等于 11
    // 单测没通过,这行就会报错
    expect(user.age).toBe(11)
  });
});

复杂数据类型,使用 Proxy 代理对象?

根据单测编写代码

// reactive.ts
export function reactive(target) {
    // 返回一个构造器函数
    return createReactiveObject(target);
};

function createReactiveObject(target) {
    const proxy = new Proxy(target, {
        get(target, key) {
            const res = target[key]
            console.log(`get key ==> ${target[key]}`);
            return res
        },
        set(target, key, value) {
            console.log(`set key ==> ${value}`);
            target[key] = value
            return target[key]
        }
    });
    return proxy
}

运行单测,看控制台是否是预期输出

ps: vscode 需要安装相应的 jest 插件,并且安转相应 bable,让 jest 支持 esm

结果图

两次 get 一次 set 打印

以上只是实现了对于用户在 reactive 中定义对象,使用 proxy 进行代理。还没有实现响应式。笔者认为实现响应式的核心就是:1、在定义对象的时候,将这些变量放在一个桶里。2、在函数内,如果用到了这个变量,也会把这个函数收集起来。3、一旦变量发生了改变,立即通知所有用到它的函数,重新执行。(ps:废话太多)简言之:收集依赖和触发依赖。

Vue3 通过 Effect 函数来收集副作用函数,en ~~记下笔记📒(副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。 例如修改全局变量(函数外的变量),修改参数等;简言之就是改变了函数作用域外的变量之类的东西)

// effect.ts
let activeEffect
export function effect(fn) {
    // 为了方便扩展 实现一个 ReactiveEffect 类
    const _effect = new ReactiveEffect(fn)
    _effect.run()
};

class ReactiveEffect {
    _fn
    constructor(fn) {
        this._fn = fn
    }
    run() {
        activeEffect = this
        this._fn()
    }
}
// 上面简单实现了 effect
// 下面就要简单实现 Vue 的依赖地图
const targetMap = new WeakMap()
export function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
        dep = new Set()
        depsMap.set(key, dep)
    }
    dep.add(activeEffect)
};
export function trigger(target, key) {
    let depsMap = targetMap.get(target)
    let dep = depsMap.get(key)
    dep.forEach(effect => {
        effect.run()
    });
};

实现了 effect、track、trigger,之后运行之前的单测,还是不能通过。原因是我们还需要在代理对象的时候 get 添加 track,set 添加 trigger。

import { track, trigger } from "./effect";

export function reactive(target) {
    return createReactiveObject(target);
};

function createReactiveObject(target) {
    const proxy = new Proxy(target, {
        get(target, key) {
            const res = target[key]
            // === 新增
            track(target, key)
            return res
        },
        set(target, key, value) {
            target[key] = value
            // === 新增
            trigger(target, key)
            return target[key]
        }
    });
    return proxy
}

接着再调整之前的单测

import { effect } from "../effect";
import { reactive } from "../reactive";

describe('effect', () => {
  it('reactive base', () => {
    const user = reactive({
      name:'zs',
      age: 10
    })

    let nextAge
    effect(() => {
      nextAge = user.age + 1
    })
    expect(nextAge).toBe(11)

    // update
    user.age++  // user.age = user.age + 1
    expect(nextAge).toBe(12)
  });
});

再运行单测,查看控制台

出现这个,即表示 demo 成功通过了检测~~~

图解 --- 响应式依赖地图

ref 和 reactive 的原理区别?

  • ref 的底层原理是利用了属性的 get 和 set
    • 常用来定义基本数据类型,如 const name = ref('zs')
      • 在 setup 中,我们需要使用 .value 来访问变量
      • 在 template 中,模板解析的时候内部自动拆箱,所以不需要 .value
    • ref 也能定义复杂数据类型,因为 Vue3 会自动判断,如果是复杂数据类型就依然使用 reactive 定义
  • reactive 的底层原理是利用了 Proxy 进行代理

如何监听数组?以及其他复杂数据类型?

  • Vue3 能够直接监听到数组下标的变化,但是如果 index >= oldIndex 则需要在 set 的时候做一些处理,同时也是重写了能够改变数组长度的方法
  • 因为 Proxy 的代理方法多达 13 中,所以 Vue3 能够监听 map、set、WeakMap 和 WeakSet 的变化

数组栈方法 push、pop、shift、unshift 以及 splice 都会隐式的改变数组的 length。例如 push 的时候会先读取 length 然后再设置 length,如果多个 effect 做相同的操作就会引起栈溢出,所以 Vue3 对于以上几个方法设置了先暂停收集,然后再调用原始方法,最后再允许收集这一个过程。另外对于 includes、indexOf、lastIndexOf 在 Vue3 中会涉及到 this 的问题,所以也额外处理了。而在 Vue2 中就重写了数组的七个方法 push、pop、shift、unshift、splice、reverse、sort,对于这几个方法再通过 Object.defineProperty 进行相应的劫持。

前端路漫漫,上下求索之

看完此文后,再去阅读响应式源码部分,或许会轻松一些吧~

说了好多口水话,希望屏幕面前的你不要介意,最后希望你一直做认为对的事,并保持热爱和专注~