注:本文使用的vite版本为2.9.14
模块化一直驱动着前端的发展,而前端的发展,也产生了一系列的模块化标准和规范,改革了以模块化作为底层的构建工具,从而加速了前端的发展。
一、模块化的刀耕火种到ESM
在很久很久以前,久到Jquery都算是一个新型框架的年代 ,那个时候受其他语言的影响,同时也为了满足自身的需求,前端也萌生了模块化思想,但受制于javascript能力的弱势,没有统一的标准,只能自己去实现。
总体来说,共三个手段:
文件划分
命名空间
私有作用域
文件划分:
在那个没有太多打包、构建工具的年代,往同一个html引入不同的script标签都是一种中等操作,因此,开发者就产生了一种想法:如果把js文件看做是模块,那么不就可以实现类模块化了吗?
// module1
function test1 () {
console.log("i am module1");
},
// module2
function test2 () {
console.log("i am module2");
},
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./module1.js"></script>
<script src="./module2.js"></script>
<script>
window.test1()
</script>
</body>
</html>
但这样有2个致命的问题
变量声明都是全局定义,模块化仿佛是在骗自己,文件之间的变量很容易冲突
无法管理加载顺序,以及依赖加载的问题
命名空间
命名空间跟上面文件划分类似的解决办法,不过,将它们统一包裹在不同的对象之中,这样为了解决变量声明是全局定义的问题。
这样一来,每个模块都会挂载到对应的命名空间,可以在一定程度上解决命名冲突的问题。
但依然存在问题:
内部变量都是共有的,外接很容易访问到
同文件划分一样,无法管理加载顺序。
可能存在重名问题
立即执行函数
立即执行函数由于作用域的关系,有了自己的私有变量,于是较为彻底地实现了变量的问题,同时,产生的闭包也可能让开发者自行定义模块名称
但是,依然存在一个无法回避的问题,那就是无法解决模块只见的加载顺序,以及模块依赖的问题,也就是模块加载的问题。
随着前端的发展,工程越来越大,模块依赖已经是很常见的事情,模块加载的需求日益迫切,非标准化模块规范并不能得到满足,因此也催生了各种各样的模块化规范。
例如:
CommonJs规范
AMD规范
CMD规范
UMD规范
CommonJS
CommonJs是最早提出的模块化规范,一开始主要用于服务端,后来Node.js采用了这个规范。
CommonJS 规范是一个文件就是一个模块,加载模块使用require方法,改方法读取文件并执行,最后返回文件内部的exports对象。
由于CommonJS是服务器规范,并且模块加载器是Node.js提供,所以CommonJS模块直接放在浏览器是无法执行,不过社区也有阉割版的打包工具。
CommonJS 加载模块是同步的,只有加载完成之后,才能执行之后的操作,只在服务器本地是没有太大问题的,但放到浏览器,就受制于网络影响了,JS是单线程,模块请求会阻塞JS的解析。导致页面卡顿。
所以,CommonJS不太适合浏览器运行,那么就需要一个浏览器端的模块规范了。
AMD
AMD是RequireJs推广过程中,对模块定义的规范化,全称Asynchronous Module Definition,翻译过来是异步模块定义规范,在这个规范中浏览器中的模块会被异步加载,也就不会产生页面阻塞的情况了。
在AMD规范中,可以使用define来定义、加载一个模块。
如果第一个参数是函数,那么就是无依赖定义模块 ,也就是此函数return出去的成员,就是需要导出的成员。
如果第一个参数是数组,那么就是有依赖定义模块,先加载对应依赖,然后根据依赖在第二个参数执行相应逻辑,同时可以return形式,导出本模块需要导出的成员。
require也可以来加载模块,但不能定义模块。
其实可以从以上的代码可以看出来,如果一个模块依赖另一个模块,那么就需要在一开始把对应的依赖写好,同时提起加载所有可能用到的模块(RequireJs2.0也可以延迟加载)。也就是说AMD推崇依赖前置。
CMD
cmd是SeaJS推广过程中,对模块定义的规范化,全称Common Module Definition ,翻译过来就是通用模块定义规范。在这个规范中,模块与模块的的依赖也可以就近执行,而不用提前定义。
在CMD规范中,我们需要使用define来定义模块、加载一个模块。
第一个参数一直都是一个函数,这个函数暴露三个方法,分别是require,exports,module。
require是一个方法,接受模块标识作为唯一参数,用来获取其他模块提给的接口。
exports是一个对象,用来提供向外的模块接口,同时,亦可以如同AMD一样,直接return出需要导出的成员。
module是一个对象,上面存储了当前模块相关联的属性与方法,例如exports的引用,也可以通过module.exports来获取。
可以看出,CMD的API 严格区分,并且模块并不需要提前写好、加载,也就是CMD推崇依赖就近。
UMD
AMD是浏览器规范,异步加载模块,CommonJS是服务器规范,同步加载模块,并且模块无需包装。
那么两个规范相互柔和,就成为了UMD,全称Universal Module Definition,翻译过来也是通用模块定义规范。
在这个规范中,会先判断是否支持Node.js模块,如果支持那么启用Node.js模块模式,再判断是否支持AMD,存在就使用AMD的方式加载模块。
从上述代码中可以看出,UMD并非一个新规范,而是兼容了ADM和CommonJS,同时运行浏览器和Node.js环境的规范,也就是说,UMD具有了跨平台能力。
同时也意味着,前端模块进化出了跨平台能力。
ES6 Module
ES6 Module也被叫为ES Module或者ESM。
经过这么长时间的迭代,ECMAScript官方也提出了模块化规范,在提出后,很快得到了浏览器的支持。
ESM具有以下特性:
浏览器可以直接加载ESM,也是使用script标签,但需要加入type="module"属性
自动采用严格模式
输出的是值的引用
编译时输出接口
可以import()异步加载
Node.js 12.20版本支持ESM,因此有跨平台能力
ESM主要两个命令构成:export, import,export用于规定模块对外接口,import用于引入其他模块。
由于原生支持,因此使用起来比之前的规范方便、简单得多。
二、vite
之所以讲述前置知识,是因为接下来的认识VIte的过程需要用到以上知识。
vite与webpack
如何评价一款构建工具的优劣,主要从四个方面来进行判断:
模块化方案:指提供模块加载方案,以及对不同模块的规范做兼容
语法转译:sass、typesript的转移,以及对图片、字体、worker的加载、集成
产物质量:指打包好的文件的压缩、混淆、tree shaking、以及语法降级
开发效率: HMR,构建速度等
vue-cli是基于webpack的构建、开发脚手架。
webpack具有强大的社区生态,各种开发业务需求大部分能够覆盖到。但缺点也很明显:冷启动和热更新的时间会随着项目的扩增而增加。
导致这个原因是因为:
项目冷启动需要递归打包整个依赖树
构建使用JavaScript构建,JavaScript本身需要转义为字节码,然后才转化成机器码
这样改动后无法立即看到效果,开发体验就会越来越差。
Vite则是基于Esbuild和Rollup的构建、开发框架工具。它具有以下特点。
在开发阶段,Vite基于浏览器原生ESM实现了no-bundle,同时借助Esbuild实现了第三方库的构建和ts/jsx的语法编译,同时Esbuild基于Go语言实现,直接转化成机器码,速度提升飞快。
模块化方面,由于基于浏览器原生ESM,因此可以使用ESM实现模块加载,通也可以将开发环境、生产环境中的模块产物转换为ESM
语法转译,内置对typescript、JSX、sass等高级语法支持,也能加载图片、字体、worker等
产物质量方面,基于成熟的Rollup实现生产环境打包,同时也能配合Terser、Babel等工具链,也能保证产物质量。
可以说Vite属于人有我优的前端框架,Vite不是第一个no-bundle构建工具,在之前还有Snowpack,但Vite的出现补齐了Dev Server能力,比如HMR。
Snowpack自身有一套插件机制,但并不兼容任何打包工具,如需打包,需要调用其他打包API,自身没有打包能力。
但Vite生态方面根植Rollup,意味着Vite从诞生起就有一个比较高的起点,产物质量和稳定性都相对成熟,更有利于工具的推广和发展。
vite的双引擎是如何工作的
关于Vite的的双引擎,大部分人都能说出大概:开发阶段用Esbuild,生产环境用Rollup,但是真的这么简单吗?通过查阅不少资料,发现并没有想的那么简单。
下面是一张画的比较完善的Vite的架构图。
预构建Esbuild
可以从架构图上注意到,Vite在开发环境,Esbuild主要参与的是有一个预构建的阶段。那么什么是预构建阶段呢?
前文不断地强调:Vite是一个no-bundle构建工具,并且能按需编译,但实际上:Vite并非一个纯粹的no-bundle构建工具。
我们的代码分为两部分,项目中的源代码,也就是业务代码,另一部分是第三方依赖,也就是node_modules那一些第三方包。
针对这些第三方包,Vite依然会进行打包,所谓的no-bundle是对于业务代码来说的。
给第三方包转化为ESM,并合并到一起的过程,称为预构建过程。
这一过程在Vite1.X的时候,是由Rollup来进行预构建,在2.X更换为Esbuild,编译速度提升100倍。
如果手头有一个Vite项目,那么当它在开发环境启动成功的时候,我们可以通过node_modules/.vite看到一堆打包产物,这就是Esbuild的预构建产物。
如果我们使用浏览器进入项目网页,我们可以从Network看到第三方依赖的请求结果,使用了强缓存,并且过期时间为一年。
当然,Vite并非一直使用缓存文件,如果下面三处进行改动,那么缓存将失效
poackage.json的dependencies字段
包管理器的lock文件
vite.config.js的optimizeDeps配置
之前提到过,Esbuild是go语言开发,但它性能极高还有其它因素:
同时多核并行,内部步骤尽可能并行,也得益于go在多线程共享内存的优势
几乎没有使用任何第三方库,一切手撸
高效内存利用,众所周知wabpack的AST和babel的AST是不共用的,但Esbuild从头到尾尽可能复制一份AST,而不用传统打包软件那样频繁转换AST造成打量内存浪费。
下图为Esbuild官网上,它与传统打包工具的对比
看到这里,可能觉得Esbuild应该是一个超级打包工具,秒天秒地的那种,但实际上,它自身也有很多缺点:
不支持降级到ES5,意味着低端浏览器基本告别Esbuild
不支持操作打包产物的接口,比如Rollup的renderChunk
不支持自定义的代码分割,降低拆包的灵活性
这么看下来,Esbuild具有很大的局限性,作为独立打包工具的话,很难跟Rollup以及webpack进行抗衡.
但是,Esbuild实在是太快了!
单文件编译Esbuild
前文中提到,ESbuild参与了预构建环境,但也提到Esbuild也实现了ts/jsx的编译。同时还提到了,Esbuild高效地利用AST,那么,把它跟babel、typescript以及swc对比,会如何呢?
以下结果对比来源:
https://datastation.multiprocess.io/blog/2021-11-13-benchmarking-esbuild-swc-typescript-babel.html
上图为50MB文件平均编译速度。是的,依然非常快
当然,缺点还是有的:Esbuild没有实现TS的类型检查,仅仅在编译的时候抹除类型相关代码。
这个方法可以通过编辑器插件,在开发阶段进行类型检查,以及借用tsc来实现类型检查功能
代码压缩Esbuild
回顾下架构图,我们已经讲了两处Esbuild参与过程,还剩下一处,那就是架构图右上角的生产环境中,Esbuild也参与其中。
Esbuild通过插件的形式,参与到Rollup的打包流程,根据上面两小节的阅读,我们已经能大概猜到是什么原因了,那就是它的压缩效率是太高了。
传统方式是使用Terser这种基于javascript的压缩器来实现,比如在webpack或者Rollip作为一个插件来完成压缩混淆工作
既然是基于javascript,那么它的速度就没有go的速度快
Esbuild的AST共享是Terser望尘莫及的
以下结果来源:
https://github.com/privatenumber/minification-benchmarks
虽然Esbuild时间不是每次都是第一,但在这个数量级上还能兼顾压缩质量,那么可以把它称为性能利器,因此Vite将其作为默认的压缩方案。
同时需要注意的是,在生产环境使用Esbuild并不会让代码无法降级到ES5,因为生产环境使用了babel进行语法降级。
构建与Vite插件
之前都是讲Esbuild的,但另一个引擎Rollup却很少提到,但这并非不重要,它反而贯彻了Vite始终。
虽然Vite开发环境实现了no-bundle,但生产环境该打包还是得打包,否则会有网络问题(巨量的请求带来巨量的网络开销),那么既然用到Rollup,那么Vite也基于Rollup做了扩展和优化。
CSS打码分割,如果某个异步模块用了一些CSS,Vite会自动抽取成为单独文件,提高缓存复用率。
自动预加载,Vite为入口chunk的依赖生成预加载标签<link rel="moduelpreload">
异步chunk加载优化,如果两个chunk有相同的依赖A,那么并不会按正常加载逻辑加载chunk的过程中依赖A,而是在请求第一个chunk的时候就自动预加载那个A。
同时,在之前讲过,Vite根植于Rollup的插件机制生态,简单点来说就是:
不论开发环境还是生产环境,Rollup的插件,不少是可以直接用到Vite里面的,而Vite的插件写法,是完全兼容Rollup的。
Vite实现了Rollup的的调度逻辑,生产环境中奖所有Vite插件传入Rollup也是没有问题的。
兼容插件可以从一下网址寻找
https://vite-rollup-plugins.patak.dev/
小结
在Vite中,双引擎各司其职,然后VIte本身参与其中并且调度,这个框架才会顺利运行起来。
Esbuild负责将node_modules包编译为ESM,同时业务文件的ts/jsx也由Esbuild编译,同时它还作为打包时候的代码压缩默认方案参与其中。
Rollup负责将node_modules和业务文件进行打包
Vite负责将业务代码转成ESM,同时做好调用Esbuild编译完的第三方包的准备,以及使用兼容Rollup的插件进行代码处理,也就是说,Vite本身以及Vite插件是接触不到node_modules真正第三方包的,而是接触的是node_modules/.vite已经被转译的第三方包的ESM,换句话说,Vite以及vite插件的作用域是node_modules/.vite和业务代码。
但是vite.config.js也是可以配置Esbuild插件的,此插件属于Esbuild范畴。
三、迁移
之前的理论知识已经积累的差不多了,那么接下来就应该是迁移了。
index.html处理
首先是index.html的改装,将index.html从public放到根目录下,同时在body中增加
<script type="module" src="/src/main.js"></script>
这个地方设置为项目的入口文件,同样是也可以是ts文件。
然后,我们类似<%= VUE_APP_TITLE %>的模板变量,统统改成硬解码值,也就是具体的值。
虽然我们可以通过vite-plugin-html这个插件实现相同的效果,但是在迁移初期,尽可能保持少的插件安装,然后根据情况一步步增加插件。
Vue文件引入处理
将所有引入vue文件的地方,增加.vue扩展名,这个是设计如此,即使在extensions中进行配置['.vue'],也会报错,必须显式引入。
安装Vite
首先我们将@vue/cli-开头的插件全部卸载,如果项目中使用了ts,包含了@vue/cli-plugin-typescript,也一并卸载,卸载后编辑器会有报错,这个我们之后解决
sass-loader也可以删除,vite内置了css预处理器
安装vite-plugin-vue2与vite,因为vite需要认识vue文件,所以需要安装vite-plugin-vue2,让vite认识vue2文件,如果是vue3的话,插件需要更换为@vitejs/plugin-vue
删除vue-template-compiler,因为vite-plugin-vue2自带对应功能
删除babel-eslint和core-js,因为我们开发仅支持现代浏览器,因此这俩个包就需要删除,毕竟没有用了,后续旧浏览器的兼容我们之后解决
既然我们删除了babel-eslint,如果我们在.eslintrc用到了babel-eslint,我们也需要移除对应字段
然后我们在.eslintrc中,把node:true替换为es2021:true
如果可以的话,更新我们的eslint到最新版
在pacckage.json增加以下三个命令,并移除之前的vue-cli的命令
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
如果使用了lint,可自行编写eslint的命令比如
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src"
替换环境变量
vue-cli的环境变量文件与vite环境变量文件名称相同,可以不作调整。
但环境变量规范却不同。
在vue-cli中,用户自定义的环境变量以VUE_APP_开头,同时暴露了BASE_URL与NODE_ENV,通过webpack.DefinePlugin嵌入到业务代码中。
在Vite中,用户自定义的环境变量以VITE_开头,但没有暴露BASE_URL字段,虽然在业务代码依然可以获取BASE_URL的值,但此值是通过配置文件base字段获取,而非通过环境变量文件自定义的。如果配置了NODE_ENV字段,则会被包装为VITE_USER_NODE_ENV字段。
Vite获取环境变量的方式也与vue-cli不同,在vue-cli中,获取环境变量直接可以通过process.env获取的,但VIte不同。
Vite在读取完配置文件之前,是默认不加载环境变量文件的,这是因为配置文件中的配置项会影响加载行为,比如envDir以及root,于是Vite提供了两种获取环境变量的方式。
在浏览器环境,也就是业务代码部分(这时早就加载完环境变量文件),使用import.meta.env获取环境变量对象,这里需要注意的一点的是,Vite全局匹配了import.meta.env这个字符串,然后在对应文件顶部,定义这个对象,所以能获取环境变量对应的值。
import.meta.env并不是一个实时存在的内部对象,同时Vite也仅仅匹配了’import.meta.env‘,也就是说,import.meta['env']这种形式是没有匹配到,Vite是不在文件头部定义这个对象,因此是获取不到环境变量的。
同时也可以注意到,import.meta.env不光暴露了我们自己定义环境变量,也附带了比如MODE,DEV等环境变量
在Node.js环境中,最常见情况的是在Vite的配置文件中,由于之前提到过影响加载行文的问题,这里面是无法使用import.meta.env的,所以VIte暴露了一个API来获取对应的环境变量:loadEnv,这个我们将在下一节讲到。
需要提醒的一点是,环境变量文件.env,.env.local,.env.[mode],.env.[mode].local都会被加载,而非只匹配一个,环境变量的优先级跟vue-cli相同,按照从左到右优先级依次增大。
增加Vite配置
识别vue文件
如同vue.config.js对vue-cli一样,Vite也需要对应的配置文件:vite.config.js,以下是一个普通的vue2的Vite配置文件
如果用的是vue3,把vite-plugin-vue2替换为官方的@vitejs/plugin-vue即可。
base与环境变量
在上一节,我们提到了获取环境变量的API:loadEnv,那么如何使用这个API呢?以下是官方文档提给的例子
在这里,defineConfig接受的参数从之前的对象变为函数,然后通过这个函数的参数,我们可以获取启动vite的时候的两个信息 mode 和 command。
mode在 执行npm脚本 ”vite“的时候,默认为development,执行"vite build" 与 "vite preview"的时候默认为production,如果在命令后面增加--mode XXX参数,就会变为XXX,同时环境变量文件也变为.env.XXX文件,在业务代码访问import.meta.env.MODE的时候,也显示为XXX,这一点与vue-cli类似。
command只有两个值,build与serve,在执行"vite build"的时候,会传入build,执行"vite"与"vite preview"的时候为serve
loadEnv接受三个参数,mode,envDir,以及可选参数prefixes。
mode为当前环境,也可以认为是env文件的mode,意义与defineConfig的参数mode相同
envDir为项目工程中,env文件的存放处
prefixes可选,标识env环境中,环境变量的前缀,默认是VITE,env文件中有vue-cli的变量,使用VUE_APP也是可以匹配出来东西的
参数介绍完之后,大家会有什么想法呢?是不是看着这些参数感觉不像是一个经过vite高度封装的API,而是根据信息解析文件内容的小插件。
事实上的确如此的,分析下loadEnv的源码,大体也是这么写的,这个函数通过envDir寻找env文件地址,通过mode读取所需要的env文件合集,通过prefixes匹配文件中的字符串,然后按照优先级进行合并,最后返回来的就是我们想要的环境变量。
我们通过环境变量配置了base字段,这个字段类似vue-cli中的publicPath,可以用来定义项目的base和CDN。
alias
别名alias不需要通过webpack引入,而是通过resolve.alias引入,如下图
改造项目中,由于使用了私有vue-cli插件,因此产生了多余引用引用,但到vite里面,由于不存在对应包,所以会在编译阶段报错,因此我们可以在alias中进行处理、屏蔽。
虽然alias在Vite中配置无误,但编辑器与eslint并不认可,因此我们需要在在对应地方补充别名
jsconfig.json/tsconfig.js中的paths字段,使用相对路径
.eslintrc中的settings['import/resolver'],在这里面我们需要使用绝对路径,因为路径起点并非入口文件,而是每一个跑lint的文件,如果使用相对路径,那么很大一部分代码会报找不到路径的lint警告。正确配置如下图
同时也可以注意到,上面使用了node.extensions,这个是用来解决,引用ts文件被lint要求写后缀名,结果加上后缀名ts又提示需要去掉后缀名这个矛盾问题的。若出现此问题,可依照此法解决,若没有,可忽略此配置。
define
同样是使用了私有插件的原因,暴露了__DEV__、ASSETS_URL_DEFINE等在浏览器环境中使用的环境变量,在vue-cli中,是通过使用webpack.DefinePlugin进行注入实现了,显然,vite需要使用其他方法,官方提供了denfine字段,来实现类似的功能。
这里需要注意的一点是,与define虽然与webpack.DefinePlugin实现了类似的功能,但具有本质的不同:那就是define接受一个JSON对象,而非webpack.DefinePlugin的可执行js代码,在原理上webpack.DefinePlugin是将对应目标变量替换为js语句,然后进行编译操作,而Vite的define的替换不会进行任何语法分析,在构建时被静态替换。
在处理私有部署相关的环境变量getAssetsDefine的时候,因为使用的webpack的规范,将代码转为JSON字符串,因此我们需要在Vite使用之前,使用JSON.parse将其转成JSON对象。
注意:
目前很多包不支持ESM,因此编译之后的包依然残留process.env,所以我们可以通过这个功能来静态注入Node.js环境下的process.env
目前很多包也使用了global,但是经过实践,却发现无法同process.env一样使用define注入,因为global并非一个JSON对象,因此Vite无法格式化global,浏览器发生报错。好在,浏览器是有globalThis,所以解决方法是在index.html文件,使用script标签将globalThis指向global
<script>
window.global = window.globalThis
</script>
inject
在私有插件中,我们可以通过使用webpack.ProvidePlugin,来让使用了自定义变量的文件,自动在头部引入一些文件。
但Vite并没有提供对应功能,因此需要使用插件来达到此目的。
我们在之前提到过,Vite根植于Rollup生态,因此是可以使用部分Rollup插件的,而Rollup刚好存在与ProvidePlugin类似的插件:@rollup/plugin-inject
而使用方法同样与webpack.ProvidePlugin类似,如下代码:
如果检测到R_URL,那么就会在顶部动加入以下代码
import { default as R_URL } from 'XXXX/constants/url'
这里需要注意的是,地址是支持alias的。
同时,/constants/url/index.js文件也需要使用Vite的API,之前使用了require.context这个API来实现批量加载文件,但在Vite,批量加载的API是import.meta.globEager,他的底层加载逻辑是基于fast-glob实现的,因此可以参考fast-glob的文档来了解匹配规则。
https://github.com/mrmlnc/fast-glob#pattern-syntax
以下为更改后的获取API代码,同之前的逻辑类似,只不过更换成了Vite的批量引入API。
如果代码中require.context这个API较多的话,可以考虑使用@originjs/vite-plugin-require-context这个vite插件,这个插件会检查你的业务代码中的require-context,将其替换为依次引入。
需要注意的是@originjs/vite-plugin-require-context不会处理node_modules中的代码,因此无法处理第三方包使用了require-context的这种情况。
如果使用了define和inject,请记得一定要在.eslintrc以及.d.ts文件(如果使用了ts的话)注册对应的变量,否则会在编辑器以及命令行弹出警告。
css
在vue-cli中,如果想更改css loader相关的配置,需要在css.loaderOptions.css或者css.loaderOptions.postcss等中进行配置,里面的参数,比如additionalData等,会传给对应的loader进行相应的处理。
在Vite中,也有对应的配置项,但结构上有所不同,Vite将postcss的配置与css预处理器的配置进行了区分:
用来传递给css的预处理器。Vite使用css.preprocessorOptions.less或者css.preprocessorOptions.scss进行配置,其中scss、less为文件扩展名。
用来传递给postcss的配置。Vite使用css.modules和css.postcss,其中css.postcss为postcss的配置项,而css.modules为postcss-modules的配置项。
在项目中,私有插件通过修改webpack的sass-resources-loader,定义了common-resources.scss为公共变量scss文件,Vite显然没有sass-resources-loader,但这不妨碍我们使用Vite的api将其定义为公共变量scss文件。
我们使用css.preprocessorOptions.scss来处理公共变量文件,同时还处理了私有部署相关的scss变量,使用additionalData可以让一段sass/scss代码出现在实际入口文件之前。
这里需要注意的是,在vue.config.js中,我们配置的是css.loaderOptions.sass,而preprocessorOptions使用的是文件扩展名作为键值,因此是css.preprocessorOptions.scss
需要使用postcss插件的话,只需要在postcss.plugins使用即可。虽然在vue.config.js中,也可以使用css.loaderOptions.postcss.plugins挂载私有部署相关插件,但由于此处配置的插件,会在resolve-url-loader之前执行,导致resolve-url-loader无法获取到对应链接而报错。所以实际操作中,需要通过修改webpack中的loader执行顺序,然后才能挂载插件。
但在Vite中,并没有resolve-url-loader的相关逻辑,因此可以直接使用postcss插件。
server与preview
本地开发服务器配置字段server与vue-cli中大体相似,因此可以直接复用,不过依然还是有些许不同。
server.force字段,如果为true,那么每次使用本地开发服务器都会强制预构建,而不使用缓存,一般情况下我们不会使用这个字段,但依然有少数场景,比如在调试某些包的预构建结果的时候,需要屏蔽一段时间缓存,那么就可以将server.force置为true,即可在使用本地开发服务器的时候,预构建一直是最新版本。
包括将server.force置为true,一共有三种方法强制启用预构建:
删除node_modules/.vite目录
将server.force置为true
命令行执行npx vite --force或者npx vite optimize
这里需要注意的是,Vite启动分为两部,第一步是预构建,第二步是启动Dev Server,因此npx vite optimize与npx vite --force不同之处在于npx vite optimize仅仅是进行了预构建,后者启动了Vite
除了server,Vite还有一个类似的配置项,preview,可以将build的产物在本地开启一个服务器,进行预览,因此称为预览服务器配置。只不过区别在于,server的资源来源是项目工程,而preview的资源来自于build后的产物。
server.https字段,在vue-cli中,使用的是webpack的dev-server,如果想要启用http2,需要在Node.js 15.0.0以下版本使用,设置server.http2:true,因为所依赖的spdy包,不支持15.0.0以及之后的版本,webpack计划将dev-server迁移到Express,使用Node.js内置http2包,但由于牵扯太多,直到今天依然没有动手。
在Vite中,使用的是http-proxy,如果想要启用https与http2,设置https:true即可,但仍然有一点需要注意,如果配置了server.proxy,那么Vite仅使用https。
如果使用https浏览器报不安全的问题。
在vue-cli中我们可以通过使用node_modules/webpack-dev-server/ssl文件中的证书,通过添加信任即规避此问题。
在Vite中,我们可以使用mkcert,自主生产一个本地https证书
开启https后,Vite不会存在sockjs-node报错问题,因为是直接建立ws,而vue-cli并非这样,默认以内网ip为host,发起了一个http下允许跨域的请求,然后才会建立ws,但这个请求https下会因为没有证书,导致https请求跨域报错,所以会不断轮询、报错。如果规避此问题,需要配置sockhost,或者配置devServer.disableHostCheck: true,以及devServer.public为localhost + 端口,但这样就代表放弃了network调试能力。
(注:本文vue-cli指的是V4版本,V5版本基于webpack-dev-server V4,也已经废弃了sockhost和public)
亦或者使用mkcert自主生产一个内网ip的https证书,让第一次scokjs-node的https请求成功,建立ws链接,或者用更加“方便”的方法,一个项目启动两次,其中一次为http版本的,然后用proxy把/sockjs-node/代理到http版本的项目中,成功后建立ws链接。
实际上vue-cli的默认ip行为本身是令人诟病的,如果较为完善解决,需要用到nginx,以下为关于此问题的讨论:vue-cli默认使用当前ip为常量
https://github.com/vuejs/vue-cli/issues/1616
如何使用mkcert:
其实,mkcert可以单独拎出来当做一篇文章来讲,但由于跟上文关系紧密,就放在一起讲了。
如果对上文开启https之后,sockjs-node报错处理云里雾里的话,那么这一节就是来讲如何干净利落地解决报错
其实解决报错的方法很简单,就是安装https证书,但是为什么按了证书还会报错呢?因为node_modules/webpack-dev-server/ssl 的证书是localhost,sockjs-node是局域网ip,因此证书无法覆盖,所以发起这个接口是跨域、报错。
既然webpack-dev-server的证书覆盖有局限性,那么我们自己生产一个覆盖全面的证书就可以了。
使用
mkcert localhost 192.168.0.112
即可在当前目录生成cert和key
然后执行
mkcert -install
将证书注册到证书管理
在上一节中,我们一般使用https的布尔值,其实它还可以接受一个对象,键为cert、key,值为路径,用于加载对应证书。我们将生成的两个文件加入https中,即可解决证书覆盖不全的问题。
https:{
cert: fs.readFileSync(path.join(__dirname, './localhost.pem'),'utf8'),
key: fs.readFileSync(path.join(__dirname, './localhost-key.pem'),'utf8')
},
第三方包的修改
从之前的内容来看,与Vite启动相关的内容基本都覆盖到了,文档也将快结束了,事实上也正是如此。但我们忽略了一个东西——我们都知道,Vite之所以很快,一部分的原因是Esbuild将第三方库转译为ESM,进行预构建,那么问题来了,如何确保Esbuild转译出来的代码,支持浏览器环境?
换句话说,如果第三方库的代码出现问题,包括但不限于使用了浏览器环境下不存在的API,应该怎么办?
举个例子,第三方包中有使用到了require.context,这个API显然在浏览器是没有的,那么应该怎么办呢?
修改源代码
这是一种十分狂野的做法,如果给不兼容的库提交代码,周期肯定会很长。如果在私有npm发布包的定制版本,那么后续包的更新上肯定也是个麻烦的事情,如果修改本地的node_modules,那么团队协作上就会有问题。
关于此问题,其实社区也给出了解决方法,那就是patch-package这个库,它可以把对本地node_modules的修改记录下来,随着git同步给协作的团队,然后在postinstall的时候根据修改记录,对目标包进行修改。
这里需要注意的是,我们需要锁定目标包的版本,防止自动更新到其他版本引起代码变化。
首先,我们安装patch-package
npm i patch-package -D
然后,我们将第三方包口文件中,将require.context替换为import.meta.globEager
npx patch-package XXX
这样,我们项目本地就会生成一个patches文件夹,记录了我们的更改
最后,我们在package.json的scripts中增加如下内容:
{
"scripts": {
"postinstall": "patch-package"
}
}
这样,我们就解决了改动第三方包所带来的的团队协同问题。
Esbuild 插件
我们都知道,预构建是Esbuild的工作,那么是不是可以通过Esbuild插件,来达到修改第三方源码的目的呢?答案是可行的。
Esbuild的配置主要集中在optimizeDeps.esbuildOptions中,关于optimizeDeps,我们之前提到过很多次了,它在Vite中被称作依赖优化选项,这也是为什么之前提到的optimizeDeps的改动会导致强制预构建的原因。
在optimizeDeps.esbuildOptions.plugins中,接受一个数组,可以将我们自己写的替换插件,传入进去。
Esbuild的插件,被设计为一个对象,里面包括name和setup两个属性,其中,name是插件名称,setup是一个函数,接受一个build对象,这个对象上挂载了一些钩子,可以让我们实现一些自定义逻辑。
onResolve是路径解析的时候触发的钩子
onLoad是模块加载的时候触发的钩子,也是我们这次使用的钩子
onStart构建开始的时候的钩子,包括触发watch或者serve模式下的重新构建
onEnd是构建结束的钩子
这次我们主要将onLoad如何实现第三方包的修改。
onLoad需要传入两个参数第一个是一个对象options,第二个是回调函数callback。其中options跟onResolve一样,包含filter和namespace两个属性。
filter是必传参数,为一个正则表达式,可以用它来匹配目标文件。
注意: 插件中的 filter 正则是使用 Go 原生正则实现的,为了不使性能过于劣化,规则应该尽可能严格。同时它本身和 JS 的正则也有所区别,不支持前瞻(?<=)、后顾(?=)和反向引用(\1)这三种规则。
namespace为选填参数,一般在onResolve钩子中的回调函数callback,返回namespace属性作为标识,然后我们可以在onLoad钩子中通过namespace,拿到对应模块。
callback每个钩子不相同,以下为onLoad使用的callback
有了这些API,那么我们实际上很快可以搞出一个删除目标引用的Esbuild插件
小结
我们了解了前端模块的发展历史,同时也了解了Vite的原理,然后,我们根据这些原理,将vue-cli的项目迁移过来,最后我们执行了build命令,成功打包,这一切都是曲折但成功的。
但是,这也只是开始,我们只是达到了他人使用Vite创建新项目的高度。
这次只是让整个项目流程跑通,还有山一样多的优化在等着我们,比如路由的懒加载、资源文件的合并、打包的优化等等等等。
需要学习的东西有很多,这并非我们的终点,而是我们新的起点。