likes
comments
collection
share

Vite 为什么这么快前言 webpack统治了前端打包界也有一段时间了,除了比较老的项目,新项目都会采用webpack

作者站长头像
站长
· 阅读数 78

前言

webpack统治了前端打包界也有一段时间了,除了比较老的项目,新项目都会采用webpack来打包。然而时代总是会更替的,就像十几年前用的诺基亚、摩托罗拉这样带键盘的手机,现在已难寻踪迹。如今人手一部正面一整块屏幕的智能手机。随着浏览器对原生esm模块的支持越来越好,近几年社区也出现了像 rollup, vite 等直接使用esm模块打包的工具,并逐渐流行起来,大有要挑战webpack统治地位的意思。在这篇文章中我们用一个简易项目来说明vite的工作原理,了解一下vite为什么这么快。

1. 初始化项目

首先初始化一个项目

npm init -y

并装一些依赖

npm i react react-dom chokidar // 生产环境依赖
npm i esno express ws chalk -D // 测试环境依赖
// esno用来执行采用esm模块规范编写的js文件
// ws 是node端的websocket库
// chokidar 用来监听文件变化
// chalk 用来设置控制台文字的样式

接着在package.json里加入一个npm脚本命令

./package.json
{
...
  "scripts":{
    "dev":"esno src/opt-command.js && esno src/dev-command.js" // 串行执行
  }
...
}

这行启动命令会串行执行两个js文件,我们暂且先知道要需要这两个文件,在下面的小结会逐个创建。列一个列表,可以对要做的事情先留个印象。

  1. 起一个本地node服务
  2. 访问这个服务时返回一个html模版
  3. 这个html的头部塞入一个js文件,这个js文件使用websocket接收服务端传来的文件变化(也就是热更新)
  4. 在服务端处理客户端的其他请求
  5. 在服务端监听文件变化,并使用websocket告知客户端 这么一看有些抽象,没关系我们一步一步来

2. 搭建服务端

在项目根目录创建一个src文件夹,并在src里创建一个dev-command.js, 并把一些需要的依赖引进来。

// /src/dev-command.js
import express from 'express';
import { createServer } from 'http';
import { join, extname, posix } from 'path'
import { readFileSync } from 'fs';
import chokidar from 'chokidar' // 监听文件变化的库
import WebSocket from 'ws'; // 服务端的websocket库
import { transformCode, transformCss, transformJSX } from './transform'; // 这个文件需要我们自己写

起一个本地服务

// /src/dev-command.js
async function dev(){
  const app = express()
  ...
  const server = createServer(app) // createServer来自node自带的http模块
  const port = 3333
  server.listen(port, () => {
    console.log('App is running at 127.0.0.1:', port)
  })
}
dev().catch(console.error)

当我们在地址栏输入127.0.0.1:3333时要返回一个html文件,这个html文件就不费劲吧啦自己写了。用vite创建一个react项目,直接用它生成的html文件。 在一个新的文件夹下执行

yarn create vite

在这一步选择react

Vite 为什么这么快前言 webpack统治了前端打包界也有一段时间了,除了比较老的项目,新项目都会采用webpack 在我们的项目根目录下创建target文件夹,把vite项目生成的html文件和src目录下的所有文件复制过去。

// 这是刚刚生成的vite项目
|-vite-project
  |-index.html
  |-package.json
  |-src
  |  |-App.css
  |  |-App.jsx
  |  |-favicon.svg
  |  |-index.css
  |  |-logo.svg
  |  |-main.jsx
  |-vite.config.js
  |-yarn.lock

演示项目的目录结构

// 这是复制完成后演示项目当前的目录结构
|-myvite
  |-package.json
  |-src
  |  |-dev.command.js
  |-target
  |  |-App.css
  |  |-App.jsx
  |  |-index.css
  |  |-index.html
  |  |-logo.svg
  |  |-main.jsx
  |-yarn.lock

接着返回html文件

// ./src/dev-command.js
...
async function dev(){
  const app = express()
  app.get('/', (req, res) => {
    res.set('Content-type', 'text/html') // 设置响应头
    const htmlPath = join(__dirname, '../target', 'index.html') // html文件的路径
    let html = readFileSync(htmlPath, 'utf-8') // 读取html文件
    html = html.replace('<head>', '<head> \n <script type="module" src="/@myvite/client"></script>').trim()
    send(html)
  })
  ...
}
dev().catch(console.error)

当浏览器解析到这个script标签时,会向src里的地址发一个请求,我们给它返回一个js文件。

// ./src/dev-command.js
...
async function dev(){
  ...
  app.get('/@myvite/client', (req, res) => {
    res.set('Content-Type', 'application/javascript')
    res.send(
      transformCode({
        code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
      }).code
    )
  })

这个返回的js文件,等会儿再讲。在html的body尾部还有一个用来渲染页面的js, 我们先来处理这个。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> // 删掉这一行,这个svg也可以删除
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script> // 把src改成 /target/main.jsx, 因为我们把文件都复制到了target下
  </body>
</html>

浏览器会去获取main.jsx, 但浏览器不认识啥是jsx, 我们需要把jsx转成js, 顺道把其他文件也一并处理了。

// /src/dev-command.js
...
async function dev(){
  ...
  // 命中target下的所有文件
  app.get('/target/*', (req, res) => {
    // 获取文件的完整路径
    // req.path >>> /target/main.jsx
    const filePath = join(__dirname, '..', req.path.slice(1))
    
    switch(extname(req.path)){ // extname从fs解构而来
      case '.svg': // 处理svg文件
        res.set('Content-Type', 'image/svg+xml')
        res.send(
          readfileSync(filepath, 'utf-8')
        )
        break
      case '.css': // 处理css文件
        res.set('Content-Type', 'application/javascript')
        res.send(
          transformCss({
            path: req.path,
            code: readfileSync(filepath), 'utf-8')
          })
        )
        break
      default: // 处理jsx文件
        res.set('Content-Type', 'application/javascript')
        res.send(
          transformJSX({
            path: req.path,
            code: readfileSync(filepath), 'utf-8') 
          })
        )
        break
    }
    ...
  })

dev-command文件先写到这里,其他逻辑后面再补上。

3. 转换各种类型文件

在html里解析到这个script标签时,就走到了第二节最后的switch里的default分支

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

我们在src目录下新建一个transform.js文件,先试着把jsx转换成浏览器认识的js. 这里要说明的是esbuild这个库,初始化项目的时候也没有装,它是从哪来的?它又是干什么的?其实不难想到,一定是安装的某个依赖又依赖了这个包。答案是esno把esbuild作为依赖引入了,esbuild使用golang编写, 在编译js方面相比于js书写的工具更有优势,vite内部在转译jsx时也使用了这个库。

// /src/transform.js
import { transformSync } from 'esbuild';
import { extname, dirname, join } from 'path'
// 封装一个工具函数,返回esbuild转换后的内容
export function transformCode({code, loader}){
  return transformSync(code, { // jsx -> js
    loader: loader || 'js',
    sourcemap: true,
    format: 'esm'
  })
}
function transformJSX(opts){
  const {appRoot, code, path} = opts
  const ext = extname(path).slice(1) // jsx
  const ret = transformCode({
    loader: ext,
    code
  })
  ...
}
...
export {
  transformJSX,
  transformCss,
  transformCode
}

借助esbuild成功的把jsx转成了js, 其实就是把带尖括号的标签转成了React.createElement.

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App.jsx";
ReactDOM.render(/* @__PURE__ */ React.createElement(React.StrictMode, null, /* @__PURE__ */ React.createElement(App, null)), document.getElementById("root"));

现在问题来了,顶部这么些个import能成功引入么。要知道每个import浏览器都会发一个请求去获取相应资源,对浏览器来说"react", "react-dom", "./index.css" 是什么鬼,不好意思我不认识。所以转成js之后下一步就是处理这些import.来一个脑阔痛的正则 😁,匹配目标是import语句。再来一个脑阔没那么疼的replace第二个参数为函数时的高级用法。 😭

// /src/transform.js
...
function transformJSX(opts){
  ...
  const {code} = ret
  // 反向预查,非捕获分组,反向引用... 额 补一波正则知识吧
  code = code.replace(
    /\bimport(?!\s+type)(?:[\w*{}\r\n\t, ]+from\s*)?\s*("([^']+)"|'([^"]+)')/gm,
    (a, b, c, d, e, f)=>{
    // 每成功匹配一次,就调用一次此函数。因为只有三个捕获分组,所以有六个有效行参。
    // 如果一个捕获分组都没有,那就有3个有效形参(去除b, c, d)
    // 函数的返回将用来替换正则匹配到的字符串
    // a 为匹配到的字符串
    // b 为第一个捕获分组捕获到的内容
    // c 为第二个捕获分组捕获到的内容
    // d 为第三个捕获分组捕获到的内容
    // e 为匹配到的字符串在原始字符串中的索引位置
    // f 为原始字符串
    ...
    }
  )
}
...

单独写一个代码块吧,毕竟上面的内容需要消化一下 😢。ok 接下来要判断一下哪些是项目内的文件比如 './index.css', 哪些是第三方依赖比如 'react'. 怎么判断呢? 简单粗暴点,以' . '开始的我们就当是项目内文件,否则就是第三方依赖。

// /src/transform.js
...
function transformJSX(opts){
  ...
  const { code } = ret
  code = code.replace(
    /\bimport(?!\s+type)(?:[\w*{}\r\n\t, ]+from\s*)?\s*("([^']+)"|'([^"]+)')/gm,
    (a, b, c, d, e, f)=>{
      // 举个例子
      // a import App from './App.jsx'
      // b './App.jsx'
      // c  ./App.jsx
      
      let realFrom // 用这个替换原 from 后面的内容
      if(c.charAt(0) === '.'){ // 项目内文件
        // opts.path 为 /target/main.jsx 
        // 调用dirname后返回/target/ 
        // join后为 /target/App.jsx 
        realFrom = join(dirname(opts.path), c) 
        
        // 如果是svg文件标记一下,标记的用途下面再讲
        if(['svg'].includes(extname(realFrom).slice(1))){
          realFrom = `${realFrom}?import`
        }
      }else{ // 第三方依赖
      
        // taget下现在还没有.cache文件夹,这个也在下面讲
        realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
      }
      
      // 返回的内容,举个例子
      // import App from '/target/App.jsx'
      return a.relace(b, `'${realFrom}'`)
    }
  )
  // 最终返回所有import处理好的js代码字符串
  return code
}

处理jsx文件的函数写好了,接下来写处理css文件的函数 transformCss. 这里应该不难理解,返回的js代码做的事情就是创建一个style标签,内容设置为css代码,最后塞到head里。

// /src/transform.js
...
function transformCss(opts){
  return `
    const css = "${opts.code.replace(/\n/g, '')}"
    
    const styleTag = document.createElement('style')
    styleTag.setAttribute('type', 'text/css')
    styleTag.innerHTML = css
    
    document.head.appendChild(styleTag)
  `.trim()
}

好, 现在回过头说说为什么svg文件要标记一下。

// 如果是svg文件标记一下,标记的用途现在就讲
if(['svg'].includes(extname(realFrom).slice(1))){
  realFrom = `${realFrom}?import`
}

在浏览器里我们只能import进来js文件,引入svg文件会报下面的错误。

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "image/svg+xml". Strict MIME type checking is enforced for module scripts per HTML spec.

所以我们在后面拼接上 ?import 当浏览器解析到下面这一行时,会发一个请求。

import logo from '/target/logo.svg?import'

我们接到这个请求后返回一个js文件就行了。

// /src/dev-command.js
...
async function dev(){
  ...
  app.get('/target/*', (req, res) => {
    ...  
    // 在这里捕获刚刚标记的svg文件
    if('import' in req.query){
      res.set('Content-Type', 'application/javascript')
      res.send(`export default '${req.path}'`) 
      // 返回一行js代码字符串
      return
      
    }
    
    switch(extname(req.path)){ // extname从fs解构而来
      ...
    }
    ...
  })

这里要注意的是,我们返给浏览器的js代码字符串执行后export的是一串字符串 '/target/logo.svg' . 即下面这个logo其实就是这个字符串。

// /target/App.jsx

import React from 'react';
import './App.css';
import logo from './logo.svg'; // 这里import进来的logo, 就是 '/target/logo.svg'

function App() {
  const [count, setCount] = React.useState(0);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>Hello Vite + React!</p>
...

logo作为img的src属性传入后,浏览器去请求这个资源,就会进入下面的这个case里。这样页面就能正常显示这个图片了。

// /src/dev-command.js
...
async function dev(){
  ...
  app.get('/target/*', (req, res) => {
    ...
    switch(extname(req.path)){ // extname从fs解构而来
      case '.svg': // 处理svg文件
        res.set('Content-Type', 'image/svg+xml')
        res.send(
          readfileSync(filepath, 'utf-8')
        )
        break
      ...
    }
    ...
  })

4. 处理第三方依赖

处理完项目内各类型的文件后还有一个坑需要填,那就是target下的.cache文件夹是做什么的。先在 /target 下 手动创建 .cache 文件夹

}else{ // 第三方依赖
      
// taget下现在还没有.cache文件夹,这个现在讲
realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
}

还记得 package.json 里面的 scripts 脚本吗,我们是这么配置的。dev.command.js 写的差不多了, opt.command.js 还没有写。顺带一提,这里的opt是optimize的缩写。

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

在 src 下创建 opt.command.js, 里面有只有一个 async iife.

// /src/opt.command.js
import { esbuild } from 'esbuild' // 再次登场
import { join } form 'path'

const appRoot = join(__dirname, '..') // 获取项目根目录
const cache = join(appRoot, 'target', '.cache')

(function async (){
  const dep = ['react', 'react-dom'] // 需要处理的依赖列表
  const ep = dep.reduce((a, b) => {
    a.push(join(appRoot, 'node_modules', b, `cjs/${b}.development.js`))
    return a
  }, [])
  await esbuild({ // 从属性名应该能大致猜到作用,具体可以参考esbuild官网
    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")
    }
  })
})()

执行了这个js文件后,.cache文件夹里就会生成按照esm规范打包好的依赖文件。

5. 热更新

热更新是个比较实用的功能,这看似魔法的功能,细想的话其实也不难琢磨。首先肯定要监听文件的变化,那么怎么监听文件的变化呢? 这就要用到 chokidar 这个库了。 在 dev.command.js 文件中已经引入了 chokidar , 在启动服务时就开始监听。

// /src/dev.command.js
...
import chokidar from 'chokidar'

const targetRootPath = join(__dirname, '../target') // target文件夹的绝对路径
(async function dev(){
  ...
  const watcher = chokidar.watch(targetRootPath, {
    ignored: ['**/node_modules**', '**/.catch/**'], // 忽略目录
    ignoreInitial: true,
    ignorePremissionErrors: true,
    disableGlobbing: true
  })
  // 文件变化时
  watcher.on('change', (file)=>{
    // TODO
  })
})()

ok 那么文件变化时要做哪些事情呢? 在这里要说明一下的是,现在是在服务端监听文件变化。但代码最终是在客户端也就是浏览器上运行的,那服务端监听到了变化如何主动告知客户端呢?没错,正是你的脑海中浮现出的 websocket. 服务端的 websocket 我们使用 ws 库,下面的代码创建了webSocket实例。

// /src/dev.command.js
...
import WebSocket from 'ws'
import chalk from 'chalk'
...
function createWebSocketServer(server){
  const wss = new WebSoket.Server({noServer : true}) // 允许WebSocket服务器完全脱离HTTP/S服务器
  // server为服务的实例,监听upgrade事件,处理升级请求
  server.on('upgrade', (req, socket, head) => {
    // 确认匹配客户端的websocket, 客户端的websocket等会儿创建
    if(req.headers['sec-websocket-protocol'] === 'vite-hmr'){
      wss.handleUpgrade(req, socket, head, (ws) => { // noServer模式下手动调用 handleUpgrade
        wss.emit('connection', ws, req)
      })
    }
    // 监听建立连接事件,注册处理函数
    wss.on('connection', (websocket) => {
      // 向客户端发送数据
      websocket.send(JSON.stringify({type: 'connected'}))
    })
    // 监听错误事件,注册处理函数
    wss.on('error', (e) => {
      if(e.code !== 'EADDRINUSE'){
        console.log(chalk.red(`WebSocket server error: \n ${e.stack || e.message}`))
      }
    })
    // 最后返回包含了两个方法的对象,send方法向客户端发送数据,close方法关闭websocket
    return {
      send(payload){
        const stringified = JSON.stringify(payload)
        // 遍历所有的客户端
        wss.clients.forEach( client => {
          client.send(stringified)
        })
      },
      close(){
        wss.close()
      }
    }
  })
}
...

把 server 传进 createWebSocketServer, 并在监听到文件发生变化时调用 handleHMRUpdate

// /src/dev.command.js
...
(async function dev(){
  ...
  const server = createServer(app) // app是express实例,createServer解构自http模块
  const wsMethods = createWebSocketServer(server)
  // 文件变化时
  watcher.on('change', (file)=>{
    handleHMRUpdate({file, wsMethods})
  })
  const port = '3333'
  server.listen(port, ()=>{
    console.log('App is running at 127.0.0.1:' + port)
  })
})()

在 handleHMRUpdate 里,把更新的信息发给了客户端。

// /src/dev.command.js
...
const targetRootPath = join(__dirname, '../target')
function handleHMRUpdate({file, wsMethods}){
  // 获取文件名
  const shortName = file.startsWith(targetRootPath + '/') ? 
    posix.relative(targetRootPath, file) :  // posix也是解构自path模块,是一种兼容性方案
    file
  ;const timestamp = Date.now()
  
  let updates
  if(shortName.endsWith('.css') || shortName.endsWith('.jsx')){
    updates = {
      type: 'js-update',
      timestamp,
      path: `/${shortName}`,
      acceptedPath: `/${shortName}`
    }
  }
  // 发给客户端
  weMethods.send({
    type: 'update',
    updates
  })
}
...

终于到了最后一步了,现在还差一个处理服务端返回的热更新信息的逻辑。还记得我们在处理html文件的返回时,head塞的script标签么

// ./src/dev-command.js
...
async function dev(){
  const app = express()
  app.get('/', (req, res) => {
    res.set('Content-type', 'text/html') // 设置响应头
    const htmlPath = join(__dirname, '../target', 'index.html') // html文件的路径
    let html = readFileSync(htmlPath, 'utf-8') // 读取html文件
    html = html.replace('<head>', '<head> \n <script type="module" src="/@myvite/client"></script>').trim()
    send(html)
  })
  ...
}
dev().catch(console.error)

当浏览器解析到这个script标签,发请求获取src里的资源时,我们会返回一个名为client的js文件。

// ./src/dev-command.js
...
async function dev(){
  app.get('/@myvite/client', (req, res) => {
    res.set('Content-Type', 'application/javascript')
    res.send(
      transformCode({
        code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
      }).code
    )
  })
  ...
}

我们在src目录下创建一个 client.js 文件。

// /src/client.js
// 创建一个客户端的websocket, 协议指定为 vite-hmr , 才能和服务端成功建立连接。
// 这是为什么呢?可以在页面 ctrl/command + f 搜索下 vite-hmr
const socket = new WebSocket(`ws://${location.host}`, 'vite-hmr')
// 拿到数据
socket.addEventListener('message', ({data})=>{
  handleMessage(JSON.parse(data)).catch(console.error)
})
// 处理数据
async function(payload){
  switch(payload.type){
    case 'connected':
      console.log('connected')
      break
    case 'update': // 处理更新内容
      payload.updates.forEach(async (update) => {
        if(update.type === 'js-update'){ // 不明白这个type的含义的话,也可以在页面搜一下'js-update' :)
          console.log('js update...')
          await import(`/target/${update.path}?t=${update.timestamp}`) // 动态加载
          
          location.reload()
        }
      })
      break
  }
} 

到这里所有代码就写完了。

结语

正如你看到的,最后是用 location.reload 刷新了整个页面,并没有实现动态替换有变化的部分......这个我也需要进一步学习,不过就像开篇时说的, 对于 Vite 为什么这么快,相信你已经有了一定的了解。

转载自:https://juejin.cn/post/7024474558321131528
评论
请登录