🔥Vue3响应式系统玩明白了吗?一文带你从0入门响应式

210 阅读7分钟

前言

哈喽大家好!我是 嘟老板。Vue3 用了这么久,响应式系统玩明白了吗,今天我们从一个简单的 html 页面切入,逐步实现一个简易版的响应式系统,感受下 Vue3 响应式的魅力。

抛砖引玉

实现一个”简陋“的响应式数据渲染。

页面框架

目标:实现一个 html 页面,展示一个数字 count,点击数字时, count +1。

代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Simple Reactive</title>
</head>
<body>
  <script>
    // 定义数据
    let count = 0
    // dom
    const body = document.body

    // 渲染函数
    function render() {
      body.innerHTML = `<h1>${count}<h1>`
    }

    body.addEventListener('click', () => {
      count++
      render()
    })

    render()
  </script>
</body>
</html>

浏览器打开 index.html,运行效果如下:

reactivity-1.gif

OK,效果还行...

定义并渲染响应式数据

现在我想在 count 发生变化时自动更新页面,而不是每次都调用 render 函数。就像这样:

body.addEventListener('click', () => {
  count++
  // 不再调用 render,而是 count 发生变化时自动更新页面
  // render()
})

这要怎么处理呢?稍微暂停思考一下。

OK,揭晓答案...

首先我们需要让程序知道 count 发生了变化,然后去更新页面。那怎么才能让程序知道 count 发生变化了呢?答案是代理,也就是我们常用的 Proxy

这也正是 Vue3 的响应式数据定义的核心(Vue2 使用 Object.defineProperty)。

<script> 部分调整如下:

<script>
  // 定义数据
  const obj = {
    count: 0
  }
  // 代理 handler
  const proxyHandler = {
    get(target, key) {
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      Reflect.set(target, key, value)
      render()
    }
  }
  // 代理数据对象,
  const proxyObj = new Proxy(obj, proxyHandler)
  // dom
  const body = document.body

  // 渲染函数
  function render() {
    body.innerHTML = `<h1>${proxyObj.count}<h1>`
  }

  body.addEventListener('click', () => {
    proxyObj.count++
  })

  render()
</script>

主要调整点如下:

  1. 将原来的 count 变量改为 obj 对象,并通过 new Proxy 创建代理对象 proxyObj
  2. new Proxy() 的 handler 中,定义 getter/setter,其中 setter 变更数据后,调用 render 函数重新渲染。
  3. 原本所有使用 count 的位置,全部改为使用 proxyObj.count

现在我们运行看下效果,刷新浏览器 index.html 页面:

reactivity-1.gif

OK,效果与原本的一致

小结一下

我们借助 Proxy 实现了一个简单的响应式数据渲染,通过定义代理对象的 getter/setter,实现在数据更改时,重新渲染页面。

然而,以上只能叫做定义响应式数据,而不能称作响应式系统,我们只是通过 Proxy 定义了一个响应式对象,后续若需要更多的响应式对象,每次都需要手动定义,这显然是不合理的。而且还有一个最明显的缺点,数据(proxyObj.count)渲染的逻辑(render) 耦合在一起,没有抽离出来,这也是响应式系统需要解决的核心问题。

那响应式系统到底是怎样的呢,请继续往下看。

响应系统

Vue3 响应式系统源码在 packages/reactivity 下。

整体介绍

响应式系统通过发布订阅模式实现数据的依赖追踪和更新。其中 Dep 负责依赖追踪,Watcher 负责数据侦听。

整个响应式系统核心构成主要有以下几个部分:

  • Dep:依赖收集、追踪。
  • Watcher:数据侦听。
  • effect:副作用。
  • reactive(或 ref,方便起见,本文仅实现 reactive):定义响应式对象。

我们新建一个 index.js 文件,专门编写响应式系统相关代码。

Dep

Dep 的作用是收集依赖,追踪依赖,及通知更新

我们新建一个 Dep 类,并定义如下属性和方法:

  • subs:存储依赖。
  • addSub: 添加依赖
  • notify:通知依赖更新

代码如下:

// Dep: 依赖收集
class Dep {
  constructor() {
    this.subs = new Set()
  }

  // 添加依赖
  addSub(sub) {
    if (activeEffect) {
      this.subs.add(activeEffect)
    }
  }

  // 通知依赖更新
  notify() {
    this.subs.forEach(effect => {
      effect.run()
    });
  }
}

可以发现代码十分精简,便于理解,此处仅实现最基础的部分。

最新的 Vue3 Dep双向链表结构实现,这里方便理解,使用 Set 结构。

Watcher

DepaddSub 函数中,用到了一个变量 activeEffect,这个变量是干什么的呢?

Watcher 中我们就能看到它的身影了。

Watcher 的核心作用是数据侦听,侦听了以后怎么办呢?执行相关的副作用。

什么是副作用?举个例子,我发烧了,吃了退烧药,有点犯困,这里”犯困“就是副作用,因为我吃药的目的是退烧,而不是为了”犯困“。对应到程序中,我对数据做修改,会触发一些其他的关联操作,比如更新页面,这些关联操作就属于副作用。

let activeEffect = null

class Watcher {
  constructor(effect) {
    // 当前副作用
    this.effect = effect
  }

  run() {
    // 激活的 effect 指向当前实例
    activeEffect = this
    // 执行副作用
    this.effect()
    // 执行完后置空
    activeEffect = null
  }
}

我们看到,Watcher 中定义了一个 run 方法,用来执行副作用。副作用执行前,将 activeEffect 指向了当前实例,可见 activeEffect 就是当前正在激活的副作用对象,当副作用函数执行完毕,需要将 activeEffect 置空。

effect

effect 函数主要用来创建、执行副作用,接受一个函数参数,可通过如下方式使用:

effect(() => {
  console.log(obj.count)
})

它的参数就是一个副作用函数,假设 obj 是一个响应式对象,当 obj.count 发生变化时,就会执行该副作用函数,打印 obj.count。

简单点的 effect 函数就是创建一个 Watcher 实例,并执行 run 方法。

export function effect(fn) {
  const _effect = new Watcher(fn)
  _effect.run()
}

reactive

reactive 函数用来创建响应式数据,核心就是借助 Proxy 创建并返回一个响应式对象。

核心需要做以下两件事:

  • 定义 getter:追踪依赖(track)
  • 定义 setter:触发更新(trigger)

getter 中,通过 dep.addSub 函数,向 dep 中添加依赖,实现依赖收集;

setter 中,通过 dep.notify 函数,触发副作用更新。

export function reactive(target) {
  const mutableCollectionHandlers = {
    get(target, key) {
      // 追踪依赖 track deps
      const dep = getDepFromReactive(target, key)
      dep.addSub()
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      // 先更新值,在触发更新
      const result = Reflect.set(target, key, value)
      // 触发更新 trigger deps
      const dep = getDepFromReactive(target, key)
      dep.notify()
      return result
    }
  }

  return new Proxy(target, mutableCollectionHandlers)
}

这里需要定义一个工具函数 - getDepFromReactive,用来获取目标对象某个键值(target.key)对应的依赖集 dep

// 存储所有的 target 对应的 key-dep
const targetMap = new WeakMap()

// 获取 target key 对应的 dep
function getDepFromReactive(target, key) {
  let depMap = targetMap.get(target)
  if (!depMap) {
    depMap = new Map()
    targetMap.set(target, depMap)
  }
  let dep = depMap.get(key)
  if (!dep) {
    dep = new Dep()
    depMap.set(key, dep)
  }
  return dep
}

突然一问:为什么 targetMap 使用 WeakMap 结构?

  1. WeakMap 的键可以是对象类型。
  2. WeakMap弱引用,方便进行垃圾回收。

OK,一个简易版的响应式系统,到这就结束了,Vue 源码实现远远要比这复杂得多,还包括 computed 实现等等,本着纯粹好理解的原则,我们就不涉及太多的东西。

应用

现在我们来应用下上面的响应式系统。

清空 index.html 中原本的 <script> 部分,在 <body> 页签下添加如下代码:

<script type="module">
  import { effect, reactive } from './index.js'

  const obj = reactive({
    count: 0
  })

  // dom
  const body = document.body

  // 渲染函数
  function render() {
    body.innerHTML = `<h1>${obj.count}<h1>`
  }

  effect(render)

  body.addEventListener('click', () => {
    obj.count++
  })
</script>

主要借助响应式api - reactiveeffect,创建响应式数据并监听,当 obj.count 发生改变时,执行 render 函数,重新渲染页面。

我们启一个本地服务进行测试。这里借助工具 - http-server,快速启动一个本地服务。

终端执行以下命令:

npx http-server -c-1

若原本没有全局安装,会提示你是否安装,输入 y 即可。

-c-1 是命令行配置,表示禁用缓存,更多详情,请前往 http-server 查看。

终端展示如下,启动成功:

image.png

浏览器输入网址:http://127.0.0.1:8080/ 访问:

reactivity-1.gif

OK,效果与预期一致。

结语

本文从一个简单的 html 页面入手,逐步实现了一个简易版的响应式系统,重点介绍了响应式系统的核心构件及完整实现过程,不能说与 Vue3 实现完全相同,但是核心思想大同小异。希望对您有所帮助!

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期干货