vite源码分析之dev

369 阅读5分钟

最近研究socket, 所以就顺便看了一下vite源码, vite的热更新就是根据socket实现的, 所以正好记录一下.

前端任何脚手架的入口,肯定是在package.json文件中,当我们输入script命令时, 会经历什么样的步骤呢? 接下来我们一起来探索一下~~~

入口-package.json

看下面就是一个普通前端vite脚手架启动的服务

当我们在终端输入npm run dev时, 会如何调用vite进行项目的启动呢???

当我们输入npm run dev时, 当然我们就相当于执行vite --mode dev

命令

然后就会区node-module文件夹中的.bin文件夹中找, 我们能够找到两个关于vite命令的文件

这两个不同的命令,表示在不同的操作系统,调用不同的命令执行

.cmd为后缀名是在window操作系统下执行的命令, 而没有后缀名的,则是在linux系统里面执行的

.cmd

    @IF EXIST "%~dp0\node.exe" (
      "%~dp0\node.exe"  "%~dp0..\vite\bin\vite.js" %*
    ) ELSE (
      @SETLOCAL
      @SET PATHEXT=%PATHEXT:;.JS;=;%
      node  "%~dp0..\vite\bin\vite.js" %*
    )

.cmd文件是Windows系统下的批处理文件,用于执行命令行命令的集合。.cmd文件的主要特点是:1. 只能运行在Windows系统下。
2. 以文本格式保存,可以用记事本编辑。

  1. 扩展名为.cmd。
  2. 支持Windows命令行命令,如dir、copy、del等,也支持if语法、for循环等逻辑控制语句。
  3. 需要管理员权限才能执行某些命令。
  4. 在文件开头指定编码格式,如@echo off和chcp 65001等,否则会出现中文乱码。

.sh

这个看不到后缀名的是sh文件

以#!/bin/sh开头

        #!/bin/sh
        basedir=$(dirname "$(echo "$0" | sed -e 's,\,/,g')")

        case `uname` in
            *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
        esac

        if [ -x "$basedir/node" ]; then
          "$basedir/node"  "$basedir/../vite/bin/vite.js" "$@"
          ret=$?
        else 
          node  "$basedir/../vite/bin/vite.js" "$@"
          ret=$?
        fi
        exit $ret

是Linux/Unix Shell脚本的标记,用于声明脚本的shell类型和执行路径。当系统遇到以#!/开头的脚本时,会提取脚本第一行的信息来确定执行该脚本的shell环境与路径。例如,以#!/bin/sh开头的脚本会使用/bin/sh路径下的shell来执行脚本。
常见的shell类型有:

  1. /bin/sh:指向系统默认的shell,通常是bash。

  2. /bin/bash:直接指定bash shell来执行脚本。

  3. /usr/bin/perl:使用perl来执行脚本。

  4. /usr/bin/python:使用python来执行脚本。

通过#!/bin/sh指定要使用系统默认shell(通常是bash)来执行该脚本。

vite源码目录

可以从上面的命令文件中,找到, 其实最后就是执行了/../vite/bin/vite.js文件

这还是相对路径指定,就是指定在node-modules文件夹下的vite文件

展开之后就是这样

/bin/vite.js

vite.js其实最重要的一句就是start函数

function start() {
  require('../dist/node/cli')
}

inspector

这里可以讲一下inspector.Session

inspector.Session是Chrome DevTools中的API,用于与Chrome浏览器建立调试会话,以调试和分析页面。
使用inspector.Session API可以:

  1. 与目标页面建立调试连接,随时启动或停止调试。
  2. 收集页面的事件、网络请求、控制台输出等信息。
  3. 设置断点和黑箱断点,调试运行中的JavaScript代码。
  4. 执行运行时命令,如:清理缓存、重新加载页面等。
  5. 分析DOM、CSS、内存等,检查页面的布局、样式和性能问题。

不过可以看到这是node环境, 所以没有直接使用inspector对象, 而是用require引入

process.argv.splice(profileIndex, 1)
  const next = process.argv[profileIndex]
  if (next && !next.startsWith('-')) {
    process.argv.splice(profileIndex, 1)
  }
  const inspector = require('inspector')
  const session = (global.__vite_profile_session = new inspector.Session())
  session.connect()
  session.post('Profiler.enable', () => {
    session.post('Profiler.start', start)
  })

这部分是什么功能呢?

在启动项目时添加--profile,可以打开Chrome的"性能"面板,用于分析项目在运行时的CPU/内存使用情况,找出潜在的性能瓶颈。例如,在一个Vue项目中,可以这样启动:

npm run serve --profile

这会在Chrome中打开"性能"面板,并开始记录项目的性能数据。 在面板中,你可以看到项目在运行时:

  1. CPU的使用占比。可以找出消耗CPU最多的文件/函数。
  2. 内存的增长曲线。查看内存泄露或非常耗内存的组件。
  3. 事件的触发情况。如鼠标点击、页面滚动等事件的频率。
  4. 帧率的变化。帧率过低会造成卡顿,需要优化。
  5. 页面加载与渲染的时间轴。分析页面加载流程与瓶颈。
  6. 网络请求的数量和耗时。
  7. 网络请求的数量和耗时。这些可以缩短加载时间以获得更快的体验。

/node/cli- vite命令

这个文件记录了vite有哪些命令


const cli = cac('vite') // Command And Conquer 是一个用于构建 CLI 应用程序的 JavaScript 库。

比如执行了vite dev 或者vite serve, 就会去执行action里面的逻辑

执行了vite build, 就会执行下面的action逻辑

vite中就只有dev和build这两个命令最重要

接下来介绍一下各个命令

build: 打包

dev: 运行

preview: 预览生产环境构建结果

optimize: 用于对Vite项目进行生产环境构建与优化

version: 查看当前项目中使用的Vite版本

help: 查看Vite CLI提供的所有命令与选项的帮助信息

parse: 解析Vite项目中的import语句与别名,获得其最终解析结果

dev

action里面就是创建一个serve,然后开始监听

server中处理vite.config.ts配置, 处理httpsConfig, 处理ChokidarOptions

这里介绍一下Chokidar

chokidar是一个用于Node.js的文件系统监视器,可以监听文件和文件夹的变化,并执行相应的回调函数。它支持跨平台运行,并可以监视新增、修改、删除、移动等文件系统操作。chokidar还支持通过正则表达式或glob模式对指定的文件进行过滤,并且支持批量处理多个文件。由于其功能强大和易于使用,chokidar已经成为Node.js生态系统中最受欢迎的文件系统监视器之一。

后面就是这个监听文件是否变化, 然后执行HMR(hot modules replacement)更新的

当然,这个只能监听本地文件, 所以它监听的参数只能是相对或绝对路径, 不能监控远程文件的变化

启动一个httpServer, 这个服务是node端处理文件是否发生变化, 以及发生变化之后去处理

resolveHttpServer

创建一个http服务直接使用node自带的http模块建立

函数具体源代码如下:

    export async function resolveHttpServer(
      { proxy }: CommonServerOptions,
      app: Connect.Server,
      httpsOptions?: HttpsServerOptions,
    ): Promise<HttpServer> {
      if (!httpsOptions) {
        const { createServer } = await import('node:http')
        return createServer(app)
      }

      // #484 fallback to http1 when proxy is needed.
      if (proxy) {
        const { createServer } = await import('node:https')
        return createServer(httpsOptions, app)
      } else {
        const { createSecureServer } = await import('node:http2')
        return createSecureServer(
          {
            // Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
            // errors on large numbers of requests
            maxSessionMemory: 1000,
            ...httpsOptions,
            allowHTTP1: true,
          },
          // @ts-expect-error TODO: is this correct?
          app,
        ) as unknown as HttpServer
      }
    }

启动一个http服务之后就是在node端建立websocket

server-createSocket

可以看到socekt经过封装之后, 返回了listen, on, off, send , close方法和clients属性

后面node端发送socket信息, 就是使用send方法

函数具体源代码如下:


 export function createWebSocketServer(
    server: Server | null,
    config: ResolvedConfig,
    httpsOptions?: HttpsServerOptions,
  ): WebSocketServer {
    let wss: WebSocketServerRaw
    let wsHttpServer: Server | undefined = undefined

    const hmr = isObject(config.server.hmr) && config.server.hmr
    const hmrServer = hmr && hmr.server
    const hmrPort = hmr && hmr.port
    // TODO: the main server port may not have been chosen yet as it may use the next available
    const portsAreCompatible = !hmrPort || hmrPort === config.server.port
    const wsServer = hmrServer || (portsAreCompatible && server)
    const customListeners = new Map<string, Set<WebSocketCustomListener<any>>>()
    const clientsMap = new WeakMap<WebSocketRaw, WebSocketClient>()
    const port = hmrPort || 24678
    const host = (hmr && hmr.host) || undefined

    if (wsServer) {
      wss = new WebSocketServerRaw({ noServer: true })
      wsServer.on('upgrade', (req, socket, head) => {
        if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
          wss.handleUpgrade(req, socket as Socket, head, (ws) => {
            wss.emit('connection', ws, req)
          })
        }
      })
    } else {
      // http server request handler keeps the same with
      // https://github.com/websockets/ws/blob/45e17acea791d865df6b255a55182e9c42e5877a/lib/websocket-server.js#L88-L96
      const route = ((_, res) => {
        const statusCode = 426
        const body = STATUS_CODES[statusCode]
        if (!body)
          throw new Error(`No body text found for the ${statusCode} status code`)

        res.writeHead(statusCode, {
          'Content-Length': body.length,
          'Content-Type': 'text/plain',
        })
        res.end(body)
      }) as Parameters<typeof createHttpServer>[1]
      if (httpsOptions) {
        wsHttpServer = createHttpsServer(httpsOptions, route)
      } else {
        wsHttpServer = createHttpServer(route)
      }
      // vite dev server in middleware mode
      // need to call ws listen manually
      wss = new WebSocketServerRaw({ server: wsHttpServer })
    }

    wss.on('connection', (socket) => {
      socket.on('message', (raw) => {
        if (!customListeners.size) return
        let parsed: any
        try {
          parsed = JSON.parse(String(raw))
        } catch {}
        if (!parsed || parsed.type !== 'custom' || !parsed.event) return
        const listeners = customListeners.get(parsed.event)
        if (!listeners?.size) return
        const client = getSocketClient(socket)
        listeners.forEach((listener) => listener(parsed.data, client))
      })
      socket.on('error', (err) => {
        // config.logger.error(`${colors.red(`ws error:`)}\n${err.stack}`, {
        //   timestamp: true,
        //   error: err,
        // })
        console.error(`ws error:`)
      })
      socket.send(JSON.stringify({ type: 'connected' }))
      if (bufferedError) {
        socket.send(JSON.stringify(bufferedError))
        bufferedError = null
      }
    })

    wss.on('error', (e: Error & { code: string }) => {
      if (e.code === 'EADDRINUSE') {
        config.logger.error(
          colors.red(`WebSocket server error: Port is already in use`),
          { error: e },
        )
      } else {
        config.logger.error(
          colors.red(`WebSocket server error:\n${e.stack || e.message}`),
          { error: e },
        )
      }
    })

    // Provide a wrapper to the ws client so we can send messages in JSON format
    // To be consistent with server.ws.send
    function getSocketClient(socket: WebSocketRaw) {
      if (!clientsMap.has(socket)) {
        clientsMap.set(socket, {
          send: (...args) => {
            let payload: HMRPayload
            if (typeof args[0] === 'string') {
              payload = {
                type: 'custom',
                event: args[0],
                data: args[1],
              }
            } else {
              payload = args[0]
            }
            socket.send(JSON.stringify(payload))
          },
          socket,
        })
      }
      return clientsMap.get(socket)!
    }

    // On page reloads, if a file fails to compile and returns 500, the server
    // sends the error payload before the client connection is established.
    // If we have no open clients, buffer the error and send it to the next
    // connected client.
    let bufferedError: ErrorPayload | null = null

    return {
      listen: () => {
        wsHttpServer?.listen(port, host)
      },
      on: ((event: string, fn: () => void) => {
        if (wsServerEvents.includes(event)) wss.on(event, fn)
        else {
          if (!customListeners.has(event)) {
            customListeners.set(event, new Set())
          }
          customListeners.get(event)!.add(fn)
        }
      }) as WebSocketServer['on'],
      off: ((event: string, fn: () => void) => {
        if (wsServerEvents.includes(event)) {
          wss.off(event, fn)
        } else {
          customListeners.get(event)?.delete(fn)
        }
      }) as WebSocketServer['off'],

      get clients() {
        return new Set(Array.from(wss.clients).map(getSocketClient))
      },

      send(...args: any[]) {
        let payload: HMRPayload
        if (typeof args[0] === 'string') {
          payload = {
            type: 'custom',
            event: args[0],
            data: args[1],
          }
        } else {
          payload = args[0]
        }

        if (payload.type === 'error' && !wss.clients.size) {
          bufferedError = payload
          return
        }

        const stringified = JSON.stringify(payload)
        wss.clients.forEach((client) => {
          // readyState 1 means the connection is open
          if (client.readyState === 1) {
            client.send(stringified)
          }
        })
      },

      close() {
        return new Promise((resolve, reject) => {
          wss.clients.forEach((client) => {
            client.terminate()
          })
          wss.close((err) => {
            if (err) {
              reject(err)
            } else {
              if (wsHttpServer) {
                wsHttpServer.close((err) => {
                  if (err) {
                    reject(err)
                  } else {
                    resolve()
                  }
                })
              } else {
                resolve()
              }
            }
          })
        })
      },
    }
  }

moduleGraph: 将所有文件的保存在这里

然后就是使用chokidar监听文件的change, add, unlink

当监听到文件change, 就触发onHMRUpdate

接下来就到了处理热更新handleHMRUpdate

handleHMRUpdate

handleHMRUpdate方法中, 首先处理了如果是配置文件发生改变

如果是.env环境变量改变, 如果是依赖发生改变

就需要重启服务server.restart()

如果仅仅只有客户端, 没有node端, 也就是说不在开发环境, 是不需要热更新的

(仅限开发)客户端本身不能热更新。

这个时候socket只会发送消息,让客户端重新加载

接着处理html文件, html文件是不支持热更新的

所以如果是html文件发生改变, 也需要重新加载页面

如果以上情况都不是就进行更新模块updateModules

函数具体源代码如下:

    export async function handleHMRUpdate(
      file: string,
      server: ViteDevServer,
      configOnly: boolean,
    ): Promise<void> {
      const { ws, config, moduleGraph } = server
      const shortFile = getShortName(file, config.root)
      const fileName = path.basename(file)

      const isConfig = file === config.configFile
      const isConfigDependency = config.configFileDependencies.some(
        (name) => file === name,
      )
      const isEnv =
        config.inlineConfig.envFile !== false &&
        (fileName === '.env' || fileName.startsWith('.env.'))
      if (isConfig || isConfigDependency || isEnv) {
        // auto restart server
        debugHmr?.(`[config change] ${colors.dim(shortFile)}`)
        config.logger.info(
          colors.green(
            `${path.relative(process.cwd(), file)} changed, restarting server...`,
          ),
          { clear: true, timestamp: true },
        )
        try {
          await server.restart()
        } catch (e) {
          config.logger.error(colors.red(e))
        }
        return
      }

      if (configOnly) {
        return
      }

      debugHmr?.(`[file change] ${colors.dim(shortFile)}`)

      // (dev only) the client itself cannot be hot updated.
      if (file.startsWith(normalizedClientDir)) {
        ws.send({
          type: 'full-reload',
          path: '*',
        })
        return
      }

      const mods = moduleGraph.getModulesByFile(file)

      // check if any plugin wants to perform custom HMR handling
      const timestamp = Date.now()
      const hmrContext: HmrContext = {
        file,
        timestamp,
        modules: mods ? [...mods] : [],
        read: () => readModifiedFile(file),
        server,
      }

      for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
        const filteredModules = await hook(hmrContext)
        if (filteredModules) {
          hmrContext.modules = filteredModules
        }
      }

      if (!hmrContext.modules.length) {
        // html file cannot be hot updated
        if (file.endsWith('.html')) {
          config.logger.info(colors.green(`page reload `) + colors.dim(shortFile), {
            clear: true,
            timestamp: true,
          })
          ws.send({
            type: 'full-reload',
            path: config.server.middlewareMode
              ? '*'
              : '/' + normalizePath(path.relative(config.root, file)),
          })
        } else {
          // loaded but not in the module graph, probably not js
          debugHmr?.(`[no modules matched] ${colors.dim(shortFile)}`)
        }
        return
      }

      updateModules(shortFile, hmrContext.modules, timestamp, server)
    }

我简单画了一下流程图

updateModules

模块更新就需要上面讲到的moduleGraph

这里存储了所有的文件模块图

当有监听到有模块更新, moduleGraph就有发生改变

去除无效的模块, 找到需要更新的模块

最后当发出send类型为update类型, 就是一个文件发生变化啦

    export function updateModules(
      file: string,
      modules: ModuleNode[],
      timestamp: number,
      { config, ws, moduleGraph }: ViteDevServer,
      afterInvalidation?: boolean,
    ): void {
      const updates: Update[] = []
      const invalidatedModules = new Set<ModuleNode>()
      const traversedModules = new Set<ModuleNode>()
      let needFullReload = false

      for (const mod of modules) {
        moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
        if (needFullReload) {
          continue
        }

        const boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[] = []
        const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
        if (hasDeadEnd) {
          needFullReload = true
          continue
        }

        updates.push(
          ...boundaries.map(({ boundary, acceptedVia }) => ({
            type: `${boundary.type}-update` as const,
            timestamp,
            path: normalizeHmrUrl(boundary.url),
            explicitImportRequired:
              boundary.type === 'js'
                ? isExplicitImportRequired(acceptedVia.url)
                : undefined,
            acceptedPath: normalizeHmrUrl(acceptedVia.url),
          })),
        )
      }

      if (needFullReload) {
        config.logger.info(colors.green(`page reload `) + colors.dim(file), {
          clear: !afterInvalidation,
          timestamp: true,
        })
        ws.send({
          type: 'full-reload',
        })
        return
      }

      if (updates.length === 0) {
        debugHmr?.(colors.yellow(`no update happened `) + colors.dim(file))
        return
      }

      config.logger.info(
        colors.green(`hmr update `) +
          colors.dim([...new Set(updates.map((u) => u.path))].join(', ')),
        { clear: !afterInvalidation, timestamp: true },
      )
      ws.send({
        type: 'update',
        updates,
      })
    }

就比如下图, 文件路径和名称就来自于moduleGraph

流程图附上

client-createSocket

讲完了node端如何发送socket

接下来肯定要有客户端监听到socket

查看目录时, 我们能够看到有一个client目录

这里其实就是客户端处理socket的文件

不知道有没有人好奇, 怎么把文件引入到客户端去, 没有看源码之前, 反正我是很好奇的

不知道大家有没有记得,在action里面有一个createServer

在createServer里面有一个_createServer

在_createServer里面有一个resolveConfig

在resolveConfig里面有resolvePlugins

在resolvePlugins里面有importAnalysisPlugin

就是在importAnalysisPlugin里面

通过字符串导入方式把createHotContext导入到了客户端

这里str是什么

这是来自一个外部的npm包

magic-string

假设您有一些源代码。您想要对其进行一些轻微的修改 - 在这里和那里替换一些字符,用页眉和页脚包装它等等 - 理想情况下您希望在它的末尾生成一个源映射。您考虑过使用 recast 之类的东西(它允许您从一些 JavaScript 生成 AST,对其进行操作,并使用 sourcemap 重新打印它而不会丢失您的注释和格式),但它似乎对您的需求(或者可能是源代码不是 JavaScript)。

能够在客户端代码里面注入,这样就将createHotContext注入到客户端中,并使用

在createHotContent中, 就存在setupWebSocket啦

在浏览器端建立socket核心代码如下:


    function setupWebSocket(
      protocol: string,
      hostAndPath: string,
      onCloseWithoutOpen?: () => void,
    ) {
      const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'socket-hmr')
      let isOpened = false

      socket.addEventListener(
        'open',
        () => {
          isOpened = true
        },
        { once: true },
      )

      // Listen for messages
      socket.addEventListener('message', async ({ data }) => {
        // handleMessage(JSON.parse(data))
        console.log(data)
      })

      // ping server
      socket.addEventListener('close', async ({ wasClean }) => {
        if (wasClean) return

        if (!isOpened && onCloseWithoutOpen) {
          onCloseWithoutOpen()
          return
        }

        console.log(`[socket] server connection lost. polling for restart...`)
        await waitForSuccessfulPing(protocol, hostAndPath)
        location.reload()
      })

      return socket
    }


    function warnFailedFetch(err: Error, path: string | string[]) {
      if (!err.message.match('fetch')) {
        console.error(err)
      }
      console.error(
        `[hmr] Failed to reload ${path}. ` +
          `This could be due to syntax errors or importing non-existent ` +
          `modules. (see errors above)`,
      )
    }


    async function waitForSuccessfulPing(
      socketProtocol: string,
      hostAndPath: string,
      ms = 1000,
    ) {
      const pingHostProtocol = socketProtocol === 'wss' ? 'https' : 'http'

      const ping = async () => {
        // A fetch on a websocket URL will return a successful promise with status 400,
        // but will reject a networking error.
        // When running on middleware mode, it returns status 426, and an cors error happens if mode is not no-cors
        try {
          await fetch(`${pingHostProtocol}://${hostAndPath}`, {
            mode: 'no-cors',
            headers: {
              // Custom headers won't be included in a request with no-cors so (ab)use one of the
              // safelisted headers to identify the ping request
              Accept: 'text/x-socket-ping',
            },
          })
          return true
        } catch {}
        return false
      }

      if (await ping()) {
        return
      }
      await wait(ms)

      // eslint-disable-next-line no-constant-condition
      while (true) {
        if (document.visibilityState === 'visible') {
          if (await ping()) {
            break
          }
          await wait(ms)
        } else {
          await waitForWindowShow()
        }
      }
    }

    function waitForWindowShow() {
      return new Promise<void>((resolve) => {
        const onChange = async () => {
          if (document.visibilityState === 'visible') {
            resolve()
            document.removeEventListener('visibilitychange', onChange)
          }
        }
        document.addEventListener('visibilitychange', onChange)
      })
    }


    function wait(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms))
    }

这样就建立了浏览器端与node端的通信啦

好了, 今天分享到这里就结束了, 源码分析解释还是篇幅太长, 所以我这里就只提到了dev命令.

vite的其他命令其实也是这样分析, 如果有什么疑问或者不好之处, 欢迎大家在评论区指出, 祝大家有个愉快的周末!