webpack 打包原理

一、为什么要使用webpack?

如今的很多网页其实可以看做是功能丰富的应用,它们拥有着复杂的JavaScript代码和一大堆依赖包。为了简化开发的复杂度,前端社区涌现出了很多好的实践方法:

模块化,让我们可以把复杂的程序细化为小的文件;

类似于TypeScript这种在JavaScript基础上拓展的开发语言:使我们能够实现目前版本的JavaScript不能直接使用的特性,并且之后还能能装换为JavaScript文件使浏览器可以识别;

scss,less等CSS预处理器

.........

这些改进确实大大的提高了我们的开发效率,但是利用它们开发的文件往往需要进行额外的处理才能让浏览器识别,而手动处理又是非常繁琐的,这就为webpack类的工具的出现提供了需求。

二、什么是webpack?

  webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。

这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。

插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

webpack 通过 Tapable 来组织这条复杂的生产线。

webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。

webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。-- 《深入浅出 webpack》 吴浩麟

三、webpack核心概念

1、Entry

入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

每个依赖项随即被处理,最后输出到称之为 bundles 的文件中。

2、Output

output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist。

基本上,整个应用程序结构,都会被编译到你指定的输出路径的文件夹中。

Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。

3、Chunk

代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

4、Loader

loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。 本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。

5、Plugins

loader 被用于转换某些类型的模块,而plugins(插件)则可以用于执行范围更广的任务。 插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。

四、webpack的核心机制

Loader工作原理

loader是用来加载处理各种形式的资源的机制,本质上是一个函数, 接受文件作为参数,返回转化后的结构。

loader是运行在NodeJS中的。因为webpack不认识一些外来模块,所以要使用一些加载器,比如识别css/react/vue/png等。

loader虽然是扩展了 webpack ,但是它只专注于转化文件(transform)这一个领域,完成压缩,打包,语言翻译。

例如:

css-loader和style-loader模块是为了打包css的

babel-loader和babel-core模块时为了把ES6的代码转成ES5

url-loader和file-loader是把图片进行打包的。

用webpack源码中的代码来理解loader的工作原理:

模拟style-loader的功能,loader的简单实现:

// 将css插入到head标签内部 
module.exports = function (source) { 
   let script = 
       (` let style = document.createElement("style"); 
       style.innerText = ${JSON.stringify(source)}; 
       document.head.appendChild(style);`); 
       return script; 
   } 
  // 使用方式1 
   resolveLoader: { modules: [
      path.resolve('node_modules'),
      path.resolve(__dirname, 'src', 'loaders')] 
   }, 
   { test: /\.css$/, use: ['style-loader']}, 
}
// 使用方式2 
// 将自己写的loaders发布到npm仓库,然后添加到依赖,按照方式1中的配置方式使用即可

以下代码是webpack源码中loader执行关键步骤,以递归的方式执行loader,执行机制流程似于express中间件机制:

function iteratePitchingLoaders(options, loaderContext, callback) { 
    var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex]; 
   // load loader module 
   loadLoader(currentLoaderObject, function(err) { 
     var fn = currentLoaderObject.pitch; 
     runSyncOrAsync( fn, loaderContext,
       [
          loaderContext.remainingRequest, 
          loaderContext.previousRequest, 
          currentLoaderObject.data = {}
       ],
     function(err) { 
       if(err) return callback(err); 
       var args = Array.prototype.slice.call(arguments, 1); 
       if(args.length > 0) { 
           loaderContext.loaderIndex--; 
           iterateNormalLoaders(options, loaderContext, args, callback); 
       } else { 
           iteratePitchingLoaders(options, loaderContext, callback); 
       } 
     } ); 
  }); 
}

plugin工作原理

plugin是一个具有apply方法的js对象。 apply方法会被 webpack的compiler(编译器)对象调用,并且compiler 对象可在整个compilation(编译)生命周期内访问。

plugins是作用于webpack本身上的。webpack提供了很多开箱即用的插件 插件可以携带参数,所以可以在plugins属性传入new实例:

1)CommonChunkPlugin主要用于提取第三方库和公共模块,避免首屏加载的bundle文件,或者按需加载的bundle文件体积过大,导致加载时间过长,是一把优化的利器。而在多页面应用中,更是能够为每个页面间的应用程序共享代码创建bundle。

2)针对html文件打包和拷贝(还有很多设置)的插件:html-webpack-plugin

其不但完成了html文件的拷贝,打包,还给html中自动增加了引入打包后的js文件的代码(),还能指明把js文 件引入到html文件的底部等等。

代码如下:

plugins:[
    // 对html模板进行处理,生成对应的html,引入需要的资源模块
    new HtmlWebpackPlugin({
      template:'./index.html',
      // 模板文件,即需要打包和拷贝到build目录下的html文件
      filename:'index.html',
      // 目标html文件
      chunks:['useperson'],
      // 对应加载的资源,即html文件需要引入的js模块
      inject:true // 资源加入到底部,把模块引入到html文件的底部
    })
]

webpack中plugins的组成:

  • 一个JavaScript函数或者class(ES6语法)。
  • 在它的原型上定义一个apply方法。
  • 指定挂载的webpack事件钩子。
  • 处理webpack内部实例的特定数据。
  • 功能完成后调用webpack提供的回调。

以常用的插件UglifyJsPlugin 为分析示例:

class UglifyJsPlugin { 
    apply(compiler) { 
        const options = this.options; 
        options.test = options.test || /\.js($|\?)/i; 
      ...... 
     //绑定compilation事件 
        compiler.plugin("compilation", (compilation) => { 
          if(options.sourceMap) { 
             compilation.plugin("build-module", (module) => { 
               // to get detailed location info about errors
               module.useSourceMap = true; 
            });
          } 
     //绑定optimize-chunk-assets事件 
         compilation.plugin("optimize-chunk-assets", (chunks, callback) => { 
             const files = []; 
             chunks.forEach((chunk) => files.push.apply(files, chunk.files));
             ...... 
             callback(); 
         }); 
      }); 
   } 
}

module.exports = UglifyJsPlugin;

五、webpack的核心构建流程

img

这个过程核心完成了 内容转换 + 资源合并 两种功能,在实现上包含三个阶段:

可理解为Webpack 的运行流程是一个串行的过程,从启动到结束依次执行的流程 如下:

1、初始化阶段

初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数

创建编译器对象:用上一步得到的参数创建Compiler对象

初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化RuleSet 集合、加载配置的插件等

开始编译:执行compiler对象的run方法

确定入口:根据配置中的entry找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象

2、构建阶段

编译模块(make):根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理

完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图

3、生成阶段

输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

看图加深理解webpack打包流程,如下图所示:
img

图示流程理解分析:

1、读取入口文件;

2、基于 AST(抽象语法树) 分析入口文件,并产出依赖列表;

3、AST (Abstract Syntax Tree)抽象语法树 在计算机科学中,或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

4、(https://astexplorer.net/)

5、使用 Babel 将相关模块编译到 ES5;

6、webpack有一个智能解析器(各种babel),几乎可以处理任何第三方库。无论它们的模块形式是CommonJS、AMD还是普通的JS文件;甚至在加载依赖的时候,允许使用动态表require("./templates/" + name + ".jade")。

7、以下这些工具底层依赖了不同的解析器生成AST,比如eslint使用了espree、babel使用了acorn

8、对每个依赖模块产出一个唯一的 ID,方便后续读取模块相关内容;

9、将每个依赖以及经过 Babel 编译过后的内容,存储在一个对象中进行维护;

10、遍历上一步中的对象,构建出一个依赖图(Dependency Graph);

11、将各模块内容 bundle 产出

六、原作者的个人理解

1、webpack是是npm的工具模块,是一个JS应用打包器, 它将应用中的各个模块打包成一个或者多个bundle文件。
2、借助loaders和plugins,它可以改变、压缩和优化各种各样的文件。
3、输入不同资源,比如:html、css、js、img、font文件等,然后将它们输出浏览器可以正常解析的文件。

以上是作者对webpack的简单理解,但要理解webpack到底是什么,一定要弄清楚下面两个词:

  • 模块化
  • 打包

1、模块化

1.1 什么是模块化?

  模块化开发是一种管理方式,是一种生产方式,一种解决问题的方案。它按照功能将一个软件切分成许多部分单独开发,然后再组装起来,每一个部分即为模块,使用这种方式可以让开发的效率变高,以及方便后期的维护。

1.2 为什么需要模块化?

现今的很多网页其实可以看做是功能丰富的应用,它们拥有着复杂的JavaScript代码和一大堆依赖包。

当一个项目开发的越来越复杂的时候,不可避免的都会遇到一些问题:命名冲突(变量和函数命名可能相同),文件依赖(引入外部的文件数目、顺序问题)等。

JavaScript发展的越来越快,超过了它产生时候的自我定位。这时候js模块化就出现了

1.3 模块化进程

早期:script标签

这是最原始的 JavaScript文件加载方式,如果把每一个文件看做是一个模块,那么他们的接口通常是暴露在全局作用域下,也就是定义在 window 对象中。

缺点:

1.污染全局作用域

2.只能按script标签书写顺序加载

3.文件依赖关系靠开发者主观解决

发展一:CommonJS规范

CommonJS规范 允许模块通过require方法来同步加载(同步意味阻塞)所要依赖的其他模块,然后通过module.exports来导出需要暴露的接口。

// 模块导出
module.exports = function add (a, b) { 
    return a + b; 
} 

// 模块导入 
var {add} = require('./aa'); 
console.log('1 + 2 = ' + add(1,2);

CommonJS 是以在浏览器环境之外构建JavaScript 生态系统为目标而产生的项目,比如在服务器和桌面环境中。

发展二:AMD/CMD

(1)AMD

AMD 是 RequireJS 在推广过程中对模块定义的规范化产出(异步模块定义)。

AMD标准中定义了以下两个API:

// 模块导出
define(id, [depends], callback);
// 模块导入 
require([module], callback);

require接口用来加载一系列模块,define接口用来定义并暴露一个模块。

define(['./a', './b'], function(a, b) { 
    // 依赖必须一开始就写好 
    a.add1();
    b.add2();
})

优点: 1、适合在浏览器环境中异步加载模块。2、可以并行加载多个模块

(2)CMD

CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。(在CommomJS和AMD基础上提出)

define(function (requie, exports, module) { 
    //依赖可以就近书写 
    var a = require('./a'); 
    a.add1(); 
    if (status) { 
        var b = requie('./b'); 
        b.add2(); 
    } 
});

优点:

1、依赖就近,延迟执行。2、可以很容易在服务器中运行。

AMD 和 CMD 的区别:

1、对于依赖的模块,AMD是提前执行,CMD是延迟执行。

2、AMD推崇依赖前置;CMD推崇依赖就近,只有在用到某个模块的时候再去require。

3、AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一

发展三:ES6Module

ECMAScript 2015标准增加了JavaScript语言层面的模块体系定义(关键字)。 在 ES6 中,我们使用export关键字来导出模块,使用import关键字引用模块。

// aa.js
export default class Math extends React.Component{}
// main.js 
import Math from "./aa.js";

ES6 模块与 CommonJS 模块的差异:

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。 即 CommonJS 先加载整个模块,输出一个对象,取对象内相应的值,输出后内部不会再变化;ES6 是静态编译命令,先加载一个引用,等执行时再根据引用到加载模块内取值输出,动态引用不缓存。

目前只有很少的JS引擎能直接支持 ES6 标准,因此 Babel 的做法实际上是将不被支持的 import 翻译成目前已被支持的 require。

发展四:后模块化的编译时代

编译是为了降低js语法版本; 打包是为了统一不同的模块规范使浏览器可以运行

  由于目前很少JS引擎能直接支持 ES6 标准,但是为了让我们的新代码也能运行在用户的老浏览器中,社区涌现了越来越多的工具,这些工具能将静态将高版本规范的代码编译为低版本规范的代码,最为大家所熟知的就是babel。Babel 可以将不被支持的 import 翻译成目前已被支持的 require。

  它把JS Core中高版本规范的语法,也能按照相同语义在静态阶段转化成为低版本的语法,这样即使是早期的浏览器,他们内置的JS解释器也能看懂。

  然而不幸的是,对于模块化相关的import和export关键字,babel最终会将它编译为包含require和exports的CommonJS规范。这就造成了另一个问题,这样带有模块化关键词的模块,编译之后还是没办法直接运行在浏览器中,因为浏览器端并不能运行CommonJS的模块。

所以编译这一步并不能帮我们解决,模块化通用的问题。

那么,我们该怎么解决模块化通用的问题呢?

一来,我们怎么把 ESModule 里面的 import 和 export 运用在各个地方;

二来,就是如何让我们在各个地方共同的使用我们的 AMD、CommonJS 等的模块化规范,那我们需要的步骤就是打包。

所以,为了能在 WEB 端直接使用 CommonJS 规范的模块, 除了编译(babel)之外我们还需要一个步骤叫做 打包(bundle)

2、打包

常见打包工具:webpack、Rollup、Parcel、fis、vite、esbuild

2.1 打包工具要解决的问题

1.文件依赖管理 梳理文件之间的依赖关系

2.资源加载管理 处理文件的加载顺序(先后时机)和文件的加载数量(合并、嵌入、拆分)

3.效率与优化管理 提高开发效率,完成页面优化

2.2 webpack打包的规则

一个入口文件对应一个bundle。该bundle包括入口文件模块和其依赖的模块。

按需加载的模块或需单独加载的模块则分开打包成其他的bundle。

除了这些bundle外,还有一个特别重要的bundle,就是manifest.bundle.js文件,即webpackBootstrap

这个manifest文件是最先加载的,负责解析webpack打包的其他bundle文件,使其按要求进行加载和执行。

2.3 webpack底层是如何处理打包的?

2.3.1 参考Node.js源码来熟悉CommonJS的处理方式

  在Node.js中,所有的CommonJS模块文件内容都会被包裹在一个函数中,然后在node.js中使用vm(虚拟机)来运行它,最终达到一个模块化导入和导出的目的。

  好比我们执行了 node index.js 执行的时候,node会通过文件系统读取index.js里面的内容,这时候将读取的index.js文件内容视为字符串,同时在对这个字符串进行一个包裹。通过一个函数字符串的形式,将这个文件的内容包裹进去。把它变成了一个字符串的函数。

  1.首先,当node加载进来一个模块之后,它确定我们要执行哪个commonJS模块之后,node会通过文件系统(fs)读取index.js里面的内容,会在上面和下面加入函数字符串,这样在里面就可以使 用require和exports了,变成了一个函数,也有了参数。

  2.然后,将字符串变成可执行的函数,很多种方式eval、new Function之类的,但node中直接调用vm的模块,这个模块和fs、path一样是一个内置模块。作用和new Function、eval类型,就 是把字符串变成可执行的函数。Node中将字符串放入runInNewContent或者runInThisContent之类的方法就可以变成一个可以执行的函数。

  3.同时,注入进去require和exports等的内容。

  4.之后,就可以在模块之间进行导入和导出了。

​ 以上就是Node对CommonJS模块的处理流程。

代码:

// 1、str指向fs读取的源文件内容
const str = `require('./moduleA'); 
const str = require('./moduleB'); 
console.log(str);`;
// 包裹函数,将源文件内容进行包裹,成为一个字符串函数
const functionWrapper = ['function(require, module, exports) {','}']; 
const result = functionWrapper[0] + str + functionWrapper[1]; 
const vm = require('vm'); 
vm.runInNewContext()

比较难想到的(Node.js中比较核心的就是这一步):

  • 如何将require、exports注入进每一个模块。
  • 如何将CommonJS模块变成一个可执行文件这个是比较难想到的。VM模块就是调用V8相关的接口将我们的字符串变成一个真正可执行的函数。
  • 如何将一个CommonJS的模块变成一个可执行的函数呢?从而把他们执行呢?其实就是VM这一层做的。

2.3.2 浏览器中对CommonJS的处理

我们在浏览器中也可以用相同的思路进行处理:

我们在打包阶段将每个模块包裹上一层函数字符串,然后放置到浏览器中去执行它。 同时我们实现一个简单版本的require函数和module对象来处理运行时加载的问题,这样一个基本流程就好了。 接下来我们要处理运行时模块之间的依赖关系,所以我们需要自己维护一个。

我们要做一个东西,怎么把 CommonJS 的规范运行在浏览器里面去?

运用逆推的思路来想这个事情。怎么结合刚才讲的CommonJS模块的原理。

思考一下,假如我们要把这个index.js模块放在浏览器里面运行,需要做哪些东西呢?

我们可以先手动写bundle文件:

1、首先,放到作用域里面去,要解决变量提升、函数提升等作用域冲突。需要先定义一个自执行函数,函数作用域是比较稳定的;

2、然后,我们仿照CommonJS的步骤,对模块进行包裹。将我们 index.js 的模块用函数包裹的形式包裹起来,让它出现在打包的结果里面,这样至少执行的时候不会报错了,因为注入了变量。

3、接下来,是我们怎么注入变量,之前说到我们要实现require函数和module.exports对象,我们先实现一个module对象吧,同时里面有exports方法。

而require函数是加载模块用的,实际接收一个id,通过id去找其他模块。

结合用webpack打包后的bundle.js内的代码,举例来验证:

文件目录结构:

(function (modules) {
    // 打包成了一个自执行函数 
   var installedModules = {} 
   // 缓存 
   function __webpack_require__(moduleId) { 
       // 模拟了一个require方法 原理:通过递归的方式不停的调用自己
       if (installedModules[moduleId]) { 
         return installedModules[moduleId].exports } 
         var module = installedModules[moduleId] = {
           i: moduleId, 
           l: false, 
           exports: {}
         } 
       modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
 
     module.l = true 
     return module.exports 
 
  } 
   // return __webpack_require_((__webpack_require_.s = "./main.js")) 
 
   return __webpack_require__(0) })({ 
     // 0 key:index.js value: 是一个函数 
     "./index.js": (function (module, exports) { eval( 'import a from 
     "./a";\n\nconsole.log("hello word");\n\n\n//#sourceURL=webpack:///./index.js?' 
     ), "./a.js": function (module,exports) { eval( '// import b from 
     "./b";\n\nconsole.log("hello word");\n\n\n//#sourceURL=webpack:///./index.js?' 
     ), "./c.js": function (module,exports) { eval( '// \n\nconsole.log("hello 
     word");\n\n\n//#sourceURL=webpack:///./index.js?' ), "./d.js": function 
     (module,exports) { eval( '// \n\nconsole.log("hello 
     word");\n\n\n//#sourceURL=webpack:///./index.js?' ), }, "./b.js": function 
     (module,exports) { eval( '// import c from "./c";\n\nconsole.log("hello 
     word");\n\n\n//#sourceURL=webpack:///./index.js?' )} 
   }) 
})
它借助了一个__webpack_require函数来实现自己的模块化,把代码都存放在installedModules,代码文件以对象形式传递进来,key 是文件的路径(需要打包的文件),value是一个函数,通过eval()执行当前文件的代码。value可以理解为:包裹代码的字符串,并且代码内部的require,都被替换成了__webpack_require__。

咱们来分析下上述代码的运行机制:

  • 打包出来的bundle.js是一个 IIFE (立即调用函数表达式)
  • modules是一个对象,
  • 每个 key 对应的是一个模块函数
  • 函数webpack_require加载模块,返回 module.exports
  • webpack中每个模块都有一个唯一的id,是从0开始递增的,即从入口文件开始。
  • 通过 webpack_require(0) 启动程序

我们实际上就是把index.js这个模块用函数包裹了一下,然后mock了一个module,它就可以运行了。模块内容其实是没有变化的,只是包裹在了一个函数里面,同时执行了它。

node index.bundle.js执行成功,然后放到浏览器里面执行也可以,这个时候说明这个模块他就是一个环境无关的代码了,经过我们的这么一个处理之后,就不用再关心它有没有module.exports这种CommonJS规范了。

咱们对这个立即执行函数进行简单的理解:

(闭包函数)(以入口文件为首的需要打包的文件们)

对闭包函数部分进行分析:

首先它接收一个id,同时通过闭包的形式把currentModuleId也传入进去,这样就能让每一个require函数都知道是由哪一个模块进入这个模块的,最终返回结果。 这个闭包的作用就是当我require index.js 的时候,我应该去modules里面的哪个下标来去找这个对应关系

总结:
webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本)。

对 Webpack 的使用者来说,它是一个简单强大的工具,对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

Node.js 从最一开始就支持模块化编程。然而,在 web,模块化的支持正缓慢到来。在 web 存在多种支持 JavaScript 模块化的工具,这些工具各有优势和限制。webpack 基于从这些系统获得的经验教训,并将模块的概念应用于项目中的任何文件。

参考文章

原文地址:https://blog.csdn.net/weixin_41319237/article/details/116194091

posted @ 2022-10-30 00:35  黄河大道东  阅读(108)  评论(0编辑  收藏  举报