Vue组合式API原理及应用

504 阅读9分钟

背景

  • 对象式API (Options API) 的局限性
  • Hooks的启发

对象式API (Options API) 的局限性

  1. 组件越大,可读性越差,维护成本高
  2. 不利于有状态的逻辑复用
export default {
  data(){
    return {
      list:[],
      search:'', // 搜索逻辑
      filters:{  // 筛选逻辑
        type:1
      },
      page:1,   // 分页逻辑
      pageSize:10
    }
  },
  watch:{
    // 搜索逻辑
    // 筛选逻辑
    // 分页逻辑
  },
  computed:{
    // 搜索逻辑
    // 筛选逻辑
    // 分页逻辑
  },
  methods:{
    handleSearch(){
      // 搜索逻辑
    },
    handleFilter(){
      // 筛选逻辑
    },
    handlePageChange(){
      // 分页逻辑
    }
  }
}

在对象式API中,混入 (mixin) 提供了复用逻辑功能,混入对象的选项将以合理的方式与组件本身的选项合并。 但是mixin存在缺点:

  • 不清晰的数据来源:当使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使追溯实现和理解组件行为变得困难。这也是我们推荐在组合式函数中使用 ref + 解构模式的理由:让属性的来源在消费组件时一目了然。
  • 命名空间冲突:多个来自不同作者的 mixin 可能会注册相同的属性名,造成命名冲突。若使用组合式函数,你可以通过在解构变量时对变量进行重命名来避免相同的键名。
  • 隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得它们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。

Hooks的启发

React Hooks 已经彻底取代了Class Components,启发了组件逻辑表达和逻辑复用的新范式。 Vue 受到 Hooks 影响推出了 组合式 API。

什么是组合式 API?

组合式 API (Composition API) 是一系列 API 的集合,使我们可以使用函数而不是声明选项的方式书写 Vue 组件。它是一个概括性的术语,涵盖了以下方面的 API:

  • 响应式 API,使我们可以直接创建响应式状态、计算属性和侦听器,如:
    • ref()
    • computed ()
    • reactive()
    • readonly()
    • watchEffect()
    • watch()
    • ...
  • 生命周期钩子,使我们可以在组件各个生命周期阶段添加逻辑,如:
    • onMounted()
    • onUpdated()
    • onUnmounted()
    • onBeforeMount()
    • ...
  • 依赖注入,使我们可以在使用响应式 API 时,利用 Vue 的依赖注入系统,如:
    • provide()
    • inject()

组合式 API 原理

reactive

响应性是一种可以使我们声明式地处理变化的编程范式

如Excel表格,单元格 A2 是通过表达式 = A0 + A1 来声明

let A0 = 1 
let A1 = 2
let A2 
function effect() {
  A2 = A0 + A1
}
A0++ // 响应性-自动运行effect

术语:

  • effect函数产生了一个副作用,或者简称为作用 (effect),它可以改变程序的状态
  • A0 和 A1 被视为副作用的依赖 (dependency)
  • 副作用可以说是其依赖的订阅者 (subscriber)

要实现响应性,我们还需要:

  1. 劫持 property 访问,getter / setters 和 Proxy
  2. 一个追踪函数 track 去存储effect,以便依赖改变时运行
  3. 一个触发函数 trigger,在存储中找到对应的effect执行

reactive图示:

Proxy.png

代码描述:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 收集effect
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      // 执行effect
      trigger(target, key)
    }
  })
}

track存储effect:

let activeEffect
function track(target, key) {
  if (activeEffect) {
    const effects = getOrCreateEffects(target, key)
    effects.add(activeEffect)
  }
}

trigger在存储中找到对应的effect并执行:

function trigger(target, key) {
  const effects = getEffects(target, key)
  effects.forEach((effect) => effect())
}

获取key对应的副作用getEffects函数的实现以其存储结构密切相关。存储target、key对应的effect的数据结构: WeakMap<target, Map<key, Set<effect>>>

targetMap: {
    target:  {
        key: [effect1, effect2, ...]
    }
}

有了targetMap后可以写出track、trigger的完整代码:

const targetMap = new WeakMap();
let activeEffect = null; // The active effect running
function track(target, key) {
  if (activeEffect) {
    console.log('track:', target, key, activeEffect);
    // Check to see if we have an activeEffect
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect); // Add effect to dependency map
  }
}
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      // for watch
      if (effect.scheduler) {
        effect.scheduler();
      } else {
        effect.run();
      }
    });
  }
}

何时进行依赖收集?

  • 渲染
  • watch
  • computed
  • watchEffect
  • effect
  • ...
let state = reactive({ court: 0 });
function render() {
  document.body.innerHTML = state.court;
}
// 响应式渲染:赋值activeEffect,为依赖添加订阅者fn
function renderEffect(fn){
    activeEffect = { run: fn };
    activeEffect.run();
    activeEffect = null;
}
renderEffect(render)
state.court++;

响应式渲染

ReactiveEffect.png

ReactiveEffect

可以把renderEffect的逻辑抽象成ReactiveEffect。 响应式地追踪依赖,并在依赖更改时执行effect的fn或者scheduler。

class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn;
    this.scheduler = scheduler;
    this.deps = [];
  }
  run() {
    activeEffect = this;
    const result = this.fn();
    activeEffect = undefined;
    return result;
  }
}

至此我们已经实现简单的响应式渲染。

ref

reactive 中使用到的Proxy不能拦截基本数据类型的访问和修改 Vue的解决方案:将传入参数的值包装为一个带 .value 属性的 ref 对象,响应性的实现和reactive类似:

const toReactive = (v) => isObject(v) ? reactive(v) : v;
class RefImpl {
  _value;
  __v_isRef = true;
  constructor(v) {
    // 考虑ref(object)
    this._value = toReactive(v);
  }
  get value() {
    track(this, 'value');
    return this._value;
  }
  set value(v) {
    this._value = toReactive(v);
    trigger(this, 'value', v);
  }
}
export function ref(raw) {
  return new RefImpl(raw);
}

这里直接使用 reactive 中的 track 跟踪ref对象,把effect 存入 targetMap,实际上vue源码中使用trackRefValue把effect存入ref.dep中。

ref 函数可实现原始值类型转换为 响应式数据,但 ref 接收的值类型并没只限定为原始值类型,若接收到的是引用类型,还是会将其通过 reactive 函数的方式转换为响应式数据

unRef、isRef:


export function unRef(ref) {
  return isRef(ref) ? ref.value : ref;
}
export function isRef(value) {
  return !!value.__v_isRef;
}

watchEffect

立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行

利用响应式副作用ReactiveEffect实现watchEffect(fn):

const effect = new ReactiveEffect(fn);

watchEffect.png

watchEffect的实现:

export function watchEffect(fn) {
  const effect = new ReactiveEffect(fn);
  // 收集依赖
  effect.run();
  return () => effect.stop();
}
export class ReactiveEffect {
  active = true;
  deps = [];
  constructor(fn, scheduler) {
    this.fn = fn;
    // for watch trigger
    this.scheduler = scheduler;
  }
  run() {
    // 只在effect(fn)执行期间,通过track收集依赖
    activeEffect = this;
    const result = this.fn();
    activeEffect = undefined;
    return result;
  }
  stop() {
    if (this.active) {
      this.deps.forEach((dep) => {
        dep.delete(this);
      });
      this.deps.length = 0;
      this.active = false;
    }
  }
}

watch

响应式地追踪依赖,并在依赖更改时执行调度函数

watch(getter,fn)可以用ReactiveEffect实现

const effect = new ReactiveEffect(getter, scheduler);

watch.png

依赖改变时自动运行用户传入的函数:

export function watch(source, cb) {
  let getter = () => {};
  if (isRef(source)) {
    getter = () => source.value;
  } else if (isFunction(source)) {
    getter = () => source();
  }
  let oldValue;
  let scheduler = () => {
    let newValue = effect.run();
    cb(oldValue, newValue);
    oldValue = newValue;
  };
  const effect = new ReactiveEffect(getter, scheduler);
  oldValue = effect.run();
  return () => effect.stop();
}

trigger相应改动:

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  console.log('trigger', target, key);
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      // for watch
      if (effect.scheduler) {
        effect.scheduler();
      } else {
        effect.run();
      }
    });
  }
}

深层遍历:

export function watch(source, cb) {
  let getter = () => {};
  if (isRef(source)) {
    getter = () => source.value;
  } else if (isReactive(source)) {
    // 遍历 source 触发所有属性的track
    getter = () => traverse(source);
  } else if (isFunction(source)) {
    getter = () => source();
  }
  let oldValue;
  let scheduler = () => {
    // watch(source, cb) // 收集依赖
    let newValue = effect.run();
    cb(oldValue, newValue);
    oldValue = newValue;
  };
  const effect = new ReactiveEffect(getter, scheduler);
  oldValue = effect.run();
  return () => effect.stop();
}

export function traverse(value, seen) {
  if (!isObject(value)) return value;
  seen = seen || new Set();
  if (seen.has(value)) return value;
  seen.add(value);
  for (const key in value) {
    traverse(value[key], seen);
  }
  return value;
}

computed

计算属性会自动追踪响应式依赖,更新返回值。即依赖改变时自动运行副作用:

export function computed(getter) {
  let result = ref();
  watchEffect(() => {
    result.value = getter();
  });
  return result;
}

计算属性的特点:计算属性值会基于其响应式依赖被缓存,计算属性仅会在其响应式依赖更新时才重新计算。

compued.png

为了实现缓存功能需要改动watchEffect的内部逻辑:

export function computed(getter) {
  class ComputedRefImpl {
    _value;
    dirty = true;
    constructor() {
      this.effect = new ReactiveEffect(getter, () => {
        // scheduler 被执行时说明依赖有更新
        this.dirty = true;
        // 派发更新,compued的观察者
        trigger(this, 'value');
      });
    }
    get value() {
      // 收集依赖
      track(this, 'value');
      // 依赖若更新,返回新值,否则取缓存
      if (this.dirty) {
        this._value = this.effect.run();
        this.dirty = false;
      }
      return this._value;
    }
  }
  return new ComputedRefImpl();
}

Veux4 Store 响应式的实现

  • Vuex 是以插件的形式在 Vue 中使用的,在 createApp 时调用 install 安装
  • Store 类的 install,通过 app.provide 和 config.globalProperties.$store 提供 store 实例
  • Vuex4 中的 state 是通过 reactive API 去创建的响应式数据
  • useStore 用 inject 获取 provide 时存入的 store
import { inject } from 'vue';

class Store {
  constructor(options = {}) {
    this._committing = false;
    this._actions = Object.create(null);
    this._actionSubscribers = [];
    this._mutations = Object.create(null);
    this._wrappedGetters = Object.create(null);
    this._modules = new ModuleCollection(options);
    this._modulesNamespaceMap = Object.create(null);
    this._subscribers = [];
    this._makeLocalGettersCache = Object.create(null);
    // ...
    const state = this._modules.root.state;
    resetStoreState(this, state);
  }

  install(app, injectKey) {
    app.provide(injectKey || storeKey, this);
    app.config.globalProperties.$store = this;
  }
}

function resetStoreState(store, state, hot) {
  store._state = reactive({
    data: state
  });
}

export const storeKey = 'store';

export function useStore(key = null) {
  return inject(key !== null ? key : storeKey);
}

组合式 API 应用

组合式 API 是 Vue 3 Vue 2.7 的内置功能。setup 和 <script setup>是使用 Composition API 的入口点。

setup选项通常只在以下情况下使用:

  1. 需要在非单文件组件中使用组合式 API 时。
  2. 需要在基于选项式 API 的组件中集成基于组合式 API 的代码时。

其他情况下,都应优先使用 <script setup> 语法。

组合式 API 如何复用逻辑

Vue3 使用组合式 API 的地方为 setup。

在 setup 中,我们可以按逻辑关注点对部分代码进行分组,然后提取逻辑片段并与其他组件共享代码。因此,组合式 API(Composition API) 允许我们编写更有条理的代码。

<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

image.png

可以通过抽取组合式函数改善代码结构,组合函数即Vue 的组合式 API 来封装和复用有状态逻辑的函数:

// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

组合实例

  1. 不同页面可复用的逻辑
  • 文件上传

  • 验证码

  • 列表、详情的文件预览、收藏

  1. 同一页面混在一起的不同功能

逻辑拆分的原则

  • 保持功能单一,内部代码之与该功能相关
  • 低耦合度,不要与外部产生过的的交互
  • 文件命名应准确描述组合函数的功能

如何组织拆分出来的组件

somepage // 存放当前页面的文件夹
    |-- components  // 存放当前页面组件的文件夹
        |-- component-a  存放当前页面的组成部分A的文件夹
            |-- components
            |-- index.vue  组件A
            |-- hooks  存放组合函数文件
                |-- function-a.js  组合函数
                |-- function-b.js  组合函数
            |-- function-a.js  组合函数(层级过深、文件数少)
        |-- component-b.vue  组件B
        |-- component-c.vue  组件C
    |-- index.vue  当前页面主文件
    |-- section.vue  当前页面的一部分
    |-- style  页面样式
    |-- hooks  页面组合函数
    |-- utils  无状态公共逻辑
    |-- subpage  子页面

项目迁移 TODO

vue 2.7.x

  • [Composition API]
  • SFC <script setup>
  • SFC CSS v-bind
  • h、nextTick、defineComponent 等函数
  • 模板表达式中使用 ESNext 语法

ref=>defineReactive()

reactive=>observe

参考

composable: cn.vuejs.org/guide/reusa…

antfu: www.bilibili.com/video/BV1x5…

Vue Mastery: www.bilibili.com/video/BV1SZ…

www.bilibili.com/video/BV1rC…

Lesson Resource: github.com/Code-Pop/vu…

mini-vue:www.bilibili.com/video/BV1Rt…

简单模型: juejin.cn/post/698758…

源码解析: vue3js.cn/compiler/sp…

segmentfault.com/a/119000004…

高质量的Hooks: juejin.cn/post/712396…

常见问题

  1. 需要兼容IE的项目可以使用组合式API吗

Vue2.7 正式发布了。在此版本中,从 Vue3 向后移植了一些最重要的功能,以便 Vue2 用户也可以从中受益。

在 Vue2.7 中,Vue3 的很多功能将会向后移植,以便于 Vue2 的很多项目可以使用 Vue3 的一些很好用的新特性,例如:

  • Composition API (组合式 API)

  • SFC

  • SFC CSS v-bind (单文件组件 CSS 中的 v-bind)

  • defineComponent():具有改进的类型推断(与Vue.extend相比);

  • h()、useSlot()、useAttrs()、useCssModules();

  • set()、del() 和 nextTick() 在 ESM 构建中也作为命名导出提供;

  1. 可以同时使用两种 API 吗?

可以。你可以在一个选项式 API 的组件中通过 [setup()]选项来使用组合式 API。

然而,我们只推荐你在一个已经基于选项式 API 开发了很久、但又需要和基于组合式 API 的新代码或是第三方库整合的项目中这样做。

  1. ref 需要到处使用 .value 则感觉很繁琐,有优化方式吗 vue 有一个实验性的功能 响应性语法糖(reactivity-transform), 提供了$ 为前缀的宏函数ref,在编译时的转换ref,在编译时的转换ref为ref.value, 创建ref无需使用 .value,当启用响应性语法糖时,这些宏函数都是全局可用的、无需手动导入。

  2. vue3响应式系统相比vue2有哪些性能上的提升

vue2中采用 defineProperty来劫持整个对象,然后进行深度遍历所有属性,给每个属性添加getter和setter,实现响应式 vue3采用proxy重写了响应式系统,因为proxy可以对整个对象进行监听,所以不需要深度遍历 可以监听动态属性的添加 可以监听到数组的索引和数组length属性 可以监听删除属性