Vite 实现原理
了解 Vite 的核心实现原理
• Vite 是一个面向现代浏览器的一个 更轻、更快的 Web 应用开发工具
• 它基于 ECMAScript 标准原生模块系统(ES Module)实现
• 它的出现是为了解决 webpack 在开发阶段,使用 webpack-dev-server 冷启动时间过长、webpack HMR 热更新响应慢的问题
• 使用 Vite 创建的项目,就是一个普通的 Vue3 项目。相比于 VueCli 创建的项目,少了很多配置文件和依赖
Vite 创建的项目,开发依赖只有:
• Vite: 命令行工具
• @vue/compiler-sfc:专门用于编译 .vue 结尾的单文件组件的工具。Vue2 中使用的是 vue-template-compiler
Vite 只支持 Vue3 版本,在创建项目的时候,通过指定不同模板可以支持其他框架
Vite 项目中提供了两个子命令:
• vite serve:用于开启一个用于开发的服务器,启动服务器的时候,不需要编译所有代码文件,启动速度非常快
• vite build:打包
Vite & vue-cli-service serve
Vite 打包使用的是 vite build 命令
随着 Vite 的出现,引发了另一个问题:究竟有没有必要去打包应用?
IE11 是不支持 ES Module 的,所以如果项目需要支持 IE11,则需要使用过去的打包方式。
现代的浏览器都是支持 ES Module 的
开箱即用
Vite 创建的项目,几乎不需要配置的,默认就支持 TypeScript
• TypeScript - 内置支持
• less/sass/stylus/postcss - 内置支持(需要单独安装编译器)
• JSX
• Web Assembly
Vite 带来的优势主要体现在提升开发者在开发过程中的体验
• 快速冷启动:web 服务器不需要等待,可以立即启动
• 模块热更新:只会编译当前所需的文件,几乎是实时的
• 按需编译:避免编译没有用到的文件
• 开箱即用:避免各种 loader 和 plugin 的配置
通过实现一个自己的 vite 工具,来深入了解 vite 的工作原理
• 启动一个静态 web 服务器:将当前项目目录作为静态文件服务器的根目录
• 编译单文件组件
• 拦截浏览器不识别的模块,并处理
• HMR:通过 web socket 实现
实现一个能够开启 web 静态服务器的命令行工具,把当前运行 vite 的目录,作为静态 web 服务器的根目录
• 创建 vite-cli 文件夹,并使用 npm init 初始化
• 安装 koa、koa-send(静态文件处理的中间件) 模块
npm i koa koa-send -S
{
"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"
}
}
#! /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')
})
修改第三方模块的路径
创建两个中间件:
• 把加载第三方模块的 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==
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"')
}
})
重启服务器之后刷新浏览器查看结果
这次终于可以看到结果了,这里没有样式是因为我们把导入样式模块的代码给注释了,而且点击按钮,组件也可以正常工作