webpack

171 阅读13分钟
  • bable 原理
  • babel的缓存是怎么实现的
  • webpack 简介
  • webpack 打包输出后的内容分析
  • webapck的HMR,怎么配置:
  • loader和plugin 区别
  • webpack通过什么把公共的部分抽出来的,属性配置是什么
  • webpack优化
  • Webpack构建流程
  • 常见的Loader
  • 常见的Plugin
  • source map
  • 代码分割
  • webpack 如何实现动态加载
  • require 引入的模块 webpack 能做 Tree Shaking 吗?
  • npm ci么,和npm install的区别是啥?
  • webpack和rollup
  • Tree Shaking,
  • webpack的一些特点
  • webpack打包原理解析
  • webpack打包流程分析
  • 实现webpack
  • cjs 实现 es moudle

bable 原理

简单来说把 JavaScript 中 es2015/2016/2017/2046 的新语法转化为 es5,让低端运行环境(如浏览器和 node )能够认识并执行。

babel 总共分为三个阶段:解析,转换,生成。

babel 本身不具有任何转化功能,它把转化的功能都分解到一个个 plugin 里面。因此当我们不配置任何插件时,经过 babel 的代码和输入是相同的。

插件总共分为两种:

  • 当我们添加 语法插件 之后,在解析这一步就使得 babel 能够解析更多的语法。(顺带一提,babel 内部使用的解析类库叫做 babylon,并非 babel 自行开发)
  • 当我们添加 转译插件 之后,在转换这一步把源码转换并输出。这也是我们使用 babel 最本质的需求。

执行顺序 很简单的几条原则:

  • Plugin 会运行在 Preset 之前。
  • Plugin 会从前到后顺序执行。
  • Preset 的顺序则 刚好相反(从后向前)。

解析:将代码转换成 AST

词法分析:将代码(字符串)分割为token流,即语法单元成的数组语法分析:分析token流(上面生成的数组)并生成 AST 转换:访问 AST 的节点进行变换操作生产新的 AST

Taro就是利用 babel 完成的小程序语法转换 生成:以新的 AST 为基础生成代码

babel的缓存是怎么实现的

只要在转换的时候,记录下转换前的文件和转换后的文件,然后对比文件是否有改动,如果文件没有改动那就继续拿上次转换之后的文件,所以就跳过这一次转换的过程,大大提高了速度。 babel-loader 对 source、identifier 、options 做 JSON.stringify 之后做一次哈希作为文件名,以 json 文件的形式保存在指定目录中。如果三个变量一致的话其实我们就确定了两个文件是一样的, 所以问题就变成了如何高效的验证前后两次文件是否一致,从上面 babel-loader 源码中我们可以看到 babel-loader 中使用 md4 对文件做哈希来验证文件前后是否一致。

webpack 简介

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

简单说,webpack可以看做是一个模块打包机,主要作用就是: 分析你的项目结构,找到JavaScript模块以及一些浏览器不能直接运行的拓展语言(sass、less、typescript等),然后将它们打包为合适的格式以供浏览器使用。

webpack主要实现的功能:

  • 代码转换(如: ES6转换ES5、sass和less转换为css等)
  • 文件优化(如: 将模块内容进行压缩)
  • 代码分割(如: 多页面应用公共模块的抽离、路由懒加载)
  • 模块合并(如: 按照不同的功能将多个模块合并为一个模块)
  • 自动刷新(如: 启动本地服务,代码更新后进行自动刷新)
  • 代码校验(如: 添加eslint进行代码规范检查)
  • 自动发布(如: 应用打包完成后,自动发布)

webpack 打包输出后的内容分析

webpack打包输出后的结果默认是一个匿名自执行函数,匿名自执行函数传递的参数为一个对象,对象的属性名为入口文件的路径名,属性值为一个函数,函数体内部通过会执行eval(),eval()方法的参数为入口文件的内容字符串,而这个匿名自执行函数,内部有一个自定义的__webpack_require__方法,该方法需要传入入口文件的路径名作为参数,匿名自执行函数执行完成后会返回__webpack_require__的结果,而__webpack_require__()方法内部执行的时候,会首先创建一个module对象,module对象里面有exports属性,属性值为一个空的对象,用于接收入口文件的模块输出,如:

(function(modules) {
    function __webpack_require__(moduleId) { // 传入入口文件的路径
        var module = installedModules[moduleId] = { // 创建一个module对象
             i: moduleId,
             l: false,
             exports: {} // exports对象用于保存入口文件的导出结果
         };
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 执行入口文件
        return module.exports; // 返回模块输出结果
    }
    return __webpack_require__(__webpack_require__.s = "./src/bar.js"); // 返回入口文件
})({
     "./src/bar.js": (function(module, exports) {
         eval("module.exports = \"bar\";");
     })
  });

所以不管入口文件是以ES6模块的方式输出还是以commonjs模块的方式输出,最终入口文件的模块输出结果都会被绑定到__webpack_require__方法中定义的module对象的exports属性上,只不过,如果是以commonjs的方式输出,那么入口文件的输出结果将会直接替换掉__webpack_require__方法中定义的module对象的exports属性;如果是以ES6模块的方式输出,则是在__webpack_require__方法中定义的module对象的exports属性值中添加一个default属性或者具体变量名来保存入口文件的输出。

// webpack中任何一个模块被require或者import的时候,都会被转换为__webpack_require__
import foo from "./foo.js"; // 等价于 __webpack_require__("./foo.js")
const foo = require("./foo.js"); // 等价于 __webpack_require__("./foo.js")

webpack打包后,会将整个模块的代码解析成字符串,并放到eval()中执行,然后用一个函数包裹起来,如:

function(module, module.exports, __webpack_require__) {
 eval("当前模块代码");
}

如果一个模块使用的是export default 导出,如:

export default "foo";

那么包裹函数如下:

function(module, __webpack_exports__, __webpack_require__) { // __webpack_exports__对象的值就是module.exports只是换了个名字
 eval("__webpack_require__.r(__webpack_exports__);__webpack_exports__[\"default\"] = (\"foo\");");
}

执行的时候,首先将module.exports对象交给__webpack_require__.r()方法进行处理,主要就是给module.exports对象添加一个__esModule属性,值为true。

__webpack_require__.r = function(exports) {
    if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
    }
    Object.defineProperty(exports, '__esModule', { value: true });
};

然后在module.exports对象上添加一个defaul属性,值为模块的输出,最终输出的module.exports对象为:

// 模块输出对象
{default: "foo", __esModule: true}

如果一个模块使用的是export 非default导出,如:

export const foo = "foo"; 那么包裹函数如下:

function(module, __webpack_exports__, __webpack_require__) { // __webpack_exports__对象的值就是module.exports只是换了个名字
 eval("__webpack_require__.r(__webpack_exports__);__webpack_require__.d(__webpack_exports__, \"foo\", function() { return foo; });\nvar foo = \"foo\";");
}

也是先执行的时候,首先将module.exports对象交给__webpack_require__.r()方法进行处理,主要就是给module.exports对象添加一个__esModule属性,值为true 然后通过__webpack_require__.d()函数给module.exports对象定义export的属性

__webpack_require__.d = function(exports, name, getter) {
    if(!__webpack_require__.o(exports, name)) {
        Object.defineProperty(exports, name, { enumerable: true, get: getter });
    }
}

最终输出的module.exports对象为:

// 最终的模块输出对象
{foo: "foo", __esModule: true}

如果一个模块是通过module.exports 导出,如:

module.exports = "foo";

那么包裹函数如下:

function(module, exports) {
    eval("module.exports = \"foo\";");
}

直接给module.exports对象赋值为"foo"即可 模块最终的输出结果为module.exports对象,即"foo"

// 模块最终输出结果
"foo"

如果一个模块是通过exports导出,如:

exports.foo = "foo"; 那么包裹函数如下:

function(module, exports) {
    eval("exports.foo = \"foo\";");
}

直接给module.exports对象添加foo属性 模块最终的输出结果为module.exports对象,即

// 模块最终输出结果
{foo: "foo"}

不管使用的是什么方式引入,拿到的结果都是module.exports这个对象,只不过不同的方式引入,使用的时候会有一定的差别。

如果使用的是require的方式引入模块,如:

const foo = require("./foo");
console.log(foo);

那么require()拿到的结果就是module.exports的值,使用的时候也是直接用的module.exports这个对象。 所以如果引入的模块使用的是ES模块标准导出,那么require方式拿到的将是这样一个对象

{default: "foo", __esModule: true}
{foo: "foo", __esModule: true}

如果使用的是import的方式引入模块,如:

import foo from "./foo.js";
console.log(foo);

那么拿到的结果是module.exports对象,但是使用的时候用的是module.exports对象的default属性的值

import {foo} from "./foo.js";
console.log(foo);

那么拿到的结果是module.exports对象,但是使用的时候用的是module.exports对象的foo属性的值

所以我们最好用import的方式来引入,模块的输出可以使用commonjs也可以使用ES模块标准,引入的时候能够正确匹配即可。

webapck的HMR,怎么配置:

webpack-dev-server webapck-dev-middleware

devServer: { hot: true } HMR的核心就是客户端从服务端拉去更新后的文件,准确的说是 chunk diff (chunk 需要更新的部分),实际上 WDS 与浏览器之间维护了一个 Websocket,当本地资源发生变化时,WDS 会向浏览器推送更新,并带上构建时的 hash,让客户端与上一次资源进行对比。客户端对比出差异后会向 WDS 发起 Ajax 请求来获取更改内容(文件列表、hash),这样客户端就可以再借助这些信息继续向 WDS 发起 jsonp 请求获取该chunk的增量更新。 后续的部分(拿到增量更新之后如何处理?哪些状态该保留?哪些又需要更新?)由 HotModulePlugin 来完成,提供了相关 API 以供开发者针对自身场景进行处理,像react-hot-loader 和 vue-loader 都是借助这些 API 实现 HMR。

  • webpack 对文件系统进行 watch 打包到内存中
  • devServer 通知浏览器端文件发生改变
  • webpack-dev-server/client 接收到服务端消息做出响应
  • webpack 接收到最新 hash 值验证并请求模块代码
  • webpack 接收到最新 hash 值验证并请求模块代码

loader和plugin 区别

  • Loader 本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。 因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。
  • Plugin 就是插件,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。针对是loader结束后,webpack打包的整个过程,它并不直接操作文件,而是基于事件机制工作,会监听webpack打包过程中的某些节点
  • Loader 在 module.rules 中配置,作为模块的解析规则,类型为数组。每一项都是一个 Object,内部包含了 test(类型文件)、loader、options (参数)等属性。
  • Plugin 在 plugins 中单独配置,类型为数组,每一项是一个 Plugin 的实例,参数都通过构造函数传入。
  • webpack plugin最为核心的便是这个apply方法。webpack执行时,先生成了插件的实例对象,之后会调用插件上的apply方法,并将compiler对象(webpack实例对象,包含了webpack的各种配置信息...)作为参数传递给apply。之后我们便可以在apply方法中使用compiler对象去监听webpack在不同时刻触发的各种事件来进行我们想要的操作了。
class plugin1 {
  constructor(option) {
    this.option = option
    console.log(option.name + '初始化')
  }
  apply(compiler) {
    console.log(this.option.name + ' apply被调用')
    
     //在webpack的afterPlugins生命周期上添加一个方法
    compiler.hooks.afterPlugins.tap('plugin2', (compilation) => {
      console.log('webpack设置完初始插件之后执行的生命周期')
    })
    //在webpack的emit生命周期上添加一个方法
    compiler.hooks.emit.tap('plugin1', (compilation) => {
      console.log('生成资源到 output 目录之前执行的生命周期')
    })
  }
}

webpack通过什么把公共的部分抽出来的,属性配置是什么

CommonsChunkPlugin。不过在 webpack4 中CommonsChunkPlugin被删除,取而代之的是optimization.splitChunks

optimization: {
    splitChunks: {
        minSize: 30,  //提取出的chunk的最小大小
        cacheGroups: {
            default: {
                name: 'common',
                chunks: 'initial',
                minChunks: 2,  //模块被引用2次以上的才抽离
                priority: -20
            },
            vendors: {  //拆分第三方库(通过npm|yarn安装的库)
            	test: /[\\/]node_modules[\\/]/,
                name: 'vendor',
                chunks: 'initial',
                priority: -10
            },
            locallib: {  //拆分指定文件
            	test: /(src\/locallib\.js)$/,
                name: 'locallib',
                chunks: 'initial',
                priority: -9
            }
        }
    }
}

webpack优化

优化开发体验:

优化构建速度,项目变得庞大复杂的时候,所以需要优化项目的打包时间,比如,缩小文件搜索范围、DllPlugin、HappyPack、ParallelUglifyPlugin 优化使用体验,通过自动化手段,实现自动刷新和模块热替换。 优化输出质量:

减少用户能感知到的加载时间,即首屏加载时间,比如区分环境、压缩、提取公共代码、按需加载、CDN、Tree Shaking; 提升流畅度,比如prepack、scope hoisting;

优化Loader配置: 比如babel-loader,这是一个非常耗时的编译过程,由于我们的项目中存在着大量的js文件(项目中大部分都是js文件),所以需要babel-loader处理的js文件非常多,我们可以通过include、exclude来缩小命中范围。

使用DllPlugin: 即动态链接库,由于我们的项目中会存在大量的第三方库文件,比如react和react-dom,而这些库文件只要不升级,是不变的,不需要每次打包都重新打包一遍,所以我们可以通过动态链接库,将这些第三方库文件打包进一个单独的动态链接库文件中,然后告诉webpack动态链接库文件中包含了哪些模块,当打包的时候遇到这些模块就不需要重新打包了,而是直接使用动态链接库中的代码即可。

使用HappyPack:由于在打包过程中有大量的文件需要交个loader进行处理,包括解析和转换等操作,而由于js是单线程的,所以这些文件只能一个一个地处理,而HappyPack的工作原理就是充分发挥CPU的多核功能,将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进程,happypack主要起到一个任务劫持的作用,在创建HappyPack实例的时候要传入对应文件的loader,即use部分,loader配置中将使用经过HappyPack包装后的loader进行处理

开启模块热更新: 模块热更新可以做到在不刷新网页的情况下,更新修改的模块,只编译变化的模块,而不用全部模块重新打包;抽离公共模块: 对于多入口情况,如果某个或某些模块,被两个以上文件所依赖,那么可以将这个模块单独抽离出来;按需加载,即在需要使用的时候才加载,webpack提供了import()方法,传入要动态加载的模块,来动态加载指定的模块,使用Scope Hoisting: Scope Hoisting可以让Webpack打包出来的代码文件更小、运行更快。因为使用Scope Hoisting之后,webpack会将模块进行关联,将相关联的模块合并到一起,从而在运行的时候可以减少函数作用域的创建,减少内存开销,同时由于模块关联合并后代码体积也会相应减少。开启Tree Shaking(摇树): 可以对未使用到的代码或者死代码通过摇树的方式去除掉,可以进一步减小打包后代码的体积。

抽离公共模块: 对于多入口情况,如果某个或某些模块,被两个以上文件所依赖,那么可以将这个模块单独抽离出来,不需要将这些公共的代码都打包进每个输出文件中,这样会造成代码的重复和流量的浪费,即如果有两个入口文件index.js和other.js,它们都依赖了foo.js,那么如果不抽离公共模块,那么foo.js中的代码都会打包进最终输出的index.js和other.js中去,即有两份foo.js了。抽离公共模块也很简单,直接在optimization中配置即可

按需加载,即在需要使用的时候才加载,webpack提供了import()方法,传入要动态加载的模块,来动态加载指定的模块,当webpack遇到import()语句的时候,不会立即去加载该模块,而是在用到该模块的时候,再去加载,也就是说打包的时候会一起打包出来,但是在浏览器中加载的时候并不会立即加载,而是等到用到的时候再去加载,比如,点击按钮后才会加载某个模块,如:

使用Scope Hoisting: Scope Hoisting可以让Webpack打包出来的代码文件更小、运行更快。因为使用Scope Hoisting之后,webpack会将模块进行关联,将相关联的模块合并到一起,从而在运行的时候可以减少函数作用域的创建,减少内存开销,同时由于模块关联合并后代码体积也会相应减少。 要开启Scope Hoisting,需要使用到模块关联插件,webpack已经内置了模块关联插件,即webpack.optimize.ModuleConcatenationPlugin,创建插件对象即可

开启Tree Shaking(摇树): 可以对未使用到的代码或者死代码通过摇树的方式去除掉,可以进一步减小打包后代码的体积。生产模式下会自动开启Trees Shaking功能。比如有一个工具类导出了很多方法,但是我们只使用到了其中的一个方法,那么我们就可以开启Tree Shaking去除那些未使用到的代码,如: optimization: { usedExports: true // 只导出外部使用到的代码 minimize: true // 开启JS压缩去除未使用的代码 }

使用IgnorePlugin: 可以忽略某个模块中某些目录中的模块引用,比如在引入某个模块的时候,该模块会引入大量的语言包,而我们不会用到那么多语言包,如果都打包进项目中,那么就会影响打包速度和最终包的大小,然后再引入需要使用的语言包即可,如:

size-plugin:监控资源体积变化,尽早发现问题

Webpack构建流程

  • 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
  • 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译
  • 确定入口:根据配置中的 entry 找出所有的入口文件
  • 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  • 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

常见的Loader

  • raw-loader:加载文件原始内容(utf-8)
  • file-loader:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件 (处理图片和字体)
  • url-loader:与 file-loader 类似,区别是用户可以设置一个阈值,大于阈值会交给 file-loader 处理,小于阈值时返回文件 base64 形式编码 (处理图片和字体)
  • source-map-loader:加载额外的 Source Map 文件,以方便断点调试
  • svg-inline-loader:将压缩后的 SVG 内容注入代码中
  • image-loader:加载并且压缩图片文件
  • json-loader 加载 JSON 文件(默认包含)
  • handlebars-loader: 将 Handlebars 模版编译成函数并返回
  • babel-loader:把 ES6 转换成 ES5
  • ts-loader: 将 TypeScript 转换成 JavaScript
  • awesome-typescript-loader:将 TypeScript 转换成 JavaScript,性能优于 ts-loader
  • sass-loader:将SCSS/SASS代码转换成CSS
  • css-loader:加载 CSS,支持模块化、压缩、文件导入等特性
  • style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS
  • postcss-loader:扩展 CSS 语法,使用下一代 CSS,可以配合 autoprefixer 插件自动补齐 CSS3 前缀
  • eslint-loader:通过 ESLint 检查 JavaScript 代码
  • tslint-loader:通过 TSLint检查 TypeScript 代码
  • mocha-loader:加载 Mocha 测试用例的代码
  • coverjs-loader:计算测试的覆盖率
  • vue-loader:加载 Vue.js 单文件组件
  • i18n-loader: 国际化
  • cache-loader: 可以在一些性能开销较大的 Loader 之前添加,目的是将结果缓存到磁盘里

常见的Plugin

  • ignore-plugin:忽略部分文件
  • html-webpack-plugin:简化 HTML 文件创建 (依赖于 html-loader)
  • web-webpack-plugin:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
  • uglifyjs-webpack-plugin:不支持 ES6 压缩 (Webpack4 以前)
  • terser-webpack-plugin: 支持压缩 ES6 (Webpack4)
  • webpack-parallel-uglify-plugin: 多进程执行代码压缩,提升构建速度
  • mini-css-extract-plugin: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代extract-text-webpack-plugin)
  • serviceworker-webpack-plugin:为网页应用增加离线缓存功能
  • clean-webpack-plugin: 目录清理
  • ModuleConcatenationPlugin: 开启 Scope Hoisting
  • speed-measure-webpack-plugin: 可以看到每个 Loader 和 Plugin 执行耗时 (整个打包耗时、每个 Plugin 和 Loader 耗时)
  • webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)

source map

source map 是将编译、打包、压缩后的代码映射回源代码的过程。打包压缩后的代码不具备良好的可读性,想要调试源码就需要 soucre map。

代码分割

代码分割是指,将脚本中无需立即调用的代码在代码构建时转变为异步加载的过程。 在 Webpack 构建时,会避免加载已声明要异步加载的代码,异步代码会被单独分离出一个文件,当代码实际调用时被加载至页面。

代码分割技术的核心是「异步加载资源」,可以通过 import() 关键字让浏览器在程序执行时异步加载相关资源。实际上,Webpack 底层帮你将异步加载的代码抽离成一份新的文件,并在你需要时通过 JSONP 的方式去获取文件资源,因此,你可以在任何浏览器上实现代码的异步加载,

「静态分割」:在代码中明确声明需要异步加载的代码。和「“动态”分割」:在代码调用时根据当前的状态,「动态地」异步加载对应的代码块。

webpack 如何实现动态加载

讲道理 webpack 动态加载就两种方式:import()和 require.ensure,不过他们实现原理是相同的。 我觉得这道题的重点在于动态的创建 script 标签,以及通过 jsonp 去请求 chunk,推荐的文章是:webpack是如何实现动态导入的

require 引入的模块 webpack 能做 Tree Shaking 吗?

不能,Tree Shaking 需要静态分析,只有 ES6 的模块才支持。

npm ci么,和npm install的区别是啥?

npm install读取package.json以创建依赖关系列表,并使用package-lock.json告知要安装这些依赖关系的版本。如果依赖项不在package-lock.json中,它将由npm install添加。 npm ci(以持续集成命名)直接从package-lock.json安装依赖关系,并且仅使用package.json来验证没有不匹配的版本。如果缺少任何依赖项或版本不兼容,则将引发错误。

速度上ci明显比install快,线上发布打包的时候使用ci是优于install的

webpack和rollup

Webpack和Rollup的使用场景其实不太一样。Webpack功能十分强大,主要用于解决开发复杂SPA应用时面临的许多问题:如代码分离(code splitting)、静态资源引用、模块按需加载等。但对于Rollup来说,它则更加轻量级,主要是用来构建能够被开发者广泛使用的第三方JS库的。

Tree Shaking,

在去除代码冗余的过程中,程序会从入口文件出发扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个“抽象语法树”(AST)。随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将“抽象语法树”中没有用到的代码“摇落”。这样一个过程后,就去除了没有用到的代码

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只会影响死变量(只写不读)

原理 利用ES6模块的特点:

  • 只能作为模块顶层的语句出现;
  • import 的模块名只能是字符串常量
  • import 的常量是不可变的
  • uglify 阶段删除无用代码

webpack的一些特点

  1. webpack的配置文件是一个.js文件,其采用的是node语法,主要是导出一个配置对象,并且其采用commonjs2规范进行导出,即以module.exports={}的方式导出配置对象,之所以采用这种方式是为了方便解析配置文件对象,webpack会找到配置文件然后以require的方式即可读取到配置文件对象。
  2. webpack中所有的资源都可以通过require的方式引入,比如require一张图片,require一个css文件、一个scss文件等。
  3. webpack中的loader是一个函数,主要为了实现源码的转换,所以loader函数会以源码作为参数,比如,将ES6转换为ES5,将less转换为css,然后再将css转换为js,以便能嵌入到html文件中,plugin是一个类,类中有一个apply()方法,主要用于Plugin的安装,可以在其中监听一些来自编译器发出的事件,在合适的时机做一些事情。

webpack打包原理解析

webpack通过自定义了一个可以在node和浏览器环境都能执行__webpack_require__函数来模拟Node.js中的require语句,将源码中的所有require语句替换为__webpack_require__,同时从入口文件开始遍历查找入口文件依赖,并且将入口文件及其依赖文件的路径和对应源码映射到一个modules对象上,当__webpack_require__执行的时候,首先传入的是入口文件的id,就会从这个modules对象上去取源码并执行,由于源码中的require语句都被替换为了__webpack_require__函数,所以每当遇到__webpack_require__函数的时候都会从modules对象上获取到对应的源码并执行,从而实现模块的打包并且保证源码执行顺序不变。

webpack打包流程分析

  1. webpack启动文件:

webpack首先会找到项目中的webpack.config.js配置文件,并以require(configPath)的方式,获取到整个config配置对象,接着创建webpack的编译器对象,并且将获取到的config对象作为参数传入编译器对象中,即在创建Compiler对象的时候将config对象作为参数传入Compiler类的构造函数中,编译器创建完成后调用其run()方法执行编译。

  1. 编译器构造函数:

编译器构造函数要做的事:创建编译器的时候,会将config对象传入编译器的构造函数内,所以要将config对象进行保存,然后还需要保存两个特别重要的数据: 一个是入口文件的id,即入口文件相对于根目录的相对路径,因为webpack打包输出的文件内是一个匿名自执行函数,其执行的时候,首先是从入口文件开始的,会调用__webpack_require__(entryId)这个函数,所以需要告诉webpack入口文件的路径。 另一个是modules对象,对象的属性为入口文件及其所有依赖文件相对于根目录的相对路径,因为一个模块被__webpack_require__(某个模块的相对路径)的时候,webpack会根据这个相对路径从modules对象中获取对应的源码并执行,对象的属性值为一个函数,函数内容为当前模块的eval(源码)。

总之,modules对象保存的就是入口文件及其依赖模块的路径和源码对应关系,webpack打包输出文件bundle.js执行的时候就会执行匿名自执行函数中的__webpack_require__(entryId),从modules对象中找到入口文件对应的源码执行,执行入口文件的时候,发现其依赖,又继续执行__webpack_require__(dependId),再从modules对象中获取dependId的源码执行,直到全部依赖都执行完成。

编译器构造函数中还有一个非常重要的事情要处理,那就是安装插件,即遍历配置文件中配置的plugins插件数组,然后调用插件对象的apply()方法,apply()方法会被传入compiler编译器对象,可以通过传入的compiler编译器对象进行监听编译器发射出来的事件,插件就可以选择在特定的时机完成一些事情。

  1. 编译器run:

编译器的run()方法内主要就是: buildModule和emitFile。而buildModule要做的就是传入入口文件的绝对路径,然后根据入口文件路径获取到入口文件的源码内容,然后对源码进行解析。

其中获取源码过程分为两步: 首先直接读出文件中的源码内容,然后根据配置的loader进行匹配,匹配成功后交给对应的loader函数进行处理,loader处理完成后再返回最终处理过的源码。

源码的解析,主要是: 将由loader处理过的源码内容转换为AST抽象语法树,然后遍历AST抽象语法树,找到源码中的require语句,并替换成webpack自己的require方法,即__webpack_require__,同时将require()的路径替换为相对于根目录的相对路径,替换完成后重新生成替换后的源码内容,在遍历过程中找到该模块所有依赖,解析完成后返回替换后的源码和查找到的所以依赖,如果存在依赖则遍历依赖,让其依赖模块也执行一遍buildModule(),直到入口文件所有依赖都buildModule完成。

入口文件及其依赖模块都build完成后,就可以emitFile了,首先读取输出模板文件,然后传入entryId和modules对象作为数据进行渲染,主要就是遍历modules对象生成webpack匿名自执行函数的参数对象,同时填入webpack匿名自执行函数执行后要执行的__webpack_require__(entryId)入口文件id。

实现webpack

获取配置文件,创建编译器并执行

#! /usr/bin/env node
const path = require("path");
const config = require(path.resolve("webpack.config.js")); // 获取到项目根目录下的webpack.config.js的配置文件
const Compiler = require("../lib/Compiler.js");// 引入Compiler编译器类
const compiler = new Compiler(config); // 传入config配置对象并创建编译器对象
compiler.run(); // 编译器对象调用run()方法执行
  1. 编译器构造函数

之前说过,编译器的构造函数主要就是保存config对象、保存入口模块id、保存所有模块依赖(路径和源码映射)、插件安装。 插件的安装主要就是监听webpack编译器compiler发出的一些事件,等收到相应的事件后,插件就可以进行一些处理了。

  1. 编译器run()方法

编译器run()方法,主要就是完成buildModule和emitFile,buildModule的时候需要从入口文件开始,即需要传入文件的绝对路径,如果入口文件有依赖,那么buildModule()会被递归调用,即build依赖模块,由于还需要保存入口文件id,所以需要有一个变量来告诉传入的模块是否是入口文件。

  1. 实现buildModule()方法

buildModule方法主要就是获取源码内容,并且对源码内容进行解析,解析完成后拿到解析后的源码以及当前模块的依赖,将解析后的源码保存到modules对象中,并且遍历依赖,继续buildModule,如:

  1. 实现获取源码内容getSource()方法

获取源码主要做的就是,读取源码内容,遍历配置的rules,再根据rule中的test正则表达式与源码的文件格式进行匹配,如果匹配成功则交给对应的loader进行处理,如果有多个loader则从最后一个loader开始递归调用依次执行所有的loader。

  1. 解析源码并获取当前源码的依赖

解析源码主要就是将源码转换为AST抽象语法树,然后对AST抽象语法树进行遍历,找到require调用表达式节点,并将其替换为__webpack_require__,然后找到require的参数节点,这是一个字符串常量节点,将require的参数替换为相对于根目录下的路径,操作AST语法树节点时候不能直接赋值为一个字符串常量,应该用字符串常量生成一个字符串常量节点进行替换。找到require节点的时候同时也就找到了当前模块的依赖,并将依赖保存起来返回,以便遍历依赖。

  1. emitFile发射文件

获取到输出模板内容,这里采用ejs模板,然后传入entryId(入口文件Id)和modules对象(路径和源码映射对象),对模板进行渲染出最终的输出内容,然后写入输出文件中,即bundle.js中。

  1. 编写loader

为了便于测试,这里编写一个简单的loader来处理css即style-loader,我们已经知道loader其实就是一个函数,其会接收源码进行相应的转换,也就是会将css源码传递给style-loader进行处理,而css的执行需要放到style标签内,故需要通过js创建一个style标签,并将css源码嵌入到style标签内,如:

  1. 编写Plugin

为了便于测试,这里编写一个简单的插件结构,不处理具体的内容,只是让插件可以正常运行,我们已经知道插件是一个类,里面有一个apply()方法,webpack插件主要是通过tapable模块,tapable模块会提供各种各样的钩子,可以创建各种钩子对象,然后在编译的时候通过调用钩子对象的call()方法发射事件,然后插件监听到这些事件就可以做一些特定的事情。

const fs = require("fs");
const path = require("path");
// babylon 将源码转换为AST语法树
const babylon = require("babylon");
// @babel/traverse 遍历AST节点
const traverse = require("@babel/traverse").default;
// @babel/types 生成一个各种类型的AST节点
const types = require("@babel/types");
// @babel/generator 将AST语法树重新转换为源码
const generator = require("@babel/generator").default;

const ejs = require("ejs");

const {SyncHook} = require("tapable");

class Compiler {
    constructor(config) {
        this.config = config; // 保存配置文件对象
        // 保存入口文件的路径
        this.entryId; // "./src/index.js"
        // 存放所有的模块依赖,包括入口文件和入口文件的依赖,因为所有模块都要执行
        this.modules = {}
        this.entry = config.entry; // 入口路径,即配置文件配置的入口文件的路径
        this.root = process.cwd(); // 运行wb-pack的工作路径,即要打包项目的根目录
        this.hooks = {
            entryOption: new SyncHook(),
            compile: new SyncHook(),
            afterCompile: new SyncHook(),
            afterPlugins: new SyncHook(),
            run: new SyncHook(),
            emit: new SyncHook(),
            done: new SyncHook()
        }
        // 遍历配置的插件并安装
        const plugins = this.config.plugins; // 获取使用的plugins
        if(Array.isArray(plugins)) {
            plugins.forEach((plugin) => {
                plugin.apply(this); // 调用plugin的apply()方法
            });
        }
        this.hooks.afterPlugins.call(); // 执行插件安装结束后的钩子
    }
    // 获取源码内容,获取源码的过程中会根据loader的配置对匹配的文件交给相应的loader处理
    getSource(modulePath) {
        console.log("get source start.");
        // 获取源码内容
        let content = fs.readFileSync(modulePath, "utf8");
        // 遍历loader
        const rules = this.config.module.rules;
        for (let i = 0; i< rules.length; i++) {
            const rule = rules[i];
            const {test, use} = rule;
            let len = use.length -1;
            if (test.test(modulePath)) { // 根据源码文件的路径于loader配置进行匹配,交给匹配的loader进行处理
                function startLoader() {
                    // 引入loader,loader是一个函数,并将源码内容作为参数传递给loader函数进行处理
                    const loader = require(use[len--]);
                    content = loader(content);
                    // console.log(content);
                    if (len >= 0) { // 如果有多个loader则继续执行下一个loader
                        startLoader();
                    }
                }
                startLoader();
            }
        }
        return content;
    }
    // 解析源码内容并获取其依赖
    parse(source, parentPath) {
        console.log("parse start.");
        console.log(`before parse ${source}`);
        // ① 将源码内容解析为AST抽象语法树
        const ast = babylon.parse(source);
        // console.log(ast);
        const dependencies = []; // 保存模块依赖
        // ② 遍历AST抽象语法树
        traverse(ast, {
            CallExpression(p) { // 找到require语句
                const node = p.node; // 对应的节点
                if (node.callee.name == "require") { // 把require替换成webpack自己的require方法,即__webpack_require__即
                    node.callee.name = "__webpack_require__"; 
                    let moduleName = node.arguments[0].value; // 获取require的模块名称
                    if (moduleName) {
                        const extname = path.extname(moduleName) ? "" : ".js";
                        moduleName = moduleName + extname; // 如果引入的模块没有写后缀名,则给它加上后缀名
                        moduleName = "./" + path.join(parentPath, moduleName);
                        // console.log(moduleName);
                        dependencies.push(moduleName);
                        // 将依赖文件的路径替换为相对于入口文件所在目录
                        console.log(`moduleName is ${moduleName}`);
                        console.log(`types.stringLiteral(moduleName) is ${JSON.stringify(types.stringLiteral(moduleName))}`);
                        console.log(node);
                        console.log(node.arguments);
                        node.arguments = [types.stringLiteral(moduleName)];
                    }
                }
            }
        });
        // 处理完AST后,重新生成源码
        const sourceCode = generator(ast).code;
        console.log(`after parse ${sourceCode}`);
        // 返回处理后的源码,和入口文件依赖
        return {sourceCode, dependencies};

    }
    // 获取源码,交给loader处理,解析源码进行一些修改替换,找到模块依赖,遍历依赖继续解析依赖
    buildModule(modulePath, isEntry) { // 创建模块的依赖关系
        console.log("buildModule start.");
        console.log(`modulePath is ${modulePath}`);
        // 获取模块内容,即源码
        const source = this.getSource(modulePath);
        // 获取模块的相对路径
        const moduleName = "./" + path.relative(this.root, modulePath); // 通过模块的绝对路径减去项目根目录路径,即可拿到模块相对于根目录的相对路径
        if (isEntry) {
            this.entryId = moduleName; // 保存入口的相对路径作为entryId
        }
        // 解析源码内容,将源码中的依赖路径进行改造,并返回依赖列表
        // console.log(path.dirname(moduleName));// 去除扩展名,返回目录名,即"./src"
        const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName));
        console.log("source code");
        console.log(sourceCode);
        console.log(dependencies);
        this.modules[moduleName] = sourceCode; // 保存源码
        // 递归查找依赖关系
        dependencies.forEach((dep) => {
            this.buildModule(path.join(this.root, dep), false);//("./src/a.js", false)("./src/index.less", false)
        });
    }
    emitFile() { // 发射打包后的输出结果文件
        console.log("emit file start.");
        // 获取输出文件路径
        const outputFile = path.join(this.config.output.path, this.config.output.filename);
        // 获取输出文件模板
        const templateStr = this.getSource(path.join(__dirname, "template.ejs"));
        // 渲染输出文件模板
        const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules});
        this.assets = {};
        this.assets[outputFile] = code;
        // 将渲染后的代码写入输出文件中
        fs.writeFileSync(outputFile, this.assets[outputFile]);
    }
    run() {
        this.hooks.compile.call(); // 执行编译前的钩子
        // 传入入口文件的绝对路径
        this.buildModule(path.resolve(this.root, this.entry), true); 
        this.hooks.afterCompile.call(); // 执行编译结束后的钩子
        // console.log(this.modules, this.entryId);
        this.emitFile();
        this.hooks.emit.call(); // 执行文件发射完成后的钩子
        this.hooks.done.call(); // 执行打包完成后的钩子
    }
}
module.exports = Compiler;

实现一个简易 Webpack

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { transformFromAst } = require('@babel/core');

// 1. 解析单个模块,生成模块信息(ID、依赖、转换后的代码)
function createModuleInfo(filePath) {
 const content = fs.readFileSync(filePath, 'utf-8');
 const ast = parser.parse(content, { sourceType: 'module' });
 
 // 收集依赖路径
 const dependencies = [];
 traverse(ast, {
   ImportDeclaration: ({ node }) => {
     dependencies.push(node.source.value);
   }
 });
 
 // 转换代码(如 ES6 → ES5)
 const { code } = transformFromAst(ast, null, {
   presets: ['@babel/preset-env']
 });
 
 return {
   id: filePath,
   dependencies,
   code
 };
}

// 2. 构建依赖图(递归解析所有模块)
function createDependencyGraph(entry) {
 const mainModule = createModuleInfo(entry);
 const queue = [mainModule];
 
 // 相对路径 → 绝对路径的映射
 const idToAbsolutePath = new Map();
 idToAbsolutePath.set(entry, entry);
 
 for (const module of queue) {
   const dirname = path.dirname(module.id);
   
   module.dependencies = module.dependencies.map(relativePath => {
     // 解析相对路径为绝对路径
     const absolutePath = path.join(dirname, relativePath);
     idToAbsolutePath.set(relativePath, absolutePath);
     
     // 如果依赖未被解析过,则解析并加入队列
     if (!queue.some(m => m.id === absolutePath)) {
       const childModule = createModuleInfo(absolutePath);
       queue.push(childModule);
     }
     
     return absolutePath;
   });
 }
 
 return queue;
}

// 3. 生成最终打包代码
function bundle(graph) {
 let modules = '';
 
 // 构建模块映射对象
 graph.forEach(module => {
   modules += `
     '${module.id}': [
       function(require, module, exports) {
         ${module.code}
       },
       ${JSON.stringify(module.dependencies)}
     ],
   `;
 });
 
 // 生成自执行函数包裹的代码
 const result = `
   (function(modules) {
     // 模块缓存
     const installedModules = {};
     
     // 实现 require 函数
     function require(id) {
       if (installedModules[id]) {
         return installedModules[id].exports;
       }
       
       const [fn, dependencies] = modules[id];
       const module = { exports: {} };
       
       // 递归解析依赖
       function localRequire(relativePath) {
         const absolutePath = modules[id][1][dependencies.indexOf(relativePath)];
         return require(absolutePath);
       }
       
       // 执行模块代码
       fn(localRequire, module, module.exports);
       
       // 缓存结果
       installedModules[id] = module;
       
       return module.exports;
     }
     
     // 从入口模块开始执行
     require('${graph[0].id}');
   })({${modules}})
 `;
 
 return result;
}

// 使用示例
const graph = createDependencyGraph('./src/index.js');
const result = bundle(graph);
fs.writeFileSync('./dist/bundle.js', result);

cjs 实现 es moudle

export const a = 1;
export const b = 2;

export default () => {
  return 3;
};
'use strict';

Object.defineProperty(exports, '__esModule', {
  value: true
});
exports.default = exports.b = exports.a = void 0;
var a = 1;
exports.a = a;
var b = 2;
exports.b = b;
var _default = 3;
exports.default = _default;

import { a, b } from 'lib';

console.log(a);
console.log(b);
'use strict';

var _lib = require('lib');

console.log(_lib.a);
console.log(_lib.b);

babel 在处理这一块的时候,使用整体导入的。那 default import 呢?

'use strict';

var _lib = _interopRequireDefault(require('lib'));

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

console.log(_lib.default);
// esm
export default function add(a, b) {
  console.log(a + b);
}

// rollup 处理成 cjs
function add(a, b) {
  console.log(a + b);
}

module.exports = add;