渲染器:数据访问是如何被代理的?

渲染器:数据访问是如何被代理的?

我们先看一个有意思的示例

前言

组件上有一个动态文本节点 []{.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} 开头,则首先会判断是否是存在于组件实例上的内置属性:

截图.png

整体的获取顺序依次是:[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。

# 推荐文章
  1.卡片翻动效果
  2.每日进步:一篇文章带你走进3D的世界
  3.每日进步:动画的暂停与恢复
  4.每日进步:大整数相加
  5.响应式原理:Vue 3 的 nextTick ?

:D 舔狗日记获取中...