简单的Vite实现与热更新

747 阅读2分钟

最近在学习vite的相关,简单实现了一下

项目文件

原始文件都在target文件夹中,.cache是后来生成的,实现的代码将放在src路径下,下面的就是react项目初始化的内容

image.png

请求的拦截处理

开启一个http服务,来拦截从浏览器过来的请求(知识点:type为module时,浏览器遇到import的时候会发一个请求去获取对应文件,请求地址为文件路径.同时,我们还要在index.html中塞入一个文件,这个文件后续将用于热更新的实现,作为websocket的客户端

const app = express()
const server = createServer(app)
const ws = createWebSocketServer(server);

app.get('/', (req, res) => {
  res.set('Content-Type', 'text/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)
})

接下来我们将在dev.js中对浏览器的文件引入请求做拦截,主要是对.jsx文件和.css文件进行拦截

import express from 'express'
import { createServer } from 'http'
import { read, readFileSync } from 'fs'
import { transformCode, transformJSX, transformCss } from './transform'

app.get('/target/*', (req, res) => {
  const filePath = join(__dirname, '..', req.path.slice(1))
  // 这里是对静态资源的处理,后续会给.svg文件的请求的末尾加一个?import
  if('import' in req.query){
    res.set('Content-Type', 'application/javascript')
    res.send(`export default "${req.path}"`)
    return
  }
  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
      )
  }
})

下面去实现transform系列方法,在transform.js中

import { transformSync } from 'esbuild'
import path, { join,extname, dirname } from 'path'

// 封装一个基于esbuild的代码处理函数
export function transformCode(opts){
  return transformSync(opts.code, {
    loader: opts.loader || 'js',
    sourcemap: true,
    format: 'esm'
  })
}

// 如果是css,就要在页面浏览器页面上塞一个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文件esbuild解析后直接返回,但是要处理一下里面的代码引用,如果是node_modules的文件,从处理过的缓存里面那,如果是本地的,就直接拿
export function transformJSX(opts){
  const ext = extname(opts.path).slice(1)
  const ret = transformCode({
    loader: ext,
    code: opts.code
  })
  let { code } = ret
  code = code.replace(
    /\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm,
    (a,b,c) => {
      let from
      if(c.charAt(0) === '.'){
        from = join(dirname(opts.path), c)
        from = JSON.parse(JSON.stringify(from).replace(/\\\\/g, "/"))
        if(['svg'].includes(extname(from).slice(1))){
          from = `${from}?import`
        }
      }else{
        from = `/target/.cache/${c}/cjs/${c}.development.js`
      }
      return a.replace(b, `"${from}"`)
    }
  )
  return {
    ...ret,
    code
  }
}

完善一下client.js中的相关函数

const sheetMap = new Map()
// 这是向页面塞style标签的实现
export function updateStyle(id,content){
  let style = sheetMap.get(id)
  if(!style){
    style = document.createElement('style')
    style.setAttribute('type', 'text/css')
    style.innerHTML = content
    document.head.appendChild(style)
  }else{
     style.innerHTML = content
  }
  sheetMap.set(id, style)
}

vite的依赖缓存

vite在项目启动的时候,会先把项目中的一些依赖缓存起来

// optmize.js中

import { build } from 'esbuild'
import { join } from 'path'

const appRoot = join(__dirname, '..')
// 缓存的路径为target/.cache文件夹中
const cache = join(appRoot, 'target', '.cache');

export async function optmize(pkgs = ['react', 'react-dom']){
  const ep = pkgs.reduce((c, n) => {
    c.push(join(appRoot, 'node_modules', n, `cjs/${n}.development.js`))
    return c
  }, [])

  await build({
    entryPoints: ep,
    bundle: true,
    format: 'esm',
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: cache,
    treeShaking: 'ignore-annotations',
    metafile: true,
    define: {
      "process.env.NODE_ENV": JSON.stringify("development")
    }
  })
}

热更新

热更新的实现是通过webSocket来实现的,dev.js中启动服务端,塞到浏览器中的client.js中有客户端,以此达到文件改变,通知浏览器的功能

// dev.js
import WebSocket from 'ws'
const targetRootPath = join(__dirname, '../target');

function createWebSocketServer(server){
  const wss = new WebSocket.Server({ noServer: true})
  server.on('upgrade', (req, socket, head) => {
    if (req.headers['sec-websocket-protocol'] === 'vite-hmr') {
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit('connection', ws, req);
      });
    }
  })

  wss.on('connection', (socket) => {
    socket.send(JSON.stringify({ type: 'connected'}))
  })
  wss.on('error', (e) => {
    if(e.code !== 'EADDRINUSE'){
      console.error(
        chalk.red(`WebSocket server error:\n${e.stack || e.message}`)
      )
    }
  })

  return {
    send(payload){
      const stringified = JSON.stringify(payload)
      wss.clients.forEach( client => {
        if(client.readyState === WebSocket.OPEN){
          client.send(stringified)
        }
      })
    },
    close() {
      wss.close()
    }
  }
}

function watch () {
  return chokidar.watch(targetRootPath, {
    ignored: ['**/node_modeules/**', '**/.cache/**'],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true
  })
}

function getShortName(file, root){
  return file.startsWith(root + '/') ? posix.relative(root, file) : file
}

function handleHMRUpdate(opts){
  const {file, ws} = opts
  const timestamp = Date.now()
  // const shortFile = getShortName(file, targetRootPath)
  const shortFile = '/App.jsx'
  let updates
  if(shortFile.endsWith('.css') || shortFile.endsWith('.jsx')){
    updates = [
      {
        type: 'js-update',
        timestamp,
        path: `/${shortFile}`,
        acceptedPath: `/${shortFile}`
      }
    ]
  }

  ws.send({
    type: 'update',
    updates
  })
}
// clent.js中
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){
    case 'connected':
      console.log('[vite] connected.')
      setInterval(() => {
        socket.send('ping')
      }, 3000)
      break
    case 'update':
      console.log(payload)
      payload.updates.forEach(async (update) => {
        if(update.type === 'js-update'){
          console.log('[vite] is update')
          await import(`/target/${update.path}?t=${update.timestamp}`)
          location.reload()
        }  
      })
      break;
  }
}

启动的时候,先执行optmize文件,再执行dev.js 成功启动项目。