作者:buzhanhua序言 现在前端项目的开发过程离不开构建工具帮助,面对琳琅满目的构建工具我们该如何选择最合适自己场景的构建工具是一个问题。在研究各种配置之余,我们去研究一下构建工具发展过程、底层原理,面对一些问题的时候往往事半功倍。 通过本文你可以了解到:前端构建工具的进化历程前端构建工具技术方案对比常用构建工具核心实现原理什么是构建? 构建简单的说就是将我们开发环境的代码,转化成生产环境可用来部署的代码。 市面上存在很多构建工具,但是最终的目标都是转化开发环境的代码为生产环境中可用的代码。在不同的前端项目中使用的技术栈是不一样的,比如:不同的框架、不同的样式处理方案等,为了生产出生产环境可用的JS、CSS,构建工具实现了诸如:代码转换、代码压缩、treeshaking、codespliting等。前端构建工具可以做什么? 前端构建工具进化史 无模块化时代 YUIToolAnt YUITool是07年左右出现的一个构建工具,可以实现压缩混淆前端代码,依赖于java的ant使用。 在早期web应用开发主要采用JSP,处于混合开发的状态,不像是现在的前后端分离开发。通常由java开发人员来编写js、css代码。此时出现的构建工具依赖于别的语言实现。 JS内联外联 前端代码是否必须通过构建才可以在浏览器中运行呢?当然不是。如下:htmlheadtitleHelloWorldtitleheadbodybodyhtml 上述代码,我们只需要按格式写几个HTML标签,插入简单的JS脚本,打开浏览器,一个HelloWorld的前端页面就呈现在我们面前了。但是当项目进入真正的实战开发,代码规模开始急速扩张后,大量逻辑混杂在一个文件之中就变得难以维护起来。早期的前端项目一般如下组织:htmlheadtitleJQuerytitleheadbodybodyhtml 通过JS的内联外联组织代码,将不同的代码放在不同的文件中,但是这也仅仅解决了代码组织混乱的问题,还存在很多问题:大量的全局变量,代码之间的依赖是不透明的,任何代码都可能悄悄的改变了全局变量。脚本的引入需要依赖特定的顺序。 后续出现过一些IIFE、命名空间等解决方案,但是从本质上都没有解决依赖全局变量通信的问题。在这样的背景下,前端工程化成为解决此类问题的正轨。 社区模块化时代 AMDCMD异步模块加载 为了解决浏览器端JS模块化的问题,出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:AMD(RequireJs)和CMD(Sea。js)。AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。下面领略下相关写法: Require。js加载完jquery后,将执行结果作为参数传入了回调函数define(〔jquery〕,function(){(document)。ready(function(){(root)〔0〕。innerTextHelloWorld;})return}) Sea。js预加载jquerydefine(function(require,exports,module){执行jquery模块,并得到结果赋值给varrequire(jquery);调用jquery。js模块提供的方法(header)。hide();}); 两种模块化规范实现的原理基本上是一致的,只不过各自坚持的理念不同。两者都是以异步的方式获取当前模块所需的模块,不同的地方在于AMD在获取到相关模块后立即执行,CMD则是在用到相关模块的位置再执行的。 AMDCMD解决问题:手动维护代码引用顺序。从此不再需要手动调整HTML文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。全局变量污染问题。将模块内容在函数内实现,利用闭包导出的变量通信,不会存在全局变量污染的问题。 GruntGulp 在GoogleChrome推出V8引擎后,基于其高性能和平台独立的特性,Nodejs这个JS运行时也现世了。至此,JS打破了浏览器的限制,拥有了文件读写的能力。Nodejs不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。 在这个背景下,第一批基于Node。js的构建工具出现了。 Grunt Grunt〔1〕主要能够帮助我们自动化的处理一些反复重复的任务,例如压缩、编译、单元测试、linting等。Gruntfile。jsmodule。exportsfunction(grunt){功能配置grunt。initConfig({定义任务jshint:{files:〔Gruntfile。js,src。js,test。js〕,options:{globals:{jQuery:true}}},时时侦听文件变化所执行的任务watch:{files:〔lt;jshint。files〕,tasks:〔jshint〕}});加载任务所需要的插件grunt。loadNpmTasks(gruntcontribjshint);grunt。loadNpmTasks(gruntcontribwatch);默认执行的任务grunt。registerTask(default,〔jshint〕);}; Gulp Grunt的IO操作比较呆板,每个任务执行结束后都会将文件写入磁盘,下个任务执行时再将文件从磁盘中读出,这样的操作会产生一些问题:运行速度较慢硬件压力大 Gulp〔2〕最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。同时Gulp设计简单,既可以单独使用,也可以结合别的工具一起使用。gulpfile。jsconst{src,dest}require(gulp);gulp提供的一系列apisrc读取文件dest写入文件constbabelrequire(gulpbabel);exports。defaultfunction(){将src文件夹下的所有js文件取出,经过Babel转换后放入output文件夹之中returnsrc(src。js)。pipe(babel())。pipe(dest(output));} Browserify 随着Node。js的兴起,CommonJS模块化规范成为了当时的主流规范。但是我们知道CommonJS所使用的require语法是同步的,当代码执行到require方法的时候,必须要等这个模块加载完后,才会执行后面的代码。这种方式在服务端是可行的,这是因为服务器只需要从本地磁盘中读取文件,速度还是很快的,但是在浏览器端,我们通过网络请求获取文件,网络环境以及文件大小都可能使页面无响应。 browserify〔3〕致力于打包产出在浏览器端可以运行的CommonJS规范的JS代码。varbrowserifyrequire(browserify)varbbrowserify()varfsrequire(fs)添加入口文件b。add(。srcbrowserifyIndex。js)打包所有模块至一个文件之中并输出bundleb。bundle()。pipe(fs。createWriteStream(。outputbundle。js)) browserify怎么实现的呢? browserify在运行时会通过进行AST语法树分析,确定各个模块之间的依赖关系,生成一个依赖字典。之后包装每个模块,传入依赖字典以及自己实现的export和require函数,最终生成一个可以在浏览器环境中执行的JS文件。 browserify专注于JS打包,功能单一,一般配合Gulp一起使用。 ESM规范出现 在2015年JavaScript官方的模块化终于出现了,但是官方只阐述如何实现该规范,浏览器少有支持。 Webpack 其实在ESM标准出现之前,webpack〔4〕已经诞生了,只是没有火起来。webpack的理念更偏向于工程化,伴随着MVC框架以及ESM的出现与兴起,webpack2顺势发布,宣布支持AMDCommonJSESM、csslesssassstylus、babel、TypeScript、JSX、Angular2组件和vue组件。从来没有一个如此大而全的工具支持如此多的功能,几乎能够解决目前所有构建相关的问题。至此webpack真正成为了前端工程化的核心。 webpack是基于配置。module。exports{SPA入口文件entry:srcjsindex。js,出口output:{filename:bundle。js}模块匹配和处理大部分都是做编译处理module:{rules:〔babel转换语法{test:。js,use:babelloader},。。。〕},插件plugins:〔根据模版创建html文件newHtmlWebpackPlugin({template:。srcindex。html}),〕,} webpack要兼顾各种方案的支持,也暴露出其缺点:配置往往非常繁琐,开发人员心智负担大。webpack为了支持cjs和esm,自己做了polyfill,导致产物代码很丑。 在webpack出现两年后,rollup诞生了 Rollup rollup〔5〕是一款面向未来的构建工具,完全基于ESM模块规范进行打包,率先提出了TreeShaking的概念。并且配置简单,易于上手,成为了目前最流行的JS库打包工具。importresolvefromrolluppluginnoderesolve;importbabelfromrolluppluginbabel;exportdefault{入口文件input:srcmain。js,output:{file:bundle。js,输出模块规范format:esm},plugins:〔转换commonjs模块为ESMresolve(),babel转换语法babel({exclude:nodemodules})〕} rollup基于esm,实现了强大的TreeShaking功能,使得构建产物足够的简洁、体积足够的小。但是要考虑浏览器的兼容性问题的话,往往需要配合额外的polyfill库,或者结合webpack使用。 ESM规范原生支持 Esbuild 在实际开发过程中,随着项目规模逐渐庞大,前端工程的启动和打包的时间也不断上升,一些工程动辄几分钟甚至十几分钟,漫长的等待,真的让人绝望。这使得打包工具的性能被越来越多的人关注。 esbuild〔6〕是一个非常新的模块打包工具,它提供了类似webpack资源打包的能力,但是拥有着超高的性能。 esbuild支持ES6CommonJS规范、TreeShaking、TypeScript、JSX等功能特性。提供了JSAPIGoAPICLI多种调用方式。JSAPI调用require(esbuild)。build({entryPoints:〔app。jsx〕,bundle:true,outfile:out。js,})。catch(()process。exit(1)) 根据官方提供的性能对比,我们可以看到性能足有百倍的提升,为什么会这么快? 语言优势esBuild是选择Go语言编写的,而在esBuild之前,前端构建工具都是基于Node,使用JS进行编写。JavaScript是一门解释性脚本语言,即使V8引擎做了大量优化(JWT及时编译),本质上还是无法打破性能的瓶颈。而Go是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。Go天生具有多线程运行能力,而JavaScript本质上是一门单线程语言。esBuild经过精心的设计,将代码parse、代码生成等过程实现完全并行处理。 性能至上原则esBuild只提供现代Web应用最小的功能集合,所以其架构复杂度相对较小,更容易将性能做到极致在webpack、rollup这类工具中,我们习惯于使用多种第三方工作来增强工程能力。比如:babel、eslint、less等。在代码经过多个工具流转的过程中,存在着很多性能上的浪费,比如:多次进行代码AST、AST代码的转换。esBuild对此类工具完全进行了定制化重写,舍弃部分可维护性,追求极致的编译性能。 虽然esBuild性能非常高,但是其提供的功能很基础,不适合直接用到生产环境,更适合作为底层的模块构建工具,在它基础上进行二次封装。 Vite vite〔7〕是下一代前端开发与构建工具,提供noBundle的开发服务,并内置丰富的功能,无需复杂配置。 vite在开发环境和生产环境分别做了不同的处理,在开发环境中底层基于esBuild进行提速,在生产环境中使用rollup进行打包。 为什么vite开发服务这么快? 传统bundlebased服务:无论是webpack还是rollup提供给开发者使用的服务,都是基于构建结果的。基于构建结果提供服务,意味着提供服务前一定要构建结束,随着项目膨胀,等待时间也会逐渐变长。 noBundle服务:对于vite、snowpack这类工具,提供的都是noBundle服务,无需等待构建,直接提供服务。对于项目中的第三方依赖,仅在初次启动和依赖变化时重构建,会执行一个依赖预构建的过程。由于是基于esBuild做的构建,所以非常快。对于项目代码,则会依赖于浏览器的ESM的支持,直接按需访问,不必全量构建。 为什么在生产环境中构建使用rollup?由于浏览器的兼容性问题以及实际网络中使用ESM可能会造成RTT时间过长,所以仍然需要打包构建。esbuild虽然快,但是它还没有发布1。0稳定版本,另外esbuild对代码分割和css处理等支持较弱,所以生产环境仍然使用rollup。 目前vite发布了3。0版本,相对于2。0,修复了400issue,已经比较稳定,可以用于生产了。Vite决定每年发布一个新的版本。 vite。config。js:import{defineConfig}fromviteimportvuefromvitejspluginvueimport{resolve}frompath;defineConfig这个方法没有什么实际的含义,主要是可以提供语法提示exportdefaultdefineConfig({resolve:{alias:{:resolve(src)}},plugins:〔vue()〕})技术方案对比 前端构建工具实在是琳琅满目,以工程化的视角对社区仍然比较流行的构建工具进行简单对比,一些功能专一、特定场景下的工具不在考虑范围内。 2021前端构建工具排行〔8〕 一些值得思考的问题 为什么webpack构建产物看着很丑? 我们在使用webpack构建项目后,会发现打包出来的代码非常的丑,这是为什么?原因就是:webpack支持多种模块规范,但是最后都会变成commonJS规范(webpack5对纯esm做了一定的优化),但是浏览器不支持commonJS规范,于是webpack自己实现了require和module。exports,所以会有很多polyfill代码的注入。 针对common。js加载common。js这种情况分析一下构建产物。 源代码:srcindex。jslettitlerequire(。title。js)console。log(title);srctitle。jsmodule。exportsbu; 产物代码:((){把所有模块定义全部存放到modules对象里属性名是模块的ID,也就是相对于根目录的相对路径,包括文件扩展名值是此模块的定义函数,函数体就是原来的模块内的代码varmodules({。srctitle。js:((module){module。exportsbu;})});缓存对象varcache{};webpack打包后的代码能够运行,是因为webpack根据commonJS规范实现了一个require方法functionrequire(moduleId){varcachedModulecache〔moduleId〕;if(cachedModule!undefined){returncachedModule。exports;}缓存和创建模块对象varmodulecache〔moduleId〕{exports:{}};运行模块代码modules〔moduleId〕(module,module。exports,requiremoduleId);returnmodule。exports;}varexports{};((){入口相关的代码lettitlerequire(。srctitle。js)console。log(title);})();})(); webpack按需加载的模块怎么在浏览器中运行? 在实际项目开发中,随着代码越写越多,构建后的bundle文件也会越来越大,我们往往按照种种策略对代码进行按需加载,将某部分代码在用户事件触发后再进行加载,那么webpack在运行时是怎么实现的呢? 其实原理很简单,就是以JSONP的方式加载按需的脚本,但是如何将这些异步模块使用起来就比较有意思了 对一个简单的case进行分析。 源代码:index。jsimport(。hello)。then((result){console。log(result。default);});hello。jsexportdefaulthello; 产物代码: main。jsPS:对代码做了部分简化及优化,否则太难读了定一个模块对象varmodules({});webpack在浏览器里实现require方法functionrequire(moduleId){xxx}chunkIds代码块的ID数组moreModules代码块的模块定义functionwebpackJsonpCallback(〔chunkIds,moreModules〕){constresult〔〕;for(leti0;ichunkIds。length;i){constchunkIdchunkIds〔i〕;result。push(installedChunks〔chunkId〕〔0〕);installedChunks〔chunkId〕0;表示此代码块已经下载完毕}将代码块合并到modules对象中去for(constmoduleIdinmoreModules){modules〔moduleId〕moreModules〔moduleId〕;}依次将require。e方法中的promise变为成功态while(result。length){result。shift()();}}用来存放代码块的加载状态,key是代码块的名字每次打包至少产生main的代码块0表示已经加载就绪varinstalledChunks{main:0}require。d(exports,definition){for(varkeyindefinition){Object。defineProperty(exports,key,{enumerable:true,get:definition〔key〕});}};require。r(exports){Object。defineProperty(exports,Symbol。toStringTag,{value:Module});Object。defineProperty(exports,esModule,{value:true});};给require方法定义一个m属性,指向模块定义对象require。mmodules;require。f{};利用JSONP加载一个按需引入的模块require。lfunction(url){letscriptdocument。createElement(script);script。srcurl;document。head。appendChild(script);}用于通过JSONP异步加载一个chunkId对应的代码块文件,其实就是hello。main。jsrequire。f。jfunction(chunkId,promises){letinstalledChunkData;当前代码块的数据constpromisenewPromise((resolve,reject){installedChunkDatainstalledChunks〔chunkId〕〔resolve,reject〕;});promises。push(installedChunkData〔2〕promise);获取模块的访问路径consturlchunkId。main。js;require。l(url);}require。efunction(chunkId){letpromises〔〕;require。f。j(chunkId,promises);console。log(promises);returnPromise。all(promises);}varchunkLoadingGlobalwindow〔webpack〕〔〕;由于按需加载的模块,会在加载成功后调用此模块,所以这是JSONP的成功后的回掉chunkLoadingGlobal。pushwebpackJsonpCallback;require。e异步加载hello代码块文件hello。main。jspromise成功后会把hello。main。js里面的代码定义合并到require。m对象上,也就是modules上调用require方法加载。srchello。js模块,获取模块的导出对象,进行打印require。e(hello)。then(require。bind(require,。srchello。js))。then(resultconsole。log(result)); hello。main。jsusestrict;(self〔webpack〕self〔webpack〕〔〕)。push(〔〔hello〕,{。srchello。js:((module,exports,require){require。r(exports);require。d(exports,{default:()(DEFAULTEXPORT)});constDEFAULTEXPORT(hello);})}〕); webpack在产物代码中声明了一个全局变量webpack并赋值为一个数组,然后改写了这个数组的push方法。在异步代码加载完成后执行时,会调用这个push方法,在重写的方法内会将异步模块放到全局模块中然后等待使用。 白话版webpack构建流程 时至今日,webpack的功能集已经非常庞大了,代码量更是非常惊人,源码的学习成本非常高,但是了解webpack构建流程又十分有必要,可以帮我们了解构建产物是怎么产生的,以及遇到实际问题时如何下手解决问题。 思路实现 简单模拟下webpack实现思路:classCompilation{constructor(options){this。optionsoptions;本次编译所有生成出来的模块this。modules〔〕;本次编译产出的所有代码块,入口模块和依赖模块打包在一起成为代码块this。chunks〔〕;本次编译产出的资源文件this。assets{};}build(callback){5。根据配置文件中的entry配置项找到所有的入口letentry{xxx:xxx};6。从入口文件出发,调用所有配置的loader规则,比如说loader对模块进行编译for(letentryNameinentry){6。从入口文件出发,调用所有配置的Loader对模块进行编译constentryModulethis。buildModule(entryName,entryFilePath);this。modules。push(entryModule);8。等把所有的模块编译完成后,根据模块之间的依赖关系,组装成一个个包含多个模块的chunkletchunk{name:entryName,代码块的名称就是入口的名称entryModule,此代码块对应的入口模块modules:this。modules。filter((module)module。names。includes(entryName))此代码块包含的依赖模块};this。chunks。push(chunk);}9。再把各个代码块chunk转换成一个一个的文件(asset)加入到输出列表this。chunks。forEach((chunk){constfilenamethis。options。output。filename。replace(〔name〕,chunk。name);获取输出文件名称this。assets〔filename〕getSource(chunk);});调用编译结束的回掉callback(null,{modules:this。modules,chunks:this。chunks,assets:this。assets},this。fileDependencies);}当你编译模块的时候,需要传递你这个模块是属于哪个代码块的,传入代码块的名称buildModule(name,modulePath){6。从入口文件出发,调用所有配置的Loader对模块进行编译,loader只会在编译过程中使用,plugin则会贯穿整个流程读取模块内容letsourceCodefs。readFileSync(modulePath,utf8);创建一个模块对象letmodule{id:moduleId,模块ID》相对于工作目录的相对路径names:〔name〕,表示当前的模块属于哪个代码块(chunk)dependencies:〔〕,表示当前模块依赖的模块}查找所有匹配的loader,自右向左读取loader,进行转译,通过loader翻译后的内容一定是JS内容sourceCodeloaders。reduceRight((sourceCode,loader){returnrequire(loader)(sourceCode);},sourceCode);7。再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理创建语法树,遍历语法树,在此过程进行依赖收集,绘制依赖图letastparser。parse(sourceCode,{sourceType:module});traverse(ast,{});let{code}generator(ast);把转译后的源代码放到module。source上module。sourcecode;再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理module。dependencies。forEach(({depModuleId,depModulePath}){constdepModulethis。buildModule(name,depModulePath);this。modules。push(depModule)});returnmodule;}}functiongetSource(chunk){return((){varmodules{{chunk。modules。map((module){module。id}:(module){{module。source}})}};varcache{};functionrequire(moduleId){varcachedModulecache〔moduleId〕;if(cachedModule!undefined){returncachedModule。exports;}varmodule(cache〔moduleId〕{exports:{},});modules〔moduleId〕(module,module。exports,requiremoduleId);returnmodule。exports;}varexports{};{chunk。entryModule。source}})();;}classCompiler{constructor(options){this。optionsoptions;this。hooks{run:newSyncHook(),会在编译刚开始的时候触发此run钩子done:newSyncHook(),会在编译结束的时候触发此done钩子}}4。执行Compiler对象的run方法开始执行编译run(){在编译前触发run钩子执行,表示开始启动编译了this。hooks。run。call();编译成功之后的回掉constonCompiled(err,stats,fileDependencies){10。在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统for(letfilenameinstats。assets){fs。writeFileSync(filePath,stats。assets〔filename〕,utf8);}当编译成功后会触发done这个钩子执行this。hooks。done。call();}开始编译,编译成功之后调用onCompiled方法this。compile(onCompiled);}compile(callback){webpack虽然只有一个Compiler,但是每次编译都会产出一个新的Compilation,用来存放本次编译产出的文件、chunk、和模块比如:监听模式会触发多次编译letcompilationnewCompilation(this。options);执行compilation的build方法进行编译,编译成功之后执行回调compilation。build(callback);}}functionwebpack(options){1。初始化参数,从配置文件和shell语句中读取并合并参数,并得到最终的配置对象letfinalOptions{。。。options,。。。shellOptions};2。用上一步的配置对象初始化Compiler对象,整个编译流程只有一个complier对象constcompilernewCompiler(finalOptions);3。加载所有在配置文件中配置的插件const{plugins}finalOptions;for(letpluginofplugins){plugin。apply(compiler);}returncompiler;}webpackOptionswebpack的配置项constcompilerwebpack(webpackOptions);4。执行对象的run方法开始执行编译compiler。run(); 为什么Rollup构建产物很干净?rollup只对ESM模块进行打包,对于cjs模块也会通过插件将其转化为ESM模块进行打包。所以不会像webpack有很多的代码注入。rollup对打包结果也支持多种format的输出,比如:esm、cjs、am等等,但是rollup并不保证代码可靠运行,需要运行环境可靠支持。比如我们输出esm规范代码,代码运行时完全依赖高版本浏览器原生去支持esm,rollup不会像webpack一样注入一系列兼容代码。rollup实现了强大的treeshaking能力。 为什么Vite可以让代码直接运行在浏览器上? 前文我们提到,在开发环境时,我们使用vite开发,是无需打包的,直接利用浏览器对ESM的支持,就可以访问我们写的组件代码,但是一些组件代码文件往往不是JS文件,而是。ts、。tsx、。vue等类型的文件。这些文件浏览器肯定直接是识别不了的,vite在这个过程中做了些什么? 我们以一个简单的vue组件访问分析一下:index。html!DOCTYPEhtmlhtmllangenheadmetacharsetUTF8linkrelicontypeimagesvgxmlhrefvite。svgmetanameviewportcontentwidthdevicewidth,initialscale1。0titleViteVuetitleheadbodybodyhtmlsrcmain。jsimport{createApp}fromvueimportAppfrom。App。vuecreateApp(App)。mount(app);srcApp。vuetemplateh1Helloh1template 在浏览器中打开页面后,会发现浏览器对入口文件发起了请求: 我们可以观察到vue这个第三方包的访问路径改变了,变成了nodemodules。vite下的一个vue文件,这里真正访问的文件就是前面我们提到的,vite会对第三方依赖进行依赖预构建所生成的缓存文件。 另外浏览器也对App。vue发起了访问,相应内容是JS: 返回内容(做了部分简化,移除了一些热更新的代码):constsfcmain{name:App}vue提供的一些API,用于生成block、虚拟DOMimport{openBlockasopenBlock,createElementBlockascreateElementBlock}fromnodemodules。vitevue。js?vb618a526functionsfcrender(ctx,cache,props,setup,data,options){return(openBlock(),createElementBlock(h1,null,App))}组件的render方法sfcmain。rendersfcrender;exportdefaultsfcmain; 总结:当用户访问vite提供的开发服务器时,对于浏览器不能直接识别的文件,服务器的一些中间件会将此类文件转换成浏览器认识的文件,从而保证正常访问。参考资料 〔1〕Grunt:https:www。gruntjs。net 〔2〕Gulp:https:www。gulpjs。com。cn 〔3〕browserify:https:browserify。org 〔4〕webpack:https:webpack。docschina。org 〔5〕rollup:https:rollupjs。orgguidezh 〔6〕esbuild:https:esbuild。github。io 〔7〕vite:https:cn。vitejs。devguide 〔8〕2021前端构建工具排行:https:risingstars。js。org2021zhsectionbuild 〔9〕前端构建工具简史:https:juejin。cnpost7085613927249215525 〔10〕ESM实现原理:https:hacks。mozilla。org201803esmodulesacartoondeeppe 〔11〕Webpack核心原理:https:mp。weixin。qq。comsSbJNbSVzSPSKBe2YStn2Zw 关注字节前端ByteFE公众号,追更不迷路!