共享依赖在前端工程化中的实践与常见问题解决方案
项目初期的技术选型
去年下半年接手一个内部运营平台,要同时维护 Web、H5 和微信小程序三端。一开始是三个独立仓库,各自 npm install 一堆重复依赖:lodash、dayjs、axios、自定义 hooks(比如 useRequest、usePermission),连 request 拦截逻辑都 copy-paste 了三次。后来某天 QA 提了个 bug:“用户在 H5 端改了手机号,Web 端没同步更新 token”,查了半天发现是 Web 端的 axios 响应拦截器漏写了 refresh token 逻辑——而 H5 端上周刚修过。
我盯着 git diff 里那几行几乎一模一样的代码,心想:不能再这样了。不是为了炫技,就是单纯不想再因为“某端漏同步一个字段校验”被半夜 call 起来修生产问题。于是拉上后端同学开了个短会,决定搞「共享依赖」:把通用逻辑抽成私有 npm 包,三端统一依赖,发版时一起升。
最大的坑:性能问题
第一版我们用的是最朴素的方式:建了个 @company/shared-utils,里面放工具函数、常量、基础 hooks。发布后上线,H5 打包体积涨了 120KB(gzip 后),首屏时间慢了 300ms。Chrome DevTools 里一看,lodash 被打了两份:一份来自 shared-utils 的 dependencies,一份来自主项目的 dependencies。更糟的是,shared-utils 里有个 useAuth hook 引用了 react-query,但 Web 项目用的是 @tanstack/react-query@4.36,而 shared-utils 锁的是 ^4.29 ——导致 webpack 打包时把两个版本的 react-query 都塞进去了。
开始没想到,以为发个包就完事了。结果上线后发现:不仅体积大,还偶发 queryClient 不一致,useQuery 返回的 data 是 stale 的。折腾了半天才发现是多个 react-query 实例在打架。
最终的解决方案
我们最后落地的是「peer + 构建时 externals + 类型透传」组合拳。核心思路很土:shared-utils 不打包任何三方依赖,只做类型和逻辑中转;所有运行时依赖由宿主项目提供。
先改 package.json:
{
"name": "@company/shared-utils",
"version": "1.2.4",
"peerDependencies": {
"react": "^18.2.0",
"@tanstack/react-query": "^4.36.0",
"dayjs": "^1.11.10",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/axios": "^1.6.0"
}
}
然后在 shared-utils 的入口文件里,用 declare module 显式声明对 peer 的依赖引用,避免类型报错:
// src/index.ts
import type { QueryClient } from '@tanstack/react-query'
import type { AxiosInstance } from 'axios'
export interface AuthConfig {
apiUrl: string
}
export function createAuthClient(config: AuthConfig): AxiosInstance {
// 实际实现里直接用外部传入的 axios,不 import
// 这里只是类型示意
return {} as any
}
// 注意:不 import * as dayjs,而是导出类型供外部调用
export type Dayjs = typeof import('dayjs')
最关键的是构建配置。我们用 tsc + rollup,rollup.config.js 里明确 external 掉所有 peer:
// rollup.config.js
export default {
input: 'src/index.ts',
external: [
'react',
'react-dom',
'@tanstack/react-query',
'axios',
'dayjs',
'lodash'
],
plugins: [typescript()],
output: {
dir: 'dist',
format: 'esm',
exports: 'named'
}
}
宿主项目(比如 H5)的 webpack.config.js 也要加 externals,防止二次打包:
module.exports = {
// ...其他配置
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'@tanstack/react-query': 'ReactQuery',
axios: 'axios',
dayjs: 'dayjs'
}
}
最后,所有业务端必须保证 peer 版本严格对齐。我们加了个 preinstall 脚本强制检查:
# package.json scripts
"preinstall": "node ./scripts/check-peer-version.mjs"
// scripts/check-peer-version.mjs
const fs = require('fs')
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
const required = {
'@tanstack/react-query': '^4.36.0',
'axios': '^1.6.0'
}
Object.entries(required).forEach(([dep, range]) => {
const version = pkg.dependencies?.[dep] || pkg.devDependencies?.[dep]
if (!version || !version.match(new RegExp(^${range.replace(/^/g, '')}))) {
throw new Error(⚠️ ${dep} 版本不匹配:期望 ${range},当前 ${version})
}
})
现在跑得咋样?
上线三个月,效果是明显的:三端 request 拦截逻辑改一次,全端生效;权限判断、登录态校验、错误码映射这些高频变动点,再也不用切三个 tab 同步改了。打包体积也回落到比原来还小 40KB —— 因为 shared-utils 本身只有不到 2KB 的 JS,全是逻辑胶水。
但真要说完美?也不是。比如小程序端用的是 webpack5 + mini-program-webpack-plugin,它不支持 externals 的完整语义,我们只能手动 patch 了插件源码,在构建时跳过 shared-utils 的 node_modules 解析。这个 patch 目前还在维护,但影响不大,毕竟小程序发版频率低。
还有个小问题:shared-utils 里的 hook 无法直接用 useQuery,必须由宿主项目通过 props 或 context 注入 queryClient。写法上略啰嗦,但换来的是彻底解耦和可控性,我认了。
回顾与反思
回看整个过程,最值钱的经验其实是:共享依赖不是“多建一个包”,而是“重新定义边界”。你得想清楚:什么该进包?什么必须留给宿主?像 UI 组件、主题色变量、API 基础路径这些,我们后来全挪到了另一个包 @company/shared-ui 里,用 CSS-in-JS + emotion,配合 runtime 主题切换——这部分没放在这次分享里,因为太重,下次单开一篇。
另外提醒一句:别迷信 monorepo。我们试过用 turborepo 把三端和 shared-utils 放一起,结果本地开发时每次改 shared-utils 都要 rebuild 所有端,热更新延迟 8 秒起步。最后还是回归“独立 repo + npm link + CI 自动 publish”,简单粗暴,稳定省心。
以上是我踩坑后的总结,希望对你有帮助。如果你也在搞跨端共享逻辑,欢迎评论区交流 —— 尤其是小程序 externals 的替代方案,求轻喷,我还在找更优雅的解法。

暂无评论