当你的项目需要同时维护 PC 端和 H5 端,如何在 Vite 下实现多入口的物理隔离、开发路由共存、以及样式管道的按路径分流?本文以一个真实的 Webpack 多端方案为起点,逐一拆解迁移到 Vite 的工程化方案。

一、背景:为什么多端项目迁移 Vite 没那么简单?
Vite 凭借原生 ESM 和闪电般的冷启动,已经成为新项目的首选构建工具。大多数单页面应用(SPA)迁移到 Vite 几乎是无痛的------改改配置、换换插件就行。
但当你的项目是多入口 + 多端的架构时,事情就变得不一样了。典型的多端项目会有这些诉求:
| 需求 | 说明 |
|---|---|
| 多入口物理隔离 | PC 和 H5 有各自独立的入口文件、路由、状态管理,构建产物互不干扰 |
| 开发环境路由共存 | 同一个 dev server 下,PC 和 H5 的 SPA 路由不能互相冲突 |
| 样式管道隔离 | H5 端做 pxtorem 适配(750 设计稿),PC 端保持 px 不动;两端的 SCSS 变量也要隔离 |
Webpack 通过灵活的 module.rules + include/exclude 可以很自然地实现这些。但 Vite 的构建管线设计理念不同------它更倾向于"统一管道 + 条件分支",缺少 Webpack 那种按路径堆叠独立规则链的能力。
本文将用一个简化的双端项目(PC + H5)为例,对比两种构建工具的实现方式。

二、项目结构
我们假设项目结构如下:
csharp
project/
├── index.html # PC 端入口
├── h5.html # H5 端入口
├── postcss.config.js # PostCSS 全局配置
├── vite.config.ts # Vite 配置
├── webpack/
│ ├── base.config.js # Webpack 基础配置
│ ├── dev.config.js # Webpack 开发配置
│ └── prod.config.js # Webpack 生产配置
├── src/
│ ├── pc/
│ │ ├── main.ts # PC 入口
│ │ ├── App.vue
│ │ ├── router/
│ │ ├── views/
│ │ └── styles/
│ │ ├── variable.scss # PC 端 SCSS 变量
│ │ └── util.scss # PC 端 mixin
│ └── h5/
│ ├── main.ts # H5 入口
│ ├── App.vue
│ ├── router/
│ ├── views/
│ └── styles/
│ ├── variable.scss # H5 端 SCSS 变量
│ └── util.scss # H5 端 mixin
└── public/
两个端各自有独立的入口、路由、视图和样式文件,互不依赖。
三、Webpack 方案回顾
先看 Webpack 是怎么解决这三个问题的。
3.1 多入口 + Chunks 绑定
Webpack 通过 entry 定义多个入口,再用 HtmlWebpackPlugin 的 chunks 选项把每个入口绑定到对应的 HTML 模板:
js
// webpack/dev.config.js
module.exports = merge(baseConfig, {
entry: {
index: './src/pc/main.ts',
h5: './src/h5/main.ts',
},
plugins: [
// PC 端 HTML ------ 只注入 index chunk
new HtmlWebpackPlugin({
template: 'public/index.html',
filename: 'index.html',
chunks: ['index'],
}),
// H5 端 HTML ------ 只注入 h5 chunk
new HtmlWebpackPlugin({
template: 'public/h5.html',
filename: 'h5.html',
chunks: ['h5'],
}),
],
})
chunks: ['index'] 确保 index.html 只会打包 PC 端的代码,不会混入 H5 的代码。这就实现了构建产物的物理隔离。
3.2 开发环境路由共存
两个 SPA 共用一个 dev server,各自使用 vue-router 的 history 模式。如果不做处理,访问 /h5/about 时 dev server 不知道该返回 h5.html 还是 index.html。
Webpack 通过 devServer.historyApiFallback 的 rewrites 规则解决:
js
devServer: {
historyApiFallback: {
rewrites: [
// /h5/ 开头的路径 → 返回 h5.html
{ from: /^\/h5(\/.*)?$/, to: '/h5.html' },
// 其他路径 → 返回 index.html(PC 端)
{ from: /./, to: '/index.html' },
],
},
}
当浏览器请求 /h5/about 这种非静态资源路径时,dev server 会返回 h5.html,让前端路由接管。
3.3 按路径分流的 SCSS 管道
这是 Webpack 方案中最精巧也最复杂的部分 。通过定义多条 module.rules,用 include/exclude 把不同目录的 SCSS 文件导向不同的处理管道:
js
module: {
rules: [
// ========== H5 端 SCSS:做 pxtorem 转换,注入 H5 变量 ==========
{
test: /\.scss$/,
include: [resolve('src/h5')],
use: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
['postcss-pxtorem', {
rootValue: 37.5, // 750 设计稿
propList: ['*'],
selectorBlackList: ['.no-rem'],
}],
],
},
},
},
{
loader: 'sass-loader',
options: {
additionalData:
'@use "@/h5/styles/util.scss" as *;@use "@/h5/styles/variable.scss" as *;',
},
},
],
},
// ========== PC 端 SCSS:不做 pxtorem,注入 PC 变量 ==========
{
test: /\.scss$/,
exclude: [resolve('src/h5')],
use: [
'style-loader',
'css-loader',
'postcss-loader', // 无 pxtorem 插件
{
loader: 'sass-loader',
options: {
additionalData:
'@use "@/pc/styles/util.scss" as *;@use "@/pc/styles/variable.scss" as *;',
},
},
],
},
],
}
Webpack 的 module.rules 天然支持"按路径分流",每条规则可以有自己独立的 loader 链和配置。这是 Webpack 最大的灵活性所在。

四、Vite 迁移方案
Vite 的设计哲学和 Webpack 不同:它没有一个万能的 module.rules 让你随意堆叠规则,而是提供了几个精准的扩展点。我们需要用不同的方式来达到同样的效果。
4.1 多入口:MPA 模式 + rollupOptions.input
Vite 原生支持多页面应用(MPA)。核心是 build.rollupOptions.input,指向多个 HTML 文件:
ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
h5: resolve(__dirname, 'h5.html'),
},
},
},
})
然后在项目根目录放置两个 HTML 文件,每个 HTML 通过 <script> 标签声明自己的入口:
html
<!-- index.html(PC 端) -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>PC 端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/pc/main.ts"></script>
</body>
</html>
html
<!-- h5.html(H5 端) -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>H5 端</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/h5/main.ts"></script>
</body>
</html>
💡 关键区别 :Webpack 用
chunks选项显式绑定入口和 HTML;Vite 由 HTML 文件通过<script type="module">声明依赖,Rollup 自动分析依赖图。效果等价,但 Vite 的方式更直观。
构建产物:
css
dist/
├── index.html
├── h5.html
├── assets/
│ ├── index-[hash].js # PC 端代码
│ ├── h5-[hash].js # H5 端代码
│ ├── vendor-[hash].js # 共享的 node_modules(如果两端有公共依赖)
│ ├── index-[hash].css
│ └── h5-[hash].css
4.2 开发环境路由共存:appType + configureServer 自定义中间件
Vite 内置了单页 SPA fallback(htmlFallbackMiddleware),默认会把所有无后缀路径回退到 index.html------这对单页应用够用,但不支持多 SPA 的自定义路由回退规则 。更关键的是,默认 appType: 'spa' 下,内置 fallback 会在你的自定义中间件之前执行,抢先重写 URL,导致你的多端路由逻辑永远拿不到原始路径。
解决方法分两步:
- 设置
appType: 'mpa',关闭 Vite 内置的 SPA fallback - 用
configureServer钩子注入自定义的 Connect 中间件,接管路由回退逻辑
ts
// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
// 自定义插件:多 SPA 路由回退
function multiSpaFallback(): Plugin {
return {
name: 'multi-spa-fallback',
configureServer(server) {
// 返回一个函数,使中间件在 Vite 内部中间件之后安装
// 这样静态资源请求会先被 Vite 处理
return () => {
server.middlewares.use((req, _res, next) => {
const url = req.url || ''
// 去掉查询参数后再匹配,避免 /style.css?v=xxx 被误判
const pathname = url.split('?')[0]
// /h5/ 开头的路径 → 回退到 h5.html
if (/^\/h5(\/.*)?$/.test(pathname)) {
req.url = '/h5.html'
return next()
}
// 非静态资源路径 → 回退到 index.html(PC 端)
if (!/\.\w+$/.test(pathname)) {
req.url = '/index.html'
}
next()
})
}
},
}
}
export default defineConfig({
// 关键:设为 'mpa' 关闭 Vite 内置的 SPA fallback
// 否则内置 htmlFallbackMiddleware 会抢先重写 /h5/about → /index.html
appType: 'mpa',
plugins: [vue(), multiSpaFallback()],
// ...
})
⚠️ 踩坑点 1 :必须设置
appType: 'mpa'。Vite 默认appType: 'spa',内置的htmlFallbackMiddleware会在自定义中间件之前运行,把所有无后缀路径重写为/index.html,导致/h5/about被抢先处理,你的multiSpaFallback拿到的已经是/index.html,/h5/正则永远不匹配。⚠️ 踩坑点 2 :
configureServer中直接server.middlewares.use()注册的是前置 中间件(在 Vite 内部中间件之前);而返回一个函数(return () => { server.middlewares.use(...) })注册的是后置中间件。对于 fallback 场景,我们通常希望后置------让 Vite 先尝试处理静态资源,处理不了的再走 fallback 逻辑。⚠️ 踩坑点 3 :中间件中匹配 URL 时要先去掉查询参数。Vite dev server 中模块请求常带查询串(如
/style.css?v=abc123),如果直接对req.url做正则匹配,?v=abc123末尾的abc123不是.+\w,会导致正则失配,静态资源被错误重写为 HTML。
效果和 Webpack 完全一致:访问 /h5/about 返回 h5.html,访问 /dashboard 返回 index.html。
4.3 样式管道隔离:additionalData 函数 + 条件 PostCSS
这是迁移中最需要花心思的部分,也是网上资料最少的地方。
Webpack 中我们可以定义多条 module.rules,每条规则有独立的 loader 配置。Vite 的 CSS 处理是统一管道------css.preprocessorOptions 和 css.postcss 都是全局的。
4.3.1 SCSS 变量注入:additionalData 函数形式
很多人只知道 additionalData 可以传字符串,其实它也支持函数 ------接收 (source, filename) 两个参数,可以根据文件路径返回不同的注入内容:
ts
// vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData(source: string, filename: string) {
// H5 端:注入 H5 的变量和 mixin
if (filename.includes('/src/h5/')) {
return [
'@use "@/h5/styles/util.scss" as *;',
'@use "@/h5/styles/variable.scss" as *;',
source,
].join('\n')
}
// PC 端(默认):注入 PC 的变量和 mixin
return [
'@use "@/pc/styles/util.scss" as *;',
'@use "@/pc/styles/variable.scss" as *;',
source,
].join('\n')
},
},
},
},
})
这一个配置就替代了 Webpack 中两条独立的 sass-loader 规则,简洁了很多。
💡 为什么用函数形式? 字符串形式的
additionalData对所有 SCSS 文件注入同样的内容,无法区分路径。函数形式让我们可以根据filename做条件注入,这是实现样式变量隔离的关键。⚠️ 注意 @use 重复 :使用
additionalData自动注入@use后,组件的<style>块中不要手动@use同一个文件 ,否则 Sass 会报duplicate @use错误。additionalData会prepend 到每个 SCSS 文件顶部,已经全局生效。
4.3.2 pxtorem 按路径生效:自定义 PostCSS 插件
Vite 的 PostCSS 配置是全局的,不能像 Webpack 那样用 include 限定范围。而 postcss-pxtorem 本身也不支持按文件路径做条件转换。
解决方案:自定义一个 PostCSS 8 插件,在插件内部判断文件路径,只对 H5 目录下的文件执行 px→rem 转换。
⚠️ 为什么不直接包装 postcss-pxtorem?
postcss-pxtorem使用已废弃的postcss.plugin()API,在 PostCSS 8(Vite 6 使用)中直接调用其Once钩子不会生效。更可靠的方式是使用 PostCSS 8 的原生插件 API,直接遍历 AST 中的声明节点进行替换。当然,PostCSS 配置可以放在独立的postcss.config.cjs中,也可以内联到vite.config.ts的css.postcss中------后者更可靠,能确保 Vite 正确传递文件路径。
以下是内联在 vite.config.ts 中的实现:
ts
// vite.config.ts
import autoprefixer from 'autoprefixer'
// pxtorem 配置参数(750 设计稿)
// rootValue: 375px viewport / 10 = 37.5(即 1rem = 37.5px)
const ROOT_VALUE = 37.5
/**
* 条件 pxtorem 插件:仅对 H5 目录下的样式文件生效
*
* 原理:在 PostCSS 8 插件的 Once 钩子中,
* 通过 result.opts.from 获取当前处理的文件路径,
* 只有路径包含 "/src/h5/" 时才执行 px → rem 转换。
* 直接遍历 AST 中的 Declaration 节点,替换 px 值为 rem。
* 路径分隔符统一 replace 为 /,保证跨平台兼容。
*/
const conditionalPxtorem = () => ({
postcssPlugin: 'conditional-pxtorem',
Once(root, { result }) {
const file = (result.opts.from || '').replace(/\\/g, '/')
if (!file.includes('/src/h5/')) return // 非 H5 文件,直接跳过
// 遍历所有声明节点,将 px 转换为 rem
root.walkDecls((decl) => {
if (!decl.value.includes('px')) return
// 跳过 .no-rem 选择器下的声明
let skip = false
let parent = decl.parent
while (parent && parent.type !== 'root') {
if (parent.selector && parent.selector.includes('.no-rem')) {
skip = true
break
}
parent = parent.parent
}
if (skip) return
// 替换所有 Npx 为 rem
decl.value = decl.value.replace(/(\d*\.?\d+)px/g, (_match, px) => {
const num = parseFloat(px)
if (num <= 1) return `${num}px` // minPixelValue: 1
const rem = num / ROOT_VALUE
return `${rem.toFixed(5).replace(/0+$/, '').replace(/\.$/, '.0')}rem`
})
})
},
})
conditionalPxtorem.postcss = true
export default defineConfig({
css: {
postcss: {
plugins: [
autoprefixer(),
conditionalPxtorem(),
],
},
// ... preprocessorOptions 等
},
})
4.3.3 效果验证
假设 H5 端有一个样式文件:
scss
// src/h5/views/Home.vue 中的 <style lang="scss">
.banner {
width: 750px;
height: 200px;
font-size: 28px;
padding: 20px;
}
构建后输出(H5 端会被 pxtorem 转换):
css
.banner {
width: 20rem; /* 750 / 37.5 */
height: 5.33333rem; /* 200 / 37.5 */
font-size: 0.74667rem;
padding: 0.53333rem;
}
而 PC 端同样的写法不会被转换,保持 px 不变:
css
.banner {
width: 750px;
height: 200px;
font-size: 28px;
padding: 20px;
}
⚠️ 必须设置 rem 根字体 :pxtorem 把
px转成了rem,但浏览器默认html { font-size: 16px },会导致所有 rem 值偏小(比如0.74667rem实际只有 ~12px)。必须在 H5 端的全局样式中设置根字体,使其与 rootValue 匹配:
scss// src/h5/styles/global.scss // 375px 屏幕下 1rem = 37.5px,与 rootValue 一致 html { font-size: calc(100vw / 10); }

五、完整 Vite 配置
把上面所有内容合在一起,一份可以直接用的完整配置:
ts
// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import autoprefixer from 'autoprefixer'
/**
* 多 SPA 路由回退插件
* 替代 Webpack 的 devServer.historyApiFallback
*/
function multiSpaFallback(): Plugin {
return {
name: 'multi-spa-fallback',
configureServer(server) {
return () => {
server.middlewares.use((req, _res, next) => {
const url = req.url || ''
// 去掉查询参数后再匹配
const pathname = url.split('?')[0]
// /h5/ 开头的路径 → 回退到 h5.html
if (/^\/h5(\/.*)?$/.test(pathname)) {
req.url = '/h5.html'
return next()
}
// 非静态资源路径 → 回退到 index.html(PC 端)
if (!/\.\w+$/.test(pathname)) {
req.url = '/index.html'
}
next()
})
}
},
}
}
// pxtorem 配置参数(750 设计稿)
const ROOT_VALUE = 37.5
/**
* 条件 pxtorem 插件:仅对 H5 目录下的样式文件生效
*/
const conditionalPxtorem = () => ({
postcssPlugin: 'conditional-pxtorem',
Once(root, { result }) {
const file = (result.opts.from || '').replace(/\\/g, '/')
if (!file.includes('/src/h5/')) return
root.walkDecls((decl) => {
if (!decl.value.includes('px')) return
let skip = false
let parent = decl.parent
while (parent && parent.type !== 'root') {
if (parent.selector && parent.selector.includes('.no-rem')) {
skip = true; break
}
parent = parent.parent
}
if (skip) return
decl.value = decl.value.replace(/(\d*\.?\d+)px/g, (_match, px) => {
const num = parseFloat(px)
if (num <= 1) return `${num}px`
const rem = num / ROOT_VALUE
return `${rem.toFixed(5).replace(/0+$/, '').replace(/\.$/, '.0')}rem`
})
})
},
})
conditionalPxtorem.postcss = true
export default defineConfig({
// 关键:设为 'mpa' 关闭 Vite 内置的 SPA fallback
appType: 'mpa',
plugins: [vue(), multiSpaFallback()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
css: {
// PostCSS 配置内联在 vite.config.ts 中
postcss: {
plugins: [
autoprefixer(),
conditionalPxtorem(),
],
},
preprocessorOptions: {
scss: {
// 根据文件路径注入不同的 SCSS 变量
additionalData(source: string, filename: string) {
const normalizedPath = filename.replace(/\\/g, '/')
if (normalizedPath.includes('/src/h5/')) {
return [
'@use "@/h5/styles/util.scss" as *;',
'@use "@/h5/styles/variable.scss" as *;',
source,
].join('\n')
}
return [
'@use "@/pc/styles/util.scss" as *;',
'@use "@/pc/styles/variable.scss" as *;',
source,
].join('\n')
},
},
},
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
h5: resolve(__dirname, 'h5.html'),
},
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
💡 PostCSS 配置为什么内联? 虽然可以放在独立的
postcss.config.cjs中,但内联到vite.config.ts的css.postcss更可靠------Vite 能确保正确传递文件路径给 PostCSS 插件。如果使用独立配置文件,且package.json设置了"type": "module",必须用.cjs后缀。
六、Webpack vs Vite 方案对比
| 能力 | Webpack | Vite |
|---|---|---|
| 多入口绑定 | entry + HtmlWebpackPlugin(chunks) |
rollupOptions.input + HTML <script> |
| 开发路由回退 | devServer.historyApiFallback.rewrites |
appType: 'mpa' + configureServer 自定义中间件 |
| SCSS 变量按路径注入 | 多条 module.rules + include |
additionalData 函数形式 |
| pxtorem 按路径生效 | 独立 postcss-loader 配置 + include |
内联 PostCSS + 自定义条件插件(walkDecls) |
| 配置复杂度 | 规则多、配置冗长 | 配置精简,但需要理解扩展点 |
| 开发体验 | 冷启动慢,HMR 延迟明显 | 秒级冷启动,HMR 几乎无感 |

七、进阶话题:多入口下的依赖预打包与环境变量
在多入口项目中,还有两个常被忽略但影响实际开发体验的问题。
7.1 依赖预打包(optimizeDeps)的合并行为
Vite 在 dev server 启动时,会用 esbuild 预打包 node_modules 中的依赖。多入口场景下,所有入口的依赖会被合并预打包------这意味着即使某个依赖只在 H5 入口被引用,它也会出现在预打包结果中。
大多数情况下这不是问题,但如果你遇到了 dev server 启动变慢,可以通过 optimizeDeps.include 手动指定需要预打包的依赖,避免 esbuild 扫描所有入口:
ts
export default defineConfig({
optimizeDeps: {
include: ['vue', 'vue-router', 'axios'],
},
})
7.2 环境变量隔离
多端项目通常需要不同的环境变量(如 PC 和 H5 的 API 基地址不同)。Vite 的 .env 文件是全局的,没有按入口区分的机制。可以通过 define 注入入口标识,让代码自行判断:
ts
export default defineConfig({
define: {
__APP_TYPE__: JSON.stringify(process.env.VITE_ENTRY || 'pc'),
},
})
然后在代码中:
ts
const apiBase = __APP_TYPE__ === 'h5'
? import.meta.env.VITE_H5_API_BASE
: import.meta.env.VITE_PC_API_BASE
八、踩坑总结
迁移过程中有几个容易踩坑的地方,值得注意:
坑 1:additionalData 的函数签名
additionalData 的函数形式在不同版本的 Vite 中签名可能不同。在 Vite 5/6 中是 (source: string, filename: string) => string,但早期版本可能只传 source。升级 Vite 后记得验证。此外,PostCSS 配置建议内联到 vite.config.ts 的 css.postcss 中,而非使用独立的配置文件------这样能避免 package.json 中 "type": "module" 导致的 CommonJS/ESM 冲突,也确保 Vite 正确传递文件路径。
坑 2:必须设置 appType: 'mpa' + 中间件执行顺序
这是最容易踩的坑。Vite 默认 appType: 'spa',内置的 htmlFallbackMiddleware 会在自定义中间件之前运行,把所有无后缀路径重写为 /index.html,导致 /h5/about 被抢先处理。
ts
// ❌ 缺少 appType 配置
export default defineConfig({
plugins: [vue(), multiSpaFallback()], // 内置 SPA fallback 会抢先重写 URL
})
// ✅ 设置 appType: 'mpa'
export default defineConfig({
appType: 'mpa', // 关闭内置 SPA fallback
plugins: [vue(), multiSpaFallback()],
})
同时,configureServer 中中间件的注册方式也有讲究:
ts
// ❌ 前置中间件 ------ 静态资源请求也会被拦截
configureServer(server) {
server.middlewares.use(fallbackHandler)
}
// ✅ 后置中间件 ------ 静态资源先由 Vite 处理,未命中的才走 fallback
configureServer(server) {
return () => server.middlewares.use(fallbackHandler)
}
对于路由回退场景,必须同时设置 appType: 'mpa' 并使用后置中间件,否则路由共存方案无法正常工作。
坑 3:postcss-pxtorem 兼容性与 postcss 属性
postcss-pxtorem 使用已废弃的 postcss.plugin() API,在 PostCSS 8(Vite 6 使用)中直接调用其 Once 钩子不会生效。推荐使用 PostCSS 8 原生插件 API(postcssPlugin + walkDecls)直接遍历 AST 做转换。
同时,自定义 PostCSS 插件必须设置 postcss = true,否则 PostCSS 可能无法正确识别插件:
js
const myPlugin = () => ({
postcssPlugin: 'my-plugin',
Once(root, { result }) { /* ... */ },
})
// ⚠️ 这行不能漏!
myPlugin.postcss = true
另外,result.opts.from 在不同操作系统上路径分隔符不同(/ vs \),匹配时建议统一 replace 为 / 再判断,保证跨平台兼容。
坑 4:H5 路由 base 与 path 冲突导致页面空白
createWebHistory('/h5/') 会自动剥离 URL 中的 /h5/ 前缀,因此路由的 path 不需要 再带 /h5/。如果两者都写了,实际匹配的路径变成了 /h5/h5/,导致路由永远匹配不上,<RouterView /> 渲染为空,页面只显示 header。
ts
// ❌ base 和 path 都带 /h5/,实际匹配 /h5/h5/
const router = createRouter({
history: createWebHistory('/h5/'),
routes: [
{ path: '/h5/', component: Home }, // 实际匹配 /h5/h5/
{ path: '/h5/about', component: About }, // 实际匹配 /h5/h5/about
],
})
// ✅ base 带 /h5/,path 不带
const router = createRouter({
history: createWebHistory('/h5/'),
routes: [
{ path: '/', component: Home }, // 访问 /h5/ 时匹配
{ path: '/about', component: About }, // 访问 /h5/about 时匹配
],
})
同样,<RouterLink to="/about"> 也会自动加上 base 前缀变成 /h5/about,不需要手动写完整路径。
坑 5:rem 根字体未设置导致内容极小
pxtorem 把 28px 转成了 0.74667rem,但浏览器默认 html { font-size: 16px },实际渲染只有约 12px------内容极小、几乎看不见。必须在 H5 全局样式中设置根字体:
scss
// src/h5/styles/global.scss
html {
font-size: calc(100vw / 10); // 375px 屏幕下 1rem = 37.5px
}
这个 calc(100vw / 10) 的原理:viewport 宽度 / 10 = rootValue,使得 1rem = rootValue px,pxtorem 转换后的 rem 值与原始 px 值在视觉上一致。
坑 6:两端共享依赖的 chunk 拆分
如果 PC 和 H5 引用了同一个 npm 包(比如 lodash、axios),Rollup 默认会把它们抽成共享 chunk------这其实是好事,可以减少总包体积。
如果你确实需要完全隔离(比如两端用了同一个包的不同版本),manualChunks 的函数形式拿不到模块图信息,无法直接判断某个 node_modules 模块是被 PC 还是 H5 引用的。一个可行方案是用 Rollup 插件在 buildStart 阶段分析模块图:
ts
import type { Plugin } from 'vite'
function splitVendorByEntry(): Plugin {
let moduleGraph: Map<string, Set<string>>
return {
name: 'split-vendor-by-entry',
buildStart() {
moduleGraph = new Map()
},
resolveId(source, importer) {
// 记录每个模块的引用者
if (importer) {
if (!moduleGraph.has(source)) moduleGraph.set(source, new Set())
moduleGraph.get(source)!.add(importer)
}
return null
},
// 在 output.manualChunks 中使用收集到的信息
}
}
💡 实践建议:在大多数场景下,让两端共享依赖是更优选择------减少总包体积,两端只在运行时加载各自入口的代码。只有在确实需要完全隔离时才考虑拆分。
九、什么时候该迁移?什么时候不该?
不是所有项目都适合迁移 Vite。简单给个判断:
适合迁移的场景:
- 新项目直接用 Vite,没有历史包袱
- 现有 Webpack 项目冷启动太慢,严重影响开发效率
- 项目依赖生态良好,使用的 npm 包都有 ESM 支持
- 不需要复杂的
module.rules自定义 loader 链
不太适合的场景:
- 重度依赖 Webpack 独有的 loader(如
svg-sprite-loader、thread-loader等) - 需要兼容 IE(Vite 产物默认不支持 IE)
- 构建配置极其复杂且稳定运行,迁移风险大于收益
结语
Webpack 和 Vite 在设计理念上的差异,决定了它们实现多端隔离的方式不同:
- Webpack:多条规则并行,每条规则自成一套处理链------灵活但配置量大
- Vite:统一管道 + 精准扩展点------配置精简但需要理解其设计思路
从实际效果看,Vite 的方案完全能做到 Webpack 方案的所有能力,而且配置量更少、开发体验更好。如果你也在考虑多端项目的 Vite 迁移,希望这篇文章能帮你少走一些弯路。