1.sourcemap的隐源码认知与运用
2.探究webpack代码热更新原理
3.源码细读-深入了解terser-webpack-plugin的实现
sourcemap的认知与运用
在讨论sourcemap之前,我们首先需要了解编程语言的藏源编译过程。
编译是解析将编程语言转换为可执行代码的过程。然而,隐源码可执行代码和源代码是藏源完全不同的两种格式。如果在执行过程中出现错误,解析readfilesync源码分析很难定位源代码编写过程中出现的隐源码问题。因此,藏源现代编译器基本上都支持一种标准的解析可执行代码与源代码之间的映射,以特定格式将这种映射关系持久化存储到文件中。隐源码当程序执行出现问题时,藏源根据堆栈信息使用映射文件逆向还原到源代码,解析可以清晰地、隐源码语义化地查找编写过程中的藏源错误。
在常见的解析C Family languages中,这种可执行代码和源码之间映射的格式已经是一种标准化的东西了,称为DWARF。尽管在每个厂商应用的平台上产生的文件格式不尽相同,但它们内里遵循的映射格式都遵循了DWARF调试标准。
回到Web前端领域讨论这个问题,类似地,我们有sourcemap这个东西。或者我们应该称它为sourcemap调试格式标准。因为目前我们对sourcemap的Qt教程源码格式都遵循了一定的标准,但在它的用途上,却出现了千差万别。
本文中讨论sourcemap的格式组成是没有意义的,sourcemap的格式应当是一种持续发展的标准,每当标准更新,自然会有健壮的工具帮助解读。理解sourcemap的格式对它的应用不会有太明显的帮助。
按照我的理解,最早sourcemap是为了协助JavaScript代码混淆(或者压缩)而产生的。框架的作者为了缩小代码体积、避免生产环境的JavaScript代码能被轻松地阅读,使用压缩、混淆工具对源码进行了处理。如果产生了问题,有sourcemap还能轻松地还原代码。但当时的sourcemap并不是web标准之一,这为今日的web前端工程化体系埋下了隐患。
在上一节中提到,sourcemap是一种“可执行代码”和源码之间的映射关系,那么只要是产生编译的地方,就应该会有sourcemap产生。
首先想到了Babel这个工具,它是tg频道源码应用最广泛的JavaScript编译器,接下来我们尝试一个示例:
接着,我们安装babel,指定sourcemap参数为inline,并对其进行转换,得到:
可以看到,转换后的代码和源码产生了很大的区别,并且sourcemap以注释的形式紧跟在可执行代码尾部,它的链接指向了一个data URL。
这么看就很容易了解到,sourcemap是一个基于之前的web标准拓展的功能,它只需要以固定的注释格式跟随在一个脚本文件的末尾即可。那么不同的浏览器厂商想要兼容这个标准,只需要加以识别这个格式即可。
但上文可以看出,data URL加在注释中,任何外部人员都可以轻易获得。因此在做代码转换时,可以将sourcemap生成到一个单独的文件中,一般是文件名加.map后缀,那么对应的sourcemap的地址也就变成:
sourcemap的这种设计方法并不是独一份的。例如在C Family Language的构建交付体系中,为了调试方便,对于测试环境交付的新年烟花源码库、可执行代码,并不会将它们的"sourcemap"从可执行代码中分离出来。它们的执行环境和大部分JavaScript引擎一样,在调试断点或者Exception时,都能自动地载入"sourcemap"并且给出对应的源码栈信息。而在生产交付时,都会在构建时将"sourcemap"从库和可执行文件中提取出来单独保存。这样既减小了交付的产物体积,还能保证源码信息不被外部人员知晓。
既然sourcemap在逐渐地成为一个标准,那么自然就有配套的标准工具对这种格式进行解析。除了Chrome devtools自带的以外,还可以使用常见的库,接下来我们使用mozilla的source-map库,对刚才的代码进行一些还原的操作。
这是断点调试常见经常会碰到的场景,比如某个exception发生在了我们刚才代码的line 9; column 2; 即console.log的位置。那么我们可以这么做:
通过载入map文件,然后查找line 9 column 2对应的源代码位置可得:
然后我们去index.ts中的3行2列,正好是ts文件中的console.log
可以看到,sourcemap不仅记录了代码行列数,还记录了源文件的位置,那么不用翻sourcemap的城堡突袭源码manual也能说明,sourcemap也像其他编译系统一样,支持多个文件、库的代码映射的集成合并。这对于大型工程的构建十分有用,大型工程往往由数以千计万计的库和代码文件组成,在这种复杂的构建中,我们往往只想要获得有限几个产物。除了可执行代码,sourcemap的统一产出也有利于我们方便地追溯生产环境的问题。
mozilla的source-map标准库不止有查找映射位置的这个作用,更能直接还原代码。我们可以直接载入一个sourcemap文件,然后查看它对应了那些源代码文件,然后根据这些文件进行逐个还原内容:
由于我们只有一个,得到的输出是
在实际开发场景中,我们碰到的sourcemap运用比上面提到的情况复杂得多,但是问题又没有办法一一枚举,所以这里讨论一些常见的例子
前面就见过两种形式:dataURL放在JavaScript文件结尾的、和独立写入文件的。他们的形式是显而易见的,并且特点也能清楚地看出来。前者内联,增大了体积,但是降低了使用的门槛。后者独立,可以隐藏保存起来,非必要时不使用。
但在webpack这种打包构建工具中,对sourcemap做了更多更详细的定义,其基本分类的依据也遵循以下几个维度:
具体的规则可以翻阅webpack对应的文档,光靠脑子记是没办法的。
对库、预编译/转换的sourcemap处理
日常开发中,使用库或者预编译的组件是很常见的事,例如:
预编译文件的处理
像这些场景对工程的构建提出了比较多的要求。这里就针对常用的webpack做一个处理的展示,还是以前面的工程为例,我们的Typescript库导出了函数a,然后我们直接引用dist/index.js(而非src/index.ts)
然后我们通过webpack打包并且导出它的sourcemap
最后我们得到了./dist/demo.dist.js.map(名字有点诡异哈)
然后我们再通过刚才写的sourcemap工具进行还原看看:
可以看到,webpack默认情况下可以识别预编译的文件它的sourcemap,并且进行合理的目录转换,保证指向正确
那么我们如果删除预编译文件的sourcemap呢?我对a函数的库重新编译了一遍,并且不导出任何sourcemap,结果对引入的a函数库的文件还原就变成了
库的处理
如果a函数是从npm库引用的呢?我尝试了下把函数a放到了库中,然后将源码引用改成了
结果虽然node_modules中带了sourcemap,但是还原后并不是原本的ts代码
这时候需要对webpack进行配置,增加source-map-loader作为预处理,且不能排除node_modules目录:
然后再进行打包编译出来的map我们对其进行还原发现:
webpack成功帮我们提取了node_modules库中的sourcemap
本文主要阐述了sourcemap的基本知识,包含了几个方面:
探究webpack代码热更新原理
一、前备知识
1.HMR-HotModuleReplacement热模块替换发生代码改动时,保持当前页面状态的同时,局部更新修改模块
2.piler?=?webpack(config);?//?这里的server是全局变量?server?=?new?Server(compiler,?options,?log);?if?(options.socket)?{ ?server.listen(options.socket,?options.host,?(err)?=>?{ })?}?else?{ ?server.listen(options.port,?options.host,?(err)?=>?{ })?}?}
深入核心,了解如何通过compiler初始化服务器server对象,并且调用listen方法
//?webpack-dev-server/lib/Server.js?class?Server?{ ?constructor(compiler,?options?=?{ },?_log)?{ ?//?保存webpack实例?this.compiler?=?compiler;?//?保存用户的配置参数?this.options?=?options;?this.heartbeatInterval?=?;?//?socketServer参数?this.socketServerImplementation?=?getSocketServerImplementation(this.options);?this.sockets?=?[];?//?设置文件监听的目录范围?this.contentBaseWatchers?=?[];?//?开启代码热更新的必备参数?this.hot?=?this.options.hot?||?this.options.hotOnly;?//?文件监听配置?this.watchOptions?=?options.watchOptions?||?{ };?this.setupHooks();?this.setupApp();?this.setupDevMiddleware();?this.createServer();?}?//?使用文件编译结束的钩子?setupHooks()?{ ?const?addHooks?=?(compiler)?=>?{ ?done.tap('webpack-dev-server',?(stats)?=>?{ ?//?服务端编译结束通过websocket告知客户端,以及传递当前的hash值和ok?this._sendStats(this.sockets,?this.getStats(stats));?this._stats?=?stats;?})?}?if?(this.compiler.compilers)?{ ?this.compiler.compilers.forEach(addHooks);?}?else?{ ?addHooks(this.compiler);?}?}?_sendStats(sockets,?stats,?force)?{ ?this.sockWrite(sockets,?'hash',?stats.hash);?this.sockWrite(sockets,?'ok');?}?//?利用express初始化一个服务器,用于静态资源的路由?setupApp()?{ ?this.app?=?new?express();?}?//?配置express搭建后的服务器,确认使用的协议?createServer()?{ ?//?如果使用的协议是piler,?Object.assign({ },?this.options,?{ ?logLevel:?this.log.options.level?})?)?}//?创建websocket服务器,用于下发模块更新的通知到客户端?createSocketServer()?{ ?const?SocketServerImplementation?=?this.socketServerImplementation;?this.socketServer?=?new?SocketServerImplementation(this);?this.socketServer.onConnection((connection,?headers)?=>?{ })?}?//?监听对应的端口开启静态资源路由,同时部署另一个websocket服务器?listen(port,?hostname,?fn)?{ ?return?this.listeningApp.listen(port,?hostname,?(err)?=>?{ ?this.createSocketServer();?}?}?}?//?添加两个打包入口模块,利用webpack将相关代码注入到bundle.js中,用于客户端开启websokct以及处理热模块替换?Server.addDevServerEntrypoints?=?require('./utils/addEntries');?module.exports?=?Server;到这里webpack的HMR在node层做的处理基本完成了,这部分同样是让服务端拥有静态资源路由以及主动下发代码更新通知到客户端的能力,下面看一下如何实现客户端接收websocket通知后主动拉取更新后的服务端代码,并且替换执行新的模块代码
webpack客户端的代码肯定不会让开发人员自己去实现,不然就会出现千奇百怪的问题。这部分代码处理被黑盒处理,隐藏在了Server.addDevServerEntrypoints方法内,悄悄得在webpack带包过程中添加entry注入这部分代码处理
巧妙地划分客户端能力到两个模块中
//?webpack-dev-server/utils/addEntries.js?function?addEntries(config,?options,?server)?{ ?const?domain?=?createDomain(options,?app);?const?sockHost?=?options.sockHost`&sockHost=${ options.sockHost}`?:?'';?const?sockPath?=?options.sockPath`&sockPath=${ options.sockPath}`?:?'';?const?sockPort?=?options.sockPort`&sockPort=${ options.sockPort}`?:?'';?//?引入搭建websocket客户端代码块module?const?clientEntry?=?`${ require.resolve(?'../../client/'?)}?${ domain}${ sockHost}${ sockPath}${ sockPort}`;?//?处理客户端从服务端获取新模块并且替换执行的代码块module?let?hotEntry;?if?(options.hotOnly)?{ ?hotEntry?=?require.resolve('webpack/hot/only-dev-server');?}?else?if?(options.hot)?{ ?hotEntry?=?require.resolve('webpack/hot/dev-server');?}?}?module.exports?=?addEntries;从这里可以看出来,客户端需要的两个能力被划分到了两个代码模块中,一个是搭建websocket客户端,一个是处理客户端的代码模块更新和替换
搭建websocket客户端
//?webpack-dev-server/client/index.js?var?socket?=?require('./socket');?var?sendMessage?=?require('./utils/sendMessage');?var?createSocketUrl?=?require('./utils/createSocketUrl');?var?reloadApp?=?require('./utils/reloadApp');?var?socketUrl?=?createSocketUrl(__resourceQuery);?var?onSocketMessage?=?{ ?//?接收websocket服务端返回的最新hash值?hash:?function?hash(_hash)?{ ?status.currentHash?=?_hash;?}?ok:?function?ok()?{ ?sendMessage('Ok');?reloadApp(options,?status);?}?}?socket(socketUrl,?onSocketMessage);当客户端收到服务端返回的ok消息推送时,会调用reloadApp,这里看一下具体是怎么处理的
//?webpack-dev-server/client/utils/reloadApp.js?function?reloadApp(_ref,?_ref2)?{ ?if?(hot)?{ ?log.info('[WDS]?App?hot?update...');?var?hotEmitter?=?require('webpack/hot/emitter');?hotEmitter.emit('webpackHotUpdate',?currentHash);?//?如果当前宿主是浏览器环境,则触发webpackHotUpdate消息推送?if?(typeof?self?!==?'undefined'?&&?self.window)?{ ?self.postMessage("webpackHotUpdate".concat(currentHash),?'*');?}?}?}?module.exports?=?reloadApp;所以调用this.postMessage("webpackHotUpdate".concat(currentHash),'*')
有发送就会有接收,找一下对应的回调处理,而处理这部分的代码被划分到了hot模块中,根据hash获取新的代码模块并进行替换执行
处理客户端的代码模块更新和替换
//?webpack/hot/dev-server.js?if?(module.hot)?{ ?var?lastHash;?var?check?=?function?check()?{ ?module.hot?.check(true)?.then(function(updatedModules)?{ ?//?容错,如果不存在待更新的模块,直接刷新页面?if?(!updatedModules)?{ ?log("warning",?"[HMR]?Cannot?find?update.?Need?to?do?a?full?reload!");?log(?"warning",?"[HMR]?(Probably?because?of?restarting?the?webpack-dev-server)"?);?window.location.reload();?return;?}?}?.catch(function(err)?{ ?window.location.reload();?}?}?hotEmitter.on("webpackHotUpdate",?function(currentHash)?{ ?lastHash?=?currentHash;?if?(!upToDate()?&&?module.hot.status()?===?"idle")?{ ?log("info",?"[HMR]?Checking?for?updates?on?the?server...");?//?调用check方法拉取更新后的模块代码并进行处理?check();?}?});?}这里的module.hot.check方法,其实是另一位隐藏的大佬进行的方法注入
HotModuleReplacementPlugin
由于涉及到另一个插件的解析,放到后面去扩展。感兴趣的读者可以去webpack/lib/hotModuleReplacement.js阅读源码。
这里重点介绍针对module.hot.check都注入了怎样的代码
//?webpack/lib/web/JsonpMainTemplate.runtime.jsfunction?hotCreateModule(moduleId)?{ ?var?hot?=?{ ?check:?hotCheck?}function?hotCheck(apply)?{ ?hotSetStatus("check");?return?hotDownloadManifest(hotRequestTimeout).then(function(update)?{ ?hotAvailableFilesMap?=?update.c;?hotUpdateNewHash?=?update.h;?hotSetStatus("prepare");})?}?function?hotDownloadManifest(requestTimeout)?{ ?requestTimeout?=?requestTimeout?||?;?return?new?Promise(function(resolve,?reject)?{ ?var?request?=?new?XMLHttpRequest();?var?requestPath?=?__webpack_require__.p?+?""?+?hotCurrentHash?+?".hot-update.json";?request.open("GET",?requestPath,?true);?request.timeout?=?requestTimeout;?request.send(null);?request.onreadystatechange?=?function()?{ ?var?update?=?JSON.parse(request.responseText);?resolve(update);?}?}?}这里之所以使用JSONP的方式获取新的模块代码,是因为JSONP获取的代码可以直接执行,而hash.hot-update.js代码里有个webpackHotUpdate函数调用,最后重点看一下这个函数是如何处理代码模块替换和执行的
//?webpack/lib/HotModuleReplacement.runtime.js?window["webpackHotUpdate"]?=?function?(chunkId,?moreModules)?{ ?hotAddUpdateChunk(chunkId,?moreModules);?};?function?hotAddUpdateChunk(chunkId,?moreModules)?{ ?//?更新的模块moreModules赋值给全局全量hotUpdate?for?(var?moduleId?in?moreModules)?{ ?if?(Object.prototype.hasOwnProperty.call(moreModules,?moduleId))?{ ?hotUpdate[moduleId]?=?moreModules[moduleId];?}?}?//?调源码细读-深入了解terser-webpack-plugin的实现
深入探索 terser-webpack-plugin:代码压缩与优化的秘密</ terser-webpack-plugin 是一款强大的 webpack 插件,它巧妙地融合了 terser 库的功能,旨在为你的 JavaScript 代码带来高效且优雅的压缩体验。要开始使用,只需参考官方文档中关于 minify-options</的配置指导。这款插件在 webpack 的 compilation 阶段大展身手,通过 optimizeChunkAssets</钩子实现了异步的代码优化,核心逻辑则隐藏在了名为 optimise</的神秘函数中。 优化艺术</ 在 optimise</函数的舞台,一场资源名的魔术表演正在上演。它首先从 compilation 中获取资源,接着根据 availableNumberOfCores</动态决定是否启用并行模式,创建适当的 Worker</。在这里,pLimit</起到了关键作用,它巧妙地控制并发任务的数量,确保效率与稳定性并存。紧接着,遍历每一个 assetNames,一个个任务被 scheduleTask 准备就绪,等待着执行。 任务分解</ 而每个任务的核心 scheduleTask,就像拆解谜题一般,包含着获取 asset 信息、代码检查、minify 的选择(Worker 或主线程)、新代码生成和缓存更新,以及对资产内容的即时更新。整个过程紧凑而有序,以资源处理和并发控制为核心。 并行力量</ terser-webpack-plugin 的亮点之一就是其 parallel</功能,能根据你的计算机 CPU 核心数动态启动 worker,巧妙地利用了 jest-worker 线程池,优先选择高性能的 worker_threads 模式。它通过私有任务队列和先进先出 (FIFO) 管理机制,确保了多进程处理的高效性和一致性。 代码简化与压缩</ minify 函数的精妙之处在于,它直接调用 terser 库的强大功能,略过不必要的 comments 处理,通过出口 API 实现代码的高效压缩。这个过程既简洁又高效,确保了代码质量的提升。 全面优化流程</ terser-webpack-plugin 的优化流程井然有序:异步注册 optimizeChunkAssets</,开启多线程编译(Worker),并在 minify 阶段,利用 terser 的强大压缩能力对代码进行深度处理。而 v4 版本更是增添了异步优化点,让并行处理更加灵活和高效。