游乐游手机版
首页/前端开发/文章详情

VueUse createInjectionState 类型安全封装 provide inject

时间:2026-06-20 09:35
VueUse的createInjectionState将provide inject的类型安全封装为配对函数,自动生成Symbol并推导类型,无需手动声明InjectionKey。支持自定义key、默认值或抛错兜底,源码仅20行,通过provideLocal实现同组件提供和注入,显著提升跨层级状态共享的代码简洁性与安全性。

一、痛点:原生 provide/inject 类型安全写法为何如此繁琐?

跨层级共享状态时,Vue 官方提供了三种解决方案:props 逐层透传、provide/inject 依赖注入、以及 Pinia 全局状态管理。其中 provide/inject 最容易被低估——它专精于“组件子树内共享上下文”的场景,既比 props 透传简洁,又比 Pinia 轻量许多。

VueUse createInjectionState —— provide/inject 的类型安全封装

不过,但凡你亲手写过一次类型安全的 provide/inject,就一定会同意它也是最冗长的

来看一段教科书式的实现:

 复制代码// keys.ts
import type { InjectionKey, Ref } from 'vue'export interface CounterContext {
  count: Ref<number>
  double: Readonly<Ref<number>>
  increment: () => void
}// 1) 必须手动声明 InjectionKey 并配合 Symbol
export const counterKey: InjectionKey<CounterContext> = Symbol('counter')
 复制代码

 复制代码

具体问题集中于三个方面:

  1. 必须手动声明 InjectionKey,每增加一个上下文就多一个 Symbol;
  2. 类型需要在 key、provide、inject 三处保持一致,修改一处而忘了另外两处,就会静默失效;
  3. inject 的返回值默认是 T | undefined,业务代码中到处充斥着 !if (!ctx),要么接受不安全的非空断言,要么编写大量防御性代码。

VueUse 的 createInjectionState 正是针对这三点而生。它用一个 composable 函数,把“声明上下文 + 提供 + 注入”三项操作打包成 [useProvideXxx, useXxxState] 的配对 API。下面逐项详细拆解。

本文基于 VueUse v14.3.0(2026 年 5 月发布,要求 Vue 3.5+),所有代码示例均可直接运行。

二、API 详解:一对配对函数搞定所有事

2.1 最小示例

直接看官方的计数器示例(来自 VueUse 官方文档):

 复制代码// useCounterStore.ts
import { computed, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideCounterStore, useCounterStore] = createInjectionState(
  (initialValue: number) => {
    const count = shallowRef(initialValue)
    const double = computed(() => count.value * 2)
    function increment() {
      count.value++
    }
    return { count, double, increment }
  },
)export { useProvideCounterStore, useCounterStore }
 复制代码

 复制代码

与原生写法对比,差异一目了然:

  • 不再需要手动定义 InjectionKey——内部自动生成 Symbol;
  • 无需手动调用 provide / inject——这些逻辑封装在 useProvideXxx / useXxxState 内部;
  • 类型从 composable 返回值自动推导——你怎么编写,类型就怎么生成;
  • 支持传参——useProvideCounterStore(0)initialValue 直接透传给 composable,参数类型也由泛型推导。

2.2 配对设计哲学:明确角色边界

createInjectionState 返回的两个函数命名带有强烈的语义暗示:

  • useProvideXxx:在祖先组件中调用,表示“我来提供这套状态”;
  • useXxxState:在后代组件中调用,表示“我要消费这套状态”。

数组解构出的两个函数本质上是配对使用的:调用 useProvideXxx 时,内部执行 composable,并将返回值通过 provideLocal 注入到组件子树;调用 useXxxState 时,通过 injectLocal 取出。这种配对模式比单一 hook 更清晰——你一看代码就知道当前组件在状态树中扮演什么角色。

设计上还有一个常被忽略的细节:useProvideXxx 也会把 composable 的返回值作为普通函数返回。也就是说,provider 自身也可以直接使用这些状态:

 复制代码

这项特性从 v10.5.0 PR #3387 开始支持:允许 provide 和 inject 在同一组件中同时发生。在原生 API 中要做到这一点,你需要在 setup() 里手动 provide 完再 inject 一次,既繁琐又反直觉。

2.3 自定义 InjectionKey

默认情况下,key 是 Symbol(composable.name || 'InjectionState')——从 v10.8.0 之后,会自动使用 composable 的函数名作为 Symbol 描述符(PR #3788),便于调试时识别。

注意,每次调用 createInjectionState 都会生成新的 Symbol。模块级别声明一次,所有组件共用同一个 Symbol,这通常没有问题。但如果你需要:

  • 在多个独立打包产物之间共享同一个上下文(例如微前端场景);
  • 使用字符串 key 方便调试或 DevTools 识别;

可以传入 injectionKey 选项:

 复制代码import type { InjectionKey } from 'vue'const counterKey: InjectionKey<CounterStore> = Symbol('counter-store')const [useProvideCounterStore, useCounterStore] = createInjectionState(
  (initialValue: number) => { /* ... */ },
  { injectionKey: counterKey },
)// 也支持字符串
// { injectionKey: 'counter-store' }

字符串 key 是 Vue 原生 inject 所支持的,但这样一来会失去类型推导的桥梁,因此优先使用 InjectionKey 强类型

2.4 默认值与 throw 兜底:三种错误处理策略

当 provide 尚未调用时就去 inject,是最常见的运行时陷阱。createInjectionState 提供了三档应对策略:

策略 A:不做任何处理(默认)

 复制代码const counter = useCounterStore() // 类型: CounterStore | undefined
counter?.increment()

useXxxState 的返回类型默认包含 undefined,调用方需要自行处理。

策略 B:传入 defaultValue,自动兜底

该功能从 v10.10.0(PR #3902)开始新增。v14.3.0 进一步改进了类型(PR #5306)——一旦传了 defaultValue,返回类型就不再包含 undefined

 复制代码const [useProvideCounterStore, useCounterStore] = createInjectionState(
  (initialValue: number) => ({ /* ... */ }),
  {
    defaultValue: {
      count: shallowRef(0),
      double: shallowRef(0),
      increment: () => {},
    },
  },
)const { count, double, increment } = useCounterStore() // ✅ 类型中不再包含 undefined

适合“祖先没 provide 也能正常工作”的可选上下文场景。

策略 C:包裹一层抛错或回退函数

官方推荐的工程实践是:不要直接 export useCounterStore,而是额外封装一层

 复制代码// useCounterStore.ts
const [useProvideCounterStore, useCounterStoreRaw] = createInjectionState(
  (initialValue: number) => { /* ... */ },
)export { useProvideCounterStore }// 强制存在:祖先未提供上下文时直接抛错
export function useCounterStoreOrThrow() {
  const ctx = useCounterStoreRaw()
  if (ctx == null) {
    throw new Error(
      '[CounterStore] 请先在祖先组件调用 useProvideCounterStore',
    )
  }
  return ctx
}// 默认兜底:祖先未提供时返回缺省实现
export function useCounterStoreWithDefault() {
  return useCounterStoreRaw() ?? {
    count: shallowRef(0),
    double: shallowRef(0),
    increment: () => {},
  }
}

通过封装,让 inject 端彻底摆脱 undefined,业务代码中再也不需要到处都是 ! 断言。这正是 VueUse 官方文档推荐的模式。

三、源码原理浅析:本质是 provide/inject 的薄包装

打开 packages/shared/createInjectionState/index.ts,核心实现非常简短:

 复制代码import type { InjectionKey } from 'vue'
import { injectLocal } from '../injectLocal'
import { provideLocal } from '../provideLocal'export function createInjectionState<Arguments extends Array<any>, Return>(
  composable: (...args: Arguments) => Return,
  options?: CreateInjectionStateOptions<Return>,
) {
  // 1) 默认使用 composable 的函数名作为 Symbol 描述符
  const key: string | InjectionKey<Return> =
    options?.injectionKey || Symbol(composable.name || 'InjectionState')
  const defaultValue = options?.defaultValue  // 2) provider 端:执行 composable + provide
  const useProvidingState = (...args: Arguments) => {
    const state = composable(...args)
    provideLocal(key, state)
    return state
  }  // 3) consumer 端:从同一个 key 注入
  const useInjectedState = () => injectLocal(key, defaultValue)  return [useProvidingState, useInjectedState]
}

总共不到 20 行。但有两个值得关注的细节:

1. provideLocal / injectLocal,并非原生的 provide / inject

这是 VueUse 自己的小封装。原生的 Vue provide 不允许在同一个组件内,先 provide 完再 inject 同一个 key(取出来的仍然是上层的 provide 值)。provideLocal 通过将 provide 内容直接挂载到当前实例的本地缓存,使得“自身组件 inject 自身 provide 的值”成为可能。

这项能力来自 PR #3387,官方文档中“provider 自己也能用 store”那一段就是基于这一机制。

2. 函数重载提供两套类型签名。

 复制代码// 重载 1:传了 defaultValue,返回类型不含 undefined
export function createInjectionState<Args, Return>(
  composable: (...args: Args) => Return,
  options: { defaultValue: Return } & CreateInjectionStateOptions<Return>,
): CreateInjectionStateReturn<Args, Return, Return>// 重载 2:没传 defaultValue,返回类型含 undefined
export function createInjectionState<Args, Return>(
  composable: (...args: Args) => Return,
  options?: CreateInjectionStateOptions<Return>,
): CreateInjectionStateReturn<Args, Return, Return | undefined>

通过函数重载,defaultValue 的存在与否会精确反映在 useXxxState 的返回类型上。这是 v14.3.0 PR #5306 新增的能力——之前的版本无论是否传 defaultValue,类型都会带上 undefined,开发者不得不反复断言;现在终于“传了就保证有”。

理解到这一层,你就能感受到 VueUse 对类型安全的执着:接口未变,但开发体验提升了一个台阶

四、实战:四个跨层级状态共享的真实场景

4.1 表单上下文:让任意嵌套字段共享校验状态

复杂表单经常面临“嵌套区域 + 全局校验”的需求。比如下面这个用户资料表,外层控制整体禁用、是否正在提交、以及字段错误集合:

 复制代码// useFormContext.ts
import { computed, reactive, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'interface FormContextOptions {
  initialValues?: Record<string, any>
}const [useProvideFormContext, useFormContextRaw] = createInjectionState(
  (options: FormContextOptions = {}) => {
    const values = reactive<Record<string, any>>({ ...options.initialValues })
    const errors = reactive<Record<string, string>>({})
    const submitting = shallowRef(false)
    const disabled = shallowRef(false)    const isValid = computed(() => Object.keys(errors).length === 0)    function setValue(key: string, value: any) {
      values[key] = value
      delete errors[key]
    }    function setError(key: string, message: string) {
      errors[key] = message
    }    return { values, errors, submitting, disabled, isValid, setValue, setError }
  },
)export { useProvideFormContext }export function useFormContext() {
  const ctx = useFormContextRaw()
  if (!ctx) throw new Error('请在 
组件内使用表单字段') return ctx }
 复制代码

 复制代码

无论 嵌套多深、外层套了多少层 layout 容器,它始终能拿到表单上下文。整套机制比 Pinia 轻量得多——它只在这棵子树中生效,多个 实例互不干扰。

4.2 Tab 容器:父子组件解耦的经典场景

是 provide/inject 的经典用法。用 createInjectionState 改写:

 复制代码// useTabsContext.ts
import { shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideTabsContext, useTabsContextRaw] = createInjectionState(
  (initial?: string) => {
    const active = shallowRef(initial ?? '')
    const tabs = shallowRef<{ name: string; label: string }[]>([])    function register(name: string, label: string) {
      if (!tabs.value.some(t => t.name === name)) {
        tabs.value = [...tabs.value, { name, label }]
        if (!active.value) active.value = name
      }
    }    function unregister(name: string) {
      tabs.value = tabs.value.filter(t => t.name !== name)
    }    return { active, tabs, register, unregister }
  },
)export { useProvideTabsContext }
export function useTabsContext() {
  const ctx = useTabsContextRaw()
  if (!ctx) throw new Error(' 必须在  内使用')
  return ctx
}
 复制代码

 复制代码

使用示例:

 复制代码
  
    
  
  
    
  

不需要了解 的内部实现,注册/注销都通过上下文完成。相比 的“声明式” props 配置,组合式 + provide/inject 更接近“插件化”的思维方式。

4.3 Stepper 步骤器:当前步、能否前进/后退

 复制代码// useStepperContext.ts
import { computed, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideStepperContext, useStepperContextRaw] = createInjectionState(
  (totalSteps: number) => {
    const current = shallowRef(0)    const isFirst = computed(() => current.value === 0)
    const isLast = computed(() => current.value === totalSteps - 1)    function next() {
      if (!isLast.value) current.value++
    }
    function prev() {
      if (!isFirst.value) current.value--
    }
    function goto(index: number) {
      if (index >= 0 && index < totalSteps) current.value = index
    }    return { current, isFirst, isLast, next, prev, goto }
  },
)export { useProvideStepperContext }
export function useStepperContext() {
  const ctx = useStepperContextRaw()
  if (!ctx) throw new Error(' 必须在  内使用')
  return ctx
}
 复制代码

 复制代码

与 Tab 同理, 完全不需要 props,它从上下文中自动获取所需信息。关键在于这种状态绝对不该上 Pinia——它与当前 实例强绑定,全局化反而成为负担。

4.4 表格行选:跨表头/行/批量操作栏共享选中集合

 复制代码// useSelectionContext.ts
import { computed, reactive } from 'vue'
import { createInjectionState } from '@vueuse/core'interface SelectionContextOptions {
  itemKey: (item: T) => string
}const [useProvideSelectionContext, useSelectionContextRaw] =
  createInjectionState((options: SelectionContextOptions) => {
    const selected = reactive(new Set<string>())    function isSelected(item: T) {
      return selected.has(options.itemKey(item))
    }    function toggle(item: T) {
      const key = options.itemKey(item)
      selected.has(key) ? selected.delete(key) : selected.add(key)
    }    function clear() {
      selected.clear()
    }    function selectAll(items: T[]) {
      items.forEach(item => selected.add(options.itemKey(item)))
    }    const count = computed(() => selected.size)    return { selected, isSelected, toggle, clear, selectAll, count }
  })export { useProvideSelectionContext }
export function useSelectionContext() {
  const ctx = useSelectionContextRaw()
  if (!ctx) throw new Error('请在  内使用')return ctx as ReturnType<typeof useSelectionContextRaw> & {} // 类型见下文
}
 复制代码

表头中的“全选”复选框、行中的“单选”、批量操作栏中的“已选 N 条”——三个部分分别在三个不同的 slot 中渲染,但都共享同一个选中状态。这正是 provide/inject 模式擅长的场景。

五、对比矩阵:跟 Pinia / props / createSharedComposable 怎么选

每隔半年就会出现“Pinia 和 provide/inject 选哪个”的争论。createInjectionState 的定位其实非常清晰——它是 provide/inject 的现代封装,而非 Pinia 的替代品。

方案作用范围实例隔离类型推导SSR 安全适合的场景
props 透传父子直接每实例独立✅ 显式定义✅ 安全一两层、字段有限
createInjectionState子树每个 provider 子树独立✅ 完整推导✅ 安全表单/Tab/Stepper/表格行选等“组件族”
createSharedComposable整个客户端所有调用共享同一份✅ 推导✅ SSR 自动降级为非共享全局鼠标位置、滚动状态等“单例资源”
createGlobalState整个客户端所有调用共享✅ 推导✅ 同上与 SharedComposable 类似,更轻量
Pinia应用级单例 store✅ 一流✅(带 hydration)用户身份、购物车、跨路由的业务状态

几条选型直觉:

  1. 状态生命周期跟随某个父组件createInjectionState 卸载时,所有内部字段的状态一同被回收,自然且安全。
  2. 状态需要在多个互相独立的实例间隔离createInjectionState。同一页面中的两个 绝不能共享 active tab;如果使用 Pinia,反而需要给每个 store 加 id,徒增复杂度。
  3. 状态确实是“全局唯一资源”createSharedComposable。例如 useMouse() 希望全局只注册一个 mousemove 监听器,用 createSharedComposable 包裹一层即可。
  4. 状态是业务状态、跨路由、需要持久化或调试 → 选用 Pinia。createInjectionState 不是 store,没有 devtools 集成、没有 SSR 序列化、没有时间旅行能力。

特别区分 createInjectionStatecreateSharedComposable,这两者最容易混淆——名字都带 “create”,都来自 @vueuse/shared

 复制代码// createSharedComposable:全应用单例
import { createSharedComposable, useMouse } from '@vueuse/core'
const useSharedMouse = createSharedComposable(useMouse)// CompA.vue
const { x, y } = useSharedMouse() // 第一次调用:注册 mousemove 事件
// CompB.vue
const { x, y } = useSharedMouse() // 复用上一次的状态,不再重复注册// createInjectionState:组件树作用域
const [useProvideXxx, useXxx] = createInjectionState(/* ... */)
// 必须先调用祖先组件的 useProvideXxx() 才能在后代组件中使用 useXxx()

核心区别createSharedComposable 是“全应用一份”,没有 provider 概念;createInjectionState 是“每个 provider 子树一份”,必须有祖先 provide 才能在后代中使用。生命周期上,createSharedComposable 在最后一个订阅者卸载时停止 effectScope;createInjectionState 的生命周期跟随 provider 组件。

createSharedComposable 官方文档:

createInjectionState 因为基于 provide/inject,天然就是请求隔离的,不需要此类降级。SSR 项目中更推荐 createInjectionState

六、坑与解

6.1 别忘了 provider 必须先挂载

createReusableTemplate 一样,使用顺序是硬性约束:

 复制代码

   
       


  
    
  

编写工具组件时,养成习惯:永远封装一个 useXxxOrThrow,让缺失 provider 时直接报错而非静默失败。这条建议来自官方文档。

6.2 异步 setup 中的 inject 时机

如果某个组件的 setupasync 的(例如包含 top-level await),需要确保 useXxxState() 调用发生在第一个 await 之前,否则会丢失当前组件实例:

 复制代码

这是 Vue 3 inject 自身的限制,并非 VueUse 的特殊行为。

6.3 别在 createInjectionState 内部用 onMounted 等 hooks 期望它跟 consumer 绑定

composable 函数体是在 provider 组件的 setup 阶段执行:

 复制代码const [useProvideXxx] = createInjectionState(() => {
  onMounted(() => {
    // 这是 provider 组件的 onMounted,并非 consumer
  })
  return { /* ... */ }
})

这与普通 composable 的行为一致,但由于 createInjectionState 的“消费”动作延迟到后代组件执行,新手容易误认为生命周期会跟着 consumer 走。记住:composable 体在 useProvideXxx() 被调用时立即执行,且只执行一次

6.4 多个 provider 的优先级

如果在祖先链上多次调用同一个 useProvideXxx,inject 拿到的是最近的祖先那一份。这是 Vue 原生行为,可以利用它做“覆盖式上下文”(例如某个区域内的表单禁用规则覆盖父级),但也容易在不经意间产生 bug。建议使用 DevTools 的 Provided / Injected 面板检查实际拿到的是哪一份。

6.5 SSR 下不要在 composable 里直接访问 window

createInjectionState 本身 SSR 安全,但你的 composable 函数体一旦访问 window / document,就会像原生 composable 一样引起错误。该写的 if (typeof window !== 'undefined') 检查必不可少。

七、版本要求与变更速览

据 VueUse createInjectionState Changelog:

版本关键变更
v14.3.0(2026-05-01)defaultValue 时返回类型不再含 undefined(#5306)
v14.0.0(2025-10)要求 Vue 3.5+,构建产物迁移
v13.6.0(2025-07-28)添加 @__NO_SIDE_EFFECTS__ 注解,改善 tree-shaking
v12.0.0-beta.1(2024-11-21)不再支持 Vue 2
v10.10.0(2024-05-27)新增 defaultValue 选项(#3902)
v10.8.0(2024-02-20)injectionKey 默认使用 composable 名称(#3788)
v10.5.0(2023-10-07)新增 injectionKey 选项(#3404);允许在同一组件中同时 provide 和 inject(#3387)

新项目直接安装 v14.3.0 最为稳妥:

 复制代码npm i @vueuse/core

如果你的项目仍使用 Vue 2,需要锁定在 v9.x 版本。

八、小结

createInjectionState 的价值不在于它能做什么——这些功能原生 provide/inject 也同样能做到。它的价值在于将那些重复的样板代码全部砍掉,并将 TypeScript 类型推导推进到极致

可以这样记忆它的定位:

  • 想让 provide/inject 更好写?createInjectionState
  • 想让一棵子树共享状态?createInjectionState,不要轻易上 Pinia。
  • 想让全局只有一份资源?createSharedComposable
  • 想做应用级业务状态? 用 Pinia。

它的 API 设计——[useProvideXxx, useXxxState] 配对——精准填补了“抽象成 store 又怕过度工程”的灰色地带:你既无需手写 InjectionKey 三件套,也不必引入完整的 store 库。该有类型有类型,该有错误处理有错误处理,该轻量轻量,完全符合 VueUse 一贯的“够用就好”哲学。

下次再遇到跨层级状态共享的场景,先问自己:这状态真的需要 Pinia 吗?还是它只跟某个父组件相关? 如果是后者,createInjectionState 应该是你的第一选择。


参考资料:

  • VueUse createInjectionState 官方文档:vueuse.org/shared/crea…
  • 源码(GitHub):github.com/vueuse/vueu…
  • 同组件 provide+inject PR:github.com/vueuse/vueu…
  • defaultValue 选项 PR:github.com/vueuse/vueu…
  • 默认值非 undefined 类型 PR(v14.3.0):github.com/vueuse/vueu…
  • createSharedComposable 官方文档:vueuse.org/shared/crea…
  • Vue 官方 Provide / Inject 指南:cn.vuejs.org/guide/compo…
  • Composables vs. Provide/Inject vs. Pinia — When to Use What:vueschool.io/articles/vu…
来源:https://juejin.cn/post/7652194092413845542
上一篇Less混合器在大型CSS项目中难以维护的深层原因 下一篇Vue完整项目实战:从零搭建组件化路由的现代前端应用
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
如何在JavaScript中实现基于旋转视野的FOV射线绘制详解
前端开发 · 2026-07-01

如何在JavaScript中实现基于旋转视野的FOV射线绘制详解

如果用一句话概括核心,那就是:在 RayCasting 游戏开发中,绘制动态视野边界线(FOV)最可靠的方式是在逻辑层通过数学公式将坐标“算”出来,而不是依赖 Canvas 绘图上下文的旋转操作。 在实现类似 Doom 风格的 RayCasting 游戏时,动态视野(Field of View, F

TypeScript后端数据正确映射为前端接口类型的方法
前端开发 · 2026-07-01

TypeScript后端数据正确映射为前端接口类型的方法

在后端数据与前端类型之间来回转换,几乎是每位 TypeScript 开发者都无法回避的常态。后端返回的 car_brand、reg_number,和前端接口中定义的 brand、govtNumber,命名风格常常对不上号。此时,如果为了省事直接用 as 类型断言“强行”指认类型,那就踩进了常见的陷阱

动态HTML表格按层级条件合并单元格的JavaScript实现
前端开发 · 2026-07-01

动态HTML表格按层级条件合并单元格的JavaScript实现

本文详细讲解一种递归式 JavaScript 合并单元格方法,用于按列优先级(如前3列)智能合并表格行:仅当前一列已合并的前提下,才允许后续列合并相同值,从而精准实现多级分组与层级表格合并效果。 在动态生成的 HTML 表格中,按业务逻辑合并重复行是常见需求。然而,简单地对单列分别遍历合并——例如先

Next.js 13+重定向后滚动失效解决方案
前端开发 · 2026-07-01

Next.js 13+重定向后滚动失效解决方案

在 Next js App Router 的日常开发中,有一个令人颇为困扰的异常现象——当服务端执行 `redirect()` 跳转后,目标页面竟然无法正常滚动。没错,页面已经渲染完成,内容也完整显示,但垂直滚动条仿佛凭空消失。这个问题在 Next js 13 5 4 版本中尤为突出。 先给出结论:

WebGL图像加载延迟的纹理初始化时立即显示方法
前端开发 · 2026-07-01

WebGL图像加载延迟的纹理初始化时立即显示方法

本文详细介绍如何利用 Promise 与 async await 重构 WebGL 纹理加载流程,彻底解决首次渲染显示蓝色占位色、需要手动交互才能刷新的问题,实现文件导入后四张纹理平面即时正确渲染。 实际上,这个坑在 WebGL 开发中相当常见——纹理异步加载的小陷阱,说起来不大,但第一次遇到确实令