【Vite】核心原理解析

124 阅读5分钟

【Vite】核心原理解析

本篇文章主要是通过原生实现一个迷你的vite脚手架,将React的demo跑起来,用来探究vite的核心原理

既然要实现一个vite,首先要准备一个小项目用于测试,成功启动就算成功

前戏

文件目录结构

|-- my-vite 
    |-- src
    |-- target
        |-- App.css
        |-- App.js
        |-- index.css
        |-- index.html
        |-- logo.svg
        |-- main.jsx
    |-- package.json
    |-- yarn.lock

其中,src为vite脚手架源码,target为测试用的react项目

利用浏览器支持ESM机制,发请求获取对应那个文件的方式

esm机制:import 的内容都会走请求去拉取资源,我们自己起一个服务,就可以对这些请求的返回进行拦截处理,返回我们处理过后的内容

/整个应用就完全基于 node 服务,静态资源加载,没有编译构建的过程,肯定就会很快了。

主要思路如下

  1. 使用express启动服务

  2. 拦截入口请求,塞入客户端代码文件,返回他的html文件

  3. 客户端代码内可以加一些骚操作,比如 热更新

    1. 拦截客户端请求接口,获取代码,使用esbuild依赖将代码转换为esm格式统一处理后返回

    2. 拦截静态文件请求接口,处理后返回该目录下对应文件

      1. 处理svg图片

      2. 处理css文件

      3. 处理jsx文件

        1. 使用正则分析 import

        2. 分别处理本地文件和依赖包

          1. 依赖包预先编译并缓存

客户端代码

  1. 使用websocket建立一个全双工通信
  2. 监听通信,拿服务端塞过来的 已修改 的代码文件数据
  3. 根据修改的文件类型,使用对应的策略进行处理,更新页面

代码

启动服务,塞入客户端

先用express启动一个服务,塞入客户端代码文件,返回他的html文件

src/dev.js

import express from 'express'
import { readFileSync } from 'fs'
import { createServer } from 'http'
import { join } from 'path'// 目标文件路径
const targetRootPath = join(__dirname, '../target')
​
export async function dec() {
    const app = express()
​
    // 入口请求
    app.get('/', (req, res) => {
        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)
    })
​
    const server = createServer(app)
​
    const port = 8888
    server.listen(port, () => {
      console.log('App is running at 127.0.0.1' + port);
    })
}
​

src/dev.command.js

import {dev} from './dev'dev().catch(console.error)

注意:浏览器只支持ESM模块化的语法,所以要在代码内只能用ESM而不能用CMJ,可以使用esno在node环境使用ESM

package.json

{
  "name": "my-vite",
  "version": "1.0.0",
  "description": "demo",
  "main": "index.js",
  "scripts": {
    "dev": "esno src/dev.command.js"
  },
  "author": "cbbfcd",
  "license": "ISC",
  "devDependencies": {
    "esno": "^0.5.0",
    "express": "^4.17.1",
    "prettier": "^2.3.0",
    "prettier-plugin-organize-imports": "^2.1.0",
    "react": "^17.0.0",
    "react-dom": "^17.0.0",
    "typescript": "^4.2.4",
    "ws": "^7.4.5"
  },
  "dependencies": {
    "chokidar": "^3.5.2"
  }
}

可以使用命令行启动该服务看看

yarn dev

拦截客户端请求,返回客户端代码

  1. 服务端建立一个通信
  2. 监听通信,拿数据,然后做处理

src/dev.js

// 客户端
app.get('/@vite/client', (req,res) => {
    res.set('Content-Type', 'application/json')
    res.send(
      // TODO 读取准备好的客户端文件
    )
})

src/client.js

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) {
    case 'connected':
      console.log('[vite] connected.');
      setInterval(() => {
        socket.send('ping')
      }, 30000);
      break;
  
    default:
      payload.update.forEach(update => {
        if(update.type === 'js-update') {
          console.log('[vite] is update...');
        }
      });
      break;
  }
}

统一打包处理代码

由于浏览器只认识ESM,所以需要封装函数,统一打包处理,防止用户使用CMJ或者其他方式引用文件

src/transform.js

import { transformSync } from 'esbuild'
​
export function transformCode(opts) {
  return transformSync(opts.code, {
    loader: opts.loader || 'js',
    sourcemap:true,
    format: 'esm'
  })
}

返回客户端代码前,先处理一遍

src/dev.js

// 客户端
app.get('/@vite/client', (req,res) => {
    res.set('Content-Type', 'application/json')
    res.send(
        transformCode({
            code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
        }).code
    )
})

拦截target路径的所有请求,处理返回的数据

src/dev.js

import { join, extname } from 'path'

// 静态文件的处理,返回给浏览器能认识的
    app.get('/target/*', (req,res) => {
      // extname获取文件的后缀
      // 获取完整的文件路径
      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
      }
      switch (extname(req.path)) {
        // svg图片可以直接返回,浏览器会识别
        case '.svg':
           // TODO 处理svg图片
          break;
        case '.css':
           // TODO 处理css文件
          break;
        default:
           // TODO 处理jsx文件
          break;
      }
    })	

处理svg图片

src/dev.js

case '.svg':
    // TODO 处理svg图片
    res.set('Content-Type', 'image/svg+xml')
    res.send(
        readFileSync(filePath, 'utf-8')
    )
    break;

处理css文件

src/client.js

// 封装一些操作 css 的工具方法,因为 client 是放 html 里的,可以导出来给其它模块使用
const sheetsMap = new Map();

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);
}

export function rmStyle(id) {
  const style = sheetsMap.get(id);
  if (style) {
    document.head.removeChild(style);
  }
  sheetsMap.delete(id);
}

src/transform.js

// 把 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()
}

src/dev.js

import { transformCode,transformCss } from './transform'
...
case '.css':
    res.set('Content-Type', 'application/javascript')
    res.send(
        transformCss({
            path: req.path,
            code: readFileSync(filePath, 'utf-8')
        })
    )
    break;

处理jsx文件

src/dev.js

import { transformCode,transformCss,transformJSX } from './transform'
...
default:
          res.set('Content-Type', 'application/javascript');
          res.send(
            transformJSX({
              appRoot: join(__dirname, '../target'),
              path: req.path,
              code: readFileSync(filePath, 'utf-8')
            }).code
          )
          break;

将jsx转成js

src/transform.js

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", 然后通过有没有 "." 判断是引用的本地文件还是三方库
  // 本地文件就拼路径
  // 三方库就从我们预先编译的缓存里面取
  code = code.replace(  
    /\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm,
    (a, b, c) => {
      // a import React from "react"
      // b "react"
      // c  react
      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`
        }
        // 将反斜杠替换成斜杠,否则浏览器不认识该路径
        from = from.replace(/\/g,"/");
      }
      else { // 从 node_modules 里来的
        from = `/target/.cache/${c}/cjs/${c}.development.js`;
      }
      return a.replace(b, `"${from}"`)
    }
  )

  return {
    ...ret,
    code
  }
}

预处理node_modules依赖文件

预先编译node_modules里面的包,预先编译,并且缓存起来

可以预先把相关文件从 node_modules 取出来,build 成 esm 模块,放进一个缓存文件中,这些依赖的三方库一般是不会变更的,所以可以这样预先处理

src/optmize.js

// node_modules 里的包,预先编译,并且缓存起来
import { build } from 'esbuild'
import { join } from 'path'

const appRoot = join(__dirname, '..')
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")
    }
  })
}

src/opt.command.js

import { optmize } from './optmize';

(async () => {
  await optmize()
})()

修改package.json,启动项目前预先打包缓存依赖

 "scripts": {
    "dev": "esno src/opt.command.js && esno src/dev.command.js"
  }

至此,已经可以将该React项目跑起来了


处理热更新

  1. 使用chokidar监听本地文件的变更
  2. 使用ws,通过websocket通知客户端
  3. 客户端收到消息,进行自动更新

src/client.js

....
async function handleMessage(payload) {
  switch (payload.type) {
    case 'connected':
     ...
      break;
  
    default:
      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;
  }
}
....

src/dev.js

import express from 'express'
import { readFileSync } from 'fs'
import { createServer } from 'http'
import { join, extname, relative } from 'path'
import chokidar from 'chokidar'
import WebSocket from 'ws';
import { transformCode,transformCss,transformJSX } from './transform'

const targetRootPath = join(__dirname, '../target');

// 建立一个 websocket 服务,封装 send 方法
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_modules/**', '**/.cache/**'],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
  })
}


function getShortName(file, root) {
  console.log('file, root',file, root);
  // 方法根据当前工作目录返回从 from 到 to 的相对路径
  return file.startsWith(root + '\') ? relative(root, file) : file;
}

// 文件变化了执行的回调,里面其实就是用 websocket 推送变更数据
function handleHMRUpdate(opts) {
  const {file, ws} = opts;
  const shortFile = getShortName(file, targetRootPath);
  console.log('shortFile',shortFile);
  const timestamp = Date.now();
  let updates
  if (shortFile.endsWith('.css') || shortFile.endsWith('.jsx')) {
    updates = [
      {
        type: 'js-update',
        timestamp,
        path:  `/${shortFile}`,
        acceptedPath: `/${shortFile}`
      }
    ]
  }

  ws.send({
    type: 'update',
    updates
  })
}

export async function dev() {
    const app = express()

    // 入口请求
    app.get('/', (req, res) => {
        ...
    })

    // 客户端
    app.get('/@vite/client', (req,res) => {
      ...
    })

    // 静态文件的处理,返回给浏览器能认识的
    app.get('/target/*', (req,res) => {
      ...
    })

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

    // 监听文件的变化
    watch().on('change', async (file) => {
      handleHMRUpdate({ file, ws });
    })

    const port = 8888
    server.listen(port, () => {
      console.log('App is running at 127.0.0.1:' + port);
    })
}

以上,就是所有的代码了

直接运行命令行启动,即可

yarn dev

最终目录结构如下

|-- my-vite 
  	|-- src
  	    |-- client.js
        |-- dev.command.js
        |-- dev.js
        |-- opt.command.js
        |-- optmize.js
        |-- transform.js
    |-- target
    	|-- .cache
    		|-- ...
        |-- App.css
        |-- App.js
        |-- index.css
        |-- index.html
        |-- logo.svg
        |-- main.jsx
    |-- package.json
    |-- yarn.lock