【源码共读】第11期 | 从vue-dev-server中学到了vue文件到html页面渲染过程中发生了什么
前言
-
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
-
这是源码共读的第11期,链接:juejin.cn/post/708315… 。
清单
- 学会使用 VSCode 调试源码
- 学会如何编译 Vue 单文件组件
- 学会如何使用 recast 生成 ast 转换文件
- 如何加载包文件
入口
俗话说,“万事开头难”,那是因为大部分人不知道是如何开头的,不知道从哪里开始。 读了好几期源码后,我悟道了如何开头,即先从项目目录开始,从package.json开始了解项目,找到入口文件所在
了解项目目录
- 从目录中看有这几个文件需要了解:
- README.md文件中了解该库的使用方法和场景
- package.json文件中找到入口文件和项目启动方式
- test目录一般是测试文件所在地
- bin目录在nodejs中一般是作为入口处(这里猜测是和node一样),如何验证呢
看package.json文件找入口
"bin": {
"vue-dev-server": "./bin/vue-dev-server.js"
},
"scripts": {
"test": "cd test && node ../bin/vue-dev-server.js"
},
从bin配置对象中看,当前项目vue-dev-server
指向./bin/vue-dev-server.js
这个文件夹,说明bin
目录下的vue-dev-server.js
即这个项目的入口文件;
然后在scripts
配置项中可知npm run test
即项目的启动方式,启动的过程中先进入到test
文件目录,然后通过node
命令启动了./bin/vue-dev-server.js
这个文件
流程
上面通过查看
项目目录
和package.json
了解到了项目的入口文件和启动方式,接下来就看下主流程都做了什么
// bin/vue-dev-server.js
const express = require('express')
const { vueMiddleware } = require('../middleware')
const app = express()
const root = process.cwd(); //当前工作目录
app.use(vueMiddleware()) // 中间件
app.use(express.static(root)) //
app.listen(3000, () => {
console.log('server running at http://localhost:3000')
})
/**
* 通过express启动了一个服务 端口3000
*/
既然启动了一个本地服务器http://localhost:3000
,那么打开这个网址看看都发生了什么?
输入网址后,看到当前网址请求了四个资源分别是
localhost
、main.js
、vue
、test.vue
,那么为什么会请求这四个资源呢?从下图断点处可以看到:
express的静态目录指向了
test
目录,网址默认优先加载index.html
在
index.html
的script标签中引入了main.js
文件资源,那么再看main.js
中内容都是什么?
通过上图可知,
main.js
中使用了vue
的写法,引入Vue
资源和test.vue
文件,并把test.vue
资源挂载到#app
节点上;
到这里为止,index.html所需是所有资源和页面请求中请求到的资源一一对应上了,但是点开请求的资源,发现并不是和代码中写法完全一致,比如:
这?发生了什么?在什么时候被改变了呢?
再回头看请求到资源之前发生了什么?
app.use(vueMiddleware())
看到这里使用了一个vueMiddleware
函数,作为express的中间件,接着去看vueMiddleware
函数中做了什么?
通过对
app.use(vueMiddleware())
打断点,一步步执行,发现走到了这里
这里通过对请求资源路径的判断,对资源加以处理并通过send函数返回处理后的结果,这样以来似乎资源中途改变找到了原因!至此主流程的分析结束,接下来对细节进行分析~
细节
中间件
中间件是什么?其实就是服务端接收到请求资源的信息后,在返回信息给客户端之前,中间拦截了资源后做了某些资源处理的过程就是中间件的作用
中间件在express中就是一个fucntion(req,res,next){}
,其中req就是请求的资源相关信息,res可以用来返回资源,next就是这个函数通过后,把控制权交给下一个环节
请求main.js的时候发生了什么?
if (req.path.endsWith(".js")) { // 处理js后缀的文件
const key = parseUrl(req).pathname; // /main.js
let out = await tryCache(key); //从缓存中拿数据结果
if (!out) { // 缓存中没数据的时候,重新生成数据,并存入缓存中
// transform import statements
const result = await readSource(req);
out = transformModuleImports(result.source); // 做的最主要的作用是整个js,把main.js中的vue替换成import Vue from "/__modules/vue"
cacheData(key, out, result.updateTime); // 存储数据 把文件名为key ast整合后的文件内容为value,文件的最后更新时间为最新更新时间
}
send(res, out, "application/javascript"); // js资源发送给客户端
}
- js后缀的文件,获取路径名
main.js
作为key去缓存中拿数据,如果缓存中没有数据,则重新读取main.js文件资源 - 读取到文件资源后,交给
transformModuleImports
函数处理,拿到处理结果后,先根据key缓存一份,然后发送资源给客户端
接下来看看tryCache
做了什么?
// 功能:拿缓存中的数据
async function tryCache(key, checkUpdateTime = true) {
const data = cache.get(key);
if (checkUpdateTime) {
const cacheUpdateTime = time[key];
const fileUpdateTime = (await stat(
path.resolve(root, key.replace(/^\//, ""))
)).mtime.getTime();
if (cacheUpdateTime < fileUpdateTime) return null; // 缓存的更新时间小于文件的更新时间的时候,返回空
}
return data;
}
看完了tryCache
再看下readSource
函数做了什么?
// 作用:处理文件,并返回当前文件全路径和文件流 更新时间
async function readSource(req) {
const { pathname } = parseUrl(req) // 拿路径
const filepath = path.resolve(root, pathname.replace(/^\//, '')) // 文件路径组合 当前进程+路径
return {
filepath, // D:\2023\vue-dev-server-analysis\vue-dev-server\test\main.js
source: await readFile(filepath, 'utf-8'), // 读取的main.js的文件流
updateTime: (await stat(filepath)).mtime.getTime() // 获取当前文件的更新时间,修改时间
}
}
这个函数主要是返回文件全路径、main.js文件流、文件更新时间,接着核心地方来了transformModuleImports
函数
// 作用:js 处理import并转换成js
function transformModuleImports(code) {
const ast = recast.parse(code) // ast
recast.types.visit(ast, {
visitImportDeclaration(path) {
const source = path.node.source.value
if (!/^\.\/?/.test(source) && isPkg(source)) { // 主要处理import xxx from xxx 并且不是相对路径的 并且在package.json文件中存在的依赖包
path.node.source = recast.types.builders.literal(`/__modules/${source}`) // 主要处理package.json中第三方依赖的路径问题,重新定义新的路径
}
this.traverse(path)
}
})
return recast.print(ast).code
}
exports.transformModuleImports = transformModuleImports
/**
* recast:
* 作用:用于操作js代码的AST抽象语法树,AST是一个表示代码结构的树形结构,通过解析js代码生成
* 1.解析js代码并生成AST
* 2.遍历和查询AST节点,进行静态代码分析
* 3.修改AST节点,进行重构和转换操作,如添加、删除、重命名和移除节点等操作
* 4.将修改后的AST重新生成js代码
*/
当js处理的时候发现了需要加载的新资源vue
和test.vue
,按照顺序,需要先处理vue
资源
第三方库vue资源是如何处理的呢
if (!/^\.\/?/.test(source) && isPkg(source)) { // 主要处理import xxx from xxx 并且不是相对路径的 并且在package.json文件中存在的依赖包
path.node.source = recast.types.builders.literal(`/__modules/${source}`) // 主要处理package.json中第三方依赖的路径问题,重新定义新的路径
}
- 在处理js资源的时候,需要第三方库vue就处理成了
/__modules/vue
这样的,如main.js中import Vue from "/__modules/vue"
- 而中间件拦截的地方有个关于
/__modules/
的判断,如下:
if (req.path.startsWith("/__modules/")) { // 处理/__modules/相关的文件逻辑
const key = parseUrl(req).pathname;
const pkg = req.path.replace(/^\/__modules\//, ""); // 三方包名
let out = await tryCache(key, false); // Do not outdate modules 拿存储数据
if (!out) {
out = (await loadPkg(pkg)).toString(); // 读取的是vue.esm.browser.js文件流
cacheData(key, out, false); // Do not outdate modules 存放当前文件名和数据 不用设置日期
}
send(res, out, "application/javascript");
}
- 同js类似的步骤,先从缓存中拿数据,没拿到,就通过
loadPkg
函数加载数据,然后再次缓存数据,最后发给客户端资源 - 其中如何加载vue数据的呢
// 作用:加载处理vue包
async function loadPkg(pkg) {
if (pkg === 'vue') {
// require.resolve('vue') D:\2023\vue-dev-server-analysis\vue-dev-server\node_modules\vue\dist\vue.runtime.common.js
const dir = path.dirname(require.resolve('vue'))
// require.resolve():获取指定模块的绝对路径 path.dirname:获取该路径所在的目录路径
const filepath = path.join(dir, 'vue.esm.browser.js') // 拼接成浏览器支持的文件路径
return readFile(filepath) // 根据路径读文件
}
else {
// TODO
// check if the package has a browser es module that can be used
// otherwise bundle it with rollup on the fly?
throw new Error('npm imports support are not ready yet.')
}
}
到这里就清楚的知道了为什么能加载到node_modulues中的vue资源了,接下来还有一个test.vue
资源的加载处理,这个是如何做的呢?
.vue文件的处理
if (req.path.endsWith(".vue")) { // 处理vue后缀的文件
const key = parseUrl(req).pathname;
let out = await tryCache(key);
if (!out) {
// Bundle Single-File Component 编译处理单文件组件
const result = await bundleSFC(req);
out = result;
cacheData(key, out, result.updateTime);
}
send(res, out.code, "application/javascript");
}
- 同样代码四步走:拿缓存数据---没有则对.vue文件处理
bundleSFC
---处理后的数据,缓存----发送给客户端 - 其他都不说了,就重点说下
bundleSFC
函数
// 作用:编译成纯js相关的文件
async function bundleSFC(req) {
const { filepath, source, updateTime } = await readSource(req); // 获取当前文件的信息
const descriptorResult = compiler.compileToDescriptor(filepath, source); // 编译文件
const assembledResult = vueCompiler.assemble(compiler, filepath, {
...descriptorResult,
script: injectSourceMapToScript(descriptorResult.script),
styles: injectSourceMapsToStyles(descriptorResult.styles),
});
return { ...assembledResult, updateTime };
}
通过断点可以看到@vue/component-compiler
库加载了test.vue
后,形成的数据解构和结果,然后通过vueCompiler.assemble
方法拿到的结果
最后,assembledResult.code就是
test.vue
源文件转换成js之后的具体内容,如下
这就是关于
test.vue
文件所做的事情
LRU原理
/**
* lru-cache:最近最少使用算法
* 帮助缓存函数的结果,减少不必要的重复计算
*
* 工作原理:
* 当缓存空间满了之后,会先cleared最久未使用的缓存项;
* 每当读取或写入缓存时,对应的缓存项就会被移动至最近使用链表的头部
*/
总结
通过对vue-dev-server
仓库源码的学习,看到了express中间件对不同类型的文件的处理,就比如index.html
、main.js
、vue
和test.vue
四类资源的处理方式,也看到了对性能的考虑,使用到了cache
缓存原理LRU
的具体应用,其中从第三方库vue和vue组件的编译中学到了如何加载包文件和编译 Vue 单文件组件,进一步加深了我对Vue框架的理解,最后说一句,在vscode中断点调试的方式真方便~~~。
转载自:https://juejin.cn/post/7249650184215674938