聊聊使用 composition-api plugin 遇到的坑

5,218 阅读5分钟

image.png 本文字数约 2000 字,阅读时间约 6 分钟

看懂本文需要一些 Vue2 响应式的源码知识

背景

部门要开发一款小程序,技术栈为 Vue

此时 Vue3 刚发布 beta 版,Taro3 还没有横空出世情况下,为了能享受 composition-api 的红利,我们将目标瞄向了 composition-api plugin

image.png

使用这个插件可以让 Vue2 支持 composition-api 部分功能

而这个部分功能就是引发问题的根源

现象

为了简化认知成本,写了 2 个简易 demo,技术栈均为 Vue2,分别用 composition-api plugin 和 optional-api 实现相似的功能

composition-api plugin

<template>
  <div>
    <div>Composition Api Plugin</div>
    <div>reactiveObj: {{ reactiveObj }}</div>
    <div>computedValue: {{ computedValue }}</div>
    <button @click="onClick">add a</button>
  </div>
</template>

<script>
import { reactive ,defineComponent,computed,set } from '@vue/composition-api'

export default defineComponent({
  setup(){
    const reactiveObj = reactive({})
    const computedValue = computed(() => {
      return reactiveObj.a
    })
    return {
      reactiveObj,
      computedValue,
      onClick: () => {
        set(reactiveObj,'a',1)
      }
    }
  }
})
</script>

optional-api

<template>
  <div id="app">
    <div>OptionalApi</div>
    <div>reactiveObj: {{ reactiveObj }}</div>
    <div>computedValue: {{ computedValue }}</div>
    <button @click="onClick">add a</button>
  </div>
</template>

<script>
import Vue from "vue";

export default {
  data(){
    return {
      reactiveObj:{}
    }
  },
  computed:{
    computedValue(){
      return this.reactiveObj.a
    }
  },
  methods:{
    onClick(){
      Vue.set(this.reactiveObj,'a',1)
    }
  },
}
</script>

结果

定义一个空的响应式对象 reactiveObj,一个值为 reactiveObj.a 的计算属性 computedValue

由于 Vue2 直接给响应式对象设置一个不存在的 key 会导致视图无法更新,所以这里借助 Vue.set 主动通知 reactiveObj 的订阅,触发更新

理论上 reactiveObjcomputedValue 都会被更新,最终在页面上显示 { "a":1 } 和数字 1

来看结果

Kapture 2021-09-05 at 00.21.56.gif

reactiveObj 都被成功更新了,而只有 optional-api 版本的 computedValue 得到了更新,这是为什么呢?

分析

找寻问题源头

打开 Vue devtools 查看数据源

image.png

image-20210906152038346

可以发现 composition-api plugin 版本 computedValue 数据层面也没有更新

继续追查,computedValue 未被更新的原因无非 2 种

  1. Vue.setreactiveObj 赋值后,没有通知 reactiveObj 的订阅更新
  2. Vue.set 通知了 reactiveObj 的订阅更新,但 computedValue 没有收到更新消息

为了弄清缘由,给 Vue.set 设置断点,查看此时 reactiveObj 里收集的订阅(下图 subs 数组)

image-20210906131518271

可以发现,在调用 Vue.set 之前 reactiveObj 只收集了一个订阅,通过 expression 字段的值可以断定它是一个渲染 watcher(即和视图相关的 watcher,当渲染 watcher 更新时,视图会重新渲染)

这里问题来了,因为在 setup 时调用了 computed 函数,收集 reactiveObj 作为依赖,所以在 reactiveObj 里理论上应该还有一个订阅,即名为 computedValue 的计算 watcher

正确的逻辑应该为以下流程:

Untitled-2020-07-09-1639

在 optional-api 里调试可以发现,此时的 reactiveObj 内确实存储了 2 个订阅,分别为渲染 watcher 和名为 computedValue 的计算 watcher

image.png

reactiveObj 缺少的订阅可以推倒出,应该是前面提到的第二种情况,即

composition-api plugin 版本的计算属性 computedValue 没有收集 reactiveObj 作为依赖,所以无论 reactiveObj 如何更新,computedValue也无法被更新

分析问题原因

找到问题源头后,接着分析

为什么 computedValue 没有收集到 reactiveObj 作为依赖?

这里得简述下 Vue 依赖收集更新的原理

Vue 内部通过调用 Object.defineProperty ,拦截对象的 getter/setter,得到一个响应式对象

当计算属性访问(依赖)响应式对象时,会触发其 getter,在该对象的 __ob__.dep.subs 里添加一个订阅

当某个 key 的值被修改时,会遍历其 subs 里所有的订阅并通知更新

碎花我们给 2 个版本的计算属性分别打断点,查看计算属性依赖收集的行为

composition-api plugin

Kapture 2021-09-06 at 13.39.48

可以发现,在访问 reactiveObj.a 时,并没有触发 reactiveObj 的 getter,所以没有收集到依赖,也就是说,此时的计算属性值是一个常量

optional-api

Kapture 2021-09-06 at 13.59.02

从图里可以看到,optional-api 版本会触发 reactiveObj 的 getter

分析 reactive 函数

来到了最后一个问题,为什么在 composition-api plugin 里的 reactive 函数创建的响应式对象,没有触发 getter?

还是老办法,通过源码查看底层实现,reactive 的核心实现在 composition-api plugin src/reactivity/reactive.ts 第 247 行

image-20210906141356524

image-20210906141429244

抛开一些边缘判断,其实 composition-api plugin 提供的 reactive 函数就是 Vue.observable 的语法糖

熟悉 Vue2 的同学应该了解, Vue.observable 返回的对象,本身并不是响应式的(但是会对其已知的 key 做递归的响应式绑定),这一点官方文档上也有明确标注

image-20210906142234920

而 optional-api 里,reactiveObj 本身是响应式的,原因在于 optional-api 在组件初始化时,会对整个 data 做递归的响应式绑定

image-20210906143240674

所以 composition-api plugin 里,computedValue 无法对一个普通对象收集依赖,而 optional-api 里,由于 reactiveObj 是响应式的,所以 computedValue可以正常收集依赖

题外话

例子中的 reactiveObj 是一个空对象,并且是因为设置了一个不存在的值,导致视图没有更新

那么如果是一个已知 key 呢?将代码改造一下,给 reactiveObj 预先添加一个名为 a 的 key

<template>
  <div>
    <div>Composition Api Plugin</div>
    <div>reactiveObj: {{ reactiveObj }}</div>
    <div>computedValue: {{ computedValue }}</div>
    <button @click="onClick">add a</button>
  </div>
</template>

<script>
import { reactive ,defineComponent,computed } from '@vue/composition-api'

export default defineComponent({
  setup() {
    const reactiveObj = reactive({
      a: 1  // 预先添加的 key
    })
    const computedValue = computed(() => {
      return reactiveObj.a
    })
    return {
      reactiveObj,
      computedValue,
      onClick: () => {
        reactiveObj.a = 2
      }
    }
  }
})
</script>

Kapture 2021-09-06 at 14.48.44

竟然也能成功更新!

前面提到 reactive 函数返回的对象虽然本身不是响应式,但是会对其已知的 key 做递归的响应式绑定

也就是说此时计算属性在访问 reactiveObj.a 时,会触发 a 的 getter, 最终实现依赖收集

但如果给 reactive 传入一个空对象,由于 composition-api plugin 底层还是会用 Object.defineProperty 拦截 getter/setter 作响应式的绑定,所以无法处理不存在的 key

当然解决办法也很简单,使用 Vue3 版本的 reactive

它的底层基于 Proxy 实现, 而 Proxy会代理整个对象,可以拦截对象几乎任何操作,包括创建一个不存在的 key

<template>
  <div>
    <div>Vue3</div>
    <div>reactiveObj: {{ reactiveObj }}</div>
    <div>computedValue: {{ computedValue }}</div>
    <button @click="onClick">add a</button>
  </div>
</template>

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

export default defineComponent({
  setup() {
    const reactiveObj = reactive({})
    const computedValue = computed(() => {
      return reactiveObj.a
    })
    return {
      reactiveObj,
      computedValue,
      onClick: () => {
        reactiveObj.a = 1
      }
    }
  }
})
</script>

Kapture 2021-09-06 at 15.11.16

总结

计算属性需要收集依赖才能实现数据更新,而收集依赖的前提必须是响应式

composition-api plugin 里使用 reactive 函数创建的对象,和 optional-api 里在 data 里定义的对象,行为略有区别

  • 前者创建的对象,本身并不是响应式的,但会对它已知的 key 作递归的响应式绑定
  • 后者会在组件初始化时将 data 里的所有值作递归的响应式绑定

composition-api plugin 的 reactive 函数,和 Vue3 版本的 reactive 函数,行为略有区别

  • 前者返回的是源对象,并且无法对不存在的 key 作响应式绑定
  • 后者返回的是新的代理对象,允许对不存在的 key 作响应式绑定