- 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的一些特点
- webpack的配置文件是一个.js文件,其采用的是node语法,主要是导出一个配置对象,并且其采用commonjs2规范进行导出,即以module.exports={}的方式导出配置对象,之所以采用这种方式是为了方便解析配置文件对象,webpack会找到配置文件然后以require的方式即可读取到配置文件对象。
- webpack中所有的资源都可以通过require的方式引入,比如require一张图片,require一个css文件、一个scss文件等。
- 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打包流程分析
- webpack启动文件:
webpack首先会找到项目中的webpack.config.js配置文件,并以require(configPath)的方式,获取到整个config配置对象,接着创建webpack的编译器对象,并且将获取到的config对象作为参数传入编译器对象中,即在创建Compiler对象的时候将config对象作为参数传入Compiler类的构造函数中,编译器创建完成后调用其run()方法执行编译。
- 编译器构造函数:
编译器构造函数要做的事:创建编译器的时候,会将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编译器对象进行监听编译器发射出来的事件,插件就可以选择在特定的时机完成一些事情。
- 编译器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()方法执行
- 编译器构造函数
之前说过,编译器的构造函数主要就是保存config对象、保存入口模块id、保存所有模块依赖(路径和源码映射)、插件安装。 插件的安装主要就是监听webpack编译器compiler发出的一些事件,等收到相应的事件后,插件就可以进行一些处理了。
- 编译器run()方法
编译器run()方法,主要就是完成buildModule和emitFile,buildModule的时候需要从入口文件开始,即需要传入文件的绝对路径,如果入口文件有依赖,那么buildModule()会被递归调用,即build依赖模块,由于还需要保存入口文件id,所以需要有一个变量来告诉传入的模块是否是入口文件。
- 实现buildModule()方法
buildModule方法主要就是获取源码内容,并且对源码内容进行解析,解析完成后拿到解析后的源码以及当前模块的依赖,将解析后的源码保存到modules对象中,并且遍历依赖,继续buildModule,如:
- 实现获取源码内容getSource()方法
获取源码主要做的就是,读取源码内容,遍历配置的rules,再根据rule中的test正则表达式与源码的文件格式进行匹配,如果匹配成功则交给对应的loader进行处理,如果有多个loader则从最后一个loader开始递归调用依次执行所有的loader。
- 解析源码并获取当前源码的依赖
解析源码主要就是将源码转换为AST抽象语法树,然后对AST抽象语法树进行遍历,找到require调用表达式节点,并将其替换为__webpack_require__,然后找到require的参数节点,这是一个字符串常量节点,将require的参数替换为相对于根目录下的路径,操作AST语法树节点时候不能直接赋值为一个字符串常量,应该用字符串常量生成一个字符串常量节点进行替换。找到require节点的时候同时也就找到了当前模块的依赖,并将依赖保存起来返回,以便遍历依赖。
- emitFile发射文件
获取到输出模板内容,这里采用ejs模板,然后传入entryId(入口文件Id)和modules对象(路径和源码映射对象),对模板进行渲染出最终的输出内容,然后写入输出文件中,即bundle.js中。
- 编写loader
为了便于测试,这里编写一个简单的loader来处理css即style-loader,我们已经知道loader其实就是一个函数,其会接收源码进行相应的转换,也就是会将css源码传递给style-loader进行处理,而css的执行需要放到style标签内,故需要通过js创建一个style标签,并将css源码嵌入到style标签内,如:
- 编写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;