下一代构建工具vite 的实现原理

1,910 阅读3分钟

Vite是什么以及解决了什么

Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 rollup 打包。

如今前端的打包工具 webpack 一统江山。但是在开发一些大型项目或者多页面应用时,webpack的首次启动速度往往需要数分钟才能编译成功,大型的ts项目中,启动时间往往更长。同时webpack的是按需加载实际是假按需,一个文件的改动,需要从新编译所有文件,导致热更新的速度在大型项目中也会很慢。

而随着vue3发布的的工具vite就是为了解决此类问题,直接基于浏览器原生的esModle来实现,它的优点:

  • 拥有更快的启动速度
  • 及时的热更新
  • 真正的按需加载 上图是 caniuse 上各个浏览器关于 import的支持情况。可见在开发环境使用是完全可以的。

vite的实现原理

vite的整体实现的流程如下:

  • 拦截 import 请求(无需编译 ES6)
  • 解析 .vue 模版,编译成 js 和 css 文件,返回浏览器。

Talk is cheap. Show me the code。 下面我们来实现一个简易版的 simple-vite。 项目目录如下

├── client
│   ├── app.js
│   ├── bin
│   │   └── www
│   ├── compile
│   │   └── index.js
│   └── util
│       └── findPort.js
├── index.html
├── package.json
├── public
│   └── favicon.ico
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── index.css
│   └── main.js
└── yarn.lock

client文件夹下为simple-vite的实现。src下的目录为 vite初始化项目时的文件。

1、建立本地服务器拦截

yarn add koa koa-static -D
// app.js
const Koa = require('koa')
const path = require('path')
const koaStatic = require('koa-static')
const app = new Koa();
// 根路径
global.$rootPath = path.resolve(__dirname, '../')
// 静态资源
app.use(koaStatic(global.$rootPath))
module.exports = app
// bin/www
#!/usr/bin/env node
const app = require('../app')
const findPort = require('../util/findPort')
findPort(3000).then(port=>{
  app.listen(port);
})
// util/findPort
const net = require('net')
const findPort = function(port){
  const server = net.createServer().listen(port)
  return new Promise((resolve,reject)=>{
    server.on('listening',function(){
      console.log(`server is runing on port ${port}.........`)
      server.close()
      return resolve(port)
    })
    server.on('error',function(err){
      if(err.code==='EADDRINUSE'){
        return resolve(findPort(port+1))
      }else{
        console.log(err)
        return reject(err)
      }
    })
  })
}
module.exports = findPort

此时执行node bin/www 访问 http://localhost:3000/index.html 已经可以访问到页面。

2、拦截 html 请求

对于 html 页面的请求,首先注入一个函数,用来后期的更新 css 操作

// app.js
const { injectUpdateStyle } = require('./compile')

app.use(async (ctx, next) => {
  await next();
  const filePath = path.join(global.$rootPath, ctx.path)
  /* 如果是返回的html 则注入js 的处理 */
  if (ctx.response.is('html')) {
    let str = fs.readFileSync(filePath, 'utf-8')
    ctx.body = injectUpdateStyle(str)
  }
})
// compile/index.js
module.exports.injectUpdateStyle = function injectUpdateStyle(content) {
  const inject = `  <script>
    function updateStyle(css){
      const style = document.createElement('style')
      style.innerHTML = css
      document.head.appendChild(style)
    }
  </script>`
  return content.replace(/<\/head>/, `$&${inject}`)
}

3、拦截 js 文件

main.js 举例。main.js中的代码如下:

//  main.js
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')

原生的esModule并不能解析import { createApp } from 'vue' 这样的语法,因此我们需要解析上面的from 'vue'这种语法,解析问浏览器可以识别的import请求。 此时需要用到两个库.

yarn add es-module-lexer magic-string @vue/compiler-sfc -D

同时拦截 js 的请求

// app.js
app.use(async (ctx, next) => {
  await next();
  const filePath = path.join(global.$rootPath, ctx.path)
  // 增加一下代码
  /* 如果是js */
  if (ctx.response.is('js')) {
    if (ctx.body instanceof Readable) {  // 如果读取到 /@modules/ 时已经做了处理 不再是流
      const source = fs.readFileSync(filePath, 'utf-8')
      ctx.body = compileJs(source)
    }
  }
})
// compile/index.js
const { parse } = require('es-module-lexer')
const MagicString = require('magic-string')
/**
 * 编译 js 
 * import { createApp } from 'vue' 转化为 import { createApp } from '@/modules/vue' 格式
 */
function compileJs(source) {
  const [imports, exports] = parse(source, 'optional-sourcename');
  const magicString = new MagicString(source)
  const regx = /^[^\/\.@]/ // 开头不是  / . @   
  imports.forEach(({ s, e }) => {
    const str = source.substring(s, e)
    if (regx.test(str)) {
      magicString.overwrite(s, e, `/@modules/${str}`)
    }
  })
  return magicString.toString()
}
module.exports.compileJs = compileJs

此时用于将 import { createApp } from 'vue' 转化为 import { createApp } from '@/modules/vue' 因此需要拦截 /@modules/vue 并返回 vue.js。增加如下代码:

// app.js
app.use(async (ctx, next) => {
  await next()
  if (ctx.path.startsWith('/@modules/vue')) {
    const vuePath = path.join(global.$rootPath, 'node_modules/vue/dist/vue.runtime.esm-browser.prod.js')
    ctx.type = 'js'
    const data = fs.readFileSync(vuePath, 'utf-8')
    ctx.body = data
  }
})

3、解析 css 请求

//  app.js
app.use(async (ctx, next) => {
  await next();
  const filePath = path.join(global.$rootPath, ctx.path)
  // 增加如下代码
  /* 如果是返回css */
  if (ctx.response.is('css')) {
    const data = fs.readFileSync(filePath, 'utf-8').replace(/\n/g, '')
    ctx.type = 'js'
    ctx.body = `updateStyle('${compileCss(data)}')`
  }
})
// compile/index.js
/* 编译 css  */
const {  compileStyle } = require('@vue/compiler-sfc')
module.exports.compileCss = function compileCss(content) {
  const { code } = compileStyle({ source: content, trim: true }) // 此处的 compileStyle 是可以支持 是否 scoped postcss 等的
  return code
}

4、解析 .vue 文件

const { parse: VueParse } = require('@vue/compiler-sfc')

// app.js
app.use(async (ctx, next) => {
  await next();
  const filePath = path.join(global.$rootPath, ctx.path)
  // 增加如下代码
  /* 如果是 vue 文件 */
  if (ctx.path.endsWith('.vue')) {
    const source = fs.readFileSync(filePath, 'utf-8')
    if (!ctx.query.type) {
      ctx.type = 'js'
      ctx.body = compileVue(source, ctx.path)
    }
  }
})
// compile/index.js
const { parse: VueParse, compileScript,  } = require('@vue/compiler-sfc')

/**
 * 编译 vue
 * 第一次编译 .vue 文件
 */
module.exports.compileVue = function compileVue(source, path) {
  const { descriptor } = VueParse(source)
  let { content } = compileScript(descriptor)
  content = content.replace('export default', `const __script = `)
  console.log(descriptor, 'descriptordescriptordescriptordescriptor')
  let str = ''
  if (descriptor.styles.length) {
    str = descriptor.styles.reduce((total, item, index) => {
      total += `import "${path}?type=style&index=${index}"\n`
      return total
    }, '')
  }
  let response = `${content}
          ${str}
          import {render as __render} from "${path}?type=template"
          __script.render = __render
          export default __script
        `
  return response
}

此时一个 .vue 文件的请求编译成了如下的样子。 编译后的文件,会再次请求对应的 styletemplate

5、解析 .vue 中的 template 和 style 返回

 // app.js
 app.use(async (ctx, next) => {
  await next();
  const filePath = path.join(global.$rootPath, ctx.path)
  
  /* 如果是 vue 文件 */
  if (ctx.path.endsWith('.vue')) {
    const source = fs.readFileSync(filePath, 'utf-8')
    const { descriptor } = VueParse(source)
    if (!ctx.query.type) {
      ctx.type = 'js'
      ctx.body = compileVue(source, ctx.path)
    } else if (ctx.query.type === 'template') { // 编译后的 .vue 文件发送的请求
      ctx.type = 'js';
      ctx.body = compileVueTemplate(descriptor.template.content)
    } else if (ctx.query.type === 'style') { // 编译后的 .vue 文件发送的请求
      const index = ctx.query.index
      const code = compileCss(descriptor.styles[index].content)
      ctx.body = `updateStyle(${JSON.stringify(code)})`
      ctx.type = 'js'
    }
  }
})
const { compileTemplate } = require('@vue/compiler-sfc')

// compile/index.js
/* 编译 vue template  */
module.exports.compileVueTemplate = function compileVueTemplate(content) {
  const { code } = compileTemplate({ source: content, transformAssetUrls: false });
  return compileJs(code)
}

此时vite初始化的项目已经可以运行。如下:

源代码

具体代码 simple-vite