前端工程化的学习(偏向vite构建工具)
好早就听说了vite,也早就简单的使用并了解了一点,之前在公司实习团队也正在迁移webpack的项目到vite,但我自己却一直没有深入,毕竟还是初级前端工程师,功力还欠缺很多,但最近封装了一个小组件,整个项目不使用脚手架挺难受的,到处参考别人的代码希望能找到组件开发的最佳实践,整个过程举步维艰,所以开始先从vite入手学习一下前端工程化相关的东西了...
为什么需要构建工具
摘抄一段vite
官网对打包
的描述:
使用工具抓取、处理并将我们的源码模块串联成可以在浏览器中运行的文件
现阶段我们基本都不会直接编写可以浏览器上运行的文件,更多的是使用各种新的框架(Vue/React)、语法(TypeScript/less/sass),用这些工具编写出来的代码时不能直接在浏览器上直接运行的,我们需要每次都手动使用不同的解释器/编译器去将用高级语法编写的代码转换为能在浏览器中运行的代码: 所以简单理解构建工具(打包工具)要做的就是这样一件事:将这条工具链内置,面向开发者透明,避免开发者每次查看效果都要重复机械化地输入不同的命令,除此之外,构建工具还可以使用各种优化工具优化最终生成的文件。 一般来说,一个构建工具会有以下功能:
- 模块化支持:兼容多种模块化规范写法,支持从
node_modules
中引入代码(浏览器本身只识别路径方式的模块导入,imoprt { forEach } from 'loadsh'
这样直接以名字导入需构建工具识别) - 框架编译/语法转换:如:
tsc->lessc->vueComplier
- 构建产物性能优化:文件打包、代码压缩、code splitting、tree shaking...
- 开发体验优化:hot module replacement、跨域解决等...
- ... 总的来说,构建工具让我们开发人员可以更加关注代码的编写,而非代码的运行。
五花八门的构建工具
市面上常见的构建工具有如下(这里简单说一下各种构建工具的特点,具体展开就太多了,大家感兴趣可以直接去官网看看):
- grunt:基于配置驱动的,开发者需要做的就是了解各种插件的功能,然后把配置整合到 Gruntfile.js 中,然后就可以自动处理一些需要反复重复的任务,例如代码压缩、编译、单元测试、linting等工作,配置复杂度较高且IO操作较多。
- gulp:Gulp最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。这使得它本身被设计的非常简单,但却拥有强大的功能,既可以单独完成构建,也可以和其他工具搭配使用
- webpack:最主流的打包构建工具,兼容覆盖基本所有场景,前端工程化的核心,但相应带来的缺点就是配置繁琐
- rollup:由于webpack配置繁琐,对于小型项目开发者较不友好,他们更倾向于rollup。其配置简单,易于上手,成为了目前最流行的JS库打包工具
- esbuild:使用go语言并大量使用了其高并发的特性,速度极快。不过目前Esbuild还很年轻,没有达到1.0版本,并且其打包构建与Rollup类似更关注于JS本身,所以并不适合单独使用在前端项目的生产环境之中
- parcel:...
- ...
- vite:开发环境基于esmodule规范按需加载,速度极快,具有极佳的开发体验,生产环境底层调用rollup,接下来主要介绍webpack与vite之间的一个对比。
其实官网介绍vite的优势已经非常详细了,我自身也没有额外的理解,这里就直接摘要一段官网的话:
当我们开始构建越来越大型的应用时,需要处理的 JavaScript 代码量也呈指数级增长。包含数千个模块的大型项目相当普遍。基于 JavaScript 开发的工具就会开始遇到性能瓶颈,Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
再贴两张大家可能已经很熟的对比图: 相信大家看了上述官网的摘要差不多已经明白为什么vite在开发环境下启动速度非常快的原因了,主要就是使用了浏览器原生支持的esmodule
规范,当然还少不了vite本身在这之上做的一些优化,比如依赖预构建 我在一篇文章中看到过这样一个问题:这个思路既然能解决开发启动速度上的问题,为什么webpack不能支持呢? 答:
- webpack的设计理念就是大而全,它需要兼容不同的模块化,我们的工程既有可能跑在浏览器端,也有可能跑在服务端,所以webpack会将不同的模块化规范转换为独有的一个函数
webpack_require
进行处理,为了做到这一点,它必须一开始就要统一编译转换模块化代码,也就意味着它需要将所有的依赖全部读取一遍; - 而我们在使用vite项目的时候,就只能使用
esmodule
规范,但项目的依赖仍然可能使用了不同的模块规范,vite会在依赖预构建中处理这一步,将依赖树转换为单个模块并缓存在/node_modules/.vite
下方便浏览器按需加载,将打包的部分工作交给了浏览器执行,优化了开发体验。而构建交给了rollup
同样会兼容各种模块化规范...
总结:webpack更多的关注兼容性,而vite关注浏览器端的开发体验,侧重点不一样
vite处理细节
自身对前端工程化的理解也比较浅,从vite官网文档中可以学到不少前端工程化相关的知识,知识点总结至vite官网,快速入口
1. 导入路径补全
在处理的过程中如果说看到了有非绝对路径或者相对路径的引用, 他则会尝试开启路径补全:
javascript
复制代码
import _ from "lodash" // 补全前,浏览器并不认识这种裸模块导入 import _ from "/node_modules/.vite/lodash"; // 补全后,使用依赖预构建处理后的结果
2. 依赖预构建
主要就是为了解网络多包传输的性能问题,官网原话:
一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,
lodash-es
有超过 600 个内置模块!当我们执行import { debounce } from 'lodash-es'
时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。通过预构建
lodash-es
成为一个模块,我们就只需要一个 HTTP 请求了!
重写前:
// a.js export default function a() {}
export { default as a } from "./a.js"
function a() {}
顺便解决了以下两个问题:
- 不同的第三方包会有不同的导出格式,Vite 会将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM
- 对路径的处理上可以直接使用.vite/deps, 方便路径重写
其他:构建这一步由 esbuild 执行,这使得 Vite 的冷启动时间比任何基于 JavaScript 的打包器都要快得多
注意:这里都是指的开发环境,生产环境会交给rollup去执行
3. vite与ts
vite他天生就对ts支持非常良好, 因为vite在开发时态是基于esbuild, 而esbuild是天生支持对ts文件的转换的快速入口
4. 环境变量
一个产品可能要经过如下环境:
- 开发环境
- 测试环境
- 预发布环境
- 灰度环境
- 生产环境 不同的环境使用的数据应该是隔离的,或者是经过处理的,比如小流量环境,很显然,不同环境在一些密钥上的设置上是不同的,环境变量在这时候就尤为重要了,vite中内置了dotenv对环境变量进行处理: dotenv会自动读取.env文件, 并解析这个文件中的对应环境变量 并将其注入到process对象下(但是vite考虑到和其他配置的一些冲突问题, 他不会直接注入到process对象下) 配置:
.env # 所有情况下都会加载 .env.local # 所有情况下都会加载,但会被 git 忽略 .env.[mode] # 只在指定模式下加载 .env.[mode].local # 只在指定模式下加载,但会被 git 忽略 然后文件里使用VITE
前缀的命名变量VITE_SOME_KEY=123
,可以在vite.config.ts
中配置envPrefix: "ENV_"
修改这个前缀 使用:
console.log(import.meta.env.VITE_SOME_KEY) // 123 console.log(import.meta.env.DB_PASSWORD) // undefined
其他:为什么vite.config.js可以书写成esmodule的形式(vite明明是运行在服务端的), 这是因为vite他在读取这个vite.config.js的时候会率先node去解析文件语法, 如果发现你是esmodule规范会直接将你的esmodule规范进行替换变成commonjs规范
5. vite对css的处理
基本流程:1. vite在读取到main.js中引用到了Index.css
- 直接去使用fs模块去读取index.css中文件内容
- 直接创建一个style标签, 将index.css中文件内容直接copy进style标签里
- 将style标签插入到index.html的head中
- 将该css文件中的内容直接替换为js脚本(方便热更新或者css模块化), 同时设置Content-Type为js 从而让浏览器以JS脚本的形式来执行该css后缀的文件
处理重复类名
全部都是基于node
- module.css (module是一种约定, 表示需要开启css模块化)
- 他会将你的所有类名进行一定规则的替换(将footer 替换成 _footer_i22st_1)
- 同时创建一个映射对象{ footer: "_footer_i22st_1" }
- 将替换过后的内容塞进style标签里然后放入到head标签中 (能够读到index.html的文件内容)
- 将componentA.module.css内容进行全部抹除, 替换成JS脚本
- 将创建的映射对象在脚本中进行默认导出
config参考
// 摘自https://github.com/passerecho/vite- css: { // 对css的行为进行配置 // modules配置最终会丢给postcss modules modules: { // 是对css模块化的默认行为进行覆盖 localsConvention: "camelCaseOnly", // 修改生成的配置对象的key的展示形式(驼峰还是中划线形式) scopeBehaviour: "local", // 配置当前的模块化行为是模块化还是全局化 (有hash就是开启了模块化的一个标志, 因为他可以保证产生不同的hash值来控制我们的样式类名不被覆盖) // generateScopedName: "[name]_[local]_[hash:5]" // https://github.com/webpack/loader-utils#interpolatename // generateScopedName: (name, filename, css) => { // // name -> 代表的是你此刻css文件中的类名 // // filename -> 是你当前css文件的绝对路径 // // css -> 给的就是你当前样式 // console.log("name", name, "filename", filename, "css", css); // 这一行会输出在哪??? 输出在node // // 配置成函数以后, 返回值就决定了他最终显示的类型 // return `${name}_${Math.random().toString(36).substr(3, 8) }`; // } hashPrefix: "hello", // 生成hash会根据你的类名 + 一些其他的字符串(文件名 + 他内部随机生成一个字符串)去进行生成, 如果你想要你生成hash更加的独特一点, 你可以配置hashPrefix, 你配置的这个字符串会参与到最终的hash生成, (hash: 只要你的字符串有一个字不一样, 那么生成的hash就完全不一样, 但是只要你的字符串完全一样, 生成的hash就会一样) globalModulePaths: ["./componentB.module.css"], // 代表你不想参与到css模块化的路径 }, preprocessorOptions: { // key + config key代表预处理器的名 less: { // 整个的配置对象都会最终给到less的执行参数(全局参数)中去 // 在webpack里就给less-loader去配置就好了 math: "always", globalVars: { // 全局变量 mainColor: "red", } }, }, devSourcemap: true, },
6. 静态资源
服务时引入一个静态资源会返回解析后的公共路径:
import shaderString from './shader.glsl?raw'
import imgUrl from './img.png' document.getElementById('hero-img').src = imgUrl
例如,imgUrl
在开发时会是 /img.png
,在生产构建后会是 /assets/img.2d8efhg.png
。
行为类似于 Webpack 的 file-loader
。区别在于导入既可以使用绝对公共路径(基于开发期间的项目根路径),也可以使用相对路径。
为什么要使用hash
浏览器是有一个缓存机制 静态资源名字只要不改, 那么他就会直接用缓存的 刷新页面--> 请求的名字是不是同一个 --> 读取缓存 --> 所以我们要尽量去避免名字一致(每次开发完新代码并构建打包时)
1. 显式 URL 引入
未被包含在内部列表或 assetsInclude
中的资源,可以使用 ?url
后缀显式导入为一个 URL。这十分有用,例如,要导入 Houdini Paint Worklets 时:
import workletURL from 'extra-scalloped-border/worklet.js?url' CSS.paintWorklet.addModule(workletURL)
2. 将资源引入为字符串
资源可以使用 ?raw
后缀声明作为字符串引入。
import shaderString from './shader.glsl?raw'
比如svg文件如果我们以url的方式导入文件,则相当于导入一张图片,只能对其进行图片的相关操作,如果我们想要对其进行svg相关的操作,我们则需要使用?raw
的方式导入:
import svgIcon from "./assets/svgs/fullScreen.svg?url"; // 这种是以图片的方式加载svg,无其他特殊操作 import svgRaw from "./assets/svgs/fullScreen.svg?raw"; // 加载svg的源文件,这种方式的加载可以做到修改svg的颜色等操作 console.log("svgIcon", svgIcon, svgRaw); document.body.innerHTML = svgRaw; const svgElement = document.getElementsByTagName("svg")[0]; svgElement.onmouseenter = function() { // 不是去改他的background 也不是color // 而是fill属性 this.style.fill = "red"; } // 第一种使用svg的方式 // const img = document.createElement("img"); // img.src = svgIcon; // document.body.appendChild(img); // 第二种加载svg的方式
3. 导入脚本作为 Worker
脚本可以通过 ?worker
或 ?sharedworker
后缀导入为 web worker。
// 在生产构建中将会分离出 chunk import Worker from './shader.js?worker' const worker = new Worker()
// sharedworker import SharedWorker from './shader.js?sharedworker' const sharedWorker = new SharedWorker()
// 内联为 base64 字符串 import InlineWorker from './shader.js?worker&inline'
性能优化
- 代码逻辑上的优化,如:
- 使用
lodash
工具中的防抖、节流而非自己编写;数组数据量大时,也可以使用lodash
中的forEach
方法等等 for(let i = 0; i < arr.length; i++){}
替换为for(let i = 0, len = arr.length; i < len; i++)
这样只用通过作用域链获取一次父作用域中的arr
变量- ...
- 构建优化(构建工具关注的事):体积优化->压缩、treeshaking、图片资源压缩、cdn加载、分包...
- ...
其中分包知识我第一次接触到,这里记录一下: 主要是为了配合浏览器中的缓存策略
- 假设这样一个场景,我们使用
lodash
中的forEach
函数编写了console('1')
,最终打包后的代码如果不分包则会将lodash
中的相关实现和console('1')
合并为一个文件传给浏览器; - 而我们的业务代码经常变化,比如
console('1')
-->console('2')
这时候我们仍然需要将lodash
中的相关实现和console('1')
合并为一个文件传给浏览器; - 但显然
lodash
中的代码实现并没有更改,浏览器直接使用以前的就可以了 - 所以分包就是把一些不会经常更新的文件,进行单独打包处理为一个文件,配置参考
最后
前端工程化我也是最近开始学习,如有理解错误希望各位大佬不吝赐教