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

这次问题很小:一个头像组件只有一个真实调用方,调用方每次都会传 fakereviewing 两个布尔值,但组件自己的 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。review 当然能看出来,但这种问题太容易反复出现:组件刚拆出来时为了省事写成可选;后面调用面缩到一个地方,props 契约没有同步收紧;再过一段时间,子组件里堆出一层层“万一没传”的判断。optional prop 是公开 API 契约,不是随手写的默认值提示。

可选 props 会制造幽灵状态

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

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

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

这两个契约差异很大。下面这几种状态不是同一类东西:

写法 契约含义
fake: boolean 调用方必须给出明确真假
fake?: boolean 调用方可以不传,组件要理解 undefined
fake: boolean | undefined 调用方必须显式传入一个可能为 undefined 的值
fake?: boolean + withDefaults 调用方可以省略,组件自己定义默认行为

单调用组件里,如果唯一调用方每次都能传出明确值,fake?: boolean 就会制造一个业务里不存在的 undefined 分支。这个分支不一定马上造成 bug,但会让代码读起来变虚:读者不知道 undefined 是真实状态、历史兼容,还是只是当时顺手写松了。

更麻烦的是,这种松契约会往下传染。父组件把值传给子组件时要写 fake === true,子组件再传给更小组件时继续 Boolean(fake),测试 helper 里也会写 fake ?? false。最后项目里到处都在处理一个理论上不会出现的输入。

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

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

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

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

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

这条判断可以压成一个规则:

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

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

普通 ESLint 规则为什么不够

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

这条规则太粗,会误伤一大片真实公共组件。可选 props 本身没错,错的是「只有一个真实调用方,却仍然把输入写成可选」。

再往前一步,能不能让 ESLint 规则自己判断调用次数?理论上可以,实践上不太舒服。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 默认组件名生成规则为例:

app/components/common/Profile/Identity/EditableAvatar/index.vue
    ↓
CommonProfileIdentityEditableAvatar

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

第二步扫描调用点。运行时代码应该优先看 app/componentsapp/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 都更有用。它把“为什么报错”“哪里调用”“应该怎么判断”放在同一个位置,避免开发者再自己 grep 半天。

例外要写成显式名单

这条规则一定需要 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: '跨页面行为组件,阈值和禁用态允许调用方省略。',
  },
]

原因很重要。没有原因的 allowlist 会慢慢变成垃圾桶;有原因的 allowlist 则是规则边界的一部分。后续某个例外不再成立,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"
  }
}

是否接进 predevprebuild 要看仓库规模。如果扫描速度足够快,可以接;如果全仓组件很多,先只放在 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

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

第一版不要追求完美

这种架构 lint 容易写过头。第一版最好只覆盖项目里真实会发生、误报可以解释清楚的情况。

可以先支持:

  • Nuxt 默认自动导入组件名。
  • <script setup> 里的 defineProps 类型参数。
  • 本地 interface Props
  • 模板里的 PascalCase 组件 tag。
  • app/componentsapp/pages 运行时调用点。
  • allowlist 和 baseline。

先不支持也可以明确写出来:

  • defineProps({ ... }) runtime object 里复杂的默认值推导。
  • 从外部类型文件 import 的 props type。
  • <component :is="..."> 动态组件。
  • 全局注册、异步组件、第三方自动生成组件。
  • 单测、Storybook、playground 里的演示用法。

这些能力可以延后。第一版先让规则守住当前最容易退化的那块;等真实项目里出现新的误报或漏报,再根据证据扩展。

工程护栏最怕第一版写成“全能分析器”。规则越大,越难解释;误报越多,越容易被关掉。小规则只要能稳定挡住已经踩过的坑,就已经有价值。

这条规则真正保护的是什么

表面看,它保护的是 props 必填性。更深一层,它保护的是组件边界的诚实程度。

单调用组件本来就不应该伪装成通用组件。它只有一个父级,父级知道所有业务状态,子组件就不要把这些状态写成“可能没有”。必填 props 会迫使父组件把数据准备完整,也会让子组件内部少一层不必要的防御。

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

所以这条 lint 不是在消灭 optional props。它是在提醒我们:可选性要跟组件的使用面匹配

总结

这次小重构给出的规则很朴素:

  • 单调用组件优先使用必填 props,调用方显式传值。
  • optional prop 表示组件承认省略输入,不是为了少写默认值。
  • 单文件 ESLint 不擅长判断“只有一个调用方”,这类问题更适合全仓架构 lint。
  • 架构 lint 先建组件调用图,再解析 defineProps,最后按调用方数量判断。
  • allowlist 要写原因,baseline 要能逐步收缩。
  • 第一版只覆盖真实高频场景,不急着支持所有动态组件和复杂类型。

很多组件退化都不是突然发生的。它们通常从一个小小的 ?: 开始:今天只是多写一个 === true,明天就多一层默认值,后天再多一个不确定状态。把这种小松动变成可见的 lint,比每次 review 里靠记忆提醒要稳得多。