Skip to content

用 100 行代码构建一个简易版 Vite,以便对其原理有基础的认识。

Notifications You must be signed in to change notification settings

XC0703/weak-vite

Folders and files

NameName
Last commit message
Last commit date

Latest commit

d4767bf · Nov 17, 2024

History

1 Commit
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024
Nov 17, 2024

Repository files navigation

1、前言

Vite 相较于传统的 Webpack、Rollup 的编译方式,采用的是 ESM 混合编译。即在开发环境,使用 Server 动态编译 + 浏览器的 ESM ,实现了开发环境 “0 编译”。在生产环境时,采用了 Rollup 进行打包编译。Vite 的优势便是热更新速度速度快,且易于上手。

本文将带领读者用 100 行代码构建一个简易版 Vite,以便对其原理有基础的认识(更多原理请看源码或者其他博主的文章),相关代码已经上传到仓库weak-vite

2、初始化项目

mkdir weak-vite
yarn init
yarn add vue

同时新建 src 与 vite 两个子目录,分别存放我们要运行的代码与建议 vite 的实现代码:

其中:

// src\App.vue

<template>
	<h1>{{ msg }}</h1>
</template>

<script>
	import { reactive, toRefs } from 'vue';
	export default {
		setup() {
			const state = reactive({
				msg: 'hello weak-vite !'
			});
			return {
				...toRefs(state)
			};
		}
	};
</script>
// src\main.js

import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');
<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>weak-vite</title>
	</head>
	<body>
		<div id="app"></div>
		<script type="module" src="/src/main.js"></script>
	</body>
</html>

上面这三个文件就是功能实现代码,剩下的工作就是去要实现一个 vite 把这三个文件可以正常运行起来并在浏览器上看到效果。

cd vite
yarn init
yarn add @vue/compiler-sfc connect es-module-lexer esbuild magic-string
  • @vue/compiler-sfc:Vue.js 的单文件组件(Single File Component, SFC)编译器, 将 SFC 文件编译成 json 数据(Vue 组件的模板、脚本和样式)
  • connect:中间件框架
  • es-module-lexer:解析 ES 模块的库,获取文件中 import 语句的信息
  • esbuild:这是一个极快的 JavaScript 构建工具,类似于 Webpack 或 Rollup。它用于编译、打包和优化 JavaScript 代码
  • magic-string:用来替换第三方包的路径

最后在vite\index.js下面初始化我们的 vite server:

// vite\index.js

const http = require('http');
const connect = require('connect');
const middlewares = connect();

// 用于返回 html 的中间件
middlewares.use(indexHtmlMiddleware);

// 处理 js 和 vue 请求的中间件
middlewares.use(transformMiddleware);

// 创建 node 服务
const createServer = async () => {
	// 依赖预构建
	await optimizeDeps();
	http.createServer(middlewares).listen(5173, () => {
		console.log('weak-vite-dev-server start at localhost: 5173!');
	});
};
createServer();

其中,中间件函数indexHtmlMiddleware没什么好说的,就是读取返回根目录的 index.html,这里先进行书写:

// vite\index.js

const indexHtmlMiddleware = (req, res, next) => {
	if (req.url === '/') {
		const htmlPath = path.join(__dirname, '../index.html');
		const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
		res.setHeader('Content-Type', 'text/html');
		res.statusCode = 200;
		return res.end(htmlContent);
	}
	next();
};

3、依赖预构建

我们在上面的 server 初始化代码中可以看到在创建 node 服务前我们执行了一个依赖预构建的工作,那么何为依赖预构建呢,vite 不是 No Bundle 吗?对此,官方文档做出了详细解释:点此查看原因,简而言之其目的有二:

  • 兼容 CommonJS 和 AMD 模块的依赖
  • 减少模块间依赖引用导致过多的请求次数

依赖预构建总结:依赖预构建大致的原理是

  • 先收集打包的依赖,比如代码中引入了 vue,那么匹配到 vue 后到 node_modules 中找到对应 vue 的 esm 模块 js 然后把 js 都存储到一个对象中(收集依赖的过程也用到了 esbuild build 方法,收集的过程是在一个 esbuild 插件中完成的)
  • 得到一个所有依赖的对象后再次用 esbuild 对这些依赖进行编译,编译后的文件存储到.vite 中 下次再使用依赖的时候 直接从.vite 中获取,不需要再次编译。
//  vite\index.js

// 依赖预构建的缓存目录
const cacheDir = path.join(__dirname, '../', 'node_modules/.vite');

// 依赖预构建
const optimizeDeps = async () => {
	if (fs.existsSync(cacheDir)) return false;
	fs.mkdirSync(cacheDir, { recursive: true });
	// 分析依赖时,源码使用了 esbuild 插件去分析,这里为了简化逻辑,直接读取了上级 package.json 的 dependencies 字段
	const deps = Object.keys(require('../package.json').dependencies);
	// 利用 esbuild 的 build 方法去构建依赖
	const result = await esbuild.build({
		entryPoints: deps, // 入口文件
		bundle: true, // 是否打包,表示将所有依赖项打包成一个或多个文件。
		format: 'esm', // 输出文件格式
		logLevel: 'error', // 日志级别
		splitting: true, // 是否拆分,表示将所有依赖项拆分成多个文件
		sourcemap: true, // 是否生成源映射文件
		outdir: cacheDir, // 输出目录
		treeShaking: false, // 是否启用 tree-shaking
		metafile: true, // 是否生成元数据文件
		// 后面三个环境变量不是必须的,只是为了让 esbuild 不报警告
		define: {
			'process.env.NODE_ENV': '"development"',
			__VUE_OPTIONS_API__: 'true',
			__VUE_PROD_DEVTOOLS__: 'false',
			__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
		}
	});
	const outputs = Object.keys(result.metafile.outputs);
	// console.log('outputs:', outputs);
	// 打印结果为:outputs: [ 'node_modules/.vite/vue.js.map', 'node_modules/.vite/vue.js' ]
	const data = {};
	deps.forEach(dep => {
		data[dep] = '/' + outputs.find(output => output.endsWith(`${dep}.js`));
	});
	// 将依赖路径写入 _metadata.json 文件
	const dataPath = path.join(cacheDir, '_metadata.json');
	fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
};

此时运行则会看到发现有打包后的依赖包及依赖映射的 json 文件:

4、路径重写机制

在讲路径重写之前,我们先简单聊一聊 Vite 这类基于 ESM 的构建工具为何需要进行模块的路径重写。 当我们直接将源码运行在浏览器,而不对 import 路径进行任何处理时,可能会出现如下错误:

很显然,这是因为我们对一些依赖的引入(对于本项目来说,就是main.js文件的import导入)导致的,

import { createApp } from 'vue';
import App from './App.vue';

浏览器是无法直接通过上面的路径拿到对应的文件的,这是因为第一个是要获取node_modules里面的模块,第二个是相对路径。

// vite\index.js

// 解析 import 语句
const importAnalysis = async code => {
	// es-module-lexer 的 init 必须在 parse 前 Resolve
	await init;
	// 通过 es-module-lexer 分析源 code 中所有的 import 语句
	const [imports] = parse(code);
	// 如果没有 import 语句我们直接返回源 code
	if (!imports || !imports.length) return code;
	// 定义依赖映射的对象
	const metaData = require(path.join(cacheDir, '_metadata.json'));
	// magic-string vite2 源码中使用到的一个工具 主要适用于将源代码中的某些轻微修改或者替换
	let transformCode = new MagicString(code);
	imports.forEach(importer => {
		// n: 表示模块的名称 如 vue
		// s: 模块名称在导入语句中的起始位置
		// e: 模块名称在导入语句中的结束位置
		const { n, s, e } = importer;
		// 得到模块对应预构建后的真实路径  如
		const replacePath = metaData[n] || n;
		// 将模块名称替换成真实路径如/node_modules/.vite
		transformCode = transformCode.overwrite(s, e, replacePath);
	});
	return transformCode.toString();
};

// 处理 js 和 vue 请求的中间件
const transformMiddleware = async (req, res, next) => {
	// 因为预构建我们配置生成了 map 文件所以同样要处理下 map 文件
	if (req.url.endsWith('.js') || req.url.endsWith('.map')) {
		const jsPath = path.join(__dirname, '../', req.url);
		const code = fs.readFileSync(jsPath, 'utf-8');
		res.setHeader('Content-Type', 'application/javascript');
		res.statusCode = 200;
		// map 文件不需要分析 import 语句
		const transformCode = req.url.endsWith('.map') ? code : await importAnalysis(code);
		return res.end(transformCode);
	}
	if (req.url.indexOf('.vue') !== -1) {
		const vuePath = path.join(__dirname, '../', req.url.split('?')[0]);
		// 拿到 vue 文件中的内容
		const vueContent = fs.readFileSync(vuePath, 'utf-8');
		// 通过@vue/compiler-sfc 将 vue 中的内容解析成 AST
		const vueParseContet = compileSFC.parse(vueContent);
		// 得到 vue 文件中 script 内的 code
		const scriptContent = vueParseContet.descriptor.script.content;
		const replaceScript = scriptContent.replace('export default ', 'const __script = ');
		// 得到 vue 文件中 template 内的内容
		const tpl = vueParseContet.descriptor.template.content;
		// 通过@vue/compiler-dom 将其解析成 render 函数
		const tplCode = compileDom.compile(tpl, { mode: 'module' }).code;
		const tplCodeReplace = tplCode.replace(
			'export function render(_ctx, _cache)',
			'__script.render=(_ctx, _cache)=>'
		);
		// 最后 script 内还要再一次进行 import 语句分析替换
		const code = `
			${await importAnalysis(replaceScript)}
			${tplCodeReplace}
			export default __script;
		`;
		res.setHeader('Content-Type', 'application/javascript');
		res.statusCode = 200;
		return res.end(await importAnalysis(code));
	}
	next();
};

可以看到,里面的导入路径已经被重写:



此时可以看到页面正确地显示了效果:

5、一点 Vite 的八股

5-1 Vite 跟 Webpack 的区别?Vite 有什么优势?

参考博客:

它们存在四个主要区别:

  1. 热更新效率不同:Webpack 在开发模式下依然会对所有模块进行打包操作,虽然提供了热更新,但大型项目中依然可能会出现启动和编译缓慢的问题;而 Vite 则采用了基于 ES Module 的开发服务器,只有在需要时才会编译对应的模块,大幅度提升了开发环境的响应速度。
  2. 构建产物不同:Webpack 会生成一个或多个 bundle 文件,这些文件包含了整个项目的代码和依赖关系。而 Vite 在开发环境下生成的是单独的模块文件,而在生产环境下生成的是优化后的静态资源文件。
  3. 插件生态不同:Webpack 的插件生态非常丰富,有大量社区和官方插件可以选择,覆盖了前端开发的各个方面;而 Vite 的插件生态尽管在不断发展,但相比 Webpack 来说还显得较为稀少。
  4. 配置复杂度不同:Webpack 的配置相对复杂,对新手不够友好;而 Vite 在设计上更注重开箱即用,大部分场景下用户无需自己写配置文件。

Vite 的优势便是热更新速度速度快,且易于上手。

5-2 Vite 为什么比 Webpack 快?(启动速度与热更新速度)

参考博客:深入理解 Vite 核心原理

  • webpack 会先打包,然后启动开发服务器,请求服务器时直接给予打包结果。 而 vite 是直接启动开发服务器,请求哪个模块再对该模块进行实时编译。 由于现代浏览器本身就支持 ES Module,会自动向依赖的 Module 发出请求。vite 充分利用这一点,将开发环境下的模块文件,就作为浏览器要执行的文件,而不是像 webpack 那样进行打包合并。 由于 vite 在启动的时候不需要打包,也就意味着不需要分析模块的依赖、不需要编译,因此启动速度非常快。当浏览器请求某个模块时,再根据需要对模块内容进行编译。这种按需动态编译的方式,极大的缩减了编译时间,项目越复杂、模块越多,vite 的优势越明显。
  • 在 HMR(热更新)方面,当改动了一个模块后,仅需让浏览器重新请求该模块即可,不像 webpack 那样需要把该模块的相关依赖模块全部编译一次,效率更高。 当需要打包到生产环境时,vite 使用传统的 rollup(也可以自己手动安装 webpack 来)进行打包,因此,vite 的主要优势在开发阶段。另外,由于 vite 利用的是 ES Module,因此在代码中(除了 vite.config.js 里面,这里是 node 的执行环境)不可以使用 CommonJS

5-3 如果想让 Vite 的兼容性更好,应该作什么配置呢?

由于 vite 利用的是 ES Module,因此在代码中(除了 vite.config.js 里面,这里是 node 的执行环境)不可以使用 CommonJS。虽然大部分浏览器能兼容,但是少数如 ie 系浏览器全军覆没,移动端浏览器 uc、baidu 等不支持。

build.target 配置可以选择浏览器版本,参考 esbuild.target 配置,但是还是不支持 ie。

如果要支持低版本浏览器可以使用官方提供的插件 @vitejs/plugin-legacy

plugin-legacy 会将代码打包两套:

  • 如果浏览器支持 <script type="module">则使用原生 ESM 加载,引入 index.[hash].js,代码里使用 import 导入文件
  • 如果浏览器不支持 ESM <script nomodule>则使用另外一套 System.import 的方案,引入 index-legacy.[hash].js

5-4 Vite 在构建运行之前对第三方库作出了什么操作或者说优化?

参考博客:Vite 原理学习之按需编译

当 Vite 启动开发服务器之前会完成依赖预构建工作,这个工作整个流程简单来说是通过入口文件扫描所有源码,并分析相关 import 语句得到使用的第三方依赖包名,之后使用 esbuild 对依赖进行编译,至此完成整个预编译过程。之后会启动开发服务器并在相关端口进行监听。

About

用 100 行代码构建一个简易版 Vite,以便对其原理有基础的认识。

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published