likes
comments
collection
share

面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

作者站长头像
站长
· 阅读数 15

写在前面

最近在阅读霍春阳大佬的 《Vue.js技术设计与实现》,本文的内容主要来源于这本书,非常喜欢这本非纯源码分析的技术书籍,强烈推荐对Vue底层实现的同学阅读,再次膜拜大佬。

也许你我素未谋面,但很可能相见恨晚,我是前端胖头鱼.


前言

面试官:Vue3响应式系统都不会写,还敢说精通?中我们实现了一个最基本的响应式系统。

它包含以下功能:

  1. 借助Proxy将一个对象obj变成响应式数据,拦截其get和set操作。
  2. 通过effect注册副作用函数,并在首次执行副作用函数时完成obj对象的依赖收集(track)。
  3. 当数据发生变化的时候,第2步注册的副作用函数会重新执行(trigger)。

回顾源码

const bucket = new WeakMap()
// 重新定义bucket数据类型为WeakMap
let activeEffect
const effect = function (fn) {
  activeEffect = fn
  fn()
}
// track表示追踪的意思
function track (target, key) {
  // activeEffect无值意味着没有执行effect函数,无法收集依赖,直接return掉
  if (!activeEffect) {
    return
  }
  // 每个target在bucket中都是一个Map类型: key => effects
  let depsMap = bucket.get(target)
  // 第一次拦截,depsMap不存在,先创建联系
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 根据当前读取的key,尝试读取key的effects函数  
  let deps = depsMap.get(key)

  if (!deps) {
    // deps本质是个Set结构,即一个key可以存在多个effect函数,被多个effect所依赖
    depsMap.set(key, (deps = new Set()))
  }
  // 将激活的effectFn存进桶中
  deps.add(activeEffect)
}
// trigger执行依赖
function trigger (target, key) {
  // 读取depsMap 其结构是 key => effects
  const depsMap = bucket.get(target)

  if (!depsMap) {
    return
  }
  // 真正读取依赖当前属性值key的effects
  const effects = depsMap.get(key)
  // 挨个执行即可
  effects && effects.forEach((fn) => fn())
}
// 统一对外暴露响应式函数
function reactive (state) {
  return new Proxy(state, {
    get (target, key) {
      const value = target[ key ]

      track(target, key)
      // console.log(`get ${key}: ${value}`)
      return value
    },
    set (target, key, newValue) {
      // console.log(`set ${key}: ${newValue}`)
      // 设置属性值
      target[ key ] = newValue

      trigger(target, key)
    }
  })
}

测试一下


const state = reactive({
  name: 'fatfish',
  age: 100
})
// effect1
effect(() => {
  console.log(state.name, 'name')
})
// effect2
effect(() => {
  console.log(state.age, 'age')
})

state.name = 'fatfish2' // 因为name属性发生变化了,effect1将会重新执行,打印出的name是fatfish2

 面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

看起来还不错,不过他还存在很多缺陷和不足,比如:

  1. 分支切换会导致不必要的effect执行损耗
  2. effect不支持嵌套注册副作用函数
  3. ...

咱们挨个看看,这都是些啥...

支持分支切换

什么是分支切换?

按照上文的结论,这段代码执行后会形成这样的数据结构。

state
  |___ok
    |___ effectFn
  |___text
    |___ effectFn

const state = reactive({
  ok: true,
  text: 'hello world',
});

effect(() => {
  console.log('渲染执行')
  document.querySelector('#app').innerHTML = state.ok ? state.text : 'not'
})

当我们把ok的值改成false后,页面将渲染为"not"。意味着后续无论text如何变化,页面都永远只可能是"not"。

所以当我们修改text的值时,副作用函数重新执行是没有必要的。

const state = reactive({
  ok: true,
  text: 'hello world',
});

effect(() => {
  console.log('渲染执行')
  document.querySelector('#app').innerHTML = state.ok ? state.text : 'not'
})

setTimeout(() => {
  state.ok = false // 此时页面变成了not

  setTimeout(() => {
    state.text = 'other' // 页面依然是not,但是副作用函数却还会执行一次
  }, 1000)
}, 1000)

如何解决?

修改state.text,副作用函数会执行是因为state与其形成的数据结构是这样的。

state
  |___ok
    |___ effectFn
  |___text
    |___ effectFn

如果希望state.text的改动effectFn不再执行,我们就要想办法改变这个结构。

state
  |___ok
    |___ effectFn

此时无论你怎样修改state.texteffectFn都不会执行,因为他们俩之间并没有形成依赖关系。

在副作用函数执行前先将其从与该副作用函数有关的依赖集合中删除怎么样?

比如前面的例子,形成了:

state
  |___ok
    |___ effectFn
  |___text
    |___ effectFn

当我们修改state.ok = false时,effectFn将会被执行,在执行前,我们将effectFn从与之相关的依赖集合中删除,最终形成了一个光杆司令。

state

但是不要忘记,effectFn的重新执行,又会触发一次依赖收集,结束后,数据结构会变成:

state
  |___ok
    |___ effectFn

为了支持这样的特性,我们需要简单的改一下effecttrigger函数.


const effect = function (fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  // 用来存储哪些依赖集合包含这个副作用函数
  effectFn.deps = []
  effectFn()
}

function cleanup (effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

trigger

// trigger执行依赖
function trigger(target, key) {
  // 读取depsMap 其结构是 key => effects
  const depsMap = bucket.get(target);

  if (!depsMap) {
    return;
  }
  // 真正读取依赖当前属性值key的effects
  const effects = depsMap.get(key);
  // 解决cleanup 执行会无限执行的问题
  const effectsToRun = new Set(effects)
  // 挨个执行即可
  effectsToRun.forEach((fn) => fn());
}

最后测试一把

const state = reactive({
  ok: true,
  text: 'hello world',
});

effect(() => {
  console.log('渲染执行')
  document.querySelector('#app').innerHTML = state.ok ? state.text : 'not'
})

setTimeout(() => {
  state.ok = false // 页面渲染为not

  setTimeout(() => {
    state.text = 'other' // 页面依然是not,但是副作用函数不会再执行。
  }, 1000)
}, 1000)

支持effect嵌套

为什么要支持effect嵌套?

先说结论:因为组件是可以嵌套的,而Vue组件又恰巧是在effect中执行的。

来看看Vue中的组件是怎么执行的。

const Foo = {
  render () {
    return // ....
  }
}

effect(() => {
  Foo.render()
})

而当组件发生嵌套时,就会存在effect嵌套:

const Bar = {
  render () {
    return // ....
  }
}
const Foo = {
  render () {
    return <Bar /> // ...
  }
}

最后会变成这样:


effect(() => {
  Foo.render()
  
  effect(() => {
    Bar.render()
  })
})

目前的effect存在什么问题?

先来试试看目前它的问题是什么!!!

const state = reactive({
  foo: true,
  bar: true
})

effect(function effectFn1 () {
  console.log('effectFn1')

  effect(function effectFn2 () {
    console.log('effectFn2')
    console.log('Bar', state.bar)
  })
  
  console.log('Foo', state.foo)
})

根据上一篇文章的结论,我们认为响应式数据state与副作用函数应该会形成这种数据结构:


state
  |___foo
    |___ effectFn1
  |___bar
    |___ effectFn2 

所以首次执行时会打印出这两行信息:

 面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

当我们分别修改foo和bar属性时会发生什么?

修改bar

effectFn2会重新执行。

const state = reactive({
  foo: true,
  bar: true
})

effect(function effectFn1 () {
  console.log('effectFn1')

  effect(function effectFn2 () {
    console.log('effectFn2')
    console.log('Bar', state.bar)
  })
  
  console.log('Foo', state.foo)
})

setTimeout(() => {
  state.bar = false
}, 1000)

 面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

修改foo

effectFn1会重新执行,而effectFn2因为被其嵌套所以会被间接执行。 然而现实终归会告诉我们生活没那么美好.

const state = reactive({
  foo: true,
  bar: true
})

effect(function effectFn1 () {
  console.log('effectFn1')

  effect(function effectFn2 () {
    console.log('effectFn2')
    console.log('Bar', state.bar)
  })
  
  console.log('Foo', state.foo)
})

setTimeout(() => {
  state.foo = false
}, 1000)

 面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

所以本质上形成了这样的数据结构,以至于改变foo的值调用的是effectFn2

state
  |___foo
    |___ effectFn2
  |___bar
    |___ effectFn2 

问题出在哪里?

effectFn1开始执行的时,activeEffect指向的是effectFn1。而effectFn1的执行会间接地导致effectFn2的执行,此时activeEffect指向的是effectFn2

const effect = function (fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 问题点~~~
    activeEffect = effectFn
    fn()
  }
  // 用来存储哪些依赖集合包含这个副作用函数
  effectFn.deps = []
  effectFn()
}

effectFn2执行完毕时,因为activeEffect指向的是effectFn2。所以foo自然也就是和effectFn2建立了联系,而不是我们期待的effectFn1

effect(function effectFn1 () {
  console.log('effectFn1')

  effect(function effectFn2 () {
    console.log('effectFn2')
    console.log('Bar', state.bar)
  })
  
  console.log('Foo', state.foo)
})

要解决这个问题也很简单,我们新维护一个注册副作用函数的栈,让activeEffect指向的是永远是栈顶的副作用函数。用上面例子来模拟一下这个过程。

// 第1步:effectFn1执行入栈
// effectFn1 ← activeEffect
// 第2步:effectFn2执行入栈
/*
  此时栈变成了
  effectFn2 ←activeEffect
  effectFn1
*/
// 第3步:effectFn2执行完毕,将effectFn2出栈处理
// effectFn1 ←activeEffect
// 第4步:effectFn1执行完毕,将effectFn1出栈处理
// 此时栈已是空的

所以我们很容易对effect做出以下改造:

const bucket = new WeakMap();
const effectStack = []
// 重新定义bucket数据类型为WeakMap
let activeEffect;
const effect = function (fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    // 入栈
    effectStack.push(effectFn)
    fn()
    // 出栈
    effectStack.pop()
    activeEffect = effectStack[ effectStack.length - 1 ]
  }
  // 用来存储哪些依赖集合包含这个副作用函数
  effectFn.deps = []
  effectFn()
  console.log(effectStack.length, '---')
  // 非常重要
  // activeEffect = null
};

再测试一下上面的例子,一秒钟后成功的打印了effectFn1和effectFn2

const state = reactive({
  foo: true,
  bar: true
})

effect(function effectFn1 () {
  console.log('effectFn1')

  effect(function effectFn2 () {
    console.log('effectFn2')
    console.log('Bar', state.bar)
  })
  
  console.log('Foo', state.foo)
})

setTimeout(() => {
  state.foo = false
}, 1000)

 面试官:你觉得Vue的响应式系统仅仅是一个Proxy?

最后

希望能一直给大家分享实用、基础、进阶的知识点,一起早早下班,快乐摸鱼。

期待你在掘金关注我:前端胖头鱼,也可以在公众号里找到我:前端胖头鱼