webpack实战之从roadhog2.x到webpack4.x

3,357 阅读5分钟

 这半周做了一件事,将手上的前端项目从使用过去dva脚手架自带的roadhog2.x打包工具迁移至使用webpack4.x打包,成功让本人掉了不少头发。

背景

  先说背景,目前主要做的项目其实都是兄弟姐妹系统(是的没错,就是前端圈位于鄙视链底部的TO B系统),基于早期的JSP多页应用使用React进行拆分重构;技术选型采用的是react + antd + dva。我从学校回来接入的时候,项目已经开始一段时间了。当时dva脚手架还是带的roadhog2.x构建包工具,它是在webpack之上的封装,大体上就是提供一个开箱即用的傻瓜式构建方案,技术本身是没有问题的,但是难受就难受在相关文档不是那么全,而且扩展性不足(当然如果你是随便改底层的带哥,当我没说...);比如roadhog2.x移除了过去支持的dll配置项,同时sorrycc老哥重心也转移到umi的开发维护上了...这边随着公司项目版本不断迭代,代码量的日渐增长以及一些工具、第三方库的引入导致项目构建越来越慢,拖了一万年的我终于忍不住,开始了将roadhog2.x对应构建方式迁移至webpack4.x的工作。

webpack4.x

老生常谈

源文件、Chunk、Bundle三者的联系

  一语蔽之,它们三个就是同一份代码在不同阶段的产物或者说别名,源文件是我们本地coding的代码,chunk则是源代码在webpack编译过程中的中间产物,最终源代码打包出来的就是bundle文件。

约定大于配置

  webpack 4.x要再装一个webpack-cli依赖配合,可以通过npm i webpack webpack-cli -D一起安装。

  撸过webpack 4.x的兄弟姐妹肯定有见过一个WARNINGThe 'mode' option has not been set, webpack will fallback to 'production' for this value.。现在我们再进行webpack命令行操作的时候需要指定模式--mode production/development,如果没有指定会使用默认的production。两个模式下webpack会自动地进行相应的优化操作,比如指定production会自动进行代码压缩等等。

默认情况下entry就是src/index.js

  过去我们还需要指定入口文件比如下面这样的:

    entry: {
        index: ['babel-polyfill', path.resolve(__dirname, './src/index.js')],
    }   

  现在则根本不需要配置了,因为默认使用的就是这个模块。

默认情况下output被指定为dist/main.js

  emm,这个一般就不能不设置了,如果每次打包后的资源文件(html,js,css)名相同,由于强缓存的原因,我们部署在服务器(比如Nginx)上的项目并不会更新,虽然这也可以通过Nginx配置,但其实没啥必要,我们只要使每次打出来的文件名不同(设置hash),浏览器访问的时候就会重新去请求最新的资源。比如:

  output: {
    filename: '[name].[hash:8].js',
    path: path.resolve(__dirname, './dist'),
    publicPath: '/'
  }

development模式下自动会开启source-map

  作为开发者,我们在开发环境下debug往往需要根据控制台的报错信息定位具体文件,如果没有source-map,我们得到的将是一段处理过的压缩代码,无法定位到具体文件具体代码行,这样非常不利于调试,在webpack4.x前,我们需要手动配置:

  module.exports = {
    devtool: 'source-map'
  }

  而现在在webpack4.x中通过指定模式--mode development将会自动开启该功能。

基本格调

  在开始讲迁移的踩坑记录前,我先简要讲讲一般webpack的配置文件由哪些部分组成:

  1. entry,即我们的总入口文件,我们要打包总得把从哪里开始告诉webpack吧?通常这个文件都在src/index.js。举个例子,你配置完所有的组件以后,肯定有一个顶层爹,中间嵌套的用来提供Provider的也好,配置路由的也好,最终都是将这个爹通过选择器挂载到你的根节点上,类似下面这样:

  ReactDOM.render(<Father />, document.getElementById('root'));

  当然我这边项目看了下之前貌似直接拿的ant-design-prov1版本的改的(裂开,现在都到v4了)...入口文件dva有自己的封装,v1版本的大概长下面这样:

  const app = dva({
    history: createHistory(),
  });
  app.use(createLoading());
  app.model(require('./models/global').default);
  app.router(require('./router').default);
  app.start('#root');
  export default app._store;

  2. webpack现在有文件解析了,但是咋解析,这个方案需要你告诉webpack。我们需要在module配置项下的rules内通过正则判定文件类型然后根据该类型选择不同的loader来进行不同编译,下面以解析jsjsx文件为例子:

  {
    test: /\.(js|jsx)$/,
    use: {
        loader: 'babel-loader',
        options: {
            cacheDirectory: true, // 默认false,开启后,转换结果会被缓存,再次编译优先读取缓存内容
        }
    },
    exclude: /node_modules/, // include指定包含文件,exclude除去包含文件
  }

  3. 指定了不同类型文件的处理方式以后,我们可能还想要做一些额外的扩展,比如代码压缩、生成linkscript标签、图片拷贝到存放静态资源的目录、编译过程根据库依赖关系自动引入依赖等等。这时候就需要配置plugins配置项了,拿生成script标签引入我们的bundle为例:

  new HtmlWebpackPlugin({
      template: path.join(__dirname, '/src/index.ejs'), // 参照模板,bundle会在这个模板中通过插入script的方式引入
      filename: 'index.html',
      hash: true, // 防止缓存
  })

  4. 最终我们得到的编译结果需要一个输出,可以通过配置项中的output来控制:

    output: {
      filename: '[name].[hash:8].js',
      path: path.resolve( __dirname, './dist' ),
      chunkFilename: '[name].[hash:8].async.js', // 按需加载的异步模块输出名
      publicPath: '/'
    }

实战踩坑

mini-css-extract-plugin

  webpack4.x中推荐使用的CSS压缩提取插件,最终会在我们提供的模板HTML中插入一个link标签引入编译后的样式文件;过去版本中的webpack使用的是extract-text-webpack-plugin,但是本人最初尝试使用的时候,报了Tapable.plugin is deprecated. Use new API on .hooks instead问题,去github对应项目下可以发现如下提示:

loader的支持写法以及加载顺序

  loader支持很多种写法,具体看实际场景,简单配置的可以直接写在一个字符串内比如loader: 'style-loader!css-loader',匹配顺序从右向左。复杂配置的推荐还是用数组,虽然字符串也可以通过类似GET请求那种拼接方案来设置配置项,但是可阅读性太差了。在数组中,具体loader我们可以通过对象写法来配置,看上去就清晰明了,例子如下:

  module.exports = {
    module: {
      rules: [
        test: /\.css$/,
        use: [
          {
              loader: MiniCssExtractPlugin.loader,
              options: {
                  hmr: true,
              }
          },
          {
              loader: 'css-loader',
          },
        ]
      ]
    }
  }

less处理除了less-loader还需要装less的开发环境依赖

  emm...这其实是我当时睿智了,想想都知道没有装less咋处理呢,通过npm i -D less解决。

style-loader与mini-css-extract-plugin存在冲突

  在我自己鼓捣小DEMO的时候,用style-loader都是没啥问题的,不过在迁移的项目里,加上就会报错。这里就要理清一个问题,style-loader到底负责的内容是什么,根据webpack官方的文档说明,它最终会将处理后的CSS以<style></style>的DOM结构写入HTML。然后思考一下前面的mini-css-extract-plugin功能,它俩最终想要的效果是一致的,会有冲突,所以我们移除style-loader即可。关联issue可以看下这个issue

css和less文件分开解析

  最开始的时候,我对样式的处理都是通过正则test: /\.(css|less)$/写在一块的,但是一直编译报错,估计是具体配置项不能共享或者有冲突,分开单独做处理问题解决。

antd的样式未加载

  之前roadhog中在webpackrc.js中的处理是:

  ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }],

  改用webpack4.x后,在.babelrc文件中同样写入以上配置,但是要把style的值设置为css,修改后,antd样式成功载入。

@connect装饰器报错

  HOC的装饰器写法,需要配置babel支持。现在webpack一般都不直接在自身配置文件里面设置babel了,而是将babel的配置信息抽出来放到.babelrc内以JSON格式维护,在plugins内加入下面这段即可:

  ["@babel/plugin-proposal-decorators", { "legacy": true }],

babel版本

  在转webpack4.x的过程中发现有babel报错的问题,后查发现是兼容性的坑,所以将有问题的怼到了babel7.x版本配合webpack,7.x版本的babel都带上了@前缀。

CSS-IN-JS

  因为项目内的样式是按照css-modules的规范来写的,所以编译的时候也需要开启支持,在css-loaderoptions内设置modules: true即可。

根据文件目录以及样式类名生成class

  如此生成class名可以方便我们定位调试一些样式,比如你想在控制台Element的DOM树结构里ctrl + F检索对应样式类,然后直接进行调试。这里就需要接着上面的css-modules配置调整了:

  {
      loader: 'css-loader',
      options: {
          importLoaders: 1, // 设置css-loader处理样式文件前 允许别的loader处理的数量 默认为0
          modules: {
              localIdentName: '[name]_[local]_[hash:base64:5]', // 修改生成的class的名称 name就是文件名 local对应类名 [path]支持路径
          }
      }
  },
  {
      loader: 'less-loader',
      options: {
          javascriptEnabled: true,
      }
  }

  当时改的时候有一个坑,即不能像下面这样设置class:

  改进后前后对比:

React is not defined

  这是我迁移得差不多的时候突然发现的,即部分场景出现了React is not defined的报错,然后定位了代码发现的确会缺少依赖,比如我在一个组件中引入了antd的UI组件,即便只是对引入的UI组件进行纯函数的操作,但antd本身也有对React的依赖,那为什么之前roadhog处理就没有问题呢?肯定是有额外的插件做了骚操作!最后在stackoverflow上看到一个老哥的回答,又去webpack官方文档对比了下,靠谱!加入对应插件后解决该问题。

  new webpack.ProvidePlugin({ // 根据上下文,在需要依赖React处,自动引入
      "React": "react",
  })

路由跳转组件未挂载

  不吹不黑,这东西是我迁移过程中遇到最坑的问题...最早的时候我曾经在webpack输出的内容里看到Router的warning,但是后面就消失了,造成当时走了弯路,其实罪魁祸首是这个项目在.webpackrc.js内禁用了import()这种按需动态引入的方式,就直接导致了我编译出来的文件其实除了根路由的内容,别的内容缺失。找到根源,再定位解决,就容易了,看下roadhog内对应配置项是用什么处理的即可,最后引入babel-plugin-dynamic-import-node-sync解决:

CommonsChunkPlugin

  webpack4.x中,该用于抽离不同入口文件公共部分的插件已被移除,改用optimization配置项下的splitChunks选项使用。

What's more?

progress-bar-webpack-plugin

  用来在命令行可视化webpack编译进度的插件:

  new ProgressBar({
      format: '  build [:bar] ' + chalk.green.bold(':percent') + ' (:elapsed seconds)',
      clear: false
  })

chalk

  用来设置输出颜色的“粉笔”,通过const chalk = require('chalk');引入。

friendly-errors-webpack-plugin

  自定义输出提示工具:

  new FriendlyErrorsWebpackPlugin({
      compilationSuccessInfo: {
          messages: [`You application is running here http://localhost:3000`],
      },
  })

webpack-merge

  这个库主要是用来进行webpack分包的,针对不同环境和功能,我们完全可以将webpack配置文件拆成多个,比如base文件里就是分包的webpack会共用的配置信息,dev里就是webpack-dev-serverdevelopment模式下的配置信息,prod放生产部署的压缩优化配置,dll进行代码预编译,提升首次编译后的代码编译效率,一般结构如下:

DllPlugin&DllReferencePlugin

  webpack携带的dll预编译插件,它会将几乎不改动的库进行编译(由你指定),然后生成一个编译后的js以及负责告知webpack之后编译过程哪些内容不需要再处理的json

portfinder

  查找可用端口。

Result

  开发环境编译时长从之前的半分到一分钟不等到现在的10s左右:

TODO

  进行生产打包部署的替换。毕竟迁移后的打包结果还需要评估依赖缺失的风险,这中间需要经过大量测试及灰度验证...

附录:具体配置

.babelrc

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react",
    ],
    "plugins": [
        "dva-hmr",
        [
            "babel-plugin-module-resolver",
            {
                "alias": {
                    "components": "./src/components",
                },
            },
        ],
        "@babel/plugin-proposal-function-bind",
        "dynamic-import-node-sync",
        ["@babel/plugin-proposal-decorators", { "legacy": true }],
        ["@babel/plugin-proposal-class-properties", { "loose" : false }],
        ["@babel/plugin-transform-runtime"],
        ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": "css" }],
    ],
}

package.json

{
  "name": "your-app-name",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "scripts": {
    "dll": "cross-env ESLINT=none webpack --progress --colors --config webpack.config.dll.js --mode production",
    "dev": "cross-env ESLINT=none webpack-dev-server --open --config webpack.config.dev.js --mode development",
  },
  "dependencies": {
    "@babel/polyfill": "^7.0.0-beta.36",
    "antd": "3.7.2",
    "aphrodite": "^1.2.1",
    "axios": "^0.18.0",
    "classnames": "^2.2.5",
    "dva": "^2.1.0",
    "dva-loading": "^1.0.4",
    "enquire-js": "^0.1.1",
    "jquery": "^3.2.1",
    "lodash": "^4.17.4",
    "lodash-decorators": "^4.4.1",
    "moment": "^2.19.1",
    "omit.js": "^1.0.0",
    "path-to-regexp": "^2.1.0",
    "prop-types": "^15.5.10",
    "qs": "^6.5.0",
    "rc-drawer-menu": "^0.5.0",
    "react": "^16.7.0-alpha.0",
    "react-addons-css-transition-group": "^15.6.2",
    "react-container-query": "^0.9.1",
    "react-document-title": "^2.0.3",
    "react-dom": "^16.7.0-alpha.0",
    "react-fittext": "^1.0.0",
    "react-image-lightbox-rotate": "^1.2.0",
    "react-lazyload": "^2.3.0",
    "react-pdf-js": "^4.2.3",
    "react-swf": "^1.0.7",
    "rollbar": "^2.3.4",
    "url-polyfill": "^1.0.10"
  },
  "devDependencies": {
    "@babel/core": "^7.5.5",
    "@babel/plugin-proposal-class-properties": "^7.5.5",
    "@babel/plugin-proposal-decorators": "^7.3.0",
    "@babel/plugin-proposal-function-bind": "^7.2.0",
    "@babel/plugin-transform-runtime": "^7.5.5",
    "@babel/preset-env": "^7.5.5",
    "@babel/preset-react": "^7.0.0",
    "babel-eslint": "^8.1.2",
    "babel-loader": "^8.0.6",
    "babel-plugin-dva-hmr": "^0.4.2",
    "babel-plugin-dynamic-import-node-sync": "^2.0.1",
    "babel-plugin-import": "^1.6.7",
    "babel-plugin-module-resolver": "^3.1.1",
    "babel-plugin-transform-decorators-legacy": "^1.3.5",
    "babel-polyfill": "^6.26.0",
    "chalk": "^2.4.2",
    "clean-webpack-plugin": "^3.0.0",
    "copy-webpack-plugin": "^5.0.4",
    "cross-env": "^5.2.0",
    "cross-port-killer": "^1.0.1",
    "css-loader": "^3.1.0",
    "eslint": "^4.14.0",
    "eslint-config-airbnb": "^16.0.0",
    "eslint-config-prettier": "^2.9.0",
    "eslint-plugin-babel": "^4.0.0",
    "eslint-plugin-compat": "^2.1.0",
    "eslint-plugin-import": "^2.8.0",
    "eslint-plugin-jsx-a11y": "^6.0.3",
    "eslint-plugin-markdown": "^1.0.0-beta.6",
    "eslint-plugin-react": "^7.7.0",
    "file-loader": "^4.1.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "glob": "^7.1.4",
    "html-webpack-plugin": "^3.2.0",
    "less": "^2.7.3",
    "less-loader": "^5.0.0",
    "mini-css-extract-plugin": "^0.8.0",
    "mockjs": "^1.0.1-beta3",
    "portfinder": "^1.0.13",
    "postcss-loader": "^3.0.0",
    "progress-bar-webpack-plugin": "^1.12.1",
    "purify-css": "^1.2.5",
    "purifycss-webpack": "^0.7.0",
    "redbox-react": "^1.6.0",
    "regenerator-runtime": "^0.11.1",
    "style-loader": "^0.23.1",
    "url-loader": "^2.1.0",
    "webpack": "^4.39.1",
    "webpack-bundle-analyzer": "^2.11.2",
    "webpack-cli": "^3.3.6",
    "webpack-dev-server": "^3.7.2",
    "webpack-merge": "^4.2.1"
  },
  "engines": {
    "node": ">=8.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 10"
  ]
}

webpack.config.base.js

const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const theme = require('./src/theme');
const Mode = process.env.NODE_ENV !== 'production';

module.exports = {
    output: {
        filename: '[name].[hash:8].js',
        path: path.resolve( __dirname, './dist' ),
        chunkFilename: '[name].[hash:8].async.js',
        publicPath: '/'
    },
    module: {
        rules: [
            {
                test: /\.css$/, 
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            hmr: Mode,
                        }
                    },
                    {
                        loader: 'css-loader',
                    },
                ]
            },
            {
                test: /\.less$/,
                exclude: /node_modules/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            hmr: Mode,
                        }
                    },
                    {
                        loader: 'css-loader',
                        options: {
                            importLoaders: 1,
                            modules: {
                                localIdentName: '[name]_[local]_[hash:base64:5]',
                            }
                        }
                    },
                    {
                        loader: 'less-loader',
                        options: {
                            javascriptEnabled: true,
                            modifyVars: theme,
                        }
                    }
                ]
            },
            {
                test: /(\.js|\.jsx)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        cacheDirectory: true,
                    }
                },
                exclude: /node_modules/,
            },
            {
                test: /\.(jpg|jpeg|png|svg|git|swf)$/,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            limit: 1024,
                            outputPath: 'images'
                        }
                    }
                ]   
            }
        ],
    },

    plugins: [
        new MiniCssExtractPlugin({
            filename: Mode ? '[name].css' : '[name].[hash:8].css',
            chunkFilename: Mode ? '[id].css' : '[id].[hash:8].css',
            ignoreOrder: false,
        }),
        new CopyWebpackPlugin(
            [
                {
                    from: path.resolve(__dirname, './public'),
                }
            ]
        ),
        new webpack.IgnorePlugin(/\.\/locale/, /moment/),
        new webpack.ProvidePlugin({
            "React": "react",
        }),
    ],

    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
}

webpack.config.dev.js

const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.config.base');
const ProgressBar = require('progress-bar-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin');
const portfinder = require('portfinder');
const chalk = require('chalk');
const path = require('path');

let DEFAULT_PORT = 8000;

let checkAndGetPort = () => {
    portfinder.basePort = DEFAULT_PORT;
    portfinder.getPort((err, port) => {
        if (!err) {
            DEFAULT_PORT = port;
        }
    })
}

let mergeConfig = async () => {
    await checkAndGetPort();
    return merge(
        baseConfig, {
            devServer: {
                contentBase: './dist',
                port: DEFAULT_PORT,
                inline: true,
                historyApiFallback: true,
                hot: true,
                quiet: true,
                proxy: {}                      
            },
            plugins: [
                new webpack.HotModuleReplacementPlugin(),
                new HtmlWebpackPlugin({
                    template: path.join(__dirname, '/src/index.ejs'),
                    filename: 'index.html',
                    hash: true,
                    isDev: true,
                }),
                new ProgressBar({
                    format: '  build [:bar] ' + chalk.green.bold(':percent') + ' (:elapsed seconds)',
                    clear: false
                }),
                new webpack.DllReferencePlugin({
                    context: __dirname,
                    manifest: require('./dist/vendor-manifest.json')
                }),
                new FriendlyErrorsWebpackPlugin({
                    compilationSuccessInfo: {
                        messages: [`You application is running here http://localhost:${DEFAULT_PORT}`],
                    },
                })
            ]
        }
    )
}

module.exports = mergeConfig();

webpack.config.dll.js

const path = require('path');
const webpack = require('webpack');
module.exports = {
  resolve: {
    extensions: [ '.js', '.jsx' ]
  },
  entry: {
    vendor: [
      'antd', 'aphrodite', 'axios', 'classnames',
      'dva', 'dva-loading', 'enquire-js',
      'react', 'react-dom', 'react-image-lightbox-rotate', 'moment',
      'qs', 'prop-types', 'path-to-regexp', 'react-pdf-js', 'react-swf',
      'lodash', 'jquery', 'rc-drawer-menu'
    ]
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, './dist'),
    library: 'vendor_lib_[hash:8]',
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      path: path.resolve(__dirname, './dist/vendor-manifest.json'),
      name: 'vendor_lib_[hash:8]',
    })
  ],
};


index.ejs

<%= htmlWebpackPlugin.options.isDev ? '<script src='./vendor.dll.js'></script>' : '' %>