前言
vue-dev-server是尤大在19年写的一个demo项目(项目地址为:github.com/vuejs/vue-d…
主要实现了以下代码在浏览器端的正常打开
<div id="app"></div>
<script type="module">
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
</script>
<template>
<div>{{ msg }}</div>
</template>
<script>
export default {
data() {
return {
msg: 'Hi from the Vue file!'
}
}
}
</script>
<style scoped>
div {
color: red;
}
</style>
本文将简单实现一下这个过程
为什么上诉代码无法直接在浏览器中运行
-
对于vue这种node_modules中的依赖,浏览器无法自动添加node_modules的路径前缀
-
浏览器无法识别.vue结尾的文件
服务器准备
- 创建一个目录,初始化项目
pnpm init
- 安装express依赖
pnpm i express
- 按以下流程图写好简单的服务端代码:
// server/index.js
// express是一个比较快捷的服务器,可以方便地使用中间件去处理请求
const express = require('express')
const { vueMiddleware } = require('../middleware')
const app = express()
const root = process.cwd();
// vueMiddleware就是我们代码的主体
app.use(vueMiddleware())
// express.static是express内置的静态资源处理中间件,可以处理常规的静态资源
// 比如html文件、js文件、img文件等
app.use(express.static(root))
app.listen(3002, () => {
console.log('server running at http://localhost:3002')
})
// vueMiddleware.js
const vueMiddleware = (options) => {
return (req, res, next) => {
next();
}
}
module.exports.vueMiddleware = vueMiddleware;
完成后发现项目目录结构为:
终端执行node ./server/index.js
发现页面空白,且浏览器控制台报错:Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".
处理依赖
本节将使代码中的import vue from 'vue'
转换为 import vue from '/__modules/vue'
,用来解决上面操作的浏览器控制报错
原因分析
import vue from 'vue'这样的代码无法在浏览器中直接运行,浏览器只能识别http
、./
、/
、../
开头的路径资源
拦截.html或者.js结尾的请求,并读取内容
// vueMiddleware.js
const vueMiddleware = (options) => {
return (req, res, next) => {
if (req.path.endsWith('.js')) {
// 读取js相关代码(简化代码)
const { source } = readSource(req.path);
const newSrc = addPrefixBeforeImportInJs(source);
// 返回处理后的js代码
send(res, newSrc, 'application/javascript');
} else if (req.path.endsWith('.html')) {
// 读取html相关(简化代码)
const { source } = readSource(req.path);
// 处理html代码(后续会写)
const newSrc = addPrefixBeforeImportInHtml(source)
// 返回处理后的html代码
send(res, newSrc, 'text/html')
} else {
next();
}
}
}
module.exports.vueMiddleware = vueMiddleware;
用parse5库将html代码解析出每一段script标签
安装依赖:pnpm i parse5
// source.js
const parse5 = require('parse5');
function findScriptTagsInHtml(node, scriptTags = []) {
if (node.tagName === 'script') {
scriptTags.push(node);
} else if (node.childNodes) {
for (const childNode of node.childNodes) {
scriptTags.push(...findScriptTagsInHtml(childNode, scriptTags));
}
}
}
用babel内核库解析处理script标签中的内容
安装依赖:pnpm i @babel/parser @babel/traverse magic-string
- @babel/parser 和 @babel/traverse是babel解析器,用来生成ast树和解析获取import所在节点,并且能返回节点对应源码的位置信息
- magic-string是一个字符串处理的库,可以处理指定位置的字符串,搭配上面获得的位置信息,可以很方便的修改代码,避免直接改动ast树,这种操作受到众多知名框架的欢迎,比如vite。
// 判断依赖是否为本地依赖,只有本地依赖才需要添加前缀
// 例如import vue from 'vue';
function isDependency(str) {
return !str.startsWith('.') && !str.startsWith('/') && !str.startsWith('http') && !str.startsWith('https');
}
// 解析js中的import代码
function addPrefixBeforeImportInJs(jsSrc) {
try {
// 解析 JavaScript 代码
const ast = parse(jsSrc, {
sourceType: 'module',
});
const code = new MagicString(jsSrc);
// 遍历 AST,找到所有 import 语句
traverse(ast, {
enter(path) {
if (path.isImportDeclaration()) {
// 使用 MagicString 来替换 import 语句中的 from 关键字后面的内容
const source = path.node.source;
const importStart = source.start + 1;
const importEnd = source.end - 1 ;
console.log(source.value);
if (importEnd > importStart && isDependency(source.value)) {
code.overwrite(importStart, importEnd, `/__modules/${source.value}`);
}
}
},
});
return code.toString();
} catch (error) {
console.error(`Error parsing script content: ${error.message}`);
}
}
// 添加/__modules/到每一段
function addPrefixBeforeImportInHtml(htmlSrc) {
const document = parse5.parse(htmlSrc);
const scriptTags = [];
// 获取每一段type=module的script
findScriptTagsInHtml(document, scriptTags);
scriptTags.forEach(scriptTag => {
if (scriptTag.childNodes && scriptTag.childNodes.length > 0) {
const scriptContent = scriptTag.childNodes[0].value;
const newScriptContent = addPrefixBeforeImportInJs(scriptContent);
scriptTag.childNodes[0].value = newScriptContent;
}
});
// 返回新的内容
return parse5.serialize(document);
}
效果
请求的html资源返回为:
<html><head></head><body><div id="app"></div>
<script type="module">
import Vue from '/__modules/vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')</script></body></html>
浏览器依旧显示为空页面,且出现下面两个问题
- /__modules/vue 请求返回404
- /App.vue 请求返回vue文件里面的内容,但是.vue文件浏览器没有对应的mime type,报错:Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.
疑问
问题1:为啥上诉效果只处理了html代码,却对js后缀的请求也做了相同处理?
因为引入的js也有可能写入import vue from 'vue'这类引入依赖的语句
问题2:为啥要给依赖添加前缀?
因为需要二次拦截指定前缀开头的请求,接下来就是这个步骤
引入依赖
本节解决上诉步骤出现的/__modules/vue 请求返回404
问题
原因分析
服务器的根目录下没有/__modules/vue 对应的资源,需要添加对应的内容返回,实际上我们想要返回node_modules/vue/dist/vue.runtime.esm-browser.js的内容
拦截/__modules/开头的请求
const vueMiddleware = (options) => {
return async (req, res, next) => {
if (req.path.endsWith('.js')) {
// js相关
...
} else if (req.path === '/' || req.path.endsWith('.html')) {
// html相关
...
} else if (req.path.startsWith('/__modules/')) {
// 解析依赖名称
const pkgName = req.path.replace('/__modules/', '');
// 加载依赖的browser-esm版本的代码
const { source } = loadkg(pkgName);
send(source, 'application/javascript')
} else {
next();
}
}
}
加载依赖的browser-esm版本的代码
对于vue这类成熟框架而言,是有browser-esm版本的js提供的,可以直接读取/node_modues/vue/dist/
安装依赖:pnpm i vue@2.6.8
// 加载依赖的esm版本代码
async function loadPkg(pkgName) {
console.log(pkgName, 'pkgName')
if (pkgName === 'vue') {
const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
return res;
} else {
}
}
效果:打开http://localhost:3002/__modules/vue 正确返回js内容
解析vue文件
本节解决/App.vue请求返回浏览器无法识别内容的问题
拦截.vue结尾的请求
const vueMiddleware = (options) => {
return async (req, res, next) => {
if (req.path.endsWith('.js')) {
// js相关
...
} else if (req.path === '/' || req.path.endsWith('.html')) {
// html相关
...
} else if (req.path.startsWith('/__modules/')) {
// 解析依赖名称
...
} else if (req.path.endsWith('.vue'))) {
// 解析.vue文件,转换为可执行的js
const { source } = loadVue(req.path);
// 处理js,同上为pkg依赖添加/__modules/前缀
const vueJs = transformModuleImports(source)
send(vueJs, 'application/javascript')
} else {
next();
}
}
}
处理vue文件,转换为可执行的js
"@vue/component-compiler" 是一个 Vue.js 组件编译器的 npm 包。它被设计为与 Vue.js 的构建工具一起使用,可以将 Vue.js 单文件组件 (.vue 文件) 编译为 JavaScript 模块,以便可以在浏览器或 Node.js 环境中使用,它依赖于vue-template-compiler,我们这里将用来处理本地的.vue文件
安装组件编译器依赖:pnpm i @vue/component-compiler
安装vue@2.6.8版本对应的模板编译器: pnpm i vue-template-compiler@2.6.8
// 定义一个异步函数,用于加载 Vue 单文件组件
async function loadVue(reqFilePath) {
// 读取 Vue 文件内容,获取文件路径和文件源代码
const { filepath, source } = await readSource(reqFilePath);
// 调用编译器的 compileToDescriptor 方法,将源代码编译为一个描述对象
const descriptorResult = compiler.compileToDescriptor(filepath, source);
// 调用 vueCompiler.assemble 方法,将描述对象转换为一个 Vue 组件对象
const assembledResult = vueCompiler.assemble(compiler, filepath, {
...descriptorResult,
});
// 返回一个包含 Vue 组件代码的对象
return { source: assembledResult.code };
}
效果
浏览器打开http://localhost:8080 , 页面出现内容,如下图所示:
尝试修改样式
<style scoped>
div {
color: blue;
}
</style>
至此已经实现了一个满足要求的vue-dev-server
总结
本文简单实现了vue-dev-server,可以看出,在此项目中,我们没有像webpack一样,把包括依赖在内的js压缩到同一个文件中,而是利用浏览器module的特性,实时去编译vue文件成浏览器可运行的esm代码,这个动作和vite开发服务器的no-bundle原理是相似的。
但是上诉demo还有很多缺陷,例如:
- 每一次都要去编译vue文件,需要引入缓存机制只去编译首次访问的文件和改变了的文件
- 每次引入第三方依赖,都要引入依赖对应的esm模块代码,而有些依赖并有esm版本的文件,比如lodash、react,并且每次都要添加代码去指向
- 产生了请求依赖,如果使用了lodash-es,将会产生几百条请求
- 没有hmr机制
- ...
这些缺陷将在后续章节中一一解决,通过这些缺陷的解决,可以进一步了解包括预构建在内的vite的no-bundle原理。
链接文档
- [demo项目的git地址]: github.com/blankzust/v…
- [若川大神的vue-dev-server解析]: lxchuan12.gitee.io/vue-dev-ser…
- [@vue/component-compiler]:www.npmjs.com/package/@vu…