Roger Leung‘s Epcot

vuePress-theme-reco Roger Leung ( z3rog )    2018 - 2021
Roger Leung‘s Epcot

Choose mode

  • dark
  • auto
  • light
Blog
Note
Github (opens new window)
author-avatar

Roger Leung ( z3rog )

18

Article

20

Tag

Blog
Note
Github (opens new window)

100 行代码写一个破产版 Vite

vuePress-theme-reco Roger Leung ( z3rog )    2018 - 2021

100 行代码写一个破产版 Vite

Roger Leung ( z3rog ) 2020-09-01 ViteNodeKoa

天下苦 Webpack 久矣?

几个月前尤雨溪在实现 Vite Demo 后发了一条推,让人爆笑:

youyuxi-twitter

Sean 是 Webpack 的核心成员,学过中文,以前非常活跃,不知为何最近两年很少为 Webpack 贡献代码。尤大的一句“感觉不会再爱了”,让 Sean 直接喊了声大哥,哈哈。

# What's Vite

很多人吹嘘 Vite 是 Webpack Killer,其实有点神化了,它(至少目前)不能替代 Webpack、也不是为了替代 Webpack,对 Vite 更贴切的描述应该是「基于 ES Module 的 Dev Server + 基于 Rollup 封装的 Bundler」——我认为它定位与核心竞争力在前半句,支持打包只是让它更好用的附带功能。

截至笔者写作之日,Vite 的最新版本为 v1.0.0-rc4 (opens new window),基础功能与 API 已趋于稳定,我试着用 100 行代码实现一个破产版的 Vite。之所以称之为破产版,是因为:

  • 对文件类型支持有限
  • 未实现 Vite 的核心功能 HMR(HMR 是 Vite 优秀体验的原因之一)

本文及提供的 demo 仅为阐述 Vite 如何利用 ES Module 的能力绕开开发时的打包,提高开发体验。其余模块与功能的实现,日后可能会单独写文谈谈个人的源码解析。

code-lines

demo 见地址 (opens new window)。去掉空行总共 101 行 TS 代码。

# 前置知识

  • ES Module(参考此处 (opens new window))
  • Node / Koa
  • TypsScript
  • 模块调试(yarn link / npm link)
  • ...

# 前期准备

  • 创建一个 simple-vite 项目,指定 bin 文件
  • 使用 Vite 官方的模版 (opens new window)创建一个 simple-vite-demo 项目,然后将 node script 中的 vite 替换为 simple-vite,指定 simple-vite 执行 simple-vite 的 bin,用于测试(参考link 的使用 (opens new window))

# 开始动手

Vite 使用 Koa 作为驱动 Node 的框架,各独立功能(如:资源路径重写与转发、Vue 文件的服务端解析、Esbuild 支持、HMR 等)以 Koa 中间件的方式实现,这些中间件在 Vite 内部被称为 Plugin,本文参考 Vite 的写法但仍保留 Middleware 的概念。

# 入口

import Koa, { DefaultState, DefaultContext } from 'koa'
import chalk from 'chalk'
import middlewares from './middlewares'

const app = new Koa<DefaultState, DefaultContext>()

middlewares.forEach(middleware => middleware(app))

app.listen(3000, () => {
  console.log(chalk.green('[simple-vite] running in port 3000'))
})
1
2
3
4
5
6
7
8
9
10
11

在 3000 端口起一个 Koa 服务,将创建出的 app 作为参数传递至每一个中间件。以 staticMiddleware 为例,我们先处理一下静态资源的转发问题。

# staticMiddleware

import send from 'koa-send'
import Koa from 'koa'

export const staticMiddleware = (app: Koa): void => {
  app.use(async (ctx, next) => {
    await send(ctx, ctx.path, {
      root: process.cwd(),
      index: 'index.html',
    })
    await next()
  })
}
1
2
3
4
5
6
7
8
9
10
11
12

服务端使用 koa-send 将客户端请求静态资源的相对路径转换为绝对路径。当在 simple-vite-demo 根目录下使用 simple-vite 启动一个项目时,localhost:3000 便能访问到根目录下的 index.html。

<script type="module" src="/src/main.js"></script>
1

/src/main.js 的脚本会以 module 形式被加载,加载完成后控制台会出现一条报错信息:

Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".

main.js 中我们直接通过 import { createApp } from 'vue' 引入 node_modules 中的 vue,'vue' 这种引入方式被称为 Bare Import,而 Bare Import 是不符合 ESM 规范的,报错信息亦表明 ES Module 的路径必须为相对路径,因此需要重写 main.js 的内容。

Vite 中,会将 Bare Import 的模块转换成 /@modules/${packageName} 的形式:

// before
import { createApp } from 'vue'
// after
import { createApp } from '/@modules/vue'
1
2
3
4

Simple-Vite 也照模子抄过来吧!

# rewriteMiddleware

import Koa from 'koa'
import { streamToString, isJsFile } from '../utils'

const env = process.env.NODE_ENV || 'development'

export const rewriteMiddleware = (app: Koa): void => {
  app.use(async (ctx, next) => {
    if (isJsFile(ctx.type)) {
      const content = await streamToString(ctx.body)
      ctx.body = content
        .replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
        .replace(/process\.env\.NODE_ENV/g, `"${env}"`)
      return next()
    }

    return next()
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Vite 对文件路径的重写逻辑中,使用 path.extname 来识别文件类型,更严谨一些,我们的破产版暂且用正则匹配。当识别出 js 文件时,将二进制流转换成字符串进行替换。其中正则 /(from\s+['"])(?![\.\/])/g 表示匹配 from 后不以 . 或 / 开头的字符串,其后插入 /@modules/。

重启 Node 服务后可观察到 main.js 的代码内容已被替换。这时留给我们的问题有二:

  • 显然,项目中不存在名为 @modules 的文件夹。为了让 Node 能正确加载 package,需要找到 package 的真实路径
  • main.js 中 import App from './App.vue' 能让浏览器成功加载 .vue 文件,但未做即时编译,无法识别

我们依次解决。

# loadModuleMiddleware

import Koa from 'koa'
import { join } from 'path'

const cwd = process.cwd()

export const loadModuleMiddleware = (app: Koa): void => {
  app.use(async (ctx, next) => {
    if ((ctx.path.startsWith('/@modules'))) {
      const moduleName = ctx.path.slice(10) // 10 is length of '/@modules'
      const modulePkg = require(join(cwd, 'node_modules', moduleName, 'package.json'))
      ctx.path = join('/node_modules', moduleName, modulePkg.module)
      return next()
    }

    return next()
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

loadModuleMiddleware 识别到路径是 rewriteMiddleware 重写的格式后,将 ctx.path 重定向至 /node_modules/${moduleName}/package.json 中 module 字段指定的模块入口文件。以 Vue 3 为例,Vue 3 的 package.json 中指定 "module": "dist/vue.runtime.esm-bundler.js",最终效果:

// before
import { createVue } from 'vue'
// after `rewriteMiddleware`
import { createVue } from '/@modules/vue'
// after `loadModuleMiddleware` redirect
import { createVue } from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'
1
2
3
4
5
6

# vueSfcMiddleware

main.js 中引入的 App.vue 是未经编译的 Vue SFC,要为它进行即时编译需要用到 @vue/compiler-sfc 。















 
 
 
 

 
 
 










import Koa from 'koa'
import * as CompilerSfc from '@vue/compiler-sfc'
import { isVueSfcFile, isUndefined, streamToString, setResponseContentType } from '../utils'

export const vueSfcMiddleware = (app: Koa): void => {
  app.use(async (ctx, next) => {
    if (isVueSfcFile(ctx.path)) {
      const content = await streamToString(ctx.body)
      const { descriptor } = CompilerSfc.parse(content)

      if (isUndefined(ctx.query.type)) {
        const code = descriptor.script?.content

        ctx.body =
          `import { render as __render } from "${ctx.path}?type=template"\n` +
          `${CompilerSfc.rewriteDefault(code!, '__script')} \n` +
          '__script.render = __render \n' +
          'export default __script'
      } else if (ctx.query.type === 'template') {
        ctx.body = CompilerSfc.compileTemplate(
          { source: descriptor.template?.content } as CompilerSfc.SFCTemplateCompileOptions
        ).code
      }

      setResponseContentType(ctx, 'js')
      return next()
    }

    return next()
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

当识别到是 .vue 文件时,Vite 会做(不限于)以下两件事:

  • 使用 CompilerSfc 解析 script 的内容,插入 template 的 import 语句(当 script 中引入了 CSS 还会插入 CSS 的 import 语句,此处从简只处理 script)
  • 使用 CompilerSfc 解析 template 成 render 函数,返回
  • 将上述即时编译的元素组装成一个组件

因为没有对样式和图片资源进行处理,所以 main.js 中引入的 CSS 及 App.vue 中引入的图片仍然会报错,我们暂且移除这部分代码。你可以具体看看 Vite 是怎么处理这些资源的,随着阅读的深入会接触到热更新的核心逻辑。

# 最后

最后我们理一下各 Middleware 的执行顺序。

// src/index.ts
import middlewares from './middlewares'
// ...
middlewares.forEach(middleware => middleware(app))

// middlewares/index
import { loadModuleMiddleware } from './loadModuleMiddleware'
import { staticMiddleware } from './staticMiddleware'
import { rewriteMiddleware } from './rewriteMiddleware'
import { vueSfcMiddleware } from './vueSfcMiddleware'

export default [
  loadModuleMiddleware,
  staticMiddleware,
  vueSfcMiddleware,
  rewriteMiddleware,
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

功能逻辑顺序:

  • 重写 (Conten-Type 被标识为)Js 的文件中 import 的引入路径
  • 如果该 JS 文件是 .vue 单文件,服务端编译后返回组件
  • 将所有资源的路径转换为绝对路径
  • 确保将绝对路径中能正确引入 node_modules 下的包

Koa 使用的是洋葱圈模型,因此上面代码中的 middlewares 顺序是与执行顺序相反的。

一切就绪,刷新,大功告成:

hello-vue3-vite

# 局限

像文章开头所说的一样,因为未实现对 css、png 等资源的加载,未实现 HMR,所以 Simple-Vite 在功能上也是残缺的,称之为破产版并不为过。如果只针对现有的代码,有什么值得优化的吗?我个人不完全地列举了一下:

  • 程序的 root 写死 process.cwd(),应该做成可配置的
  • process.env.NODE_ENV 也应该做成可配置的,方便测试
  • 需要拓展各个 Middleware 接收的参数,而不是仅仅接收 new Koa() 创建的 app 实例,这样 Middleware 可以获取更丰富的信息做更多事
  • ...

这些就留到日后再优化吧,又或者,就不要优化这个破产版的了,考虑优化 Vite 给 Vite 提 PR 吧(滑稽)。