Vue 3 核心原理 -- reactivity 自己实现

标签(空格分隔): vue 前端


[toc]

前言

为了更好地理解 vue3,阅读其源码是一个重要的途径,但是单纯阅读源码可能理解不了作者为什么这样写,因此自己根据 API 来实现一遍然后再与源码作对比,可以更深刻理解 vue3 的实现原理。

该部分源码复写,可看Vue 3 核心原理 -- reactivity 源码复写


自己实现一遍

自己实现前,需要了解一下 vue3 的 API 以及大概的实现原理,可参考以下文章:

vue 3 一个简单的例子如下

<template>
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
  <button @click="addOtherCount">
    otherCount is: {{ otherCount }}, double is: {{ otherDouble }}
  </button>
</template>

<script>
import { reactive, computed } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    })
    
    function increment() {
      state.count++
    }
    
    const otherCount = ref(0)
    const otherDouble = computed(() => otherCount.value * 2)
    
    function addOhterCount() {
      otherCount.value++
    }

    return {
      state,
      increment,
      otherCount,
      addOhterCount
    }
  }
}

// 后续渲染伪代码
const renderContext = setup()
watch(() => {
  renderTemplate('...', renderContext)
})

</script>

vue 3 使用以上 API 设计的一大原因就是让业务代码可以更加高内聚低耦合,还有其他原因可以参考 API RFC

根据以上 API ,开始动手写

ref

refreactive 两个函数都是用于监听数据修改,实现数据绑定的。 两者的区别在于 ref 是用于监听原始值的。 因为 js 原始值不能引用内存地址,就算修改了也无从知晓,因而可以将其包装成一个对象,这样就可以获取到这个变量的引用,监听修改。 ref 只有一个 value 属性。

已知使用 ref 监听变量修改,使用 watch 订阅通知。


// 以下按序号阅读,可以复制全部运行。

// 4.2 新建一个保存订阅的 WeakMap<object, Set>,使用 WeakMap 防止内存泄漏
const subscription = new WeakMap()

// 7.2 当前需要加入订阅列表的回调
let currentSub

// ---- 例子 ----
const count = ref(0)
const double = computed(() => {console.log('computed'); return count.value * 2}) // log computed #这里没做 lazy
watch(() => console.log(count.value, double.value)) // log 0 0
// log computed
count.value++ // log 1 2
connsole.log(double.value) // log 2 # 没有 log computed 证明缓存了

// -------------

// 1. 先写 ref 函数,已知使用 proxy
function ref(value) {
  const innerObj = { value }
  return new Proxy(innerObj, {
    get(obj, key, receiver) {
      if (key !== 'value') { return }
      // 9.1 收集依赖,即 将当前订阅放入到每个触发了 getter 的变量的订阅列表中
      track(obj, key)
      return Reflect.get(obj, key, receiver)
    },
    // 2. 暂不知道如何收集依赖,先写 set,修改变量就要通知订阅
    set(obj, key, v, receiver) {
       if (key !== 'value') { return false }
       const res = Reflect.set(obj, key, v, receiver)
       // 3.1 通知该变量订阅的修改
       trigger(obj, key)
       return res
    }
  })
}

// 9.2 收集依赖
function track(obj, key) {
  if (!currentSub) { return }
  let subList = subscription.get(obj)
  if (!subList) {
    subList = new Set()
    subscription.set(obj, subList)
  }
  subList.add(currentSub)
}

// 3.2 通知
function trigger(obj, key) {
  // 4.1 所有订阅应该在一个列表上才能通知到
  
  // 5. 获取当前监听变量的订阅列表
  const subList = subscription.get(obj) // Set<Function>
  if (!subList) { return }
  // subList.forEach((cb) => cb())
  
  // 13. 先执行 computed 再执行 watch
  Array.from(subList)
    .sort((a, b) => Number(!!b.computed) - Number(!!a.computed))
    .forEach(cb => cb())
}

// 6. 既然发现有一个订阅列表了,那么 watch 的时候就是将订阅放入对应的列表
function watch(cb, opt = {}) {

  // 12. 标记 computed
  cb.computed = opt.computed

  // 7.1 怎么放? 可以使用一个全局变量来标记当前的订阅
  currentSub = cb
  // 8. 执行一下,这样可以触发变量的 getter
  cb()
  // 10. 收集完成,清理
  currentSub = null
  
}

// 11. 最后实现 computed,其实就是 watch 的 lazy 版,触发订阅时注意要先 computed 再到 watch
function computed(getter, setter) {
  // 数值要缓存起来,不要每次都算
  let value
  // 这里将订阅放到 computed 所依赖的变量的订阅列表,就是 count
  watch(() => { value = getter() }, { computed: true })
  return new Proxy({}, {
    get(obj, key, receiver) {
      if (key !== 'value') { return }
      return value
    },
    set(obj, key, v, receiver) {
      if (key !== 'value' || !setter) { return false }
      return setter(obj, key, v, receiver)
    }
  })
}

reactive

有了 ref 的实现思路,实现 reactive 就很简单了。 ref 其实可以算是 reactive 的特化版 -- 只包装 { value } 对象。

reactive 需要实现对象的遍历监听以及属性增删的监听。其他跟 ref 类似的代码就不写注释了,只写 reactive 特有的


const subMap = new WeakMap()
let currentCb
// 保存已经 reactive 的对象
const alreadyReactive = new WeakMap()

// --- 例子 ---
const state = reactive({
  count: 0, 
  obj: {a: 0},
  arr: [1,2,3]
})
watch(() => {  console.log('count:', state.count) })
watch(() => {  console.log('obj.a:', state.obj.a) })
// 这里有问题了,watch 的时候只收集到子对象的,没收集到子对象的属性,那么就监听不到了其属性修改
watch(() => {  console.log('obj:', state.obj) })
watch(() => {  console.log('arr:', state.arr) })
state.count++ // log count: 1
state.obj.a++ // log obj.a: 1
// 这里就监听不到了,要查看源码才知道什么做
state.arr.push(4)
state.arr.pop()
state.arr[0] = 11
// ---------

function reactive(target) {
  const cache = alreadyReactive.get(target)
  if (cache) { return cache }
  const proxy = new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)
      const res = Reflect.get(obj, key, receiver)
      // 如果属性是对象,就返回一个 reactive 包装的对象,递归遍历
      // 由于频发触发 reacitve 函数有性能问题,因此可以缓存起来
      // 使用 alreadyReactive 保存已包装过的对象
      return typeof res === 'object' ? reactive(res) : res
    },
    set(obj, key, value, receiver) {
      const res = Reflect.set(obj, key, value, receiver)
      trigger(obj, key)
      return res
    }
  })
  alreadyReactive.set(target, cache)
  return proxy
}

// 由于对象有多个属性,每个属性都有对应的订阅列表
// 因此容器 subMap 的数据结构为 WeakMap<object, Map<string, Set>
function trigger(obj, key) {
  const target = subMap.get(obj)
  if (!target) { return }
  const sub = target.get(key)
  if (!sub) { return }
  sub.forEach(cb => cb())
}

function track(obj, key) {
  if (!currentCb) { return }
  let target = subMap.get(obj)
  if (!target) {
    target = new Map()
    subMap.set(obj, target)
  }
  let sub = target.get(key)
  if (!sub) {
    sub = new Set()
    target.set(key, sub)
  }
  sub.add(currentCb)
}

function watch(cb) {
  currentCb = cb
  cb()
  currentCb = null
}


以上就是 ref reactive 的核心原理,带着自己实现时的理解与疑惑, 再去阅读源码,更容易理解作者的思路与实现。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351