Electron 实战:utilityProcess 服务脚本热更新、用户目录优先启动与 asar 依赖解析

Electron 实战:utilityProcess 服务脚本热更新、用户目录优先启动与 asar 依赖解析

在 Electron 里用 utilityProcess 承载本地服务时,很多 demo 只演示了"开发环境能跑起来"。但真正进入生产包后,会马上遇到几个更现实的问题:

  • utilityProcess.fork() 启动的必须是一个真实可访问的 JS 文件。
  • 服务脚本可能需要热更新,不能每次都等整个 Electron 主包升级。
  • 用户目录里的热更新脚本要能复用当前安装包里的 node_modules
  • 打包后依赖通常在 resources/app.asar/node_modules 里,不是普通展开目录。
  • 出问题时必须有日志定位到底是找文件失败、依赖解析失败,还是管道服务没监听起来。

本文基于一个真实 demo 的改造过程,完整记录如何实现:

  • 主进程启动时优先检测用户目录下的 utility-server.js
  • 用户目录有文件就从用户目录启动,实现服务脚本热更新
  • 用户目录没有文件就回退到安装包内的 app.asar 版本
  • utilityProcess 内的 Express 服务通过 Windows named pipe 对渲染进程提供能力
  • 用户目录脚本复用当前安装包 app.asar/node_modules 下的依赖
  • 通过日志和健康检查定位生产包问题

一、目标效果

最终我们希望启动链路是这样的:

text 复制代码
Electron main process
  |
  | 1. 检查用户目录是否存在热更新脚本
  v
%APPDATA%/electron-demo/utility-process/utility-server.js
  |
  | 存在:直接 utilityProcess.fork(user script)
  | 不存在:utilityProcess.fork(app.asar bundled script)
  v
utilityProcess
  |
  | require('express') from current app.asar/node_modules
  v
Express server listen on named pipe
  |
  v
Renderer localRequest -> named pipe -> utilityProcess Express

Windows 下用户目录大概是:

text 复制代码
C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js

安装包内 fallback 脚本大概是:

text 复制代码
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\dist\electron\server\utility-server.js

依赖路径大概是:

text 复制代码
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules

这样做以后,热更新只需要把新的 utility-server.js 放到用户目录。下次应用启动时主进程会优先使用用户目录版本。如果用户目录没有热更新文件,仍然使用包内版本兜底。

二、为什么不直接把 node_modules 也复制到用户目录

这个点很关键。

很多人第一反应是:既然服务脚本放到用户目录,那依赖也复制一份到用户目录。这个方案短期能跑,但生产上不划算:

  • 依赖体积大,复制和清理成本高。
  • 依赖版本和当前安装包容易不一致。
  • 多次热更新后用户目录会沉淀大量不可控文件。
  • 原本由安装包管理的依赖变成了用户目录散落文件,排查问题更困难。
  • 如果包含 native .node 依赖,还会涉及 ABI、平台和解压路径问题。

更稳的思路是:

  • 用户目录只放"可热更新的业务脚本"
  • 依赖仍然由当前安装包提供
  • 当前安装包是什么版本,脚本就使用这个版本的依赖

也就是说,热更新脚本不是独立运行时,它是"外挂到当前 Electron 安装包运行时上的服务入口"。

三、主进程:选择启动脚本

主进程里保留一个包内脚本路径解析函数:

js 复制代码
function resolveBundledUtilityServerPath() {
  const candidates = [
    path.resolve(__dirname, UTILITY_SERVER_BUNDLE),
    path.resolve(__dirname, '..', UTILITY_SERVER_BUNDLE),
    path.resolve(app.getAppPath(), 'dist', 'electron', UTILITY_SERVER_BUNDLE),
    path.resolve(app.getAppPath(), UTILITY_SERVER_BUNDLE)
  ]

  const servicePath = candidates.find(candidate => fs.existsSync(candidate))

  if (!servicePath) {
    throw new Error(`utility server bundle not found. Tried: ${candidates.join(', ')}`)
  }

  return servicePath
}

再加一个用户目录路径:

js 复制代码
function resolveUserInstallUtilityServerPath() {
  return path.join(app.getPath('userData'), 'utility-process', 'utility-server.js')
}

真正启动前做选择:

js 复制代码
function resolveUtilityServerStartPath(bundledServicePath) {
  const userServicePath = resolveUserInstallUtilityServerPath()

  if (fs.existsSync(userServicePath)) {
    writeUtilityBootstrapLog(`user utility server exists, using user file: ${userServicePath}`)
    return userServicePath
  }

  writeUtilityBootstrapLog(`user utility server missing, using bundled file: ${bundledServicePath}`)
  return bundledServicePath
}

这里没有"自动复制"概念。用户目录存在脚本就用用户目录,不存在就用包内版本。这更接近生产热更新模型:热更新系统负责投放文件,主程序只负责选择和启动。

四、主进程:给 utilityProcess 注入依赖路径

启动 utilityProcess 前,需要找到当前环境的 node_modules

js 复制代码
function resolveUtilityServerNodePath() {
  const candidates = [
    path.resolve(app.getAppPath(), 'node_modules'),
    path.resolve(process.resourcesPath || '', 'app.asar', 'node_modules'),
    path.resolve(process.resourcesPath || '', 'app', 'node_modules'),
    path.resolve(__dirname, '..', '..', 'node_modules'),
    path.resolve(__dirname, '..', 'node_modules')
  ]

  return candidates.find(candidate => candidate && fs.existsSync(candidate))
}

生产包里通常命中:

text 复制代码
resources/app.asar/node_modules

开发环境可能命中项目根目录的:

text 复制代码
node_modules

然后启动:

js 复制代码
const bundledServicePath = resolveBundledUtilityServerPath()
const servicePath = resolveUtilityServerStartPath(bundledServicePath)
const nodePath = resolveUtilityServerNodePath()

if (!nodePath) {
  throw new Error(`utility server node_modules path not found. appPath=${app.getAppPath()}, resourcesPath=${process.resourcesPath}, dirname=${__dirname}`)
}

protocolServiceProcess = utilityProcess.fork(servicePath, [PIPE_NAME], {
  cwd: path.dirname(servicePath),
  env: {
    ...process.env,
    UTILITY_SERVER_NODE_PATH: nodePath
  },
  stdio: 'pipe'
})

这里不要只依赖 NODE_PATH

实际排查时发现,utilityProcess.fork() 时直接在 env 里传 NODE_PATH,对这个场景并不可靠。用户目录下的脚本启动后,require('express') 仍然可能找不到 app.asar/node_modules 里的依赖。

更稳的做法是传一个业务自定义变量,例如:

js 复制代码
env: {
  ...process.env,
  UTILITY_SERVER_NODE_PATH: nodePath
}

也就是说,主进程只告诉服务脚本"当前安装包的依赖目录在哪里",不要指望 fork 时传入的 NODE_PATH 自动完成模块解析。真正让依赖搜索路径生效的动作,放到 utility-server.js 自己启动时完成。

五、服务脚本:启动前初始化模块搜索路径

utility-server.js 顶部要放这段逻辑,而且必须放在 require('express') 之前:

js 复制代码
'use strict'

const Module = require('module')
const path = require('path')

const utilityServerNodePath = process.env.UTILITY_SERVER_NODE_PATH

if (utilityServerNodePath) {
  process.env.NODE_PATH = utilityServerNodePath
  Module._initPaths()
}

const express = require('express')
const http = require('http')

这两行是整个方案里最关键的地方:

js 复制代码
process.env.NODE_PATH = utilityServerNodePath
Module._initPaths()

第一行:

js 复制代码
process.env.NODE_PATH = utilityServerNodePath

意思是把当前进程的 NODE_PATH 环境变量改成安装包里的依赖目录,比如:

text 复制代码
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules

但只改 process.env.NODE_PATH 还不够。Node 在进程启动时已经初始化过模块搜索路径了,后面再改环境变量,不会自动刷新 require() 的搜索路径。

所以还需要第二行:

js 复制代码
Module._initPaths()

它的作用是让 Node 的 CommonJS 模块系统重新根据当前的 process.env.NODE_PATH 初始化全局模块搜索路径。执行完之后,后面的:

js 复制代码
require('express')

才会去 app.asar/node_modules 里找依赖。

需要注意,Module._initPaths() 是 Node 的内部 API,不是公开类型定义里的稳定方法,所以 IDE 可能会提示 _initPaths 不存在。运行时它存在,Electron/Node 的 CommonJS loader 也会用到这套机制。这里使用它,是为了在进程启动后手动刷新模块搜索路径。

顺序非常重要。

错误顺序是:

js 复制代码
const express = require('express')

// 后面才设置 NODE_PATH

这样还没来得及把 app.asar/node_modules 加进搜索路径,就已经开始解析 express 了,必然可能报:

text 复制代码
Error: Cannot find module 'express'
Require stack:
- C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js

正确做法是:

  1. 读取 UTILITY_SERVER_NODE_PATH
  2. 写回 process.env.NODE_PATH
  3. 执行 Module._initPaths()
  4. require('express')

六、asar 里的 node_modules 到底是什么

生产包里看到的路径可能是:

text 复制代码
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules

这不是一个普通解压目录。

app.asar 是 Electron 支持的归档文件。它在文件系统上是一个文件,但 Electron 的 Node 运行时对 asar 路径做了特殊处理,所以下面这种路径可以被 require() 使用:

text 复制代码
resources/app.asar/node_modules/express

但你不能把它当普通目录来理解。它不是:

text 复制代码
resources/app.asar.unpacked/node_modules

app.asar.unpacked 是另一种机制。只有配置了 asarUnpack,或者 electron-builder 判断某些 native 模块必须解包时,文件才会出现在:

text 复制代码
resources/app.asar.unpacked

纯 JS 依赖,比如 express,通常可以直接留在 app.asar/node_modules 里,由 Electron 的 asar 支持完成读取。

什么时候必须用 app.asar.unpacked

下面这些情况更适合放到 app.asar.unpacked

  • native .node 模块
  • 需要被第三方可执行文件直接读取的文件
  • 依赖要求真实文件系统路径,不能接受 asar 虚拟路径
  • 要执行的二进制文件
  • 某些需要动态扫描目录结构的库

expressbody-parserqs 这类纯 JS 依赖,一般不需要解包。

七、named pipe 服务与健康检查

服务内部仍然是一个 Express 应用,只是不监听 TCP 端口,而是监听 Windows named pipe:

js 复制代码
const pipeName = process.argv[2] || '\\\\.\\pipe\\wadesk-protocol-bridge'
const app = express()

app.use(express.json({ limit: '1mb' }))

app.get('/health', (req, res) => {
  res.json({
    ok: true,
    pid: process.pid,
    pipeName,
    startedFrom: __filename,
    cwd: process.cwd(),
    utilityServerNodePath: process.env.UTILITY_SERVER_NODE_PATH || ''
  })
})

const server = http.createServer(app)

server.listen(pipeName, () => {
  reportStatus({
    type: 'server-ready',
    pipeName,
    pid: process.pid
  })
})

健康检查返回的几个字段很有用:

  • startedFrom:证明当前服务脚本到底从用户目录启动,还是从包内启动
  • cwd:证明 utilityProcess.fork() 的工作目录
  • utilityServerNodePath:证明主进程注入的依赖路径

如果用户目录有热更新脚本,startedFrom 应该类似:

text 复制代码
C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js

如果用户目录没有热更新脚本,startedFrom 应该类似:

text 复制代码
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\dist\electron\server\utility-server.js

八、渲染进程:通过管道请求服务

渲染进程通过 axios 的 Node http adapter 访问 named pipe:

js 复制代码
const axios = require('axios')

const PIPE_NAME = '\\\\.\\pipe\\wadesk-protocol-bridge'

const wadeskPipe = axios.create({
  baseURL: 'http://_',
  adapter: 'http',
  socketPath: PIPE_NAME,
  headers: { 'Content-Type': 'application/json' }
})

封装请求时要注意:GET 请求不要发送 data: null

曾经遇到过这个错误:

text 复制代码
SyntaxError: Unexpected token 'n', "null" is not valid JSON

原因是渲染进程请求 GET /health 时,axios 仍然发送了 body:null。Express 的 express.json() 严格模式会尝试解析请求体,结果把 "null" 当成非法 JSON body 处理。

修复方式是:只有 data 真正存在时才放进 axios 请求配置。

js 复制代码
async function localRequest(options) {
  const requestOptions = normalizeLocalRequestOptions(options)
  const axiosOptions = {
    method: requestOptions.method,
    url: requestOptions.path,
    headers: requestOptions.headers
  }

  if (requestOptions.data !== null && requestOptions.data !== undefined) {
    axiosOptions.data = requestOptions.data
  }

  const response = await wadeskPipe.request(axiosOptions)

  return response.data
}

页面上可以加一个测试按钮:

js 复制代码
async checkUtilityHealth() {
  this.startLoading('utility health')

  try {
    this.latestResult = await localRequest({
      method: 'GET',
      path: '/health'
    })
  } catch (error) {
    this.latestResult = { ok: false, message: error.message }
  } finally {
    this.stopLoading()
  }
}

这个按钮是生产排查里非常有价值的自检入口。

九、启动日志:没有日志就没有真相

这次排查里最有效的手段,是在主进程启动链路里写文件日志。

日志路径:

text 复制代码
C:\Users\senzo\AppData\Roaming\electron-demo\logs\utility-bootstrap.log

启动时记录:

js 复制代码
function initUtilityBootstrapLog() {
  const logDir = path.join(app.getPath('userData'), 'logs')
  utilityBootstrapLogPath = path.join(logDir, 'utility-bootstrap.log')

  fs.mkdirSync(logDir, { recursive: true })
  fs.writeFileSync(utilityBootstrapLogPath, `[${new Date().toISOString()}] utility bootstrap log start\n`, 'utf8')
  writeUtilityBootstrapLog(`appPath=${app.getAppPath()}`)
  writeUtilityBootstrapLog(`userData=${app.getPath('userData')}`)
  writeUtilityBootstrapLog(`resourcesPath=${process.resourcesPath || ''}`)
  writeUtilityBootstrapLog(`execPath=${process.execPath}`)
  writeUtilityBootstrapLog(`__dirname=${__dirname}`)
}

关键节点记录:

js 复制代码
writeUtilityBootstrapLog('ensureProtocolService start')
writeUtilityBootstrapLog(`bundled service path: ${bundledServicePath}`)
writeUtilityBootstrapLog(`selected service path: ${servicePath}`)
writeUtilityBootstrapLog(`resolved nodePath: ${nodePath || '<not found>'}`)
writeUtilityBootstrapLog(`fork utility process: ${servicePath}`)
writeUtilityBootstrapLog('utilityProcess.fork returned process object')

子进程输出也要记录:

js 复制代码
protocolServiceProcess.stderr.on('data', data => {
  const text = data.toString().trim()
  writeUtilityBootstrapLog(`stderr: ${text}`)
  console.error('[utility-server]', text)
})

这样线上遇到问题时,不用猜。用户只要把日志贴出来,就能看到问题发生在哪一层。

十、真实问题复盘

问题一:用户目录没有生成 utility-server.js

一开始以为应该自动复制脚本到用户目录。后来调整成更接近生产热更新的模型:

  • 主程序不负责自动投放热更新文件
  • 主程序只负责启动前检测
  • 用户目录有热更新文件就用热更新文件
  • 用户目录没有热更新文件就用包内文件

这样职责更清楚。

问题二:服务脚本启动后找不到 express

日志:

text 复制代码
Error: Cannot find module 'express'
Require stack:
- C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js

原因:

  • utility-server.js 在用户目录
  • Node 默认会从用户目录开始向上找 node_modules
  • 用户目录没有 node_modules
  • 主进程只传了自定义的 UTILITY_SERVER_NODE_PATH,服务脚本如果不自己写回 process.env.NODE_PATH 并执行 Module._initPaths(),模块搜索路径不会生效

解决:

js 复制代码
const Module = require('module')
const path = require('path')

const utilityServerNodePath = process.env.UTILITY_SERVER_NODE_PATH

if (utilityServerNodePath) {
  process.env.NODE_PATH = utilityServerNodePath
  Module._initPaths()
}

const express = require('express')

问题三:管道连接 ENOENT

页面报:

text 复制代码
connect ENOENT \\.\pipe\wadesk-protocol-bridge

这说明渲染进程请求管道时,服务没有监听成功。根因不是管道本身,而是 utilityProcess 启动后因为找不到 express 直接退出了。

解决 express 依赖解析后,日志出现:

text 复制代码
server-ready: {"type":"server-ready","pipeName":"\\\\.\\pipe\\wadesk-protocol-bridge","pid":33884}

这就证明管道服务已经监听成功。

问题四:GET /health 报 JSON parse 错误

日志:

text 复制代码
SyntaxError: Unexpected token 'n', "null" is not valid JSON

原因:

  • GET /health 不应该有 body
  • axios 请求配置里传了 data: null
  • Express JSON body parser 尝试解析 "null",严格模式报错

解决:

js 复制代码
if (requestOptions.data !== null && requestOptions.data !== undefined) {
  axiosOptions.data = requestOptions.data
}

十一、热更新发布流程建议

生产上可以这样设计:

  1. 主包内永远带一个稳定 fallback 版本:
text 复制代码
resources/app.asar/dist/electron/server/utility-server.js
  1. 热更新系统下载新服务脚本到临时文件:
text 复制代码
%APPDATA%/electron-demo/utility-process/utility-server.js.tmp
  1. 校验文件 hash、版本号、签名。

  2. 校验通过后原子替换:

text 复制代码
utility-server.js.tmp -> utility-server.js
  1. 下次应用启动时自动使用用户目录版本。

  2. 如果热更新版本异常,可以删除用户目录脚本,自动回退包内版本。

这套机制的好处是:

  • fallback 永远在安装包内
  • 用户目录脚本可独立热更新
  • 不污染用户目录的 node_modules
  • 回滚简单
  • 主进程只需要做启动前选择

十二、几个工程细节

1. 不要把所有 node_modules 复制到用户目录

热更新脚本要尽量薄,只放业务入口和业务逻辑。依赖复用安装包。

2. 用户目录脚本要和当前包依赖版本兼容

因为脚本用的是当前安装包里的依赖,所以热更新脚本不能随便使用当前包没有的依赖版本。可以在脚本里带一个 manifest,声明需要的 app version 或 dependency version。

3. native 依赖要单独处理

如果热更新脚本依赖 native .node 模块,不建议直接走 asar 虚拟路径。要考虑:

  • asarUnpack
  • ABI 匹配
  • Electron 版本匹配
  • 平台和架构
  • 真实文件路径要求

4. 日志要保留启动链路信息

至少记录:

  • 选择了用户脚本还是包内脚本
  • nodePath
  • servicePath
  • stdout
  • stderr
  • server-ready
  • exit code

5. 健康检查要保留启动来源

/health 返回里保留:

js 复制代码
startedFrom: __filename,
cwd: process.cwd(),
utilityServerNodePath: process.env.UTILITY_SERVER_NODE_PATH

这比肉眼看目录更可靠。

6. 用户目录 preload.js 也可以复用包内 node_modules,但路径要从网页 URL 透传

如果热更新的不只是 utility-server.js,还包括用户目录下的 preload.js,它同样可能需要引用当前安装包里的依赖。

例如:

js 复制代码
const axios = require('axios')

这时也会遇到同一个问题:用户目录下的 preload 文件默认不会从 app.asar/node_modules 里解析依赖。

但 preload 的参数传递方式要特别注意。

错误理解是把参数挂到 preload 文件 URL 上:

html 复制代码
<webview
  src="https://www.baidu.com"
  preload="file://C:/Users/senzo/AppData/Roaming/electron-demo/preloads/webview-preload.js?node_path=xxx">
</webview>

这种方式对 preload 自己通常没有意义。因为 preload 运行时看到的 window.location 是被嵌入网页的 URL,不是 preload 文件自己的 URL。

也就是说,preload 里:

js 复制代码
window.location.href

拿到的是:

text 复制代码
https://www.baidu.com/...

而不是:

text 复制代码
file://.../webview-preload.js?node_path=xxx

所以如果要让 preload 通过 location.search 拿到参数,参数应该挂在 webview src 的目标网页 URL 上:

html 复制代码
<webview
  src="https://www.baidu.com?node_path=xxx"
  preload="file://C:/Users/senzo/AppData/Roaming/electron-demo/preloads/webview-preload.js">
</webview>

主进程或渲染进程在拼接 webview src 时,可以这样做:

js 复制代码
const targetUrl = new URL('https://www.baidu.com')
targetUrl.searchParams.set('node_path', nodeModulesPath)

const webviewSrc = targetUrl.toString()

preload 里读取:

js 复制代码
const Module = require('module')

const params = new URLSearchParams(window.location.search)
const nodePath = params.get('node_path')

if (nodePath) {
  process.env.NODE_PATH = nodePath
  Module._initPaths()
}

const axios = require('axios')

这和 utility-server.js 的思路一致:真正让依赖路径生效的,不是"外部传了一个字符串"本身,而是 preload 自己在 require() 之前执行:

js 复制代码
process.env.NODE_PATH = nodePath
Module._initPaths()

不过这里有一个安全问题:node_path 在形式上来自网页 URL,不能无条件信任。

如果 webview 加载的是远程页面,理论上这个 URL 可能被跳转、被拼接、被用户篡改。preload 不应该随便接受任意页面传来的 node_path。至少要做来源校验:

js 复制代码
const allowedHosts = new Set(['www.baidu.com'])

if (allowedHosts.has(window.location.hostname)) {
  const nodePath = new URLSearchParams(window.location.search).get('node_path')

  if (nodePath) {
    process.env.NODE_PATH = nodePath
    Module._initPaths()
  }
}

更稳的做法是不要完全相信 URL 里的路径值,而是只把它当一个开关或版本标识。真正的依赖路径仍由 preload 内部根据固定规则推导,或者对传入路径做白名单前缀校验:

js 复制代码
const expectedPrefix = 'C:\\Users\\senzo\\AppData\\Local\\Programs\\electron-demo\\resources\\app.asar\\node_modules'

if (nodePath && nodePath === expectedPrefix) {
  process.env.NODE_PATH = nodePath
  Module._initPaths()
}

总结一下:

  • preload="file://...preload.js?node_path=xxx" 不是合适的透传方式
  • webview src="https://target.com?node_path=xxx" 才能让 preload 通过 window.location.search 读取
  • preload 必须在 require('第三方库') 之前设置 process.env.NODE_PATH 并执行 Module._initPaths()
  • 远程网页 URL 传入的路径必须校验,不能无条件信任
  • 对安全要求更高的场景,应尽量用固定路径推导或白名单,而不是完全相信 query 参数

十三、总结

这套方案的核心不是"把文件复制出来运行",而是"启动前选择可热更新的用户脚本,缺省回退包内脚本"。

最终职责边界是:

  • Electron 安装包:提供主进程、渲染进程、fallback 服务脚本、稳定依赖
  • 用户目录:提供可热更新服务脚本
  • 主进程:选择启动文件、注入依赖路径、管理 utilityProcess 生命周期
  • utilityProcess:初始化模块搜索路径、启动 Express、监听 named pipe
  • 渲染进程:通过 named pipe 调用服务,使用 /health 验证链路

最容易踩坑的是 node_modules。用户目录脚本不在 app.asar 里面,默认不会从 app.asar/node_modules 找依赖。必须显式传入依赖路径,并在服务脚本里用 Module._initPaths() 初始化搜索路径。

只要把这个点处理好,用户目录热更新脚本 + 包内依赖复用 + utilityProcess 隔离执行这条链路,就能比较稳地落到生产包里。

相关推荐
深念Y2 小时前
若依框架2026年现状:没被淘汰,反而更强了
前端·javascript·vue.js·框架·系统·模板·若依
Aliex_git2 小时前
Nuxt 学习笔记(二)
前端·笔记·学习
亿元程序员2 小时前
Cocos视频拼图,拼图游戏的最后一块碎片,支持原生!
前端
Rabbit_QL2 小时前
【前端工具链小白篇】前端工具链全景:Node、npm、Vite 各管什么
前端·npm·node.js
身如柳絮随风扬2 小时前
前端基础进阶:Node.js + ES6 + Axios + Vue 全面入门指南
前端·node.js·es6
byoass2 小时前
文件版本管理的设计与实现:解决协同编辑丢数据的核心方案
前端·javascript·网络·数据库·安全·云计算
yqcoder2 小时前
前端性能优化基石:深入解析 CSS 雪碧图 (CSS Sprites)
前端·css·性能优化
身如柳絮随风扬2 小时前
Vue项目搭建与实战:从vue-cli到vue-admin-template完整指南
前端·javascript·vue.js
最后一只小白2 小时前
封装form表单
前端·javascript·vue.js