Vue2 完整完善的 Context 封装及使用案例

184 阅读1分钟

Vue2 完整完善的 Context 封装及使用案例

下面我将提供一个更加完整、完善的 Vue2 Context 封装方案,包含响应式支持、类型提示(使用 JSDoc)和多种使用方式,然后给出详细的使用案例。

一、完整 Context 封装实现

1. 核心 Context 封装 (context.js)


/**
 * 创建 Vue2 上下文
 * @template T
 * @param {T} defaultValue 默认值
 * @param {Object} [options] 配置选项
 * @param {boolean} [options.reactive=true] 是否启用响应式
 * @returns {{
 *   Provider: VueComponent,
 *   Consumer: VueComponent,
 *   useConsumer: () => T,
 *   withConsumer: (propName?: string) => Object
 * }}
 */
export const createContext = (defaultValue, { reactive = true } = {}) => {
  const contextKey = Symbol('contextKey')
  const observableValue = reactive 
    ? Vue.observable({ value: defaultValue })
    : { value: defaultValue }

  /**
   * Context Provider 组件
   */
  const Provider = {
    name: 'ContextProvider',
    props: {
      value: {
        type: null,
        required: true
      }
    },
    provide() {
      return {
        [contextKey]: observableValue
      }
    },
    watch: {
      value: {
        immediate: true,
        handler(newVal) {
          observableValue.value = newVal
        }
      }
    },
    render() {
      return this.$slots.default && this.$slots.default[0]
    }
  }

  /**
   * Context Consumer 组件
   */
  const Consumer = {
    name: 'ContextConsumer',
    inject: {
      [contextKey]: {
        from: contextKey,
        default: () => reactive 
          ? Vue.observable({ value: defaultValue }) 
          : { value: defaultValue }
      }
    },
    render() {
      const contextValue = this[contextKey].value
      return this.$scopedSlots.default 
        ? this.$scopedSlots.default(contextValue)
        : null
    }
  }

  /**
   * 组合式 API 风格的 Hook
   * @returns {T}
   */
  const useConsumer = () => {
    const vm = getCurrentInstance()
    if (!vm) {
      console.warn('useConsumer must be called within a setup function')
      return defaultValue
    }
    return vm.proxy[contextKey]?.value ?? defaultValue
  }

  /**
   * 高阶组件混入
   * @param {string} [propName='context'] 注入的属性名
   * @returns {Object}
   */
  const withConsumer = (propName = 'context') => ({
    inject: {
      [contextKey]: {
        from: contextKey,
        default: () => reactive 
          ? Vue.observable({ value: defaultValue }) 
          : { value: defaultValue }
      }
    },
    computed: {
      [propName]() {
        return this[contextKey].value
      }
    }
  })

  return {
    Provider,
    Consumer,
    useConsumer,
    withConsumer,
    _contextKey: contextKey
  }
}

/**
 * 获取当前组件实例 (兼容 Vue2)
 * @returns {Vue|null}
 */
function getCurrentInstance() {
  // 在 Vue2 中模拟 getCurrentInstance
  if (typeof window !== 'undefined' && window.Vue) {
    return window.Vue.prototype.$options.__currentInstance
  }
  return null
}

2. 辅助工具函数 (context-utils.js)

 * 创建响应式 Context 快捷方法
 * @template T
 * @param {T} defaultValue
 * @returns {ReturnType<typeof createContext>}
 */
export const createReactiveContext = (defaultValue) => 
  createContext(defaultValue, { reactive: true })

/**
 * 创建普通 Context 快捷方法
 * @template T
 * @param {T} defaultValue
 * @returns {ReturnType<typeof createContext>}
 */
export const createPlainContext = (defaultValue) => 
  createContext(defaultValue, { reactive: false })

/**
 * 多层 Context 合并工具
 * @param {...ReturnType<typeof createContext>} contexts
 * @returns {VueComponent}
 */
export const mergeContextProviders = (...contexts) => ({
  name: 'MergedContextProvider',
  components: {
    ...contexts.reduce((comps, ctx) => ({
      ...comps,
      [`ContextProvider${ctx._contextKey.description}`]: ctx.Provider
    }), {})
  },
  render() {
    return contexts.reduceRight((child, ctx) => {
      const Provider = `ContextProvider${ctx._contextKey.description}`
      return h(Provider, { value: this.value }, [child])
    }, this.$slots.default && this.$slots.default[0])
  }
})

二、完整使用案例

1. 主题切换案例

(1) 创建主题上下文 (theme-context.js)

// 默认主题配置
const defaultTheme = {
  mode: 'light',
  colors: {
    primary: '#409EFF',
    background: '#ffffff',
    text: '#303133'
  },
  toggleMode: () => {}
}

export const ThemeContext = createReactiveContext(defaultTheme)
(2) 主题提供者组件 (ThemeProvider.vue)
  <theme-provider :value="themeValue">
    <slot></slot>
  </theme-provider>
</template>

<script>
import { ThemeContext } from './theme-context'

export default {
  components: {
    'theme-provider': ThemeContext.Provider
  },
  data() {
    return {
      themeValue: {
        mode: 'light',
        colors: {
          primary: '#409EFF',
          background: '#ffffff',
          text: '#303133'
        },
        toggleMode: this.toggleMode
      }
    }
  },
  methods: {
    toggleMode() {
      if (this.themeValue.mode === 'light') {
        this.themeValue = {
          ...this.themeValue,
          mode: 'dark',
          colors: {
            primary: '#64B5F6',
            background: '#121212',
            text: '#E0E0E0'
          }
        }
      } else {
        this.themeValue = {
          ...this.themeValue,
          mode: 'light',
          colors: {
            primary: '#409EFF',
            background: '#ffffff',
            text: '#303133'
          }
        }
      }
    }
  }
}
</script>
(3) 使用 Consumer 组件 (ThemeButton.vue)
  <theme-consumer>
    <template #default="theme">
      <button
        :style="{
          backgroundColor: theme.colors.primary,
          color: theme.colors.text,
          border: 'none',
          padding: '8px 16px',
          borderRadius: '4px'
        }"
        @click="theme.toggleMode"
      >
        {{ theme.mode === 'light' ? '🌙 Dark Mode' : '☀️ Light Mode' }}
      </button>
    </template>
  </theme-consumer>
</template>

<script>
import { ThemeContext } from './theme-context'

export default {
  components: {
    'theme-consumer': ThemeContext.Consumer
  }
}
</script>
(4) 使用高阶组件 (ThemedCard.vue)
  <div 
    :style="{
      backgroundColor: theme.colors.background,
      color: theme.colors.text,
      padding: '16px',
      borderRadius: '8px',
      boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)'
    }"
  >
    <slot></slot>
    <p>Current theme: {{ theme.mode }}</p>
  </div>
</template>

<script>
import { ThemeContext } from './theme-context'

export default {
  mixins: [ThemeContext.withConsumer('theme')]
}
</script>
(5) 使用 Hook 方式 (ThemeIndicator.vue)
  <div>
    <p>Current theme mode: {{ themeMode }}</p>
    <p>Primary color: {{ themeColors.primary }}</p>
  </div>
</template>

<script>
import { ThemeContext } from './theme-context'

export default {
  computed: {
    theme() {
      return ThemeContext.useConsumer.call(this)
    },
    themeMode() {
      return this.theme.mode
    },
    themeColors() {
      return this.theme.colors
    }
  }
}
</script>
(6) 应用入口 (App.vue)
  <theme-provider>
    <div class="app" :style="{ backgroundColor: theme.colors.background }">
      <header>
        <theme-button />
      </header>
      <main>
        <themed-card>
          <h2>Theme Context Demo</h2>
          <theme-indicator />
        </themed-card>
      </main>
    </div>
  </theme-provider>
</template>

<script>
import ThemeProvider from './ThemeProvider.vue'
import ThemeButton from './ThemeButton.vue'
import ThemedCard from './ThemedCard.vue'
import ThemeIndicator from './ThemeIndicator.vue'
import { ThemeContext } from './theme-context'

export default {
  components: {
    ThemeProvider,
    ThemeButton,
    ThemedCard,
    ThemeIndicator
  },
  computed: {
    theme() {
      return ThemeContext.useConsumer.call(this)
    }
  }
}
</script>

<style>
.app {
  min-height: 100vh;
  transition: background-color 0.3s ease;
}
</style>

2. 多上下文合并案例

(1) 创建用户上下文 (user-context.js)

const defaultUser = {
  isAuthenticated: false,
  userInfo: null,
  login: () => Promise.reject('No provider'),
  logout: () => {}
}

export const UserContext = createReactiveContext(defaultUser)
(2) 合并提供者 (AppProviders.vue)
  <merged-providers>
    <slot></slot>
  </merged-providers>
</template>

<script>
import { mergeContextProviders } from './context-utils'
import { ThemeContext } from './theme-context'
import { UserContext } from './user-context'

export default {
  components: {
    'merged-providers': mergeContextProviders(
      ThemeContext,
      UserContext
    )
  }
}
</script>
(3) 使用多个上下文 (UserProfile.vue)
  <div>
    <theme-consumer>
      <template #default="theme">
        <user-consumer>
          <template #default="user">
            <div 
              :style="{
                backgroundColor: theme.colors.background,
                color: theme.colors.text,
                padding: '16px',
                borderRadius: '8px'
              }"
            >
              <h3>User Profile</h3>
              <div v-if="user.isAuthenticated">
                <p>Welcome, {{ user.userInfo.name }}!</p>
                <button @click="user.logout">Logout</button>
              </div>
              <div v-else>
                <p>Please login</p>
                <button @click="login">Login</button>
              </div>
            </div>
          </template>
        </user-consumer>
      </template>
    </theme-consumer>
  </div>
</template>

<script>
import { ThemeContext } from './theme-context'
import { UserContext } from './user-context'

export default {
  components: {
    'theme-consumer': ThemeContext.Consumer,
    'user-consumer': UserContext.Consumer
  },
  methods: {
    async login() {
      try {
        await this.$user.login({ username: 'demo', password: '123' })
      } catch (error) {
        console.error('Login failed:', error)
      }
    }
  },
  computed: {
    $theme: ThemeContext.useConsumer,
    $user: UserContext.useConsumer
  }
}
</script>