◆

Vue3 provide/inject 依赖注入与 readonly 用法

2023-02-02 01:370

props 只能一层层往下传,组件嵌套深了就得写一串「中间组件只负责转发」的样板代码,这种传递被称为 prop 逐级透传。provide / inject 是 Vue 给的另一条路:祖先组件用 provide 把数据挂上去,后代组件不管隔了多少层,直接 inject 就能拿到,中间层完全不需要参与。

但这条捷径也带来一个治理问题:数据来源变得不明确了。子组件拿到一个注入值,如果它可以随手修改,那么当这个值出了问题,我们很难判断到底是哪一层动的手。父组件既是数据的提供者也是所有者,理应由它掌握修改权。readonly 就是用来落实这条纪律的——注入出去的值只读,同时另外注入一个修改方法,谁想改都得走这个方法。

这篇记的是 readonly 在这套流程里的实际行为,以及一个容易误判的坑。

注意点

使用 provide 注入一个 readonly 的参数时,如果想要修改它,必须使用 provide 提供的修改方法去修改 如果在子组件新建一个方法去修改无效,且控制台会抛出一个警告

关键在于 readonly() 返回的是一个代理,不是原始对象的副本。它拦截所有写操作,试图赋值时在开发环境打印 Set operation on key "value" failed: target is readonly 警告并静默失败。而原始的那个 ref 一点没变,父组件自己持有它,照样能正常修改,改动也会正常触发子组件重新渲染。

所以这是一层「单向阀门」:数据照旧从上往下流动并保持响应性,只是反向的写入被挡住了。要留意的是这个警告只在开发构建里有,生产构建为了体积会去掉警告,写操作依然失败但没有任何提示——所以不能把它当成运行时的安全机制,它是给开发者的约束提醒。

代码示例

  • 父组件代码

父组件里准备了两个 ref:location 直接 provide 出去,readonlyLocation 用 readonly() 包一层再 provide。同时把 updateLocations 方法也注入下去,这是留给子组件的唯一合法修改入口。注意这个方法内部操作的是原始的 ref(readonlyLocation.value = ...),不是只读代理,所以能改成功。

<script lang="tsx">
import { readonly, ref, provide, defineComponent } from 'vue';
import inject from './inject.vue';

``;

export default defineComponent({
  components: {
    inject,
  },

  setup() {
    const location = ref('North Pole');
    const readonlyLocation = ref('Readonly North Pole');

    const updateLocations = () => {
      location.value = 'provide提供的更改provide的值';
      readonlyLocation.value = 'provide提供的更改provide的值';
    };

    provide('location', location);
    provide('readonlyLocation', readonly(readonlyLocation));
    provide('updateLocations', updateLocations);

    return () => (
      <div>
        这是 location: {location.value}
        <br />
        这是 readonlyLocation: {readonlyLocation.value}
        <inject />
      </div>
    );
  },
});
</script>

这里 provide 的第一个参数用的是字符串 key。字符串写起来方便,但它是全局共享的,不同组件树用了同名 key 会互相干扰,而且 TypeScript 推不出类型。项目稍大一点建议改用 InjectionKey,这样类型能在提供方和消费方之间自动对齐。

  • 子组件代码

子组件把三样东西都注入进来,然后提供两个按钮做对比:一个调父组件给的 updateLocations,一个调自己写的 updateProvide 直接赋值。点第一个按钮,两个值都正常更新;点第二个按钮,userLocation 能改动(因为它没被 readonly 包裹),而 readonlyLocation 不会变,控制台同时冒出只读警告。

<script lang="tsx">
import { defineComponent, inject } from 'vue';

export default defineComponent({
  setup() {
    const userLocation: any = inject('location');
    const readonlyLocation: any = inject('readonlyLocation');
    const updateUserLocation: any = inject('updateLocations');

    function updateProvide(): void {
      userLocation.value = '子组件更改provide的值'; // 直接修改来自父组件注入的readers,控制台出现警告
      readonlyLocation.value = '子组件更改provide的值'; // 直接修改来自父组件注入的readers,控制台出现警告
    }

    return () => (
      <div>
        <div onClick={() => updateUserLocation()}>
          调用 provide 提供的方法更改 provide 中的值
        </div>
        这是子组件中使用provide的值:{userLocation.value}
        <br />
        这是子组件中使用provide的值:{readonlyLocation.value}
        <div onClick={() => updateProvide()}>
          调用子组件提供的方法更改 provide 中的值
        </div>
      </div>
    );
  },
});
</script>

保持响应性的坑

这套写法里最容易踩的不是 readonly,而是响应性丢失。上面 provide('location', location) 传的是 ref 本身,所以子组件访问 userLocation.value 时能建立依赖、父组件改值时会重新渲染。但如果写成下面这样,就断掉了:

// 传的是当前值的快照,一个普通字符串,后续变化传不下去
provide('location', location.value);

provide 本身不做任何响应式包装,它只是把你给的东西原样存进组件实例。.value 取出来的是那一刻的原始值,之后 ref 怎么变都和它无关了。所以要保持联动,传下去的必须是 ref、reactive 对象、或者 computed。

如果既想保持响应性又不想让子组件误改,computed 是比 readonly 更轻的选择——computed 返回的 ref 本身就是只读的:

provide(
  'location',
  computed(() => location.value),
);

readonly 是深层的

readonly() 会递归处理嵌套属性,一个多层对象的任意深处都改不了:

const state = reactive({ user: { name: 'wfly' } });
const ro = readonly(state);
ro.user.name = 'x'; // 同样失败并警告

只想锁最外一层、内部还允许改动的话用 shallowReadonly,它只拦截根级属性的写操作。多层对象上用 shallowReadonly 性能也更好,因为不需要为每个嵌套层级都创建代理。

应用层的 provide

如果某个值全应用都要用(比如接口 baseURL、当前主题),不必找一个根组件挂上去,可以直接在 app 实例上 provide:

const app = createApp(App);
app.provide('apiBase', readonly({ url: '/admin-api' }));

这样任何组件都能 inject('apiBase')。要注意的是 provide / inject 沿组件树向上查找,只对后代组件生效——兄弟组件之间传不了。跨组件的共享状态还是交给 Pinia 更合适,provide / inject 更适合做组件库内部的上下文传递,比如 Form 给深层的 FormItem 传校验规则这种场景。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEVue3 provide/inject 依赖注入与 readonly 用法加载中