webpack概念简介

一、webpack概述

1. 什么是webpack?

webpack被定义为现代 JavaScript 应用程序的静态模块打包器(module bundler),是目前最为流行的JavaScript打包工具之一。

webpack会以一个或多个js文件为入口,递归检查每个js模块的依赖,从而构建一个依赖关系图(dependency graph),然后依据该关系图,将整个应用程序打包成一个或多个bundle。

由于webpack是用nodejs编写的,所以它依赖的运行环境就是nodejs。也正因为这一点,webpack只能识别JavaScript,所有非JavaScript(包括HTML,CSS,Typescript等)编写的文件都需要经过处理,这是借助对应的loader实现的。

webpack使用的是nodejs默认的模块系统:commonjs,借助nodejs提供的API来操作待打包项目的源文件(如fs模块、path模块等)。webpack将这些文件整合压缩后,输出到一个特定的目录下(通常是dist)。处理过的dist一般会被直接上传到静态资源服务器使用。

2. 为什么要使用webpack?

第一,未打包的项目通常体积庞大,文件数量众多。如果将其直接上传到服务器,用户访问网站时,浏览器会发送大量的http请求来下载这些文件,这会给服务器带来很大的压力,同时客户端的体验也非常不好。

第二,浏览器本身不支持任何模块系统。因此,使用模块系统开发出的JavaScript代码无法直接在浏览器中运行,而模块系统对现代JavaScript开发是非常重要的。这样,我们需要有一个工具,将模块系统编写出的代码转化为浏览器所能识别的代码。webpack就可以完成这一任务。

第三,大多数情况下,我们不希望源代码暴露给用户,即使是保密性要求不那么高的前端代码。我们知道,PC端浏览器通常都提供开发者工具,可以方便地查看和调试前端代码,这在开发环境下意义重大。但对于生产环境,暴露源代码不仅没有太大意义,反而存在安全隐患(如果黑客比你更先发现代码中的bug,你可能面临严重损失)。因此,我们可以借助webpack重组和混淆源代码,增加黑客阅读源代码的难度,以提升系统的安全性。

第四,借助webpack提供的dev-server,可以实现前后端分离。dev-server本质上就是一个node服务。当通过命令行启用dev-server时,webpack会在本地启动一个node服务,将打包后的文件作为静态资源注入该服务,这样就可以在不依赖后台(这种说法并不完全准确,实际上webpack是通过node为你提供后台服务)的情况下进行前端开发了。

除了以上这些,webpack还有很多强大的功能,这里暂不详述。

二、webpack相关概念

要在项目中使用webpack,需要首先安装nodejs,它是webpack的运行环境。nodejs安装成功后,就可以通过npm install webpack -g来全局安装webpack。这样就可以在你的项目中使用webpack了。

在项目中使用webpack的核心是编写配置文件。配置文件通常命名为webpack.config.js,是一个符合commonjs规范的js文件。该文件通过module.exports暴露出一个js对象,我们称这个对象为webpack的配置对象(options)。webpack会根据这个配置对象来决定如何打包项目。

配置对象中包含四个核心参数:

  1. 入口(entry)
  2. 出口(output)
  3. 加载器(loader)
  4. 插件(plugin)

1. 入口(entry)

顾名思义,它定义了webpack的打包入口,也就是webpack从哪个js开始打包。

一个应用程序可以有一个或多个入口,由entry属性指定,通常是一个对象。如果这个对象内只包含了一个入口,也可以简写为一个字符串(或字符串数组)。如:

module.exports = {
  entry: {
    main: "./src/main.js"
  }
}

// 可以简写为:
  entry: "./src/main.js"

上述配置定义src目录下的main.js为打包入口,webpack将从这个文件开始,构建整个项目的依赖关系图。

一个应用程序可以有多个打包入口,常见的场景如多页应用,独立打包第三方库等:

entry: {
  app: './src/app.js',
  vendors: './src/vendors.js'
}

上面的配置,要求webpack分别以app.jsvendors.js为打包入口,独立构建依赖关系图。最终,项目代码和第三方代码将被独立打包出来。构建多页应用时,也是分别为每个页面提供一个入口文件,独立构建依赖图。

此外,入口参数允许传入字符串数组。如:

entry: ["./src/main1.js", "./src/main2.js"]

这两个文件都是应用的主入口,它们会被打包生成到同一个chunk文件中。当主入口文件过于庞大,需要拆分成多个,但希望它们输出到同一个打包文件时可以使用。

2. 出口(output)

也就是webpack的输出,由output属性定义。

与入口不同的是,一个应用程序只能有一个出口。出口是一个对象,包含两个属性:filenamepath,分别定义打包结果的文件名和输出位置。如:

module.exports = {
  entry: {
    main: "./src/main.js"
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname + '/dist')
  }
}

以上配置定义main.js为应用的入口文件,最终输出的文件名为bundle.js,输出位置是当前路径下的dist文件夹。

当应用程序由多个打包入口时,产生的输出结果也会有很多个,一一为每个文件指定文件名非常不灵活。为此,webpack允许使用占位符来定义文件名。如:

{
  entry: {
    app: './src/app.js',
    search: './src/search.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname + '/dist')
  }
}

// 打包将输出app.js和search.js两个文件

这里filename的值中[name]就是使用了占位符,webpack会将其替换成入口文件的文件名。因此,app.jssearch.js这两个入口文件在打包后会在dist文件夹下生成两个同名文件。

当然,我们几乎从来不会这样定义filename,因为固定的文件名无法用于热更新(HMR,Hot Module Replacement,直译为模块热替换)。热更新的实现机制如下:

  1. 在一份清单文件(manifest文件)中列举所有依赖的模块,每个模块对应的文件名中带有一个版本号,如chunk.1.0.0.js
  2. 当某个模块发生修改,就重新打包该模块,并修改对应文件名中的版本号,如chunk.1.0.1.js。此时文件名就发生了变化。
  3. 热更新机制检测到清单文件中的文件名发生变化,就会重新下载和更新该模块,文件名没有变化的模块不会被重新下载。这样应用就得到了更新。

由于webpack不需要对每次的代码修改都进行版本管理,所以它只需要向文件名中插入一个随机的hash值即可。这个hash值每次重新打包都会变化,以保证热更新机制可以正确更新。假如某次打包后的文件名为app.23j3j2366842h76ewhd.js,随后我们对该模块进行了修改,重新打包后webpack插入了一个新的hash值,得到app.er234hh9hydyt586.js。热更新模块检测到文件名变化,就会自动下载这个新的js文件,来更新应用的状态。

此时的出口一般写成这样:

{
  entry: {
    app: './src/app.js',
    search: './src/search.js'
  },
  output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname + '/dist')
  }
}

这会输出两个类似于app.57bjs8k8rfht7.jssearch.su774fju83jur.js的打包结果,它们会被添加到一份清单文件。每当修改模块的内容,webpack都会重新打包,生成新的hash值,并更新清单文件,这样热更新机制就可以生效了。

注意,使用splitChunk进行代码分割时,被分割出来的代码默认命名为chunk.[hash].js

3. 加载器(loader)

在介绍加载器之前,我们先来看webpack打包时会遇到的一个问题。

在概述中我们已经讲到,webpack的运行环境是nodejs,因此它只能识别JavaScript。但是我们的项目中可能存在大量的非JavaScript文件,如HTML、CSS、Typescript、txt,甚至图片文件等。

有人可能会说,webpack又不需要执行这些文件,直接输出到dist目录下不就行了吗?

如果这些非JavaScript文件只被js文件引用,而他们之间互相没有依赖关系,webpack确实没必要解析他们。但当它们存在依赖关系时,问题就不这么简单了,如:
index.css

@import "./color.css"

这里index.css中引入了color.css。我们假设index.css是在js中引入的,那么webpack在解析js时自然会把index.css添加到依赖关系图中。

可是webpack运行在nodejs环境下,它无法解析index.css的内容,因此它不知道index.css内部还依赖了color.css。这样,color.css就无法被添加到依赖关系图中,而不在依赖关系图中的文件在打包时将被舍弃。也即是说,webpack最终打包出的代码中不会包含color.css

这显然是错误的,我们需要color.css

为了解决这个问题,我们需要一些额外的代码帮助webpack识别css文件中的依赖。我们会编写一个函数,它将index.css读取为一个字符串,然后转化成js(注意,转化成js只是为了让webpack解析依赖关系,因此转化出的js与原css并不等价)输出出来,这样webpack就可以解析了。而这个用于转换的函数,就称为一个loader。

所以,一个加载器(loader)实际上就是一个将特定的字符串转化成JavaScript代码的函数。换个角度来说,一个loader就是一个字符串处理函数。

通常,为了保证loader便于测试和复用,每个loader不会写的很复杂,实现的功能也有限。所以一个文件通常需要多个loader来处理。比如对于一个css文件,我们至少需要两个loader:css-loaderstyle-loader。前者用于解析css文件,后者用于将css注入到HTML文件中。style-loader会把css添加为页内样式(即直接把样式放在head中的<style>标签内),如果你希望打包出单独的css文件,需要使用extract-loader。

如果你希望为css文件定义loader,可以这样写(当然你需要使用npm先安装这些loader):

module.exports = {
  module: {
    rules: [
      { 
        test: /\.css$/, 
        use: ['style-loader', 'css-loader'] 
      },
    ]
  }
};

它的含义是,对.css结尾的文件,使用'css-loader''style-loader'这两个loader。webpack将依次从后向前执行每个loader。比如在解析到index.css时,它将经历以下步骤:

  1. 使用nodejsfs模块读取index.css,将读取到的字符串交给css-loader
  2. 执行css-loader。它是一个函数,将原始字符串进行一定的处理,输出一个新的字符串。
  3. 将上一步输出的字符串交给style-loader,进行第二步处理,最终仍然输出一个字符串。
  4. 由webpack解析最终的处理结果。

因为webpack采用的是流式处理,所以loader的书写顺序非常重要,最先需要执行的loader必须放在数组的最后。

基于这个原理,我们也可以自行手写loader,来满足特定的需求。比如官方没有关于.txt文本文件的loader,所以webpack不能解析文本文件中所包含的依赖(因为文本文件没有任何格式约定,所以无法定义一个普适的loader)。如果你的项目中有需要解析的文本文件,并且它们有严格的格式要求,那么你就可以自行实现一个loader,实现对这类资源的打包。具体实现方法见webpack中文网 - 编写一个 loader

4. 插件(plugin)

一个插件就是一个对webpack功能的定制或扩展。

loader的使用场景是有限的,它只能用来帮助webpack加载非js文件。如果我们想在webpack打包的任何一个过程中添加某些特定的功能,就需要借助插件来实现。它是webpack灵活性的一大体现,也是webpack的支柱功能,因为webpack自身就是构建于插件系统之上的。

比如,我们想要在webpack开始构建时执行某些操作,就可以定义一个像下面的插件:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        compiler.hooks.run.tap(pluginName, compilation => {
            console.log("webpack 构建过程开始!");
            console.log("当前时间:" + new Date());
        });
    }
}

在webpack配置文件中这样使用插件:

const ConsoleLogOnBuildWebpackPlugin = require('ConsoleLogOnBuildWebpackPlugin')
module.exports = {
  ...
  plugins: [
    new ConsoleLogOnBuildWebpackPlugin()
  ]
}

这样,webpack在开始构建时,就会执行我们的console.log方法。当然,你可以定制的功能远不止这些,这里只是向你展示插件的基本用法。

我们看到,一个插件就是一个带有apply原型方法的类(也可以是一个构造函数,并且它的原型对象上有apply方法,两者是等价的)。在配置文件中使用new关键字会创建一个插件实例,webpack将所有插件定义的回调函数注册到对应的生命周期钩子上。当webpack执行到对应的阶段时,就会调用这些钩子函数,实现插件定制的功能。

插件可以传入一个配置对象,用于构造插件实例。而插件上的原型方法apply会被webpack所调用,webpack会将编译器对象compiler传入apply方法。该对象在整个编译过程中都是可用的。如:

function HelloWorldPlugin(options) {
  // 使用 options 设置插件实例
}

HelloWorldPlugin.prototype.apply = function(compiler) {
  compiler.plugin('done', function(compilation) {
    console.log('Hello World!');
  });
};

module.exports = HelloWorldPlugin;

我们在配置文件中传入的配置对象options会被构造函数接收,用于构造插件实例,在apply方法中可以通过this获得。

然后我们在插件的原型上定义了一个apply方法,webpack解析配置文件时会执行它,并传入webpack的编译器对象。我们通过语句compiler.plugin('done',function(compilation){...}为webpack的编译器对象注册了一个done阶段(即打包完成)的回调函数。当webpack打包完成时,会调用这个函数,并传入当前webpack的编译器状态对象:compilation

我们可以借助compiler和compilation这两个对象,在任何一个阶段执行我们想执行的操作。前者是编译器对象,后者是当前状态对象。具体编写插件的方法请参考webpack中文网 - 编写一个插件

三、补充 - 热更新原理

可能很多人都很好奇热更新是怎么实现的,其实它是借助websocket实现的。

前面我们提到过dev-server的实现机制,就是启动一个本地的nodejs服务,将打包结果作为静态资源使用。

当我们访问这个nodejs服务时,它就把需要的静态资源返回给浏览器。nodejs使用一个清单文件来记录当前所有的资源文件,这个清单文件也正是热更新模块的监测对象。一旦我们修改了代码,webpack就会重新进行打包,给文件名添加新的hash值,同时更新清单文件。

热更新模块监测到文件名变化后,会通过websocket将这个新的文件发送到浏览器,浏览器重新执行该文件,完成局部更新。由于只通过websocket发送了单个模块,所以页面既不会重载,也不会大面积刷新。

不过webpack并没有使用原生的websocket实现这个功能。目前的版本中使用的是一个websocket的封装库:sockjs。至于为什么不使用原生websocket,一个很大的原因websocket的浏览器兼容性问题,sockjs在不支持websocket的浏览器上通过轮询实现通信。

总结

本文只是对webpack的一些概念性介绍,涉及的是webpack的最基本用法。在真正的工程实践中,webpack的打包优化、编写自己的loader和插件都是很重要的内容,由于能力有限,这里无法一一探讨,感兴趣的可以参考webpack中文网

发布了37 篇原创文章 · 获赞 90 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/104029636