作者: Patchouli_Go_

  • VS Code TypeScript 语言服务器频繁崩溃、CPU 常态占用的排查与解决

    一、问题背景

    最近在使用 VS Code 开发一个大型 Vue 项目时,发现 TypeScript 语言服务器(TSServer)持续占用 CPU,并且不断进行项目初始化。

    VS Code 反复出现以下提示:

    正在初始化“frontend/tsconfig.json”

    同时还会收到警告:

    在过去 5 分钟内,JS/TS 语言服务崩溃了 5 次。这可能是由以下其中一个扩展提供的插件引起的:Vue.volar。

    项目本身采用 Monorepo 结构,包含多个 Vue 子项目以及大量 TypeScript 文件,使用了 Vue Official(Volar)扩展。

    主要表现

    • VS Code 长时间处于 TypeScript 项目初始化状态。
    • TSServer 持续占用 CPU,影响编辑器使用体验。
    • JavaScript/TypeScript 语言服务频繁崩溃并自动重启。
    • VS Code 提示可能与 Vue.volar 扩展有关。
    • 重启 VS Code 后问题仍然出现。

    经过排查,最终通过调整 TSServer 的最大堆内存限制解决了反复崩溃的问题。

    二、问题排查

    2.1 查看 TSServer 错误日志

    打开 VS Code,按下 Ctrl + Shift + P,执行:

    TypeScript: Open TS Server Log

    也可以通过:

    视图 → 输出 → TypeScript

    查看语言服务相关信息。

    排查过程中,发现以下错误:

    2026-10-15 16:39:08.294 [error] TSServer exited.
    Code: 134. Signal: null

    其中:

    • TSServer exited:TypeScript 语言服务器异常退出。
    • Code: 134:进程异常终止,通常与 abort() 有关。
    • Signal: null:没有报告具体的终止信号。

    退出码 134 并不直接等于内存溢出,但在 Node.js/V8 环境中,堆内存耗尽是常见诱因之一。

    如果日志中还出现以下错误,则可以进一步确认内存问题:

    FATAL ERROR: Reached heap limit
    Allocation failed - JavaScript heap out of memory

    或者:

    FATAL ERROR: Ineffective mark-compacts near heap limit
    Allocation failed - JavaScript heap out of memory

    本次排查没有直接捕获到上述 V8 致命错误,但后续调整内存上限后的表现进一步支持了内存不足的判断。

    2.2 分析 TypeScript 项目配置

    项目根目录的 tsconfig.json 中,存在以下配置:

    {
      "include": [
        "packages/**/*.ts",
        "packages/**/*.vue",
        "packages/**/*.tsx"
      ],
      "exclude": [
        "node_modules"
      ]
    }

    这意味着 TypeScript 需要分析 packages 目录下大量的 TS、TSX 和 Vue 文件。

    同时,项目中的各个 package 还存在相互依赖。

    在 TSServer 日志中,可以观察到大量模块解析和文件监听记录:

    FileWatcher:: Added:: WatchInfo
    DirectoryWatcher:: Added:: WatchInfo
    WatchType: Failed Lookup Locations

    这些信息说明 TypeScript 正在进行大量模块解析,并对相关路径建立文件监听。

    需要说明的是,Failed Lookup Locations 本身并不是错误,而是 TypeScript 模块解析机制中的正常行为。

    不过,在大型 Monorepo 项目中,跨 package 依赖、庞大的类型声明和复杂的类型推导,都可能显著增加语言服务器的内存消耗。

    三、解决方案:提高 TSServer 最大堆内存

    3.1 修改 VS Code 工作区配置

    1. 按下 Ctrl + Shift + P。
    2. 搜索 Preferences: Open Workspace Settings (JSON)。
    3. 打开工作区设置文件。
    4. 添加以下配置并保存:
    {
      "typescript.tsserver.maxTsServerMemory": 8192
    }

    其中,8192 表示 8192 MB,即 8 GB。

    该配置用于调整 VS Code 启动 TypeScript 语言服务器时使用的 Node.js/V8 堆内存限制。

    需要注意,这不是整个进程的总内存上限,也不代表启动后就会立即占用 8 GB。

    3.2 重新启动 TypeScript 语言服务器

    修改完成后,按下:

    Ctrl + Shift + P

    执行:

    TypeScript: Restart TS Server

    如果需要确保所有相关进程重新启动,也可以执行:

    Developer: Reload Window

    使工作区配置重新生效。

    3.3 验证修改效果

    本次排查中,将最大堆内存配置为 8192 MB 后:

    • TSServer 不再频繁崩溃。
    • VS Code 不再反复出现语言服务崩溃提示。
    • 项目初始化可以正常完成。
    • TSServer 内存占用约为 3.46 GB。
    • 初始化完成后,观察到 TSServer 的 CPU 占用降至 0%。

    说明此前的内存限制很可能不足以支撑当前项目的完整分析。

    对于大型 Vue/TypeScript Monorepo 项目,可以根据实际内存使用情况考虑调整:

    配置值堆内存限制
    30723 GB
    40964 GB
    61446 GB
    81928 GB

    需要注意,VS Code、Node.js 和 TypeScript 的版本可能影响实际默认堆内存限制,不能认为所有环境默认都严格为 3 GB。

    建议先使用 4096 MB 或 6144 MB 进行测试。如果仍然出现堆内存不足,再适当提高。

    本次项目最终使用 8192 MB,语言服务器运行恢复稳定。

    四、查看 TSServer 内存占用

    VS Code 内置了进程资源管理器,可以直接查看语言服务器的资源使用情况。

    按下:

    Ctrl + Shift + P

    执行:

    Developer: Open Process Explorer

    在进程列表中,找到类似:

    electron-nodejs (patch:tsserver.js; tsserver.js)

    本次排查中观察到两个相关进程:

    进程内存占用
    TSServer 进程 1175 MB
    TSServer 进程 23460 MB

    VS Code 的 TypeScript 扩展可能启动多个语言服务器进程,分别承担不同的分析任务。

    其中,占用较高的进程通常负责较为复杂的项目语义分析。

    如果想进一步确认进程实际使用的启动参数,可以在 Windows PowerShell 中执行:

    Get-CimInstance Win32_Process |
        Where-Object {
            $_.CommandLine -like "*tsserver.js*"
        } |
        Select-Object ProcessId, CommandLine |
        Format-List

    检查输出中是否存在:

    --max-old-space-size=8192

    如果存在,说明相应 Node.js 进程已配置对应的 V8 Old Space 限制。

    五、进一步优化:降低 TypeScript 项目分析开销

    提高堆内存上限能够解决内存不足引发的崩溃,但并不意味着项目的 TypeScript 分析开销已经得到优化。

    对于大型 Monorepo 项目,还可以从 tsconfig.json 入手。

    5.1 减少不必要的文件扫描

    例如:

    {
      "compilerOptions": {
        "skipLibCheck": true,
        "checkJs": false
      },
      "include": [
        "src/**/*.ts",
        "src/**/*.tsx",
        "src/**/*.vue"
      ],
      "exclude": [
        "node_modules",
        "dist",
        "build",
        "coverage"
      ]
    }

    其中:

    • skipLibCheck:跳过声明文件的类型检查,减少部分类型检查开销。
    • checkJs: false:关闭 JavaScript 文件的类型错误检查。
    • include:限制直接纳入当前 TypeScript 工程的源码范围。
    • exclude:排除不需要作为根文件加载的目录。

    注意,这只是示例配置,实际使用时应保留原项目已有的必要编译选项。

    另外,exclude 并不能完全阻止通过 import 引用的文件被加载。

    5.2 合理拆分 Monorepo 中的 TypeScript 工程

    如果项目结构类似:

    frontend/
    ├── packages/
    │   ├── portal/
    │   ├── admin/
    │   ├── cloudpivot-form/
    │   └── cloudpivot-portal-designer/
    └── tsconfig.json

    则可以考虑为各个 package 配置独立的 tsconfig.json,避免所有项目文件都被同一个 TypeScript 工程直接包含。

    必要时,还可以结合 TypeScript Project References 管理项目间依赖。

    不过,拆分工程需要结合实际的模块引用关系、构建工具和类型声明生成流程设计,不能简单地删除跨 package 引用。

    5.3 其他 VS Code 配置

    还可以根据项目情况选择性添加:

    {
      "typescript.tsserver.maxTsServerMemory": 8192,
      "typescript.disableAutomaticTypeAcquisition": true,
      "files.watcherExclude": {
        "**/node_modules/**": true,
        "**/dist/**": true,
        "**/build/**": true
      }
    }

    其中,typescript.disableAutomaticTypeAcquisition 主要用于关闭自动获取类型定义的行为。

    files.watcherExclude 则用于减少 VS Code 文件系统监听开销,并不等同于禁止 TypeScript 解析这些目录。

    这些配置可以作为辅助优化手段,但无法替代对实际项目依赖和类型分析开销的排查。

    六、总结

    本次问题的核心表现是:

    大型 Vue Monorepo 项目使 TypeScript 语言服务器消耗大量内存,在原有堆内存限制下频繁异常退出,进而造成不断初始化和 CPU 重复负载。

    最终通过提高 TSServer 堆内存限制解决了问题。

    关键配置只有一项:

    {
      "typescript.tsserver.maxTsServerMemory": 8192
    }

    需要区分两个层面:

    • 提高堆内存上限:解决语言服务器因内存不足而崩溃的问题。
    • 优化 TypeScript 工程配置:减少不必要的文件扫描、模块解析和类型检查开销。

    对于大型 Vue、React 或其他 TypeScript Monorepo 项目,当 VS Code 出现 TSServer 频繁崩溃、重复初始化、CPU 占用异常等现象时,可以优先检查进程内存和退出日志,再根据实际情况调整内存限制。

    如果提高堆内存后语言服务器能够稳定完成初始化,并且空闲时 CPU 占用恢复正常,那么通常不需要急于进行大规模工程改造。

  • 前端自动化部署实践:使用 npm、bestzip 和 SSH2 实现一键构建、打包与 Linux 服务器发布

    前言

    在前端项目开发过程中,每次发布新版本通常需要执行一系列重复操作:

    1. 执行前端构建命令。
    2. 将构建产物打包成 ZIP。
    3. 连接 Linux 服务器。
    4. 将 ZIP 上传到服务器。
    5. 执行服务器上的发布脚本。

    如果每次都手动操作,不仅效率低,还容易因为操作遗漏导致部署异常。

    本文介绍一种基于 npm Scripts + bestzip + ssh2 的轻量级自动化部署方案,将这些步骤整合到构建流程中。

    最终只需执行:

    npm run build:portal

    即可自动完成前端构建、ZIP 压缩、SSH 连接、文件上传以及服务器发布脚本的执行。

    该方案不依赖 Jenkins、Docker 或额外的 CI/CD 平台,适合已有发布脚本、希望快速实现自动化部署的前端项目。

    一、技术方案

    1.1 使用的技术

    技术用途
    npm Scripts管理构建和部署流程
    Yarn执行子项目构建
    bestzip压缩前端构建产物
    Node.js编写自动化部署脚本
    ssh2SSH 连接、SFTP 上传和远程命令执行
    dotenv管理部署环境配置

    1.2 部署流程

    npm run build:portal
            │
            ▼
    前端项目构建
            │
            ▼
    postbuild:portal 自动执行
            │
            ▼
    删除旧 ZIP(如果存在)
            │
            ▼
    压缩 dist 内的构建产物
            │
            ▼
    生成 dist/portal.zip
            │
            ▼
    执行 deploy-portal.cjs
            │
            ▼
    读取服务器部署配置
            │
            ▼
    建立 SSH 连接
            │
            ▼
    通过 SFTP 上传 ZIP
            │
            ▼
    执行 Linux 发布脚本
            │
            ▼
    输出部署结果

    1.3 项目目录结构

    以下为本文示例使用的项目结构:

    frontend/
    ├── .gitignore
    ├── .env.deploy
    ├── .env.deploy.example
    ├── .deploy-known-hosts.json
    ├── package.json
    ├── scripts/
    │   └── deploy-portal.cjs
    └── packages/
        └── portal/
            ├── package.json
            └── dist/
                ├── index.html
                ├── assets/
                └── portal.zip

    其中:

    • .env.deploy:存放服务器连接信息。
    • .deploy-known-hosts.json:记录 SSH 服务器主机密钥哈希。
    • deploy-portal.cjs:执行文件上传与远程发布。
    • portal.zip:构建完成后的部署包。

    二、安装项目依赖

    在项目根目录执行:

    npm install -D bestzip ssh2 dotenv

    三个依赖分别负责:

    bestzip

    用于将构建目录压缩为 ZIP 文件,支持 Windows、Linux 和 macOS。

    ssh2

    Node.js SSH 客户端库,主要用于:

    • 使用账号密码建立 SSH 连接。
    • 通过 SFTP 上传文件。
    • 执行服务器上的 Shell 命令。
    • 接收远程命令的标准输出和错误输出。

    dotenv

    读取本地 .env 配置文件,将服务器地址、账号、密码等信息加载到 process.env 中。


    三、配置 npm 构建与自动打包

    3.1 原有构建命令

    项目使用 Monorepo 目录结构,前端代码位于 packages/portal。

    根目录 package.json 中已有以下构建命令:

    {
      "scripts": {
        "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build"
      }
    }

    该命令完成以下操作:

    1. 设置 Node.js 环境变量。
    2. 进入 packages/portal。
    3. 使用 Yarn 执行项目构建。

    这里的 set NODE_OPTIONS=... 属于 Windows CMD 语法,本文后续打包命令同样以 Windows 环境为例。

    3.2 使用 postbuild 自动执行打包

    npm 支持 pre 和 post 生命周期脚本。

    例如,执行:

    npm run build:portal

    如果存在对应的 postbuild:portal,npm 会在 build:portal 成功完成之后自动执行它。

    因此,无需修改原有构建命令,只需要添加:

    {
      "scripts": {
        "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
        "postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip && cd ../../.. && node scripts/deploy-portal.cjs"
      }
    }

    npm 的 post 脚本机制可参考 npm Scripts 官方文档。

    3.3 分析打包命令

    第一步:进入构建目录

    cd packages/portal/dist

    这样可以确保 ZIP 内部直接包含构建产物,而不是完整的 packages/portal/dist 目录层级。

    第二步:删除旧 ZIP

    (if exist portal.zip del /Q portal.zip)

    如果存在旧的 portal.zip,则先删除,避免旧压缩包进入新压缩包。

    第三步:压缩构建产物

    bestzip ../portal.zip *

    这里有个关键细节:

    不要直接将 ZIP 输出到正在压缩的目录中。

    如果在 dist 目录执行:

    bestzip portal.zip *

    就可能导致正在生成的 ZIP 文件被包含在压缩内容中。

    因此,先将 ZIP 输出到上一级目录:

    bestzip ../portal.zip *

    第四步:将 ZIP 移动回 dist

    move /Y ..\portal.zip portal.zip

    此时压缩已经完成,可以安全地将 ZIP 移回 dist 目录。

    第五步:返回项目根目录,执行部署脚本

    cd ../../.. && node scripts/deploy-portal.cjs

    由于部署脚本中使用了基于当前工作目录的路径,必须先返回项目根目录。

    最终 ZIP 内部结构:

    portal.zip
    ├── index.html
    ├── assets/
    │   ├── index.js
    │   └── index.css
    └── ...

    而不是:

    portal.zip
    └── packages/
        └── portal/
            └── dist/
                └── index.html

    注意:这里只处理了构建产物目录中已有 ZIP 的情况。对于正式项目,建议确保构建流程会清理旧资源,或者另外添加构建产物清理逻辑。


    四、配置服务器连接信息

    在项目根目录创建 .env.deploy:

    DEPLOY_HOST=192.168.1.100
    DEPLOY_PORT=22
    DEPLOY_USER=root
    DEPLOY_PASSWORD=your_password
    
    DEPLOY_REMOTE_DIR=/root/releases
    DEPLOY_SCRIPT=/root/deploy_cp.sh
    DEPLOY_ARGS=portal portal.zip

    配置说明:

    配置项含义
    DEPLOY_HOSTLinux 服务器地址
    DEPLOY_PORTSSH 端口,默认 22
    DEPLOY_USERSSH 登录账号
    DEPLOY_PASSWORDSSH 登录密码
    DEPLOY_REMOTE_DIRZIP 上传目录
    DEPLOY_SCRIPT服务器发布脚本路径
    DEPLOY_ARGS发布脚本执行参数

    例如:

    DEPLOY_SCRIPT=/root/deploy_cp.sh
    DEPLOY_ARGS=portal portal.zip

    最终执行的 Linux 命令等价于:

    bash /root/deploy_cp.sh portal portal.zip

    其中:

    $1 = portal
    $2 = portal.zip

    这种方式可以让同一个发布脚本通过参数支持不同项目。

    需要注意,DEPLOY_REMOTE_DIR 必须已经存在,并且 SSH 登录账号拥有写入权限。

    另外,普通账号通常无法直接访问 /root 下的脚本。实际使用时应按照服务器权限配置选择合适的部署目录和账号。


    五、编写 Node.js 部署脚本

    在项目根目录创建:

    scripts/deploy-portal.cjs

    下面是完整实现。相较于最初版本,这里增加了远程参数校验、SSH 指纹记录、临时文件上传和异常处理。

    const fs = require('node:fs');
    const path = require('node:path');
    const { Client } = require('ssh2');
    
    // 加载部署配置
    require('dotenv').config({
      path: path.resolve('.env.deploy')
    });
    
    // 本地构建产物
    const zipPath = path.resolve(
      'packages/portal/dist/portal.zip'
    );
    
    // SSH 主机密钥记录文件
    const knownHostsFile = path.resolve(
      '.deploy-known-hosts.json'
    );
    
    // 远程配置
    const remoteDir = process.env.DEPLOY_REMOTE_DIR;
    const remoteScript = process.env.DEPLOY_SCRIPT;
    const remoteArgs = process.env.DEPLOY_ARGS || '';
    
    const ssh = new Client();
    
    /**
     * 校验部署配置
     */
    function validateConfig() {
      const required = [
        'DEPLOY_HOST',
        'DEPLOY_USER',
        'DEPLOY_PASSWORD',
        'DEPLOY_REMOTE_DIR',
        'DEPLOY_SCRIPT'
      ];
    
      for (const key of required) {
        if (!process.env[key]) {
          throw new Error(`缺少配置:${key}`);
        }
      }
    
      if (!fs.existsSync(zipPath)) {
        throw new Error(`ZIP 不存在:${zipPath}`);
      }
    
      // 限定远程路径为普通 Linux 绝对路径
      for (const value of [remoteDir, remoteScript]) {
        if (!/^\/[a-zA-Z0-9_./-]+$/.test(value)) {
          throw new Error(`远程路径不合法:${value}`);
        }
      }
    
      // 发布参数仅允许普通文件名和标识符字符
      if (!/^[a-zA-Z0-9_.\s/-]*$/.test(remoteArgs)) {
        throw new Error('DEPLOY_ARGS 包含非法字符');
      }
    
      const port = Number(process.env.DEPLOY_PORT || 22);
    
      if (!Number.isInteger(port) || port < 1 || port > 65535) {
        throw new Error('DEPLOY_PORT 不合法');
      }
    }
    
    /**
     * 验证 SSH 服务器主机密钥
     *
     * 首次连接时自动记录。
     * 后续连接时检查密钥是否发生变化。
     */
    function verifyHostKey(hash) {
      const host = process.env.DEPLOY_HOST;
      const port = Number(process.env.DEPLOY_PORT || 22);
      const hostId = `${host}:${port}`;
    
      let knownHosts = {};
    
      if (fs.existsSync(knownHostsFile)) {
        knownHosts = JSON.parse(
          fs.readFileSync(knownHostsFile, 'utf8')
        );
      }
    
      if (knownHosts[hostId]) {
        if (knownHosts[hostId] !== hash) {
          console.error(
            `SSH 主机密钥发生变化:${hostId}`
          );
          return false;
        }
    
        return true;
      }
    
      // 首次连接,自动信任并记录密钥
      console.log(
        `首次连接服务器 ${hostId},记录 SSH 主机密钥`
      );
    
      knownHosts[hostId] = hash;
    
      fs.writeFileSync(
        knownHostsFile,
        JSON.stringify(knownHosts, null, 2),
        { mode: 0o600 }
      );
    
      return true;
    }
    
    /**
     * 建立 SSH 连接
     */
    function connectSSH() {
      return new Promise((resolve, reject) => {
        ssh.once('ready', resolve);
        ssh.once('error', reject);
    
        ssh.connect({
          host: process.env.DEPLOY_HOST,
          port: Number(process.env.DEPLOY_PORT || 22),
          username: process.env.DEPLOY_USER,
          password: process.env.DEPLOY_PASSWORD,
          readyTimeout: 30000,
    
          // 验证服务器主机密钥
          hostHash: 'sha256',
          hostVerifier: verifyHostKey,
    
          // 排查连接问题时可以开启
          // debug: (message) => console.log('[SSH]', message)
        });
      });
    }
    
    /**
     * 使用 SFTP 上传 ZIP
     *
     * 先上传为临时文件,完成后重命名。
     */
    function uploadZip() {
      return new Promise((resolve, reject) => {
        ssh.sftp((err, sftp) => {
          if (err) return reject(err);
    
          const remoteZip = `${remoteDir}/portal.zip`;
          const tempZip = `${remoteZip}.uploading`;
    
          sftp.fastPut(zipPath, tempZip, (err) => {
            if (err) return reject(err);
    
            // 同名目标文件已存在时,
            // 某些 SFTP 服务器可能拒绝覆盖式重命名
            sftp.unlink(remoteZip, (unlinkErr) => {
              if (
                unlinkErr &&
                unlinkErr.code !== 2
              ) {
                return reject(unlinkErr);
              }
    
              sftp.rename(tempZip, remoteZip, (err) => {
                if (err) reject(err);
                else resolve();
              });
            });
          });
        });
      });
    }
    
    /**
     * 执行远程发布脚本
     */
    function executeDeployScript() {
      return new Promise((resolve, reject) => {
        const command =
          `bash '${remoteScript}' ${remoteArgs}`;
    
        console.log(`执行远程命令:${command}`);
    
        ssh.exec(command, (err, stream) => {
          if (err) return reject(err);
    
          stream.on('data', (data) => {
            process.stdout.write(data);
          });
    
          stream.stderr.on('data', (data) => {
            process.stderr.write(data);
          });
    
          stream.on('error', reject);
    
          stream.on('close', (code, signal) => {
            if (code === 0) {
              resolve();
            } else {
              reject(new Error(
                `发布失败,退出码:${code},信号:${signal}`
              ));
            }
          });
        });
      });
    }
    
    /**
     * 主部署流程
     */
    async function main() {
      try {
        validateConfig();
    
        console.log('开始连接 SSH 服务器...');
    
        await connectSSH();
    
        console.log('SSH 连接成功,开始上传 ZIP...');
    
        await uploadZip();
    
        console.log('ZIP 上传完成,执行发布脚本...');
    
        await executeDeployScript();
    
        console.log('部署成功!');
      } finally {
        ssh.end();
      }
    }
    
    // 程序入口
    main().catch((err) => {
      console.error('部署失败:', err.message);
      process.exitCode = 1;
    });

    说明:示例保留了固定的远程文件名 portal.zip。其中 SFTP 上传采用临时文件方式,降低上传过程中出现不完整正式文件的风险,但删除旧文件再重命名仍然存在短暂空档,并不是严格的原子替换。正式环境如需并发部署与无间断切换,应使用版本化部署目录和原子切换策略。

    远程参数校验也是为了匹配本文的简单部署场景,不支持包含空格等特殊字符的路径和参数。


    六、实现 SSH 首次连接自动信任

    在自动化部署中,另一个容易遇到的问题是 SSH 首次连接。

    使用 Windows 自带的 SSH 命令连接新服务器时,通常会要求确认服务器主机密钥:

    Are you sure you want to continue connecting
    (yes/no/[fingerprint])?

    手动输入 yes 后,OpenSSH 会将服务器主机密钥保存到本地 known_hosts。

    但 Node.js 的 ssh2 与系统 OpenSSH 是两个独立的 SSH 客户端。

    ssh2 不会默认读取 OpenSSH 的 known_hosts,并且在未指定 hostVerifier 时,默认接受服务器主机密钥。

    为了避免每次连接都无条件信任服务器,并且不要求其他开发人员手动执行 SSH,本方案额外实现了主机密钥记录机制。

    6.1 首次连接

    首次连接服务器时:

    首次连接服务器 192.168.1.100:22,
    记录 SSH 主机密钥

    随后自动创建:

    .deploy-known-hosts.json

    内容示意:

    {
      "192.168.1.100:22": "服务器主机密钥的SHA256十六进制哈希"
    }

    6.2 后续连接

    之后每次连接时,脚本都会检查当前服务器的主机密钥哈希是否与已记录值一致。

    如果一致,继续连接。

    如果不一致,拒绝连接:

    SSH 主机密钥发生变化:192.168.1.100:22
    部署失败

    这样可以避免服务器身份发生变化时仍然继续部署。

    6.3 安全说明

    该机制属于 TOFU(Trust On First Use,首次使用时信任)。

    需要特别注意:

    • 首次连接时仍存在信任错误服务器的风险。
    • 如果服务器重装系统并更换 SSH 主机密钥,需要重新核实服务器身份。
    • 不建议在生产环境中直接删除信任记录并盲目重新接受密钥。
    • 正式生产部署可以改为管理员预先提供经过核验的服务器公钥指纹。

    同时,需要区分 SSH 主机密钥验证和 SSH 协议握手。

    如果出现:

    Connection lost before handshake

    并不意味着一定是未接受主机密钥导致的,还需要通过 ssh2 的调试日志检查网络、SSH 服务或协议协商问题。


    七、配置 Git 忽略规则

    部署配置中包含服务器密码,不能直接提交到 Git 仓库。

    在根目录 .gitignore 中添加:

    # 本地部署配置
    /.env.deploy
    
    # SSH 主机密钥记录
    /.deploy-known-hosts.json
    
    # 构建产物
    /packages/portal/dist/

    为了方便其他开发人员使用,可以额外创建:

    .env.deploy.example

    示例内容:

    DEPLOY_HOST=
    DEPLOY_PORT=22
    DEPLOY_USER=
    DEPLOY_PASSWORD=
    
    DEPLOY_REMOTE_DIR=
    DEPLOY_SCRIPT=
    DEPLOY_ARGS=portal portal.zip

    其他开发人员只需要复制:

    copy .env.deploy.example .env.deploy

    然后填写自己的服务器连接信息即可。

    如果真实密码此前已经提交到 Git 历史中,仅添加 .gitignore 并不能消除泄露风险,还需要更换密码并视情况清理历史。


    八、Linux 服务器发布脚本

    前面已经配置:

    DEPLOY_SCRIPT=/root/deploy_cp.sh
    DEPLOY_ARGS=portal portal.zip

    因此,部署脚本最终在服务器执行:

    bash /root/deploy_cp.sh portal portal.zip

    下面提供一个简化的服务器发布脚本示例:

    #!/bin/bash
    
    set -e
    
    PROJECT="$1"
    ZIP_NAME="$2"
    
    UPLOAD_DIR="/root/releases"
    DEPLOY_DIR="/var/www/$PROJECT"
    
    ZIP_PATH="$UPLOAD_DIR/$ZIP_NAME"
    
    if [ ! -f "$ZIP_PATH" ]; then
        echo "部署包不存在:$ZIP_PATH"
        exit 1
    fi
    
    mkdir -p "$DEPLOY_DIR"
    
    unzip -oq "$ZIP_PATH" -d "$DEPLOY_DIR"
    
    echo "$PROJECT 发布成功"

    例如:

    bash /root/deploy_cp.sh portal portal.zip

    会将:

    /root/releases/portal.zip

    解压至:

    /var/www/portal/

    需要注意,这只是用于说明参数传递的简化脚本。

    实际生产环境建议保留历史版本、处理旧文件清理,并采用临时发布目录加软链接切换,避免发布过程中影响正在访问的用户。


    九、执行自动化部署

    完成以上配置后,在项目根目录执行:

    npm run build:portal

    预期执行过程如下:

    > npm run build:portal
    
    开始构建前端项目...
    
    Build completed successfully.
    
    开始压缩构建产物...
    
    ZIP 打包完成。
    
    开始连接 SSH 服务器...
    
    首次连接服务器 192.168.1.100:22,
    记录 SSH 主机密钥
    
    SSH 连接成功,开始上传 ZIP...
    
    ZIP 上传完成,执行发布脚本...
    
    执行远程命令:
    bash '/root/deploy_cp.sh' portal portal.zip
    
    portal 发布成功
    
    部署成功!

    至此,一条命令就完成了整个前端部署流程。

    可选:增加独立部署命令

    当前方案将部署直接放在 postbuild:portal 中,意味着每次正常执行 npm run build:portal 都会尝试连接服务器。

    如果希望将普通构建和正式发布区分开,可以调整为:

    {
      "scripts": {
        "build:portal": "set NODE_OPTIONS=--openssl-legacy-provider && cd packages/portal && yarn run build",
        "postbuild:portal": "cd packages/portal/dist && (if exist portal.zip del /Q portal.zip) && bestzip ../portal.zip * && move /Y ..\\portal.zip portal.zip",
        "deploy:portal": "node scripts/deploy-portal.cjs",
        "release:portal": "npm run build:portal && npm run deploy:portal"
      }
    }

    这样可以分别执行:

    # 仅构建并生成 ZIP
    npm run build:portal
    
    # 将已有 ZIP 上传并发布
    npm run deploy:portal
    
    # 构建、打包、上传、发布
    npm run release:portal

    这种设计可以避免开发人员只想构建项目时意外触发服务器发布。


    十、常见问题

    10.1 ZIP 内包含 packages/portal/dist 目录

    原因是直接使用了包含完整相对路径的压缩源:

    bestzip portal.zip packages/portal/dist/*

    解决方式是先进入构建目录:

    cd packages/portal/dist
    bestzip ../portal.zip *

    这样 ZIP 内部只包含构建产物。

    10.2 ZIP 内包含另一个 portal.zip

    原因可能是正在压缩的目录中存在旧 ZIP,或者输出 ZIP 的位置位于压缩源目录中。

    解决思路:

    1. 删除旧 ZIP。
    2. 先将新 ZIP 输出到 dist 外部。
    3. 压缩完成后再移动回 dist。

    10.3 Node.js 提示 MODULE_NOT_FOUND

    例如:

    Cannot find module
    packages\portal\dist\scripts\deploy-portal.cjs

    原因是执行 Node.js 时仍位于 dist 目录。

    解决:

    cd ../../.. && node scripts/deploy-portal.cjs

    确保返回项目根目录。

    10.4 SSH 提示 Connection lost before handshake

    先排查以下问题:

    • 服务器 SSH 端口是否正确。
    • 服务器 SSH 服务是否正常运行。
    • 服务器是否存在防火墙或连接限制。
    • SSH 握手阶段是否存在协议兼容问题。

    可以开启日志:

    debug: (message) => console.log('[SSH]', message)

    也可以使用系统 SSH 客户端辅助排查:

    ssh -vvv -p 22 root@192.168.1.100

    需要注意,系统 SSH 能正常连接,不代表 Node.js ssh2 使用完全相同的认证配置和加密算法。

    10.5 上传成功但发布脚本执行失败

    重点检查:

    • DEPLOY_SCRIPT 是否正确。
    • 当前账号是否有权限读取并执行脚本。
    • 脚本的参数是否正确。
    • 服务器是否安装了脚本依赖的命令。
    • 发布脚本是否以非零退出码表示错误。

    本方案会将服务器标准输出和标准错误实时输出至本地终端,方便排查异常。


    十一、总结

    本方案通过 npm Scripts、bestzip 和 ssh2,将原本需要多个手动步骤的前端部署流程整合到 Node.js 脚本中。

    主要优势:

    1. 部署流程简单:通过 npm 命令完成自动化构建和发布。
    2. 依赖少:无需部署额外的 CI/CD 平台。
    3. 复用现有脚本:无需重写服务器上的发布逻辑。
    4. 配置独立:服务器账号、密码、路径与项目代码分离。
    5. 支持首次主机密钥记录:开发人员无需额外手动接受 SSH 主机密钥。
    6. 错误可追踪:上传失败、SSH 连接失败和远程脚本执行失败均可输出错误信息。

    当然,这套方案并不能完全替代成熟的 CI/CD 系统。

    如果后续需要支持多环境发布、部署审批、自动回滚、发布记录和权限管理,可以在现有脚本的基础上进一步扩展,或者逐步迁移到 GitHub Actions、GitLab CI、Jenkins 等平台。

    对于日常的小型或中型前端项目,这种基于现有工具组合的自动化部署方式,已经能够显著减少重复操作,提高发布效率。

    参考资料

  • Ubuntu 下 aTrustTray2 反复崩溃导致 apport 占用 CPU

    最近在 Ubuntu 上运行深信服 aTrust 时,发现 apport 进程会不定期占用较高 CPU。

    一开始查看:

    ps -ef | grep -E 'apport|aTrust'

    发现 apport 是因为 aTrustTray2 崩溃后被系统拉起,且 aTrustTray2 的 PID 一直变化,说明它在反复:

    启动 → 崩溃 → 被重新拉起

    崩溃信号为:

    SIGABRT

    进一步手动运行:

    QT_DEBUG_PLUGINS=1 \
    /usr/share/sangfor/aTrust/resources/bin/aTrustTray2 -l zh-CN

    发现报错:

    This application failed to start because it could not find or load the Qt platform plugin "xcb"

    继续检查发现,aTrust 自己其实已经带了 libqxcb.so:

    find /usr/share/sangfor/aTrust -name 'libqxcb.so'

    结果:

    /usr/share/sangfor/aTrust/resources/lib/libqxcb.so

    但 aTrustTray2 默认搜索的路径却是:

    /usr/share/sangfor/aTrust/resources/bin/platforms

    而这个目录不存在。

    最终通过创建目录并添加软链接解决:

    sudo mkdir -p /usr/share/sangfor/aTrust/resources/bin/platforms
    
    sudo ln -s \
    /usr/share/sangfor/aTrust/resources/lib/libqxcb.so \
    /usr/share/sangfor/aTrust/resources/bin/platforms/libqxcb.so

    修复后,aTrustTray2 不再反复崩溃,apport 也不再持续占用 CPU。

    问题本质上是:

    aTrust 自带 Qt xcb 插件
    ↓
    插件实际位于 resources/lib
    ↓
    程序却只搜索 resources/bin/platforms
    ↓
    找不到插件后 SIGABRT
    ↓
    apport 不断处理崩溃

    如果以后升级或重装 aTrust 后问题再次出现,可以优先检查:

    ls -l /usr/share/sangfor/aTrust/resources/bin/platforms/libqxcb.so

    看这个软链接是否还存在。

  • R.E.P.O. 自建 Photon Server 部署教程

    本文记录如何使用 Photon On-Premise Server SDK v4 搭建一个可供 R.E.P.O. 使用的自建 Photon Server。

    需要说明的是,这种方式并不是搭建传统意义上的“游戏专用服务器”,而是通过自建 Photon Server 替代默认的 Photon 联机服务,让客户端连接到我们自己的服务器。

    ps:不如官网服务器一根,建议自行申请国际PON服务器和国区APPID

    一、准备内容

    需要准备:

    1. Windows 服务器或 Windows 主机
    2. Photon On-Premise Server SDK v4
    3. R.E.P.O. 游戏本体
    4. BepInEx 需要安装如下mod:
    4.1 Self Hosted Photon         用于修改服务器连接参数
    4.2 PhotonV4ServerVoiceFix     用于修复Photon Server V4无法语音的问题
    
    如果使用Mod管理器安装Mod,建议使用游戏内Mod配置修改Mod
    4.3 REPOConfig                 游戏内Mod配置修改Mod
    4.4 MenuLib                    游戏内Mod配置修改Mod前置
    本文只介绍如何本地部署Photon服务,Mod安装请移步B站相关视频&专栏

    本文使用的 Photon 版本为:

    Photon-OnPremise-Server-SDK_v4-0-29-11263

    客户端 Mod 使用(带*号为必须):

    Self Hosted Photon *
    PhotonV4ServerVoiceFix

    REPOConfig
    MenuLib

    二、安装 Photon Server

    下载并解压 Photon Server SDK,例如:

    D:\Photon-OnPremise-Server-SDK_v4-0-29-11263

    进入目录:

    D:\Photon-OnPremise-Server-SDK_v4-0-29-11263\deploy\bin_Win64

    运行:

    PhotonControl.exe

    运行后,系统托盘区会出现 Photon Control 图标。

    右键托盘图标,选择:

    LoadBalancing (MyCloud)
      → Start as Application

    如果成功启动,说明 Photon Server 已经跑起来了。

    后续长期使用时,也可以选择安装为服务:

    LoadBalancing (MyCloud)
      → Install Service
      → Start as Service

    测试阶段建议先使用 Start as Application,方便查看和重启。

    三、开放服务器端口

    Photon Server v4 默认需要开放以下端口:

    UDP 5055
    UDP 5056
    
    TCP 4530
    TCP 4531
    
    TCP 9090
    TCP 9091

    其中最关键的是:

    UDP 5055<br>UDP 5056

    关于如何开放端口本文也不会详细说明,教程仅给出需要开放的端口以供参考

    四、配置 GameServer 公网地址

    这一步非常重要。

    打开配置文件:

    deploy\LoadBalancing\GameServer\bin\Photon.LoadBalancing.dll.config

    找到:

    <Photon.LoadBalancing.GameServer.GameServerSettings>

    在里面修改公网地址配置。

    公网侧IP地址配置:

    <setting name="PublicIPAddress" serializeAs="String">
      <value>123.123.123.123</value>
    </setting>

    修改完成后,需要重启 Photon以应用修改:

    Photon Control
      → LoadBalancing (MyCloud)
      → Stop
      → Start as Application

    检查 Photon 是否监听端口(非必须)

    在服务器上执行:

    netstat -ano -p udp | findstr ":5055"
    netstat -ano -p udp | findstr ":5056"

    正常情况下应该能看到类似:

    UDP    0.0.0.0:5055    *:*
    UDP    0.0.0.0:5056    *:*

    六、安装客户端 Mod

    R.E.P.O. 客户端需要安装:

    BepInEx *
    Self Hosted Photon *
    PhotonV4ServerVoiceFix *
    
    REPOConfig
    MenuLib

    安装完成后,启动一次游戏,让配置文件自动生成,然后退出游戏。

    配置文件一般在:

    BepInEx\config\Self_Hosted_Photon.cfg

    七、配置客户端连接地址

    Self Hosted Photon配置:

    打开:

    BepInEx\config\Self_Hosted_Photon.cfg

    配置示例:

    ## Settings file was created by plugin Self_Hosted_Photon v0.1.0
    ## Plugin GUID: Self_Hosted_Photon
    
    [General]
    
    ## Enable or disable the plugin.
    # Setting type: Boolean
    # Default value: true
    Enabled = true
    
    [Server]
    
    ## The IP address or hostname of the Photon server.
    # Setting type: String
    # Default value: 127.0.0.1
    Address = 123.123.123.123
    
    ## The port number of the Photon server.
    # Setting type: String
    # Default value: 5055
    Port = 5055
    
    

    如果使用域名,则可以写成:

    ## Settings file was created by plugin Self_Hosted_Photon v0.1.0
    ## Plugin GUID: Self_Hosted_Photon
    
    [General]
    
    ## Enable or disable the plugin.
    # Setting type: Boolean
    # Default value: true
    Enabled = true
    
    [Server]
    
    ## The IP address or hostname of the Photon server.
    # Setting type: String
    # Default value: 127.0.0.1
    Address = www.你的域名.com
    
    ## The port number of the Photon server.
    # Setting type: String
    # Default value: 5055
    Port = 5055
    
    

    如果安装了游戏内Mod配置修改Mod:

    1、启动游戏,点击MODS

    2、点击Self_Hosted_Photon,即可在右侧修改连接配置,修改完点击保存更改即可。

    八、测试连接

    配置完成后,就可以启动 R.E.P.O.进行游玩测试服务器了。

  • 转载:VUE防抖与节流的最佳解决方案——函数式组件

    来源:https://www.ucloud.cn/yun/6841.html

    摘要:案例持续触发事件时,并不立即执行函数,当毫秒内没有触发事件时,才会延时触发一次函数。也以函数形式暴露普通插槽。这样的场景组件用函数式组件是非常方便的。相关阅读函数式组件自定义指令

    前言

    有echarts使用经验的同学可能遇到过这样的场景,在window.onresize事件回调里触发echartsBox.resize()方法来达到重绘的目的,resize事件是连续触发的这意味着echarts实例会连续的重绘这是非常耗性能的。还有一个常见的场景在input标签的input事件里请求后端接口,input事件也是连续触发的,假设我输入了“12”就会请求两次接口参数分别是“1”和“12”,比浪费网络资源更要命的是如果参数为“1”的请求返回数据的时间晚于参数为“12”的接口,那么我们得到的数据是和期望不符的。当然基于axios可以做很多封装可以取消上一个请求或者通过拦截做处理,但还是从防抖入手比较简单。

    防抖和节流到底是啥函数防抖(debounce)

    解释:当持续触发某事件时,一定时间间隔内没有再触发事件时,事件处理函数才会执行一次,如果设定的时间间隔到来之前,又一次触发了事件,就重新开始延时。
    案例:持续触发scroll事件时,并不立即执行handle函数,当1000毫秒内没有触发scroll事件时,才会延时触发一次handle函数。

    function debounce(fn, wait) {
      let timeout = null
      return function() {
        if(timeout !== null) clearTimeout(timeout)     
        timeout = setTimeout(fn, wait);
      }
    }
    function handle() {   
      console.log(Math.random())
    }
    window.addEventListener("scroll", debounce(handle, 1000))
    

    addEventListener的第二个参数实际上是debounce函数里return回的方法,let timeout = null 这行代码只在addEventListener的时候执行了一次 触发事件的时候不会执行,那么每次触发scroll事件的时候都会清除上次的延时器同时记录一个新的延时器,当scroll事件停止触发后最后一次记录的延时器不会被清除可以延时执行,这是debounce函数的原理函数节流(throttle)

    解释:当持续触发事件时,有规律的每隔一个时间间隔执行一次事件处理函数。
    案例:持续触发scroll事件时,并不立即执行handle函数,每隔1000毫秒才会执行一次handle函数。

    function throttle(fn, delay) { 
      var prev = Date.now()         
      return function() {               
        var now = Date.now()               
        if (now - prev > delay) {                   
          fn()                
          prev = Date.now()             
        }         
      }       
    }       
    function handle() {           
      console.log(Math.random())      
    }
    window.addEventListener("scroll", throttle(handle, 1000))
    

    原理和防抖类似,每次执行fn函数都会更新prev用来记录本次执行的时间,下一次事件触发时判断时间间隔是否到达预先的设定,重复上述操作。

    防抖和节流都可以用于 mousemove、scroll、resize、input等事件,他们的区别在于防抖只会在连续的事件周期结束时执行一次,而节流会在事件周期内按间隔时间有规律的执行多次。

    在vue中的实践

    在vue中实现防抖无非下面这两种方法

    封装utils工具

    封装组件封装utils工具

    把上面的案例改造一下就能封装一个简单的utils工具

    utils.js
    let timeout = null
    function debounce(fn, wait) {
      if(timeout !== null) clearTimeout(timeout)
      timeout = setTimeout(fn, wait)
    }
    export default debounce
    
    app.js
    type="text" @input="debounceInput($event)">
    
    import debounce from "./utils"
    export default {
      methods: {
        debounceInput(E){
          debounce(() => {
            console.log(E.target.value)
          }, 1000)
        }
      }
    }

    封装组件

    至于组件的封装我们要用到$listeners、$attrs这两个属性,他俩都是vue2.4新增的内容,官网的介绍比较晦涩,我们来看他俩到底是干啥的:

    $listeners: 父组件在绑定子组件的时候会在子组件上绑定很多属性,然后在子组件里通过props注册使用,那么没有被props注册的就会放在$listeners里,当然不包括class和style,并且可以通过 v-bind=”$attrs” 传入子组件的内部组件。

    $listeners: 父组件在子组件上绑定的不含.native修饰器的事件会放在$listeners里,它可以通过 v-on=”$listeners” 传入内部组件。

    简单来说$listeners、$attrs他俩是做属性和事件的承接,这在对组件做二次封装的时候非常有用。

    我们以element-ui的el-input组件为例封装一个带防抖的debounce-input组件

    debounce-input.vue
    
    
    
    app.vue
    
    
    

    上面组件的封装用了$attrs,虽然不需要开发者关注属性的传递,但是在使用上还是不方便的,因为把el-input封装在了内部这样对样式的限定也比较局限。有接触过react高阶组件的同学可能有了解,react高阶组件本质上是一个函数通过包裹被传入的React组件,经过一系列处理,最终返回一个相对增强的React组件。那么在vue中可以借鉴这种思路吗,我们来了解一下vue的函数式组件。关于vue函数式组件什么是函数式组件?

    函数式组件是指用一个Function来渲染一个vue组件,这个组件只接受一些 prop,我们可以将这类组件标记为 functional,这意味着它无状态 (没有响应式数据),也没有实例 (没有this上下文)。

    一个函数式组件大概向下面这样:

    export default () => {
      functional: true, 
      props: { 
        // Props 是可选的
      },
      // 为了弥补缺少的实例, 提供第二个参数作为上下文
      render: function (createElement, context) {
        return vNode
      }
    }
    注意:在 2.3.0 之前的版本中,如果一个函数式组件想要接收 prop,则 props 选项是必须的。在 2.3.0 或以上的版本中,你可以省略 props 选项,所有组件上的特性都会被自动隐式解析为 prop。但是你一旦注册了 prop 那么只有被注册的 prop 会出现在 context.prop 里。
    

    render函数的第二个参数context用来代替上下文this他是一个包含如下字段的对象:

    props:提供所有 prop 的对象

    children: VNode 子节点的数组

    slots: 一个函数,返回了包含所有插槽的对象

    scopedSlots: (2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。

    data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件

    parent:对父组件的引用

    listeners: (2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。

    injections: (2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的属性。vm.$slots API 里面是什么

    slots用来访问被插槽分发的内容。每个具名插槽 有其相应的属性 (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default 属性包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。slots() 和 children 对比

    你可能想知道为什么同时需要 slots() 和 children。slots().default 不是和 children 类似的吗?在一些场景中,是这样——但如果是如下的带有子节点的函数式组件呢?

      
        first
      
      second
    
    

    对于这个组件,children 会给你两个段落标签,而 slots().default 只会传递第二个匿名段落标签,slots().foo 会传递第一个具名段落标签。同时拥有 children 和 slots(),因此你可以选择让组件感知某个插槽机制,还是简单地通过传递 children,移交给其它组件去处理。一个函数式组件的使用场景

    假设有一个a组件,引入了 a1,a2,a3 三个组件,a组件的父组件给a组件传入了一个type属性根据type的值a组件来决定显示 a1,a2,a3 中的那个组件。这样的场景a组件用函数式组件是非常方便的。那么为什么要用函数式组件呢?一句话:渲染开销低,因为函数式组件只是函数。用函数式组件的方式来实现防抖

    因为业务关系该防抖组件的封装同时支持 input、button、el-input、el-button 的使用,如果是input类组件对input事件做防抖处理,如果是button类组件对click事件做防抖处理。

    const debounce = (fun, delay = 500, before) => {
      let timer = null
      return (params) => {
        timer && window.clearTimeout(timer)
        before && before(params)
        timer = window.setTimeout(() => {
           // click事件fun是Function  input事件fun是Array
          if (!Array.isArray(fun)) {
            fun = [fun]
          }
          for (let i in fun) {
            fun[i](params)
          }
          timer = null
        }, parseInt(delay))
      }
    }
    export default {
      name: "Debounce",
      functional: true, // 静态组件 当不声明functional时该组件同样拥有上下文以及生命周期函数
      render(createElement, context) {
        const before = context.props.before
        const time = context.props.time
        const vnodeList = context.slots().default
        if (vnodeList === undefined){
          console.warn(" 组件必须要有子元素")
          return null
        }
        const vnode = vnodeList[0] || null // 获取子元素虚拟dom
        if (vnode.tag === "input") {
          const defaultFun = vnode.data.on.input
          const debounceFun = debounce(defaultFun, time, before) // 获取节流函数
          vnode.data.on.input = debounceFun
        } else if (vnode.tag === "button") {
          const defaultFun = vnode.data.on.click
          const debounceFun = debounce(defaultFun, time, before) // 获取节流函数
          vnode.data.on.click = debounceFun
        } else if (vnode.componentOptions && vnode.componentOptions.tag === "el-input") {
          const defaultFun = vnode.componentOptions.listeners.input
          const debounceFun = debounce(defaultFun, time, before) // 获取节流函数
          vnode.componentOptions.listeners.input = debounceFun
        } else if (vnode.componentOptions && vnode.componentOptions.tag === "el-button") {
          const defaultFun = vnode.componentOptions.listeners.click
          const debounceFun = debounce(defaultFun, time, before) // 获取节流函数
          vnode.componentOptions.listeners.click = debounceFun
        } else {
          console.warn(" 组件内只能出现下面组件的任意一个且唯一 el-button、el-input、button、input")
          return vnode
        }
        return vnode
      }
    }

    原理也很简单就是在vNode中拦截on下面的click、input事件做防抖处理,这样在使用上就非常简单了。自定义指令 directive

    我们来思考一个问题,函数式组件封装防抖的关节是获取vNode,那么我们通过自定义指令同样可以拿到vNode,甚至还可以得到原生的Dom,这样用自定义指令来处理会更加方便。。。。。。

  • 如何在Nextcloud-AIO容器中重新获取passphrase

    如果你因为某些原因丢失了AIO面板的passphrase,你可以在容器中执行以下命令重新获取:

    sudo docker exec nextcloud-aio-mastercontainer grep password /mnt/docker-aio-config/data/configuration.json

    原文:If you have somehow lost the passphrase that is used for the AIO interface, you can reobtain it by running sudo docker exec nextcloud-aio-mastercontainer grep password /mnt/docker-aio-config/data/configuration.json.

    来自https://github.com/nextcloud/all-in-one/discussions/1786

  • 自然之旅3服务器更新日志-2025年6月21日

    本次为内容更新,新增8个mod,升级1个mod,移除1个mod。

    • TACZ +
      • HellDivers2枪包扩展
      • ClassicReborn
      • 沉浸机械师
    • 网络音乐 ↑
    • 基石-清理 –
    • 机械动力:末影传输 +
    • 假人 +
    • 建筑结构 +
    • 精致存储合成冲突消除 +
    • 精致存储扩展 +
    • 精致存储无限距离增幅卡 +
    • 一起火锅 +

    更新包下载链接:下载链接

    将另外新增清理mod:清洁女仆 Sweeper Maid,无需客户端安装
    此清理mod附带垃圾桶功能,可通过指令找回被清扫的垃圾:
    /sweepermaid dustbin

    需删除mod文件:

    • [网络音乐] netmusic-forge-1.20.1-1.1.6.jar
    • [基石-清理] AtomSweep-forge-1.20.x-0.2.2.jar

    关于mods位置:
    游戏根目录\.minecraft\versions[v1.9]自然之旅3-1.20.1-1.9\mods

    本次更新包附带完整文件路径,将更新包直接解压至游戏根目录即可

  • 自然之旅3服务器更新日志-2025年6月14日

    本次为内容更新,添加与移除了若干mod:

    • 机械动力:传动工艺 +
    • 网络音乐 +
    • 住口甜浆果 +
    • 精致存储 +
    • 功能性存储 +
    • 储物抽屉 –

    更新包下载链接:下载链接

    需移除mod文件:

    • StorageDrawers-1.20.1-12.9.14.jar
    • storage-drawers-create-compat-1.0.1.jar

    关于mods位置:
    在游戏根目录\.minecraft\versions[v1.9]自然之旅3-1.20.1-1.9\mods

  • 我的世界服务器-25年6月周目

    这是25年6月份的新周末,采用自然之旅3作为底包,在其基础上添加了如下mod:

    • 机械动力
    • 植物魔法
    • 抽屉
    • YSM
    • 及机械动力的各式扩展用于补充玩法

    服务器相关资源链接:

    替换了整合包自带的地图mod,目前为JourneyMap(旅行地图),大部分UI缩放设置已经修改好

    关于游戏设置

    游戏默认亮度为50%,如果觉得太暗请自行修改。

    视角摇晃也被关闭了,觉得走路平移太抽象可以打开。

    此整合包未添加耐久显示mod,请使用F3+H查看

    任务快捷键,默认未设置,可以自行改为T键

    部分影响游戏体验的问题解决

    关于旅行地图小地图时间显示不方便人类读取的问题:

    左上角显示准星指向的生物血量与YSM模型重叠问题: