渲染器:数据访问是如何被代理的?
我们先看一个有意思的示例
前言
组件上有一个动态文本节点 []{.mark},但是却有 [2]{.mark} 处定义了 [msg]{.mark} 响应式数据;另外有一个按钮,点击后会修改响应式数据。
<template>
<p></p>
<button @click="changeMsg">点击试试</button>
</template>
<script>
import { ref } from 'vue'
export default {
data() {
return {
msg: 'msg from data'
}
},
setup() {
const msg = ref('msg from setup')
return {
msg
}
},
methods: {
changeMsg() {
this.msg = 'change'
}
}
}
</script>
思考一下:
界面显示的内容是什么?
点击按钮后,修改的是哪部分的数据?是
data 中定义的,还是
setup 中的呢?
先别急着找答案,相信你阅读完这一节,一定会得到答案。
上一节,我们知道了根组件在初始化渲染的过程中,会执行 [mountComponent]{.mark} 的函数:
function mountComponent(initialVNode, container,
parentComponent) {
// 1. 先创建一个 component instance
const instance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent
));
// 2. 初始化组件实例
setupComponent(instance);
// 3. 设置并运行带副作用的渲染函数
setupRenderEffect(instance, initialVNode, container);
}
上文,我们简单介绍了关于 [setupComponent]{.mark} 函数的作用是为了对实例化后的组件中的属性做一些优化、处理、赋值等操作。本小节我们将重点介绍 [setupComponent]{.mark} 的内部实现和作用。
初始化组件实例
我们再来回顾一下 [setupComponent]{.mark} 在源码中的实现:
export function setupComponent(instance, isSSR = false) {
const { props, children } = instance.vnode
// 判断组件是否是有状态的组件
const isStateful = isStatefulComponent(instance)
// 初始化 props
initProps(instance, props, isStateful, isSSR)
// 初始化 slots
initSlots(instance, children)
// 如果是有状态组件,那么去设置有状态组件实例
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
return setupResult
}
[setupComponent]{.mark} 方法做了什么?
通过
isStatefulComponent(instance) 判断是否是有状态的组件;
initProps 初始化
props;
initSlots 初始化
slots;
根据组件是否是有状态的,来决定是否需要执行
setupStatefulComponent 函数。
其中, [isStatefulComponent]{.mark} 判断是否是有状态的组件的函数如下:
function isStatefulComponent(instance) {
return instance.vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
}
前面我们已经说过了,[ShapeFlags]{.mark} 在遇到组件类型的 [type =
Object]{.mark} 时,[vnode]{.mark} 的[shapeFlags =
ShapeFlags.STATEFUL_COMPONENT]{.mark}。所以这里会执行 [setupStatefulComponent]{.mark} 函数。
function setupStatefulComponent(instance, isSSR) {
// 定义 Component 变量
const Component = instance.type
// 1. 创建渲染代理的属性访问缓存
instance.accessCache = Object.create(null)
// 2. 创建渲染上下文代理, proxy 对象其实是代理了 instance.ctx 对象
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers);
// 3. 执行 setup 函数
const { setup } = Component
if (setup) {
// 如果 setup 函数带参数,则创建一个 setupContext
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 执行 setup 函数,获取结果
const setupResult = callWithErrorHandling(setup, instance, 0,
[instance.props, setupContext])
// 处理 setup 执行结果
handleSetupResult(instance, setupResult)
} else {
// 4. 完成组件实例设置
finishComponentSetup(instance, isSSR)
}
}
[setupStatefulComponent]{.mark} 字面意思就是设置有状态组件,那么什么是有状态组件呢?简单而言,就是对于有状态组件,[Vue]{.mark} 内部会保留组件状态数据。相对于有状态组件而言,[Vue]{.mark} 还存在一种函数组件 [FUNCTIONAL_COMPONENT]{.mark},一起看个示例:
import { ref } from 'vue';
export default () => {
let num = ref(0);
const plusNum = () => {
num.value ++;
};
return (
<div>
<button onClick={plusNum}>
{ num.value }
</button>
</div>
)
}
这个函数点击按钮时,[num]{.mark} 的值并不会按照我们预期那样值会一直递增,因为它是一个函数组件,函数组件内部是没有状态保持的,所以 [num]{.mark} 数据更新时,组件会重新渲染,[num]{.mark} 的值永远不变一直是 [0]{.mark}。
所以在这个时候,为了能符合我们预期的结果,我们需要将其设置成有状态的组件。我们可以通过 [defineComponent]{.mark} 函数包装一下:
import { ref, defineComponent } from 'vue';
export default defineComponent(() => {
let num = ref(0);
const plusNum = () => {
num.value ++;
};
return () => (
<div>
<button onClick={plusNum}>
{ num.value }
</button>
</div>
)
});
[defineComponent]{.mark} 返回的是个对象类型的 [type]{.mark},所以就变成了有状态组件。
好了,搞清楚什么是有状态组件后,我们接着回到 [setupStatefulComponent]{.mark} 实现中,来一步步地分析其核心实现的原理。
创建渲染上下文代理
首先我们看 [1-2]{.mark} 两个步骤,关于第一点:为什么要创建渲染代理的属性访问缓存呢?这里先卖个关子,先看第二步:创建渲染上下文代理,这里为什么要对 [instance.ctx]{.mark} 做代理呢?如果熟悉 [Vue
2]{.mark} 的小伙伴应该了解对于 [Vue 2]{.mark} 的 [Options
API]{.mark} 的写法如下:
<template>
<p></p>
</template>
<script>
export default {
data() {
num: 1
},
mounted() {
this.num = 2
}
}
</script>
[Vue
2.x]{.mark} 是如何实现访问 [this.num]{.mark} 获取到 [num]{.mark} 的值,而不是通过 [this._data.num]{.mark} 来获取 [num]{.mark} 的值呢?其实 [Vue
2.x]{.mark} 版本中,为 [_data]{.mark} 设置了一层代理:
_proxy(options.data);
function _proxy (data) {
const that = this;
Object.keys(data).forEach(key => {
Object.defineProperty(that, key, {
configurable: true,
enumerable: true,
get: function proxyGetter () {
return that._data[key];
},
set: function proxySetter (val) {
that._data[key] = val;
}
})
});
}
本质就是通过 [Object.defineProperty]{.mark} 使在访问 [this]{.mark} 上的某属性时从 [this._data]{.mark} 中读取(写入)。
而 [Vue 3]{.mark} 也在这里做了类似的事情,[Vue
3]{.mark} 内部有很多状态属性,存储在不同的对象上,比如 [setupState]{.mark}、[ctx]{.mark}、[data]{.mark}、[props]{.mark}。这样用户取数据就会考虑具体从哪个对象中获取,这无疑增加了用户的使用负担,所以对 [instance.ctx]{.mark} 进行代理,然后根据属性优先级关系依次完成从特定对象上获取值。
get
了解了代理的功能后,我们来具体看一下是如何实现代理功能的,也就是 [proxy]{.mark} 的 [PublicInstanceProxyHandlers]{.mark} 它的实现。先看一下 [get]{.mark} 函数:
export const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
const { ctx, setupState, data, props, accessCache, type, appContext } =
instance
let normalizedProps
if (key[0] !== '$') {
// 从缓存中获取当前 key 存在于哪个属性中
const n = accessCache![key]
if (n !== undefined) {
switch (n) {
case AccessTypes.SETUP:
return setupState[key]
case AccessTypes.DATA:
return data[key]
case AccessTypes.CONTEXT:
return ctx[key]
case AccessTypes.PROPS:
return props![key]
}
} else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
// 从 setupState 中取
accessCache![key] = AccessTypes.SETUP
return setupState[key]
} else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
// 从 data 中取
accessCache![key] = AccessTypes.DATA
return data[key]
} else if (
(normalizedProps = instance.propsOptions[0]) &&
hasOwn(normalizedProps, key)
) {
// 从 props 中取
accessCache![key] = AccessTypes.PROPS
return props![key]
} else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
// 从 ctx 中取
accessCache![key] = AccessTypes.CONTEXT
return ctx[key]
} else if (!__FEATURE_OPTIONS_API__ || shouldCacheAccess) {
// 都取不到
accessCache![key] = AccessTypes.OTHER
}
}
const publicGetter = publicPropertiesMap[key]
let cssModule, globalProperties
if (publicGetter) {
// 以 $ 保留字开头的相关函数和方法
// ...
} else if (
// css module
(cssModule = type.__cssModules) && (cssModule = cssModule[key])
) {
// ...
} else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
// ...
} else if (
// 全局属性
((globalProperties = appContext.config.globalProperties),
hasOwn(globalProperties, key))
) {
// ...
} else if (__DEV__) {
// 一些告警
// ...
}
}
}
这里,可以回答我们的第一步 [创建渲染代理的属性访问缓存]{.mark} 这个步骤的问题了。如果我们知道 [key]{.mark} 存在于哪个对象上,那么就可以直接通过对象取值的操作获取属性上的值了。如果我们不知道用户访问的 [key]{.mark} 存在于哪个属性上,那只能通过 [hasOwn]{.mark} 的方法先判断存在于哪个属性上,再通过对象取值的操作获取属性值,这无疑是多操作了一步,而且这个判断是比较耗费性能的。如果遇到大量渲染取值的操作,那么这块就是个性能瓶颈,所以这里用了 [accessCache]{.mark} 来标记缓存 [key]{.mark} 存在于哪个属性上。这其实也相当于用一部分空间换时间的优化。
接下来,函数首先判断 [key[0] !==
'$']{.mark} 的情况([$]{.mark} 开头的一般是 [Vue]{.mark} 组件实例上的内置属性),在 [Vue
3]{.mark} 源码中,会依次从 [setupState、data、props、ctx]{.mark} 这几类数据中取状态值。
这里的定义顺序,决定了后续取值的优先级顺序:[setupState]{.mark} >[data]{.mark} >[props]{.mark} > [ctx]{.mark}。
如果 [key]{.mark} 是以 [$]{.mark} 开头,则首先会判断是否是存在于组件实例上的内置属性:
整体的获取顺序依次是:[publicGetter]{.mark} > [cssModule]{.mark} > [ctx]{.mark}。最后,如果都取不到,那么在开发环境就会给一些告警提示。
set
接着继续看一下设置对象属性的代理函数:
export const PublicInstanceProxyHandlers = {
set({ _: instance }, key, value) {
const { data, setupState, ctx } = instance
if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
// 设置 setupState
setupState[key] = value
return true
} else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
// 设置 data
data[key] = value
return true
} else if (hasOwn(instance.props, key)) {
// 不能给 props 赋值
return false
}
if (key[0] === '$' && key.slice(1) in instance) {
// 不能给组件实例上的内置属性赋值
return false
} else {
// 用户自定义数据赋值
ctx[key] = value
}
return true
}
}
可以看到这里也是和前面 [get]{.mark} 函数类似的通过调用顺序来实现对 [set]{.mark} 函数不同属性设置优先级的,可以直观地看到优先级关系为:[setupState]{.mark} > [data]{.mark} > [props]{.mark}。同时这里也有说明:就是如果直接对 [props]{.mark} 或者组件实例上的内置属性赋值,则会告警。
has
最后,再看一个 [proxy]{.mark} 属性 [has]{.mark} 的实现:
export const PublicInstanceProxyHandlers = {
has({_: { data, setupState, accessCache, ctx, appContext, propsOptions
}}, key) {
let normalizedProps
return (
!!accessCache![key] ||
(data !== EMPTY_OBJ && hasOwn(data, key)) ||
(setupState !== EMPTY_OBJ && hasOwn(setupState, key)) ||
((normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key))
||
hasOwn(ctx, key) ||
hasOwn(publicPropertiesMap, key) ||
hasOwn(appContext.config.globalProperties, key)
)
},
}
这个函数则是依次判断 [key]{.mark} 是否存在于 [accessCache]{.mark} > [data]{.mark} > [setupState]{.mark} > [prop]{.mark} > [ctx]{.mark} > [publicPropertiesMap]{.mark} > [globalProperties]{.mark},然后返回结果。
[has]{.mark} 在业务代码的使用定义如下:
export default {
created () {
// 这里会触发 has 函数
console.log('msg' in this)
}
}
至此,我们就搞清楚了创建上下文代理的过程。
调用执行 setup 函数
一个简单的包含 [CompositionAPI]{.mark} 的 [Vue 3 demo]{.mark} 如下:
<template>
<p></p>
</template>
<script>
export default {
props: {
msg: String
},
setup (props, setupContext) {
// todo
}
}
</script>
这里的 [setup]{.mark} 函数,正是在这里被调用执行的:
// 获取 setup 函数
const { setup } = Component
// 存在 setup 函数
if (setup) {
// 根据 setup 函数的入参长度,判断是否需要创建 setupContext 对象
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 调用 setup
const setupResult = callWithErrorHandling(setup, instance, 0,
[instance.props, setupContext])
// 处理 setup 执行结果
handleSetupResult(instance, setupResult)
}
createSetupContext
因为 [setupContext]{.mark} 是 [setup]{.mark} 中的第二个参数,所以会判断 [setup]{.mark} 函数参数的长度,如果大于 [1]{.mark},则会通过 [createSetupContext]{.mark} 函数创建 [setupContext]{.mark} 上下文。
该上下文创建如下:
function createSetupContext (instance) {
return {
get attrs() {
return attrs || (attrs = createAttrsProxy(instance))
},
slots: instance.slots,
emit: instance.emit,
expose
}
}
可以看到,[setupContext]{.mark} 中包含了 [attrs、slots、emit、expose]{.mark} 这些属性。这些属性分别代表着:组件的属性、插槽、派发事件的方法 [emit]{.mark}、以及所有想从当前组件实例导出的内容 [expose]{.mark}。
这里有个小的知识点,就是可以通过函数的 [length]{.mark} 属性来判断函数参数的个数:
function foo() {};
foo.length // 0
function bar(a) {};
bar.length // 1
callWithErrorHandling
第二步,通过 [callWithErrorHandling]{.mark} 函数来间接执行 [setup]{.mark} 函数,其实就是执行了以下代码:
const setupResult = setup &&
setup(shallowReadonly(instance.props), setupContext);
只不过增加了对执行过程中 [handleError]{.mark} 的捕获。
在后续章节的阅读中,你会发现 [Vue
3]{.mark} 很多函数的调用都是通过 [callWithErrorHandling]{.mark} 来包裹的:
export function callWithErrorHandling(fn, instance, type, args
= []) {
let res
try {
res = args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
return res
}
这样的好处一方面可以由 [Vue]{.mark} 内部统一 [try...catch]{.mark} 处理用户代码运行可能出现的错误。另一方面这些错误也可以交由用户统一注册的 [errorHandler]{.mark} 进行处理,比如上报给监控系统。
handleSetupResult
最后执行 [handleSetupResult]{.mark} 函数:
function handleSetupResult(instance, setupResult) {
if (isFunction(setupResult)) {
// setup 返回渲染函数
instance.render = setupResult
}
else if (isObject(setupResult)) {
// proxyRefs 的作用就是把 setupResult 对象做一层代理
instance.setupState = proxyRefs(setupResult);
}
finishComponentSetup(instance)
}
[setup]{.mark} 返回值不一样的话,会有不同的处理,如果 [setupResult]{.mark} 是个函数,那么会把该函数绑定到 [render]{.mark} 上。比如:
<script>
import { createVnode } from 'vue'
export default {
props: {
msg: String
},
setup (props, { emit }) {
return (ctx) => {
return [
createVnode('p', null, ctx.msg)
]
}
}
}
</script>
当 [setupResult]{.mark} 是一个对象的时候,我们为 [setupResult]{.mark} 对象通过 [proxyRefs]{.mark} 作了一层代理,方便用户直接访问 [ref]{.mark} 类型的值。比如,在模板中访问 [setupResult]{.mark} 中的数据,就可以省略 [.value]{.mark} 的取值,而由代理来默认取 [.value]{.mark} 的值。
注意,这里 [instance.setupState = proxyRefs(setupResult);]{.mark} 之前的
Vue 源码的写法是 [instance.setupState =
reactive(setupResult);]{.mark} ,至于为什么改成上面的,Vue
作者也有相关说明:[Template auto ref unwrapping for setup() return
object is now applied only to the root level refs.]{.mark}
完成组件实例设置
最后,到了 [finishComponentSetup]{.mark} 这个函数了:
function finishComponentSetup(instance) {
// type 是个组件对象
const Component = instance.type;
if (!instance.render) {
// 如果组件没有 render 函数,那么就需要把 template 编译成 render 函数
if (compile && !Component.render) {
if (Component.template) {
// 这里就是 runtime 模块和 compile 模块结合点
// 运行时编译
Component.render = compile(Component.template, {
isCustomElement: instance.appContext.config.isCustomElement || NO
})
}
}
instance.render = Component.render;
}
if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) {
// 兼容选项式组件的调用逻辑
}
}
这里主要做的就是根据 [instance]{.mark} 上有没有 [render]{.mark} 函数来判断是否需要进行运行时渲染,运行时渲染指的是在浏览器运行的过程中,动态编译 [<template>]{.mark} 标签内的内容,产出渲染函数。对于编译时渲染,则是有渲染函数的,因为模板中的内容会被 [webpack]{.mark} 中 [vue-loader]{.mark} 这样的插件进行编译。
另外需要注意的,这里有个 [__FEATURE_OPTIONS_API__]{.mark} 变量用来标记是否是兼容 [选项式
API]{.mark} 调用,如果我们只使用 [Composition
Api]{.mark} 那么就可以通过 [webpack]{.mark} 静态变量注入的方式关闭此特性。然后交由 [Tree-Shacking]{.mark} 删除无用的代码,从而减少引用代码包的体积。
总结
有了上面的一些介绍,我们再来回答一下开篇中提到的问题:
初始化渲染的时候,会从实例上获取状态
msg 的值,获取的优先级是:
setupState >
data >
props >
ctx。
setupState 就是
setup 函数执行后返回的状态值,所以这里渲染的是:
msg from setup。
点击按钮的时候,会更新实例上的状态,更新的优先级是:
setupState >
data。所以会更新
setup 中的状态数据
msg。