vite 实现原理

150 阅读3分钟

Vite 实现原理

  1. Vite是什么?

Vite 是 vue 的作者尤雨溪在开发 vue3.0 的时候开发的一个 基于原生 ES-Module 的前端构建工具。

核心特点:

  • 快速的冷启动
  • 即时的模块热更新(HMR)
  • 真正的按需编译
  1. vite 实现原理

原理:

  • 利用 ES6 的 import 会发送请求去加载文件的特性,拦截这些请求,做一些预编译,省去 webpack 冗长的打包时间
  • 利用 websoket 实现双通道通信,用于热更新有改动的文件。
  1. 一步步实现一个vite

3.1 准备基本项目模板

准备一个 react 模板

有以下特征:

main.jsx

import React from 'react'

import ReactDOM from 'react-dom'

import './index.css'

import App from './App.jsx'



ReactDOM.render(

  <React.StrictMode>

    <App />

  </React.StrictMode>,

  document.getElementById('root')

)

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="root"></div>

    <!-- es module导入方式 -->

    <script type="module" src="/target/main.jsx"></script>

  </body>

</html>

3.2 使用express启动一个服务

import express from 'express'

const app = express()

3.3 拦截入口请求,返回给用户处理过的 html 文件

// 拦截这个入口请求,返回给用户处理过的 html 文件

app.get('/', (req, res) => {

  // content-type

  res.set('Content-Type', 'text/html')

  // html 文件的绝对路径

  const htmlPath = join(__dirname, '../target', 'index.html')

  // 根据路径取读文件,获取到文件的字符串

  let html = readFileSync(htmlPath, 'utf-8')

  // 塞入客户端代码(包含了热更新等)

  html = html

    .replace(

      '<head>',

      `<head>\n  <script type="module" src="/@vite/client"></script>`

    )

    .trim()

  res.send(html)

})

3.4 给客户端塞入一个 script 标签,而是 es module 类型引入,浏览器会发起一个请求

<script type="module" src="/@vite/client"></script>

3.5 再通过 node 起的服务拦截这个请求@vite/client, 注入以下代码

console.log('[vite] is connecting....')



// 获取

const host = location.host



// 客户端 - 服务端建立一个通信

const socket = new WebSocket(`ws://${host}`, 'vite-hmr')



// 监听通信,拿数据,然后做处理

socket.addEventListener('message', async ({ data }) => {

  handleMessage(JSON.parse(data)).catch(console.error)

})



async function handleMessage(payload) {

  switch (payload.type) {

    // 如果拿到的是已连接json消息,就定时器启动个心跳,避免没有消息的时候,websoket掉线了

    case 'connected':

      console.log('[vite] connected.')



      setInterval(() => socket.send('ping'), 30000)

      break

    // 如果收到需要更新的消息通知

    case 'update':

      // 拿到需要更新的数组,遍历以下,分类处理

      payload.updates.forEach(async update => {

        // 如果是JS更新

        if (update.type === 'js-update') {

          console.log('[vite] js update....')

          await import(`/target/${update.path}?t=${update.timestamp}`)



          // mock

          // location.reload();

        }

      })

      break

  }

}



// 向浏览器注入如何更新css的方法, 创建缓存

const sheetsMap = new Map()



// 创建个style css 标签,把css code 放进去,插入到head里

export function updateStyle(id, content) {

  let style = sheetsMap.get(id)

  if (!style) {

    style = document.createElement('style')

    style.setAttribute('type', 'text/css')

    style.innerHTML = content

    document.head.appendChild(style)

  } else {

    style.innerHTML = content

  }



  sheetsMap.set(id, style)

}



// css更新需要先删除style标签,再注入

export function rmStyle(id) {

  const style = sheetsMap.get(id)

  if (style) {

    document.head.removeChild(style)

  }

  sheetsMap.delete(id)

}

3.6 接下来同理, 拦截 target 目录下所有类型的格式,该转换的转换,不需要转换的不处理,此时网站已经可以正常访问了

// jsx代码,使用esbuild转换成esm

export function transformCode(opts) {

  return transformSync(opts.code, {

    loader: opts.loader || 'js',

    sourcemap: true,

    format: 'esm',

  })

}



// 把 css 封装成 js 里,通过 style 绑定到页面去

export function transformCss(opts) {

  return `

    import { updateStyle } from '/@vite/client';



    const id = "${opts.path}";

    const css = "${opts.code.replace(/\n/g, '')}";



    updateStyle(id, css);

    export default css;

  `.trim()

}



// JSX文件转换

export function transformJSX(opts) {

  const ext = extname(opts.path).slice(1) // 'jsx'

  const ret = transformCode({

    // jsx -> js

    loader: ext,

    code: opts.code,

  })



  let { code } = ret

  // 分析代码字符串的 import

  // 为啥要分析 import 呢?



  // import type { XXXX } from 'xxx.ts';

  // import React from 'react';

  // 下面的正则取出 from 后面的 "react", 然后通过有没有 "." 判断是引用的本地文件还是三方库

  // 本地文件就拼路径

  // 三方库就从我们预先编译的缓存里面取

  cons reg = /\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm;

  code = code.replace(

    reg,

    (a, b, c) => {

      let from

      if (c.charAt(0) === '.') {

        // 本地文件

        from = join(dirname(opts.path), c)

        const filePath = join(opts.appRoot, from)

        if (!existsSync(filePath)) {

          if (existsSync(`${filePath}.js`)) {

            from = `${from}.js`

          }

        }



        if (['svg'].includes(extname(from).slice(1))) {

          from = `${from}?import`

        }

      } else {

        // 从 node_modules 里来的

        from = `/target/.cache/${c}/cjs/${c}.development.js`

      }



      return a.replace(b, `"${from}"`)

    }

  )



  return {

    ...ret,

    code,

  }

}



// 静态文件的处理,返回给浏览器能认识的

app.get('/target/*', (req, res) => {

  // req.path -----> /target/main.jsx

  // 完整的文件路径

  const filePath = join(__dirname, '..', req.path.slice(1))



  // 静态资源给一个 flag

  if ('import' in req.query) {

    res.set('Content-Type', 'application/javascript')

    res.send(`export default "${req.path}"`)

    return

  }



  // 对不同类型的文件做不同的处理,返回的是浏览器能够认识的结构,比如如果是 jsx 文件,就需要转成 js

  // 如果是 css 文件,就需要放 style 标签,然后塞到 html header

  switch (extname(req.path)) {

    case '.svg':

      res.set('Content-Type', 'image/svg+xml')

      res.send(readFileSync(filePath, 'utf-8'))

      break

    case '.css':

      res.set('Content-Type', 'application/javascript')

      res.send(

        transformCss({

          path: req.path,

          code: readFileSync(filePath, 'utf-8'),

        })

      )

      break

    default:

      res.set('Content-Type', 'application/javascript')

      res.send(

        transformJSX({

          appRoot: join(__dirname, '../target'),

          path: req.path,

          code: readFileSync(filePath, 'utf-8'),

        }).code

      )

      break

  }

})

3.7 使用 chokidar 库创建一个监听,监听 target 文件目录下的文件变化,变化时触发 websoket 发送消息

import chokidar from 'chokidar'

// 创建文件监听方法

function watch() {

  const watcher = chokidar.watch(join(__dirname, '../target'), {

    ignored: ['**/node_modules/**', '**/.cache/**'],

    ignoreInitial: true,

    ignorePermissionErrors: true,

    disableGlobbing: true,

  })

  return watcher

}



// 文件改变事件,回调后传参被修改的路径,触发热更新函数

watch().on('change', async ({ file }) => {

  handleHMRUpdate({ file, ws })

})



// 文件变化了执行的回调,里面其实就是用 websocket 推送变更数据

function handleHMRUpdate(opts) {

  const { file, ws } = opts

  const shortFile = getShortName(file, targetRootPath)

  const timestamp = Date.now()

  let updates

  // 判断是什么类型的文件,发什么类型的消息,组合json消息串

  if (shortFile.endsWith('.css') || shortFile.endsWith('.jsx')) {

    updates = [

      {

        type: 'js-update',

        timestamp,

        path: `/${shortFile}`,

        acceptedPath: `/${shortFile}`,

      },

    ]

  }



  // websoket发送update更新消息,浏览器端注入的JS ,此时应该可以接受到更新json串,

  ws.send({

    type: 'update',

    updates,

  })

}

3.8 回到浏览器被注入的 JS,消息事件监听处,重新 import 加载 js,带上时间搓,缓存更新,热更新完成

async function handleMessage(payload) {

  switch (payload.type) {

    case 'connected':

      console.log('[vite] connected.')



      setInterval(() => socket.send('ping'), 30000)

      break

    case 'update':

      payload.updates.forEach(async update => {

        if (update.type === 'js-update') {

          console.log('[vite] js update....')

          // 局部更新只需要把对应的JS重新import一下就可以重新走请求拦截编译流程

          await import(`/target/${update.path}?t=${update.timestamp}`)



          // 也可以刷新页面测试

          // location.reload();

        }

      })

      break

  }

}