在vuejs仓库下,有个vue-dev-server项目,看它介绍:
This is a proof of concept.
Imagine you can import Vue single-file components natively in your browser... without a build step.
意思是这是个概念项目:实现了在浏览器中本地导入Vue单文件组件……无需构建步骤。
clone下来,项目启动,其间确实无打包过程。我们来看看其具体实现。
目录结构如下:
1. 本地服务
// 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')
})
这里做了三件事:
- 执行核心中间件:
vueMiddleware - 通过
express.static()托管静态文件:托管后,root文件夹(即test目录)下的所有文件都可以通过http://localhost:3000/文件名访问了。在浏览器打开第3步中的http://localhost:3000,访问的就是test目录下的index.html。 - 启动服务
2. vueMiddleware中间件
vueMiddleware中间件是核心逻辑所在。我们先来看下其依赖的文件:
const vueCompiler = require('@vue/component-compiler')
const fs = require('fs')
const stat = require('util').promisify(fs.stat)
const root = process.cwd()
const path = require('path')
const parseUrl = require('parseurl')
const { transformModuleImports } = require('./transformModuleImports')
const { loadPkg } = require('./loadPkg')
const { readSource } = require('./readSource')
const LRU = require('lru-cache') // 缓存
2.1、@vue/component-compiler
将.vue文件编译成浏览器能解析的.js文件
2.2、stat
读取文件的状态,可以知道文件的:大小(stat.size)、创建时间(stat.birthtime)、是否是文件(sttat.isFile())、是否是目录(stat.isDirectoty()等。
这里通过util.promisify()后,stat返回promise,可以直接await执行
2.3、root
process.cwd()返回的是当前Node进程执行时的工作目录。如:
cd test && node ../bin/vue-dev-server.js
工作目录即是test目录
2.4、parseUrl
解析路径,带有记忆功能(多次解析相同的路径时会返回缓存)
2.4、transformModuleImports
const recast = require('recast')
const isPkg = require('validate-npm-package-name')
function transformModuleImports(code) {
const ast = recast.parse(code)
recast.types.visit(ast, {
visitImportDeclaration(path) {
const source = path.node.source.value
// 如果不是相对路径 且 是一个npm包
if (!/^\.\/?/.test(source) && isPkg(source)) {
path.node.source = recast.types.builders.literal(`/__modules/${source}`)
}
this.traverse(path)
}
})
return recast.print(ast).code
}
检测.js文件中依赖文件的引入路径,如何符合以下条件:
- 不是相对路径
- 是一个npm包
则将其引入路径改为:/__modules/${source},相当于将node_modules里的依赖提取至/__modules/下
2.6、loadPkg
读取/__modules/下的包资源
const fs = require('fs')
const path = require('path')
const readFile = require('util').promisify(fs.readFile)
async function loadPkg(pkg) {
if (pkg === 'vue') {
const dir = path.dirname(require.resolve('vue'))
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.')
}
}
如果是vue,返回的是node_modules/vue/dist/vue.esm.browser.js
可以看到其他包并不支持,毕竟是个实验项目。
2.7、readSource
读取文件。
async function readSource(req) {
const { pathname } = parseUrl(req)
const filepath = path.resolve(root, pathname.replace(/^\//, ''))
return {
filepath,
source: await readFile(filepath, 'utf-8'),
updateTime: (await stat(filepath)).mtime.getTime()
}
}
还返回了文件的更新时间,以便做缓存
2.8、lru-cache
Least Recently Used,按照字面意思就是最近最少使用。当缓存满了的时候,不经常使用的就直接删除,挪出空间来缓存新的。详细参考:lru-cache
主逻辑:
const vueMiddleware = (options = defaultOptions) => {
let cache
let time = {}
if (options.cache) {
// 引入缓存
const LRU = require('lru-cache')
cache = new LRU({
max: 500,
length: function (n, key) { return n * 2 + key.length }
})
}
// 创建@vue/component-compiler实例
const compiler = vueCompiler.createDefaultCompiler()
function send(res, source, mime) {
res.setHeader('Content-Type', mime)
res.end(source)
}
// 给js/css文件注入sourcemap信息
function injectSourceMapToBlock (block, lang) {
// vue-dev-server/node_modules/js-base64/base64.js全局的Base64变量
const map = Base64.toBase64(
JSON.stringify(block.map)
)
let mapInject
switch (lang) {
case 'js': mapInject = `//# sourceMappingURL=data:application/json;base64,${map}\n`; break;
case 'css': mapInject = `/*# sourceMappingURL=data:application/json;base64,${map}*/\n`; break;
default: break;
}
return {
...block,
code: mapInject + block.code
}
}
function injectSourceMapToScript (script) {
return injectSourceMapToBlock(script, 'js')
}
function injectSourceMapsToStyles (styles) {
return styles.map(style => injectSourceMapToBlock(style, 'css'))
}
// 缓存
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
}
function cacheData (key, data, updateTime) {
const old = cache.peek(key)
if (old != data) {
cache.set(key, data)
if (updateTime) time[key] = updateTime
return true
} else return false
}
// 编译.vue文件
async function bundleSFC (req) {
const { filepath, source, updateTime } = await readSource(req)
// 获取源代码并分别编译每个块。 在内部使用了来自@vue/component-compiler-utils 的 compileTemplate 和 compileStyle。
const descriptorResult = compiler.compileToDescriptor(filepath, source)
// 将描述符结果 组装成js代码并返回
const assembledResult = vueCompiler.assemble(compiler, filepath, {
...descriptorResult,
script: injectSourceMapToScript(descriptorResult.script),
styles: injectSourceMapsToStyles(descriptorResult.styles)
})
return { ...assembledResult, updateTime }
}
// 拦截文件请求
return async (req, res, next) => {
// 如果是.vue文件,调用bundleSFC编译,返回js
if (req.path.endsWith('.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')
} else if (req.path.endsWith('.js')) {
// 如果是js,校验其依赖文件的引入路径
const key = parseUrl(req).pathname
let out = await tryCache(key)
if (!out) {
// transform import statements
const result = await readSource(req)
out = transformModuleImports(result.source)
cacheData(key, out, result.updateTime)
}
send(res, out, 'application/javascript')
} else 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()
cacheData(key, out, false) // Do not outdate modules
}
send(res, out, 'application/javascript')
} else {
next()
}
}
}
3. 总结
梳理下项目启动后的完整流程:
- 打开
http://localhost:3000 - 浏览器加载并解析
http://localhost:3000/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Vue Dev Server</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import './main.js'
</script>
</body>
</html>
- 浏览器加载
./main.js:
import Vue from 'vue'
import App from './test.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
- 服务端校验js文件中所依赖文件的引入路径,将
main.js以下面形式返回:
import Vue from '/__modules/vue'
import App from './test.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
- 浏览器加载
/__modules/vue,服务端从node_modules/vue/dist/vue.esm.browser.js中读取并返回。 - 浏览器加载
./test.vue,服务端通过@vue/component-compiler编译.vue文件并返回js文件。 - 至此,
http://localhost:3000/index.html解析完毕,页面显示。
4. 最后
之前只知道vite启动服务及热更新很快,开发体验极佳,但对其原理并不清楚。vue-dev-server算是vite的简易实现?基于现代浏览器对es module的原生支持,本地启动服务拦截并处理当前文件请求,区别于其他打包器启动必须优先抓取并构建你的整个应用然后才能提供服务的方式,大大的提高了启动速度。