前言
由于历史性原因公司内的项目大多数都还是基于webpack3.6所搭建的, 并且已经长时间没有更新通用的webpack项目模板。许多新旧项目依然还是基于webpack3.6搭建,导致项目逐渐卡顿并且也无法使用最新的特性; 这让我这个急性子实在无法接受,然后打算去优化一下。另外本文的重点是升级思路,并不会去重点介绍webpack5的配置选项涵义;在文末会提供带注释完整优化代码,并能够实际运用在项目的webpack5完整配置
那既然决定要更新公司webpack项目模板,那就要做到不仅新项目可以轻松上手,旧项目也要尽可能的小代价升级替换;由于我司使用vue2为主,所以更新目标定为: webpack5 + vue2.7 + pinia + TS 并 解决以下遇到的痛点:
- 最重要是加速启动和构建的时间,优化打包代码体积
- 支持热更新
- 可兼容IE浏览器(包括css,js)
- 可使用最新的ECMA语法 如: ?? ?.
- 拥有扩展功能更多的pxtorem插件
- 增加icon组件,支持svg iconfont 图片等多种图标方式 解决老旧项目iconfont堆积如山的问题
- 由于业务特性,在开发环境我们会使用很多本地测试文件,这些在生成环境是完全用不上的;所以增加打包时过滤这部分测试文件
先看成果
由于当时太激动了,截图中的构建时间其实是指项目启动时间;项目启动时间优化(最高至缩短49.5%), 项目打包体积优化(45.5%), 打包时间优化(20%)
升级思考
首先我们已经定调了本次更新目标为webpack5 + vue2.7 + pinia + TS, 那么最好的更新思路就是借鉴市面上优秀项目的webpack5配置; 但是我们也知道, 由于vue官方生态十分强大,官方已经给开发者提供了开箱即用的vueCli, github中的开源项目也几乎都是基于vueCli搭建, 我们没办法很好的找到基于webpack5的项目; 那我们就转变思路,既然vueCli把事情都做完了 那我们直接借鉴它的配置;
升级过程
1. 找参考配置
打开vueCli官方文档, 我们看到vueCli同样支持webpack inspect能力, 它能够导致当前VueCli的webpack config伪代码
- 按照vueCli 文档进行安装 npm install -g @vue/cli
- 使用vue create 创建项目, 并根据目标技术栈选择对应的配置
- 根据
vue inspect --mode development > webpack.dev.js生成开发环境配置 - 根据
vue inspect --mode production > webpack.prod.js生成生产环境配置
2. 查缺补漏,解决痛点
- vueCli的配置是相对较为完善的, 它是真真切切的可以运用在实际的公司项目中; 里面会有许多最新的webpack5配置, 如果你不了解这些配置的涵义; 我的建议是打开webpack5官方文档, 哪里不懂查哪里 得先明确vueCli配置的意义我们才能跟已有项目相融合.
- 在我们了解vueCli配置后, 我们就要开始添砖加瓦了; 因为我们是升级配置, 需要兼容旧项目已经实现的功能,并解决已有项目中的痛点;
cache持久化缓存
最有效的加速启动和构建时间的优化方案是配置本地持久化缓存,这个配置在vueCli中没有配置
esbuild代替babel
esbuild特点就是快,具体不做阐述;熟悉webpack配置的同学,肯定能看出来 webpack中并没有js:true这个属性, 这个属性我是用于根据browserslist是否存在IE来决定是否启用esbuild的(具体查看下文完整的webpack5配置)
利用@babel/preset-env转译实现JS最新ECMA语法兼容IE, 利用postcss-preset-env实现css自动补全浏览器前缀,pxtorem
项目main.js中引入
import 'core-js/stable';
import 'regenerator-runtime/runtime';
babel.config.js 配置
postcss.config.js 配置
copy文件时省略test文件夹的测试文件
完整配置
webpack.base.js 基础配置文件
// 封装resolveApp方法, 用来获取绝对路径
const path = require('path')
const appDir = process.cwd()
const resolveApp = (relativePath) => path.resolve(appDir, relativePath)
// 引入生产和开发环境配置
const prodConfig = require('./webpack.prod')
const devConfig = require('./webpack.dev')
// browserslist在不同的前端工具之间共享目标浏览器
const browserslist = require('browserslist');
// 用于自动加载模块 而不必到处加载import
const { ProvidePlugin } = require('webpack')
// 用于合并webpack配置
const { merge } = require('webpack-merge')
// vue项目必备plugin
const { VueLoaderPlugin } = require('vue-loader')
// 用于强制所有模块的完整路径必需与磁盘上实际路径的确切大小写相匹配
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
// 用于根据模板生成HTML文件
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 用于将单个文件或整个目录复制到构建目录
const CopyPlugin = require('copy-webpack-plugin')
// 用于优化moment内的语言包
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
// webpack测速插件
const Smp = require('speed-measure-webpack-plugin')
const smp = new Smp()
let files = {
css: [],
js: ['./config/config.js?' + new Date().getTime()]
}
const babelLibsPath=resolveApp('./src/babel-libs')
// 公共webpack配置
const commonConfig = {
// 用于从配置中解析入口点和loader
context: appDir,
entry: { main: resolveApp('./src/app/main.js') },
output: {
// 选用速度更快的xxhash64算法
hashFunction: 'xxhash64',
// 打包后输出的路径
path: resolveApp('./dist'),
// 打包之后的静态资源前面的默认路径拼接
publicPath: '/',
},
//持久化缓存
cache: {
type: 'filesystem',
// 编译器闲置放置缓存到文件夹
store: 'pack',
buildDependencies: {
// Webpack 或 Webpack 的依赖项发生改变时 缓存失效
defaultWebpack: ['webpack/lib/'],
// 配置文件内容或配置文件依赖的模块文件发生变化时 缓存失效
config: [__filename],
browserslist:[resolveApp('./.browserslistrc')],
tsconfig: [resolveApp('./tsconfig.json')],
},
},
module: {
// 无依赖的包跳过文件编译
noParse: /^(vue|vue-router|vuex|vuex-router-sync|jquery|lodash|underscore|pinia)$/,
parser: {
// 指出无效导入导出名称的行为
javascript: {
exportsPresence: 'error',
reexportExportsPresence: 'error',
},
},
rules: [
{
test: /\.m?jsx?$/,
include:/\\src\\(app|babel-libs)/,
resolve: {
//引入模块时,无需编写后缀名
fullySpecified: false
}
},
{
test: /\.vue$/,
use: [
{
loader: 'vue-loader',
options: {
compilerOptions: {
// 放弃标签之间的空格,文本内的空格保留一个
whitespace: 'condense'
}
}
}
]
},
{
test: /\.vue$/,
// 用来设置匹配经过vue-loading处理后查询部分?后带type=style文件
resourceQuery: /type=style/,
// 表明vue style包含副作用
sideEffects: true
},
{
test: /\.svg$/,
loader: 'svg-sprite-loader',
include: [resolveApp('./src/app/icon')],
options: {
symbolId:'[name]'
}
},
{
test: /\.(svg)(\?.*)?$/,
exclude: [resolveApp('./src/app/icon')],
// asset/resource是file-loader的替代 将文件发送到输出目录
type: 'asset/resource',
generator: {
publicPath: '/',
filename: 'img/[name].[hash:8][ext]'
}
},
{
test: /\.(png|jpe?g|gif|webp|avif)(\?.*)?$/,
// url-loader的替代 在dataURI和发送一个单独的文件之间自动选择
type: 'asset',
generator: {
publicPath: '/',
filename: 'img/[name].[hash:8][ext]'
},
parser: {
// 如果一个模块源码大小小于maxSize,那么会被作为一个Base64编码的字符串注入到包
dataUrlCondition: {
maxSize: 8 * 1024
}
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
type: 'asset',
generator: {
publicPath: '/',
filename: 'media/[name].[hash:8][ext]'
},
parser: {
dataUrlCondition: {
maxSize: 8 * 1024
}
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i,
type: 'asset',
generator: {
publicPath: '/',
filename: 'fonts/[name].[hash:8][ext]'
},
parser: {
dataUrlCondition: {
maxSize: 8 * 1024
}
}
},
{
test: /\.html$/,
// 处理html中的ejs语法
loader: 'ejs-loader',
options: {
// 启用 CommonJS 模块语法
esModule: false
}
}
]
},
plugins: [
// 自动加载模块
new ProvidePlugin({
_: 'underscore',
moment: 'moment',
}),
new VueLoaderPlugin(),
// 优化moment打包, 语言包只保留默认的en和zh-cn
new MomentLocalesPlugin({
localesToKeep: ['zh-cn'],
}),
// 用于强制所有模块的完整路径必需与磁盘上实际路径的确切大小写相匹配
new CaseSensitivePathsPlugin(),
// 用于根据模板生成HTML文件
new HtmlWebpackPlugin({
title: '',
scriptLoading: 'defer',
template: resolveApp('./src/app/app.html'),
files: files,
inject: true
}),
new CopyPlugin({
patterns: [
{
from: resolveApp('./src/resources'),
to: './resources',
toType: 'dir',
force: true,
noErrorOnMissing: true,
globOptions: {
ignore: ['**/.DS_Store','**/test/**']
},
info: {
minimized: true
}
}
]
}),
],
resolve: {
// 指定需要检查的扩展名
extensions: ['.ts', '.tsx', '.vue', '.js', '.jsx'],
// 路径别名
alias: {
'@': resolveApp('./src'),
vue$: 'vue/dist/vue.js',
libs: resolveApp('./src/libs'),
app: resolveApp('./src/app'),
icon: resolveApp('./src/app/icon'),
}
},
// 只显示错误信息
stats: 'errors-only',
}
module.exports = async function (env) {
const isProduction = env.production || env.productionTest
process.env.NODE_ENV = isProduction ? 'production' : 'development'
const config = isProduction ? prodConfig : await devConfig()
// 获取当前项目.browserslistrc的配置
const browserslistConfig = browserslist.loadConfig({path: appDir})
// 根据browserslistrc的配置生成目标浏览器数组
var browsers = browserslist(browserslistConfig, {path: appDir})
// 判断是否包含IE
const hasIE = browsers.some(item=>item.includes('ie'))
let loaderIndex = config.module.rules.findIndex(item=>item.js)
// 如果包含IE 那么就弃用esbuild 改用babel处理js
if (hasIE && !isProduction) {
config.module.rules[loaderIndex].oneOf = config.module.rules[loaderIndex].oneOf.slice(1)
}
// 删除用于标记的js:true属性
if(loaderIndex >= 0){
delete config.module.rules[loaderIndex].js
}
// 合并配置
const mergeConfig = merge(commonConfig, config)
return mergeConfig
}
webpack.dev.js 开发环境配置文件
const path = require('path')
const appDir = process.cwd()
const resolveApp = (relativePath) => path.resolve(appDir, relativePath)
// 会将TS检查过程移至单独的进程,可以加快 TypeScript 的类型检查和 ESLint 插入的速度
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
// 本地服务器
const WebpackDevServer = require('webpack-dev-server')
// 识别某些类型的 webpack 错误并整理,以提供开发人员更好的体验
const FriendlyErrorsWebpackPlugin = require('@soda/friendly-errors-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin')
// 用于查找空闲端口
const portfinder = require('portfinder')
// 环境变量
const { DefinePlugin } = require('webpack')
module.exports = async function () {
// 获取本次ipv4地址
const localIPv4 = WebpackDevServer.internalIPSync('v4')
// 设置基础端口
portfinder.basePort = 8080
// 根据基础端口查找空闲端口
const port = await portfinder.getPortPromise()
return {
mode: 'development',
devtool: 'cheap-module-source-map',
output: {
filename: 'js/[name].js',
chunkFilename: 'js/[name].js'
},
devServer: {
hot: true,
// gzip压缩
compress: true,
open: false, // 是否打开浏览器加载
port,
static: {
// 静态文件的读取路径
directory: resolveApp('./dist'),
// 指定本地服务所在的文件夹
publicPath:'/',
},
client: {
logging: 'none',
overlay: {
errors: true,
warnings: false
}
},
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*'
},
proxy: {
'/api': {
target: '',
pathRewrite: {
'^/api': ''
},
// 不验证https证书, 得以实现支持https协议的接口
secure: false,
// 将发送接口时的header.host修改为target的host地址
changeOrigin: true
}
},
historyApiFallback: {
rewrites: [{ from: /.*/g, to: '/index.html' }]
}
},
module: {
rules: [
{
test: /\.css$/,
oneOf: [
{
use: [
{
loader: 'vue-style-loader',
options: {
sourceMap: false,
shadowMode: false
}
},
{
loader: 'css-loader',
options: {
sourceMap: false,
importLoaders: 1,
}
},
{
loader: 'postcss-loader',
options: {
sourceMap: false
}
}
]
}
]
},
{
test: /\.scss$/,
oneOf: [
{
use: [
{
loader: 'vue-style-loader',
options: {
sourceMap: false,
shadowMode: false
}
},
{
loader: 'css-loader',
options: {
sourceMap: false,
importLoaders: 2,
}
},
{
loader: 'postcss-loader',
options: {
sourceMap: false
}
},
{
loader: 'sass-loader',
options: {
sourceMap: false
}
}
]
}
]
},
{
js:true,
oneOf: [
{
test: /\.m?jsx?$/,
include:/\\src\\(app|babel-libs)/,
use: [
{
loader: 'esbuild-loader',
options: {
target: 'es2015',
loader: 'jsx',
jsx: 'automatic',
jsxImportSource: '@lancercomet/vue2-jsx-runtime'
}
}
]
},
{
test: /\.m?jsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheCompression: false,
cacheDirectory: true,
}
}
]
}
]
},
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: true,
happyPackMode: false,
appendTsSuffixTo: ['\\.vue$']
}
}
]
},
{
test: /\.tsx$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: true,
happyPackMode: false,
appendTsxSuffixTo: ['\\.vue$']
}
}
]
}
]
},
optimization: {
// runtime的额外chunk(会降低启动速度,提高热更新速度)
runtimeChunk:true,
// 代码压缩
minimize: false,
// 不进行额外的哈希编译,内部数据用于计算哈希值
realContentHash: false,
// 自动移除空chunks
removeEmptyChunks: false,
// Treeshaking
usedExports: false,
// 代码分包
splitChunks: {
cacheGroups: {
defaultVendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
},
},
plugins: [
new DefinePlugin({
'process.env.NODE_ENV': '"development"',
BASE_URL: '"/"'
}),
new ForkTsCheckerWebpackPlugin({
typescript: {
extensions: {
vue: {
enabled: true,
compiler: 'vue-template-compiler',
},
},
diagnosticOptions: {
semantic: true,
syntactic: true,
},
},
}),
new FriendlyErrorsWebpackPlugin({
compilationSuccessInfo: {
messages: [
`App running at:
- 本机访问: http://localhost:${port}/
- 局域网访问: http://${localIPv4}:${port}/`
],
notes: []
},
onErrors: undefined,
clearConsole: true
})
]
}
}
webpack.prod.js 生产环境配置文件
const path = require('path')
const appDir = process.cwd()
const resolveApp = (relativePath) => path.resolve(appDir, relativePath)
// 环境变量
const { DefinePlugin } = require('webpack')
// 该插件将CSS提取到单独的文件中。它会为每个chunk创造一个css文件。需配合loader一起使用
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 该插件将在Webpack构建过程中搜索CSS资源,并优化\最小化CSS
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
// 用于强制所有模块的完整路径必需与磁盘上实际路径的确切大小写相匹配
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
// JS代码压缩
const TerserPlugin = require('terser-webpack-plugin')
// 用于将单个文件或整个目录复制到构建目录
const CopyPlugin = require('copy-webpack-plugin')
// 会将检查过程移至单独的进程,可以加快 TypeScript 的类型检查和 ESLint 插入的速度
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
// 打包文件分析
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
// 启动多线程预热处理 webpack5 bug 启动线程预热后无法结束终端 所以选择不预热
// const threadLoader = require('thread-loader')
// threadLoader.warmup(
// {
// workers: require('os').cpus().length ? require('os').cpus().length - 1 : 1,
// workerParallelJobs: 50,
// poolTimeout: 2000,
// poolParallelJobs: 50
// },
// ['babel-loader']
// )
module.exports = {
mode: 'production',
entry: { main: resolveApp('./src/app/main.js') },
output: {
// 将webpack中的md4更换为更快速的hash函数,提升打包速度
hashFunction: 'xxhash64',
path: resolveApp('./dist'),
filename: 'js/[name].[chunkhash:8].bundle.js',
// 指定index.html文件打包引用的一个基本路径
publicPath: '',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
clean: true, // 自动清理dist文件夹
},
module: {
rules: [
{
test: /\.css$/,
oneOf: [
{
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: ''
}
},
{
loader: 'css-loader',
options: {
sourceMap: false,
importLoaders: 1,
}
},
{
loader: 'postcss-loader',
options: {
sourceMap: false,
}
}
]
}
]
},
{
test: /\.scss$/,
oneOf: [
{
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: ''
}
},
{
loader: 'css-loader',
options: {
sourceMap: false,
importLoaders: 2
}
},
{
loader: 'postcss-loader',
options: {
sourceMap: false
}
},
{
loader: 'sass-loader',
options: {
sourceMap: false
}
}
]
}
]
},
{
test: /\.m?jsx?$/,
exclude: /node_modules/,
use: [
{
// 将这些非常耗时的内容单独放到另一个线程中执行
loader: 'thread-loader',
options:{
workers: require('os').cpus().length ? require('os').cpus().length - 1 : 1,
workerParallelJobs: 50,
poolTimeout: 2000,
poolParallelJobs: 50
}
},
{
loader: 'babel-loader',
options: {
// 关闭gz压缩提升速度
cacheCompression: false,
// 使用缓存目录并开启缓存
cacheDirectory: true,
}
}
]
},
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'thread-loader',
options:{
workers: require('os').cpus().length ? require('os').cpus().length - 1 : 1,
workerParallelJobs: 50,
poolTimeout: 2000,
poolParallelJobs: 50
}
},
{
loader: 'babel-loader',
options: {
cacheCompression: false,
cacheDirectory: true,
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: true,
appendTsSuffixTo: ['\\.vue$'],
happyPackMode: true
}
}
]
},
{
test: /\.tsx$/,
exclude: /node_modules/,
use: [
{
loader: 'thread-loader',
options:{
workers: require('os').cpus().length ? require('os').cpus().length - 1 : 1,
workerParallelJobs: 50,
poolTimeout: 2000,
poolParallelJobs: 50
}
},
{
loader: 'babel-loader',
options: {
cacheCompression: false,
cacheDirectory: true,
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: true,
happyPackMode: true,
appendTsxSuffixTo: ['\\.vue$']
}
}
]
}
]
},
optimization: {
splitChunks: {
cacheGroups: {
defaultVendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial',
},
lib: {
name: 'lib',
test: /lib/,
priority: -15,
chunks: 'initial',
reuseExistingChunk: true
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
},
// 抽离runtime chunk
runtimeChunk:{ name: 'runtime' },
// 目的是标注unused出来哪些函数是没有被使用, 也就是TreeShaking
usedExports: true,
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
arrows: false,
collapse_vars: false,
comparisons: false,
computed_props: false,
drop_console:false, // 是否关闭console.log
hoist_funs: false,
hoist_props: false,
hoist_vars: false,
inline: false,
loops: false,
negate_iife: false,
properties: false,
reduce_funcs: false,
reduce_vars: false,
switches: false,
toplevel: false,
typeofs: false,
booleans: true,
if_return: true,
sequences: true,
unused: true,
conditionals: true,
dead_code: true,
evaluate: true
},
mangle: {
safari10: true
}
},
parallel: true,
// 不将代码中的备注抽取为单独文件
extractComments: false
}),
new CssMinimizerPlugin({
parallel: true,
minimizerOptions: {
preset: [
'default',
{
mergeLonghand: false,
cssDeclarationSorter: false
}
]
}
})
]
},
stats: {
builtAt: true,
},
performance: {
hints:'warning',
//入口起点的最大体积
maxEntrypointSize: 50000000,
//生成文件的最大体积
maxAssetSize: 30000000,
//只给出 js 文件的性能提示
assetFilter: function(assetFilename) {
return assetFilename.endsWith('.js');
}
},
plugins: [
new DefinePlugin({
'process.env.NODE_ENV': '"production"',
BASE_URL: '"/"'
}),
// 用于强制所有模块的完整路径必需与磁盘上实际路径的确切大小写相匹配
new CaseSensitivePathsPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].css'
}),
new ForkTsCheckerWebpackPlugin({
typescript: {
extensions: {
vue: {
enabled: true,
compiler: 'vue-template-compiler'
}
},
diagnosticOptions: {
semantic: true,
syntactic: true
}
}
}),
// new BundleAnalyzerPlugin({
// analyzerPort: 9090,
// }),
]
}