当组件只有一个调用方:可选 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 这类问题需要全仓索引:
- 扫出所有组件文件。
- 根据 Nuxt 组件自动导入规则算出组件名。
- 扫所有模板和显式 import,统计每个组件的真实调用方。
- 回到组件文件,解析
defineProps,找出 optional props。 - 只有当调用方数量等于 1 时,才报告这个组件里的 optional props。
把全仓扫描放进普通 ESLint rule,每 lint 一个文件都可能重新扫仓库,影响速度和缓存。lint-staged 只扫暂存文件时,未必能看到未改动的唯一调用方;全仓扫描又比普通 staged lint 更重。
所以更稳的落点是一个架构检查脚本。它仍然属于 lint,但不一定要挂在 ESLint 单文件规则里。
架构 lint 要先建组件调用图
这条检查的核心先是调用图,再是 props 解析。
第一步扫描 app/components/**/*.vue,把文件路径映射成 Nuxt 自动导入组件名。以 Nuxt 默认组件名生成规则为例:

路径里的 index.vue 不参与最后一段命名;目录段按 PascalCase 拼起来。真实项目里可能还要考虑 Nuxt 配置里的 pathPrefix、组件目录别名、全局组件前缀和手动注册规则。第一版可以先支持项目当前真实使用的那部分规则,不要一开始就写成通用 Nuxt 组件解析器。
第二步扫描调用点。运行时代码应该优先看 app/components 和 app/pages:
- 模板里直接写
<CommonProfileIdentityEditableAvatar />,算一次运行时调用。 - 页面或组件里显式 import
.vue,并在模板里使用 import alias,也算一次运行时调用。 - 单测里 import 组件不算运行时调用。测试是验证 surface,不应该把组件从「单调用」变成「多调用」。
- 只在
componentsmap 或动态: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 项目。接入时有两个地方建议优先改成项目自己的版本。
第一是 getNuxtComponentName。如果项目改过 Nuxt components 配置,或者目录里用了前缀、省略路径段、全局组件别名,就先补一个路径到组件名的单测,再改这里。
第二是 allowlist。省略输入确实有默认行为、组件计划用于更多场景,或调用图暂时识别不了动态用法时,把理由写进配置。这样复核者能判断例外是否仍然成立。
让可选性与使用面匹配
对于只有一个父级、且状态由父级完整提供的组件,必填 props 能表达这份责任,也能减少子组件不必要的缺省判断。单调用本身只是复核线索,省略输入是否有业务含义,仍需结合默认行为和例外理由判断。
相反,公共组件就应该承认可选输入,并把默认值、空态和省略行为写成稳定 API。组件越公共,越需要为调用方省心;组件越私有,越应该把契约收紧。
可选性要跟组件的使用面匹配。调用图找出值得复核的组件,开发者确认省略输入的含义;allowlist 留下例外理由,baseline 则把历史问题和新增问题分开处理。