Appearance
Vite的简单实现原理 案例
通过实现一个自己的Vite工具深入了解Vite的工作原理
梳理核心功能
- 开启静态服务器
- 编译单文件组件
- 拦截浏览器不识别的模块并处理
- HMR(目前不做)
使用koa开启静态web服务器
- 创建文件夹vite-cli,执行
npm init -y
初始化一个package.json,安装依赖,修改下面的两个模块。
js
{
"name": "vite-cli-demo",
...
"bin": "index.js",
...
"dependencies": {
"koa": "^2.13.0",
"koa-send": "^5.0.1"
}
}
- 创建index.js
js
#!/usr/bin/env node
// 导入两个模块
const Koa = require('koa')
const send = require('koa-send')
// 创建Koa的实例
const app = new Koa()
// 1. 开启静态文件服务器
// 创建中间件
app.use(async (ctx, next) => {
// 通过send把index.html返回给浏览器
// ctx 上下文,ctx.path 当前请求的路径,root 根目录(当前运行程序的目录),index 默认页面
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
// 执行下一个中间件
await next()
})
// 监听端口
app.listen(3000)
// 打印提示
console.log('Server running @ http://localhost:3000')
- 命令行使用
npm link
- 创建一个vue3项目,
vue create vue3-demo
,在vue3的项目的根目录创建index.html文件
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/main.js"></script>
</body>
</html>
- 使用命令行
vue-cli-demo
可以看到项目启动成功,访问http://localhost:3000
可以看到项目并没有出来,还有一些报错
Uncaught TypeError
Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".
分析
出现这种问题的原因是: 我们可以看到main.js的加载路径是vue,但是正常的应该是/@modules/vue.js
,所以我们需要手动处理路径问题。
思路: 请求一个模块进行响应的时候,我们要把该模块中加载第三方模块的路径处理,所以我们要先判断该文件是否是js文件,如果是再去处理里面的第三方模块的引用路径,然后再去处理/@modules/vue.js
这个不存在的路径。
修改第三方模块路径
- 实现第一个中间件,修改第三方模块的加载路径,在vite-cli的index.js里面添加
js
// 把流转换成字符串,参数是流ctx.body
// 返回一个promise对象
const steamToString = stream => new Promise((resolve, reject) => {
// 定义一个数组,存储Buffer
const chunks = []
// data事件,把数据存储到数组中
stream.on('data', chunk => chunks.push(chunk))
// end事件数据读取完毕之后,把数据和chunks合并并转换成字符串,通过resolve返回
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
stream.on('error',reject)
})
// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
// 判断当前返回给浏览器的文件是不是js,判断其contentType是不是js
if (ctx.type === 'application/javascript') {
// 将body转换成字符串
const contents = await steamToString(ctx.body)
// import vue from 'vue' 不能正常加载
// import App from './App.vue' 可以正常加载
// g是全局匹配,()是分组,前一个匹配 from ' 或者 from " \s+是空格的意思
// 第二个匹配?!的作用是不匹配分组的结果,这里就是不匹配./的结果
// $1就是第一个分组的结果,后面加上添加的东西
// 最后的结果就是import vue from '/@modules/vue'
ctx.body = contents.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
}
})
这个时候重启服务器,可以看到main.js里面加载的路径进行了改变
实现第二个中间件,请求
http://localhost:3000/@modules/vue
的时候现在不存在,如果以/@modules/
开头就去node_modules里面找在vite-cli的index.js里面开启静态文件服务器之前加这个新的中间件
js
const path = require('path')
// 3. 这里在处理静态文件之前,加载第三方模块
app.use(async (ctx, next) => {
// ctx.path --> /@modules/vue
// 判断path是否以 /@modules/ 开头
if(ctx.path.startsWith('/@modules/')) {
// 拿到模块名称,直接把前面10个字符截取
const moduleName = ctx.path.substr(10)
// 思路:
// 获取ES Module模块的入口文件,先找到这个模块的package.json,然后再获取package.json的ES Module模块的入口
// 拼接package.json的路径
const pkgPath = path.join(process.cwd(), 'node_modules', moduleName, 'package.json')
// 加载package.json
const pkg = require(pkgPath)
// 重新给ctx.path赋值,拼接入口文件
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
// 执行下一个中间件
await next()
})
- 重启服务器,可以看到内容加载正确。
ERROR
但是可以看到控制台还是报错了,加载模块失败
Failed to load module script: The server responded with a non-JavaScript MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.
加载App.vue和css的时候,浏览器无法识别模块,下面来处理。
编译单文件组件
浏览器只能处理js模块,其他模块都会报错,所以其他模块都需要在服务器端处理,需要把单文件组件编译成js返回给浏览器。
Vite是怎么做的?
它会发送两次请求,第一次请求是把单文件组件编译成一个对象,中间发送第二次请求,第二次请求编译单文件组件的模板,返回一个render函数,然后把render函数挂载到第一此请求的对象上。
下面看一下第一次请求的文件App.vue最后转换的样子:
js
// 没变
import HelloWorld from '/src/components/HelloWorld.vue'
// 将 export default 换成 const __script =
const __script = {
name: 'App',
components: {
HelloWorld
}
}
// 这个是第二次请求,请求的时候有加参数type
import { render as __render } from "/src/App.vue?type=template"
// 将返回的__render赋值给__script
__script.render = __render
// 这两个标识一会回忽略掉
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "E:\\professer\\Vite\\3-5-vite-vue3-demo\\src\\App.vue"
// 最后导出函数
export default __script
第二次请求的样子:
js
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 */))
}
第一次请求实现
- 先安装
npm i @vue/compiler-sfc
用于编译单文件组件,并导入
js
const compilerSFC = require('@vue/compiler-sfc')
- 在处理完静态文件之后并且在第三方模块之前(1和2之间)写中间件
js
// 导入stream模块
const { Readable } = require('stream')
// 把字符串转换成流,接收参数text是字符串
const stringToStream = text => {
// 创建Readable对象
const stream = new Readable()
// 字符串添加到stream中
stream.push(text)
// 表示流写完了
stream.push(null)
// 将stream返回
return stream
}
// 4. 处理单文件组件
app.use(async (ctx, next) => {
// 判断是否是单文件组件
if (ctx.path.endsWith('.vue')) {
// 让ctx.body转换成字符串
const contents = await steamToString(ctx.body)
// 通过compilerSFC的parse方法返回编译之后的结果
// 返回两个元素,descriptor 单文件组件的描述对象,errors 编译过程中的错误
const { descriptor } = compilerSFC.parse(contents)
// 定义code,最后返回给浏览器的数据
let code
// 处理第一次请求,没有type属性
if (!ctx.query.type) {
// 获取得到的内容
code = descriptor.script.content
// 把export default 要进行替换,并把后面的内容进行拼接
code = code.replace(/export\s+default\s+/g, 'const __script = ')
// 单文件组件的路径是ctx.path
code += `
import { render as __render } from "${ctx.path}?type=template"
__script.render = __render
export default __script
`
}
// 设置请求contentType是js类型的文件
ctx.type = 'application/javascript'
// 将code转换成只读流给body
ctx.body = stringToStream(code)
}
// 处理下一个中间件
await next()
})
- 重启浏览器打开之后可以看到,App.vue是我们要的样子,却没有看到有第二次请求的发送。
这里的问题是:
ERROR
到了这一步,index.css还是报错,然后就是render的加载路径不对,在2那个中间件中,替换的正则中要修改一下,之前是排除./
的情况,现在要改成.
开头或者/
开头的情况。
- 去index.js中修改正则表达式
js
// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
if (ctx.type === 'application/javascript') {
// 将body转换成字符串
const contents = await steamToString(ctx.body)
// 案例:
// import vue from 'vue' 不能正常加载
// import App from './App.vue' 可以正常加载
// import { render as __render } from "/src/App.vue?type=template" 可以正常加载
// g是全局匹配,()是分组,前一个匹配 from ' 或者 from " \s+是空格的意思
// 第二个匹配?!的作用是不匹配分组的结果,中括号这里表示不匹配 . 开头的情况或者 / 开头的情况
// $1就是第一个分组的结果,后面加上添加的东西
// 最后的结果就是import vue from '/@modules/vue'
ctx.body = contents.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
}
})
- 然后将vue3的项目中,main.js中的index.css和App.vue中的img标签进行注释,防止其带来干扰。再次重启服务可以看到,有type参数的请求发出去了。
第二次请求实现
第二次请求我们需要把单文件组件中的默认编译成render函数
- 在index.js中修改处理单文件组件的内容
js
// 4. 处理单文件组件
app.use(async (ctx, next) => {
if (ctx.path.endsWith('.vue')) {
...
if (!ctx.query.type) {
...
// 第二次请求,判断type是否是template
} else if (ctx.query.type === 'template') {
// compilerSFC 有一个编译模板的方法 compileTemplate
// compileTemplate 接收一个参数对象,source是模板内容,descriptor.template.content就是模板内容
const templateRender = compilerSFC.compileTemplate({ source: descriptor.template.content })
// 返回值的code属性就是render函数
code = templateRender.code
}
...
}
await next()
})
- 打开浏览器,可以看到第二次请求有内容
- 但是现在浏览器上还是什么都没有,控制台报错
这里是因为,打包工具会根据环境替换是开发环境还是生产环境,我们没有打包,所以这里没有进行替换。
所以我们需要在输出之前做整体的替换。
- 在index.js的最后一个中间件2中,添加正则替换
js
if (ctx.type === 'application/javascript') {
const contents = await steamToString(ctx.body)
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
// 因为.有含义所以这里进行转义,将 process.env.NODE_ENV 进行转义 development
.replace(/process\.env\.NODE_ENV/g, '"development"')
}
- 重启之后内容正常输出,这里因为没有加载图片和css,所以没有样式,点击按钮也可以正常工作。
小结
核心就是开发阶段不需要本地打包,根据需要请求服务器编译单文件组件,图片和样式可以根据这个思路自己模拟。
完整代码 vite-cli-demo