webpack5初探:脚手架配置与优化

966 阅读11分钟

一、前言

webpack对于前端工程师来说非常重要,包括日常的开发,发版上线构建效率,还有跳槽面试来说都必不可少,对于react框架有默认的create-react-app作为通用方案,但像一些个性化需求,比如移动端转rem等等,它并没有提供配置入口,而且需要熟悉它对应的API进行开发,create-react-app文档,网上通用的方案是git提交之后npm run eject或者使用react-app-rewired进行覆盖,但灵活程度都不如直接使用webpack,原先对webpack4的配置有一定的了解和使用,借此机会升级,并对脚手架配置做一个总结。

二、现在开动

最终产出目录

image.png

基础配置

配置package.json

配置package.json:npm init y 按照提示填写即可,scripts下添加两句最重要的代码,运行和打包

"scripts": {
    "build": "npx webpack --mode production",
    "start": "npx webpack serve --mode development --open"
},

安装webpack相关依赖

安装:npm i webpack webpack-cli webpack-dev-server webpack-bundle-analyzer -D 新建webpack.config.js 查看最近版本命令: npm info webpack version

缓存:

Webpack的打包和构建分为三步:

  • 初始化阶段:

    1.初始化参数,读取文件配置包括webpack.config.js和shell参数

    2.利用参数创建compiler对象

    3.调用内置插件,加载用户配置插件并创建编译环境

    4.调用compiler.run方法进行构建

    5.根据配置中的entry找出所有的入口文件,调用 compilition.addEntry 将入口文件转换为 dependence对象(模块间的依赖关系)

  • 编译阶段

    1.根据entry生成的dependence对象生成不同的module对象,调用loader模块将module转成原生JS对象,然后调用acorn将原生JS转成AST,找出该模块的依赖模块,依次递归entry接口,找出所有相关依赖模块 2.生成模块依赖图 -- ModuleGraph 对象

  • 生成阶段

    1.按照模块依赖图,将所有的import转成require,分析代码运行时依赖

    2.合并模块代码和运行时代码生成一个个包含module的chunk,每个chunk都输出成单独的文件,执行tree-shaking,并且根据output输出文件

  • 总结: 在编译阶段,将module,chunk,ModuleGraph 对象都写入缓存,在下次构建开始时,尝试读入并恢复这些对象的状态,从而可以跳过loader,包括AST解析等耗时步骤,从而优化编译过程。

    原先webpack4只能通过loader去缓存对应的模块,比如babel-loader和eslint-loader通过开启cache: true控制,webpack5通过持久化缓存,将构建结果存储在文件系统中,node_modules/.cache/webpack目录中, cache.png

将首次构建出的 Module、Chunk、ModuleGraph 等对象序列化后保存到硬盘中,后面再运行的时候就可以跳过一些耗时的解析,链接,编译动作,直接复用缓存信息,配置如下:

// webpack.config.js
cache: {
    // 1. 将缓存类型!设置为文件系统(持久缓存)
    type: "filesystem",
    buildDependencies: {
      // 2. 将你的 config 添加为 buildDependency,以便在改变 config 时获得缓存无效
      // config: [path.join(__dirname, 'webpack.dll_config.js')],
    },
  }  

资源管理

无需引入url-loader,raw-loader和file-loader,在webpack5中统一成资源类型,只需考虑如何正确写正则:

 module: {
     rules: [
         {
             test: /\.(png|svg|jpg|jpeg|gif)$/i,
            type: 'asset',
            parser: {
                dataUrlCondition: { // 小于10MB打包成base64字符串
                    maxSize: 10 * 1024, // 10kb
                },
            },
            generator: {
                filename: 'img/[name][hash][ext][query]', // 打包到img文件夹中
            },
          },
          {
            test: /\.(woff|woff2|eot|ttf|otf)$/i,
            type: 'asset/resource',
          },
     ]
 }

配合(resolve.alias)实现在css中缩短路径:

resolve: {
    alias: {
        '@': path.resolve(__dirname, './src'),
    }
}

css文件中:

background: url('@/images/test.jpg');

构建和打包

大多数项目都是多入口,当我们项目庞大到一定地步,对于entry和HtmlWebpackPlugin多个模板的维护让我们十分头大,那有没有解决办法呢?

  1. 在根目录下src下新建pages和index文件夹,如图: image.png
  2. 在index.html和index.js中加入如下内容:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width">
    <title>index</title>
</head>
<body>
   <div id='root'></div>
</body>
</html>
index.js
console.log('in-------')
  1. 安装glob npm i glob -D 读取src/pages下所有文件夹,以文件名约定输出js和文件模板,在webpack.config.js中加入:
const glob = require("glob");
const getPageName = (filePath, isMPA) => {
  const reg = /src\/pages\/([^/]*)/;
  const match = filePath.match(reg);
  return match ? match[1] : null;
};
const entryFiles = glob.sync(
   path.resolve(__dirname, `./src/pages/*/index.js`)
);
// 记录入口对象
const entry = {};
// 记录模板插件数组
const htmlWebpackPlugins = [];
entryFiles.forEach((filePath) => {
    const pageName = getPageName(filePath);
    if (!pageName) {
      throw new Error(`未找到${filePath}页面入口文件`);
    }
    entry[pageName] = filePath;
    htmlWebpackPlugins.push(
      new HtmlWebpackPlugin({
        template: path.resolve(
          __dirname,
          `./src/pages/${pageName}/index.html`
        ),
        filename: `${pageName}.html`,
        inject: "body",
        chunks: [pageName],
        minify: {
          // html5:true,
          minifyJS: true,
        },
      })
    );
});

4.配置出口:

output: {
    filename: 'js/[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true, // webpack5新加属性,相当于CleanWebpackPlugin,清除dist中旧文件
},

在命令行执行npm start 或者npm run build打包之后构建出如下路径,证明配置成功:

image.png

样式文件处理:

  1. 安装样式loader
npm install style-loader css-loader mini-css-extract-plugin -D

这里解释下三者的区别:

  • css: 解析css文件
  • style-loader: 将js中的import的样式文件抽离出来放入标签中
  • MiniCssExtractPlugin.loader: 将js中import的样式文件单独打包成css文件,结合html-webpack-plugin,以link的方式插入到html中,这里注意:这个插件不支持HMR热更新,需要手动刷新页面才能看效果
module: {
    rules: [
      {
        test: /.css$/i,
        use: [isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'],
      },
    ],
},
  1. 安装sass或者less
npm i sass sass-loader -D

这里sass或者less可自行看需要安装.

3.安装postcss

npm i postcss postcss-loader autoprefixer -D

postcss相当于一个平台,将css解析成语法树(AST),添加我们想要的代码,比如添加各种浏览器前缀,添加个性化前缀命名,将px转成rem或者vw,vh等实用功能。移动端有两种方案如下:

  • lib-flexible + postcss-pxtorem 使用lib-flexible设置html的font-size作为基准值,并且处理一些窗口缩放问题,此方案不足点是小屏幕上字体会看不清,因为rem是按照html作为基准值来计算,屏幕太小导致字体也随之缩小了,而且还会有精度丢失问题。
npm i lib-flexible postcss-pxtorem -D

在src/pages/index/index.html中引入lib-flexible文件,添加如下代码:

<script> <%= require('raw-loader!/node_modules/lib-flexible/flexible.js') %> </script>

webpack.config.js中添加postcss-pxtorem和postcss-loader

module:{
  rules: [
     {
    test: /\.scss$/,
    use: [
    isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader',
       {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          plugins: [
            "postcss-preset-env",
            ["postcss-pxtorem", { rootValue: 75, // 375px屏幕像素 750物理像素 10rem宽度
            propList: ["*"] }],
          ],
        },
      },
    }
    ],
}
  ]
}
  • postcss-px-to-viewport 将px直接转成vw或者vh,近几年随着支持度越来越高,慢慢演变为主流方案,但仍然有些情况是无法解决的。
npm i postcss-px-to-viewport -D
postcssOptions: {
  plugins: [
    require('postcss-px-to-viewport')({
      unitToConvert: 'px',
      viewportWidth: 375,
      unitPrecision: 3,
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      minPixelValue: 1,
      exclude: /(\/|\\)(node_modules)(\/|\\)/
    }),
  ],
},
  • 优化写法,这样写更好维护postcss的配置,比如添加autoprefixer,而且不必再次重启webpack 根目录下新建postcss.config.js,将所有postcss配置放入:
// webpack.config.js
module:{
  rules: [
     {
    test: /\.scss$/,
    use: [
    isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader',
       "postcss-loader"
    ],
}
  ]
}
// postcss.config.js
module.exports = {
    plugins: [
        require('autoprefixer'),
        require('postcss-px-to-viewport')({
            unitToConvert: 'px',
            viewportWidth: 375,
            unitPrecision: 3,
            viewportUnit: 'vw',
            fontViewportUnit: 'vw',
            minPixelValue: 1,
            exclude: /(\/|\\)(node_modules)(\/|\\)/
          }),
    ]
}

添加babel

  • 安装babel相关库:
npm i @babel/core @babel/preset-env babel-loader babel-plugin-import core-js@3.18.2 -D

解释下这几个库:

@babel/core:babel的核心api,对代码进行转译。

@babel/preset-env:官方的描述是一个智能预设,可以根据预设targets使用最新的js,而无需关心浏览器需要做哪些语法转换,这样可以让js打的包更小,其实preset-env就是个桥梁,可以检验目标环境,调用目标环境插件进行babel转译。

babel-loader:调用babel/core进行转换 core-js:javascript标准的polyfill库,模块化配合useBuiltIns: 'usage'按需引入,并且不会污染全局环境

  • 根目录下添加babel.config.js
module.exports = {
    presets: [
        [
            '@babel/preset-env',
{
           // 支持chrome 58+ 及 IE 11+
           targets: {
             chrome: '58',
             ie: '11',
           }
         }
        ],
    ],
    }

我在index.js中只引用了lodash,所以只有webpack对lodash的处理: image.png

  • 添加core-js和usage属性
module.exports = {
    presets: [
        [
            '@babel/preset-env',
{
                useBuiltIns: 'usage', // 按需引入
                corejs: '3.18.2', // 指定core-js版本
               ...
            },
        ],
    ],
    }

image.png 我们发现,core-js会自动引入Promise的polyfill,并且是按需引入,但是这样依旧有不足,因为几乎所有的polyfill都是通过修改js原型方法来打补丁,这样不仅会造成命名冲突,还会造成polyfill的函数多次声明。

  • 安装babel的runtime库
npm i @babel/plugin-transform-runtime -D
npm i @babel/runtime-corejs3

引入runtime的好处是如果100个文件中有100个promise,它只是引用这些helpers,而不会重新声明promise,,而且这样做不会污染全局变量,推荐这个大佬的文章,讲的很棒:babel-runtime,最终效果: image.png

  • 最终的配置:
module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                useBuiltIns: 'usage', // 按需引入
                corejs: '3.18.2', // 指定core-js版本
                // 支持chrome 58+ 及 IE 11+
               targets: {
                 chrome: '58',
                 ie: '11',
               }
            },
        ],
    ],
    plugins: [
        ['@babel/plugin-transform-runtime', {
            corejs: 3,
        }],
        ],
};

// webpack.config.js
module: {
    rules: [
      {
        test: /\.(jsx?|tsx?)$/, // 这里为后面改写ts和添加react的jsx做准备
        use: [
            'babel-loader',
        ],
        exclude: /node_modules/,
      },
    ],
},
resolve: {
// 这里列出loader要解析的所有文件
    extensions: [
        '.ts',
        '.tsx',
        '.js',
        '.jsx',
        '.json',
        '.css',
        '.scss',
        '.less',
    ],
 }

拆分webpack配置

现在我们的目录仅有一个webpack.config.js,如果以后项目逐渐庞大,很不好维护,为此我们将webpack.config.js拆分为三个文件:

  • build/webpack.base.js --> webpack基本配置
  • build/webpack.dev.js --> webpack开发模式配置
  • build/webpack.prod.js --> webpack生产模式配置 首先,我们把原先webpack.config.js的代码全都放入base里(这里一定要注意:检查下src和dist的路径,因为都是相对的,所以要注意下,不要打在build里了),然后安装生产环境所需的包
npm i css-minimizer-webpack-plugin mini-css-extract-plugin terser-webpack-plugin purgecss-webpack-plugin -D

介绍下这几个插件的用途:

1.css-minimizer-webpack-plugin:压缩css代码

2.mini-css-extract-plugin:将css文件单独抽离打包

3.terser-webpack-plugin:利用多进程加快打包速度,打包之后不会单独生成注释文件,在生产环境可以去除log日志

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const path = require('path');
const glob = require('glob');
module.exports = {
    mode: 'production', // 这里一定要写上production,webpack会有很多优化配置
    plugins: [
    // css文件单独抽离
        new MiniCssExtractPlugin({
            filename: 'css/[name]_[contenthash:8].css',
        }),
        // 擦除无用的css代码
        new PurgeCSSPlugin({
            paths: glob.sync(`${path.join(__dirname, '../src')}/**/*`, { nodir: true }),
        }),
    ],
    optimization: {
        minimizer: [
            // '...', // 继承默认的压缩器,比如压缩js的terser-webpack-plugin
            new TerserWebpackPlugin({
                parallel: true, // 多进程并行压缩
                extractComments: false, // 不将注释提取到单独的文件,类似于 xxx.js.LICENSE.txt
                terserOptions: { // 生产环境清除console
                    compress: { drop_console: true,drop_debugger: true },
                },
            }),
            // 压缩css
            new CssMinimizerPlugin(),
        ],
    },
};

优化开发的配置:webpack.dev.js

const defaultUrls = [];
module.exports = {
    mode: 'development',
    devtool: 'eval-cheap-module-source-map',
    // 避免额外的优化
    optimization: {
        removeAvailableModules: false,
        removeEmptyChunks: false,
        splitChunks: false,
    },
    devServer: {
        static: '../dist',
        open: true,
        compress: true,
        port: 3000,
        host: 'api.com',
        proxy: {
            context: defaultUrls.map((iteUrl) => `/${iteUrl}`),
            target: 'https://api.com',
            changeOrigin: true,
            secure: false,
        }
    },
};

这里说一下proxy的context用法:webpack配置跨域的方法,比如我们需要请求一个域名api.com ,首先把host写成需要请求的域名,一般的写法是这样

proxy: {
  '/api': {
    target: 'https://api.com',
    secure: false,
  },
},

如果我们的路径很多,后端添加一个路径就多代理一个,这样很不方便,webpack提供多代理的解决方案,可以参照:webpack.js.org/configurati… webpack.config.js将配置按照development和production合并:

const { merge } = require('webpack-merge');
const baseConfig = require('./build/webpack.base');
const developmentConfig = require('./build/webpack.dev');
const productionConfig = require('./build/webpack.prod');

module.exports = (env, argv) => {
    switch (argv.mode) {
        case 'development':
            return merge(baseConfig, developmentConfig);
        case 'production':
            return merge(baseConfig, productionConfig);
        default:
            throw new Error('No matching configuration was found!');
    }
};

安装react

npm i react react-dom
npm i @babel/preset-react -D

在babel.config.js中加入preset

presets: [
        [
            '@babel/preset-env',
            ...
        ],
        '@babel/preset-react',
    ],

在src/pages/index中将index.js改成jsx,然后新增App.jsx文件

import React from 'react';

function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {count}
        </div>
    );
}

export default App;

修改index.jsx:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.querySelector('#root'));

添加jsx和tsx到入口文件类型:

const entryFiles = glob.sync(
   path.resolve(__dirname, `../src/pages/*/index.{ts,jsx,tsx}`)
);

webpack官网提到的关于react的热更新方案 react-hot-loader,现在已经被react-fast-refresh所取代:React-Refresh-Plugin,安装:

npm i @pmmmwh/react-refresh-webpack-plugin -D

修改webpack.dev.js

const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
// 在devServer中补充
devServer: {
  hot: true,
},
plugins: [new ReactRefreshWebpackPlugin()]

代码风格和约束

eslint代码约束一共有三种规则可选分别是:

  1. ESLint + Airbnb
  2. ESLint + Standard
  3. ESLint + Prettier 因为原来的脚手架用的第一种,而且airbnb比较全面,大家也可以参考下:Airbnb 初始化eslint并安装相关依赖:
npx eslint --init

image.png 安装eslint-config-airbnb相关依赖

npx install-peerdeps --dev eslint-config-airbnb

将eslint引入webpack,原先使用eslint-loader,现在用eslint-webpack-plugin

const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
  // ...
  plugins: [new ESLintPlugin({
    extensions: ['js','jsx','ts','tsx'], // 需要lint的类型
    failOnError: false, // 任何错误都会导致build失败,这里置为false
  })],
  // ...
};

.eslintrc文件,其中rules选项可以参考react-rules typescript-rules

{
    "env": {
        "browser": true,
        "es2021": true
    },
    "extends": [
        "airbnb",
        "airbnb/hooks"
    ],
    "parser": "@typescript-eslint/parser",
    "plugins": [
        "@typescript-eslint"
    ],
    "settings":{
        "react":{
            "version":"17.0.2"
        }
    },
    "rules": {} //这里写自己eslint的规则
}

最后在package.json加入lint命令

eslint --ext src .jsx,.js,.ts,.tsx

安装typescript相关的包:

npm i typescript @babel/preset-typescript -D

在babel中加入typescript

module.exports = {
    presets: [
        ...
        '@babel/preset-react',
        '@babel/preset-typescript',
        ...
    ],
};

tsconfig.js文件

{
    "compilerOptions": {
        "experimentalDecorators": true, // 装饰器语法
        "rootDir": "src", // 指定输入文件目录(用于输出)
        "outDir": "dist", // 指定输出目录
        "sourceMap": true, // 生成目标文件的 sourceMap
        "noImplicitAny": false, // 不允许隐式的 any 类型
        "module": "commonjs", // 生成代码的模块标准
        "target": "es5", // 目标语言的版本
        "jsx": "react", // react 支持jsx
        "lib": ["es6","dom"],  // TS 需要引用的库,即声明文件,es5 默认 "dom", "es5", "scripthost"
        "allowSyntheticDefaultImports":true, // 导入的模块兼容
        "paths": {
            "@/*": ["./src/*"]
        }
    },
    "include": [
        "./src/**/*"
    ],
    "exclude": [
        "node_modules",
    ]
}

最后将App.jsx和index.jsx修改后缀变成tsx,然后随便添加一些eslint的错误看看能否报错

Webpack模块联邦

一般的webpack项目存在以下几个问题:

  • 业务中相同逻辑的方法或者组件无法重用,需要直接copy到项目中,很麻烦
  • 有的项目依赖的库,版本不同,不同版本之间不好管理
  • AB测:同一个项目局部样式和表现不同,只能运行两个项目,分别查看 为此webpack5添加了模块联邦

远程模块:

output: {
    publicPath: "http://localhost:3000/",
    clean: true,
  },
  module: {},
  plugins: [
    new ModuleFederationPlugin({
      name: "remote_app",
      filename: "remoteEntry.js",
      exposes: {
        "./react": "react",
        "./react-dom": "react-dom",
      },
    }),
  ],

请求模块:

const { ModuleFederationPlugin } = require("webpack").container;
const deps = require('./package.json').dependencies;
plugins: [
    new ModuleFederationPlugin({
      name: "component_app", // 模块名称
      filename: "remoteEntry.js", // 打包生成的文件名
      exposes: {
        "./Example": "./src/Example.js", // 对外暴露的模块
      },
      remotes: {
        // 远程应用的访问别名
        "remote-app": "remote_app@http://localhost:3000/remoteEntry.js",
      },
      shared: { // 它是一个分享池,比如app1添加了lodash,但app2没有,这时app2也可以用lodash,类似externals
         react: {
          requiredVersion: deps.react, // 引用当前应用的react
          singleton: true, // 是否单例
        },
      }
    }),
]

加载动态远程容器:

在一个容器中运行两个项目

function loadComponent(scope, module) {
  return async () => {
    // Initializes the share scope. This fills it with known provided modules from this build and all remotes
    const res = await __webpack_init_sharing__("default");
    const container = window[scope]; // or get the container somewhere else
    // console.log('container----', container);
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

模块联邦的局限性:模块联邦擅长的是公共逻辑和业务的抽离,并且只能在webpack5使用,不可以技术栈共享,比如A用了react,B用了vue,C用不了vue和react,除非将A和B都exposes出去。

再聊聊微前端

微前端 = 微应用生命周期管理 + 模块联邦(可选)

微前端有三种应用模式:

  • 基座(容器)模式:代表有single-spa,乾坤(容器管理应用),飞冰
  • 自由组织模式:Nginx路由分发,微件化
  • 模块加载:模块联邦(串联方式,没有中心容器) 乾坤:基于single-spa进行了封装,加强了微应用的集成能力,摒弃了微模块的能力,适用于快速安全的集成项目,乾坤要做的是应用的集成工具。

single-spa:功能相对比较强大,让应用逻辑组件这些都成为可共享的微服务,但是使用起来会多一些入侵,需要了解整个的生命周期。(注册,开始,加载,卸载还有监听url变化)

模块联邦:解决的是微前端模块和方法复用的问题,在编译的时候获得依赖关系,只支持webpack5

System:都支持,但只能识别自己的js模块和UMD,在运行时确定依赖关系。

三、参考文章

四、总结

webpack脚手架的相关配置终于完成了,虽然相当枯燥,但总的来说收获还是很明显的:

  1. 权衡不同方案,筛选出最优解,加入自己的优化。

  2. 对于webpack5特性使用,通过网上的文章和文档,转化成自己的知识。

  3. 沉淀出自己在掘金第一篇文章,虽然有些地方还不到位,但万事开头难,以后督促自己坚持写下去。

  4. 最后贴上我的git地址webpack-project