【若川视野 x 源码共读】第11期 | vue-dev-server的源码解读

1,062 阅读2分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

vue-dev-server

vue-dev-server是尤大编写的一个小工具,可以让你在开发环境下,无需打包,就能使用Vue单文件。

源码一共也就百来行,很适合学习。

下面我们来具体分析一下吧。

是什么

从仓库中的README我们可以看到下面这段话。

This is a proof of concept.

Imagine you can import Vue single-file components natively in your browser... without a build step.

想象你可以通过游览器直接导入Vue,中间没有一个构建步骤。

类似于下方的代码一样。

<div id="app"></div>
<script type="module">
import Vue from 'https://unpkg.com/vue/dist/vue.esm.browser.js'
import App from './App.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')
</script>

App.vue:

<template>
  <div>{{ msg }}</div>
</template>

<script>
export default {
  data() {
    return {
      msg: 'Hi from the Vue file!'
    }
  }
}
</script>

<style scoped>
div {
  color: red;
}
</style>

基本原理

  • 游览器请求导入作为ESM导入,没有打包过程。

  • 服务器会拦截.vue文件,进行编译,并返回JavaScript

  • 如果提供了ES模块的库,只需从CDN进行引入。

  • 导入到.js文件的包,会重写到本地的文件,目前,仅支持vue

我们可以直接在游览器中引入vue文件,并且中间没有构建打包过程。

效果

我们来看一下实际的效果吧,找到package.json

{
  "name": "@vue/dev-server",
  "version": "0.1.1",
  "description": "Instant dev server for Vue single file components",
  "main": "middleware.js",
  "bin": {
    "vue-dev-server": "./bin/vue-dev-server.js"
  },
  "scripts": {
    "test": "cd test && node ../bin/vue-dev-server.js"
  },
  "author": "Evan You",
  "license": "MIT",
  "dependencies": {
    "@vue/component-compiler": "^3.6.0",
    "express": "^4.16.4",
    "lru-cache": "^5.1.1",
    "recast": "^0.17.3",
    "validate-npm-package-name": "^3.0.0",
    "vue": "^2.6.8",
    "vue-template-compiler": "^2.6.8"
  }
}
  1. 安装依赖
npm install
  1. 找到script这里,运行命令
cd test && node ../bin/vue-dev-server.js

我们可以看到服务启动,我们将启动的地址用浏览器打开:

image.png 那接下来,我们就来分析这个流程。

分析

我们首先从package.json的命名可以看出,运行了bin下的vue-dev-server

#!/usr/bin/env node

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,搭建了服务端环境。

同时,我们看到process.cwd,由于我们运行npm run test的时候,来到了test目录,所以这里就是roottest目录。

我们的服务http://localhost:3000会打到testindex.html中。

接着,我们可以看到vueMiddleware这个中间件,这个是核心,对请求进行拦截,然后对具体请求的文件进行解析返回。后面再详细讲解。

大概的流程如下所示。

  1. client请求server
  2. server将请求会经过中间件。
  3. Middleware会根据文件类型进行解析并响应。

image.png

上方其实就是vue-dev-server的简单流程了,实质上是拦截请求并根据特定的文件类型进行响应

其实上方中最为重要的就是vueMiddleware这一层。

vueMiddleware

我们来看核心部分

const vueMiddleware = (options = defaultOptions) => {
  
  // some code

  return async (req, res, next) => {
    if (req.path.endsWith('.vue')) {      
      // handle .vue file
      send(res, out.code, 'application/javascript')
    } else if (req.path.endsWith('.js')) {
      // handle js
      send(res, out, 'application/javascript')
    } else if (req.path.startsWith('/__modules/')) {
      // handle package
      send(res, out, 'application/javascript')
    } else {
      // handle else
      next()
    }
  }
}

实质上,所有的请求都会经过vueMiddleware,去对不同的文件类型进行处理 我们来具体看看。

vue文件类型处理

if (req.path.endsWith('.vue')) {      
  // 拿到请求的path 生成key
  const key = parseUrl(req).pathname
  // 查看是否有缓存
  let out = await tryCache(key)

  // 没有缓存,则生成
  if (!out) {
    // Bundle Single-File Component 借用了@vue/component-compiler的能力
    const result = await bundleSFC(req)
    out = result
    cacheData(key, out, result.updateTime)
  }
  // 返回
  send(res, out.code, 'application/javascript')
}

这个过程的逻辑也不复杂,根据请求路径生成对应的key,根据key去找是否有对应的缓存,没有则生成,并返回。这里的cacheDatabundleSFC具体看看逻辑。

cacheData

实质上就是根据key,value对数据的缓存。

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 }
  })
}

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
}

bundleSFC

由于笔者对@vue/component-compiler不熟悉,对具体的编译过程就不做解释了,感兴趣的小伙伴,可以了解一下。

这部分实质上就是根据vueCompiler去编译文件,去生成数据,其中包括code,我们可以调试看看。

const compiler = vueCompiler.createDefaultCompiler()

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()
  }
}

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 }
}

调试结合如下,我们发现,我们生成code,map,updateTime等信息。

image.png

注意,这里的code其实就是vue文件编译后的js代码。我们直接把这行代码进行返回即可。

send(res, out.code, 'application/javascript')

看看案例中的请求test.vue的效果。

image.png 以上,就是加载vue文件的处理逻辑.

js文件类型处理

我们接下来看看js的处理逻辑。

if() {
  // some code
} else if (req.path.endsWith('.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')
}

这段逻辑也比较易读,也是找文件的缓存,没有则再生成。

这里我们着重看一下transformModuleImports这个函数逻辑。

function transformModuleImports(code) {
  const ast = recast.parse(code)
  recast.types.visit(ast, {
    visitImportDeclaration(path) {
      const source = path.node.source.value
      if (!/^\.\/?/.test(source) && isPkg(source)) {
        path.node.source = recast.types.builders.literal(`/__modules/${source}`)
      }
      this.traverse(path)
    }
  })
  return recast.print(ast).code
}

关于recast,个人理解也是一个类babel的转译器。 这里的作用应该是将引入语句进行转换

// before
import Vue from 'vue';

// after
import Vue from '/__modules/vue';

看看测试中的例子

首页在请求main.js时候,main.js的响应。

main.js文件

import Vue from 'vue'
import App from './test.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')

而经过recast转译,得到图下结果

image.png

/__modules/开头的文件进行处理

if (req.path.startsWith('/__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')
}

这里我们着重看看loadPkg

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做了处理,没错,这里其他的npm包都会报错。 因为下方需要检测npm是否支持esModule。(这个逻辑有点难判断)。

扩展

下面,个人也在项目的基础上进行了一点改造,使用了babelreactreact-dom去支持了tsx的写法,同时也对项目结构有所调整。

但由于里面存在部分hack写法,这里就只展示效果图。

有兴趣的话,可以看看源码哦。

image.png

image.png

image.png

总结

我们这里分析了vue-dev-server的基本流程和原理,这里有很多东西值得我们学习。

  • 充分利用缓存
  • 利用编译工具做代码转译
  • 中间件对请求类型专门的处理

等等,以上的思路其实都是非常不错的。

同时我们应该思考,我们后续能做啥呢

  • 完善loadPkg的逻辑
  • 不同文件的引入, 如jsx, tsx甚至你也可以自定义。(处理好解析层即可)
  • 等等......

参考