toy vite 源码学习

82 阅读3分钟

前言

从 vue-dev-server 来看 vite 实现原理

vue-dev-server 他的工作原理是什么

vue-dev-server#how-it-works 里有介绍,翻译过来就是

  • 浏览器请求导入作为原生 ES 模块导入 - 没有捆绑。
  • 服务器拦截对 *.vue 文件的请求,即时编译它们,然后将它们作为 JavaScript 发回。
  • 对于提供在浏览器中工作的 ES 模块构建的库,只需直接从 CDN 导入它们。
  • 导入到 .js 文件中的 npm 包(仅包名称)会即时重写以指向本地安装的文件。 目前,仅支持 vue 作为特例。 其他包可能需要进行转换才能作为本地浏览器目标 ES 模块公开。

这里需要注意的是,首先需要支持es模块导入,然后是对.vue文件及时编译并返回编译后的 js,里面的html、css再由h函数负责处理。

package.json

package.json里可以看到其执行的是 cd test && node ../bin/vue-dev-server.js,即以 test 目录为测试模板启动项目

./bin/vue-dev-server.js

#!/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做服务,关键是这个 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 defaultOptions = {
  cache: true
}
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模板编译器
  const compiler = vueCompiler.createDefaultCompiler()
  // 发送处理好的文件
  function send(res, source, mime) {
    res.setHeader('Content-Type', mime)
    res.end(source)
  }
  // 插入SourceMap
  function injectSourceMapToBlock (block, lang) {
    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
    }
  }
  // js SourceMap
  function injectSourceMapToScript (script) {
    return injectSourceMapToBlock(script, 'js')
  }
  // style SourceMap
  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 sfc
  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 }
  }
  return async (req, res, next) => {
    // 匹配到.vue文件的请求
    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文件转换为ModuleImport,因为整个理念就是基于ESModule的
      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/')) {
      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()
    }
  }
}

这里主要是将 .vue 代码转换成 ES模块 代码并缓存,因为只要文件未改动就不需要重新编译,所以缓存的收益非常大

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
      if (!/^./?/.test(source) && isPkg(source)) {
        path.node.source = recast.types.builders.literal(`/__modules/${source}`)
      }
      this.traverse(path)
    }
  })
  return recast.print(ast).code
}

exports.transformModuleImports = transformModuleImports

使用 recast转换 js

总结

toy vite 需要的核心知识点:

  • node 服务及中间件
  • vue文件即时编译,从中可以看到无论是 reactjsx还是vue的模板,编译后都是js,这是理解前端框架的一个很好的点
  • recast一个可以把js拆来拆去的库,核心就是 ast 和 js 代码互转
  • lru-cache 缓存
  • SourceMap

最后, vite 启动快是因为无需编译启动,不像 webpack 那样要把所有文件编译后再启动,可以参考我的这篇文章聊聊前端性能优化-编译时优化