共享依赖在前端工程化中的实践与常见问题解决方案

景岩 Dev 前端 阅读 2,143
赞 40 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年接手一个内部运营平台,要同时维护 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 的替代方案,求轻喷,我还在找更优雅的解法。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论