飞道的博客

Vite 实现原理

462人阅读  评论(0)

Vite 实现原理
了解 Vite 的核心实现原理

Vite 概念

• Vite 是一个面向现代浏览器的一个 更轻、更快的 Web 应用开发工具
• 它基于 ECMAScript 标准原生模块系统(ES Module)实现
• 它的出现是为了解决 webpack 在开发阶段,使用 webpack-dev-server 冷启动时间过长、webpack HMR 热更新响应慢的问题
• 使用 Vite 创建的项目,就是一个普通的 Vue3 项目。相比于 VueCli 创建的项目,少了很多配置文件和依赖

Vite 项目依赖

Vite 创建的项目,开发依赖只有:
• Vite: 命令行工具
• @vue/compiler-sfc:专门用于编译 .vue 结尾的单文件组件的工具。Vue2 中使用的是 vue-template-compiler
Vite 只支持 Vue3 版本,在创建项目的时候,通过指定不同模板可以支持其他框架

基础使用

Vite 项目中提供了两个子命令:
• vite serve:用于开启一个用于开发的服务器,启动服务器的时候,不需要编译所有代码文件,启动速度非常快
• vite build:打包
Vite & vue-cli-service serve

Vite & vue-cli-service serve

  • vite

    在运行 vite serve 的时候,不需要编译打包,直接开启一个 web 服务器,当浏览器请求服务器,例如请求一个单文件组件的时候,此时才在服务器端编译单文件组件,然后把编译结果返回给浏览器。
  • vue-cli-service serve

    当运行 vue-cli-service serve 的时候,内部会使用 webpack 打包所有模块,如果模块比较多,打包的速度比较慢。把打包的结果存储到内存中,然后开启一个 web 服务器。浏览器请求服务器,把内存中缓存的结果直接返回给浏览器。
    webpack 这类工具是提前将所有模块编译打包进 bundle 里,换句话说,不管模块有没有被使用到,都要被编译打包到 bundle 里
    Vite 利用现代浏览器原生支持的 ES Module 模块化的方式,省略对模块的打包,对于需要编译的组件(例如单文件组件,样式模块等),vite 采用另一种模式 —— 即时编译,也就是说只有具体去请求某个文件的时候,才会在服务端编译这个文件,所以这种模式体现在按需编译,编译速度会更快

HMR

  • Vite HMR
    • Vite 默认也支持 HMR 模块热更新,相对于 webpack 的 HMR 性能更好,因为 Vite 只需要立即编译当前所需要的文件即可,所以响应 速度非常快。
  • Webpack HMR
    • 修改某个文件过后,会自动以这个文件为入口重新 build 一次,所有涉及到的依赖也都会被加载一遍,所以相应速度相对慢一些

Build

Vite 打包使用的是 vite build 命令

  • 内部使用 Rollup 打包,最终还是会将文件提前编译并打包到一起
  • 对于代码分割的功能,vite 内部采用的是原生的动态导入的特性实现的,所以打包结果只能支持现代浏览器
    • 动态导入特性还是有相应的 Polyfill 的

打包 or 不打包

随着 Vite 的出现,引发了另一个问题:究竟有没有必要去打包应用?

  • 使用 webpack 打包的两个原因:
    • 浏览器环境并不支持模块化
    • 零散的模块文件会产生大量的 HTTP 请求
  1. 随着现代浏览器对 ES 标准支持的逐渐完善,第一个问题慢慢的已经不存在了,现阶段绝大多数浏览器都是支持 ES Module 特性的
  2. 当 JS 文件比较多的时候,每个 JS 文件都要发送一次请求,每个请求都要创建一个连接,为了减少请求服务器的次数,所以打包成一个文件。这个问题 HTTP2 已经解决,它可以复用连接

浏览器对 ES Module 的支持


IE11 是不支持 ES Module 的,所以如果项目需要支持 IE11,则需要使用过去的打包方式。
现代的浏览器都是支持 ES Module 的
开箱即用
Vite 创建的项目,几乎不需要配置的,默认就支持 TypeScript
• TypeScript - 内置支持
• less/sass/stylus/postcss - 内置支持(需要单独安装编译器)
• JSX
• Web Assembly

Vite 特性

Vite 带来的优势主要体现在提升开发者在开发过程中的体验
• 快速冷启动:web 服务器不需要等待,可以立即启动
• 模块热更新:只会编译当前所需的文件,几乎是实时的
• 按需编译:避免编译没有用到的文件
• 开箱即用:避免各种 loader 和 plugin 的配置

Vite 实现原理

通过实现一个自己的 vite 工具,来深入了解 vite 的工作原理

Vite 核心功能

• 启动一个静态 web 服务器:将当前项目目录作为静态文件服务器的根目录
• 编译单文件组件
• 拦截浏览器不识别的模块,并处理
• HMR:通过 web socket 实现

静态 web 服务器

实现一个能够开启 web 静态服务器的命令行工具,把当前运行 vite 的目录,作为静态 web 服务器的根目录
• 创建 vite-cli 文件夹,并使用 npm init 初始化
• 安装 koa、koa-send(静态文件处理的中间件) 模块
npm i koa koa-send -S

  • 配置 package.json 文件,添加 bin 字段,值为 index.js
{
   
  "name": "vite-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": "index.js",
  "license": "ISC",
  "dependencies": {
   
    "koa": "^2.13.1",
    "koa-send": "^5.0.1"
  }
}
  • index.js
    开发基于node的命令行工具,所以需要加这个头
#! /usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')

const app = new Koa() // 创建Koa的实例
// 接下来使用Koa开发静态web服务器,默认返回根目录中的index.html
// 创建一个中间件,负责处理静态文件,默认加载当前目录下,也就是运行该命令行工具目录中的index.html
// 1. 开启静态文件服务器
app.use(async (ctx, next) => {
   
  // 默认返回运行该命令行工具的目录下的 index.html
  // ctx 上下文 ctx.path当前请求的路径
  await send(ctx, ctx.path, {
   
  	// 配置web服务的根目录 
    root: process.cwd(), // 运行命令行工具(node程序)的目录
    index: 'index.html' // 默认页面
  })

  await next()  // 因为是中间件,调用next执行下一个中间件 
})

app.listen(3000, () => {
   
  console.log('server running at http://localhost:3000')
})
  • 使用 npm link(会当前的这个项目链接到npm安装目录里) 将项目 link 到全局
  • 进入 vue3 创建的项目,在项目根目录下运行 vite-cli
  • 打开 http://localhost:3000


    解析 vue 文件失败了,说加载模块的时候需要使用路径,在 main.js 文件中 import 的 vue 是模块行为,浏览器不认识。

    在 Vite 中,在加载 main.js 的时候,首先会去处理第三方模块的路径。所以需要在服务器端手动处理这个路径问题,当请求一个模块的时候,需要将这个模块中加载第三方模块 import 的路径进行处理

    main.js 的响应头是 JavaScript,在 web 服务器输出文件之前,先判断当前返回的文件是否是 js 文件,如果是的话再来处理里面的第三方模块的路径,然后再去请求 /@modules/vue.js,在服务器中处理这个请求 —— 在 node_modules 中加载模块

加载第三方模块的问题

修改第三方模块的路径
创建两个中间件:
• 把加载第三方模块的 import 中的路径改成 /@modules/<模块名称>


// 把流转换成字符串
const streamToString = stream => new Promise((resolve, reject) => {
   
  const chunks = [] // 存储读取到的buffer
  stream.on('data', chunk => chunks.push(chunk)) // 注册stream的data事件,监听读取到的buffer
  stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
  stream.on('error', reject)
})

// 1. 开启静态文件服务器

// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
   
  // 在把文件返回给浏览器之前,判断当前文件是否是 JavaScript
  if (ctx.type === 'application/javascript') {
   
  // 找到文件中的内容,处理import中的路径
    const contents = await streamToString(ctx.body)
    // import Vue from 'vue'
    // import App from './App.vue'
    // 正则: 匹配 from './xxx'
    // (?![\.\/]) 排除 . 开头或者 / 开头
    // 将 (from ') 替换为 (from '/@modules/)
    ctx.body = contents.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
  }
})

重启服务器


当请求过来之后,判断请求路径中是否有 /@modules/<模块名称>,如果有的话,去 node_modules 中加载对应的模块

// 3. 加载第三方模块: 
// 将请求路径修改成 node_modules 中对应的模块路径, 然后继续交给处理静态文件的中间件继续处理
app.use(async (ctx, next) => {
   
  // ctx.path --> /@modules/vue
  if (ctx.path.startsWith('/@modules/')) {
   
    const moduleName = ctx.path.substr(10)
    const pkgPath = path.join(process.cwd(), 'node_modules', moduleName, 'package.json')
    const pkg = require(pkgPath)
    // 重写 path 请求, 改为 node_modules 中的模块路径
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  await next()
})



这里有个问题,我们加载的 vue 是 bundle 版本的 vue,也就是需要打包的 vue。
vue 模块去加载了 runtime-dom 和 shared 模块,但是浏览器中并没有去请求这两个模块。

在加载 App.vue 和 index.css 模块的时候,浏览器报错了,浏览器不能识别这两个模块。
所以还需要在服务器处理浏览器不能识别的模块

处理浏览器不能识别的模块

浏览器无法处理我们在 main.js 中使用 import 加载的单文件组件模块和样式模块,浏览器只能处理 JS 模块,所以通过 import 加载的模块都需要在服务器端处理,当请求单文件组件的时候,需要在服务器上编译成 JS 模块,然后返回给浏览器。

在 Vite 中处理单文件组件会发送两次请求

  • 第一次请求的时候,服务器端会把单文件组件编译成一个对象
import HelloWorld from '/src/components/HelloWorld.vue'
// 创建组件的选项对象. 
// 这里没有模板, 因为模板最终要被编译成 render 函数, 然后挂载到选项对象上
const __script = {
   
  name: 'App',
  components: {
   
    HelloWorld
  }
}
// 加载 App.vue, 并加上 type=template
// 这次请求是告诉服务器, 编译这个单文件组件的模板, 然后返回一个 render 函数
import {
   render as __render} from "/src/App.vue?type=template"
// 把 render 函数挂载到组件的选项对象上
__script.render = __render
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "E:\\file\\study\\big_front_end\\part03\\module-05\\task03\\vite-cli-test\\src\\App.vue"
// 导出选项对象
export default __script
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkU6XFxmaWxlXFxzdHVkeVxcYmlnX2Zyb250X2VuZFxccGFydDAzXFxtb2R1bGUtMDVcXHRhc2swM1xcdml0ZS1jbGktdGVzdFxcc3JjXFxBcHAudnVlIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFNQSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQzs7QUFFbkQsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRTtFQUNiLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7RUFDWCxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUU7SUFDVixDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQztFQUNYO0FBQ0YiLCJmaWxlIjoiRTovZmlsZS9zdHVkeS9iaWdfZnJvbnRfZW5kL3BhcnQwMy9tb2R1bGUtMDUvdGFzazAzL3ZpdGUtY2xpLXRlc3Qvc3JjL0FwcC52dWUiLCJzb3VyY2VSb290IjoiIiwic291cmNlc0NvbnRlbnQiOlsiPHRlbXBsYXRlPlxuICA8aW1nIGFsdD1cIlZ1ZSBsb2dvXCIgc3JjPVwiLi9hc3NldHMvbG9nby5wbmdcIiAvPlxuICA8SGVsbG9Xb3JsZCBtc2c9XCJIZWxsbyBWdWUgMy4wICsgVml0ZVwiIC8+XG48L3RlbXBsYXRlPlxuXG48c2NyaXB0PlxuaW1wb3J0IEhlbGxvV29ybGQgZnJvbSAnLi9jb21wb25lbnRzL0hlbGxvV29ybGQudnVlJ1xuXG5leHBvcnQgZGVmYXVsdCB7XG4gIG5hbWU6ICdBcHAnLFxuICBjb21wb25lbnRzOiB7XG4gICAgSGVsbG9Xb3JsZFxuICB9XG59XG48L3NjcmlwdD5cbiJdfQ==
  • 第二次请求,在服务器端编译单文件组件的模板,然后导出一个 render 函数
import {
   createVNode as _createVNode, resolveComponent as _resolveComponent, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock} from "/@modules/vue.js"

const _hoisted_1 = /*#__PURE__*/
_createVNode("img", {
   
  alt: "Vue logo",
  src: "/src/assets/logo.png"
}, null, -1 /* HOISTED */
)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
   
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(),
  _createBlock(_Fragment, null, [_hoisted_1, _createVNode(_component_HelloWorld, {
   
    msg: "Hello Vue 3.0 + Vite"
  })], 64 /* STABLE_FRAGMENT */
  ))
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkU6XFxmaWxlXFxzdHVkeVxcYmlnX2Zyb250X2VuZFxccGFydDAzXFxtb2R1bGUtMDVcXHRhc2swM1xcdml0ZS1jbGktdGVzdFxcc3JjXFxBcHAudnVlIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O2dDQUNFLGFBQThDO0VBQXpDLEdBQUcsRUFBQyxVQUFVO0VBQUMsR0FBRyxFQUFDLHNCQUFtQjs7Ozs7OztJQUEzQyxVQUE4QztJQUM5QyxhQUF5Qyx5QkFBN0IsR0FBRyxFQUFDLHNCQUFzQiIsImZpbGUiOiJFOi9maWxlL3N0dWR5L2JpZ19mcm9udF9lbmQvcGFydDAzL21vZHVsZS0wNS90YXNrMDMvdml0ZS1jbGktdGVzdC9zcmMvQXBwLnZ1ZSIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzQ29udGVudCI6WyI8dGVtcGxhdGU+XG4gIDxpbWcgYWx0PVwiVnVlIGxvZ29cIiBzcmM9XCIuL2Fzc2V0cy9sb2dvLnBuZ1wiIC8+XG4gIDxIZWxsb1dvcmxkIG1zZz1cIkhlbGxvIFZ1ZSAzLjAgKyBWaXRlXCIgLz5cbjwvdGVtcGxhdGU+XG5cbjxzY3JpcHQ+XG5pbXBvcnQgSGVsbG9Xb3JsZCBmcm9tICcuL2NvbXBvbmVudHMvSGVsbG9Xb3JsZC52dWUnXG5cbmV4cG9ydCBkZWZhdWx0IHtcbiAgbmFtZTogJ0FwcCcsXG4gIGNvbXBvbmVudHM6IHtcbiAgICBIZWxsb1dvcmxkXG4gIH1cbn1cbjwvc2NyaXB0PlxuIl19

处理浏览器第一次请求单文件组件 —— 将组件编译成组件选项对象
这次请求需要在服务器端把单文件组件编译成组件的选项对象
这里需要写一个中间件来处理单文件组件。当请求到单文件组件并把单文件组件读取完成之后,接下来需要对单文件组件进行编译,并把编译结果返回给浏览器。
核心是读取完单文件组件之后再进行处理,所以这个中间件应该写在 处理完成静态文件 之后,并且单文件组件也有可能加载第三方模块,所以是在 处理第三方模块 之前。

// 1.

// 4. 处理单文件组件
app.use(async (ctx, next) => {
   
  // 判断是否为单文件组件:后缀是否为 .vue 结尾
  if (ctx.path.endsWith('.vue')) {
   
    // 把 ctx.body 转化为字符串,
    // ctx.body 就是单文件组件的内容,在编译单文件组件的时候,需要单文件组件的内容的
    const contents = await streamToString(ctx.body)
    // 将组件编译成选项对象
    const {
    descriptor } = compilerSfc.parse(contents)
    let code
    // 处理第一次请求,不带 type 的情况
    if (!ctx.query.type) {
   
      code = descriptor.script.content
      console.log(code)
    }
  }
  await next()
})

// 2.

code 的输出结果

而 vite 中的结果是这样的

所以我们需要将 code 改造成与 Vite 类似的样子

const {
    Readable } = require('stream')

// 把字符串转换为流
const stringToStream = string => {
   
  const stream = new Readable()
  stream.push(string)
  // 标识这个流已经写完了
  stream.push(null)
  return stream
}

// 4. 处理单文件组件
app.use(async (ctx, next) => {
   
  // 判断是否为单文件组件:后缀是否为 .vue 结尾
  if (ctx.path.endsWith('.vue')) {
   
    // 把 ctx.body 转化为字符串,
    // ctx.body 就是单文件组件的内容,在编译单文件组件的时候,需要单文件组件的内容的
    const contents = await streamToString(ctx.body)
    const {
    descriptor } = compilerSfc.parse(contents)
    let code
    // 处理第一次请求,不带 type 的情况
    if (!ctx.query.type) {
   
      code = descriptor.script.content
      // console.log(code)
      // 将选项对象缓存到变量 __script 中
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      // 拼接
      code += `
        import {render as __render} from "${
     ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 设置响应头为 JavaScript
    ctx.type = 'application/javascript'
    // 将 code 转换为只读流输出给浏览器
    // 因为下一个中间件中的 ctx.body 是流的形式
    ctx.body = stringToStream(code)
  }
  await next()
})

刷新浏览器查看请求结果

但是看左边的请求列表,并没有看到有 App.vue?type=template 的请求。这是因为浏览器在加载 index.css 模块的时候不能识别报错了,导致后续的请求被阻塞

先将项目中引入图片、样式的代码注释起来,防止干扰。
重启服务器之后查看浏览器的请求:

此时已经能够正常请求 App.vue?type=template 了,但是没有响应,这是因为我们还没有去处理这个请求的响应。

处理浏览器第二次请求单文件组件 —— 编译单文件组件的模板并导出 render 函数

前面我们已经将浏览器第一次单文件组件的请求处理完毕了,第一次请求是将单文件组件编译成组件的选项对象并返回给浏览器,但是这个选项对象中没有模板或者 render 函数。
在第二次请求中,url 中会带着参数 ?type=template,在第二次请求中要把单文件组件的模板编译成 render 函数

// 4.
...
    let code
    // 处理第一次请求,不带 type 的情况
    if (!ctx.query.type) {
   
			...
    }
    // 第二次请求,type=template
    else if (ctx.query.type === 'template') {
   
      const templateRender = compilerSfc.compileTemplate({
   
        source: descriptor.template.content
      })
      code = templateRender.code
    }
...

重启服务器并刷新浏览器查看请求结果:

可以看到第二次请求单文件组件也成功响应了,并且返回了 render 函数。
到这编译模板就做完了,但是此时页面上还是什么都没有
控制台中报了个错,是 Vue 源码中的 shared 文件中的 process 不存在

process 是 node 环境中的变量,而我们的代码是运行在浏览器里的,所以就报错了。
而源码中这句代码的作用是让打包工具根据环境变量来分别进行生产环境或者开发环境的打包操作,但是这里我们没有使用打包工具,所以这句话直接返回给了浏览器,而浏览器不认识,所以就报错了。
所以我们应该在服务器上处理一下,在返回 JS 模块之前我们应该把所有代码中的 process.env.NODE_ENV 都替换成 ‘development’,因为当前环境是开发环境下的 web 服务器

// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
   
  // 在把文件返回给浏览器之前,判断当前文件是否是 JavaScript
  if (ctx.type === 'application/javascript') {
   
    const contents = await streamToString(ctx.body)
    // import Vue from 'vue'
    // import App from './App.vue'
    // 正则: 匹配 from './xxx'
    // (?![\.\/]) 排除 . 开头或者 / 开头
    // 将 (from ') 替换为 (from '/@modules/)
    ctx.body = contents
      .replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
      // 替换所有模块的 process.env.NODE_ENV 为 'development'
      .replace(/process\.env\.NODE_ENV/g, '"development"')

  }
})

重启服务器之后刷新浏览器查看结果

这次终于可以看到结果了,这里没有样式是因为我们把导入样式模块的代码给注释了,而且点击按钮,组件也可以正常工作


转载:https://blog.csdn.net/weixin_38550182/article/details/116714630
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场