普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月8日技术

成为AI全栈 - 第4课:Drizzle ORM SQLite Elysia 数据库实战

作者 铁皮饭盒
2026年5月8日 16:41

从今天开始, 你是架构师,学会关键词就行,代码让AI实现😁


数据库就像持久化的 Excel,ORM 让你用代码操作它


今天你会学到这些关键词

| 关键词 | 一句话解释 | | :-- | :-- | | Drizzle ORM | TypeScript 友好的 ORM,用代码操作数据库 | | SQLite | 轻量级文件数据库,无需安装 | | Elysia | 高性能 Web 框架,链式 API 设计 | | 参数化查询 | 防止 SQL 注入的安全查询方式 |

一句话总结:用 Drizzle ORM + 参数化查询操作 SQLite,让 Elysia 服务拥有真正的数据持久化能力。


上节课回顾

上节课我们用 Elysia 实现了用户管理 API:

GET /users      → 查询所有用户
GET /users/:id  → 查询单个用户
POST /users     → 创建用户
PUT /users/:id  → 更新用户
DELETE /users/:id → 删除用户

但数据存在内存里:

const users = new Map();

问题:

  • • 服务重启,数据丢失

  • • 无法多服务共享数据

  • • 不能复杂查询

解决方案:使用数据库。


数据库是什么?

一句话:持久化存储数据的地方。

内存存储(Map/数组)    数据库(SQLite/MySQL)
─────────────────────────────────────────────
• 服务重启数据丢失      • 数据持久保存
• 存在内存里            • 存在硬盘上
• 简单快速              • 功能强大,支持复杂查询

类比:

  • • 内存存储 = 草稿纸,写完就扔

  • • 数据库 = 笔记本,永久保存


为什么选择 SQLite 学习数据库?

SQLite 的适用场景:

| 场景 | 是否适合 SQLite | | :-- | :-- | | 学习数据库基础 | ✅ 完美 | | 开发测试环境 | ✅ 快速搭建 | | 小型应用(用户 < 10万) | ✅ 推荐 | | 独立桌面/移动应用 | ✅ 推荐 | | 高并发写入(> 1000 QPS) | ❌ 不推荐 | | 多进程同时写入 | ❌ 不推荐 | | 需要网络访问 | ❌ 不推荐 |

为什么用 SQLite 学习 SQL?

传统数据库(MySQL/PostgreSQL)    SQLite
─────────────────────────────────────────────
• 需要安装数据库服务            • 零配置,零安装
• 需要启动数据库服务            • 直接打开文件就能用
• 配置复杂(用户、权限、端口)    • 就是一个 .db 文件
• 占用系统资源多                • 占用资源极少
• 命令行工具需要单独学习         • 直接用 AI 生成 SQL

SQLite 学习路线:

第4课(SQLite)→ 理解数据库和 ORM 基础
     ↓
第8课(PostgreSQL)→ Docker 部署生产级数据库
     ↓
第15课(MySQL)→ Java Spring Boot 生产环境

一句话:先用 SQLite 快速入门,等项目大了再换 PostgreSQL。


SQL 是什么?

SQL 是操作数据库的语言。

-- 查询所有用户
SELECT * FROM users;

-- 创建新用户
INSERT INTO users (name, email) VALUES ('张三''zs@example.com');

-- 更新用户
UPDATE users SET name = '李四' WHERE id = 1;

-- 删除用户
DELETE FROM users WHERE id = 1;

但我们不需要手写 SQL。


ORM 是什么?

ORM = 用代码操作数据库,不用写 SQL。

// 不用写 SQL,用代码操作
await db.insert(users).values({ name: "张三", email: "zs@example.com" });

// 自动转成:INSERT INTO users (name, email) VALUES (?, ?)

好处:

  • • ✅ 不用记 SQL 语法

  • • ✅ 类型安全,IDE 有提示

  • • ✅ 代码更易维护


Drizzle ORM 简介

Drizzle 是一个轻量级、类型安全的 ORM,完美支持 Bun.js 和 Node.js。

安装:

bun add drizzle-orm
bun add -d drizzle-kit

# Node.js 用户
npm install drizzle-orm
npm install -D drizzle-kit

用 AI 生成完整代码

复制这段提示词:

用 Bun.js + Drizzle ORM + SQLite 创建用户管理系统

要求:
1. 数据库配置
   - 使用 bun:sqlite(Bun 用户)或 better-sqlite3(Node.js 用户)
   - 数据库文件 app.db

2. 用户表结构:
   - id: 整数,自增主键
   - name: 文本,必填
   - email: 文本,必填,唯一
   - createdAt: 时间戳,默认当前时间

3. 实现 RESTful API:
   - GET /users - 查询所有用户
   - GET /users/:id - 查询单个用户
   - POST /users - 创建用户
   - PUT /users/:id - 更新用户
   - DELETE /users/:id - 删除用户

4. 安全要求:
   - 所有 SQL 使用 :name 占位符
   - 禁止字符串拼接 SQL

5. 统一响应格式:
   { success, data, message }

6. 使用 Elysia 框架

请生成完整的项目代码,包含:
- schema.ts - 表定义
- db.ts - 数据库连接
- index.ts - API 路由

AI 生成的代码结构

1. schema.ts - 定义表结构

import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  idinteger("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date())
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

对应 SQL:

CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  created_at INTEGER NOT NULL
);

2. db.ts - 数据库连接

💡 Bun 用户:使用 bun:sqlite💡 Node.js 用户:使用 better-sqlite3

// Bun 用户
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.run(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);
// Node.js 用户
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);

3. index.ts - API 实现

import { eq } from "drizzle-orm";
import { Elysia } from "elysia";
import { db } from "./db";
import { users } from "./schema";

const app = new Elysia()
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })

  // 查询所有用户
  .get("/users", async () => {
    const allUsers = await db.select().from(users).all();
    return {
      success: true,
      data: allUsers,
      message: "查询成功"
    };
  })

  // 查询单个用户
  .get("/users/:id", async ({ params: { id }, set }) => {
    const user = await db.select().from(users).where(eq(users.id, Number(id))).get();

    if (!user) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: user,
      message: "查询成功"
    };
  })

  // 创建用户
  .post("/users", async ({ body, set }) => {
    const { name, email } = body as { name: string; email: string };

    if (!name || !email) {
      set.status = 400;
      return { success: false, data: null, message: "name 和 email 不能为空" };
    }

    try {
      const result = await db.insert(users).values({
        name,
        email
      }).returning();

      set.status = 201;
      return {
        success: true,
        data: result[0],
        message: "创建成功"
      };
    } catch (error) {
      set.status = 400;
      return { success: false, data: null, message: "邮箱已存在" };
    }
  })

  // 更新用户
  .put("/users/:id", async ({ params: { id }, body, set }) => {
    const { name, email } = body as { name?: string; email?: string };

    const result = await db.update(users)
      .set({ name, email })
      .where(eq(users.id, Number(id)))
      .returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: result[0],
      message: "更新成功"
    };
  })

  // 删除用户
  .delete("/users/:id", async ({ params: { id }, set }) => {
    const result = await db.delete(users).where(eq(users.id, Number(id))).returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: null,
      message: "删除成功"
    };
  })

  .listen(3000);

console.log(`Server running at http://localhost:${app.server?.port}`);

💡 Node.js 用户注意:需要使用 Node 适配器

import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

// ... 路由代码 ...

.listen(3000, node);  // ← 加上 node 适配器

运行测试

安装依赖:

bun install
# Node.js 用户
npm install

启动服务:

bun run index.ts
# Node.js 用户
npm run dev

测试接口:

# 创建用户
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "张三", "email": "zs@example.com"}'

# 查询所有用户
curl http://localhost:3000/users

# 服务重启后,数据还在!

核心概念对照

| 概念 | Drizzle 代码 | 对应 SQL | | :-- | :-- | :-- | | 查询所有 | db.select().from(users) | SELECT * FROM users | | 条件查询 | .where(eq(users.id, id)) | WHERE id = ? | | 插入 | db.insert(users).values({...}) | INSERT INTO users ... | | 更新 | db.update(users).set({...}) | UPDATE users SET ... | | 删除 | db.delete(users) | DELETE FROM users |


安全提示:参数化查询

❌ 错误写法(SQL 注入风险):

const sql = `SELECT * FROM users WHERE email = '${email}'`;
// 如果 email = "' OR '1'='1"
// 变成:SELECT * FROM users WHERE email = '' OR '1'='1'
// 结果:返回所有用户!

✅ 正确写法(Drizzle 自动参数化):

await db.select().from(users).where(eq(users.email, email));
// 自动使用占位符,安全!

核心收获

今天学习了:

✅ 数据库 = 持久化存储✅ ORM = 用代码操作数据库✅ Drizzle = Bun.js/Node.js 的 ORM 选择✅ 参数化查询 = 防止 SQL 注入


下节课预告

第5课:登录功能怎么实现?一文搞懂认证

我们将:

  • • 理解认证的概念

  • • 学习 JWT 工作原理

  • • 实现注册登录功能


思考题:

如果要给文章表添加一个外键关联用户(作者),表结构应该怎么设计?

欢迎在评论区分享你的设计。


如果觉得有帮助,欢迎点赞、在看、转发。

告别手动切换 Node 版本:从 nvm 迁移到 Volta

作者 MPGWJPMTJT
2026年5月8日 16:00

从 nvm 迁移到 Volta:让 Node 版本跟着项目自动切换

做前端开发时,多个项目使用不同 Node.js 版本几乎是常态。

比如有的老项目还停留在 Node 14.x,有的项目依赖 Node 16.x,新项目又可能要求 Node 22+。如果每次进入项目都要手动切换版本,不仅麻烦,还很容易忘。

我之前一直使用的是 nvm,它能解决多版本安装的问题,但版本切换依赖手动执行命令。一旦进入项目后忘记切换 Node 版本,轻则安装依赖报错,重则出现一些很难定位的构建问题。

所以这次我决定把本地 Node 版本管理从 nvm 迁移到 Volta

为什么选择 Volta

Volta 也是一个 Node.js 版本管理工具,但它和 nvm 的使用体验不太一样。

它最大的特点是:可以把项目需要的 Node 版本写进 package.json,之后只要进入项目目录,Volta 就会自动使用项目指定的版本。

也就是说,迁移完成后,基本不需要再记住“这个项目应该用哪个 Node 版本”,工具会替你处理。

迁移前准备

在卸载 nvm 之前,建议先查看当前已经安装过哪些 Node 版本:

nvm list

把仍然需要使用的版本记录下来,比如:

14.21.3
16.20.2
22.22.2

这些版本后面可以通过 Volta 重新安装。

另外,卸载前最好关闭所有正在运行的 Node 相关进程,避免因为进程占用导致卸载不完整。

卸载 nvm

先关闭 nvm 对 Node 的管理:

nvm off

然后在 Windows 中卸载 NVM for Windows

Windows 设置 -> 应用 -> 已安装的应用 -> NVM for Windows -> 卸载

卸载完成后,可以重新打开一个 PowerShell,确认 nvm 已经不可用。

nvm version

如果命令不存在,说明 nvm 已经卸载完成。

安装 Volta

在 PowerShell 中执行:

winget install Volta.Volta

安装完成后,重新打开终端,让环境变量生效。

可以通过下面的命令确认 Volta 是否安装成功:

volta --version

设置默认 Node 版本

先安装一个较新的 Node.js 版本作为全局默认版本。

例如我这里使用的是:

volta install node@22.22.2

安装完成后,可以检查当前默认版本:

node -v

后续如果想修改默认 Node 版本,也继续使用 volta install

volta install node@新版本

需要注意的是,这里的默认版本主要影响没有单独配置 Node 版本的目录。

为项目指定 Node 版本

进入某个项目目录,然后使用 volta pin 指定该项目需要的 Node 版本。

例如某个老项目需要 Node 14:

cd D:\project\old-app
volta pin node@14.21.3

执行完成后,项目的 package.json 中会自动新增一个 volta 字段:

{
  "volta": {
    "node": "14.21.3"
  }
}

之后只要在这个项目目录中执行:

node -v

Volta 就会自动使用项目指定的 Node 版本。

这也是我从 nvm 迁移到 Volta 的主要原因:版本跟项目绑定,而不是靠人脑记忆。

查看已安装版本

可以使用下面的命令查看 Volta 当前管理的工具和版本:

volta list all

如果某个项目 pin 了一个本地还没有安装过的 Node 版本,Volta 会在需要时自动处理对应版本。

全局包怎么安装

迁移到 Volta 后,全局包不建议再使用:

npm install -g 包名

更推荐使用:

volta install 包名

例如:

volta install pnpm
volta install yarn

这样安装的全局工具会由 Volta 管理,行为更稳定,也不会被当前项目的 Node 版本影响。

如果想安装指定版本:

volta install 包名@版本

例如:

volta install pnpm@9.15.0

项目内的包管理器版本

项目内安装依赖时,仍然使用项目自己的包管理工具,比如:

npm install
yarn install
pnpm install

如果某个项目对包管理器版本也有要求,可以同样通过 volta pin 固定。

例如固定 Yarn 版本:

volta pin yarn@1.22.22

执行后,package.json 中的 volta 字段会变成类似这样:

{
  "volta": {
    "node": "14.21.3",
    "yarn": "1.22.22"
  }
}

这样团队成员拉取代码后,也能使用一致的 Node 和包管理器版本。

迁移后的体验

迁移完成后,我最明显的感受是:不用再频繁思考 Node 版本了。

以前进入项目后,第一反应是:

nvm use 14.21.3

现在只需要进入项目目录,Volta 会自动根据 package.json 中的配置切换版本。

对同时维护多个新老项目的人来说,这个体验提升还是很明显的。

总结

这次从 nvm 迁移到 Volta,核心流程其实很简单:

  1. 记录原来 nvm 中需要保留的 Node 版本
  2. 卸载 NVM for Windows
  3. 安装 Volta
  4. 使用 volta install 设置默认 Node 版本
  5. 在项目中使用 volta pin 固定 Node 版本
  6. 使用 volta install 管理全局工具

如果你也经常在多个前端项目之间切换,尤其是项目 Node 版本差异比较大,Volta 会比手动切换版本省心很多。

它不只是“安装多个 Node 版本”,更重要的是把版本管理这件事从个人习惯变成项目配置。

Promise链式调用原理

作者 卷帘依旧
2026年5月8日 15:39

Promise链式调用原理,then在不同情况下的返回值

在JavaScript中,Promise是一种用于处理异步操作的对象。它可以让你以同步的方式来编写异步代码,从而提高代码的可读性和可维护性。Promise对象代表了异步操作的最终完成(或失败)及其结果值。

Promise的基本用法

一个Promise对象有三种状态:

  1. Pending(等待) ‌ - 初始状态,既不是成功,也不是失败。
  2. Fulfilled(已成功) ‌ - 操作成功完成。
  3. Rejected(已失败) ‌ - 操作失败。

创建Promise

你可以使用new Promise()构造函数来创建一个Promise对象。这个构造函数接受一个执行器函数作为参数,执行器函数有两个参数,分别是resolvereject

let promise = new Promise(function(resolve, reject) {
    // 异步操作
    if (/* 成功条件 */) {
        resolve(value); // 操作成功,将Promise的状态改为Fulfilled,并传递结果值
    } else {
        reject(error); // 操作失败,将Promise的状态改为Rejected,并传递错误信息
    }
});

Promise链式调用

Promise的链式调用是通过.then().catch()方法实现的。.then()方法返回一个新的Promise实例,并且可以接受两个参数:第一个参数是操作成功时的回调函数,第二个参数是操作失败时的回调函数(可选)。.catch()方法用于指定发生错误时的回调函数。

let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("成功"), 1000); // 模拟异步操作成功
});

promise.then(result => {
    console.log(result); // "成功"
    return "下一个Promise"; // 这里返回的结果会传递给下一个.then()的回调函数
}).then(result => {
    console.log(result); // "下一个Promise"
}).catch(error => {
    console.log(error); // 错误处理
});

使用async/await进行链式调用

async/await是建立在Promise之上的语法糖,让异步代码的书写和阅读更加直观。async函数可以包含await表达式,await关键字用于等待一个Promise对象解析完成。

async function asyncCall() {
    try {
        let result = await firstPromise(); // 等待第一个Promise解析完成
        let nextResult = await secondPromise(result); // 使用第一个Promise的结果作为第二个Promise的输入,并等待解析完成
        console.log(nextResult); // 处理结果
    } catch (error) {
        console.error(error); // 错误处理
    }
}

总结

通过.then().catch()方法或者使用async/await,你可以很容易地实现Promise的链式调用,使得异步代码的管理变得更加容易和清晰。

react 单向数据流理解

作者 光影少年
2026年5月8日 15:26

在 React 里,“单向数据流(One-Way Data Flow) ” 是最核心的思想之一。

简单理解:

数据只能从父组件流向子组件,不能反过来直接修改。


一、先用一句话理解

React 中:

父组件 state -> 子组件 props -> 页面UI

数据像水流一样:

Parent
  ↓ props
Child
  ↓ props
GrandChild

只能从上往下。


二、为什么叫“单向”?

因为:

  • 父组件可以把数据传给子组件
  • 子组件不能直接修改父组件的数据

例如:

function Parent() {
  const [count, setCount] = React.useState(0);

  return <Child count={count} />;
}

function Child(props) {
  return <h1>{props.count}</h1>;
}

这里:

  • count 在 Parent 中
  • Parent 通过 props 传给 Child
  • Child 只能“使用”
  • 不能:
props.count = 100; // ❌ 错误

因为 props 是只读的。


三、React 为什么这样设计?

因为:

1. 数据变化更容易追踪

你知道:

谁的数据变了
↓
谁重新渲染

不会乱。


2. 组件更稳定

如果子组件可以随便改父组件数据:

A 改一下
B 改一下
C 再改一下

整个应用会非常难维护。


3. 更符合函数式思想

React 组件本质像:

UI = f(state)

即:

输入数据
↓
输出页面

四、单向数据流完整示例


父组件

import React, { useState } from "react";

function App() {
  const [msg, setMsg] = useState("你好");

  return (
    <div>
      <Child message={msg} />
    </div>
  );
}

function Child(props) {
  return <h1>{props.message}</h1>;
}

export default App;

流程:

App state
  ↓
props.message
  ↓
Child UI

五、子组件想修改怎么办?

虽然不能直接改:

props.message = "新值"; // ❌

但可以:

父组件把“修改方法”传下去。


正确方式

function App() {
  const [count, setCount] = useState(0);

  return (
    <Child
      count={count}
      setCount={setCount}
    />
  );
}

function Child({ count, setCount }) {
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

这里本质还是:

父组件管理数据
子组件通知父组件修改

所以:

数据仍然是单向流动的。


六、非常重要的理解

很多初学者误以为:

子组件调用了 setCount
=
子组件修改了父组件

其实不是。

真正发生的是:

子组件触发事件
↓
调用父组件传来的函数
↓
父组件自己修改 state
↓
重新向下传递 props

所以数据方向始终没变:

->

七、React 单向数据流 vs Vue 双向绑定

Vue 常见:

v-model

看起来:

数据 ↔ UI

而 React 更强调:

state -> UI

React 更偏:

  • 可预测
  • 易维护
  • 数据透明

大型项目优势非常明显。


八、面试标准回答(很重要)

可以这样回答:


React 的单向数据流指的是:

数据只能从父组件通过 props 向子组件传递,子组件不能直接修改父组件的数据。

React 中:

  • state 通常由父组件管理
  • 子组件通过 props 接收数据
  • 如果子组件需要修改数据,需要调用父组件传递的回调函数

这样可以让:

  • 数据变化可追踪
  • 应用状态更稳定
  • 组件职责更清晰
  • 更容易维护大型项目

九、你必须真正理解的本质

React 核心哲学:

状态驱动视图

即:

state 改变
↓
组件重新渲染
↓
UI 更新

而不是:

直接操作DOM

所以:

React 更关注:

数据怎么流动

而不是:

页面怎么改

十、进阶理解(真正高手)

React 单向数据流最终会形成:

State
 ↓
View
 ↓
Action
 ↓
State

这其实就是:

  • Redux
  • Flux
  • Zustand
  • Mobx(部分)
  • Vuex

等状态管理的核心思想。

也叫:

Flux 架构思想

数据单向循环

十一、最经典一句话

记住:

React 中,谁拥有 state,谁才有资格修改 state。

全面重构的 uni-app 多平台上传组件,功能强到离谱!

2026年5月8日 15:05

一、前言

在移动应用开发中,文件上传是一个高频且复杂的需求场景,无论是用户头像上传、图片分享,还是文档提交、视频发布,都离不开一个稳定、易用的上传组件。

uView Pro 的 u-upload 组件经过几次迭代、重构,现已支持图片、视频、文档等多种文件类型,提供网格(grid)和列表(list)两种展示模式,完全向后兼容的同时带来了更强大的功能和更优雅的使用体验。

二、组件核心优势

1. 多文件类型支持

不再局限于图片上传,u-upload 现已支持:

  • 图片 - 支持预览、压缩、多选
  • 视频 - 支持时长限制、摄像头方向设置
  • 文件 - PDF、Word、Excel 等文档类型(H5/微信小程序)
  • 媒体文件 - 图片+视频混合选择
  • 所有类型 - 一键开启全类型支持

0.png

2. 双模式展示

根据文件类型自动适配最佳展示方式:

网格模式(默认) - 适合图片展示

  • 宫格布局,视觉整齐
  • 支持图片预览、删除
  • 适合头像、相册等场景

1.png

列表模式 - 适合文件展示

  • 显示文件名、文件大小
  • 进度条直观展示上传状态
  • 适合文档、资料上传场景

2.png

3. v-model 双向绑定

最新版本告别繁琐的事件监听,支持双向绑定,一行代码实现数据同步:

<u-upload :action="action" v-model="fileList"></u-upload>

4. 全平台兼容

完美适配 uni-app 所有平台:

  • App(Android/iOS/鸿蒙)
  • H5
  • 微信小程序、支付宝小程序、百度小程序、头条小程序、QQ小程序

三、快速上手

1. 基础用法

最简单的上传配置,只需设置服务器地址:

<template>
    <u-upload :action="action" v-model="fileList"></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const fileList = ref([
    {
        url: 'https://example.com/avatar.jpg',
        name: 'avatar.jpg',
        size: 1024 * 50,
        progress: 100,
        error: false
    }
])
</script>

2. 上传不同文件类型

通过 accept 参数一键切换文件类型:

<!-- 上传图片(默认) -->
<u-upload :action="action" accept="image"></u-upload>

<!-- 上传视频 -->
<u-upload :action="action" accept="video" :max-duration="120"></u-upload>

<!-- 上传文件(H5/微信小程序) -->
<u-upload :action="action" accept="file" :extension="['.pdf', '.docx']"></u-upload>

<!-- 上传所有类型 -->
<u-upload :action="action" accept="all"></u-upload>

3. 展示模式切换

<!-- 网格模式 - 适合图片 -->
<u-upload :action="action" accept="image" mode="grid"></u-upload>

<!-- 列表模式 - 适合文件 -->
<u-upload :action="action" accept="file" mode="list" :show-file-name="true" :show-file-size="true"></u-upload>

4.png

四、进阶功能

1. 手动上传控制

默认自动上传,也可改为手动控制:

<template>
    <view>
        <u-upload ref="uUploadRef" :action="action" :auto-upload="false"></u-upload>
        <u-button @click="submit" type="primary">提交上传</u-button>
    </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uUploadRef = ref()

function submit() {
    // 手动触发上传
    uUploadRef.value?.upload()
}
</script>

3.gif

2. 上传前处理

通过 before-upload 钩子实现自定义逻辑:

<template>
    <u-upload :before-upload="beforeUpload" :action="action"></u-upload>
</template>

<script setup lang="ts">
async function beforeUpload(index: number, list: any[]) {
    // 示例:上传前获取签名
    const sign = await getUploadSign()
    
    // 返回 true 继续上传,false 跳过当前文件
    return !!sign
}

async function getUploadSign() {
    // 模拟获取上传签名
    return 'upload-sign-xxx'
}
</script>

3. 文件限制

灵活控制上传文件的数量、大小和类型:

<u-upload 
    :action="action"
    :max-count="6"                    <!-- 最多选择6个文件 -->
    :max-size="5 * 1024 * 1024"       <!-- 单个文件最大5MB -->
    accept="image"
    :limit-type="['png', 'jpg', 'jpeg']"  <!-- 限制图片格式 -->
></u-upload>

4. 自定义文件选择

对于不支持文件选择的平台(如 App),可以通过 custom-choose 属性开启自定义选择:

<template>
    <u-upload 
        ref="uploadRef"
        accept="file"
        :custom-choose="true"
        :action="action"
        @on-choose="handleCustomChoose"
    ></u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const action = ref('https://your-server.com/upload')
const uploadRef = ref()

// 自定义文件选择
function handleCustomChoose({ accept, maxCount, fileList, index }: any) {
    // App 端使用原生文件选择
    // #ifdef APP-PLUS
    plus.runtime.chooseFile({
        success: (res: any) => {
            const files = res.files.map((file: any) => ({
                path: file.path,
                name: file.name,
                size: file.size,
                fileType: 'file'
            }))
            // 将文件添加到组件
            uploadRef.value?.addFiles(files)
        }
    })
    // #endif
}
</script>

核心要点:

  1. 设置 :custom-choose="true" 开启自定义选择模式
  2. 监听 @on-choose 事件,自行处理文件选择逻辑
  3. 选择完成后调用 uploadRef.value?.addFiles(files) 将文件添加到组件

5. 自定义上传按钮

通过插槽打造个性化上传入口:

5.png

<u-upload :custom-btn="true">
    <template #addBtn>
        <view class="custom-upload-btn">
            <u-icon name="plus" size="40" color="#2979ff"></u-icon>
            <text class="upload-text">点击上传</text>
        </view>
    </template>
</u-upload>

<style>
.custom-upload-btn {
    width: 200rpx;
    height: 200rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #f5f5f5;
    border-radius: 10rpx;
    border: 2rpx dashed #ddd;
}
.upload-text {
    margin-top: 10rpx;
    font-size: 24rpx;
    color: #666;
}
</style>

6. 完全自定义文件列表展示

通过 file 插槽完全自定义文件列表的展示方式,实现更灵活的文件管理界面:

6.png

<template>
  <u-upload
    ref="customFileListRef"
    v-model="customFileList"
    accept="file"
    mode="list"
    :action="action"
    :show-upload-list="false"
    :custom-btn="true"
    :max-count="5"
  >
    <!-- 自定义文件列表 -->
    <template #file="{ file }">
      <view class="custom-file-list">
        <view 
          v-for="(item, index) in file" 
          :key="index" 
          class="custom-file-item"
        >
          <!-- 文件类型图标 -->
          <u-icon
            :name="isImageFile(item) ? 'photo' : 'file-text'"
            size="40"
            color="var(--u-type-primary)"
          />
          
          <!-- 文件信息 -->
          <view class="custom-file-info">
            <text class="custom-file-name">{{ item.name || '未命名文件' }}</text>
            <text v-if="item.size" class="custom-file-size">
              {{ formatSize(item.size) }}
            </text>
          </view>
          
          <!-- 上传进度条 -->
          <view
            v-if="item.progress < 100 && item.progress > 0"
            class="custom-file-progress"
          >
            <u-line-progress :percent="item.progress" height="8" />
          </view>
          
          <!-- 上传状态 -->
          <view class="custom-file-status">
            <u-icon
              v-if="item.progress === 100"
              :name="item.error ? 'close-circle' : 'checkmark-circle'"
              size="34"
              :color="item.error ? 'var(--u-type-error)' : 'var(--u-type-success)'"
            />
            <text v-else class="custom-file-progress-text">
              {{ Math.floor(item.progress || 0) }}%
            </text>
          </view>
          
          <!-- 删除按钮 -->
          <view class="custom-file-delete" @click="removeCustomFile(index)">
            <u-icon name="close" size="24" color="var(--u-tips-color)" />
          </view>
        </view>
      </view>
    </template>
    
    <!-- 自定义添加按钮 -->
    <template #addBtn>
      <view class="custom-file-add-btn">
        <u-icon name="plus" size="32" color="var(--u-type-primary)" />
        <text class="custom-file-add-text">添加文件</text>
      </view>
    </template>
  </u-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { UploadFileItem } from '@/uni_modules/uview-pro/types/global'

const action = ref('https://your-server.com/upload')
const customFileList = ref<UploadFileItem[]>([])
const customFileListRef = ref()

// 判断是否为图片文件
function isImageFile(item: UploadFileItem): boolean {
  const ext = item.name?.split('.').pop()?.toLowerCase() || ''
  return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)
}

// 格式化文件大小
function formatSize(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

// 删除文件
function removeCustomFile(index: number) {
  customFileListRef.value?.remove(index)
}
</script>

<style scoped>
.custom-file-list {
  width: 100%;
  margin-bottom: 20rpx;
}

.custom-file-item {
  display: flex;
  align-items: center;
  padding: 24rpx;
  background: var(--u-bg-white);
  border-radius: 12rpx;
  margin-bottom: 16rpx;
  border: 1rpx solid var(--u-border-color);
}

.custom-file-item:last-child {
  margin-bottom: 0;
}

.custom-file-info {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.custom-file-name {
  font-size: 28rpx;
  color: var(--u-main-color);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-file-size {
  font-size: 24rpx;
  color: var(--u-tips-color);
  margin-top: 8rpx;
}

.custom-file-progress {
  width: 120rpx;
  margin-left: 20rpx;
}

.custom-file-progress-text {
  font-size: 24rpx;
  color: var(--u-primary-color);
}

.custom-file-status {
  margin-left: 20rpx;
  min-width: 48rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.custom-file-delete {
  display: flex;
  align-items: center;
  margin-left: 20rpx;
  padding: 8rpx;
}

.custom-file-add-btn {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  background: var(--u-bg-white);
}

.custom-file-add-text {
  margin-left: 16rpx;
  font-size: 28rpx;
  color: var(--u-tips-color);
}
</style>

核心要点:

  1. 隐藏默认列表:设置 :show-upload-list="false"
  2. file 插槽:接收 { file } 参数,file 即当前文件列表
  3. 文件属性
    • item.name - 文件名
    • item.size - 文件大小(字节)
    • item.progress - 上传进度 0-100
    • item.error - 上传失败标记
  4. 操作文件:通过 ref 调用 remove(index) 删除文件
  5. 进度展示:使用 u-line-progress 组件显示上传进度

五、实际应用场景

场景一:用户头像上传

<u-upload 
    accept="image"
    image-shape="circle"
    :action="action" 
    :max-count="1"
    :max-size="2 * 1024 * 1024"
    :limit-type="['jpg', 'png']"
    @on-success="onAvatarSuccess"
></u-upload>

7.png

场景二:资料文档上传

<u-upload 
    accept="file"
    mode="list"
    :action="action"
    :show-file-name="true"
    :show-file-size="true"
    :extension="['.pdf', '.doc', '.docx']"
></u-upload>

场景三:视频作品发布

<u-upload 
    accept="video"
    camera="back"
    :action="action" 
    :max-count="1"
    :max-size="50 * 1024 * 1024"
    :max-duration="300"
></u-upload>

六、平台适配说明

虽然 u-upload 已实现全平台支持,但部分功能在不同平台存在差异:

功能 App H5 微信小程序 支付宝小程序
图片上传
视频上传
文件上传
文件预览
压缩选项

最佳实践建议:

  • 文件上传功能在 H5 和微信小程序体验最佳
  • 如需在 App 中使用文件上传,建议使用原生能力或第三方 SDK
  • 生产环境务必做好各平台的真机测试

七、总结

uView Pro 的 u-upload 组件经历了从单一图片上传到全能文件管理。无论是简单的头像上传,还是复杂的资料提交,还支持高度自定义,无论如何都能找到最适合的配置方案。

核心亮点:

  • 多类型支持 - 图片、视频、文档全覆盖
  • 双模式展示 - 网格/列表随心切换
  • 高度自定义 - 插槽机制、自定义满足个性需求
  • 全平台适配 - 一套代码多端运行

附录:API 完整参考

Props 参数

参数 说明 类型 默认值 可选值
action 服务器上传地址 String '' -
accept 接受的文件类型 String image image / video / file / media / all
image-shape 图片/图标展示形状 String square circle / square
modelValue 文件列表(推荐,v-model 双向绑定) Array [] -
file-list 默认显示的文件列表(旧版,建议使用 v-model) Array [] -
custom-choose 是否使用自定义文件选择 Boolean false true / false
mode 展示模式 String grid grid / list
max-count 最大选择文件的数量 String/Number 52 -
max-size 选择单个文件的最大大小,单位字节 String/Number Number.MAX_VALUE -
width 预览区域和添加按钮的宽度,单位rpx String/Number 200 -
height 预览区域和添加按钮的高度,单位rpx String/Number 200 -
multiple 是否开启文件多选 Boolean true true / false
disabled 是否禁用组件 Boolean false true / false
auto-upload 选择完文件是否自动上传 Boolean true true / false
deletable 是否显示删除文件的按钮 Boolean true true / false
show-confirm 删除文件前是否显示确认弹窗 Boolean true true / false
show-tips 特殊情况下是否自动提示toast Boolean true true / false
show-progress 是否显示上传进度条 Boolean true true / false
show-upload-list 是否显示组件内部的文件预览列表 Boolean true true / false
show-file-name 是否显示文件名 Boolean true true / false
show-file-size 是否显示文件大小 Boolean false true / false
preview-full-image 是否可以通过 uni.previewImage 预览已选择的图片 Boolean true true / false
preview-file 是否可预览文件(非图片类型) Boolean true true / false
custom-btn 是否自定义选择文件的按钮 Boolean false true / false
upload-text 选择文件按钮的提示文字 String 根据accept自动显示 -
image-mode 预览图片的显示模式 String aspectFill -
del-icon 右上角删除图标名称 String close -
del-bg-color 右上角删除按钮的背景颜色 String var(--u-type-error) -
del-color 右上角删除按钮图标的颜色 String var(--u-white-color) -
header 上传携带的请求头信息 Object {} -
form-data 上传额外携带的参数 Object {} -
name 上传文件的字段名 String file -
size-type original 原图,compressed 压缩图 Array ['original', 'compressed'] -
source-type 选择文件的来源,album-相册,camera-相机 Array ['album', 'camera'] -
limit-type 限制允许上传的文件后缀,优先级高于accept Array [] -
extension 选择文件时的扩展名过滤,仅H5和微信小程序有效 Array [] -
file-icon-map 文件类型图标映射配置 Object {} -
compressed 选择视频时是否压缩 Boolean true true / false
max-duration 选择视频时拍摄最长时长,单位秒 Number 60 -
camera 选择视频时摄像头方向 String back front / back
before-upload 上传前钩子,返回 true/false/Promise Function null -
before-remove 删除前钩子,返回 true/false/Promise Function null -
to-json 如果上传后返回值为json字符串,是否自动转为json Boolean true true / false
index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件 String/Number '' -
custom-style 自定义根节点样式 String/Object {} -
custom-class 自定义根节点样式类 String '' -

Methods 方法

通过 ref 手动调用组件方法:

名称 说明 参数
upload 手动触发上传文件 -
clear 清空内部文件列表 -
reUpload 重新上传所有失败/未上传的文件 -
retry(index) 重新上传指定索引的文件 index: 文件索引
remove(index) 手动移除指定索引的文件 index: 文件索引
selectFile 手动触发文件选择 -
doPreviewImage(url, index) 预览图片 url: 图片地址, index: 索引
doPreviewFile(item, index) 预览/打开文件 item: 文件对象, index: 索引
addFiles(files) 添加文件到列表(配合 custom-choose 使用) files: 文件数组

Slots 插槽

名称 说明
addBtn 自定义选择文件按钮
file 自定义文件列表插槽

Events 事件

事件名 说明 回调参数
on-oversize 文件大小超出 max-size 限制时触发 (file, lists, name)
on-exceed 文件数量超出 max-count 限制时触发 (file, lists, name)
on-choose-complete 每次选择文件后触发 (lists, name)
on-choose-fail 文件选择失败时触发 (error)
on-uploaded 所有文件上传完毕触发 (lists, name)
on-success 单个文件上传成功时触发 (data, index, lists, name)
on-error 单个文件上传失败时触发 (res, index, lists, name)
on-change 单个文件上传状态改变时触发(无论成功或失败) (res, index, lists, name)
on-progress 文件上传过程中的进度变化时触发 (res, index, lists, name)
on-remove 移除文件时触发 (index, lists, name)
on-preview 预览文件时触发 (url, lists, name)
on-list-change 文件列表发生变化时触发 (lists, name)
on-choose 启用 custom-choose 时触发,用户可自定义文件选择逻辑 ({ accept, maxCount, currentFiles, index })
update:modelValue v-model 双向绑定事件,文件列表变化时触发 (lists)

说明:

  • lists - 当前组件内的所有文件数组
  • index - 当前操作的文件索引
  • name - 通过 props 传递的 index 参数,用于区分多个组件实例

文件列表对象结构

lists 数组中每个元素(UploadFileItem)的结构:

{
  // 基础信息
  url: string,           // 文件地址(上传成功后返回)
  path: string,          // 文件本地路径
  name: string,          // 文件名
  size: number,          // 文件大小(字节)
  fileType: 'image' | 'video' | 'file',  // 文件类型
  
  // 上传状态
  progress: number,      // 上传进度 0-100,100表示上传成功
  error: boolean,        // 上传失败标记
  response?: any,        // 服务器返回的数据
  
  // 媒体文件特有
  thumb?: string,        // 视频缩略图(仅视频)
  width?: number,        // 图片/视频宽度
  height?: number,       // 图片/视频高度
  duration?: number,     // 视频时长(秒)
  
  // 原始文件对象
  file?: any,            // 原始文件对象
  uploadTask?: UniApp.UploadTask  // 上传任务对象(用于取消上传)
}

文件类型说明

根据 accept 参数,支持以下文件类型:

accept 值 说明 自动检测的文件后缀
image 图片 png, jpg, jpeg, gif, webp, bmp, svg
video 视频 mp4, avi, mov, wmv, flv, mkv, rmvb, 3gp, m3u8
file 文件 根据 extension 参数或允许所有
media 媒体(图片+视频) 图片和视频后缀合集
all 所有文件 允许所有文件类型

注意:

  • 文件上传(accept=file)仅在 H5 和微信小程序支持
  • 媒体选择(accept=media)仅在微信小程序、App、头条小程序支持
  • 文件预览功能在 H5 体验最佳,其他平台可能受限
  • 通过自定义,你也可以实现不支持的平台特性功能

现在就开始使用 u-upload,让文件上传功能开发变得更加方便!更多内容请参考官方文档。

文档地址: uviewpro.cn/

开源地址:

告别“class 命名地狱”:从面向对象 CSS 到原子 CSS(Tailwind) 的思维跃迁

作者 暗不需求
2026年5月8日 14:50

引言:一封来自“传统 CSS”的挑战书

作为一名前端开发者,你是否常常为“这个 div 该叫什么 class”而苦恼?是否在一个大型项目中,面对庞杂的 CSS 文件,修改一个样式都要瞻前顾后,生怕引发其他模块的“雪崩”?

我们先来看一段非常常见的 HTML 代码:

<button class="primary-btn">提交</button>
<button class="default-btn">默认</button>

对应的 CSS 可能是这样的:

.primary-btn {
  padding: 8px 16px;
  background: blue;
  color: white;
  border-radius: 6px;
}
.default-btn {
  padding: 8px 16px;
  background: #ccc;
  color: #000;
  border-radius: 6px;
}

这个写法有问题吗?在它被抛弃之前,没有。 然而当业务扩张,你发现按钮还有危险按钮、文字按钮、超大按钮……于是你开始用“面向对象 CSS”(OOCSS) 的模式来优化。

/* 基础类:封装共性 */
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
}
/* 扩展类:表现多态 */
.btn-primary {
  background: blue;
  color: white;
}
.btn-default {
  background: #ccc;
  color: #000;
}
<button class="btn btn-primary">提交</button>
<button class="btn btn-default">默认</button>

这便是 OOCSS 的核心思想:封装基类,利用多态和组合实现样式复用。这极大地缓解了样式重复的问题。但它就是终点吗?不,因为我们依然在绞尽脑汁地为各种“业务块”命名,而且 .btn-primary 这个名字仍然带着浓厚的业务属性,很难跨项目复用。

那么,有没有一种方式,能让我们抛开给 class 取名的苦恼,直接在 HTML 中像搭积木一样写样式,甚至在未来让 AI 帮我们直接生成 UI?这就引出了本文的主角——原子 CSS 及其代表性框架 Tailwind CSS


一、原子 CSS 的哲学:从“业务命名”到“视觉属性”

原子 CSS (Atomic/Utility-First CSS) 的意思是,将 CSS 规则拆分成一个个不可再分的、单一职责的小类,每个类只代表一种视觉属性(比如 margin-top: 16pxcolor: reddisplay: flex)。通过像堆积木一样,将这些“原子”组合在一个 HTML 元素上,来构建整个界面。

  • Bad 模式:样式带有太多的业务属性,在一个或少数类名里,样式几乎不能复用。
  • 面向对象 CSS:封装(基类)、多态(业务)、组合,这是一大进步。
  • 原子 CSS
    • 大量的基类,具有极高的复用性。
    • 通过组合来构建界面。
    • 代表性的框架就是 Tailwind CSS
    • 另一个巨大优势:与 LLM(大语言模型)结合,通过自然语言 Prompt 描述布局和风格,能极其高效地生成语义化好的 Tailwind CSS 代码。

原子 CSS 没有神秘的“模态框”、“轮播图”组件,只有 flextext-centerbg-whiteshadow 这些最纯粹的视觉原子。那么,在实际代码中,它是什么样的呢?


二、初探 Tailwind CSS:逐行解析你的第一个原子 UI

这是一个典型的 React 组件,但已经完全融入了 Tailwind CSS 的血液。我们来逐行、逐类解析:

const AriticleCard = () => {
  return(
   <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">  
    <h2 className="text-lg font-bold">Tailwindcss</h2>
    <p className="text-gray-500 mt-2">
      用utlity class 快速构建UI
    </p>
   </div>
  )
}

逐行解释 ArticleCard 组件

  • <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">
    • p-4padding: 1rem; (Tailwind 中 1 unit=0.25rem,所以 4 代表 1rem)。控制内边距。
    • bg-whitebackground-color: white;。设置背景色为白色。
    • rounded-xlborder-radius: 0.75rem;。设置 12px 的大圆角,拟物卡片感。
    • shadowbox-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);。添加一个轻盈的阴影。
    • hover:shadow-lg:当鼠标悬停时,box-shadow 变为更大更重的阴影(变体前缀 hover:)。这是交互反馈。
    • transitiontransition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;。使阴影变化过程平顺过渡,提升体验。
  • <h2 className="text-lg font-bold">
    • text-lgfont-size: 1.125rem; line-height: 1.75rem;。设定标题为大号字体。
    • font-boldfont-weight: 700;。加粗。
  • <p className="text-gray-500 mt-2">
    • text-gray-500color: rgb(107 114 128);。将文字颜色设为灰色(中等灰度),用于次要描述文本,形成对比层次。
    • mt-2margin-top: 0.5rem;。与上方标题拉开一点距离。

小结:我们看到,整个卡片组件没有写一行自定义 CSS,完全通过组合预定义的原子类,就实现了一个带有悬停效果、层次清晰的内容卡片。你不再需要在 HTML 和 CSS 文件之间来回跳转,大脑的上下文切换成本极大降低。


三、移动优先的响应式设计:像说话一样简单

传统 CSS 中写响应式,要用到 @media 查询,往往分散在不同的 CSS 块底部,维护时极其痛苦。Tailwind 把响应式也变成了“原子类”,通过前缀 {屏幕尺寸}: 即可随时应用。

它是一个经典的“主内容 + 侧边栏”布局:

export default function App() {
    return (
     <div className="flex flex-col md:flex-row gap-4">
        <main className="bg-blue-100 p-4 md:w-2/3">
            主内容
        </main>
        <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
     </div>
    )
}

逐行解析响应式布局

  • <div className="flex flex-col md:flex-row gap-4">
    • flex:声明一个弹性盒容器(display: flex;)。
    • flex-col:弹性盒子主轴方向为垂直(flex-direction: column;)。这是移动端优先的策略,默认(宽度<768px)时,元素上下堆叠。
    • md:flex-row:当屏幕宽度 ≥ 768px(md 断点)时,主轴方向变为水平(flex-direction: row;),这时主内容和侧边栏左右排列。
    • gap-4:子元素之间的间距为 1remgap: 1rem;),无论是水平还是垂直方向都生效。
  • <main className="bg-blue-100 p-4 md:w-2/3">
    • bg-blue-100:非常淡的蓝色背景,视觉区分。
    • p-4:内边距 1rem。
    • md:w-2/3:在桌面端(≥768px)时,该元素宽度占父容器的 2/3。
  • <aside className="bg-green-100 p-4 md:w-1/3">
    • md:w-1/3:在桌面端时,宽度占 1/3。两者配合,一个完美的 2/3 + 1/3 列布局就完成了。

这种“移动优先”(Mobile First)的设计哲学,让你先保证在小屏幕上体验良好,再通过 md:lg: 这样的前缀逐步增强在大屏幕上的布局。这是现代响应式设计的最佳实践。


四、一个被忽视的性能利器:DocumentFragment 与 JSX 片段

在深入 Tailwind 之前,让我们把目光短暂地投向一个看似与 CSS 无关,但思维相通的概念:Fragment(片段)

1. 原生 JavaScript 的 DocumentFragment

这个例子展示了 DOM 操作中的一个重要性能优化:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

// 创建一个文档碎片结点
const fragment = document.createDocumentFragment(); 
fragment.appendChild(p1);
fragment.appendChild(p2);

// 一次性将所有结点添加到真实 DOM,只引发一次回流(Reflow)
container.appendChild(fragment);

DocumentFragment 是一个轻量级的“虚拟容器”,它不会被渲染到页面上。把多个 DOM 操作先在内存中的 Fragment 完成了,最后一次性挂载到真实 DOM,杜绝了因多次操作导致的重复重绘与回流,极大提升性能。同时,它也避免了为包裹元素而引入多余的无意义 <div> 节点。

2. React 中的 Fragment(<></><React.Fragment>

React 受此启发,要求组件返回一个单一根节点。但某些时候,你并不想在 DOM 中增加一个多余的 <div>,因为这会破坏 CSS 弹性盒或栅格布局的父子关系。Fragment 就是解决方案。

export default function App() {
 return (
  // 使用 <> </> 作为包的根节点
  <>
    <h1>111</h1>
    <h2>222</h2>
    <button className="...">提交</button>
    <button className="...">默认</button>
    <AriticleCard/>
  </>
 )
}

这里的 <>...</> 就是 React.Fragment 的语法糖。它和 DocumentFragment 理念一致:一个不渲染到页面的虚拟包裹节点,既满足了“单一根节点”的语法要求,又保持了 DOM 树的清洁,不产生多余标签

这种追求“精简、直接、无多余包装”的设计哲学,与我们将要讲的 Tailwind CSS 的 Utility-First 理念是否有异曲同工之妙?两者都旨在消除不必要的抽象层


五、Tailwind CSS 与传统 CSS 方案的终极对决

为什么我们要放弃已熟悉的传统 CSS 或 OOCSS,转向 Tailwind?我们用你所有的代码文件进行一次全面对比。

维度 传统 CSS / OOCSS Tailwind CSS (原子CSS) 评述
命名与上下文切换 需要在 .css.html 间频繁切换。为无数状态命名(.btn-primary, .sidebar__item--active),低质量命名是技术债。 无需命名。在 HTML 中直接套用视觉原子类,所见即所得,零切换成本。 Tailwind 让你专注于“效果”,而非“叫什么”。
样式复用与冗余 OOCSS 通过继承/组合复用,但基类库仍需自我构建。独特样式仍会导致代码膨胀。(如 App.css 中大量的独立样式块) 天生高复用flexpt-4 等原子类全局通用,项目越大,新增的 CSS 代码越少。最终打包体积通过 Tree-Shaking 变得极小。 Tailwind 避免了“多写一个新类”的冲动,鼓励用工具集解决。
响应式设计 往往采用多文件或分散的 @media 查询,维护时需在代码中跳跃。(如 App.css 中多处 @media (max-width: 1024px) 内联式响应式md:flex-row 将断点样式与基础样式写在一起,直觉且易维护。 查看一个元素时,它的所有表现(含所有断点)都在眼前。
可维护性与风格一致性 文本颜色、间距可能因手误出现 1px 偏差,时间久了产生“样式污染”。 设计系统即代码text-gray-500p-4 等映射到设计令牌(Design Tokens)的值,强制使用预定义规范,UI 天然统一。 Tailwind 自带一个专业的设计系统约束。
代码耦合度 HTML 类名与 CSS 结构强耦合。删除组件时,经常遗留“僵尸 CSS”。 耦合转移到了 HTML 上。删除一个组件,它的所有样式跟随标签一起消失,彻底告别“僵尸代码”。 这是“成本转移”,从管理样式文件依赖,转为直接管理组件本身的属性。
性能与体验 初始加载整个 CSS 文件(可能很大)。 JIT(即时编译)引擎 按需扫描你的模板,仅生成你用到的原子类,CSS 体积通常极小(< 10KB)。 生产环境下的极致轻量。

六、不仅仅是类名:Tailwind CSS 的进阶与扩展

理解了基础后,让我们跳出你给出的文件,看看 Tailwind 在真实项目中还能如何大放异彩。这些都是你必须知道的扩展知识。

1. 主题定制:打造你的设计语言

仅用一行引入了 Tailwind:

@import "tailwindcss";

但 Tailwind 的强大在于可配置性。通过 tailwind.config.js,你可以覆盖或扩展整个设计系统。例如,你可以定义公司品牌色:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        'brand': '#ff7e5f', // 自定义颜色令牌
        'dark-bg': '#1a202c',
      },
      spacing: {
        '128': '32rem', // 一个超大间距原子
      }
    }
  }
}

然后你就能在代码里直接使用 bg-brandtext-dark-bgp-128 了。这意味着,Tailwind 是你的设计系统的最佳执行者,而非限制者

2. 与 JS 框架的深度融合(以 React 为例)

在 React、Vue 中,我们可以用工具函数优雅地处理动态类名。例如,根据 isActive 状态切换按钮样式:

function MyButton({ isActive }) {
  return (
    <button className={`
      px-4 py-2 rounded 
      ${isActive ? 'bg-blue-600 text-white' : 'bg-gray-200 text-black'}
    `}>
      提交
    </button>
  );
}

搭配 clsxtailwind-merge 这类极小的库,可以让条件类名拼接像德芙一样丝滑,彻底解决类名字符串拼接的混乱。

3. “不会让 HTML 变得臃肿吗?”——组件化就是答案

这是最常见的问题。当你看到 <div class="flex items-center space-x-2 p-4 bg-white shadow-lg rounded-xl ..."> 这么一长串时,确实会感觉不适。

解决方案:封装成组件。 把卡片提取为 AriticleCard 组件一样。那些长长的原子类字符串,只是该组件的“内部实现细节”。在你的业务页面中,你看到的依然是干净、语义化的 <AriticleCard />

所以,原子 CSS 的冗长类名,不是让你到处复制粘贴,而是驱动你更早、更自然地进行组件化拆分

4. AI 时代的 UI 生成:为什么 Tailwind 是大模型的最爱?

目前有一个非常前瞻的观点:

prompt 描述布局、风格和语义化好的 tailwindcss 更有利于生成

确实如此。对于 LLM(如 GPT-4), 生成一个传统 UI 需要它理解一套自制的 CSS 规则,这是不可能的。但生成 Tailwind UI 是极其高效的,因为:

  • 有限且确定的词汇表:大模型只需要学习一套固定的原子类(如 grid, col-span-2, hover:bg-blue-700),而不是无限的、用户自创的命名。
  • 语法就是语义bg-red-500 本身就是视觉描述。模型的 Prompt:“一个红色背景的按钮” → 生成 bg-red-500 text-white px-4 py-2 rounded,匹配度极高。
  • 上下文准确性:由于没有外部样式表依赖,生成的一个独立 HTML 片段就能完全复现视觉样式,非常适合 AI 驱动的低代码或无代码平台。

你现在写下的每一个 Tailwind 类,都是在用一种与未来 AI 协作的语言来构建 UI。


七、结语:拥抱 Utility-First,追寻开发的“心流”

回顾我们走过的路:

我们从传统的 primary-btn 命名困境出发,经历了 OOCSS 的抽象与组合,最终抵达了原子 CSS 的领地。通过分解你提供的 App.jsxApp2.jsx 等代码,我们不仅理解了 flex, md:flex-row, shadow-lg 这些具体指令的细节,更体会到了一种范式转移:将设计决策从样式表拉回到标记本身

这种转移带来了一种称作 “心流” 的开发体验: 当你构建一个界面时,你的目光不再需要在文件标签页之间跳跃。你盯着 HTML (或 JSX),脑海中设想它的外观——蓝色的背景、水平的布局、鼠标悬停时加深的阴影——然后,你的手指几乎无意识地敲出 bg-blue-100, flex, hover:shadow-lg。UI 就这样在你眼前生长出来,如同乐高拼装,每一个积木的质感都了然于胸。

正如 Fragment 组件消灭了不必要的 DOM 包装、追求树的纯净一样,Tailwind CSS 则致力于消灭不必要的样式抽象,追求所见即所得的极致表达。它不只是一个 CSS 框架,更是一种与组件化、设计系统、乃至未来 AI 开发高度契合的前端哲学。

是时候打开你的终端,执行 npm install -D tailwindcss postcss autoprefixer,然后在你下个项目的根组件里,敲下第一个 flex 了。

将 libsmb2 集成到 HarmonyOS ArkTS 项目

作者 xyccstudio
2026年5月8日 14:34

将 libsmb2 集成到 HarmonyOS ArkTS 项目

本文记录在鸿蒙媒体播放器项目 hmplayer 中集成 libsmb2 实现 SMB 网络文件浏览与播放的完整过程。libsmb2支持smb3协议。能够查看macos上的文件夹分享。配置macos分享的时候需要把选项中的window共享创建一个账号和密码。之后使用使用此账号密码进行连接。

整体架构

ArkTS 层 (Smb2Client.ets)
    ↓ NAPI 绑定 (libentry.so)
C/C++ 层 (napi_init.cpp)
    ↓ 静态链接
libsmb2 (thirdparty/libsmb2/arm64-v8a/lib/libsmb2.so.1)

ArkTS 通过 NAPI 调用 C++ 导出的函数,C++ 层直接调用 libsmb2 的 C API。整个模块编译为 libentry.so,在应用加载时自动注册。

第一步:放置预编译库

libsmb2 不需要在项目中从源码编译,直接放置预编译好的 arm64-v8a 架构的 .so 文件:

entry/src/main/cpp/thirdparty/libsmb2/arm64-v8a/
├── include/smb2/
│   ├── libsmb2.h
│   ├── smb2.h
│   ├── smb2-errors.h
│   ├── libsmb2-raw.h
│   └── libsmb2-dcerpc*.h
├── lib/
│   ├── libsmb2.so.6.1.0
│   ├── libsmb2.so.1 -> libsmb2.so.6.1.0
│   ├── libsmb2.so -> libsmb2.so.1
│   └── cmake/libsmb2/
└── lib/pkgconfig/

第二步:配置 CMake

entry/src/main/cpp/CMakeLists.txt 中添加:

cmake_minimum_required(VERSION 3.4.1)
project(libsmb2project)

# 应用主库,包含 NAPI 绑定
add_library(entry SHARED napi_init.cpp)

# 链接 NAPI 运行时和日志库
target_link_libraries(entry PUBLIC libace_napi.z.so libhilog_ndk.z.so)

# 引入 libsmb2 预编译库
set(LIBSMB2_DIR ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/libsmb2/${OHOS_ARCH})
set(LIBSMB2_LIB ${LIBSMB2_DIR}/lib/libsmb2.so.1)

target_link_libraries(entry PRIVATE ${LIBSMB2_LIB})
target_include_directories(entry PRIVATE ${LIBSMB2_DIR}/include)

${OHOS_ARCH} 由 DevEco Studio 构建时自动注入,值为 arm64-v8a

第三步:配置 ABI 过滤

entry/build-profile.json5 中指定只构建 arm64-v8a

{
  externalNativeOptions: {
    path: './src/main/cpp/CMakeLists.txt',
    abiFilters: ['arm64-v8a'],
    arguments: '',
    cppFlags: '',
  },
}

第四步:编写 NAPI 绑定

entry/src/main/cpp/napi_init.cpp 是核心绑定文件,将 libsmb2 的 C API 暴露给 ArkTS。

模块注册

#include <napi/native_api.h>

static napi_module entryModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = RegisterEntryModule,   // 你的注册函数
    .nm_modname = "entry",
    .nm_priv = nullptr,
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&entryModule);
}

__attribute__((constructor)) 保证共享库加载时自动执行注册。

全局状态管理

Native 层使用全局变量维护单一连接:

static smb2_context* g_smb2_ctx = nullptr;
static smb2dir*      g_smb2_dir = nullptr;
static smb2fh*       g_smb2_fh  = nullptr;

同一时间只允许一个 SMB 连接,打开新文件时自动关闭旧的文件句柄。

导出函数示例

每个 NAPI 函数都是 libsmb2 C API 的薄封装,负责 napi_value 与 C 类型之间的转换:

// 初始化上下文
static napi_value InitContext(napi_env env, napi_callback_info info)
{
    // ...
    g_smb2_ctx = smb2_init_context();
    if (!g_smb2_ctx) {
        napi_create_int32(env, -1, &result);
        return result;
    }
    napi_create_int32(env, 0, &result);
    return result;
}

// 连接共享
static napi_value ConnectShare(napi_env env, napi_callback_info info)
{
    size_t argc = 4;
    napi_value args[4];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    char server[256], share[256], user[256], password[256];
    // ... 从 napi_value 提取字符串

    int ret = smb2_connect_share(g_smb2_ctx, server, share, user, password);
    // ...
}

// 读取文件(随机访问)
static napi_value ReadFile(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    int64_t offset, size;
    // ... 提取参数

    uint8_t* buf = new uint8_t[size];
    int n = smb2_pread(g_smb2_ctx, g_smb2_fh, buf, size, offset);

    // 将数据拷贝到 ArrayBuffer 返回给 ArkTS
    void* data;
    napi_create_arraybuffer(env, n, &data, &ab);
    memcpy(data, buf, n);
    delete[] buf;
    return ab;
}

所有导出函数在 RegisterEntryModule 中统一注册:

static napi_value RegisterEntryModule(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "initContext",        nullptr, InitContext,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "destroyContext",     nullptr, DestroyContext,     nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setUser",            nullptr, SetUser,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setPassword",        nullptr, SetPassword,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setDomain",          nullptr, SetDomain,          nullptr, nullptr, nullptr, napi_default, nullptr },
        { "setAuthentication",  nullptr, SetAuthentication,  nullptr, nullptr, nullptr, napi_default, nullptr },
        { "connectShare",       nullptr, ConnectShare,       nullptr, nullptr, nullptr, napi_default, nullptr },
        { "disconnectShare",    nullptr, DisconnectShare,    nullptr, nullptr, nullptr, napi_default, nullptr },
        { "openDir",            nullptr, OpenDir,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "closeDir",           nullptr, CloseDir,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "readDir",            nullptr, ReadDir,            nullptr, nullptr, nullptr, napi_default, nullptr },
        { "getError",           nullptr, GetError,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "openFile",           nullptr, OpenFile,           nullptr, nullptr, nullptr, napi_default, nullptr },
        { "closeFile",          nullptr, CloseFile,          nullptr, nullptr, nullptr, napi_default, nullptr },
        { "getFileSize",        nullptr, GetFileSize,        nullptr, nullptr, nullptr, napi_default, nullptr },
        { "readFile",           nullptr, ReadFile,           nullptr, nullptr, nullptr, napi_default, nullptr },
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}

第五步:声明 TypeScript 类型

entry/src/main/cpp/types/libentry/index.d.ts 中声明类型,让 ArkTS 能获得完整的类型提示:

export interface Smb2DirEntry {
  name: string
  type: number
  isDirectory: boolean
}

export const SMB2_SEC_UNDEFINED: number
export const SMB2_SEC_NTLMSSP: number
export const SMB2_SEC_KRB5: number

export function initContext(): number
export function destroyContext(): number
export function setUser(user: string): number
export function setPassword(password: string): number
export function setDomain(domain: string): number
export function setAuthentication(method: number): number
export function connectShare(
  server: string,
  share: string,
  user: string,
  password: string
): number
export function disconnectShare(): number
export function openDir(path: string): number
export function closeDir(): number
export function readDir(): Smb2DirEntry[]
export function getError(): string
export function openFile(path: string): number
export function closeFile(): number
export function getFileSize(): number
export function readFile(offset: number, size: number): ArrayBuffer

对应的 oh-package.json5

{
  name: 'libentry.so',
  types: './index.d.ts',
  version: '1.0.0',
}

第六步:ArkTS 封装层

在 ArkTS 侧通过 import libentry from 'libentry.so' 导入原生模块,封装为易用的类。

Smb2Client

import libentry from 'libentry.so'

export class Smb2Client {
  private connected: boolean = false

  init(): number {
    return libentry.initContext()
  }

  destroy(): number {
    this.connected = false
    return libentry.destroyContext()
  }

  setUser(user: string): number {
    return libentry.setUser(user)
  }

  setPassword(password: string): number {
    return libentry.setPassword(password)
  }

  setDomain(domain: string): number {
    return libentry.setDomain(domain)
  }

  setAuthentication(method: number): number {
    return libentry.setAuthentication(method)
  }

  connect(
    server: string,
    share: string,
    user: string,
    password: string
  ): number {
    const ret = libentry.connectShare(server, share, user, password)
    if (ret === 0) {
      this.connected = true
    }
    return ret
  }

  disconnect(): number {
    this.connected = false
    return libentry.disconnectShare()
  }

  openDir(path: string): number {
    return libentry.openDir(path)
  }

  closeDir(): number {
    return libentry.closeDir()
  }

  readDir(): libentry.Smb2DirEntry[] {
    return libentry.readDir()
  }

  getError(): string {
    return libentry.getError()
  }

  openFile(path: string): number {
    return libentry.openFile(path)
  }

  closeFile(): number {
    return libentry.closeFile()
  }

  getFileSize(): number {
    return libentry.getFileSize()
  }

  readFile(offset: number, size: number): ArrayBuffer {
    return libentry.readFile(offset, size)
  }
}

SmbFileCache(文件缓存下载)

SMB 文件不能直接作为视频播放器的数据源,需要先下载到本地缓存。SmbFileCache 通过分块读取 + 并发写入实现高效下载:

export class SmbFileCache {
  private client: Smb2Client
  private readonly CHUNK_SIZE = 4 * 1024 * 1024 // 4MB

  async download(
    remotePath: string,
    localPath: string,
    onProgress?: (progress: number, speed: string) => void
  ): Promise<string> {
    // 1. 打开远程文件
    this.client.openFile(remotePath)
    const totalSize = this.client.getFileSize()

    // 2. 分块读取,通过 ConcurrentFileDownloader 写入本地文件
    let downloaded = 0
    let startTime = Date.now()

    while (downloaded < totalSize) {
      const chunkSize = Math.min(this.CHUNK_SIZE, totalSize - downloaded)
      const data = this.client.readFile(downloaded, chunkSize)

      // 并发写入磁盘(不阻塞主线程)
      await ConcurrentFileDownloader.writeChunk(localPath, downloaded, data)

      downloaded += chunkSize

      // 回调进度
      if (onProgress) {
        const elapsed = (Date.now() - startTime) / 1000
        const speed = downloaded / elapsed / 1024
        onProgress(downloaded / totalSize, `${speed.toFixed(1)} KB/s`)
      }
    }

    // 3. 关闭文件句柄
    this.client.closeFile()
    return localPath
  }
}

第七步:UI 层集成

SmbEntry 类型

目录条目类型与 NAPI 声明一致:

interface SmbEntry {
  name: string
  type: number
  isDirectory: boolean
}

连接流程

pageSmbBrowser.ets 中,页面组件的生命周期管理 SMB 连接:

@Entry
@Component
struct PageSmbBrowser {
  @State client: Smb2Client = new Smb2Client();
  @State entries: SmbEntry[] = [];
  @State currentPath: string = '/';
  @State downloadProgress: number = 0;
  @State downloadSpeed: string = '';
  @State isDownloading: boolean = false;

  aboutToAppear() {
    this.client.init();
    this.client.setUser('admin');
    this.client.setPassword('123456');
    this.client.setDomain('WORKGROUP');

    const ret = this.client.connect('192.168.1.100', 'Public', 'admin', '123456');
    if (ret === 0) {
      this.loadDirectory('/');
    }
  }

  aboutToDisappear() {
    this.client.disconnect();
    this.client.destroy();
  }

  loadDirectory(path: string) {
    const ret = this.client.openDir(path);
    if (ret === 0) {
      const raw = this.client.readDir();
      // 过滤 . 和 ..,目录优先排序
      this.entries = raw
        .filter(e => e.name !== '.' && e.name !== '..')
        .sort((a, b) => {
          if (a.isDirectory && !b.isDirectory) return -1;
          if (!a.isDirectory && b.isDirectory) return 1;
          return a.name.localeCompare(b.name);
        });
      this.client.closeDir();
    }
  }
}

文件操作

// 点击目录:进入子目录
onDirClick(entry: SmbEntry) {
  const newPath = this.currentPath === '/' ? `/${entry.name}` : `${this.currentPath}/${entry.name}`;
  this.currentPath = newPath;
  this.loadDirectory(newPath);
}

// 点击媒体文件:下载到缓存后播放
onMediaClick(entry: SmbEntry) {
  const remotePath = this.currentPath === '/' ? `/${entry.name}` : `${this.currentPath}/${entry.name}`;
  const localPath = `/data/storage/el2/base/cache/smb/${entry.name}`;

  this.isDownloading = true;
  const cache = new SmbFileCache(this.client);
  cache.download(remotePath, localPath, (progress, speed) => {
    this.downloadProgress = progress;
    this.downloadSpeed = speed;
  }).then((path) => {
    // 播放本地缓存文件(调用导航跳转到播放器页面)
    this.isDownloading = false;
    // pushPath({ name: PageName.videoPlayer, param: { uri: path } });
  });
}

// 返回上级目录
onBack() {
  if (this.currentPath === '/') return;
  const parts = this.currentPath.split('/').filter(p => p.length > 0);
  parts.pop();
  this.currentPath = parts.length > 0 ? '/' + parts.join('/') : '/';
  this.loadDirectory(this.currentPath);
}

API 列表

集成的 16 个 NAPI 函数覆盖了 SMB 文件浏览与读取的完整流程:

函数 对应 libsmb2 API 说明
initContext() smb2_init_context() 创建 SMB 上下文
destroyContext() smb2_destroy_context() 销毁上下文
setUser(user) smb2_set_user() 设置用户名
setPassword(password) smb2_set_password() 设置密码
setDomain(domain) smb2_set_domain() 设置域名
setAuthentication(method) smb2_set_authentication() 认证方式(0=自动, 1=NTLM, 2=Kerberos)
connectShare(server, share, user, password) smb2_connect_share() 连接 SMB 共享
disconnectShare() smb2_disconnect_share() 断开连接
openDir(path) smb2_opendir() 打开远程目录
closeDir() smb2_closedir() 关闭目录句柄
readDir() smb2_readdir() 循环至 NULL 读取全部目录条目
getError() smb2_get_error() 获取最近一次错误信息
openFile(path) smb2_open(path, O_RDONLY) 打开文件(只读)
closeFile() smb2_close() 关闭文件句柄
getFileSize() smb2_fstat() 获取文件大小
readFile(offset, size) smb2_pread() 按偏移量读取指定字节,返回 ArrayBuffer

关键注意事项

  1. 单一连接限制:Native 层使用全局变量 g_smb2_ctxg_smb2_dirg_smb2_fh,同一时间只能维持一个 SMB 连接、一个目录句柄、一个文件句柄。对于单页面浏览场景足够,如需并发连接需重构为句柄池模式。

  2. 只读文件访问openFile 固定使用 O_RDONLY 标志,不支持写操作。

  3. 同步 API:绑定只使用了 libsmb2 的同步接口(smb2_connect_sharesmb2_pread 等),未接入 libsmb2 的异步事件循环(smb2_get_fd/smb2_service)。在鸿蒙的 TaskPool 中执行可避免阻塞 UI 主线程。

  4. 权限声明:在 module.json5 中需要声明 ohos.permission.INTERNETohos.permission.GET_NETWORK_INFO

  5. ABI 支持:当前仅构建 arm64-v8a。如需支持 armeabi-v7ax86_64,需交叉编译对应架构的 libsmb2 并添加到 abiFilters 中。

参考文档

参考文档:参考文档

作用域链与闭包

2026年5月8日 14:33

作用域链

核心原理

  1. VO中包含一个额外的属性(假设为 source),该属性指向创建该VO的函数本身
  2. 每个函数在创建时,会有一个隐藏属性[[scope]],它指向创建该函数时的AO
  3. 当访问一个变量时,会先查找自身VO中是否存在,如果不存在,则依次查找[[scope]]属性。

某些浏览器会优化作用域链,函数的[[scope]]中仅保留需要用到的数据。

示例分析

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>

  <body>
    <script>
      var g = 0;

      function A() {
        var a = 1;

        function B() {
          var b = 2;
          var C = function () {
            var c = 3;
            console.log(c, b, a, g);
          };
          C();
        }

        B();
      }

      A();
    </script>
  </body>
</html>

说明:

蓝线:VO中包含一个额外的属性(假设为 source),该属性指向创建该VO的函数本身

红线:每个函数在创建时,会有一个隐藏属性[[scope]],它指向创建该函数时的AO

作用域链:当访问一个变量时,会先查找自身VO中是否存在,如果不存在,则依次查找[[scope]]属性(蓝红线交替 )

image.png

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>

  <body>
    <script>
      var count = 100;
      
      function A() {
        var count = 0;
        return function () {
          count++;
          console.log(count);
        };
      }

      var test = A();

      test();
      test();
      test();

      console.log(count);
    </script>
  </body>
</html>

执行至 22 行时,执行上下文栈中的情况:

image.png

执行至 24 行时,执行上下文栈中的情况:

image.png

执行至 25 行时,执行上下文栈中的情况:

image.png

执行 count++时,线找寻 test 执行上下文中的 VO 是否有 count,如果没有,则根据 source寻找到创建该 VO 的函数本身,然后通过[[scope]]属性寻找到下一个 VO,若有 count 则该 count++,否则再往下寻找。

闭包

重新理解闭包:

原理上理解:执行上下文已经销毁,但执行上下文中 VO 被保留下来,因为匿名函数中有一个[[scope]]属性还指向他。并没有被垃圾回收器回收。

表面上理解:内部函数使用外部函数的变量,内部函数被外部调用。

Nuxt 3 + Vue 3 + TypeScript 全栈开发知识点汇总(2026 最新版)

作者 WayneYang
2026年5月8日 13:54

本文基于 Nuxt 3.11.2(2026 年 5 月最新稳定版),全面覆盖 Nuxt 3 核心特性、应用场景与实战示例。


一、Nuxt 3 核心概念与优势

1.1 什么是 Nuxt 3?

Nuxt 3 是基于Vue 3ViteNitro引擎的全栈框架,提供服务端渲染 (SSR)、静态站点生成 (SSG)、客户端渲染 (CSR) 等多种渲染模式,同时内置路由、状态管理、API 服务等功能,大幅提升开发效率Nuxt。

1.2 核心优势对比

特性 Nuxt 3 传统 Vue 3 优势说明
渲染模式 支持 SSR/SSG/CSR/ISR/Edge 仅 CSR 灵活适配不同场景,兼顾 SEO 与性能
路由系统 文件系统路由,自动生成 需手动配置 vue-router 零配置,开发效率提升 50%+
自动导入 组件、Composables、Vue API 自动导入 需手动 import 减少样板代码,代码更简洁Nuxt
TypeScript 原生支持,类型安全 需额外配置 开发体验更优,减少运行时错误
构建工具 Vite + Nitro 需手动配置 Vite 启动速度提升 10 倍 +,打包体积更小
全栈能力 内置 API 服务、中间件 需额外集成 Node.js 前后端一体化,减少跨域问题

二、环境搭建与项目初始化

2.1 快速创建 Nuxt 3 项目

# 使用nuxi创建项目(推荐)
npx nuxi@latest init my-nuxt3-app
cd my-nuxt3-app
npm install

# 启动开发服务器
npm run dev

2.2 TypeScript 配置(nuxt.config.ts)

// nuxt.config.ts
export default defineNuxtConfig({
  typescript: {
    strict: true, // 启用严格类型检查
    typeCheck: true, // 构建时进行类型检查
    shim: false // 禁用Vue类型垫片,使用原生类型
  },
  devtools: { enabled: true } // 启用Nuxt开发者工具
})

2.3 目录结构解析

my-nuxt3-app/
├── app.vue               # 应用入口组件
├── nuxt.config.ts        # 项目配置文件
├── package.json          # 依赖管理
├── tsconfig.json         # TypeScript配置
├── pages/                # 页面组件(自动生成路由)
├── components/           # 组件(自动导入)
├── composables/          # 可复用组合式函数(自动导入)
├── server/               # 服务器端代码(API、中间件)
├── public/               # 静态资源
└── assets/               # 待编译资源(CSS、图片等)

三、核心特性详解与实战示例

3.1 文件系统路由(零配置路由)

基础路由示例

pages/
├── index.vue → /              # 首页
├── about.vue → /about         # 关于页
├── blog/
│   ├── index.vue → /blog      # 博客列表页
│   └── [id].vue → /blog/:id   # 博客详情页(动态路由)
└── users/
    └── [id]/
        └── settings.vue → /users/:id/settings

动态路由与参数获取

<!-- pages/blog/[id].vue -->
<template>
  <div>
    <h1>博客文章 #{{ postId }}</h1>
    <p>{{ post?.content }}</p>
  </div>
</template>

<script setup lang="ts">
import type { RouteParams } from 'vue-router'

// 获取路由参数
const route = useRoute()
const postId = route.params.id as string

// 类型安全的路由参数
interface BlogParams extends RouteParams {
  id: string
}
const typedParams = route.params as BlogParams
</script>

路由元信息(Page Meta)

<script setup lang="ts">
definePageMeta({
  title: '博客详情页', // 页面标题
  layout: 'blog',    // 指定布局组件
  middleware: 'auth' // 应用路由中间件
})
</script>

3.2 数据获取与状态管理

3.2.1 useFetch(通用数据获取)

<template>
  <div>
    <h1>博客列表</h1>
    <div v-if="pending">加载中...</div>
    <div v-else-if="error">错误:{{ error.message }}</div>
    <ul v-else>
      <li v-for="post in data" :key="post.id">
        <NuxtLink :to="`/blog/${post.id}`">{{ post.title }}</NuxtLink>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
interface Post {
  id: number
  title: string
  content: string
}

// 类型安全的数据获取
const { data, pending, error, refresh } = await useFetch<Post[]>('/api/posts', {
  method: 'GET',
  headers: {
    'Content-Type': 'application/json'
  }
})
</script>

3.2.2 useAsyncData(更灵活的数据获取)

<script setup lang="ts">
const { data, error } = await useAsyncData('posts', async () => {
  const res = await $fetch('/api/posts')
  return res as Post[]
}, {
  watch: [], // 监听依赖变化自动刷新
  initialCache: false, // 禁用初始缓存
  staleTime: 60000 // 数据保鲜时间(毫秒)
})
</script>

3.2.3 Pinia 状态管理集成

# 安装Pinia
npm install @pinia/nuxt pinia
// stores/user.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    id: 0,
    name: '',
    email: ''
  }),
  getters: {
    isLoggedIn: (state) => state.id !== 0
  },
  actions: {
    async fetchUser(id: number) {
      const user = await $fetch(`/api/users/${id}`)
      this.$patch(user)
    }
  }
})

在组件中使用:

<script setup lang="ts">
import { useUserStore } from '~/stores/user'

const userStore = useUserStore()

// 调用action获取用户数据
await userStore.fetchUser(1)
</script>

3.3 组件与 Composables 自动导入

3.3.1 组件自动导入

components/
├── Button.vue → <Button />
├── blog/
│   └── PostCard.vue → <BlogPostCard />
└── ui/
    ├── Input.vue → <UiInput />
    └── Modal.vue → <UiModal />

使用示例:

<template>
  <div>
    <BlogPostCard :post="post" />
    <UiButton @click="openModal">打开模态框</UiButton>
    <UiModal v-model:open="isModalOpen">
      <p>这是自动导入的模态框组件</p>
    </UiModal>
  </div>
</template>

3.3.2 Composables 自动导入

// composables/useCounter.ts
export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const increment = () => count.value++
  const decrement = () => count.value--
  
  return { count, increment, decrement }
}

在组件中使用(无需 import):

<script setup lang="ts">
const { count, increment } = useCounter(10)
</script>

3.4 服务器端开发(API 与中间件)

3.4.1 服务器 API 路由

// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
  // 从数据库获取数据
  const posts = await prisma.post.findMany()
  return posts
})

// server/api/posts/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const post = await prisma.post.findUnique({
    where: { id: Number(id) }
  })
  
  if (!post) {
    setResponseStatus(event, 404)
    return { error: 'Post not found' }
  }
  
  return post
})

3.4.2 服务器中间件

// server/middleware/auth.ts
export default defineEventHandler((event) => {
  const authHeader = getHeader(event, 'authorization')
  
  if (!authHeader) {
    setResponseStatus(event, 401)
    return { error: 'Unauthorized' }
  }
  
  // 验证token逻辑
  const token = authHeader.split(' ')[1]
  const isValid = verifyToken(token)
  
  if (!isValid) {
    setResponseStatus(event, 403)
    return { error: 'Forbidden' }
  }
})

3.4.3 服务器工具函数

// server/utils/db.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default prisma

3.5 渲染模式与性能优化

3.5.1 渲染模式配置

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 首页使用SSG预渲染
    '/': { prerender: true },
    // 博客详情页使用ISR(增量静态再生)
    '/blog/**': { swr: 3600 }, // 缓存1小时
    // 管理后台使用纯客户端渲染
    '/admin/**': { ssr: false },
    // API路由配置
    '/api/**': { cors: true, cache: { maxAge: 60 } }
  }
})

3.5.2 静态站点生成(SSG)

# 生成静态站点
npm run generate

# 预览生成结果
npm run preview

3.5.3 服务端渲染(SSR)优化

<script setup lang="ts">
// 关键CSS预加载
definePageMeta({
  preload: [
    { rel: 'stylesheet', href: '/css/main.css' }
  ]
})

// 仅在服务端执行
if (process.server) {
  // 服务端特定逻辑
  console.log('This runs only on server')
}

// 仅在客户端执行
if (process.client) {
  // 客户端特定逻辑
  console.log('This runs only on client')
}
</script>

四、Nuxt 3 全场景应用指南

4.1 企业级网站(SEO 优先)

核心需求:SEO 友好、首屏加载快、内容更新频繁推荐方案:SSR + ISR 混合模式

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 核心页面使用SSR
    '/': { ssr: true },
    '/products/**': { ssr: true },
    // 博客内容使用ISR(每小时更新)
    '/blog/**': { swr: 3600 },
    // 静态页面预渲染
    '/about': { prerender: true },
    '/contact': { prerender: true }
  }
})

数据获取示例

<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useAsyncData(`product-${route.params.id}`, () => {
  return $fetch(`/api/products/${route.params.id}`)
}, {
  // 服务端预取数据,提升首屏性能
  server: true,
  // 客户端缓存数据,减少重复请求
  client: true
})
</script>

4.2 电商平台(高性能 + 动态内容)

核心需求:商品展示、购物车、用户中心、订单管理推荐方案:SSR(商品页)+ CSR(用户中心)+ API 服务

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 商品页使用SSR,SEO优先
    '/products/**': { ssr: true },
    // 首页使用SSG+ISR,兼顾性能与更新
    '/': { prerender: true, swr: 300 }, // 每5分钟更新
    // 用户中心使用CSR,丰富交互
    '/account/**': { ssr: false },
    // 购物车API配置缓存
    '/api/cart/**': { cache: { maxAge: 60 } }
  }
})

购物车状态管理示例

// stores/cart.ts
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
    total: 0
  }),
  actions: {
    async addToCart(productId: number, quantity: number) {
      // 调用API添加商品到购物车
      const response = await $fetch('/api/cart/add', {
        method: 'POST',
        body: { productId, quantity }
      })
      this.items = response.items
      this.total = response.total
    }
  }
})

4.3 全栈应用(前后端一体化)

核心需求:用户认证、数据管理、实时交互推荐方案:Nuxt 3 全栈模式(Nitro 服务器 + API + 数据库)

// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)
  
  // 验证用户
  const user = await prisma.user.findUnique({
    where: { email }
  })
  
  if (!user || !await verifyPassword(password, user.password)) {
    setResponseStatus(event, 401)
    return { error: 'Invalid credentials' }
  }
  
  // 生成JWT令牌
  const token = signToken({ userId: user.id })
  
  // 设置Cookie
  setCookie(event, 'token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60*60*24*7 // 7天
  })
  
  return { user: { id: user.id, name: user.name, email: user.email } }
})

用户认证中间件示例

// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  const token = getCookie(event, 'token')
  
  if (!token) {
    setResponseStatus(event, 401)
    return { error: 'Unauthorized' }
  }
  
  try {
    const decoded = verifyToken(token)
    event.context.userId = decoded.userId
  } catch (error) {
    setResponseStatus(event, 403)
    return { error: 'Invalid token' }
  }
})

4.4 静态博客 / 文档站(内容为王)

核心需求:静态部署、快速加载、内容稳定推荐方案:SSG(静态站点生成)+ 内容管理系统

# 生成静态站点
npm run generate

内容管理集成示例(使用 Contentlayer):

npm install contentlayer @contentlayer/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@contentlayer/nuxt'],
  contentlayer: {
    // 配置内容目录
    contentDirPath: 'content',
    // 定义内容类型
    documentTypes: ['Post']
  }
})
// contentlayer.config.ts
import { defineDocumentType, makeSource } from '@contentlayer/source-files'

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.md`,
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    tags: { type: 'list', of: { type: 'string' } }
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (post) => post._raw.flattenedPath
    }
  }
}))

export default makeSource({ contentDirPath: 'content', documentTypes: [Post] })

4.5 边缘计算应用(低延迟 + 全球部署)

核心需求:全球低延迟、高可用性、动态内容推荐方案:Nuxt 3 + Nitro + 边缘部署(Vercel Edge Functions/Cloudflare Workers)

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'vercel-edge' // 或 'cloudflare-pages'
  }
})

边缘函数示例

// server/api/edge.ts
export default defineEventHandler((event) => {
  // 边缘位置检测
  const edgeRegion = event.context.cloudflare?.region || 'unknown'
  
  return {
    message: 'Hello from the edge!',
    region: edgeRegion,
    timestamp: Date.now()
  }
})

五、最佳实践与性能优化

5.1 性能优化清单

优化项 实现方法 效果
代码分割 路由级自动分割,组件异步导入 减少首屏 JS 体积,提升加载速度
图片优化 使用<NuxtImg>组件,自动处理格式、尺寸 减少图片体积,提升 LCP 指标
缓存策略 合理使用 ISR、SWR、HTTP 缓存 减少服务器负载,提升响应速度
关键 CSS 内联首屏 CSS,延迟加载非关键 CSS 提升 FCP 和 LCP 指标
预加载 使用definePageMeta预加载关键资源 减少资源加载延迟
懒加载 组件与图片懒加载 减少初始加载资源数量

5.2 TypeScript 最佳实践

  1. 接口定义:为 API 响应、组件 props 等定义清晰的接口
// types/post.ts
export interface Post {
  id: number
  title: string
  content: string
  createdAt: string
  author: {
    id: number
    name: string
  }
}
  1. 组件类型安全
<script setup lang="ts">
interface Props {
  post: Post
  isFeatured?: boolean
}

const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'click', id: number): void
}>()
</script>
  1. 路由类型增强
// types/router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
  }
}

5.3 部署与 CI/CD

5.3.1 部署选项对比

部署方式 适用场景 部署命令 推荐平台
SSR 部署 动态内容、用户交互多 npm run build Vercel、Render、AWS EC2
静态部署 内容稳定、SEO 优先 npm run generate Netlify、Vercel、GitHub Pages
边缘部署 全球低延迟、高可用性 npm run build Vercel Edge、Cloudflare Pages

5.3.2 GitHub Actions 配置示例

# .github/workflows/deploy.yml
name: Deploy Nuxt 3 App
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm install
      - run: npm run generate
      - uses: netlify/actions/cli@master
        with:
          args: deploy --dir=dist --prod
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

六、常见问题与解决方案

6.1 跨域问题

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    devProxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true
      }
    }
  }
})

6.2 开发环境与生产环境差异

// 环境变量配置(.env文件)
NUXT_API_BASE_URL=http://localhost:3000/api
NUXT_PUBLIC_BASE_URL=http://localhost:3000

// 生产环境(.env.production)
NUXT_API_BASE_URL=https://api.example.com
NUXT_PUBLIC_BASE_URL=https://example.com

在代码中使用:

const apiBaseUrl = useRuntimeConfig().apiBaseUrl
const publicBaseUrl = useRuntimeConfig().public.baseUrl

6.3 调试技巧

  1. 启用 Nuxt 开发者工具
// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true }
})
  1. 服务器端调试
# 启动调试模式
npm run dev -- --inspect
  1. 性能分析
# 生成性能报告
npm run build --analyze

七、总结与未来展望

Nuxt 3 作为 Vue 3 生态的全栈框架,凭借其文件系统路由自动导入多种渲染模式全栈能力,大幅提升了 Vue 开发者的生产力。结合 TypeScript 的类型安全,Nuxt 3 已成为构建现代 Web 应用的首选框架之一Nuxt。

未来,Nuxt 4 将进一步提升性能,引入更多全栈特性,如内置数据库支持、实时通信等,让开发者能够更专注于业务逻辑,而非基础设施建设。


参考资料

  1. Nuxt 3 官方文档
  2. Vue 3 官方文档
  3. TypeScript 官方文档
  4. Nitro 引擎文档

【Vue3】withDefaults和defineProps设置默认值

作者 WebGirl
2026年5月8日 13:32

直接在defineProps中设置默认值

优点:

  • 对于简单的组件,在defineProps对象中直接设置默认值是一种简洁的方式。所有props的定义,包括类型和默认值,都集中在一个地方,一目了然。可以很方便地看到每个props的类型和默认值信息。例如:
<script setup>
const props = defineProps({
    count: {type: Number,default: 0},
    name: {type: String,default: 'Guest'}
});
</script>
  • props是一个复杂的对象类型,并且默认值是一个对象时,直接在defineProps中设置默认值可以更好地利用对象的解构特性。例如:
<script setup>
const props = defineProps({
    user: {type: Object,default: () => ({name: 'Guest',age: 18})}
});
</script>

这里可以直接返回一个函数来生成默认的对象值,在组件初始化时会根据这个函数生成默认的user对象。

缺点:

  • props的定义比较复杂,或者需要对默认值进行一些复杂的计算或处理时,会使defineProps中的代码变得臃肿。例如,如果默认值需要调用一个函数来生成,会让props的定义看起来不够清晰。而且如果多个组件有相似的props默认值设置逻辑,这种方式不利于代码的复用。
  • Vue3 的defineProps解决了部分响应式问题(返回的是 “响应式 Proxy 对象”),但如果直接对defineProps的结果解构,依然会丢失默认值(响应式可通过toRefs保留,但默认值不行)
<!-- Vue3 组件:TestProps.vue(无withDefaults) -->
<template>
  <div>
    <!-- 父组件没传props时,期望显示默认值,实际显示 undefined -->
    <h1>{{ title }}</h1>
    <p>计数:{{ count }}</p>
  </div>
</template>

<script setup lang="ts">
// 1. 定义props并设置默认值(通过第二个参数)
const props = defineProps<{
  title?: string;
  count?: number;
}>({
  title: {
    type: String,
    default: "默认标题"
  },
  count: {
    type: Number,
    default: 100
  }
});

// 2. 直接解构:丢失默认值(响应式可通过toRefs保留,但默认值不行)
const { title, count } = props; 
console.log(title); // undefined(而非 "默认标题")
console.log(count); // undefined(而非 100)

// 3. 用toRefs解构:保留响应式,但仍丢失默认值
import { toRefs } from "vue";
const { title: refTitle, count: refCount } = toRefs(props);
console.log(refTitle.value); // undefined(依然没有默认值)
</script>

可见:Vue3 中即便用了defineProps,若不配合withDefaults,Vue2 的 “解构丢失默认值” 问题依然存在 —— 这正是withDefaults要解决的核心场景之一。

使用withDefaults设置默认值

优点:

  • withDefaults将默认值的设置与props的类型定义分离开来,使得代码结构更加清晰。特别是当props的类型定义比较复杂,或者有多个props需要设置默认值时,这种分离可以提高代码的可读性。例如:
<script setup>
import { withDefaults } from 'vue';
const props = defineProps({count: Number,name: String});
withDefaults(props, {count: 0,name: 'Guest'});
</script>
  • 先专注于props的类型定义,然后在另一个地方(withDefaults调用处)清晰地设置默认值。如果需要在多个组件中复用默认值设置逻辑,可以将withDefaults的调用封装成一个函数。
  • withDefaults在处理复杂对象类型的默认值时,与直接在defineProps中设置默认值的功能类似,但语法上略有不同。
 <script setup>
 import { withDefaults } from 'vue';
 const props = defineProps({user: Object});
 withDefaults(props, {user: () => ({name: 'Guest',age: 18})});
 </script>

同样是通过一个函数来生成默认的对象值,但需要注意的是,withDefaults在处理默认值时,对于引用类型(如对象、数组)的数据,如果默认值是一个引用类型的字面量(如{}[]),这个引用在所有组件实例中是共享的。这可能会导致一些意外的行为,例如一个组件修改了默认值对象的属性,可能会影响到其他组件的默认值。所以在使用withDefaults设置引用类型的默认值时,通常建议像上面那样返回一个新的对象或数组。

withDefaults解构后仍能读取到默认值

withDefaults是 Vue3 为defineProps设计的 “默认值增强工具”,它的核心作用有两个:

  1. defineProps的默认值与 TypeScript 类型紧密绑定(类型安全);
  2. 确保解构后仍能读取到默认值(彻底解决 Vue2 遗留的痛点)。
<!-- Vue3 组件:TestProps.vue(用withDefaults) -->
<template>
  <div>
    <!-- 父组件没传props时,正确显示默认值 -->
    <h1>{{ title }}</h1> <!-- "默认标题" -->
    <p>计数:{{ count }}</p> <!-- 100 -->
  </div>
</template>

<script setup lang="ts">
// 1. 用withDefaults定义props:默认值与类型绑定
const props = withDefaults(defineProps<{
  title?: string; // 可选类型,配合默认值
  count?: number; // 可选类型,配合默认值
}>(), {
  // 定义默认值(会自动校验类型,不符合TS类型会报错)
  title: "默认标题", 
  count: () => 100, // 复杂类型(如对象/数组)需用函数返回(避免复用问题)
});

// 2. 直接解构:默认值生效,且响应式保留(因为withDefaults处理过)
const { title, count } = props; 
console.log(title); // "默认标题"(正确读取默认值)
console.log(count); // 100(正确读取默认值)

// 3. 父组件传值时,会覆盖默认值(符合预期)
// 若父组件调用:<TestProps title="新标题" count={200} />
// 此时解构后 title = "新标题",count = 200
</script>

withDefaults 为什么能解决问题?

withDefaults本质是对defineProps返回的 “响应式 props 对象” 做了一层 “增强代理”:

  • 当访问props.title时,若父组件没传值,代理会自动返回withDefaults中定义的默认值;
  • 即便解构(const { title } = props),拿到的依然是 “带默认值逻辑的响应式引用”(而非原始值),所以默认值不会丢失;
  • 同时,withDefaults会强制默认值的类型与defineProps的 TS 类型一致(比如给title123,TS 会直接报错),比 Vue2 的type校验更严格。

缺点:

  • 代码的位置相对分散,需要查看两个地方(definePropswithDefaults)才能完整地了解props的定义和默认值情况。对于简单的props设置,可能会觉得有些繁琐。

withDefaults的使用场景

提高代码可读性和组织性

(1)复杂组件的**props**默认值设置

当组件具有多个props,并且每个props都需要设置默认值时,使用withDefaults可以让代码结构更清晰。

这样,props的类型定义和默认值设置分开,便于开发者阅读和理解。先看到props的类型定义,能快速了解组件接受哪些类型的属性,然后通过withDefaults看到默认值的设置,使得代码逻辑更加清晰,尤其在大型项目或者复杂组件开发中,有助于提高代码的可维护性。

(2)遵循组件开发规范和团队协作要求 在团队开发中,可能会有代码风格和组件开发规范的要求。使用withDefaults可以更好地符合这些规范。例如,规定props的类型定义和默认值设置分开,这样在代码审查或者新成员加入团队时,能够更容易理解组件的props逻辑。而且,这种规范的代码结构有助于提高代码的复用性,方便在其他组件中复用props的定义和默认值设置逻辑。

便于默认值的动态生成和复用

(1)动态生成默认值

有时候props的默认值需要根据一些外部条件或者组件内部状态来动态生成。使用withDefaults可以方便地实现这一点。例如,一个国际化组件,其默认文本需要根据当前语言环境来生成:

<script setup>
import { withDefaults, ref } from 'vue';
import i18n from './i18n';
const props = defineProps({
    buttonText: String
});
const currentLanguage = ref(i18n.getCurrentLanguage());
function generateDefaultButtonText() {
    return i18n.getTextForLanguage(currentLanguage.value, 'defaultButtonText');
}
withDefaults(props, {
    buttonText: generateDefaultButtonText
});
</script>

这里通过一个函数generateDefaultButtonText来动态生成buttonText的默认值,该函数可以根据当前语言环境获取合适的默认文本。这种方式使得默认值的生成更加灵活,能够适应不同的应用场景。

(2)默认值逻辑复用

如果多个组件需要相同或相似的默认值设置逻辑,可以将withDefaults的调用封装成一个函数。例如,有多个数据展示组件都需要对data属性设置默认值为空数组,对loading属性设置默认值为false

function setCommonDefaults(props) {
    withDefaults(props, {
        data: () => [],
        loading: false
    });
}

然后在各个组件中使用这个函数:

<script setup>
import { defineProps, withDefaults } from 'vue';
import { setCommonDefaults } from './commonDefaults';
const props = defineProps({
    data: Array,
    loading: Boolean
});
setCommonDefaults(props);
</script>

这样可以提高代码的复用性,减少重复代码,同时也便于统一修改默认值设置逻辑。

用 NiceGUI 为 nanobot 打造 Web GUI:让 AI 对话更便捷

作者 huzhongqiang
2026年5月8日 12:04

用 NiceGUI 为 nanobot 打造 Web GUI:让 AI 对话更便捷

标签:#Python #nanobot #NiceGUI #AI工具 #GUI开发
日期:2026-05-08
摘要:nanobot 是一个轻量级的 AI Agent 框架,但仅提供 CLI 界面。本文介绍如何使用 NiceGUI(纯 Python Web 框架)为 nanobot 打造一个基本的 Web GUI,支持复制粘贴、远程访问,让 AI 对话更加便捷高效。


前言

在 AI Agent 的世界里,命令行界面(CLI)虽然高效,但复制粘贴对话内容总是不够方便。如果能有一个图形界面,可以远程访问,那体验会好很多。

今天我来分享如何用 NiceGUInanobot 打造一个 Web GUI 界面。界面如下图所示:

image.png

一、nanobot 简介

🎯 什么是 nanobot

nanobot 是一个轻量级的 AI Agent 框架,由 HKUDS 团队开发。其特点是:麻雀虽小,五脏俱全

⚡ 核心特点

特点 说明
原生 Python 纯 Python 实现,无需其他依赖
跨平台 不需要 JS、不需要 WSL,Windows 即可运行
提供 API 完整的 Python API 库,方便学习和集成
Skills 兼容 工具和 Skills 可以和龙虾、Hermes 通用
轻量级 代码简洁,适合学习 AI Agent 架构

💡 美中不足

虽然 nanobot 功能完备,但目前只提供了 CLI 界面,没有 GUI 或 Web UI。对于日常使用来说,复制粘贴对话内容不够方便,也无法远程访问。


二、NiceGUI 简介

🎯 什么是 NiceGUI

NiceGUI 是一个基于 Python 的现代 Web UI 框架。它的核心理念是:用 Python 开发 Web 页面,无需掌握 JS 和 HTML

⚡ 核心特点

特点 说明
纯 Python 只需写 Python 代码,自动生成 Web 页面
精美 UI 内置 Material Design 风格的组件
实时更新 支持响应式 UI,告别页面刷新
原生应用 可打包成 Windows/macOS/Linux 原生应用
易于部署 就是一个 Web 服务,启动即可访问

💡 简单示例

from nicegui import ui

ui.label('Hello, NiceGUI!')
ui.button('Click me', on_click=lambda: ui.notify('Clicked!'))

ui.run()

只需这几行代码,一个精美的 Web 页面就诞生了!


三、用 NiceGUI 打造 nanobot GUI

🎯 设计目标

  1. 便捷交互:支持复制粘贴,告别繁琐的 CLI 操作
  2. 远程访问:通过 Web 界面,随时随地访问 AI 对话
  3. 美观界面:利用 NiceGUI 的组件,打造现代化体验
  4. 功能完整:保留 nanobot 的核心功能(Skills、Tools 等)

💻 核心实现

1. 导入依赖与初始化
import asyncio
from nicegui import ui
from nanobot import Nanobot
from nanobot.agent import AgentHook, AgentHookContext

# 从配置文件初始化 nanobot
bot = Nanobot.from_config()
2. 自定义 Hook:处理流式输出与工具调用

这是核心部分,通过 ChatHook 实现流式输出和工具调用的实时显示:

class ChatHook(AgentHook):
    def __init__(self):
        self.content = ''
        '''存放 Agent 的输出内容. 根据流式输出累加更新'''
        self.user_message = ''
        '''存放用户输入的消息'''
        self.tool_calls = set()
        '''记录当前用到的工具名称(去重)'''
        self._display_ref = None  # 保存 markdown_display 的引用

    async def on_stream(self, ctx: AgentHookContext, delta: str):
        self.content += delta
        self._update_display()

    async def before_execute_tools(self, ctx: AgentHookContext) -> None:
        '''在工具执行前记录工具名称'''
        for tc in ctx.tool_calls:
            self.tool_calls.add(tc.name)
        self._update_display()

    def bind_display(self, display):
        '''绑定 markdown_display 引用'''
        self._display_ref = display

    def _update_display(self):
        '''更新 Markdown 显示内容'''
        if self._display_ref is None:
            return
        parts = [f'**你**: {self.user_message}']
        parts.append('\n\n---\n\n')

        # 如果有工具调用,精简显示
        if self.tool_calls:
            tools_str = ' → '.join(sorted(self.tool_calls))
            parts.append(f'🛠️ 调用工具: `{tools_str}`')
            parts.append('\n\n---\n\n')

        parts.append('**Agent:**\n\n')
        if self.content:
            parts.append(f'{self.content}▌')
        else:
            parts.append('_正在思考..._')

        self._display_ref.set_content(''.join(parts))

关键点

  • before_execute_tools 钩子自动捕获 Agent 调用的工具名称
  • _update_display 方法封装显示更新逻辑,支持流式输出和工具信息的精简展示
3. 键盘事件处理

主要是实现Enter发送用户prompt,Ctrl+Enter编辑框内换行的功能:

KEYBOARD_HANDLER_JS = '''
function setupKeyboardHandler() {
    const observer = new MutationObserver(() => {
        const textarea = document.querySelector('textarea');
        if (textarea && !textarea.dataset.hermesHandler) {
            textarea.dataset.hermesHandler = 'true';
            textarea.addEventListener('keydown', function(e) {
                if (e.key === 'Enter') {
                    if (e.ctrlKey || e.metaKey) {
                        // Ctrl+Enter: 手动插入换行
                        const start = this.selectionStart;
                        const end = this.selectionEnd;
                        const value = this.value;
                        this.value = value.substring(0, start) + '\\n' + value.substring(end);
                        this.selectionStart = this.selectionEnd = start + 1;
                        this.dispatchEvent(new Event('input', { bubbles: true }));
                        e.preventDefault();
                    } else {
                        // 单纯 Enter: 阻止换行并触发发送
                        e.preventDefault();
                        this.dispatchEvent(new CustomEvent('enter-send'));
                    }
                }
            });
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });
}
setupKeyboardHandler();
'''
4. 主页面 UI 布局
@ui.page('/')
def main_page():
    global markdown_display, input_field, status_label, card_container
    dark = ui.dark_mode()
    dark.enable()
    ui.add_head_html('<meta name="darkreader-lock">')
    ui.add_body_html(f'<script>{KEYBOARD_HANDLER_JS}</script>')

    with ui.column().classes('w-full max-w-4xl mx-auto p-6 gap-4'):
        # 标题
        ui.label('nanobot Agent 控制台').classes('text-3xl font-bold text-center text-white')

        # 输入区域
        with ui.card().classes('w-full shadow-lg').props('dark'):
            with ui.column().classes('w-full gap-2'):
                ui.label('输入消息').classes('text-sm text-gray-300')
                input_field = ui.textarea(
                    placeholder='按 Enter 发送,按 Ctrl+Enter 换行'
                ).classes('w-full').props('outlined autogrow')
                input_field.on('enter-send', lambda: asyncio.create_task(handle_send()))

                with ui.row().classes('w-full justify-between items-center'):
                    ui.label('Ctrl+Enter 换行 | Enter 发送').classes('text-xs text-gray-500')
                    with ui.row().classes('gap-2'):
                        ui.button('发送', on_click=lambda: asyncio.create_task(handle_send()),
                                 icon='send').props('color=primary')
                        ui.button('新建会话', on_click=new_conversation, 
                                 icon='refresh').props('outline')

        # 输出区域
        with ui.card().classes('w-full h-[500px] overflow-y-auto shadow-lg').props('dark') as card_container:
            with ui.scroll_area().classes('w-full h-full p-4'):
                markdown_display = ui.markdown(
                    '### 欢迎!\n\n在下方输入消息开始对话。'
                ).classes('w-full')

        status_label = ui.label('就绪').classes('text-sm text-gray-400 mt-2')

注意card_container 用于后续的上下文管理,确保 UI 更新在正确环境中执行。

5. UI 上下文辅助函数
def safe_update_display(content: str):
    """安全地更新 Markdown 显示内容,确保在正确的 UI 上下文中执行"""
    with card_container:
        markdown_display.set_content(content)


def safe_notify(message: str, type: str = 'info', **kwargs):
    """安全地显示通知,确保在正确的 UI 上下文中执行"""
    with card_container:
        ui.notify(message, type=type, **kwargs)
6. 发送消息与调用 Agent
async def handle_send():
    '''处理用户发送的消息'''
    user_input = input_field.value.strip()
    if not user_input:
        with card_container:
            ui.notify('请输入消息', type='warning')
        return
    
    input_field.value = ''  # 清空输入框
    
    hook = ChatHook()
    hook.user_message = user_input
    hook.bind_display(markdown_display)  # 绑定引用
    
    # 在正确的上下文中设置初始状态
    with card_container:
        markdown_display.set_content(
            f'**你**: {user_input}\n\n---\n\n*Agent 正在思考...*'
        )
    
    # 调用 Agent 进行对话
    try:
        result = await bot.run(
            user_input,
            session_key="main-session",
            hooks=[hook]
        )
        # 处理 Agent 输出,优先使用流式输出,否则使用直接输出
        final_content = hook.content if hook.content else result.content
        
        # 构建最终显示内容
        parts = [f'**你**: {user_input}']
        parts.append('\n\n---\n\n')
        
        # 精简显示工具信息
        if hook.tool_calls:
            tools_str = ' → '.join(sorted(hook.tool_calls))
            parts.append(f'🛠️ 调用工具: `{tools_str}`')
            parts.append('\n\n---\n\n')
        
        parts.append(f'**Agent:**\n\n{final_content}')
        
        # 在正确的上下文中更新显示
        with card_container:
            markdown_display.set_content(''.join(parts))
        
        # 简洁的工具使用提示
        if hook.tool_calls:
            tools_str = ', '.join(sorted(hook.tool_calls))
            with card_container:
                ui.notify(f'🛠️ 使用工具: {tools_str}', type='info', position='top-right', timeout=2000)

    except Exception as e:
        with card_container:
            markdown_display.set_content(
                f'**你**: {user_input}\n\n---\n\n**错误**: {str(e)}'
            )
            ui.notify(f'错误: {str(e)}', type='negative')
7. 新建会话功能
def new_conversation():
    '''新建会话'''
    with card_container:
        markdown_display.set_content('### 新会话已开始\n\n请输入消息...')
    input_field.value = ''
    with card_container:
        ui.notify('新会话已创建', type='positive')
8. 启动服务
if __name__ == '__main__':
    ui.run(
        title='nanobot Agent',
        host='127.0.0.1',
        port=8080,
        show=True,
        reload=False,
        dark=True
    )

🚀 使用方法

# 安装依赖
pip install nicegui nanobot

# 启动服务
python nanobot_gui.py

# 访问 http://127.0.0.1:8080

📱 远程访问

如果需要远程访问,只需修改启动参数:

ui.run(
    host='0.0.0.0',  # 允许外部访问
    port=8080,
    ...
)

然后在同一网络下,用 http://<电脑IP>:8080 访问。配合内网穿透工具(如 frp、ngrok)还可实现公网访问。


四、功能特性

✨ 本文实现的功能

功能 说明
流式输出 Agent 回复实时显示
工具调用捕获 自动记录并显示 Agent 调用的工具
精简工具展示 🛠️ 调用工具: xxx 格式直观展示
Markdown 渲染 支持代码高亮、格式美观
键盘优化 Enter 发送,Ctrl+Enter 换行
深色模式 保护眼睛的深色主题
新建会话 一键清空对话历史
滚动区域 自动滚动到最新内容
上下文安全 通过 card_container 确保 UI 操作线程安全

五、总结

📌 本文要点

  1. nanobot 是一个轻量级的 Python AI Agent 框架,适合学习和二次开发
  2. NiceGUI 让 Python 开发者也能轻松打造精美的 Web 界面
  3. 通过 AgentHook 机制,可以实时获取 nanobot 的流式输出和工具调用
  4. card_container 上下文管理确保了 UI 更新的线程安全
  5. Web 界面的优势:跨平台、远程访问、无需安装

💡 进一步优化

  • 添加消息时间戳
  • 支持图片上传
  • 保存对话历史到文件
  • 集成更多 nanobot Skills
  • 打包成独立桌面应用

📚 参考资料


本文为本人原创,首发于掘金。
如果你有任何问题或想法,欢迎在评论区交流!

每天一个高级前端知识 - Day 3

2026年5月8日 12:04

每天一个高级前端知识 - Day 3

今日主题:V8引擎的隐藏类与内联缓存 - 写出JIT友好的高性能代码

核心概念:JavaScript在V8中并非解释执行,而是JIT编译

V8使用两种编译器:

  • Ignition:解释器,快速生成字节码
  • TurboFan:优化编译器,将热点代码编译为机器码

🔍 隐藏类(Hidden Classes)

V8为相同形状的对象分配相同的隐藏类,优化属性访问:

// ✅ 好的模式 - 相同形状
function Point(x, y) {
  this.x = x;  // 隐藏类: C0 → C1 (添加x)
  this.y = y;  // 隐藏类: C1 → C2 (添加y)
}

const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
// p1和p2共享相同的隐藏类 → 优化

// ❌ 坏的模式 - 动态添加/删除属性
const p3 = new Point(5, 6);
p3.z = 7;     // 创建新的隐藏类,破坏结构
delete p3.y;  // 导致隐藏类退化成字典模式

🚀 内联缓存(Inline Caching)

V8会缓存属性访问的偏移量位置:

function getX(obj) {
  return obj.x;  // V8记住obj的隐藏类和x的位置
}

const obj1 = { x: 1, y: 2 };
const obj2 = { x: 3, y: 4 };

// 第一次调用:未优化(Miss)
getX(obj1);
// 第二次调用:相同隐藏类 → 命中缓存(Hit)- 极快
getX(obj2);

// ❌ 破坏 - 不同形状导致缓存失效
const obj3 = { x: 5, z: 6, y: 7 };  // 属性顺序不同
getX(obj3);  // 缓存未命中,性能下降

📊 性能对比实验

// 测试1:保持相同形状
console.time('consistent-shape');
for (let i = 0; i < 1000000; i++) {
  const obj = { x: i, y: i * 2 };
  obj.x + obj.y;
}
console.timeEnd('consistent-shape');  // ~8ms

// 测试2:动态添加属性
console.time('dynamic-shape');
for (let i = 0; i < 1000000; i++) {
  const obj = { x: i };
  obj.y = i * 2;  // 每次创建新隐藏类
  obj.x + obj.y;
}
console.timeEnd('dynamic-shape');  // ~35ms (慢4倍+)

💡 高级优化技巧

1. 使用数组而非动态对象

// ❌ 慢 - 动态键值对
const map = {};
for (let i = 0; i < 100000; i++) {
  map[`key_${i}`] = i;
}

// ✅ 快 - 使用数组
const arr = new Array(100000);
for (let i = 0; i < 100000; i++) {
  arr[i] = i;
}
// 或使用Map(适合频繁增删)
const map2 = new Map();

2. 单态(Monomorphic) > 多态(Polymorphic) > 超态(Megamorphic)

// 单态 - 最佳(一种隐藏类)
function process(obj) {
  return obj.value;
}

// 多态 - 较差(2-4种隐藏类)
function processPoly(obj) {
  return obj.value;  // V8会生成检查代码
}

// 超态 - 最差(5+种隐藏类)
function processMega(obj) {
  return obj.value;  // 放弃优化,回退到字典模式
}

3. 函数参数保持相同类型

// ❌ 坏 - 参数类型变化
function add(a, b) {
  return a + b;
}
add(1, 2);      // 整数
add(1.5, 2.5);  // 双精度浮点 - 导致去优化

// ✅ 好 - 保持类型一致
function addInt(a, b) {
  return (a | 0) + (b | 0);  // 强制整数运算
}

🎯 今日挑战

实现一个高性能的对象池,利用隐藏类复用和数组预分配,优化频繁的对象创建销毁:

class ObjectPool {
  constructor(createFn, resetFn, initialSize = 100) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
    this.active = new Set();
    
    // 预创建对象,保持相同形状
    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn());
    }
  }
  
  acquire() {
    let obj = this.pool.pop();
    if (!obj) {
      // 池子空了,创建新对象
      obj = this.createFn();
    }
    this.active.add(obj);
    return obj;
  }
  
  release(obj) {
    if (!this.active.has(obj)) return;
    this.resetFn(obj);  // 重置到初始状态,保持隐藏类
    this.active.delete(obj);
    this.pool.push(obj);
  }
  
  // 批量操作,减少GC压力
  acquireBatch(count) {
    const batch = [];
    for (let i = 0; i < count; i++) {
      batch.push(this.acquire());
    }
    return batch;
  }
}

// 使用示例 - 游戏粒子系统
const particlePool = new ObjectPool(
  // 创建函数 - 保持属性顺序一致
  () => ({
    x: 0, y: 0, vx: 0, vy: 0,
    life: 0, color: '#fff', active: false
  }),
  // 重置函数 - 恢复所有属性
  (p) => {
    p.x = 0; p.y = 0; p.vx = 0; p.vy = 0;
    p.life = 0; p.active = false;
  },
  1000  // 预分配1000个粒子
);

// 游戏循环
function updateParticles() {
  const activeParticles = particlePool.active;
  for (const p of activeParticles) {
    p.x += p.vx;
    p.y += p.vy;
    p.life--;
    
    if (p.life <= 0) {
      particlePool.release(p);  // 回收而非销毁
    }
  }
}

🔧 V8调试工具

// 1. 查看优化状态(Node.js --trace-opt)
// node --trace-opt --trace-deopt script.js

// 2. 查看隐藏类(Chrome DevTools)
// 在console中:%DebugPrint(obj)

// 3. 性能分析
function measureOptimization(fn, iterations = 10000) {
  // 预热 - 触发优化编译
  for (let i = 0; i < 1000; i++) fn();
  
  const start = performance.now();
  for (let i = 0; i < iterations; i++) fn();
  const end = performance.now();
  
  console.log(`平均: ${(end - start) / iterations * 1000}μs`);
}

📈 今日核心要点

模式 性能 说明
相同形状对象 ⚡极快 单态内联缓存
动态添加属性 🐌慢 创建新隐藏类
对象字面量重复使用 ⚡快 保持属性顺序一致
多态参数 🐢慢 触发去优化
数组 vs 对象 数组快~300% 连续内存访问

明日预告:WebAssembly 高级调优 - 突破JavaScript性能极限,将C++库编译为浏览器可运行的超高速代码

💡 思考题:为什么{a:1,b:2}{b:2,a:1}慢?两者不同形状导致隐藏类不同,影响内联缓存命中率!

D3.js入门教程

作者 Lodestar
2026年5月8日 11:50

:本文不对 D3.js 的整体背景与功能做过多赘述,相关介绍请读者自行查阅 D3.js 官方网站:d3js.org/。本文仅聚焦于帮助读者快速上手 D3.js 项目搭建与基础使用,文中内容如有疏漏,欢迎各位读者批评指正。

1. D3 简介

D3.js(全称 Data-Driven Documents,数据驱动文档)是一款免费、开源的 JavaScript 数据可视化库,基于 HTML、CSS、SVG 等 Web 标准构建,专注于为数据提供极致灵活的动态可视化能力。

与 ECharts、Chart.js 等开箱即用的封装型图表库不同,D3.js 没有预设好的 “柱状图”“折线图”“饼图” 等标准化图表组件,它的核心定位是可视化底层工具集。它能将数据与 DOM 元素绑定,通过强大的数据处理、比例尺、布局、动画交互等核心能力,让开发者自由组合 SVG、Canvas 等基础图形元素,从零构建任意形式的可视化作品 —— 无论是常规统计图表、动态交互大屏,还是网络图、地图、桑基图、力导向图等复杂定制化可视化,都能实现。

D3.js 的核心优势是数据驱动与高度自由:它支持数据实时更新、平滑过渡动画、丰富的鼠标交互与事件响应,能完美适配浏览器环境,兼顾可视化效果与性能。凭借无上限的定制化能力,它成为数据可视化领域专业开发者、数据分析师、科研可视化从业者的首选工具,也是构建高端、个性化数据可视化项目的核心技术。

2. 前置条件

在正式学习 D3.js 之前,我们需要了解一些必要的 Web 基础知识,包括 HTML、CSS、JavaScript 以及 SVG。本文不对 HTML、CSS、JavaScript 做详细介绍,读者可通过 W3C 官网(www.w3schools.com/)进行查阅学习。下面我们将对 SVG 做简单介绍 —— 它是 D3.js 实现数据可视化的核心底层载体。

2.1 .SVG 简介

SVG 全称可缩放矢量图形,是基于XML的矢量图形格式,依靠代码绘制图形,放大缩小永不失真

SVG 拥有专属坐标系,以画布左上角为坐标原点 (0,0),水平向右为 x 轴正方向,垂直向下为 y 轴正方向。可通过标签绘制矩形、圆形、直线、文字、路径等基础图形,支持填充、描边、透明度、平移变换等样式与布局设置,是 D3.js 实现数据可视化的核心底层载体。

2.1.1. 通用属性

  • fill:填充颜色
  • stroke:描边 / 边框颜色
  • stroke-width:边框粗细
  • opacity:透明度 0~1
<rect fill="skyblue" stroke="black" stroke-width="2" opacity="0.8"/>

2.1.2. 矩形 rect

  • x:左上角横坐标
  • y:左上角纵坐标
  • width:宽度
  • height:高度
<rect x="50" y="50" width="100" height="60" fill="red"/>

2.1.3. 圆形 circle

  • cx:圆心 x
  • cy:圆心 y
  • r:半径
<circle cx="100" cy="100" r="40" fill="green"/>

2.1.4. 椭圆 ellipse

  • rx:水平半径
  • ry:垂直半径
<ellipse cx="150" cy="100" rx="50" ry="30" fill="orange"/>

2.1.5. 直线 line

  • x1 y1:起点
  • x2 y2:终点
<line x1="20" y1="20" x2="200" y2="150" stroke="#333" stroke-width="3"/>

2.1.6. 文字 text

  • x y:文字左下角坐标
  • font-size:字号
<text x="80" y="80" font-size="14" fill="#000">我是文字</text>

2.1.7. path 路径

D3 折线、面积、地图 GeoJSON 最后都生成 path。语法靠指令 + 坐标

常用指令:

  • M x y:移动到起点(Move)
  • L x y:画直线到下一点(Line)
  • Z:闭合路径

示例:三角形

<path d="M 50 50 L 150 50 L 100 150 Z" stroke="black" fill="none"/>

D3 里 d3.line() 自动帮你生成这个 d 属性字符串,不用自己手写。

2.1.8. 分组 g 标签

<g> 相当于容器,统一管理一组图形,可整体平移。

<g transform="translate(50,30)">
  <rect x="0" y="0" width="80" height="50"/>
  <text x="10" y="30">组内文字</text>
</g>

transform="translate(x,y)":整体向右移 x,向下移 y

D3 画坐标轴、图例、图表分区全靠 <g>

3. D3 安装

3.1. D3 在纯 HTML 中引入

在不使用构建工具的情况下,我们可以直接通过 CDN 引入 D3.js,快速搭建开发环境。

方式 1:使用 CDN 引入(推荐新手使用)直接在 HTML 文件的 <head><body> 中添加如下代码:

<!-- 引入 D3.js v7 稳定版 -->
<script src="https://d3js.org/d3.v7.min.js"></script>

引入后,全局会暴露 d3 对象,即可在后续 <script> 标签中直接使用 D3 的所有 API。

方式 2:下载本地文件引入

  1. 前往 D3 官网下载最新版:d3js.org/
  2. 将下载的 d3.min.js 文件放入项目目录中
  3. 在 HTML 中通过相对路径引入:
<script src="./js/d3.v7.min.js"></script>

3.2. D3 在 Vue / React 项目中引入

如果是在工程化项目中使用,推荐通过 npm 安装:

步骤 1:安装依赖

npm install d3
# 或 yarn
yarn add d3

步骤 2:在组件中引入并使用

// 引入整个 D3 库
import * as d3 from 'd3';

// 也可以按需引入模块(推荐,减少打包体积)
import { scaleLinear, select } from 'd3';

4. 选择元素与绑定数据

D3 的核心逻辑是数据驱动 DOM,而实现这一切的第一步,就是学会如何选择元素、如何把数据绑定到元素上。

4.1. 如何选择元素

D3 提供了两个最基础的选择方法:

  • d3.select(selector):选择文档中第一个匹配的元素
  • d3.selectAll(selector):选择文档中所有匹配的元素

它们返回的结果被称为选择集(Selection),支持链式调用,是 D3 所有操作的起点。

// 示例:选择元素
const body = d3.select("body");          // 选择 <body> 元素
const firstP = body.select("p");         // 选择 body 里第一个 <p>
const allP = body.selectAll("p");        // 选择 body 里所有 <p>
const svg = body.select("svg");          // 选择 <svg> 元素
const allRect = svg.selectAll("rect");   // 选择 svg 里所有 <rect>

4.2. 如何绑定数据

D3 最独特的功能之一,就是能将数据直接绑定到 DOM 元素上,后续所有的渲染、更新操作,都可以围绕这些数据展开。

D3 提供了两种绑定数据的方法:

  • .datum(value):将单个数据绑定到整个选择集上
  • .data(array):将数组绑定到选择集,数组的每一项会依次与选择集的元素一一对应(最常用)

4.2.1. 用 .datum() 绑定单个数据

<p>Apple</p>
<p>Pear</p>
<p>Banana</p>

<script>
  let text = "Polaris";

  d3.selectAll("p")
    .datum(text)
    .text(function (d, i) {
        return "Hello " + i + " " + d;
    });
</script>
Hello 1 Polaris

Hello 2 Polaris

Hello 3 Polaris

运行后三个段落都会显示:Hello 1/2/3 Polaris。这里用到的 (d, i) 回调是 D3 的标准写法:

  • d:当前元素绑定的数据
  • i:当前元素在选择集中的索引(从 0 开始)

4.2.2. 用 .data() 绑定数组(重点)

<p>Apple</p>
<p>Pear</p>
<p>Banana</p>

<script>
  const dataset = ["I like dogs", "I like cats", "I like snakes"];
  d3.selectAll("p")
    .data(dataset)
    .text((d) => d);
</script>
I like cats

I like snakes

Banana

运行后三个段落会依次显示数组里的三个字符串,实现了数据与 DOM 的一一对应。

5. 元素的增删改

在选择集和数据绑定的基础上,我们可以对 DOM 元素进行插入、删除和修改操作。

5.1. 选择元素进阶

D3 的选择器完全兼容 CSS 选择器语法,支持通过标签、ID、类名来选择元素:

<p>Apple</p>
<p id="my-id">Pear</p>
<p class="my-class">Banana</p>
<p class="my-class">Orange</p>
// 1. 选择第一个 <p>
d3.select("p").style("color", "red");

// 2. 选择所有 <p>
d3.selectAll("p").style("color", "blue");

// 3. 选择 id="my-id" 的元素(用 #)
d3.select("#my-id").style("color", "green");

// 4. 选择 class="my-class" 的所有元素(用 .)
d3.selectAll(".my-class").style("color", "purple");

5.2. 插入元素

  • .append(tag):在选择集的末尾追加新元素
  • .insert(tag, beforeSelector):在指定元素的前面插入新元素
// 在 body 末尾追加一个 <p>
d3.select("body").append("p").text("我是 append 添加的段落");

// 在 #my-id 元素前面插入一个 <p>
d3.select("body").insert("p", "#my-id").text("我是 insert 添加的段落");
Apple

我是 insert 添加的段落

Pear

Banana

Orange

我是 append 添加的段落

5.3. 删除元素

  • .remove():删除选择集中的所有元素
// 删除 id="my-id" 的元素
d3.select("#my-id").remove();

6. 第一个简单图表

了解了基础操作后,我们来画第一个图表:一个横向柱状图,只包含矩形部分,用来理解 D3 绘图的核心流程。

6.1. 创建 SVG 画布

D3 推荐在 SVG 中绘图,我们先创建一个 SVG 画布:

// 画布尺寸
const width = 300;
const height = 300;

// 在 body 中添加 SVG 元素
const svg = d3.select("body")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

6.2. 绘制矩形(数据驱动)

// 数据(代表每个矩形的宽度)
const dataset = [250, 210, 170, 130, 90];
const rectHeight = 25; // 每个矩形的高度

// 核心流程:选择集 + 数据绑定 + enter() + append()
svg.selectAll("rect")
  .data(dataset)
  .enter()
  .append("rect")
  .attr("x", 20) // 矩形左上角 x 坐标
  .attr("y", (d, i) => i * rectHeight) // 矩形左上角 y 坐标
  .attr("width", d => d) // 矩形宽度 = 数据值
  .attr("height", rectHeight - 2) // 矩形高度
  .attr("fill", "red"); // 填充颜色

这里的 .selectAll("rect").data(dataset).enter().append("rect") 是 D3 绘图的核心公式:

  • .selectAll("rect"):先选择所有已有的 rect(初始为空)
  • .data(dataset):绑定数据
  • .enter():处理 “数据比元素多” 的情况
  • .append("rect"):为多余的数据创建新元素

运行后会在 SVG 中生成 5 个红色横向矩形:

  • 每个矩形的宽度直接对应数据值(250、210、170、130、90)
  • 矩形按索引 i 依次向下排列,y 坐标为 i * rectHeight
  • 矩形之间会有 2px 的间距,整体看起来整齐不重

这种写法虽然简单,但数据值会直接影响画布尺寸,比如数据过大就会超出 SVG 边界,数据过小则几乎看不见。为了解决这个问题,我们需要引入比例尺的概念,这也是下一章的重点。

7. 比例尺与坐标轴

上一节直接用数据值作为像素值有明显的局限性,比如数据太大超出画布、太小看不见,这时候就需要比例尺来解决问题。

7.1. 什么是比例尺

比例尺是 D3 提供的一种映射函数,它可以把数据(定义域 domain)映射到画布像素(值域 range)上,保持数据的大小关系不变。

7.2. 常用比例尺(D3 v7 API)

7.2.1 线性比例尺 d3.scaleLinear()

用于连续数值的映射,比如柱状图的 Y 轴、折线图的坐标轴。

const dataset = [1.2, 2.3, 0.9, 1.5, 3.3];
const min = d3.min(dataset);
const max = d3.max(dataset);

// 创建线性比例尺
const linearScale = d3.scaleLinear()
  .domain([min, max]) // 数据范围:0.9 ~ 3.3
  .range([0, 300]);   // 像素范围:0 ~ 300px

console.log(linearScale(0.9)); // 输出 0
console.log(linearScale(3.3)); // 输出 300
console.log(linearScale(2.3)); // 输出 175

7.2.2 条带比例尺 d3.scaleBand()

用于离散分类数据的映射,比如柱状图的 X 轴,用来给每个柱子分配位置和宽度。

const labels = ["A", "B", "C", "D", "E"];

const bandScale = d3.scaleBand()
  .domain(labels)       // 分类数据
  .range([0, 300])      // 像素范围
  .padding(0.1);        // 柱子之间的间距

console.log(bandScale("A")); // 第一个柱子的 x 坐标
console.log(bandScale.bandwidth()); // 每个柱子的宽度

7.2.3 坐标轴(D3 v7 API)

D3 提供了现成的坐标轴组件,基于比例尺生成刻度和轴线。

  • d3.axisBottom(scale):底部 X 轴
  • d3.axisLeft(scale):左侧 Y 轴
// 基于线性比例尺创建坐标轴
const xAxis = d3.axisBottom(linearScale);

// 添加坐标轴到 SVG
svg.append("g")
  .attr("transform", "translate(20, 280)") // 移动到画布底部
  .call(xAxis); // 渲染坐标轴

8. 完整柱状图实战

综合前面的知识,我们来实现一个包含矩形、文字标签、坐标轴的完整柱状图,以下是可直接运行的完整代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>D3 v7 完整柱状图</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <style>
      .axis path, .axis line {
        fill: none;
        stroke: #333;
        shape-rendering: crispEdges;
      }
      .axis text {
        font-size: 12px;
      }
    </style>
  </head>
  <body>
    <script>
      // 1. 画布配置
      const width = 400;
      const height = 400;
      const padding = { top: 20, right: 20, bottom: 30, left: 40 };

      // 2. 创建 SVG
      const svg = d3.select("body")
        .append("svg")
        .attr("width", width)
        .attr("height", height)
        .append("g")
        .attr("transform", `translate(${padding.left},${padding.top})`);

      // 3. 数据
      const dataset = [10, 20, 30, 40, 33, 24, 12, 5];

      // 4. 比例尺
      const xScale = d3.scaleBand()
        .domain(d3.range(dataset.length))
        .range([0, width - padding.left - padding.right])
        .padding(0.1);

      const yScale = d3.scaleLinear()
        .domain([0, d3.max(dataset)])
        .range([height - padding.top - padding.bottom, 0]);

      // 5. 坐标轴
      const xAxis = d3.axisBottom(xScale);
      const yAxis = d3.axisLeft(yScale);

      svg.append("g")
        .attr("class", "axis")
        .attr("transform", `translate(0,${height - padding.top - padding.bottom})`)
        .call(xAxis);

      svg.append("g")
        .attr("class", "axis")
        .call(yAxis);

      // 6. 绘制矩形
      svg.selectAll("rect")
        .data(dataset)
        .enter()
        .append("rect")
        .attr("x", (d, i) => xScale(i))
        .attr("y", d => yScale(d))
        .attr("width", xScale.bandwidth())
        .attr("height", d => height - padding.top - padding.bottom - yScale(d))
        .attr("fill", "red");

      // 7. 文字标签
      svg.selectAll("text.label")
        .data(dataset)
        .enter()
        .append("text")
        .attr("class", "label")
        .attr("x", (d, i) => xScale(i) + xScale.bandwidth() / 2)
        .attr("y", d => yScale(d) - 5)
        .attr("text-anchor", "middle")
        .text(d => d);
    </script>
  </body>
</html>

GDScript使用静态类型并开启严格的类型检查

作者 Being09
2026年5月8日 11:28

为 Godot 项目配置严格的 GDScript 静态类型检查

Godot 4.2+ 的 GDScript 警告系统允许你在项目级别精细控制类型安全检查的严格程度。 本文介绍如何一键开启最严格的类型检查,同时跳过第三方插件,避免误报。


背景

GDScript 从 Godot 4.0 起支持完整的静态类型系统。默认情况下,许多类型相关的警告只是提示级别(warning),不会阻止你运行游戏。对于追求代码质量的项目,可以将其提升为错误级别,让编辑器在编译阶段就拦截类型问题。

核心思路很简单:每一项警告都可以独立设置为三个等级之一

等级值 含义 效果
0 IGNORE 完全忽略,不显示
1 WARN 黄色警告,不阻止运行
2 ERROR 红色错误,阻止编译

快速配置

在项目根目录的 project.godot 中添加(或合并)以下内容:

[debug]

; 启用警告系统
gdscript/warnings/enable=true

; 跳过 addons/ 目录(第三方插件不检查)
gdscript/warnings/exclude_addons=true

; ====== 类型安全 — ERROR ======
gdscript/warnings/untyped_declaration=2
gdscript/warnings/unsafe_property_access=2
gdscript/warnings/unsafe_method_access=2
gdscript/warnings/unsafe_cast=2
gdscript/warnings/unsafe_call_argument=2
gdscript/warnings/incompatible_ternary=2
gdscript/warnings/narrowing_conversion=2
gdscript/warnings/int_as_enum_without_cast=2
gdscript/warnings/int_as_enum_without_match=2
gdscript/warnings/confusable_identifier=2
gdscript/warnings/redundant_static_unreachable=2

; ====== 未使用 / 影子变量 — WARN ======
gdscript/warnings/unused_variable=1
gdscript/warnings/unused_parameter=1
gdscript/warnings/unused_signal=1
gdscript/warnings/unused_local_constant=1
gdscript/warnings/unused_private_class_variable=1
gdscript/warnings/shadowed_variable=1
gdscript/warnings/shadowed_variable_base_class=1
gdscript/warnings/shadowed_global_identifier=1

; ====== 代码质量 — WARN ======
gdscript/warnings/unreachable_code=1
gdscript/warnings/unreachable_pattern=1
gdscript/warnings/standalone_expression=1
gdscript/warnings/standalone_ternary=1
gdscript/warnings/deprecated_keyword=1
gdscript/warnings/confusable_local_declaration=1
gdscript/warnings/empty_assignment=1
gdscript/warnings/return_value_discarded=1

; ====== 推断风格 — WARN ======
gdscript/warnings/inferred_declaration=1

也可以在 Godot 编辑器中操作:Project → Project Settings → 勾选 Advanced SettingsDebug → GDScript → Warnings


完整警告项参考

以下列出 Godot 4.2+ 所有可配置的 GDScript 警告项,附中英文名称和推荐等级。

🔴 类型安全 — 设为 ERROR(2)

这些警告直接关系到运行时类型安全,建议始终设为 2(错误)。

设置键 英文名称 中文名称 触发场景
untyped_declaration Untyped Declaration 未类型化声明 变量、参数或返回值缺少类型注解
unsafe_property_access Unsafe Property Access 不安全的属性访问 在 Variant 或动态类型上访问属性
unsafe_method_access Unsafe Method Access 不安全的方法访问 在 Variant 或动态类型上调用方法
unsafe_cast Unsafe Cast 不安全的类型转换 无法验证的 as 转换
unsafe_call_argument Unsafe Call Argument 不安全的调用参数 传递了类型不匹配的参数
incompatible_ternary Incompatible Ternary 不兼容的三元运算 三元运算符的两个分支类型不同
narrowing_conversion Narrowing Conversion 窄化转换 隐式将 float 转为 int 等精度损失转换
int_as_enum_without_cast Int as Enum Without Cast 整数直接当枚举(无转换) 将整数赋值给枚举类型但未显式转换
int_as_enum_without_match Int as Enum Without Match 整数枚举无匹配 整数值不对应任何枚举成员
confusable_identifier Confusable Identifier 易混淆标识符 变量名与内置类型/类名极度相似
redundant_static_unreachable Redundant Static Unreachable 冗余的静态不可达代码 @static_unload 中不可达的代码

🟡 未使用 / 影子变量 — 设为 WARN(1)

这些是代码整洁性警告,提示但不阻止运行。

设置键 英文名称 中文名称 触发场景
unused_variable Unused Variable 未使用的变量 声明了变量但从未读取
unused_parameter Unused Parameter 未使用的参数 函数参数声明了但函数体内未使用
unused_signal Unused Signal 未使用的信号 声明了信号但从未 connect 或 emit
unused_local_constant Unused Local Constant 未使用的局部常量 声明了常量但从未引用
unused_private_class_variable Unused Private Class Variable 未使用的私有类变量 _ 前缀的类变量未被使用
shadowed_variable Shadowed Variable 影子变量 内层作用域声明了与外层同名的变量
shadowed_variable_base_class Shadowed Variable Base Class 影子基类变量 子类变量遮蔽了基类同名变量
shadowed_global_identifier Shadowed Global Identifier 影子全局标识符 局部变量遮蔽了全局类名/常量

🟡 代码质量 — 设为 WARN(1)

设置键 英文名称 中文名称 触发场景
unreachable_code Unreachable Code 不可达代码 return/break 之后还有代码
unreachable_pattern Unreachable Pattern 不可达的 match 分支 match 中永远无法匹配到的 pattern
standalone_expression Standalone Expression 独立表达式 仅计算但不使用结果的语句
standalone_ternary Standalone Ternary 独立的三元运算 三元表达式作为独立语句使用
deprecated_keyword Deprecated Keyword 已弃用的关键字 使用了旧版 GDScript 关键字
confusable_local_declaration Confusable Local Declaration 易混淆的局部声明 局部变量名与外部作用域极度相似
empty_assignment Empty Assignment 空赋值 将变量赋值为自身(无意义操作)
return_value_discarded Return Value Discarded 返回值被丢弃 调用了有返回值的函数但未使用返回值

🟡 推断风格 — 设为 WARN(1)

设置键 英文名称 中文名称 触发场景
inferred_declaration Inferred Declaration 推断类型声明 使用 := 推断而非显式 : Type 声明

⚙️ Godot 4.2 默认已为 ERROR 的警告

以下警告在 Godot 4.2 中默认就是错误级别,无需额外配置:

设置键 英文名称 中文名称 触发场景
inference_on_variant Inference on Variant Variant 上的类型推断 对 Variant 使用 := 推断
native_method_override Native Method Override 原生方法覆盖 覆盖了引擎内置的虚方法
get_node_default_without_onready get_node Without @onready get_node 缺少 @onready 在 _ready 外使用 get_node 但未加 @onready
onready_with_export @onready With @export @onready 与 @export 共用 同时使用 @onready 和 @export

跳过插件检查

gdscript/warnings/exclude_addons=true

这一行是关键。Godot 生态中有大量社区插件(在 addons/ 目录下),它们的代码质量参差不齐。如果你开启了严格检查,这些插件的警告会淹没你自己代码的警告。

设置 exclude_addons=true 后,编辑器只会检查 addons/ 目录之外的 .gd 文件,插件代码完全跳过。


推荐策略

根据项目阶段选择不同的严格程度:

🔥 新项目 / 追求高质量

直接使用本文开头的完整配置。从第一天起就强制类型安全,避免技术债积累。

⚡ 现有项目迁移

分两步走:

  1. 第一步:将所有类型安全警告设为 WARN(1),先修复代码
  2. 第二步:确认无警告后,再升级为 ERROR(2)

🛡️ 游戏原型 / Game Jam

只开启类型安全相关的 ERROR 项,其余保持默认即可。在有限时间内不必追求零警告。


配置原则总结

类型安全问题    →  ERROR(必须修)
代码整洁问题    →  WARN(应该修)
第三方插件代码  →  跳过(exclude_addons=true

这三条规则覆盖了 90% 的场景。如果你需要更细粒度的控制,参考上面的完整表格逐项调整。

Tiptap之标注组件

作者 时光足迹
2026年5月8日 11:00

Tiptap 图片组件

图片节点Image Node:只能控制基础属性,如 src,alt,title,width, height

增强图片节点Image Node Pro:增加了浮动工具栏控件,可以操作图片对齐方式,具有下载及删除功能

npx @tiptap/cli@latest add image-node-pro

但是组件安装时,需要授权,高级功能吧

tiptap-5-1.png

不想付费的话,只能自己写了,加一个 align 属性控制

按钮可以用官方的Image Align Button

addAttributes() {
  align: {
    default: 'center',
    parseHTML: element => element.getAttribute('data-align') || 'center',
    renderHTML: attributes => {
      return {
        'data-align': attributes.align
      }
    }
  }
}

Tiptap 表格组件

官方文档:Table

# 安装
npm install @tiptap/extension-table
import { TableKit } from "@tiptap/extension-table";

// 注册使用
const editor = useEditor({
  extensions: [
    // 表格扩展
    TableKit.configure({
      table: {
        resizable: true, // 启用列宽调整
      },
    }),
  ],
});

样式代码需要自己加,自己定义:

tiptap-5-2.png

目前只是实现了预览,新增/编辑暂未实现,里面操作逻辑太多了,感觉好难搞

不过Tiptap付费功能好像有,可以直接用

tiptap-5-3.png

Tiptap 标注组件

根据高亮组件Color Highlight改造而成。

编辑器效果如下所示:

tiptap-5-4.png

编辑器渲染代码,如下所示:

tiptap-5-5.png

移除标注

最开始使用如下代码移除标注:

editor.chain().focus().unsetAnnotation().run();

问题:unsetAnnotation 命令默认只对当前选区生效。如果未选中内容(光标在标注内但未选中文本),可能无法移除。

解决方案:selectParentNode或者是extendMarkRange("annotation")移除前强制选中整个标注内容(适合光标在标注内的场景)

const handleRemove = React.useCallback(() => {
  if (!editor || !editor.isEditable) return false;
  if (!canSetAnnotation(editor)) return false;

  // 关键:如果选区为空(光标在标注内),自动选中整个标注节点
  const { from, to } = editor.state.selection;
  const isEmptySelection = from === to;

  const chain = editor.chain().focus();
  // 若选区为空,先选中整个标注节点(确保作用范围)
  if (isEmptySelection) {
    // chain.selectParentNode();
    chain.extendMarkRange("annotation");
  }
  // 执行移除(和高亮的 unsetMark 逻辑一致)
  const success = chain.unsetAnnotation().run();

  if (success) {
    setAnnotationState({ type: defaultType, info: "" });
  }
}, [editor]);

更新标注

添加标注:

editor.chain().focus().setAnnotation(data).run();

更新标注:需要处理「旧标记属性覆盖」和「选区范围」的问题

const handleApply = React.useCallback(() => {
    if (!editor) return false;

    const { type, info } = annotationState;
    const typeData =
      ANNOTATION_TYPES.find((item) => item.value === type) ||
      ANNOTATION_TYPES[0];
    const data = { ...typeData, type, info };

    const { from, to } = editor.state.selection;
    // 无选区(光标在文本中间)
    const isEmptySelection = from === to;
    // 检查当前选区是否已有 annotation 标记
    const isActive = editor.isActive("annotation");

    const chain = editor.chain().focus();

    // 若选区为空且光标在标注内,自动选中整个标注
    if (isEmptySelection && isActive) {
      chain.extendMarkRange("annotation");
    }
    // 关键:如果已有标注,先移除旧的,确保新属性能生效
    if (isActive) {
      chain.unsetAnnotation();
    }

    // 应用新的标注属性
    const success = chain.setAnnotation(data).run();
    if (success) {
      onApplied?.(data as AnnotationData);
    }
    return success;
  }, [editor, annotationState, onApplied]);

但是如果旁边也有一个标注,更新时,会把旁边的也同步掉;或者把整行内容都标注了

如果希望改变标注的范围,那么需要先移除原有标注,再在新的选区上设置标注

反之,updateAttributes 只会更新当前选区内已存在的标注,而不会改变标注的范围

但是目前是点击文本,就打开弹框了,而不是选中文本,打开弹框,所以也不太适用

最终,还是得精确当前位置的选区,然后进行操作

import {
  findNodeAtPosition,
  findNodePosition,
  isValidPosition,
} from "@/lib/tiptap-utils";

// 若选区为空且光标在标注内,自动选中整个标注
if (isEmptySelection && isActive) {
  // chain.extendMarkRange("annotation");

  // 1. 验证光标位置有效性
  if (!isValidPosition(from)) return false;

  // 2. 找到光标所在的文本节点(确认在标注内)
  const currentNode = findNodeAtPosition(editor, from);
  if (!currentNode) return false;

  // 3. 找到该文本节点的完整位置范围(避免选中相邻标注)
  const nodePosition = findNodePosition({
    editor,
    node: currentNode,
  });
  if (!nodePosition) return false;

  // 4. 精准选中当前标注的范围
  chain.setTextSelection({
    from: nodePosition.pos,
    to: nodePosition.pos + currentNode.nodeSize, // nodeSize 是节点的长度
  });
}

tiptap-5-6.png

Tiptap之造字组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 自定义扩展

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

造字组件

使用继承现有扩展方式创建造字组件,主要用于在文本流中插入和展示那些无法通过常规输入法输入的特殊字符、图标或自定义图形。它实际上是一个特殊的图片节点,用于在文本中插入一个代表特定字符的图片,并且有替换文本(alt)属性

造字组件扩展

造字组件扩展可以直接继承官方 Image 组件,然后添加自定义属性。

需要多一个 glyph 字段就行,能展示替换文本,其实可以直接使用 alt 属性也行。

目前是有两种方案:

  1. 自定义扩展:直接把 extension-image 拷贝过来,在其基础上更改
  2. 继承官方扩展:继承官方 Image 节点,然后添加自定义属性

我选择了第二种,并且直接复用 alt 属性,减少改动,保证稳定性和兼容性。

import { Image as TiptapImage } from "@tiptap/extension-image";
import "./index.scss";

export const GlyphImage = TiptapImage.extend({
  name: "glyphImage",

  addOptions() {
    return {
      ...super.addOptions?.(),
      inline: true, // 强制设置为行内,确保可以在文字中间显示
      HTMLAttributes: { class: "glyph-image" },
    };
  },

  addCommands() {
    return {
      ...super.addCommands?.(),
      // 新增方法
      setGlyphImage:
        (options) =>
        ({ commands }) => {
          return commands.insertContent({ type: this.name, attrs: options });
        },
    };
  },
});
.glyph-image {
  display: inline !important; /* 强制行内显示 */
  /* width: 1em; */
  border: 1px solid #bae6fd; /* 可视化边界 */
}

造字组件使用

import { GlyphImage } from "@/components/tiptap-ui/glyph-image/extension-glyph-image";
// 在编辑器配置中注册组件
const editor = useEditor({ extensions: [GlyphImage] });

// 使用命令插入造字组件
editor.commands.setGlyphImage({
  src: "https://placehold.co/40x40/6A00F5/white",
  alt: "造字替换文本", // 替换文本
  title: "造字标题", // 标题
});

json 数据展示:

{
  "type": "glyphImage",
  "attrs": {
    "src": "/pdf/1-1-2.png",
    "alt": "造字替换文本",
    "title": "造字标题"
  }
},

tiptap-4-1.png

造字组件弹框

同“脚注组件”一样,参照“链接组件”改造:

  • 图片地址 src:可以直接输入地址,也可以上传图片
  • 替换文本 alt:复用 alt 属性作为替换文本,利用 title 属性提供鼠标悬停提示
// glyph-image-popover.tsx文件
const GlyphImageMain: React.FC<GlyphImageMainProps> = ({
  src,
  setSrc,
  alt,
  setAlt,
  setGlyph,
  glyphUpload,
  isActive,
  uploading,
  uploadProgress,
}) => {
  const fileInputRef = React.useRef < HTMLInputElement > null;

  const handleFileChange = async (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const file = event.target.files?.[0];
    if (file) {
      try {
        await glyphUpload(file);
      } finally {
        // 清空input,允许重复选择同一文件
        if (fileInputRef.current) {
          fileInputRef.current.value = "";
        }
      }
    }
  };
  const handleUploadClick = () => {
    if (!uploading) {
      fileInputRef.current?.click();
    }
  };

  return (
    <Card>
      <CardBody>
        <CardItemGroup>
          <Input
            type="url"
            placeholder="输入图片地址(src)"
            value={src}
            onChange={(e) => setSrc(e.target.value)}
          />
          <Input
            type="text"
            placeholder="输入替换文本(alt)"
            value={alt}
            onChange={(e) => setAlt(e.target.value)}
          />

          <ButtonGroup orientation="horizontal" className="justify-end mt-2">
            <Button
              type="button"
              onClick={handleUploadClick}
              title="上传图片"
              data-style="outline"
              disabled={uploading}
            >
              {uploading ? `上传中${Math.round(uploadProgress)}%` : "上传图片"}
            </Button>
            {/* 隐藏的文件输入 */}
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              onChange={handleFileChange}
              style={{ display: "none" }}
            />

            <Button
              type="button"
              onClick={setGlyph}
              title="保存造字"
              disabled={!src && !isActive}
              data-style="outline"
              className="ml-2"
            >
              保存
            </Button>
          </ButtonGroup>
        </CardItemGroup>
      </CardBody>
    </Card>
  );
};

tiptap-4-2.png

图片上传时,不使用 base64 保存图片,而是通过 OSS 保存到阿里云服务器,富文本组件中置保存地址即可

  • use-glyph-image-popover.ts文件

tiptap-4-4.png

tiptap-4-5.png

  • /lib/tiptap-utils.ts 文件

tiptap-4-6.png

最终效果,如下图所示:

tiptap-4-3.png

造字组件高亮问题

选中图片的时候,造字组件是高亮的,需要修复。

tiptap-4-8.png

主要是修改canSetGlyph方法:脚注组件也是类似的,修改canSetFootnote即可

// 检查是否可以设置造字
export function canSetGlyph(editor: Editor | null): boolean {
  // 基础校验:编辑器是否存在或者编辑器是否可编辑
  if (!editor || !editor.isEditable) return false;

  // 节点合法性检测
  // - 检查"glyphImage"节点是否在编辑器的schema中注册(确保功能支持)
  // - 检查当前选中的节点是否为"image"类型(避免与普通图片冲突)
  if (
    !isNodeInSchema("glyphImage", editor) || 
    isNodeTypeSelected(editor, ["image"])
  )
    return false;

  // 最终校验:调用编辑器的can方法检查是否可以执行setGlyphImage命令
  return editor.can().setGlyphImage?.() || false;
}

Tiptap 之自定义脚注组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

脚注组件Footnote

脚注组件 Footnote 是通过第二种方式,即创建新扩展实现的。总体参照 LinkPopover 组件改造,完成上标及悬浮提示的功能。

创建脚注组件扩展

// extension-footnote.ts
import { Node, mergeAttributes } from "@tiptap/core";

export interface FootnoteOptions {
  HTMLAttributes: Record<string, any>;
}

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    footnote: {
      /** 设置脚注(插入或更新) */
      setFootnote: (attrs: { text: string; content: string }) => ReturnType;
      /** 移除脚注 */
      unsetFootnote: () => ReturnType;
      /** 更新脚注 */
      updateFootnote: (attrs: { text: string; content: string }) => ReturnType;
    };
  }
}

export const Footnote = Node.create<FootnoteOptions>({
  name: "footnote", // 节点唯一标识
  group: "inline", // 属于行内元素组,可嵌入文本中
  inline: true, // 行内节点
  atom: true, // 原子节点,不可拆分
  selectable: true, // 可被选中

  addAttributes() {
    return {
      // 脚注符号(上标显示的内容,如①②③④⑤等)
      text: {
        default: "", // 默认符号
        parseHTML: (element) => element.getAttribute("data-text"),
        renderHTML: (attrs) => ({ "data-text": attrs.text }),
      },
      // 脚注内容(悬浮提示/编辑内容)
      content: {
        default: "",
        parseHTML: (element) => element.getAttribute("data-content"),
        renderHTML: (attrs) => ({ "data-content": attrs.content }),
      },
    };
  },

  // 解析规则:识别带data-footnote属性的sup标签
  parseHTML() {
    return [
      {
        tag: "sup[data-footnote]",
        getAttrs: (dom) => {
          if (typeof dom !== "object") return false;
          const element = dom as HTMLElement;
          return {
            text: element.getAttribute("data-text"),
            content: element.getAttribute("data-content"),
          };
        },
      },
    ];
  },

  // 渲染逻辑:上标标签+自定义符号+内容属性
  renderHTML({ node, HTMLAttributes }) {
    const { text, content } = node.attrs;

    return [
      "sup", // 使用上标标签,符合脚注排版习惯
      mergeAttributes(
        this.options.HTMLAttributes,
        {
          "data-footnote": "", // 标识为脚注节点
          "data-text": text,
          "data-content": content,
          class: "footnote-marker",
          // title: content,
        },
        HTMLAttributes,
      ),
      text, // 显示脚注符号
    ];
  },

  // 插入命令:接收符号和内容参数
  addCommands() {
    return {
      setFootnote:
        (attrs: { text: string; content: string }) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
      unsetFootnote:
        () =>
        ({ commands }) => {
          return commands.deleteSelection();
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      "Mod-Shift-F": () => {
        return this.editor.commands.setFootnote({
          text: "①",
          content: "请输入脚注内容",
        });
      },
    };
  },
});
  1. 编辑器配置
const editor = useEditor({ extensions: [Footnote] });
  1. 命令创建
// 插入脚注
editor.commands.setFootnote({
  text: "①",
  content: "这是脚注内容",
});

// 移除脚注(需要先选中脚注节点)
editor.commands.unsetFootnote();
  1. JSON 数据初始化
// 上标
{
  "type": "text",
  "marks": [{ "type": "superscript" }],
  "text": "②"
},
// 脚注
{
  "type": "footnote",
  "attrs":
    "text": "②",
    "content": "这是脚注内容"
},
  1. 渲染效果

如下所示:脚注内容是通过 title 属性显示的,使用的浏览器默认样式,需要优化

tiptap-3-1.png

解析源码:

tiptap-3-2.png

脚注弹框组件

整体依照 LinkPopover 组件改造

使用到了文本框组件 TextareaAutosize,需要先安装一下,样式我也调整了一下,参考Input组件对齐:

npx @tiptap/cli@latest add textarea-autosize

tiptap-3-8.png

选中文本初始化标记

默认情况下,脚注标记和脚注内容都是空的;如果选中文本后,再点击脚注组件,则会将选中的文本作为脚注标记,自动填充进去。

tiptap-3-3.png

const setFootnote = React.useCallback(() => {
  if (!text || !editor) return;

  const { selection, doc } = editor.state;
  // 获取选中文本
  const selectedText = doc.textBetween(selection.from, selection.to, "\n");
  // 文本赋值
  const finalText = selectedText || text;

  let chain = editor.chain().focus();

  // 如果已经选中了脚注,就更新它
  if (isFootnoteActive(editor)) {
    chain = chain.updateFootnote({ text: finalText, content });
  } else {
    // 否则插入新的脚注
    chain = chain.setFootnote({ text: finalText, content });
  }

  chain.run();
  onSetFootnote?.();
}, [editor, onSetFootnote, text, content]);
React.useEffect(() => {
  if (!editor) return;

  const updateFootnoteState = () => {
    const { selection, doc } = editor.state;
    // 提取选中的文本
    const selectedText = doc.textBetween(selection.from, selection.to, "\n");

    const { text: curText, content: curContent } =
      editor.getAttributes("footnote");

    // 如果有选中的文本且当前不是编辑已有脚注,自动填充到 text
    if (selectedText && !isFootnoteActive(editor)) {
      setText(selectedText);
    } else {
      setText(curText || "");
    }
    setContent(curContent || "");
  };

  editor.on("selectionUpdate", updateFootnoteState);
  return () => {
    editor.off("selectionUpdate", updateFootnoteState);
  };
}, [editor]);

行首插入问题

父节点是 h1,在行首插入脚注时,会将父节点变成 p 标签,导致类型都变了。

  • 问题原因

当光标位于行首且没有选中任何内容时,insertContent 会尝试在当前块级节点(如 H1)的最开始插入脚注节点。如果 H1 的 schema 约束不够宽松,编辑器可能会为了兼容插入的节点而修改父节点类型。

这种方式在行首空选择时可能会破坏父节点(如 H1)的结构约束,导致编辑器自动将 H1 降级为 P 标签。

  • 问题解决:先插入一个空文本节点

零宽空格 Unicode: \u200B

chain = chain
  .insertContent("") // 解决插入行首时,将h1改成p了
  .setFootnote({ text: finalText, content });

上面代码可以解决,但是每次都插入一个零宽空格,也不好,需要继续优化setFootnote命令。

目前没找到更好的方法,只能这样了。。。。。。

tiptap-3-4.png

提示优化

默认使用的title属性显示脚注内容,但是这样无法实现点击时弹出提示框,需要自定义处理addNodeView

// 修改extension-footnote.tsx代码
const FootnoteView = ({ node }: any) => {
  const { text, content } = node.attrs;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <sup data-footnote="">{text}</sup>
      </TooltipTrigger>
      <TooltipContent>
        <p>{content}</p>
      </TooltipContent>
    </Tooltip>
  );
};

// 绑定自定义 NodeView:不行,sup都变成span了
addNodeView() {
  return ReactNodeViewRenderer(FootnoteView);
},

上述方法不行,元素都被改变了,sup 变成 span 了

tiptap-3-5.png

还是回归最原始的方法了,更改 鼠标悬浮时title提示的样式

tiptap-3-6.png

tiptap-3-7.png

WebView 兼容性踩坑实录:那些让我加班的坑

2026年5月8日 10:45

WebView 兼容性踩坑实录:那些让我加班的坑

做了多年移动端H5开发,踩过的坑能绕地球一圈,今天盘点几个让我印象深刻的

前言

如果你是做移动端H5的,一定遇到过这种场景:

QA:这个页面在iOS上正常,Android上挂了
开发:什么Android机型?
QA:华为
开发:具体型号?
QA:不知道,就是华为
开发:...

或者:

用户:页面显示有问题
开发:什么手机?
用户:我就一破手机
开发:...

WebView的兼容性问题,每个都是坑。今天分享几个让我印象深刻(加班到深夜)的案例。


坑一:100vh 包含地址栏问题

问题现象

页面设置了 height: 100vh,在 iOS Safari 上正常,但在 Android Chrome 上:

  • 页面加载时,地址栏可见,100vh 包含地址栏高度
  • 用户向上滑动,地址栏隐藏,100vh 不变
  • 结果:页面底部多出一块空白

问题原因

iOS Safari 的 100vh 是视口高度,不包含地址栏。

Android Chrome 的 100vh 包含地址栏高度,但地址栏隐藏后不会重新计算。

解决方案

方案一:使用 dvh(推荐)

.container {
  height: 100vh; /* 兜底 */
  height: 100dvh; /* 动态视口高度,会随地址栏变化 */
}

但要注意:dvh 在 iOS 15.4+ 和 Android 108+ 才支持。

方案二:JS 计算

function setVH() {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

setVH();
window.addEventListener('resize', setVH);
.container {
  height: calc(var(--vh, 1vh) * 100);
}

如何用 WebView Inspector 排查

打开「兼容」Tab,检查 dvh 是否支持:

const compat = WebViewInspector.getCompat();
if (compat.css.dvh) {
  console.log('支持 dvh,可以直接使用');
} else {
  console.log('不支持 dvh,使用 JS 方案');
}

坑二:fixed 定位 + 软键盘

问题现象

页面底部有一个固定输入框:

.input-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
}

在 iOS 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘顶上去
  • 收起键盘后,输入框还在那个位置,不回到底部

在 Android 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘遮挡
  • 或者整个页面上移,布局乱掉

问题原因

iOS 和 Android 对软键盘的处理机制不同:

  • iOS:键盘弹出时,视口会调整,fixed 元素跟着动
  • Android:键盘弹出时,视口高度不变,fixed 元素位置不变

解决方案

方案一:监听 resize,动态调整

let originalHeight = window.innerHeight;

window.addEventListener('resize', () => {
  const currentHeight = window.innerHeight;
  
  if (currentHeight < originalHeight) {
    // 键盘弹出
    document.querySelector('.input-bar').style.position = 'absolute';
  } else {
    // 键盘收起
    document.querySelector('.input-bar').style.position = 'fixed';
  }
});

方案二:使用 visualViewport API

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', () => {
    const viewportHeight = window.visualViewport.height;
    document.querySelector('.input-bar').style.bottom = 
      `${window.innerHeight - viewportHeight}px`;
  });
}

如何用 WebView Inspector 排查

检查是否支持 visualViewport:

const compat = WebViewInspector.getCompat();
if (compat.js.visualViewport) {
  console.log('支持 visualViewport API');
}

坑三:iOS 安全区适配

问题现象

页面底部有按钮,在 iPhone X 及以上机型:

  • 按钮被 Home Indicator 遮挡
  • 或者按钮和屏幕边缘贴得太紧

问题原因

iPhone X 开始,屏幕底部有 Home Indicator 区域(约 34px),需要预留安全区。

解决方案

方案一:使用 env(safe-area-inset-bottom)

.button-bar {
  padding-bottom: env(safe-area-inset-bottom);
}

方案二:viewport 设置

<meta name="viewport" content="viewport-fit=cover">
.button-bar {
  padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0-11.2 */
  padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
}

如何用 WebView Inspector 排查

打开「环境」Tab,可以看到安全区尺寸:

const env = WebViewInspector.getEnv();
console.log('安全区底部:', env.safeAreaInsets.bottom);

坑四:微信 X5 内核的特殊行为

问题现象

在微信内置浏览器中:

  • 某些 CSS 特性不支持
  • 某些 JS API 行为异常
  • 页面渲染和 Chrome 不一致

问题原因

微信使用的是 X5 内核,基于旧版 Chromium,可能存在兼容性问题。

常见问题

  1. 不支持 ES Module

    // X5 内核可能不支持
    import xxx from './xxx.js';
    
  2. 不支持某些 CSS 特性

    /* X5 可能不支持 */
    .element {
      backdrop-filter: blur(10px);
      color: oklch(0.7 0.15 180);
    }
    
  3. localStorage 配额异常

    // X5 可能限制更严格
    localStorage.setItem('key', largeData);
    

解决方案

使用 WebView Inspector 检测 X5 内核支持的特性:

const env = WebViewInspector.getEnv();
const compat = WebViewInspector.getCompat();

if (env.webview.includes('X5')) {
  console.log('检测到 X5 内核');
  
  if (!compat.css.backdropFilter) {
    // 不支持 backdrop-filter,使用替代方案
    element.style.background = 'rgba(0,0,0,0.8)';
  }
}

坑五:Promise 未捕获错误静默失败

问题现象

// 这段代码在 PC 上会报错,但在某些 WebView 上静默失败
Promise.reject('错误');

用户反馈页面"卡住了",但控制台没有任何错误信息。

问题原因

某些 WebView 对未捕获的 Promise rejection 处理不一致:

  • 有的会触发 unhandledrejection 事件
  • 有的静默失败,不报错

解决方案

全局捕获 Promise 错误

window.addEventListener('unhandledrejection', (event) => {
  console.error('未捕获的 Promise 错误:', event.reason);
  
  // 上报错误
  reportError({
    type: 'PROMISE',
    message: event.reason,
    stack: event.reason?.stack,
    env: WebViewInspector.getEnv()
  });
});

WebView Inspector 已内置

WebView Inspector 会自动捕获 Promise 错误,打开「错误」Tab 即可看到。


坑六:IntersectionObserver 不触发

问题现象

const observer = new IntersectionObserver((entries) => {
  console.log('触发');
});
observer.observe(target);

在 PC 上正常触发,但在某些 WebView 上永远不触发。

问题原因

某些 WebView(特别是 iOS 12.2 以下)不支持 IntersectionObserver,或者行为异常。

解决方案

兼容性检测 + 降级方案

const compat = WebViewInspector.getCompat();

if (compat.js.IntersectionObserver) {
  // 使用 IntersectionObserver
  const observer = new IntersectionObserver(callback);
  observer.observe(target);
} else {
  // 降级:使用 scroll 事件
  window.addEventListener('scroll', () => {
    const rect = target.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      callback();
    }
  });
}

排查方法论

踩了这么多坑,我总结了一套排查方法:

1. 获取环境信息

用户反馈问题时,先获取环境信息:

const env = WebViewInspector.getEnv();
console.log('设备:', env.device);
console.log('系统:', env.os);
console.log('WebView:', env.webview);

2. 检测特性支持

const compat = WebViewInspector.getCompat();
if (!compat.css.grid) {
  console.log('不支持 Grid');
}
if (!compat.js.IntersectionObserver) {
  console.log('不支持 IntersectionObserver');
}

3. 捕获错误

const errors = WebViewInspector.getErrors();
if (errors.length > 0) {
  // 有错误,分析错误信息
}

4. 一键复制报告

把环境信息 + 兼容性报告 + 错误信息一起复制,发给后端或记录到工单。


工具推荐

以上排查方法,都可以用 WebView Inspector 一键搞定:

  • 环境检测:设备、系统、WebView 类型/版本
  • 兼容性检测:30+ CSS 特性 + 56+ JS API
  • 错误捕获:JS 错误 + Promise 错误 + 资源错误

安装方式

npm install webview-inspector
import WebViewInspector from 'webview-inspector';
WebViewInspector.init();

相关链接


结语

WebView 兼容性是个无底洞,每个坑都能让你加班到深夜。

但有了正确的工具和方法,排查效率至少提升 10 倍。

你踩过哪些坑?评论区分享一下吧!


#WebView兼容性 #移动端H5 #前端踩坑 #WebView调试

用官方模板理解 Decky 插件:一次从模板到架构的速览

作者 jump_jump
2026年5月8日 10:43

面向第一次接触 Steam Deck 插件开发的读者。本文以官方仓库 decky-plugin-template 为索引,逐个文件讲清它们为什么存在、如何协作,并给出模板之外、上线前必遇的几个坑。

TL;DR

  • 一个 Decky 插件 = Steam CEF 里的 React 组件 + SteamOS 上的 Python 进程,两者通过 Decky Loader 提供的 callable / emit 通信;
  • 前端入口固定是 src/index.tsxdefinePlugin(factory),后端入口固定是 main.py 里的 class Plugin,方法必须 async、参数必须 JSON-safe;
  • 路径、日志、配置全部走 decky.DECKY_PLUGIN_* 常量,不要硬编码 ~/homebrew/...
  • 开发流程靠 .vscode/ 下一套 shell 脚本闭环(build → rsync → 重启 plugin_loader),不用 VS Code 也能复刻;
  • 需要 root 就加 _root flag,但能用精确 sudo 解决的就别加——商店审核不喜欢。

Decky 插件到底是什么

Steam Deck 的游戏模式并不是一个独立 UI,而是 Steam 客户端内部的一组 CEF(Chromium Embedded Framework)页面。Valve 没有公开扩展 API,于是社区做了一个注入框架 —— Decky Loader。它做三件事:

  1. 注入 UI:在游戏模式的侧边栏挂一个入口,加载第三方插件的 React 组件;
  2. 管理后端:为每个插件拉起一个独立的 Python 进程,提供生命周期钩子与 RPC 通道;
  3. 约定目录:给每个插件划拨固定的配置、运行时、日志目录,写在 decky.DECKY_PLUGIN_* 这一组环境变量里。

所以一个 Decky 插件的最小认知是:

一个跑在 Steam 客户端 CEF 里的 React 组件 + 一个跑在 SteamOS 上的 Python 小后端,两者通过 Decky Loader 的 RPC/事件总线通信。

下文以官方模板作为参照,逐个文件拆开看。

模板长什么样

克隆官方模板后,目录结构大致如下(无关文件省略):

decky-plugin-template/
├── plugin.json              # 插件元数据
├── main.py                  # Python 后端入口,类名必须是 Plugin
├── package.json             # 前端依赖(pnpm 管理)
├── rollup.config.js         # 使用 @decky/rollup 官方 preset
├── tsconfig.json
├── decky.pyi                # decky 运行时模块的类型存根,供 IDE 用
├── src/
│   ├── index.tsx            # 前端入口,默认导出 definePlugin(...)
│   └── types.d.ts           # 让 TS 识别 *.png / *.svg / *.jpg 资源
├── assets/
│   └── logo.png             # 插件图标/资源
├── defaults/
│   └── defaults.txt         # 会被打进插件根目录的静态文件
├── py_modules/              # 第三方 Python 依赖放这里(vendored)
├── backend/                 # 原生后端(可选),用 Docker + Make 构建
│   ├── Dockerfile
│   ├── Makefile
│   ├── entrypoint.sh
│   └── src/main.c
└── .vscode/                 # 一键 setup/build/deploy 任务
    ├── tasks.json
    ├── setup.sh
    ├── build.sh
    ├── config.sh
    └── defsettings.json

这个结构看似很多,但按职责其实只有四组:插件声明plugin.jsonpackage.json)、前端运行时src/rollup.config.jstsconfig.json)、后端运行时main.pypy_modules/backend/defaults/)、开发工作流.vscode/ 下的脚本与任务)。下面每一节就按这四组展开。

运行时的调用链可以用一张图收拢:

        Steam Client (CEF)                    SteamOS (userland)
+-----------------------------------+   +-----------------------------------+
|  Steam UI                         |   |  Decky Loader (systemd service)   |
|  (hosts shared React, external'd) |   |     |                             |
|     |                             |   |     +--> python main.py           |
|     +--> dist/index.js            |   |          (class Plugin, 1 proc)   |
|          (ESM, injected by Loader)|   |               ^                   |
|               ^                   |   |               | async def xxx     |
|               | definePlugin()    |   |               |                   |
|               |                   |   |               |                   |
|          callable("xxx", args) ---+---+---> RPC ----->|                   |
|                                   |   |                                   |
|          addEventListener <-------+---+---- decky.emit("evt", ...)        |
+-----------------------------------+   +-----------------------------------+

前端 ESM bundle 和后端 Python 进程物理上互不知晓,唯一的桥是 Decky Loader 提供的一对原语:callable / emit。后面每一节本质上都在讲这张图里某一块的细节。

插件声明:plugin.jsonpackage.json

plugin.json 是 Decky Loader 在加载插件时第一个读到的文件。官方模板里长这样:

{
  "name": "Example Plugin",
  "author": "John Doe",
  "flags": ["debug", "_root"],
  "api_version": 1,
  "publish": {
    "tags": ["template", "root"],
    "description": "Decky example plugin.",
    "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
  }
}

几处容易被忽略的细节:

  • name 是展示名,不是插件目录名
    • 它是环境变量 DECKY_PLUGIN_NAME,也是菜单/商店里显示给用户的名字;
    • 真正的插件目录由安装时的 zip 顶层目录/安装路径决定,Decky Loader 源码里 plugin.json.name 和实际的 plugin_directory 是两个独立概念——配置、日志、运行时目录用的是"目录名",不是这里的展示名;
    • 模板 .vscode/defsettings.json 里的 pluginname 只是部署脚本的变量,它决定 rsync 到 Deck 上的目标目录叫什么,并不是一种"绑定关系",只是多数人习惯让两者保持一致;
    • 不要据此手拼 ~/homebrew/settings/<name>/ 这类配置目录,真实的设置、运行时、日志路径请统一读 decky.DECKY_PLUGIN_*_DIR 常量。改过插件目录名、包名或历史配置路径时,用 _migration 钩子迁移旧数据(下文讲)。
  • flags 是权限/行为声明,目前 Decky Loader 实际消费的是下面这两个:
    • _root / root:含义是让后端以 root 身份运行,能访问 /usr/ 下的系统文件。历史上模板里的 key 与 Loader 源码中判断的 key 存在过命名不一致(_root vs root),近期有所统一——提交前请以当前 SteamDeckHomebrew/decky-loader 源码及 decky-plugin-database CI 的校验结果为准,不要把任何一侧当作权威;
    • debug:在开发期打开额外日志。
    • 模板默认开着 _root 只是为了演示能力,真实插件通常不要主动开 _root——能用 subprocess + 精确 sudo 命令解决的事,就不要让整个后端进程都带特权。社区经验上,带 _root 的插件在商店审核时也更容易被打回。其他 flags 值 Loader 当前会忽略,不要依赖未文档化的行为。
  • api_version 目前固定是 1,未来协议升级时会变。
  • publish 段仅用于 decky-plugin-database 上架,开发期不写也能跑。

package.json 则声明前端依赖。完整版里还会包含仓库元数据、test 占位脚本等——下面列出与构建/运行直接相关的字段:

{
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w"
  },
  "devDependencies": {
    "@decky/rollup": "^1.0.2",
    "@decky/ui": "^4.11.0",
    "@rollup/rollup-linux-x64-musl": "^4.53.3",
    "@types/react": "19.1.1",
    "@types/react-dom": "19.1.1",
    "@types/webpack": "^5.28.5",
    "rollup": "^4.53.3",
    "typescript": "^5.6.2"
  },
  "dependencies": {
    "@decky/api": "^1.1.3",
    "react-icons": "^5.3.0",
    "tslib": "^2.7.0"
  },
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": ["react", "react-dom"]
    }
  }
}

@rollup/rollup-linux-x64-musl 是模板显式声明的依赖,用来兜底 Rollup 在某些构建环境里加载不到对应 native binding 的情况——少了它 Rollup 可能直接报 "Cannot find module" 而终止。注意:SteamOS 3 / Holo 本身是 Arch 系 glibc 发行版,并不是 musl 发行版,这里加这个依赖是为了让 Rollup 的原生 binding 解析更稳,不要把它理解成"holo 镜像基于 musl"。

这里有两条"反直觉"的点一定要记住:

  • 不要自己安装 react / react-dom 作为运行时依赖:Decky Loader 在 Steam CEF 里已经提供了一份共享的 React 实例,你再打进一份,hook 很容易因为运行时实例不一致而报错。@decky/ui 声明了 react / react-dom 作为 peerDependency,模板里的 pnpm.peerDependencyRules.ignoreMissing 就是在告诉 pnpm:"别警告,这俩由宿主环境在运行时提供"。顺带两条补充:
    • @types/react 的大版本要跟 Steam 客户端 CEF 里的 React 对齐(当前是 19.x),否则 hook 签名 / JSX 类型会在编译期就报错;
    • 包管理器建议锁定 pnpm@decky/rollupignoreMissing 规则都默认按 pnpm 的 hoist 行为设计,npm i / yarn 可能会把 react 拉成直接依赖一起打进 bundle,绕过 external。
  • @decky/ui 必须跟 Decky Loader 的版本同步:官方在 tasks 里专门准备了 updatefrontendlib 任务(即 pnpm update @decky/ui --latest),构建前一刻强制升级一次,避免把过期的类型定义带进商店审核。

前端运行时:src/、Rollup 与 definePlugin

构建:为什么是 Rollup 而不是 Vite

rollup.config.js 只有三行:

import deckyPlugin from "@decky/rollup";

export default deckyPlugin({
  // Add your extra Rollup options here
});

@decky/rollup 预置了插件需要的一切 —— TypeScript、JSX、资源处理(由 preset 内部的资源插件把 import 重写成 Decky 提供的本地资源 URL)、external React、以 format: "esm" 输出到 dist/index.js。Decky Loader 加载插件时,会把这个单文件读成字符串注入到 Steam CEF,所以:

  • 不要分包、不要动态 import():最终必须是一个文件;
  • 不要引入 Tailwind / CSS-in-JS 运行时:包体积会快速膨胀,而且可能和 Steam 原生样式冲突,更推荐直接在组件里内嵌 <style>{...}</style>
  • 资源由 preset 内置的资源处理插件接管:模板里给了一份 src/types.d.ts,声明 *.png*.svg*.jpg 为 string 模块。import logo from "../assets/logo.png" 拿到的不是 base64 data URL,而是由 preset 注入、指向 Decky 本地资源服务的相对 URL——运行时由 Loader 从插件目录里真实读取文件。这样既不会把图片塞进 bundle 撑大体积,也保留了缓存能力(具体插件名在 @decky/rollup 各版本间有变动,以实际 pnpm list 为准)。

tsconfig.json 开了 strictnoUnusedLocalsnoUnusedParameters 等一揽子严格选项,jsx: "react-jsx" 保证 JSX 编译到共享 React 运行时。新建插件时建议原样保留 —— Decky Loader 本身不强制,但严格模式能帮你避开大量运行时惊喜。

入口:definePlugin 的返回值就是插件

下面是基于模板 src/index.tsx精简改写版——删掉了原文件里注释掉的 router / logo 示例,把随机数范围从 Math.random() 换成 Math.floor(Math.random() * 100) 便于演示,骨架和 API 用法与模板一致:

// src/index.tsx
import {
  ButtonItem,
  PanelSection,
  PanelSectionRow,
  staticClasses,
} from "@decky/ui";
import {
  addEventListener,
  removeEventListener,
  callable,
  definePlugin,
  toaster,
} from "@decky/api";
import { useState } from "react";
import { FaShip } from "react-icons/fa";

// 前端 RPC 代理:对应 Python 端的 Plugin.add(left, right) -> int
const add = callable<[first: number, second: number], number>("add");
// 触发一个耗时 15s 的后端任务,完成后通过事件回传
const startTimer = callable<[], void>("start_timer");

/** 侧边栏面板的主体内容 */
function Content() {
  const [result, setResult] = useState<number | undefined>();

  /** 点击按钮时调用后端 add 并展示结果 */
  const onClick = async () => {
    const sum = await add(
      Math.floor(Math.random() * 100),
      Math.floor(Math.random() * 100),
    );
    setResult(sum);
  };

  return (
    <PanelSection title="Panel Section">
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={onClick}>
          {result ?? "Add two numbers via Python"}
        </ButtonItem>
      </PanelSectionRow>
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={() => startTimer()}>
          Start Python timer
        </ButtonItem>
      </PanelSectionRow>
    </PanelSection>
  );
}

export default definePlugin(() => {
  // 订阅后端通过 decky.emit 发出的事件
  const listener = addEventListener<[string, boolean, number]>(
    "timer_event",
    (a, b, c) => {
      toaster.toast({ title: "timer_event", body: `${a}, ${b}, ${c}` });
    },
  );

  return {
    name: "Test Plugin",
    titleView: <div className={staticClasses.Title}>Decky Example Plugin</div>,
    content: <Content />,
    icon: <FaShip />,
    onDismount() {
      // 插件可以热重载,必须在卸载时注销监听/路由/补丁
      removeEventListener("timer_event", listener);
    },
  };
});

关键心智模型:

  1. definePlugin(factory) 返回的对象就是插件的形状。最常用的四个字段:titleViewcontenticononDismount。如果你还要注册自定义路由,就在 factory 里调 routerHook.addRoute(...),并在 onDismount 里对应地 removeRoute
  2. 交互控件优先来自 @decky/uiPanelSectionPanelSectionRowButtonItemToggleFieldFocusableSidebarNavigation 等等。这些组件已经处理好了手柄聚焦、主题色跟随、与 Steam CSS 的兼容性。展示型 <div> 可以用,但可点击、可选择、可滚动的自定义元素要包进 Focusable,否则手柄模式下很容易失焦。
  3. 通信只有两种形态
    • 前端 → 后端callable<[Args], Ret>(name) 生成一个强类型 RPC 代理;
    • 后端 → 前端:Python 里 await decky.emit("event_name", ...),前端用 addEventListener 订阅。
  4. onDismount 是热重载的保命符。Decky Loader 允许在设置里单独重载某个插件,不清理监听会残留"幽灵事件",页面一刷新就会看到重复 toast。要意识到 Decky 的"热重载"只重启 Python 后端进程并重新注入前端 bundle,CEF 全局状态(window.*、定时器、React Portal)不会被清理——所以不光要 removeEventListener,凡是你挂到全局对象上的字段、注册的 setIntervalrouterHook.addRoute 的路由,全都要在 onDismount 里显式回收。

💡 语法细节:callable<[first: number, second: number], number>(...) 里的 [first: number, second: number] 是 TypeScript 4.0+ 引入的带标签元组类型,只影响 IDE 提示(参数名悬浮),不是 Decky 特殊 DSL,也不参与运行时。如果你觉得啰嗦,写成 callable<[number, number], number>(...) 完全等价。

后端运行时:main.pydecky.pyi 与目录约定

Python 入口

模板的 main.py 展示了后端的所有骨架:

# main.py
import os
import asyncio
import decky


class Plugin:
    """Decky Loader 通过反射加载这个固定名字的类。"""

    async def add(self, left: int, right: int) -> int:
        """简单的同步风格 RPC:返回两数之和。"""
        return left + right

    async def long_running(self):
        """演示:异步任务 + 通过事件向前端回传结果。"""
        await asyncio.sleep(15)
        await decky.emit("timer_event", "Hello from the backend!", True, 2)

    async def start_timer(self):
        """被前端通过 callable('start_timer') 触发。"""
        self.loop.create_task(self.long_running())

    async def _main(self):
        """插件进入时调用一次,适合做初始化/读配置。"""
        self.loop = asyncio.get_event_loop()
        decky.logger.info("Hello World!")

    async def _unload(self):
        """被停用/热重载时调用,清理资源但保留设置。"""
        decky.logger.info("Goodnight World!")

    async def _uninstall(self):
        """彻底卸载时调用,做最终清理。"""
        decky.logger.info("Goodbye World!")

    async def _migration(self):
        """迁移历史目录/配置;由 Loader 在 `_main` 之前自动调用一次。"""
        decky.migrate_logs(os.path.join(
            decky.DECKY_USER_HOME, ".config", "decky-template", "template.log"))
        decky.migrate_settings(
            os.path.join(decky.DECKY_HOME, "settings", "template.json"),
            os.path.join(decky.DECKY_USER_HOME, ".config", "decky-template"))
        decky.migrate_runtime(
            os.path.join(decky.DECKY_HOME, "template"),
            os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-template"))

提炼几条容易踩的坑:

  • 类名必须叫 Plugin,Decky Loader 通过字符串反射拿它,改了就起不来。
  • 所有对外方法都必须是 async,哪怕是同步操作。Decky Loader 会 await 每一次 RPC。
  • 方法参数和返回值必须是 JSON-safe(基本类型、dictlist)。想要类型提示就用 TypedDict
  • 生命周期钩子_main_unload_uninstall_migration 四个,命名固定、全部可选。其中 _migration 由 Decky Loader 在 _main 之前自动调用一次,不需要(也不应该)在 _main 里再手动调用。模板里把这些都写全了,可以作为"要不要支持这个行为"的 checklist。
  • _migration 的幂等原则:不要靠版本号,而是看目标字段/目录是否已经存在,用户可能跨多个版本升级。

decky 模块:一个"受约束的标准库"

模板里附带一份 decky.pyi —— 它是 Decky Loader 注入到 Python 进程里的 decky 模块的类型存根。读它等于读了一份后端能用的 API 清单。

📌 常量 vs 环境变量:下表中以 DECKY_ 开头的项同时以 decky.XXX 常量和 os.environ["XXX"] 环境变量两种形式存在。二者内容一致,但在你自己 subprocess.Popen 启动的子进程(例如 C/Rust 编出来的后端)里 只能 通过环境变量拿到——decky 模块不会被自动继承下去。

常量 / 函数 含义
decky.HOME / decky.USER 当前进程的 HOME 与用户名(受 _root 影响)
decky.DECKY_USER_HOME 真正的 deck 用户家目录,/home/deck
decky.DECKY_HOME ~/homebrew,Decky 自己的根目录
decky.DECKY_PLUGIN_DIR 当前插件解压后的根目录
decky.DECKY_PLUGIN_NAME 当前插件名,来自 plugin.json
decky.DECKY_PLUGIN_VERSION / DECKY_PLUGIN_AUTHOR 版本号、作者;上报遥测或日志时比硬编码好
decky.DECKY_VERSION Decky Loader 自身版本,做兼容性判断用
decky.DECKY_PLUGIN_SETTINGS_DIR 推荐写配置的位置,已由 loader 自动创建
decky.DECKY_PLUGIN_RUNTIME_DIR 推荐写运行时数据(缓存、临时文件)
decky.DECKY_PLUGIN_LOG_DIR 推荐写持久日志
decky.DECKY_PLUGIN_LOG 主日志文件路径
decky.logger 已绑定到上面日志文件的 logging.Logger
decky.emit(event, *args) 向前端推事件
decky.migrate_settings / _runtime / _logs 分别迁移配置/运行时/日志到约定目录
decky.migrate_any(target_dir, *sources) 上面三者的通用版:把任意旧路径搬到指定目标目录,用于不属于三类标准目录的数据

一条很关键的规则:不要往 DECKY_HOME 之外写任何东西。写 /etc/usr/local 这类路径即使拿到了 _root 也会被商店审核打回来,而且 SteamOS 下次更新会把只读分区整个覆盖掉。

带原生后端:backend/ 目录

如果你需要 C/C++/Rust/Go 编出的二进制(例如调用底层驱动),就把源码放进 backend/src/,再写一个 Makefile 把产物丢进 backend/out/。模板里的 backend/Makefile 简化到极致:

all: hello

hello:
mkdir -p ./out
gcc -o ./out/hello ./src/main.c

.PHONY: clean
clean:
rm -f hello

⚠️ 模板 backend/Makefileclean 规则与实际产物路径不一致rm -f hello 想删的是 backend/hello,但产物实际在 backend/out/hello——这条规则在模板里是个 no-op。套到实际项目时,改成 rm -rf ./out 或精确删除 ./out/<binary>

Dockerfile 使用官方提供的 holo 基础镜像(还有 holo-toolchain-rust / holo-toolchain-go 变体),entrypoint.sh 里只做一件事:cd /backend && make。Decky CLI 在构建插件时会 docker run 这个镜像,得到的 backend/out/* 会被拷贝到最终 zip 的 bin/ 下,插件运行时通过 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "hello") 调用。

这么做的好处是构建环境和 Steam Deck 完全一致,避免了"在 Ubuntu 编出来扔到 Deck 上找不到 glibc"的经典问题。

第三方依赖:py_modules/

SteamOS 的 /usr 是只读的,你没法 pip install 到系统 Python。社区约定的做法是:把第三方 Python 包 vendored 进 py_modules/,Decky Loader 会自动把这个目录加入 sys.path。模板里留了一个 .keep 占位,开发时你只需要 pip install --target=py_modules xxx 即可。

静态文件:defaults/

defaults/defaults.txt 的注释里说得很清楚:这个目录里的内容会被原样打进插件根目录。常见用途:默认 CSS 主题、种子配置、离线资源。注意它不能把文件铺到任意路径,只能放在插件目录内部。

开发工作流:.vscode/ 的一套"远程开发套件"

这是很多教程一笔带过、但对日常体验最友好的部分。模板的 .vscode/ 目录里是一套把"本地改代码"连接到"Steam Deck 上重载运行"的脚本。核心文件:

文件 作用
tasks.json 声明 VS Code 任务:setup / build / deploy / builddeploy / restartdecky
setup.sh 首次初始化:检测 pnpm、Docker,下载 Decky CLI
config.sh 校验是否已有 .vscode/settings.json,没有就复制 defsettings.json
build.sh 调用 Decky CLI 把当前目录打成符合商店规范的 zip
defsettings.json Deck 的 IP / 用户名 / 密码 / 插件名等默认值

首次设置

打开 VS Code 后运行 setup 任务,它会按顺序:

  1. 执行 setup.sh,检查 pnpm 与 Docker——Docker 只会检测是否存在并给出安装提示(不会替你装),pnpm / Decky CLI 则是辅助安装或下载缺失文件;
  2. 执行 pnpm i
  3. 执行 updatefrontendlib,把 @decky/ui 升到最新。

然后 config.sh 会拷贝 defsettings.json 生成 .vscode/settings.json

⚠️ 先看这里再复制:模板里的 deckpass: "ssap" 只是占位值,不要把真实密码写进生成的 .vscode/settings.json 再提交到仓库。推荐的做法是生成一对 SSH key(ssh-keygen 然后 ssh-copy-id deck@steamdeck.local),把 deckpass 留空,靠 deckkey 指定的私钥免密登录;部署脚本里的 sudo -S 几处确实还需要密码,但至少 ssh 本身不再依赖明文。模板 .gitignore 默认忽略了 .vscode/settings.json,但很多人会"一不小心" git add -f 上去——养成 git diff --cached 再提交的习惯。

{
    "deckip":     "steamdeck.local",
    "deckport":   "22",
    "deckuser":   "deck",
    "deckpass":   "",
    "deckkey":    "-i ${env:HOME}/.ssh/id_rsa",
    "deckdir":    "/home/deck",
    "pluginname": "Example Plugin",
    "python.analysis.extraPaths": ["./py_modules"]
}

把前几项改成你自己的 Deck 配置。首次连接前需要在桌面模式用 passwd 给 deck 用户设个密码(SteamOS 默认无密码),然后 ssh-copy-id 推公钥上去,之后就可以把 deckpass 清空了。

一条命令从代码到 Deck

build 任务会:

  1. 跑完上面的 setup + settingscheck

  2. 执行 build.sh,里头只有一行核心:

    sudo -E $CLI_LOCATION/decky plugin build $(pwd)
    

    Decky CLI 会读 plugin.json,跑 backend/Dockerfile 编原生后端,再把 dist/main.pyplugin.json 等打成 zip 塞进 out/

deploy 任务负责把 zip 传到 Deck:

  1. chmodplugins:在 Deck 上 chown 插件目录,避免 rsync 时因为只读报错;
  2. copyziprsyncout/*.zip 上传;
  3. extractzip:在 Deck 上 bsdtar -xzpf 解压到 ~/homebrew/plugins/<pluginname>/

组合任务 builddeploy 一键完成编译 + 上传 + 解压,再配上 restartdeckysudo systemctl restart plugin_loader)就完成了"改代码 → 一个快捷键 → Deck 上看效果"的闭环。

如果你不用 VS Code,其实只要直接调用 pnpm run build + Decky CLI + rsync 就能复刻同样的流程。整套脚本真正的价值在于把开发者常用的远程操作做成了自包含、幂等的 shell 脚本,可读性很高,推荐逐字读一遍。

打包与分发:插件 zip 的目录结构

上面 .vscode/ 那套脚本本质上就是 CI 流水线的"本地版"——跑的都是同一条 decky plugin build。搞懂本地产物长什么样,再把同一段 shell 搬到 GitHub Actions 里就是 CI。当你准备把插件交给用户或提交到 decky-plugin-database 时,zip 的结构是有严格约束的:

pluginname-v1.0.0.zip
└── pluginname/
    ├── bin/              (可选,原生后端的产物)
    │   └── <binary>
    ├── dist/
    │   └── index.js      (必需)
    ├── package.json      (必需)
    ├── plugin.json       (必需)
    ├── main.py           (必需,如果用了 Python 后端)
    ├── README.md         (建议)
    └── LICENSE(.md)      (提交商店时必需)

几条硬性规则:

  • LICENSE 随包分发:插件商店(decky-plugin-database)的 README 重点在于"如果许可证要求随源码/二进制一起分发,商店不会接受缺少许可证的提交"——换言之,是否必需取决于你选的许可证本身。官方 zip 目录结构列表把它标为 required,最保险的做法仍是把 LICENSE 放仓库根目录,由打包流程自动复制进 zip;
  • zip 内有且仅有一个同名顶层目录,Decky Loader 就是靠这个目录名识别插件;
  • dist/index.js 是唯一入口,所有前端代码都必须打进这一个文件;
  • bin/ 下的二进制要可执行,打包脚本会自动 chmod,但你本地 rsync 调试时得注意权限。

用户侧安装需要先在 Decky Loader 的设置里打开 Developer Mode,之后会多出两个安装入口:

  • Install Plugin from URL:粘贴一个指向 zip 的公开直链即可,Loader 会自行下载并解压。CI 产物最常见的做法是配合 nightly.link 暴露 GitHub Actions artifact,用户一行地址就能装上最新开发版;
  • Install Plugin from ZIP File:把本地 zip 丢进去,适合离线分发或内部测试。

如果非要手动处理文件,不是把 zip 原样丢进 ~/homebrew/plugins,而是把它解压成 ~/homebrew/plugins/<plugin-dir>/ 这种目录结构后再重启 loader。

提交商店前的最小自检

正式向 decky-plugin-database 提交 PR 前,建议过一遍这份 checklist,能挡住绝大多数一眼驳回:

  • zip 内的顶层目录名未与 decky-plugin-database 已收录的插件目录冲突;
  • 未启用 _root / root,或在 PR 描述里解释必要性;
  • LICENSE 文件随 zip 分发,且与仓库实际许可证一致;
  • CI 产物能通过 nightly.link 公开直链下载(方便审核者复现);
  • README 标明了支持的 Decky Loader 版本下限;
  • zip 内只有一个pluginname 一致的顶层目录,没有多余 dotfile(.DS_Store / .git/ / node_modules/)。

调试与排错

插件一旦跑起来就很容易"卡在某一层"——前端白屏、按钮点了没反应、后端一启动就崩。按照数据流向从上到下排查最省时间。

前端:CEF DevTools

Steam Deck 开启开发者模式后,Steam 客户端会把 CEF 的远程调试端口开在 http://<deck-ip>:8081(Decky Loader 自带的 scripts/deckdebug.sh 就是这么约定的)。用桌面 Chrome 访问这个地址,找到对应的 Steam UI 页面点进去,就是熟悉的 DevTools:断点、Console、Network、React DevTools 都能用。

几个高频场景:

  • callable(...) 调用没反应:在 DevTools 里 await 那个代理函数看返回值——后端抛异常时 callable 返回的 Promise 会 reject,必须 try/catch,否则 UI 只会静默失败;
  • addEventListener 收不到事件:事件名是字符串匹配,前后端拼写必须完全一致;同时确认后端的 decky.emit 是在 _main 之后被调用的,_main 之前 emit 会丢;
  • 白屏但没报错:多半是 definePlugin 的 factory 里同步抛了异常,Loader 只会静默跳过。把 factory 内容用 try/catch 包一层,错误写进 console.error

后端:日志与直接运行

后端的 print 会写到 Decky Loader 的主日志,混在所有插件输出里很难找。decky.logger 代替 print:它已经绑定到 DECKY_PLUGIN_LOG 指向的文件。需要注意的是,Decky Loader 每次启动插件时会按时间戳新开一份 .log 文件,DECKY_PLUGIN_LOG 常量指向的就是"本次启动的那一份"(而不是固定的 plugin.log),decky.logger 也写入这同一个文件。所以查看时要按修改时间排序拿最新一份。常用方式:

# 1. SSH 到 Deck 上,按修改时间挑最新一份 tail
ssh deck@steamdeck.local \
  "LOG=$(ls -t ~/homebrew/logs/<plugin-dir>/*.log | head -n1) && tail -f "$LOG""

# 2. Decky Loader 设置 → Developer → Plugin Logs 里点插件名

如果插件根本起不来(UI 侧边栏里看不到图标),走这个顺序:

  1. ~/homebrew/services/PluginLoader/PluginLoader.log,Loader 加载插件失败的 traceback 在这里;
  2. 本地先用 python3 -m py_compile main.py 做一次语法检查;真正的运行期问题(尤其是 import decky 立刻失败)不能ssh 到 Deck 上直接跑 python3 main.py 复现——decky 模块是 Loader 在启动插件进程时注入到 sys.modules 的,裸跑会立刻在 import 阶段就失败,误导排查。要么看 Loader 自身和插件日志,要么自己写一个 harness 预先把 decky 环境伪造进 sys.modules 再跑;
  3. sudo systemctl status plugin_loader 看 Loader 自身是否健康,偶尔 SteamOS 更新会把 service 搞挂。

常见"看起来很诡异"的故障

  • Steam 客户端更新后插件白屏:大概率是你劫持的内部 React 组件换了结构或 CSS 类名变了。先看 console.error,再用 React DevTools 对比 DOM 结构;长期方案是避开 afterPatch 深层注入,改用 @decky/ui 官方组件;
  • 改代码后 Deck 上没变化:检查 builddeploy 是否真的跑完、restartdecky 是否执行;有时候 rsync 被 Deck 上只读文件系统拦下来,表现为静默失败;
  • 原生后端 exec 报 Permission deniedbin/ 里的二进制丢了可执行位,chmod +x 或重新 builddeploy 一次。

模板没覆盖、但很快会遇到的事

读完这套模板,你已经具备一个可运行的"Hello World"。真正做产品化还有几个常见话题:

  1. 国际化:Decky 并没有官方 i18n 方案,社区做法是在 src/data/i18n/*.json 下放翻译,封装一个 t(key, fallback),第一次使用时读 window.LocalizationManager 拿当前 UI 语言。
  2. 持久化配置:官方早期插件普遍依赖一个叫 settings.py 的小库(可以从其它插件仓库里复制一份到 py_modules/settings.py),它把 JSON 配置落到 DECKY_PLUGIN_SETTINGS_DIR,两行代码搞定读写。
  3. 调用 Steam 内部 APISteamClient.* 是 Steam 客户端在 CEF 里挂的全局对象,能拿到游戏列表、启动参数、好友状态等。没有官方文档,类型定义主要散落在 @decky/ui 以及社区反向工程的仓库里,写的时候务必做 undefined 判断。
  4. 打补丁 / 注入 UI@decky/ui 导出了 afterPatchfindInReactTreefindModuleByExport 等工具,用来劫持 Steam 自己的 React 组件(例如在游戏右键菜单加一项)。这类代码对 Steam 客户端版本非常敏感,最好写好 try/catch 和 fallback,一次更新就可能失效。
  5. 调用原生二进制:想在 Python 后端里调 backend/out/ 编出的程序,用 asyncio.create_subprocess_execsubprocess.run 更合适——不阻塞事件循环,能 await proc.communicate() 拿 stdout/stderr;路径用 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "<name>") 拼,别写死。
  6. 长任务取消asyncio.create_task 返回的 Task 存起来,前端要中止时通过一个 cancel_* RPC 调 task.cancel();任务里用 try/except asyncio.CancelledError 做清理。
  7. 并发共享状态:多个前端 RPC 可能并发进来(手柄连点、多个面板同时打开)。改共享状态前套 asyncio.Lock,比事后 debug 竞态快得多。配置落盘同理,建议用 tempfile.NamedTemporaryFile 写完后 os.replace 原子替换,而不是直接 open(path, 'w')——Steam Deck 电量耗尽的一瞬间,json.dump 写了一半会留下一个损坏的配置文件,下次启动插件就直接炸了。
  8. CI 发布GitHub Actions + softprops/action-gh-release 是社区常用方案:push 到 main 打一个 artifact(可以用 nightly.link 给用户分发开发版),打 tag 时自动生成 Release 和 zip。

写在最后

从一个模板出发理解 Decky,其实就是记住这四层:

  1. plugin.jsonpackage.json 声明"我是谁";
  2. src/index.tsx + definePlugin 提供嵌入 Steam 的 UI;
  3. main.py + decky 模块提供受约束的后端能力;
  4. .vscode/backend/、Decky CLI 把开发到发布的流程串起来。

等这四层在你脑子里跑通了,就可以大胆扔掉模板、按自己的审美重组代码 —— 你做的事情本质上只是在 Steam 客户端里塞一个 React 组件,以及在 Deck 上跑一个 Python 小服务。

参考资源:

❌
❌