从一个实验项目了解vite原理

479 阅读4分钟

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下来,项目启动,其间确实无打包过程。我们来看看其具体实现。

目录结构如下:

3.png

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

这里做了三件事:

  1. 执行核心中间件:vueMiddleware
  2. 通过express.static()托管静态文件:托管后,root文件夹(即test目录)下的所有文件都可以通过http://localhost:3000/文件名访问了。在浏览器打开第3步中的http://localhost:3000,访问的就是test目录下的index.html
  3. 启动服务

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文件中依赖文件的引入路径,如何符合以下条件:

  1. 不是相对路径
  2. 是一个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. 总结

梳理下项目启动后的完整流程:

  1. 打开http://localhost:3000
  2. 浏览器加载并解析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>
  1. 浏览器加载./main.js:
import Vue from 'vue'
import App from './test.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')
  1. 服务端校验js文件中所依赖文件的引入路径,将main.js以下面形式返回:
import Vue from '/__modules/vue'
import App from './test.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')
  1. 浏览器加载/__modules/vue,服务端从node_modules/vue/dist/vue.esm.browser.js中读取并返回。
  2. 浏览器加载./test.vue,服务端通过@vue/component-compiler编译.vue文件并返回js文件。
  3. 至此,http://localhost:3000/index.html解析完毕,页面显示。

4.png

4. 最后

之前只知道vite启动服务及热更新很快,开发体验极佳,但对其原理并不清楚。vue-dev-server算是vite的简易实现?基于现代浏览器对es module的原生支持,本地启动服务拦截并处理当前文件请求,区别于其他打包器启动必须优先抓取并构建你的整个应用然后才能提供服务的方式,大大的提高了启动速度。