
本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐业务背景近些年来,随着前端工程架构发展,使得前端项目中也能拥有如后端工程的模块能力。正所谓 “能力(越)越大(来),责任(越)越大(卷)”,现在的前端工程不仅仅要满足业务需求,还伴随更多复杂的环境适配问题,例如:api请求的域名会根据不同环境而不同;线上环境和测试环境在打包策略有所不同「如线上要隔离sourceMap、屏蔽vue|react devtools等...」;前端spa组件根据不同环境做出不同逻辑;老板恨不得把所有应用端都收归到一个项目里面,什么微前端、uniapp多端方案接踵而至。。。但无论是什么方案,都离不开一个核心点:环境变量和多环境适配。那么,今天我们就来聊下如何在Vite中实现一套拓展能力强的多环境适配方案。多环境场景的业务形态我们先来了解,在多环境下要求前端工程架构流程是怎样的?如上图所示,在工程启动 / 构建时:环境变量注入:一般通过命令参数模式,可在package.json里配置;多模式文件:Vite根据环境变量来读取配置文件,把文件参数抽取出来做特性区分,这块也称为Vite的环境模式;环境收集器:简单理解为1个函数,做的事情就是把第二步的特性参数归整到一处并做些特定的逻辑,之后通过插件生成客户端的最终参数并吐出;客户端环境差异定制化:客户端(也就是工程里面的.vue、.ts、.tsx等前端文件)获取到环境参数做一些特定区分逻辑;构建和发布:之后就是项目根据以上几步产出的环境特性文件来打包,最终推送到服务端完成整个前端工程的生产。以上是大体流程,接下来会每步细分给大家讲解如何实现。方便大家理解,本次笔者专门开了个新GitHub项目来存放本文所有实现代码,有兴趣的同学可以拿下来实操下🌹🌹。Vite多环境方案实现多模式文件配置自定义环境变量Vite通过 多模式 来配置不同启动场景下的特性环境变量,你可以创建自定义的模式文件,如下: 这个项目创建了4种模式分别兼容release、beta、测试、本地环境,每种模式下有自己特定的环境变量,例如.env.local的内如如下:# .env._local # 透传客户端参数 VITE_NODE_ENV=local VITE_OWNER=Tom VITE_POSITION=广州,天河 # 私有参数,仅在vite server获取到, # 假如你的项目包含此类敏感变量。应该将文件添加到你的 .gitignore 中,以避免它们被 git 检入。 MODE_KEY=PRIVATE_KEY_LOCAL根据Vite的约定规则,只有以“VITE_”开头的变量才会在客户端被捕获,捕获方式为:import.meta.env.{参数名}。至于非“VITE_”开头的变量属于私有属性,不会传递出去。假如你的项目包含此类敏感变量。应该将文件添加到你的 .gitignore 中,以避免它们被 git 检入。完成上述配置后,我们只需要在package.json增加对应的启动命令就可以让Vite获取哪个模式来运行项目了:{ "name": "vite-mul-env-learn", "version": "0.0.0", "scripts": { "dev:local": "vite --mode _local", "dev:test": "vite --mode test", "build:beta": "vite build --mode beta", "build:release": "vite build --mode release", "lint": "eslint --fix --ext .js,.vue,ts src" } }Vite默认环境变量Vite 在一个特殊的 import.meta.env 对象上暴露环境变量。这里有一些在所有情况下都可以使用的内建变量:import.meta.env.MODE: {string} 应用运行的模式。import.meta.env.BASE_URL: {string} 部署应用时的基本 URL。他由base 配置项决定。import.meta.env.PROD: {boolean} 应用是否运行在生产环境。import.meta.env.DEV: {boolean} 应用是否运行在开发环境 (永远与 import.meta.env.PROD相反)。import.meta.env.SSR: {boolean} 应用是否运行在服务器渲染环境。这里补充说明下,DEV 和 PROD分别对应package.json中启动dev和build命令决定的,而SSR则是对应了Vite启动时设定的middlewareMode变量决定的:const { createServer: createViteServer } = require('vite') const vite = await createViteServer({ server: { middlewareMode: 'ssr' } })通过插件透传环境变量很多情况下,我们的环境变量不仅仅是简单的字符串,而是通过vite服务中二次计算才能得到最终结果,有点类似Vue中computed或React中useMemo、useCallback的效果。 像这类非静态的环境变量,我们需要借助插件能力来让它们也能够返回客户端,插件很多,这里推荐vite-plugin-environment,使用大概是这样子的:You can provide a list of environment variable names to expose to your client code:import { defineConfig } from 'vite' import EnvironmentPlugin from 'vite-plugin-environment' export default defineConfig({ plugins: [ EnvironmentPlugin(['API_KEY', 'DEBUG']), ], })And then use them as:const apiKey = process.env.API_KEY在这个基础上,我们还能配合模式文件进行联合判断:import { defineConfig, ConfigEnv, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; import path from 'path'; import EnvironmentPlugin from 'vite-plugin-environment'; import { fetchEnv } from './server/envUitls'; // https://vitejs.dev/config/ export default defineConfig(({ command, mode }: ConfigEnv) => { const env = loadEnv(mode, __dirname); const { proxy } = fetchEnv(env.VITE_NODE_ENV); // 设置域名和端口 return { base: './', plugins: [ vue(), EnvironmentPlugin({ PROXY: proxy }) ] }; });const env = loadEnv(mode, __dirname);可以获取.env._local是所有非私密参数,接下来程序可以根据模式参数来计算最终的环境变量,通过插件返回到客户端。fetchEnv方法可以理解成环境收集器,里面可以写逻辑让环境参数得到统一整合。客户端环境差异定制这块就很好理解了,无非就是通过指定方法获取环境变量,来条件渲染vue或React组件。下面做了个demo:<script setup lang="ts"> import { ref } from 'vue'; import { proxy } from '@/api/proxy'; interface IEnv extends ImportMetaEnv { VITE_NODE_ENV: string; VITE_OWNER: string; VITE_POSITION: string; } const viteEnv: IEnv = import.meta.env; </script> <template> <div class="app"> <img alt="Vue logo" src="./assets/logo.png" /> <section class="main"> <div class="card"> <h3>①通过环境文件传入的参数</h3> <div class="tips">说明:只包含"VITE_"开头参数</div> <div>项目owner:{{ viteEnv.VITE_OWNER }}</div> <div>owner位置:{{ viteEnv.VITE_POSITION }}</div> <div>项目mode:{{ viteEnv.VITE_NODE_ENV }}</div> </div> <div class="card"> <h3>②环境插件传递的参数</h3> <div class="tips"> 说明:通过vite-plugin-environment插件传递过来,一般为二次计算后的参数。假如是静态参数值则直接通过方案①传回来即可。 </div> <p>服务请求域:{{ proxy }}</p> </div> <div class="card"> <h3>③Vite环境自带参数</h3> <div class="tips"> 说明:Vite默认参数,参考 <a href="https://cn.vitejs.dev/guide/env-and-mode.html#env-variables" >Vite环境变量</a > </div> <p>是否为SSR模式:{{ viteEnv.SSR }}</p> <p>是否为本地开发模式:{{ viteEnv.DEV }}</p> <p>是否为构建模式:{{ viteEnv.PROD }}</p> <p>当前启动命令读取的mode为:{{ viteEnv.MODE }}</p> <p>部署应用时的基本 URL:{{ viteEnv.BASE_URL }}</p> </div> </section> </div> </template> <style lang="less" scoped> .app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } .main { display: flex; .card { margin: 10px; padding: 10px; width: 300px; text-align: left; background-color: #dbf1e7; font-size: 14px; h3 { margin-bottom: 0; } .tips { margin-bottom: 10px; font-size: 12px; color: #898989; } } } </style>效果图解决的业务场景思考除了本文 “业务背景” 模块所说的最直观的场景外,其实还可以做很多项目工程化相关的高阶操作。假如项目构建操作放在远程服务器进行,那么在构建打包前就可以联动服务api来生产出不同版本、不同模式的构建包,甚至可以把SSR逻辑放到这块来做,达到“千人千面”的效果。收场本篇文章到这里就结束了,如果文章对你有用,可以三连支持一下,如果你有更好的见解,欢迎指正~欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹本文的所有demo,都专门开了个GitHub项目来保存,有需要的同学可以拿下来实操。
本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐开场Hello大家好,相信在座各位假如使用Vue生态开发项目情况下,对Pinia状态管理库应该有所听闻或正在使用,假如还没接触到Pinia,这篇文章可以帮你快速入门,并如何在企业项目中更优雅封装使用。本文先给大家阐述如何去理解、使用Pinia,最后讲怎样把Pinia集成到工程中,适合大多数读者,至于研读Pinia的源码等进阶科普,会另外开一篇文章细述。另外,本文的所有demo,都专门开了个GitHub项目来保存,有需要的同学可以拿下来实操一下。🌹🌹认识PiniaPinia读音:/piːnjʌ/,是Vue官方团队推荐代替Vuex的一款轻量级状态管理库。 它最初的设计理念是让Vue Store拥有一款Composition API方式的状态管理库,并同时能支持 Vue2.x版本的Option API 和 Vue3版本的setup Composition API开发模式,并完整兼容Typescript写法(这也是优于Vuex的重要因素之一),适用于所有的vue项目。比起Vuex,Pinia具备以下优点:完整的 TypeScript 支持:与在 Vuex 中添加 TypeScript 相比,添加 TypeScript 更容易极其轻巧(体积约 1KB)store 的 action 被调度为常规的函数调用,而不是使用 dispatch 方法或 MapAction 辅助函数,这在 Vuex 中很常见支持多个Store支持 Vue devtools、SSR 和 webpack 代码拆分Pinia与Vuex代码分割机制上述的Pinia轻量有一部分体现在它的代码分割机制中。举个例子:某项目有3个store「user、job、pay」,另外有2个路由页面「首页、个人中心页」,首页用到job store,个人中心页用到了user store,分别用Pinia和Vuex对其状态管理。 先看Vuex的代码分割: 打包时,vuex会把3个store合并打包,当首页用到Vuex时,这个包会引入到首页一起打包,最后输出1个js chunk。这样的问题是,其实首页只需要其中1个store,但其他2个无关的store也被打包进来,造成资源浪费。 Pinia的代码分割: 打包时,Pinia会检查引用依赖,当首页用到job store,打包只会把用到的store和页面合并输出1个js chunk,其他2个store不耦合在其中。Pinia能做到这点,是因为它的设计就是store分离的,解决了项目的耦合问题。Pinia的常规用法事不宜迟,直接开始使用Pinia「本文默认使用Vue3的setup Composition API开发模式」。假如你对Pinia使用熟悉,可以略过这part👻1. 安装yarn add pinia # or with npm npm install pinia2. 挂载全局实例import { createPinia } from 'pinia' app.use(createPinia())3. 创建第一个store在src/store/counterForOptions.ts创建你的store。定义store模式有2种:使用options API模式定义,这种方式和vue2的组件模型形式类似,也是对vue2技术栈开发者较为友好的编程模式。import { defineStore } from 'pinia'; // 使用options API模式定义 export const useCounterStoreForOption = defineStore('counterForOptions', { // 定义state state: () => { return { count1: 1 }; }, // 定义action actions: { increment() { this.count1++; } }, // 定义getters getters: { doubleCount(state) { return state.count1 * 2; } } });使用setup模式定义,符合Vue3 setup的编程模式,让结构更加扁平化,个人推荐推荐使用这种方式。import { ref } from 'vue'; import { defineStore } from 'pinia'; // 使用setup模式定义 export const useCounterStoreForSetup = defineStore('counterForSetup', () => { const count = ref<number>(1); function increment() { count.value++; } function doubleCount() { return count.value * 2; } return { count, increment, doubleCount }; });上面2种定义方式效果都是一样的,我们用defineStore方法定义一个store,里面分别定义1个count的state,1个increment action 和1个doubleCount的getters。其中state是要共享的全局状态,而action则是让业务方调用来改变state的入口,getters是获取state的计算结果。之所以用第一种方式定义是还要额外写getters、action关键字来区分,是因为在options API模式下可以通过mapState()、mapActions()等方法获取对应项;但第二种方式就可以直接获取了(下面会细述)。4. 业务组件对store的调用在src/components/PiniaBasicSetup.vue目录下创建个组件。<script setup lang="ts" name="component-PiniaBasicSetup"> import { storeToRefs } from 'pinia'; import { useCounterStoreForSetup } from '@/store/counterForSetup'; // setup composition API模式 const counterStoreForSetup = useCounterStoreForSetup(); const { count } = storeToRefs(counterStoreForSetup); const { increment, doubleCount } = counterStoreForSetup; </script> <template> <div class="box-styl"> <h1>Setup模式</h1> <p class="section-box"> Pinia的state: count = <b>{{ count }}</b> </p> <p class="section-box"> Pinia的getters: doubleCount() = <b>{{ doubleCount() }}</b> </p> <div class="section-box"> <p>Pinia的action: increment()</p> <button @click="increment">点我</button> </div> </div> </template> <style lang="less" scoped> .box-styl { margin: 10px; .section-box { margin: 20px auto; width: 300px; background-color: #d7ffed; border: 1px solid #000; } } </style>Pinia在setup模式下的调用机制是先install再调用。install这样写: const counterStoreForSetup = useCounterStoreForSetup();,其中 useCounterStoreForSetup就是你定义store的变量;调用就直接用 counterStoreForSetup.xxx(xxx包括:state、getters、action)就好。代码中获取state是用了解构赋值,为了保持state的响应式特性,需要用storeToRefs进行包裹。兼容Vue2的Options API调用方式可以到 这里。5. 良好的编程习惯state的改变交给action去处理: 上面例子,counterStoreForSetup有个pinia实例属性叫$state是可以直接改变state的值,但不建议怎么做。一是难维护,在组件繁多情况下,一处隐蔽state更改,整个开发组帮你排查;二是破坏store封装,难以移植到其他地方。所以,为了你的声誉和安全着想,请停止游离之外的coding😇😇。用hook代替pinia实例属性: install后的counterStoreForSetup对象里面,带有不少$开头的方法,其实这些方法大多数都能通过hook引入代替。其他的想到再补充...企业项目封装攻略1. 全局注册机重复打包问题在上面的例子我们可以知道,使用store时要先把store的定义import进来,再执行定义函数使得实例化。但是,在项目逐渐庞大起来后,每个组件要使用时候都要实例化吗?在文中开头讲过,pinia的代码分割机制是把引用它的页面合并打包,那像下面的例子就会有问题,user被多个页面引用,最后user store被重复打包。 为了解决这个问题,我们可以引入 ”全局注册“ 的概念。做法如下:创建总入口在src/store目录下创建一个入口index.ts,其中包含一个注册函数registerStore(),其作用是把整个项目的store都提前注册好,最后把所有的store实例挂到appStore透传出去。这样以后,只要我们在项目任何组件要使用pinia时,只要import appStore进来,取对应的store实例就行。// src/store/index.ts import { roleStore } from './roleStore'; import { useCounterStoreForSetup } from '@/store/counterForSetup'; import { useCounterStoreForOption } from '@/store/counterForOptions'; export interface IAppStore { roleStore: ReturnType<typeof roleStore>; useCounterStoreForSetup: ReturnType<typeof useCounterStoreForSetup>; useCounterStoreForOption: ReturnType<typeof useCounterStoreForOption>; } const appStore: IAppStore = {} as IAppStore; /** * 注册app状态库 */ export const registerStore = () => { appStore.roleStore = roleStore(); appStore.useCounterStoreForSetup = useCounterStoreForSetup(); appStore.useCounterStoreForOption = useCounterStoreForOption(); }; export default appStore;总线注册在src/main.ts项目总线执行注册操作:import { createApp } from 'vue'; import App from './App.vue'; import { createPinia } from 'pinia'; import { registerStore } from '@/store'; const app = createApp(App); app.use(createPinia()); // 注册pinia状态管理库 registerStore(); app.mount('#app');业务组件内直接使用// src/components/PiniaBasicSetup.vue <script setup lang="ts" name="component-PiniaBasicSetup"> import { storeToRefs } from 'pinia'; import appStore from '@/store'; // setup composition API模式 const { count } = storeToRefs(appStore.useCounterStoreForSetup); const { increment, doubleCount } = appStore.useCounterStoreForSetup; </script> <template> <div class="box-styl"> <h1>Setup模式</h1> <p class="section-box"> Pinia的state: count = <b>{{ count }}</b> </p> <p class="section-box"> Pinia的getters: doubleCount() = <b>{{ doubleCount() }}</b> </p> <div class="section-box"> <p>Pinia的action: increment()</p> <button @click="increment">点我</button> </div> </div> </template>打包解耦到这里还不行,为了让appStore实例与项目解耦,在构建时要把appStore抽取到公共chunk,在vite.config.ts做如下配置export default defineConfig(({ command }: ConfigEnv) => { return { // ...其他配置 build: { // ...其他配置 rollupOptions: { output: { manualChunks(id) { // 将pinia的全局库实例打包进vendor,避免和页面一起打包造成资源重复引入 if (id.includes(path.resolve(__dirname, '/src/store/index.ts'))) { return 'vendor'; } } } } } }; });经过这样封装后,pinia状态库得到解耦,最终的项目结构图是这样的: 2. Store组管理场景分析大家在项目中是否经常遇到某个方法要更新多个store的情况呢?例如:你要做个游戏,有3种职业「战士、法师、道士」,另外,玩家角色有3个store来控制「人物属性、装备、技能」,页面有个”转职“按钮,可以转其他职业。当玩家改变职业时,3个store的state都要改变,怎么做呢?方法1:在业务组件创建个函数,单点击”转职“时,获取3个store并且更新它们的值。方法2:抽象一个新pinia store,store里有个”转职“的action,当玩家转职时,响应这个action,在action更新3个store的值。对比起来,无论从封装还是业务解耦,明显方法2更好。要做到这样,这也得益于pinia的store独立管理特性,我们只需要把抽象的store作为父store,「人物属性、装备、技能」3个store作为单元store,让父store的action去管理自己的单元store。组级Store创建继续上才艺,父store:src/store/roleStore/index.tsimport { defineStore } from 'pinia'; import { roleBasic } from './basic'; import { roleEquipment } from './equipment'; import { roleSkill } from './skill'; import { ROLE_INIT_INFO } from './constants'; type TProfession = 'warrior' | 'mage' | 'warlock'; // 角色组,汇聚「人物属性、装备、技能」3个store统一管理 export const roleStore = defineStore('roleStore', () => { // 注册组内store const basic = roleBasic(); const equipment = roleEquipment(); const skill = roleSkill(); // 转职业 function changeProfession(profession: TProfession) { basic.setItem(ROLE_INIT_INFO[profession].basic); equipment.setItem(ROLE_INIT_INFO[profession].equipment); skill.setItem(ROLE_INIT_INFO[profession].skill); } return { basic, equipment, skill, changeProfession }; });单元Store3个单元store:人物属性装备技能业务组件调用<script setup lang="ts" name="component-StoreGroup"> import appStore from '@/store'; </script> <template> <div class="box-styl"> <h1>Store组管理</h1> <div class="section-box"> <p> 当前职业: <b>{{ appStore.roleStore.basic.basic.profession }}</b> </p> <p> 名字: <b>{{ appStore.roleStore.basic.basic.name }}</b> </p> <p> 性别: <b>{{ appStore.roleStore.basic.basic.sex }}</b> </p> <p> 装备: <b>{{ appStore.roleStore.equipment.equipment }}</b> </p> <p> 技能: <b>{{ appStore.roleStore.skill.skill }}</b> </p> <span>转职:</span> <button @click="appStore.roleStore.changeProfession('warrior')"> 战士 </button> <button @click="appStore.roleStore.changeProfession('mage')">法师</button> <button @click="appStore.roleStore.changeProfession('warlock')"> 道士 </button> </div> </div> </template> <style lang="less" scoped> .box-styl { margin: 10px; .section-box { margin: 20px auto; width: 300px; background-color: #d7ffed; border: 1px solid #000; } } </style>效果落幕磨刀不误砍柴工,对于一个项目来讲,好的状态管理方案在当中发挥重要的作用,不仅能让项目思路清晰,而且便于项目日后维护和迭代。项目传送门最后重新提下,本文的所有demo,都专门开了个GitHub项目来保存,有需要的同学可以拿下来实操。🌹🌹欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹
本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐背景前段时间用Vite2.x造了个Vue3的个人项目,在Vite的加持下,无论是项目冷启动、热更新和构建,比起webpack速度都提升n00%(n≥10)以上,怀着强烈的好奇心,就把Vite的源码搞了下来学习下,顺便水篇文章方便以后重温😂😂😂。认识构建工具的开发服务「Dev server」开发服务是指开发者在本地开发前端项目时,构建工具会额外启动的一个本地服务。如执行npm run dev命令后,框架执行服务启动操作,启动完成后,你就能通过浏览器输入http://localhost:xxxx("xxxx"为端口号)看到自己开发的页面了。OK,咋们聊下Vite的本地服务。。。它思路设计还是很独特的,Vite也是通过这种机制取得更高效的处理速度和更好的开发体验!为了做对比,先看下传统bundle打包方式服务启动方式,以webpack为例。Webpack的开发服务 在项目冷启动时,webpack会通过entry入口文件逐层检查文件的依赖,例如有3个ts文件:// a.ts export const a = 1; // b.ts export const b = 2; // sum.ts import { a } from './a.ts'; import { b } from './b.ts'; export default sum() => a + b; // bundle打包后大致是这样的 // bundle.js const a = 1; const b = 2; const sum = function() { return a + b; } export default sum;为了方便理解,上面是简略代码,但可以看出来,webpack在成功启动开发服务前,要收集所有依赖后打包成一个bundle.js文件。这种打包方法能有效整合模块之间的依赖关系,统一输出,减少资源加载数量。但也是是有短板的:一是服务的启动需要前置依赖组件打包完成,当组件越来越多且复杂后,项目的启动时间会越来越长(几十秒甚至几分钟);二是在热更新项目时,哪怕使用HRM方式去diff文件差异,修改后的效果也需要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。。Vite的开发服务下面是引用官方对Vite开发服务的解析。Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。依赖 大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会存在多种模块化格式(例如 ESM 或者 CommonJS)。Vite 将会使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载(例如基于路由拆分的代码模块)。Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。通俗来讲,执行npm run dev命令后,Vite先把本地server启动,之后通过项目的入口收集依赖,把没有提供esm格式的依赖和内部大量的依赖提前打包,这个过程成为:预优化(Pre-Bundling)。预优化后在页面需要加载依赖时,通过http方式把资源请求回来,做到了真正的按需加载。关于如何实现预优化,正是下面要详述部分。Vite1.0和2.0预优化工具差异Vite至此发布了2个大版本。其实,Vite1.0和2.0预优化还是有很大差异的。按开发者的描述: Vite2.0 在底层使用了 http + connect模块来代替 1.0 中的 koa 框架的一些能力。并且预优化的工具也由 rollup 的 commonjs 插件 替换为 esbuild ,这两个关键点的优化,使得执行效率大幅增加。大家可以感受一下esbuild带来的速度加成 比起rollup,esbuild能有如此表现主要得益于它的底层原理:js是单线程串行,esbuild是新开一个进程,然后多线程并行执行;esbuild用go语法编写,go是纯机器码,执行速度比JIT快;免去 AST 抽象语法树生成,优化了构建流程。关键词:connect、esbuild Vite预优化事不宜迟,直接把Vite源码clone下来「github地址」,在packages/vite/src/node/server/index.ts找到server启动函数:createServer,在这里可以找到预优化的入口optimizeDeps方法:export async function createServer( inlineConfig: InlineConfig = {} ): Promise<ViteDevServer> { // 此处省略好长代码... const runOptimize = async () => { if (config.cacheDir) { server._isRunningOptimizer = true try { server._optimizeDepsMetadata = await optimizeDeps( config, config.server.force || server._forceOptimizeOnRestart ) } finally { server._isRunningOptimizer = false } server._registerMissingImport = createMissingImporterRegisterFn(server) } } // 此处又省略好长代码... return server }接下来我们到packages/vite/src/node/optimizer/index.ts找到optimizeDeps方法的定义:export async function optimizeDeps( config: ResolvedConfig, force = config.server.force, asCommand = false, newDeps?: Record<string, string>, // missing imports encountered after server has started ssr?: boolean ): Promise<DepOptimizationMetadata | null> { // 省略好长代码... const result = await build({ absWorkingDir: process.cwd(), entryPoints: Object.keys(flatIdDeps), bundle: true, format: 'esm', target: config.build.target || undefined, external: config.optimizeDeps?.exclude, logLevel: 'error', splitting: true, sourcemap: true, outdir: cacheDir, ignoreAnnotations: true, metafile: true, define, plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr) ], ...esbuildOptions }) // 省略好长代码... }build()来源esbuild的构建方法,至于里面的参数大家有兴趣可以到插件官网查阅build-api。这里面要讲的,pulgins中包含了esbuildDepPlugin插件,这个插件是 Vite 在 esbuild 打包中最核心的逻辑,这插件的工作流程如下。特定格式文件external首先,插件对特定格式文件进行 external 处理,因为这些文件不会在esbuild阶段进行处理,所以要提前把它们找出并解析。其中externalTypes是Array类型,可以在packages/vite/src/node/optimizer/esbuildDepPlugin.ts找到它的定义。// externalize assets and commonly known non-js file types build.onResolve( { filter: new RegExp(`\.(` + externalTypes.join('|') + `)(\?.*)?$`) }, async ({ path: id, importer, kind }) => { const resolved = await resolve(id, importer, kind) if (resolved) { return { path: resolved, external: true } } } )解析不同的模块开发作者将打包模块分为两种:入口模块 和 依赖模块。入口模块:直接 import 的模块或者通过 include 制定的模块,如:import Vue from 'vue';依赖模块:入口模块自身的依赖,也就是 dependenciesfunction resolveEntry(id: string) { const flatId = flattenId(id) if (flatId in qualified) { return { path: flatId, namespace: 'dep' } } } build.onResolve( { filter: /^[\w@][^:]/ }, async ({ path: id, importer, kind }) => { if (moduleListContains(config.optimizeDeps?.exclude, id)) { return { path: id, external: true } } // ensure esbuild uses our resolved entries let entry: { path: string; namespace: string } | undefined // if this is an entry, return entry namespace resolve result if (!importer) { if ((entry = resolveEntry(id))) return entry // check if this is aliased to an entry - also return entry namespace const aliased = await _resolve(id, undefined, true) if (aliased && (entry = resolveEntry(aliased))) { return entry } } // use vite's own resolver const resolved = await resolve(id, importer, kind) if (resolved) { if (resolved.startsWith(browserExternalId)) { return { path: id, namespace: 'browser-external' } } if (isExternalUrl(resolved)) { return { path: resolved, external: true } } return { path: path.resolve(resolved) } } } )为了方便大家理解,这里写段伪代码,每个依赖打包前都执行以下逻辑:if 入口模块 将模块解析为namespace='dep'的处理流程 else if 为browser-external模块 将模块解析为namespace='browser-external'的处理流程 if 以http(s)引入的模块 将模块解析为外部引用模块 else 直接解析路径对namespace为dep的依赖打包完成模块分类后,接下来要对dep模块进行解析打包。build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => { const entryFile = qualified[id] let relativePath = normalizePath(path.relative(root, entryFile)) if ( !relativePath.startsWith('./') && !relativePath.startsWith('../') && relativePath !== '.' ) { relativePath = `./${relativePath}` } let contents = '' const data = exportsData[id] const [imports, exports] = data if (!imports.length && !exports.length) { // cjs contents += `export default require("${relativePath}");` } else { if (exports.includes('default')) { contents += `import d from "${relativePath}";export default d;` } if ( data.hasReExports || exports.length > 1 || exports[0] !== 'default' ) { contents += `\nexport * from "${relativePath}"` } } let ext = path.extname(entryFile).slice(1) if (ext === 'mjs') ext = 'js' return { loader: ext as Loader, contents, resolveDir: root } })首先将entryFile的相对路径解析出来放到relativePath变量中保存;分析模块类型,给contents赋值。通过 esbuild 词法分析入口模块的 import 和 export 信息,当两个关键字都没有,判断是一个 cjs 模块,生成下面格式contents;contents += `export default require("${relativePath}");`假如不满足第2步条件,则系统认为是一个 esm 模块,生成对应的contents:contents += `import d from "${relativePath}";export default d;` // or contents += `\nexport * from "${relativePath}"`解析文件扩展名,取得对应的loader;返回loader类型、模块内容、导入路径给esbuild打包,语法参考 esbuild onLoad result。在2和3步中,contents保存都是模块的相对路径(也就是第1步的relativePath),这样做可以让程序生成正确的cache文件目录结构。对namespace为browser-external的依赖打包build.onLoad( { filter: /.*/, namespace: 'browser-external' }, ({ path: id }) => { return { contents: `export default new Proxy({}, { get() { throw new Error('Module "${id}" has been externalized for ` + `browser compatibility and cannot be accessed in client code.') } })` } } )兼容yarn pnp环境if (isRunningWithYarnPnp) { build.onResolve( { filter: /.*/ }, async ({ path, importer, kind, resolveDir }) => ({ // pass along resolveDir for entries path: await resolve(path, importer, kind, resolveDir) }) ) build.onLoad({ filter: /.*/ }, async (args) => ({ contents: await require('fs').promises.readFile(args.path), loader: 'default' })) }总结总的看下来,在预优化这块,Vite先对依赖进行模块分类,针对不同类型resolved它们的引用路径,之后启动esbuild打包输出ES Module,最后通过http network拉取资源,使资源组装到应用上,完成按需加载。写在最后 至此,Vite2.0的预优化部分基本讲完了,由于时间仓促,在某些细节讲解可能略显粗糙,但主流程大致如此,细节之处等以后有空再慢慢补齐🤝🤝。另外,以后有时间再搞一篇Vite的渲染机制,当资源请求回来后,如何从原始形态渲染成最终的js|ts、css、vue template。欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹
2022年07月