Vite + Vue3 构建优化:CDN 外部化方案
背景
默认情况下,Vite 会将项目所有依赖(包括 vue、element-plus 等体积较大的第三方库)全部打入 bundle。这会导致:
-
首屏 JS 体积过大,加载缓慢
-
每次部署都需要重新下载相同的库代码
-
无法利用 CDN 的边缘节点加速和浏览器长效缓存
CDN 外部化的核心思路:将这些大型依赖从 bundle 中剔除,改由 CDN 在运行时加载,应用 bundle 只包含业务代码。
环境信息
| 工具 | 版本 |
|------|------|
| Vite | ^8.0.0 |
| Vue | ^3.5.30 |
| Element Plus | ^2.9.10 |
| TypeScript | ~5.9.3 |
| 包管理器 | pnpm |
方案一:vite-plugin-cdn-import(推荐多库场景)
插件在构建时自动将指定模块外部化,并向 index.html 的 <head> 中注入对应的 CDN <script> 标签,无需手动维护 HTML。
安装
pnpm i vite-plugin-cdn-import --save-dev
配置 vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { Plugin as importToCDN } from "vite-plugin-cdn-import";
export default defineConfig({
plugins: [
vue(),
importToCDN({
modules: [
{
name: "vue", // 对应 import 语句中的包名
var: "Vue", // CDN 脚本在 window 上暴露的全局变量名
path: "https://cdn.jsdelivr.net/npm/vue@3.5.30/dist/vue.global.prod.js",
},
{
name: "element-plus",
var: "ElementPlus",
path: "https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.full.min.js",
css: "https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.css",
},
{
name: "@element-plus/icons-vue",
var: "ElementPlusIconsVue",
path: "https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js",
},
],
}),
],
});
字段说明
| 字段 | 说明 |
|------|------|
| name | 与 import ... from 'xxx' 中的包名完全一致 |
| var | CDN 脚本挂载到 window 的全局变量名,必须与 CDN 文件实际暴露的名称一致 |
| path | CDN 脚本地址,建议锁定到具体版本号 |
| css | 可选,有样式文件的库(如 element-plus)填写 CSS 的 CDN 地址 |
构建后效果
插件自动在 dist/index.html 的 <head> 中注入:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.30/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
打包产物中不再包含上述库的代码,bundle 体积显著减小。
优缺点
优点
-
配置集中,一处修改即可同步 HTML 和 bundle 外部化
-
支持批量配置多个库,支持 CSS CDN 一并处理
-
无需手动维护 HTML
缺点
-
引入第三方插件依赖,存在维护和兼容风险
-
插件内部行为不透明,出问题时排查较难
-
内置预设版本可能滞后,需手动指定
path确保版本一致
方案二:手动配置(原生 Rollup external)
不依赖任何额外插件,直接使用 Vite/Rollup 的原生能力将模块外部化,手动在 HTML 中添加 CDN 链接。
第一步:配置 vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
// 声明外部依赖,不打入 bundle
external: ["vue", "element-plus", "@element-plus/icons-vue"],
output: {
// 告知 Rollup 运行时从哪个全局变量获取该模块
globals: {
vue: "Vue",
"element-plus": "ElementPlus",
"@element-plus/icons-vue": "ElementPlusIconsVue",
},
},
},
},
});
第二步:修改 index.html
在 <head> 中手动添加 CDN 标签,必须在应用入口脚本之前加载:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cdn-project</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.30/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.9.10/dist/index.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
已知问题:ESM 裸模块报错
Uncaught TypeError: Failed to resolve module specifier "vue".
Relative references must start with either "/", "./", or "../".
原因: Vite 默认输出 ESM 格式,打包产物中保留 import { ... } from 'vue' 语句。
浏览器无法解析 'vue' 这样的裸模块标识符,而 globals 配置只对 UMD/IIFE 格式生效。
解决方案: 在 index.html 中添加 Import Map,将裸模块映射到 CDN 地址,
同时需使用 Vue 的 ESM 版本(非 vue.global.prod.js):
<script type="importmap">
{
"imports": {
"vue": "https://cdn.jsdelivr.net/npm/vue@3.5.30/dist/vue.esm-browser.prod.js"
}
}
</script>
优缺点
优点
-
零额外插件依赖,使用 Rollup 原生机制,行为完全可预期
-
可精确控制 script 的
crossorigin、integrity、加载顺序等属性 -
适合 SSR、多入口等复杂 HTML 模板场景
缺点
-
需同时维护
vite.config.ts和index.html两处,库多时容易遗漏 -
版本管理分散,CDN 地址与
package.json版本需手动保持同步 -
ESM 模式下需额外配置 Import Map
方案选择建议
| 场景 | 推荐方案 |
|------|---------|
| 需要 CDN 化的库较多 | 方案一 |
| 项目长期维护、不想引入额外插件 | 方案二 |
| 需要精确控制 script 加载属性 | 方案二 |
| 快速原型 / 中小项目 | 方案一 |
Element Plus 接入完整指南
安装本地依赖
无论 dev 还是 build,都需要本地安装 element-plus。dev 时直接使用本地包,build 时被 CDN external 接管不打入 bundle。
pnpm add element-plus @element-plus/icons-vue
main.ts 配置
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import './style.css'
import App from './App.vue'
// dev 模式走本地样式,build 后由 CDN css 字段注入,避免样式打入 bundle
if (import.meta.env.DEV) {
await import('element-plus/dist/index.css')
}
createApp(App).use(ElementPlus).mount('#app')
组件使用
在 .vue 文件中从 element-plus 根包直接 import(注意不是 element-plus/es):
import { ElButton, ElDialog, ElInput } from 'element-plus'
示例:
<script setup lang="ts">
import { ref } from 'vue'
import { ElButton, ElDialog, ElInput } from 'element-plus'
const inputVal = ref('')
const dialogVisible = ref(false)
const loading = ref(false)
function handleClick() {
loading.value = true
setTimeout(() => {
loading.value = false
dialogVisible.value = true
}, 1000)
}
</script>
<template>
<el-input v-model="inputVal" placeholder="请输入内容" clearable />
<el-button type="primary" :loading="loading" @click="handleClick">
打开 Dialog
</el-button>
<el-dialog v-model="dialogVisible" title="提示" width="360px">
<span>输入内容:{{ inputVal || '(空)' }}</span>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
Element Plus 接入踩坑记录
坑一:ElementPlus is not defined
现象: 页面白屏,控制台报 Uncaught ReferenceError: ElementPlus is not defined。
原因: vite-plugin-cdn-import 只在 build 时注入 CDN script 标签,dev 模式下不注入,
window.ElementPlus 不存在。若 main.ts 中用 declare const ElementPlus: any 并引用 CDN 全局变量,
dev 时必然报错。
错误写法:
declare const ElementPlus: any
createApp(App).use(ElementPlus).mount('#app') // dev 模式报错
正确写法: 本地安装 element-plus,直接 import:
import ElementPlus from 'element-plus'
createApp(App).use(ElementPlus).mount('#app')
坑二:element-plus 样式被打入 bundle
现象: build 后 dist/assets/index-xxx.css 文件异常庞大,包含完整 element-plus 样式。
原因: import 'element-plus/dist/index.css' 是静态导入,Vite build 时直接处理为 CSS bundle,
不受 JS external 规则影响,CSS 文件会被完整打入产物。
错误写法:
import 'element-plus/dist/index.css' // 静态导入,build 后打入 bundle
正确写法: 用 import.meta.env.DEV 条件隔离,build 时整个 if 块被 tree-shake 掉:
if (import.meta.env.DEV) {
await import('element-plus/dist/index.css')
}
build 后样式由 vite-plugin-cdn-import 的 css 字段注入到 index.html,不再进入 bundle。
坑三:ElementPlusResolver 导致 build 后组件失效
现象: dev 正常,build 后 el-dialog、el-button 等组件无法渲染或交互失效。
原因: unplugin-vue-components 的 ElementPlusResolver 默认将组件解析为子路径导入:
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
vite-plugin-cdn-import 的 external 规则只覆盖 element-plus 根包,
element-plus/es/... 子路径不在 external 列表中,会被完整打入 bundle。
bundle 中存在本地 element-plus 代码,与 CDN 加载的 window.ElementPlus 形成双实例,
导致组件行为异常。
错误写法:
// ElementPlusResolver 走 element-plus/es 子路径,不被 external 覆盖
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
})
正确写法: 从 element-plus 根包直接 import,根包在 external 列表中,build 后正确映射到 window.ElementPlus:
// 在组件中手动从根包导入
import { ElButton, ElDialog, ElInput } from 'element-plus'
| 导入来源 | 是否被 external | build 结果 |
|---------|----------------|-----------|
| element-plus/es/components/dialog/index.mjs | 否 | 打入 bundle,形成双实例 |
| element-plus(根包) | 是 | 替换为 window.ElementPlus.ElDialog |
坑四:import 'element-plus/es/...' 本地文件找不到
现象: [plugin:vite:import-analysis] Failed to resolve import "element-plus/es/components/base/style/css"
原因: ElementPlusResolver 默认还会尝试按需注入样式:
import 'element-plus/es/components/dialog/style/css'
若未安装 element-plus 本地包,这些文件不存在,dev 模式直接报错。
解决: 安装本地依赖 pnpm add element-plus,或设置 importStyle: false 禁用样式注入
(但根本上应换用直接从根包 import 的方式,彻底避开子路径问题)。
Nginx 部署配置
Vue SPA 项目部署后需要配置 try_files 支持前端路由,同时应开启 Gzip 并为静态资源设置缓存策略。
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Gzip 压缩(JS/CSS 体积减少 60-70%)
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css application/javascript
application/json application/xml
image/svg+xml font/woff2;
gzip_vary on;
server {
listen 8999;
server_name localhost;
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
location /app {
root html/dist;
index index.html index.htm;
try_files $uri $uri/ /app/index.html;
# index.html 不缓存,确保每次部署后用户立即获取新版本
location = /app/index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Vite 输出的 assets 文件名含 hash,可长期缓存
location ~* /app/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
location /mapp {
root html/dist;
index index.html index.htm;
try_files $uri $uri/ /mapp/index.html;
location = /mapp/index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ~* /mapp/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
}
缓存策略说明
| 资源类型 | 缓存策略 | 原因 |
|---------|---------|------|
| index.html | 不缓存 | 入口文件需每次获取最新版本,否则更新部署后用户无感知 |
| assets/ 下的 JS/CSS | 缓存 1 年 | Vite 构建产物文件名含内容 hash,内容变化则文件名变化,可安全长期缓存 |
构建产物分析
如需可视化分析 bundle 组成,排查是否有意外打入的依赖:
npx vite-bundle-visualizer
执行后在浏览器中打开生成的报告,可直观看到各模块在 bundle 中的占比。
常见问题
Q:var 填写的全局变量名怎么确认?
打开 CDN 链接对应的 JS 文件,搜索文件末尾的 global.xxx = 或 window.xxx =,xxx 即为全局变量名。
或直接在浏览器控制台加载该脚本后,查看 window 对象新增了哪个键。
Q:CDN 访问失败导致页面白屏怎么办?
建议在生产环境准备备用 CDN 地址(如同时配置 jsDelivr 和 unpkg),
或在内网环境下将 CDN 文件托管到自己的静态服务器,而非依赖公共 CDN。
Q:怎么判断 element-plus 是否真的没有打入 bundle?
build 后执行以下命令分析产物:
npx vite-bundle-visualizer
或直接检查 dist/assets/index-xxx.js 文件大小。配置正确时,该文件应只包含业务代码,
通常在几十 KB 以内,不会出现 MB 级别的文件。