# Vite的简单实现原理 案例

通过实现一个自己的Vite工具深入了解Vite的工作原理

# 梳理核心功能

  • 开启静态服务器
  • 编译单文件组件
    • 拦截浏览器不识别的模块并处理
  • HMR(目前不做)

# 使用koa开启静态web服务器

  1. 创建文件夹vite-cli,执行npm init -y初始化一个package.json,安装依赖,修改下面的两个模块。
{
  "name": "vite-cli-demo",
  ...
  "bin": "index.js",
  ...
  "dependencies": {
    "koa": "^2.13.0",
    "koa-send": "^5.0.1"
  }
}

1
2
3
4
5
6
7
8
9
10
11
  1. 创建index.js
#!/usr/bin/env node

// 导入两个模块
const Koa = require('koa')
const send = require('koa-send')

// 创建Koa的实例
const app = new Koa()

// 1. 开启静态文件服务器
// 创建中间件
app.use(async (ctx, next) => {
  // 通过send把index.html返回给浏览器
  // ctx 上下文,ctx.path 当前请求的路径,root 根目录(当前运行程序的目录),index 默认页面
  await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
  // 执行下一个中间件
  await next()
})

// 监听端口
app.listen(3000)
// 打印提示
console.log('Server running @ http://localhost:3000')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  1. 命令行使用npm link
  2. 创建一个vue3项目,vue create vue3-demo,在vue3的项目的根目录创建index.html文件
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="src/main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 使用命令行vue-cli-demo可以看到项目启动成功,访问http://localhost:3000可以看到项目并没有出来,还有一些报错

Uncaught TypeError

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

分析

出现这种问题的原因是:
我们可以看到main.js的加载路径是vue,但是正常的应该是/@modules/vue.js,所以我们需要手动处理路径问题。

思路:
请求一个模块进行响应的时候,我们要把该模块中加载第三方模块的路径处理,所以我们要先判断该文件是否是js文件,如果是再去处理里面的第三方模块的引用路径,然后再去处理/@modules/vue.js这个不存在的路径。

# 修改第三方模块路径

  1. 实现第一个中间件,修改第三方模块的加载路径,在vite-cli的index.js里面添加
// 把流转换成字符串,参数是流ctx.body
// 返回一个promise对象
const steamToString = stream => new Promise((resolve, reject) => {
  // 定义一个数组,存储Buffer
  const chunks = []
  // data事件,把数据存储到数组中
  stream.on('data', chunk => chunks.push(chunk))
  // end事件数据读取完毕之后,把数据和chunks合并并转换成字符串,通过resolve返回
  stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
  stream.on('error',reject)
})

// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
  // 判断当前返回给浏览器的文件是不是js,判断其contentType是不是js
  if (ctx.type === 'application/javascript') {
    // 将body转换成字符串
    const contents = await steamToString(ctx.body)
    // import vue from 'vue' 不能正常加载
    // import App from './App.vue' 可以正常加载
    // g是全局匹配,()是分组,前一个匹配 from ' 或者 from " \s+是空格的意思
    // 第二个匹配?!的作用是不匹配分组的结果,这里就是不匹配./的结果
    // $1就是第一个分组的结果,后面加上添加的东西
    // 最后的结果就是import vue from '/@modules/vue'
    ctx.body = contents.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
  }
})
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

这个时候重启服务器,可以看到main.js里面加载的路径进行了改变

main.js

  1. 实现第二个中间件,请求http://localhost:3000/@modules/vue的时候现在不存在,如果以/@modules/开头就去node_modules里面找

  2. 在vite-cli的index.js里面开启静态文件服务器之前加这个新的中间件

const path = require('path')
// 3. 这里在处理静态文件之前,加载第三方模块
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  // 判断path是否以 /@modules/ 开头
  if(ctx.path.startsWith('/@modules/')) {
    // 拿到模块名称,直接把前面10个字符截取
    const moduleName = ctx.path.substr(10)
    // 思路:
    // 获取ES Module模块的入口文件,先找到这个模块的package.json,然后再获取package.json的ES Module模块的入口
    
    // 拼接package.json的路径
    const pkgPath = path.join(process.cwd(), 'node_modules', moduleName, 'package.json')
    // 加载package.json
    const pkg = require(pkgPath)
    // 重新给ctx.path赋值,拼接入口文件
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  // 执行下一个中间件
  await next()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 重启服务器,可以看到内容加载正确。

ERROR

但是可以看到控制台还是报错了,加载模块失败

Failed to load module script: The server responded with a non-JavaScript MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.

加载App.vue和css的时候,浏览器无法识别模块,下面来处理。

# 编译单文件组件

浏览器只能处理js模块,其他模块都会报错,所以其他模块都需要在服务器端处理,需要把单文件组件编译成js返回给浏览器。

Vite是怎么做的?

它会发送两次请求,第一次请求是把单文件组件编译成一个对象,中间发送第二次请求,第二次请求编译单文件组件的模板,返回一个render函数,然后把render函数挂载到第一此请求的对象上。

下面看一下第一次请求的文件App.vue最后转换的样子:

// 没变
import HelloWorld from '/src/components/HelloWorld.vue'

// 将 export default 换成 const __script =
const __script = {
  name: 'App',
  components: {
    HelloWorld
  }
}

// 这个是第二次请求,请求的时候有加参数type
import { render as __render } from "/src/App.vue?type=template"
// 将返回的__render赋值给__script
__script.render = __render
// 这两个标识一会回忽略掉
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "E:\\professer\\Vite\\3-5-vite-vue3-demo\\src\\App.vue"
// 最后导出函数
export default __script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

第二次请求的样子:

import { createVNode as _createVNode, resolveComponent as _resolveComponent, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "/@modules/vue.js"

const _hoisted_1 = /*#__PURE__*/_createVNode("img", {
  alt: "Vue logo",
  src: "/src/assets/logo.png"
}, null, -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(), _createBlock(_Fragment, null, [
    _hoisted_1,
    _createVNode(_component_HelloWorld, { msg: "Hello Vue 3.0 + Vite" })
  ], 64 /* STABLE_FRAGMENT */))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 第一次请求实现

  1. 先安装npm i @vue/compiler-sfc用于编译单文件组件,并导入
const compilerSFC = require('@vue/compiler-sfc')
1
  1. 在处理完静态文件之后并且在第三方模块之前(1和2之间)写中间件
// 导入stream模块
const { Readable } = require('stream')

// 把字符串转换成流,接收参数text是字符串
const stringToStream = text => {
  // 创建Readable对象
  const stream = new Readable()
  // 字符串添加到stream中
  stream.push(text)
  // 表示流写完了
  stream.push(null)
  // 将stream返回
  return stream
}

// 4. 处理单文件组件
app.use(async (ctx, next) => {
  // 判断是否是单文件组件
  if (ctx.path.endsWith('.vue')) {
    // 让ctx.body转换成字符串
    const contents = await steamToString(ctx.body)
    // 通过compilerSFC的parse方法返回编译之后的结果
    // 返回两个元素,descriptor 单文件组件的描述对象,errors 编译过程中的错误
    const { descriptor } = compilerSFC.parse(contents)
    // 定义code,最后返回给浏览器的数据
    let code
    // 处理第一次请求,没有type属性
    if (!ctx.query.type) {
      // 获取得到的内容
      code = descriptor.script.content
      // 把export default 要进行替换,并把后面的内容进行拼接
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      // 单文件组件的路径是ctx.path
      code += `
      import { render as __render } from "${ctx.path}?type=template"
      __script.render = __render
      export default __script
      `
    }
    // 设置请求contentType是js类型的文件
    ctx.type = 'application/javascript'
    // 将code转换成只读流给body
    ctx.body = stringToStream(code)
  }
  // 处理下一个中间件
  await 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
  1. 重启浏览器打开之后可以看到,App.vue是我们要的样子,却没有看到有第二次请求的发送。

App.vue

这里的问题是:

ERROR

到了这一步,index.css还是报错,然后就是render的加载路径不对,在2那个中间件中,替换的正则中要修改一下,之前是排除./的情况,现在要改成.开头或者/开头的情况。

  1. 去index.js中修改正则表达式
// 2. 修改第三方模块的路径
app.use(async (ctx, next) => {
  if (ctx.type === 'application/javascript') {
    // 将body转换成字符串
    const contents = await steamToString(ctx.body)
    // 案例:
    // import vue from 'vue' 不能正常加载
    // import App from './App.vue' 可以正常加载
    // import { render as __render } from "/src/App.vue?type=template" 可以正常加载

    // g是全局匹配,()是分组,前一个匹配 from ' 或者 from " \s+是空格的意思
    // 第二个匹配?!的作用是不匹配分组的结果,中括号这里表示不匹配 . 开头的情况或者 / 开头的情况 
    // $1就是第一个分组的结果,后面加上添加的东西
    // 最后的结果就是import vue from '/@modules/vue'
    ctx.body = contents.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  1. 然后将vue3的项目中,main.js中的index.css和App.vue中的img标签进行注释,防止其带来干扰。再次重启服务可以看到,有type参数的请求发出去了。

type

# 第二次请求实现

第二次请求我们需要把单文件组件中的默认编译成render函数

  1. 在index.js中修改处理单文件组件的内容
// 4. 处理单文件组件
app.use(async (ctx, next) => {
  if (ctx.path.endsWith('.vue')) {
    ...
    if (!ctx.query.type) {
      ...
    // 第二次请求,判断type是否是template
    } else if (ctx.query.type === 'template') {
      // compilerSFC 有一个编译模板的方法 compileTemplate
      // compileTemplate 接收一个参数对象,source是模板内容,descriptor.template.content就是模板内容
      const templateRender = compilerSFC.compileTemplate({ source: descriptor.template.content })
      // 返回值的code属性就是render函数
      code = templateRender.code
    }
    ...
  }
  await next()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. 打开浏览器,可以看到第二次请求有内容

response

  1. 但是现在浏览器上还是什么都没有,控制台报错

process is defined

这里是因为,打包工具会根据环境替换是开发环境还是生产环境,我们没有打包,所以这里没有进行替换。

ENV

所以我们需要在输出之前做整体的替换。

  1. 在index.js的最后一个中间件2中,添加正则替换
if (ctx.type === 'application/javascript') {
    const contents = await steamToString(ctx.body)
    ctx.body = contents
    .replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
    // 因为.有含义所以这里进行转义,将 process.env.NODE_ENV 进行转义 development
    .replace(/process\.env\.NODE_ENV/g, '"development"')
  }
1
2
3
4
5
6
7
  1. 重启之后内容正常输出,这里因为没有加载图片和css,所以没有样式,点击按钮也可以正常工作。

Hello Vue3.0+Vite

# 小结

核心就是开发阶段不需要本地打包,根据需要请求服务器编译单文件组件,图片和样式可以根据这个思路自己模拟。

完整代码 vite-cli-demo (opens new window)

更新时间: 2021-02-25 00:05