用 vite 2 平滑升级 vue 2 + webpack 项目实战

2022-03-10 09:37:34 浏览数 (1)

目录

  • Vite vs. Webpack
  • 完整迁移实战

Vite vs. Webpack

指标对比

经过实际运行,在同一项目中、采用几乎相同的设置,结果如下:

指标 工具

Vite

Vite(legecy)

Vue-cli Webpack

npm run debug 至页面可用 (ms)

2405

4351

21418

npm run build 时间 (ms)

19727

82277

61000

打包后的 JS 文件数量

22

45

46

平均 JS 文件体积 (kb)

175

174

88

总 JS 文件体积 (kb)

3864

7832

4080

开发环节区别

webpack:

  • 先转译打包,然后启动 dev server
  • 热更新时,把改动过模块的相关依赖模块全部编译一次

vite:

  • 对于不会变动的第三方依赖,采用编译速度更快的go编写的esbuild预构建
  • 对于 js/jsx/css 等源码,转译为原生 ES Module(ESM)
  • 利用了现代浏览器支持 ESM,会自动向依赖的 Module 发出请求的特性
  • 直接启动 dev server (不需要打包),对请求的模块按需实时编译
  • 热更新时,仅让浏览器重新请求改动过的模块

目前由 webpack 或 vite 做的这些架设本地服务、静态资源打包、动态更新的工作,起码追溯到十多年前陆续都有各种解决方案了

构建环节

  • 考虑到加载和缓存等,在生产环境中发布未打包的 ESM 仍然效率低下
  • vite 利用成熟的 Rollup,完成 tree-shaking、懒加载和 chunk 分割等

源码浅析

运行 vite 命令后:

代码语言:javascript复制
-> start() // packages/vite/bin/vite.js
-> 利用 cac 工具构建可以处理 dev/build/preview 等命令的 cli 实例
-> cli.parse() // packages/vite/src/node/cli.ts
复制代码
1. vite (dev 模式)
代码语言:javascript复制
-> createServer() // packages/vite/src/node/server/index.ts
	- resolveHttpServer() // 基于 http 原生模块创建服务
	- createWebSocketServer() // 用 WebSocket 发送类似下面这样的热更新消息
	- chokidar.watch(path.resolve(root), ...) // 监听源码变化
-> handleHMRUpdate() // 处理热更新 packages/vite/src/node/server/hmr.ts
	-  updateModules()  
		``````
	    ws.send({
	      type: 'update',
	      updates
	    })
		
		[浏览器中 ws://localhost:8080/my-report/]
		{
		  "type": "update",
		  "updates": [
		    {
		      "type": "js-update",
		      "timestamp": 1646797458716,
		      "path": "/src/app.vue",
		      "acceptedPath": "/src/app.vue?vue&type=template&lang.js"
		    }
		  ]
		}
		`````` 
复制代码

浏览器中响应 hmr 的部分:

代码语言:javascript复制
-> handleMessage() // packages/vite/src/client/client.ts
	``````
	   if (update.type === 'js-update') {
          queueUpdate(fetchUpdate(update))
        } else {
	``````
-> fetchUpdate() 
	``````
	// 利用了浏览器的动态引入 https://github.com/tc39/proposal-dynamic-import
	// 可见请求如 http://.../src/app.vue?import&t=1646797458716&vue&type=template&lang.js
    const newMod = await import(
      /* @vite-ignore */
      base  
        path.slice(1)  
        `?import&t=${timestamp}${query ? `&${query}` : ''}`
    )
	``````
复制代码
2. vite build
代码语言:javascript复制
-> build() // packages/vite/src/node/cli.ts
-> doBuild() // packages/vite/src/node/build.ts
	- resolveConfig() // 处理 vite.config.js 和 cli 参数等配置
	- prepareOutDir() // 清空打包目录等
	- rollup.rollup()['write']() // 用 rollup 完成实际打包和写入工作
复制代码

迁移实践

业务背景和迁移原则

迁移背景:

  • 现有项目的 webpack 开发调试和打包速度已经较慢
  • 查看后台统计数据,项目的浏览器覆盖情况可以支持抛掉历史包袱
  • 项目具有代表性,已经包含了 TS/JSX/FC 等写法的组件和模块
  • 需要渐进迈向 vue3 技术栈

升级原则:

  • 对原有开发打包流程无痛、交付产出物结构基本不变
  • 保证线上产品安全,设置观察期并 兼容 webpack 流程 而非直接替换
  • 覆盖后台访问记录中的主流浏览器并周知测试产品等研发环节

主要涉及文件:

  • /index.html -- 新的入口,原有 src/index.html 暂时保留
  • /vite.config.js -- vite 工具的配置文件

vite版本:

  • vite v2.8.2

node 版本:

  • node v14.19.0
  • 实践表明 v14 可以兼顾新的 vite 和既有 webpack 两套流程
  • 如果涉及 jenkins 等部署环节,可能需要关心相关 node 软件包的升级

package.json

依赖
代码语言:javascript复制
"devDependencies": {
  "vite": "^2.8.2",
  "vite-plugin-vue2": "^1.9.3",
  "vite-plugin-html": "^3.0.4",
  "vite-plugin-time-reporter": "^1.0.0",
  "sass": "^1.49.7",
  "rollup-plugin-copy": "^3.4.0",
  "@vue/compiler-sfc": "^3.2.31",
},
复制代码
npm scripts
代码语言:javascript复制
"debug": "vite --mode dev",
"build": "vite build --mode production",
"preview": "vite preview --port 8082",
复制代码

之前的 webpack 命令加前缀(如:"webpack:build"),继续可用

node-sass

升级版本,同时满足了 webpack/vite 的打包要求

代码语言:javascript复制
-    "node-sass": "^4.9.2",
     "node-sass": "^6.0.0",
-    "sass-loader": "^7.0.3",
     "sass-loader": "^10.0.0"
复制代码

index.html

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <link rel="shortcut icon" href="/src/assets/imgs/report.ico" />
    <link rel="stylesheet" href="<%- htmlWebpackPlugin.options.navCss %>" />
    <title><%- htmlWebpackPlugin.options.title %></title>
    <script
      type="text/javascript"
      src="<%- htmlWebpackPlugin.options.navJs %>"
    ></script>
  </head>
  <body>
    <div id="nav"></div>
    <div id="app"></div>
    <script type="module" src="/src/index.js"></script>
  </body>
</html>
复制代码
  • 位于根目录,vite 默认的入口
  • 加入 type="module" 的入口文件 script 元素
  • <%= => 语法变为 <%- ->

基础配置

复用并完善了之前的打包和开发配置文件:

代码语言:javascript复制
// build/config.js

module.exports = {
    title: '报表',
    // 打包文件夹名称
    base: 'my-report',
    // 调试配置
    debug: {
        pubDir: 'dist',
        assetsDir: 'assets',
        host: 'localhost',
        port: 8080,
        navCss: '/src/assets/common2.0/scss/nav-common.css',
        navJs: '/src/assets/common2.0/js/nav-common.js',
        proxy: {
            target: 'http://api.foo.com'
        }
    },
    // 生产配置
    prod: {
        navJs: '/public/v3/js/nav-common.js',
        navCss: '/public/v3/css/nav-common.css',
    }
};

复制代码

vite.config.js 基本结构

代码语言:javascript复制
import {createVuePlugin} from 'vite-plugin-vue2';

export default ({mode}) => {
    const isProduction = mode === 'production';

    return defineConfig({
        base: `/${config.base}/`,
		logLevel: 'info',
		// 插件,兼容 rollup
        plugins: [
			// vue2 和 jsx
		    createVuePlugin({
		        jsx: true,
		        jsxOptions: {
		            compositionAPI: true
		        }
		    }),
			// 打包统计
        	timeReporter()
        ],
		// devServer 设置
		server: {},
		// 依赖解析规则等
		resolve: {
			alias: {}
		},
		// 打包目录、素材目录、rollup原生选项等
		build: {}
    });
};
复制代码

resolve 的迁移

之前 webpack 中的配置:

代码语言:javascript复制
resolve: {
    extensions: ['.ts', '.tsx', '.vue', '.js', '.jsx', '.json', '.css', '.scss'],
    alias: {
        '@': path.resolve(__dirname, '../src'),
        assets: path.resolve(__dirname, '../src/assets'),
        vue$: path.resolve(__dirname, '../node_modules', 'vue/dist/vue.esm.js')
    },
    symlinks: false
},
复制代码

vite 中的写法:

代码语言:javascript复制
resolve: {
    extensions: ['.ts', '.tsx', '.vue', '.js', '.jsx', '.json', '.css', '.scss'],
    alias: [
        {
            find: '@',
            replacement: path.resolve(__dirname, 'src')
        },
        {
            find: 'assets',
            replacement: path.resolve(__dirname, 'src', 'assets')
        },
        {
            find: 'vue$',
            replacement: path.resolve(__dirname, 'node_modules', 'vue/dist/vue.esm.js')
        },
        {
            find: '~@foo/src/styles/common/publicVar',
            replacement: 'node_modules/@foo/src/styles/common/_publicVar.scss'
        },
        {
            find: '~@foo/src/styles/mixins/all',
            replacement: 'node_modules/@foo/src/styles/mixins/_all.scss'
        }
    ]
},
复制代码

以上最后两项配置属于之前引用的错误路径,vite 无法跳过,并将引起打包失败;需要修正引用或在此特殊处理

build 的迁移

之前 webpack 中的配置:

代码语言:javascript复制
context: path.resolve(__dirname, '../'),
mode: isProduction ? 'production' : 'development',
entry: {
    index: './src/index.js'
},
output: {
    path: path.resolve(__dirname, '../dist', config.base),
    publicPath,
    filename: isProduction ? 'assets/js/[name].[contenthash:8].js' : 'assets/js/[name].[hash:8].js',
    chunkFilename: isProduction
        ? 'assets/js/[name].[contenthash:8].chunk.js'
        : 'assets/js/[name].[hash:8].chunk.js'
},
performance: {
    maxEntrypointSize: 2000000,
    maxAssetSize: 1000000
}
复制代码

vite 中的写法:

代码语言:javascript复制
build: {
    outDir: `${pubDir}/${config.base}`,
    assetsDir,
    rollupOptions: {
    },
    chunkSizeWarningLimit: 1000000,
    cssCodeSplit: true
}

复制代码

直接拷贝的素材

  • 业务中有一部分动态路径的素材图引用 <img :src="path">,path 可能为 assets/imgs/noData.png 这样的相对路径
  • webpack 中用 'copy-webpack-plugin' 插件拷贝图片到发布目录下,调试过程中是可以访问到的
  • vite 用拷贝插件 'rollup-plugin-copy' 同样可以拷贝成功,但调试进程中访问不了 dist 目录
代码语言:javascript复制
import copy from 'rollup-plugin-copy';

...

// 打包时才拷贝
plugins: [
  isProduction
    ? copy({
          targets: [
              {
                  src: path.resolve(__dirname, 'src/assets/imgs'),
                  dest: `${pubDir}/${config.base}/${assetsDir}`
              }
          ],
          hook: 'writeBundle'
      })
    : void 0,
],
// 调试过程中特殊转写
server: {
    proxy: {
        '/my-report/assets/imgs/': {
            target: `http://${host}:${port}/`,
            rewrite: path => path.replace('assets', 'src/assets')
        }
    },
}
复制代码

特殊的外部引用

  • vite 需要用 'vite-plugin-html' 插件来达成和兼容与 'html-webpack-plugin' 一样的 html 注入效果
  • 形如 '/public/v3/css/nav-common.css' 这样的特殊引用,不符合 vite 内部的保留策略,会被删除原 <link> 标签并转换成 js import,这将造成页面无法正常访问
  • 结合自定义插件实现打包过程中的 hack 和打包结束后的恢复
代码语言:javascript复制
import {createHtmlPlugin} from 'vite-plugin-html';

...

const indexReplaceHolder = '//fakePrefix';

...

plugins: [
    createHtmlPlugin({
        template: 'index.html',
        minify: true,
        inject: {
            data: {
                htmlWebpackPlugin: {
                    options: {
                        title: config.title,
                        navCss: isProduction ? indexReplaceHolder   config.prod.navCss : config.debug.navCss,
                        navJs: isProduction ? indexReplaceHolder   config.prod.navJs : config.debug.navJs
                    }
                }
            }
        }
    }),
    (function() {
        let viteConfig;
        return {
            name: 'vite-plugin-fix-index',
            configResolved(resolvedConfig) {
                viteConfig = resolvedConfig;
            },
            transformIndexHtml(code) {
                if (viteConfig.command === 'build' && isProduction) {
                    const re = new RegExp(indexReplaceHolder, 'g');
                    code = code.replace(re, '');
                }
                return code;
            }
        };
    })(),
],
复制代码

传统浏览器兼容

  • vite 用 @vitejs/plugin-legacy 插件为打包后的文件提供传统浏览器兼容性支持
  • legacy 对 build 速度影响较大,酌情采用
代码语言:javascript复制
plugins: [
	legacy({
		targets: ['> 1%', 'last 2 versions', 'not ie <= 10']
	}),
]
复制代码
legecy后全局 css 失效
  • vue 2 中,build.cssCodeSplit: false 加上 legecy 将导致全局样式丢失等问题(gitee.com/ZhongBangKeJi)

环境变量

  • process.env 的写法在 vite 中改为了 import.meta,并且使用上有差异
代码语言:javascript复制
// src/utils/env.js

export const getEnvMode = () => {
    try {
        // eslint-disable-next-line
        if (typeof process !== 'undefined' && process.env) {
            // eslint-disable-next-line
            return process.env.NODE_ENV;
        }
        // eslint-disable-next-line
        if (import.meta && import.meta.env) {
            return import.meta.env.MODE;
        }
    } catch (e) {
        console.log(e);
    }
};
复制代码
代码语言:javascript复制
// package.json

"devDependencies": {
  "@open-wc/webpack-import-meta-loader": "^0.4.7",	
}
复制代码
代码语言:javascript复制
// webpack -> module -> rules

{
  test: /.jsx?$/,
  -loader: 'babel-loader',
   loaders: ['babel-loader', {loader: require.resolve('@open-wc/webpack-import-meta-loader')}],
  include: [path.resolve(__dirname, '../src')]
}
复制代码
代码语言:javascript复制
// jest.config.js -> collectCoverageFrom

[
  '!<rootDir>/src/utils/env.js'
]
复制代码
代码语言:javascript复制
// __tests__/setup.js 

jest.mock('../src/utils/env.js', () => {
    return {
        getEnvMode: () => 'production'
    };
});
复制代码

require.ensure

  • 暂时没有很好的兼容写法,应尽量避免

new Set()

  • 如果使用了 Map/Set 等 ES6 的类型且没有使用 polyfill,应该注意其行为
  • 比如 Set 的值可能在 webpack/babel 的转写中会自动变为数组,而新的流程中需要手动用 Array.from() 处理

总结

  • webpack 工作流基本可以被 vite 完整复刻,适应线上平滑升级
  • 基于浏览器访问记录评估,大部分项目可以享受 vite 极速打包福利
  • 对于需要兼容 IE 11 等特殊情况的,需要充分测试后,考虑用 legecy 模式迁移
  • 需要注意生产环境rollup打包与开发环境的代码会不一致,最好用 preview 验证

0 人点赞