【若川视野 x 源码共读】第11期 | 尤雨溪写的玩具 vite

691 阅读6分钟

vue-dev-server是尤大前些年写的“玩具vite”, 理解其源码对于理解Vite非常有帮助,开始学习吧!

1.学习准备工作

clone代码

 git clone https://github.com/vuejs/vue-dev-server.git
 

了解功能:

如何工作:

1.浏览器请求导入作为原生 ES 模块导入 - 没有捆绑。

2.服务器拦截对 *.vue 文件的请求,即时编译它们,然后将它们作为 JavaScript 发回。

3.对于提供在浏览器中工作的 ES 模块构建的库,只需直接从 CDN 导入它们。

4.导入到 .js 文件中的 npm 包(仅包名称)会即时重写以指向本地安装的文件。
目前,仅支持 vue 作为特例。 其他包可能需要进行转换才能作为本地浏览器目标 ES 模块公开。

2.猜测实现方法

要实现的功能是在浏览器中导入vue的但文件组件,不需要build这个步骤。

因为浏览器能够识别的就是html还有js文件,css文件。所以vue要被转换成js, 有点像webpack那样。但是这里说不需要build,那么肯定需要别的翻译过程,在一个vue文件中可以导入其他依赖,可以写各种样式。。。剩下的不知道了,看源码吧!

3.大概了解代码流程

package.json中:

"bin": {
    "vue-dev-server": "./bin/vue-dev-server.js"
  }

所以看bin目录下的vue-dev-server.js文件

#!/usr/bin/env node

const express = require('express')
const { vueMiddleware } = require('../middleware')

const app = express()
// 方法返回 Node.js 进程的当前工作目录
const root = process.cwd();

// 使用vueMiddleware中间件
app.use(vueMiddleware())

//为了提供对静态资源文件(图片、csss文件、javascript文件)的服务,
//请使用Express内置的中间函数 express.static 。
//传递一个包含静态资源的目录给 express.static 中间件用于立刻开始提供文件
app.use(express.static(root))

//监听3000端口
app.listen(3000, () => {
  console.log('server running at http://localhost:3000')
})

看定义vueMiddleware的文件,在这个文件中最后返回了一个函数,在这个函数里面有4个分支判断:

1.对.vue文件的处理
2.对js文件的处理
3.对__modules开头路径处理
4.其他

4.详细读代码

4.1 vue-dev-server.js

// vue-dev-server/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')
})

vue-dev-server启动了端口为3000的服务,应用了vueMiddleware中间件,下面重点看这个中间件的实现。

4.2 vueMiddleware

vue-dev-server/middleware.js

//vueCompiler 这个模块将一个像下面这样的单个文件 Vue 组件编译成一个 CommonJS 模块,
//可以在 Browserify/Webpack/Component/Duo 构建中使用。 
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) {
    // 引入LRU
    const LRU = require('lru-cache')
		
    // 新建一个LRU缓存实例
    cache = new LRU({
      max: 500,
      length: function (n, key) { return n * 2 + key.length }
    })
  }
	// 创建一个默认的编译器
  const compiler = vueCompiler.createDefaultCompiler()

  function send(res, source, mime) {
    res.setHeader('Content-Type', mime)
    res.end(source)
  }

  function injectSourceMapToBlock (block, lang) {
    // 转成base64格式
    const map = Base64.toBase64(
      JSON.stringify(block.map)
    )
    let mapInject
		// 判断是JS 还是 css
    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字段
      code: mapInject + block.code
    }
  }
	// 调用injectSourceMapToBlock,lang参数传js
  function injectSourceMapToScript (script) {
    return injectSourceMapToBlock(script, 'js')
  }
  // 调用injectSourceMapToBlock,lang参数传css
  function injectSourceMapsToStyles (styles) {
    return styles.map(style => injectSourceMapToBlock(style, 'css'))
  }
 
  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')) {
      const key = parseUrl(req).pathname
      let out = await tryCache(key)

      if (!out) {
        // transform import statements
        const result = await readSource(req)
        //对js中import的内容处理一下
        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()
    }
  }
}

exports.vueMiddleware = vueMiddleware

vueMiddleware最终返回一个函数,这个函数里主要做了4件事情:

对.vue结尾的文件进行处理;

对.js结尾买的文件进行处理;

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

否则执行next方法交给下一个中间件处理。

4.3 readSource

readSource文件内容:

const path = require('path')
const fs = require('fs')
// require('util').promisify 把原来的异步回调方法改成返回 Promise 实例的方法
// 读取文件内容
const readFile = require('util').promisify(fs.readFile)
// 获取文件信息
const stat = require('util').promisify(fs.stat)
const parseUrl = require('parseurl')
const root = process.cwd()

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

exports.readSource = readSource

可以看到readSource的作用是读取文件资源,返回文件的路径,文件的内容,以及文件的更新时间。

4.4 transformModuleImports

transformModuleImports文件内容:

const recast = require('recast')
const isPkg = require('validate-npm-package-name')

function transformModuleImports(code) {
  // 获得代码对应的抽象语法树
  const ast = recast.parse(code)
  recast.types.visit(ast, {
    // 处理import语句
    visitImportDeclaration(path) {
      const source = path.node.source.value
      // 如果不是以./开头的 并且是一个合法的包名
      if (!/^./?/.test(source) && isPkg(source)) {
        // 将路径修改为__modules下面的?
        path.node.source = recast.types.builders.literal(`/__modules/${source}`)
      }
      // ?指向?
      this.traverse(path)
    }
  })
  return recast.print(ast).code
}

exports.transformModuleImports = transformModuleImports

可以看到此方法是针对npm包进行转换,如对Vue的转换:

import Vue from 'vue' => import Vue from "/__modules/vue"

4.5 loadPkg

loadPkg的内容:

const fs = require('fs')
const path = require('path')
const readFile = require('util').promisify(fs.readFile)

async function loadPkg(pkg) {
  // 如果是vue则读取vue的浏览器版本
  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.')
  }
}

exports.loadPkg = loadPkg

此方法用于加载包,可以看到对vue进行了判断说明只支持vue文件。

5.调试验证

调试代码:(记录一些变量)

.js文件

处理__modules ( import Vue from 'vue' 被翻译成 __modules/....)

处理.vue (import App from './test.vue')

6.总结

vue-dev-server的主要逻辑在vueMiddleware中,此中间件的作用是对.js文件、.vue文件以及.css文件进行处理,最终可以让浏览器运行vue项目。

7.参考资料

node-npm发布包-package.json中bin的用法

package.json的所有配置项及其用法,你都熟悉么

川哥的解析本源码的文章:

尤雨溪几年前开发的“玩具 vite”,才100多行代码,却十分有助于理解 vite 原理

学完本期源码,你可以思考如下问题:

1.谈谈你你对node.js中间件的理解

2.vue-dev-server如何处理vue文件?

3.对于能够在浏览器中运行的ES模块构建的库,vue-dev-server如何处理?

4.vue-dev-server如何处理js文件中引入的npm包?

5.@vue/component-compiler的作用是什么?