实现mini-vue -- runtime-core模块(二)render函数中通过this访问setupState与$el

614 阅读5分钟

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

本节会介绍如何在render函数中访问setupState以及$el

setupState指的就是setup函数返回的对象,现在我们要实现的就是能够在render函数中通过this访问到setupState,这是vue3中常用到的特性

this.$el则可以获取到组件挂载到的目标DOM元素对象,掌握了本章的内容后,读者们可以自行扩展this.$dataOptions API的特性,原理都是类似的

1. render函数中访问setupState

1.1 案例场景

我们修改一下我们的hello world,在render函数中试图通过this.msg访问到setup函数返回的内容

import { h } from '../../lib/plasticine-mini-vue.esm.js';

export const App = {
  // 由于还没有实现模板编译的功能 因此先用 render 函数来替代
  render() {
    return h(
      'div',
      {
        class: ['cyan', 'success'],
      },
      [
        h('p', { class: 'cyan' }, 'hi '),
        h('p', { class: 'darkcyan' }, 'plasticine '),
        h('p', { class: 'darkviolet' }, 'mini-vue!'),
        h('p', { class: 'darkcyan' }, `setupState msg: ${this.msg}`),
      ]
    );
  },
  setup() {
    // Composition API
    return {
      msg: 'plasticine-mini-vue',
    };
  },
};

目前是访问不到的,结果会是undefined,因为目前render函数中的this是指向组件实例instance的,这是jsthis隐式绑定的特性导致的,如果想在render中通过this访问到setup返回的对象,也就是setupState,那么就需要进行this显式绑定,那么应该在哪里进行这个操作呢?

我们先回忆以下setupState被放在哪里了,是在component.tshandleSetupResult中将setupResult作为组件实例的setupState挂载了

function handleSetupResult(instance, setupResult: any) {
  // TODO 处理 setupResult 是 function 的情况
  if (typeof setupResult === 'object') {
    instance.setupState = setupResult;
  }

  finishComponentSetup(instance);
}

既然如此,那我们就可以在setupRenderEffect中通过组件实例访问到挂载的setupState,然后进行显式绑定

function setupRenderEffect(instance, container) {
- const subTree = instance.render();
+ const { setupState } = instance;
+ const subTree = instance.render.call(setupState);

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

现在就能够在render函数中通过this访问setup返回的对象了 image.png

1.2 优化可扩展性

我认为目前这样做不利于后续扩展,大家都知道,vue3是仍然支持Options API的,因此在render中除了能够通过this访问到setupState,还能够以this.$el访问组件的根元素DOM对象,如果我们只是这样简单地显式绑定thissetupState上,是无法实现this.$el的效果的,因此可以考虑使用代理对象的方式去处理

这个代理对象的代理目标是一个空对象,它起到一个上下文的作用,多个函数都可以通过组件实例上的代理对象访问一些数据,一般将它命名为ctx

// src/runtime-core/component.ts
function setupStatefulComponent(instance: any) {
  const Component = instance.type;
  const { setup } = Component;

  // ctx -- context
+ instance.proxy = new Proxy(
+   {},
+   {
+     get(target, key) {
+       const { setupState } = instance;
+       if (key in setupState) {
+         return setupState[key];
+       }
+     },
+   }
+ );

  if (setup) {
    const setupResult = setup();

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

代理对象创建好后,我们修改调用组件实例的render函数的地方,显式绑定this到这个代理对象上

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

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

之后想要实现this.$el等特性的时候就可以在代理对象中实现了


2. 实现this.$el

this.$elOptions API的功能,vue3为了保证vue2项目也能够正常运行,因此仍然会支持以前的API,那么你有好奇过this.$el是如何实现的吗?

首先要明确,this.$el是可以访问到组件的DOM根节点的,也就是说组件挂载在哪个DOM上,this.$el就能够获取到对应的DOM对象,而DOM的创建是在mountElement的时候创建的,如果我们想在组件实例中获取到它,就应该将它挂载到组件实例的vnode

既然已经知道现在要将DOM对象挂载到vnode上了,那么我们就马上找到创建el的地方

// src/runtime-core/renderer.ts
function mountElement(vnode: any, container: any) {
- const el = document.createElement(vnode.type);
+ const el = (vnode.el = document.createElement(vnode.type));
  const { children } = vnode;

  if (typeof children === 'string') {
    el.textContent = children;
  } else if (Array.isArray(children)) {
    mountChildren(children, el);
  }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    el.setAttribute(key, value);
  }

  container.append(el);
}

由于给vnode新增了一个el属性,因此我们还需要修改创建vnode的函数,给el赋予一个初始值,尽管不赋予初始值也不影响功能的实现,但是这能让读代码的人一眼就知道vnode应当包含哪些属性

export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
+   el: null,
  };

  return vnode;
}

然后再修改上下文对象的代理对象,当访问的key$el的时候,返回vnode.el出去

function setupStatefulComponent(instance: any) {
  const Component = instance.type;
  const { setup } = Component;

  // ctx -- context
  instance.proxy = new Proxy(
    {},
    {
      get(target, key) {
        const { setupState, vnode } = instance;
+       if (key === '$el') {
+         return vnode.el;
+       }
        if (key in setupState) {
          return setupState[key];
        }
      },
    }
  );

  if (setup) {
    const setupResult = setup();

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

但是要注意,刚才我们给vnode添加el属性的时候,是在mountElement中添加的,也就是说el是在Element类型虚拟DOM上的,而代理对象又是对于Component类型vnode设置的,因此肯定是无法访问到的

那么我们就应该想办法将Elementvnode.elComponentvnode.el关联起来,在setupRenderEffect中调用组件实例的render方法会得到子树vnode,这个子树vnode就是Elementvnode

patch完子树vnode后,就已经调用完mountElement,也就意味着subTree.el已经初始化完毕了,此时就可以将subTree.el赋值给组件实例的vnode.el即可

// src/runtime-core/renderer.ts
function setupRenderEffect(instance, container) {
  const { proxy, vnode } = instance;
  const subTree = instance.render.call(proxy);

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

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

现在可以进行测试了,由于我们还没有实现事件机制,不然就能够通过一个按钮绑定点击事件,点击的时候在控制台输出this.$el来查看是否成功了,不过也没关系,我们可以在App.js中给window对象挂载一个self属性,并且在render函数中将window.self指向this,然后在控制台中验证this.$el是否能够访问到DOM根节点

+ window.self = null;
export const App = {
  // 由于还没有实现模板编译的功能 因此先用 render 函数来替代
  render() {
+   window.self = this;
    return h(
      'div',
      {
        class: ['cyan', 'success'],
      },
      [
        h('p', { class: 'cyan' }, 'hi '),
        h('p', { class: 'darkcyan' }, 'plasticine '),
        h('p', { class: 'darkviolet' }, 'mini-vue!'),
        h('p', { class: 'darkcyan' }, `setupState msg: ${this.msg}`),
      ]
    );
  },
  setup() {
    // Composition API
    return {
      msg: 'plasticine-mini-vue',
    };
  },
};

image.png 可以看到确实是实现了!


3. 重构this.$el

类似之前reactivity模块中抽离proxybaseHandlers一样,现在我们可以将上下文对象的代理对象的handlers也抽离一下,新建一个src/runtime-core/componentPublicInstance.ts文件,并将原本的handlers剪切到这里面

// src/runtime-core/componentPublicInstance.ts
export const PublicInstanceProxyHandlers = {
  get(target, key) {
    const { setupState, vnode } = instance;
    if (key === '$el') {
      return vnode.el;
    }

    if (key in setupState) {
      return setupState[key];
    }
  },
};

会发现在get拦截中无法访问到instance,那么我们可以将instance传给上下文对象

function setupStatefulComponent(instance: any) {
  const Component = instance.type;
  const { setup } = Component;

  // ctx -- context
-  instance.proxy = new Proxy({}, {
-    get(target, key) {
-      const { setupState, vnode } = instance;
-      const publicGetter = publicPropertiesMap[key];
-
-      if (publicGetter) {
-        return publicGetter(instance);
-      }
-
-      if (key in setupState) {
-        return setupState[key];
-      }
-    },
-  });
+  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);

  if (setup) {
    const setupResult = setup();

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

instance作为上下文对象的_属性,这样一来抽离出去的PublicInstanceProxyHandlers就能够在别的文件中访问到instance了,这也是上下文对象存在的意义以及为什么叫做“上下文”

export const PublicInstanceProxyHandlers = {
-  get(target, key) {
+  get({ _: instance }, key) {
    const { setupState, vnode } = instance;
    if (key === '$el') {
      return vnode.el;
    }

    if (key in setupState) {
      return setupState[key];
    }
  },
};

这里对key的判断我认为也能够重构,因为可能还会实现this.$datathis.$propsOptions API的组件实例特性,如果全都这样判断那代码会很长,并且每新增一个API都要去修改PublicInstanceProxyHandlersget拦截代码,不利于维护

这里可以考虑使用一个对象建立一个映射

+ const publicPropertiesMap = {
+   $el: (i) => i.vnode.el,
+ };

export const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, vnode } = instance;
-    if (key === '$el') {
-      return vnode.el;
-    }
+    const publicGetter = publicPropertiesMap[key];
+
+    if (publicGetter) {
+      return publicGetter(instance);
+    }

    if (key in setupState) {
      return setupState[key];
    }
  },
};

这样一来比如要新增$data的实现,就只需要在publicPropertiesMap中添加即可,不需要去修改PublicInstanceProxyHandlers了,这也是vue3源码中的做法 image.png