1. 先把概念掰开:page.json、pages.json 和 easycom 谁管谁
很多人在搜“page.json 配置 easycom”的时候,脑子里其实是三件事搅在一起:微信小程序原生的页面配置、uni-app 项目根目录那个总配置、以及 easycom 这套编译期的组件自动引入机制。这三样东西名字长得像,职责却完全不同,我见过不止一个同学在页面目录里新建一个 page.json 写 easycom,然后对着编译报错发呆。所以动手之前,先把它们各自的边界划清楚,后面配置起来才不会绕。
1.1 三个配置文件的实际分工
微信原生小程序里,app.json管全局(页面路由、窗口表现、底部 tabBar),每个页面自己的目录下有一个页面级 json(习惯上就叫 page.json 或者 index.json),里面能配usingComponents、navigationBarTitleText、disableScroll这类只对当前页面生效的项。这是原生体系的规则,跟 easycom 没有半点关系。
uni-app 把这层结构收拢了:项目根目录只有一个pages.json,页面路由、全局样式、tabBar、分包、usingComponents全都往里塞,编译到微信小程序时,构建工具再把它拆成app.json加若干份页面级 json。easycom 的配置项就挂在pages.json的顶层,跟pages数组平级。
所以第一句话要说明白:easycom 写在pages.json里,不是写在页面的 page.json 里。你在编译产物里看到的那些usingComponents,是构建工具根据 easycom 规则替你填进去的,属于结果,不是配置入口。
| 文件 | 位置 | 谁解析 | 主要职责 | 能否写 easycom |
|---|---|---|---|---|
| app.json | 微信原生项目根目录 | 微信基础库 | 全局路由与窗口配置 | 否 |
| page.json(页面级) | 原生项目页面目录 | 微信基础库 | 当前页面的窗口表现、usingComponents | 否 |
| pages.json | uni-app 项目根目录 | 构建工具 | 全量路由、分包、easycom、全局样式 | 是 |
| 编译后的 page.json | 构建产物目录 | 微信基础库 | 由 pages.json 拆分生成 | 只读结果 |
这张表我建议你截图存下来,团队里新人问“配置写哪”的时候直接甩过去,比解释十分钟管用。
1.2 easycom 到底解决了什么麻烦
在原生小程序里引一个自定义组件,标准流程是三步:页面 json 的usingComponents里登记组件路径,页面 js 里require拿到组件,然后 wxml 里写标签。uni-app 的 Vue 语法稍微好一点,但仍然是“import 进来 + 在components选项里注册 + 模板里用”这三件套。
单个组件无所谓,问题出在规模上。一个中等体量的项目,通用组件三四十个很正常,首页要用八个卡片类组件,详情页要用六个表单类组件。每开一个新页面,你就得对着组件目录翻一遍,想想这次要引哪些,路径写错了编译报错,路径写对了但忘了注册,页面直接渲染成空白标签——这种低级问题排查起来还特别费时间,因为控制台不一定给你明确提示。
easycom 的思路很朴素:用目录约定代替手工注册。只要你的组件放在规定的位置、按规定的名字命名,模板里写上标签就能用,import和components注册这两步全省了。构建工具在编译阶段扫一遍目录,发现模板里用到了my-card,就去约定路径找my-card/my-card.vue,找到就自动引入并注册,找不到就当普通标签处理(这时候通常会渲染失败,也是后面要重点排查的情况)。
1.3 我为什么在中小项目里默认开 easycom
先说收益。第一是页面代码变干净,一个页面顶部原本可能堆十几行 import,现在一行没有,模板直接从业务标签开始,可读性提升很明显。第二是按需打包,没在模板里出现过的组件不会进入产物,这跟“全量注册”是本质区别,我实测过一个组件库项目,全量注册和 easycom 按需引入,主包体积差了将近 300KB。第三是新人上手门槛低,不用理解模块系统,复制一个组件目录改个名就能用。
再说代价,这也是必须提前讲清楚的。easycom 是隐式依赖,你在模板里看到<my-card>,光看代码不知道它从哪来,IDE 的跳转能力也弱,按住 Ctrl 点不进去是常态。组件重名的时候,谁覆盖谁取决于扫描顺序,排查起来比较难受。另外它只在编译期生效,运行时的行为跟手动注册完全一致,所以调试时不能指望它有额外提示。
我的判断标准是:组件数量超过 10 个、团队超过 2 个人,easycom 就该开;如果项目只有三五个页面、组件两只手数得过来,手动注册反而更透明。
2. easycom 的扫描规则与匹配原理
配置写错一个字符就不生效,这是 easycom 最容易踩的地方。它不像普通配置项那样写错了给你个警告,很多时候是静默失效,页面直接白板。所以这一节把规则讲透,你照着改就行。
2.1 默认扫描的两个目录
autoscan为 true 时(默认就是 true),构建工具会扫两个位置:
components/组件名/组件名.vue——这个路径是相对项目源码根目录的。如果你的项目开了src目录结构,那就是src/components/组件名/组件名.vue。uni_modules/插件标识/components/组件名/组件名.vue——这是插件市场的目录规范,装了插件之后组件基本开箱即用。
关键点在于目录名和文件名必须完全一致。components/my-card/index.vue这种写法在普通 import 里完全合法,但 easycom 的默认规则认不出来,因为默认匹配的是同名文件。我见过太多人栽在这里,目录建好了、组件写好了、模板里也写了,就是不显示,最后发现文件名是index.vue。
如果想支持index.vue,可以在pages.json的easycom里加一行约定,不过我的建议是干脆别这么干,统一用同名文件,团队里少一套规则就少一类问题。
2.2 custom 自定义规则的正则怎么写
默认扫描只认components/和uni_modules/,如果你的组件放在别的地方,比如src/biz-components/,或者你想加个统一前缀,就需要写custom。
{ "easycom": { "autoscan": true, "custom": { "^my-(.*)": "@/biz-components/my-$1/my-$1.vue", "^ui-(.*)": "@/components/ui-$1/ui-$1.vue" } } }拆开看:键是一个正则表达式,^my-(.*)表示以my-开头的标签名,括号里的部分会被捕获;值是一个路径模板,$1就是捕获到的内容,@代表源码根目录。所以模板里写<my-card>,捕获到card,最终解析成@/biz-components/my-card/my-card.vue。
几个容易翻车的细节:
- 正则要用双反斜杠转义。JSON 字符串里
\.必须写成\\.,\d写成\\d,否则 JSON 解析那关就挂了。我一般干脆避免在规则里用点号和反斜杠,用(.*)和字符类就够了。 - 顺序有讲究。多条规则都能匹配同一个标签时,写在前面的优先。所以要把更具体的规则放前面,宽泛的放后面。
@的含义跟着项目结构走。开了src的项目,@/components/xxx指的是src/components/xxx;没开src的就是项目根目录下的components/xxx。这个在迁移老项目时特别容易搞混。- 路径里的扩展名写不写都行,但写全
.vue更明确,也方便别人阅读。
2.3 编译之后到底生成了什么
这是我认为最值得讲的一部分,因为理解了产物,排查问题就有据可依。
编译到微信小程序时,easycom 的作用体现在页面的 json 文件里。假设首页模板里用了<my-card>,产物目录下pages/index/index.json会长这样:
{ "usingComponents": { "my-card": "../../biz-components/my-card/my-card" } }注意路径是相对路径,不是@/开头的别名,而且是相对于当前页面 json 的位置算出来的。这就是为什么有时候你在源码里改个目录,产物路径就变了,微信开发者工具报“组件未找到”。
再看组件本身的产物。一个.vue单文件组件会被拆成四件套:.wxml(模板)、.wxss(样式)、.js(逻辑)、.json(组件配置,包含component: true)。如果你在组件里写了options: { styleIsolation: 'isolated' }或者virtualHost,这些都会落到那个 json 里。
还有个容易忽略的点:easycom 只对模板里真正出现的标签生效。你写<my-card v-if="false">,标签在模板里存在,组件就会被引入;但如果整个组件是通过:is动态拼出来的,easycom 大概率识别不到,这时候还得老老实实手动注册。
3. 从零走一遍:建组件、配规则、页面里直接用
理论讲完,来一遍完整流程。我按真实项目的顺序来写,每一步都说清楚为什么这么干。
3.1 目录结构先定死
src/ ├── biz-components/ │ ├── my-card/ │ │ └── my-card.vue │ └── my-tag/ │ └── my-tag.vue ├── pages/ │ └── index/ │ └── index.vue └── pages.json为什么不放默认的components目录?因为我要跟第三方组件库隔开。components目录经常被插件或者脚手架占用,混在一起容易重名。自己起个biz-components(业务组件)或者base-components(基础组件),边界清晰,配一条^my-规则就能覆盖全部。
前缀my-也是有意为之。微信原生有一批内置标签:view、text、image、button、scroll-view、swiper等等,你如果自定义一个叫button的组件,编译时行为会非常诡异——有的平台会优先用内置组件,你的组件永远不生效。加个前缀等于给自己筑了一道防火墙。
3.2 组件本身怎么写才符合规范
<template> <view class="card" :class="{ 'card--disabled': disabled }" @tap="handleTap"> <view class="card__header"> <text class="card__title">{{ title }}</text> <my-tag v-if="badge" :text="badge" type="primary" /> </view> <view class="card__body"> <slot /> </view> </view> </template> <script> export default { name: 'MyCard', options: { virtualHost: true }, props: { title: { type: String, default: '' }, badge: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, emits: ['select'], methods: { handleTap() { if (this.disabled) return this.$emit('select') } } } </script> <style lang="scss" scoped> .card { padding: 24rpx; background: #fff; border-radius: 16rpx; &--disabled { opacity: 0.5; } } </style>这里有几个点值得单独说。name选项建议跟文件名保持一致,虽然 easycom 不靠name做匹配(它靠路径),但调试工具里显示的组件名就是这个,对不上会很乱。options.virtualHost开启之后,组件根节点不会额外生成一层包裹节点,布局上少一层嵌套,我基本默认开。
还有my-card里面又用了my-tag,这就是 easycom 的嵌套使用。只要my-tag也符合规则,它会被自动引入,不需要在my-card里做任何声明。这一点在设计组件体系的时候特别好用,基础组件互相组合几乎零成本。
注意:
emits声明是 Vue 3 的写法。Vue 2 项目里删掉这行,改用this.$emit就行,行为一致。
3.3 pages.json 里那几行配置
{ "easycom": { "autoscan": true, "custom": { "^my-(.*)": "@/biz-components/my-$1/my-$1.vue" } }, "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } } ] }easycom必须放在pages.json的顶层,跟pages、globalStyle、tabBar平级。放错层级是最常见的失效原因之一,而且不报错,纯静默。
另外pages.json这个文件在很多编辑器里是严格 JSON 校验的,不允许注释。虽然某些工具链内部用宽松解析器能容忍注释,但我不建议赌,写注释的习惯改到别的地方去。真需要说明,就在团队文档里写。
3.4 页面里直接用,然后去产物里验证
<template> <view class="page"> <my-card v-for="item in list" :key="item.id" :title="item.title" :badge="item.badge" @select="onSelect(item)" > <text>{{ item.desc }}</text> </my-card> </view> </template> <script> export default { data() { return { list: [] } }, methods: { onSelect(item) { console.log('selected', item.id) } } } </script>模板里没有任何 import,也没有components注册,直接就能跑。跑通之后,我强烈建议你做一件事:去编译产物的dist/dev/mp-weixin/pages/index/index.json里看一眼。
{ "usingComponents": { "my-card": "../../biz-components/my-card/my-card" } }看到这一行,说明 easycom 匹配成功了。看不到,就说明规则没对上,问题一定出在命名、路径或者正则上,不用怀疑别的。这个验证动作能帮你把排查范围从“整个项目”缩小到“一条规则”,效率差好几倍。
再进一步,如果你的组件内部用了别的组件,去看看biz-components/my-card/my-card.json,里面应该也有对应的usingComponents。这条链路完整了,组件树就是通的。
4. 组件封装的边界:什么该进 easycom,什么不该
easycom 用起来太顺手,很容易滑向另一个极端——什么都往组件目录里塞。我踩过这个坑,一个项目里堆了六十多个自定义组件,其中一半只用过一次,最后维护成本比手动注册还高。
4.1 三条准入标准
第一,复用次数。至少两个页面会用到,才值得抽成 easycom 组件。只在单个页面用的局部结构,写在页面里就行,用v-for或者子模板拆一拆足够。
第二,职责单一。一个组件只干一件事。my-card只管卡片的外框和头部,里面的内容交给 slot;不要让它同时负责数据请求、格式化、埋点上报。我见过一个“万能卡片”,props 有十几个,调用方每次要传八个参数,最后没人愿意用。
第三,不依赖具体业务数据。组件接收 props,不主动调接口。一旦组件内部发请求,它就跟具体的后端结构绑死了,换一个页面就得改组件。这类逻辑应该抽成独立的工具函数或者状态层,组件只负责渲染。
按照这个标准,我通常把组件分三层:基础层(按钮、标签、输入框这类原子组件,前缀my-)、组合层(卡片、列表项、表单块,前缀my-但依赖基础层)、业务层(某个特定页面才会用的复杂模块,这类我倾向不放 easycom,直接在页面里局部注册,避免污染全局命名空间)。
4.2 样式隔离怎么配
微信小程序的组件默认样式隔离是isolated,意思是组件内的 class 不会影响外部,外部的样式也进不来。这个默认值其实挺好,但会带来一个实际困扰:父页面想微调组件内部的间距,改不了。
两种解法。一是让组件暴露 CSS 变量,父级通过style传进去:
<template> <view class="card" :style="{ '--card-padding': padding }"> <slot /> </view> </template>组件内部样式写padding: var(--card-padding, 24rpx);,父级就能覆盖了。这种方式干净,不破坏隔离。
二是配置styleIsolation: 'apply-shared',让外部样式能作用到组件内部。方便是方便,但容易造成样式污染,组件多了之后 class 冲突排查起来很痛苦。我的做法是基础组件一律保持isolated,只有极少数需要深度定制的容器类组件才开apply-shared,并且在组件注释里写明“本组件允许外部覆盖”。
4.3 多 slot 的坑
Vue 里写多个具名 slot 很自然,但在小程序端需要显式开启:
export default { options: { multipleSlots: true } }不开的话,具名 slot 的内容会全部塞进默认插槽,渲染结果跟你预期完全不一样。这个坑我在两个项目里各踩过一次,因为开发环境有时候表现正常、真机上就错乱,特别迷惑。建议所有带具名 slot 的组件都把这一行加上,反正没坏处。
5. 常见问题与排查实录
这一节是整篇里最“实战”的部分。下面这些现象,基本都是我在真实项目里遇到过的,按出现频率排序。
5.1 组件写了但页面一片空白
这是最高频的问题。排查顺序我固定成下面这条链路,从头往下走,基本上三步之内能定位。
| 排查顺序 | 检查项 | 常见错误 | 处理方式 |
|---|---|---|---|
| 1 | 产物 json 里有没有 usingComponents | 整个字段都是空的 | 说明规则没匹配上,往下查 |
| 2 | 目录名与文件名是否一致 | my-card/index.vue | 改成my-card/my-card.vue |
| 3 | 标签名与规则前缀是否吻合 | 配了^my-,标签写成card | 统一前缀 |
| 4 | 正则是否被 JSON 转义破坏 | \.没写成\\. | 简化正则,避免点号 |
| 5 | 是否新增组件后没重启编译 | 热更新不触发目录扫描 | 停止运行重新编译 |
| 6 | 大小写是否一致 | MyCard与my-card | 全小写加连字符 |
第 5 条要额外强调。easycom 的扫描发生在编译启动阶段,运行中新增一个组件目录,热更新不一定能感知到。我在两个不同的工具链上都遇到过这种情况,表现是改代码完全没反应,重启一下就好了。所以只要新增了组件目录或改了easycom配置,第一反应就应该是重启编译,别浪费时间在代码里找 bug。
5.2 组件被覆盖或者串味
团队协作时,两个人各自写了一个my-card,分别放在不同目录,规则都能匹配,最后谁生效取决于配置顺序,这就很麻烦。
我的解法是命名空间分层。基础组件统一my-前缀,业务 A 模块的组件用my-a-前缀,业务 B 模块用my-b-。往custom里写规则的时候,更具体的前缀放前面:
"custom": { "^my-order-(.*)": "@/biz-components/order/my-$1/my-$1.vue", "^my-(.*)": "@/biz-components/base/my-$1/my-$1.vue" }这样my-order-list会走第一条,my-tag走第二条,互不干扰。代价是规则变多,但换来的是可预期性,值得。
还有一点,千万不要用内置组件名做自定义组件名。view、text、image、icon、label、form、picker,这些都是保留字,撞上了行为不可预测。加前缀是成本最低的规避方式。
5.3 分包页面的组件体积问题
这个稍微进阶一点。主包有 2MB 限制,如果你的 easycom 组件只被分包页面使用,它会被打到哪个包里?
结论是:组件跟着引用它的页面走。分包页面引用的组件,会被复制进分包目录,主包里不会重复。但有个前提,这个组件没有被主包页面引用过。如果一个组件主包和分包都用,它会被打进主包,分包再从主包引用。
实操建议:把纯基础组件(按钮、标签、空状态这类体积小的)留在主包,把业务耦合重、体积大的组件放到分包对应的目录里,规则单独配一条。我曾经把一个富文本渲染组件放主包,光它就占了 200 多 KB,挪到分包之后主包立刻松了口气。
5.4 props 改了但视图不更新
这个严格来说不算 easycom 的问题,但因为它经常跟组件化一起出现,我放在这里说。
小程序的数据更新走的是setData,对象和数组的引用变化才会触发更新。父组件传一个对象进去,子组件里直接改属性,视图不会动:
// 错误示范 this.innerData.name = 'x'正确的做法是整体替换,或者用this.$set那种方式触发。我的习惯是组件内部永远不改 props,需要改就先深拷贝到 data 里,改完再通过事件通知父级。这条规矩坚持下来,能省掉大量“为什么页面不刷新”的疑问。
6. 工程化收尾:类型提示、规范约束和团队约定
一个人写代码,能把功能跑起来就够了;一个团队写代码,靠的是约定。easycom 这种隐式机制,如果没有配套约束,半年后接手的人会很痛苦。
6.1 类型提示怎么补
模板里写<my-card>,编辑器不知道这个标签有哪些 props,补全全无,传错了也不提示。这个体验确实不如手动 import。
解决办法是维护一份全局组件类型声明。新建一个components.d.ts,声明组件的 props 类型:
declare module 'vue' { export interface GlobalComponents { MyCard: typeof import('./biz-components/my-card/my-card.vue')['default'] MyTag: typeof import('./biz-components/my-tag/my-tag.vue')['default'] } } export {}这份文件需要手工维护,或者写个脚本在构建前扫一遍目录自动生成。我倾向后者,加一个 npm 脚本,扫biz-components下的所有目录,按模板生成声明文件。花半小时写一次,后面每次都省事。
6.2 目录规范写进文档
团队里我会明确三条:
- 组件目录名、文件名、模板标签名三者必须完全一致,全小写,多个单词用连字符。
- 每个组件目录下必须有同名
.vue文件,不允许index.vue。 - 新增组件必须同步更新
components.d.ts,否则提交检查拦下来。
第三条用 husky 加一个简单的脚本就能实现,检查目录和声明文件是否对得上。刚开始会有人抱怨麻烦,两周之后就习惯了,而且新人接手时能立刻看到全貌。
6.3 一个容易被忽略的细节:组件的“空壳”成本
即使是 easycom 按需引入,每个组件在产物里也会生成四个文件。组件数量上去了,产物的文件数会暴涨,微信开发者工具的构建和上传时间都会变长。我做过一次统计,四十个组件的项目,产物文件数比二十个组件的项目多出将近一倍。
所以定期做一次组件盘点是有必要的。我的做法是每个迭代结束时跑一遍搜索,把每个组件名在项目里 grep 一次,引用数为 0 或者 1 的,列出来评估:要么合并到调用页面里,要么删掉。这个动作看着琐碎,但能让组件库保持健康,不至于越滚越大。
6.4 我个人实际使用中的体会
刚接触 easycom 的时候,我觉得它是个“爽但危险”的东西——写起来快,出问题难查。用了几个项目之后,我的看法变了:它的风险是可管理的,前提是你把约定当回事。
真正让我改变看法的是一次组件重构。那次要把所有卡片类组件的圆角参数统一调整,涉及十几个页面。如果还是手动 import 的写法,我得挨个文件改引入路径或者逐个确认注册;有了 easycom 加统一的my-前缀,组件的路径是规则生成的,我把组件目录整体挪位置,只需要改pages.json里的一条规则,其他代码一个字没动,编译就过了。那一刻我才意识到,路径的“约定化”本身就是一种解耦。
最后分享一个小习惯:我会在biz-components下放一个README.md,里面就一张表,列出组件名、一句话用途、负责人的名字缩写。新增组件时顺手加一行,成本极低。等到项目做到第三个月,这张表就是团队最常打开的文档,比任何代码注释都实用。