Skip to content

https://zhuanlan.zhihu.com/p/564104010

https://zhuanlan.zhihu.com/p/424842555

https://www.zhihu.com/question/453317359/answer/2166643816

  • 浏览器不能处理裸模块导入:浏览器只能处理以 /、./ 或 ../ 开头的模块路径,不能直接处理 node_modules 中的模块。
  • 依赖预构建的原因:
    • CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
    • 性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。

有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。

通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

  • 实现 hmr:

  • 2024年12月17日 10点40分插件机制的实现其实是用的 koa 的 use 函数;

  • 2024年12月17日 16点31分node 下执行 esm 模块规范的 js 文件,要求这个 js 文件在使用相对导入(以 ./ 或 ../ 开头)必须使用完整的文件扩展名(.js、.mjs、.cjs 等),比如 import a from "./a.js",而不可以是 import a from "./a"

    • 这也涉及了 typescript 的 tsconfig.json 中关于module:nodeNext的知识。
  • es-module-lexer:

前置知识

vite vs webpack

vite 为什么出现?

koa

我们简单了解下 koa,koa是Express的下一代基于Node.js的web框架。

koa 是对 node.js 的 http 进行了封装,提供了一些便捷的 api 用于开发 web 应用。

首先来看下 koa 的基本使用:

javascript
const Koa = require('koa');
// 创建一个Koa实例
const app = new Koa();
// koa的核心概念:middware中间件
app.use(async (ctx) => {
  ctx.body = 'Hello World';
});
// 在3000端口监听
app.listen(3000);

上边的示例创建了一个 koa 实例,这个实例监听 3000 端口,当监听到请求进来时,会交给 middware 中间件函数进行执行,最终返回"hello world"。

middware “中间件”函数是 koa 的核心,koa 实例会把ctxnext参数传递给它,ctx上下文对象是 koa 对requestresponse等对象的封装,next是koa传入的将要处理的下一个异步函数。

第二个我们需要了解的middware函数的串联执行:

koa实例可以通过use支持挂载多个 middware 函数,从而实现类似串联执行的效果。借助于 es6 的 asyncawait函数,koa把很多async函数组成一个处理链,每个async函数都可以做一些自己的事情,然后用await next()来调用下一个async函数。这些middleware可以组合起来,完成很多有用的功能。

koa 会按照middware函数注册的顺序进行执行,所以我们需要合理的分配middware函数的顺序,以及next函数的执行时机来完成需求。

bare import 解决

我们在 js 代码中会通过这种方式来引入 我们安装的包的内容 import Vue from 'vue',这种导入方式称为bare import裸导入,而浏览器是不支持这种方式的。(因为浏览器并不知道去哪里找“vue”)。

所以我们需要去解决这个问题,解决的方式是对 裸导入进行重写,在裸导入的代码中添加一个我们约定好的前缀,

css

vite 支持在 js 中导入 css 文件,比如说像下边这样:

javascript
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).mount('#app')

但我们知道,js 本身是不支持这种用法的,所以 vite 需要对这种情况进行特殊处理,处理的思路也很简单,把 css 转换成 js 代码,再插入到浏览器中:

typescript
// 包装css代码为js
const codeGenCss = (css: string) => {
  return `
    const insertStyle = (css) => {
        let el = document.createElement('style')
        el.setAttribute('type', 'text/css')
        el.innerHTML = css
        document.head.appendChild(el)
    }
    insertStyle(\`${css}\`)
    export default insertStyle
  `
}
// middleware
export const cssPlugin = (context: PluginContext) => {
  context.app.use(async (ctx, next) => {
    // 匹配已css结尾的请求
    if (/\.css\??[^.]*$/.test(ctx.path)) {
      let cssRes = fs.readFileSync(
        path.join(context.basePath, ctx.path),
        "utf-8"
      );
      cssRes = codeGenCss(cssRes)
      ctx.type = 'application/javascript'
      ctx.body = cssRes
      return
    }
    await next()
  })
}

重新运行npm run dev,可以看到 style.css 被正确的转换了

同时 css 也被插入到了 html 中了:

scss 等 css 扩展语言

在项目开发中,我们通常会使用 scss、less 等 css 扩展语言,所以也需要支持这些扩展语言的导入。

实现也很简单,我们在前边 cssplugin 基础上添加对scss等后缀判断,并调用相应的扩展语言的编译器进行处理即可。

typescript
import sass from 'sass'
export const cssPlugin = (context: PluginContext) => {
  context.app.use(async (ctx, next) => {
    // ...省略代码
    if (/\.scss\??[^.]*$/.test(ctx.path)) {
      let scssRes = fs.readFileSync(
        path.join(context.basePath, ctx.path),
        "utf-8"
      );
      const result = codeGenCss(
        // 使用sass对原始的scss
        sass.compileString(scssRes).css.toString()
      )
      ctx.type = 'application/javascript'
      ctx.body = result
      return
    }
  })
}

vue

vue 渲染机制

充分了解 vite 如何处理 vue,需要了解 vue 的渲染机制,可以查看官网了解详细信息。

vue 渲染机制是基于“虚拟 dom”这个概念构建的。虚拟 dom本质上就是 js 对象,用于描述要渲染的 dom。

比如说下边这个虚拟 dom 代表了一个 div 元素,可以用 children 属性指定这个节点包含的子节点。

typescript
const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* 更多 vnode */
  ]
}

有了虚拟 dom,我们最终需要把它转换为真实 dom。“渲染器”是 vue 底层用于将“虚拟 dom”转换为真实 dom 的核心部分。

而组件其实就是一种特殊类型的虚拟 dom,比如我们可以使用包含 render 函数的虚拟 dom 来描述一个组件:

typescript
const component = {
  data:{
  },
  render(){
  }
}

而我们编写的 sfc(单文件组件)是 vue 为我们提供的一种方便编写的机制,会被 vue 编译器转换为“虚拟 dom”,被渲染器渲染为真实 dom。

这也是为什么我们通过 cdn 的方式引入 vue 的话,无法使用 sfc 语法书写组件。

sfc 中的 template 部分会被编译成渲染函数,这个渲染函数执行结果会返回“虚拟 dom”。

在这个网站中可以看到 tempalte 编译为渲染函数的结果:

下边这张图就是 组件渲染为真实 dom 的流程:

了解了这些,我们再来梳理下 vite 处理 vue sfc 文件的核心思路就是将 sfc 转换为一个组件选项对象,也就是一个 js 对象,最终交给渲染器来执行。

在 vue 官网的 playground 中也可以看到.vue文件被转换为 js 后的结果:

vue/compile-sfc

@vue/compile-sfc是 vue 官方提供的将 编译 sfc 的包,我们来看下:

我们知道 vue 的 sfc 是由三个部分组成的,templatescriptstyle@vue/compile-sfc提供了针对这三个部分的 转换 API。

首先需要使用 parse 函数编译 vue sfc 文件,我们来用 app.vue 来看看编译后的结果:

可以看到 parse 函数并没做太多的处理,只是把 sfc 中 template、script、style 三个部分给解析出来。

对于 script部分,需要用到@vue/compiler-sfccompilerScript函数,这个函数会对<script>部分的代码做一些转换,生成一份最终的组件定义代码,包括:

  • 如果使用的是<script setup> 语法糖,将其转换为普通的 <script>代码
  • 处理各种宏函数的编译: defineProps

我们来看看对 app.vue 中 script 部分转换前后的代码:

转换前:

typescript
<script setup>
import HelloWorld from "./components/HelloWorld.vue";
import { ref } from 'vue'
const text = ref('')
</script>

转换后:

typescript
import HelloWorld from "./components/HelloWorld.vue";
import { ref } from 'vue';

export function setup() {
  const text = ref('');
  return {
    text
  };
}
export default { setup };

对于 template 部分,需要用到compileTemplate函数;对于 Style 部分,需要用到compileStyle函数。

接下来我们来具体编写 我们的mini-vite中对于.vue 文件的处理。

script 部分

首先添加 vuePlugin.ts,并使用vue/compile-sfc的 parse 函数解析 vue 文件;

typescript
import { compileScript, compileStyle, compileTemplate, parse, rewriteDefault } from 'vue/compiler-sfc'
import fs from 'fs'
import path from 'path'
import { parseBareImport } from './modulePlugin.js'
import { hash } from '../utils/index.js'
import type Koa from 'koa'

interface PluginContext {
  root: string // 项目根目录
  app: Koa
  basePath: string // play目录
}

export const vuePlugin = (context: PluginContext) => {
  context.app.use(async (ctx, next) => {
    // 拦截请求路径中包含 .vue请求
    if (/\.vue\??[^.]*$/.test(ctx.path)) {
      let source = fs.readFileSync(
        path.join(context.basePath, ctx.path),
        "utf-8"
      );
      let { descriptor } = parse(source);
      console.log(descriptor)
    }
    await next()
  })
}

我们来执行下,看看 descriptor:

接下来我们处理 script 部分:

typescript
export const vuePlugin = (context: PluginContext) => {
  context.app.use(async (ctx, next) => {
    // 拦截请求路径中包含 .vue请求
    if (/\.vue\??[^.]*$/.test(ctx.path)) {
      let source = fs.readFileSync(
        path.join(context.basePath, ctx.path),
        "utf-8"
      );
      let { descriptor } = parse(source);
      // compileScript函数需要id参数,用于为每一个组件添加唯一标识以及热更新相关;
      let id = descriptor.id = hash(path.resolve(context.basePath, descriptor.filename))
      // 处理script部分
      let scriptRes = compileScript(descriptor, {
        id: descriptor.id,
        isProd: false,
      })
      // 处理裸导入
      if (scriptRes) {
        let bareJs = await parseBareImport(scriptRes.content)
        scriptRes.content = rewriteDefault(bareJs, "__script");
      }
    }
    await next()
  })
}

注意因为compileScript编译后的 js 还存在裸导入(类似于import {ref} from 'vue'),所以我们添加了裸导入的处理逻辑,并用rewriteDefault重写了默认导出。

最后结果如图:

typescript
import HelloWorld from "./components/HelloWorld.vue?import";
import { ref } from '/@module/vue?import'

const __script = {
  name: 'App',
  components: {
    HelloWorld
  },
  setup() {
    const text = ref('vite+vue')
    return { text }
  }
}

template 部分

接下来我们来处理 template 部分,使用compileTemplate函数:

typescript

// 拦截请求路径中包含 .vue请求
if (/\.vue\??[^.]*$/.test(ctx.path)) {
  // 省略代码
  // template部分,把template部分转换为一个render函数
  let templateRender
  if (descriptor.template) {
    templateRender = compileTemplate({
      id: descriptor.id,
      filename: descriptor.filename,
      source: descriptor.template?.content
    }).code
    // 处理裸导入
    templateRender = await parseBareImport(templateRender)
    result += `\n${templateRender}`
    result += `\n__script.render = render`
  }
}

App.vue template部分编译后的结果如下:

typescript
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/@module/vue?import"

const _hoisted_1 = { class: "title" }

export function render(_ctx, _cache) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.text), 1 /* TEXT */),
    _createVNode(_component_HelloWorld, { msg: "Hello World Component" })
  ], 64 /* STABLE_FRAGMENT */))
}
__script.render = render

style 部分

因为后续需要实现 hmr,所以这里style 部分处理思路是转换为一个单独的 import 请求:

typescript
if (/\.vue\??[^.]*$/.test(ctx.path)) {
  // 省略代码
  // style部分,转换为一个单独的请求
  let styleRequest = ctx.path + `?type=style`
  result += `\nimport ${JSON.stringify(styleRequest)}`
}

最后改写返回的 js 文件默认导出即可。我们来看下完整的 vue sfc 解析代码:

typescript
import { PluginContext } from './index.js';
import { compileScript, compileStyle, compileTemplate, parse, rewriteDefault } from 'vue/compiler-sfc'
import fs from 'fs'
import path from 'path'
import { parseBareImport } from './modulePlugin.js'
import { hash } from '../utils/index.js'

export const vuePlugin = (context: PluginContext) => {
  context.app.use(async (ctx, next) => {
    if (/\.vue\??[^.]*$/.test(ctx.path)) {
      let source = fs.readFileSync(
        path.join(context.basePath, ctx.path),
        "utf-8"
      );
      let result = ''
      let { descriptor } = parse(source);
      let id = hash(path.resolve(context.basePath, descriptor.filename))
      descriptor.id = id
      // js部分
      let scriptRes = compileScript(descriptor, {
        id: descriptor.id,
        isProd: false,
      })
      if (scriptRes) {
        let bareJs = await parseBareImport(scriptRes.content)
        scriptRes.content = rewriteDefault(bareJs, "__script");
      }
      result += scriptRes.content
      // template部分,把template部分转换为一个render函数
      let templateRender
      if (descriptor.template) {
        templateRender = compileTemplate({
          id: descriptor.id,
          filename: descriptor.filename,
          source: descriptor.template?.content
        }).code
        templateRender = await parseBareImport(templateRender)
      }
      result += `\n${templateRender}`
      result += `\n__script.render = render`
      // style部分,转换为一个单独的请求
      let styleRequest = ctx.path + `?type=style`
      result += `\nimport ${JSON.stringify(styleRequest)}`
      // 最终把__script作为默认导出
      result += `\nexport default __script`
      ctx.type = 'application/javascript'
      ctx.body = result
    }
    await next()
  })
}

最后,npm run dev我们应该就可以看到浏览器中正确渲染了 vue:

环境变量与模式实现

我们在开发项目时,经常会使用环境变量存储不同环境下可变的数据。你是否好奇背后是怎么实现的?

首先我们先看看 vite 中环境变量的基本使用, vite 是在import.meta.env对象上暴露的环境变量,我们在项目根目录下声明的.env.[mode]文件会被加载,加载的环境变量也会通过 import.meta.env 以字符串形式暴露给客户端源码。

比如:

plain
VITE_SOME_KEY=123

在代码中可以访问:

typescript
console.log(import.meta.env.VITE_SOME_KEY) // "123"

那 vite 是如何实现环境变量的呢?我们打开 vite-vue 的在线项目,简单修改下 App.vue 添加环境变量,看下效果:

我们打开控制台,来看下 app.vue 编译后的文件:

可以看到 vite 在代码顶部,添加了赋值语句,将声明的环境变量赋值给 import.meta.env。

好了,可以看出 vite 中环境变量的额实现并不复杂,我们来动手实现。首先我们先来实现如何传递当前模式参数给 vite,vite 才能知道去加载那个环境变量:

我们使用minimist这个库,有了它,我们就可以像这样来在命令行中 传递出参数:

node bin/vite.js --port 3000 --host localhost

我们修改 bin/vite.js:

typescript
#!/usr/bin/env node
import { createServer } from '../vite/dist/app.js'
import minimist from 'minimist'

const args = minimist(process.argv.slice(2))
createServer(args)

就可以正确的传递参数了;

接下来我们来实现读取环境变量文件.env.[mode]的逻辑: