vite1.x 热更新(HMR)的实现原理

article/2025/10/7 13:02:02

前言

将近一年前自己尝试阅读vite源码(2.x),虽然也有些收获但整体并没有到达我的预期,对于vite也是停留在一知半解的程度上。最近想重新开始学习vite,但回顾之前的学习历程,感觉不太想继续之前的方式,自己的水平有限,读起来太费劲,经常在不同的函数调用间迷失自己,最后草草收场。想起之前看文章很多人是看代码的最初实现版本的,于是也想尝试一下,选择阅读vite的最初版本分支1.x,效果是明显比之前好的,后续我觉得再阅读最新版本的代码的话是有很大帮助的。

阅读过程中发现关于热更新(HMR)这块逻辑略微复杂,想着记录下来,避免之后忘记。

HMR

之前对于HMR的了解大概是:webpack/vite会在启动后开启websocket服务用于浏览器端和服务端之间的通信,每当我们修改代码后服务端就会发送消息给浏览器端,浏览器端进行更新,对于具体过程是不太了解的

阅读分支

vite-v1

下文中的vite如无特殊说明均指的v1版本

前置了解

vite开发模式下会启动一个server以供开发者访问调试,具体实现中是启动了一个Koa服务,vite对被访问文件的处理都是以插件的形式进行的,HMR相关的主要有以下几个文件

vite
├─ src
│  ├─ client
│  │  ├─ client.ts   
│  ├─ hmrPayload.ts
│  └─ node
│     ├─ server
│     │  ├─ index.ts
│     │  ├─ serverPluginClient.ts
│     │  ├─ serverPluginCss.ts
│     │  ├─ serverPluginHmr.ts
│     │  ├─ serverPluginHtml.ts
│     │  ├─ serverPluginModuleRewrite.ts
│     │  ├─ serverPluginVue.ts

在这里插入图片描述

整体流程

首先按照vite官网命令起一个demo,npm run dev之后打开开发者工具,可以看到请求的大概过程是:

在这里插入图片描述

浏览器端

入口文件index.html的处理

第一个请求是访问index.html的,与源文件不同的是这里多了一行代码,浏览器就会请求client.js

<script type="module" src="/@vite/client"></script>

这个处理是有htmlRewritePlugin插件完成的,代码如下(不过vite-v1中不是以src的方式引入的,而是import /vite/client)

export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"
</script>\n`
...
injectScriptToHtml(html, devInjectionCode)
...

client.js

对于/vite/client的访问 是由clientPlugin插件处理的,主要是读取client/client.js文件并进行一些初始化后返回,具体实现如下:

// src/node/server/serverPluginClient.ts 
export const clientFilePath = path.resolve(__dirname, '../../client/client.js')
export const clientPublicPath = `/vite/client`
export const clientPlugin: ServerPlugin = ({ app, config }) => {const clientCode = fs.readFileSync(clientFilePath, 'utf-8').replace(`__MODE__`, JSON.stringify(config.mode || 'development'))app.use(async (ctx, next) => {if (ctx.path === clientPublicPath) {// ...ctx.type = 'js'ctx.status = 200ctx.body = clientCode}})
}

client.js中主要做了以下三件事

  1. 启动websocket建立与服务端之前的连接
  2. 接受websocket信息并进行相应处理(处理细节在后面)
  3. 暴露出一个HMR Context,以供其他模块(文件)调用
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')socket.addEventListener('message', async ({ data }) => {const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayloadif (payload.type === 'multi') {payload.updates.forEach(handleMessage)} else {handleMessage(payload)}
})export const createHotContext = (id: string) => {...const hot = {accept(callback: HotCallback['fn'] = () => {}) {hot.acceptDeps(id, callback)},acceptDeps(deps: HotCallback['deps'],callback: HotCallback['fn'] = () => {}) {...},...}return hot
}

其他模块(文件)HMR能力的注入

随便打开几个文件可以发现在某些文件中是由HMR相关代码注入的,比如App.vue
在这里插入图片描述

可以看到除了业务代码外在最开始引入了client.js并创建了一个App.vue的HMR模块,在结束的地方调用了一些HMR的方法,有了这些就可以完成App.vue的热更新了

服务端

服务端的处理主要是hmrPlugin moduleRewritePlugin插件和一些专门处理某类文件HMR的cssPlugin vuePlugin插件完成的。

hmrPlugin主要做了以下几件事

  1. 启动服务端的websocket
  2. 每当文件有变化的时候会向浏览器端发送信息
import chokidar from 'chokidar'  // `chokidar`是用来监听文件变化的
const watcher = chokidar.watch(root, {ignored: ['**/node_modules/**', '**/.git/**'],ignoreInitial: true,...chokidarWatchOptions
}) as HMRWatcher
const wss = new WebSocket.Server({ noServer: true })
watcher.on('change', (file) => {if (!(file.endsWith('.vue') || isCSSRequest(file))) {//  vue文件和plain css文件在serverPluginVue 和 serverPluginCss文件中处理handleJSReload(file)}
})// 这里把send方法直接放到watcher实例上了,便于有文件变化的话可以直接send消息
const send = (watcher.send = (payload: HMRPayload) => {wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(stringified)}})
})

cssPluginvuePlugin是分别用来处理.css文件和.vue文件的,里面包含了HMR相关的部分,比如,App.vue最下方的HMR逻辑的注入就是从vuePlugin写入的(这里其实我没找到import.meta.hot.accept相关的逻辑,只有hmrId注入,但在最新版plugin-vue插件中找到了相关逻辑,这里我就认为是在vue插件中注入的了)

// src/node/server/serverPluginVue.ts
...code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`code += `\ntypeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)`code += `\n__script.__file = ${JSON.stringify(filePath)}`code += `\nexport default __script`
...
// https://github.com/vitejs/vite/blob/7a6d4bc0d7fa614d3ac469ca35352a23aaef8232/packages/plugin-vue/src/main.ts#L115
// HMR
if (devServer &&devServer.config.server.hmr !== false &&!ssr &&!isProduction
) {...output.push(`import.meta.hot.accept(mod => {`,`  if (!mod) return`,`  const { default: updated, _rerender_only } = mod`,`  if (_rerender_only) {`,`    __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,`  } else {`,`    __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,`  }`,`})`)
}

moduleRewritePlugin主要并不是来处理HMR的,只是在对请求的模块(文件)进行重写处理的过程中进行了文件依赖关系的分析和HMR逻辑的相关重写,支撑了HMR功能。App.vue模块上方HMR Context的注入就是在此插件中完成的。

const hasHMR = source.includes('import.meta.hot')
if (hasHMR) {rewriteFileWithHMR(root, source, importer, resolver, s)
}function rewriteFileWithHMR() {...s.prepend(`import { createHotContext } from "${clientPublicPath}"; ` +`import.meta.hot = createHotContext(${JSON.stringify(importer)}); `)...
}

具体流程

通过以上插件的执行,浏览器端和服务端通信的websocket就有了,各个文件要进行HMR的预先处理也完成了,只要文件发生变化,服务端就通知客户端进行热更新,那么文件变化=>进行热更新的具体流程是啥呢?

前置了解-HMR设计的思路

hmrPlugin中对于HMR的设计思路进行了注释(如下图)
在这里插入图片描述

大意是指对文件进行HMR graph analysis, 只要是走到dead end,就发送full page reload,否则找到相应的hmr boundary(指的是本次hmr受到影响的文件),把要进行hmr的所有模块hmr Boundaries都发送给浏览器端。对应的代码实现就是上面提到的hmrPlugin里的handleJSReload(file)法,这里留个大概印象就行,先不用关心具体细节比如是dead end,什么是hmr boundary,下面都有涉及

初次访问某个文件

moduleRewritePlugin是最后执行的koa插件,接收到的文件已经全部被处理为了js文件,moduleRewritePlugin的主要作用就是

  1. 路径的重写,比如把对某些第三方包的请求路径改为预购建后的路径
  2. 记录模块之前的依赖关系
  3. 加入HMR逻辑并track HMR boundary accept whitelists(这一段不知道该怎么翻译)

这里的2、3都是使用es-module-lexer将文件转化为ast然后再进行解析得到的。
其中模块之间的依赖关系被放在了以下两个变量中

// moduleRewritePlugin / function rewriteImports
export const importerMap: HMRStateMap = new Map()
export const importeeMap: HMRStateMap = new Map() 
// 例如,a模块有有一句`import {x} from 'b'`
// 那么importeeMap里就会加一条  {key: a, value:['b']}
// importerMap里就会加一条{ key: b, value: ['a']},两者的key 、value是相反的关系
// 每个文件都这么记录下来就能获取到所有文件的依赖关系

加入HMR逻辑是指上面提到的检测到vuePlugin注入了一段import.meta.hot.accept后在文件头部注入的HMR Context,track HMR boundary accept whitelists(这一段不知道该怎么翻译)是指下面这两个变量

// fucntion rewriteFileWithHMR
export const hmrAcceptanceMap: HMRStateMap = new Map() 
export const hmrDeclineSet = new Set<string>() // 当发现文件中有调用Hmr方法,比如 import.hot.meta.accept 或者其他方法时,就会开始记录
// 比如 a 模块中有  import.meta.hot.accept(['./b','./c'],callback)
// hmrAcceptanceMap里就会加一个  {key: a, value: ['./b', './c']}
// 这里简单说下accept是单个模块,如`import.meta.hot.accept('./foo', () => {})`
// `import.meta.hot.accept() OR import.meta.hot.accept(() => {})`会把当前模块加进去
// accepts是接受多个模块
// decline的话会加到hmrDeclineSet里

有了以上这些变量后就能够支持HMR graph analysis

监听文件变化

// src/node/server/serverPluginHmr.tswatcher.on('change', (file) => {if (!(file.endsWith('.vue') || isCSSRequest(file))) {// everything except plain .css are considered HMR dependencies.// plain css has its own HMR logic in ./serverPluginCss.ts.handleJSReload(file)}})

可以看到当监听到文件变化后,会有两大类的处理

  1. 一般性的处理 ,执行handleJSReload
  2. 特殊处理,对于.vue .css文件需要在其对应的插件中处理

这里我们先看对一般性文件handleJSReload的处理

HMR graph analysis

这里的实现也就对应着上面提到的HMR设计思路

fn handleJSReload里表明 HMR graph analysis分析的结果就两种,要么是dead end,发送

send({ type: 'full-reload', path: publicPath })

要么就是把找到的多个hmr boundary发送出去

fn walkImportChain是来判断到底是dead end还是存在hmr boundary的,

  1. 当前文件调用了import.meta.hot.decline(),那么一定是dead end
  2. 当前文件自己就存在自己的hmrAcceptanceMap里, 那么自己就是hmr boundary
  3. 如果(被当前文件使用的文件)是.vue文件或者(被当前文件使用的文件)的hmrAcceptanceMap里包括当前文件或者自己,那么被(当前文件使用的文件)就是hmr boundary
  4. 如果非以上情况,那么就递归的判断 被(被当前文件使用的文件)使用的文件,一直往上,直到结束
//  在importer 的hmrAcceptanceMap里
function isHmrAccepted(importer: string, dep: string): boolean {const deps = hmrAcceptanceMap.get(importer)return deps ? deps.has(dep) : false
}
const handleJSReload = (watcher.handleJSReload = (filePath: string,timestamp: number = Date.now()
) => {const publicPath = resolver.fileToRequest(filePath)const importers = importerMap.get(publicPath) //  获取被publicPath使用的模块if (importers || isHmrAccepted(publicPath, publicPath)) {const hmrBoundaries = new Set<string>()const dirtyFiles = new Set<string>() //  记录被影响了的文件dirtyFiles.add(publicPath)const hasDeadEnd = walkImportChain(publicPath,importers || new Set(),hmrBoundaries,dirtyFiles)// record dirty files - this is used when HMR requests coming in with// timestamp to determine what files need to be force re-fetchedhmrDirtyFilesMap.set(String(timestamp), dirtyFiles)const relativeFile = '/' + slash(path.relative(root, filePath))if (hasDeadEnd) {send({type: 'full-reload',path: publicPath})console.log(chalk.green(`[vite] `) + `page reloaded.`)} else {const boundaries = [...hmrBoundaries]send({type: 'multi',updates: boundaries.map((boundary) => {return {type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',path: boundary,changeSrcPath: publicPath,timestamp}})})}} else {debugHmr(`no importers for ${publicPath}.`)}
})
function walkImportChain(importee: string,importers: Set<string>,hmrBoundaries: Set<string>,dirtyFiles: Set<string>,currentChain: string[] = []
): boolean {if (hmrDeclineSet.has(importee)) {// 文件调用了import.meta.hot.declinereturn true}if (isHmrAccepted(importee, importee)) {//  自己就自己的hmrAcceptanceMap里的话,直接返回了hmrBoundaries.add(importee)dirtyFiles.add(importee)return false}for (const importer of importers) {if (importer.endsWith('.vue') ||// explicitly accepted by this importerisHmrAccepted(importer, importee) ||// importer is a self accepting moduleisHmrAccepted(importer, importer)) {// vue boundaries are considered dirty for the reloadif (importer.endsWith('.vue')) {dirtyFiles.add(importer)}hmrBoundaries.add(importer)currentChain.forEach((file) => dirtyFiles.add(file))} else {const parentImpoters = importerMap.get(importer) //  获取被importer(被当前importee使用的模块)使用的模块if (!parentImpoters) {//  dead endreturn true} else if (!currentChain.includes(importer)) {if (walkImportChain(importer,parentImpoters,hmrBoundaries,dirtyFiles,currentChain.concat(importer))) {return true}}}}return false
}

消息类型

send的消息类型定义在 src/hmrPayload.ts里,针对每种type,浏览器端都会有不同的相应,在src/client/client.ts

export type HMRPayload =| ConnectedPayload| UpdatePayload| FullReloadPayload| StyleRemovePayload| SWBustCachePayload| CustomPayload| MultiUpdatePayloadinterface ConnectedPayload {type: 'connected'
}export interface UpdatePayload {type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'path: stringchangeSrcPath: stringtimestamp: number
}interface StyleRemovePayload {type: 'style-remove'path: stringid: string
}interface FullReloadPayload {type: 'full-reload'path: string
}interface SWBustCachePayload {type: 'sw-bust-cache'path: string
}interface CustomPayload {type: 'custom'id: stringcustomData: any
}export interface MultiUpdatePayload {type: 'multi'updates: UpdatePayload[]
}

浏览器端响应

clients.js除了前面描述的一些功能外,还定义了一些变量和方法用于处理HMR相关的逻辑

const hotModulesMap = new Map<string, HotModule>() // 记录HMR模块相关的信息

初次访问

比如App.vue中增加了如下的HMR逻辑

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.vue"); // 注册一个HMR模块
_sfc_main.__hmrId = "7a7a37b1";
import.meta.hot.accept((mod)=>{if (!mod)return;const {default: updated, _rerender_only} = mod;if (_rerender_only) {__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);} else {__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);}
}
);

import.meta.hot.accept执行后,hotModulesMap里会增加一条记录

key: '/src/App.vue'
values:  {id: '/src/App.vue',callbacks: [{deps: '/src/App.vue',fn: callback},{deps: 'xxxxxx/xxxx.vue',fn: callback}]
}

响应HMR

服务端发送的消息类型有很多,每种类型都有对应的方法,比如full-reload会触发页面刷新等等,这里我们主要看下js-update的时候

switch (payload.type) {...case 'vue-rerender':const templatePath = `${path}?type=template`import(`${templatePath}&t=${timestamp}`).then((m) => {__VUE_HMR_RUNTIME__.rerender(path, m.render)console.log(`[vite] ${path} template updated.`)})breakcase 'style-remove':removeStyle(payload.id)breakcase 'js-update':queueUpdate(updateModule(path, changeSrcPath, timestamp))breakcase 'full-reload':if (path.endsWith('.html')) {const pagePath = location.pathnameif (pagePath === path ||(pagePath.endsWith('/') && pagePath + 'index.html' === path)) {location.reload()}return} else {location.reload()}
}

服务端把hmr有变化的文件目录都发送了过来,fn updateModule里就是把hotModulesMap里这些文件里注册的所有callback(deps,callback )都拿出来,并重新请求各个depsimport deps, fn queueUpdate就是在这么deps重新加载后执行之前对应的callback,到这里一次HMR就完成了

总结

HMR消息类型有多种,以下是多个hmr boundary 类型为js-update时的一次更新流程图
在这里插入图片描述

End

  1. 本次源码阅读的结论主要是通过阅读源码和百度一些资料得到的,没有经过断点一步一步调试,所以可能会存在理解有偏差的地方,有任何问题都欢迎大家一起讨论
  2. 阅读过程中建了一个分支,有随手加的一些注释,有需要可以看下
  3. 自己阅读源码的记录会统一放在这里,包括single-spa rollup qiankun ...
  4. 感谢阅读!

http://chatgpt.dhexx.cn/article/GyQFvUAh.shtml

相关文章

Webpack HMR 原理解析

Hot Module Replacement&#xff08;以下简称 HMR&#xff09;是 webpack 发展至今引入的最令人兴奋的特性之一 &#xff0c;当你对代码进行修改并保存后&#xff0c;webpack 将对代码重新打包&#xff0c;并将新的模块发送到浏览器端&#xff0c;浏览器通过新的模块替换老的模…

Webpack的HMR原理解析

Hot Module Replacement&#xff08;以下简称 HMR&#xff09;是 webpack 发展至今引入的最令人兴奋的特性之一 &#xff0c;当你对代码进行修改并保存后&#xff0c;webpack 将对代码重新打包&#xff0c;并将新的模块发送到浏览器端&#xff0c;浏览器通过新的模块替换老的模…

Esbuild Bundler HMR

Esbuild 虽然 bundler 非常快&#xff0c;但是其没有提供 HMR 的能力&#xff0c;在开发过程中只能采用 live-reload 的方案&#xff0c;一有代码改动&#xff0c;页面就需要全量 reload &#xff0c;这极大降低开发体验。为此添加 HMR 功能至关重要。 经过调研&#xff0c;社…

Vite HMR

传统webpack的hmr是使用webpack的HotModuleReplacementPlugin&#xff0c;而vite则是采用native ES Module的devServer。 初始化本地服务器加载并运行对应的plugin 最重要的一件事就是运行plugin&#xff0c;目前vite支持的plugin大体如下图所示 1、建立ViteDevServer服务器…

webpack HMR

HMR或者hot模式下&#xff0c;启动webpack会在浏览器与服务器之间会建立一个websocket连接&#xff0c;使得浏览器可以和服务端建立全双工通信&#xff1b;当应用程序的代码更新时&#xff0c;会要求HMR runtime检查更新&#xff0c;有更新时&#xff0c;在websoket连接中会返回…

webpack中的HMR(热更新)原理剖析

简介 Hot Module Replacement&#xff08;以下简称 HMR&#xff09;是 webpack 发展至今引入的最令人兴奋的特性之一 &#xff0c;当你对代码进行修改并保存后&#xff0c;webpack 将对代码重新打包&#xff0c;并将新的模块发送到浏览器端&#xff0c;浏览器通过新的模块替换…

HMR(热替换)

HMR 即模块热替换&#xff08;hot module replacement&#xff09;的简称&#xff0c;它可以在应用运行的时候&#xff0c;不需要刷新页面&#xff0c;就可以直接替换、增删模块。webpack 可以通过配置 webpack.HotModuleReplacementPlugin 插件来开启全局的 HMR 能力&#xff…

面试官:说说Webpack的热更新是如何做到的?原理是什么?

一、是什么 HMR全称 Hot Module Replacement&#xff0c;可以理解为模块热替换&#xff0c;指在应用程序运行过程中&#xff0c;替换、添加、删除模块&#xff0c;而无需重新刷新整个应用 例如&#xff0c;我们在应用运行过程中修改了某个模块&#xff0c;通过自动刷新会导致整…

Webpack 热更新HMR 原理全解析

一、什么是 HMR HMR 全称 Hot Module Replacement&#xff0c;中文语境通常翻译为模块热更新&#xff0c;它能够在保持页面状态的情况下动态替换资源模块&#xff0c;提供丝滑顺畅的 Web 页面开发体验。 HMR 最初由 Webpack 设计实现&#xff0c;至今已几乎成为现代工程化工具…

curl.perform() pycurl.error: (23, 'Failed writing body (0 != 59)')

在使用python3.7编码时&#xff0c;引入pycurl模块和StringIO模块后&#xff0c;容易引起上述错误 导入StringIO模块的解决方案&#xff1a; 只有在python2中才能导入StringIO模块&#xff0c;直接 from StringIO import StringIO 即可 但是python3&#xff0c;STringIO和…

关于python的url处理

基本环境&#xff1a; python2.7 1 完整的url语法格式&#xff1a; 协议://用户名密码:子域名.域名.顶级域名:端口号/目录/文件名.文件后缀?参数值#标识 2 urlparse模块对url的处理方法 urlparse模块对url的主要处理方法有&#xff1a;urljoin/urlsplit/urlunsplit/urlpar…

windows10+python3.7使用pip安装pycurl失败

使用pip install pycurl安装pycurl失败&#xff1a; python setup.py egg_info did not run successfully. 可以单独下载pycurl依赖文件然后安装 sArchived: Python Extension Packages for Windows - Christoph Gohlke (uci.edu) 选择Python对应版本的文件进行下载&#xff0…

Pycurl介绍

pycurl — A Python interface to the cURL library Pycurl包是一个libcurl的Python接口.pycurl已经成功的在Python2.2到Python2.5版编译测试过了. Libcurl是一个支持FTP, FTPS, HTTP, HTTPS, GOPHER, TELNET, DICT, FILE 和 LDAP的客户端URL传输库.libcurl也支持HTTPS认证,H…

[windows]python 安装pycurl

问题描述 pip install pycurl 报错 手动安装 下载地址&#xff1a;https://www.lfd.uci.edu/~gohlke/pythonlibs/ 页面搜索&#xff1a; pycurl 下载对应版本的whl文件&#xff0c;我是windows环境 python3.8 所以下载pycurl-7.45.1-cp38-cp38-win32.whl 安装&#xff1a;…

Python实用模块之pycurl

软硬件环境 ubuntu 19.04 64bitanaconda3 with python 3.7.3pycurl 7.43.0.2 简介 CURL是一个基于URL进行数据传输的命令行工具&#xff0c;使用C语言编写&#xff0c;支持http&#xff0c;https&#xff0c;ftp&#xff0c;telnet&#xff0c;file&#xff0c;ldap等常见网络传…

ipcs -a

消息队列、共享内存、信号量

ipcc

IPCCX装完后连接不了LDAP,怎么解决&#xff1f;&#xff1f; 装完了IPCCX, 靠&#xff0c; 直接给我来歌60秒关机&#xff0c; 后面还有LDAP连接问题&#xff0c; 我的IPCCX server 可以ping通ccm server, 为什么LDAP会挂呢&#xff1f;&#xff1f;&#xff1f; 请问我现在要怎…

ipcs报错:kernel not configured for shared memory、semaphore、message queues [解决方法]

前言 今天在复习linux进程间通信的shared memory 共享内存时&#xff0c;在PC端的VMare Workstation虚拟机的Ubuntu上测试我写的shared_memory_CREAT.c 和shared_memory_CONSUME.c 时正常在PC端运行&#xff0c;就想着把程序用交叉编译器编译成arm格式放到linux开发板上运行试…

ipcs

&#xfeff;&#xfeff; linux命令-ipcs 格式&#xff1a;ipcs [-asmq] [-tclup] ipcs [-smq] -i id ipcs -h 功能描述&#xff1a;ipcs命令用来显示系统存在的ipc&#xff08;进程间通信&#xff09;相关信息。 参数&#xff1a;-i 显示指定id的ip…

IPC方案

近期了解了不少网络摄像头相关知识&#xff0c;主要功能组成如下&#xff1a; WIFI&#xff0c;USB接口或者SDIO接口实现 RJ45 本地TF存储 IR CUT&#xff0c;滤光片切换 移动侦测&#xff0c;人体感应 夜视功能&#xff0c;依靠红外灯 云台控制&#xff0c;PWM控制Moto&#…