前端构建(三)——使用webpack5去构建项目

472 阅读7分钟

1 项目结构

image.png 为了方便讲解,这里我们可以直接先使用 npx create-react-app my-app快速搭建一个简单的项目,创建出来的项目的的src就是上图的src

添加一些配置文件

在项目根目录下加一些配置文件

.gitignore

git提交忽略的文件配置

.eslintignore

eslint忽略目录文件

.babelrc.js

babel配置,下面是社区总结出来的最佳实践,嗯官方也承认了,只不过官方觉得太多了,目前还在讨论中

/**
 * babel 配置
 */

 function resolvePlugin(plugins) {
  return plugins.filter(Boolean).map((plugin) => {
    if (Array.isArray(plugin)) {
      const [pluginName, ...args] = plugin
      return [require.resolve(pluginName), ...args]
    }
    return require.resolve(plugin)
  })
}

/**
 * 基本plugins
 * @doc https://babeljs.io/blog/2018/07/27/removing-babels-stage-presets
 */
const basePlugins = [
  // Stage 0
  '@babel/plugin-proposal-function-bind',
  // Stage 1
  '@babel/plugin-proposal-export-default-from',
  '@babel/plugin-proposal-logical-assignment-operators',
  ['@babel/plugin-proposal-optional-chaining', { loose: false }],
  ['@babel/plugin-proposal-pipeline-operator', { proposal: 'minimal' }],
  ['@babel/plugin-proposal-nullish-coalescing-operator', { loose: false }],
  '@babel/plugin-proposal-do-expressions',
  // Stage 2
  ['@babel/plugin-proposal-decorators', { legacy: true }],
  '@babel/plugin-proposal-function-sent',
  '@babel/plugin-proposal-export-namespace-from',
  '@babel/plugin-proposal-numeric-separator',
  '@babel/plugin-proposal-throw-expressions',
  // Stage 3
  '@babel/plugin-syntax-dynamic-import',
  '@babel/plugin-syntax-import-meta',
  ['@babel/plugin-proposal-class-properties', { loose: false }],
  '@babel/plugin-proposal-json-strings',
  // '@babel/plugin-proposal-private-methods',
]

// 项目自定义plugins
const customPlugins = [
  '@babel/plugin-transform-runtime',
  [
    'ramda',
    {
      useES: true,
    },
  ],
  'lodash',
  [
    'import',
    {
      libraryName: 'antd',
      style: true,
    },
  ],
]

module.exports = {
  presets: resolvePlugin([
    [
      '@babel/preset-env',
      {
        // will add direct references to core-js modules as bare imports (or requires).
        useBuiltIns: 'usage',
        // Set the corejs version we are using to avoid warnings in console
        // This will need to change once we upgrade to corejs@3
        // https://github.com/babel/babel/blob/master/packages/babel-preset-env/src/polyfills/corejs3/built-in-definitions.js
        corejs: 3,
        // Do not transform modules to CJS
        modules: false,
        // Exclude transforms that make all code slower
        exclude: ['transform-typeof-symbol'],
      },
    ],
    '@babel/preset-react',
    '@babel/preset-typescript',
  ]),
  plugins: [...resolvePlugin(basePlugins), ...customPlugins],
  // sourceType: 'unambiguous',
}

.prettierrc

prettier插件格式化配置,因团队所好

2. 包管理package.json介绍

{
  "name": "my-app",
  "version": "0.1.0",
  "author": "ywen",
  "license": "ISC",
  "description": "react template",
  "dependencies": {
    "axios": "0.18.0"
  },
  "devDependencies": {
    "@babel/core": "^7.16.0",
    "@babel/plugin-proposal-class-properties": "^7.16.5",
    "@babel/plugin-proposal-decorators": "^7.16.5",
    "@babel/plugin-proposal-do-expressions": "^7.16.5",
    "@babel/plugin-proposal-export-default-from": "^7.16.5",
    "@babel/plugin-proposal-export-namespace-from": "^7.16.5",
    "@babel/plugin-proposal-function-bind": "^7.16.5",
    "@babel/plugin-proposal-function-sent": "^7.16.5",
    "@babel/plugin-proposal-json-strings": "^7.16.5",
    "@babel/plugin-proposal-logical-assignment-operators": "^7.16.5",
    "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.5",
    "@babel/plugin-proposal-numeric-separator": "^7.16.5",
    "@babel/plugin-proposal-optional-chaining": "^7.16.5",
    "@babel/plugin-proposal-pipeline-operator": "^7.16.5",
    "@babel/plugin-proposal-private-methods": "^7.16.5",
    "@babel/plugin-proposal-throw-expressions": "^7.16.5",
    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
    "@babel/plugin-syntax-import-meta": "^7.10.4",
    "@babel/plugin-transform-runtime": "^7.16.4",
    "@babel/preset-env": "^7.16.4",
    "@babel/preset-react": "^7.16.0",
    "@babel/preset-typescript": "^7.16.0",
    "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
    "antd": "^4.17.1",
    "babel-loader": "^8.2.3",
    "babel-plugin-import": "^1.13.3",
    "babel-plugin-lodash": "^3.3.4",
    "babel-plugin-ramda": "^2.0.0",
    "case-sensitive-paths-webpack-plugin": "^2.4.0",
    "connected-react-router": "^6.9.1",
    "copy-webpack-plugin": "^10.0.0",
    "core-js": "^3.19.1",
    "cross-env": "^7.0.3",
    "css-loader": "^6.5.1",
    "css-minimizer-webpack-plugin": "^3.3.1",
    "file-loader": "^6.2.0",
    "html-loader": "^3.0.1",
    "html-webpack-plugin": "^5.5.0",
    "json-loader": "^0.5.7",
    "less": "^4.1.2",
    "less-loader": "^10.2.0",
    "lodash": "^4.17.21",
    "mini-css-extract-plugin": "^2.4.5",
    "numeral": "^2.0.6",
    "postcss": "^8.3.11",
    "postcss-loader": "^6.2.0",
    "postcss-preset-env": "^7.1.0",
    "react": "^17.0.2",
    "react-dev-inspector": "^1.7.1",
    "react-dev-utils": "^11.0.4",
    "react-document-title": "^2.0.3",
    "react-dom": "^17.0.2",
    "react-redux": "^7.2.6",
    "react-refresh": "^0.11.0",
    "react-router-dom": "^5.3.0",
    "redux": "^4.1.2",
    "redux-thunk": "^2.4.0",
    "shelljs": "^0.8.4",
    "style-loader": "^3.3.1",
    "url-loader": "^4.1.1",
    "web-vitals": "^2.1.2",
    "webpack": "^5.64.2",
    "webpack-cli": "^4.9.1",
    "webpack-dev-server": "^4.5.0",
    "webpack-merge": "^5.8.0"
  },
  "scripts": {
    "start": "npm run dev",
    "dev": "cross-env NODE_ENV=development node scripts/webpack/build.dev.js",
    "build": "cross-env NODE_ENV=production node scripts/webpack/build.prod.js"
  }
}

嗯,看起来很多包,像create-react-app,就把这些包的作用分别拆分不同的包里,接下里我们说说这些包的作用

处理js

image.png 包中带babel名字的都是和babel处理相关,其中截图中的包都是.babelrc.js文件中用到的

处理css

  • css-loader 处理css文件
  • less-loader 处理less文件
  • postcss-loader 处理css前缀比如 -ms-
  • style-loader 开发环境处理,把样式全打入html根下style标签里,这样能更快的响应热更新

image.png

  • mini-css-extract-plugin 生产环境用于模块分析,异步加载、css分割,样式冲突检测等

html处理

html-webpack-plugin可以把一些变量打入html模版中

其他图片,json文件等资源处理

webpack5之前会用到url-loader、file-loader;webpack5可以使用内置的资源处理模块(asset module type)来处理,当然你也可以继续使用原来的方式

其他的包

  • lodash 工具库,用过了都说好
  • antd UI组件库
  • case-sensitive-paths-webpack-plugin 严格区分大小写文件名,以解决git识别不了大小写文件名导致的问题
  • axios 借口请求库 0.18以后的版本需要注意下请求头权限的配置...,我一般写死0.18
  • @pmmmwh/react-refresh-webpack-plugin 开发环境热更新
  • react-dev-inspector 用于点击浏览器位置直接触发vscode代码位置神器
  • shelljs 自定义脚本处理,你懂的
  • web-vitals create-react-app自带的性能统计,我留下了

构建webpack配置讲解

我们为项目根目录下创建一个scripts的目录用于存放各种处理脚本,比如包检查,webassembly编译等。在scripts目录下创建一个叫webpack目录,用于放webpack处理的脚本和配置。

image.png 嗯,看到了熟悉的webpack配置文件

  • webpack.base.conf.js 基本配置
  • webpack.dev.conf.js 开发环境配置
  • webpack.prod.conf.js 生产环境配置
  • paths.js 文件路径集合
  • build.dev.js 开发环境启动文件
  • build.prod.js 生产环境启动文件 可能大家会疑惑为啥要多出两个build.*.js文件下面我会一一介绍各个文件

image.png

基本配置base.conf

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const webpack = require('webpack')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin')
const { resolve } = require('path')
const paths = require('./paths')
const fs = require('fs')
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')

const appDirectory = fs.realpathSync(process.cwd())
const resolveApp = (relativePath) => resolve(appDirectory, relativePath)

// Exclude and Include
const defaultIncludePath = [paths.src]
const isDev = process.env.WEBPACK_SERVE === 'true'
const entriesMap = {
  index: 'src/index.js',
}
const entriesNames = Object.keys(entriesMap) || []
const baseCssLoader = [
  {
    loader: isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
  },
  'css-loader',
  {
    loader: 'postcss-loader',
    options: {
      postcssOptions: {
        plugins: [['postcss-preset-env']],
      },
    },
  },
]

module.exports = {
  entry: entriesMap,
  target: 'web',
  resolve: {
    extensions: [
      '.web.js',
      '.jsx',
      '.js',
      '.css',
      '.vue',
      '.html',
      '.less',
      '.postcss',
      '.json',
    ],
    alias: { // 快捷路径别名设置比如可以直接 import utils from 'utils'
      '@': paths.src,
      public: paths.public,
      src: paths.src,
      components: resolve('src/components'),
      utils: resolve('src/utils')
    },
  },

  /**
   * 核心编译模块可以对照上图所示
   * webpack 中所有的loader 都可以拥有include和exclude属性。
   * exclude:排除不满足条件的文件夹(这样可以排除webpack查找不必要的文件)
   * include:需要被loader 处理的文件或文件夹
   */
  module: {
    rules: [
      { // 处理js
        test: /\.js|\.jsx?$/,
        use: [
          {
            loader: 'babel-loader',
          },
          // 调试代码神器,点击页面快速定位到代码位置,注意这个 loader babel 编译之前执行
          // {
          //   loader: 'react-dev-inspector/plugins/webpack/inspector-loader',
          //   options: { exclude: [resolve(__dirname, 'assets')] },
          // },
        ],
        include: defaultIncludePath,
        exclude: /node_modules/,
      },
      { // 处理.css后缀文件
        test: /\.css$/,
        use: [...baseCssLoader],
      },
      { // 处理.less后缀文件和注入全局less变量
        test: /\.less$/,
        use: [
          ...baseCssLoader,
          {
            loader: 'less-loader',
            options: {
              lessOptions: {
                modifyVars: {
                  'primary-color': '#1890ff', // 全局主色
                  'link-color': '#1890ff', // 链接色
                  'success-color': '#52c41a', // 成功色
                  'warning-color': '#faad14', // 警告色
                  'error-color': '#f5222d', // 错误色
                },
                javascriptEnabled: true,
              },
            },
          },
        ],
      },
      // assets资源处理
      // {
      //   test: /\.(png|svg|jpg|jpeg|gif)$/i,
      //   include: [paths.src],
      //   type: 'asset/resource',
      // },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          // esModule: false,
          limit: 10000,
          name: 'img/[name].[hash:base64:7].[ext]',
        },
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name].[hash:base64:7].[ext]',
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'fonts/[name].[hash:base64:7].[ext]',
        },
      },
    ],
  },
  plugins: [
    // 忽略moment国际化文件
    new webpack.IgnorePlugin({
      resourceRegExp: /^\.\/locale$/,
      contextRegExp: /moment$/,
    }),
    // html模版处理,这里可以注入 变量然后在html模版中直接使用变量
    ...entriesNames.map((entryName) => {
      // const excludeChunks = entriesNames.filter((n) => n !== entryName)
      return new HtmlWebpackPlugin({
        // excludeChunks: excludeChunks.concat(
        //   excludeChunks.map((entryName) => `runtime~${entryName}`),
        // ),
        filename: `${entryName}.html`,
        // inject: true,
        templateParameters: {
          NODE_ENV: process.env.NODE_ENV,
        },
        favicon: resolve('public/favicon.ico'),
        template: resolve('public/index.html'),
        minify: false,
      })
    }),
    // 严格大小写文件区分
    new CaseSensitivePathsPlugin(),
    // 拷贝
    new CopyWebpackPlugin({
      patterns: [
        {
          from: resolve('public/manifest.json'),
          to: resolve('dist'),
        },
        {
          from: resolve('public/logo192.png'),
          to: resolve('dist'),
        },
      ],
    }),
    new ModuleNotFoundPlugin(resolveApp('.')),
  ],
}

开发环境dev.conf

const webpack = require('webpack')
const { merge } = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
// const ESLintPlugin = require('eslint-webpack-plugin') // webpack5专用的eslint
// const CircularDependencyPlugin = require('circular-dependency-plugin') // 找出循环依赖

const { resolve } = require('path')
const defaultIncludePath = [resolve('src')]
const eslintExclude = [/node_modules/]

const devWebpackConfig = merge(baseWebpackConfig, {
  output: {
    path: resolve('dist'),
    filename: 'js/[name].[hash:6].js',
    publicPath: '/',
    devtoolModuleFilenameTemplate: (info) =>
      resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
  },
  mode: 'development',
  cache: {
    type: 'filesystem', // 使用文件缓存
  },
  module: {
    rules: [
     // 热更新
      {
        test: /\.(js|ts)$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              plugins: [require.resolve('react-refresh/babel')].filter(Boolean),
            },
          },
        ],
      },
    ],
  },
  infrastructureLogging: {
    level: 'none',
  },
  devServer: {
    historyApiFallback: true,
    hot: true,
    compress: true,
    port: 5000,
    open: false,
  },
  devtool: 'cheap-module-source-map',
  plugins: [
    new webpack.DefinePlugin({
      process: {
        env: {
          NODE_ENV: '"development"',
          PWD: JSON.stringify(process.env.PWD),
        },
      },
    }),
    // 热更新
    new ReactRefreshWebpackPlugin({
      overlay: false,
    }),
    // new CircularDependencyPlugin({
    //   exclude: /node_modules/,
    //   include: /src/,
    //   failOnError: true,
    //   allowAsyncCycles: false,
    //   cwd: process.cwd(),
    // }),
  ],
  optimization: {
    providedExports: true,
    usedExports: true,
  },
})
module.exports = devWebpackConfig

这里特别要注意的是webpack.DefinePlugin,和之前使用的不一样,参数要变成对象方式

生产环境

const path = require('path')
const { merge } = require('webpack-merge')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const webpack = require('webpack')
// const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const baseWebpackConfig = require('./webpack.base.conf')
const { resolve } = require('path')

const wbpackConfig = merge(baseWebpackConfig, {
  mode: 'production',
  output: {
    publicPath: './',
    path: resolve('dist'),
    filename: path.posix.join('static', 'js/[name].[contenthash].js'), // 占位符如[id]、[chunkhash]、[name]等分别代表编译后的模块id、chunk的hashnum值、chunk名等
  },
  optimization: {
    minimize: true,
    moduleIds: 'deterministic',
    minimizer: [
      new TerserPlugin({
        parallel: true,
        terserOptions: {
          parse: {
            ecma: 8,
          },
          compress: {
            ecma: 5,
            warnings: false,
            drop_debugger: true,
            // drop_console 会删去 console.*
            // drop_console: true,
            // 仅丢弃 console.log
            pure_funcs: ['console.log'],
          },
          output: {
            comments: false,
          },
        },
      }),
      new CssMinimizerPlugin({
        parallel: 4,
      }),
    ],
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          name: `chunk-vendors`,
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          chunks: 'initial',
        },
        dll: {
          name: `chunk-dll`,
          test: /[\\/]bizcharts|[\\/]\@antv[\\/]data-set/,
          priority: 15,
          reuseExistingChunk: true,
        },
        common: {
          name: `chunk-common`,
          minChunks: 3,
          priority: -20,
          chunks: 'all',
          reuseExistingChunk: true,
        },
      },
    },
    runtimeChunk: true,
  },
  plugins: [
    new webpack.DefinePlugin({
      process: {
        env: {
          NODE_ENV: '"production"',
          PWD: JSON.stringify(process.env.PWD),
        },
      },
    }),
    // css处理
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash].css',
      chunkFilename: 'static/css/[name].[contenthash].css',
      // 如果出现css 覆盖冲突,可以加上这个
      // ignoreOrder: true,
    }),
    // 包大小分析
    // new BundleAnalyzerPlugin({ analyzerPort: '9900' })
  ],
})

module.exports = wbpackConfig

开发环境启动build.dev

为什么不直接用webpackConfig.devServer的熟悉open就行了呢,那是因为如果那么用的话,会导致每次启动都会新开一个窗口,而不是看浏览器是否已经有了打开的窗口直接复用

const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
const clearConsole = require('react-dev-utils/clearConsole')
const openBrowser = require('react-dev-utils/openBrowser')
const webpackConfig = require('./webpack.dev.conf')
const portfinder = require('portfinder')

const HOST = webpackConfig.devServer.host || '0.0.0.0'
const PORT = webpackConfig.devServer.port || 5000
const protocol = webpackConfig.devServer.https ? 'https' : 'http'
const url = `${protocol}://${HOST}:${PORT}`
let isFirstCompile = true

const compiler = webpack(webpackConfig)
// 防止重复打开窗口
const devServer = new WebpackDevServer(webpackConfig.devServer, compiler)
compiler.hooks.done.tap('done', stats => {
  clearConsole()

  if (isFirstCompile) {
    isFirstCompile = false
    console.log('oppo the Browser to::', url)
    openBrowser(url)
  }
})

// ip冲突时候重新获取
portfinder.getPort(
  {
    port: PORT,
    stopPort: 7000,
  },
  (err, port) => {
    if (err) {
      return
    }
    devServer.start(port, 'localhost', (error, result) => {
      if (error) {
        console.log(error)
      }
    })
  }
)

生产环境启动build.prod

这里我没有用clean-webpack插件,而是直接运行rm删除dist,单独把构建文件拎出来还有个好处就是定制输出日志就这里的stats,你可以用网上的一些好看的插件去输出

const rm = require('rimraf')
const chalk = require('chalk')
const webpack = require('webpack')
const webpackConfig = require('./webpack.prod.conf')

rm('dist', err => {
  if (err) throw err
  webpack(webpackConfig, function (err, stats) {
    if (err) throw err
    process.stdout.write(stats.toString({
      colors: true,
      modules: false,
      children: false,
      chunks: false,
      chunkModules: false
    }) + '\n\n')

    console.log(chalk.cyan('  Build complete.\n'))
    console.log(chalk.yellow(
      '  Tip: built files are meant to be served over an HTTP server.\n' +
      '  Opening index.html over file:// won\'t work.\n'
    ))
  })
})

最后

命令配置

"scripts": {
    "start": "npm run dev",
    "dev": "cross-env NODE_ENV=development node scripts/webpack/build.dev.js",
    "build": "cross-env NODE_ENV=production node scripts/webpack/build.prod.js"
  }

这样就可以去构建一个项目了