实现mini-vue -- runtime-core模块(十三)实现provide/inject

404 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第18天,点击查看活动详情

本节我们会来实现provide/inject,有了它我们就可以实现组件跨级传递数据,在父组件的setup中通过provide提供数据,无论多深层级的子组件都可以通过inject的方式获取到数据

实现的思路借鉴了js的原型链的思想,其实从表面上看也能够发现provide/inject和原型链就是挺像的

1. 思考实现方式

首先思考一下如何实现,我们可以先搭建一个应用场景,通过场景去进行思考,场景大致如下:

  • 父组件App中通过provide提供数据
  • 一级子组件Foo通过inject注入直接父组件App中提供的数据

目前先只考虑这种使用provide/inject实现简单的直接父子组件通信的功能,等实现了之后我们再一步步往后扩展

1.1 应用场景准备

首先是父组件App

export const App = {
  name: 'App',
  setup() {
    provide('appKey', 'appValue');
  },
  render() {
    return h('div', {}, [h(Foo)]);
  },
};

然后是Foo子组件

const Foo = {
  name: 'Foo',
  setup() {
    const appValue = inject('appKey');

    return {
      appValue,
    };
  },
  render() {
    return h('div', {}, [
      h('p', {}, 'Foo'),
      h('p', {}, `Foo inject appKey -- ${this.appValue}`),
    ]);
  },
};

1.2 思考需要做什么

首先肯定需要实现provideinject这两个函数,然后可以给组件实例扩展一个provides属性,这样一来就可以在provide中将key/value挂载到组件实例的provides属性上 但是由于provide是独立的函数,没办法直接获取到组件实例,所以我们应当先实现一个getCurrentInstance函数,用于返回当前组件实例 类似的,在子组件调用inject的时候,我们也应该要能够获取到子组件的组件实例,然后通过parent获取到它的父组件实例,并返回父组件实例中provides对象的相应key对应的value即可 这样就可以实现直接父子组件的provide/inject逻辑了,再梳理一下,我们要干一下几件事情:

  1. 实现getCurrentInstance函数
  2. 给组件实例扩展provides属性,该属性是一个对象类型
  3. 给组件实例扩展parent属性,用于获取父组件实例
  4. 创建并实现provide/inject函数

那么接下来就逐一实现吧


provide/injectvue提供的用于父子组件之间跨层级通讯的一个 API,比如说父组件想给直接子组件传值时,可以使用props,但是如果是想给间接子组件传值,则可以使用provide/inject,当然,也有别的方案,但是本节主要是讨论如何实现provide/inject


2. 实现直接父子组件之间的 provide/inject

2.1 组件实例扩展 provides 和 parent 属性

根据前面的梳理,我们先去给组件实例扩展一个provides属性,它是一个对象,相当于一个容器,当调用provides的时候,就会往这里面存入数据,而子组件需要用到的时候,就会到这个容器当中取出数据 其次,还需要加一个parent属性,用于给子组件获取到自己的父组件实例

- export function createComponentInstance(vnode) {
+ export function createComponentInstance(vnode, parent) {
  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
+   provides: {},
+   parent,
  };

  component.emit = emit as any;

  return component;
}

2.2 修复添加 parent 参数的函数调用链关系

现在我们修改了reateComponentInstance函数,添加了一个parent参数,那么之前用到这个函数的地方就需要进行修改,如果你是用vscode的话,按住crtl然后点击createComponentInstance函数,就会找到引用了这个函数的代码,然后跳转过去进行修改,或者将光标移动到该函数的位置,然后按下F12也可以 image.png 目前我们只有renderer.tsmountComponent函数中有用到创建组件实例的函数,现在也给它修改,添加上parent参数

- function mountComponent(initialVNode: any, container) {
+ function mountComponent(initialVNode: any, container, parentComponent) {
  // 根据 vnode 创建组件实例
- const instance = createComponentInstance(initialVNode);
+ const instance = createComponentInstance(initialVNode, parentComponent);

  // setup 组件实例
  setupComponent(instance);
  setupRenderEffect(instance, container);
}

修改了之后发现processComponent函数也报错了,也给它加上

- function processComponent(vnode: any, container: any) {
+ function processComponent(vnode: any, container: any, parentComponent) {
- mountComponent(vnode, container);
+ mountComponent(vnode, container, parentComponent);
}

由于patch中用到了processComponent函数,因此也要修改

- export function patch(vnode, container) {
+ export function patch(vnode, container, parentComponent = null) {
  const { type, shapeFlag } = vnode;

  switch (type) {
    case Fragment:
      processFragment(vnode, container);
      break;
    case Text:
      processText(vnode, container);
      break;

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 真实 DOM
        processElement(vnode, container);
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 处理 component 类型
-       processComponent(vnode, container);
+       processComponent(vnode, container, parentComponent);
      }
      break;
  }
}

小技巧:当前代码中有报错的时候,可以按下**F8**直接跳转 mountChildren中调用了patch,按下F8跳转过去进行修改

- function mountChildren(children: any, container: any, parentComponent) {
+ function mountChildren(children: any, container: any, parentComponent) {
  children.forEach((v) => {
-   patch(v, container);
+   patch(v, container, parentComponent);
  });
}

setupRenderEffect中也是,但是setupRenderEffect中调用patch的时候,subTree的父组件就是instance实例,所以不需要给函数签名扩展parentComponent参数了

function setupRenderEffect(instance, container) {
  const { proxy, vnode } = instance;
  const subTree = instance.render.call(proxy);

  // subTree 可能是 Component 类型也可能是 Element 类型
  // 调用 patch 去处理 subTree
  // Element 类型则直接挂载
- patch(subTree, container);
+ patch(subTree, container, instance);

  // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
  // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
  vnode.el = subTree.el;
}

processFragment中调用了mountChildren,也要修改

- function processFragment(vnode: any, container: any) {
+ function processFragment(vnode: any, container: any, parentComponent) {
-   mountChildren(vnode.children, container);
+   mountChildren(vnode.children, container, parentComponent);
}

mountElement中调用了mountChildren,需要修改

- function mountElement(vnode: any, container: any) {
+ function mountElement(vnode: any, container: any, parentComponent) {
  // 将 DOM 对象挂载到 vnode 上 从而让组件实例能够访问到
  const el = (vnode.el = document.createElement(vnode.type));
  const { children, shapeFlag } = vnode;

  // children
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = children;
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
-  	mountChildren(children, el);
+   mountChildren(children, el, parentComponent);
  }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    const isOn = (key: string) => /^on[A-Z]/.test(key);
    // 处理事件监听
    if (isOn(key)) {
      el.addEventListener(key.slice(2).toLowerCase(), value);
    } else {
      el.setAttribute(key, value);
    }
  }

  container.append(el);
}

patch中调用了processFragment,需要修改

export function patch(vnode, container, parentComponent) {
  const { type, shapeFlag } = vnode;

  switch (type) {
    case Fragment:
-			processFragment(vnode, container);
+     processFragment(vnode, container, parentComponent);
      break;
    case Text:
      processText(vnode, container);
      break;

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 真实 DOM
        processElement(vnode, container);
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 处理 component 类型
        processComponent(vnode, container, parentComponent);
      }
      break;
  }
}

processElement中调用了mountElement,需要修改

- function processElement(vnode: any, container: any) {
+ function processElement(vnode: any, container: any, parentComponent) {
-		mountElement(vnode, container);
+   mountElement(vnode, container, parentComponent);
}

最后是patch中调用了processElement,修改

export function patch(vnode, container, parentComponent) {
  const { type, shapeFlag } = vnode;

  switch (type) {
    case Fragment:
      processFragment(vnode, container, parentComponent);
      break;
    case Text:
      processText(vnode, container);
      break;

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 真实 DOM
-				processElement(vnode, container);
+       processElement(vnode, container, parentComponent);
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 处理 component 类型
        processComponent(vnode, container, parentComponent);
      }
      break;
  }
}

现在就全部修复完毕啦


2.3 检查是否能够获取到父组件实例

我们在createComponentInstance的时候输出一下parentComponent看看是否能够成功在子组件中获取到父组件实例

export function createComponentInstance(vnode, parent) {
+  console.log('createComponentInstance -- parent: ', parent);

  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    provides: {},
    parent,
  };

  component.emit = emit as any;

  return component;
}

image.png 可以看到确实是正确获取到父组件实例了,第一个输出是App根组件实例的,因为是根组件,所以父组件自然为null,而第二个则是Foo组件的父组件,为App组件


2.4 创建并实现 provide/inject

现在的组件实例中已经有providesparent属性了,那也就意味着我们可以开始实现provide/inject了 首先创建src/runtime-core/apiInject.ts,并创建两个函数

import { getCurrentInstance } from './component';

export function provide(key, value) {
  const currentInstance: any = getCurrentInstance();
  if (currentInstance) {
    const { provides } = currentInstance;
    provides[key] = value;
  }
}

export function inject(key) {
  const currentInstance: any = getCurrentInstance();
  if (currentInstance) {
    const parentProvides = currentInstance.parent.provides;
    return parentProvides[key];
  }
}

目前由于只需要考虑直接父子组件之间的provide/inject实现,所以inject中只会到父组件的provides中去取值,先看看效果如何 image.png 可以看到成功了!


3. 实现跨级父子组件之间的 provide/inject

3.1 简单实现

现在我们可以适当升级一下需求,我们给Foo添加一个子组件Bar,并且希望在Bar中能够通过inject获取到App组件提供的数据

const Bar = {
  name: 'Bar',
  setup() {
    const appValue = inject('appKey');

    return {
      appValue,
    };
  },
  render() {
    return h('div', {}, [
      h('p', {}, 'Bar'),
      h('p', {}, `Bar inject appKey -- ${this.appValue}`),
    ]);
  },
};

Foo组件中使用Bar组件

const Foo = {
  name: 'Foo',
  setup() {
    const appValue = inject('appKey');

    return {
      appValue,
    };
  },
  render() {
    return h('div', {}, [
      h('p', {}, 'Foo'),
      h('p', {}, `Foo inject appKey -- ${this.appValue}`),
+     h(Bar),
    ]);
  },
};

image.png 可以看到现在是undefined,基于当前的场景,我们可以修改组件实例的provides初始值,让其指向父组件实例的provides,这样一来Foo组件的provides就是对App组件的provides的引用,那么Bar组件寻找父组件的provides的时候,获取到的就是对App组件实例的provides

export function createComponentInstance(vnode, parent) {
  console.log('createComponentInstance -- parent: ', parent);

  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
-		provides: {},
+   provides: parent ? parent.provides : {},
    parent,
  };

  component.emit = emit as any;

  return component;
}

image.png 这样一来就可以实现跨级父子组件之间的provide/inject


3.2 通过原型链的方式优化

上面这种情况只适用于组件的setup中没有调用provide提供数据的情况,这时组件实例的provides指向父组件的provides,能够正常工作,但是如果组件自身有通过provide提供数据的话,由于其provides是对父组件的provides的引用,因此提供的数据会被添加到父组件的provides中 可以通过下面这个小案例看看目前存在的问题

const Foo = {
  name: 'Foo',
  setup() {
+   // 提供一个和父组件 App 中提供过的同名数据 检查是否会覆盖父组件中提供的 appKey
+   provide('appKey', 'fooValue');
    const appValue = inject('appKey');

    return {
      appValue,
    };
  },
  render() {
    return h('div', {}, [
      h('p', {}, 'Foo'),
      h('p', {}, `Foo inject appKey -- ${this.appValue}`),
      h(Bar),
    ]);
  },
};

现在整个组件的层级结构是App是根组件,Foo是一级子组件,Bar是二级子组件 App中通过provide提供了一个appKey的数据,值为appValue Foo中也通过provide提供了一个appKey的数据,但是值为fooValue,并且Foo自身注入了名为appKey的数据,那么其值应当为它的父组件App中的appValue Bar中通过inject注入了名为appKey的数据,由于它的直接父组件Foo中有提供这个数据,所以值应当为fooValue 以上是正常情况下provide/inject的情况,但是现在我们看看效果,真的是这样吗? image.png 可以看到并不是,Foo中的appKey的值应当为appValue,但是现在却变成了fooValue,说明它提供appKey这个数据的时候,覆盖了父组件App中提供的appKey数据 而Bar组件中注入的appKey的值虽然是fooValue,和我们预期的一样,但它其实并不是从Fooprovides中获取的,而是从Appprovides中获取的,事实上Foo并没有自己的provides对象 所以这就是问题的根源所在了!每个组件实例都应该有自己的provides对象,而不是简单地设置为一个对父组件provides的引用就当完事儿了 然后组件通过inject注入数据其实就是一个往父组件中找值的过程,这个过程和js的原型链十分像,父组件中有则直接返回该值,如果没有,则继续往父组件的父组件中去找,直到找到null都找不到,则说明没有提供过该数据 既然如此,那么我们就可以通过原型链的方式实现正确的provide/inject逻辑 可以在provide中让当前组件实例的provides对象指向一个空对象,并且这个空对象的原型要指向父组件实例的provides对象,这个需求正好能够通过Object.create实现

export function provide(key, value) {
  const currentInstance: any = getCurrentInstance();
  if (currentInstance) {
    const { provides } = currentInstance;
+   // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
+   currentInstance.provides = Object.create(currentInstance.parent.provides);
    provides[key] = value;
  }
}

export function inject(key) {
  const currentInstance: any = getCurrentInstance();
  if (currentInstance) {
    const parentProvides = currentInstance.parent.provides;
    return parentProvides[key];
  }
}

但是问题就是不能每次调用provide都将当前组件实例的provides变为空对象,这样会导致之前提供的数据被清空,这个置为空对象的逻辑更像是一个初始化的逻辑,那么我们就要思考一下什么时候才要初始化呢? 初始化,也就意味着是首次使用provide,此时组件实例的provides是初始化为父组件实例的provides的,那么我们就可以判断一下了,如果自己的provides和父组件实例的provides是同一个对象的话,说明需要进行初始化了

export function provide(key, value) {
  const currentInstance: any = getCurrentInstance();

  if (currentInstance) {
    let { provides } = currentInstance;
+   const parentProvides = currentInstance.parent?.provides;

-   // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
-   currentInstance.provides = Object.create(currentInstance.parent.provides);

+   if (provides === parentProvides) {
+     // 让当前组件实例的 provides 指向一个空对象 并且该空对象以父组件的 provides 为原型
+     provides = currentInstance.provides = Object.create(parentProvides);
+   }

    provides[key] = value;
  }
}

image.png 现在的结果就是正确的了


4. 扩展 inject 支持默认值

我们还可以扩展inject默认值,当父级组件没有提供需要的数据的时候,就使用提供的默认值,默认值可以是普通值,也可以是一个函数

export function inject(key, defaultValue) {
  const currentInstance: any = getCurrentInstance();

  if (currentInstance) {
    const parentProvides = currentInstance.parent.provides;

    if (!(key in parentProvides)) {
      if (typeof defaultValue === 'function') {
        return defaultValue();
      }
      return defaultValue;
    }

    return parentProvides[key];
  }
}

然后修改一下Bar组件,注入两个父组件没有提供过的数据

const Bar = {
  name: 'Bar',
  setup() {
    const appValue = inject('appKey');
+   const normalValue = inject('normalKey', 'normalValue');
+   const functionValue = inject('functionKey', () => 'functionValue');

    return {
      appValue,
+     normalValue,
+     functionValue,
    };
  },
  render() {
    return h('div', {}, [
      h('p', {}, 'Bar'),
      h('p', {}, `Bar inject appKey -- ${this.appValue}`),
+     h(
+       'p',
+       {},
+       `Bar inject normalKey that not provide in parent -- ${this.normalValue}`
+     ),
+     h(
+       'p',
+       {},
+       `Bar inject functionKey that not provide in parent -- ${this.functionValue}`
+     ),
    ]);
  },
};

image.png 可以看到默认值生效了,无论是普通值还是函数返回的默认值都可以