简单实现 Vite
设计哲学
- 开发环境:利用浏览器去解析 imports,在服务端按需编译返回,完全跳过打包这个概念(bundless)。
- 生成环境:利用 rollup 打包
- 灵感来自于 snowpack
特性
- 开发环境下,基于浏览器原生 ES imports
type="module"
属性 - 真正意义上的按需编译
- 快速的冷启动
- 即时的模块热更新
启动命
npm link
npx my-vite
{
"name": "learning-demo-17-my-vite",
"main": "index.js",
"bin": {
"my-vite": "bin/vite.js"
},
"dependencies": {
"es-module-lexer": "^0.3.24",
"koa": "^2.13.0",
"koa-static": "^5.0.0",
"magic-string": "^0.25.7"
}
}
#!/usr/bin/env node --inspect=9233
const createServer = require('../src/index.js');
createServer().listen(3001, () => {
console.log('this is my vite listening on 3001');
});
运行 DEV SERVER
一个 Koa Server
const Koa = require('koa');
const serveStaticPlugin = require('./node/server/serverPluginServeStatic.js');
const moduleRewritePlugin = require('./node/server/serverPluginModuleRewrite.js');
const moduleResolvePlugin = require('./node/server/serverPluginModuleResolve.js');
const htmlRewritePlugin = require('./node/server/serverPluginHtml.js');
const vuePlugin = require('./node/server/serverPluginVue.js');
function createServer() {
const app = new Koa();
const context = {
app,
root: process.cwd() // 进程运行的目录
};
const resolvedPlugins = [
// 2) 重写 html 一些环境变量
htmlRewritePlugin,
// 3) 解析 import,重写路径: "vue" => "/@modules/vue"
moduleRewritePlugin,
// 4) 解析 "/@modules" 开头的模块,匹配相应路径
moduleResolvePlugin,
// 5) 利用 vue-compiler 编译 vue 后缀文件
vuePlugin,
// 1) 静态服务插件
serveStaticPlugin
];
resolvedPlugins.forEach((m) => m && m(context));
return app;
}
module.exports = createServer;
核心插件
1) serveStaticPlugin
const static = require('koa-static');
const path = require('path');
function serveStaticPlugin({ app, root }) {
app.use(static(root));
app.use(static(path.join(root, 'src')));
}
2) htmlRewritePlugin
const injectScriptToHtml = require('../utils/injectScriptToHtml');
const readBody = require('../utils/readBody');
const injectScript = `
<script>
window.process = {
env: {
NODE_ENV: 'development'
}
};
</script>
`;
function htmlRewritePlugin({ app, root }) {
app.use(async (ctx, next) => {
await next();
if (ctx.body && ctx.response.is('html')) { // 只处理 js 文件
let content = await readBody(ctx.body);
const result = injectScriptToHtml(content, injectScript)
ctx.body = result;
}
});
}
- utils/injectScriptToHtml.js
const injectReplaceRE = [/<head>/, /<!doctype html>/i]
function injectScriptToHtml(html, script) {
// inject after head or doctype
for (const re of injectReplaceRE) {
if (re.test(html)) {
return html.replace(re, `$&${script}`)
}
}
// if no <head> tag or doctype is present, just prepend
return script + html
}
3) moduleRewritePlugin
- 使用
es-module-lexer
解析 import - 使用
magic-string
重写路径: vue
=> /@modules/vue
等
const readBody = require('../utils/readBody');
const rewriteImports = require('../utils/rewriteImports');
function moduleRewritePlugin({ app, root }) {
app.use(async (ctx, next) => {
await next();
if (ctx.body && ctx.response.is('js')) { // 只处理 js 文件
let content = await readBody(ctx.body);
const result = rewriteImports(content);
ctx.body = result;
}
});
}
- utils/readBody.js
- 将请求的 ReadableStream 转换成 String
const Stream = require('stream');
async function readBody(stream) {
if (stream instanceof Stream) {
return new Promise((resolve, reject) => {
let result = '';
stream.on('data', (chunk) => {
result += chunk;
});
stream.on('end', () => {
resolve(result);
});
});
} else {
return stream.toString();
}
}
- utils/rewriteImports.js
- 重写路径:
vue => /@modules/vue
等
const { parse } = require('es-module-lexer'); // 解析资源 ast 拿到 import 的内容
const MagicString = require('magic-string'); // 重写 string
function rewriteImports(source) {
let magicString = new MagicString(source);
let imports = parse(source)[0]; // import 语句
for(let i = 0; i < imports.length; i++) { // 拦截 import 语句,并重写
let { s, e } = imports[i];
let id = source.substring(s, e);
if (!/^[\.\/]/.test(id)) { // 没有 . 或 / 开头的,如 vue
id = `/@modules/${id}`;
magicString.overwrite(s, e, id);
}
}
return magicString.toString();
}
4) moduleResolvePlugin
- 解析 vue 依赖的模块,并按需加载进页面。流程如下:
- 在 koa 中间件里获取请求 path 对应的 body 内容
- 通过
es-module-lexer
解析资源 AST,并拿到 import 的内容 - 如果判断 import 是资源绝对路径,即可以认为该资源为项目应用资源,并返回处理后的资源路径。如
./App.vue
-> /src/App.vue
- 判断是否以
/@modules
,比如 vue -> /@modules/vue
;如果是,取出包名,去 node_modules
寻找对应的 npm 库。
const fs = require('fs');
const resolveVue = require('../utils/resolveVue');
const moduleRE = /^\/@modules\//;
// plugin for resolving /@modules/:id requests.
function moduleResolvePlugin({ app, root }) {
const vueResolved = resolveVue(root)
app.use(async (ctx, next) => {
if (!moduleRE.test(ctx.path)) {
return next();
}
// path maybe contain encode chars
const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
ctx.type = 'js'
const content = await fs.promises.readFile(vueResolved[id]);
ctx.body = content;
});
}
- utils/resolveVue.js
- 解析 vue 依赖的几个模块
const path = require('path');
const fs = require('fs');
const resolvePath = (name, from) => path.resolve(from, `@vue/${name}/dist/${name}.esm-bundler.js`);
function resolveVue(root) {
const modulesBasePath = path.join(root, 'node_modules');
// 其他核心模块
const runtimeDomPath = resolvePath('runtime-dom', modulesBasePath);
const runtimeCorePath = resolvePath('runtime-core', modulesBasePath);
const reactivityPath = resolvePath('reactivity', modulesBasePath);
const sharedPath = resolvePath('shared', modulesBasePath);
// 编译模块
const compilerPkgPath = path.join(modulesBasePath, '@vue/compiler-sfc/package.json');
const compilerPkg = require(compilerPkgPath);
const compilerPath = path.join(path.dirname(compilerPkgPath), compilerPkg['main']);
return {
vue: runtimeDomPath,
'@vue/runtime-dom': runtimeDomPath,
'@vue/runtime-core': runtimeCorePath,
'@vue/reactivity': reactivityPath,
'@vue/shared': sharedPath,
compiler: compilerPath
};
}
5) vuePlugin
- 使用
@vue/compiler-sfc
对 .vue
文件进行编译:通过 parseSFC
方法解决但文件组件,并通过 compileSFCMain
方法将单文件组件拆分。 - 将 sfc 的
script
部分和 template
部分分开编译及加载。
const path = require('path');
const fs = require('fs');
const resolveVue = require('../utils/resolveVue');
const readBody = require('../utils/readBody');
const defaultExportRE = /((\n|;)\s*)export default/;
function vuePlugin({ app, root }) {
app.use(async (ctx, next) => {
if (!ctx.path.endsWith('.vue') && !ctx.vue) {
return next();
}
const { parse, compileTemplate } = require(resolveVue(root).compiler);
let content = await fs.promises.readFile(path.join(root, ctx.path));
content = await readBody(content);
let { descriptor } = parse(content);
if (!ctx.query.type) { // script 部分
let code = ``;
if (descriptor.script) {
let content = descriptor.script.content;
let replaced = content.replace(defaultExportRE, '$1const __script = ');
code += replaced;
}
if (descriptor.template) {
const templateRequest = ctx.path + '?type=template';
code += `\nimport { render as __render } from '${templateRequest}'`;
code += `\n__script.render = __render`;
}
code += `\nexport default __script`;
ctx.type = 'js';
ctx.body = code;
} else { // template 部分
let content = descriptor.template.content;
const { code } = compileTemplate({ source: content });
ctx.type = 'js';
ctx.body = code;
}
});
}
总结
- Vite 利用浏览区支持 ESM 这一特性,省略对模块的打包,不用生成 bundle,因此初次启动更快,HMR 特性友好
- Vite 开发模式下,通过 koa 在服务端完成模块的改写、单文件解析编译和请求处理,实现真正按需编译
- Vite 中间件拦截请求后:
- 1)处理 ESM 语法
- 2)对于
.ts
、.vue
实现即时编译 - 3)对 Sass/Less 中需要预编的模块进行编译
- 4)和浏览区建立 socket 连接,实现 HMR
- Vite 的HMR
- 通过 watcher 监听文件改动
- server 端编译资源,并推送新模块内容给浏览器
- 浏览器收到新模块内容,执行 rerender/reload