面试官又问我是否详细的了解过webpack?

368 阅读8分钟

某个技术的出现都是为了解决某些方面的问题。

遇到了什么问题呢?

  • 模块化的出现,我们写代码的时候,尽可能地细分模块,提高代码的可维护性。但是细分模块的结果导致js文件变得更多,给浏览器带来了更多的请求,降低了访问效率。我们期望写代码时模块化分更细,但是浏览器请求的时候如何减少请求。
  • 浏览器只能识别ES6模块化,如果某个模块使用commonJS实现,浏览器如何兼容。我们期望多种模块化标准我们都可以直接支持。
  • 浏览器如何导出npm下载的第三方包呢?node端可以通过node找取文件规则,找到node_modules文件夹下导出的包,ES6如何能找到呢?
// jquery
import $ from 'jquery';

//直接放在浏览器运行会报错。 
// 第一:ESM导入文件必须以./或 ../开头 
// 第二:jquery导出方式是commonJs导出。
  • 浏览器只认识html js css ,那给我们开发中提供便利的如ts less这一类文件如何识别呢?
  • 开发出的代码,如何让各大浏览器厂商兼容。
  • 我们写代码的时候,希望代码结构,代码质量越清晰越好,便于维护。但是又不期望别人在浏览器访问到我们的文件之后,轻松的看到我们的代码。(知识产权)

这些问题的本质是,开发时运行时的侧重点不同。所以能不能出现一种工具,既能让我们开发方便,又能保证运行时的兼容性和代码安全。

所以出现了构建工具。社区很多构建工具,先学习webpack:致力于解决前端工程化问题,让开发者集中注意编写代码,其他问题交给webpack。

webpack 2012年

简介

用于现代javeScript应用程序的静态模块打包工具。他会再内部一个或者多个入口为起点构建一个依赖图,然后将项目中所需要的所有模块组合成一个或者多个浏览器兼容的bundles,来展现你的内容。

一切皆模块

安装

两个核心包

  • webpack:包含了webpack构建中所有的api

  • webpack-cli:提供一些cli命令,调用api

使用

命令行:npx webpack

默认情况下,webpack会./src/index.js 为入口进行分析文件的依赖关系,打包到./dist/main.js。

添加 --mode 判断环境。 webpack会进行进一步处理。 比如dev会有一些注释,生成的文件较为好看。但是pro就是完全压缩的文件内容了。

npx webpack --mode=production
npx webpack --mode=development

核心概念

  • Entry:指定webpack开始构建的入口模块,
  • output:告诉webpack输出的文件名以及输出目录
  • Loaders:由于webpack只能处理js文件,所以提供需要对一些非js的文件进行处理的能力,用来转换
  • Plugins:webpack构建的过程中,会在特定的时机广播事件,插件可以监听这些行为,再特点给的时候介入编译过程,例如:打包优化,压缩,资源管理

构建的核心流程

打包的基本机制:

  1. 利用babel完成代码转换,并生成单个文件的依赖
  2. 从入口开始递归分析,生成依赖图谱
  3. 将各个引用模块打包为一个立即执行函数
  4. 输出

根据这个基本机制,可以分为更详细的基本步骤,可以分为三个阶段:初始化阶段,构建阶段,生成阶段

初始化阶段(Compiler对象

  1. 初始化参数:从配置文件,配置对象,shell参数中读取,校验参数,合并出最终的参数。
  2. 初始化编译器对象:使用上一步的参数创建Compiler对象。
  3. 初始化编译环境:遍历用户定义的所有plugins集合,执行插件的apply方法。加载内部插件。
  4. 开始编译:执行Compiler对象的run方法
  5. 确定入口:找到配置文件的Entry找到所有文件的入口。

构建阶段(compilation对象)

  1. 从入口文件出发,根据文件类型构建module对象。

  2. 将module对象通过runLoader转换为js文本

  3. 将js文本通过解析为AST(babel),

  4. 遍历AST(babel)触发各种钩子:解析js文本的资源依赖(识别require/import之类的关键字),并将依赖加入到依赖列表。

  5. 使用babel转换代码(ES6转换成ES5)

  6. 递归以上步骤,直到所有文件都经过处理,都得到每个模块被编译后的最终内容和他们之间的依赖关系(构建依赖图谱)。

生成阶段

经过构建阶段之后,webpack得到了所有的模块内容和模块之间的关系,接下来生成最终资源。

  1. 输出资源:根据入口和模块之间的依赖关系(分析module和chunk,将module分配给各自的chunk),组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,可以在这一步修改输出内容。
  2. 根据配置的输出路径和文件(遍历chunk根据规则生成assets集合)写入到文件系统

chunk是输出的基本单位,默认情况下,一个entry会对应打包出一个资源。

优化chunk:

多入口打包生成的文件,可能有对相同文件的依赖,使用SplitChunksPlugin 可以优化,避免重复打包。

在输出资源的步骤中,先分析modul和chunk的关系;触发各种优化的钩子;遍历module构建chunk;再触发优化钩子。

SplitChunksPlugin 就是再构建完chunk之后的钩子中通过分析chunk的内容,生成通用的chunk;

资源的历程

先找到文件----根据类型生成module ---- 再进行loader转换---通过babel一系列操作生成依赖图谱(对module操作完毕) ----生成chunk --- 生成assrts集合(写入到系统文件)

核心模块

Loader

可以理解为翻译员。一个Loader的职责是单一的,只需要完成一种转换。

在构建阶段,runLoader会调用用户配置的loader集合读取转义资源,将千奇百怪的内容转换成标准的js文本或者AST对象, webpack才能继续的处理。

本质就是一个Node.js的模块,这个模块需要导出一个函数。这个函数会在加载文件时执行。

Loader的两种配置

  • config配置(从右往左)
module.exports = {
  module: {
    rules: [
      { test: /.css$/, use: 'css-loader',enforce: 'post' },
      { test: /.ts$/, use: 'ts-loader' },
    ],
  },
}

// test正则进行匹配文件。
// use 将匹配到的文件使用对应的loader
// enforce 配置loader的执行顺序, pre:前置loader post:后置loader

作者:19组清风
链接:https://juejin.cn/post/7036379350710616078
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
  • 行内 inline loader
import a from 'raw-loader!../../utils.js'

loader的执行顺序

  • 执行 pre loader
  • nomal loader
  • inline loader
  • post loader

loader的pitch阶段和normal阶段

loader的执行阶段实际上分两个阶段 patch阶段和normal阶段。 平时说的loader的执行顺序就是normal阶段(从右往左)。pitch是倒序调用(从左往右)。

每个 Loader 都可以有一个 pitch 方法,这个方法会在正常的 Loader 处理流程之前执行。pitch 方法的执行顺序与正常 Loader 执行顺序相反,正常情况下 Loader 是从右到左(或者说从下到上,取决于配置方式)执行,而 pitch 方法是从左到右执行。

加入有 a b c 三个loader。 解析这三个loader的顺序是 先 pitch阶段:a -> B ->C 后normal阶段 C ->B ->A

可以给loader导出的函数上添加一个pitch属性, 当解析loader的时候, pitch loader返回非undefined值是,就会发生熔断效果。后续的loader不执行了, 返回上一个loader。

通过pitch方法 可以在这里读取到一些想要的信息 必要时可以提前终止loader链,或者改变后续loader的处理逻辑

style-loader了解pitch阶段

style-loader做的事情:获取对应的样式文件内容,然后在页面创建style节点, 将内容赋值给style节点,再插入head标签

这个loader的所有逻辑都设计在pitch阶段, normal函数就是一个空函数。

假如将style-loader设计为normal loader, 根据平时我们处理css文件时,都会配合css-loader处理。 根据执行顺序, 会先经过css-loader处理,将处理结果再交给style-loader处理。

这样子理解是没错的对吧, 但是我们尝试打印出css-loader的返回结果会发现,返回的是一段js脚本。那style-loader拿到这个脚本,直接插入到style节点中, 显然是不能做到正确显示的。 想要显示,需要将读取这段js脚本的导出内容,才能生效。 这就意味这需要再normal阶段实现一系列js方法。 聪明的你会发现, 这种事情不就是webpack的能力吗?

那如果设计再pitch阶段有什么好处呢? 首先pitch阶段如果返回非空内容会进行熔断效果, 后续的pitch loader不再执行, 直接掉头执行 normal loader。

如果再pitch函数中, 通过import/require引入css loader处理文件,返回js脚本,作为这个pitch 的返回值,发生熔断, 掉头执行normal loader ,这样的好处是, 直接将css loader返回的js 脚本交给webpack执行, 不再需要我们去编写处理这段脚本的逻辑。

执行过程:

  1. style-loader的pitch阶段,返回了一段脚本,产生熔断,掉头返还给webpack。
  2. webpack读取这段脚本, 发现了 import/require,编译成一个module,省略(webpack打包步骤)
  3. 然后将处理结果,给到pitch阶段进行执行,获取到它的导出内容。

如果loader开发中需要的依赖其他loader,但上一个loader的函数返回结果不是处理好之后的资源文件内容,而是一段js脚本,那么将loader设计在pitch阶段是更合理的。

同步Loader or 异步loader

// 同步loader

function loader(content){
  return content
}

module.exports = loader;

// 异步loader
function loader(content){
  return  Promise((resolve)=>{
     setTimeout(() => {
            resolve('')
        },3000)
  })
}

// 异步loader
function loader(content){
  const callback = this.async();
  callback()
}

module.exports = loader;

缓存加速

webpack默认情况下会将loader的处理结果标记为可缓存的, 如果被处理的文件或者依赖的文件没有变化,会使用缓存的结果,可以禁用缓存

module.exports = function(source) {
  // 强制不缓存
  this.cacheable(false);
  return source;
};

实现一个Loader需要注意什么?

  • 确保单一职责,让loader的维护变得简单。
  • 链式组合,每一个loader接受前一个loader的执行结果。
  • 统一原则:遵循webpack指定的设计规定和结构,输入和输出均为字符串
function loader(content){
  // 一系列操作
  return content
}

module.exports = loader;

如何测试自己写的loader

  • 直接配绝对路径

module.exports = {
    ...
    module: {
        rules: [
            {
                test:/.js$/,
                // .js后缀其实可以省略,后续我们会为大家说明这里如何配置loader的模块查找规则
                loader: path.resolve(__dirname,'../loaders/babel-loader.js')
            }
        ]
    }
}

作者:19组清风
链接:https://juejin.cn/post/7036379350710616078
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
  • 在webpack的resolveLoader中进行配置。

Plugin

专注处理webpack在编译过程中的某个特定任务的功能和模块。

是一个独立的模块, 对外暴露一个js函数,需要提供一个apply方法(用于注入comliler对象)通过这个对象可以读取webpack的事件钩子。 钩子的回调中可以拿到编译时的compilation对象,然后对webpack进行操作,在不同的周期触发不同的Hook从而影响最终的打包结果。

  1. 初始化阶段会遍历plugin,执行apply方法, 这时候就注册了一系列的监听事件。
  2. 当webpack广播事件时,就会触发注册事件的回调函数。
  3. 回调函数中通过compiler/compilation提供的api 可以添加module、添加入口,添加编译信息等操作。
class SomePlugin {
    apply(compiler) {
    }
}

打包结果分析

再复习下webpack的流程。 找到入口文件,分析依赖,生成兼容性高的js文件(js降级)。

那是否能按住这个流程我们尝试的写一个打包后的结果呢?我们可以进行反推一波。

  1. 生成原生js文件。 原生js代码说明就没有了模块化这一说。不再有commonJS和ESM。再模块化出来之前,我们是如何实现一个模块的呢?---立即执行函数。
(function(){
  
})()
  1. 分析依赖;想要分析依赖,就要先找到文件和它对应的函数,这是不是相当于字典的数据结构。可以将这个对象传入到立即执行函数中。 每个模块的函数中可能会使用到的方法和对象:require, module,export,所以要作为参数传入
(function(map){
  
})({
  './src/a.js':function(module,exports,require){
    // ...函数
  },
  './src/inde.js':function(module,exports,require){
    // ...函数
  }
})
  1. 现在就差从入口文件开始执行,一直将每个所依赖的弄块都执行完,并且创建函数中所需要的对象和方法就ok了。
(function (map) {
  //执行函数
  function require(id) {
    //找到对应的模块函数
    var func = map[id];
    //创建module,用来保存函数执行结果,也就是导出的东西。
    var module = {
      export: {},
    };
    //创建export
    var myexport = module.export;
    //执行函数
    func(module, myexport, require);
    // 导出模块结果
    return module.export;
  }

  // 执行入口文件
  require("./src/index.js");
})({
  "./src/a.js": function (module, exports, require) {
    console.log("a");
    module.export = "a";
  },
  "./src/index.js": function (module, exports, require) {
    console.log("index");
    console.log(require("./src/a.js"));
  },
});

ok我们成功了。 现在只需要知道, 如何找到入口文件,如何生成依赖分析的map结构。 我们的mini-webpack就成功了。(直接看神三元大佬的文章 实现一个简单的Webpack

文中提到的提到的几个对象

Compiler

webpack的编译对象,包含了所有webpack的操作配置,例如loader,plugin等等完整配置信息。

该对象会在启动webpack的时候初始化,一直存活到结束退出。

这个对象中包含属性options(webpack 的完整配置信息)、获取文件的API对象、输出文件的相关API对象等

Compilation

代表一次资源的构建,每次文件重新编译都会创建一个compilation对象。

包含属性有 modules(可以认为一个文件就是一个模块,每个模块可以理解为一个module),chunk(多个modules组合成的代码快,从入口开始分析依赖关系,将多个modules组合成一个chunk),assrts(记录了本次打包的结果)

Tapable

webpack本质上是一种事件流的机制,实现整个工作流的核心就是Tapable对象。 负责编译的compiler和负责构建的compilation都是Tapable对象的实例(所以在这两个对象中也有很多Hook,可以利用这个Hook做一些事情)。 专注于自定义事件的触发和操作。tapable暴露出了很多钩子,插件可以使用这些钩子创建函数向webpack中注入自定义构建的步骤。

注册hooks的时候可以同步钩子(tap)可以注册异步钩子(tapAsync和tapPromise)。

class MyPlugin {
  apply(compiler) {
    // 在done钩子上执行一些操作
    compiler.hooks.done.tap("MyPlugin", (compilation) => {
      console.log("compilation done");
    });
    // 在emit钩子上执行一些操作
    compiler.hooks.emit.tapPromise("MyPlugin", (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(()=>{
          console.log("compilation emit");
          resolve();
        }, 1000)
      });
    });
  }
}

其他

如何处理esm和commonJS?

webpack在打包的过程中, 会讲所有的模块转换成类commonjs。

解析 webpack , vite 处理 commonjs 和 esm 的原理

如何优化打包速度

  1. 缩小文件查找范围:配置include exclude较少范围、配置resolve指定范围。

  2. 使用缓存:避免重复打包没有变化的模块

  3. 开启多线程loader转换:HappyPack

  4. 抽离第三方模块(dllPlugin):webpack只需要打包我们项目本身的文件代码

  5. splitChunks:将公共依赖快提取到chunk中减少代码重复。也可以自定义分割chunk

如何减小打包体积

  1. tree-shaking:移除没有使用到的模块,在编译分析依赖的时候, 会对每一个文件进行标记有没有使用, 没有使用最后会被剔除。(必须是ES6模块,依赖于ES6模块的静态结构特性(commonJS可以动态加载模块,分析变得困难),分析模块间的依赖)
  2. 压缩代码:uglifyJS
  3. 代码层面按需引入
  4. 图片优化:可以使用 image-webpack-loader 对图片进行压缩。

webpack热更新

webpack dev server启动一个websocket服务器,进行全双工通信

webpack监听项目中的文件变化, 文件变化之后会重新编译模块,将文件清单Manifest发送给浏览器, 浏览器接收到消息之后,进行替换旧的模块,触发页面刷新。

source map

通过以上的学习,知道运行代码的是打包之后的代码,并不是我们写的源代码。

如果报错,看到报错的发生的文件是我们打包之后的文件,并且可能是压缩丑化之后的,这就给我们的调试带来了困难。

那么如何能找到报错文件的源地址呢? 配置dectool 开启source map

首先需要明白:source map 是用来调试用的,所以应该再开发环境中使用。 生成的source map文件,也不需要上传到服务器。 如果上传到服务器, 导致额外的网络传输,也会造成自己的代码暴露。

如果上传到服务器, 也需要进行服务器配置,不能让普通用户也能请求到。

webpack5

  1. 持久化缓存:通过配置可以将编译结果放在磁盘上,显著提高构建速度
  2. 长缓存优化:优化了hash算法,尤其是contenthash的生成更加准确。只有文件内容本身未改变,hash就不会改变。 webpack5还引入了模块标识,根据内容生成稳定的ID,而不是依赖模块的索引或者路径。
  3. Tree shaking优化:改进了模块依赖分析算法,更准确的识别哪些模块实际使用,哪些未被使用。
// utils.js
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

// Webpack 5 能准确识别出 subtract 函数未被使用,从而在打包时将其移除。
  1. 资产模块:新的模块类型,处理字体图标图片等资源(webpack4中使用loader)
module.exports = {
    module: {
        rules: [
            {
                test: /.(png|jpg|gif)$/i,
                type: 'asset/resource'
            }
        ]
    }
};
  1. 模块联邦:多个独立的构建可以组成一个应用程序。 之间不存在依赖关系,可以独立开发和部署(微前端?)。可以让跨应用间真正做到模块共享
// 主应用配置
module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            remotes: {
                app2: 'app2@http://localhost:3002/remoteEntry.js'
            }
        })
    ]
};

// 子应用配置
module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app2',
            filename: 'remoteEntry.js',
            exposes: {
                './Button': './src/Button'
            }
        })
    ]
};

参考文章