当组件只有一个调用方:可选 props 为什么应该被 lint 管住

这次问题很小:一个头像组件只有一个真实调用方,调用方每次都会传 fake 和 reviewing 两个布尔值,但组件自己的 props 仍然写成可选。

代码大概是这样:

const props = defineProps<{
  name: string
  avatarSrc: string | null | undefined
  isSelf: boolean
  fake?: boolean
  reviewing?: boolean
}>()

模板里再把可选值转成明确布尔值:

<OwnerActions
  v-if="isSelf"
  :fake="fake === true"
  :reviewing="reviewing === true"
/>

这段代码能跑。问题在于 fake?: boolean 允许调用方省略输入,但这个页面的父组件本来就负责提供两个状态。类型声明没有表达这份责任,模板里又留下 === true 这样的转换,读者很难判断省略输入是不是有意设计的默认行为。

最后的改法很直接:

const props = defineProps<{
  name: string
  avatarSrc: string | null | undefined
  isSelf: boolean
  fake: boolean
  reviewing: boolean
}>()

模板也可以恢复成普通布尔透传:

<OwnerActions
  v-if="isSelf"
  :fake="fake"
  :reviewing="reviewing"
/>

review 能发现这类问题,但它容易反复出现。组件刚拆出来时为了省事写成可选,后来调用面缩到一个地方,props 契约却没有同步收紧,子组件里逐渐堆出「万一没传」的判断。optional prop 是公开 API 契约,它允许调用方省略输入,不能只当成默认值提示。

可选性决定省略输入的含义

组件 props 的可选性会直接改变调用方和组件之间的责任分工。

如果一个 prop 是必填,意思是调用方负责提供这个值。组件内部可以把它当作稳定输入,只处理业务本身的真假分支。

如果一个 prop 是可选,意思是组件承认「调用方可能不给」。组件内部就要决定默认值、空值语义、是否展示、是否透传,以及这个空值和 false、null、空字符串有什么区别。

必填性、undefined 和默认值分别表达不同的契约:

写法 契约含义
fake: boolean 调用方必须给出明确真假
fake?: boolean 调用方可以省略,运行时取值还要看 Vue 的 Boolean 转换和默认值
fake: boolean | undefined 调用方必须显式传入一个可能为 undefined 的值
fake?: boolean + withDefaults 调用方可以省略,组件自己定义默认行为

这里要区分类型契约和运行时行为。Vue 的 Boolean props 转换会把未传入的 Boolean prop 转成 false;显式设置 default: undefined 等配置又会改变这个行为。因此,fake?: boolean 不等于子组件一定会收到 undefined,=== true 也不一定是必需的。

这条检查要提醒的是:唯一调用方明明负责提供状态,子组件却仍然承诺「可以省略」。即使运行时恰好落到 false,省略究竟代表默认行为还是漏传,仍然需要由组件契约说清楚。

如果维护者没有分清这两层,父组件可能写一次 fake === true,子组件再写一次 Boolean(fake),测试 helper 又写 fake ?? false。与其不断补转换,不如先确认省略是否有业务含义,再决定必填性和默认值。

只有一个调用方时,契约应该跟着收紧

组件是否应该使用可选 props,不能只看这个组件文件本身,也要看它的使用面。

公共基础组件保留可选 props 很正常。比如 Modal 的 closeOnEsc、Icon 的 size、PullRefresh 的 wheelThreshold,它们面向很多调用方,默认值就是组件能力的一部分。调用方可以选择只传关键参数,剩下交给组件默认行为。

页面私有组件或单调用组件是另一种情况。它通常只是把父组件的一段模板拆出来,方便控制文件体积、局部样式和测试边界。这样的组件不是一个真正面向全项目的公共 API,它和父组件之间更像一条内部函数调用链。

内部函数调用链的参数应该尽量明确。父组件已经算好的状态,子组件就直接接收;父组件不知道的状态,才让子组件自己计算;确实需要默认值的状态,才写成可选并说明默认行为。

判断必填性时,要同时看使用面和父组件的责任:

  • 多调用、跨场景、基础组件:可选 props 可以是组件 API 的一部分。
  • 单调用、页面私有、父组件总能提供值:优先写必填 props。
  • 单调用但仍然保留可选:需要能说清它为什么真的是可省略输入,而不是历史遗留。

这也是它适合做成 lint 的原因:项目仍然允许可选 props,但「单调用组件」里的可选 props 需要被额外审视。

普通 ESLint 规则为什么不够

第一反应可能是写一条 ESLint 规则:遇到 defineProps<{ foo?: string }>() 就报错。

这条规则太粗,会把公共组件有意提供的默认行为也报出来。检查需要结合调用面,找到那些父组件负责提供状态、子组件却允许省略输入的地方。

ESLint 规则也可以自己统计调用次数,但全仓关系与它的常规模型不太匹配。自定义规则通常访问当前文件的 AST,再通过 context.report() 报告当前位置,适合检查动态 i18n key、冗余 watch 比较、显式 .vue import 这类单文件写法。

single-use component optional props 这类问题需要全仓索引:

  1. 扫出所有组件文件。
  2. 根据 Nuxt 组件自动导入规则算出组件名。
  3. 扫所有模板和显式 import,统计每个组件的真实调用方。
  4. 回到组件文件,解析 defineProps,找出 optional props。
  5. 只有当调用方数量等于 1 时,才报告这个组件里的 optional props。

把全仓扫描放进普通 ESLint rule,每 lint 一个文件都可能重新扫仓库,影响速度和缓存。lint-staged 只扫暂存文件时,未必能看到未改动的唯一调用方;全仓扫描又比普通 staged lint 更重。

所以更稳的落点是一个架构检查脚本。它仍然属于 lint,但不一定要挂在 ESLint 单文件规则里。

架构 lint 要先建组件调用图

这条检查的核心先是调用图,再是 props 解析。

第一步扫描 app/components/**/*.vue,把文件路径映射成 Nuxt 自动导入组件名。以 Nuxt 默认组件名生成规则为例:

Nuxt 组件文件路径怎样得到自动导入名

路径里的 index.vue 不参与最后一段命名;目录段按 PascalCase 拼起来。真实项目里可能还要考虑 Nuxt 配置里的 pathPrefix、组件目录别名、全局组件前缀和手动注册规则。第一版可以先支持项目当前真实使用的那部分规则,不要一开始就写成通用 Nuxt 组件解析器。

第二步扫描调用点。运行时代码应该优先看 app/components 和 app/pages:

  • 模板里直接写 <CommonProfileIdentityEditableAvatar />,算一次运行时调用。
  • 页面或组件里显式 import .vue,并在模板里使用 import alias,也算一次运行时调用。
  • 单测里 import 组件不算运行时调用。测试是验证 surface,不应该把组件从「单调用」变成「多调用」。
  • 只在 components map 或动态 :is 里出现的用法,需要按项目真实写法补规则。第一版识别不了时,宁可放进边界说明,不要假装完全覆盖。

调用次数不要只计总出现次数,最好计「调用方文件数」。同一个父组件模板里出现两次,仍然是一个调用方;两个不同业务文件都用到,才说明它开始接近公共组件。

先统计调用方,再复核可省略输入的含义

再解析 defineProps

调用图建好后,props 解析反而是比较机械的部分。

脚本可以用 @vue/compiler-sfc 解析 Vue SFC,然后读取 <script setup>。如果项目主要用 TypeScript 类型形式声明 props,第一版重点覆盖这两类:

defineProps<{
  fake?: boolean
  reviewing?: boolean
}>()

以及本地 interface:

interface Props {
  fake?: boolean
  reviewing?: boolean
}

defineProps<Props>()

内部可以用 TypeScript AST 或 Babel parser 解析脚本内容。伪代码大概是这样:

// scripts/check-single-use-component-props.mjs 的伪代码
const components = await collectVueComponents('app/components')
const componentNames = buildNuxtComponentNameMap(components)
const usages = await collectRuntimeComponentUsages({
  names: componentNames,
  roots: ['app/components', 'app/pages'],
})

for (const component of components) {
  const callers = usages.get(component.name) ?? new Set()
  if (callers.size !== 1) continue

  const optionalProps = collectOptionalPropsFromDefineProps(component.file)
  if (optionalProps.length === 0) continue

  report({
    component,
    caller: [...callers][0],
    optionalProps,
  })
}

报告信息要直接给出改法,不要只说「违反规则」:

单调用组件存在可选 props:

- app/components/common/Profile/Identity/EditableAvatar/index.vue
  调用方:app/components/common/Profile/Identity/index.vue
  可选 props:fake, reviewing
  建议:如果调用方始终能提供这些值,改成必填 props,并在调用方显式传入。
  例外:如果它们确实是组件默认行为的一部分,请加入 allowlist 并写明原因。

报告同时给出原因、调用位置和判断方法,人和 Agent 都能直接复核,不必重新检索整条调用链。

例外要写成显式名单

这条规则需要 allowlist,记录单调用但允许省略输入的组件,避免反复报告合理用法。

合理例外至少有几类:

  • 基础组件虽然当前只有一个调用方,但定位就是公共组件,例如新抽出来的 CommonModalFooter。
  • 组件正在迁移中,短期只有一个调用方,后续会被多个页面接入。
  • 可选 prop 本身承载默认行为,例如 disabled?: boolean 默认为 false,调用方不传就是有意义的 API。
  • 组件兼容第三方或 slot 场景,调用方数量不能只靠模板 tag 统计。

allowlist 不应该只是字符串数组。每个例外都要写原因:

export const singleUseOptionalPropsAllowlist = [
  {
    component: 'app/components/common/Modal.vue',
    reason: '公共基础弹窗,optional props 是组件默认行为的一部分。',
  },
  {
    component: 'app/components/common/PullRefresh/index.vue',
    reason: '跨页面行为组件,阈值和禁用态允许调用方省略。',
  },
]

例外原因是规则边界的一部分。后续使用面或默认行为变化,review 时可以据此判断是否还应保留该项。

如果担心历史存量太多,可以再加 baseline 文件:

{
  "app/components/foo/LegacyPanel.vue": ["loading", "error"]
}

脚本运行时只阻止新增问题,同时提示历史 baseline 数量。某个模块重构时,再把它从 baseline 里拿出来;这个策略比全仓直接 error 更容易进入真实项目。

接到哪里更合适

这条检查适合挂成单独命令:

{
  "scripts": {
    "lint:component-contracts": "node scripts/check-single-use-component-props.mjs",
    "lint": "eslint . && pnpm lint:component-contracts"
  }
}

是否接进 predev 或 prebuild 要看仓库规模。如果扫描速度足够快,可以接;如果全仓组件很多,先只放在 pnpm lint 和 CI。它不像 Tailwind @apply 黑名单那样必须在每次 dev 前挡住,也不像语法 lint 那样适合每个 staged 文件单独跑。

lint-staged 里不建议直接跑全量组件调用图。提交一个组件文件时,唯一调用方可能没改;提交一个父组件时,被调用组件也可能没改。全仓关系类检查天然不适合只看 staged 文件。更好的做法是:

  • 本地快速提交继续跑普通 ESLint、Stylelint、Prettier。
  • pnpm lint 或 CI 跑全仓组件契约检查。
  • 脚本支持传路径,用于专项治理,例如 pnpm lint:component-contracts app/components/common/Profile。

这样既不会拖慢每次小提交,也能让分支合并前看到结构问题。

可复制源码

下面这份源码包把前面的判断整理成了一个完整脚本:组件名映射、运行时调用图、defineProps AST 解析、allowlist、baseline、CLI 和回归测试都在里面。它的边界也很明确:先覆盖常见 Nuxt 目录和 PascalCase 组件调用,再按真实目录和命名规则继续调整。

请接入单调用 Vue 组件 optional props 检查。
工具包根路径:https://shengsheng.fun/files/single-use-component-optional-props-lint/kits/single-use-component-props-guardrail/
先读取 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;再按 FILES.json 读取 copy/scripts/、copy/tests/ 和 examples/,把脚本、配置、测试和 package scripts 接入当前 Vue/Nuxt 项目。
单调用组件 optional props 检查源码正在加载代码工作区...

接入时有两个地方建议优先改成项目自己的版本。

第一是 getNuxtComponentName。如果项目改过 Nuxt components 配置,或者目录里用了前缀、省略路径段、全局组件别名,就先补一个路径到组件名的单测,再改这里。

第二是 allowlist。省略输入确实有默认行为、组件计划用于更多场景,或调用图暂时识别不了动态用法时,把理由写进配置。这样复核者能判断例外是否仍然成立。

让可选性与使用面匹配

对于只有一个父级、且状态由父级完整提供的组件,必填 props 能表达这份责任,也能减少子组件不必要的缺省判断。单调用本身只是复核线索,省略输入是否有业务含义,仍需结合默认行为和例外理由判断。

相反,公共组件就应该承认可选输入,并把默认值、空态和省略行为写成稳定 API。组件越公共,越需要为调用方省心;组件越私有,越应该把契约收紧。

可选性要跟组件的使用面匹配。调用图找出值得复核的组件,开发者确认省略输入的含义;allowlist 留下例外理由,baseline 则把历史问题和新增问题分开处理。