实用的VUE系列——vite 真的跟 vue 没关系,他只是这个团队的人写的构建工具

5,359 阅读11分钟

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

vite 到底是什么

说起vite 很多jym可能非常熟悉,并且脱口而出,不就是vue脚手架吗

我说,说的对,但是不全对,他除了可以是vue的脚手架,还可以是react脚手架,还可以是svelte的脚手架

目前支持的模板预设如下:

image.png

其实这种东西,他有一个特殊的名字,叫构建工具

接下来,我们开始第二个问题,构建工具和脚手架有什么区别呢?

脚手架

所谓脚手架是创建前端项目的命令行工具,集成了常用的功能和配置,方便我们快速搭建项目

说人话

脚手架建立的是前端的工程

他有以下特点

  • 可以帮助我们快速生成项目的基础代码
  • 脚手架工具的项目模板经过了开发者的提炼和检验,一定程度上代表了某类项目的最佳实践
  • 脚手架工具支持使用自定义模板,我们可以根据不同的项目进行“定制”

构建工具

所谓构建工具,我的理解其实就是解放我们双手的自动化的兼容不同的代码的转换工具

es语法现在已经更新到了ECMAScript2024,我们在写代码的时候就可以用最新的语法

可浏览器做不到啊,因为他不认识,于是这时候就需要有一种工具能自动化的将代码打包成浏览器认识的代码

这时候就需要构建工具

所谓前端构建工具 简单的说就是将我们开发环境的代码,转化成生产环境可用来部署的代码。

image.png

那么脚手架跟构建工具有什么区别呢?

区别

我们知道脚手架是为了创建项目的,而构建工具是为了打包项目的

也就是说,脚手架中包含构建工具,并且传统意义上的构建工具(注意:这里不一定是前端构建工具)可构建脚手架

所以他们是可能一个交叉关系,

image.png

很绕是吧,总而言之,言而总之,我们只需要理解,他们一个是打包的,一个是制作打包的就可以了,这也不是咱们本次的重点

当然,只拿前端构建工具来说应该是个包含关系(脚手架包含构建工具)

在vite 出现之前,我们还是要礼貌性的介绍一下他的前辈们

其他的构建工具们

在vite 出现之前,

有远古时代的browserifygruntglup ,有传统的WebpackRollupParcel,也有现代的EsbuildVite 

他们的出现分别是为了解决不同的痛点承载不同的使命。有的已经退出历史舞台,有的即将退出历史舞台

有人深入群众,地位不可撼动,总之,感谢他们,让我们看到到了这辉煌而又繁荣的前端江湖

在这个江湖中,如果说地位最牢固的,当属webpack

image.png

那么他到底和vite 有什么区别呢?

vite 和 webpack 的区别

开始之前我们来说一下 vite 的优点

  • 1.光速启动
  • 2.热模块替换
  • 3.按需编译

看了以上这三个优点,我们就可以用一句话概括,vite 和 webpack 在生产环境没有区别

vite 主要解决的问题是开发环境的体验问题

他对比webpack用一句很中肯的话可以评价 生态落后,技术领先,开发体验好

我们一个个来解释

生态落后

这个就不需要多说了,老牌打包工具webpack 插件生态琳琅满目,只要你业务能用到的,基本上都能找到插件使用,这一点,我对后来的后起之秀们说,革命尚未成功,同志仍需努力

vite虽然继承了 rollup 的一些插件机制,可与老大哥还是有一定差距

技术领先

这个就更不用多说了, 使用esbuild 插件机制 常用业务中常用功能都做到了顶尖, 而反观 webpack就是,单拎出来哪一项都不行,总体来看条件还行

开发体验好

这个但凡用过 webpackvite 的 对比下就知道,高下立判

所以,大家在选用构建工具的时候,请根据自身项目的需求,酌情选择,就好像择偶一样

你是想要个好看的,还是想要个条件好的

请想清楚!!!!

vite原理

说起原理,其实这是一个老生常谈的问题,因为前面有无说的人讲过了 ——vite 在开发环境就是利用 <script type="module"> 现代浏览器支持模块化语法的能力,动态按需加载代码

但我认为其实不是这么简单

所以这一次,结合一个项目来简单研究下原理

<div id="app"></div>
<script type="module">
  import { createApp } from 'vue'
  import Main from './Main.vue'
  createApp(Main).mount('#app')
</script>

上方就是我们的 html 文件,当我们去访问这个 html 文件的时候他 虽然他支持module 但也是执行不了的,因为vue路径找不到

所以虽然 vite 号称是利用module,但其实还是要对源文件做处理,他支持的 esm 模块,要是浏览器认识的模块才行,于是他也要对代码进行编译,只不过是即时编译

image.png

以上代码就是编译之后html文件

接下来执行执行其中代码 请求 vue.js文件 请求.vue文件

然后问题来了vue.js 浏览器好歹认识 可这个.vue怎么办呢?

这时候就是 vite的服务就起作用了,继续处理,本质上来讲, 当我访问到 .vue 文件在浏览器层面就要发请求了 要不就是文件系统请求,要不就是 http 请求

于是 vite 就可以上手段了,他对这个请求做处理不就好了嘛! 返回浏览器能看得懂的

源代码

<template>
  <h1>Vue version {{ version }}</h1>
  <div class="comments"><!--hello--></div>
  <pre>{{ time as string }}</pre>
  <div class="hmr-block">
    <Hmr />
  </div>
</template>

<script setup lang="ts">
import { version, defineAsyncComponent } from 'vue'
import Hmr from './Hmr.vue'

import { ref } from 'vue'

const TsGeneric = defineAsyncComponent(() => import('./TsGeneric.vue'))

const time = ref('loading...')

window.addEventListener('load', () => {
  setTimeout(() => {
    const [entry] = performance.getEntriesByType('navigation')
    time.value = `loaded in ${entry.duration.toFixed(2)}ms.`
  }, 0)
})
</script>
<style scoped>
.comments {
  color: red;
}
</style>

摇身一变就会成为这样

// 浏览器热更新内容
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/Main.vue");import { defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";
import { version, defineAsyncComponent } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";
import Hmr from "/Hmr.vue";
import { ref } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";
// 组件js 编译后的信息
const _sfc_main = /* @__PURE__ */ _defineComponent({
  __name: "Main",
  setup(__props, { expose: __expose }) {
    __expose();
    const TsGeneric = defineAsyncComponent(() => import("/TsGeneric.vue"));
    const time = ref("loading...");
    window.addEventListener("load", () => {
      setTimeout(() => {
        const [entry] = performance.getEntriesByType("navigation");
        time.value = `loaded in ${entry.duration.toFixed(2)}ms.`;
      }, 0);
    });
    const __returned__ = { TsGeneric, time, version, Hmr };
    Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
    return __returned__;
  }
});
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";
const _hoisted_1 = { class: "hmr-block" };
// render函数
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock(
    _Fragment,
    null,
    [
      _createElementVNode("h1", null, "Vue version " + _toDisplayString($setup.version)),
      _cache[0] || (_cache[0] = _createElementVNode(
        "div",
        { class: "comments" },
        [
          _createCommentVNode("hello")
        ],
        -1
        /* HOISTED */
      )),
      _createElementVNode(
        "pre",
        null,
        _toDisplayString($setup.time),
        1
        /* TEXT */
      ),
      _createElementVNode("div", _hoisted_1, [
        _createVNode($setup["Hmr"])
      ])
    ],
    64
    /* STABLE_FRAGMENT */
  );
}
import "/Main.vue?t=1723640466717&vue&type=style&index=0&scoped=4902c357&lang.css";
_sfc_main.__hmrId = "4902c357";
typeof __VUE_HMR_RUNTIME__ !== "undefined" && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main);
import.meta.hot.accept((mod) => {
  if (!mod) return;
  const { default: updated, _rerender_only } = mod;
  if (_rerender_only) {
    __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
  } else {
    __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
  }
});
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
export default /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render], ["__scopeId", "data-v-4902c357"], ["__file", "/Users/a58/open_source_project/vite-plugin-vue/playground/vue/Main.vue"]]);

然后我们有惊奇的发现css没有处理,别急,我们发现,有这样一行代码

import "/Main.vue?t=1723640466717&vue&type=style&index=0&scoped=4902c357&lang.css";

他其实本质上将 当做一个请求重新处理了,结果如下

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/Main.vue?vue&type=style&index=0&scoped=4902c357&lang.css");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client"
const __vite__id = "/Users/a58/open_source_project/vite-plugin-vue/playground/vue/Main.vue?vue&type=style&index=0&scoped=4902c357&lang.css"
const __vite__css = "\n.comments[data-v-4902c357] {\n  color: red;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
import.meta.hot.accept()
import.meta.hot.prune(()=>__vite__removeStyle(__vite__id))

他将 css 内容变成 js,通过执行js动态的将css 挂在dom

所以 vite 的原理就是将编译好的各种文件变成模块化的 js执行,js 的执行过程中,引用了vuereact 等框架 从而绘制出页面

如此一来,相信大家对vite 原理应该有了初步的理解,其实在 vite 的背后并没有大家想的只是利用type="module的能力去做资源的读取,是一个资源服务器

本质上,他就是一个强大的web服务 对于所有的请求做拦截,读取本地资源做对应的处理,变成浏览器能看懂的 esm代码,他背后承载着,打包收发请求编译热更新 等各种操作

vite 的本质: 利用浏览器type="module能力和 http 能力的web打包服务器

源码解读

说完了原理,我们知道他是讲源码中的文件,处理成 esm 的js 文件顺序执行,最终绘制出页面

那么他是怎么实现的呢?

这里我实现了一个 mini-vite 供大家参考 mini-vite

接下来我们就简单的解读一下源码

少说废话,先看图

image.png

上图中,我们简单介绍了,vite 初始化以及运行图 流程图

这里由于篇幅关系,我们就用简写的方式研究一下他的内部构造

我们主要需要研究一下几个方向

  • 1、怎样建立 web 服务器的
  • 2、怎样处理编译的
  • 3、hmr 热更新怎么处理的
  • 4、为什么这么快
  • 5、为什么能按需处理

1、怎样建立 web 服务器的

建立web 服务器,其实很简单了,其实我们常用的 web服务框架都可koaexpressconnect 这种小而美的都可以,源码中选择的就是connect

服务器选好了,我们就要开始启动了,但是我们发现,在日常的开发中,是这一堆命令

 "scripts": {
    "dev": "vite",
    "build": "vite build",
    "debug": "node --inspect-brk vite",
    "preview": "vite preview"
  },

于是我们就又需要有命令行工具,这样当我们启动 vite 命令的时候 才能启动命令行工具来启动服务

那么怎么启动命令行工具呢?

我们只需要在 node_modules的包中,对应下载的项目的package.json中加入以下命令

 "bin": {
    "mini-vite": "bin/mini-vite"
  },

如果有不明白 bin是个什么东西请,去温习下npm相关内容

这样就可以正式开始了

const cli = cac()
//命令行工具 跟commander类似,   比如输入 vite serve  vite dev 执行对应的方法
cli
  .command('[root]', 'Run the development server')
  .alias('serve')
  .alias('dev')
  .action(async (option) => {
    // 核心 server 启动方法
    await startDevServer(option)
  })

以上代码中就可以拿到命令行的参数,执行不同的逻辑,我们本次就是 dev

接下来就是启动服务的时刻了,我们简化了源码中的流程,只保留核心逻辑

export async function startDevServer(type) {
  // Connect是一个可扩展的HTTP服务器框架,类似于 koa
  const app = connect()
  //process.cwd() 是一个方法,用于获取 Node.js 进程的当前工作目录
  const root = process.cwd()
  //  时间戳
  const startTime = Date.now()
  // 得到插件数组
  const plugins = resolvePlugins()
  // 初始化插件
  const pluginContainer = createPluginContainer(plugins)
  // 初始化模块映射实例,主要后期用来快速存取用的
  const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url))
  // 监听文件变动,后期用来做热跟新
  const watcher = chokidar.watch(root, {
    ignored: ['**/node_modules/**', '**/.git/**'],
    ignoreInitial: true,
  })
  // WebSocket 对象 后期热更新就靠它
  const ws = createWebSocketServer(app)
  // 开发服务器上下文
  const serverContext: ServerContext = {
    root: normalizePath(process.cwd()),
    app,
    pluginContainer,
    plugins,
    moduleGraph,
    ws,
    type: type == 'react' ? 'react' : 'vue',
    watcher,
  }
  // 绑定热更新
  bindingHMREvents(serverContext)
  for (const plugin of plugins) {
    // 调用插件的配置方法,主要就是在插件中保存当前上下文实例
    // 后续插件中可能用的到
    if (plugin.configureServer) {
      await plugin.configureServer(serverContext)
    }
  }
  // 添加洋葱圈中间件,每次请求都会过一遍中间件
  // 根据不同类型,返回不同的资源
  // 核心编译逻辑
  app.use(transformMiddleware(serverContext))

  // 入口 HTML 资源
  app.use(indexHtmlMiddware(serverContext))

  // 静态资源
  app.use(staticMiddleware(serverContext.root))
  // 启动服务器
  app.listen(3000, async () => {
    await optimize(root, type == 'react' ? 'src/main.tsx' : 'src/main.ts')
    console.log(
      green('🚀 No-Bundle 服务已经成功启动!'),
      `耗时: ${Date.now() - startTime}ms`,
    )
    console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`)
  })
}

以上代码中,就是服务启动的流程,启动过程中,通过中间件将编译 热更新 等流程全部初始化完毕,接下来,当浏览器请求的时候服务就开始加载对应资源返回给客户端

2、怎样处理编译

处理编译其实就是利用vite 最优秀的插件设计,其实写过 rollup插件 的jym 就会发现vite 跟他插件设计几乎相同

我们先来简单的找个 rollup 插件举例

// @filename: rollup-plugin-my-example.js
export default function myExample () {
  return {
    name: 'my-example', // 此名称将出现在警告和错误中
    resolveId ( source ) {
      if (source === 'virtual-module') {
        // 这表示 rollup 不应询问其他插件或
        // 从文件系统检查以找到此 ID
        return source;
      }
      return null; // 其他ID应按通常方式处理
    },
    load ( id ) {
      if (id === 'virtual-module') {
        // "virtual-module"的源代码
        return 'export default "This is virtual!"';
      }
      return null; // 其他ID应按通常方式处理
    }
  };
}

// @filename: rollup.config.js
import myExample from './rollup-plugin-my-example.js';
export default ({
  input: 'virtual-module', // 由我们的插件解析
  plugins: [myExample()],
  output: [{
    file: 'bundle.js',
    format: 'es'
  }]
});

接下来我们用 vite 如法炮制一个简单的插件,就用源码中最简单的json 插件举例,


/**
 * https://github.com/rollup/plugins/blob/master/packages/json/src/index.js
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file at
 * https://github.com/rollup/plugins/blob/master/LICENSE
 */

import { dataToEsm } from '@rollup/pluginutils'
import { SPECIAL_QUERY_RE } from '../constants'
import type { Plugin } from '../plugin'
import { stripBomTag } from '../utils'

export interface JsonOptions {
  /**
   * Generate a named export for every property of the JSON object
   * @default true
   */
  namedExports?: boolean
  /**
   * Generate performant output as JSON.parse("stringified").
   * Enabling this will disable namedExports.
   * @default false
   */
  stringify?: boolean
}

// Custom json filter for vite
const jsonExtRE = /\.json(?:$|\?)(?!commonjs-(?:proxy|external))/

const jsonLangs = `\\.(?:json|json5)(?:$|\\?)`
const jsonLangRE = new RegExp(jsonLangs)
export const isJSONRequest = (request: string): boolean =>
  jsonLangRE.test(request)

export function jsonPlugin(
  options: JsonOptions = {},
  isBuild: boolean,
): Plugin {
  return {
    name: 'vite:json',
    //只有编译方法
    transform(json, id) {
      // 判断当前文件是不是 json 类型 如果不是就不处理直接交给中间件插件处理
      if (!jsonExtRE.test(id)) return null
      // 含有特殊的 json也不处理
      if (SPECIAL_QUERY_RE.test(id)) return null
      // 去除文件的 bom 头
      json = stripBomTag(json)

      try {
        // vite传入配置能提升性能
        if (options.stringify) {
          // 生产环境
          if (isBuild) {
            return {
              // during build, parse then double-stringify to remove all
              // unnecessary whitespaces to reduce bundle size.
              code: `export default JSON.parse(${JSON.stringify(
                JSON.stringify(JSON.parse(json)),
              )})`,
              map: { mappings: '' },
            }
          } else {
            // 开发环境直接返回
            return `export default JSON.parse(${JSON.stringify(json)})`
          }
        }
        // 默认逻辑
        // 转为对象
        const parsed = JSON.parse(json)
        return {
          //将给定的数据转换为ES模块导出语法的字符串。
          code: dataToEsm(parsed, {
            preferConst: true,
            namedExports: options.namedExports,
          }),
          map: { mappings: '' },
        }
      } catch (e) {
        // 错误兜底处理暂时不看
        const position = extractJsonErrorPosition(e.message, json.length)
        const msg = position
          ? `, invalid JSON syntax found at position ${position}`
          : `.`
        this.error(`Failed to parse JSON file` + msg, position)
      }
    },
  }
}

export function extractJsonErrorPosition(
  errorMessage: string,
  inputLength: number,
): number | undefined {
  if (errorMessage.startsWith('Unexpected end of JSON input')) {
    return inputLength - 1
  }

  const errorMessageList = /at position (\d+)/.exec(errorMessage)
  return errorMessageList
    ? Math.max(parseInt(errorMessageList[1], 10) - 1, 0)
    : undefined
}


上述代码中,就是一个插件的简单的实现过程 他的返回值主要包含resolveIdloadtransform 三个方法,当然,都不是必须的

那么接下来问题来了,插件有了,他是怎么执行实现编译的呢?

很简单,还记得服务的中间件机制吗?

插件系统 和中间件机制类似,当我们注册插件的中间件以后,请求过来以后,注册的插件就会遍历启动相关插件 顺序的对代码进行处理, 最终返回目标结果

image.png

上图就是 json文件的请求结果

那么接下来问题又来了,他怎么实现流水线顺序执行的呢?

很简单,利用 for 循环,当请求来了以后,我们利用注册的中间件 处理得到请求 url

然后对 url 进行处理,for 循环所有插件,插件流水线处理内容,这里我们简单的用简版代码举个例子

 // 核心编译逻辑中间件,当请求的时候每次执行执行中间件逻辑
  app.use(transformMiddleware(serverContext))
 // 中间件逻辑
 
 export function transformMiddleware(
  serverContext: ServerContext,
): NextHandleFunction {
  return async (req, res, next) => {
    // 如果请求方法不是 GET 或者请求 URL 为空,则跳过当前中间件,交给下一个中间件或者路由处理函数
    // 相当于兜底处理
    if (req.method !== 'GET' || !req.url) {
      return next()
    }
    const url = req.url
    debug('transformMiddleware: %s', url)
    // transform JS and CSS request
    // 判断是否是 js css,vue 或者静态资源类型
    if (
      isJSRequest(url) ||
      isVue(url) ||
      isCSSRequest(url) ||
      // 静态资源的 import 请求,如 import logo from './logo.svg?import';
      isImportRequest(url)
    ) {
      // 核心处理函数 传入 url 和上下文对象
      let result = await transformRequest(url, serverContext)
      if (!result) {
        // 如果没有结果,那么就交给下一个中间件处理
        return next()
      }
      // 如果有,并且不是一个字符串,我们拿到编译结果就可以了
      // 因为有的返回的可能是一个对象
      if (result && typeof result !== 'string') {
        result = result.code
      }
      res.statusCode = 200
      res.setHeader('Content-Type', 'application/javascript')
      // 返回给客户端,下一个中间件就不走了
      return res.end(result)
    }
    // 如果什么都不命中,给加一个中间件处理

    return next()
  }
}

上述代码中,就是请求来了以后的处理逻辑,这里我们且不看其他内人,我们关注pluginContainer.transform方法,就是核心的编译逻辑,他是怎么做的呢?

 async transform(code, id) {
      const ctx = new Context() as any
      // 遍历所有插件
      for (const plugin of plugins) {
         // 找到有编译的方法
        if (plugin.transform) {
           // 执行编译完成后交给下一个插件
          const result = await plugin.transform.call(ctx, code, id)
          if (!result) continue
          if (typeof result === 'string') {
            code = result
          } else if (result.code) {
            code = result.code
          }
        }
      }
      return { code }
    },

上述代码中你就会发现,他是利用 for 循环遍历插件,处理code代码,最后得到我们想要的内容,至于插件的形式,就不再赘述,开文中已经展示过!

如此一来,vite 就通过插件的形式,高明的可拓展的实现我们的需求

3、hmr 热更新怎么处理的

hmr 是目前前端开发最重要的东西,因为谁都不想改动一个代码就得重启一下服务,用户体验很重要 那么他到底是怎么实现的呢?

其实本质很简单:利用 ws 的能力,主动推送变动内容通知客户端更新代码

当然,别看这么简单的事情,也是要严格的执行三步走战略

  • 1、 监听文件变化
  • 2、建立 ws通信
  • 3、通知客户端更改代码

1、 监听文件变化

第一步就很简单了,我们只需要一个工具chokidar

  // 监听文件变动,后期用来做热跟新
  const watcher = chokidar.watch(root, {
    ignored: ['**/node_modules/**', '**/.git/**'],
    ignoreInitial: true,
  })
 

2、建立 ws通信

这一步 我们同样的也是用 node 的ws这个库

// 服务端启动 ws 服务
import connect from 'connect'
import { red } from 'picocolors'
import { WebSocketServer, WebSocket } from 'ws'
import { HMR_PORT } from './constants'

export function createWebSocketServer(server: connect.Server): {
  send: (msg: string) => void
  close: () => void
} {
  let wss: WebSocketServer
  // 启动服务端创建 ws 注意这里 port 要和客户端保持一致
  wss = new WebSocketServer({ port: HMR_PORT })
  wss.on('connection', (socket) => {
    socket.send(JSON.stringify({ type: 'connected' }))
  })

  wss.on('error', (e: Error & { code: string }) => {
    if (e.code !== 'EADDRINUSE') {
      console.error(red(`WebSocket server error:\n${e.stack || e.message}`))
    }
  })

  return {
    send(payload: Object) {
      const stringified = JSON.stringify(payload)
      wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          // 发送 ws 消息
          client.send(stringified)
        }
      })
    },

    close() {
      wss.close()
    },
  }
}
// 客户端连接
// 客户端 本身支持 ws 直接连接就可以了
const socket = new WebSocket(`ws://localhost:__HMR_PORT__`, 'vite-hmr')
socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data)).catch(console.error)
})

3、通知客户端更改代码

这是最重要的一步,当服务端监听到文件变化以后,发送通知

代码如下

export function bindingHMREvents(serverContext: ServerContext) {
  const { watcher, ws, root } = serverContext
  // 监听文件变化
  watcher.on('change', async (file) => {
    console.log(`✨${blue('[hmr]')} ${green(file)} changed`)
    const { moduleGraph } = serverContext
    // 确定改动文件
    await moduleGraph.invalidateModule(file)
    // 发送更新内容
    ws.send({
      type: 'update',
      updates: [
        {
          type: 'js-update',
          timestamp: Date.now(),
          path: '/' + getShortName(file, root),
          acceptedPath: '/' + getShortName(file, root),
        },
      ],
    })
  })
}

这里我们为了能让大家看明白,模拟一下简单的 js 变动,这里发送的内容其实很简单,只是变动的文件名,时间戳等信息

image.png

接下来就是客户端的更新问题了,也很简单重新请求一下这个js 文件即可

// 热更新逻辑
async function fetchUpdate({ path, timestamp }: Update) {
  const mod = hotModulesMap.get(path)
  if (!mod) return

  const moduleMap = new Map()
  const modulesToUpdate = new Set<string>()

  modulesToUpdate.add(path)

  await Promise.all(
    Array.from(modulesToUpdate).map(async (dep) => {
      const [path, query] = dep.split(`?`)
      try {
        // 这里会去请求新的文件,导入之后直接执行
        const newMod = await import(
          path + `?t=${timestamp}${query ? `&${query}` : ''}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {}
    }),
  )
}

image.png

当然,其实有些热更新细节,比如 css请求如何保存原始数据 等等,可能要费劲一点,但主要流程也是大致相同,我们理解主要原理即可

4、为什么这么快以及为什么能按需处理

这个其实在文章开头已经埋过伏笔,这两个问题,其实就是一个相辅相成问题,之所以快就是因为能按需处理,而按需处理就导致他非常快

好像还是很绕,我们来详细解释一下

在传统的 webpack 时代,我们的 serve 服务在启动之前是需要编译全部文件的,所以在启动之初是非常耗时的

Kapture 2024-08-15 at 14.48.11.gif

而到了 vite 时代,我们由于利用 浏览器对于 esm,我们只需要请求的时候服务处理成 esm 模块即可,所以,理论上来说启动服务只需要一瞬间,因为他省略了打包的逻辑,但当然也是有代价的,你请求页面的时候,就会即时编译,会有些许的损耗

但这也比启动的全量编译消耗小很多

这时候我们就解释了第一个问题 之所以快是因为启动的时候不用编译

我们在来解释第二个问题,为什么能按需处理

同样的在 webpack 时代,我们启动的时候要全量打包,之所以这么做是因为,我不知道你最开始要打开什么页面,进入哪个路由,因为是编译是前置的,所以要一股脑全给你

而由于 vite即时编译,于是我就能知道你要什么,你要什么就给什么, 当然就做到了按需加载

最后

终于写完了希望各位 jym 给个三连,老骥下台鞠躬!!!!