持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情
本节会介绍如何在render函数中访问setupState以及$el
setupState
指的就是setup
函数返回的对象,现在我们要实现的就是能够在render
函数中通过this
访问到setupState
,这是vue3
中常用到的特性
而this.$el
则可以获取到组件挂载到的目标DOM
元素对象,掌握了本章的内容后,读者们可以自行扩展this.$data
等Options 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
的,这是js
的this
隐式绑定的特性导致的,如果想在render
中通过this
访问到setup
返回的对象,也就是setupState
,那么就需要进行this
显式绑定,那么应该在哪里进行这个操作呢?
我们先回忆以下setupState
被放在哪里了,是在component.ts
的handleSetupResult
中将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
返回的对象了
1.2 优化可扩展性
我认为目前这样做不利于后续扩展,大家都知道,vue3
是仍然支持Options API
的,因此在render
中除了能够通过this
访问到setupState
,还能够以this.$el
访问组件的根元素DOM
对象,如果我们只是这样简单地显式绑定this
到setupState
上,是无法实现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.$el
是Options 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
设置的,因此肯定是无法访问到的
那么我们就应该想办法将Element
的vnode.el
和Component
的vnode.el
关联起来,在setupRenderEffect
中调用组件实例的render
方法会得到子树vnode
,这个子树vnode
就是Element
的vnode
在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',
};
},
};
可以看到确实是实现了!
3. 重构this.$el
类似之前reactivity
模块中抽离proxy
的baseHandlers
一样,现在我们可以将上下文对象的代理对象的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.$data
、this.$props
等Options API
的组件实例特性,如果全都这样判断那代码会很长,并且每新增一个API
都要去修改PublicInstanceProxyHandlers
的get
拦截代码,不利于维护
这里可以考虑使用一个对象建立一个映射
+ 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
源码中的做法