从 90KB 到 24KB:我如何把远程 React 组件做成可版本化、可缓存、可观测的主题系统

背景与目标

在 template-system 里,我要把主题以 远程 ESM 组件的形式发布,要求:

  • 运行时只加载组件与纯数据(JSON Schema),夹带开发期工具库;

  • 产物可版本化(/v1/...)、可长缓存(hash 文件名)、可观测(体积分布、依赖结构);

  • CSS 与字体加载解耦,避免阻塞与冗余。

总体架构

  • JS 产物:Vite lib 模式,多入口(cool-shop-header.tsx / cool-shop-main.tsx)。

    • reactreact-dom external,避免把宿主依赖吃回包里。

    • 输出路径规范:入口文件走 dist/${theme_version}/cool|general/...非入口 chunk 统一落在 dist/${theme_version}/vendor/

    • 自定义插件 writeManifestgenerateBundle 中用 emitFile 生成 ${theme_version}/manifest.json,记录 “入口名 → 实际文件名(含 hash)” 的映射,方便远程加载器按名称定位最新文件。

  • Schema 产物:开发期脚本 gen:schema 把 Zod 定义转成纯 JSON落盘;组件侧只 import *.schema.json不引入 zod,JS 体积立降。

  • CSS 产物:Tailwind 针对单主题独立构建为 dist/v1/cool/cool.css

    • content 精准指向主题源码;

    • @tailwindcss/postcss + autoprefixer + 生产态 cssnano

    • Google Fonts 从 CSS 的 @import 改为 HTML <link>,并配 preconnect

  • 观测rollup-plugin-visualizer 输出 dist/${theme_version}/stats.html,随构建检查包体积与共享块。

构建流程(scripts)

{
  "scripts": {
    "gen:schema": "tsx scripts/gen-schema.ts",
    "build": "tsc -b && npm run clean && vite build --config vite.config.js",
    "css:cool:build": "cross-env NODE_ENV=production postcss ./src/css/style.css -o ./dist/v1/cool/cool.css",
    "build:cool:full": "npm run gen:schema && npm run build && npm run css:cool:build"
  }
}

 

要点:先 gen:schema,避免把 zod 打进包;JS/CSS 分离产出,独立缓存演进。

关键配置(Vite)

  • Babel 接管 JSX(锁死 prod 路径)

plugins: [
  react({
    babel: {
      plugins: [
        ['@babel/plugin-transform-react-jsx', { runtime: 'automatic' }]
      ]
    }
  }),
  writeManifest(),
  visualizer({ filename: `dist/${theme_version}/stats.html`, gzipSize: true, brotliSize: true })
]

 

  • 输出规范与 external

build: {
  lib: {
    entry: {
      'cool-shop-header': path.resolve(__dirname, 'src/themes/cool/ShopHeader.tsx'),
      'cool-shop-main':   path.resolve(__dirname, 'src/themes/cool/Shop.tsx')
    },
    formats: ['es'],
  },
  outDir: 'dist',
  sourcemap: false,
  emptyOutDir: true,
  target: 'esnext',
  minify: true,
  rollupOptions: {
    output: {
      entryFileNames: ({ name }) => {
        if (name?.startsWith('cool-'))    return `${theme_version}/cool/${name}.[hash].js`
        if (name?.startsWith('general-')) return `${theme_version}/general/${name}.[hash].js`
        return `${theme_version}/${name}.[hash].js`
      },
      // 非入口 chunk 统一落 vendor 目录(如有)
      chunkFileNames: `${theme_version}/vendor/[name].[hash].js`,
    },
    external: ['react', 'react-dom']
  }
}

 

  • manifest 插件(emitFile 版)

function writeManifest() {
  return {
    name: 'write-manifest',
    generateBundle(_opts, bundle) {
      const manifest = {}
      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type === 'chunk' && chunk.isEntry) {
          manifest[chunk.name] = fileName
        }
      }
      this.emitFile({
        type: 'asset',
        fileName: `${theme_version}/manifest.json`,
        source: JSON.stringify(manifest, null, 2),
      })
    }
  }
}

 

  • (可选)删 JS 注释与 console:打开即可

esbuild: { drop: ['console','debugger'], legalComments: 'none' }

 

PostCSS(Tailwind v4)

// postcss.config.cjs
const tailwindcss = require('@tailwindcss/postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');

module.exports = {
  plugins: [
    tailwindcss({ config: 'tailwind.config.cool.ts' }),
    autoprefixer(),
    ...(process.env.NODE_ENV === 'production'
      ? [cssnano({ preset: 'default' })]
      : []),
  ],
};

 

HTML 中放字体与主题 CSS:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

<link rel="preload" as="style" href="https://cdn/v1/cool/cool.css" fetchpriority="high">
<link rel="stylesheet" href="https://cdn/v1/cool/cool.css">

 

体积与结果

  • 移除运行时 zod 后,入口 JS 从 ~87 KB 降至 ~24 KB(raw);visualizer 不再出现 zod*

  • CSS 原始 ~94 KB;CDN 传输(Brotli)通常十几 KB。进一步瘦身靠精准 content、精简动画与按需模块化。

  • 目录按 ${theme_version} 归档,CDN 可配 max-age=31536000, immutable,新版本走新目录,老缓存自然不破。

常见坑

  • Tailwind v4 的 PostCSS 插件是 @tailwindcss/postcss不是 tailwindcss

  • --config 是 PostCSS 的配置文件选项;Tailwind 的配置文件通过插件参数 tailwindcss({ config: ... }) 指定。

  • 生成清单用 emitFile,避免 emptyOutDir/目录不存在导致的 ENOENT。

  • 在你的组合下,显式加 Babel JSX 插件能稳定产出 prod 变体,避免 jsxDEV 潜入。

下一步

  • 如果需要彻底掌控 runtime 位置,把 react/jsx-runtime 也 external,并做一份 vendor(react.js/react-dom.js/react-jsx-runtime.js)上传到 ${theme_version}/vendor/,由 import map 指向。

  • 做一份首页 critical CSS 内联,其余主题 CSS 延迟加载;同时用 prefetch 提前拉远程组件。

  • 远程加载器里加 stale-while-revalidate 和重试/熔断策略,提升“冷启动”与弱网容错。

posted @ 2025-08-11 12:44  PEAR2020  阅读(4)  评论(0)    收藏  举报