三石的webpack(Plugins篇)

452 阅读9分钟

概念

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

事件流怎么实现的?

webpack的事件流是通过 Tapable 实现的,它就和我们的EventEmit一样,是这一系列的事件的生成和管理工具,它的部分核心代码就像下面这样:

class SyncHook{
    constructor(){
        this.hooks = [];
    }

    // 订阅事件
    tap(name, fn){
        this.hooks.push(fn);
    }

    // 发布
    call(){
        this.hooks.forEach(hook => hook(...arguments));
    }
}

在 webpack hook 上的所有钩子都是 Tapable 的示例,所以我们可以通过 tap 方法监听事件,使用 call 方法广播事件,就像官方文档介绍的这样:

compiler.hooks.someHook.tap(/* ... */);

Plugin由什么组成?

webpack 插件由以下组成:

  • 一个 JavaScript 命名函数。
  • 在插件函数的 prototype 上定义一个 apply 方法。
  • 指定一个绑定到 webpack 自身的事件钩子。
  • 处理 webpack 内部实例的特定数据。
  • 功能完成后调用 webpack 提供的回调

也就是说:

webpack 插件是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在 整个 编译生命周期都可以访问 compiler 对象。

由于插件可以携带参数/选项,你必须在 webpack 配置中,向 plugins 属性传入一个 new 实例。

一个例子:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack'); // 访问内置的插件
const path = require('path');

module.exports = {
  entry: './file.js',
  output: {
    filename: 'webpack.bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.(js|jsx)$/,
        use: 'babel-loader',
      },
    ],
  },
  plugins: [
    new webpack.ProgressPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
};

自定义插件

官网详解

// 一个 JavaScript 命名函数。
function MyExampleWebpackPlugin() {

};

// 在插件函数的 prototype 上定义一个 `apply` 方法。
MyExampleWebpackPlugin.prototype.apply = function(compiler) {
  // 指定一个挂载到 webpack 自身的事件钩子。
  compiler.hooks.emit.tapAsync(
      'MyExampleWebpackPlugin',
      (compilation, callback) => {
        console.log("This is an example plugin!!!");
        
       // 功能完成后调用 webpack 提供的回调。
        callback();
      }
  );
};

也可以写成一个类

class MyExampleWebpackPlugin {
  constructor(options) {
    //
  }
  
  apply(compiler) {
    // 指定一个挂载到 webpack 自身的事件钩子。
    compiler.hooks.emit.tapAsync(
      'MyExampleWebpackPlugin',
      (compilation, callback) => {
        console.log("This is an example plugin!!!");

        callback();
      }
    );
  }
}
module.exports = MyExampleWebpackPlugin

Compiler

Compiler模块是webpack的主要引擎,它扩展自Tapable类,用来注册和调用插件。大多数面向用户的插件都会先在Compiler上注册。

compiler会存在于webpack的整个生命周期,直到node进程关闭。

Compilation

Compilation模块会被Compiler使用配置数据创建一个compilation实例。compilation实例可以访问所有的模块和他们的依赖。

Compilation也扩展自Tapable类,提供了编译过程中的生命周期钩子。

compilation实例在每次代码更新或者是打包时都会重新创建。

Webpack中的钩子函数

我们编写插件前,需要知道我们插件是在什么时候去被调用。这个就需要了解webpack中提供的钩子函数有哪些:

  1. compiler.hooks.compilation:启动编译创建出 compilation 对象后触发
  2. compiler.hooks.make:正式开始编译时触发
  3. compiler.hooks.emit:输出资源到output目录前执行
  4. compiler.hooks.afterEmit:输出资源到output目录后执行
  5. compiler.hooks.done:编译完成后触发

想要看完整的请查看官网

常用Plugins

html-webpack-plugin

根据指定模板创建 HTML 页面文件到你的输出目录,且将 webpack 打包后的指定 chunk 自动引入到这个 HTML 中

  • 单页应用可以生成一个html入口,多页应用可以配置多个html-webpack-plugin实例来生成多个页面入口
  • 为html引入外部资源如script、link,将entry配置的相关入口chunk以及mini-css-extract-plugin抽取的css文件插入到基于该插件设置的template文件生成的html文件里面,具体的方式是link插入到head中,script插入到head或body中
const HtmlPlugin = require('html-webpack-plugin')
new HtmlPlugin({
    // 生成的文件名称,位置相对于webpackConfig.output.path路径而言
    filename: 'index.html',
    // 指定模板
    template: 'pages/index.html'
}

如何生成多个 HTML ? 第一反应,要生成多个 html,那多创建几个实例不就行了?

const HtmlPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {
    news: [path.resolve(__dirname, '../src/news/index.js')],
    video: path.resolve(__dirname, '../src/video/index.js'),
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'news page',
      filename: 'news.html', // 不写则默认 index.html,多个同名会冲突
      template: 'pages/news.html',
      chunks: ['news']  // 不写则会包括 news 和 video 打包出的两个chunks,因为现在两个entry
    }),
    new HtmlWebpackPlugin({
      title: 'video page',
      filename: 'video.html',
      template: 'pages/video.html',
      chunks: ['video']
    }),
  ]
};

事实证明也是可以的。那有不有更好的方式呢?

  1. 定好项目结构 对于多个 html,我们都需要 模板 以及待打包的 chunk
├── src 
     ├── list 
     │   ├── index.html    
     │   └── index.js
     ├── login 
     │   ├── index.html    
     │   └── index.js
     ├── detail 
     │   ├── index.html    
     │   └── index.js
     ├── index 
     │   ├── index.html    
     │   └── index.js
  1. 配置webpack 利用 setMap 方法自动生成 entry 和 htmlWebpackPlugins,这样每次添加文件都不用我们修改配置了
const path = require('path')
const htmlWebpackPlugin = require("html-webpack-plugin")

// 模糊匹配路径
const glob = require('glob')

// 自动生成 entry 和 htmlWebpackPlugins
const setMap = () => {
  const entry = {};
  const htmlWebpackPlugins = []
  
  // 模糊匹配 src 目录下 任意目录下的 index.js 返回的是文件的绝对路径
  const entryFiles = glob.sync(path.join(__dirname, "./src/*/index.js"))
  
  // 遍历匹配到的结果
  entryFiles.forEach((entryFile) => {
    // 获取到文件名
    const pageName = entryFile.match(/src\/(.*)\/index\.js$/)[1]
    
    entry[pageName] = entryFile
    
    htmlWebpackPlugins.push(
      new htmlWebpackPlugin({
        template: `./src/${pageName}/index.html`,
        filename: `${pageName}.html`,
        chunks: [pageName]
      }))
  })

  return {
    entry,
    htmlWebpackPlugins
  }
}

const { entry, htmlWebpackPlugins } = setMap()

module.exports = {
  entry,
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: " [name].js"
  },
  mode: "development",
  plugins: [
    ...htmlWebpackPlugins,
  ],
}

mini-css-extract-plugin

把 css 代码抽离出来单独放到一个文件里,可以指定文件名,支持绝对路径,会自动生成文件夹。使用它后,不需要再写 style-loader

和 extract-text-webpack-plugin 相比,它:

  • 异步加载
  • 无重复编译,性能有所提升
  • 用法简单
  • 之支持css分割
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports={
    mode: 'production', 
    devtool: 'cheap-module-source-map',
    plugins:[
        new MiniCssExtractPlugin({
        filename: "[name].css",
        chunkFilename: "[id].css"
        })
    ],
    module: {
      rules: [
        {
          test: /\.css$/,
          use: [
            {
              loader: MiniCssExtractPlugin.loader,
              options: {
                publicPath: '../'
              }
            },
            "css-loader",
            'postcss-loader', 
            'sass-loader',
          ]
        }
      ]
    }
}

需要注意的一点是,当webpack版本是4版本的时候,需要去package.json中配置sideEffects属性,这样子就避免了把css文件作为Tree-shaking

{
  "name": "webpack-demo",
  "sideEffects": [
  	"*.css"
  ]
}

optimize-css-assets-webpack-plugin

单独提取 css 文件后,可以使用这个插件对css文件进行压缩,它需要配置到 optimization.minimizer 里

安装

npm i -D optimize-css-assets-webpack-plugin

配置

接下来就是设置 optimization.minimizer ,这里需要注意的就是,此时设置optimization.minimizer会覆盖webpack默认提供的规则,比如JS代码就不会再去压缩了,所以需要手动加上JS压缩插件

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const {
    merge
} = require('webpack-merge')
const commomConfig = require('./webpack.common')

const prodConfig = {
    mode: 'production',
    devtool: 'cheap-module-source-map',
    optimization: {
        minimizer: [
            new UglifyJsPlugin({
                sourceMap: true,
                parallel: true, // 启用多线程并行运行提高编译速度
            }),
            new OptimizeCSSAssetsPlugin({}),
        ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].[hash].css',
            chunkFilename: '[id].[hash].css'
        })
    ],
    module: {
        rules: [{
            test: /\.css$/,
            use: [{
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                      publicPath: '../'
                    }
                },
                'css-loader',
                'postcss-loader',
                'sass-loader',
            ],
        }]
    },

}

module.exports = merge(commomConfig, prodConfig)

DefinePlugin

在 webpack 编译打包时可以定义的全局常量,在代码运行时可以读取使用

  • 格式 可以直接使用字符串如 "production",也可以使用 JSON.stringify('production')
  • 场景1 有些代码需要在开发时执行,例如打印详细的错误或警告信息。
    有些代码我们需要在线上运行时执行,例如数据埋点
new webpack.DefinePlugin({
    DEBUG: !/(product|stage)/.test(process.env.NODE_ENV)
}),
// task.js
if (DEBUG) {
    // 开发环境打印日志
    console.log('可查看详细的错误信息了');
} else {
    ....
}
  • 场景2
// webpack.config.js
const isProd = process.env.NODE_ENV === 'production';
module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      PAGE_URL: JSON.stringify(isProd
        ? 'https://www.homePro.com/page'
        : 'http://www.homeDev.com/page'
      )
    }),
  ]
}
// index.js
console.log(PAGE_URL);

UglifyJsPlugin

文件压缩功能,在 webpack4 中已被 optimization.minimize 替代

CommonsChunkPlugin

抽离公共模块,在 webpack4 中已被 optimization.SplitChunks 替代
如果还想了解CommonsChunkPlugin那么看我

clean-webpack-plugin

每次打包之前清理打包目录。默认情况下,这个插件会删除webpack的output.path中的所有文件,以及每次成功重新构建后所有未使用的资源。

这个插件在生产环境用的频率非常高,因为生产环境经常会通过 hash 生成很多 bundle 文件,如果不进行清理的话每次都会生成新的,导致文件夹非常庞大。

// webpack.config.js 
const { CleanWebpackPlugin } = require('clean-webpack-plugin') 
module.exports = { 
  plugins: [ 
    new CleanWebpackPlugin() 
  ], 
}

webpack-parallel-uglify-plugin

  • webpack默认提供了UglifyJS插件来压缩代码,但它是单线程的,若多个文件需要压缩,则需要依次进行。所以在正式环境打包速度比较慢(因为压缩JS代码需要先把代码解析成用Object抽象表示的AST语法树,再去应用各种规则分析和处理AST,导致这个过程耗时非常大)。

  • ParallelUglifyPlugin 作用就是会开启多个子进程,把对多个文件压缩的工作分别给多个子进程去完成,每个子进程还是通过UglifyJS去压缩代码,变成并行处理

const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');

module.exports = {
  plugins: [
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有用到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只用到一次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引用的静态值
          reduce_vars: true,
        }
      },
    }),
  ],
};

HappyPack

重点:现在已经没怎么维护了!!!
重点:现在已经没怎么维护了!!!
重点:可以转战thread-loader!!!
和 webpack-parallel-uglify-plugin 类似,前者是并行压缩,这个是并行打包。

在webpack构建过程中,需要使用Loader对js,css,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大的,在Node环境下这些转换操作不能并发处理文件,而是需要一个个文件进行处理。而 HappyPack 基本原理是将这部分 Loader 任务分解到多个子进程中去并行处理,子进程处理完成后把结果发送到主进程中,从而减少总的构建时间


const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        // 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
        use: ['happypack/loader?id=babel'],
        // 排除 node_modules 目录下的文件
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // 把对 .css 文件的处理转交给 id 为 css 的 HappyPack 实例
        test: /.css$/,
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
        }),
      },
    ]
  },
  plugins: [
    new HappyPack({
      // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
      id: 'babel',
      // 如何处理 .js 文件,用法和 Loader 配置中一样
      loaders: ['babel-loader?cacheDirectory'],
      // 共享进程池
      threadPool: happyThreadPool,
      // 日志输出
      verbose: true
    }),
    new HappyPack({
      id: 'css',
      loaders: ['css-loader'],
      // ... 其他配置项
    }),
    new ExtractTextPlugin({
      filename: `[name].css`,
    }),
  ],
};

HappyPack

DllPlugin

  • DLL是什么 DLL 是动态链接库,许多应用程序并不是一个完整的可执行文件,它们被分割成一些相对独立的动态链接库,即DLL文件,放置于系统中。当我们执行某一个程序时,相应的DLL文件就会被调用。但与 exe 不同,DLL不能独立运行,必须由其他程序调用载入内存。
  • 为什么要用它 通常来说,我们的代码至少可以分成业务代码第三方库。如果不做处理,每次构建时都需要把所有的代码重新构建一次,耗费大量的时间。然而大部分情况下,很多第三方库的代码并不会发生变更(除非是版本升级),这时就可以用到dll:

把复用性较高的第三方模块打包到动态链接库中,在不升级这些库的情况下,动态库不需要重新打包,每次构建只重新打包业务代码。

  • 理解 把每次构建,当做是生产产品的过程,我们把生产螺丝的过程先提取出来,之后我们不管调整产品的功能或者设计(对应于业务代码变更),都不必重复生产螺丝(第三方模块不需要重复打包);除非是产品要使用新型号的螺丝(第三方模块需要升级),才需要去重新生产新的螺丝,然后接下来又可以专注于调整产品本身。

  • 怎么用 使用dll时,可以把构建过程分成dll构建过程主构建过程,所以需要两个构建配置文件,webpack.config.jswebpack.dll.config.js
    在打包 dll 的时候,webpack 做一个索引,写在 manifest 文件中。然后打包项目文件时只需要读取 manifest 文件。

  • Demo 1.使用DLLPlugin打包需要分离到动态库的模块 DllPlugin是webpack内置的插件,不需要额外安装,直接新增配置webpack.dll.config.js文件:

const path = require('path')
const DllPlugin = require('webpack/lib/DllPlugin')
const dllPath = path.resolve(__dirname, "../dist"); // dll文件存放的目录
const CleanWebpackPlugin = require("clean-webpack-plugin");

module.exports = {
  // JS 执行入口文件
  entry: {
    // 把 React 相关模块的放到一个单独的动态链接库
    react: ['react', 'react-dom', 'redux']
  },
  output: {
    // 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,这里输出  react.dll.js
    filename: '[name].dll.js',
    path: dllPath,
    // 存放动态链接库的全局变量名称
    // 之所以在前面加上 _dll_ 是为了防止全局变量冲突
    library: '_dll_[name]',
  },
  plugins: [
    new CleanWebpackPlugin(["*.js"], { 
      // 清除之前的dll文件 
      root: dllPath,
     }),
    
    new DllPlugin({
      // 动态链接库的全局变量名称,需要和 output.library 中保持一致
      // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
      // 必须和上面的output中的library 对应!
      name: '_dll_[name]',
      // manifest.json 描述动态链接库包含了哪些内容, 这里输出 react.manifest.json
      // 里面包含上面的name字段 ->  "name": "_dll_react"
      path: path.join(dllPath, '[name].manifest.json'),
    }),
  ],
}

2.在package.json的scripts中增加:

"dll": "webpack --config build/webpack.dll.js",

执行npm run dll,然后到输出文件夹 dllPath 查看输出,有 react.dll.jsreact.manifest.json 文件

  • react.dll.js 里是一个js对象,对象里面的key对应的就是下面文件里的id
  • react.manifest.json文件里,用来描述对应的 dll文件 里保存的模块,如下:
// react.manifest.json
{
  "name": "_dll_react",
  "content": {
    "./node_modules/react-dom/index.js": {
      "id": "./node_modules/react-dom/index.js",
      "buildMeta": {
        "exportsType": "dynamic",
        "defaultObject": "redirect"
      }
    },
    "./node_modules/react/index.js": {
      "id": "./node_modules/react/index.js",
      "buildMeta": {
        "exportsType": "dynamic",
        "defaultObject": "redirect"
      }
    },
    "./node_modules/redux/index.js": {
      "id": "./node_modules/redux/index.js",
      "buildMeta": {
        "exportsType": "dynamic",
        "defaultObject": "redirect"
      }
    }
  }
}

3.在主构建配置文件使用动态库文件 在webpack.config.js中使用 DllReferencePlugin,DllReferencePlugin去 manifest.json 文件读取 name 字段的值,把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名,因此:在 webpack_dll.config.js 文件中,DllPlugin 中的 name 参数必须和 output.library 中保持一致

const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
const dllPath = path.resolve(__dirname, "../dist");

module.exports = merge(webpackCommonConf, {
    //...
    plugins: [
      ...(config.common.needDll ? [ 
        new DllReferencePlugin({
            manifest: require(path.join(dllPath, 'react.manifest.json')),
        }),
      ] : [])
   ]
})
  1. 在入口文件引入dll文件。

生成的dll暴露出的是全局函数,因此还需要在入口文件里面引入对应的dll文件

<body>
  <div id="app"></div>
  <!--引用dll文件-->
  <script src="../../dist/dll/react.dll.js" ></script>
</body>

DllPlugin动态链接库文件

copy-webpack-plugin

将已经存在的单个文件或整个目录复制到构建目录。

const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  plugins: [
    new CopyPlugin({
      patterns: [
        { 
          from: './template/page.html', 
          to: `${__dirname}/output/cp/page.html` 
        },
      ],
    }),
  ],
};

webpack-bundle-analyzer

一个webpack的bundle文件分析工具,将bundle文件以可交互缩放的treemap的形式展示,可以看到项目各模块的大小

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

启动服务:

  • 生产环境查看:NODE_ENV=production npm run build
  • 开发环境查看:NODE_ENV=development npm run start

webpack-merge

对 Webpack 的配置进行合并,环境区分时,常用配置

// webpack.prod.js
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base')

const prodConfig = {...}

module.exports=merge(baseConfig, prodConfig)

参考

初入门webpack