Vite入门从手写一个乞丐版的Vite开始(上)
csdh11 2024-12-02 16:39 22 浏览
Vite[1]是什么就不用笔者多说了,用过Vue的朋友肯定都知道,本文会通过手写一个非常简单的乞丐版Vite来了解一下Vite的基本实现原理,参考的是Vite最早的版本(vite-1.0.0-rc.5版本,Vue版本为3.0.0-rc.10)实现的,现在已经是3.x的版本了,为什么不直接参考最新的版本呢,因为一上来就看这种比较完善的工具源码比较难看懂,反正笔者不行,所以我们可以先从最早的版本来窥探一下原理,能力强的朋友可以忽略~
本文会分为上下两篇,上篇主要讨论如何成功运行项目,下篇主要讨论热更新。
前端测试项目
前端测试项目结构如下:
Vue组件使用的是Options Api ,不涉及到css预处理语言、ts等js语言,所以是一个非常简单的项目,我们的目标很简单,就是要写一个Vite服务让这个项目能运行起来!
搭建基本服务
vite服务的基本结构如下:
首先让我们来起个服务,HTTP应用框架我们使用connect[2]:
// app.js
const connect = require("connect");
const http = require("http");
const app = connect();
app.use(function (req, res) {
res.end("Hello from Connect!\n");
});
http.createServer(app).listen(3000);
接下来我们需要做的就是拦截各种类型的请求来进行不同的处理。
拦截html
项目访问的入口地址是http://localhost:3000/index.html,所以接到的第一个请求就是html文件的请求,我们暂时直接返回html文件的内容即可:
// app.js
const path = require("path");
const fs = require("fs");
const basePath = path.join("../test/");
const typeAlias = {
js: "application/javascript",
css: "text/css",
html: "text/html",
json: "application/json",
};
app.use(function (req, res) {
// 提供html页面
if (req.url === "/index.html") {
let html = fs.readFileSync(path.join(basePath, "index.html"), "utf-8");
res.setHeader("Content-Type", typeAlias.html);
res.statusCode = 200;
res.end(html);
} else {
res.end('')
}
});
现在访问页面肯定还是一片空白,因为页面发起的main.js的请求我们还没有处理,main.js的内容如下:
拦截js请求
main.js请求需要做一点处理,因为浏览器是不支持裸导入的,所以我们要转换一下裸导入的语句,将import xxx from 'xxx'转换为import xxx from '/@module/xxx',然后再拦截/@module请求,从node_modules里获取要导入的模块进行返回。
解析导入语句我们使用es-module-lexer[3]:
// app.js
const { init, parse: parseEsModule } = require("es-module-lexer");
app.use(async function (req, res) {
if (/\.js\??[^.]*$/.test(req.url)) {
// js请求
let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
await init;
let parseResult = parseEsModule(js);
// ...
}
});
解析的结果为:
解析结果为一个数组,第一项也是个数组代表导入的数据,第二项代表导出,main.js没有,所以是空的。s、e代表导入来源的起止位置,ss、se代表整个导入语句的起止位置。
接下来我们检查当导入来源不是.或/开头的就转换为/@module/xxx的形式:
// app.js
const MagicString = require("magic-string");
app.use(async function (req, res) {
if (/\.js\??[^.]*$/.test(req.url)) {
// js请求
let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
await init;
let parseResult = parseEsModule(js);
let s = new MagicString(js);
// 遍历导入语句
parseResult[0].forEach((item) => {
// 不是裸导入则替换
if (item.n[0] !== "." && item.n[0] !== "/") {
s.overwrite(item.s, item.e, `/@module/${item.n}`);
}
});
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(s.toString());
}
});
修改js字符串我们使用了magic-string[4],从这个简单的示例上你应该能发现它的魔法之处,就是即使字符串已经变了,但使用原始字符串计算出来的索引修改它也还是正确的,因为索引还是相对于原始字符串。
可以看到vue已经成功被修改成/@module/vue了。
紧接着我们需要拦截一下/@module请求:
// app.js
const { buildSync } = require("esbuild");
app.use(async function (req, res) {
if (/^\/@module\//.test(req.url)) {
// 拦截/@module请求
let pkg = req.url.slice(9);
// 获取该模块的package.json
let pkgJson = JSON.parse(
fs.readFileSync(
path.join(basePath, "node_modules", pkg, "package.json"),
"utf8"
)
);
// 找出该模块的入口文件
let entry = pkgJson.module || pkgJson.main;
// 使用esbuild编译
let outfile = path.join(`./esbuild/${pkg}.js`);
buildSync({
entryPoints: [path.join(basePath, "node_modules", pkg, entry)],
format: "esm",
bundle: true,
outfile,
});
let js = fs.readFileSync(outfile, "utf8");
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(js);
}
})
我们先获取了包的package.json文件,目的是找出它的入口文件,然后读取并使用esbuild[5]进行转换,当然Vue是有ES模块的产物的,但是可能有的包没有,所以直接就统一处理了。
拦截css请求
css请求有两种,一种来源于link标签,一种来源于import方式,link标签的css请求我们直接返回css即可,但是import的css直接返回是不行的,ES模块只支持js,所以我们需要转成js类型,主要逻辑就是手动把css插入页面,所以这两种请求我们需要分开处理。
为了能区分import请求,我们修改一下前面拦截js的代码,把每个导入来源都加上?import查询参数:
// ...
// 遍历导入语句
parseResult[0].forEach((item) => {
// 不是裸导入则替换
if (item.n[0] !== "." && item.n[0] !== "/") {
s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
} else {
s.overwrite(item.s, item.e, `${item.n}?import`);
}
});
//...
拦截/@module的地方也别忘了修改:
// ...
let pkg = removeQuery(req.url.slice(9));// 从/@module/vue?import中解析出vue
// ...
// 去除url的查询参数
const removeQuery = (url) => {
return url.split("?")[0];
};
这样import的请求就都会带上一个标志:
然后根据这个标志来分别处理css请求:
// app.js
app.use(async function (req, res) {
if (/\.css\??[^.]*$/.test(req.url)) {
// 拦截css请求
let cssRes = fs.readFileSync(
path.join(basePath, req.url.split("?")[0]),
"utf-8"
);
if (checkQueryExist(req.url, "import")) {
// import请求,返回js文件
cssRes = `
const insertStyle = (css) => {
let el = document.createElement('style')
el.setAttribute('type', 'text/css')
el.innerHTML = css
document.head.appendChild(el)
}
insertStyle(\`${cssRes}\`)
export default insertStyle
`;
res.setHeader("Content-Type", typeAlias.js);
} else {
// link请求,返回css文件
res.setHeader("Content-Type", typeAlias.css);
}
res.statusCode = 200;
res.end(cssRes);
}
})
// 判断url的某个query名是否存在
const checkQueryExist = (url, key) => {
return new URL(path.resolve(basePath, url)).searchParams.has(key);
};
如果是import导入的css那么就把它转换为js类型的响应,然后提供一个创建style标签并插入到页面的方法,并且立即执行,那么这个css就会被插入到页面中,一般这个方法会被提前注入页面。
如果是link标签的css请求直接返回css即可。
拦截vue请求
最后,就是处理Vue单文件的请求了,这个会稍微复杂一点,处理Vue单文件我们使用@vue/compiler-sfc的3.0.0-rc.10版本,首先需要把Vue单文件的template、js、style三部分解析出来:
// app.js
const { parse: parseVue } = require("@vue/compiler-sfc");
app.use(async function (req, res) {
if (/\.vue\??[^.]*$/.test(req.url)) {
// Vue单文件
let vue = fs.readFileSync(
path.join(basePath, removeQuery(req.url)),
"utf-8"
);
let { descriptor } = parseVue(vue);
}
})
然后再分别解析三部分,template和css部分会转换成一个import请求。
处理js部分
// ...
const { compileScript, rewriteDefault } = require("@vue/compiler-sfc");
let code = "";
// 处理js部分
let script = compileScript(descriptor);
if (script) {
code += rewriteDefault(script.content, "__script");
}
rewriteDefault方法用于将export default转换为一个新的变量定义,这样我们可以注入更多数据,比如:
// 转换前
let js = `
export default {
data() {
return {}
}
}
`
// 转换后
let js = `
const __script = {
data() {
return {}
}
}
`
//然后可以给__script添加更多属性,最后再手动添加到导出即可
js += `\n__script.xxx = xxx`
js += `\nexport default __script`
处理template部分
// ...
// 处理模板
if (descriptor.template) {
let templateRequest = removeQuery(req.url) + `?type=template`;
code += `\nimport { render as __render } from ${JSON.stringify(
templateRequest
)}`;
code += `\n__script.render = __render`;
}
将模板转换成了一个import语句,然后获取导入的render函数挂载到__script上,后面我们会拦截这个type=template的请求,返回模板的编译结果。
处理style部分
// ...
// 处理样式
if (descriptor.styles) {
descriptor.styles.forEach((s, i) => {
const styleRequest = removeQuery(req.url) + `?type=style&index=${i}`;
code += `\nimport ${JSON.stringify(styleRequest)}`
})
}
和模板一样,样式也转换成了一个单独的请求。
最后导出__script并返回数据:
// ...
// 导出
code += `\nexport default __script`;
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(code);
可以看到__script其实就是一个Vue的组件选项对象,模板部分编译的结果就是组件的渲染函数render,相当于把js和模板部分组合成一个完整的组件选项对象。
处理模板请求
当Vue单文件的请求url存在type=template参数,我们就编译一下模板然后返回:
// app.js
const { compileTemplate } = require("@vue/compiler-sfc");
app.use(async function (req, res) {
if (/\.vue\??[^.]*$/.test(req.url)) {
// vue单文件
// 处理模板请求
if (getQuery(req.url, "type") === "template") {
// 编译模板为渲染函数
code = compileTemplate({
source: descriptor.template.content,
}).code;
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(code);
return;
}
// ...
}
})
// 获取url的某个query值
const getQuery = (url, key) => {
return new URL(path.resolve(basePath, url)).searchParams.get(key);
};
处理样式请求
样式和前面我们拦截样式请求一样,也需要转换成js然后手动插入到页面:
// app.js
const { compileTemplate } = require("@vue/compiler-sfc");
app.use(async function (req, res) {
if (/\.vue\??[^.]*$/.test(req.url)) {
// vue单文件
}
// 处理样式请求
if (getQuery(req.url, "type") === "style") {
// 获取样式块索引
let index = getQuery(req.url, "index");
let styleContent = descriptor.styles[index].content;
code = `
const insertStyle = (css) => {
let el = document.createElement('style')
el.setAttribute('type', 'text/css')
el.innerHTML = css
document.head.appendChild(el)
}
insertStyle(\`${styleContent}\`)
export default insertStyle
`;
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(code);
return;
}
})
样式转换为js的这个逻辑因为有两个地方用到了,所以我们可以提取成一个函数:
// app.js
// css to js
const cssToJs = (css) => {
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
`;
};
修复单文件的裸导入问题
单文件内的js部分也可以导入模块,所以也会存在裸导入的问题,前面介绍了裸导入的处理方法,那就是先替换导入来源,所以单文件的js部分解析出来以后我们也需要进行一个替换操作,我们先把替换的逻辑提取成一个公共方法:
// 处理裸导入
const parseBareImport = async (js) => {
await init;
let parseResult = parseEsModule(js);
let s = new MagicString(js);
// 遍历导入语句
parseResult[0].forEach((item) => {
// 不是裸导入则替换
if (item.n[0] !== "." && item.n[0] !== "/") {
s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
} else {
s.overwrite(item.s, item.e, `${item.n}?import`);
}
});
return s.toString();
};
然后编译完js部分后立即处理一下:
// 处理js部分
let script = compileScript(descriptor);
if (script) {
let scriptContent = await parseBareImport(script.content);// ++
code += rewriteDefault(scriptContent, "__script");
}
另外,编译后的模板部分代码也会存在一个裸导入Vue,也需要处理一下:
// 处理模板请求
if (
new URL(path.resolve(basePath, req.url)).searchParams.get("type") ===
"template"
) {
code = compileTemplate({
source: descriptor.template.content,
}).code;
code = await parseBareImport(code);// ++
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(code);
return;
}
处理静态文件
App.vue里面引入了两张图片:
编译后的结果为:
ES模块只能导入js文件,所以静态文件的导入,响应结果也需要是js:
// vite/app.js
app.use(async function (req, res) {
if (isStaticAsset(req.url) && checkQueryExist(req.url, "import")) {
// import导入的静态文件
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(`export default ${JSON.stringify(removeQuery(req.url))}`);
}
})
// 检查是否是静态文件
const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/;
const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/;
const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i;
const isStaticAsset = (file) => {
return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file);
};
import导入的静态文件处理很简单,直接把静态文件的url字符串作为默认导出即可。
这样我们又会收到两个静态文件的请求:
简单起见,没有匹配到以上任何规则的我们都认为是静态文件,使用serve-static[6]来提供静态文件服务即可:
// vite/app.js
const serveStatic = require("serve-static");
app.use(async function (req, res, next) {
if (xxx) {
// xxx
} else if (xxx) {
// xxx
// ...
} else {
next();// ++
}
})
// 静态文件服务
app.use(serveStatic(path.join(basePath, "public")));
app.use(serveStatic(path.join(basePath)));
静态文件服务的中间件放到最后,这样没有匹配到的路由就会走到这里,到这一步效果如下:
可以看到页面已经被加载出来。
下一篇我们会介绍一下热更新的实现,See you later~
参考资料
[1]
Vite: https://vitejs.dev/
[2]
connect: https://github.com/senchalabs/connect
[3]
es-module-lexer: https://github.com/guybedford/es-module-lexer
[4]
magic-string: https://github.com/Rich-Harris/magic-string
[5]
esbuild: https://esbuild.github.io/
[6]
serve-static: https://github.com/expressjs/serve-static
相关推荐
- 用Python轻松修改Word文件的作者和时间,打造自己的专属效率工具
-
你是否曾经遇到过需要批量修改Word文件的作者、创建时间或修改时间的情况?手动操作不仅费时费力,还容易出错。可以用Python编写一个小工具,轻松解决这个问题!无论你是编程新手还是有一定经验的...
- 插件开发js代码划分(js插件编写)
-
在开发Chrome插件时,将JavaScript代码拆分成多个模块而非集中放置,主要基于性能优化、可维护性提升和浏览器插件特性适配等多方面的考量。以下是具体原因及区别分析:一、拆分的核心原因...
- 5分钟掌握Python中的标准输入、标准输出、标准错误
-
读取用户输入从标准输入获取输入:user_input=input("Impartyourwisdom:")print(f"Youshared:{user_input}")...
- 高大上的解答:在 'packages.pyi' 中找不到引用 'urllib3'
-
DeepSeek的一句代码:...
- Flask 入门教程(flask快速入门)
-
目录什么是Flask?环境配置与安装第一个Flask应用:HelloWorld路由与视图函数模板与Jinja2表单处理与用户输入...
- 每日一库之 Go 语言开发者的神器—Gotx
-
点击上方蓝色“Go语言中文网”关注我们,领全套Go资料,每天学习Go语言简介Gotx是一个Go语言(Golang)的解释器和运行环境,只有单个可执行文件,绿色、跨平台,无需安装任何Go语言环境就可...
- MySQL性能调优工具包制作(mysql性能调整)
-
一、最终工具包内容mysql_tuning_toolkit/├──scripts/#核心脚本│├──sysbench-pro.sh#...
- 掌握TensorFlow核心用法:从安装到实战的完整指南
-
一、为什么TensorFlow值得学习?作为全球使用最广泛的开源机器学习框架,TensorFlow已累计获得超过17万GitHub星标,支撑着Google搜索、Waymo自动驾驶、NASA卫星图像分析...
- 如何把PY 打包成EXE安装文件(pypy 打包exe)
-
将Python脚本打包成EXE文件通常使用第三方工具实现,以下是详细步骤和注意事项:...
- Pygame Zero 详细使用教程(python zerorpc)
-
PygameZero是一个基于Pygame的简化游戏开发框架,特别适合初学者和快速原型开发。它隐藏了许多底层的复杂性,使得开发者可以更专注于游戏逻辑的实现。本文将通过分析提供的代码,详细介绍如...
- Stable diffusion AI画图辅助脚本 Script 的使用(二)
-
本篇为脚本使用介绍的第二部分,主要介绍Promptmatrix提示词矩阵以及UltimateSDUpscale终极SD放大这两个脚本,同时也简单介绍一下如何编写自己的脚本。1、Promp...
- 一文明白Python 的import如何工作
-
Pythonimport系统的基础知识Python的import系统是该语言设计的关键部分,允许模块化编程和代码的轻松重用。了解这个系统对任何Python程序员都很重要,因为它决定了代码的结构...
- Highlight.js - 前端的代码语法高亮库
-
千辛万苦写了篇技术分享,贴了一堆代码,兴高采烈地发到了自己的博客网站上。结果却发现代码全是白底黑字,字体还难看得很,你瞬间就没了兴致。能不能让网页也能像IDE那样,做带语法高亮的炫酷显示呢?来看一...
- xbox xsx/s ps2模拟器 战神12,北欧女神2 配置教程
-
xsxxss下载PS2独立模拟器,Retroarch全能模拟器地址。...
- RetroArch 着色器、金手指怎么用? 重返复古游戏萤幕滤镜效果
-
自从上次分享RetroArch模拟器的一些技巧后,许多模拟器新用户对老旧游戏机感到好奇,为什么游戏画面看起来会有很多马赛克。这主要是因为当年的游戏开发商是针对当时的屏幕进行设计的,所以在现在的高分辨率...
- 一周热门
- 最近发表
-
- 用Python轻松修改Word文件的作者和时间,打造自己的专属效率工具
- 插件开发js代码划分(js插件编写)
- 5分钟掌握Python中的标准输入、标准输出、标准错误
- 高大上的解答:在 'packages.pyi' 中找不到引用 'urllib3'
- Flask 入门教程(flask快速入门)
- 每日一库之 Go 语言开发者的神器—Gotx
- MySQL性能调优工具包制作(mysql性能调整)
- 掌握TensorFlow核心用法:从安装到实战的完整指南
- 如何把PY 打包成EXE安装文件(pypy 打包exe)
- Pygame Zero 详细使用教程(python zerorpc)
- 标签列表
-
- mydisktest_v298 (34)
- document.appendchild (35)
- 头像打包下载 (61)
- acmecadconverter_8.52绿色版 (39)
- word文档批量处理大师破解版 (36)
- server2016安装密钥 (33)
- mysql 昨天的日期 (37)
- parsevideo (33)
- 个人网站源码 (37)
- centos7.4下载 (33)
- mysql 查询今天的数据 (34)
- intouch2014r2sp1永久授权 (36)
- 先锋影音源资2019 (35)
- jdk1.8.0_191下载 (33)
- axure9注册码 (33)
- pts/1 (33)
- spire.pdf 破解版 (35)
- shiro jwt (35)
- sklearn中文手册pdf (35)
- itextsharp使用手册 (33)
- 凯立德2012夏季版懒人包 (34)
- 反恐24小时电话铃声 (33)
- 冒险岛代码查询器 (34)
- 128*128png图片 (34)
- jdk1.8.0_131下载 (34)