🌐 CDN 外挂方案
让 import {createApp} from 'vue' 在生产构建中变成 const {createApp} = window.Vue,bundle 里一行 Vue 代码都不打。
读完之后你应该能:
- 🎯 理解为什么
rollupOptions.external 不够用
- 🧪 知道 import 改写的 4 种语法变体
- 🛡️ 掌握三层降级(多 CDN → 本地 fallback → 报错)
- 🔧 用
aliasGlobals / initScript 做 Preact 替代 React
一、🎯 动机
1.1 为什么要做 CDN 外挂
React 19 + React Router 的首屏成本
vendor-react 192 KB gzip 60 KB
vendor-react-router 20 KB gzip 7 KB
────────────────────────────────────────
合计 212 KB gzip 67 KB
即使用了 ⚡ 性能优化 的全部手段,这 67KB 依然在每个首屏的关键路径上。
唯一能让它
完全不出现在首屏传输路径的办法
- 浏览器从 CDN 加载同版本 React(
https://cdn.jsdelivr.net/npm/react@19/umd/react.production.min.js)
- 浏览器缓存命中率极高(jsDelivr / unpkg 用户规模大)
- 多项目共享同一份缓存,跨项目受益
:::
1.2 为什么不直接用 Rollup external
❌ 仅用 Rollup external 的结果
build: {
rollupOptions: {
external: ['vue']
}
}
// 构建产物里还是:
import {createApp} from 'vue'
// 浏览器运行时:
// ❌ Uncaught TypeError: Failed to resolve module specifier "vue"
:::danger Rollup external 只剥离模块图,不改写 import 语句
必须有一个源码级改写层把 import 'vue' 变成 const {createApp} = window.Vue。这是 lhx-kit 在 @lhx-kit/vite-plugin 里做的事。
二、🧠 整体思路
在 project.config.ts 声明哪些包走 CDN:
project.config.ts showLineNumbers
1import {defineProjectConfig} from '@lhx-kit/config';
2
3export default defineProjectConfig({
4 cdn: {
5 enabled: true,
6 applyOn: ['build'], // 仅 build 启用,dev 正常打包方便调试
7 fallback: 'local', // CDN 全挂时降级到本地 vendor chunk
8 globalNamespace: 'LhxCdn', // window.LhxCdn API 挂载点
9
10 entries: [
11 {
12 name: 'vue',
13 globalVar: 'Vue',
14 urls: [
15 'https://cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js',
16 'https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js'
17 ]
18 },
19 {
20 name: 'pinia',
21 globalVar: 'Pinia',
22 urls: ['https://cdn.jsdelivr.net/npm/pinia@2.2.6/dist/pinia.iife.min.js'],
23 depends: ['vue'] // 声明依赖:Pinia 的 UMD 需要 window.Vue 先到
24 }
25 ]
26 }
27});
构建时发生的四件事:
1. @lhx-kit/vite-plugin 把 'vue' / 'pinia' 设为 Rollup external
2. transform() 钩子把所有源码里 import ... from 'vue' → const = window.Vue
3. HTML 里注入运行时 CDN loader(从 @lhx-kit/runtime/cdn-loader 生成)
4. 为每个 entry emit 本地 vendor chunk(dist/shared/vendor/vue.js)
→ CDN 全挂时 loader 动态 import() 它
三、🔧 核心算法:import 改写
3.1 为什么需要改写
Rollup `external` 行为: 仅"告诉 bundler 这个 id 不要打"
↓
ESM 语法要求: bare specifier 必须能解析
↓
结果: import {createApp} from 'vue' 出现在 bundle 里
↓
浏览器: ❌ Failed to resolve module specifier "vue"
必须有源码级改写层把 import 翻译成 window 访问。
3.2 改写的 4 种语法变体
:::code-group
// 原始
import vue from 'vue'
// 改写后
const vue = (window.Vue.default !== undefined ? window.Vue.default : window.Vue);
// 原始
import * as ns from 'vue'
// 改写后
const ns = window.Vue;
// 原始
import {ref, computed as c} from 'vue'
// 改写后
const {ref, computed: c} = window.Vue;
// 原始
import 'vue'
// 改写后
/* lhx-cdn: side-effect import "vue" replaced by global */
:::
3.3 为什么用正则而不是 AST
packages/vite-plugin/src/plugin.ts::rewriteCdnImports
const importRe = /import\s+([^'"]+?)\s+from\s+(['"])([^'"]+)\2\s*;?/g;
const sideRe = /import\s+(['"])([^'"]+)\1\s*;?/g;
| 方面 |
正则 |
AST (acorn/swc) |
| 代码量 |
🟢 30 行 |
🔴 100+ 行 |
| Per-file 开销 |
🟢 < 1ms |
🟡 5–10ms |
| 包体积增加 |
🟢 0 |
🔴 +200KB (acorn) |
| 边界情况 |
🟡 靠 Vite 归一化兜底 |
🟢 完美 |
做这个决策时的关键观察
Vite 到达 transform() 钩子时,源码已经被归一化了:
- TypeScript 已 strip
- JSX 已编译
- 只剩 ESM
- 导入语句统一格式
这时候正则的误匹配率极低。权衡下来,正则是更合适的选择。
3.4 Early exit 优化
packages/vite-plugin/src/plugin.ts showLineNumbers
1transform(code, id) {
2 // Early exit: 文件里压根不含任何 external 名字 → 跳过
3 let hit = false;
4 for (const name of Object.keys(externalsByName)) {
5 if (code.includes(name)) { hit = true; break; }
6 }
7 if (!hit) return null;
8
9 // 真正改写
10 const rewritten = rewriteCdnImports(code, externalsByName);
11 return rewritten === code ? null : {code: rewritten, map: null};
12}
为什么这个优化重要
绝大多数模块不 import CDN external 包。code.includes(name) 是一个 C 实现的快速子串检查,把 per-file transform 调用量砍到 5% 以下。
3.5 踩过的坑:node_modules 也要改写
早期错误的实现
transform(code, id) {
if (id.includes('node_modules')) return null; // ❌ 跳过 node_modules
// ...
}
会发生什么
react-router-dom 打进 bundle 时内部还是这样:
// react-router-dom/dist/index.js
import {createContext} from 'react'
import {useEffect} from 'react'
Rollup external 只从用户模块图里剥离,不会改写 third-party 包产物里的 import。浏览器运行时立刻炸:
❌ Uncaught TypeError: Failed to resolve module specifier "react"
修复:去掉 node_modules 跳过,靠 early exit 控制性能。现在的 transform() 对每个文件都跑一次 includes() 快速检查。
四、🛡️ CDN 加载器运行时
4.1 状态机
每个 CDN entry 有 4 个状态:
┌─────────┐
│ pending │
└────┬────┘
│
├─ 某个 CDN URL 加载成功 ───→ ┌────┐
│ │ ok │
│ └────┘
│
├─ 所有 CDN URL 都挂 ┌──────────┐
│ 但本地 vendor 成功 ───→ │ fallback │
│ └──────────┘
│
└─ 所有路径都挂 ───→ ┌────────┐
│ failed │
└────────┘
4.2 HTML 里注入的 loader
dist/home/index.html
1<!DOCTYPE html>
2<html lang="zh-CN">
3 <head>
4 <!-- lhx-kit: CDN loader -->
5 <script>
6 ;(function(){
7 var plan = {entries:[{name:'vue',globalVar:'Vue',urls:[...]}], fallback:'local'};
8 var w = window;
9 var state = {};
10 // ... 完整状态机 + 事件系统 + whenReady / on
11 w.LhxCdn = {plan, state, _ok, _failUrl, whenReady, on};
12 for (var k=0; k<plan.entries.length; k++) {
13 state[plan.entries[k].name] = 'pending';
14 }
15 })();
16 </script>
17
18 <script src="https://cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js"
19 crossorigin="anonymous"
20 data-lhx-cdn="vue"
21 onload="LhxCdn._ok('vue')"
22 onerror="LhxCdn._failUrl('vue', 0)"></script>
23 <!-- /lhx-kit: CDN loader -->
24 <script type="module" src="/home/assets/home-*.js"></script>
25 </head>
26</html>
关键点:
| 设计 |
原因 |
Classic <script> 不用 module |
UMD bundle 必须在 module entry 之前完成 |
async=false |
保证多个 CDN script 顺序加载(pinia 依赖 vue) |
onload / onerror |
DOM 属性,ES5 兼容,老 IE 都能跑 |
data-lhx-cdn |
调试时在 DevTools Elements 能识别哪些 script 是 kit 注入的 |
4.3 失败降级链
举例:vue 三级降级
t=0ms 尝试 CDN URL 0 (jsDelivr)
└─ onerror → _failUrl('vue', 0)
↓
t=100ms 尝试 CDN URL 1 (unpkg)
└─ onerror → _failUrl('vue', 1)
↓
t=200ms 所有 CDN 挂了
→ loadLocalFallback(entry)
→ dynamic import('/shared/vendor/vue.js')
↓
t=250ms 本地 vendor 加载成功
→ state.vue = 'fallback'
→ emit('fallback', {name:'vue'})
INFO
fallback: 'error' 模式
如果 cdn.fallback === 'error',则跳过最后一步,直接 state = 'failed'。用户代码里的 whenReady(['vue']) 会 reject。
适合对完整性有严格要求的场景(比如监控系统要上报 CDN 挂了的事件)。
4.4 为什么 loader inline 而不是外链
关键开销权衡
外链 loader:
multiple HTTP/2 streams
loader 必须是 type="module" 或等 <script> 解析
多一次 HTTP 请求 + 一次 RTT(50ms)
inline loader:
HTML 多 ~2KB(压缩后 ~0.6KB gzip)
省一次 RTT
明显应该 inline。代价:loader 代码必须不依赖任何 bundler,手写 ES5 IIFE。
五、🎨 aliasGlobals / initScript / localFallback
这三个字段是 lhx-kit CDN 相比"裸 Rollup external"最有特色的能力。都是从 Preact 替代 React 的踩坑中提炼。
5.1 aliasGlobals 的由来
场景
用户代码 (transform 后):
const {lazy} = window.React
↑
但我们实际引入的是 preact/compat:
window.preactCompat = {lazy, Suspense, createContext, ...}
如果不做 alias,要么改所有源码(不可能),要么 loader 写一堆 special case。
解法:
project.config.ts
1{
2 name: 'preact',
3 globalVar: 'preactCompat',
4 aliasGlobals: ['React', 'ReactDOM'], // 👈 关键
5 urls: ['https://.../preact-compat.umd.js']
6}
Loader 在 entry 加载成功后执行:
loader 内部(renderCdnLoaderScript 生成)
function applyAliases(entry) {
if (entry.aliasGlobals) {
var src = w[entry.globalVar]; // window.preactCompat
for (var ai = 0; ai < entry.aliasGlobals.length; ai++) {
w[entry.aliasGlobals[ai]] = src; // window.React = src
// window.ReactDOM = src
}
}
}
之后 const {lazy} = window.React → 🎯 成功。
5.2 initScript 的由来
Preact compat 的 UMD 没有 createRoot(React 18/19 API)。需要 polyfill。
project.config.ts showLineNumbers
1{
2 name: 'preact',
3 globalVar: 'preactCompat',
4 aliasGlobals: ['React', 'ReactDOM'],
5 initScript: `
6 var R = window.React;
7 if (!R.createRoot) {
8 R.createRoot = function(container){
9 return {
10 render: function(el){ R.render(el, container); },
11 unmount: function(){
12 R.unmountComponentAtNode && R.unmountComponentAtNode(container);
13 }
14 };
15 };
16 }
17 `
18}
执行时机
initScript 在 aliasGlobals 执行完之后被调用:
(new Function(entry.initScript))()
try-catch 包裹保证 script 错误不会阻塞后续 entries。
5.3 localFallback 的由来
默认情况下 lhx-kit 会从 node_modules/<pkg>/dist/ 自动找 UMD 产物(见 resolveLocalVendor)。但:
- 🔴 有些包 UMD 放在奇怪路径(
dist/umd/production.min.js)
- 🔴 有些包的
package.json#exports 封闭了子路径,require.resolve 找不到
- 🔴 有些场景想用完全自定义的兜底版本
project.config.ts
{
name: 'vue',
localFallback: 'node_modules/vue/dist/vue.global.prod.js'
}
查找优先级:
1. entry.localFallback (用户显式指定)
↓ miss
2. require.resolve(<name>/package.json) → 扫候选路径:
- dist/<name>.umd.production.min.js
- dist/<name>.production.min.js
- dist/<name>.umd.js
- umd/<name>.production.min.js
↓ miss
3. 文件系统递归扫 node_modules/<name>/dist/**/*.umd.production.min.js
六、❌ 失败的尝试:React 19 CDN
6.1 React 19 不再发 UMD
:::danger 重大变化
React 18 还有 umd/react.production.min.js;React 19 彻底删除了 UMD 构建。
官方理由:"生态已经进 ESM 时代"。
现实后果:
- CDN URL
https://cdn.jsdelivr.net/npm/react@19/umd/react.production.min.js → 404
- React 19 的 ESM 构建走
<script type="module">,但 classic <script> loader 模式不兼容
:::
6.2 尝试 Preact 替代
Preact 有完整 UMD,社区成熟度高。但踩了一串坑:
| 坑 |
现象 |
解法 |
| 1 |
window.preactCompat ≠ window.React |
新增 aliasGlobals |
| 2 |
Preact compat 没 createRoot |
新增 initScript |
| 3 |
react-router-dom 内部 import 没改写 |
去掉 transform() 里 node_modules 跳过 |
| 4 |
react-router-dom 依赖 @remix-run/router(无 UMD) |
router 不走 CDN,打进 bundle |
| 5 |
require.resolve('preact/hooks/dist/hooks.umd.js') 失败 |
新增文件系统直查 fallback |
6.3 最终决定
:::warning 决策
经过五轮修复后,Preact 方案能跑,但对 React 生态的兼容性不稳定(react-router-dom 升级后可能再次踩坑)。
最终策略:
- ✅
examples/rmpa 关闭 CDN,React 19 全量打进 bundle
- ✅ CDN 能力在 kit 里保留(
aliasGlobals / initScript / localFallback 三板斧都可用)
- ✅
examples/vmpa (Vue3) 用 CDN 外挂作为演示(Vue UMD 长期稳定)
- ✅ React 项目想用 CDN 走 Preact compat 路径,文档给完整 recipe
:::
这个决定看似"失败",但本质是把"CDN 方案能不能落地"从代码问题变成了**"是否符合你项目的生态假设"**。未来 React 19 的 ESM CDN 成熟后可以直接接入。
七、📦 离线场景
7.1 问题
离线 WebView 容器里网络不可达,CDN URLs 全部 404。
不做特殊处理的结果
t=0ms loader 启动
t=100ms <script src="https://..." onerror>
↓
DNS 失败 + TCP SYN 超时 (2~5s)
↓
onerror 触发 → _failUrl → 尝试下一个 URL
↓
再等一次 2~5s
↓
最终 loadLocalFallback
↓
t=10s+ 终于加载完
7.2 解决:stripCdnUrlsFromHtml
packages/offline/src/index.ts::stripCdnUrlsFromHtml
// 1. 把 loader 块里的 "urls":[...] 替换成 "urls":[]
// 2. 删除所有 <script src="https://..."> 标签
离线包中的 HTML
1<!-- lhx-kit: CDN loader -->
2<script>
3var plan = {entries:[{name:'vue',globalVar:'Vue',urls:[]}], fallback:'local'};
4// ...
5</script>
6<!-- /lhx-kit: CDN loader -->
效果:
离线 WebView 里的行为
t=0ms loader 启动
发现 urls 数组空
↓ 直接
t=0ms loadLocalFallback(entry)
dynamic import('/shared/vendor/vue.js')
↓
t=50ms 本地 vendor 加载成功
state.vue = 'fallback'
零网络请求,首屏提速 10s+。
八、📋 配置 API 参考
8.1 project.config.ts::cdn
| 字段 |
类型 |
默认 |
说明 |
enabled |
boolean |
false |
全局开关 |
applyOn |
('dev'|'build'|'preview')[] |
['build'] |
哪些命令启用 |
fallback |
'local'|'error' |
'local' |
CDN 全挂时策略 |
timeoutMs |
number |
5000 |
保留字段,当前版本暂未启用 |
globalNamespace |
string |
'LhxCdn' |
window.LhxCdn 的挂载名 |
entries |
CdnEntry[] |
[] |
待外挂的包列表 |
8.2 CdnEntry
| 字段 |
类型 |
必填 |
说明 |
name |
string |
✅ |
npm 包名,loader 状态表的 key |
globalVar |
string |
|
UMD 全局名;默认按 kebab→PascalCase 推导 |
urls |
string[] |
✅ |
CDN URL 列表,按顺序尝试 |
depends |
string[] |
|
此 entry 依赖的其他 entry 名 |
externals |
string[] |
|
要映射到 globalVar 的 bare specifier;默认 [name] |
localFallback |
string |
|
覆盖自动扫描的本地 UMD 路径 |
aliasGlobals |
string[] |
|
entry 加载后追加的 window[x] = window[globalVar] 别名 |
initScript |
string |
|
alias 后执行的 polyfill 片段 |
九、👤 用户侧运行时 API
CDN 激活后,window[namespace](默认 window.LhxCdn)提供:
API 类型
interface LhxCdnApi {
/** 等待一批 entry 就绪(ok 或 fallback 都算) */
whenReady(names: string[]): Promise<void>;
/** 订阅生命周期 */
on(event: 'ok', h: (e: {name: string; url: string}) => void): () => void;
on(event: 'fallback', h: (e: {name: string; url: string}) => void): () => void;
on(event: 'failed', h: (e: {name: string; reason: string}) => void): () => void;
on(event: 'ready', h: (e: {names: string[]}) => void): () => void;
/** 只读状态(调试用) */
readonly state: Readonly<Record<string, 'pending'|'ok'|'fallback'|'failed'>>;
readonly plan: CdnPlan;
}
典型使用:
src/bootstrap.ts showLineNumbers
import {createApp} from 'vue';
// 等 CDN 加载完成再启动
await window.LhxCdn.whenReady(['vue', 'pinia', 'vue-router']);
// 此后可以放心 import Vue APIs
createApp(App).use(router).use(pinia).mount('#app');
监控埋点:
上报 CDN 降级事件
window.LhxCdn.on('fallback', ({name, url}) => {
analytics.track('cdn.fallback', {package: name, attempted: url});
});
window.LhxCdn.on('failed', ({name, reason}) => {
analytics.track('cdn.failed', {package: name, reason});
});
十、🏗️ 架构设计回顾
┌─────────────────────────────────────────────────┐
│ 构建阶段 │
├─────────────────────────────────────────────────┤
│ project.config.ts (cdn.entries) │
│ ↓ │
│ @lhx-kit/vite-plugin │
│ ├─ resolveCdnEntries() │
│ ├─ transform() → 源码改写 bare imports │
│ ├─ emitFile() → shared/vendor/*.js │
│ └─ generateBundle() → 注入 loader HTML │
└─────────────────────────────────────────────────┘
↓ dist/*
┌─────────────────────────────────────────────────┐
│ 运行时 │
├─────────────────────────────────────────────────┤
│ 浏览器加载 HTML │
│ └─ 执行 inline loader (ES5 IIFE) │
│ ├─ 注册 window.LhxCdn API │
│ ├─ 装 script 标签顺序加载 CDN UMD │
│ ├─ onerror → 降级链 │
│ ├─ 全挂 → loadLocalFallback (dynamic import) │
│ └─ applyAliases + initScript │
│ │
│ 之后加载 entry module │
│ └─ const {createApp} = window.Vue │
└─────────────────────────────────────────────────┘
十一、📚 相关资源