关于Webpack与Vue CLI的知识点整理

这篇文章来梳理一下工程化的一部分内容。Webpack的内容很杂很碎,且版本更迭较快,理解核心思想才能不至于在版本更新中迷失自我。Vue CLI目前已经稳定在4.x版本,整理Vue CLI也算是对这个老牌的工具做个简单的总结,2021年Vite开始爆火,2022年Vite也将继续稳定发展,Vue CLI胜在一手稳定性,大型企业项目的首选,新的小型项目建议首选Vite,开发体验会更舒适。

本文采用的Webpack版本为4.x和5.x,Vue CLI的版本为4.x。行文风格偏理论介绍,实际项目配置其实资料和规范都是很容易查到的,这里就不会举太多例子了。

Webpack

自前端工程第二次大变革(Node.js问世)以来,以高效率开发和高性能应用为目的而生的前端新技术层出不穷,如新的JavaScript框架、CSS扩展语言,TypeScript等。尽管这些工具都可以方便我们开发前端项目,但它们都有一个共同的问题:浏览器并不能直接识别这些文件,需要使用工具将这些高级的代码转换成最基本的HTML、CSS、JavaScript,浏览器才可以正常执行,这样的工具叫做构建工具,或者称之为打包器亦可。

大部分构建工具都是使用JavaScript开发的。近年来,出现的构建工具有:

  • Grunt:老牌构建工具,生态系统比较庞大,它可以自动运行给定的打包任务,优点是具有大量插件,但缺点是需要提供大量配置
  • Gulp:一个基于流的构建工具,Gulp通过管道的形式进行文件转换,API相对简单,缺点同Grunt,也需要大量配置
  • Webpack:模块化构建工具,可以通过使用较多的CommonJS(require)、CMD(define)、AMD(异步define)、ESM(import)等规范进行打包工作,缺点是只支持模块化项目(其实无伤大雅,因为现代Web项目几乎都是模块化的)
  • Rollup:利用ESM的巧妙设计,构建尽可能高效精简的JavaScript代码,目前Vite采用Rollup代替Webpack作为构建工具

整体概念

Webpack是基于模块的,它会对你编写的各种js文件、vue文件、css,less文件等进行处理,Webpack会进行依赖分析,在Webpack内部会构建出一个依赖关系图,这个图中的每个节点都是一个模块,然后借助这个图去生成并输出静态资源。Webpack官方把打包产物称为bundle,意为一束、一捆、一套。

在正确使用npm安装了Webpack之后(即安装Webpack本体和它的CLI命令行),需要使用webpack.config.js文件控制Webpack的行为,此文件使用CommonJS来包裹所有的配置。

Webpack有四个核心概念,在学习Webpack前必须要熟悉这四个核心概念:

  • 入口(entry):Webpack进行依赖分析的时候的起始点,以此为开始进行一个类似DFS的依赖分析。当然,入口也可以是多个。入口的默认值是./src

  • 输出(output):此配置项决定Webpack要在哪里输出它的打包文件,以及决定这些文件如何命名,其默认值为./dist,基本上整个项目的编译产物都会被输出到此配置指定的文件夹里。

  • 加载器(loader):这是Webpack能够转换那些千奇百怪的代码的根源,通过npm安装不同的loader(有很多,用到什么就用什么,文档也很全),就可以用来打包各种浏览器不识别的代码,让它能够被识别。其实loader不仅可以转换代码,它可以转换任意类型的文件,比如图片也是可以的。可以在Webpack配置中的module.rules里面引入loader。

    loader本质上是一个函数,输入的是代码文件的字符串,输出的是转化后的代码文件,它支持链式调用,打包的时候是按照配置的loader从后往前的顺序逐个处理。

  • 插件(plugin):插件可以借助Webpack引擎的能力,将自定义行为注入到Webpack的构建流程中,解决一些额外的功能,比如分离打包,压缩文件,代码混淆等。插件可以根据特定需求实现自定义。在配置文件里通过plugins配置项设置插件,它接收一个数组用来存放各种引入的插件。

    plugin是在Webpack打包的某个时间点上去做一些操作,可以把Webpack理解为一个工厂生产线,代码需要经过一系列的工艺处理才能得到结果,plugin就好比是生产线上插入的一个功能,在打包进行到某个阶段时做一些特殊的处理。plugin的本质是一个类,使用的时候都是new Plugin()这种形式,这个类里面需要实现一个apply方法,在Webpack打包时会按顺序执行每个plugin的逻辑。

此外,还有一个打包模式的配置项(mode),它告诉Webpack应该按什么样的模式来进行优化,其可选值为nonedevelopmentproduction

  • none:不启用任何默认优化选项
  • development:开发模式,这个模式下打包会更快,但不会进行代码优化
  • production:这是mode的默认值,打包速度略慢,但会开启tree-sheaking和代码压缩(这两个概念会在下文”性能优化“中讲到)。

模式的切换可以通过配置文件修改,也可以在使用Webpack CLI命令的时候通过--mode=xxxx进行传递。

Webpack启动本地服务器使用的是webpack-dev-server简称wds。Vue CLI的本地调试服务也是基于wds的。可以在config里的devServer里配置wds的各种选项,比如静态文件目录、端口、启动压缩gzip,自动打开网页等。wds是基于Node.js编写的,可以在浏览器和服务器之间建立一个WebSocket长链接,从而可以自动加载页面。

构建流程

Webpack要做的事情是内容转换和资源合并,整个Webpack的构建流程包含三个阶段:初始化、构建、生成,有一点“要把大象装冰箱”的意味(

  • 初始化阶段:
    • 初始化参数:执行打包指令时,从默认配置、用户配置、Shell参数中读取所有的参数,将它们结合,得到最终的参数用于运行。
    • 创建编译器对象:用上一步的参数创建一个编译器(compiler)对象
    • 初始化编译环境:注入内置插件、注册模块工厂、初始化RuleSet、加载配置
      • 内置插件:各种用户加入的plugin
      • 模块工厂:一个工厂函数,通过它的create方法去实例化module
      • RuleSet:是Webpack的规则集,用于在Webpack中配置匹配、修改和插入的规则。
      • 配置:Webpack配置文件
    • 从入口开始编译:执行编译器对象的run方法,根据配置的入口找到所有的入口文件,首先将所有的入口文件作为一开始的依赖,然后以深度优先的方式开始进行依赖收集。
    • 总的来说,依赖分析是依靠AST的。Webpack需要读到入口文件里的内容,然后使用@babel/parser把js代码转换成js对象,这种对象就是抽象语法树AST。从AST中可以提取CallExpression的arguments的value值,它是一个相对路径,然后需要用path库把它转换成绝对路径,根据绝对路径找到下一个依赖文件,再换换成AST,从AST中提取模块,反复迭代直到遍历完成。
  • 构建阶段:
    • 编译模块:首先分析入口文件的依赖,调用对应的loader把模块转换成JavaScript,再调用JavaScript解释器把内容都转成AST,找出模块依赖的模块,再递归寻找,直到所有的依赖都被遍历
    • 构建关系图:遍历完成所有能触达的模块后,根据遍历的信息生成一个依赖关系图
  • 生成阶段:
    • 输出资源:根据入口和模块之间的依赖关系,将依赖组装成一个个chunk(释义为区块或者数据块),再把每个生成的chunk转成单独的文件加入到输出列表(此为默认配置,可以不让它全输出到一个文件里)
    • 写入文件:确定好输出内容后,根据output配置确定的输出路径和文件名,把文件内容写入到文件系统

构建产物

以下构建产物的讨论基于v4版本。Webpack构建产物分两种情况:

  • 默认情况,所有内容打包到一个chunk里。
    • 此种方法会把读到所有模块依赖都放到一个作用域里,然后用一个modules数组保存,所有的模块按加载顺序加入数组,并按数组索引访问。
    • Webpack会自己实现引入的api,让浏览器支持代码里面的模块化写法(比如ESM和CommonJS)

对于默认情况,可以自己尝试一下打个包看看会发生什么。Webpack打包产物使用一个IIFE包裹,传参是一个数组(modules数组),数组的每一项都是源代码的模块代码。如果采用CommonJS写法,那么Webpack就会自己实现一个requiremodule.exports,前者被替换成__webpack_require__,后者不变,会由Webpack作为函数的参数传递给源代码。如果采用ESM写法,那么Webpack就会让__webpack_require__去模拟import,同时使用__webpack_exports__模拟export。如果混用ESM和CommonJS,Webpack就会同时模拟这两种方法。

  • 自定义情况,把代码打到多个chunk包里。
    • 当存在 import做动态加载、公共依赖、还有多个打包入口这三种里任意一个或多个的情况时,Webpack将尝试把代码打包成多个chunk。
    • Webpack可以根据设定好的条件自动拆分chunks:
      • 新的chunk可以被共享,或者来自node_modules
      • 新的chunk在进行压缩混淆之前的体积大于20kb
      • 当按需加载chunk时,并行请求的最大数量<=30
      • 当加载初始化页面时,并发请求的最大数量<=30

多个chunk的时候情况不太一样。可以分别分析一下:

  • 多个打包入口:这个是最直观的,由于打包入口有多个,所以最后输出时肯定会分成多个不同的包,不然多个入口的意义就失去了很多。多个入口分离多个包,如果使用了html-webpack-plugin,那么在入口html里将引入多个script标签。分离出来的多个包都包含同样的Webpack注入代码。
  • 存在公共依赖:如果存在公共依赖,且在配置文件中配置了optimization.splitChunks.chunks = 'all',那么打包产物中将出现带有vendors字样的文件,其中就包含公共使用的依赖。(vendor原意为供应商,这里代表各种第三方依赖)如果你试图查看vendors输出的文件,会发现第一行代码是Webpack在window对象上注入的webpackJsonp,后续Webpack访问模块就是通过这个webpackJsonp,可以通过打开一个任意Webpack项目来验证,只要window.webpackJsonp有值,那就说明Webpack的分包是起作用的。这种情况的执行流程是,首先加载vendors包(把第三方依赖模块变成以Webpack可以解析的形式存储到全局对象window['webpackJsonp']内),然后把window['webpackJsonp']的代码传到打包产物代码里的modules里,之后的操作和单chunk一样。
  • import动态加载:这种方式可以称之为懒加载或者按需加载,因为在Webpack内通过import()函数可以让模块异步加载,且只有当使用到此模块时才执行加载操作。使用了import操作的JavaScript代码,结构和单chunk包是一样的,但遇到import函数时就会被替换成一个复杂的 __webpack_require__操作,其步骤大概是这样的:
    • 首先使用 __webpack_require__和模块在modules里的索引找到此按需引入的模块,然后它会返回一个Promise,如果Promise兑现说明加载成功,此时可以使用thenable方法,Webpack的实现也是这样的。
    • 第一步替换之后, __webpack_require__会生成一个script标签,然后使用document.head.appendChild把它插入到DOM里,然后return一个Promise对象,此时的Promise是pending状态,等待模块加载。
    • 模块下载完成后会直接解析执行,此时触发webpackJsonpCallback函数,它会把刚刚懒加载的模块内容push到modules数组里面,然后上一步的Promise状态就更改为兑现,下面的__webpack_require__可以继续执行。
    • 在上一个 __webpack_require__的then里面,它执行 __webpack_require__.bind(null, 模块索引),这一步就是正常解析了。(也就是两个 __webpack_require__的作用不完全相同,实际上我在做笔记时省略了第一步时的一些细节,第一步实际上是 __webpack_require__.e。完整实现可以去自己打个包看看),它实际上模拟了module.exports,这次 __webpack_require__之后再接thenable方法就可以接收到具体参数。

如果采用生产环境模式打包,打包逻辑其实也是完全相同的,但其产物会进行压缩,一来压缩体积降低传输数据量,二来提高JavaScript执行速度。但即便是使用开发环境模式打包,Webpack的产物看起来也特别丑。其实是因为Webpack诞生于CommonJS出现后,ESM出现前,这时的浏览器只能通过script标签加载模块,且标签本身没有作用域,想要分割作用域只能通过闭包(也就是实现里采用的IIFE),每个模块都要封装成function,这样才能互不干扰。并且由于浏览器不支持cjs(cjs是同步的,浏览器访问服务端资源需要通过网络请求,如果弱网环境的话执行就会卡住),所以Webpack才要自己实现CommonJS的方法,相当于它本身提供了一个polyfill。即便现在有了ESM,但Webpack为了向前兼容,就保留了IIFE的写法,所以即便是新版的Webpack,产物看起来还是这么乱糟糟。

特色功能

runtime和manifest

Webpack的打包产物中包含runtime和manifest。runtime是Webpack的运行时辅助工具,用于加载模块。

manifest是一组数据,记录模块和打包产物文件之间的映射关系,可以通过安装webpack-manifest-plugin来查看manifest,默认打包的话manifest是不可见的。

Source Map

由于Webpack在合并、编译压缩源代码后会破坏原有的代码结构,这就导致如果打包后出现bug,很难对着压缩后的代码进行调试。Source Map就是为了解决这个问题而生的。它可以在源文件和打包文件之间建立映射,本质是将每个代码字符的新旧位置对应起来,这样当运行的代码出现警告或报错时,通过Source Map就可以把报错位置转换到源代码的位置,以方便调试。但是Source Map本身会提升一部分打包体积,且有泄漏源代码的风险,所以是否启用需要根据实际情况灵活决定。

HMR

其全称为Hot Module Replacement,模块热替换,也就是可以在Webpack项目运行时替换、新增或删除模块,且不需要重新刷新页面,开启HMR需要把devServer.hot改为true,并且添加HotModuleReplacementPlugin插件。样式文件的HMR由style-loader内部实现,Vue文件的HMR由vue-loader内部实现,js文件需要修改源代码,当moudle.hot为true时执行module.hot.accept方法接收更新通知。

HMR借助的是本地服务和浏览器之间建立的长连接websocket,当本地文件发生变化,可以立马告知浏览器热更新代码,此后HMR会监听webpack的编译结束,每次监听到编译结束,就会通过ws向浏览器发送通知,浏览器就可以拿到每次打包后的新hash值,然后做检查更新逻辑。

tree-shaking

tree-shaking是一种删除冗余代码的操作,具体来说是将一个webpack项目想象成一棵树,每个依赖都是树上的一些节点,实际上你可能依赖了某个模块后但只使用了模块中的一部分功能,这时候就需要tree-shaking来把无用的部分摇掉了。此技术在Rollup中率先实现,Webpack自2.0版本起也采用了tree-shaking技术。

tree-shaking是基于ESM的,它会分析模块之间的导入导出,确定哪些导出值没有被使用,这些没有被使用的导出值就会被删除掉。最早这个方法是在rollup中实现的,后来webpack也加入了tree-shaking。

可以先了解一下什么是DCE,DCE叫做Dead Code Elimination,也就是消灭“死代码”,可以把tree-shaking理解为是DCE的一种实现。传统的DCE通常会把符合以下条件的代码认作死代码,并将其消除:

  • 没办法到达或执行的代码
  • 代码执行的结果没有被使用
  • 代码产生的影响只对死变量有效(只写不读的变量就是死变量)

对于tree-shaking来说,它消除无用代码的思路更偏重于以模块为单位,由于ESM的依赖关系是静态的,这就给依赖分析带来了便利。相比于CommonJS,它的require是可以动态引入模块的,只有执行代码时才能知道哪些代码没有被使用到,但ESM可以做到不执行就能优化掉无用代码。比如export的变量中同时有foo和bar,引入它们的代码中只使用到了foo,那么bar就会被tree-shaking删除。

整个tree-shaking的实现一共分为两步,第一步是标记所有模块中未使用的导出值,第二步是使用Terser删除这些没有用到的导出语句。

其中,第一步还分为以下三个子步骤(以下流程经过简化):

  • 弄清楚每个模块的导出值,将所有导出语句转换为依赖对象,记录到modules.dependcies中。等webpack编译完成后有专门的插件从Webpack入口开始读取ModuleGraph中存储的模块信息,并开始遍历module对象,把所有找到的依赖对象都记录到ModuleGraph
  • 标记导出列表中哪些值有被用到,哪些没有。从Webpack入口开始遍历之前记录在ModuleGraph的依赖对象,每个对象都具有一个exportInfo数组,通过访问此数组来确定当前依赖对象有没有被其他对象使用,将已经被使用的对象打上标记
  • 根据上一步的标记情况生成不同的打包代码,将使用过的对象和没被使用过的对象分别保存

现在,模块导出列表中没有被使用的值都不会在__webpack_exports__对象中定义,这就变成了Dead Code,此后使用Terser把这些代码删除,即完成tree-shaking操作。

webpack-bundle-analyzer

这是一个图形化分析Webpack打包产物的插件,可以很直观的看到Webpack各个产物的大小,辅助我们进行打包优化。如果使用Vue CLI构建项目,则自带此插件。

性能优化

为什么要优化

性能优化是一个永恒的话题。从HTTP的角度来看,请求的资源尽量少、请求速度尽量快是优化的目标之一。对于Webpack而言,对应的目标就是打包体积的优化。对于开发者而言,还有一个要关注的点就是打包时间。如果项目很小,构建速度并不是很慢,那么其实也不必太过关注性能的问题。但对于大部分企业级的前端项目来说,随着其架构的复杂和功能模块的增加,Webpack的构建时间将疯狂增长,这可能导致每次调试都要耗费一些时间,在快节奏的调试场景下,构建就成为了调试工作的瓶颈。

所以接下来就讨论这两个话题,构建速度提升和降低打包体积。

构建速度提升

谈到构建速度的优化,那必然要考虑构建的流程,要让构建速度得到提升,那就要细化到每个环节,考虑考虑在哪个环节是耗费时间的。

首先是需要获取依赖模块构建依赖关系图,那么搜索依赖项就是耗费时间的。然后需要根据配置的loader对每个依赖进行一次解析,并且其转换量通常不小,那么转换依赖项也是耗费时间的。在转换完成后,还需要把所有的代码打包到一个或多个文件里,为了减小白屏时间,Webpack还需要对代码进行一些压缩优化等操作。JavaScript压缩的时候计算量是非常大的,因为它需要把JS先转换成AST然后再去处理AST,最后把AST还原为JS,那么压缩代码操作也是耗费时间的。

此外,二次打包也是耗费时间的,这是对于打包之后再次改动而言。如果在打包后代码需要做小幅改动,那在没有特殊配置下所有文件都要重新打包,但其实此时项目中大部分打包产物都是可以二次利用的。

总的来说,构建速度提升的详细指标有以下四个:

  • 搜索依赖时间
  • loader解析时间
  • 代码压缩时间
  • 二次打包时间

接下来就逐个记录一下每个指标的优化方式。

优化搜索时间可以从配置入手。Webpack打包时,会从entry出发,以深度优先的方式递归解析依赖,当项目量比较大的时候,这样的解析链路可能会很长,耗费的时间将大幅增加。Webpack中有一个配置项叫做resolve.modules,它用于配置Webpack去哪里找第三方模块,其默认值为['node_modules'],也就是它会先从node_modules里面找第三方模块,如果没有找到,则去它的父级目录,父级没有的话就去父级的父级…以此类推。它可以接收多个值,且允许使用绝对路径和相对路径,这里可以使用绝对路径(添加__dirname,利用path.resolve)来优化其寻找策略,减少模块的搜索层级。

resolve.extensions是一个判别后缀的配置项,如果导入语句没有带后缀,那么Webpack会自动带上后缀去尝试询问它是否存在,查询的顺序是按照我们配置的resolve.extensions内容顺序查找,默认支持.js和.json,这里可以把常用的后缀写在前面(但不要写多,保证这个列表尽可能小,不然可能起到反向优化的效果),或者导入模块时默认都带后缀,从而降低尝试匹配的次数。

使用resolve.alias减少查找过程也是一种方式,alias意为别名,可以建立起一个标识符和原路径的映射关系,比如把@作为./src使用,当然也可以配置其他的路径,从而减少耗时的递归解析。

使用resolve.noParse可以让Webpack忽略对部分没采用模块化的文件的递归解析处理,比如jQuery,这样也可以节省一些构建时间。

总结:

  • 降低搜索依赖时间:
    • 配置resolve.modules以减少模块搜索层级
    • 合理配置resolve.extensions以减少后缀匹配次数
    • 配置resolve.alias以减少递归解析层次
    • 配置resolve.noParse以不去解析非模块化的文件

运行在Node.js之上的Webpack是单线程的,也就是loader只能逐个文件处理,当Webpack需要转换大量文件时,耗费时间就略长。首先想到的是缩小loader解析范围,可以为它配置includeexclude,前者代表将符合条件的模块进行解析,后者代表排除符合条件的模块,同时存在时则exclude的优先级更高。此外,可以使用thread-loader来开启多进程打包,它可以让所有的loader都在一个单独的worker池中运行,以前还有HappyPack可以多进程打包,但它已经不再维护,所以不推荐使用。

总结:

  • 降低loader解析时间:
    • 缩小loader解析范围,合理使用excludeinclude,将不需要解析的位置排除在外,解析需要解析的位置
    • 使用thread-loaderHappyPack进行多进程打包,但后者目前已弃用

Webpack在生产环境模式打包会默认添加uglify等混淆操作,早期的Webpack使用的是UglifyJsPlugin,但在4.x版本已经废弃,换成了terser-webpack-plugin来优化代码。terser是一个用于ES6+的JavaScript解析器和压缩器,而且可以启动多进程压缩,并发运行的默认数量为os.cpus().length - 1

总结:

  • 降低代码压缩时间:
    • optimization.minimizer中配置new TerserPlugin,且设置parallel为true。

对于二次打包,我们的目的是不要让没有改动过的文件再做改动,那么第一时间应该就能够想到缓存。我们可以开启对应loader或者plugin的缓存来提升二次构建的速度。比如把babel-loaderterser-webpack-plugin的缓存打开,可以有效降低二次打包的时间。还可以使用cache-loaderHardSourceWebpackPlugin来给模块提供一个中间缓存,不过这东西我还没用过,不知道实际效果怎么样。

此外,还可以使用预编译资源模块,听起来有点玄学,其实这玩意就是dll库,用于把一些长期不需要更新的第三方库抽离出来打包成dll文件,然后当需要导入这个模块时,这个模块就会从dll中获取。dll只需要被编译一次,就可以持续使用,它适用于一些基础库的版本,比如vue,react等,如果是一些需要经常更新的库则不适合使用。目前Webpack已经内置支持动态链接库,使用webpack.DllPlugin就额可以在第一次编译打包后生成一份不变的dll供其他模块引用。

总结:

  • 降低二次打包时间:
    • 打开loader和plugin的缓存,或者使用第三方缓存loader或plugin
    • 使用动态链接库作为预编译资源模块

降低打包体积

打包体积直接决定着项目的加载速度,因为即便代码级别的优化做得再好,代码体积过大,在HTTP传输时所需时间也就变长,就会影响用户体验。所以说,我们的打包体积总是越小越好的。既然要考虑打包文件体积,那就要细分一下打包文件到底都有什么。其实无非就是HTML、CSS、JavaScript以及图片文件。

如果你使用了webpack-bundle-analyzer,就可以在优化前看到到底是谁在占用打包体积,方便对症下药。

压缩HTML虽然看起来不起眼,但却也是一种很值得做的事情。这个操作Webpack已经帮我们做了,所以其实并不需要太多配置,不过可以简单了解其原理。压缩HTML主要是通过把空格、回车、制表等空白字符删除,以及把注释删除,从而达到压缩的效果。这些字符对于我们阅读和编写代码是很有帮助的,但对于浏览器解析代码并没有什么帮助。这个压缩的比率其实很小,但其实并不能这样考虑。对于大型网站而言,其流量可能会相当大,如果通过优化让原来每1M的请求减少一个字节,那一整年节省的流量可能也要按TB来计算,即便每GB流量只按一毛钱,那么每年省下的流量费也不是小数目。这个道理其实也同样可以应用于其他资源。

压缩CSS使用的是,其默认使用cssnano作为压缩引擎,只需要在optimization.minimizer里配置一下OptimizeCSSAssetsPlugin就可以了。同时还可以使用PurgeCSS完成对无用css的去除,需要配合purgecss-webpack-plugin使用。

压缩JavaScript也是Webpack帮我们做到了,它使用terser-webpack-plugin完成代码压缩和混淆,同时可以开启parallel以加快压缩。

关键之处在于压缩图片文件。有时候一些图片的大小会远比js和css文件大,且过大的图片文件加载也会比较慢。我们倒是可以手动做图片压缩(比如使用tiny jpg,我的博客就是这么干的),但在Webpack里这个操作是可以自动化完成的,可以在file-loader之后引入image-webpack-loader,并提供对应格式的压缩选项,就可以对图片进行压缩。如果你或你的公司拥有个人CDN地址,也可以将这些图片资源分发到CDN上,在访问图片时通过CDN加速访问,同时也不至于占用代码文件的HTTP请求,做到静态文件分离。可以在output的publicPath中配置URL地址。

总结:

  • 压缩HTML:默认Webpack完成
  • 压缩CSS:optimize-css-assets-webpack-plugin可以压缩CSS,purgecss-webpack-plugin可以去除没有用到的CSS
  • 压缩JavaScript:默认Webpack完成,使用terser-webpack-plugin
  • 优化图片:使用image-webpack-loader,或者使用CDN

v4和v5的一些区别

  • 代码压缩
    • v4的代码压缩需要手动安装terser-webpack-plugin,需要额外配置
    • v5自带此插件,mode设置为production时自动开启
  • 缓存配置
    • v4使用hard-source-webpack-plugin开启构建缓存
    • v5内部内置了cache缓存机制,通过配置即可开启缓存
  • loader优化
    • v4加载资源需要使用各种loader
      • raw-loader将文件导入为字符串
      • url-loader将文件作为 data URI 内联到 bundle 中
      • file-loader将文件发送到输出目录
    • v5将一些常用loader替换为了资源模块
      • asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
      • asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
      • asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
      • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。
  • 启动服务
    • v4使用wds启动服务
    • v5内置webpack serve启动

Vue CLI

Vue CLI是一个基于Vue.js进行快速开发的完整系统,致力于将 Vue 生态中的工具基础标准化。它确保了各种构建工具能够基于智能的默认配置即可平稳衔接,这样你可以专注在撰写应用上,而不必花好几天去纠结配置的问题。

摘自Vue CLI的官方文档

何为CLI

在聊Vue CLI之前,我们应当知道什么是CLI。CLI的全称为Command Line Interface,即命令行交互界面,通常我们会将它称之为脚手架。我们为什么要在工程项目中使用脚手架呢?其目的有二:

  • 快速搭建项目的基本结构
  • 为项目提供必要的规范和约定

脚手架工具相当于是一种开箱即用的整合包,通过人为的提前预设好一系列工具和配置,做到搭建新项目时提高配置效率,为老项目统一开发风格和规范。

通常来讲,脚手架工具在安装后能够为用户提供一个命令,或能够通过其他交互方式向用户请求信息,这一步的目的是向用户询问一些简单的配置问题,并按照用户的要求生成对应的工程文件,有点像是定制点餐那样的风格。比如使用vue create命令的时候就能做到询问用户安装哪些特性(如babel,ts,pwa,eslint等),然后就可以按照预设,将这些定制的配置生成为对应的项目文件。

那么,Vue CLI为我们做了哪些呢?它同样拥有一个交互式界面,能够为新项目自由选择配置,同时拥有一个直观的图形化界面用来管理Vue CLI项目。Vue CLI里包含一个基于webpack和wds的CLI服务,可以用于加载其他的CLI插件,以及一个预设优化的webpack配置,还有Vue CLI-service的npm命令,支持npm run build/dev等。Vue CLI还支持添加各种CLI插件,·可以把插件理解为一些提前配置好的Webpack预设。

从特色功能看前端工程化

如果仅仅是做配置整合和界面优化,那还不够。Vue CLI作为一个重要的前端工程化工具,自然有其特色之处。谈到前端工程化,我们通常会聊到什么?

前端工程体系是一种服务,以项目迭代过程中的前端开发为主要服务对象,涉及开发、构建、部署等多个环节。

前端工程化的主要目标是解放生产力和提高生产效率,通过制定一系列的规范,借助工具和框架解决前端开发以及前后端协作开发过程中的痛点和难点问题。

摘自《前端工程化体系设计与实践》

一个常规的前端DevOps过程包含基建、Git工作流和CI/CD,其中Vue CLI所负责的部分大多属于基建,为项目提供一个标准的脚手架。但Vue CLI所能提供的并不止基建。

在构建阶段,我们会考虑打包产物的ES兼容性问题。尽管现在绝大部分浏览器都已经支持ES6,但仍然有一小部分浏览器无法很好的对ES6代码做支持,一旦业务中需要接触到这部分盲区,那就需要考虑到兼顾旧浏览器的问题。Babel可以把ES6+的代码彻底转换成ES5代码,通过转译和垫片来保证产物中没有高阶特性,然而这通常意味着转义之后的产物体积会变得比较臃肿,且性能也会有所下降。为了支持旧浏览器而放弃对新浏览器的支持是得不偿失的,但Vue CLI提供了现代模式构建,也就是会同时打两个包,在生成的html中会同时引入这个兼容版本的js和全新版本的js。

  • 现代版的包会通过 <script type="module"> 在被支持的浏览器中加载;它们还会使用 <link rel="modulepreload"> 进行预加载。
  • 旧版的包会通过 <script nomodule> 加载,并会被支持 ES modules 的浏览器忽略。

Vue CLI支持零配置的快速原型开发,这使得你可以随时随地测试一个新想法或验证某些功能,而无需大动干戈的新建一个大项目,这在临时需要一个试验场测试一些特性时尤为有效。只需要安装全局扩展npm install -g @vue/cli-service-global就可以使用此功能。需要说明的是,零配置不是无配置,Vue CLI帮你省略掉了你并不需要过多关心的配置,可以将关注点更多的放在代码和业务上面,这个功能体现了一种关注点分离的思想,我们将问题做了合理的分解,配置的事情由Vue CLI去关注,业务的事情由程序员去关注,这样就实现了技术和业务的一种解耦。

一个优秀的软件项目应当是扩展性良好的,Vue CLI在扩展性方面也下足了功夫。Vue CLI是基于插件的架构,插件可以做到诸如以下此类功能:

  • 修改项目的Webpack配置,比如需要添加额外的loader和plugin
  • vue-cli-service添加额外的命令,比如添加单元测试命令等
  • 扩展依赖,修改package.json,当插件中需要额外的依赖时,在package中维护依赖是必要的
  • 在项目中创建一些新文件,或者修改一些原有的文件
  • 提示用户选择一个特定的选项(官方文档中有记载此功能,但我暂未发现在哪里可以应用)

Vue CLI插件的本质是一个npm包,可以把插件想象成一种细粒度的整合包,每个插件都可以帮助你修改一系列功能。也就是说,原本你需要npm install后再新建配置文件修改配置并验证生效等等一系列的操作,只需要安装一个插件就能完成,Vue CLI的插件做到了前端工程化中解放生产力的作用。

然而,我们不应该滥用Vue CLI插件。插件的目的是为了帮助你完成“一系列”的操作,但如果你的需求只需一步安装导入即可完成,那倒也不用费劲为此寻找一个插件或自己开发一个插件,依然继续使用npm安装就可以了。总的来说,插件是一个处理依赖安装和功能扩展中无法自动化配置的解决方案,它的出现解决了一些生产效率方面的问题,但同时也引入了一些潜在的不可维护风险。这里建议的最佳实践是仅对复杂且常用的功能使用插件安装,或封装公司团队的标准插件以复用。

Vue CLI支持预设(preset),里面包含了创建新项目所需预定义的选项和插件,其配置文件是一个JSON对象。在vue create中保存的预设将会被放置在home目录(linux是~,windows是C:/user/用户名)下的.vuerc文件里,你可以修改这个文件。预设可以将你团队的一些标准配置提前配好,在新建项目时自动使用这些配置,从而可以做到新建后直接上手开发。

原理浅析

我们把Vue CLI做一下拆分,探索一下它是如何设计的。整个Vue CLI分为多个独立的部分,可以把它们简单分为CLI,CLI服务和CLI插件。

CLI是一个纯正的脚手架工具,提供命令行的vue指令,诸如创建新项目,快速原型,GUI管理器等功能都包含在CLI中。CLI服务是一个Vue的开发环境,本质上是一系列依赖的集合,它构建于webpack和wds之上,提供的是vue-cli-service命令(要和上面的vue命令区分开),使用此命令可以运行一个Vue CLI项目。CLI插件在上文中说了很多,它是一个npm包,但对名称有限定。

  • CLI

CLI是如何做到提供一个新的指令的?关键在于下面两个配置:

1
2
3
"bin": {
"vue": "bin/vue.js"
}
1
#! /usr/bin/env node

第一个是CLI的package.json文件,也就是指定某命令的入口文件,这里指定vue指令的入口文件为bin/vue.js,之后需要在这个文件的第一行写入第二个配置中的信息。这玩意是干嘛的?

开头的#!叫做Shebang,用于指明这个文件的解释程序,Node CLI应用必须使用这样的文件头,相当于指明这个文件要使用Node.js的服务运行。之后需要执行npm link将命令挂载到全局,接下来就可以正常使用此命令了。

CLI在启动时会首先判断用户的Node版本,如果版本与要求的不一致,那么将会退出程序,提示用户下载对应版本的Node。CLI使用chalk插件来向命令行显示彩色文字。

现在我们拥有自定义指令了,接下来还需要一个能够解析其多种命令的工具,这部分功能交由commander完成。commander是用来解析用户输入指令的工具,通过它的处理,可以获得用户输入的指令名称和后面跟带的参数配置等信息。在commander正常接收命令后便执行对应命令,比如执行create,此时需要启动一个交互式命令,这里采用的是inquirer来收集用户输入,然后根据用户的输入来处理流程,最后调用创建新项目的方法(本质上是文件的复制粘贴或者git clone)即可。

整个CLI的简单流程大概如上面所述,但其实内部细节还有很多,感兴趣可以自己去读读CLI的源码。

  • CLI服务

CLI提供的vue-cli-service指令在package文件中也有定义:

1
2
3
"bin": {
"vue-cli-service": "bin/vue-cli-service.js"
}

且仍然存在Node CLI文件头。

1
#! /usr/bin/env node

第一步和CLI相同,也是判断Node的版本。之后它新实例化了一个Service对象,使用process.argv.slice(2)来接收,并使用minimist插件来格式化输入参数,将格式化的命令和参数输入到service.run函数中执行。此函数根据接收到的参数处理不同的命令,解析并执行不同的操作。

CLI服务包含有build,serve等操作的具体实现。serve操作将按照CLI配置启动wds,build相对复杂一些,但整体思路也是提供一系列配置然后启动webpack打包。这部分细节太复杂了,有机会的话可以再写一篇文章具体讲讲怎么做的。

  • CLI插件

CLI插件包含一个主要功能文件,并且能够可选的加入generator,prompt或Vue UI集成。并且由于CLI插件是一个npm包,所以还需要遵循一下npm包的规范,比如需要包含readme文件和package文件。

CLI插件的名字是固定的,它只能是 vue-cli-plugin-<name> 或者 @scope/vue-cli-plugin-<name> 这样的名字,否则它将无法被add方法安装,也不能在Vue UI中搜索到。

插件的主要功能文件(官方叫做service)可以用来修改webpack配置,创建新的vue-cli-service命令或者修改已有命令的行为,也可以为命令指定在哪个模式下运行。service会在Service实例被创建后自动加载。

prompt代表对话,在创建一个新项目或在已有项目中添加新插件时用于处理用户选项。对话逻辑放置于一个单独的js文件,对话实现使用inquirer。

使用命令行安装本地插件时,需要首先npm install 插件 ,然后调用vue invoke 插件名来加载此插件,每次插件被修改后都要重复这两个步骤,但借助Vue UI就可以用图形化的方式管理插件。如果需要让插件实现UI集成,那还需要额外配置一下。UI集成功能也需要放置在一个单独的js文件中,借助UI可以实现执行npm任务,展示自定义配置,展示对话,开启i18n,且可以让Vue UI的插件搜索搜到你的插件。此部分的配置官方文档比较详细,这里就不展开多说了。

自定义CLI

我们可否以Vue CLI的思路为基础,自己造一个CLI?

当然可以,而且也不算重复造轮子。不同团队对项目的要求不一样,采用自研脚手架促进前端工程化体系建设是件很有意义的事。

自研CLI可以不仅局限于Vue,它就是一个封装好的工具集,可以做任何你想做的事情。封装业务组件,单元测试,发布系统,utils实用工具等等均可以包含在内。

整个CLI的流程和Vue CLI是类似的,需要支持自定义命令,自定义命令选项,支持交互式询问,美化CLI界面,能够提供一系列配置预设等。这里我就把简单的思路说一下。

第一大环节,初始化自定义指令

  • 新建一个空文件夹作为工作区域,并使用npm init初始化项目。
  • 新建一个index.js作为入口文件,在最上方添加Node CLI文件头(上文有讲)
  • 在package.json里配置bin选项,将自定义命令名称和index.js做个关联
  • 使用npm link将这个项目和本地npm模块建立连接,测试自定义指令能否正常运行此js(可以console.log一个helloworld试试)

第二大环节,使用commander处理用户指令

  • npm安装commander
  • 按照官网示例配置在index中引入commander,并增加自定义指令处理代码,其中包括:
    • 指定命令名
    • 命令的描述
    • 处理命令行参数
    • 执行命令时调用的功能函数

第三大环节,实现功能函数中的交互式询问

(注意,并不是所有命令都需要交互式,只有诸如创建新项目这种需要用户选择选项的才需要,如果是需要开启本地服务器等就直接执行其他对应的业务代码就好了)

  • npm安装inquirer
  • 根据inquirer官网的示例配置修改成你想要的内容,包括:
    • 交互界面需要询问用户的问题
    • 每个问题的备选答案
    • 接收到答案后的处理程序
    • 出现回答异常的捕获

第四大环节,美化CLI界面

你可以使用chalk等工具让CLI的输入变得丰富多彩,也可以通过打印字符画等操作凸显品牌。就维护性考虑,你可以在CLI工具里维护一个美化输出的utils库,把chalk封装一下。

  • npm安装chalk
  • 在console.log里调用chalk的加颜色方法

如果你的业务操作需要诸如git clone或者下载代码等操作需要等待,那么你可以试试引入ora这个loading动效插件。

  • npm安装ora
  • 用ora新建一个spinner对象
  • 在异步操作的开始让spinner启动
  • 异步操作完成或失败时停止spinner

第五大环节,提供配置预设

配置预设其实包含很多方面,代码模板、配置文件等都算,步骤大概如下:

  • 新建一个template文件夹用于存放默认模板和配置
  • 当执行初始化操作时,将这个文件夹的文件复制到用户指定的新建项目的目录下,可以使用Metalsmith这个插件方便复制

你可能不希望把template和CLI耦合在一起,你的模板也可能是放在git库中,那么需要一个clone项目到本地的工具。

  • npm安装download-git-repo
  • 需要下载时,调用下载方法
  • 处理下载完成和下载失败的逻辑

也许静态的模板无法满足你的要求,你希望模板配置能够随着用户的输入变化而变化,那这里可以采用ejs进行一个模板动态渲染。

  • npm安装ejs
  • 在复制之前加入额外的处理程序,获取配置并渲染ejs
  • 复制新渲染之后的内容

在此之外,你可能需要在Node.js中执行shell脚本,这里使用的是child_process 的spawn,实现从主进程的输出流连通到子进程的输出流,这个不需要安装,直接引入使用就可以。

最后,如果你想把你的CLI文件发布到npm或者私有npm,那么按照npm的发布操作即可将你的包正常发布。如果别人要使用,只需要npm install 名称 -g就可以把你的CLI安装在别人的电脑上,之后就可以正常使用了。

结语

这篇文章断断续续的写了快一个月,中间因为在忙春招和学校的其他事,进度没有赶的特别快。文章很多内容其实展开的并没有很详细,更多像是一个笔记的形式吧,把一些提纲挈领记录下来,而不是长篇大论的去写到底要怎么做。详细去做的教程网上有很多,每个人的习惯也不尽相同,个人感觉实战还是要靠实际去敲和实际去钻研,而教程只能提供众多可行性中的一种范本,初学者完全可以跟着教程一步步走,但我更建议有机会的话要多去合理作死,合理瞎折腾,通过一点点制造错误和修改错误去学习技巧要比看教程按部就班的来效果好得多。

就写到这吧,准备面试去了。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2018-2023 Shawn Zhou
  • Hexo 框架强力驱动 | 主题 - Ayer
  • 访问人数: | 浏览次数:

感谢打赏~

支付宝
微信