深入解析:从 Webpack 到 Vite,多入口多端项目的工程化迁移实战

当你的项目需要同时维护 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 定义多个入口,再用 HtmlWebpackPluginchunks 选项把每个入口绑定到对应的 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.historyApiFallbackrewrites 规则解决:

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,导致你的多端路由逻辑永远拿不到原始路径。

解决方法分两步:

  1. 设置 appType: 'mpa',关闭 Vite 内置的 SPA fallback
  2. 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/ 正则永远不匹配。

⚠️ 踩坑点 2configureServer 中直接 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.preprocessorOptionscss.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.tscss.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.tscss.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.tscss.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 包(比如 lodashaxios),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-loaderthread-loader 等)
  • 需要兼容 IE(Vite 产物默认不支持 IE)
  • 构建配置极其复杂且稳定运行,迁移风险大于收益

结语

Webpack 和 Vite 在设计理念上的差异,决定了它们实现多端隔离的方式不同:

  • Webpack:多条规则并行,每条规则自成一套处理链------灵活但配置量大
  • Vite:统一管道 + 精准扩展点------配置精简但需要理解其设计思路

从实际效果看,Vite 的方案完全能做到 Webpack 方案的所有能力,而且配置量更少、开发体验更好。如果你也在考虑多端项目的 Vite 迁移,希望这篇文章能帮你少走一些弯路。

相关推荐
涛涛ing1 小时前
Vue 3.6 还没正式发布,但前端的方向已经被它定下来了
前端
阿新聊ai1 小时前
Claude Code 为什么总不懂你的项目?缺一张「项目地图」
前端·后端·面试
暗冰ཏོ1 小时前
Spring Boot + Vue3 订单支付模块技术实现:支付单、回调验签、幂等处理与余额扣减
前端·spring boot·后端·vue·微信支付·支付宝支付
Csvn1 小时前
AbortController 不止能取消 Fetch!3 个你不知道的隐藏用法
前端·javascript
爷_2 小时前
AI Coding Agent 沙箱的设计原理解析
前端·人工智能·后端
爱勇宝2 小时前
第 3 章:别把团队设计成一场内卷竞赛
前端·后端·程序员
蜡台2 小时前
修改Uniapp input 组件不刷新视图问题
前端·uni-app·input
逸铭2 小时前
Day 8:Preload 是什么?——在别人的网页里打黑工还要合规
前端·客户端