Vite + Vue3 构建优化:CDN 外部化方案

5 阅读8分钟

Vite + Vue3 构建优化:CDN 外部化方案

背景

默认情况下,Vite 会将项目所有依赖(包括 vueelement-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 的 crossoriginintegrity、加载顺序等属性

  • 适合 SSR、多入口等复杂 HTML 模板场景

缺点

  • 需同时维护 vite.config.tsindex.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-importcss 字段注入到 index.html,不再进入 bundle。


坑三:ElementPlusResolver 导致 build 后组件失效

现象: dev 正常,build 后 el-dialogel-button 等组件无法渲染或交互失效。

原因: unplugin-vue-componentsElementPlusResolver 默认将组件解析为子路径导入:


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 级别的文件。