Vite本地构建:手写核心原理
前言
接上篇文章,我们了解到vite
的本地构建原理主要是:启动一个 connect 服务器拦截由浏览器请求 ESM的请求。通过请求的路径找到目录下对应的文件做一下编译最终以 ESM的格式返回给浏览器。
基于这个核心思想,我们可以尝试来动手实现一下。
搭建静态服务器
基于koa
搭建一个项目:
项目结构如上,服务使用koa
搭建,bin
指定cli可执行文件的位置
#!/usr/bin/env node
// 代表该脚本使用node执行
const koa = require('koa');
const send = require('koa-send');
const App = new koa()
App.listen(3000, () => {
console.log('Server is running at http://localhost:3000');
});
这样一个服务就搭建好了,为了方便调试,我们在该工作目录下执行npm link
,这样可以将该项目链接支全局的npm,相当于全局安装了这个npm包。
接着我们在任意项目下执行my-vite
就能够启动该服务了!
处理根目录html文件
由于上面服务我们没有对任何路由进行处理,当访问http://localhost:3000
会发现什么也没有,我门首先需要将项目的模版文件index.html
返回给浏览器
const root = process.cwd(); // 获取当前工作目录
console.log('当前工作目录:', process.cwd());
// 静态文件服务区
App.use(async (ctx, next) => {
// 处理根路径,返回index.html
await send(ctx, ctx.path, { root: process.cwd() ,index: 'index.html'});
await next();
});
index.html
模版文件如下:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script>
window.process = { env: { NODE_ENV: 'development' } };
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
就是以ESM
的方式加载了vue
的入口文件main.ts
加完这段代码,我们在vue3
项目下执行一下my-vite
来到浏览器看一下此时的情况:
此时浏览器加载了main.ts
,该文件如下:它通过import
引入了两个模块
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
按理来说,浏览器此时应该会接着发起请求,去获取这两个模块,但现在却并没有🤔
此时控制台有个错误:
意思就是加载模块,必须以相对路径才可以(/、./、../)
所以我们现在需要来处理这些模块的加载路径问题
处理模块加载路径
由于三方模块都是直接以模块名来加载的,所以这里我们需要将这些模块的引用路径转换成相对路径。
// 处理模块导入
const importAction = (content) => {
return content.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
}
// 修改第三方模块的路径
App.use(async (ctx, next) => {
// console.log('ctx.path', ctx.type, ctx.path);
// 处理ts或者js文件
if (ctx.path.endsWith('.ts') || ctx.path.endsWith('.js')){
const content = await fileToString(ctx.body); // 获取文件内容
ctx.type = 'application/javascript'; // 设置响应类型为js
ctx.body = importAction(content); // 处理import加载路径
}
await next();
});
在这个中间件中,我们使用正则表达式将模块的引用路径替换成了/@modules
开头,这样就符合浏览器的引用规则了。
接着再到浏览器中来观察此时的情况:
此时浏览器已经可以发出另外两个请求,分别去加载vue
模块以及App.vue
组件了。
可以看到vue
模块的加载路径已经变成了/@modules
开头了,虽然现在该路径还是404
,但最起码比起之前我们又往前走一步了。
其实404
也很好理解,因为我们的服务现在压根就还没处理这类路径,所以接下来就该处理/@modules
这类path
并加载模块内容
加载第三方模块
这里我们只需要去拦截刚刚/@modules
开头的路径,并找到该路径下的模块的真正位置,最后返回给浏览器就可以了。
// 加载第三方模块
App.use(async (ctx, next) => {
if (ctx.path.startsWith('/@modules/')) {
const moduleName = ctx.path.substr(10); // 获取模块名称
const modulePath = path.join(root, 'node_modules', moduleName); // 获取模块路径
const package = require(modulePath + '/package.json'); // 获取模块的package.json
// console.log('modulePath', modulePath);
ctx.path = path.join('/node_modules', moduleName, package.module); // 重写路径
}
await next();
});
我们可以通过读取package.json
文件中的module
字段,来找到第三方模块的入口文件。
该中间件需要在处理模块加载路径的中间件之前执行
此时再来到浏览器中查看:
可以看到,此时的vue
模块已经能够重新加载了,但下面又多加载了四个模块,它们又是从哪来的呢?
可以看到vue
模块中又引入了runtime-dom
模块,并且它们的加载路径也被转成了/@modules
开头,这就是上面提到的加载模块的中间件需要在处理模块加载路径的中间件之前执行,模块加载回来之后又经过了处理加载路径的中间件,所以就相当于递归把模块的路径全都转换成相对路径了
runtime-dom
模块又引入了runtime-core
与shared
模块,而runtime-core
模块又引入了reactivity
模块,所以会看到上图中这样的一种加载顺序。
模块的加载引入都正确了,但页面还是没又任何渲染内容出现
这是因为此时的App.vue
还没经过任何编译处理,浏览器并不能直接识别并执行该文件
所以接下来的重点是需要将App.vue
文件编译成浏览器能够执行的javascript
内容(render函数)
处理Vue单文件组件
这里我们需要使用Vue
的编译模块@vue/compiler-sfc
与@vue/compiler-dom
来对vue
文件进行编译处理。
处理script
const content = await fileToString(ctx.body); // 获取文件内容
const { descriptor } = compilerSfc.parse(content); // 解析单文件组件
const compileScript = importAction(
compilerSfc.compileScript(
descriptor,
{
id: descriptor.filename
}
).content); // 编译script
处理template
const compileRender =importAction(compilerDom.compile(descriptor.template.content,
// 编译template, render函数中变量从setup中获取
{ mode: 'module',
sourceMap: true,
filename: path.basename(ctx.path),
__isScriptSetup: true, // 标记是否是setup
compatConfig: { MODE: 3 }, // 兼容vue3
}).code); // 编译template
处理style
let styles = '';
if(descriptor.styles.length){
console.log('descriptor.styles', descriptor.styles);
// 处理样式
styles = descriptor.styles.map((style,index) => {
return `
import '${ctx.path}?type=style&index=${index}';
`
}).join('\n');
} // 处理样式
这里是通过让它另外发起一次请求来对style
进行处理,这样隔离开逻辑能够更清晰
处理样式的请求
在中间件中通过拦截type
为style
的请求来进行处理
if (ctx.query.type === 'style') {
// 处理样式
const styleBlock = descriptor.styles[ctx.query.index];
console.log('styleBlock', styleBlock);
ctx.type = 'application/javascript';
ctx.body = `
const _style = (css) => {
const __style = document.createElement('style');
__style.type = 'text/css';
__style.innerHTML = css;
document.head.appendChild(__style);
}
_style(${JSON.stringify(styleBlock.content)});
export default _style;
`;
}
最后验证
总结
在深入探索了vite
的工作流程之后,你可能会发现,尽管从概念上看似简单,但vite
背后的实现却相当复杂且精妙。我们刚刚通过走一遍其核心流程,对vite
如何加载模块、解析和编译文件有了初步的认识。然而,这仅仅是冰山一角。
总的来说,vite
的工作原理虽然可以通过一个简化的示例来理解,但其真正的强大和复杂性远不止于此。如果对vite
的深入工作原理感兴趣,可以去深入阅读它的源码,在那里我们能够学习到更多知识。