一次把 6 个 H5 项目里复制粘贴的组件,迁移到 Vue 3 Vant 4 并封装成 npm 包的实战复盘。结论先行:组件代码很简单,难的从来不是组件,而是组件周围那层「工程契约」。开篇:组件很简单,难的不是组件接手时我也以为这是个体力活——acme-*系列十几个 H5 项目里,cascader、bank-search、dialog等这些组件被复制粘贴了好几份。把它们拷进一个新仓库、npm publish一下不就完了?真正动手才发现:每个组件单独看确实简单(就是 Vant 套一层加点样式),但把它变成一个能被别人安全引用的包这件事,藏着一堆编译器不会替你报错的坑。下面是几个我认为最值得讲透的。1. 哪些能抽、哪些不能:看耦合,不看复杂度第一反应是按简单的先抽。错了。决定一个组件能不能干净抽出来的,是它的依赖图,不是它的代码行数。cascader有 260 行、逻辑复杂,但只依赖 Vant →好抽。bank-search是个简单搜索框,却import { queryBankList } from /api→难抽。库组件不能假设宿主有某个/api。解法是依赖反转:把取数据这件事从组件里赶出去。三种做法,权衡在于谁持有 loading / 缓存 / 错误处理:data prop : 宿主取好数据传进来 → 组件最纯,宿主最累 fetcher : 把取数函数当 prop 传入 → 灵活,但耦合了调用时机 emit 事件 : 组件 search,宿主回填 → 交互解耦,通常最优沉淀:评估能不能抽成库先画依赖图。视觉复杂度会骗你,耦合度不会。2. 一个编译过却跑不起来的坑这是这次最有价值的一个认知。Vant 4删除了DatetimePicker(拆成了DatePicker)。我顺手写下:import { DatetimePicker } from vant // 在 Vant 4 里,这是 undefinedvite build绿了。TypeScript 不报错,esbuild 不报错,什么都没说。直到组件在浏览器里渲染,才冒出一句[Vue warn]: Failed to resolve component。为什么 build 抓不到?因为Vue 的组件解析是运行时的。模板van-datetime-picker会被编译成resolveComponent(van-datetime-picker)——一个在运行时按字符串名去组件注册表里查的调用。import拿到undefined,对 JS 而言完全合法(你只是注册了一个值为 undefined 的局部组件),只有当渲染真的发生、查表失败时才会 warn。// 模板编译后的样子(简化) const _component_van_datetime_picker resolveComponent(van-datetime-picker) // import 是 undefined → 注册表查不到 → 运行时才 warn,编译期无感沉淀:「build 绿」是最弱的保证。框架大版本迁移必须读 migration guide,核对被删 / 改名的 API,并用「导出名存在性核对 真实渲染」补上编译器盖不住的盲区。3. v-model 迁移:同一个组件既是消费者又是提供者Vue 2 → 3 的 v-model 契约整个变了:valueinput→modelValueupdate:modelValue;.sync→v-model:x。Vant 4 又把 Popup/Dialog 的可见性从v-model改成了v-model:show。关键在于:我们的Dialog组件对内是 van-dialog 的 v-model 消费者,对外又是宿主的 v-model 提供者,两端要同时改,中间用一个 watch 做双向透传:van-dialog v-model:showisShow…/van-dialog script export default { props: { modelValue: { type: Boolean, default: false } }, emits: [update:modelValue], watch: { modelValue(v){ this.isShow v }, // 宿主 → 内部 isShow(v){ if (v ! this.modelValue) this.$emit(update:modelValue, v) }, // 内部 → 宿主 }, } /script对级联 / 日期这类用 popup 的组件,我用computed的 get/set 把宿主的modelValue直接透传给v-model:show,更干净。4. 库的本质,是一层防腐层DatePicker最能说明问题。Vant 4 里它的 v-model 数据从Date变成了字符串数组[2024,06,30]。如果我把这个变化透传出去,所有消费方都得跟着改——那这个库就成了上游 breaking change 的二传手,毫无价值。所以我让组件内部适配新格式,对外仍然收 / 发Date:// 进:Date → [Y,M,D] 喂给 Vant 4 // 出:confirm 的 string[] → 转回 Date 抛给宿主 confirm({ selectedValues }){ const [y,m,d] selectedValues this.$emit(select, new Date(y, m - 1, d)) }沉淀:库的价值 把上游的 churn 吸收在内部,给消费者一个稳定的对外契约。这就是后端常说的防腐层(Anti-Corruption Layer)在前端组件库里的样子。内部实现随便换,对外 API 要稳。5. peerDependencies 与两个 Vue惨案很多人以为 vue / vant 放peerDependencies是为了减小包体积。这只是副作用。真正的原因是框架单例。如果库把 vue 放进dependencies并打进产物,宿主项目里就会存在两份 Vue 运行时。后果不是浪费体积,而是功能性损坏:响应式系统是两套,跨实例的provide / inject不通;app.use(Vant)注册在宿主的 app 实例上,而你的组件跑在库自带的那份 Vue 上 → 组件找不到、注入拿不到;React 世界里这个病叫Invalid hook call——多份 react 各有一套 dispatcher,hooks 直接崩。这也是为什么npm link联调组件库经常炸:软链会让库解析到自己node_modules里的那份 Vue/React,瞬间变成两份实例。解法是 peerDeps 构建时external挖空 让宿主提供唯一那份(或联调时用yalc、配resolve.dedupe)。// vite.config:把 peer 挖空,产物里绝不包含 vue/vant rollupOptions: { external: [vue, vant] }6. 你以为发布的是组件,其实发布的是契约应用项目几乎不碰package.json的这几个字段,但对库来说它们就是对外接口定义:exports:现代解析的权威入口,优先级高于main/module。它还有个被低估的作用——封锁子路径:没在exports里列出的文件,消费者根本import不到,等于强制了封装边界。(写 TS 库时还要注意types条件必须放在最前,否则类型解析会落空。)sideEffects:声明哪些模块有副作用。组件库里import ./x.css是副作用,如果图省事写了sideEffects: false,打包器会认为这些 CSS没人用而摇掉,样式全丢。正确写法是sideEffects: [**/*.css]。files:发布白名单。用白名单([dist])而不是.npmignore黑名单——漏配时后果是少发了文件而不是误把源码 / 密钥发出去。还有资源:cascader引用的本地图片必须随包走。小图(4KB)让 Vite 自动转 base64 内联进产物,否则发出去就是运行时 404。7. 四层防线:怎么确认真的能用既然build 绿不可信,我用四层验证,每层抓不同的 bug:① npm run build → 模板 / 语法编译错 ② node 核对 vant 导出名 → 运行时组件解析错(build 抓不到) ③ npm pack tar -tf → 漏发 / 误发文件 ④ 全新项目装 .tgz 再 build → exports 映射、peerDeps 能否被装第④层尤其关键:它是唯一能验证exports字段、scope/style.css子路径、peerDeps在真实消费视角下成不成立的方式。源项目都是 Vue 2,装不了这个 Vue 3 包,所以我专门起了一个全新 Vue 3 工程装.tgz跑通。8. 发布:用 Verdaccio 零风险练手内部库不该发公共 npm(代码会公开且几乎不可撤回)。我用Verdaccio起了个本地私有 registry 走通全流程:acme这个 scope 通过.npmrc路由到localhost:4873,其余依赖照走公共源。acme:registryhttp://localhost:4873/跑通后要迁到公司的 Azure Artifacts / Nexus,只需改这行 registry URL 和 token——命令一模一样。Verdaccio 是练手与 CI 离线缓存的好选择。收尾:一套可迁移的方法论回头看,这次真正学到的东西没一个和 Vant 绑定:抽取看耦合不看复杂度,依赖反转解耦;框架迁移有编译期沉默的坑,build 绿是最弱保证;库的本质是防腐层,对外契约要稳;peerDeps 是为了框架单例,不是减体积;package.json是对外接口,exports/sideEffects/files都有讲究;用分层验证代替我觉得没问题。组件逻辑是算法,这些是工程。换成 React 组件库、甚至以后封装一个 LLM 工具 SDK 成 npm 包,套路完全一样——这才是区分会写组件和能交付可复用资产的地方。