Webpack迁到Vite第一节:模块系统迁移:require → import

Webpack迁到Vite第一节:模块系统迁移:require → import

本篇定位 :迁移的第一块基石------Webpack 的 require() 体系(含 require.context)如何替换为 ESM 的 import。从两者打包原理的差异讲起,逐文件拆解改造过程,并给出 Vue 模板中 require() 的兼容结论。

思维导图

复制代码
模块系统迁移
├── 1.1 核心概念:打包原理对比
│   ├── 1.1.1 Webpack 打包原理
│   │   ├── 构建时:模块工厂化(每个文件包裹为函数)
│   │   ├── 运行时:__webpack_require__ 自实现模块加载器
│   │   └── 遵循的规范:无(自实现仿 CommonJS 运行时)
│   ├── 1.1.2 Vite 打包原理
│   │   ├── 开发模式:浏览器原生 ESM + esbuild 按需编译
│   │   ├── 依赖预构建:esbuild 转换 CJS→ESM
│   │   ├── 生产构建:委托 Rollup
│   │   └── 遵循的规范:ESM / WHATWG Fetch / Rollup 标准
│   ├── 1.1.3 核心差异对比
│   ├── 1.1.4 各自遵从的规范
│   └── 1.1.5 为什么 require() 不能用了
├── 1.2 require() → import 改造
│   ├── 1.2.1 user.js(动态路由引入 → 优化解构)
│   ├── 1.2.2 util.js(图片引入 → 已迁移 → 提升开销分析)
│   ├── 1.2.3 已成功迁移案例(envelopeMixin.js / envelopeDownloadHook.js)
│   └── 1.2.4 main.js(动态 import() 按需加载)
├── 1.3 require.context → Vite 虚拟模块(virtual:svg-icons)
│   ├── 什么是 require.context
│   ├── import.meta.glob 语法(Vite 专有,非标准)
│   ├── Glob 语法 vs 正则
│   ├── 编译时转换原理(源代码 → 展开的 import)
│   └── eager: true 的作用
├── 1.4 默认导出 vs 命名导出
│   ├── Webpack 5 实测:警告但不阻断(运行时 undefined)
│   ├── Rollup 实测:直接报错拒绝构建
│   ├── ESM 规范的准确定义
│   └── 命名导入 → 默认导入修复(示意案例)
└── 1.5 模板中的 require()
    ├── @vitejs/plugin-vue2 自动处理(transformRequireToImport 正则替换编译产物)
    ├── 完整转换流程(6 步:编译→扫描→变量名→import→替换→Vite 接管)
    ├── vue-template-compiler 与 vue/compiler-sfc 的区别
    └── 为什么 <script> 不处理(4 个原因)

1.1 核心概念:打包原理对比

1.1.1 Webpack 打包原理

Webpack 的核心是一个自实现的模块系统。打包过程分两步:

① 构建时 --- 模块工厂化

从入口文件出发,递归追踪所有 import/require 依赖,将每个源文件包装为一个模块工厂函数,注入到 bundle 中:

javascript 复制代码
// Webpack 构建产物(简化)
var modules = {
  "./src/utils.js": function(module, exports, __webpack_require__) {
    // 原文件内容被包裹在函数里,形成模块作用域
    var _ = __webpack_require__("lodash");
    exports.format = function() { /* ... */ };
  },
  "./src/app.js": function(module, exports, __webpack_require__) {
    var utils = __webpack_require__("./src/utils.js");
    // ...
  }
};

每个文件被编译为一个函数,接收 module、exports、__webpack_require__ 三个参数。这种"工厂函数"模式实现了模块作用域隔离------每个模块的变量不会泄露到全局。

② 运行时 --- 自实现模块加载器

浏览器加载 bundle 后,Webpack 自实现的 __webpack_require__ 函数接管模块加载:

javascript 复制代码
var installedModules = {}; // 模块缓存(对应 CommonJS 的 require.cache)

function __webpack_require__(moduleId) {
  // 缓存命中 → 直接返回
  if (installedModules[moduleId]) return installedModules[moduleId].exports;

  // 创建模块对象(对应 CommonJS 的 module/exports)
  var module = installedModules[moduleId] = {
    i: moduleId,     // 模块 ID
    l: false,        // loaded 标记
    exports: {}      // 导出对象
  };

  // 执行模块工厂函数,传入 module、exports、require
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  module.l = true;   // 标记已加载
  return module.exports;
}

③ 遵循的规范:无

__webpack_require__ 是 Webpack 在浏览器中自行实现的一套模块加载运行时。它模仿了 CommonJS 的三个核心语义:

CommonJS 语义 Webpack 实现
module.exports 和 exports 是同一个引用 call(module.exports, module, module.exports, ...)
模块加载后缓存,第二次 require 直接返回缓存 installedModules 检查
循环引用时返回未完成的 exports 工厂函数执行前已挂载 module 对象

但它不遵循任何正式规范 ------既不是 Node.js 的 require,也不是 CommonJS 标准的实现。CommonJS 规范只定义了 API 语义(require/module.exports/exports),从未定义浏览器中的模块加载机制。Webpack 在此基础上额外增加了:

  • 模块 ID 是数字或路径字符串(构建时分配,非文件系统路径)
  • 动态 import() 触发的代码分割与 chunk 加载
  • HMR 热模块替换(accept/dispose 生命周期)
  • ESM 与 CommonJS 的互操作(__esModule 标记)

一句话 :Webpack 在浏览器里模拟了一套模块系统,自己实现打包、编译、加载全链路,不遵循任何外部规范。


1.1.2 Vite 打包原理

Vite 将开发与构建分离为两套独立机制,各司其职:

① 开发模式 --- 浏览器原生 ESM

复制代码
浏览器请求 http://localhost:8080/src/main.js
  │
  ├── Vite Dev Server 接收请求
  │   ├── 读取 src/main.js 源文件
  │   ├── esbuild 编译(TS→JS、JSX→JS,仅类型擦除)
  │   ├── 改写 import 路径
  │   │   裸路径:import vue from 'vue' → import vue from '/node_modules/.vite/deps/vue.js'
  │   │   相对路径:import App from './App.vue' → import App from '/src/App.vue'
  │   └── 返回标准 ESM 代码
  │
  ├── 浏览器解析返回的代码,发现新的 import
  │   ├── /node_modules/.vite/deps/vue.js → Vite 返回预构建的 ESM 版本
  │   ├── /src/App.vue → Vite 编译 SFC 返回 JS
  │   └── /src/style.less → Vite 编译 Less 返回 CSS,注入 HMR 代码
  │
  └── 每个请求独立编译、按需返回

核心 :不打包,不实现模块系统。浏览器原生 import/export 就是模块系统,Vite 只负责编译和路径改写。

/@id/ 前缀说明 :开发模式下绝大多数模块的 URL 直接映射为文件路径(如 /src/main.js、/node_modules/.vite/deps/vue.js);对于无法直接映射为合法 URL 的模块(虚拟模块、含特殊字符的模块 ID 等),Vite 会加上 /@id/ 前缀,如虚拟模块 virtual:svg-icons 在开发模式下对应 /@id/virtual:svg-icons。

② 依赖预构建 --- esbuild

node_modules 中的包有两个问题:CJS 格式(浏览器不支持)和碎片文件(如 lodash 有 600+ 个小文件)。Vite 开发服务器启动时用 esbuild 预先处理:

复制代码
首次 npx vite
  │
  ├── 扫描 src/ 中所有 import 语句,找到裸路径依赖
  ├── 对每个依赖用 esbuild 打包
  │   ├── 入口:包的 ESM 入口(或 CJS 入口)
  │   ├── CJS → ESM 转换:module.exports = xxx → export default xxx
  │   ├── 合并碎片文件:lodash 600+ 文件 → 1 个 ESM 文件
  │   └── 输出到 node_modules/.vite/deps/
  └── 浏览器请求 /node_modules/.vite/deps/lodash.js 时直接返回预构建结果

③ 生产构建 --- 委托 Rollup

复制代码
npm run build
  │
  ├── ① Rollup 分析依赖图
  │   从 index.html → src/main.js → 递归追踪所有 import
  │
  ├── ② 插件处理
  │   vue() 编译 .vue 文件、viteCompression() 生成 .gz、visualizer() 生成分析报告
  │
  ├── ③ Rollup 打包
  │   Tree-shaking(基于 ESM 静态分析,比 Webpack 更精确)
  │   代码分割(manualChunks 配置)
  │   输出标准 ESM 格式到 dist/
  │
  └── ④ esbuild 压缩
      minify JS/CSS、drop console、生成 sourcemap

核心 :Vite 不自己实现打包器,而是委托 Rollup 完成。Rollup 本身就是 ESM 原生打包器,输出遵循 ESM 标准。

④ 遵循的规范

环节 遵循的规范
模块系统 ESM (ES2015+,import/export 浏览器原生支持)
开发通信 WHATWG Fetch (HTTP 请求获取模块,Content-Type: application/javascript)
路径解析 import.meta (import.meta.glob、import.meta.url)
构建输出 Rollup 标准 ESM 格式(tree-shaking、code splitting 均基于 ESM 静态分析)
编译 esbuild(ES2020+、TypeScript、JSX)
CSS 原生 CSS import + Less/Sass 编译器
资源 浏览器原生 import + Vite 约定的 ?url/?raw query 后缀(非标准)

一句话 :Vite 是直接使用浏览器原生的模块系统(ESM),编译委托 esbuild,打包委托 Rollup------每个环节都遵循对应的标准规范,不自创运行时。


1.1.3 核心差异对比

Webpack Vite
模块系统 自实现 __webpack_require__ 运行时 开发:浏览器原生 ESM;构建:Rollup 输出标准 ESM
开发启动 全量打包后才启动(30-60s) 直接启动,按需编译(<1s)
HMR 重新打包变更模块链 ESM 动态替换,粒度更细(<100ms)
编译工具 自实现 loader 链(babel-loader、css-loader...) 委托 esbuild(开发)/ Rollup plugins(构建)
打包器 自实现(Webpack 核心) 委托 Rollup(ESM 原生打包器)
CJS 兼容 原生支持(__webpack_require__ 直接处理) 预构建转换(esbuild 转 CJS→ESM)
Tree-shaking 基于 usedExports 标记(可能误判副作用) 基于 ESM 静态分析(import 是静态的,更精确)
模块解析 自实现 enhanced-resolve(仿 Node.js 算法) esbuild/Rollup 内置 resolver

1.1.4 各自遵从的规范

Webpack Vite
模块规范 无标准,自实现仿 CommonJS 运行时 ESM(ES2015+,浏览器原生支持)
模块解析 自实现 enhanced-resolve(仿 Node.js 算法) esbuild/Rollup 内置 resolver
构建输出 自实现 bundle 格式(IIFE/UMD/CJS/ESM) 委托 Rollup,输出标准 ESM
开发环境 自实现模块系统 + WebSocket HMR WHATWG Fetch (HTTP 请求)+ WebSocket HMR
CSS 处理 自实现 css-loader/style-loader(注入 <style> 或提取文件) 委托 Less/Sass 编译器,注入 <style> 标签
资源处理 自实现 file-loader/url-loader(文件复制/Base64 内联) 浏览器原生 import + Vite 约定的 ?url/?raw query 后缀(非标准)

核心区别:Webpack 是"全能运行时"------自己实现模块加载、编译、打包、HMR 全链路,所有环节都是自创的。Vite 是"标准委托"------开发时用浏览器原生 ESM,编译委托 esbuild,打包委托 Rollup,每个环节都遵循对应的标准规范。


1.1.5 为什么 require() 不能用了

了解了以上原理后,这个问题的答案就很清楚了:

Webpack 开发模式 :所有文件被打包成 bundle,require() 由 Webpack 运行时 __webpack_require__ 提供 → 可用。

Vite 开发模式 :浏览器通过 <script type="module"> 直接加载源文件,import/export 是浏览器原生支持的,但 require() 不是 → 报错:

复制代码
Uncaught ReferenceError: require is not defined

这就是所有 require() → import 改动的根本原因。后续 §1.2~§1.5 详述每一处改动的具体做法。


1.2 require() → import 逐文件改造

1.2.1 src/store/modules/user.js --- 动态路由引入

原代码:

javascript 复制代码
const constantRouterMap = window.__isMobile__
  ? require('@/router/h5').constantRouterMap
  : require('@/router/pc').constantRouterMap
const asyncRouterMap = window.__isMobile__
  ? require('@/router/h5').asyncRouterMap
  : require('@/router/pc').asyncRouterMap
const router = window.__isMobile__
  ? require('@/router/h5').default
  : require('@/router/pc').default

为什么这样写 :根据 window.__isMobile__ 判断是移动端还是 PC 端,动态加载对应的路由文件。Webpack 的 require() 是同步的,可以直接在模块顶层使用。

改为:

javascript 复制代码
import * as h5Router from '@/router/h5'
import * as pcRouter from '@/router/pc'

window.__isMobile__ = navigator.userAgent.toLowerCase().indexOf('mobile') > -1
const activeRouter = window.__isMobile__ ? h5Router : pcRouter
const { constantRouterMap, asyncRouterMap, default: router } = activeRouter

为什么这样改:

  • import 是静态的,必须在模块顶层声明
  • import * as xxx from '...' 导入模块的所有导出(命名空间导入)
  • 同时导入两个路由文件,在运行时根据条件选择使用哪个
  • 先用一次条件判断选定活跃路由模块,再通过解构提取三个变量,避免三处重复的 window.__isMobile__ ? ... : ...
  • 两个路由文件都不大,同时导入不会有性能问题
  • 不能用 import() 动态导入(返回 Promise),因为这里的代码是同步的

为什么不改成这样:

javascript 复制代码
// ❌ 不能这样改
const { constantRouterMap } = window.__isMobile__
  ? await import('@/router/h5')
  : await import('@/router/pc')

因为模块顶层不能用 await(除非是 ES2022 的 top-level await,但项目配置不支持)。


1.2.2 src/utils/util.js --- 图片引入(已迁移)

原代码 (Webpack 时期,openUserGuide 函数体内):

javascript 复制代码
const tipsUrl = require('@/assets/images/djq-tips.png')

迁移后 (Vite 时期,提升到 util.js 顶部第 6 行):

javascript 复制代码
import tipsUrl from '@/assets/images/djq-tips.png'

迁移状态 :已完成 。函数体内的 require() 已提升为文件顶部的 import,openUserGuide(util.js 第 583 行)内部直接使用该变量(第 606 行)。

迁移决策说明 :初稿时此项属于低优先级(openUserGuide 仅在打开用户手册链接时调用),后随迁移收尾一并完成。对图片/静态资源而言,提升到顶部无副作用------见下方分析。

"函数体内 require() → 顶部 import" 的行为分析:

对于图片/静态资源,require() 在函数体内和 import 在顶部,构建时行为完全一致:

构建阶段 require() 在函数内(Webpack) import 在顶部(Vite)
静态分析 ✅ 字符串字面量可追踪 ✅ import 语句可追踪
图片处理 输出到 dist/,生成哈希文件名 完全相同
代码替换 调用处替换为 URL 字符串 变量绑定为 URL 字符串
图片是否内联到 JS 否(除非 <4KB 触发 base64) 否(同上)
最终产物 一个独立图片文件 + JS 中一个 URL 字符串 完全相同

真正的差异只有赋值时机 :函数体内的 require() 在函数被调用时才赋值,顶部 import 在模块加载时就赋值。图片文件不会因为提升而额外加载,JS bundle 体积也不变。

什么时候需要担心 :如果 require() 引用的是重 JS 模块 (几百 KB 的逻辑代码),且该模块在极少数场景下才使用,则提升到顶部会导致该模块总是被加载。此时可以用动态 import() 保持按需加载:

javascript 复制代码
// 原(函数体内 require,按需)
function handleClick() {
  const heavyLib = require('heavy-library')
  heavyLib.process()
}

// 改(动态 import,保持按需)
async function handleClick() {
  const { default: heavyLib } = await import('heavy-library')
  heavyLib.process()
}

但动态 import() 返回 Promise,需要把调用方改为 async/await,改动面更大。所以只在模块确实很重时才这样做。


1.2.3 已成功迁移的图片引入案例

以下文件中的 require() 图片引入已成功迁移为顶部 import:

src/views/pc/sign/mixins/envelopeMixin.js

迁移的三张图片:

javascript 复制代码
import tipsUrl from '@/assets/images/tips.png'
import ukeyIcon from '@/assets/images/certType/ukey.png'
import mobileCertIcon from '@/assets/images/certType/mobileCert.png'

迁移方式 :从各函数体内的 require() 直接提升到文件顶部,改为 import 语句。tipsUrl 用于签署页面的提示图标,ukeyIcon 和 mobileCertIcon 用于证书类型选择时的图标展示。由于这三张图片在 mixin 中多处使用,提升到顶部还避免了重复的 require() 调用。

src/views/h5/envelope/envelopeDownloadHook.js

迁移的图片:

javascript 复制代码
import tipsUrl from '@/assets/images/djq-tips.png'

迁移方式 :与该文件同目录下的 envelopeDownloadHook.js 中,tipsUrl 原在 handleDownloadRuntime 函数体内通过 require() 引入,改为顶部 import。该图片用于移动端非浏览器环境下载时弹出的提示弹窗中。

注意 :src/utils/util.js 中 openUserGuide 函数也使用了相同的图片 djq-tips.png,现已采用相同的导入模式:import tipsUrl from '@/assets/images/djq-tips.png'(见 §1.2.2)。两个文件导入的是同一张图片。


1.2.4 src/main.js --- 动态 import() 按需加载

src/main.js 没有使用 require(),但采用了 async function main() + import() 动态导入 模式,实现双端路由/权限/App/UI 组件的按需加载。这是 Vite 下替代 require() 条件加载的标准方案,值得单独说明。

实际代码 (src/main.js):

javascript 复制代码
async function main() {
  let routerPromise
  let appPromise
  let permissionPromise
  let uiComponentsPromise
  if (window.__isMobile__) {
    routerPromise = import('./router/h5').then((data) => data.default)
    permissionPromise = import('./router/h5Permission')
    appPromise = import('@/views/h5/App').then((data) => data.default)
    uiComponentsPromise = import('@/components/h5/vant/index').then((data) => data.default)
  } else {
    routerPromise = import('./router/pc').then((data) => data.default)
    permissionPromise = import('./router/pcPermission')
    appPromise = import('@/views/pc/App').then((data) => data.default)
    uiComponentsPromise = import('@/components/pc/antd').then((data) => data.default)
  }

  const [router, app, uiComponents] = await Promise.all([
    routerPromise, appPromise, uiComponentsPromise, permissionPromise
  ])

  Vue.use(uiComponents)
  Vue.use(AuthConfirm)
  new Vue({
    router,
    store,
    i18n,
    render: (h) => h(app),
  }).$mount('#app');
}

main()

import() 动态导入 vs require() 条件加载:

特性 require() 条件加载(Webpack) import() 动态导入(Vite)
同步/异步 同步 异步(返回 Promise)
代码分割 Webpack 自动分割为独立 chunk Vite/Rollup 自动分割为独立 chunk
按需加载 条件分支内 require() 只在执行到该分支时加载 仅在 if 分支内执行 import(),对应分支才加载
顶层 await 支持 不支持 需要 async function 包裹

为什么用 async function main() 包裹 :import() 返回 Promise,需要 await 等待所有模块加载完成后再创建 Vue 实例。ESM 模块不支持顶层 await(除非项目配置支持),因此需要一个 async 入口函数来包裹。

加载策略 :四个 import() 调用分别代表路由、权限守卫、App 根组件、UI 组件库。通过 if (window.__isMobile__) 分支,PC 端只加载 PC 相关的模块,H5 端只加载 H5 相关的模块,互不干扰。Promise.all 并行加载,减少串行等待时间。

对比 src/store/modules/user.js 的做法 :user.js 采用静态导入两个路由文件(import * as h5Router / import * as pcRouter),运行时条件选择。这是因为 user.js 是同步执行的模块(state 定义、mutations、actions 都是同步的),不适合用 import()。而 main.js 是应用启动入口,天然适合异步加载------在模块加载完成前,页面不会渲染,用户不会感知等待。


1.3 require.context → Vite 虚拟模块(virtual:svg-icons)

原代码

javascript 复制代码
const req = require.context('./', false, /\.svg$/)
const requireAll = (requireContext) => requireContext.keys().map(requireContext)
requireAll(req)

require.context 是什么

require.context 是 Webpack 特有的 API,用于批量导入文件。三个参数:

  • './':搜索目录
  • false:不搜索子目录
  • /\.svg$/:只匹配 .svg 文件

requireAll(req) 会执行所有匹配到的 SVG 文件,触发 svg-sprite-loader 将它们注册为 SVG symbol。

改为 Vite 虚拟模块方案

实际代码 (src/assets/svgIcons/index.js):

javascript 复制代码
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon'// svg component

// Vite 替代 svg-sprite-loader:虚拟模块在编译时合并 86 个 SVG 为单个请求
import 'virtual:svg-icons'

// register globally
Vue.component('svg-icon', SvgIcon)

为什么不是 import.meta.glob :虽然 import.meta.glob 是 Vite 提供的标准批量导入方案,但 SVG 图标系统的需求不仅仅是导入文件内容,还需要将 SVG 注册为可复用的 SVG Symbol(Sprite 图集)。import.meta.glob 不能直接实现 SVG Sprite 的构建和注册。因此采用了更合适的方案------Vite 插件虚拟模块。

virtual:svg-icons 是什么 :这是一个 Vite 插件(vite-plugin-svg-icons 或项目自定义插件)提供的虚拟模块标识符。虚拟模块是 Vite 插件机制的一部分------插件在 resolveId 钩子中拦截 virtual: 前缀的模块标识,在 load 钩子中动态生成模块内容。浏览器端看不到这个标识符,只在构建时由插件处理。

工作原理:

复制代码
源码中的 import 'virtual:svg-icons'
  │
  ▼ Vite 插件 resolveId 钩子
  识别 virtual: 前缀,标记为虚拟模块
  │
  ▼ Vite 插件 load 钩子
  扫描 src/assets/svgIcons/ 目录下所有 .svg 文件
  生成一个 JS 模块,内容包含:
  - 每个 SVG 文件的内容(作为字符串内联)
  - SVG Sprite 构建逻辑(将各 SVG symbol 合并到 DOM 中)
  │
  ▼ 构建产物
  一个包含所有 SVG 内容的 JS chunk(86 个 SVG 合并为单个请求)
  运行时将 SVG symbol 注入 DOM 的 <svg> 中,供全局 <use> 引用

对比:

方案 实现方式 适用场景
require.context + svg-sprite-loader Webpack loader 在构建时处理 SVG 并注册 symbol Webpack 项目
virtual:svg-icons Vite 插件虚拟模块,编译时扫描目录生成模块代码 Vite 项目(本项目实际采用)
import.meta.glob Vite 内置批量导入,返回文件路径或内容映射 通用批量导入(非 SVG Sprite 场景)

import.meta.glob 语法(Vite 专有,非标准)------作为对比参考:

import.meta.glob 是 Vite 专有的编译时语法糖 ,不是任何标准。与 require.context 一样,它是构建工具提供的批量导入能力,在构建时被展开为实际的 import 语句,浏览器运行时看不到它。

标准 vs 专有:

import.meta.url import.meta.glob
来源 ES2020 标准 Vite 专有扩展
运行时 浏览器原生支持 构建时转换,浏览器看不到

工作原理 :import.meta.glob 是编译时转换,不是运行时 API:

javascript 复制代码
// 源代码
const modules = import.meta.glob('./*.svg', { eager: true, query: '?raw' })

// Vite 构建后实际输出
import * as __glob__0_0 from './application.svg?raw'
import * as __glob__0_1 from './attachment.svg?raw'
const modules = {
  './application.svg': __glob__0_0,
  './attachment.svg': __glob__0_1,
}

对标:

Webpack Vite
require.context() import.meta.glob() 或 virtual:svg-icons(插件虚拟模块)
编译时转换 编译时转换
Webpack 专有 Vite 专有
都不是标准 都不是标准

参数解释 (import.meta.glob 的参考信息):

  • './*.svg':Glob 模式,匹配当前目录下所有 .svg 文件。Glob 不是正则,是专为文件路径匹配设计的通配符语法,比正则更简洁:
Glob 正则
任意字符 *(不含路径分隔符) .*
单字符 ? .
跨目录 **(递归匹配) 无直接等价
文件扩展名 *.svg .*\.svg(需转义)
设计目的 文件路径匹配 通用字符串模式匹配
  • eager: true:静态导入 (默认是动态导入,返回懒加载的 Promise)
    • eager: true → 返回 { [path]: Module },模块已加载
    • eager: false(默认)→ 返回 { [path]: () => Promise<Module> },需要调用才加载
  • query: '?raw':以原始文本方式导入文件内容,而不是返回文件 URL

为什么本项目的 SVG 没有用 import.meta.glob :SVG 图标系统需要将 SVG 文件注册为可复用的 SVG Symbol(Sprite),而不仅仅是获取文件内容。import.meta.glob 返回的是模块命名空间映射,需要自行处理 SVG Sprite 的构建逻辑。而 virtual:svg-icons 虚拟模块方案将 SVG 扫描、内容读取、Sprite 构建全封装在插件内部,调用方只需一行 import 'virtual:svg-icons',更简洁。

验证效果 :require is not defined 报错消失;SVG Sprite 由虚拟模块在编译时合并注入,图标正常渲染。

后续的 SVG Sprite 构建逻辑见 Webpack迁到Vite第四节:SVG 图标系统。


1.4 默认导出 vs 命名导出

问题

构建时报错:

复制代码
"VALID_MAP" is not exported by "src/constant/valid.js"

代码

src/constant/valid.js:

javascript 复制代码
const VALID_MAP = { /* ... */ }
export default VALID_MAP  // ← 默认导出

某业务组件(示意,原代码):

javascript 复制代码
import { VALID_MAP } from '@/constant/valid'  // ← 命名导入(示意,用于说明问题模式)

为什么 Webpack 不报错而 Vite 报错

要理解这个差别,先把 ESM 的导出想象成一个带标签的盒子:

复制代码
模块的导出 = { 标签名: 值 }

export default VALID_MAP 的意思是:把值放进盒子,标签贴上 "default":

复制代码
{ "default": VALID_MAP }

盒子里的标签只有一个:"default"。没有 "VALID_MAP" 这个标签。

import X from '...' 等价于 import { default as X } from '...'------从盒子里找标签 "default",拿到值后重命名为 X。所以能正常工作。

import { VALID_MAP } 的意思是:从盒子里找标签 "VALID_MAP"。但盒子里根本没有这个标签,只有 "default"------所以按照 ESM 规范,这里应该报错。


明白了这个模型,再看 Webpack 和 Rollup 的区别就清楚了。为此我们做了一个实际测试------用 Webpack 5 构建 import { VALID_MAP } + export default VALID_MAP 的代码,看它到底怎么处理:

Webpack 5 实测:

复制代码
构建输出:
  ⚠️ WARNING  export 'VALID_MAP' was not found in './valid.js'
             (possible exports: default)
  ✅ 构建成功(仅警告,不阻断)

运行时:
  VALID_MAP: undefined  ← 实际值是 undefined!

Webpack 实际生成的代码:

javascript 复制代码
// 导出侧(valid.js)--- Webpack 的 harmony exports
// 只有 default 的 getter,没有 VALID_MAP
__webpack_require__.d(__webpack_exports__, {
  "default": () => (__WEBPACK_DEFAULT_EXPORT__)
});

// 导入侧(app.js)--- Webpack 仍然生成 .VALID_MAP 访问
var _valid = __webpack_require__("./valid.js");
console.log(_valid.VALID_MAP); // undefined

结论 :Webpack 既没有"多贴标签"(exports 上只有 default),也没有"改写 import"(仍然生成 .VALID_MAP)。它只是警告但不报错 ------构建成功,但运行时拿到的是 undefined。

Rollup 实测 :同样代码,Rollup 直接拒绝构建:

复制代码
error: "VALID_MAP" is not exported by "src/constant/valid.js"

本质区别:

Webpack 5 Rollup
实际行为 警告,构建继续 报错,构建拒绝
运行时 VALID_MAP = undefined(可能触发运行时错误) 不生成产物
导出产物 只有 { default: VALID_MAP } 只有 { default: VALID_MAP }
严格程度 宽松------警告但不阻断 严格------不符合规范就拒绝

根本差异不是处理方式不同,而是严格程度不同。Webpack 的宽松掩盖了问题(构建成功但运行时可能出错),Rollup 的严格把问题暴露在编译阶段,避免了隐藏的运行时 bug。

规范是怎么定的 :import X from '...' 是 import { default as X } from '...' 的语法糖------它只是从盒子里取 "default" 标签的值并重命名。import { VALID_MAP } 去匹配 export default VALID_MAP 在规范上就是错的,因为 VALID_MAP 从来不是标签名,default 才是。Webpack 的宽松处理掩盖了这个错误,Rollup 严格执行规范。

一句话 :export default 贴的标签叫 "default",不叫变量名。

修复

改为默认导入:

javascript 复制代码
import VALID_MAP from '@/constant/valid'

验证效果 :Rollup 构建通过,VALID_MAP 正常解析为校验规则对象,表单校验功能正常。


1.5 模板中的 require()

为什么不需要手动改

Vue 模板中大量使用了:

html 复制代码
<img :src="require('@/assets/images/auth.png')" />

这些不需要手动改 。@vitejs/plugin-vue2 在编译 .vue 文件时,会自动把模板中的 require() 转换为 import 语句。

完整转换流程

转换由插件源码中的 transformRequireToImport 函数完成------对模板编译产物做正则替换:

javascript 复制代码
// @vitejs/plugin-vue2 源码(简化)
function transformRequireToImport(code) {
  const imports = {};
  let strImports = '';
  code = code.replace(/require\(("(?:[^"\\]|\\.)+"|'(?:[^'\\]|\\.)+')\)/g, (_, name) => {
    if (!(name in imports)) {
      imports[name] = `__$_require_${hashSum(name)}__`;   // hashSum = hash-sum 算法
      strImports += `import ${imports[name]} from ${name}\n`;
    }
    return imports[name];
  });
  return strImports + code;   // import 语句插入到编译输出顶部
}

应用到实际文件的完整流程:

复制代码
源文件(.vue)
  │
  ├── <template>
  │   <img :src="require('@/assets/images/auth.png')" />
  │
  └── <script>
      // 其他代码...

  │
  ▼ ① 模板编译:vue/compiler-sfc 将模板编译为 render 函数代码
  │   (require('@/assets/images/auth.png') 原样保留在编译产物中)
  │
  ▼ ② 正则扫描:transformRequireToImport 对编译产物匹配
  │   require('字符串字面量') ------ 仅限字面量参数,require(变量) 不匹配
  │
  ▼ ③ 生成变量名:对参数字符串(含引号)做 hash-sum
  │   hash-sum("'@/assets/images/auth.png'") → 4cb3918e
  │   → 变量名 __$_require_4cb3918e__
  │
  ▼ ④ 顶部插入 import(原始路径,alias 未解析):
  │   import __$_require_4cb3918e__ from '@/assets/images/auth.png'
  │
  ▼ ⑤ 调用替换:require() → 变量引用
  │   { attrs: { src: __$_require_4cb3918e__ } }
  │
  ▼ ⑥ Vite 后续处理:import-analysis 解析 alias、对静态资源加 ?import 标记
      → import __$_require_4cb3918e__ from "/src/assets/images/auth.png?import"
      → 浏览器最终收到标准的 ESM 模块

关键环节:

步骤 说明
① 模板编译 vue/compiler-sfc(vue 2.7 内置)编译模板,require() 调用原样保留在 render 函数代码中
② 正则扫描 插件对编译产物 做正则匹配------不是"解析 AST 时识别",而是编译后处理;仅匹配 require("...") / require('...') 字符串字面量参数
③ 变量名 对 require 参数字符串(含引号)做 hash-sum(8 位十六进制),同一路径复用同一变量;示例 '@/assets/images/auth.png' → 4cb3918e
④ 插入 import 以原始路径 插入到编译输出顶部(@/... alias 尚未解析)
⑤ 调用替换 require() 被替换为变量引用
⑥ Vite 接管 alias 解析(@/ → /src/)和 ?import 后缀由 Vite 的 import-analysis 完成,非插件行为

整个过程对开发者透明 ------你不需要手动改模板中的 require(),插件自动完成转换。但这个过程只发生在模板编译阶段。

补充:容易混淆的 vue-template-compiler

@vitejs/plugin-vue2 使用的是 vue/compiler-sfc(Vue 2.7 内置),不是 vue-template-compiler。但 package.json 的 devDependencies 中仍有 vue-template-compiler,容易误以为它是 Vite 编译链的一部分。

实际关系:

复制代码
Vite 编译链:
  @vitejs/plugin-vue2 → vue/compiler-sfc(vue 2.7 内置)→ 编译模板

vitest 测试链:
  vitest → @vue/test-utils@1.x → 硬编码 import 'vue-template-compiler' → 编译模板

vue-template-compiler 是 @vue/test-utils v1 的硬依赖 ,不是 Vite 的依赖。@vue/test-utils v1 在 Vue 2.6 时代开发,当时编译器就是独立包,Vue 2.7 虽然内置了编译器,但 @vue/test-utils v1 已进入维护模式,没有更新这个导入路径。所以两个编译器包同时存在,但各司其职:

编译器 谁用 为什么
vue/compiler-sfc(vue 2.7 内置) @vitejs/plugin-vue2 Vite 编译 SFC 模板
vue-template-compiler @vue/test-utils vitest 挂载组件时编译模板

但是 <script> 中的 require() 不会自动处理

@vitejs/plugin-vue2 的转换只作用于模板编译 过程,不处理 <script> 块。这不是遗漏,而是有明确的设计原因:

① 编译管道不同

复制代码
.vue 文件
  ├── <template>  →  vue/compiler-sfc(vue 2.7 内置,@vitejs/plugin-vue2 的职责范围)
  │                   编译模板为 render 代码 → 正则替换 require('...') → 转为 import
  │
  └── <script>    →  Vite(esbuild/Rollup 的职责范围)
                      标准 JS 处理 → 不做 CJS→ESM 转换 → require() 原样下发,浏览器报 ReferenceError

@vitejs/plugin-vue2 的职责是编译 SFC 文件 ,不是转换 JS 语法 。它接管 <template> 的编译,但 <script> 块原样交给 Vite 处理。Vite 不做 CJS→ESM 转换,require() 被原样下发,浏览器执行时抛 ReferenceError。

② 模板 require() 是 Vue 2 遗留模式,插件提供向后兼容

html 复制代码
<!-- Vue 2 项目中大量存在的写法 -->
<img :src="require('@/assets/logo.png')" />

这是 Vue 2 + Webpack 时代的常见模式。@vitejs/plugin-vue2 在模板编译阶段自动转换,是为了让大量 Vue 2 项目无缝迁移到 Vite,不需要手动改模板代码。

③ 脚本 require() 场景复杂,插件无法安全处理

javascript 复制代码
// 场景一:动态路径(无法静态分析)
const moduleName = isMobile ? './h5' : './pc'
const router = require(`@/router/${moduleName}`).default

// 场景二:条件加载
if (someCondition) {
  const lib = require('heavy-lib')
}

// 场景三:try-catch 包裹
try {
  const mod = require('optional-module')
} catch (e) {}

模板中的 require() 是静态的 (字符串字面量),插件可以安全转换。但脚本中的 require() 可能是动态的(变量拼接、条件调用),如果插件尝试自动转换,可能产生错误的结果。与其做不完整的自动转换,不如让开发者显式处理。

④ 分工边界清晰

层级 谁负责 处理什么
模板编译 @vitejs/plugin-vue2 模板表达式中的 require() → 自动 import
JS 编译 Vite / esbuild 标准 JS 处理;不做 CJS→ESM 转换,require() 原样下发
构建打包 Rollup 严格 ESM 规范,不接受 require()

所以脚本中的 require() 必须手动改为 import。

相关推荐
小白的码BUG之路1 小时前
Jenkins -- 配置ssh
运维·ssh·jenkins
元岳数字人小元1 小时前
数字人私有化,如何搭建全链路内网推理体系
运维·人工智能·开源·人机交互·交互
Allen-Steven1 小时前
SQL查询专项:从完整书写到综合判断
运维·服务器·数据库
guo_wen_qiang1 小时前
云服务器安装MongoDB
运维·服务器
小熊在忙fpga1 小时前
N32G430芯片的flash表示
前端·javascript·html
Data analyse4561 小时前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
anew___1 小时前
《从零手写操作系统 (46):设备模型与devtmpfs——硬件资源的文件系统化》
linux·运维·服务器
todoitbo1 小时前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
Data analyse4561 小时前
Nginx日志访客识别的边界处理
大数据·运维·服务器·数据库·数据分析