23.Vue Vapor 组件 attrs 的实现

前言

在 Vue3 中,没有定义在组件的 props 选项中的 props 数据会被存储到 attrs 对象中。例如我们之前使用到的子组件的 props 配置项里面只声明了一个 count,这样只要用户在父组件传递了其他 props,那么这些 props 就应该出现在 attrs 对象中。

例如在父组件中传递一个 name 和 count 的 props:

javascript 复制代码
const n1 = createComponent(ChildComponent, {
  name: () => '掘金签约作者:cobyte',
  count: () => _ctx.count
})

而在子组件中只声明了一个 count 的 props:

javascript 复制代码
const ChildComponent = {
  props: {
    count: {
      type: Number,
      default: 520
    }
  },
  setup(props, context) {
    console.log('props', props)
    console.log('attrs', context.attrs)
  }
}

那么明显 name 这个 prop 就不会出现在子组件的 setup 方法的第一个参数 props 中,而是应该出现在第二个参数对象中的 attrs 属性对象中。这又是如何实现的呢?让我们在这一章中来了解一下 attrs 的实现原理吧。

解析设置 attrs

我们要解析设置 attrs,就必须要先知道有哪些用户传递的 props 是组件 props 配置项中的,也就是要先解析设置完 props 之后才能再进行解析 attrs。同时我们知道组件的 props 解析完后会设置到组件实例对象上,同样 attrs 也一样需要设置到组件实例对象上。所以我们首先要在组件实例对象上设置一个 attrs 的属性。设置如下:

javascript 复制代码
const instance = {
  // 省略...
  props: {},
  attrs: {},
  // 省略...
}

接着我们在 initProps 函数中进行解析 attrs,迭代代码如下:

diff 复制代码
export function initProps(instance, rawProps) {
  // 省略...
  // 开发环境进行检查 props 的数据是否合规
  if (__DEV__) {
    validateProps(rawProps, props, options || {})
  }
+  // 设置 attrs
+  patchAttrs(instance)

  instance.props = props
}

我们通过一个 patchAttrs 的函数进行处理 attrs,可以看到把所有的属性都设置到组件实例对象上之后,后续处理只需要传递一个组件实例对象就可以了,然后需要的参数数据都可以从组件实例对象上获取,这也是组件实例对象存在的好处。

这个 patchAttrs 函数我们把单独放到一个文件文件中进行处理,起到一个单一职责的作用,我们把该文件命名为 componentAttrs 路径为:runtime-vapor\src\componentAttrs.js,代码实现如下:

javascript 复制代码
export function patchAttrs(instance) {
  // 获取组件实例对象上的属性
  const {
    attrs,
    rawProps, // 用户传递过来的 props
    propsOptions: [options],
  } = instance    
  // 循环用户传递的 props
  for (const rawKey in rawProps) {
    // 通过一个函数处理
    const getter = rawProps[rawKey]
    // 如果传递的 prop 不存在组件 props 配置项中就进行设置
    if (!(rawKey in options)) {
      // attrs 的属性跟 props 一样需要通过 Object.defineProperty 进行劫持代理 
      Object.defineProperty(attrs, rawKey, {
        get: getter
      })        
    } 
  }
}

我们可以看到 patchAttrs 函数的处理逻辑很简单,先循环用户传递过来的 props,然后判断如果传递的 props 不存在组件的 props 配置项中就通过 Object.defineProperty 进行劫持代理,跟 props 一样。

我们设置完 attrs 之后,可以在执行组件 setup 方法的时候把 attrs 传递过去了。相关代码在 runtime-vapor\src\render.js 的 setupComponent 方法中,迭代如下:

diff 复制代码
export function setupComponent(instance) {
  // 省略...
  // 获取 setup 方法的执行结果
-  const stateOrNode = setupFn && setupFn(shallowReadonly(instance.props))
+  const stateOrNode = setupFn && setupFn(shallowReadonly(instance.props), { attrs: instance.attrs })
  // 省略...
}

经过上述步骤后,我们就可以测试我们的 attrs 的功能了,测试打印如下:

我们可以看到如期打印了我们的想要的结果。

setupContext 实现

我们上述的讲到的 setup 方法的第二个参数对象里面还有很多其他参数,比如 emit、slots、expose,这些参数我们应该把它统一设置到组件实例对象上,方便后续统一获取,这个对象就是 setupContext。接下来我们就来了解一下它的实现原理吧。

首先我们需要在组件实例对象上添加一个 setupContext 的属性:

diff 复制代码
const instance = {
+   setupContext: null,
}

然后我们可以在 setupComponent 中进行设置:

diff 复制代码
export function setupComponent(instance) {
  // 省略...
+  instance.setupContext = { attrs: instance.attrs }
  // 获取 setup 方法的执行结果
-  const stateOrNode = setupFn && setupFn(shallowReadonly(instance.props), { attrs: instance.attrs })
+  const stateOrNode = setupFn && setupFn(shallowReadonly(instance.props), instance.setupContext)
  // 省略...
}

这样我们就初步实现了 setupContext 的设置,是不是非常简单,其实任何程序都是从简单开始慢慢不停迭代的。现在我们要去思考程序的健壮性,我们在开发的过程中肯定是不希望 setupContext 中的 attrs 属性可以被修改的,而我们目前的 attrs 属性是可以被修改的。

例如我们在子组件中修改 attrs,代码如下:

javascript 复制代码
const ChildComponent = {
  setup(props, context) {
      context.attrs = '掘金签约作者:Cobyte'
      console.log('attrs', context.attrs)
  }
}

测试结果打印如下:

我们发现 attrs 属性居然可以被修改掉了,这不是我们期望的结果,我们希望 setupContext 的属性是不能修改的,跟常量一样。那么要实现这个功能我们就可以使用 Object.freeze() 方法了,以下是 MDN 对其作用的描述:

Object.freeze() 静态方法可以使一个对象被冻结 。冻结对象可以防止扩展,并使现有的属性不可写入和不可配置。被冻结的对象不能再被更改:不能添加新的属性,不能移除现有的属性,不能更改它们的可枚举性、可配置性、可写性或值,对象的原型也不能被重新指定。freeze() 返回与传入的对象相同的对象。

我们使用 Object.freeze() 进行修改,代码如下:

diff 复制代码
export function setupComponent(instance) {
  // 省略...
-  instance.setupContext = { attrs: instance.attrs }
+  instance.setupContext = Object.freeze({ attrs: instance.attrs })
  // 省略...
}

这时我们再测试,我们发现报错了。

提升开发体验

我们发现 attrs 传递的是一个对象的时候修改是没有报错的,测试代码如下:

父组件:

javascript 复制代码
const n1 = createComponent(ChildComponent, {
    name: () => { return { info: '掘金签约作者' } },
    count: () => _ctx.count
})

子组件调用代码:

javascript 复制代码
const ChildComponent = {
    props: {
        count: {
            type: Number,
            default: 520
        }
    },
    setup(props, { attrs }) {
        attrs.name.info = '掘金签约作者:Cobyte'
        console.log('attrs', attrs.name)
    }
}

测试结果打印如下:

我们发现修改依然没有成功的,但也没有提示报错,我们希望开发体验友好一些,当用户想去修改的时候,给他一个友好提示。

那么首先我们很容易想到的就是通过 Object.defineProperty 进行劫持 setupContext 对象中属性,然后在 setter 的时候提示不允许修改即可。同时由此可以总结出 Object.defineProperty 的使用场景:可以使用 Object.defineProperty() 来定义数据模型的属性,这样可以精确地控制属性的操作,例如是否可写、是否可枚举等,从而确保数据模型的正确使用和防止误操作。

首先我们把创建 setupContext 对象的过程进行封装成一个 createSetupContext 方法放在 runtime-vapor\src\component.js 中,代码如下:

javascript 复制代码
export function createSetupContext(instance) {
  const setupContext = {}
  Object.defineProperty(setupContext, 'attrs', {
    get() {
      return instance.attrs
    }
  })
  return Object.freeze(setupContext)
}

然后 setupComponent 方法中相关修改如下:

diff 复制代码
export function setupComponent(instance) {
    // 省略...
-    instance.setupContext = Object.freeze({ attrs: instance.attrs })
+    instance.setupContext = createSetupContext(instance)
    // 省略...
}

我们上述 createSetupContext 方法中通过 Object.defineProperty() 来定义要对外暴露的属性接口,我们还可以简写成如下方式:

javascript 复制代码
export function createSetupContext(instance) {
  return Object.freeze({
    get attrs() {
      return instance.attrs
    }
  })
}

接着我们去处理 attrs 对象,通过前面 Vue2 的数据响应式原理的实现我们可以知道使用 Object.defineProperty 监听一个引用对象是一件很麻烦的事情,所以我们可以使用 Vue3 中使用的 Proxy 来代替。我们把这个处理过程封装成一个 getAttrsProxy 函数,代码如下:

javascript 复制代码
export function createSetupContext(instance) {
  return Object.freeze({
    get attrs() {
      return getAttrsProxy(instance)
    }
  })
}

function getAttrsProxy(instance) {
  // 把 attrs 通过 Proxy 生成一个代理对象设置到组件实例对象上 attrsProxy 属性上
  return (
    instance.attrsProxy ||
    (instance.attrsProxy = new Proxy(
      instance.attrs,
      __DEV__ // 只在开发环境才进行提醒
        ? {
            get(target, key) {
              return target[key]
            },
            set() {
              // 不允许修改
              console.warn(`setupContext.attrs is readonly.`)
              return false
            },
            deleteProperty() {
              // 不允许删除
              console.warn(`setupContext.attrs is readonly.`)
              return false
            },
          }
        : {
            get(target, key) {
              return target[key]
            },
          },
    ))
  )
}

在 getAttrsProxy 函数中我们首先判断如果 instance 对象上已经存在 attrsProxy 属性,则直接返回它,如果 attrsProxy 不存在,则创建一个基于 instance.attrs 新的代理对象并将其赋值给 instance.attrsProxy。在代理处理函数定义了在访问、设置或删除代理对象的属性时应该执行的操作,例如修改或删除 attrs 属性时发出警告,并阻止这些操作,并且只有在开发环境中才进行这些判断,在生产环境中,只提供了属性访问功能(get方法),没有定义 set 和deleteProperty 方法,以提高性能。

最后,getAttrsProxy 函数返回创建的代理对象 attrsProxy,这样用户就可以通过这个代理对象来安全地访问 attrs,在开发环境中不小心修改它的时候会发出友好的提示从而提高开发体验。

这个时候我们修改 attrs 的属性的时候就有友好提示了。

useAttrs() 实现

我们知道在 Vue3 中除了通过 setup 方法的第二个参数获取 attrs 属性之外,还可以通过 Vue3 Composition API 中的 useAttrs() 进行获取,特别是在 <script setup> 模式中。那么 useAttrs() 是怎么实现的呢?其实很简单,我们上面已经把 attrs 设置到组件实例对象上了,我们只需要获取到组件实例对象就可以获取到 attrs 了。

我们把这一功能的实现封装到 runtime-vapor\src\apiSetupHelpers.js,代码实现如下:

javascript 复制代码
import {
  createSetupContext,
  getCurrentInstance,
} from './component'
const __DEV__ = true // 模拟开发环境
export function useAttrs() {
  // 通过获取 setupContext 来获取 attrs
  return getContext().attrs
}

// 获取 setupContext 上下文
function getContext() {
  // 获取组件实例对象
  const i = getCurrentInstance()
  // 如果是开发环境要判断是否存在组件实例对象
  if (__DEV__ && !i) {
    console.warn(`useContext() called without active instance.`)
  }
  // 如果存在 setupContext 就返回,否则就创建一个 setupContext
  return i.setupContext || (i.setupContext = createSetupContext(i))
}

我们可以看到 useAttrs() 是通过获取 setupContext 来获取 attrs,而 setupContext 我们已经在上节中实现过了,我们只要获取了组件的实例对象就可以判断是否存在 setupContext,如果存在就返回,否则就通过 createSetupContext 方法创建一个 setupContext,而 createSetupContext 方法我们也在上一节中实现过了。此外如果是开发环境还要判断是否存在组件实例对象,如果不存在则发出友好提示警告以增强开发体验。

接着我们就可以在 runtime-vapor\src\index.js 导出 useAttrs()

diff 复制代码
+ export { useAttrs } from './apiSetupHelpers'

这样我们就可以在子组件中进行测试了,测试代码如下:

javascript 复制代码
import { useAttrs } from '../runtime-vapor/src'
const ChildComponent = {
    // 省略...
    setup(props, context) {
      console.log('attrs', useAttrs())
      // 省略... 
    }
}

我们可以看到测试结果如期打印了我们期待的结果。

总结

本章深入剖析了 Vue Vapor 中 attrs 的核心实现机制。首先,通过 initPropspatchAttrs 函数,在组件实例上挂载 attrs 对象,并基于 Object.defineProperty 完成属性劫持,将未在 props 中声明的用户传递属性收集到 attrs 中。

接着,将 attrs 封装进 setupContext,并利用 Object.freeze() 冻结整体上下文,防止外部直接替换。为进一步提升开发体验,我们采用 Proxy 代理 attrs 对象,在开发环境下对修改和删除操作给出清晰警告,同时保留生产环境的性能。

最后,借助 getCurrentInstancecreateSetupContext,实现了组合式 API 中的 useAttrs() 函数,使得在 <script setup> 等场景下也能便捷地访问 attrs。这一系列设计既保证了数据的响应式与不可变性,又兼顾了开发友好性,充分体现了 Vue3 在底层架构上的精妙与严谨。

相关推荐
前端H2 小时前
生成式 UI 实战:AI 如何重塑前端界面
前端·人工智能·ui
触底反弹2 小时前
🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
javascript·后端·node.js
懂懂tty2 小时前
Web前端性能指标
前端
绝世唐门三哥2 小时前
vue3中页面返回时刷新首页的处理方案
开发语言·前端·javascript
sugar__salt2 小时前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
东方小月3 小时前
agent-skills:把资深工程师的‘工作流’,装进你的AI编程里
前端·架构·代码规范
Maynor9963 小时前
AI Coding 零基础实战教程|第五部分:完整项目案例实操
java·前端·人工智能·claude code·ai coding
binbin_523 小时前
React 井字棋教程:用一个小游戏理解组件和状态
前端·react.js·前端框架
梦曦i3 小时前
uni-router新推useUniEventChannel,彻底解决页面通信难题
前端·uni-app