likes
comments
collection
share

webpack初学者看这篇就够了

作者站长头像
站长
· 阅读数 14

前言

学习webpack已经有一段时间了,于是整理一些常见的面试题和自己的思考。

webpack是什么

简单来说webpack就是基于nodejs的Web应用程序的静态打包工具,主要用于将多个 JavaScript 文件、CSS 文件、图片等静态资源打包成一个或多个最终的优化文件,以供浏览器加载和运行。

webpack的打包流程

我们可以先简单总结一下神三元这篇文章实现一个简单webpack的流程。

  1. @babel/parser生成AST抽象语法树,然后利用@babel/traverse进行AST遍历,记录当前文件依赖关系,通过@babel/core@babel/preset-env进行代码的转换。
  2. 通过遍历,来完成依赖关系图谱的构建。
  3. 进行代码的打包。

再看一看webpack的流程就轻松多了:

  1. 初始化:读取配置参数,启动webpack,创建Compiler对象并开始解析项目。
// webpack.config.js

var path = require('path');
var node_modules = path.resolve(__dirname, 'node_modules');
var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js');

module.exports = {
  // 入口文件,是模块构建的起点.
  entry: './path/to/my/entry/file.js'
  // 文件路径指向(可加快打包过程)。
  resolve: {
    alias: {
      'react': pathToReact
    }
  },
  // 生成文件,是模块构建的终点,包括输出文件与输出路径。
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },
  // 这里配置了处理各模块的 loader ,包括 css 预处理 loader ,es6 编译 loader,图片处理 loader。
  module: {
    loaders: [
      {
        test: /.js$/,
        loader: 'babel',
        query: {
          presets: ['es2015', 'react']
        }
      }
    ],
    noParse: [pathToReact]
  },
  // webpack 各插件对象,在 webpack 的事件流中执行对应的方法。
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

完成上述步骤之后,则开始初始化Compiler编译对象,该对象掌控着webpack生命周期,不执行具体的任务,只是进行一些调度工作。

compiler对象是一个全局单例,他负责把控整个webpack打包的构建流程。

Compiler 对象继承自 Tapable,初始化时定义了很多钩子函数

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            make: new AsyncParallelHook(["compilation"]),
            entryOption: new SyncBailHook(["context", "entry"])
            // 定义了很多不同类型的钩子
        };
        // ...
    }
}

function webpack(options) {
  var compiler = new Compiler();
  ...// 检查options,若watch字段为true,则开启watch线程
  return compiler;
}
...
  1. 编译构建流程:从Entry发出,针对每个Module串行调用对应的Loader去翻译文件内容,再找到该 Module 依赖的 Module递归地进行编译处理。
// 入口
module.exports = {
  entry: './src/file.js'
}

初始化完成后会调用Compilerrun来真正启动webpack编译构建流程:

  • compiler 开始编译,主要是构建一个Compilation对象。
  • compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建,compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。
  • build module 完成模块编译,此时loader开始发挥作用,因为要分清文件的依赖关系,需要通过遍历AST 抽象语法树分析依赖的模块,进而继续循环执行下一个模块的编译解析。
  1. 输出流程:对编译后的Module组合成 Chunk,把 Chunk 转换成文件,输出到文件系统,最终Webpack打包出来的bundle文件是一个IIFE的执行函数。

什么是Loader和Plugin,什么时候发挥作用?

  • Loader本质就是js函数,它充当着翻译官的角色,对源代码文件进行处理,因为Webpack默认只能处理JavaScript文件,因为是基于nodejs,所以需要一个翻译官来对非JavaScript文件(如CSS、图片等)转换成可被Webpack处理的模块
  • module.rules 中配置,作为模块的解析规则,类型为数组。每一项都是一个 Object,内部包含了 test(类型文件)loaderoptions (参数)等属性。
  • 每个loader都有一些选项或配置参数,这些选项可以通过webpack配置文件的module.rules字段来设置。这些选项通常用于控制loader的行为,例如指定loader所处理的文件类型、启用或禁用某些功能、设置相应的输出格式等等。
  • Plugin的本质就是本质是一个具有apply方法javascript对象,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,可以对JS代码进行压缩混淆、对处理图片、字体等资源文件,将其转换为base64格式或者单独的文件、将多个JS文件合并成一个等等。这些操作都需要依靠Plugin来完成。在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
  • Plugin 在 plugins 中单独配置,类型为数组,每一项是一个 Plugin 的实例,参数都通过构造函数传入。

看看常见的配置文件吧~

// webpack vite 使用场景 
// bundler 打包一切静态资源 
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
    entry: {
        main: './src/index.js',
        vendor: ['vue']
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].[contenthash].js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            },
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader
                    },
                    'css-loader'
                ]
            },
            {
                test: /\.(png|jpe?g|gif)$/i,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            limit: 8192,
                            name: '[name].[hash].[ext]',
                            outputPath: 'images'
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: 'css/[name].css'
        }),
        new HtmlWebpackPlugin({
            filename: 'index.html'
        }),
        new CleanWebpackPlugin()
    ],
    optimization: {
        minimize: true,
        minimizer: [
            new CssMinimizerPlugin(),
            new TerserWebpackPlugin({
                terserOptions: {
                    compress: {
                        pure_funcs: ['console.log()']
                    },
                    keep_classnames: true,
                    keep_fnames: true
                }
            })
        ]
    }
}

手写一个loader

下面就手写一下使用babel来实现一个对源代码剔除console的loader。

// webpack.config.js
const path = require('path');
module.exports = {
    mode: 'development',
    entry: path.resolve(__dirname, 'index.js'),
    output: {
        filename: '[name].[contenthash].js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [{
            test: /\.js$/,
            use: path.resolve(__dirname, 'drop-console.js'),
        }]
    }
}
// drop-console.js
const parser = require('@babel/parser') // 将源代码转换成AST抽象语法树
const traverse = require('@babel/traverse').default // 遍历并更新AST中的节点
const generator = require('@babel/generator').default // 将AST抽象语法树转换为源代码
const type = require('@babel/types') // 包含对AST节点类型进行检查和创建的方法

module.exports = function(source) {
    // 将源代码解析成AST抽象语法树
    const ast = parser.parse(source, { sourceType: 'module'})
    console.log(ast);
    // 遍历AST,当满足是console对象的函数调用时删除该节点
    traverse(ast, {
        CallExpression(path) {
            if (type.isMemberExpression(path.node.callee) 
                && type.isIdentifier(path.node.callee.object, {name: "console"})) {
                path.remove();
            }
        }
    })
    // 将处理后的AST转换回源代码
    const output = generator(ast, {}, source)
    return output.code
}

分析流程:

  1. 通过path.reslove将源代码拼接,作为参数source传入函数,进行处理
  2. 通过@babel/parser将源代码解析为AST抽象语法树
  3. 通过@babel/traverse遍历AST抽象语法树,剔除console。
  4. 通过@babel/generator来将处理后的AST转化为源代码。

手写Plugin的思路

首先我们要了解webpack是基于订阅发布者模式,在它的整个生命周期里,插件Plugin会在特定的时间被调用、执行。

在之前也了解过,webpack编译会创建两个核心对象:

  • compiler: 包含了webpack的所有配置信息,包括 options 、plugin 、loader 和 webpack 整个生命周期的钩子函数。
  • compilation:作为 plugin 内置事件回调函数的参数,包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一次新的 Compilation 将被创建。

如果自己要实现plugin,也需要遵循一定的规范:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问compiler实例
  • 传给每个插件的 compiler 和 compilation 对象都是同一个引用,因此不建议修改
  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住

实现plugin的模板如下:

class MyPlugin {
    // Webpack 会调用 MyPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply (compiler) {
    // 找到合适的事件钩子,实现自己的插件功能
    compiler.hooks.emit.tap('MyPlugin', compilation => {
        // compilation: 当前打包构建流程的上下文
        console.log(compilation);
        
        // do something...
    })
  }
}

在 emit 事件发生时,代表源文件的转换和组装已经完成,可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容

webpack 可以为前端性能优化做什么?

  • 代码压缩和混淆
  • Tree Shaking
  • code splitting

下面讲一讲具体的:

  • UglifyJSPlugin 插件可以用来移除JavaScript代码中的无用内容(如注释、空格等),将变量名缩短等,从而减小代码体积。本质就是根据AST抽象语法树来进行重命名和分配。
  • MiniCssExtractPlugin插件将CSS提取为单独的文件,做到code splitting,并使用css-loaderuglifyjs-webpack-plugin对其进行压缩。
  • 默认开启所有模块的 tree shaking,无需再进行额外的配置,本质是利用ES6 模块化环境下的静态引入,来标记代码是否使用过。
  • 再增加一个入口vendor,比如一些库、框架抽离出来,独立打包,避免了重复打包,减少文件的体积,做到code splitting

常见的loder和plugin

loder部分:

  • babel-loader: 用babel来转换ES6文件到ES5
  • url-loader: 和file-loader类似,但是当文件小于设定的limit时可以返回一个Data Url
  • style-loader: 将css添加到DOM的内联样式标签style里
  • css-loader : 允许将css文件通过require的方式引入,并返回css代码
  • vue-loader: 将单文件组件中的 HTML 模板、CSS 样式表和 JavaScript 代码分离出来,然后经过相应的处理后再合并在一起,以便在浏览器中渲染出完整的组件

plugin部分:

  • UglifyJSPlugin
  • MiniCssExtractPlugin
  • clean-webpack-plugin,删除(清理)构建目录
  • HtmlWebpackPlugin,在打包结束后,⾃动生成⼀个 html ⽂文件,并把打包生成的js 模块引⼊到该 html 中

tree shaking工作原理

在ES6以前,我们可以使用CommonJS引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:

let dynamicModule; 
// 动态导入 
if (condition) {
    myDynamicModule = require("foo"); 
} else { 
    myDynamicModule = require("bar"); 
}

但是CommonJS规范无法确定在实际运行前需要或者不需要某些模块,所以CommonJS不适合tree-shaking机制。在 ES6 中,引入了完全静态的导入语法:import。

import foo from "foo"; 
import bar from "bar"; 
if (condition) {
    // foo.xxxx 
} else {
    // bar.xxx 
}

ES6的import语法可以完美使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

common.js 和 es6 Module 规范引入的区别?

1、CommonJS 模块输出的是一个值的拷贝,容易产生循环依赖问题,ES6 模块输出的是值的引用

2、CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

3、CommonJs 是无法支持异步加载,ES6 Module可以支持异步加载。

4、CommonJs 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层。

5、CommonJs 的 this 是当前模块,ES6 Module的 this 是 undefined。

webpack的作用

  • 模块打包:将不同的文件整合在一起,保证引用正确,执行有序。
  • 编译兼容:通过webpackLoader机制,不仅仅可以帮助我们对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件,让我们在开发的时候可以使用新特性和新语法做开发,提高开发效率。
  • 自动化构建:可以自动化进行打包、压缩、混淆、优化等操作,从而简化了前端开发流程,提高了开发效率。