Vue3 竟然可以像React的useContext,通过高阶组件透传数据

1,407 阅读4分钟

Vue中跨多层级组件传递数据,可使用provide和inject。从provide和inject字面理解,类似于依赖注入,但这种模式使用起来太碎片化,缺乏智能提示,子组件根本不知道父级组件到底通过provide提供了哪些数据。

例如App.vue提供了key为name、version的provide:

import { ref, provide } from 'vue' 
const count = ref(0) 
provide('name', 'useContext')
provide('version', '1.0.0')

子组件使用inject获取配置:

import { inject } from 'vue' 

const name = inject('name', '')
const version = inject('version', '0.0.1')

这种使用方式的缺点:provide使用显得碎片化,子组件不能感知到上级节点到底提供了哪些注入信息。

那么问题来了:能不能像React一样使用createContext、useContext方式通过高阶组件注入信息?

先看下React是如何使用useContext的:

import { createContext, useContext } from 'react';
// 使用createContext创建一个Context,其默认provider为`{}`对象
const ThemeContext = createContext({});

export default function MyApp() {
  return (
    // 使用Provider高阶组件提供value
    <ThemeContext.Provider value={ name: 'use-context' }>
      <Panel />
    </ThemeContext.Provider>
  )
}

function Panel({ title, children }) {
  // 通过useContext获取高阶组件传递的数据
  const theme = useContext(ThemeContext);
  const className = 'panel-' + theme;
  return (
    <section className={className}>
      <h1>{title}</h1>
      {children}
    </section>
  )
}

功能实现的几个关键信息:

  • 使用createContext创建Context上下文对象
  • Context上下文包含Provider高阶组件
  • 使用useContext获取注入的数据
  • 支持数据更新

先看效果:Vue版useContext Demo

  1. 定义State和ThemeContext:
// context.ts文件

import { createContext } from '@vueuse/core'

export interface ThemeState {
    type: string;
}

export const ThemeContext = createContext<ThemeState>()

2. 在容器组件中像React一样使用Provider高阶组件:

// App.vue 

<script lang="ts" setup>
import { ThemeContext, ThemeState } from './context'
import Child from './Child.vue'
import { Ref, ref } from 'vue';
const themeState: Ref<ThemeState> = ref({ type: 'light' })
</script>
<template>
  <ThemeContext.Provider :provider="themeState">
    <Child></Child>
  </ThemeContext.Provider>
</template>

3. 在子组件中读取、更新state:

// Child.vue

<script lang="ts" setup>
import { useContext } from '@vueuse/core'
import { ThemeContext } from './context'

const { state, setState } = useContext(ThemeContext)
</script>
<template>
  <div :class="['container', `theme-${state.type}`]">
    <span>当前主题:{{ state.type }}</span>
    <button @click="setState({ type: state.type === 'dark' ? 'light' : 'dark' })">切换主题</button>
  </div>
</template>
<style scoped>
.theme-light {
  background-color: #777;
  color: #000;
}
.theme-dark {
  background-color: #333;
  color: #fff;
}
</style>

实现效果如下,点击按钮,调用setState函数,主题在dark、light之间切换, 而state作为响应式对象实时更新。

useContextDemo.gif

实现源码

createContext高阶组件Provider

先看createContext函数签名,defaultValue为注入value的缺省值,返回Context<T>类型。

export interface Context<T> {
  [x: string]: any
  Provider: Component<T>
  setState: (state: T) => void
}

function createContext<T>(defaultValue?: T): Context<T>;

Context<T>提供了Provider和setState,Provider为Vue组件,如:

<Context.Provider :provider="{...}"></Context.Provider>

而setState为数据更新函数。

function createContext<T>(defaultValue?: T): Context<T> {
  const { injectionKey, Provider, setState } = createContextProvider(defaultValue)
  const context = {
    _injectionKey: injectionKey,
    Provider,
    setState,
  } as Context<T>

  return context
}

createContext实现也就几行代码,调用createContextProvider函数返回三个属性:

  • injectionKey:为provide(key, value)中的key;
  • Provider:为支持数据透传的高阶组件;
  • setState:用于数据更新;

接下来看createContextProvider函数实现:

import { defineComponent, isRef, provide, ref } from 'vue-demi'

export function createContextProvider<T>(defaultValue: T) {
  const injectionKey = Symbol('')
  // 使用类型为ref的存储注入信息
  const state = ref<T>(defaultValue)
  // 动态定义Component,组件提供provider属性
  const Provider = defineComponent({
    props: ['provider'],
    setup(props, { slots }) {
      const originalValue = props.provider || state.value
      // 如果原始值为Ref类型,则解构
      state.value = isRef(originalValue) ? originalValue.value : originalValue
      provide(injectionKey, state)

      return () => {
        if (slots.default) {
          return slots.default()
        }
      }
    },
  })
  // 数据更新函数
  const setState = (value: T) => {
    state.value = value
  }

  return {
    injectionKey,
    Provider,
    setState,
  }
}

定义类型为Ref<T>的state,用于保存从Provider组件传入的数据,并在setState对其更新, 使用类型Ref的目的是支持响应式。

Provider是通过defineComponent函数动态生成的组件,当执行setup时:

  • 先将传入的props.provider赋值给state.value,如果原始值是Ref类型,则将其解构,其目的是将数据当做plain object使用。
  • 然后调用provide函数将key为injectionKey的state注入到组件,便于后续通过inject获取。
  • 最后返回默认插槽内容,也就是子组件。

createContextProvider函数除了返回Provider、setState外,还返回内部使用的injectionKey,其目的是提供给给后续的useContext函数获取注入的数据。

useContext函数,获取透传数据

function useContext<T>(context: Context<T>): { state: Ref<T>, setState: ((value: T) => void) } {
  const { setState } = context

  return {
    state: inject(context._injectionKey) as Ref<T>,
    setState: (value: T) => setState?.(value),
  }
}

useContext函数接受的参数为上文定义的Context<T>类型,返回信息包含state对象、setState函数,其中state调用inject(context._injectionKey)函数后区,而_injectionKey即为上文中createContext返回的injectionKey字段。

一般在子组件中调用useContext函数获取state数据,例如:

const { state, setState } = useContext(ThemeContext)

对外API:createContext、useContext

上文介绍的都是内部实现逻辑,而提供给研发使用的仅需要createContext和useContext两个函数即可。

// index.ts

export {
  createContext,
  useContext,
}

在使用state时,由于其类型已知,因此能感知到包含的属性,这样也解决了不能感知的问题。

image.png

下步计划:将useContext提交给vueuse

useContext基于vueuse实现,也是按vueuse要求的格式编写,包含markdown、test、demo。接下来打算将其提交给vueuse,下次给面试官吹牛,俺也是开源贡献者😄😄😄😄😄😄。

什么是vueuse?可参考《Vue无处不use的VueUse: Composition工具集,代码减半神器!》了解。

image.png

完整代码

  • index.ts:
import type { Component, Ref } from 'vue-demi'
import { inject } from 'vue-demi'
import { createContextProvider } from './provider'

export interface Context<T> {
  [x: string]: any
  Provider: Component<T>
  setState: (state: T) => void
}

function createContext<T>(defaultValue?: T): Context<T> {
  const { injectionKey, Provider, setState } = createContextProvider(defaultValue)
  const context = {
    _injectionKey: injectionKey,
    Provider,
    setState,
  } as Context<T>

  return context
}

function useContext<T>(context: Context<T>): { state: Ref<T>, setState: ((value: T) => void) } {
  const { setState } = context

  return {
    state: inject(context._injectionKey) as Ref<T>,
    setState: (value: T) => setState?.(value),
  }
}

export {
  createContext,
  useContext,
}

  • provider.ts:
import { defineComponent, isRef, provide, ref } from 'vue-demi'

export function createContextProvider<T>(defaultValue: T) {
  const injectionKey = Symbol('')
  const state = ref<T>(defaultValue)

  const Provider = defineComponent({
    props: ['provider'],
    setup(props, { slots }) {
      const originalValue = props.provider || state.value
      state.value = isRef(originalValue) ? originalValue.value : originalValue
      provide(injectionKey, state)

      return () => {
        if (slots.default) {
          return slots.default()
        }
      }
    },
  })

  const setState = (value: T) => {
    state.value = value
  }

  return {
    injectionKey,
    Provider,
    setState,
  }
}

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!