阅读视图

发现新文章,点击刷新页面。

给到夯!前端工具链新标杆 Vite Plus 初探

p2.jpg

一、介绍和安装

1.1 什么是 Vite Plus?

Vite Plus(简称 Vite+) 是由 VoidZero (Vite / Vitest / Rolldown / Oxc 团队,隶属尤雨溪在 2024 年创办的公司) 在 3 月 13 日发布的一款面向 Web 的统一工具链,其具备如下几个核心特性:

  • 出色的开发者体验(DX)

    从项目创建、脚手架和包管理器的选择,到项目的开发构建、代码的检测与格式化,甚至包括对环境与依赖的管理等,Vite Plus 都提供了可视化的交互和简化的 CLI 指令,让开发者能轻松地搭建和操作自己的项目。我们会在后文(第二节)体验这块流程。

  • 告别配置地狱

    告别 vitest.config.tstsdown.config.ts.eslintrclint-staged.config.js.oxlintrc.json.prettierrc 等配置文件,所有配置统一归纳到 vite.config.ts 进行维护即可,可大幅降低开发的心智成本。

    💡 像 tsconfig.json 需要被 IDE 解析、package.json 需要被包管理器解析,故这些配置文件依旧需要独立存在。

  • 工具链覆盖完整,性能卓越

    内置了丰富的、可扩展的工具,包括 ViteRolldowntsdownOxlintOxfmttsgolintVitestViteTaskVitePress 等,涵盖前端工程化的各种功能需求:

    功能需求 内置工具
    开发和构建 web 应用 Vite + Rolldown
    构建库 tsdown
    代码检查 (Lint) Oxlint
    代码格式化 Oxfmt
    TypeScript 类型检查 tsgolint
    测试 Vitest
    任务调度 ViteTask
    文档生成 VitePress

    其中 Rolldown(内置 Oxc)、OxlintOxfmt 等都是基于 Rust 开发的,具备极高的性能

    Rolldown 为例,其构建速度可以比 Rollup 快 10~100 倍。


Vite Plus 目前仍处于 Alpha 阶段,并以 MIT 协议托管在 github.com/voidzero-de… 全量开源。

💡 Vue 3.6 的源码构建也将接入 Vite Plus,参考 PR#14556

1.2 和 Vite 的区别?

Vite 属于前端构建工具,其服务仅覆盖了「开发 + 构建」,而 Vite Plus 属于前端工具链,其服务完整覆盖了「开发构建 + 检查 + 格式化 + 环境管理 + 任务调度」等前端工作流环节。

Vite Plus 的底层依旧使用了 Vite 来作为面向用户的上层构建工具,因此也可以简单地把 Vite Plus 当做 Vite 的「强化版」。

它们虽然出于同个开发团队,但二者之间并非替代关系。如果你只是想给一个小项目找个打包工具,特别当你已经有一套非常习惯的 ESLint / Prettier 配置,用 Vite 就足够了。

1.3 安装 Vite Plus

macOS 或 Linux 系统可以在终端通过指令来安装 Vite Plus:

curl -fsSL https://vite.plus | bash

1.gif

Windows 系统的(PowerShell 面板)安装指令则为:

irm https://vite.plus/ps1 | iex

💡 安装过程若出现 SSL_ERROR_SYSCALL 的报错,可能需要尝试科学上网或更换节点。

若安装成功,你的系统会新增一个全局的 CLI vp,执行 vp help 会打印出 vp 可用指令列表:

image.png

💡 macOS 下若出现 zsh: command not found: vp 的报错,可先执行 source ~/.zshrc 指令来刷新环境变量。

二、核心 vp 指令

完整的 vp 指令请查阅官方指引文档

2.1 vp create —— 创建项目

执行 vp create 指令可以创建一个新的 Web 项目脚手架,Vite Plus 会逐步让你输入项目名称,并选择项目类型 (Web 应用 / 库 / Monorepo)、包管理器、使用的 Agent 等信息,并自动安装该项目所需的全部依赖:

Mar-19-2026 12-50-52.gif

选择 Agent 的一项会生成对应的「上下文指南(Instructions)」,例如针对 ChatGPT (Codex) 会生成 AGENTS.md,针对 Claude Code 会生成 CLAUDE.md,针对 Cursor会生成 .cursor/rules/viteplus.mdc....

该文件可以辅助 Agent 学习「如何使用 Vite Plus 来操作这个项目」,你可以点击这里查阅它的完整内容。

以上图创建一个 Web Application 项目为例,Vite Plus 最终会生成如下资源文件:

image.png

其树状图为:

/** Web Application 项目文件夹 **/
.
├── .gitignore
├── .vite-hooks
│   └── pre-commit
├── .vscode
│   ├── extensions.json
│   └── settings.json
├── AGENTS.md
├── index.html
├── list.txt
├── package.json
├── pnpm-lock.yaml
├── public
│   ├── favicon.svg
│   └── icons.svg
├── src
│   ├── assets
│   │   ├── hero.png
│   │   ├── typescript.svg
│   │   └── vite.svg
│   ├── counter.ts
│   ├── main.ts
│   └── style.css
├── tsconfig.json
└── vite.config.ts

其中多个工具链的配置都被整合到了 vite.config.ts 中,脱离了 vitest.config.tstsdown.config.ts.eslintrc 等配置文件后的项目资源变得非常「干净整洁」。

我们会在后文(第三节)了解 vite.config.ts 的配置项。


💡 参考 —— 通过 vp create 创建的「库」和「Monorepo」项目的初始化结构

「库」类型项目结构
.
├── .gitignore
├── .vite-hooks
│   └── pre-commit
├── .vscode
│   ├── extensions.json
│   └── settings.json
├── AGENTS.md
├── list.txt
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│   └── index.ts
├── tests
│   └── index.test.ts
├── tsconfig.json
└── vite.config.ts
「Monorepo」类型项目结构
.
├── .git
│   ├── config
│   ├── description
│   ├── 略...
│   └── refs
│       ├── heads
│       └── tags
├── .gitignore
├── .vite-hooks
│   ├── _
│   │   ├── commit-msg
│   │   ├── 略...
│   │   ├── pre-rebase
│   │   └── prepare-commit-msg
│   └── pre-commit
├── .vscode
│   ├── extensions.json
│   └── settings.json
├── AGENTS.md
├── apps
│   └── website
│       ├── .gitignore
│       ├── index.html
│       ├── package.json
│       ├── public
│       │   ├── favicon.svg
│       │   └── icons.svg
│       ├── src
│       │   ├── assets
│       │   │   ├── hero.png
│       │   │   ├── typescript.svg
│       │   │   └── vite.svg
│       │   ├── counter.ts
│       │   ├── main.ts
│       │   └── style.css
│       └── tsconfig.json
├── list.txt
├── package.json
├── packages
│   └── utils
│       ├── .gitignore
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   └── index.ts
│       ├── tests
│       │   └── index.test.ts
│       ├── tsconfig.json
│       └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── tsconfig.json
└── vite.config.ts

vp create 还支持创建指定技术栈的脚手架,例如执行 vp create react-router,Vite Plus 会自动调用 npx create-react-router 下载 react-router 的项目脚手架

009.gif

目前 vp create 所支持创建指定脚手架的指令包括:

vp create 指令 实际执行
vp create vite npx create-vite
vp create react-router npx create-react-router
vp create vue npx create-vue
vp create nuxt npx create-nuxt
vp create next-app npx create-next-app
vp create svelte npx sv
vp create @tanstack/start npx @tanstack/create-start
vp create nitro npx create-nitro-app
vp create <local-template-path> 根据指定路径下的本地模板创建脚手架
vp create https://github.com/user/template-repo 下载远程仓库模板创建脚手架

2.2 vp dev —— 启动开发环境服务(面向 Web 应用)

Web Application 项目执行 vp dev 指令可以启动开发环境服务(支持热更新):

222.gif

访问默认的服务地址 localhost:5173 便能访问开发环境页面:

image.png

💡 vp dev 的底层使用的是 Vite 8 + Rolldown,其中 Rolldown 内置了 Oxc 这一高效的解析工具 —— 在开发或构建流程中,Rolldown 只负责依赖扫描和打包的能力, .ts.tsx.jsx 等文件的解析编译能力是交由 Oxc 去实现的。

旧版 Vite 所使用的 Rollup 缺乏面向长期运行进程的增量构建能力,因此在开发环境需要依赖 esbuild 来辅助,这是 Vite 8 / Vite Plus 彻底抛弃 Rollupesbuild 的原因之一。

2.3 vp build —— 构建应用产物(面向 Web 应用)

Web Application 项目执行 vp build 指令会构建生产环境产物到 dist 文件夹下:

bbb.gif

vp build 支持携带 watchsourcemap 参数:

// 构建完成后进程不退出,当项目文件被修改时会触发构建
vp build --watch

// 构建时为 JavaScript、CSS 等资源文件生成 sourcemap
vp build --sourcemap

💡 如 2.2 小节结尾所述,vp build 底层同样使用的是 Vite 8 + Rolldown。


在执行完 vp build 之后,还可以继续执行 vp preview 指令来为构建产物生成可访问的 Web 服务,方便查看构建出来的页面效果:

image.png

2.4 vp pack —— 库 / Monorepo 项目的包构建指令

库类型的前端项目执行 vp pack --watch 指令可以启动包构建(产物默认为 .mjs 文件和类型文件 .d.mts),并监听源文件变更:

image.png

若无需监听源文件变动(实时触发打包),去掉 --watch 参数即可。

Monorepo 类型的项目本质上也是使用同样的指令来启动构建,不过需要到各模块的目录下(例如 ./packages/utils)去执行。

💡 vp pack 底层使用的是专为打包 npm 包设计的 tsdown,它也是一款基于 Rolldown 的打包工具。

2.5 vp run —— 任务编排与缓存运行器

vp run 是基于 ViteTask 的任务编排与缓存运行器,具备如下能力:

  • 执行 package.json 中指定的脚本任务:

    // 执行 package.json 中的 build 脚本。
    //(等同于 pnpm run build)
    vp run build 
    
    // 遍历项目 pnpm-workspace.yaml 中 `packages` 字段所定义的所有工作区,并执行它们各自 package.json 里的 build 脚本。
    //(等同于 pnpm run build -r)
    vp run build -r
    
  • 查找并执行具体包下的具体脚本

    // 遍历工作区里所有的 package.json,找出 name 为 website 的包,并执行该包的 dev 脚本任务。
    vp run website#dev
    
  • 启用任务缓存

    Vite Task 具备任务缓存能力,可以大幅节省任务再次执行的耗时,但 vp run 默认不会开启这个功能,需要手动带上 --cache 参数来启用:

    // 当执行过一次 build 任务后,再次执行该指令时,
    // 若输入的文件没发生过变化,会从上次的任务缓存中取出产物(来作为这次任务的产物),绕过任务执行的过程。
    vp run --cache build
    

💡 Monorepo 项目中的 vp run 示例

当我们使用 vp create 创建一个 Monorepo 类型的前端项目后,其初始化的 pnpm-workspace.yaml 内容如下:

/** pnpm-workspace.yaml **/

packages:      // 工作区声明
  - apps/*
  - packages/*

// 略...

项目根目录的 package.json 所定义的构建任务如下:

/** package.json **/

{
  "scripts": {
    "ready": "vp fmt && vp lint && vp run test -r && vp run build -r",
    "dev": "vp run website#dev",
    // ...
  },
  // ...
}

因此 ready 最终执行的 vp run build -r 会遍历项目 apps/*packages/* 下的模块,并执行这些模块下各自 package.json 中定义的 build 脚本指令(vp pack)。

dev 执行的 vp run website#dev 会遍历项目 apps/*packages/* 下的模块,找到名为 website 的模块(apps/website),并执行该模块 package.json 中定义的 dev 脚本指令(vp dev)。

2.6 vp check —— 代码质量检查

在项目中执行 vp check 会一次性、并行地触发三项代码质量检查任务:

  • 基于 Oxlint 的代码 Lint 检查

    Oxlint 是由 Rust 开发的,会比 ESLint 快几十倍甚至上百倍的速度扫描你的代码,找出潜在的逻辑错误、未使用的变量、不安全的语法等。

  • 基于 Oxfmt 的代码格式化检查

    Oxfmt 同样是基于 Rust 的格式化工具,它会检查你的代码缩进、引号、逗号等是否符合规范。

    留意 vp check 并不会主动修改发现的格式化错误,除非执行 vp check --fix,即带上 fix 参数。

  • 基于 tsgolint 的 TypeScript 类型检测

    tsgolint 是基于 Go 语言开发的类型检测工具,它会和 Oxlint互相通讯、一并执行 TypeScript 类型检测任务,整体效率远超缓慢的 tsc(Node.js)。


示例

在项目中执行 vp check --fix 效果如下:

77667.gif

2.7 vp staged —— Git 暂存区文件检测

vp staged 是 Vite Plus 内置的 lint-staged 替代品,它会根据 vite.config.ts 中的配置去处理放入了 Git 暂存区(Staged)的文件:

/** vite.config.ts **/

import { defineConfig } from "vite-plus";

export default defineConfig({
  staged: {
    "*": "vp check --fix",   // 检查并修复暂存区文件的代码问题
  },
  // 略...
});

vp staged 指令的意义主要有两个:

  • 当你接手了一个文件众多的老项目时,只需针对提交的文件进行 vp check
  • 无需额外配置 .lintstagedrc 文件,纳入 vite.config.ts 里进行统一维护。

留意 vp staged 一般是搭配 Vite Plus 项目自带的 .vite-hooks/pre-commit 钩子来使用的:

image.png

Vite Plus 会在用户执行 Git commit 之前自动触发该钩子,因此常规无需手动来执行 vp staged 指令。

2.8 vp test —— 通过 Vitest 执行测试

Vitest 是一款兼容 Jest 的高性能测试框架,而 vp test 是执行 vitest run 的语法糖,它会通过正则(**/*.{test,spec}.?(c|m)[jt]s?(x))自行匹配项目中的测试文件,执行并打印出测试结果:

image.png

2.9 vp env —— NodeJS 版本管理

Vite Plus 作为一个全链路工具,其愿景是实现「零配置开箱即用」和「绝对的环境一致性」,因此内置一个类似 nvm 的 NodeJS 管理器会非常必要。

假设团队里的某个项目是基于 NodeJS 22 开发的,假设某位新同学的电脑安装的是 NodeJS 20,那他可能跑不起来这个项目。

理论上传统的前端项目需要在 package.json 中维护一个 engines.node,开发该项目的同学需根据此字段(通过 nvm 之类的管理器)手动切换到对应版本的 NodeJS。但这一步很容易被遗漏,也增添了开发者的心智负担。

针对该问题,Vite Plus 内置了自己的、基于 Rust 开发的高性能 NodeJS 管理器 vp env,任何人在使用 vp 指令(例如 vp dev)操作项目时,vp env 都会自动去读取 .node-versionpackage.json 里的 engines.node —— 如果你的电脑上没有这个版本,它会自动下载这个版本的 NodeJS,并在隔离的环境中启动它,全程不需要手动干预!

💡 留意在安装 Vite Plus 时若拒绝 Vite Plus 接管 NodeJS 版本,后续希望使用此功能,需执行 vp env setup 指令安装垫片(Shims)并重启命令行终端。

以下是 vp env 的常用指令:

/** setup **/
vp env on           // 启用 Vite Plus 的 NodeJS 管理器来管理 NodeJS 版本
vp env off          // 禁用 Vite Plus 的 NodeJS 管理器,恢复使用系统默认的 NodeJS

/** Manage **/
vp env pin          // 锁定当前项目 NodeJS 版本号(会生成一份 .node-version 文件)
vp env unpin        // 移除项目的 .node-version 文件
vp env default      // 设置或打印全局默认的 NodeJS 版本号
vp env use          // 使用指定版本的 NodeJS,例如「vp env use 22」
vp env install      // 下载指定版本的 NodeJS
vp env uninstall    // 卸载指定版本的 NodeJS
vp env exec         // 使用指定版本的 NodeJS 运行命令,例如「vp env exec --node 22.0.0 node my-script.js」

/** Inspect **/
vp env list         // 打印本地已安装的全部 NodeJS 版本
vp env list-remote  // 获取并打印远程仓库上可安装的所有 NodeJS 版本
vp env doctor       // 诊断当前环境配置并打印诊断结果,若 vp env 执行的结果有误,建议执行该指令进行检测

示例

在 IDE 的终端执行 vp env doctor 后提示了 ⚠ GUI applications may not see shell PATH changes 错误:

image.png

这是由于在 macOS 中通过应用图标打开 IDE 的话,IDE 便属于 GUI 应用程序 —— GUI 应用程序启动时只会读取 ~/.zshenv 配置(而非 ~/.zshrc)。

然而 ~/.zshenv 中默认不携带 Vite Plus 的环境变量,因此我们需要手动将这块信息添加进去:

echo '. "$HOME/.vite-plus/env"' >> ~/.zshenv

处理后再执行 vp env doctor 会发现该配置问题已被解决:

image.png

2.10 vp migrate —— 迁移现有项目到 Vite Plus

如果想将现有的前端项目迁移至 Vite Plus,可以在项目下执行 vp migrate 指令:

88776666.gif

留意在迁移成功后,Vite Plus 会移除所有可并入 vite.config.ts 的工具配置文件(如 eslint.config.js)。

💡 目前 vp migrate 仅支持迁移版本号大于等于 7.0.0 的 Vite 项目,非 Vite 或者非 NodeJS 运行时(例如 Bun)的项目在迁移过程可能会出错。

三、vite.config.ts 配置

我们在前文有提到,通过 Vite Plus 创建的项目不再需要 vitest.config.ts.eslintrc.oxlintrc.json.prettierrc 等配置文件,而是统一在 vite.config.ts 文件中维护所有工具的配置,这种化繁为简的能力是 Vite Plus 的一大优势。

vite.config.ts 文件的可配置项参考如下:

import { defineConfig } from 'vite-plus';
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
  // dev 场景的服务配置
  server: {
    origin: 'http://127.0.0.1:8080',  // 修改静态资源(assets)的域名
  },
  
  // build 场景的服务配置
  build: {
    emitAssets: false,  // 构建时不要生成静态资源(assets)
  },
  
  // preview 场景的服务配置
  preview: {
    port: 8080,    // preview 服务端口号定为 8080
  },
  
  // 插件
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11'],
    }),
  ],

  // Vitest 配置
  test: {
    include: ['src/**/*.test.ts'],
  },

  // Oxlint 配置
  lint: {
    ignorePatterns: ['dist/**'],
  },

  // Oxfmt 配置
  fmt: {
    semi: true,
    singleQuote: true,
  },

  // ViteTask 配置
  run: {
    tasks: {    // 定义一个 generate:icons 任务,后续可以通过「vp run generate:icons」来执行
      'generate:icons': {
        command: 'node scripts/generate-icons.js',
        envs: ['ICON_THEME'],  // 声明这个任务依赖这个环境变量,如果环境变量变了,缓存失效
      },
    },
  },

  // `vp staged` 配置
  staged: {
    '*': 'vp check --fix',         // 匹配所有的文件,执行代码检查并自动修复格式
    // '*.css': 'stylelint --fix'  // 针对特定后缀执行特定命令
  },
});

各项的详细配置可参考官方文档:

配置项 说明 文档地址
server vp dev 场景的服务配置(与 Vite 的配置一致) vite.dev/config/serv…
build vp build 场景的服务配置(与 Vite 的配置一致) vite.dev/config/buil…
preview vp preview 场景的服务配置(与 Vite 的配置一致) vite.dev/config/prev…
plugins 插件(与 Vite 的配置一致) vite.dev/guide/using…
test Vitest 配置 viteplus.dev/config/test
lint Oxlint 配置 viteplus.dev/config/lint
fmt Oxfmt 配置 viteplus.dev/config/fmt
run ViteTask 配置 viteplus.dev/config/run
staged vp staged 配置 viteplus.dev/config/stag…

四、小结

Vite Plus 有效地解决了前端工程化中严重的「决策疲劳」和「工具碎片化」问题、大幅简化了日常开发,并提供了舒适的开发者体验和基于 Rust 的卓越性能。

对于笔者而言,Vite Plus 无疑定义了未来前端工具链的标杆形象。

目前 Vite Plus 仍处于 Alpha 阶段,会存在一些待优化的问题,因此暂时不建议将其接入到重要的项目中去(特别是需要通过 vp migrate 迁移的项目)。

如果你也对 Vite Plus 产生兴趣,可以点击下方的 FYI 链接获取更多资讯。

FYI

Vue-Vue2与Vue3核心差异与进化

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

熬夜通宵读完 VitePlus 全部源码,我后悔没早点看

尤雨溪搞了个大的。我花一整夜把它拆了个底朝天,发现这东西远比你想的恐怖。

1. 为什么我要一夜读 VitePlus

3 月 13 日深夜,尤雨溪在 X 上发了一条推文,平静地宣布了一件大事:

image.png

Vite+ 以 MIT 协议全量开源,官网 viteplus.dev 同步上线。

如果说 Vite 8 的发布是"换了个引擎",那 Vite+ 的开源就是直接掀了桌子——它不是 Vite 的升级版,而是一个全新的物种。一个二进制文件,吃掉你整条前端工具链。

官方定位很直白:"The Unified Toolchain for the Web"。一个 vp 命令,把 Vite、Vitest、Oxlint、Oxfmt、Rolldown、tsdown、Vite Task 七个项目合并成了一个 CLI。管构建,管运行时,管包依赖,管代码检查,管格式化,管测试,管打包发布,甚至管 monorepo 的任务编排。以前你需要 npm、pnpm、Vite、ESLint、Prettier、Jest、nvm 各自配置、各自维护,现在一个 vp 全包了。

性能数字更是夸张:生产构建比 webpack 快 40 倍,Oxlint 比 ESLint 快 50 到 100 倍,Oxfmt 比 Prettier 快 30 倍。背后是 VoidZero 的豪华阵容——尤雨溪、Oxc 核心作者 LONG Yinan、Jest 创造者 Christoph Nakazawa。GitHub 仓库 62.9% Rust,33.4% TypeScript。

朋友圈、技术群都在转发。铺天盖地都是功能介绍,但我看了一圈,没有一篇文章认真读过它的源码。

所有人都在说"大一统",但没人说清楚:它到底是怎么做到的?Rust 和 Node.js 是怎么配合的?一个 CLI 怎么可能同时接管 Vite、Vitest、Oxlint 这些完全不同的工具?

我决定自己搞清楚。

当晚,我 clone 了 vite-plus 的仓库,泡了一壶咖啡,准备从源码层面彻底拆解这个"前端工具链终结者"。

git clone https://github.com/voidzero-dev/vite-plus.git

接下来几个小时发生的事,彻底刷新了我对前端工程的认知。


2. 自顶向下总览架构

在翻了 Cargo.tomlpackages/ 目录和 CLAUDE.md 之后,我脑子里逐渐浮现出整个 vite-plus 的架构全貌。

我画了一张文字架构图:

┌─────────────────────────────────────────────────────────────┐
│                      用户命令入口                            │
│                    $ vp dev / build / test / lint           │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│               全局 CLI 层(Rust Binary: vp)                 │
│  crates/vite_global_cli  —  Clap 命令解析 + 命令路由         │
│  ├── A 类命令:包管理(install/add/remove)→ Rust 直接处理    │
│  ├── B 类命令:env/create/config → Rust 直接处理             │
│  └── C 类命令:dev/build/test/lint → 委托给 Node 层         │
└────────────────────────┬────────────────────────────────────┘
                         │ JsExecutor(spawn Node.js 进程)
┌────────────────────────▼────────────────────────────────────┐
│               本地 CLI 层(Node: vite-plus/dist/bin.js)     │
│  packages/cli/src/bin.ts  —  命令分发 + 工具解析             │
│  ├── 全局命令(create/migrate/config)→ JS 模块直接处理      │
│  └── 核心命令(dev/build/test/lint/fmt)→ NAPI 桥接到 Rust  │
└────────────────────────┬────────────────────────────────────┘
                         │ NAPI-RS 绑定
┌────────────────────────▼────────────────────────────────────┐
│            Rust 核心执行层(NAPI Binding)                    │
│  packages/cli/binding/src/  —  命令执行 + 任务调度           │
│  ├── cli.rs → vite_task Session API                         │
│  ├── exec/ → 工作区解析 + 参数处理                          │
│  └── 调用 JS 回调解析工具路径 → 启动子进程执行              │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                底层工具执行层                                 │
│  Vite (dev/build) │ Vitest (test) │ Oxlint (lint)           │
│  Oxfmt (fmt)      │ tsdown (pack) │ Rolldown (bundle)       │
└─────────────────────────────────────────────────────────────┘

我一开始以为 vite-plus 就是一个 CLI 壳子,封装了几个命令而已。但看到这个分层之后,我意识到它的架构远比我想象的复杂——它是一个双层混合架构:Rust 做入口和性能敏感的操作,Node.js 做生态桥接和配置解析,两者通过 NAPI-RS 和进程派生双通道通信。

这个设计其实很关键。让我一层一层拆。


3. CLI 入口拆解:Rust 是真正的门面

打开 crates/vite_global_cli/src/main.rs,这是用户输入 vp 时真正被执行的二进制文件的入口。

// crates/vite_global_cli/src/main.rs(关键逻辑,有删减)

#[tokio::main]
async fn main() -> ExitCode {
    vite_shared::init_tracing();

    let mut args: Vec<String> = std::env::args().collect();
    let argv0 = args.first().map(|s| s.as_str()).unwrap_or("vp");

    // 第一步:检测是否处于 shim 模式(被当作 node/npm/npx 调用)
    if let Some(tool) = shim::detect_shim_tool(argv0) {
        let exit_code = shim::dispatch(&tool, &args[1..]).await;
        return ExitCode::from(exit_code as u8);
    }

    // 第二步:如果没有子命令,弹出交互式选择器
    if args.len() == 1 {
        match command_picker::pick_top_level_command_if_interactive(&cwd) {
            Ok(TopLevelCommandPick::Selected(selection)) => {
                args.push(selection.command.to_string());
            }
            Ok(TopLevelCommandPick::Cancelled) => return ExitCode::SUCCESS,
            // ...
        }
    }

    // 第三步:标准化参数,然后解析并执行命令
    let normalized_args = normalize_args(args);
    match try_parse_args_from(normalized_args) {
        Ok(args) => match run_command(cwd, args).await { /* ... */ },
        Err(e) => { /* 错误处理 + 智能纠错 */ }
    }
}

设计解读:

这里有三个我觉得非常精巧的设计点。

第一,Shim 模式检测。 vp 不仅仅是 vp。当你运行 vp env on 后,系统的 nodenpmnpx 命令实际上会被重定向到 vp 这个二进制文件。vp 通过检查 argv[0](即进程被以什么名字调用)来判断自己是被当作 vp 还是 node 调用的。如果发现自己被当作 node 调用,就自动路由到 shim 逻辑,透明地使用它管理的 Node.js 版本来执行。这就是 vp env 能替代 nvm 的核心原理。

第二,交互式命令选择器。 当用户直接输入 vp 不带任何参数时,不是打印一堆 help 文字,而是弹出一个可交互的终端选择器(用 crossterm 实现),让用户用方向键选择想执行的命令。这个交互体验很 modern。

第三,智能命令纠错。 如果你输入了一个不存在的子命令,比如 vp fnt(想输 fmt),CLI 会用字符串相似度算法给出建议,并询问你是否要执行建议的命令。这种细节体验在纯 shell 脚本的 CLI 里是做不到的。

再看命令定义,在 cli.rs 里,命令被分成了三个清晰的类别:

// crates/vite_global_cli/src/cli.rs(有删减)

/// Available commands
#[derive(Subcommand, Debug)]
pub enum Commands {
    // =============================================
    // Category A: 包管理命令 — Rust 直接处理
    // =============================================
    Install { /* 大量参数... */ },
    Add { /* ... */ },
    Remove { /* ... */ },
    Update { /* ... */ },
    Dedupe { /* ... */ },
    Dlx { /* ... */ },
    // ...

    // =============================================
    // Category B: 全局/环境命令 — Rust 直接处理
    // =============================================
    Env { /* ... */ },
    Create { /* ... */ },
    Config { /* ... */ },
    // ...

    // =============================================
    // Category C: 开发命令 — 委托给 vite-plus Node 包
    // =============================================
    Dev { args: Vec<String> },
    Build { args: Vec<String> },
    Test { args: Vec<String> },
    Lint { args: Vec<String> },
    Fmt { args: Vec<String> },
    Check { args: Vec<String> },
    // ...
}

设计解读:

这个分类非常重要。A 类和 B 类命令,比如 installaddenv,整个流程都在 Rust 里完成,不需要启动 Node.js 进程。这意味着这些命令的启动速度极快——因为跳过了 Node.js 的冷启动开销。

而 C 类命令,比如 devbuildtestlint,则需要委托给 Node 层。原因很简单:这些命令本质上要运行的是 Vite、Vitest、Oxlint 这些 Node.js 生态的工具,它们的插件系统和配置加载都依赖 Node.js 运行时。

结论:CLI 不仅仅是入口,它是一个智能的工程调度中心。它根据命令类型决定走 Rust 快车道还是 Node.js 桥接通道,把"启动速度"和"生态兼容性"两个看似矛盾的目标统一了起来。


4. 配置系统:一个 defineConfig 统治所有

翻开 packages/cli/src/index.ts,这是 vite-plus 的 npm 包入口:

// packages/cli/src/index.ts

declare module '@voidzero-dev/vite-plus-core' {
  interface UserConfig {
    lint?: OxlintConfig;
    fmt?: FormatOptions;
    pack?: PackUserConfig | PackUserConfig[];
    run?: RunConfig;
    staged?: StagedConfig;
    lazy?: () => Promise<{ plugins?: VitestPlugin[] }>;
  }
}

export * from '@voidzero-dev/vite-plus-core';
export * from '@voidzero-dev/vite-plus-test/config';
export { defineConfig };

设计解读:

这里用了 TypeScript 的 declare module + interface 合并(declaration merging),在 Vite 原有的 UserConfig 上扩展了 lintfmtpackrunstaged 等字段。这意味着用户在 vite.config.ts 里通过 defineConfig 定义的配置,不仅包含 Vite 原有的配置(serverbuildplugins 等),还一并包含了 lint、格式化、测试、任务编排、库打包的配置。

一个文件管所有,不是口号,是真的在类型层面就统一了。

再看 defineConfig 的实现:

// packages/cli/src/define-config.ts(关键逻辑)

export function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport {
  if (typeof config === 'object') {
    if (config instanceof Promise) {
      return config.then((config) => {
        if (config.lazy) {
          return config.lazy().then(({ plugins }) =>
            viteDefineConfig({
              ...config,
              plugins: [...(config.plugins || []), ...(plugins || [])],
            }),
          );
        }
        return viteDefineConfig(config);
      });
    } else if (config.lazy) {
      return config.lazy().then(({ plugins }) =>
        viteDefineConfig({
          ...config,
          plugins: [...(config.plugins || []), ...(plugins || [])],
        }),
      );
    }
  } else if (typeof config === 'function') {
    return viteDefineConfig((env) => {
      const c = config(env);
      // 处理异步 + lazy 加载...
    });
  }
  return viteDefineConfig(config);
}

设计解读:

这里有一个 lazy 字段的处理逻辑特别值得注意。它允许插件被懒加载——在配置解析阶段不立即加载插件模块,而是延迟到实际需要时才加载。这对大型项目的启动速度有直接帮助。代码注释里也写了:"temporary solution to load plugins lazily, we need to support this in the upstream vite"。说明这个特性后续会推到 Vite 上游。

而更让我惊讶的是 Rust 侧对配置的处理。打开 crates/vite_static_config/src/lib.rs,这个 crate 做了一件非常聪明的事情:

// crates/vite_static_config/src/lib.rs(关键逻辑,有删减)

/// 静态解析 vite.config.* 文件,不需要执行 JavaScript。
/// 使用 oxc_parser 解析 AST,提取纯 JSON 字面量字段。

pub fn resolve_static_config(dir: &AbsolutePath) -> FieldMap {
    let Some(config_path) = resolve_config_path(dir) else {
        return FieldMap::no_config();
    };
    let Ok(source) = std::fs::read_to_string(&config_path) else {
        return FieldMap::unanalyzable();
    };
    parse_js_ts_config(&source, extension)
}

fn parse_js_ts_config(source: &str, extension: &str) -> FieldMap {
    let allocator = Allocator::default();
    let source_type = match extension {
        "ts" | "mts" | "cts" => SourceType::ts(),
        _ => SourceType::mjs(),
    };
    let parser = Parser::new(&allocator, source, source_type);
    let result = parser.parse();
    extract_config_fields(&result.program)
}

/// 搜索模式(按优先级):
/// 1. export default defineConfig({ ... })
/// 2. export default { ... }
/// 3. module.exports = defineConfig({ ... })
/// 4. module.exports = { ... }
fn extract_config_fields(program: &Program<'_>) -> FieldMap {
    for stmt in &program.body {
        if let Statement::ExportDefaultDeclaration(decl) = stmt {
            if let Some(expr) = decl.declaration.as_expression() {
                return extract_config_from_expr(expr);
            }
        }
        // CJS: module.exports = ...
        if let Statement::ExpressionStatement(expr_stmt) = stmt
            && let Expression::AssignmentExpression(assign) = &expr_stmt.expression
            && assign.left.as_member_expression().is_some_and(|m| {
                m.object().is_specific_id("module")
                    && m.static_property_name() == Some("exports")
            })
        {
            return extract_config_from_expr(&assign.right);
        }
    }
    FieldMap::unanalyzable()
}

这段代码让我直接愣住了。

它用 Oxc 的 Rust 解析器在不启动 Node.js 的情况下,直接从 vite.config.ts 的源码 AST 中提取配置字段。如果某个字段的值是纯 JSON 字面量(字符串、数字、布尔、数组、对象),就直接提取出来用;如果包含函数调用、变量引用等动态内容,就标记为 NonStatic,后续再通过 Node.js 侧的完整配置解析来获取。

为什么要这么做? 因为像 vp run 这样的命令需要读取 vite.config.ts 中的 run 字段来构建任务图,但如果每次都要启动 Node.js 来解析配置,就会有几百毫秒的冷启动开销。通过 Rust 侧的静态分析,对于大多数场景(run 字段通常是纯 JSON),可以跳过 Node.js 直接读取。

结论:配置系统是双层的——Rust 侧做静态快速提取(零 Node.js 开销),Node 侧做完整解析(支持动态配置)。两层配合,既保证了速度,又保证了灵活性。这是一个典型的工程抽象:在性能和表达力之间找到了最优平衡点。


5. 插件系统与调度机制:控制,而不是使用

翻到 packages/cli/src/bin.ts,这是 Node 侧的命令入口。当 C 类命令(devbuild 等)被委托到 Node 层后,所有命令的执行都汇聚到这个文件:

// packages/cli/src/bin.ts(关键逻辑,有删减)

import { run } from '../binding/index.js';
import { lint } from './resolve-lint.js';
import { pack } from './resolve-pack.js';
import { test } from './resolve-test.js';
import { vite } from './resolve-vite.js';
import { fmt } from './resolve-fmt.js';
import { doc } from './resolve-doc.js';
import { resolveUniversalViteConfig } from './resolve-vite-config.js';

const command = args[0];

// 全局命令直接由 JS 处理
if (command === 'create') {
  await import('./global/create.js');
} else if (command === 'migrate') {
  await import('./global/migrate.js');
} else {
  // 核心命令 —— 委托给 Rust 核心
  const exitCode = await run({
    lint,    // JS 函数:解析 oxlint 的二进制路径
    pack,    // JS 函数:解析 tsdown 的二进制路径
    fmt,     // JS 函数:解析 oxfmt 的二进制路径
    vite,    // JS 函数:解析 vite 的二进制路径
    test,    // JS 函数:解析 vitest 的二进制路径
    doc,     // JS 函数:解析 vitepress 的二进制路径
    resolveUniversalViteConfig,
    args: process.argv.slice(2),
  });
  process.exit(exitCode);
}

这里的 run 不是一个普通函数——它是 NAPI-RS 绑定的 Rust 函数。

来看 Rust 侧怎么接收这些 JS 回调的:

// packages/cli/binding/src/lib.rs(关键逻辑,有删减)

#[napi(object, object_to_js = false)]
pub struct CliOptions {
    pub lint: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub fmt: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub vite: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub test: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub pack: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub doc: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub resolve_universal_vite_config: Arc<ThreadsafeFunction<String, Promise<String>>>,
}

#[napi]
pub async fn run(options: CliOptions) -> Result<i32> {
    let cwd = current_dir()?;
    let (tx, rx) = tokio::sync::oneshot::channel();

    // 在新线程中运行,避免阻塞 Node.js 事件循环
    std::thread::spawn(move || {
        let cli_options = ViteTaskCliOptions {
            lint: create_resolver(lint_tsf, "Failed to resolve lint command"),
            fmt: create_resolver(fmt_tsf, "Failed to resolve fmt command"),
            vite: create_resolver(vite_tsf, "Failed to resolve vite command"),
            test: create_resolver(test_tsf, "Failed to resolve test command"),
            pack: create_resolver(pack_tsf, "Failed to resolve pack command"),
            // ...
        };

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all().build().unwrap();
        let local = tokio::task::LocalSet::new();
        let result = local.block_on(&rt, async {
            crate::cli::main(cwd, Some(cli_options), args).await
        });
        let _ = tx.send(result);
    });

    let result = rx.await?;
    // ...
}

设计解读:

这段代码揭示了 vite-plus 最精妙的架构设计之一——反向回调模式

传统的 Node.js 工具链是这样工作的:Node.js 是主控方,它调用各种工具。但在 vite-plus 里,Rust 是主控方。JS 侧传给 Rust 的不是数据,而是一组 resolver 函数——这些函数只负责一件事:告诉 Rust "这个工具的二进制路径在哪里"。

来看一个具体的 resolver 实现:

// packages/cli/src/resolve-lint.ts

export async function lint(): Promise<{
  binPath: string;
  envs: Record<string, string>;
}> {
  const oxlintMainPath = resolve('oxlint');
  const oxlintPackageRoot = dirname(dirname(oxlintMainPath));
  const binPath = join(oxlintPackageRoot, 'bin', 'oxlint');
  return {
    binPath,
    envs: {
      ...DEFAULT_ENVS,
      OXLINT_TSGOLINT_PATH: oxlintTsgolintPath,
    },
  };
}

JS 侧只做了"路径解析"——用 Node.js 的模块解析机制(require.resolve)来找到 oxlintvitestvite 等工具的真实路径。然后把路径和环境变量返回给 Rust 侧。

Rust 侧拿到路径后,才是真正的执行引擎。 它通过 vite_task crate 的 Session API 来:

  • 构建任务依赖图(Task Graph)
  • 按拓扑排序调度执行
  • 管理缓存和增量执行
  • fspy 追踪文件访问(用于智能缓存)

结论:vite-plus 不是在"使用"这些工具,它在"控制"这些工具。JS 侧只是一个"路径探测器",Rust 侧才是"调度中心"。这种反向控制的设计,让 Rust 能掌控整个执行流程的生命周期,包括并发调度、缓存决策、进程管理等——这些在纯 JS 实现里要么做不到,要么性能很差。


6. Rust 模块深度分析:不只是"写了个壳"

看完了架构全貌,让我钻进 Rust 代码的细节。先看 Cargo Workspace 的结构:

# Cargo.toml(根工作区)
[workspace]
resolver = "3"
members = ["bench", "crates/*", "packages/cli/binding"]

本地 crates 列表:

Crate 职责
vite_global_cli 全局 CLI 二进制(vp 命令)
vite_command 进程执行抽象 + fspy 文件追踪
vite_error 统一错误类型
vite_install 包管理逻辑(install/add/remove/update/dedupe)
vite_js_runtime Node.js 版本管理(下载/缓存/切换)
vite_migration 项目迁移逻辑
vite_shared 共享工具(输出格式、环境变量、tracing)
vite_static_config 静态配置解析(用 Oxc 解析 AST)
vite_trampoline Shim 二进制(用于 node/npm 命令代理)

外部 Git 依赖(来自 vite-task 仓库):

Crate 职责
vite_task 任务调度核心(Session API、任务图、调度器)
vite_workspace Monorepo 工作区解析
vite_path 类型安全的路径系统(AbsolutePath/RelativePath
vite_glob 文件 glob 匹配
fspy 文件系统访问追踪

还有 rolldownoxc 系列的几十个 crates 被作为依赖引入,用于构建和代码分析。

这个 crate 拓扑结构说明了什么?

Rust 在 vite-plus 中不是做某一个单一功能,而是覆盖了四大类职责:

6.1 命令解析与路由

vite_global_cli 用 Clap 框架实现了完整的 CLI 解析。所有命令、参数、别名、互斥选项都在 Rust 侧定义。这意味着 vp --help 的速度是原生的——不需要启动 Node.js。

6.2 包管理

vite_install 实现了跨包管理器的统一抽象。我看了它的依赖:它引入了 vite_workspace(工作区解析)、vite_command(进程执行)、vite_glob(glob 匹配)。它能识别当前项目用的是 npm、pnpm 还是 yarn,然后生成对应的命令来执行。

6.3 Node.js 版本管理

vite_js_runtime 是一个完整的 Node.js 版本管理器。它能:

  • 从官方源下载指定版本的 Node.js(支持 macOS/Linux/Windows)
  • 管理本地版本缓存
  • 根据项目配置(.node-versionengines.nodedevEngines.runtime)自动选择版本
  • 通过 shim 机制透明代理 node 命令

来看 JsExecutor 的版本解析逻辑:

// crates/vite_global_cli/src/js_executor.rs(关键逻辑,有删减)

pub struct JsExecutor {
    /// CLI 命令使用的运行时(A/B 类命令)
    cli_runtime: Option<JsRuntime>,
    /// 项目委托使用的运行时(C 类命令)
    project_runtime: Option<JsRuntime>,
    /// JS 脚本目录
    scripts_dir: Option<AbsolutePathBuf>,
}

/// 确保项目运行时已下载并缓存。
/// 解析顺序:
/// 1. 会话覆盖(vp env use 设置的环境变量)
/// 2. 会话覆盖(vp env use 写入的文件)
/// 3. 项目源(.node-version / engines.node / devEngines.runtime)
/// 4. 用户默认版本(config.json)
/// 5. 最新 LTS
pub async fn ensure_project_runtime(
    &mut self,
    project_path: &AbsolutePath,
) -> Result<&JsRuntime, Error> {
    // ...
}

设计解读:

注意这里有两个独立的运行时:cli_runtimeproject_runtime。CLI 自身的运行时版本是固定的(由 vite-plus 包的 devEngines.runtime 决定),而项目的运行时版本是动态的(由项目配置决定)。这种分离确保了 CLI 本身的稳定性不受项目配置影响。

6.4 静态配置解析

前面已经详细分析了 vite_static_config,它用 Oxc 解析器在 Rust 侧直接读取 vite.config.ts。这里补充一个设计细节:

// crates/vite_static_config/src/lib.rs

enum FieldMapInner {
    /// 对象没有展开运算符 → 闭合映射,缺失的键确定不存在
    Closed(FxHashMap<Box<str>, FieldValue>),
    /// 对象有展开运算符 → 开放映射,缺失的键可能存在于展开中
    Open(FxHashMap<Box<str>, serde_json::Value>),
}

它区分了"闭合映射"和"开放映射"两种状态。如果配置对象中没有 ...spread 语法,那么没出现在映射中的键就是确定不存在的;如果有 spread,那缺失的键可能在 spread 的源对象中,需要回退到 Node.js 侧解析。

这种精确的语义建模,不是"大概能用"的工程,是严谨的编译器级别的思维。

6.5 Rust 与 Node 的通信方式

通过源码分析,我确认了 Rust 和 Node 之间存在两种通信方式:

方式一:NAPI-RS(进程内调用)

packages/cli/binding/ 是一个 NAPI-RS 原生模块。它被编译为 .node 文件,由 Node.js 直接加载。JS 和 Rust 在同一个进程内通信,通过 ThreadsafeFunction 实现跨线程回调。

方式二:进程派生(跨进程调用)

全局 CLI(vp 二进制)通过 JsExecutor 派生 Node.js 子进程来运行 JS 脚本。Rust 管理 Node.js 的下载、版本选择和进程启动。

// crates/vite_global_cli/src/js_executor.rs

async fn run_js_entry(&self, project_path: &AbsolutePath,
    node_binary: &AbsolutePath, bin_prefix: &AbsolutePath,
    args: &[String]) -> Result<ExitStatus, Error>
{
    let entry_point = match Self::resolve_local_vite_plus(project_path) {
        Some(path) => path,        // 优先使用项目本地安装的 vite-plus
        None => {
            let scripts_dir = self.get_scripts_dir()?;
            scripts_dir.join("bin.js")  // 回退到全局安装
        }
    };
    let mut cmd = Self::create_js_command(node_binary, bin_prefix);
    cmd.arg(entry_point.as_path()).args(args)
       .current_dir(project_path.as_path());
    let status = cmd.status().await?;
    Ok(status)
}

这里还有一个细节让我印象深刻: 它用 oxc_resolver(Oxc 的模块解析器,Rust 实现)在 Rust 侧直接解析 vite-plus/package.json 的路径,来找到项目本地安装的 vite-plus。不需要启动 Node.js 就能完成模块解析。

结论:Rust 在 vite-plus 中的定位不是"性能加速层"这么简单。它是整个系统的控制平面(Control Plane),负责命令路由、版本管理、包管理、配置预读、任务调度、进程编排。Node.js 则是数据平面(Data Plane),负责具体工具的运行和生态桥接。这种"控制平面/数据平面"的分离,是企业级基础设施的典型设计模式。


7. 多工具整合机制:它不是在调用,是在接管

弄清楚了架构之后,我开始关注一个核心问题:vite-plus 是如何把 Vite、Vitest、Oxlint、Oxfmt、Rolldown、tsdown 这些工具整合到一起的?

7.1 工具路径解析:统一的 resolver 模式

每个工具都有一个对应的 resolver 文件:

packages/cli/src/
├── resolve-vite.ts      → Vite (dev/build/preview)
├── resolve-test.ts      → Vitest (test)
├── resolve-lint.ts      → Oxlint (lint/check)
├── resolve-fmt.ts       → Oxfmt (fmt/check)
├── resolve-pack.ts      → tsdown (pack)
├── resolve-doc.ts       → VitePress (doc)

每个 resolver 的接口完全一致:

interface ResolvedTool {
  binPath: string;                    // 工具二进制路径
  envs: Record<string, string>;       // 运行时环境变量
}

这个统一接口让 Rust 侧可以用完全相同的方式处理所有工具——解析路径、设置环境变量、启动子进程。

7.2 配置统一:从 vite.config.ts 到各工具

当用户在 vite.config.ts 里写:

import { defineConfig } from 'vite-plus'

export default defineConfig({
  server: { port: 3000 },           // → Vite
  lint: { options: { typeAware: true } },  // → Oxlint
  fmt: { /* ... */ },                // → Oxfmt
  test: { /* ... */ },               // → Vitest
  run: { tasks: { /* ... */ } },     // → vite_task
  staged: { '*.ts': 'vp check --fix' }, // → lint-staged 替代
  pack: { entry: ['src/index.ts'] }, // → tsdown
})

这个配置文件会被两条路径消费:

  1. Rust 侧的静态解析vite_static_config):提取 runlintfmt 等纯 JSON 字段
  2. Node 侧的完整解析resolve-vite-config.ts):通过 Vite 的 resolveConfig API 加载完整配置
// packages/cli/src/resolve-vite-config.ts

export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) {
  const config = await resolveViteConfig(viteConfigCwd);
  return JSON.stringify({
    configFile: config.configFile,
    lint: config.lint,
    fmt: config.fmt,
    run: config.run,
    staged: config.staged,
  });
}

这个函数被 NAPI 侧的 resolve_universal_vite_config 回调所引用。当 Rust 侧的静态解析无法满足需求时(比如配置包含动态值),就会调用这个 JS 回调来获取完整配置。

7.3 "接管"而非"调用"

传统的前端工具链是这样的:你分别安装 Vite、ESLint、Prettier、Vitest,然后分别配置它们。每个工具是独立的——它们各自有入口、各自解析配置、各自输出结果。

vite-plus 的做法完全不同:

  1. 统一入口:所有命令都从 vp 进入,用户不直接调用 eslintprettiervitest
  2. 统一配置:所有工具的配置都在 vite.config.ts 中声明
  3. 统一调度:Rust 核心负责解析命令、加载配置、启动工具进程
  4. 统一输出:所有 CLI 输出都经过 vite_shared::output 格式化(Rust 侧)或 utils/terminal.ts 格式化(JS 侧)

看 CLAUDE.md 里的这段话:

## CLI Output
All user-facing output must go through shared output modules instead of raw print calls.
- Rust: Use `vite_shared::output` functions (info, warn, error, note, success)
- TypeScript: Use `packages/cli/src/utils/terminal.ts` functions

连输出格式都统一了。这不是"把几个工具串起来",这是在"接管整个开发体验"。

结论:vite-plus 做的不是工具的简单组合,而是工具的完全收编。它用统一的 resolver 模式抽象了工具路径发现,用 declaration merging 统一了配置类型,用 NAPI 双向回调统一了执行流程。它在做的事情是——"工程能力统一入口"。


8. 我的顿悟:它不是工具,而是体系

读到这里,大概凌晨三点。

我一开始是把 vite-plus 当作一个 CLI 工具来看的——就像 npm、turborepo、或者 nx 那样。但读完源码后我意识到,它的定位远不止如此。

让我梳理一下认知的升级路径:

阶段一:它是一个 CLI 工具。vp devvp buildvp test 这些命令统一了。

阶段二:它是一个工程平台。 它不仅统一了命令,还统一了配置(一个 vite.config.ts)、统一了包管理(自动检测 npm/pnpm/yarn)、统一了版本管理(内建 Node.js 版本管理)。

阶段三:它是一个工程体系。 从 Rust 到 Node.js 的双层架构、从静态解析到动态解析的双轨配置系统、从 Clap 到 NAPI 到子进程的多级调度、从 fspy 文件追踪到任务图缓存的智能构建系统——这些不是一个工具能做的事。

这是一个完整的前端工程体系。

它背后的方法论可以概括为三条:

  1. 性能敏感的部分用 Rust,生态敏感的部分用 Node.js。 不是全部重写,而是在正确的层放正确的语言。
  2. 控制平面和数据平面分离。 Rust 负责"做什么"(命令路由、任务调度、配置预读),Node.js 负责"怎么做"(工具执行、插件加载)。
  3. 统一抽象而非统一实现。 vite-plus 没有重新实现 Vite 或 Vitest,而是通过 resolver + NAPI + 配置合并的方式,把现有工具收编到统一框架下。

这第三点尤其重要。它意味着 vite-plus 不会和现有生态对抗——所有 Vite 插件、Vitest 扩展、Oxlint 规则都能继续使用。它做的是在上层加了一个编排层。


9. 优势与代价:必须客观

说了这么多优点,但作为一个认真读过源码的人,我也看到了一些需要正视的问题。

优势

工程一致性。 一个团队里,不管谁来建项目,用 vp create 出来的结构都是一样的。lint 规则一样、格式化风格一样、测试框架一样、构建配置一样。这对大团队的效率提升是巨大的。

可复用性。 一个 vite.config.ts 就是整个项目的工程规范。你甚至可以把它抽成一个 shared preset,在多个项目间复用。不再需要同步 .eslintrc + .prettierrc + vitest.config.ts 的组合。

启动速度。 Rust 二进制启动是毫秒级的。vp --help 不需要启动 Node.js,vp env current 不需要启动 Node.js,vp run(读取静态配置时)不需要启动 Node.js。这种"零冷启动"体验在 CI 环境里尤其重要。

智能缓存。 vite_task 通过 fspy 追踪每个任务的文件访问,实现精确的缓存失效。这不是简单的"输入文件 hash",而是在系统调用层面追踪了每个 readwrite 操作。

代价

灵活性下降。 当你需要对某个工具做非常规的定制时,vite-plus 的抽象层可能会挡在中间。比如你想用 oxlint 的某个实验性 flag,需要确认 vite-plus 是否透传了这个 flag。

学习成本。 虽然 vite-plus 简化了日常使用,但当出了问题需要 debug 时,你面对的是一个 Rust + Node.js + NAPI 的混合架构。排查问题的路径比纯 Node.js 工具链要长。

版本耦合。 Vite、Vitest、Oxlint 的版本由 vite-plus 统一管理。如果你需要某个工具的特定版本(比如 Vitest 的 nightly),可能需要等 vite-plus 更新。

Alpha 阶段风险。 目前是 v0.1.x,API 可能随时变化。vp migrate 之后大多数项目还需要手动调整。在生产环境使用需要谨慎评估。


10. 总结:前端工程正在发生什么变化

读完整个源码库,我对前端工程的趋势有了更清晰的认知。

Node + Rust 混合架构正在成为主流

vite-plus 不是第一个走这条路的项目。Turbopack(Rust)、SWC(Rust)、Biome(Rust)、Bun(Zig)……用系统级语言重写前端工具链的性能关键路径,已经是不可逆的趋势。

但 vite-plus 的做法更加务实。它没有选择用 Rust 重写一切(像 Bun 那样),而是在 Rust 和 Node.js 之间找到了一条清晰的分界线:Rust 做基础设施(CLI、进程管理、版本管理、配置预读、任务调度),Node.js 做生态桥接(插件系统、工具执行、配置解析)。这种"各取所长"的混合架构,可能是当前最现实的路线。

前端工程正在体系化

过去十年,前端工程经历了从"手动配置"到"脚手架生成"到"框架约定"的演进。vite-plus 代表了下一步——"工具链统一"。它把开发服务器、构建、测试、Lint、格式化、包管理、版本管理、任务编排这些散落的能力,收拢到一个统一的体系里。

这和后端世界的 cargo(Rust)、go(Go)的设计理念是一致的——一个工具管一切。前端终于也开始走这条路了。

VitePlus 的行业意义

站在 Vite 78.7K Star 和每周 6900 万次 npm 下载的用户基数上,vite-plus 的迁移成本几乎是所有同类方案中最低的。它不需要你切换框架、不需要你重写配置、不需要你学习全新的 API——你的 Vite 插件还能用,你的 vite.config.ts 只需要改一下 import 路径。

从这个角度看,vite-plus 不只是一个工具的升级,它可能是整个前端工程体系演进的一个拐点。


凌晨五点,咖啡见底。合上 IDE,我觉得这一夜没白熬。

如果你也对前端工程体系化感兴趣,建议 clone 一份 vite-plus 的源码自己翻翻。从 crates/vite_global_cli/src/main.rs 开始,顺着调用链走一遍——你会对"现代前端工具链应该长什么样"有全新的理解。

git clone https://github.com/voidzero-dev/vite-plus.git
cd vite-plus
# 先看全局 CLI 入口
cat crates/vite_global_cli/src/main.rs
# 再看 NAPI 绑定层
cat packages/cli/binding/src/lib.rs
# 最后看 Node 侧入口
cat packages/cli/src/bin.ts

三个文件,就能看懂整条链路。


参考资料:

Vue2:数组/对象操作避坑大全

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

Vue3:ref 与 reactive 超全对比

前言

在 Vue 3 的 Composition API 中,refreactive 是定义响应式数据的两大基石。很多初学者常纠结于“什么时候该用哪个”。本文将从底层原理到实战场景,带你彻底理清两者的区别。

一、 核心概念对比

1. ref:全能型选手

  • 定义:主要用于定义基本类型(String, Number, Boolean 等),也可以定义引用类型。

  • 本质:通过对原始值进行包装,生成一个具有 .value 属性的对象。对于引用类型,ref 内部会自动调用 reactive 来处理。

  • 访问控制

    • 在 JS 中必须通过 .value 访问;
    • <template> 模板中,Vue 会自动解包,直接写变量名即可,无需加 .value。

2. reactive:对象专家

  • 定义:专门用于定义引用类型(Object, Array, Map, Set)。

  • 本质:基于 ES6 Proxy 实现,直接代理整个对象。

  • 访问控制:像操作普通原生对象一样直接访问属性,无需 .value

    注意: 传入基本类型会触发 Vue 警告且丢失响应式。


二、 深度差异对比

特性 ref reactive
支持类型 基本类型 + 引用类型 仅限 引用类型
JS 访问方式 .value 直接访问属性
模板访问 自动解包,无需 .value 直接访问
底层实现 包装基本类型,内部调用 reactive 处理引用类型 基于 Proxy 深度代理整个对象
替换整个对象 支持 (ref.value = 新对象/新数组) 不支持(直接赋值会丢失代理,失去响应式)
解构支持 直接解构丢失响应式(需 toRefs 直接解构丢失响应式(需 toRefs

三、 使用场景:我该怎么选?

推荐使用 ref 的场景:

  1. 基本类型数据:计数器、开关状态、输入框的值。

  2. 需要重置的数据:例如从后端获取列表后,直接 list.value = res.data

  3. 简单组件逻辑:代码更清晰,.value 提醒这是一个响应式变量。

推荐使用 reactive 的场景:

  1. 复杂业务模型:包含多个相互关联属性的大对象(如用户信息、表单整组数据)。

  2. 追求原生感:不希望在逻辑代码中到处看到 .value

  3. 聚合数据:将一类变量聚合在一个对象中管理,减少变量声明。


四、 高频易错点

1. reactive 直接赋值整个对象会丢失响应式

let state = reactive({ count: 0 });
// ❌ 错误操作:这会导致 state 失去响应式,因为它变成了一个普通的普通对象
state = { count: 1 }; 

// ✅ 正确方案 A (ref):
const state = ref({ count: 0 });
state.value = { count: 1 };

// ✅ 正确方案 B (Object.assign):
Object.assign(state, { count: 1 });

2. 解构 reactive 数据丢失响应式

当你需要从一个响应式对象中提取属性并保持响应式时,必须使用 toRefs,否则会丢失响应式

const props = reactive({ title: 'Vue3', author: 'Gemini' });
// 直接解构:const { title } = props; -> title 只是一个普通的字符串
const { title } = toRefs(props); // -> title 变成了一个 ref,保持响应式

3. Watch 监听的差异

  • 监听 ref:默认只监听 .value 的变化,如果 ref 包裹的是对象,深度监听需要开启 { deep: true }

  • 监听 reactive:默认强制开启深度监听,且无法关闭。


📝 总结

  • ref 是万金油,虽然多了个 .value,但胜在灵活且不易出错。
  • reactive 适合组织复杂的对象数据,但要注意赋值和解构的陷阱。

如何在 CSS 中正确使用 if()

CSS 正在不断进化,而 if() 函数 的引入,就是其中一个非常值得关注的新特性。它让我们可以直接在 CSS 中编写条件逻辑,根据不同状态动态切换样式,从而减少对 JavaScript 的依赖。

if() 的语法非常直观,甚至有点类似我们熟悉的编程语言中的条件判断。但在实际使用中,它的行为却和直觉并不完全一致。如果不了解其中的细节,很容易写出“看起来正确,但结果却错误”的代码。

其中最常见的一个问题是:if() 的判断依据到底是“计算后的值”,还是“原始值”

这个问题看似简单,却直接决定了你的条件是否能够正确命中,也是很多人第一次使用 if() 时踩坑的根源。接下来,我们通过一个具体的例子来看看这个问题是如何产生的,以及应该如何正确处理。

来看一个简单的例子:

.box {
    --n: 6;
    --f: calc(var(--n) / 2);
    background: if(
        style(--f: 3): red;
        else: green
    );
}

从直觉上看,--n6--f 计算后应该是 3 ,因此条件成立,.box 元素的背景颜色理应是红色(red)。但实际渲染结果却是绿色(green)。

问题的关键在于 if() 中的 style() 并不会基于“计算后的结果”进行判断,而是直接对“原始值”进行字符串匹配。也就是说,浏览器看到的是 calc(var(--n) / 2) 这个表达式本身,而不是它计算后的数值 3 ,因此条件匹配失败。

.box {
    --n: 6;
    --f: calc(var(--n) / 2);
    /* 不匹配,返回 false,因此背景颜色是 green*/
    background: if(
        style(--f: 3): red;
        else: green
    );
}

.box {
    --n: 6;
    --f: calc(var(--n) / 2);
    /* 匹配,返回 true,因此背景颜色是 red */
    background: if(
        style(--f: calc(var(--n) / 2)): red;
        else: green
    );
}

Demo 地址:codepen.io/airen/full/…

正确方式:使用 @property 让值参与计算

如果希望 if() 的判断基于“计算后的结果”,而不是原始字符串,就需要借助 @property 来注册自定义属性。通过这种方式,可以明确告诉浏览器该变量的类型,从而让它在参与比较之前先完成计算与解析。例如:

@property --f {
    syntax: "<number>";
    inherits: false;
    initial-value: 0;
}

.box {
    --n: 6;
    --f: calc(var(--n) / 2);
    background: if(style(--f: 3): red; else: green);
}

在这个例子中,--f 被注册为 <number> 类型,因此浏览器会先对 calc(var(--n)/2) 进行求值,得到数值 3,再参与条件判断。也正因为如此,if() 中的条件能够正确匹配,最终背景颜色会按预期显示为红色。

Demo 地址:codepen.io/airen/full/…

换句话说,一旦自定义属性具备了明确的类型信息,它就不再只是一个“字符串”,而是一个可以参与计算和比较的真正数值。这正是解决该问题的关键所在。

温馨提示:如果你想更深入了解 @property 的用法与原理,推荐继续阅读《CSS 自定义属性: @property》和《Web UI:你需要的是 @property》,可以帮助你更系统地掌握这一特性。

不涉及计算时:无需注册属性

当自定义属性的值只是一个固定值,而不包含 calc() 等计算表达式时,其实不需要使用 @property 进行注册。这种情况下,if() 的判断逻辑非常直接——它只是对值进行字符串层面的精确匹配。例如:

.box {
    --f: error;
    background: if(style(--f: error): red; else: green);
}

.box {
    --v: 0;
    background: if(style(--v: 0): red; else: green);
}

在这个例子中,--f--v 都是明确的静态值,浏览器无需进行额外计算,因此 if() 可以直接完成匹配,并按预期应用对应的样式。

Demo 地址:codepen.io/airen/full/…

也就是说,只要不涉及计算,if() 的行为就是简单可靠的字符串比较,这也是它最直观、最容易理解的一种使用方式。

另一种技巧:使用 = 进行数值比较

除了使用 @property 让属性参与计算之外,还有一种更简洁的方式可以达到相同效果,那就是在 if() 中使用 = 运算符进行匹配。例如:

.box {
    --n: 6;
    --f: calc(var(--n)/2);
    background: if(style(--f = 3): red; else: green);
}

这种写法与使用 : 的行为本质不同。= 会基于“计算后的结果”进行比较,而不是简单的字符串匹配。因此,即使 --f 是通过 calc() 计算得来的,也能正确参与判断。

也正因为如此,这种方式可以在不注册 @property 的情况下,依然得到正确的结果。在很多场景下,它是一种更轻量、更实用的解决方案。

如果你继续深入探索,会发现 if() 还可以与样式查询(style queries)结合,构建更强大的条件判断能力,例如基于范围的比较(>< 等)。这类用法已经超出了基础范畴,属于更进阶的技巧。如果你想进一步了解相关内容,可以查阅《CSS 技巧:样式查询与 if() 函数隐藏技巧》,会有更系统和深入的讲解。

小结

从整体来看,if() 的判断方式可以归纳为两种核心逻辑:一种是使用 : 进行匹配,此时本质上是字符串级别的比较,不会触发计算;另一种是使用 =>< 等,则会基于计算后的结果进行数值比较。

理解这一区别至关重要。它不仅关系到条件是否能够正确命中,也直接决定了你是否需要借助 @property 来让自定义属性参与计算,从而影响最终的渲染结果。

掌握这一点,你就能更从容地在实际项目中使用 if(),避免那些“看起来没问题,但结果却不对”的常见陷阱。

踩坑记录:Mac M系列芯片下 pnpm dlx 触发的 esbuild 架构不匹配错误

踩坑记录:Mac M系列芯片下 pnpm dlx 触发的 esbuild 架构不匹配错误

背景

在日常开发中,克隆一个前端项目后,我们习惯性地执行 pnpm installpnpm dev。但最近在搭载 Apple Silicon (M系列芯片) 的 Mac 上,项目启动时却抛出了一个极其刺眼的致命错误,甚至重新 git clone 项目也无法解决

错误现象

执行命令后,终端抛出如下错误堆栈,直接导致进程退出 (exit code 1):

node:internal/modules/run_main:123
    triggerUncaughtException(
    ^
Error:
You installed esbuild for another platform than the one you're currently using.
This won't work because esbuild is written with native code and needs to
install a platform-specific binary executable.
Specifically the "@esbuild/darwin-x64" package is present but this platform
needs the "@esbuild/darwin-arm64" package instead. People often get into this
situation by installing esbuild with npm running inside of Rosetta 2 and then
trying to use it with node running outside of Rosetta 2, or vice versa (Rosetta
2 is Apple's on-the-fly x86_64-to-arm64 translation service).
...
    at generateBinPath (/Users/xxx/Library/Caches/pnpm/dlx/fa19b49eb7fa...)
    at esbuildCommandAndArgs (/Users/xxx/Library/Caches/pnpm/dlx/fa19b...)
...

问题分析

错误信息其实已经说得很清楚了:架构不匹配 (Architecture Mismatch)

esbuild 是一个使用 Go 语言编写的高性能构建工具,它在安装时会根据当前的操作系统和 CPU 架构下载对应的底层二进制文件。

在我们的场景中:

  • 期望环境:Mac M系列芯片,原生架构是 arm64 (darwin-arm64)。
  • 实际加载的包:系统却发现本地存在的是为 Intel 芯片编译的包 darwin-x64

为什么重新 clone 项目也没用?

这就是这个 Bug 最搞人心态的地方。如果你仔细观察报错堆栈,会发现错误并不是从项目本地的 node_modules 抛出的,而是来自: /Users/xxx/Library/Caches/pnpm/dlx/...

这说明问题出在执行 pnpm dlx 命令时。pnpm dlx 类似于 npx,用于临时下载并执行一个包。pnpm 把之前(可能是在旧的 Intel Mac 上,或者是误用 Rosetta 终端时)下载的 darwin-x64 版本的包缓存在了全局的 dlx 目录中

当你再次运行项目时,哪怕项目是全新 clone 的,pnpm dlx 依然会去读取这个全局的、架构错误的缓存,从而导致崩溃。

解决方案

明确了是全局缓存作祟,解决起来就非常简单粗暴了:进行深度清理。

第一步:核实 Node.js 架构

首先,必须确保你当前运行的 Node.js 本身是原生的 arm64 版本,而不是通过 Rosetta 2 翻译运行的 Intel 版本。

在终端输入:

node -p "process.arch"
  • 如果输出是 arm64,说明环境正确,请进行下一步。
  • 如果输出是 x64,说明你的 Node.js 版本不对。你需要卸载当前的 Node.js,并重新安装原生版本(例如使用 nvm install <version>)。同时检查你的终端软件(Terminal/iTerm2)是否在“显示简介”中勾选了“使用 Rosetta 打开”,如果有,请取消勾选。

第二步:彻底清空 pnpm 的全局 DLX 缓存

既然缓存污染了,我们就手动将其根除。在终端执行以下命令:

# 强制删除 pnpm 的全局 dlx 缓存目录(将 /Users/xxx 替换为你报错信息中的实际路径,通常是 ~/.local/share/pnpm 或 ~/Library/Caches/pnpm)
rm -rf ~/Library/Caches/pnpm/dlx

# 清理 pnpm 的全局 store 缓存
pnpm store prune

第三步:重新安装依赖

回到你的项目根目录,为了保险起见,清空本地的 node_modules,然后重新安装:

# 删除本地 node_modules
rm -rf node_modules

# 重新安装依赖,此时 pnpm dlx 会重新拉取正确的 arm64 版本
pnpm install

# 启动项目
pnpm dev

总结

当我们在 Mac M 系列芯片上遇到类似 @esbuild/darwin-x64@esbuild/darwin-arm64 的冲突,且重装项目无效时,一定要优先排查全局缓存(如 pnpm dlx 缓存目录)以及 Node.js 自身的运行架构。暴力清理特定的全局缓存目录,往往是解决此类“幽灵报错”的最快途径。

JavaScript 对象操作进阶:从属性描述符到对象创建模式

背景与收益

在实际开发中,我们经常遇到这样的场景:需要批量创建结构相似的对象,或者需要精确控制对象属性的行为(可写、可枚举、可配置等)。如果只用最基础的对象字面量和 Object.defineProperty,代码会变得冗长且难以维护。

本文将带你深入理解:

  • 如何高效地批量定义对象属性及其描述符
  • JavaScript 提供的对象限制方法及其实战应用场景
  • 创建多个同类对象的最佳实践:工厂模式 vs 构造函数

适合已掌握 JavaScript 基础语法、希望提升对象操作能力的开发者。


一、批量定义对象属性

1.1 问题场景

在上一章节中,我们学习了 Object.defineProperty 来定义单个属性的描述符。但实际开发中,一个对象往往有多个属性需要配置。如果每个属性都调用一次 defineProperty,代码会非常冗余:

let obj = { JS: 1 };

Object.defineProperty(obj, 'name', {
  value: 'XiaoWu',
  writable: true,
  enumerable: true,
  configurable: true
});

Object.defineProperty(obj, 'age', {
  value: 18,
  writable: false,
  enumerable: true,
  configurable: true
});

能否通过遍历来优化?当然可以。

1.2 手动实现批量定义

我们可以将多个属性的描述符封装成对象,然后遍历处理:

let obj = {
  JS: 1
};

let props = {
  name: {
    value: 'XiaoWu',
    writable: true,
    enumerable: true,
    configurable: true
  },
  age: {
    value: 18,
    writable: false,
    enumerable: true,
    configurable: true
  }
};

function defineProperties(obj, properties) {
  for (let prop in properties) {
    // hasOwnProperty 用于判断是否为对象自有属性(非继承属性)
    if (properties.hasOwnProperty(prop)) {
      Object.defineProperty(obj, prop, properties[prop]);
    }
  }
  return obj;
}

defineProperties(obj, props);

console.log(obj.name);  // XiaoWu
console.log(obj.age);   // 18

1.3 原生方法:Object.defineProperties

JavaScript 原生提供了 Object.defineProperties 方法,功能与我们手动实现的一致,但处理了更多边界情况:

Object.defineProperties(obj, props);

实战案例:私有属性的访问控制

在实际开发中,我们常用 _ 前缀标识私有属性,并通过 getter/setter 控制访问:

var obj = {
  _age: 20  // 私有属性,存储真实数据
};

Object.defineProperties(obj, {
  name: {
    configurable: true,
    enumerable: true,
    value: "小吴",
    writable: true
  },
  age: {
    configurable: false,
    enumerable: false,  // 不可枚举,for-in 遍历时不会出现
    get: function() {
      return this._age;
    },
    set: function(value) {
      this._age = value;
    }
  }
});

console.log(obj.age);  // 20
console.log(obj);      // { _age: 20, name: '小吴' }  注意:age 不可枚举
obj.age = 18;
console.log(obj.age);  // 18

设计思想

  • _age 是真实数据存储,外部不应直接访问
  • age 是对外暴露的接口,通过 getter/setter 控制访问逻辑
  • 这种"马甲模式"可以在 setter 中加入校验、日志等逻辑,保证数据安全

1.4 对象字面量中的 getter/setter

除了使用 defineProperties,我们也可以直接在对象字面量中定义 getter/setter:

var obj = {
  _age: 20,
  set age(value) {
    this._age = value;
  },
  get age() {
    return this._age;
  }
};

两种写法的差异

写法 控制台输出 精细控制
对象字面量 { _age: 20, age: [Getter/Setter] } 无法配置 configurable/enumerable
defineProperties { _age: 20 } 可精确控制所有描述符

图 1:getter/setter 在终端的表达形式

选择建议

  • 简单场景:直接在对象字面量中定义,代码更简洁
  • 需要精细控制(如设置不可枚举):使用 defineProperties

二、对象方法补充

2.1 获取属性描述符

之前我们提到,[[]] 标记的内部属性无法直接访问,需要通过特定 API 获取:

// 获取单个属性的描述符
Object.getOwnPropertyDescriptor(obj, prop);

// 获取所有自有属性的描述符
Object.getOwnPropertyDescriptors(obj);

示例

var obj = {
  names: "小吴",
  age: 18
};

console.log(Object.getOwnPropertyDescriptor(obj, 'names'));
// { value: '小吴', writable: true, enumerable: true, configurable: true }

console.log(Object.getOwnPropertyDescriptors(obj));
// {
//   names: { value: '小吴', writable: true, enumerable: true, configurable: true },
//   age: { value: 18, writable: true, enumerable: true, configurable: true }
// }

图 2:obj 对象的属性描述符详情

2.2 对象限制方法

JavaScript 提供了三个方法来限制对象的可变性,它们的限制程度逐级递增:

2.2.1 Object.preventExtensions - 禁止扩展

禁止给对象添加新属性,但可以修改和删除现有属性:

var obj = {
  names: "小吴",
  age: 18
};

Object.preventExtensions(obj);
obj.newProperty = 'new';  // 添加失败(严格模式下报错)
console.log(obj.newProperty);  // undefined

2.2.2 Object.seal - 密封对象

preventExtensions 基础上,将所有现有属性的 configurable 设为 false,禁止删除和重新配置属性:

Object.seal(obj);
delete obj.age;  // 删除失败
console.log(obj.age);  // 18
obj.names = "JS高级";  // 可以修改值
console.log(obj.names);  // JS高级

2.2.3 Object.freeze - 冻结对象

seal 基础上,将所有现有属性的 writable 设为 false,完全冻结对象:

Object.freeze(obj);
obj.names = "why";  // 修改失败
console.log(obj.names);  // JS高级

实战应用:Vue 性能优化

在 Vue 中,响应式系统会劫持对象的 getter/setter。如果有大量静态数据(如几十万条配置数据)不需要响应式,可以用 Object.freeze 冻结,避免 Vue 进行响应式处理,显著提升性能:

// 大量静态数据
const staticData = Object.freeze([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  // ... 几十万条
]);

export default {
  data() {
    return {
      list: staticData  // 不会被 Vue 响应式处理
    };
  }
};

三种方法对比

方法 禁止新增 禁止删除 禁止修改值 禁止重新配置
preventExtensions
seal
freeze

三、创建多个对象的方案

3.1 问题场景

假设我们需要创建多个 Person 对象,每个对象都有 name、age、sex、address 等属性,以及 eating、running 等方法。如果用对象字面量:

var p1 = {
  name: "小吴",
  age: 20,
  sex: "男",
  address: "福建",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

var p2 = {
  name: "why",
  age: 35,
  sex: "男",
  address: "广州",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

问题:代码重复率极高,难以维护。

解决方案

  1. 工厂模式
  2. 构造函数
  3. ES6 Class(后续章节)
  4. 原型 + Object.create(后续章节)

本文重点讲解前两种。

3.2 方案一:工厂模式

3.2.1 基本实现

工厂模式的核心思想:抽离共性,参数化差异,流水线生产

function createPerson(name, age, sex, occupation, address) {
  var p = new Object();
  p.name = name;
  p.age = age;
  p.sex = sex;
  p.occupation = occupation;
  p.address = address;
  p.eating = function() {
    console.log(this.name + "在吃满汉全席");
  };
  return p;
}

var p1 = createPerson("小吴", 20, "男", "大三学生", "福建");
var p2 = createPerson("why", 35, "男", "全栈工程师兼教师", "广州");

console.log(p1, p2);

图 3:new 调用所产生的结构共性

3.2.2 工厂模式的缺点

  1. 类型信息丢失:所有对象的类型都是 Object,无法区分是 Person 还是其他类型
  2. 无法利用原型链:每个对象都有自己的方法副本,无法共享,浪费内存
  3. 调试困难:堆栈跟踪中难以定位对象的创建源
console.log(p1);  // Object { name: '小吴', age: 20, ... }
// 无法看出这是一个 Person 对象

适用场景

  • 简单的对象创建,不需要类型区分
  • 临时性的数据结构封装

3.3 方案二:构造函数

3.3.1 什么是构造函数

构造函数本质上是普通函数,但通过 new 关键字调用时,会执行特殊的对象创建流程:

function foo() {
  console.log("foo~");
}

// 普通调用
foo();

// 构造函数调用
new foo();  // 或 new foo

3.3.2 new 操作符的执行流程

当使用 new 调用函数时,会自动执行以下步骤:

  1. 在内存中创建一个新的空对象
  2. 将这个对象的 [[Prototype]] 指向构造函数的 prototype 属性
  3. 将构造函数内部的 this 指向这个新对象
  4. 执行构造函数的代码(给 this 添加属性)
  5. 如果构造函数返回一个对象,则返回该对象;否则返回步骤 1 创建的对象
function foo() {
  // 内部隐式执行:
  // var obj = {};
  // this = obj;
  console.log("foo~");
  // 隐式返回 this
}

var f1 = new foo();  // foo~
console.log(f1);     // foo {}

类型验证

function XiaoWu(name) {
  this.name = name;
  console.log("我是小吴");
}

var f1 = new XiaoWu("小吴");  // 我是小吴
console.log(f1);  // XiaoWu { name: '小吴' }
console.log(f1.__proto__.constructor.name);  // XiaoWu

3.3.3 构造函数实现

function Person(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new Person("小吴同学", 20, "男", "福建");
console.log(f1);
// Person {
//   name: '小吴同学',
//   age: 20,
//   sex: '男',
//   address: '福建',
//   eating: [Function (anonymous)],
//   running: [Function (anonymous)]
// }

var f2 = new Person("小满zs", 23, "男", "北京");
var f3 = new Person("洛洛", 20, "萌妹子", "福建");

图 4:构造函数 Person 调用结果

3.3.4 如何识别构造函数

构造函数与普通函数在语法上没有区别,社区约定了以下规范:

  1. 命名规范:首字母大写,使用大驼峰命名(PascalCase)
  2. 编辑器提示:当函数内使用 this 赋值时,编辑器会提示"此构造函数可能会转换为类声明"
function XiaoWu(name) {
  this.name = name;  // 使用 this 赋值,编辑器识别为构造函数
}

图 5:如何区分是否为构造函数(编辑器中的构造函数)

注意:只有通过 new 调用时,函数才真正成为构造函数。

3.3.5 构造函数的缺点

每次创建对象时,方法都会被重新创建,导致内存浪费:

function foo() {
  function bar() {
    console.log("你猜一不一样");
  }
  return bar;
}

var f1 = foo();
var f2 = foo();
console.log(f1 === f2);  // false  每次调用都创建新的函数对象

应用到构造函数

function XiaoWu(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  // 每次 new 都会创建新的函数对象
  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new XiaoWu("小吴同学", 20, "男", "福建");
var f2 = new XiaoWu("小吴同学", 20, "男", "福建");

console.log(f1.eating === f2.eating);  // false
console.log(f1.running === f2.running);  // false

问题分析

  • 虽然 f1f2eating 方法功能完全相同,但它们是两个不同的函数对象
  • 当创建大量实例时,会造成内存浪费

解决方案:使用原型(Prototype),将方法定义在原型上,所有实例共享。这将在下一章节详细讲解。


四、工厂模式 vs 构造函数

对比维度 工厂模式 构造函数
调用方式 普通函数调用 使用 new 关键字
类型识别 所有对象都是 Object 可以识别具体类型(如 Person
原型链 无法利用 可以利用原型共享方法
内存占用 每个对象独立方法 每个对象独立方法(未优化时)
代码复杂度 简单直观 需要理解 newthis
适用场景 简单对象创建 需要类型区分和原型链的场景

选择建议

  • 简单场景、不需要类型区分:工厂模式
  • 需要类型识别、后续会用到原型链:构造函数
  • 现代开发:优先使用 ES6 Class(本质是构造函数的语法糖)

五、实战建议

5.1 属性描述符使用场景

  1. 配置对象保护:将配置对象冻结,防止被意外修改
  2. 私有属性模拟:通过不可枚举 + getter/setter 实现访问控制
  3. 数据校验:在 setter 中加入校验逻辑

5.2 对象创建模式选择

  1. 单个对象:对象字面量
  2. 少量同类对象:工厂模式或构造函数
  3. 大量同类对象:构造函数 + 原型(下一章)
  4. 现代项目:ES6 Class

5.3 性能优化要点

  1. 避免在构造函数中定义方法:应该定义在原型上(下一章详解)
  2. 大量静态数据使用 Object.freeze:特别是在 Vue 等响应式框架中
  3. 合理使用属性描述符:不要过度使用,会增加代码复杂度

六、总结与下一步

6.1 核心要点

  1. Object.defineProperties 可以批量定义属性描述符,比多次调用 defineProperty 更高效
  2. preventExtensionssealfreeze 三个方法提供了不同级别的对象保护
  3. 工厂模式简单直观,但无法识别对象类型
  4. 构造函数通过 new 调用,可以创建具有特定类型的对象
  5. 构造函数的缺点是方法无法共享,需要通过原型解决

6.2 遗留问题

在本文中,我们多次提到"原型"(Prototype),并且发现构造函数存在方法无法共享的问题。在控制台查看对象时,总能看到神秘的 [[Prototype]] 属性:

图 6:对象中的原型世界

6.3 下一章预告

在下一章节中,我们将深入学习:

  • 什么是原型(Prototype)和原型链
  • 如何通过原型实现方法共享,解决构造函数的内存浪费问题
  • 原型链的查找机制和继承原理
  • 大量内存图帮助理解原型的指向关系

原型是 JavaScript 中最重要的概念之一,理解原型是掌握 JavaScript 面向对象编程的关键。


WebGPU 基础 (WebGPU Fundamentals)

WebGPU 基础 (WebGPU Fundamentals)

本文将尝试向你教授 WebGPU 的最基本基础知识。

在阅读本文之前,默认你已经了解 JavaScript。本文将广泛使用数组映射(mapping arrays)、解构赋值(destructuring assignment)、展开运算符(spreading values)、async/await、es6 模块等概念。如果你还不了解 JavaScript 并想学习它,请参阅 JavaScript.info、Eloquent JavaScript 和/或 CodeCademy。

如果你已经了解 WebGL,请阅读这篇文章

WebGPU 是一个允许你执行 2 项基本操作的 API:

  1. 在纹理(textures)上绘制三角形/点/线
  2. 在 GPU 上运行计算(computations)

仅此而已!

除此之外,关于 WebGPU 的一切都取决于你。这就像学习 JavaScript、Rust 或 C++ 等计算机语言一样。首先你学习基础知识,然后由你创造性地利用这些基础知识来解决你的问题。

WebGPU 是一个极低级别的 API。虽然你可以制作一些简单的示例,但对于许多应用来说,它可能需要大量的代码和严的数据组织。例如,支持 WebGPU 的 three.js 包含约 550k 字节的压缩 JavaScript,而这仅仅是其基础库。这还不包括加载器(loaders)、控制器(controls)、后处理(post-processing)和许多其他功能。同样,TensorFlow 的核心加上 WebGPU 后端约为 600k 字节的压缩 JavaScript,且不包括对各种可选功能的持。

重点是,如果你只是想在屏幕上显示某些东西,最好选择一个能提供大量代码的库,因为如果你自己动手,就必须编写这些代码。

另一方面,也许你有自定义的使用场景,或者你想修改现有库,或者你只是好奇它是如何工作的。在这些情况下,请继续阅读!

入门 (Getting Started)

很难决定从哪里开始。在某种程度上,WebGPU 是一个非常简单的系统。它所做的只是在 GPU 上运行 3 种类型的函数:顶点着色器(Vertex Shaders)、片元着色器(Fragment Shaders)和计算着色器(Compute Shaders)。

顶点着色器计算顶点。着色器返回顶点位置。对于顶点着色器函数返回的每组 3 个顶点,都会在这 3 个位置之间绘制一个三角形。[1]

片元着色器计算颜色。[2] 当绘制三角形时,对于要绘制的每个像素,GPU 都会调用你的片元着色器。片元着色器随后返回一种颜色。

计算着色器更通用。它实际上只是一个你调用的函数,并说“执行这个函数 N 次”。GPU 在每次调用函数时都会传递迭代次数,因此你可以使用该数字在每次迭代中执行独特的操作。

如果你仔细观察,可以认为这些函数类似于传递给 array.forEacharray.map 的函数。你在 GPU 上运行的函数就是函数,就像 JavaScript 函数一样。不同之处在于它们运行在 GPU 上,因此为了运行它们,你需要将希望它们访问的所有数据以缓冲区(buffers)和纹理(textures)的形式复制到 GPU,并且它们只能输出到这些缓冲区和纹理。你需要在函数中指定函数将查找数据的绑定(bindings)或位置(locations)。而且,在 JavaScript 中,你需要将持有数据的缓冲区和纹理绑定到这些绑定或位置。完成这些操作后,你告诉 GPU 执行该函数。

关于这张图需要注意的地方:

  • 有一个 管线(Pipeline) 。它包含了 GPU 将运行的顶点着色器和片元着色器。你也可以拥有包含计算着色器的管线。
  • 着色器通过 绑定组(Bind Groups) 间接引用资源(缓冲区、纹理、采样器)。
  • 管线定义了通过内部状态间接引用缓冲区的属性(Attributes)。
  • 属性从缓冲区中提取数据并将其输入到顶点着色器中。
  • 顶点着色器可能会将数据输入到片元着色器中。
  • 片元着色器通过渲染通道描述(render pass description)间接写入纹理。

要在 GPU 上执行着色器,你需要创建所有这些资源并设置这些状态。资源的创建相对直接。有趣的一点是,大多数 WebGPU 资源在创建后不能更改。你可以更改它们的内容,但不能更改它们的大小、用法、格式等。如果你想更改这些内容,你需要创建一个新资源并销毁旧资源。

某些状态是通过创建并执行 命令缓冲区(command buffers) 来设置的。命令缓冲区正如其名,它们是命令的缓冲区。你创建 命令编码器(command encoders) 。编码器将命令编码到命令缓冲区中。然后你完成编码器,它会返回创建的命令缓冲区。接着你可以提交该命令缓冲区,让 WebGPU 执行这些命令。

以下是编码命令缓冲区的一些伪代码,以及生成的命令缓冲区的表示:

JavaScript

encoder = device.createCommandEncoder()
// 绘制某些东西
{
  pass = encoder.beginRenderPass(...)
  pass.setPipeline(...)
  pass.setVertexBuffer(0, …)
  pass.setVertexBuffer(1, …)
  pass.setIndexBuffer(...)
  pass.setBindGroup(0, …)
  pass.setBindGroup(1, …)
  pass.draw(...)
  pass.end()
}
// 绘制其他东西
{
  pass = encoder.beginRenderPass(...)
  pass.setPipeline(...)
  pass.setVertexBuffer(0, …)
  pass.setBindGroup(0, …)
  pass.draw(...)
  pass.end()
}
// 计算某些东西
{
  pass = encoder.beginComputePass(...)
  pass.setBindGroup(0, …)
  pass.setPipeline(...)
  pass.dispatchWorkgroups(...)
  pass.end();
}
commandBuffer = encoder.finish();

一旦创建了命令缓冲区,你就可以提交它来执行:

JavaScript

device.queue.submit([commandBuffer]);

前面显示的“WebGPU 设置简化图”表示命令缓冲区中单个绘制命令的状态。执行命令将设置内部状态,然后绘制命令将告诉 GPU 执行顶点着色器(并间接执行片元着色器)。dispatchWorkgroup 命令将告诉 GPU 执行计算着色器。

我希望这能让你对需要设置的状态有一些心理映射。如上所述,WebGPU 可以做 2 件基本事情:

  1. 在纹理上绘制三角形/点/线
  2. 在 GPU 上运行计算

我们将详细讲解执行这两件事的小示例。其他文章将展示向这些事物提供数据的各种方法。请注意,这将非常基础。我们需要建立这些基础。稍后我们将展示如何使用它们来执行人们通常使用 GPU 执行的操作,如 2D 图形、3D 图形等。

在纹理上绘制三角形 (Drawing triangles to textures)

WebGPU 可以将三角形绘制到纹理上。就本文而言,纹理是像素的 2D 矩形。[3] <canvas> 元素代表网页上的一个纹理。在 WebGPU 中,我们可以向画布请求一个纹理,然后渲染到该纹理。

为了使用 WebGPU 绘制三角形,我们必须提供 2 个“着色器”。再次强调,着色器是运行在 GPU 上的函数。这两个着色器是:

  1. 顶点着色器 (Vertex Shaders) :计算用于绘制三角形/线/点的顶点位置的函数。
  2. 片元着色器 (Fragment Shaders) :计算在绘制三角形/线/点时,要绘制/光栅化的每个像素的颜色(或其他数据)的函数。

让我们从一个非常小的 WebGPU 程序开始画一个三角形。

我们需要一个画布来显示我们的三角形:

<canvas></canvas>

然后我们需要一个 <script> 标签来存放我们的 JavaScript:

<canvas></canvas>
<script type="module">
  ... javascript goes here ...
</script>

下面的所有 JavaScript 都将放在这个 script 标签内。

WebGPU 是一个异步 API,因此在异步函数中使用它是最简单的。我们首先请求一个适配器(adapter),然后从适配器请求一个设备(device)。

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }
}
main();

上面的代码相当直白。首先,我们使用 ?. 可选链操作符请求适配器,这样如果 navigator.gpu 不存在,适配器将是 undefined。如果它存在,我们将调用 requestAdapter。它异步返回结果,所以我们需要 await。适配器代表特定的 GPU。某些设备有多个 GPU。

从适配器中,我们请求设备,同样使用 ?.,这样如果适配器恰好是 undefined,设备也将是 undefined。如果设备未设置,可能是用户使用了旧浏览器。

接下来,我们查找画布并为其创建 webgpu 上下文。这将让我们获得一个要渲染到的纹理。该纹理将用于在网页中显示画布。

// 从画布获取 WebGPU 上下文并进行配置
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device,
  format: presentationFormat,
});

同样,上面的代码非常直白。我们从画布获取 "webgpu" 上下文。我们询问系统首选的画布格式是什么。这将是 "rgba8unorm" 或 "bgra8unorm"。它是什么并不重要,但查询它会使用户的系统运行得更快。

我们将该格式作为 format 通过调用 configure 传递到 webgpu 画布上下文中。我们还传入了 device,这将此画布与我们刚刚创建的设备关联起来。

接下来,我们创建一个着色器模块。着色器模块包含一个或多个着色器函数。在我们的例子中,我们将创建一个顶点着色器函数和一个片元着色器函数。

const module = device.createShaderModule({
  label: 'our hardcoded red triangle shaders',
  code: /* wgsl */ `
    @vertex
    fn vs(
      @builtin(vertex_index) vertexIndex : u32
    ) -> @builtin(position) vec4f {
      let pos = array(
        vec2f( 0.0,  0.5),  // 顶部中心
        vec2f(-0.5, -0.5),  // 左下角
        vec2f( 0.5, -0.5)   // 右下角
      );
      return vec4f(pos[vertexIndex], 0.0, 1.0);
    }

    @fragment
    fn fs() -> @location(0) vec4f {
      return vec4f(1.0, 0.0, 0.0, 1.0);
    }
  `,
});

着色器是用一种称为 WebGPU 着色语言 (WGSL) 的语言编写的,通常发音为 wig-sil。WGSL 是一种强类型语言,我们将在另一篇文章中尝试更详细地介绍。目前,我希望通过一些解释,你可以推断出一些基础知识。

注意:在本网站中,存储 WGSL 的字符串前面都有 /* wgsl */ 注释。这是一种约定,旨在帮助文本编辑器尝试对 WGSL 进行语法高亮和/或提供智能提示。

上面我们看到一个名为 vs 的函数使用了 @vertex 属性声明。这指定它为一个顶点着色器函数。

@vertex
fn vs(
  @builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
  ...

它接受一个我们命名为 vertexIndex 的参数。vertexIndex 是一个 u32,意思是 32 位无符号整数。它从名为 vertex_index 的内置变量(builtin)中获取值。vertex_index 就像一个迭代次数,类似于 JavaScript 的 Array.map(function(value, index) { ... }) 中的 index。如果我们通过调用 draw 告诉 GPU 执行此函数 10 次,第一次 vertex_index 将为 0,第二次为 1,第三次为 2,依此类推。[4]

我们的 vs 函数声明返回一个 vec4f,它是四个 32 位浮点值的向量。可以把它看作一个包含 4 个值的数组或一个具有 4 个属性的对象,如 {x: 0, y: 0, z: 0, w: 0}。此返回值将被分配给 position 内置变量。在“三角形列表”(triangle-list)模式下,顶点着色器每执行 3 次,就会连接我们返回的 3 个位置值绘制一个三角形。

WebGPU 中的位置需要返回到 裁剪空间(clip space) 中,其中 X 从左侧的 -1.0 到右侧的 +1.0,Y 从底部的 -1.0 到顶部的 +1.0。无论我们要绘制的纹理大小如何,这都是正确的。

vs 函数声明了一个由 3 个 vec2f 组成的数组。每个 vec2f 由两个 32 位浮点值组成。

let pos = array(
  vec2f( 0.0,  0.5),  // 顶部中心
  vec2f(-0.5, -0.5),  // 左下角
  vec2f( 0.5, -0.5)   // 右下角
);

最后,它使用 vertexIndex 从数组中返回 3 个值之一。由于该函数要求返回类型为 4 个浮点值,且由于 posvec2f 数组,因此代码为剩余的 2 个值提供了 0.0 和 1.0。

return vec4f(pos[vertexIndex], 0.0, 1.0);

请注意,对于在 2D 中绘制内容,我们通常只需要位置的 x 和 y 值。z 值用于深度测试(depth testing),将在正交投影文章中提到。w 值用于透视除法(perspective divide),将在透视投影文章中提到。目前,将 z 设置为 0.0,将 w 设置为 1.0 是我们绘制三角形所需的。

着色器模块还声明了一个名为 fs 的函数,该函数使用 @fragment 属性声明,使其成为片元着色器函数。

@fragment
fn fs() -> @location(0) vec4f {

此函数不接受任何参数,并在 location(0) 返回一个 vec4f。这意味着它将写入第一个渲染目标。稍后我们将使第一个渲染目标成为我们的画布纹理。

return vec4f(1, 0, 0, 1);

代码返回 1, 0, 0, 1,即红色。WebGPU 中的颜色通常指定为 0.0 到 1.0 的浮点值,其中上述 4 个值分别对应红、绿、蓝和阿尔法(alpha)。

当 GPU 对三角形进行光栅化(用像素绘制)时,它将调用片元着色器以找出每个像素的颜色。在我们的例子中,我们只是返回红色。

还需要注意的一点是 label。几乎每个 WebGPU 对象都可以带有一个标签。标签完全是可选的,但最好给它们贴上标签。当发生错误时,大多数 WebGPU 错误会显示引发错误的对象的标签。在包含 100 个着色器模块、100 个管线、100 个缓冲区的程序中,如果没有标签,你可能会收到类似“着色器模块发生错误”的错误,这将需要大量工作才能找出具体是哪一个。如果给它们贴上标签,你会得到类似“着色器模块 '我们的硬编码红色三角形着色器' 发生错误”的错误,这更具描述性。

现在我们有了着色器模块,接下来需要创建一个渲染管线。

const pipeline = device.createRenderPipeline({
  label: 'our hardcoded red triangle pipeline',
  layout: 'auto',
  vertex: {
    module,
    entryPoint: 'vs',
  },
  fragment: {
    module,
    entryPoint: 'fs',
    targets: [{ format: presentationFormat }],
  },
});

在这种情况下,没有太多要设置的。我们将 layout 设置为 'auto',这意味着我们希望 WebGPU 从着色器中派生数据的布局。不过我们没有使用任何数据。

然后我们告诉渲染管线为顶点着色器使用着色器模块中的 vs 函数,为片元着色器使用 fs 函数。此外,我们告诉它第一个渲染目标的格式。“渲染目标”意味着我们将要渲染到的纹理。当我们创建管线时,我们必须指定最终将使用此管线进行渲染的纹理的格式。

targets 数组的元素 0 对应于我们在片元着色器返回值中指定的 location 0。稍后,我们将把该目标设置为画布的纹理。

一个快捷方式是,对于每个着色阶段 vertexfragment,如果对应类型只有一个函数,则无需指定 entryPoint。WebGPU 将使用与着色阶段匹配的唯一函数。因此我们可以简化上面的代码。

接下来,我们准备一个 GPURenderPassDescriptor,它描述了我们要绘制到哪些纹理以及如何使用它们。

const renderPassDescriptor = {
  label: 'our basic canvas renderPass',
  colorAttachments: [
    {
      // view: <- 渲染时填充
      clearValue: [0.3, 0.3, 0.3, 1],
      loadOp: 'clear',
      storeOp: 'store',
    },
  ],
};

GPURenderPassDescriptor 有一个 colorAttachments 数组,其中列出了我们要渲染到的纹理以及如何处理它们。我们将稍后填充实际要渲染到的纹理。目前,我们设置了一个半深灰色的清除值,以及 loadOpstoreOploadOp: 'clear' 指定在绘制之前将纹理清除为清除值。另一个选项是 'load',这意味着将纹理的现有内容加载到 GPU 中,以便我们可以绘制在已有内容之上。storeOp: 'store' 意味着存储我们绘制的结果。我们也可以传递 'discard',这将丢弃我们绘制的内容。我们将在另一篇文章中讨论为什么要这样做。

现在是渲染的时候了。

function render() {
  // 从画布上下文获取当前纹理
  // 并将其设置为我们要渲染到的纹理。
  renderPassDescriptor.colorAttachments[0].view =
      context.getCurrentTexture().createView();

  // 创建一个命令编码器来开始编码命令
  const encoder = device.createCommandEncoder({ label: 'our encoder' });

  // 开启渲染通道来运行着色器
  const pass = encoder.beginRenderPass(renderPassDescriptor);
  pass.setPipeline(pipeline);
  pass.draw(3);  // 调用顶点着色器 3 次
  pass.end();

  // 完成编码并提交命令
  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);
}

render();

首先,我们通过调用 context.getCurrentTexture().createView() 获取画布的当前纹理视图。通过调用 context.getCurrentTexture(),我们正在获取一个将显示在网页画布中的纹理。我们还调用 createView。你可以从纹理的一部分创建视图,但在没有任何参数的情况下,它将返回最常见的默认视图。我们需要设置 colorAttachments[0].view

接下来,我们创建一个命令编码器。然后通过调用 encoder.beginRenderPass(renderPassDescriptor) 创建一个渲染通道(render pass)。渲染通道会执行我们的渲染命令。我们在 renderPassDescriptor 中传入了颜色附件,因此它将开始通过清除纹理进行渲染。

我们设置管线,然后调用 draw。由于我们将 3 传递给 draw,我们的顶点着色器将被调用 3 次,vertex_index 将分别为 0、1 和 2。由于我们的顶点着色器在每次执行时返回不同的位置,因此每组 3 个位置将产生一个三角形。

最后,我们结束通道,完成编码器以获得命令缓冲区,并提交命令缓冲区。

运行该程序,我们得到一个三角形。

[Triangle Demo Placeholder]

GPU 计算 (Running computations on the GPU)

接下来让我们看看如何利用 GPU 进行计算。

我们将使用一个简单的例子:取一些数字并将它们翻倍。

首先,我们需要一个计算着色器。

const module = device.createShaderModule({
  label: 'doubling compute shader',
  code: /* wgsl */ `
    @group(0) @binding(0) var<storage, read_write> data: array<f32>;

    @compute @workgroup_size(1)
    fn main(@builtin(global_invocation_id) id: vec3u) {
      data[id.x] = data[id.x] * 2.0;
    }
  `,
});

在这个着色器中,我们声明了一个名为 data 的变量。

@group(0) @binding(0) var<storage, read_write> data: array<f32>;

它被赋予了 @group(0)@binding(0)。它被声明为 var<storage, read_write>,这意味着它将被存储在缓冲区中,并且它是可读写的。它被定义为 array<f32>,即 32 位浮点数的数组。

然后我们定义了函数 main

@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) id: vec3u) {

我们赋予它 @compute 属性,使其成为计算着色器。我们还赋予它 @workgroup_size(1) 属性,我们将在另一篇文章中讨论它的含义。

它接受一个参数 idid 是一个 vec3u 类型,由三个 32 位无符号整数组成。它通过内置变量 global_invocation_id 获取它的值。如果你仔细观察,你可以认为这就像我们在上面谈到的 vertex_index。如果我们告诉 GPU 运行此函数 10 次,那么在第一次运行中 id.x 将为 0,第二次为 1,第三次为 2,依此类推。

代码本身非常简单:

data[id.x] = data[id.x] * 2.0;

它使用 id.x 索引到我们的数组中,并将值乘以 2。

现在我们有了着色器,我们需要创建一个计算管线。

const pipeline = device.createComputePipeline({
  label: 'doubling compute pipeline',
  layout: 'auto',
  compute: {
    module,
    entryPoint: 'main',
  },
});

正如我们之前所做的,我们将 layout 设置为 'auto'

接下来,我们需要一些数据。

const input = new Float32Array([1, 3, 5]);

由于数据是在 JavaScript 端(CPU 端),我们需要在 GPU 端创建一个缓冲区,并将数据从 JavaScript 复制到 GPU 缓冲区。

// 在 GPU 上创建一个缓冲区来保存数据
const workBuffer = device.createBuffer({
  label: 'work buffer',
  size: input.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
// 将数据复制到缓冲区
device.queue.writeBuffer(workBuffer, 0, input);

通过传递 GPUBufferUsage.STORAGE,我们说希望该缓冲区可被用作存储。这使其与着色器中的 var<storage,...> 兼容。此外,我们希望能够将数据复制到此缓冲区,因此我们包含了 GPUBufferUsage.COPY_DST 标志。最后,我们希望能够从缓冲区复制数据,因此我们包含了 GPUBufferUsage.COPY_SRC

请注意,你无法直接从 JavaScript 读取 WebGPU 缓冲区的内容。相反,你必须对其进行“映射”(map),这是另一种向 WebGPU 请求访问缓冲区的方式,因为缓冲区可能正在使用中,并且它可能仅存在于 GPU 上。

可以映射到 JavaScript 的 WebGPU 缓冲区不能用于太多其他用途。换句话说,我们不能直接映射上面创建的缓冲区,如果我们尝试添加标志使其可映射,我们将收到一个错误,因为它与用法 STORAGE 不兼容。

因此,为了看到计算结果,我们需要另一个缓冲区。运行计算后,我们将把上面的缓冲区复制到这个结果缓冲区中,并设置其标志以便我们可以对其进行映射。

// 在 GPU 上创建一个缓冲区来获取结果的副本
const resultBuffer = device.createBuffer({
  label: 'result buffer',
  size: input.byteLength,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});

MAP_READ 意味着我们希望能够映射此缓冲区以读取数据。

为了告诉我们的着色器我们要处理的缓冲区,我们需要创建一个绑定组(bindGroup)。

// 设置一个绑定组来告诉着色器使用哪个
// 缓冲区进行计算
const bindGroup = device.createBindGroup({
  label: 'bindGroup for work buffer',
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: { buffer: workBuffer } },
  ],
});

我们从管线获取绑定组的布局。然后设置绑定组条目。pipeline.getBindGroupLayout(0) 中的 0 对应着色器中的 @group(0)。条目中的 {binding: 0 ... 对应着色器中的 @group(0) @binding(0)

现在我们可以开始编码命令。

// 编码执行计算的命令
const encoder = device.createCommandEncoder({
  label: 'doubling encoder',
});
const pass = encoder.beginComputePass({
  label: 'doubling compute pass',
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(input.length);
pass.end();

我们创建一个命令编码器。开始一个计算通道。设置管线,然后设置绑定组。这里,pass.setBindGroup(0, bindGroup) 中的 0 对应着色器中的 @group(0)。然后我们调用 dispatchWorkgroups,在这种情况下,我们传入 input.length(即 3),告诉 WebGPU 运行计算着色器 3 次。然后结束通道。

这是执行 dispatchWorkgroups 时的情况。

计算完成后,我们要求 WebGPU 从 workBuffer 复制到 resultBuffer

// 编码将结果复制到可映射缓冲区的命令。
encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);

现在我们可以完成编码器以获得命令缓冲区,然后提交该命令缓冲区。

// 完成编码并提交命令
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);

然后我们映射结果缓冲区并获取数据的副本。

// 读取结果
await resultBuffer.mapAsync(GPUMapMode.READ);
const result = new Float32Array(resultBuffer.getMappedRange());
console.log('input', input);
console.log('result', result);
resultBuffer.unmap();

要映射结果缓冲区,我们调用 mapAsync 并必须等待它完成。映射后,我们可以调用 resultBuffer.getMappedRange(),在不带参数的情况下它将返回整个缓冲区的 ArrayBuffer。我们将其放入 Float32Array 类型数组视图中,然后查看这些值。一个重要的细节是,getMappedRange 返回的 ArrayBuffer 仅在调用 unmap 之前有效。在 unmap 之后,它的长度将被设置为 0,其数据将不再可访问。

运行该程序,我们可以看到我们得到了结果,所有的数字都翻倍了。

[Compute Demo Placeholder]

我们将在其他文章中介绍如何真正使用计算着色器。目前,希望你已经对 WebGPU 的作用有了初步的了解。除此之外的一切都取决于你!将 WebGPU 视为类似于其他编程语言。它提供了一些基本功能,其余的留给你的创造力。

使 WebGPU 编程特别的是,这些函数(顶点着色器、片元着色器和计算着色器)运行在你的 GPU 上。GPU 可能拥有超过 10,000 个处理器,这意味着它们可以并行进行 10,000 次以上的计算,这可能比你的 CPU 并行计算能力高出 3 个或更多数量级。

(后略:关于画布调整大小等细节)

我用 PixiJS 撸了个圆桌会议选座系统,从 0 到 1 踩坑全复盘

大家好,我是写了 10 年代码的老前端,最近接了个需求:做一个圆桌会议可视化选座系统


一、需求拆解:圆桌会议到底要什么?

先把需求扒干净,避免做无用功:

  1. 形态:中间是圆桌,座位沿圆周均匀分布,绝对不能重叠
  2. 交互:点击选座 / 取消、拖拽换位、保存布局(刷新不丢)
  3. 性能:座位最多 20 个,要流畅拖拽,不能卡顿
  4. 兼容:PixiJS 版本坑多,要兼容 v6/v7 所有版本

核心难点:

  • 长方桌改圆桌:坐标计算从「上下左右」变成「极坐标 + 角度」
  • 避免重叠:必须用圆周均分算法,不能手动硬编码
  • PixiJS API 差异:getGlobalPosition 在不同版本里写法不一样,很容易踩坑

二、技术选型:为什么选 PixiJS 而不是 Konva?

我做过 Konva 版,也对比过 Fabric.js,最后选 PixiJS 的原因很简单:

  1. 性能更强:PixiJS 是 WebGL 渲染,大量座位时帧率更稳
  2. 分层更灵活:用 Container 做基础层 + 拖拽层,性能损耗极小
  3. 社区成熟:大厂可视化项目都在用,坑都被踩过了
  4. 轻量:比 Fabric.js 小,比原生 Canvas 开发快 10 倍

三、核心实现:从 0 到 1 搭骨架

1. 初始化 Pixi 应用

先搭好画布和分层容器,这是 Pixi 项目的标准起点:

javascript

运行

const app = new PIXI.Application({
  width: 1200,
  height: 700,
  backgroundColor: 0xf5f5f5,
  resolution: window.devicePixelRatio || 1,
  antialias: true,
});
document.body.appendChild(app.view);

// 分层:基础层(桌+座位)+ 拖拽层(临时元素)
const baseLayer = new PIXI.Container();
const dragLayer = new PIXI.Container();
app.stage.addChild(baseLayer, dragLayer);

2. 绘制圆桌:从矩形到圆形

把之前的蓝色长方桌换成灰色圆桌,用 drawCircle 实现:

javascript

运行

const TABLE_RADIUS = 180; // 圆桌半径
const CENTER_X = 600;     // 画布中心X
const CENTER_Y = 350;     // 画布中心Y

const table = new PIXI.Graphics();
table.beginFill(0xCCCCCC);
table.drawCircle(0, 0, TABLE_RADIUS);
table.endFill();
table.x = CENTER_X;
table.y = CENTER_Y;
baseLayer.addChild(table);

3. 环形座位:极坐标计算避免重叠

这是最核心的算法:用极坐标把座位均匀分布在圆周上,彻底解决重叠问题:

javascript

运行

const SEAT_COUNT = 16;    // 总座位数
const SEAT_DISTANCE = TABLE_RADIUS + 40; // 座位到圆心的距离

function createSeat(key, index, isOccupied) {
  const seat = new PIXI.Graphics();
  updateSeatStyle(seat, isOccupied);

  // 极坐标转直角坐标:角度 → x/y
  const angle = (index / SEAT_COUNT) * Math.PI * 2;
  const x = CENTER_X + Math.cos(angle) * SEAT_DISTANCE;
  const y = CENTER_Y + Math.sin(angle) * SEAT_DISTANCE;

  seat.x = x;
  seat.y = y;
  seat.rotation = angle + Math.PI/2; // 让座位朝向圆心,更自然
  // ... 交互逻辑
}

4. 交互实现:点击 + 拖拽 + 保存

点击选座

直接监听 pointertap 事件,切换座位状态:

javascript

运行

seat.on('pointertap', () => {
  seat.isOccupied = !seat.isOccupied;
  updateSeatStyle(seat, seat.isOccupied);
  // 更新数据数组
});

拖拽换位

PixiJS 拖拽的坑:不同版本获取鼠标坐标的 API 不一样,我封装了一个兼容函数:

javascript

运行

// 兼容 PixiJS v6/v7 的坐标获取
function getGlobalPosition(e) {
  if (e.data && typeof e.data.getGlobalPosition === 'function') {
    return e.data.getGlobalPosition();
  } else if (e.data && e.data.global) {
    return e.data.global;
  } else {
    return app.renderer.plugins.interaction.mouse.global;
  }
}

拖拽时在 dragLayer 渲染临时座位,结束后碰撞检测目标座位,交换状态。

保存布局

localStorage 持久化座位数据,刷新页面自动加载:

javascript

运行

const STORAGE_KEY = 'roundTableSeats';
let occupiedSeats = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');

function saveSeatLayout() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(occupiedSeats));
}

四、踩坑复盘:10 年程序员的血泪教训

坑 1:PixiJS 版本 API 不兼容

  • 问题:e.data.getGlobalPosition is not a function
  • 原因:v6 和 v7 的事件对象结构不一样
  • 解决:封装 getGlobalPosition 兼容函数,同时锁定 CDN 版本为 v6.5.10(最稳定)

坑 2:座位重叠

  • 问题:手动算坐标导致座位挤在一起
  • 解决:用极坐标均分算法,angle = (index / SEAT_COUNT) * Math.PI * 2,保证每个座位间隔一致

坑 3:拖拽卡顿

  • 问题:频繁重绘基础层导致帧率掉帧
  • 解决:用 dragLayer 单独渲染拖拽元素,基础层只在状态变化时重绘

坑 4:CSP 警告

  • 问题:浏览器报 upgrade-insecure-requests 警告
  • 解决:在 <head> 加 CSP 元标签,明确允许 PixiJS CDN 和内联脚本

五、完整代码 & 运行方式

直接复制下面的代码,保存为 .html,双击打开就能跑:(这里放你之前的完整圆桌版代码即可)

运行效果

  • 中间灰色圆桌,16 个红色 / 灰色座位均匀环绕
  • 点击灰色 → 变红(选中),点击红色 → 变灰(取消)
  • 拖动红色座位到空座位 → 自动换位
  • 点击「保存」→ 刷新页面后选中状态不丢失

六、扩展思路:给产品交差的加分项

  1. 座位数量动态调整:加个输入框,修改 SEAT_COUNT 后重新渲染
  2. 座位信息编辑:右键菜单,修改座位名称、备注
  3. 批量操作:框选多个座位,批量移动 / 清空
  4. 后端对接:把 localStorage 换成接口请求,实现多端同步
  5. 权限控制:不同角色只能选指定区域的座位

七、总结

这次重构让我深刻体会到:

  • 可视化项目的核心是坐标计算,圆桌比长方桌难就难在极坐标的理解
  • 分层渲染是性能优化的银弹,把频繁更新的元素单独拎出来
  • 兼容老版本是前端的宿命,封装兼容函数能少踩 90% 的坑

如果你也在做类似的可视化选座需求,直接拿我的代码改,少走半年弯路。


结尾互动

你在做可视化项目时踩过什么坑?评论区聊聊,我帮你一起解决~预览地址


记 华为鸿蒙机型小程序使用uni.createInnerAudioContext() 播放音频播放两次的问题

记 华为鸿蒙机型小程序使用uni.createInnerAudioContext() 播放音频播放两次的问题,被这个问题折磨了两天

直接上解决方案demo 代码 主要是红框代码是解决鸿蒙播放两次的问题,播放前先初始化一遍实例,然后赋值音频url,延迟300ms销毁在重新创建实例播放音频就好了

image.png

async play(item) {
                try {
                    const url = await this.getAudioSrc(item);
                    innerAudioContext = uni.createInnerAudioContext();
                    innerAudioContext.src = url;
                    setTimeout(() => {
                        this.pause();
                        innerAudioContext = uni.createInnerAudioContext();
                        innerAudioContext.src = url;
                        innerAudioContext.onPlay(() => {
                            console.log('开始播放');
                        });
                        innerAudioContext.onError((res) => {
                            console.log(res.errMsg);
                            console.log(res.errCode);
                        });
                        innerAudioContext.play();
                    }, 300);
                } catch (error) {
                    //TODO handle the exception
                }
            },

css border-left 怎么设置 border 展示为椭圆

通过 border-left 实现左侧边框呈现椭圆效果,核心思路是利用 CSS 的 border-radius(圆角)结合宽高 / 边框宽度的搭配来模拟椭圆,因为单纯的 border-left 本身是矩形边框,无法直接变成椭圆,需要通过元素样式组合来实现。

方法 1:纯边框实现椭圆左侧边框(常用)

这种方式通过给元素设置左侧边框,并利用 border-radius 将边框上下两端做成圆角,形成椭圆 / 半圆的视觉效果:

css

/* 椭圆左侧边框示例 */
.ellipse-border-left {
  width: 100px;       /* 元素宽度(可根据需求调整) */
  height: 80px;       /* 元素高度(决定椭圆的纵向尺寸) */
  border-left: 10px solid #007bff; /* 左侧边框:宽度+颜色 */
  /* 关键:border-radius 上下设为 50%(椭圆核心),左右设为 0 */
  border-radius: 0 0 50% 50% / 0 0 100% 100%; 
  /* 也可以简写为:border-radius: 0 0 50px 50px; (数值≈边框+元素高度的一半) */
  background: #f5f5f5; /* 背景色,方便看效果 */
}
  • 关键解释border-radius/ 前是水平圆角,后是垂直圆角;设置 0 0 50% 50% / 0 0 100% 100% 能让左侧边框的上下两端完全圆弧化,配合边框宽度和元素高度,形成椭圆边框。
  • 效果:左侧会呈现一个椭圆(半圆)的边框,高度由元素 height 决定,粗细由 border-left-width 决定。

方法 2:伪元素实现独立的椭圆左侧边框(更灵活)

如果不想影响原元素的样式,可通过 ::before/::after 伪元素单独做一个椭圆边框,定位到元素左侧:

css

/* 伪元素实现椭圆左侧边框 */
.box {
  width: 100px;
  height: 80px;
  margin-left: 20px; /* 给椭圆边框留位置 */
  background: #f5f5f5;
  position: relative; /* 相对定位,让伪元素绝对定位 */
}

.box::before {
  content: '';
  position: absolute;
  left: -10px; /* 定位到元素左侧 */
  top: 0;
  bottom: 0;
  width: 10px; /* 椭圆的宽度(对应边框粗细) */
  /* 关键:宽高比例+border-radius: 50% 实现椭圆 */
  border-radius: 50%; /* 50% 圆角直接生成椭圆/圆形 */
  background: #007bff; /* 椭圆填充色(模拟边框) */
}
  • 关键解释:伪元素设置 border-radius: 50% 后,只要宽高比例不是 1:1,就会呈现椭圆;这里宽(10px)远小于高(继承父元素 80px),所以是竖向椭圆,刚好作为左侧边框。
  • 优势:不修改原元素的边框属性,椭圆边框独立可控,适合复杂布局。

效果验证

将上述代码复制到 HTML 中,就能看到效果:

html

预览

<div class="ellipse-border-left">方法1:纯边框椭圆左侧边框</div>
<br>
<div class="box">方法2:伪元素椭圆左侧边框</div>

总结

  1. 单纯 border-left 无法直接变成椭圆,需结合 border-radius 调整圆角,或用伪元素单独制作椭圆;
  2. 方法 1 适合简单场景,通过 border-radius 把左侧边框两端圆角拉满形成椭圆;
  3. 方法 2 更灵活,用伪元素 +border-radius: 50% 生成独立椭圆,作为左侧边框使用。

核心要点:椭圆的形状由「宽高比例」和 border-radius: 50% 共同决定,调整宽高 / 边框宽度就能改变椭圆的粗细和长短。

借助AI,1周,0后端成本,我开源了一款Office预览SDK

今天给大家分享一款我借助AI, 开发的一款开源 Office 文件预览SDK。

图片

我把它起名叫 jit-Viewer,刚好和我们做的 JitWord 协同AI文档相呼应。它主要的功能就是可以在浏览器中一键预览 Docx,PDF,Excel,PPT,Markdown,Txt等各种格式的文件。

图片

同时只需要3行代码,就可以轻松集成到Vue,React,Angular,Html项目中。话不多说,先上开源地址。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

演示地址:jitword.com/jit-viewer.…

核心功能亮点

图片

jit-viewer 不依赖后端转换,而是直接在浏览器端解析 Office Open XML 格式(.docx/.xlsx/.pptx 的底层结构),最后通过 jit-viewer 封装的渲染器渲染成可视化组件。PDF 预览则是基于 PDF.js 做深度优化。

所以我们完全不依赖后端,同时目前支持的文件预览方式有:

  • 本地上传文件
  • 通过url地址直接预览文件

这2种方式基本上是用户需求最多的方式。

总结几个核心亮点,方便大家参考评估:

  1. 零后端依赖,纯前端渲染:无需配置任何后端服务,静态站点也能用
  2. 框架无绑定:一个SDK同时支持Vue/React/Angular,团队技术栈切换无成本
  3. 隐私安全:文件解析在浏览器本地完成,不上传服务器
  4. 性能优异:虚拟滚动 + Web Worker,大文件不卡主线程
  5. 扩展性强:插件化架构,可自定义渲染器、添加水印、集成审批流

这里我在补充一条,目前预览访问不仅能在PC端预览,还能直接在移动端预览:

图片

充分满足大家移动办公的需求。

同时为了提供开发人员对预览的样式控制,我还支持了预览控件:

  • 下载文件
  • 缩放重置
  • 旋转功能
  • 全屏
  • 主题切换
  • 获取文档信息

后续会持续优化一些更可控的功能供大家使用。

如何本地使用

我在 jit-viewer 文档中写了详细的本地使用教程,接下来给大家分享一下。

第一步,引入SDK:

<!-- 引入样式文件 -->
<link rel="stylesheet" href="jit-viewer.min.css">

<!-- 引入 JitViewer SDK -->
<script src="jit-viewer.min.js"></script>

第二步,创建预览容器:

<div id="viewer" style="width: 100%; height: 600px;"></div>

第三步,初始化实例:

// 创建预览器实例
const viewer = JitViewer.createViewer({
  file: 'document.pdf',      // 文件 URL
  filename: 'document.pdf',  // 文件名(可选)
  toolbar: true,             // 显示工具栏
  theme: 'light',            // 主题
  width: '100%',
  height: '600px',
  onReady: () => console.log('准备就绪'),
  onLoad: () => console.log('加载完成'),
  onError: (err) => console.error('错误:', err)
});

// 挂载到 DOM
viewer.mount('#viewer');

是不是非常简单?只需要3步,就能快速集成到你的系统中实现 Office 文件预览功能。

我在文档中也写了详细的API介绍,大家想定制SDK样式和交互,也可以参考文档:

图片

文档地址:jitword.com/jit-viewer.…

小小总结一下

图片

之所以要做这个项目,完全来自于之前的客户的一个需求,为了给我们的客户赋能,我们便做了这个开源SDK。

后续会继续迭代优化,实现更多文件类型的预览功能,大家有好的建议也欢迎留言区交流反馈~

对于AI,其实并不是全程参与开发(尤其是复杂的业务需求),大家短期内还是不用太神话AI的能力。

对于SDK的工程化方案(脚手架),我是完全交给AI来实现的,同时从文档的编写,网站demo的设计,也都是交给AI做的,到这里,AI基本能代替40%左右的工作了。

我提供的是SDK的产品需求,设计思路,技术选型,和设计风格,同时兼顾测试工程师的角色,这块其实是目前AI人机协作的常态。

所以技术能力还是需要,用好AI可能更好的为我们的工作和产品研发提效。

新的竞争力在于:架构设计的品味、安全风险的嗅觉、人机协作的智慧,以及对自己代码的深刻理解

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

【实战篇】老板临时催包?人在外面没电脑,我的龙虾助理直接操办

引言

想象一下这个场景:
周五晚上你刚躺在沙发上,测试突然在群里喊:“老板,急需一个最新的 Debug 包测个 Bug!”
以前,你得爬起来打开电脑,启动 Android Studio,等待 Gradle 慢吞吞地转圈,打包完还得手动打开蒲公英网页上传,最后复制链接发群里……

现在,你只需要在飞书里发一句语音:“帮我把最新的代码打包传到蒲公英。”

剩下的事情,你的 AI 代理——小龙虾 (OpenClaw) 会全自动帮你搞定。本文将手把手教你如何搭建这就套“摸鱼神器”。

04bfd585b1043327927c75873e4acd90.png


🛠️ 第一步:把“小龙虾”请回家 (环境安装)

OpenClaw(代号:小龙虾)是一个运行在你本地电脑上的 AI Agent 框架,它能操作你的终端、文件和浏览器。

1. 基础环境准备

OpenClaw 比较前卫,强制要求 Node.js v22+
(如果你是 M1/M2/M3 芯片的 Mac,记得先搞定终端代理,不然下载依赖会很慢)

# 推荐用 nvm 快速安装
nvm install 22
nvm use 22

2. 一键安装

设置好 VPN 代理后,一行命令搞定安装:(所有初始化配置,推荐快速跳过)

export http_proxy=http://127.0.0.1:7890  # 替换你的代理端口
export https_proxy=http://127.0.0.1:7890
npm install -g openclaw@latest

安装完成后,你可以运行 openclaw --version 验证一下。

➜  ~ openclaw --version
OpenClaw 2026.3.13 (61d171a)

// 如果您能看到类似 `OpenClaw 2026.x.x` 的输出,则说明安装已完全成功!

3. 给它装个脑子 (配置模型)

运行 openclaw configure,把你的大模型 API Key(比如 OpenAI、Claude 或者火山引擎的 Key)填进去。这是它听懂人话的关键。

提示:在向导中,重点配置 model (输入您的 API Key)和 workspace (初始化代理的性格和记忆)。

我选用的是 Volcano Engine (火山)

◆  Model/auth provider
│  ○ OpenAI
│  ○ Anthropic
│  ○ Chutes
│  ○ MiniMax
│  ○ Moonshot AI (Kimi K2.5)
│  ○ Google
│  ○ xAI (Grok)
│  ○ Mistral AI
│  ● Volcano Engine (API key)
│  ○ BytePlus
│  ○ OpenRouter
│  ○ Kilo Gateway
│  ○ Qwen
│  ○ Z.AI
│  ○ Qianfan
│  ○ Alibaba Cloud Model Studio
│  ○ Copilot
│  ○ Vercel AI Gateway
│  ○ OpenCode

重新选择模型使用命令行:openclaw configure --section model

4. 启动与关闭网关服务 (Gateway)

配置完成后,需要启动它的核心服务,让它在后台监听请求。建议新开一个终端窗口运行:

openclaw gateway --port 18789

  • 可以看到默认模型显示为已选的豆包模型

如何关闭服务?
如果您想修改配置并重启,或者想彻底关闭后台服务,请运行:

# 安全停止网关服务
openclaw gateway stop
# (如果上述命令无效,可尝试强杀:pkill -f "openclaw gateway")

5. 开始对话

网关启动后,您可以再开一个终端窗口,直接用命令行与它对话(需指定 session):

# 启动交互式终端 UI (推荐)
openclaw tui

或者,新起一个会话:openclaw tui --session chat3


🔗 第二步:打通“任督二脉” (关联飞书)

为了能远程指挥电脑,我们需要通过 WebSocket 长连接 把本地的小龙虾和飞书机器人连起来。这不仅不需要公网 IP,还非常稳定。

1. 飞书侧:先拿“身份证”

登录 飞书开放平台,创建一个企业自建应用:

  1. 创建应用:起个名字叫“龙虾助手1号”。
  2. 开启能力:在“应用能力”里把【机器人】勾上。(左侧导航:应用能力 → 添加能力 → 机器人)
  3. 获取凭证:在“凭证与基础信息”里,复制 App IDApp Secret

转存失败,建议直接上传图片文件

2. 本地侧:建立连接

拿到凭证后,回到电脑终端,告诉小龙虾:

# 开启飞书渠道
openclaw config set channels.feishu.enabled true
# 填入你的凭证
openclaw config set channels.feishu.appId "cli_a933xxxxxx"
openclaw config set channels.feishu.appSecret "I4tJEyrxxxxxx"
# 开启配对模式(安全第一)
openclaw config set channels.feishu.dmPolicy "pairing"

关键一步:启动网关!

openclaw gateway --port 18789

此时盯着日志,当你看到 [feishu] connected 时,说明路通了!

3. 飞书端完成订阅与发布:

3.1 配置权限

回到飞书后台 -> 左侧导航 -> 权限管理:(逐个添加以下权限)

消息与群组相关:
● im:message (获取与发送单聊、群组消息)
● im:message:readonly (读取单聊、群组消息)
● im:message:send_as_bot (以应用的身份发送消息)
● im:chat (获取与更新群组信息)
● im:chat:readonly (读取群组信息)
通讯录相关:
● contact:user.base:readonly (读取用户基础信息)
3.2 订阅事件(接收消息)

回到飞书后台 -> 左侧导航 -> 事件与回调 -> 事件配置

1. 点击「订阅方式」右侧编辑图标(✏️)。
2. 选择:**使用长连接接收事件(WebSocket)**3. 关闭「消息加密」(个人场景简化配置)。
4. **点击「保存」***(因为 OpenClaw 已经启动并连接,这里应该能顺利保存,不会报错)*
5. 在“已添加事件”区域 → 点击「添加事件」。
6. 搜索 `im.message.receive_v1`(接收消息 v1),勾选并确认。
7. 点击事件右侧「申请权限」,提交审批(自动通过)。
8. 刷新页面,确认事件状态为「已通过」。

发布应用:去“版本管理与发布”里发布一个版本,这样你在飞书里才能搜到它。

3.4 配对授权(如果未设置白名单)

OpenClaw 默认不回复陌生人:

  1. 发送消息后,机器人通常会回复一个配对码(例如:配对码:123456),或者在 OpenClaw 的终端日志中会显示拦截提示并生成配对码。
  2. 回到您的电脑终端,执行授权命令:
openclaw pairing approve feishu 123456

(把 123456 换成实际生成的配对码)

  1. 授权成功后,再次在飞书中对机器人说 你好,它应该就能正常调用您配置的 AI 模型并回复您了。


🧠 第三步:传授“独门绝技” (自定义 Skill)

默认的小龙虾只会聊天,我们需要通过编写 Markdown 文件,教会它两个硬核技能:Gradle 打包蒲公英上传

技能 1:Android 自动打包 (android-build)

这个技能教它:当用户说“打包”时,去哪里找项目,执行什么命令。

创建文件:~/.openclaw/skills/android-build/SKILL.md

(复制以下完整代码)

---
name: "android-build"
description: "Build Android APK using Gradle. Invoke when user asks to build, compile, or package an Android project into an APK."
---

# Android Build Skill

This skill allows OpenClaw to automatically build an Android project using Gradle.

## Prerequisites
- The target machine must have Java (JDK) installed.
- The target machine must have Android SDK installed.
- The target project must use Gradle (`gradlew`).

## Instructions for OpenClaw

When the user asks you to build an Android project, follow these steps:

1. **Locate the Project**: Ask the user for the absolute path to the Android project directory if they haven't provided it.
2. **Verify Project**: Ensure there is a `gradlew` (or `gradlew.bat` for Windows) file in the specified directory.
3. **Execute Build**: Run the standard Android build command. Usually, this is:
   ```bash
   cd <project_path> && ./gradlew assembleDebug
   ```
   *(If the user specifies a different flavor like `assembleRelease`, use that instead).*
4. **Report Status**: Read the terminal output. 
   - If successful, find the path to the generated APK (usually under `app/build/outputs/apk/debug/`) and report it to the user.
   - If it fails, summarize the Gradle build errors and suggest fixes.

技能 2:蒲公英光速上传 (pgyer-upload)

这个技能教它:拿到 APK 后,如何通过 API 传到蒲公英。我们使用蒲公英官方 v2 接口(COS 直传),速度极快。

创建文件:~/.openclaw/skills/pgyer-upload/SKILL.md

(请将代码中的 _api_key 替换为您自己的蒲公英 API Key)

---
name: "pgyer-upload"
description: "Upload APK/IPA to Pgyer (蒲公英) using the 3-step apiv2 process (getCOSToken, upload to COS, get buildInfo). Invoke when user asks to upload an app, apk, or distribute a build to Pgyer."
---

# Pgyer Upload Skill

This skill allows OpenClaw to automatically upload Android APKs or iOS IPAs to Pgyer (蒲公英) using their official v2 API (COS upload method).

## Prerequisites
- `curl` must be installed on the machine.

## Instructions for OpenClaw

When the user asks you to upload an app to Pgyer, follow these 3 steps precisely using terminal commands. Use the hardcoded `_api_key=34d58041e8xxxx` (Replace with your actual key).

**(Note: You do not need any User Key, only the `_api_key` is required for v2 API).**

### Step 1: Get COS Token
1. Find the absolute path to the APK/IPA file (`<file_path>`).
2. Determine the `buildType` (e.g., `android` for APK, `ios` for IPA).
3. Execute the following `curl` command to get the upload token and endpoint. Use `node -e` or `python` to parse the JSON if `jq` is not available:
   ```bash
   curl -s -X POST https://www.pgyer.com/apiv2/app/getCOSToken \
        -d "_api_key=34d58041e8xxxx" \
        -d "buildType=<buildType>" > token_response.json
   ```
4. Read `token_response.json`. If `code` is not `0`, report the error and stop.
5. Extract the following from `data`:
   - `endpoint` (the upload URL)
   - `key` (the buildKey used in Step 3)
   - `params` (an object containing multiple form fields like `signature`, `x-cos-security-token`, `key`, etc.)

### Step 2: Upload File to COS
Construct a `curl` command using the `endpoint` and all the key-value pairs inside the `params` object as `-F` form fields, and finally attach the file.
   ```bash
   curl -s -X POST <endpoint> \
        -F "key=<params.key>" \
        -F "signature=<params.signature>" \
        -F "x-cos-security-token=<params.x-cos-security-token>" \
        -F "x-cos-meta-file-name=<params.x-cos-meta-file-name>" \
        -F "file=@<file_path>" -i
   ```
*(Note: You must dynamically include all fields returned in the `params` object from Step 1. The HTTP status code returned should be 204 No Content).*

### Step 3: Get Build Info (Polling)
After a successful upload, wait 3 seconds, then query the build info using the `key` extracted in Step 1.
   ```bash
   curl -s -G https://www.pgyer.com/apiv2/app/buildInfo \
        -d "_api_key=34d58041e8xxxx" \
        -d "buildKey=<key_from_step_1>" > build_info.json
   ```
1. Read `build_info.json`.
2. If `code` is `1247` (Processing), wait 3 seconds and repeat Step 3 (up to 3 times).
3. If `code` is `0`, extract `buildName`, `buildVersion`, `buildUpdated`, and `buildShortcutUrl`.
4. Report the success to the user with this format:
   - **App Name**: <buildName> (v<buildVersion>)
   - **Update Time**: <buildUpdated>
   - **Download Link**: `https://www.pgyer.com/<buildShortcutUrl>`
5. Clean up temporary json files.

🎉 第四步:见证奇迹时刻

现在,打开你的飞书 App,找到你的机器人,发一条消息:

“帮我把桌面code文件夹内的项目打包,然后上传到蒲公英。”

接下来发生的事情会让你舒适极了:

  1. 意图识别:小龙虾识别到你需要 android-build
  2. 执行命令:你的电脑风扇开始转,Gradle 开始编译(它会在后台默默执行)。
  3. 链式调用:编译完成后,它拿到 APK 路径,自动触发 pgyer-upload
  4. 任务完成:几秒钟后,你的飞书收到一条回复:

从进程线程到 async/await,一文吃透前端异步核心原理

事件循环(Event Loop)是 JavaScript 实现单线程非阻塞异步执行的核心机制,也是浏览器与 Node.js 环境中,JS 代码能够有序执行、处理异步任务(网络请求、定时器、DOM 事件等)的底层逻辑。

本文将从进程、线程的基础概念出发,逐步拆解浏览器渲染机制、V8 引擎单线程模型、Event Loop 事件循环,最终落地到 async/await 的原理与实践,帮助前端开发者建立完整的异步编程知识体系。

一、进程与线程:浏览器的底层基石

1. 基础概念

  • 进程:进程就是操作系统中正在运行的一个程序实例,是CPU 运行指令时保存和加载上下文所需的时间与资源集合,是操作系统资源分配的最小单位
  • 线程:CPU 执行具体指令所需的最小单位,依附于进程存在,一个进程可以包含多个线程。

2. 浏览器中的进程与线程

我们日常使用浏览器多开 Tab 页,本质上就是为每个 Tab 单独创建一个进程,这样做的好处是:

  • 单个 Tab 崩溃不会影响整个浏览器
  • 资源隔离更安全,避免恶意页面窃取其他页面数据

而在每个进程内部,又包含多个关键线程:

  1. 渲染线程:负责页面的 HTML、CSS 解析与布局绘制
  2. JS 引擎线程:负责解析和执行 JavaScript 代码
  3. HTTP 请求线程:处理网络请求(如 Ajax、Fetch
  4. 事件触发线程、定时器线程等

⚠️ 核心限制:由于 JavaScript 可以直接操作 DOM,为了避免 DOM 渲染冲突,渲染线程与 JS 引擎线程必须互斥,不能同时工作。这也是 JS 执行会阻塞页面渲染的根本原因。

二、V8 引擎:单线程与异步的诞生

V8是Chrome和Node.js所使用的JS引擎,它在执行JS代码时默认只开一个线程

正是这种单线程特性,催生了JS的异步编程模式

  • 遇到同步任务:直接执行。
  • 遇到异步任务:先挂起,存入任务队列,等待同步任务执行完毕后再执行异步任务。

这种“先同步,后异步”的执行流程,就是我们常说的事件循环的基础。

三、Event Loop:微任务与宏任务

1. 任务分类

在异步任务中,又分微任务与宏任务。

微任务:指在异步任务中耗时更短的任务,优先级更高,会在当前同步代码执行完毕后立即执行

常见的微任务有:

  • Promise.then()
  • process.nextTick() (Node.js 环境)
  • MutationObserver (浏览器环境)

宏任务:指在异步任务中耗时更长的任务,优先级较低,会在微任务全部清空后才会执行

常见的宏任务有:

  • 全局 script 代码
  • setTimeout() / setInterval()
  • AJAX请求、I/O 操作
  • UI 渲染(UI-rendering

2. 完整执行顺序

事件循环机制的执行流程可以总结为 4 步:

  1. 先执行同步代码,执行过程中遇到异步任务,将其存入对应的任务队列,微任务存入微任务队列,宏任务存入宏任务队列
  2. 同步代码执行完毕后,立即执行微任务队列中的所有任务
  3. 微任务全部执行结束后,如有需要则执行页面渲染
  4. 渲染完成后,执行宏任务队列中的任务

这个循环会一直持续,直到所有任务都被处理完毕。

四、async/await

async/await 是 ES2017 引入的语法,本质是 Promise 的替代,让异步代码看起来更像同步代码。

核心规则

  • async:函数前加 async,修饰函数(函数声明 / 表达式 / 箭头函数),表示这是一个异步函数,等价于函数内部自动返回了一个 Promise 实例对象。

    • 异步函数的返回值会被自动包装成 Promise(即使你返回普通值,也会变成 Promise.resolve(值))。
    • 如果函数内部抛出错误,返回的 Promise 会变成 rejected 状态。
  • await:必须配合 async 使用,只能在 async 函数内部使用,作用是等待一个 Promise 完成(resolve/reject),如果 await 后面不是 Promise 对象,它就无法 “等待” 该操作完成。

    • 等待期间,JS 引擎会暂停当前 async 函数的执行,去执行其他代码(不会阻塞主线程)。
    • Promise 完成后,await 会返回 Promise 的 resolve 值;如果 Promise 被拒绝(reject),会抛出错误,需要用 try/catch 捕获。
    • await fn() 会把 fn() 当作同步代码看待,并将 await 之后的代码加入到微任务队列中,等待当前同步代码和微任务执行完毕后再执行

代码示例1

// async/await 基础用法 
async function asyncDemo() { 
  console.log('1. async 函数内同步代码'); 
  const res = await Promise.resolve('await 结果'); 
  console.log('3. await 之后的代码(微任务)'); 
  console.log('res:', res);
} 

console.log('0. 全局同步代码'); 
asyncDemo(); 
console.log('2. 全局同步代码结束');

//输出结果:
//0. 全局同步代码
//1. async 函数内同步代码
//2. 全局同步代码结束
//3. await 之后的代码(微任务)
//res: await 结果

上述代码示例表明:async 函数内的同步代码会立即执行,await 之后的代码会被放入微任务队列。

代码示例2

// async/await 处理异步请求 
async function fetchData() {
    try {
        console.log('开始请求数据');
        // 模拟网络请求 
        const response = await new Promise(resolve => {
            setTimeout(() => {
                resolve({ data: '用户信息' });
            }, 1000);
        });
        console.log('请求成功:', response.data);
        return response.data;
    } catch (err) {
        console.error('请求失败:', err);
    }
} 
fetchData().then(data => {
    console.log('最终处理数据:', data);
});
console.log('同步代码继续执行');

运行结果:

image.png

可以看到,async/await 让异步代码的写法和同步代码几乎一致,可读性大大提升

五、总结

从进程、线程到 async/await,我们可以了解:

  1. 浏览器是多进程多线程架构,每个 Tab 是一个独立进程,内部包含渲染线程、JS 引擎线程等
  2. V8 引擎是单线程执行 JS,因此诞生了异步编程模型
  3. Event Loop 是 JS 异步的核心,通过同步代码优先异步代码,微任务优先于宏任务的执行顺序,保证了异步代码的有序执行
  4. async/await

理解了这些底层原理,有助于我们更好了解JavaScript中的异步编程,实现更复杂高效的功能。

从 SPA 到全栈:AI 时代的前端架构升级实践

Vibe Coding 浪潮席卷而来的今天,AI 辅助开发已经不再是新鲜事。笔者所在团队维护着一个内部业务系统(技术栈:React 18 + Vite + React Router),前端独立部署,后端由 Java 同学负责。这套架构运行了两年多,一直相安无事。

直到有一天,组织架构调整,后端同学被调去支援其他项目(AI创新项目),老板拍板:前端同学顶上后端的活儿。 好家伙,说得轻巧,前后端代码都不在一个仓库,让前端同学怎么顶?

现状分析:前后端分离之痛

先来看看原来的项目结构:

前端仓库 (frontend-repo)
├── src/
│   ├── pages/
│   ├── components/
│   └── utils/
└── package.json

后端仓库 (backend-repo)
├── src/main/java/
│   ├── controller/
│   ├── service/
│   └── mapper/
└── pom.xml

看起来很标准对吧?但问题来了:

痛点一:AI 辅助开发的先天不足

用过 CursorCodeBuddy 这类 AI 编程工具的同学都知道,AI 需要理解上下文才能给出靠谱的建议。当你让 AI 帮你实现一个完整功能时,它需要同时看到:

  • 前端的组件结构和 API 调用
  • 后端的接口定义和业务逻辑
  • 数据库的表结构

但是,前后端分离的架构下,AI 只能看到半边天。让它帮你写个表单提交功能,它只能帮你写前端调用,后端接口得你自己跑到另一个仓库里去补。这就像让一个人蒙着一只眼睛打乒乓球——不是不能打,就是费劲。

痛点二:前端同学的上手成本

前端同学接手后端代码,第一反应是:这 Spring Boot 的注解也太多了吧?@RestController@Autowired@Transactional... 光是理解这些就得花不少时间。

更要命的是,本地调试还得:

  1. 先启动 MySQL
  2. 再启动 Redis
  3. 配置一堆环境变量
  4. 最后启动 Spring Boot

前端同学看到这套流程,内心 OS:我就改个接口返回值,至于吗?

痛点三:联调效率低下

前后端分离开发时,联调是个老大难问题:

  • 前端:接口好了吗?
  • 后端:好了,你试试
  • 前端:报错了,返回格式不对
  • 后端:我看看... 改好了
  • 前端:还是不行,字段名不一致
  • (循环往复...)

来回切换仓库、对着接口文档核对字段,这种低效的协作模式在 AI 时代显得尤为刺眼。

破局:全栈架构升级

经过一番调研,笔者决定将项目升级为 Express + React + Vite 的全栈架构。为什么选这套?

  1. Express:轻量、灵活,前端同学学习成本低,写 JavaScript 就能搞后端
  2. TypeScript 全栈:前后端共享类型定义,编译期就能发现问题
  3. Vite:开发体验一流,HMR 快得飞起
  4. 单一仓库:AI 终于能看到全貌了

最终的项目结构长这样:

fullstack-web-app/
├── client/                 # 前端代码
│   ├── pages/             # 页面组件
│   ├── components/        # 可复用组件
│   ├── hooks/             # React Hooks
│   ├── utils/             # 工具函数
│   ├── App.tsx            # 根组件
│   └── main.tsx           # 前端入口
│
├── server/                 # 后端代码
│   ├── middleware/        # Express 中间件
│   ├── utils/             # 工具函数
│   └── server.ts          # 服务端入口
│
├── env.ts                  # 环境变量
├── package.json           # 统一依赖管理
└── tsconfig.json          # TypeScript 配置

一眼望去,前端后端都在这儿了,AI 表示很满意。更重要的是前端写 nodejs 天然无障碍!

技术选型详解

一、后端框架:Express

为什么不用 NestJS 或者 Koa

NestJS 功能确实强大,但那套装饰器和依赖注入的玩法,跟 Spring Boot 有异曲同工之妙。前端同学刚从 Java 的"注解地狱"逃出来,别又给整进去了。

Koa 挺好,但生态不如 Express 丰富。选 Express 就图一个:中间件多、文档全、前端同学一看就懂

服务端入口 server.ts 的核心结构:

import express from "express";
import "express-async-errors";

export async function startup() {
  const app = express();

  // HTTP 日志(仅 API)
  app.use("/api", serveHttpLogger());
  
  // API 路由
  app.use("/api", serveApi());
  
  // 静态资源服务
  if (isProd) {
    app.use("/assets", serveAssets());
  }
  
  // 前端路由
  if (isProd || isDebug) {
    app.use(serveIndex());
  } else {
    // 开发模式:集成 Vite
    app.use("/", await serveClientVite());
  }
  
  // 全局错误处理
  app.use(serveErrorHandler());

  app.listen(port, () => {
    logger.info(`Server running on port ${port}`);
  });
}

express-async-errors 这个库必须夸一下,有了它,async/await 里的错误会自动被全局错误处理中间件捕获,再也不用写一堆 try-catch 了。

二、本地开发与热更新

开发体验是生产力的关键。这套架构的开发模式是这样的:

{
  "scripts": {
    "dev": "cross-env NODE_ENV=local tsx watch --inspect=9442 server/server.ts"
  }
}

一条命令启动,背后做了这些事:

  1. tsx watch:监听 TypeScript 文件变化,服务端代码改了自动重启
  2. Vite Dev Server:前端代码改了,浏览器自动热更新(不刷新页面)
  3. 统一端口:前后端都走 3003 端口,不用配代理

Vite 的集成是通过中间件实现的:

import { createServer, createViteRuntime } from "vite";

export async function serveClientVite() {
  const vite = await createServer({
    configFile: resolve(__dirname, "../client/vite.config.ts"),
    server: { middlewareMode: true },
    appType: "custom",
  });

  const router = Router();
  
  // Vite 中间件处理前端资源
  router.use(vite.middlewares);

  // 所有非 API 请求返回 index.html(SPA 路由支持)
  router.use("*", async (req, res, next) => {
    const url = req.originalUrl;
    let template = fs.readFileSync(
      resolve(__dirname, "../client/index-dev.html"),
      "utf-8"
    );
    template = await vite.transformIndexHtml(url, template);
    res.status(200).set({ "Content-Type": "text/html" }).end(template);
  });

  return router;
}

这套方案的好处是:

  • 前端同学还是熟悉的 Vite 开发体验
  • 不需要额外配置跨域代理
  • API 和页面请求走同一个端口,调试方便

三、环境隔离

环境管理是个容易被忽视但很重要的环节。笔者设计了三种环境:

环境 NODE_ENV 特点
本地开发 local Vite Dev Server,完整 HMR
联调测试 development 使用构建后的前端资源
生产环境 production 静态资源 + API 服务

环境变量管理使用 dotenv,并且在启动时强制校验必需变量:

// env.ts
import "dotenv/config";

export const { NODE_ENV, PORT, DATA_DIR } = process.env;
export const DEV = NODE_ENV === "development";
export const LOCAL = NODE_ENV === "local";

// 启动校验
for (const [key, value] of Object.entries({ NODE_ENV, DATA_DIR })) {
  if (!value) {
    throw new Error(`请设置 ${key} 环境变量`);
  }
}

少了哪个环境变量,启动就报错,避免线上出问题了才发现配置没写。

四、构建流程

构建分两步:

1. 前端构建

npm run build:client

Vite 会把前端代码打包到 client/dist 目录,资源文件名带 hash,方便 CDN 缓存。

这里有个小细节,Vite 默认的 hash 算法生成的文件名可能包含 -,部分 CDN 对此支持不好。所以我自定义了 hash 算法:

// vite.config.ts
function customMd5HashAlgorithm(data: Buffer): string {
  // 只使用十六进制字符,兼容 CDN
  return createHash("md5").update(data).digest("hex").slice(0, 8);
}

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        hashCharacters: customMd5HashAlgorithm,
      },
    },
  },
});

2. 后端部署

后端代码不需要编译,直接用 tsx 运行 TypeScript。生产环境启动命令:

npm start

五、服务日志

日志系统使用 Winston + DailyRotateFile

const logger = winston.createLogger({
  level: LOG_LEVEL,
  format: winston.format.combine(
    winston.format.timestamp({
      format: () => dayjs().format("YYYY-MM-DD HH:mm:ss.SSS"),
    }),
    winston.format.json()
  ),
  transports: [
    // 控制台输出(带颜色)
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
    // 文件输出(按小时轮转)
    new DailyRotateFile({
      dirname: LOG_DIR,
      filename: "app-%DATE%.log",
      datePattern: "YYYY-MM-DD-HH",
      maxSize: "100m",
      maxFiles: "7d",
    }),
  ],
});

HTTP 请求日志也做了定制,记录请求耗时、响应大小等关键信息:

// 日志格式示例
{
  "timestamp": "2026-03-19 14:30:25.123",
  "level": "info",
  "method": "POST",
  "url": "/api/submit",
  "status": 200,
  "duration": "45ms",
  "responseSize": "1.2KB"
}

六、Docker 部署

项目提供了 Dockerfile,一键部署:

FROM node:20-slim

# 时区设置
RUN rm -f /etc/localtime \
    && ln -sv /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

WORKDIR /app
COPY . ./
ENV DATA_DIR=/app/data

RUN npm install --force --registry=https://registry.npmmirror.com

EXPOSE 3003
ENTRYPOINT ["npm", "run", "start"]

基于 node:20-slim,镜像体积小,启动快。

项目设计文档

整体架构

┌─────────────────────────────────────────────────────────────┐
│                    全栈 Web 应用架构                          │
├─────────────────────────────────────────────────────────────┤
│  前端 (client/)                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  React 18 + TypeScript + React Router v7             │    │
│  │  Vite 7 构建 + Less 样式 + HMR 热更新                 │    │
│  └─────────────────────────────────────────────────────┘    │
│                           ↓ HTTP API                         │
│  后端 (server/)                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Express 4 + TypeScript + tsx 运行时                  │    │
│  │  Winston 日志 + 中间件链式处理                         │    │
│  └─────────────────────────────────────────────────────┘    │
├─────────────────────────────────────────────────────────────┤
│  开发工具: ESLint + Husky + lint-staged                      │
│  部署方式: Docker (node:20-slim)                             │
└─────────────────────────────────────────────────────────────┘

目录职责

目录 职责
client/pages/ 页面组件,一个文件对应一个路由
client/components/ 可复用 UI 组件
client/hooks/ 自定义 React Hooks
client/utils/ 前端工具函数(请求封装、XSS 过滤等)
server/middleware/ Express 中间件(路由、日志、错误处理等)
server/utils/ 后端工具函数(日志、格式化等)

开发流程

  1. 启动开发环境

    npm run dev
    
  2. 添加新页面

    • client/pages/ 创建页面组件
    • client/App.tsx 添加路由
  3. 添加新接口

    • server/middleware/serveApi.ts 添加路由处理
    • 前端使用 client/utils/request.ts 调用
  4. 构建部署

    npm run build:client  # 构建前端
    docker build -t my-app .  # 构建镜像
    

总结:AI 时代的全栈复兴

回到最初的问题:为什么要从 SPA 升级到全栈架构?
答案是:AI。
当 AI 成为开发的重要辅助工具时,代码的可理解性变得前所未有的重要。AI 需要看到完整的上下文才能给出高质量的建议:

  • 前端表单结构 → 后端参数校验
  • 数据库表结构 → API 返回格式
  • 业务逻辑 → 错误处理

前后端分离的架构,人为地把这些关联信息切割到了不同的仓库,AI 只能"盲人摸象"。

而全栈架构,把所有相关代码放在一个仓库里,AI 可以:

  • 根据后端接口自动生成前端调用代码
  • 根据数据库模型自动生成表单验证
  • 根据业务逻辑自动补全错误处理

这不是技术倒退,而是在新工具面前的架构演进。

所谓分久必合,合久必分

当然,全栈架构不是银弹。对于大型团队、复杂业务,微服务架构仍然有其价值。但对于中小型项目、快速迭代的业务,全栈架构 + AI 辅助开发,绝对是效率最优解。
笔者在此澄清一点,原有的 Java 后端服务仍然在线上提供支持,只是新增的功能涉及到后端开发时会改为 nodejs 实现 。

最后预测一下:在 AI 时代,全栈开发者会越来越吃香。不是说要精通前后端所有技术,而是要有全局视角,能够在 AI 的辅助下,快速完成端到端的功能开发。

前端同学们,是时候往全栈方向卷一卷了~


本文项目源码已开源,欢迎 Star:fullstack-web-app

Clipboard_Screenshot_1773908826.png

前端空值处理规范:Vue 实战避坑,可选链、?? 兜底写法|项目规范篇

帮助同学们学会在前端真实业务项目里,到底该怎么写空值处理(?.、??、||、if判断、兜底逻辑),以及为什么这么选、会踩哪些高频坑,顺便帮你拉直JS/TS空值、真值假值的基础概念,助力写出规范可维护的团队级代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

引子:为什么要专门聊“空值处理规范”?

一句话定位这篇文章:

教你在真实项目里,到底该怎么写空值处理( ?. ?? || if ** 判断、兜底逻辑),以及为什么这么选、会踩哪些坑**,顺便帮你把 JS/TS 的一些基础概念拉直。

适用人群:

  • 已经会写 JS / Vue,但概念有点混== null||?.?? 到底差在哪?

  • 刚入门前端的小伙伴:想从一开始就养成靠谱的代码习惯

  • 像我这样工作多年想回炉重造的工程师:系统校准一下“老习惯”是不是已经过时了 本文不会讲太多过度底层的规范条文,而是:

  • 围绕真实业务代码的写法

  • 配合 完整示例 + 场景解释

  • 重点放在:怎么选写法、为什么这么选、常见坑在哪里

一、先把“空值家族”讲清楚:null、undefined、空字符串、0、false…

日常开发中经常混在一起的几个值:


null           // 明确的“空值”,一般表示“这里有个位置,但现在没有值”
undefined      // 未定义,通常是“压根没传”、“没赋值”
''             // 空字符串
0              // 数字 0
false          // 布尔 false
NaN            // 不是一个合法数字

1.1 “真值/假值”概念(很关键)

在 JS 里,if (xxx) 判断的是“真值/假值(truthy / falsy)”,而不是严格意义上的 true/false。下面这些都是 falsy(假)

  • false
  • 0
  • -0
  • ''(空字符串)
  • null
  • undefined
  • NaN

其他的基本都被当成 truthy(真)

为什么要先讲这个?

因为 ||&& 这些逻辑运算符,走的就是“真值/假值”逻辑。

比如:


const value = 0;
const result = value || 100;
console.log(result); // 100,而不是 0

0 在 JS 里是假值,所以 value || 100 会拿到 100

这也是我们后面会反复提的一个大坑:“用 ** || ** 做默认值会把合法值 0/''/false 当成没传”

二、可选链 ?.:安全访问深层属性的标准写法

场景:从后端拿到一个复杂对象,但某一层可能是 null / undefined,直接访问就会炸:


// 假设 user 可能是 null
const city = user.profile.address.city; 
// TypeError: Cannot read properties of null (reading 'profile')

2.1 传统写法 VS 可选链

传统写法(防御式编程):


const city =
  user &&
  user.profile &&
  user.profile.address &&
  user.profile.address.city;

  • 可读性差
  • 很啰嗦
  • 稍微一改结构就容易漏一个判断

可选链写法:


const city = user?.profile?.address?.city;

  • 短很多
  • 语义清晰:如果中间任何一层是 null/undefined,就直接返回 undefined,而不是抛异常

2.2 在 Vue 模板里的使用

Vue 2 + Babel 环境Vue 3 默认 Vite 脚手架 一般都支持可选链。

在模板里:


<template>
  <div>
    <p>用户名:{{ user?.profile?.name || '未设置' }}</p>
    <p>城市:{{ user?.profile?.address?.city || '未知城市' }}</p>
  </div>
</template>

<script setup>
const user = ref(null);
// 后端请求完成后,再赋值
</script>

注意:模板表达式里也可以用 ?.||??,和 JS 里一样。

2.3 规范建议:何时必须用可选链?

我在项目里通常建议:

  • 从接口拿来的数据 + 多层嵌套对象默认用可选链
  • SDK / 第三方库返回的结构:尽量用可选链保护
  • 对于我们自己完全可控、结构固定的内部数据,可以不用(比如本地写死的配置)

统一规则示例:

  • 接口 Model 层(TypeScript 类型 + 接口封装):尽量把可选属性处理掉,往下传固定结构
  • 页面 / 组件层
    • 对于“接口原始数据”:用 ?. + 兜底字符串 / 兜底组件
    • 对于“内部状态”:减少可选,用默认值初始化

三、空值合并运算符 ??:给“真空”兜底,而不是给所有假值兜底

回顾刚才的例子:


const value = 0;
const result = value || 100;
console.log(result); // 100

如果 0 在业务里是合法值(比如“价格 0 元”、“数量 0 个”),那上面这行其实是错的。

我们想要的是:“只有在值为 null 或 undefined 的时候才给默认值”。

这就是 ?? 的作用。

3.1 || vs ?? 对比示例


console.log(0 || 100);       // 100
console.log(0 ?? 100);       // 0

console.log('' || '默认');   // '默认'
console.log('' ?? '默认');   // ''

console.log(null || '默认'); // '默认'
console.log(null ?? '默认'); // '默认'

console.log(undefined || '默认'); // '默认'
console.log(undefined ?? '默认'); // '默认'

总结一句话:

  • ||:只要左边是假值(包括 0 / '' / false / NaN / null / undefined),就用右边
  • ??:只有左边是 nullundefined 时,才用右边

3.2 在真实业务中的推荐用法

典型错误写法(很常见):


// 单价和数量来自接口
const price = item.price || 0;
const count = item.count || 1;
const total = price * count;

在这些场景会出错:

  • 价格为 0 元:price 会变成 0 || 0 → 0(这里还好)
  • 数量为 0:count 会变成 1(业务错了)
  • 用户输入了空字符串 '' 需要区分,但被直接当成没填

推荐写法:


const price = item.price ?? 0;  // 价格缺失才用 0
const count = item.count ?? 1;  // 只有未传 count 才默认 1

再比如配置项对象


function createDialog(options = {}) {
  const width = options.width ?? 400;         // 未传 width 才采用默认 400
  const closable = options.closable ?? true;  // 未传 closable 才用 true
}

3.3 在 Vue 模板中用 ??


<template>
  <div>
    <!-- 后端没给 nickName 时显示 '游客',但如果是空字符串就保持空 -->
    <p>昵称:{{ user.nickName ?? '游客' }}</p>
  </div>
</template>

规范建议:

  • 只要你的兜底逻辑只想针对 null/undefined,统一用 ??,不要用 ||
  • 保留 || 用于“逻辑或”场景,而不是“兜底默认值”。

四、兜底逻辑:不仅是运算符,还有“业务上的安全网”

可选链和空值合并属于“语法层面的防御”。

真实项目里,还需要“业务层面的兜底”,比如:

  • 数据为 null 时显示一个“空态组件”
  • 钱包余额为 null 时,不显示数字而是展示“--”
  • 列表为空时展示“暂无数据”

4.1 文本兜底:别让页面渲染出 undefined / null

错误示例:


<template>
  <div>
    <!-- 假设 user.name 可能 undefined -->
    <p>用户名:{{ user.name }}</p>
  </div>
</template>

页面可能出现:


<p>用户名:undefined</p>

推荐写法:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
  </div>
</template>

如果你更谨慎一点,还可以抽成一个小工具函数或指令:


function displayText(value, fallback = '--') {
  if (value === null || value === undefined) return fallback;
  return String(value);
}

模板中:


<p>用户名:{{ displayText(user?.name, '未设置') }}</p>

4.2 数字兜底:0、null、undefined 要区分

常见场景:金额 / 数量 / 积分


<template>
  <div>
    <!-- 如果 amount 为 0,要显示 0 元,而不是 “--” -->
    <p>金额:{{ formatAmount(order?.amount) }}</p>
  </div>
</template>

<script setup>
function formatAmount(value) {
  if (value === null || value === undefined) return '--'; // 真空
  const num = Number(value);
  if (Number.isNaN(num)) return '--';                     // 非法数字
  return num.toFixed(2) + ' 元';
}
</script>

这里的思路是:

  • 对于“真空”(null/undefined)和“非法值”(NaN),直接兜底成 --
  • 对于合法的 0、10.5 等,按正常格式化逻辑展示

4.3 列表兜底:空数组 vs null/undefined

错误写法:


<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup>
const list = ref(null);
</script>

list 为 null 时,Vue 其实不会崩溃,但可读性很差,而且 TypeScript 下会疯狂报错。

推荐规范:

  • 列表类型的数据,初始化为 [],不要初始化为 null

  • 接口响应里如果是 null在数据层统一转成 [],不要把“既可以是数组又可以是 null”的结构传到视图层


// 假设后端可能返回 { list: null }
interface ApiResponse<T> {
  list: T[] | null;
}

async function fetchUsers(): Promise<User[]> {
  const res: ApiResponse<User> = await request('/api/users');
  return res.list ?? [];
}

Vue 组件里直接:


const users = ref<User[]>([]);

onMounted(async () => {
  users.value = await fetchUsers(); // 一定是数组
});

好处:

  • 模板里 v-for="user in users" 不用可选判断
  • 业务逻辑中也不用 if (!users) 乱判
  • 类型更干净,TS 也容易推断

五、可读性 vs 防御性:别让“防空代码”毁了代码结构

经常看到这样的代码:


if (user && user.profile && user.profile.address && user.profile.address.city) {
  showCity(user.profile.address.city);
} else {
  showDefaultCity();
}

可读性非常差。我们可以结合 ?. 和业务逻辑重写:

5.1 利用中间变量提高可读性


const city = user?.profile?.address?.city;

if (city) {
  showCity(city);
} else {
  showDefaultCity();
}

如果业务含义更复杂,比如:

  • city 为空字符串也视为没填

可以:


const rawCity = user?.profile?.address?.city;
const city = rawCity?.trim(); // string 或 undefined

if (!city) {
  showDefaultCity();
} else {
  showCity(city);
}

规范建议:

  • 不要在 if (...) 里面写一大串可选链,可以先提取出来
  • 对于复杂逻辑(例如 if (a && b && c && d)),考虑拆成几个语义明确的变量

六、项目中推荐的“空值处理规范(示例版)”

以下是一份可直接落地到团队规范里的示例,你可以根据团队实际情况调整。

6.1 基础规则

  • 规则 1:接口层统一做“空值归一化”
    • 列表字段:null / undefined 统一转成 []
    • 数字字段:null / undefined 转成约定好的业务默认(如 0),或者保持 null,但要有清晰设计文档
    • 字符串字段:如果是必展示项,可以转 '',或保留 null,但组件层要有兜底文案
  • 规则 2:组件 / 页面层永远不要直接信任后端
    • 访问深层属性一律用 ?.
    • 模板输出中不要让 null / undefined 直接裸露
  • 规则 3:兜底默认值尽量用 ??,而不是 ||
    • 只有当你有意要把 0 / '' / false 也视为“空”时,才可以用 ||

6.2 风格对比示例(推荐 vs 不推荐)

不推荐:


// 1. 访问深层属性不做保护
const city = user.profile.address.city;

// 2. 用 || 做默认值
const price = item.price || 0;
const count = item.count || 1;

// 3. 列表用 null 表示“还没加载”
const list = ref(null);

推荐:


// 1. 使用可选链保护
const city = user?.profile?.address?.city;

// 2. 用 ?? 严格处理 null/undefined
const price = item.price ?? 0;
const count = item.count ?? 1;

// 3. 列表统一用 [] 作为初始值
const list = ref([]);

在 Vue 模板中的统一写法示例:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
    <p>年龄:{{ user?.age ?? '--' }}</p>

    <p>余额:{{ formatAmount(account?.balance) }}</p>

    <ul v-if="orders.length">
      <li v-for="order in orders" :key="order.id">
        订单号:{{ order.id }},金额:{{ formatAmount(order.amount) }}
      </li>
    </ul>
    <p v-else>暂无订单</p>
  </div>
</template>

<script setup>
const user = ref(null);
const account = ref(null);
const orders = ref([]); // 一定是数组

function formatAmount(value) {
  if (value === null || value === undefined) return '--';
  const num = Number(value);
  if (Number.isNaN(num)) return '--';
  return num.toFixed(2) + ' 元';
}
</script>

七、常见踩坑案例拆解

7.1 “把 0 当成没填”——报表类页面的大坑

需求:展示一个指标的环比增长率,后端字段 growthRate,可能是:

  • 0:说明没涨没跌
  • 正数:增长
  • 负数:下降
  • null:没有数据

错误写法:


<p>环比:{{ growthRate || '--' }}%</p>

growthRate = 0 时,会显示 --%,业务含义严重错误。

正确写法:


<p>环比:{{ growthRate ?? '--' }}{{ growthRate === null || growthRate === undefined ? '' : '%' }}</p>

或者包装一下:


function displayPercent(value) {
  if (value === null || value === undefined) return '--';
  return `${value}%`;
}

模板:


<p>环比:{{ displayPercent(growthRate) }}</p>

7.2 “深层属性访问炸页面”——常见于接口变更

场景:后端有一天把 user.profile 改成 user.info,但你代码里到处是:


user.profile.address.city

迁移时推荐策略:

  1. 先统一加可选链防御(短期止血):

const city = user?.profile?.address?.city;

  1. 在“数据适配层”做映射,避免在视图层直接跟后端结构硬绑定:

interface UserViewModel {
  city?: string;
  // ...
}

function mapUserDtoToViewModel(dto: any): UserViewModel {
  const profile = dto.profile || dto.info || {};
  return {
    city: profile.address?.city,
    // ...
  };
}
  1. 视图层只用 viewModel.city,再配合兜底:

<p>城市:{{ user.city ?? '未知城市' }}</p>

这样即使后端再改结构,你只需要改映射函数,不会到处是 ?. 打补丁。

八、结合 TypeScript:从“到处防空”升级为“类型上减少空值”

如果你的项目已经用 TypeScript,可以进一步 把“空值问题”提前到类型设计阶段解决

8.1 接口类型:把“可选”缩到最小

错误示例(很多后端生成工具会这样):


interface UserDto {
  id?: number;
  name?: string;
  age?: number | null;
  address?: {
    city?: string;
  } | null;
}

视图层到处是:


user?.address?.city ?? '未知城市'

更好的做法是:

  • 在“接口模型”层承认这些都是可选

  • 但在往页面传的时候,通过构造 ViewModel 把这些变成“非可选 + 有默认值”


interface UserViewModel {
  id: number;
  name: string;
  age: number | null;   // 业务上允许为 null
  city: string;         // 至少有兜底
}

function toUserViewModel(dto: UserDto): UserViewModel {
  return {
    id: dto.id ?? 0,                         // 或抛错,看业务
    name: dto.name ?? '未命名用户',
    age: dto.age ?? null,
    city: dto.address?.city ?? '未知城市',
  };
}

组件里就可以大胆用:


<p>用户名:{{ user.name }}</p>
<p>城市:{{ user.city }}</p>

而不是到处防空。

九、落地建议:如何在现有项目里逐步推行这套规范?

9.1 从“新代码”开始做对

  • 自己写的新组件、新方法,从一开始就用 ?.??
  • 审 PR 的时候,对用 || 做默认值的地方特别敏感,看清楚是否需要保留 0/''/false

9.2 为高风险页面补一层“空值巡检”

优先排查:

  • 面向 C 端用户的关键页面(订单、支付、结算)
  • 报表、数据面板类页面(数字特别多)

从这些点切入:

  • 所有深层属性访问,加上可选链或前置的空值判断
  • 所有数值展示,考虑是否需要 formatXXX 方法来统一兜底逻辑
  • 所有默认值逻辑,检查 || 能否替换为 ??

9.3 写到团队规范 / README / Contributing 里

可以直接摘抄下面一段到你们项目的规范文档里:

空值处理规范(摘要)

  1. 从接口拿到的原始数据,访问深层属性一律使用可选链 ?.
  2. 兜底默认值优先使用空值合并运算符 ??,只有在需要把 0 / '' / false 也当成“空”的场景才使用 ||
  3. 列表数据初始化为 [],不要用 null 表示“尚未加载”。接口返回 null 时在数据适配层统一转为 []
  4. 数字和金额展示需通过统一的格式化方法处理,避免页面出现 NaNundefined
  5. 模板中禁止直接输出可能为 null / undefined 的字段,必须有兜底显示(如 '--''未设置' 等)。

十、总结:把“空值处理”当成一个硬规范,而不是临时脑补

  • 可选链 ?.:用来安全访问深层属性,防止“Cannot read properties of undefined” 直接把页面干崩。
  • 空值合并 ??:只在 null / undefined 时兜底,避免误伤合法的 0 / '' / false
  • 兜底逻辑:不仅是语法问题,更是业务体验和数据安全网的问题,最好沉淀为项目级规范,而不是随手一写。

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

前端代码注释规范:Vue 实战避坑,让 3 年后的自己还能看懂代码|项目规范篇

一套真正能落地的前端代码注释规范,从 Vue 项目实战出发,告诉你注释该写什么、不该写什么,避开常见坑点,写出让 3 年后的自己还能看懂的可维护代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

前言:为什么要认真对待“写注释”这件小事?

你有没有遇到过这些场景:

  • 半年前自己写的业务,今天改个小需求,打开文件之后第一反应:“这谁写的垃圾代码?”,再一看作者:是自己。

  • 接手别人老项目,逻辑绕来绕去,偶尔看到一行注释:// TODO// 这里有点问题,先这么写……然后就没有然后了。

  • 为了“规范”,团队强行要求每个函数、每个变量都加注释,结果:注释和代码一起过期,甚至误导后来的人。

这篇文章就想解决一个现实问题:

日常写代码时,注释到底该怎么写?为什么这么写?坑会踩在哪?

目标是:让 3 年后的自己和队友,打开代码就能快速搞懂上下文,而不是骂人。

本文不是讲晦涩的底层原理,而是站在一线开发、项目规范的视角,用 Vue / 前端开发场景来聊聊“代码注释规范”。

一、第一原则:好代码胜过好注释,但没有注释也不一定是好代码

1.1 一句话核心原则

能用清晰的命名和结构表达含义,就不要用注释补课。注释只做代码无法表达的“额外信息”。

很多团队会陷入两个极端:

  • 极端 1:注释洁癖“好的代码不需要注释”,结果写一堆晦涩难懂的缩写变量,没人看得懂。

  • 极端 2:注释狂魔几乎每一行都要注释:

    
    // 声明一个变量 a
    let a = 1;
    // a 加 1
    a++;
    
    

    这种注释只会浪费时间、增加维护成本。

正确姿势:

  • 优先改代码,让代码本身更清晰(变量名、函数名、拆分方法、抽象组件……)

  • 其次用注释补充“代码表达不到的信息”,例如:

    • 为什么要这么写(业务背景 / 历史原因 / 兼容性)
    • 注意事项(性能、边界条件、已知坑)
    • 和其他模块的约定(接口协议、调用顺序)

二、注释的四大黄金场景:该写什么?

下面是我在项目里常用、非常推荐的四类注释场景。

2.1 解释“为什么这么写”(Why),而不是“代码在干嘛”(What)

What 代码自己能看出来,Why 只能靠你写出来。

❌ 错误示例:只是重复代码


// 获取用户列表
const users = await fetchUsers();

  • 这行注释几乎就是在重复变量名,没有信息增量

✅ 推荐示例:解释设计/业务原因


// 这里不能直接用缓存的用户列表:
// 1. 用户状态(在线/离线)是实时的
// 2. 后端会根据当前登录态过滤可见用户
// 所以每次都强制请求最新数据
const users = await fetchUsers({ forceRefresh: true });

这里的注释说明了为什么不能优化成缓存,以后有人想“优化性能”时,看到注释就会收手,避免踩坑。

2.2 标记“约定”和“前置条件”:别人需要遵守什么?

在 Vue 组件、工具函数、API 调用中,最容易出问题的往往不是“实现细节”,而是使用前提

  • 参数有没有默认值?
  • 有哪些边界情况?
  • 调用顺序有没有依赖?

✅ Vue 组件示例:在 props / emits 上写注释


// UserForm.vue <script setup lang="ts">
interface Props {
  /**
   * 表单模式:
   * - 'create':新建用户,所有字段可编辑
   * - 'edit':编辑用户,用户名不可修改
   * - 'readonly':只读模式,所有字段禁用
   */
  mode: 'create' | 'edit' | 'readonly';

  /**
   * 编辑/只读模式下必传:
   * 后端返回的完整用户信息。
   * create 模式下可以不传(内部会使用默认值)
   */
  user?: User;
}

const props = defineProps<Props>();

/**
 * 表单提交事件:
 * - create: 提交的 user.id 由后端生成
 * - edit: 必须包含原有的 user.id
 */
const emit = defineEmits<{
  (e: 'submit', payload: User): void;
}>();

这里注释的作用非常明确:

  • 告诉你 mode 不同模式的差别
  • 告诉你 user 在什么模式下是必传的
  • 告诉你 submit 的 payload 长什么样

重点:这类注释是“契约”的一部分,写在类型(interface / props / emits)附近最合适。

2.3 记录“历史遗留”和“坑点说明”:这块代码为什么这么丑?

有些代码你也知道写得不优雅,但短期内又不能重构,比如:

  • 老接口的奇怪字段命名
  • 历史版本遗留的时间格式
  • 奇怪的兼容写法(低版本浏览器 / 特定设备)

与其未来被队友(或自己)怒喷:

“这谁写的?怎么这么鬼畜?”

不如提前写清楚原因。

✅ 示例:兼容老接口


/**
 * 注意:后端这个接口是老系统保留的,字段命名非常诡异。
 * - 'usr_nm' 对应用户姓名
 * - 'crt_tm' 是创建时间字符串,格式为 'YYYY/MM/DD HH:mm:ss'
 * 暂时不能动这个接口,只在这里统一做一次映射。
 */
function normalizeLegacyUser(raw: any): User {
  return {
    id: raw.id,
    name: raw.usr_nm,
    createdAt: dayjs(raw.crt_tm, 'YYYY/MM/DD HH:mm:ss').toDate(),
  };
}

以后谁要改这个接口时,看到注释就会明白:

  • 这是历史债务,不是你写代码水。
  • 如果要改,要 连后端 / 老系统一并考虑

2.4 对复杂算法 / 业务流程做“概览说明”:给后人一张思维导图

有些模块就算代码写得再优雅,逻辑本身就是复杂的

  • 多步骤审批流
  • 复杂的优惠券 / 价格计算规则
  • 权限控制(菜单 + 按钮 + 数据权限)

这种时候,不要指望“代码自解释”,加一段流程性注释是对所有人的救赎。

✅ 示例:订单价格计算(假设你在 calculateOrderPrice.ts 里)


/**
 * 订单价格计算规则(简化版):
 *
 * 1. 基础金额 = 所有商品单价 * 数量 之和
 * 2. 商品级优惠:
 *    - 满减券:优先按商品分类应用,不能跨分类凑单
 *    - 折扣券:在满减之后应用,最多 2 张
 * 3. 订单级优惠:
 *    - 平台券:在所有商品级优惠之后应用
 *    - 封顶逻辑:总优惠金额不能超过基础金额的 30%
 * 4. 运费:
 *    - 满 99 元包邮
 *    - 其他情况按地区和重量计算
 *
 * 注意:
 * - 所有金额都用「分」为单位在内部计算,避免浮点误差
 * - 对外展示时再转换为「元」
 */
export function calculateOrderPrice(order: Order): OrderPriceDetail {
  // 具体实现略
}

这里注释的价值在于:

  • 给出了整体流程(按步骤)
  • 标明了关键约束(封顶 30%、单位是“分”)
  • 以后别人改逻辑时,有一个可以“对齐口径”的地方

三、哪些注释是坚决不要写的?

知道“该写什么”之后,更重要的是:哪些注释写了只会拖团队后腿?

3.1 重复代码的注释:浪费时间 + 增加维护成本

❌ 示例 1:重复变量名


// 用户名称
const userName = getUserName();

❌ 示例 2:重复函数名 / 类型名


/**
 * 获取用户列表
 */
function getUserList() { ... }

这些注释的问题:

  • 没有额外信息
  • 只要一改函数名/变量名,注释就有可能不一致
  • 时间久了变成“看着像对的,其实是错的”

解决办法:

  • 优先把命名改清晰:getListgetUserListdatauserList / formState
  • 确实没啥要补充的,就不要写注释,空着反而更安全。

3.2 “心情日志”注释:TODO / FIXME 不写清楚内容

❌ 典型反面教材:


// TODO: 后续优化
// FIXME: 有 bug

半年后你自己也不知道:

  • 要优化什么?
  • 有什么 bug?复现步骤是什么?
  • 是否已经修了?是否还有影响?

✅ 推荐写法:


// TODO(v2.1): 表格数据量>1w时,滚动卡顿,需要引入虚拟列表
// 影响范围:订单列表、用户列表


// FIXME(2025-03-18 by 张三):
// 后端偶发返回重复的 orderId,导致 set 里丢数据
// 临时方案:前端用 (orderId + createdAt) 拼接作为 key,等后端修复后移除

规范建议:

  • TODO / FIXME 注释建议包含:

    • 触发条件 / 复现方式
    • 影响范围
    • (可选)目标版本/时间 & 责任人缩写
  • 团队可以规定:重要 TODO / FIXME 必须对应 Jira/禅道/飞书任务号,比如:


// TODO(JIRA-1234 v2.2): 支持多语言,先写死为中文

3.3 和真实逻辑不一致的注释:比没有注释更可怕

注释一旦和代码不一致,就会变成误导信息

❌ 示例:注释没更新


/**
 * 返回 true 表示用户未登录
 */
function isLoggedIn() {
  return !!localStorage.getItem('token');
}

显然逻辑是“有 token 才是登录”,但注释写反了。

如果后来别人只看注释不看实现,很容易写出一堆反逻辑的代码。

经验结论:

写过时注释 = 欺骗未来的同事。

写了就要维护,维护不了就少写。

所以在团队规范里可以明确:

  • 改动逻辑时,必须同步检查相关注释是否仍然正确
  • Code Review 时,把**“注释是否仍然成立”**当成一个检查点

3.4 写在实现细节里的“小说故事”:越写越乱

有同学特别喜欢在函数内部“边写边感想”,比如:


function fetchData() {
  // 这里先判断一下是不是有缓存
  // 如果有缓存的话就不用请求接口了
  // 但是这里我们又觉得可能缓存会不准
  // 所以又加了一个时间戳的判断
  // 总之就是很复杂,先这么写吧……
}

这种注释的问题:

  • 没有结构,像碎碎念日记
  • 讲了一堆感受,没有讲清楚最终规则
  • 以后别人看的时候,只会更迷惑

更好的做法:

  • 把真正关键的规则整理成条目
  • 其他的犹豫、不确定、吐槽,写到需求文档 / 评审记录里,而不是代码里

✅ 重写示例:


/**
 * 缓存策略说明:
 * 1. 默认命中缓存,避免重复请求
 * 2. 如果缓存时间超过 5 分钟,则强制请求最新数据
 * 3. 切换用户时,必须清空缓存(用户隔离)
 */
function fetchData() {
  // 实现略
}

四、不同层级怎么写?以 Vue 项目为例的一套落地规范

下面从 Vue 项目常见几层结构出发,给一套可直接落地到项目里的注释建议

4.1 组件层(Vue SFC):注释重点放在哪里?

4.1.1 props / emits / expose 是最值得写注释的地方

因为它们构成了组件的“对外接口”。

✅ 示例:表单组件


<script setup lang="ts">
interface Props {
  /**
   * 表单初始值:
   * - 不传则使用内部默认值
   * - 传入时会完全覆盖默认值(不要只传部分字段)
   */
  modelValue?: UserFormModel;

  /**
   * 是否立即在 mounted 后拉取远程选项数据
   * 默认 true;如果父组件要控制时机,可以传 false 后手动调用 `reloadOptions`
   */
  autoLoadOptions?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  autoLoadOptions: true,
});

const emit = defineEmits<{
  /**
   * 表单提交成功时触发
   * payload 包含表单内的所有字段
   */
  (e: 'submit', payload: UserFormModel): void;

  /**
   * 任意字段变化时触发(用于实时保存草稿)
   */
  (e: 'update:modelValue', value: UserFormModel): void;
}>();

defineExpose({
  /**
   * 重新拉取远程下拉选项
   */
  reloadOptions,
});
</script>

这里的注释能让你在不看实现的情况下,就知道怎么用这个组件,这就是高价值注释。

4.1.2 复杂模板逻辑,优先拆组件,其次写块级注释

当模板里出现大量条件判断 / 嵌套 v-if / v-for 时:

  1. 优先选择“拆小组件 / 抽函数”
  2. 仍然复杂时,可以在逻辑块上方加一段块级注释,说明大体意图

✅ 示例:


<template>
  <!-- 展示可见的菜单项:
       1. 已被后端标记为启用
       2. 当前用户有权限
       3. 如果是移动端,只显示前 5 个
  -->
  <MenuItem
    v-for="item in visibleMenuItems"
    :key="item.id"
    :item="item"
  />
</template>

这里注释的作用:

  • 总结了 visibleMenuItems过滤规则
  • 方便别人查找时快速定位逻辑(比如“为什么这个菜单在移动端消失了?”)

4.2 业务逻辑层(hooks / composables / services)

很多 Vue 3 项目会把复杂逻辑拆到:

  • useXXX.ts(逻辑复用)
  • xxxService.ts(调用后端接口 + 业务规则)

这部分逻辑往往最需要注释,但注释也最容易乱写。

4.2.1 统一写在函数/方法签名上方,说明职责和返回值

✅ 示例:组合式函数


/**
 * 订单列表的分页 + 筛选逻辑:
 * - 对外暴露响应式数据:list、loading、pagination
 * - 支持关键字搜索、状态筛选
 * - 初始化时自动加载一次数据
 */
export function useOrderList() {
  const list = ref<Order[]>([]);
  const loading = ref(false);
  const pagination = reactive({
    page: 1,
    pageSize: 20,
    total: 0,
  });

  // ...

  return {
    list,
    loading,
    pagination,
    reload,
    resetFilters,
  };
}

4.2.2 和后端接口交互的地方,注释协议差异/约束

✅ 示例:Service 层


/**
 * 获取订单详情:
 * - 后端只在 status='PAID' 时返回 payInfo 字段
 * - 如果订单已退款,refoundInfo 字段存在但可能为空对象
 * - 接口有 500ms 左右的延迟,注意不要在输入框输入时频繁调用
 */
export async function fetchOrderDetail(orderId: string): Promise<OrderDetail> {
  const { data } = await request.get(`/api/orders/${orderId}`);
  return normalizeOrderDetail(data);
}

这些信息如果不写在这里,很难在代码中第一时间发现,却又对上层调用逻辑影响极大。

4.3 工具层(utils / helpers):何时需要注释?

  • 通用的小工具函数,命名清晰时可以不用注释:

    
    export function formatPrice(amountInCent: number): string { ... }
    
    
  • 如果函数有一些隐含约束或性能特征,就应该注释说明:

✅ 示例:


/**
 * 深拷贝对象(仅用于小对象):
 * - 基于 JSON 序列化,不支持函数 / Date / Map / Set
 * - 遇到循环引用会抛错
 * 适合用于「接口 mock 数据」等简单场景,不要在核心路径频繁使用。
 */
export function simpleClone<T>(obj: T): T {
  return JSON.parse(JSON.stringify(obj));
}

五、团队层面的“注释规范建议”:可以直接抄到你们 RULE.md 里

下面给一份可以直接落地的团队规范草稿,你可以根据实际情况微调。

5.1 总体原则

  • P1:注释是代码的一部分,写了就要维护。
  • P2:注释说明“为什么 / 有什么坑 / 有什么约定”,不要“翻译代码”。
  • P3:宁可少写,也不要写错;宁可写在“合适位置”,也不要乱丢。

5.2 “必须注释”的场景

  • 对外接口:
    • 组件的 props / emits / expose
    • 公共工具函数 / Service 层函数的入参、返回值说明(特别是有约束时)
  • 复杂业务逻辑 / 算法:
    • 在函数 / 模块顶部写整体流程说明或规则列表
  • 历史遗留 / 兼容代码:
    • 必须说明历史背景 / 兼容对象 / 计划替换方案
  • TODO / FIXME:
    • 必须写明触发条件 / 影响范围 / 预期目标
    • 建议关联任务号(如:TODO(JIRA-1234)

5.3 “禁止/不鼓励”的注释

  • 重复代码内容的注释(变量名 / 函数名已经表达清楚)
  • 空泛的 TODO / FIXME(未说明问题和上下文)
  • 纯吐槽 / 情绪化注释
  • 长篇大论但没有结构的“感想式注释”

六、一个完整的小案例:从“糟糕注释”到“可维护代码”

下面用一个实际例子,演示如何从“混乱风格”改到“规范易读”。

6.1 初版(很多人项目里真实存在的写法)


<!-- OrderList.vue -->
<script setup lang="ts">
// 订单列表组件

const data = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 获取列表
async function getList() {
  loading.value = true;
  // 调接口
  const res = await request.get('/api/list', {
    params: {
      p: page.value,
      ps: pageSize.value,
    },
  });
  // 处理数据
  data.value = res.data.list;
  total.value = res.data.total;
  loading.value = false;
}

// TODO: 后面要加筛选
</script>

<template>
  <!-- 列表 -->
  <Table :data="data" />
</template>

问题:

  • 命名不清晰(data / getList / /api/list
  • 注释几乎都是废话,没有说明任何约束
  • TODO 没有说明到底怎么“要加筛选”

6.2 改进版:结合命名 + 注释一起升级


<!-- OrderList.vue -->
<script setup lang="ts">
/**
 * 订单列表页:
 * - 支持分页
 * - 计划后续增加:状态筛选、关键字搜索(见 TODO)
 */
import { fetchOrderList } from '@/services/order';

const orders = ref<Order[]>([]);
const loading = ref(false);
const pagination = reactive({
  page: 1,
  pageSize: 10,
  total: 0,
});

/**
 * 拉取订单列表:
 * - 后端的页码从 1 开始(不要传 0)
 * - pageSize 最大不超过 100,否则后端会报错
 */
async function loadOrders() {
  loading.value = true;
  const res = await fetchOrderList({
    page: pagination.page,
    pageSize: pagination.pageSize,
  });
  orders.value = res.list;
  pagination.total = res.total;
  loading.value = false;
}

// TODO(v2.1): 增加筛选条件(状态 / 下单时间区间)
// - 与后端对齐接口 GET /api/orders:新增 status / startAt / endAt 参数
// - UI 上用折叠面板隐藏高级筛选
</script>

<template>
  <OrderTable
    :data="orders"
    :loading="loading"
    :pagination="pagination"
    @change="loadOrders"
  />
</template>

这里我们做了几件事:

  • 改变量名:dataordersgetListloadOrders
  • 提取 Service 层:fetchOrderList(便于复用与测试)
  • 用注释补充约束和未来计划,而不是重复代码

这就是一个**“代码 + 注释配合良好”的例子**。

七、如何把“注释规范”写成一篇能发 CSDN 的文章?

你可以按本文结构,稍作润色,就能产出一篇完整的博客。建议大致结构如下:

  1. 引子(痛点故事)
    • 自嘲+团队真实场景,引出“注释到底该不该写”的问题
  2. 第一原则:好代码优先,注释补充 Why & 限制
  3. 四大高价值注释场景
    • Why / 约定 / 历史坑点 / 复杂流程概览
  4. 四类反面注释示例
    • 重复代码、空 TODO/FIXME、过期注释、碎碎念
  5. 结合 Vue 项目结构的一套实践
    • 组件层、业务层、工具层分别给建议和示例
  6. 前后对比小案例
    • “糟糕版” vs “改进版”
  7. 总结 + 个人习惯分享
    • 比如:写完函数先写注释再实现、Review 时检查注释等

你可以直接把上文复制到 CSDN,稍微调整标题 / 小节顺序,并补充你自己项目中的真实故事和代码片段,会更有代入感和说服力。

八、结语:写给 3 年后的自己

注释不是给现在的你看的,是给“未来的你”和“曾经不认识你的同事”看的。

  • 多写一点“为什么这么写”,少写一点“这行在干嘛”
  • 多写一点“有什么坑 / 有什么约束”,少写一点“将来再说”
  • 写得少,但每一行都值钱,比写一堆废话强太多

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

Electron+React必看:electron-router-dom 完整实战指南(含路由守卫/传参/多窗口)

做过 Electron + React 桌面端开发的兄弟,大概率都被路由兼容坑惨过:原生 react-router-dom 在开发环境跑得溜,打包生产直接失效;多窗口场景下路由互相污染,关窗还残留历史栈;开发/生产环境加载逻辑不一致,调试到头秃。

今天带来 electron-router-dom 完整版教程,基于官方入门文扩展,不仅保留基础上手流程,还把路由守卫、动态传参、嵌套路由、生产优化、底层逻辑一次性讲透,10年桌面端开发踩坑经验全塞进去,复制代码直接落地。

核心定位:react-router-dom 官方适配器,专为 Electron 多窗口、开发/生产双环境定制

核心解决:环境兼容、多窗口路由隔离、路由污染、生产失效四大痛点

一、先搞懂:为什么原生 react-router-dom 不适配 Electron?

很多人直接把网页路由搬到 Electron,踩坑了都不知道原因。底层逻辑很简单:

  • 网页是单窗口、hash/history 路由模式,Electron 多窗口是独立渲染进程,路由状态无法隔离
  • 开发环境用 localhost 服务,生产环境加载本地 HTML 文件,路由路径解析规则不一致
  • 原生路由没有窗口 ID 绑定,多窗口共用一个路由栈,导致跳转混乱、内存泄漏

electron-router-dom 就是做了一层封装:通过窗口 ID 绑定路由,让每个窗口拥有独立路由栈,自动适配开发/生产环境的路径解析,完美兼容 react-router-dom 原有 API(useNavigate、useParams 等)。


二、完整安装流程(含依赖避坑)

该库依赖 react-router-dom,必须同步安装,别漏装导致启动报错:

# npm 安装
npm i electron-router-dom react-router-dom

# yarn 安装
yarn add electron-router-dom react-router-dom

# pnpm 安装(推荐)
pnpm add electron-router-dom react-router-dom

重点提醒:react-router-dom 必须是 v6 版本(v5 不兼容),当前主流项目都是 v6,直接安装即可。

三、主进程全配置(开发/生产双环境+多窗口)

主进程核心是创建窗口 + 绑定窗口 ID + 区分环境加载路由,这步是路由生效的关键,窗口 ID 必须和渲染进程严格对应,不能错!

import { app, BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createFileRoute, createURLRoute } from 'electron-router-dom'
import { join } from 'path'

// 封装创建窗口函数,id 为路由唯一标识
function createWindow(id: string, options: BrowserWindowConstructorOptions = {}) {
  const window = new BrowserWindow({
    width: 700,
    height: 473,
    ...options,
    // 推荐开启,避免白屏
    show: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      // 关闭跨域限制(桌面端常用)
      webSecurity: false,
      nodeIntegration: false,
      contextIsolation: true
    }
  })

  // 开发环境:加载本地服务路由
  const devURL = createURLRoute(process.env.ELECTRON_RENDERER_URL!, id)
  // 生产环境:加载本地 HTML 文件路由
  const prodRoute = createFileRoute(join(__dirname, '../renderer/index.html'), id)

  // 环境区分加载
  if (process.env.NODE_ENV === 'development') {
    window.loadURL(devURL)
  } else {
    window.loadFile(...prodRoute)
  }

  // 页面加载完毕再显示,避免闪烁
  window.once('ready-to-show', () => window.show())
  return window
}

// 应用就绪后创建窗口
app.whenReady().then(() => {
  // 主窗口,id = main
  createWindow('main')
  // 关于窗口,id = about
  createWindow('about', { width: 450, height: 350 })
})

// 关闭所有窗口退出(mac 除外)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

四、渲染进程路由配置(基础+嵌套+多窗口)

渲染进程通过 Router 组件按窗口 ID 配置路由,和主进程 ID 一一对应,支持嵌套路由、路由分组。

1. 路由配置文件(routes.tsx)

import { Router, Route } from 'electron-router-dom'
// 引入页面组件
import { MainScreen, SearchScreen, AboutScreen, UserDetailScreen } from './screens'
// 引入路由守卫组件
import { AuthGuard } from './guards/AuthGuard'

export function AppRoutes() {
  return (
    <Router
      // 主窗口路由id=main),支持嵌套/多路由
      main={
        <>
          {/* 基础路由 */}
          <Route path="/" element={<MainScreen />} />
          {/* 带路由守卫的路由(需登录) */}
          <Route path="/search" element={<AuthGuard><SearchScreen /></AuthGuard>} />
          {/* 动态传参路由 */}
          <Route path="/user/:id" element={<UserDetailScreen />} />
        </>
      }
      // 关于窗口路由(id=about),独立路由栈
      about={<Route path="/" element={<AboutScreen />} />}
    />
  )
}

2. 入口文件挂载路由(index.tsx)

import React from 'react'
import ReactDOM from 'react-dom/client'
import { AppRoutes } from './routes'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
  <React.StrictMode>
    <AppRoutes />
  </React.StrictMode>
)

五、核心进阶用法(原文缺失,必看)

1. 路由跳转(useNavigate 用法)

和原生 react-router-dom 完全一致,直接复用原有写法,无需改逻辑:

import { useNavigate } from 'react-router-dom'

export function MainScreen() {
  const navigate = useNavigate()

  return (
    <main style={{ padding: '20px' }}>
      <h1>主窗口</h1>
      {/* 普通跳转 */}
      <button onClick={() => navigate('/search')}>跳转搜索页</button>
      {/* 动态传参跳转 */}
      <button onClick={() => navigate('/user/1001')}>查看用户详情</button>
      {/* 返回上一页 */}
      <button onClick={() => navigate(-1)}>返回</button>
    </main>
  )
}

2. 动态路由参数获取(useParams)

import { useParams } from 'react-router-dom'

export function UserDetailScreen() {
  // 获取路由上的 id 参数
  const { id } = useParams<{ id: string }>()

  return (
    <div>
      <h2>用户详情页</h2>
      <p>用户ID:{id}</p>
    </div>
  )
}

3. 路由守卫(权限控制,登录拦截)

封装高阶组件,实现未登录跳转登录页,桌面端权限控制必备:

// src/guards/AuthGuard.tsx
import { Navigate } from 'react-router-dom'

interface AuthGuardProps {
  children: React.ReactNode
}

export function AuthGuard({ children }: AuthGuardProps) {
  // 判断登录状态(可从 store/preload 读取)
  const isLogin = localStorage.getItem('token') ? true : false

  // 未登录跳转首页
  if (!isLogin) return <Navigate to="/" replace /&gt;
  // 已登录放行
  return children
}

4. 多窗口通信 + 路由联动

通过 preload 暴露方法,主进程打开新窗口,渲染进程触发,路由自动隔离:

// 页面组件内调用
const { App } = window // preload 暴露的 API
<button onClick={() => App.openAboutWindow()}>打开关于窗口</button>

六、生产打包避坑(关键!)

  • 打包前务必校验 窗口 ID 一致性,主进程和渲染进程必须完全匹配
  • 生产环境关闭 devTools,路由文件路径别写错,避免加载失败
  • 路由不要用绝对路径,统一用相对路径,防止跨域/文件找不到
  • 多窗口关闭时,同步清理路由状态,避免内存泄漏

七、底层逻辑简析(看懂不踩坑)

electron-router-dom 本质是路由分发器

  1. 主进程通过窗口 ID 标记路由,开发环境拼接 URL,生产环境拼接文件路径
  2. 渲染进程通过 ID 匹配对应路由组,每个窗口路由栈独立,互不干扰
  3. 内部兼容 react-router-dom v6 核心 API,上层写法无感知,底层做环境适配

八、总结

electron-router-dom 是 Electron + React 开发的路由神器,解决了原生路由最头疼的环境和多窗口问题。基础用法简单易上手,进阶用法(守卫、传参、嵌套)完全兼容 react-router-dom,上手成本极低。

建议大家把这份教程收藏,项目里直接复制配置,再也不用折腾路由兼容问题。如果碰到窗口白屏、路由失效,优先检查窗口 ID 是否一致、环境路径是否正确,90% 的坑都能解决。

点赞+收藏,Electron 开发少走一周弯路,需要完整项目模板的评论区留言~

❌