最近研究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. 以文本格式保存,可以用记事本编辑。
- 扩展名为.cmd。
- 支持Windows命令行命令,如dir、copy、del等,也支持if语法、for循环等逻辑控制语句。
- 需要管理员权限才能执行某些命令。
- 在文件开头指定编码格式,如@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类型有:
-
/bin/sh:指向系统默认的shell,通常是bash。
-
/bin/bash:直接指定bash shell来执行脚本。
-
/usr/bin/perl:使用perl来执行脚本。
-
/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可以:
- 与目标页面建立调试连接,随时启动或停止调试。
- 收集页面的事件、网络请求、控制台输出等信息。
- 设置断点和黑箱断点,调试运行中的JavaScript代码。
- 执行运行时命令,如:清理缓存、重新加载页面等。
- 分析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中打开"性能"面板,并开始记录项目的性能数据。
在面板中,你可以看到项目在运行时:
- CPU的使用占比。可以找出消耗CPU最多的文件/函数。
- 内存的增长曲线。查看内存泄露或非常耗内存的组件。
- 事件的触发情况。如鼠标点击、页面滚动等事件的频率。
- 帧率的变化。帧率过低会造成卡顿,需要优化。
- 页面加载与渲染的时间轴。分析页面加载流程与瓶颈。
- 网络请求的数量和耗时。
- 网络请求的数量和耗时。这些可以缩短加载时间以获得更快的体验。
/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的其他命令其实也是这样分析, 如果有什么疑问或者不好之处, 欢迎大家在评论区指出, 祝大家有个愉快的周末!