阅读视图

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

timedatectl Cheatsheet

Basic Status

Check the current time, date, time zone, and sync state.

Command Description
timedatectl Show current time, time zone, and NTP status
timedatectl status Show the same formatted status explicitly
timedatectl show Show status in key-value format
timedatectl show --property=Timezone --value Print the current time zone only
timedatectl show --property=NTPSynchronized --value Print whether the clock is synchronized

Time Zones

List and change time zone settings.

Command Description
timedatectl list-timezones List all available time zones
timedatectl list-timezones | grep -i berlin Filter the time zone list
sudo timedatectl set-timezone Europe/Berlin Set the system time zone
sudo timedatectl set-timezone Etc/UTC Switch the system back to UTC
timedatectl show --property=Timezone --value Confirm the current time zone in scripts

NTP Synchronization

Enable, disable, and inspect time synchronization.

Command Description
sudo timedatectl set-ntp true Enable automatic time sync
sudo timedatectl set-ntp false Disable automatic time sync
timedatectl timesync-status Show the current NTP server, offset, and poll interval
timedatectl show-timesync Show timesync details in key-value format
timedatectl show --property=NTP --value Show whether NTP is enabled

Set Time and Date

Disable NTP first when setting the clock manually.

Command Description
sudo timedatectl set-ntp false Turn off NTP before manual changes
sudo timedatectl set-time '2026-04-21 14:30:00' Set both date and time
sudo timedatectl set-time '14:30:00' Set the time only
sudo timedatectl set-time '2026-04-21' Set the date only
sudo timedatectl set-ntp true Re-enable NTP after manual changes

RTC and Hardware Clock

Control whether the RTC uses UTC or local time.

Command Description
timedatectl show --property=LocalRTC --value Check whether the RTC uses local time
sudo timedatectl set-local-rtc 0 Set the RTC to UTC
sudo timedatectl set-local-rtc 1 Set the RTC to local time
sudo timedatectl set-local-rtc 1 --adjust-system-clock Switch RTC mode and adjust the system clock
timedatectl Confirm the RTC in local TZ status line

Script-Friendly Output

Extract single values for shell scripts and automation.

Command Description
timedatectl show --property=Timezone --value Get the current time zone
timedatectl show --property=LocalRTC --value Get the RTC mode
timedatectl show --property=CanNTP --value Check whether NTP is supported
timedatectl show --property=NTP --value Check whether NTP is enabled
timedatectl show --property=NTPSynchronized --value Check whether the clock is synchronized

Remote and Container Use

Run timedatectl against another host or a local container.

Command Description
timedatectl -H user@server status Check time settings on a remote host over SSH
timedatectl -H root@server set-timezone UTC Change the remote host time zone
timedatectl -M mycontainer status Check time settings in a local container
timedatectl -M mycontainer show --property=Timezone --value Print the container time zone only

Quick Fixes

Use these when timedatectl does not behave as expected.

Command Description
sudo timedatectl set-ntp false Fix Automatic time synchronization is enabled before set-time
timedatectl timesync-status Check which server is syncing the clock
timedatectl --no-pager Print directly without opening a pager
sudo date -s '2026-04-21 14:30:00' Set the clock on non-systemd systems where timedatectl does not work

Related Guides

Use these articles for deeper explanations and step-by-step instructions.

Guide Description
How to Set or Change the Time Zone in Linux Change the system time zone with timedatectl, tzdata, or /etc/localtime
date Command in Linux Read and set the system clock with the traditional date command
journalctl Cheatsheet Inspect time sync and service logs from the systemd journal

每日一题-距离字典两次编辑以内的单词🟡

给你两个字符串数组 queries 和 dictionary 。数组中所有单词都只包含小写英文字母,且长度都相同。

一次 编辑 中,你可以从 queries 中选择一个单词,将任意一个字母修改成任何其他字母。从 queries 中找到所有满足以下条件的字符串:不超过 两次编辑内,字符串与 dictionary 中某个字符串相同。

请你返回 queries 中的单词列表,这些单词距离 dictionary 中的单词 编辑次数 不超过 两次 。单词返回的顺序需要与 queries 中原本顺序相同。

 

示例 1:

输入:queries = ["word","note","ants","wood"], dictionary = ["wood","joke","moat"]
输出:["word","note","wood"]
解释:
- 将 "word" 中的 'r' 换成 'o' ,得到 dictionary 中的单词 "wood" 。
- 将 "note" 中的 'n' 换成 'j' 且将 't' 换成 'k' ,得到 "joke" 。
- "ants" 需要超过 2 次编辑才能得到 dictionary 中的单词。
- "wood" 不需要修改(0 次编辑),就得到 dictionary 中相同的单词。
所以我们返回 ["word","note","wood"] 。

示例 2:

输入:queries = ["yes"], dictionary = ["not"]
输出:[]
解释:
"yes" 需要超过 2 次编辑才能得到 "not" 。
所以我们返回空数组。

 

提示:

  • 1 <= queries.length, dictionary.length <= 100
  • n == queries[i].length == dictionary[j].length
  • 1 <= n <= 100
  • 所有 queries[i] 和 dictionary[j] 都只包含小写英文字母。

暴力(Python/Java/C++/C/Go/JS/Rust)

对于每个 $q = \textit{queries}[i]$,遍历 $\textit{dictionary}$ 中的字符串 $s$,判断 $q$ 和 $s$ 是否至多有两个位置上的字母不同。

class Solution:
    def twoEditWords(self, queries: List[str], dictionary: List[str]) -> List[str]:
        ans = []
        for q in queries:
            for s in dictionary:
                if sum(x != y for x, y in zip(q, s)) <= 2:
                    ans.append(q)
                    break
        return ans
class Solution {
    public List<String> twoEditWords(String[] queries, String[] dictionary) {
        List<String> ans = new ArrayList<>();
        for (String q : queries) {
            for (String s : dictionary) {
                int cnt = 0;
                for (int i = 0; i < s.length() && cnt <= 2; i++) {
                    if (q.charAt(i) != s.charAt(i)) {
                        cnt++;
                    }
                }
                if (cnt <= 2) {
                    ans.add(q);
                    break;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    vector<string> twoEditWords(vector<string>& queries, vector<string>& dictionary) {
        vector<string> ans;
        for (auto& q : queries) {
            for (auto& s : dictionary) {
                int cnt = 0;
                for (int i = 0; i < s.size() && cnt <= 2; i++) {
                    if (q[i] != s[i]) {
                        cnt++;
                    }
                }
                if (cnt <= 2) {
                    ans.push_back(q);
                    break;
                }
            }
        }
        return ans;
    }
};
char** twoEditWords(char** queries, int queriesSize, char** dictionary, int dictionarySize, int* returnSize) {
    char** ans = malloc(queriesSize * sizeof(char*));
    *returnSize = 0;

    for (int i = 0; i < queriesSize; i++) {
        char* q = queries[i];
        for (int j = 0; j < dictionarySize; j++) {
            char* s = dictionary[j];
            int cnt = 0;
            for (int k = 0; s[k] && cnt <= 2; k++) {
                if (q[k] != s[k]) {
                    cnt++;
                }
            }
            if (cnt <= 2) {
                ans[(*returnSize)++] = q;
                break;
            }
        }
    }

    return ans;
}
func twoEditWords(queries, dictionary []string) (ans []string) {
for _, q := range queries {
next:
for _, s := range dictionary {
cnt := 0
for i := range s {
if q[i] != s[i] {
cnt++
if cnt > 2 {
continue next
}
}
}
ans = append(ans, q)
break
}
}
return
}
var twoEditWords = function(queries, dictionary) {
    const ans = [];
    for (const q of queries) {
        for (const s of dictionary) {
            let cnt = 0;
            for (let i = 0; i < s.length && cnt <= 2; i++) {
                if (q[i] !== s[i]) {
                    cnt++;
                }
            }
            if (cnt <= 2) {
                ans.push(q);
                break;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn two_edit_words(queries: Vec<String>, dictionary: Vec<String>) -> Vec<String> {
        let mut ans = vec![];
        for q in queries {
            for s in &dictionary {
                let mut cnt = 0;
                for (a, b) in q.bytes().zip(s.bytes()) {
                    if a != b {
                        cnt += 1;
                        if cnt > 2 {
                            break;
                        }
                    }
                }
                if cnt <= 2 {
                    ans.push(q);
                    break;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(qdn)$,其中 $q$ 是 $\textit{queries}$ 的长度,$d$ 是 $\textit{dictionary}$ 的长度,$n$ 是 $\textit{queries}[i]$ 的长度。题目保证所有字符串长度相等。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

随便做

解题思路

代码

###python3

class Solution:
    def twoEditWords(self, queries: List[str], dictionary: List[str]) -> List[str]:
        def check(x,y):
            t=0
            for i in range(len(x)):
                if x[i]!=y[i]:
                    t+=1
            return t<=2
        covered=set()
        lst=[]
        for i in dictionary:
            for j in range(len(queries)):
                t=queries[j]
                if j not in covered and check(t,i):
                    covered.add(j)
                    lst.append(j)
        lst.sort()
        return [queries[i] for i in lst]

一行不解释

class Solution:
    def twoEditWords(self, Q: List[str], D: List[str], f = lambda w, D: min(sum(a != b for a, b in zip(w, t)) for t in D)) -> List[str]:
        return [w for w in Q if f(w, D) <= 2]

鼠标跟随倾斜动效

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。

Claude Code 源码分析 — Tool/MCP/Skill 可扩展工具系统

Claude Code 源码分析系列文章:

本文基于项目实际源码,深入分析 Claude Code 的工具系统架构。涵盖 Tool 类型定义、工具注册与发现、执行管道、并发控制、MCP 协议集成、Skill/Command 体系及 SkillTool 模型驱动调用的完整链路。


一、架构总览

Claude Code 的工具系统是一个三层可扩展架构:内置工具 (Built-in Tools) 提供文件读写、Bash 执行等基础能力;MCP 工具 通过标准协议接入外部服务;Skill/Command 提供用户可定义的高级行为模板。三者通过统一的 Tool 接口抽象,共享同一套注册、发现、权限、执行管道。

flowchart TD
    subgraph 定义层
        direction LR
        A["Tool&lt;Input, Output, P&gt;<br/>src/Tool.ts"]
        B["buildTool() 工厂<br/>应用默认值"]
    end

    subgraph 注册层
        direction LR
        C["getAllBaseTools()<br/>61+ 内置工具"]
        D["MCP Client<br/>mcp__server__tool"]
        E["Skill Loader<br/>managed/user/project"]
    end

    subgraph 过滤层
        direction LR
        F["filterToolsByDenyRules()"]
        G["getTools() — isEnabled 过滤"]
        H["assembleToolPool() — 合并去重"]
    end

    subgraph 执行层
        direction LR
        I["runToolUse() — 入口"]
        J["checkPermissionsAndCallTool()<br/>9 阶段管道"]
        K["StreamingToolExecutor<br/>并发 + 有序产出"]
    end

    A --> B
    B --> C
    D --> H
    E --> H
    C --> F --> G --> H
    H --> I --> J
    J --> K

核心设计原则:

  1. 统一接口:所有工具(内置 / MCP / Skill)都实现同一个 Tool 泛型接口
  2. 权限前置:工具执行前必须通过多层权限检查(配置规则 → Hooks → 用户确认)
  3. 并发安全:通过 isConcurrencySafe 标记控制工具并发策略
  4. 可扩展:MCP 协议和 Skill 目录允许用户自行扩展工具集

二、Tool 类型系统

源码位置:src/Tool.ts

Tool 类型系统是整个工具架构的基石,由四个核心类型组成。

2.1 Tool<Input, Output, P> — 核心泛型

Tool 是所有工具的统一接口,定义了约 40 个属性和方法:

// src/Tool.ts:362
export type Tool<
  Input extends AnyObject = AnyObject,
  Output = unknown,
  P extends ToolProgressData = ToolProgressData,
> = {
  // ========= 身份标识 =========
  name: string                                                 // 唯一名称,如 "Bash", "mcp__ide__getDiagnostics"
  aliases?: string[]                                           // 别名,兼容旧名称
  userFacingName: (input: Input) => string                     // UI 显示名
  userFacingNameBackgroundColor?(                              // UI 显示名称的颜色
    input: Partial<z.infer<Input>> | undefined,
  ): keyof Theme | undefined

  // ========= Schema =========
  inputSchema: Input                                           // Zod schema,用于输入验证
  outputSchema?: z.ZodType<unknown>                            // 输出类型(可选)

  // ========= 元信息 =========
  description(
    input: z.infer<Input>,
    options: {
      isNonInteractiveSession: boolean
      toolPermissionContext: ToolPermissionContext
      tools: Tools
    },
  ): Promise<string>                                           // 工具描述(送入 system prompt)
  prompt(options: {
    getToolPermissionContext: () => Promise<ToolPermissionContext>
    tools: Tools
    agents: AgentDefinition[]
    allowedAgentTypes?: string[]
  }): Promise<string>                                          // 使用提示
  searchHint?: string                                          // 工具搜索的额外匹配词

  // ========= 能力标记 =========
  isEnabled(): boolean                                         // 当前环境是否可用
  isReadOnly(): boolean                                        // 是否只读(影响权限策略)
  isDestructive?(): boolean                                    // 是否破坏性操作
  isConcurrencySafe(input: z.infer<Input>): boolean            // 是否可并发执行
  isSearchOrReadCommand?(input: z.infer<Input>): {             // 是否查询类工具
    isSearch: boolean
    isRead: boolean
    isList?: boolean
  }                                                             
  isOpenWorld?(input: z.infer<Input>): boolean                 // 输入是否来自外部

  // ========= 执行 =========
  call(
    args: z.infer<Input>,
    context: ToolUseContext,
    canUseTool: CanUseToolFn,
    parentMessage: AssistantMessage,
    onProgress?: ToolCallProgress<P>,
  ): Promise<ToolResult<Output>>              // 核心执行函数

  // ========= 权限 =========
  async checkPermissions(
    input: z.infer<Input>,
    context: ToolUseContext,
  ): Promise<PermissionResult>                  // 权限检查

  validateInput?(
    input: Input,
    context: ToolUseContext,
  ): Promise<ValidationResult>                  // 业务级输入校验

  // ========= 中断策略 =========
  interruptBehavior?(): 'cancel' | 'block'

  // ========= UI 渲染 =========
  renderToolUseMessage(props): React.ReactNode
  renderToolResultMessage(props): React.ReactNode
  renderToolUseProgressMessage?(props): React.ReactNode

  // ========= 高级特性 =========
  maxResultSizeChars?: number              // 结果截断阈值
  strict?: boolean                         // 严格模式
  isMcp?: boolean                          // 标记为 MCP 工具
  isLsp?: boolean                          // 标记为 LSP 工具
  shouldDefer?: boolean                    // 延迟加载(工具搜索时才启用)
  alwaysLoad?: boolean                     // 始终加载
  mcpInfo?: { serverName: string; toolName: string }  // MCP 来源信息
  maxResultSizeChars: number               // 工具最长输出,超出原始内容存储到本地文件,返回特定提示词及压缩后的结果。默认50_000

  backfillObservableInput?(                // 对输入做浅拷贝供 hooks 观察
    input: Input,
  ): Record<string, unknown> | undefined

  // ...
}

2.2 ToolResult — 工具返回值

工具执行完成后返回的统一结构:

// src/Tool.ts:321-336
export type ToolResult<T> = {
  data: T                          // 实际输出数据
  newMessages?: (                  // 注入额外消息到对话流
    | UserMessage
    | AssistantMessage
    | AttachmentMessage
    | SystemMessage
  )[]
  contextModifier?: (              // 修改后续工具的上下文
    context: ToolUseContext,
  ) => ToolUseContext
  mcpMeta?: {                      // MCP 元数据
    _meta?: Record<string, unknown>
    structuredContent?: Record<string, unknown>
  }
}

contextModifier 是一个精妙的设计:工具可以通过返回值修改后续执行的上下文。例如 EnterPlanModeTool 执行后可通过 contextModifier 切换权限模式。

2.3 ToolUseContext — 执行上下文

// src/Tool.ts:158
export type ToolUseContext = {
  options: {
    commands: Command[]           // 可用命令列表
    tools: Tools                  // 可用工具列表
    mcpClients: MCPServerConnection[]   // MCP 连接
    mcpResources: Record<string, ServerResource[]>
    mainLoopModel: string         // 主循环模型
    thinkingConfig: ThinkingConfig  // 思考模式
    agentDefinitions: AgentDefinitionsResult
    maxBudgetUsd?: number
    querySource?: QuerySource    
    refreshTools?: () => Tools    // 动态刷新工具列表
    // ...
  }
  abortController: AbortController     // 中止控制器
  readFileState: FileStateCache        // 文件状态缓存
  getAppState(): AppState              // 读取全局状态
  setAppState(f: (prev: AppState) => AppState): void  // 修改全局状态
  requestPrompt?: PermissionRequestFn  // 请求用户权限
  // ...
}

2.4 ToolPermissionContext — 权限上下文

// src/Tool.ts:123-138
export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode                    // 'default' | 'plan' | ...
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource   // 白名单规则
  alwaysDenyRules: ToolPermissionRulesBySource    // 黑名单规则
  alwaysAskRules: ToolPermissionRulesBySource     // 始终询问规则
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource
  shouldAvoidPermissionPrompts?: boolean
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode
}>

DeepImmutable 保证权限上下文在传递过程中不可被意外修改,是安全性的重要保障。

2.5 buildTool() — 工厂函数与默认值

// src/Tool.ts (TOOL_DEFAULTS)
const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: () => false,    // 默认不可并发
  isReadOnly: () => false,
  isDestructive: () => false,
  isOpenWorld: () => false,
  // ...
}
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>
}

buildTool() 将用户定义与默认值合并,确保每个工具都有完整的接口实现。工具作者只需关注核心逻辑(nameinputSchemacall()等),其余属性自动填充。

2.6 工具查找辅助函数

// src/Tool.ts:348
export function toolMatchesName(
  tool: { name: string; aliases?: string[] },
  name: string,
): boolean {
  return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

export function findToolByName(
  tools: Tools,
  name: string,
): Tool | undefined {
  return tools.find(t => toolMatchesName(t, name))
}

别名机制允许工具改名时保持向后兼容(如旧版工具名 → 新名称映射)。


三、工具注册与发现

源码位置:src/tools.ts

工具注册是一条多级过滤管道:从全量工具列表开始,逐步筛选出当前环境可用的工具集。

3.1 getAllBaseTools() — 全量工具清单

// src/tools.ts:191
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    // 嵌入式搜索工具可用时跳过 Glob/Grep
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    ExitPlanModeV2Tool,
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    NotebookEditTool,
    WebFetchTool,
    TodoWriteTool,
    WebSearchTool,
    TaskStopTool,
    AskUserQuestionTool,
    SkillTool,
    EnterPlanModeTool,
    // 条件加载 —— 基于环境变量
    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
    ...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
    ...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
    // 条件加载 —— 基于 feature flag
    ...(WebBrowserTool ? [WebBrowserTool] : []),
    ...(isTodoV2Enabled()
      ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
      : []),
    ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
    ...(SleepTool ? [SleepTool] : []),
    ...cronTools,
    ...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
    BriefTool,
    // 测试专用
    ...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
    ListMcpResourcesTool,
    ReadMcpResourceTool,
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
  ]
}

工具按以下维度条件加载:

条件类型 示例 机制
Feature Flag WebBrowserToolSleepTool feature('FLAG_NAME') + 运行时 require()
环境变量 ConfigToolTungstenTool process.env.USER_TYPE === 'ant'
运行时检测 GlobToolGrepToolTaskCreateTool hasEmbeddedSearchTools()isTodoV2Enabled()

3.2 过滤管道

flowchart LR
    A["getAllBaseTools()<br/>30+ 工具"] --> B["filterToolsByDenyRules()<br/>配置黑名单"]
    B --> C["isEnabled() 检查<br/>环境可用性"]
    C --> D["assembleToolPool()<br/>合并 MCP 工具"]

filterToolsByDenyRules() — 配置级黑名单

// src/tools.ts:260
export function filterToolsByDenyRules<T extends {
  name: string
  mcpInfo?: { serverName: string; toolName: string }
}>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

该函数不仅匹配工具精确名称,还支持 MCP 前缀规则:配置 mcp__server 可以一次性屏蔽整个 MCP Server 的所有工具。

getTools() — 完整过滤链

// src/tools.ts:269-325
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
  // 1. 简单模式:只保留 Bash/Read/Edit
  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
    const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]
    return filterToolsByDenyRules(simpleTools, permissionContext)
  }

  // 2. 排除特殊工具(MCP Resources、Synthetic Output)
  const specialTools = new Set([
    ListMcpResourcesTool.name,
    ReadMcpResourceTool.name,
    SYNTHETIC_OUTPUT_TOOL_NAME,
  ])
  const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))

  // 3. 应用黑名单过滤
  let allowedTools = filterToolsByDenyRules(tools, permissionContext)

  // 4. REPL 模式下隐藏被 REPL 包装的原始工具
  if (isReplModeEnabled()) {
    const replEnabled = allowedTools.some(
      tool => toolMatchesName(tool, REPL_TOOL_NAME),
    )
    if (replEnabled) {
      allowedTools = allowedTools.filter(
        tool => !REPL_ONLY_TOOLS.has(tool.name),
      )
    }
  }

  // 5. isEnabled() 检查
  const isEnabled = allowedTools.map(_ => _.isEnabled())
  return allowedTools.filter((_, i) => isEnabled[i])
  // 这段代码给我看懵了,不知道是不是AI写的,可以简写为 allowedTools.filter(_ => _.isEnabled())
}

3.3 assembleToolPool() — 内置工具与 MCP 工具合并

// src/tools.ts:343-365
export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext)
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

  // 分区排序:内置工具在前,MCP 工具在后
  // 保证 prompt cache 稳定性
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  )
}

排序策略的关键考量:API 端对 system prompt 做了 cache 分段(claude_code_system_cache_policy),将 cache 断点放在最后一个内置工具之后。如果用简单的全局排序,MCP 工具会穿插到内置工具之间,导致每次 MCP 工具变化都使所有下游 cache key 失效。分区排序 + uniqBy 保证:

  1. 内置工具始终形成稳定的前缀块
  2. 同名冲突时内置工具优先(uniqBy 保留第一个)
  3. MCP 工具在后缀块中独立排序

四、工具执行管道

源码位置:src/services/tools/toolExecution.ts

工具执行是一条 9 阶段的异步管道,从模型返回的 tool_use 块开始,到工具结果注入对话流结束。

4.1 runToolUse() — 管道入口

// src/services/tools/toolExecution.ts:337
export async function* runToolUse(
  toolUse: ToolUseBlock,
  assistantMessage: AssistantMessage,
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<Message> {
  const toolName = toolUse.name

  // 1. 在当前工具列表中查找
  let tool = findToolByName(toolUseContext.options.tools, toolName)

  // 2. 回退:从全量工具列表中按别名查找
  if (!tool) {
    const fallbackTool = findToolByName(getAllBaseTools(), toolName)
    if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
      tool = fallbackTool
    }
  }

  // 3. 工具未找到 → 返回错误消息
  if (!tool) {
    yield createToolResultMessage(/* error: tool not found */)
    return
  }

  // 4. 中止检查
  if (toolUseContext.abortController.signal.aborted) {
    yield createToolResultMessage(/* error: aborted */)
    return
  }

  // 5. 委托给 streamedCheckPermissionsAndCallTool
  for await (const update of streamedCheckPermissionsAndCallTool(
      tool,
      toolUse.id,
      toolInput,
      toolUseContext,
      canUseTool,
      assistantMessage,
      messageId,
      requestId,
      mcpServerType,
      mcpServerBaseUrl,
    )) {
      yield update
    }
}

别名回退机制是一个防御性设计:即使工具被重命名或从当前列表中移除,模型仍可能使用旧名称调用。通过 aliases 字段实现平滑迁移。

4.2 checkPermissionsAndCallTool() — 9 阶段管道

flowchart TD
    A["① Zod safeParse<br/>类型校验"] --> B["② validateInput<br/>业务校验"]
    B --> C["③ 推测性分类<br/>(仅 Bash)"]
    C --> D["④ 剥离内部字段<br/>(安全防御)"]
    D --> E["⑤ backfillObservableInput<br/>浅拷贝供 hooks"]
    E --> F["⑥ PreToolUse Hooks<br/>外部拦截"]
    F --> G["⑦ resolveHookPermission<br/>综合决策"]
    G --> H{"权限通过?"}
    H -->|是| I["⑧ tool.call()<br/>实际执行"]
    H -->|否| J["⑨ 权限拒绝处理<br/>PermissionDenied Hooks"]
    I --> K["PostToolUse Hooks"]

    style A fill:#e8f5e9
    style F fill:#fff3e0
    style I fill:#e1f5fe
    style J fill:#ffebee

阶段 ①:Zod Schema 校验

// src/services/tools/toolExecution.ts:615
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  // 返回格式化的 Zod 错误信息
  yield createToolResultMessage(/* schema validation error */)
  return
}

所有工具的 inputSchema 都是 Zod schema,在执行前自动校验输入类型。模型生成的 JSON 如果不符合 schema(如缺少必填字段、类型不匹配),会直接返回错误消息给模型。

阶段 ②:业务级校验

// src/services/tools/toolExecution.ts:683
const validationError = tool.validateInput?.(parsedInput.data, toolUseContext)
if (validationError) {
  yield createToolResultMessage(/* validation error */)
  return
}

validateInput 提供了 schema 之外的业务校验。例如 BashTool 可以在这里检查命令是否包含危险操作,FileEditTool 可以验证文件路径是否在工作目录范围内。

阶段 ③:推测性分类(Bash 专用)

// src/services/tools/toolExecution.ts:740-752
// 仅对 Bash 工具启动并行分类检查
const speculativeResult = startSpeculativeClassifierCheck(/*...*/)

在等待用户权限确认的同时,对 Bash 命令预分类(安全/危险),减少用户感知延迟。

阶段 ④:内部字段剥离(安全防御)

// src/services/tools/toolExecution.ts:761-773
// Defense-in-depth: 剥离 _simulatedSedEdit 等内部字段
// 防止模型注入内部控制参数

这是一个纵深防御措施:即使模型在输入中包含了内部控制字段,也会在执行前被清除。

阶段 ⑤:backfillObservableInput

// src/services/tools/toolExecution.ts:784-793
const observableInput = tool.backfillObservableInput?.(parsedInput.data)

创建输入的浅拷贝,供 PreToolUse hooks 观察。这样 hooks 可以读取完整的工具输入,但无法修改原始数据。

Hooks机制参考官方文档

阶段 ⑥:PreToolUse Hooks

// src/services/tools/toolExecution.ts:800-862
for await (const hookResult of runPreToolUseHooks(/*...*/)) {
  // hookResult 可能包含:
  // - hookPermissionResult: 权限决策
  // - hookUpdatedInput: 修改后的输入
  // - preventContinuation: 阻止继续
  // - stop: 停止
  // - additionalContext: 附加上下文
}

PreToolUse hooks 是用户自定义的 shell 脚本,在工具执行前运行。它们可以:

  • 修改输入:例如在文件路径前添加前缀
  • 注入上下文:向对话流添加额外信息
  • 阻止执行:返回权限拒绝
  • 完全停止:取消整个工具调用

阶段 ⑦:权限综合决策

// src/services/tools/toolExecution.ts:921-931
const resolved = await resolveHookPermissionDecision(
  hookPermissionResult,
  tool,
  processedInput,
  toolUseContext,
  canUseTool,
  assistantMessage,
  toolUseID,
)
const permissionDecision = resolved.decision
processedInput = resolved.input

综合三个来源的权限判断:Hook 的决策、推测性分类结果、配置文件规则,得出最终的权限决定。

用户授权也是在此过程中拉起的

claude-code源码分析-Tool-MCP-Skill可扩展工具系统_2026-04-20-17-23-15.png

阶段 ⑧/⑨:执行或拒绝

权限拒绝时创建错误消息,并触发 PermissionDenied hooks。权限通过后调用 tool.call(),执行完成后运行 PostToolUse hooks。

如果最终获取的权限不为allow,则会构建一些消息告诉模型并返回,比如上诉截图里我选择了No,则:

// src/services/tools/toolExecution.ts:1064-1071
resultingMessages.push({
  message: createUserMessage({
    content: messageContent, // "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
    imagePasteIds: rejectImageIds,
    toolUseResult: `Error: ${errorMessage}`,
    sourceToolAssistantUUID: assistantMessage.uuid,
  }),
})

模型收到消息后则会走对话中止流程,参考核心对话循环

// 执行路径 (src/services/tools/toolExecution.ts:1207)
const result = await tool.call(
  callInput,
  {
    ...toolUseContext,
    toolUseId: toolUseID,
    userModified: permissionDecision.userModified ?? false,
  },
  canUseTool,
  assistantMessage,
  progress => { // 更新工具执行过程
    onToolProgress({
      toolUseID: progress.toolUseID,
      data: progress.data,
    })
  },
)

// 注入 newMessages
resultingMessages.push({
  message: createUserMessage({
    content: contentBlocks,
    imagePasteIds: allowImageIds,
    toolUseResult:
      toolUseContext.agentId && !toolUseContext.preserveToolUseResults
        ? undefined
        : toolUseResult,
    mcpMeta: toolUseContext.agentId ? undefined : mcpMeta,
    sourceToolAssistantUUID: assistantMessage.uuid,
  }),
  // 处理 contextModifier,修改后续上下文
  contextModifier: toolContextModifier
    ? {
        toolUseID: toolUseID,
        modifyContext: toolContextModifier,
      }
    : undefined,
})

// 运行 PostToolUse hooks
for await (const hookResult of runPostToolUseHooks(/*...*/)) {
  //...
}

五、StreamingToolExecutor 并发执行

源码位置:src/services/tools/StreamingToolExecutor.ts

当模型在一个响应中返回多个 tool_use 块时,StreamingToolExecutor 负责决定哪些工具可以并行执行、哪些必须串行排队。

5.1 TrackedTool 状态模型

TrackedTool通过执行工具的isConcurrencySafe获得当前工具是否支持并行。

// src/services/tools/StreamingToolExecutor.ts:21-32
type TrackedTool = {
  id: string                         // tool_use block ID
  block: ToolUseBlock                // 工具调用块
  assistantMessage: AssistantMessage  // 所属的 assistant 消息
  status: ToolStatus                 // 'pending' | 'executing' | 'done' | 'error'
  isConcurrencySafe: boolean         // 并发安全标记
  promise?: Promise<void>            // 执行 Promise
  results?: Message[]                // 执行结果
  pendingProgress: Message[]         // 进度消息缓冲
  contextModifiers?: Array<          // 上下文修改器
    (context: ToolUseContext) => ToolUseContext
  >
}

5.2 并发策略:canExecuteTool()

// src/services/tools/StreamingToolExecutor.ts:129-135
private canExecuteTool(isConcurrencySafe: boolean): boolean {
  // 当前执行中的工具,支持并发的工具,即使执行中的工具不为0,也返回true;串行时仅当前执行中的工具为0时返回true;
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}
// 此函数会在每次executeTool结束后调用,直到队列中所有tools都不为`queued`
private async processQueue(): Promise<void> {
  for (const tool of this.tools) {
    // 如果当前工具非排队,直接轮询
    if (tool.status !== 'queued') continue
    if (this.canExecuteTool(tool.isConcurrencySafe)) {
      // 执行工具
      await this.executeTool(tool)
    } else {
      if (!tool.isConcurrencySafe) break
    }
  }
}
private async executeTool(tool: TrackedTool): Promise<void> {
  // ...
  const promise = collectResults()
  tool.promise = promise

  // 每个工具执行结束都再次调用`processQueue`
  void promise.finally(() => {
    void this.processQueue()
  })
}


Claude Code的并发设计并不复杂,支持并行的工具会在第一次执行的时候全部推入processQueue执行队列,不支持并行的工具会在最后一个并行工具执行完后再推入processQueue队列,直至所有工具执行完。

  • 典型的并发安全工具:GlobToolGrepToolFileReadToolWebSearchTool
  • 典型的非并发安全工具:BashToolFileEditToolFileWriteTool

5.3 有序结果产出

即使工具并发执行,结果仍然按原始 tool_use 块的顺序产出:

// StreamingToolExecutor 的 processQueue 逻辑
// tools 数组维持原始顺序
// 每个 tool 执行完成后检查是否可以产出结果
// 只有前序工具都完成后,当前工具的结果才会被 yield

这保证了对话流中的消息顺序与模型生成的工具调用顺序一致。

5.4 兄弟中止机制

// src/services/tools/StreamingToolExecutor.ts:48
// siblingAbortController: 当一个 Bash 工具出错时,中止所有兄弟工具

private getAbortReason(tool: TrackedTool): string {
  // 检查中止原因:
  // - 用户中断 (user_interrupted)
  // - 兄弟工具错误 (sibling_error)
  // - 流式回退 (streaming_fallback)
}

当同一批次中的一个 BashTool 执行失败时,其他尚未完成的工具会被中止。中止原因被记录到 createSyntheticErrorMessage() 中,产出一个合成的错误消息告知模型。

// src/services/tools/StreamingToolExecutor.ts:153
private createSyntheticErrorMessage(
  toolUseId: string,
    reason: 'sibling_error' | 'user_interrupted' | 'streaming_fallback',
    assistantMessage: AssistantMessage,
): Message {
  // 生成描述性错误消息,让模型理解为什么工具被取消
}

六、MCP 系统

源码位置:src/services/mcp/client.tssrc/tools/MCPTool/MCPTool.ts

MCP (Model Context Protocol) 是 Anthropic 定义的标准协议,允许外部服务向 Claude 提供工具、资源和提示。Claude Code 实现了完整的 MCP 客户端。

6.1 四种传输协议

// src/services/mcp/client.ts
// 支持四种 MCP 传输方式
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
// WebSocketTransport 也被支持
import { WebSocketTransport } from '../../utils/mcpWebSocketTransport.js'

传输方式 适用场景 配置方式
stdio 本地进程 command + args
SSE HTTP 长连接 url (带 /sse 后缀)
StreamableHTTP HTTP 流式 url (非 /sse)
WebSocket 全双工 url (ws:// 或 wss://)

6.2 MCPTool 模板 — 工具包装

MCP工具也是通过buildTool进行包装,方便实现

// src/tools/MCPTool/MCPTool.ts:27-77
export const MCPTool = buildTool({
  isMcp: true,
  // 以下属性在 mcpClient.ts 中被运行时覆盖
  name: 'mcp',                           // → mcp__<server>__<tool>
  async description() { return DESCRIPTION },  // → MCP 服务端描述
  async prompt() { return PROMPT },
  get inputSchema() {
    return inputSchema()  // z.object({}).passthrough()
  },
  async call() { return { data: '' } },       // → 实际 MCP 调用
  async checkPermissions(): Promise<PermissionResult> {
    return { behavior: 'passthrough', message: 'MCPTool requires permission.' }
  },
  maxResultSizeChars: 100_000,
  userFacingName: () => 'mcp',                 // → 实际工具名
  //...
})

MCPTool 本身是一个空壳模板。关键属性(namedescriptioncallinputSchema)在 mcpClient.tsconnectToServer() 中被运行时覆盖。

6.3 工具命名约定

// src/services/mcp/mcpStringUtils.ts:39
export function getMcpPrefix(serverName: string): string {
  return `mcp__${normalizeNameForMCP(serverName)}__`
}
export function buildMcpToolName(serverName: string, toolName: string): string {
  return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
}

命名格式:mcp__<serverName>__<toolName>

例如:

  • mcp__ide__getDiagnostics — IDE MCP Server 的诊断工具
  • mcp__filesystem__readFile — 文件系统 MCP Server 的读文件工具

这种命名方式使得 filterToolsByDenyRules() 可以通过前缀 mcp__ide 一次性屏蔽整个 Server 的所有工具。

6.4 连接管理

flowchart TD
    A["getMcpToolsCommandsAndResources<br/>(useManageMCPConnections.ts:894)"] --> B["connectToServer<br/>(mcp/client.ts:596)"]
    B --> C["onConnectionAttempt<br/>更新 appState<br/>(useManageMCPConnections.ts:310)"]
    C --> D["useMergedTools<br/>(REPL.tsx:1034)"]
    D --> E["assembleToolPool<br/>合并工具"]

当Claude Code启动后就会进行MCP的连接,连接成功后通过更新appState使REPL进行工具合并。

核心连接逻辑在connectToServer

// src/services/mcp/client.ts:596
// memoize 确保同一个 server 只建立一次连接
export const connectToServer = memoize(async function connectToServer(
  serverConfig: MCPServerConfig,
  // ...
): Promise<MCPServerConnection> {
  // 1. 选择传输方式
  // 2. 建立连接
  // 3. 获取工具列表
  // 4. 为每个工具创建 MCPTool 实例(覆盖模板属性)
  // 5. 返回连接对象
})

关键常量:

  • DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000(约 27.8 小时)— MCP 工具默认超时
  • MAX_MCP_DESCRIPTION_LENGTH = 2048 — 描述截断阈值

6.5 MCP 工具与内置工具的合并

MCP 工具在 assembleToolPool() 中与内置工具合并(见 3.3 节)。

这意味着如果一个 MCP 工具的名称与内置工具冲突,内置工具会胜出。


七、Command 命令系统

源码位置:src/types/command.tssrc/commands.tssrc/utils/processUserInput/processSlashCommand.tsx

Command(命令)是 Claude Code 的用户交互入口,用户通过在终端输入 /command 触发各种操作。Command 系统独立于 Tool 系统——Tool 由模型调用,而 Command 由用户直接调用。两者通过 SkillTool 产生交集:Command既可通过 / 触发,也可被模型通过 SkillTool 调用。我们经常说的Skill也是Command的一种实现。

7.1 Command 类型体系

// src/types/command.ts:205
export type Command = CommandBase &
  (PromptCommand | LocalCommand | LocalJSXCommand)

Command 由公共基类 CommandBase 和三种具体类型的联合组成。

CommandBase — 公共属性

// src/types/command.ts:175-203
export type CommandBase = {
  availability?: CommandAvailability[]    // 可用环境('claude-ai' | 'console')
  description: string                     // 命令描述
  isEnabled?: () => boolean               // 运行时启用条件(默认 true)
  isHidden?: boolean                      // 是否从补全/帮助中隐藏
  name: string                            // 唯一标识(如 'clear'、'config')
  aliases?: string[]                      // 别名(如 clear 的别名 ['reset', 'new'])
  whenToUse?: string                      // 模型调用时机描述
  disableModelInvocation?: boolean        // 是否禁止模型调用
  userInvocable?: boolean                 // 用户是否可通过 / 触发
  loadedFrom?: LoadedFrom                 // 来源标记
  immediate?: boolean                     // 是否立即执行(不等待队列)
  // ...
}

LoadedFrom — 来源标记

// src/skills/loadSkillsDir.ts:67-74
type LoadedFrom =
  | 'commands_DEPRECATED'  // 旧版 .claude/commands/
  | 'skills'              // .claude/skills/
  | 'plugin'              // 插件目录
  | 'managed'             // managed skills (系统管理)
  | 'bundled'             // 内置打包
  | 'mcp'                 // MCP 协议提供

三种命令类型

类型 触发方式 返回值 典型示例
LocalCommand 用户 /command 文本结果 /clear/compact/files
LocalJSXCommand 用户 /command React 组件 /config/status/help
PromptCommand 用户 /skill 或模型 SkillTool 注入对话流 用户定义的 .md 技能

LocalCommand — 纯文本命令:

// src/types/command.ts:74-78
type LocalCommand = {
  type: 'local'
  supportsNonInteractive: boolean          // 是否支持非交互模式
  load: () => Promise<LocalCommandModule>  // 懒加载实现
}

// LocalCommandModule 接口
type LocalCommandModule = {
  call: (args: string, context: LocalJSXCommandContext) => Promise<LocalCommandResult>
}

// 返回值三种形态
type LocalCommandResult =
  | { type: 'text'; value: string }        // 普通文本输出
  | { type: 'compact'; compactionResult: CompactionResult }  // 压缩操作
  | { type: 'skip' }                       // 静默执行

LocalJSXCommand — UI 渲染命令:

// src/types/command.ts:144
type LocalJSXCommand = {
  type: 'local-jsx'
  load: () => Promise<LocalJSXCommandModule>
}

// 调用签名
type LocalJSXCommandCall = (
  onDone: LocalJSXCommandOnDone,           // 完成回调
  context: ToolUseContext & LocalJSXCommandContext,
  args: string,
) => Promise<React.ReactNode>              // 返回 JSX 渲染到终端

onDone 回调控制命令完成后的行为:

// src/types/command.ts:117-126
type LocalJSXCommandOnDone = (
  result?: string,
  options?: {
    display?: 'skip' | 'system' | 'user'  // 结果展示方式
    shouldQuery?: boolean                   // 是否继续查询模型
    metaMessages?: string[]                 // 注入隐藏消息
    nextInput?: string                      // 下一轮自动输入
    submitNextInput?: boolean               // 是否自动提交
  },
) => void

PromptCommand — 提示词命令(即 Skill):

// src/types/command.ts:25-57
type PromptCommand = {
  type: 'prompt'
  progressMessage: string                  // 加载时的进度消息
  contentLength: number                    // 内容长度(用于 token 估算)
  argNames?: string[]                      // 参数名列表
  allowedTools?: string[]                  // 限制可用工具
  model?: string                           // 指定模型
  context?: 'inline' | 'fork'             // 执行上下文 inline为当前对话执行  fork为子agent里执行
  agent?: string                           // fork 时使用的 agent 类型
  effort?: EffortValue                     // 推理深度
  paths?: string[]                         // 条件触发的文件路径 glob
  getPromptForCommand(                     // 生成 prompt 内容
    args: string,
    context: ToolUseContext,
  ): Promise<ContentBlockParam[]>
}

7.2 命令注册机制

所有内置命令通过 COMMANDS() 工厂函数注册,采用 memoize 确保只初始化一次:

// src/commands.ts:258
const COMMANDS = memoize((): Command[] => [
  addDir,
  agents,
  branch,
  clear,           // type: 'local'
  compact,         // type: 'local'
  config,          // type: 'local-jsx'
  help,            // type: 'local-jsx'
  status,          // type: 'local-jsx'
  // ... 更多内置命令
  // 条件加载
  ...(webCmd ? [webCmd] : []),
  ...(voiceCommand ? [voiceCommand] : []),
  ...(process.env.USER_TYPE === 'ant' ? INTERNAL_ONLY_COMMANDS : []),
])

每个内置命令是一个简洁的描述符对象,实现代码通过 load() 懒加载:

// src/commands/clear/index.ts
const clear = {
  type: 'local',
  name: 'clear',
  description: 'Clear conversation history and free up context',
  aliases: ['reset', 'new'],
  supportsNonInteractive: false,
  load: () => import('./clear.js'),     // 懒加载
} satisfies Command

// src/commands/config/index.ts
const config = {
  aliases: ['settings'],
  type: 'local-jsx',
  name: 'config',
  description: 'Open config panel',
  load: () => import('./config.js'),
} satisfies Command

7.3 命令发现:getCommands()

getCommands() 是最终的命令列表组装函数,合并来自多个来源的命令:

// src/commands.ts:451-471
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  const [
    { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
    pluginCommands,
    workflowCommands,
  ] = await Promise.all([
    getSkills(cwd),           // Skill 目录
    getPluginCommands(),      // 插件命令
    getWorkflowCommands?.(cwd) ?? Promise.resolve([]),  // 工作流命令
  ])

  return [
    ...bundledSkills,         // 内置打包技能
    ...builtinPluginSkills,   // 内置插件技能
    ...skillDirCommands,      // 技能目录
    ...workflowCommands,      // 工作流
    ...pluginCommands,        // 插件
    ...pluginSkills,          // 插件技能
    ...COMMANDS(),            // 内置命令(最后)
  ]
})

最终过滤和动态技能合并:

// src/commands.ts:478-519
export async function getCommands(cwd: string): Promise<Command[]> {
  const allCommands = await loadAllCommands(cwd)
  const dynamicSkills = getDynamicSkills()  // 运行时动态发现的技能

  // 过滤:可用性 + 启用状态
  const baseCommands = allCommands.filter(
    _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
  )

  // 动态技能去重后插入到内置命令之前
  const builtInNames = new Set(COMMANDS().map(c => c.name))
  const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
  return [
    ...baseCommands.slice(0, insertIndex),
    ...uniqueDynamicSkills,
    ...baseCommands.slice(insertIndex),
  ]
}

合并优先级(先出现的同名命令胜出):bundledSkills > builtinPluginSkills > skillDirCommands > workflowCommands > pluginCommands > pluginSkills > COMMANDS()

7.4 斜杠命令执行流程

用户输入 /command args 后的完整处理链路:

flowchart TD
    A["用户输入 /command args"] --> B["processUserInput()"]
    B --> C{"以 / 开头?"}
    C -->|是| D["processSlashCommand()"]
    C -->|否| E["processTextPrompt()<br/>普通对话"]
    D --> F["parseSlashCommand()<br/>解析命令名和参数"]
    F --> G{"找到命令?"}
    G -->|否| H["返回 Unknown skill 错误"]
    G -->|是| I["getMessagesForSlashCommand()"]
    I --> J{"command.type"}
    J -->|local| K["command.load().call()<br/>同步执行,返回文本"]
    J -->|local-jsx| L["command.load().call(onDone)<br/>渲染 JSX,等待 onDone"]
    J -->|prompt| M{"context === 'fork'?"}
    M -->|是| N["executeForkedSlashCommand()<br/>子 Agent 执行"]
    M -->|否| O["getMessagesForPromptSlashCommand()<br/>展开 prompt 注入对话"]

local 类型的执行

// src/utils/processUserInput/processSlashCommand.tsx:860-949
case 'local': {
  const mod = await command.load()
  const result = await mod.call(args, context)

  if (result.type === 'skip') {
    return { messages: [], shouldQuery: false, command }
  }
  // 结果包装为 <local-command-stdout> 标签
  return {
    messages: [
      userMessage,
      createCommandInputMessage(
        `<local-command-stdout>${result.value}</local-command-stdout>`,
      ),
    ],
    shouldQuery: false,     // local 命令不触发模型查询
    command,
  }
}

local-jsx 类型的执行

// src/utils/processUserInput/processSlashCommand.tsx:732-859
case 'local-jsx': {
  return new Promise<SlashCommandResult>(resolve => {
    const onDone: LocalJSXCommandOnDone = (result, options) => {
      // 根据 display 选项决定结果展示方式
      // 'skip' → 无消息
      // 'system' → 系统消息
      // 'user' → 用户消息
      resolve({ messages, shouldQuery: options?.shouldQuery ?? false, command })
    }

    // 懒加载并执行,返回 JSX 渲染到终端
    command.load()
      .then(mod => mod.call(onDone, { ...context, canUseTool }, args))
      .then(jsx => {
        setToolJSX({
          jsx,
          shouldHidePromptInput: true,  // 隐藏输入框
          showSpinner: false,
          isLocalJSXCommand: true,
        })
      })
  })
}

prompt 类型的展开

// src/utils/processUserInput/processSlashCommand.tsx:1114-1262
async function getMessagesForPromptSlashCommand(command, args, context) {
  // 1. 调用 getPromptForCommand 生成内容
  const result = await command.getPromptForCommand(args, context)

  // 2. 构造元信息
  const metadata = formatCommandLoadingMetadata(command, args)

  // 3. 解析允许的工具列表
  const additionalAllowedTools = parseToolListFromCLI(command.allowedTools ?? [])

  // 4. 组装消息序列
  const messages = [
    createUserMessage({ content: metadata, uuid }),              // 元数据
    createUserMessage({ content: result, isMeta: true }),        // 技能内容(隐藏)
    ...attachmentMessages,                                       // 附件
    createAttachmentMessage({                                    // 权限声明
      type: 'command_permissions',
      allowedTools: additionalAllowedTools,
      model: command.model,
    }),
  ]
  return {
    messages,
    shouldQuery: true,          // prompt 命令触发模型查询
    allowedTools: additionalAllowedTools,
    model: command.model,
    effort: command.effort,
    command,
  }
}

local/local-jsx 不同,prompt 类型的 shouldQuerytrue——内容注入对话流后会触发模型响应。


八、Skill 技能系统

源码位置:src/skills/loadSkillsDir.ts

Skill(技能)是 PromptCommand 的具体实现形式,允许用户通过 Markdown 文件定义可复用的 prompt 模板。Skill 是 Command 系统与 Tool 系统的桥梁——它以 Command 的身份被用户 / 调用,也可以通过 SkillTool 被模型主动调用。

8.1 Skill 加载流程

flowchart TD
    A["getSkillDirCommands()"] --> B["并行加载 5 个来源"]

    B --> C["Managed Skills<br/>~/.claude/skills/managed/"]
    B --> D["User Skills<br/>~/.claude/skills/"]
    B --> E["Project Skills<br/>.claude/skills/ (各层)"]
    B --> F["Additional Skills<br/>additionalSkillPaths"]
    B --> G["Legacy Skills<br/>.claude/commands/ (已废弃)"]

    C --> H["parseSkillFile()"]
    D --> H
    E --> H
    F --> H
    G --> H

    H --> I["解析 Frontmatter"]
    I --> J["createSkillCommand()"]
    J --> K["去重 + 分离条件技能"]

getSkillsPath() — 路径解析

// src/skills/loadSkillsDir.ts:78-94
export function getSkillsPath(
  source: SettingSource | 'plugin',
  dir: 'skills' | 'commands',
): string {
  switch (source) {
    case 'policySettings':
      return join(getManagedFilePath(), '.claude', dir)
    case 'userSettings':
      return join(getClaudeConfigHomeDir(), dir)
    case 'projectSettings':
      return `.claude/${dir}`
    case 'plugin':
      return 'plugin'
    default:
      return ''
  }
}

8.2 Skill 文件格式 (Frontmatter)

一个完整的 Skill Markdown 文件:

---
name: Review PR
description: Review a pull request for code quality
allowed-tools: Bash, FileReadTool, GrepTool
arguments: pr_number
when_to_use: when the user asks to review a PR
model: opus
effort: high
context: fork
userInvocable: true
disable-model-invocation: false
---

Review the pull request #$ARGUMENTS and provide feedback on:
1. Code quality
2. Potential bugs
3. Performance issues

解析源码参考parseSkillFrontmatterFields(src/skills/loadSkillsDir.ts:185)

字段 类型 说明
name string UI 显示名
description string 技能描述
allowed-tools string[] 限制可用工具
arguments string[] 参数名列表
when_to_use string 模型自动调用的触发条件
model string 指定使用的模型
effort string 推理深度
context 'fork' 在子 agent 中执行
userInvocable boolean 是否可通过 / 触发
disable-model-invocation boolean 禁止模型自动调用
shell string Bash 工具使用的 shell

8.3 变量替换

Skill 内容支持以下变量:

  • $ARGUMENTS — 用户传入的参数
  • ${CLAUDE_SKILL_DIR} - skills存放路径
  • ${CLAUDE_SESSION_ID} - 当前sessionId

相关处理在src/skills/loadSkillsDir.ts: 270中,函数为createSkillCommand

8.4 条件技能 (Conditional Skills)

通过 paths 字段实现文件路径匹配的条件技能:

// src/types/command.ts:50-52
export type PromptCommand = {
  // ...
  paths?: string[]
  // ...
}

条件技能只在模型操作过匹配路径的文件后才变为可见。例如,配置 paths: ["*.test.ts"] 的测试技能只有在模型读取或编辑了测试文件后才会出现在可用技能列表中。项目级技能(projectSettings 来源)天然隔离于项目目录内。


九、SkillTool —— 模型驱动的技能调用

源码位置:src/tools/SkillTool/SkillTool.ts

SkillTool 是一个特殊的内置工具,它让模型可以主动调用用户定义的 Skill,而不仅仅是通过用户输入 / 命令触发。

9.1 工作机制

sequenceDiagram
    participant M as 模型
    participant ST as SkillTool
    participant CMD as Command System
    participant A as Agent/Inline

    M->>ST: tool_use: { skill: "review-pr", args: "123" }
    ST->>CMD: getAllCommands(context)
    CMD-->>ST: [PromptCommand, LocalCommand, ...]
    ST->>ST: validateInput — 查找匹配的 PromptCommand
    alt executionContext === 'fork'
        ST->>A: executeForkedSkill() → runAgent()
    else inline
        ST->>ST: processPromptSlashCommand 注入 prompt 到对话流
    end
    ST-->>M: ToolResult

9.2 getAllCommands() — 命令发现

// src/tools/SkillTool/SkillTool.ts:81-94
async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
  // 1. 获取 MCP 提供的技能
  const mcpSkills = context.getAppState().mcp.commands.filter(
    cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
  )

  // 2. 没有 MCP 技能时直接返回本地命令
  if (mcpSkills.length === 0) {
    return getCommands(getProjectRoot())
  }

  // 3. 合并本地命令和 MCP 技能,本地优先
  const localCommands = await getCommands(getProjectRoot())
  return uniqBy([...localCommands, ...mcpSkills], 'name')
}

合并顺序是 localCommands 在前,mcpSkills 在后。uniqBy 保留第一个,所以本地命令优先于同名 MCP 技能。

9.3 validateInput — 技能存在性校验

SkillToolvalidateInput 确保:

  1. 请求的技能名称存在于可用命令列表中
  2. 目标命令是 PromptCommand 类型(非 local/local-jsx)
  3. 技能没有设置 disableModelInvocation: true

如果校验失败,返回描述性错误消息,模型可以据此调整调用。

9.4 executeForkedSkill() — 子 Agent 执行

当 Skill 的 frontmatter 设置 executionContext: 'fork' 时,技能在隔离的子 Agent 中运行:

// src/tools/SkillTool/SkillTool.ts:122-200+
async function executeForkedSkill(
  skillName: string,
  prompt: string,
  context: ToolUseContext,
  allowedTools?: string[],
  model?: string,
  effort?: string,
): Promise<ToolResult<string>> {
  // 1. 构造子 agent 的配置
  // 2. 通过 runAgent() 在独立上下文中执行
  // 3. 跟踪分析事件
  // 4. 返回子 agent 的结果
}

Fork 执行的优势:

  • 隔离性:子 Agent 有独立的上下文,不污染主对话
  • 工具限制:可以通过 allowedTools 限制子 Agent 可用的工具
  • 模型选择:可以为特定技能指定不同的模型

9.5 inline 整合回主会话

inline模式调用getMessagesForPromptSlashCommand,参考7.4节


十、系统集成场景

10.1 端到端流程:模型调用内置工具

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant STE as StreamingToolExecutor
    participant TE as toolExecution.ts
    participant T as BashTool

    U->>Q: "列出当前目录文件"
    Q->>API: messages + tools
    API-->>Q: tool_use: { name: "Bash", input: { command: "ls" } }
    Q->>STE: addTool(toolUseBlock)
    STE->>STE: canExecuteTool? → true
    STE->>TE: runToolUse()
    TE->>TE: findToolByName("Bash")
    TE->>TE: safeParse(input)
    TE->>TE: checkPermissions()
    TE->>U: 请求权限确认
    U-->>TE: 允许
    TE->>T: call({ command: "ls" })
    T-->>TE: { data: "file1.ts\nfile2.ts" }
    TE-->>STE: yield toolResultMessage
    STE-->>Q: yield 有序结果
    Q->>API: tool_result + continue
    API-->>Q: "当前目录包含 file1.ts 和 file2.ts"
    Q-->>U: 显示响应

10.2 端到端流程:MCP 工具调用

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant TE as toolExecution.ts
    participant MCP as MCP Client
    participant S as External MCP Server

    U->>Q: "获取代码诊断"
    Q->>API: messages + tools (含 mcp__ide__getDiagnostics)
    API-->>Q: tool_use: { name: "mcp__ide__getDiagnostics" }
    Q->>TE: runToolUse()
    TE->>TE: findToolByName("mcp__ide__getDiagnostics")
    Note over TE: 找到运行时覆盖的 MCPTool 实例
    TE->>TE: checkPermissions() → passthrough
    TE->>MCP: tool.call() (被 mcpClient.ts 覆盖)
    MCP->>S: MCP protocol call
    S-->>MCP: result
    MCP-->>TE: { data: "diagnostics..." }
    TE-->>Q: yield toolResultMessage
    Q->>API: tool_result + continue

10.3 端到端流程:Skill 调用

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant SK as SkillTool
    participant RA as runAgent()

    U->>Q: "review PR #42"
    Q->>API: messages + tools (含 SkillTool with skill list)
    API-->>Q: tool_use: { name: "Skill", input: { skill: "review-pr", args: "42" } }
    Q->>SK: runToolUse()
    SK->>SK: validateInput — 查找 "review-pr"
    SK->>SK: executionContext === 'fork'?
    alt fork
        SK->>RA: executeForkedSkill()
        RA-->>SK: 子 agent 结果
    else inline
        SK-->>Q: 注入 prompt 到 messages
        Q->>API: 带 skill prompt 的新请求
    end

十一、设计洞察

11.1 统一接口的力量

所有工具——无论是核心的 BashTool、外部的 MCP 工具、还是用户定义的 Skill——都实现同一个 Tool<Input, Output, P> 接口。这意味着:

  • 权限系统只需实现一次,自动应用于所有工具
  • StreamingToolExecutor 的并发控制对所有工具类型透明
  • 新增工具类型无需修改执行管道

10.2 多层权限的纵深防御

权限检查不是一个单点决策,而是一条贯穿整个执行管道的防线:

配置文件 deny 规则 → filterToolsByDenyRules(注册时过滤)
    → Zod schema 校验 → validateInput 业务校验
        → PreToolUse hooks(用户自定义拦截)
            → resolveHookPermissionDecision(综合决策)
                → canUseTool(运行时权限)
                    → 内部字段剥离(防注入)

即使某一层被绕过,后续层仍然提供保护。

10.3 并发安全的简洁模型

用一个布尔值 isConcurrencySafe 就实现了完整的并发控制:

  • 全部安全 → 全并发(如多个 GlobTool 查询)
  • 任一不安全 → 排队执行(如 FileEditTool 必须独占)
  • 兄弟中止 → Bash 失败时取消同批次工具

这比复杂的锁机制更易理解、更少出错。

10.4 Skill 系统的渐进式复杂度

Skill 系统展现了优秀的渐进式设计:

  • 最简形式:一个 Markdown 文件,内容即 prompt → 零配置
  • 中等复杂:添加 frontmatter 控制工具、模型、参数 → 声明式配置
  • 高级用法executionContext: fork 在子 Agent 中运行 → 完全隔离

用户可以从最简单的形式开始,按需增加复杂度。

唯杰地图CAD图层加高性能特效扩展包发布

前言

前段时间我们发布了 WebCAD 平台(vjmap.com/app/webcad/),解决了“在 Web 端打开和编辑 CAD 图纸”这件事。

这次发布 唯杰地图扩展包 vjmapext,不是重复造一个平台,而是补上另一块能力:把 CAD 绘制、编辑和高性能渲染,以插件方式接入你现有的 vjmap 项目

一句话定位

vjmapextvjmap 的 CAD 绘图扩展层,核心入口是 MapCadLayer
它不是一个独立平台,而是一个可嵌入的能力组件:你把它加到地图里,就有了 CAD 级别的图元绘制、编辑、标注、动画和扩展能力。

官方文档入口:


image-20260421201114628

WebCAD 和 vjmapext 是什么关系

可以把它理解成“平台 + 插件能力”的分工:

  • WebCAD:更像开箱即用的平台能力,适合直接在线打开和编辑图纸。
  • vjmapext:更像开发者工具箱,适合把 CAD 能力嵌入你的业务系统里,和业务流程、绘制、数据、界面一起做深度集成。

所以它们不是替代关系,而是互补关系。
如果你要“直接编辑用”,WebCAD 合适;如果你要“接入自己的系统基于vjmap开发并持续迭代”,vjmapext 更合适。


vjmapext 能做什么

从业务角度看,最常见的是下面几类能力组合。

1) 在 CAD 底图上做业务标注和交互、自动成图

你可以打开 CAD 图作为底图,然后叠加区域、点位、路径、文字和图例,把静态图变成可交互业务图。

这类能力常见于:

  • 园区设施管理;

  • 工厂设备点巡检;

  • 室内平面图上的状态展示。

  • 自动成图功能

  • 图形的绘制,编辑

    image-20260421201553891

image-20260421203845263

2) 浏览模式与编辑模式

vjmapext 支持只读浏览和编辑模式切换,便于做“查看”和“编辑”的显示不同。
例如在只读模式下仍允许关键对象可选中,用于查看属性、定位问题。
可以在编辑模式下对数据方便进行编辑,在浏览模式下对数据进行数据查看效果展示。

image-20260421201649396

image-20260421203944672

3) 动画与特效

  • CPU 动画:拖尾、弹簧、关键帧、闪烁、线段渐现
  • GPU FX:点/线预设效果,支持较大数量级渲染
  • FX 与 CAD 实体可绑定,实体移动后特效可跟随
  • 支持性能相关控制(比如更新频率、渲染策略)
  • 支持shadertoy上面的shader支持复制过来使用

如果你要做态势图、告警图、运行状态图,这块会非常好用。
可以直接参考这个示例页:
vjmap.com/app/demoext…

image-20260421201452090

image-20260421204023218

安装

依赖与环境(必读)

  • vjmapext 不能脱离 vjmap 单独使用。 必须先具备 vjmap 运行环境(地图 SDK、底图样式、服务与 vjmap.Map 等),再使用本库。
  • npm 工程:请同时安装 vjmapvjmapext(或已安装满足版本要求的 vjmap)。仅安装 vjmapext、未安装/未加载 vjmap 时无法正常工作。
  • 本包发布内容:以 package.jsonfiles 为准,一般为 dist 下的 vjmapext.min.js(UMD) 与类型声明;

npm 安装

npm install vjmapext

快速接入(最小工程骨架)

import { MapCadLayer } from "vjmapext";

const mapcad = new MapCadLayer({
  locale: "zh",
  mode: "edit",
  defaultColor: 0x7fd3ff,
});

map.addControl(mapcad);
mapcad.createUI({ theme: "dark" });

建议启动前先配置这 6 项:

  1. modeeditbrowse
  2. drawingDefaults:颜色、线宽、图层默认值;
  3. shortcuts:是否覆盖默认快捷键;
  4. 字体:有文字实体时先 loadFont()
  5. 交互:是否启用捕捉及捕捉模式;
  6. 持久化:先确定 toJSON/fromJSON 存储位置。

SDK 功能详解(含作用说明)

1)命令系统

核心 API:

  • executeCommand(name, opts?)
  • repeatLastCommand()
  • getLastCommandName()

内置命令覆盖(40+):

  • 绘图:LINEPLINEPOINTCIRCLEARCELLIPSESPLINEFREEHANDRECTPOLYDLINEREVCLOUDHATCH
  • 编辑:MOVECOPYERASEMIRROROFFSETSCALESTRETCHBREAKTRIMEXTENDFILLETARRAYEXPLODEDRAWORDER
  • 标注:DIMLINEARDIMALIGNEDDIMANGLEDIMRADIUSMLEADER
  • 文字:TEXTTEXTEDITMTEXT
  • 块与导入:BLOCKINSERTIMPORTSVG

作用描述(详细):

  • 把 CAD 操作抽象成统一命令后,业务系统只需绑定命令,不需要重复造编辑逻辑;
  • 多模式命令(如 CIRCLEARC)可通过关键字切换子流程,减少多命令拆分维护成本;
  • 命令可以统一挂到工具栏、右键菜单、快捷键与业务流程引导页面;
  • 命令执行链可接日志,形成“用户操作轨迹”。

逐命令功能说明(精简版):

  • 绘图命令

  • LINE:按点创建直线段,适合轴线、连线、边界基础绘制。

  • PLINE:连续多段线,支持闭合和回退点,适合轮廓线与路径线。

  • POINT:创建点实体,常用于定位点、控制点、设备锚点。

  • CIRCLE:圆绘制,支持圆心半径、直径、两点、三点、切线等模式。

  • ARC:圆弧绘制,支持多构造方式,适合弧形边界和连接段。

  • ELLIPSE:椭圆绘制,支持中心法/轴端点法,常用于设备包络或符号。

  • SPLINE:样条曲线,适合平滑边界、自由曲线表达。

  • FREEHAND:自由手绘,适合快速草绘与现场标记。

  • RECTPOLY:矩形/多边形绘制,适合区域框选、面状边界初稿。

  • ARROW:箭头绘制,用于流程方向、流向标识。

  • HATCH:填充封闭区域,适合功能分区、材质区、风险区高亮。

  • TOHATCH:将封闭图形转换为填充对象,便于后处理。

  • DLINE:双线绘制,适合道路、墙体、管廊边界等平行线对象。

  • REVCLOUD:修订云线,常用于审图圈改、问题标注。

  • 文字命令

  • TEXT:单行文字,适合点位名、编号、简短说明。

  • TEXTEDIT:编辑既有文字内容,适合在线修正文案。

  • MTEXT:多行文字,适合批注段落、说明块。

  • 标注命令

  • DIMLINEAR:线性标注,输出水平或垂直尺寸。

  • DIMALIGNED:对齐标注,沿对象方向标注真实长度。

  • DIMANGLE:角度标注,适合角点控制与转角校核。

  • DIMRADIUS:半径标注,适合圆/弧尺寸表达。

  • MLEADER:多重引线,适合复杂构件说明与指向标注。

  • 编辑命令

  • MOVE:整体平移对象到新位置。

  • COPY:复制对象,适合重复布置。

  • ERASE:删除对象。

  • MIRROR:镜像对象,适合对称图形快速生成。

  • OFFSET:平行偏移对象,适合生成内外边界。

  • SCALE:按比例缩放对象。

  • STRETCH:局部拉伸对象几何。

  • BREAK:打断对象,生成断开段。

  • TRIM:按边界修剪超出部分。

  • EXTEND:按边界延伸对象到交界处。

  • FILLET:圆角连接两对象,生成平滑转角。

  • ARRAY:阵列复制,适合规则分布对象。

  • EXPLODE:分解复合对象(块、多段线等)为基础实体。

  • DRAWORDER:调整前后绘制顺序,控制遮挡与可见层次。

  • 块与导入命令

  • BLOCK:将一组对象定义为块,便于复用和规范化管理。

  • INSERT:插入块引用,支持重复放置。

  • PASTECLIP:粘贴剪贴板对象,提升跨区域编辑效率。

  • IMPORTSVG:导入 SVG 并转为可编辑对象,便于外部图标/图形接入。

命令使用建议:

  • 前台工具栏通常优先暴露 LINE/PLINE/CIRCLE/MOVE/COPY/ERASE/TRIM/EXTEND/DIMLINEAR/TEXT
  • 审图类页面建议增加 REVCLOUD/MLEADER/TEXTEDIT
  • 模板化制图建议优先启用 BLOCK/INSERT/ARRAY
  • 若是存量图改造,命令层要与 MapData + Hider + exportDwg 一起设计。

示例:


2)输入系统(InputManager)

输入能力:

  • 点输入(坐标点采集)
  • 选集输入(单选、框选、多选)
  • 数值输入(长度、半径等)
  • 关键字输入(命令子模式切换)
  • 字符串输入(文字命令等)

作用描述(详细):

  • 输入统一后,所有命令交互行为一致,降低用户学习成本;
  • 对开发者来说,命令只管业务逻辑,输入边界(取消、确认、回退)交给统一系统处理;
  • 输入与预览联动后,用户在确认前就能看到结果,减少误提交;
  • 是“可编辑能力稳定性”的底座。

示例:


3)对象捕捉(Snap)与夹点编辑(Grip)

捕捉能力:

  • 端点、中点、圆心、交点、最近点等模式;
  • 支持模式组合与开关控制;
  • 在命令点输入阶段实时生效。

夹点能力:

  • 选中实体后显示可编辑夹点;
  • 拖拽夹点修改几何;
  • 可与撤销重做联动。

作用描述(详细):

  • 捕捉解决“线上操作精度不足”问题;
  • 夹点解决“局部改图要重画”问题;
  • 两者配合,能在网页端做可用的精修工作流,而不是仅展示级编辑。

示例:


4)实体存储与选择管理

核心 API:

  • addEntity(entity)
  • deleteEntity(id)
  • getEntities()
  • getSelectedEntities()
  • clearSelection()

作用描述(详细):

  • 实体层统一管理后,渲染层、属性面板、事件系统都能共享同一数据源;
  • 选择集明确后,编辑命令可避免“误改全部对象”;
  • 可在业务系统里按选择集做批处理(改颜色、改图层、改属性);
  • 是批量编辑、批量审查、批量导出的前置基础。

示例:


5)撤销重做(Undo/Redo)

核心 API:

  • undo()
  • redo()

作用描述(详细):

  • 在线编辑可回退,用户才敢进行复杂操作;
  • 支持和快捷键联动,操作习惯接近桌面 CAD;
  • 可用于审图流程中的“试改-对比-还原”。

示例:


6)标注体系

相关命令:

  • DIMLINEAR
  • DIMALIGNED
  • DIMANGLE
  • DIMRADIUS
  • MLEADER

作用描述(详细):

  • 标注能力决定图纸可审核性,不只是视觉增强;
  • 统一标注命令可把尺寸、角度、说明纳入标准编辑流;
  • 对工程协同来说,标注是交底、复核、验收的核心数据表达。

示例:


7)文字与字体管理

相关能力:

  • 命令:TEXTTEXTEDITMTEXT
  • API:loadFont(url, name?)

作用描述(详细):

  • 文字是图纸语义信息的重要组成;
  • 字体加载可避免线上渲染错位或替换字体导致排版变化;
  • 文字编辑能力可直接承接审图意见修订流程。

示例:


8)块(Block)能力

相关能力:

  • 命令:BLOCKINSERT
  • 数据:块定义、块引用
  • 序列化:块信息可随文档保存恢复

作用描述(详细):

  • 块能力是减少重复绘制和统一规范的核心;
  • 适合设备符号、标准构件、图例模板等复用对象;
  • 可建立企业标准块库,提升制图一致性。

示例:


9)序列化与绘图默认值

核心 API:

  • toJSON()
  • fromJSON(doc)
  • setDrawingDefaults(partial)
  • getDrawingDefaults()

作用描述(详细):

  • 支持“保存当前进度 -> 跨会话继续编辑”;
  • 支持“模板化初始化图纸”;
  • 支持团队统一绘图规范(图层、线宽、颜色);
  • 是多人协作和版本回放的基础。

10)MapData 数据联动

核心 API:

  • queryMapEntities(opts)
  • queryMapEntitiesByLayer(layer, entType, extra?)
  • featuresToEntities(featureCollection, opts?)
  • createMapDataHider()

标准链路:

  1. 按条件查询后端 DWG 实体;
  2. Feature 转 SDK 实体;
  3. 隐藏原图被接管对象;
  4. 前端叠加编辑;
  5. 最终导出。

作用描述(详细):

  • 不需要一次性迁移历史图纸;
  • 可在原图基础上做增量改造;
  • 可把“后端存量数据”接入“前端可编辑流程”;
  • 适合传统项目数字化升级。

示例:


11)DWG 导出交付

核心 API:

  • exportDwg(opts)
  • setExportDwgCallback(cb)

常见组合:

  • exportDwg({ hider })
  • onBeforeUpdate 导出前加工
  • deleteFromSource 导出时清理源对象

作用描述(详细):

  • 打通“在线编辑 -> DWG 文件交付”;
  • 保持与传统 CAD 工具链衔接;
  • 减少人工二次整理步骤。

12)渲染与性能机制

可核验机制:

  • 三源分桶:hot/cold/dynamic
  • 增量更新与脏标记刷新
  • 渲染缓存复用
  • styleOnly 样式快路径

作用描述(详细):

  • 高频操作时减少全量刷新;
  • 大图场景下更稳定;
  • 只改样式时避免几何重建;
  • 给后续性能调优提供结构基础。

13)FX 特效层

能力点:

  • 批量添加特效对象;
  • 质量档位调节;
  • 指标与事件输出;
  • CAD 实体绑定。

作用描述(详细):

  • 可用于状态表达(告警、流向、活跃度);
  • 可根据设备性能动态降级;
  • 可通过指标事件接入监控系统;
  • 是“可编辑图纸 + 运行态表达”组合能力的关键层。

示例:

14)UI、事件与扩展能力

相关能力:

  • createUI(options) / getUI()
  • eventBus 事件总线
  • loadPlugin(plugin) 与插件生命周期

作用描述(详细):

  • UI 能力让你快速构建可用工作台;
  • 事件体系让 CAD 编辑流程可接入业务日志、审批、统计;
  • 插件机制支持“先上线核心,再按模块扩展”;
  • 有利于长期维护与团队协作开发。

示例:


独立开发复盘:我用 Uni-app + Strapi v5 肝了一个“会上瘾”的打卡小程序

大家好,我是一名独立开发者。最近利用业余时间,我从零到一开发并上线了一款目标打卡/习惯养成类的小程序。

今天这篇文章,不仅是想向大家推荐一下我的心血之作,更想从创作灵感核心技术实现代码细节以及无数次踩坑的角度,和大家深度复盘一下整个项目的历程。如果你也想尝试用 Uni-app + Strapi 搞全栈独立开发,这篇“避坑指南 + 技术解析”绝对不容错过!


307c9942d27117ec00e7781976431a56.jpg

1fa52da0afde72c0faf8e72dc49c1c29.jpg

💡 创作灵感与产品心得:为什么还要做一个打卡应用?

市面上的打卡应用多如牛毛,为什么我还要自己造轮子? 其实原因很简单:我觉得现有的工具太“冷冰冰”了,缺乏足够的情绪反馈。

打卡/坚持习惯本身就是一件反人性的事情,如果工具只是一个无情的“待办列表”,那用户很容易就会放弃。因此,在产品设计之初,我定下了几个核心基调:

  1. 克制与聚焦:我限制了每天最多只能创建 12 个任务,到达 10 个时会温馨警告。目标泛滥等于没有目标。
  2. 正向反馈拉满:任务完成不能只是打个勾,必须要有“爽感”。我加入了物理震动、纸屑爆裂动画(撒花)、以及 3D 翻转的徽章解锁系统。
  3. 互助与抄作业:很多时候我们不知道该养成什么习惯,所以我做了一个“社区广场”(瀑布流布局),看到别人优秀的习惯,可以直接“一键 Copy”到自己的计划中。

🛠 技术选型:单兵作战的效率最优解

作为独立开发者,开发效率是第一生产力。我选择了这套组合拳:

  • 前端:Uni-app (Vue 3) + Tailwind CSS
    • Vue 3 的 Composition API 逻辑复用非常爽。
    • 结合原子化 CSS(如 Tailwind/UnoCSS),极大提升了切图速度,摆脱了起 class 名字的内耗。
  • 后端:Strapi v5 (Headless CMS)
    • 绝对的效率神器!不用手写繁琐的 CRUD 接口,建好模型直接生成 RESTful API。
    • 自带强大的 Admin 后台,数据管理极度舒适,让我能把 80% 的精力全放在前端交互和产品体验上。

💻 核心技术点与代码实现

1. 极致的微交互:让打卡“爽”起来

为了让用户点下“完成”的那一刻有真实的成就感,我结合了 CSS 动画和原生的触觉反馈:

// 核心打卡逻辑片段
const handleCheckIn = async (task) => {
  // 1. 触发 Haptic 震动反馈 (重震动带来物理按压感)
  uni.vibrateShort({ type: 'heavy' });
  
  // 2. 触发微动效:按钮自身的弹跳 + 全局撒花特效
  task.isBouncing = true; 
  uni.$emit('trigger-particle-confetti'); // 呼叫全局纸屑动画组件
  
  try {
    await api.completeTask(task.id);
    // 3. 检查是否触发徽章解锁
    checkBadgeUnlock(task);
  } catch (e) {
    // 错误处理...
  }
}

在徽章解锁时,我还写了一个 3D 翻牌效果(利用 CSS transform: rotateY 配合 animate-flip-y),让徽章展示更有仪式感。

2. Strapi 关系模型 Hack:如何优雅地记录“徽章解锁时间”?

在后端的开发中,我遇到了一个经典问题:多对多关联表的额外字段怎么存? User 和 Badge 是多对多关系,但在 Strapi 原生模型中,中间表无法轻易添加像 unlockedAt 这样的字段。

我的解法: 直接在 User Schema 中扩展一个轻量级的 JSON 字段 badge_unlock_records

// apps/api/src/extensions/users-permissions/strapi-server.ts
// 扩展 Strapi 默认的 User Schema
export default (plugin) => {
  plugin.contentTypes.user.attributes = {
    ...plugin.contentTypes.user.attributes,
    // 原生多对多关联
    badges: {
      type: 'relation',
      relation: 'manyToMany',
      target: 'api::badge.badge',
    },
    // 💡 Hack: 用 JSON 字段记录具体的解锁元数据
    badge_unlock_records: {
      type: 'json',
      // 数据结构示例: { "badge_id_1": "2023-10-01T12:00:00Z" }
    }
  };
  return plugin;
};

这样既保留了原生关系(方便在 Admin 面板查看),又解决了业务上的元数据存储需求。

3. 社区广场的“真”瀑布流与分页

社区页面的卡片高度是不固定的,传统的 Grid 布局会留下大片空白。我通过维护左右两列的数据数组,实现了原生的瀑布流效果:

// 瀑布流计算核心逻辑
const leftColumn = ref([]);
const rightColumn = ref([]);
let leftHeight = 0;
let rightHeight = 0;

const appendToMasonry = (items) => {
  items.forEach(item => {
    // 估算卡片高度 (基于内容长度)
    const estimatedHeight = calculateHeight(item);
    
    // 哪边矮往哪边塞
    if (leftHeight <= rightHeight) {
      leftColumn.value.push(item);
      leftHeight += estimatedHeight;
    } else {
      rightColumn.value.push(item);
      rightHeight += estimatedHeight;
    }
  });
};

配合 onReachBottom 触底事件,以及自己封装的 wd-loadmore 状态组件,整个信息流刷起来非常丝滑。


🚧 吐血踩坑录:那些让我熬夜的 Bug

全栈开发最怕的就是遇到莫名其妙的兼容性和环境问题。以下这几个坑,价值好几百根头发:

坑一:iOS 13 下 Swiper 圆角失效问题

症状:在旧版 iOS 中,给 <swiper> 设了 border-radiusoverflow: hidden,但里面的图片滑动时依然会无视圆角溢出。 解法:这是 transform 堆叠上下文导致的渲染 Bug。不仅要给 swiper 和 image 都加上圆角类名,还必须强制加上 transform: translateY(0);

<!-- 💡 注意 style 中的 transform 是精髓 -->
<swiper class="rounded-[5px]" style="transform: translateY(0);">
  <swiper-item>
    <image class="rounded-[5px]" src="..." />
  </swiper-item>
</swiper>

坑二:小程序下渐变文字(bg-clip-text)直接消失

症状:想用 Tailwind 的 bg-clip-text text-transparent 做炫酷的渐变文字,结果在微信小程序/iOS上文字直接隐身了。 解法:小程序对 <text> 标签的背景裁剪支持极差。如果要用,必须把 <text> 换成 <view> 标签来写文字,或者老老实实退回到纯色文本。

坑三:Strapi v5 生产环境部署大坑

  1. 插件报错:初始化 v5 时,报 Middleware plugin::email.rateLimit not found解法:手动执行 pnpm add @strapi/email 安装缺失依赖。
  2. RTK Query 压缩报错:打包上线后 Admin 面板报 Cannot read properties of undefined (reading 'merge')。原因是 Vite 压缩把 RTK Query 的方法名压没了。 解法:在 src/admin/vite.config.ts 中关闭 minify,并清理缓存!
// src/admin/vite.config.ts
export default {
  build: {
    minify: false, // 💡 必须设为 false
  },
};

(执行 npx rimraf .strapi build 清除缓存后再 build)


结语

从一行代码都没有,到完整的前后端链路打通;从构思微交互,到处理数据备份(云端同步 + 剪贴板文本导出);这个过程虽然辛苦,但当看到产品真正跑起来,有人开始用它记录生活时,一切都值了。

目前小程序已经上线,欢迎大家在微信搜索 简行一周 体验!

如果你对文章中的技术点感兴趣,或者在用 Uni-app / Strapi 的时候也遇到了头疼的问题,欢迎在评论区留言交流,我一定知无不言!

最后,如果你觉得这篇文章对你有启发,求个点赞 + 收藏,这对我这个独立开发者是莫大的鼓励!🚀

6e051cfd4b1574ab6dc9e48938b739d7.png

Opus 4.7 使用体验

大家好,我是 uni-app 的核心开发 笨笨狗吞噬者,欢迎关注我的微信公众号 前端笨笨狗

感想

不得不说,ai 发展的实在太快了,去年我入职 uni-app,一年中的大部分时间还是在手搓代码,就是网上说的 古法编程,一方面是对 ai 的能力仍有质疑,觉得对于框架维护,ai 不懂,另一方面是体验了一些模型,感觉能力不是很强。公司这边也是大力支持使用 ai,同事甚至一天提交了一百多个 commit

上周我在家用 gpt-5.4ai 写了 https://github.com/uni-toolkit/uni-toolkit/tree/main/packages/vite-plugin-component-insight 插件,我写好了 md 文件之后,就去打王者荣耀去了,大概个把小时之后,它已经写出来,并且按照我的要求先用原生微信小程序做测试和验证,然后再用 uni-app 项目做了测试和验证,这个活以往我可能要做大概一半天,现在再看看,我基本上什么编码工作都没有做(简单修改了一下 readme)。

现在我的工作,基本上百分之八九十都是交给 ai 编写或者作决策,比如我需要修复框架的问题,我会告诉 ai,现在的问题是什么,我的修复思路是什么,具体该修改什么文件,等它改完了之后,我再来 review,看下代码修改是否合理。

有时候真不明白要我干啥了,emmmmmm....

skill

前段时间,我创建了 https://github.com/uni-toolkit/skills 项目,用来存放针对 uni-app 主包瘦身的 skills。今天就想着来再试试 Opus 4.7 的能力,同时也验证下我的 skills 是否正确,能否给开发者提供足够合理的建议,下面分享下我的测试流程

前置工作

我一般使用 ai 做某个任务的习惯时,先写个 md 文件,比如我现在的需求是验证 uniapp-subpackage-node-modules 的正确性, md 文件如下:

# 需求

验证 `uniapp-subpackage-node-modules` skill 的正确性

## 步骤一

### 需求

主包中加一个页面,创建两个分包页面,主包可以分别跳转到分包页面。页面要有一个组件示例,每个组件或者页面都要添加 rattail 工具函数的示例

### 注意事项

- 页面不要太复杂
- 使用的 rattail 版本为 1.0.8 
- 工具函数可以选一部分简单的,一部分复杂的
- rattail 官网是 https://rattail.varletjs.org/zh/getting-started.html

## 步骤二

### 需求

`https://github.com/uni-toolkit/skills/tree/main/skills/uniapp-subpackage-node-modules` 这个是要验证的skill,你把skill应用到这个项目中,同时跑一下 skill,把你觉得项目应该修改的地方写成一个 md 文件到项目的根目录

### 注意事项

- 不要修改项目的原逻辑,仅安装skill,应用skill,输出分析信息

## 步骤三

参考上述分析得到的日志,如果这个项目可以优化,你可以根据 skill ,优化下这个项目产物生成

### 注意事项

- 不要添加无关依赖和逻辑
- 分包用到的 rattail 函数应该打包到分包中,注意验证这个

指挥 ai

md 文件的内容写的差不多之后,我会让 ai 按照步骤进行工作,分步骤的目的是防止 ai 一下修改出来太多东西看不过来(吐血经历,之前让 aihttps://github.com/uni-toolkit/uni-toolkit/tree/main/packages/vite-plugin-component-insight,review 累死人)

我这个项目是主包分包都用到了 rattail 工具库的函数,hx 5.04 版本开始,hx 项目或者 cli 项目支持分包引用的 node_modules 依赖打包到分包中,我想分包用到的函数打包到分包中,ai 应用了 uniapp-subpackage-node-modules 之后,

skill.jpg

输出了一份详细的诊断报告(大概两百多字,本文仅展示部分内容)

# Skill 分析报告:`uniapp-subpackage-node-modules`

> 本文件由 `uniapp-subpackage-node-modules` skill 应用于当前项目后自动输出。
> 目的:验证 skill 的正确性,分析项目在「分包 node_modules 归属」上的现状与风险,**不直接改动项目原逻辑**。

- Skill 源:<https://github.com/uni-toolkit/skills/tree/main/skills/uniapp-subpackage-node-modules>
- 已安装到:`.cursor/skills/uniapp-subpackage-node-modules/`
  - `SKILL.md`
  - `references/node-modules-subpackage-rules.md`
  - `agents/openai.yaml`

---

## 一、诊断结论(TL;DR)

- **属于问题域**:是。项目确实存在「分包 js 未进入分包 `vendor.js`、仍全部留在主包 `common/vendor.js`」现象。
- **是否满足 uni-app 内置分包优化前提****否**。命中 3 条直接不符合项:
  1. `manifest.json` 的 `mp-weixin` **未开启** `optimization.subPackages`
  2. `packageA`、`packageB` 根目录 **没有独立的 `package.json`**
  3. 目标依赖 `rattail` **被主包 + 多个分包同时引用**,属于「共享依赖」,按规则不适合进入单分包 `vendor.js`
- **更像是哪类原因****配置未开启 + 依赖划分不合理**(双重原因)。即便开了配置,当前 `rattail` 的引用链也会把它拉回主包,这是符合官方优化规则的正常结果。

---

ai 很好地分析出了可优化点,并且我们还可以让它根据分析日志做保守修改

res.jpg

修改前,rattail 的函数都被打包到了 common/vendor.js

before.jpg

修改后,分包的内容都被打包到了分包中

after.jpg

RainbowKit快速集成多链钱包连接,我如何从“连不上”到“丝滑切换”

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目原本只支持以太坊主网,现在产品经理要求快速接入Arbitrum、Polygon和Optimism。核心需求很明确:用户进来,点一个按钮就能连接MetaMask、Coinbase Wallet等主流钱包,并且能在不同链之间无缝切换,查看不同链上的资产和协议。

时间紧,任务重。我评估了一下,自己从零实现一套完整的钱包连接、状态管理、链切换和错误处理逻辑,至少得花上一周,而且后续维护成本高。团队里之前用过wagmi,但主要是基础连接。这次我决定试试RainbowKit,因为它号称是“wagmi的最佳实践封装”,开箱即用,而且UI组件很漂亮。我的目标是在一天内搞定基础的多链连接框架。

问题分析

一开始,我的想法很简单:照着RainbowKit官方文档,安装、配置、把ConnectButton组件一扔,不就完事了?但现实很快给了我一巴掌。

我按照基础教程配好了,按钮是出来了,也能弹出钱包选择框。但第一个问题马上就来了:用户连接后,我需要在应用的其他地方(比如导航栏显示地址、资产页面)获取当前的连接状态和账户信息。我本能地想用wagmi的useAccount等hook,但发现状态有时不同步。点击断开连接后,UI上偶尔还会显示已连接的状态。

第二个问题是链的切换。我配置了多个链,但用户从MetaMask里手动切换了网络(比如从Ethereum切到Polygon),我的应用界面有时感知不到,还是显示旧链的信息,导致后续的合约调用全错在错误的链上。

我意识到,RainbowKit虽然封装了复杂性,但它和底层wagmi的状态流、以及和用户钱包扩展程序的实时通信,需要更细致的配置才能稳定工作。这不是“配完即走”,而是需要理解它们之间如何协同。

核心实现

第一步:项目初始化与依赖安装

首先,我创建了一个新的React + TypeScript项目(如果已有项目,则跳过创建)。RainbowKit需要wagmi作为底层依赖,并且需要配置对应的链信息。

# 创建新项目
npx create-react-app my-web3-app --template typescript
cd my-web3-app

# 安装核心依赖
npm install @rainbow-me/rainbowkit wagmi viem @tanstack/react-query

这里有个关键点:RainbowKit依赖于@tanstack/react-query(旧称react-query)来进行高效的状态管理和缓存。即使你不直接使用它,也必须安装,否则会报错。

第二步:配置Provider与支持的链

这是核心配置环节。我需要在应用的根组件(通常是index.tsxApp.tsx)外包一层RainbowKit和wagmi的Provider。重点在于wagmiConfig的生成,这里需要定义项目支持哪些链。

我决定先支持四个链:Ethereum, Polygon, Arbitrum, Optimism。

// App.tsx
import React from 'react';
import './App.css';
import '@rainbow-me/rainbowkit/styles.css'; // 导入RainbowKit默认样式
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';

// 1. 初始化QueryClient
const queryClient = new QueryClient();

// 2. 配置Wagmi
const config = getDefaultConfig({
  appName: 'MyMultiChainDeFiApp',
  projectId: 'YOUR_PROJECT_ID', // 需要去WalletConnect Cloud申请
  chains: [mainnet, polygon, arbitrum, optimism], // 明确声明支持的链
  ssr: false, // 如果不是Next.js等SSR框架,设为false
});

function App() {
  return (
    // 3. 用Provider层层包裹
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {/* 你的应用组件 */}
          <div className="App">
            <h1>我的多链DeFi聚合器</h1>
            {/* 其他内容 */}
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;

这里有个大坑projectId不能乱填。RainbowKit使用WalletConnect v2协议,这个ID必须从WalletConnect Cloud网站免费注册并创建一个项目来获取。如果随便写一个字符串,钱包连接(尤其是WalletConnect和Coinbase Wallet)会静默失败,控制台错误信息也不明显,我排查了好久。

第三步:使用ConnectButton并获取全局状态

现在,我可以在任何子组件中使用RainbowKit提供的ConnectButton和wagmi的hooks了。我创建了一个Header.tsx组件来放置连接按钮,并展示连接状态。

// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useChainId, useSwitchChain } from 'wagmi';

export const Header = () => {
  // 使用wagmi的hooks获取全局状态
  const { address, isConnected, chain } = useAccount();
  const chainId = useChainId();
  const { switchChain } = useSwitchChain();

  return (
    <header>
      <nav>
        <div>我的DeFi应用</div>
        <div>
          {isConnected ? (
            <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
              {/* 显示当前网络 */}
              <span>网络: {chain?.name || `未知 (ID: ${chainId})`}</span>
              {/* 显示缩短的地址 */}
              <span>
                {address?.slice(0, 6)}...{address?.slice(-4)}
              </span>
              {/* RainbowKit提供的完整功能按钮 */}
              <ConnectButton showBalance={false} />
              {/* 一个自定义的链切换示例 */}
              <button onClick={() => switchChain({ chainId: polygon.id })}>
                切换到Polygon
              </button>
            </div>
          ) : (
            <ConnectButton />
          )}
        </div>
      </nav>
    </header>
  );
};

注意这个细节useAccountuseChainId等hook的状态,与ConnectButton组件内部的状态是自动同步的,因为它们共享同一个wagmi配置。这就是为什么我们可以在应用任何地方可靠地获取连接信息。ConnectButton本身已经包含了连接、切换钱包、切换网络、查看详情、断开连接等所有功能的UI和逻辑。

第四步:处理链切换与状态同步

为了让应用能实时响应用户在钱包里手动切换网络的操作,我需要监听链的变化并更新UI。wagmi的useAccount返回的chain对象,以及useChainId hook,都是响应式的。但为了在链切换时执行一些副作用(比如更新合约实例、重新获取链上数据),我使用了useEffect

// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) return;

    console.log(`链已切换至: ${chain?.name} (ID: ${chainId})`);
    
    // 这里可以执行链切换后的副作用:
    // 1. 更新当前链的RPC Provider
    // 2. 更新合约实例的地址(如果不同链合约地址不同)
    // 3. 重新获取该链上的用户资产数据
    // 4. 更新UI上关于链的提示信息

    // 例如,重新获取资产
    fetchAssetsForChain(chainId);

  }, [chainId, isConnected, chain]); // 依赖chainId,当它变化时触发

  const fetchAssetsForChain = async (currentChainId: number) => {
    // 模拟根据链ID获取资产的函数
    console.log(`获取链 ${currentChainId} 上的资产...`);
    // ... 实际的数据获取逻辑
  };

  return (
    <div>
      <h2>资产总览</h2>
      <p>当前网络: <strong>{chain?.name || '未连接'}</strong></p>
      {/* 资产列表 */}
    </div>
  );
};

这里有个坑chain对象可能为undefined(例如钱包连接了但未授权任何账户,或者是一些边缘情况)。所以在使用chain.namechain.id时,最好使用可选链操作符?.或做空值判断,否则会导致页面渲染错误。

完整代码

以下是一个简化但可运行的核心集成示例,将所有关键部分放在一起。

// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
// App.tsx
import './App.css';
import '@rainbow-me/rainbowkit/styles.css';
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';
import { Header } from './components/Header';
import { AssetDashboard } from './components/AssetDashboard';

const queryClient = new QueryClient();

// 注意:请替换为你在 WalletConnect Cloud 申请的 projectId
const projectId = 'YOUR_WALLETCONNECT_PROJECT_ID_HERE';

const config = getDefaultConfig({
  appName: 'MultiChainDemo',
  projectId: projectId,
  chains: [mainnet, polygon, arbitrum, optimism],
  ssr: false,
});

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <div className="App">
            <Header />
            <main>
              <AssetDashboard />
              {/* 你的其他页面组件 */}
            </main>
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount } from 'wagmi';

export const Header = () => {
  const { isConnected, address, chain } = useAccount();

  return (
    <header style={{ padding: '1rem', borderBottom: '1px solid #ccc', display: 'flex', justifyContent: 'space-between' }}>
      <h1>多链DeFi演示</h1>
      <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
        {isConnected && (
          <>
            <div>
              网络: <strong>{chain?.name}</strong>
            </div>
            <div>
              地址: <code>{address?.slice(0, 8)}...{address?.slice(-6)}</code>
            </div>
          </>
        )}
        <ConnectButton />
      </div>
    </header>
  );
};
// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) {
      console.log('钱包未连接');
      return;
    }
    // 当链ID变化时,执行数据更新逻辑
    console.log(`[副作用] 检测到链变化,当前链ID: ${chainId}, 名称: ${chain?.name}`);
    // 在实际项目中,这里应调用一个函数来更新该链的资产数据
  }, [chainId, isConnected, chain]);

  return (
    <div style={{ padding: '2rem' }}>
      <h2>资产仪表板</h2>
      <p>这个组件会监听链切换。打开控制台查看日志。</p>
      <div>
        <p><strong>连接状态:</strong> {isConnected ? '已连接' : '未连接'}</p>
        <p><strong>当前网络:</strong> {chain?.name || 'N/A'}</p>
        <p><strong>链ID:</strong> {chainId || 'N/A'}</p>
      </div>
    </div>
  );
};

踩坑记录

  1. WalletConnect ProjectId 无效导致静默失败:这是最大的坑。我一开始随便写了个字符串,MetaMask能连(因为它不走WalletConnect),但Coinbase Wallet和WalletConnect二维码死活没反应,控制台也没有明显错误。后来在RainbowKit的GitHub issue里看到,必须去WalletConnect Cloud创建项目获取真实ID。解决后一切正常。
  2. 链ID不匹配导致切换失败:我自定义了一个测试链,它的id我设成了12345。当我调用switchChain({ chainId: 12345 })时,钱包弹窗提示切换,但RainbowKit内部状态没更新。后来发现,getDefaultConfigchains数组必须包含这个链的定义,并且id要和钱包里添加的网络ID完全一致。本质是RainbowKit/wagmi需要知道你打算切换到的链的详细信息(RPC URL、区块浏览器等)。
  3. Hydration错误(Next.js场景):在Next.js项目中,如果SSR开启,需要在getDefaultConfig里设置ssr: true,并且确保与钱包相关的组件只在客户端渲染(用useEffecttypeof window !== 'undefined'判断),否则会因为服务端和客户端初始渲染内容不一致而报错。虽然我这次是Create React App,但这是常见的坑。
  4. 样式冲突:RainbowKit会注入一些全局样式,如果和你项目的现有CSS(比如用了CSS-in-JS库或重置样式表)冲突,可能会导致弹窗位置错乱或样式怪异。解决方法是检查元素,用更高特异性的CSS规则覆盖,或者利用RainbowKit提供的主题定制功能来适配。

小结

通过这次集成,我最大的收获是:RainbowKit + wagmi 确实能极大加速Web3前端连接层的开发,但“开箱即用”不等于“无需理解”。清晰配置支持的链、妥善管理WalletConnect ProjectId、理解状态hook的响应式原理,是保证多链连接稳定丝滑的关键。下一步,我可以深入研究RainbowKit的主题定制,让UI完全融入项目设计,并探索如何与更复杂的多链合约读写逻辑结合。

新手小白学前端day4: 半小时彻底搞懂Position

Day4 学习文档:Position 定位实战

1. 今天要掌握什么

Day4 的目标是把“元素放哪儿”这件事彻底搞明白:

  • 理解 relativeabsolutefixedsticky 的区别
  • 知道每种定位在什么场景下最合适
  • 能做出 3 个常见交互:吸顶导航、角标、回到顶部按钮

2. 大白话理解 Position

可以把页面想象成一张地图,普通元素按“排队规则”从上到下放置。
position 就是告诉浏览器:这个元素是否要“偏离原队列”。

  • static:默认值,老老实实排队
  • relative:还在队列里,但允许“微调位置”
  • absolute:脱离队列,贴着某个参考盒子定位
  • fixed:脱离队列,直接贴着屏幕定位
  • sticky:平时排队,滚动到阈值后吸附

2.1 两个必须懂的基础词:viewport 和文档流


一、文档流(Normal Flow)

大白话:就是网页里的元素“排队”的方式,默认情况下,它们按照你在HTML里写的顺序,一个接一个地自动摆放。

  • 块级元素(比如 <div><p><h1>):就像地铁里的车厢,每个独占一整节,竖着排,上一个在顶上,下一个在底下,不会并排。
  • 行内元素(比如 <span><a><strong>):就像排队买票的人,大家并排站在一起,从左到右,一行不够了就自动换到下一行。

一句话:文档流就是“正常排队”,你什么都不改,它就这么排。


二、viewport(视口)

大白话:就是你手机或电脑屏幕上用来显示网页的那个矩形区域

  • 在电脑上,视口就是浏览器窗口的内部(不包括工具栏、地址栏)。
  • 在手机上,视口比较特殊:因为手机屏幕窄,早期网页都是为电脑设计的(宽度至少960px),如果手机用真实屏幕宽度(比如375px)去显示,网页会被挤得乱七八糟。所以手机浏览器默认用一个虚拟的宽视口(通常是980px)来加载网页,然后缩放显示。这就导致你看不清字,需要手动放大。

**<meta name="viewport"> 标签的作用**:告诉手机浏览器:“别用那个虚拟宽视口了,就用我的实际屏幕宽度来布局”。常见写法:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

意思是:

  • width=device-width:把视口宽度设成手机屏幕的实际宽度(比如375px)。
  • initial-scale=1.0:不缩放,1个CSS像素对应1个屏幕物理像素。

一句话:viewport 就是“你看网页的那个框框”,移动端必须设置那个meta标签,否则网页会像缩小的蚂蚁。


三、二者关系

  • 文档流 是元素在视口内的排列方式。
  • 视口 是容纳文档流的容器大小。

举个例子:你把一排积木(文档流)放在一个盒子里(视口)。盒子宽了,积木可能并排;盒子窄了,积木可能换行。

移动端如果不设置 viewport,盒子(视口)会默认宽980px,导致你的积木(元素)看起来很迷你,用户必须双指缩放才能看清。设置了之后,盒子宽度就是手机屏幕宽度,你按正常尺寸写CSS就行。

希望这样解释你彻底明白了。如果还有模糊的地方,可以继续问。


3. 四种核心定位方式(更直观版)

先记一句总口诀:

  • relative站在原地,可微调
  • absolute脱离队伍,找爹定位
  • fixed钉在屏幕,不跟页面走
  • sticky先正常滚,后吸顶

3.1 position: relative(站在原地,可微调)

生活类比:
你在排队时,脚还在原位置,但身体可以往前后左右挪一点。

你会看到的效果:

  • 这个元素原本位置还占着,不会让后面的元素补上来
  • 可以用 top/right/bottom/left 微调显示位置

最常见用途(不是“挪自己”,而是“给孩子当参照物”):

  • 给卡片加 position: relative,让内部角标用 absolute 对齐这张卡片

一句话判断:
需要一个“定位锚点”时,先上 relative

3.2 position: absolute(脱离队伍,找爹定位)

生活类比:
一个人离开队伍,跑去贴着“最近的定位锚点”站位。

你会看到的效果:

  • 元素脱离文档流,原位置会被其他元素“顶上来”
  • 它会找最近的、position 不是 static 的祖先作为参照
  • 如果没找到,就相对页面定位(常见“跑飞”)

典型场景:

  • 卡片角标、关闭按钮、图片文案浮层

一句话判断:
想让元素“贴着某个盒子角落”时,用 absolute + 父级 relative

3.3 position: relativeabsolute 的关系(大白话)

3.3.1 relative(相对定位)

大白话:元素原本在文档流里占着位置(排队占位),你可以通过 top/left/right/bottom 让它相对于自己的原位置挪动一下。挪动后,原来的坑位依然空着(其他元素不会挤过来)。

  • 常用场景:给绝对定位的父元素做“参照物”。
3.3.2 absolute(绝对定位)

大白话:元素完全脱离文档流(不再排队,也不再占位),其他元素会忽略它,直接填补它原来的位置。它的位置参考系是最近的那个设置了 position(非 static)的祖先元素。如果找不到这样的祖先,就参考视口(但严格说是初始包含块,可以理解为视口)。

3.3.3 它们最经典的配合:父 relative,子 absolute

这是为了让子元素相对于父元素进行绝对定位。

例子

<div class="parent" style="position: relative;">
  <div class="child" style="position: absolute; top: 0; right: 0;">角标</div>
</div>
  • 父元素 relative:不挪动自己,只是建立一个定位参照系
  • 子元素 absolute:相对于父元素左上角定位,top:0; right:0 就贴在父元素右上角。

为什么父元素不用 absolute 因为父元素如果 absolute 也会脱离文档流,破坏布局。用 relative 最安全(它不脱离文档流)。

3.3.4 对比表格
特性 relative absolute
是否脱离文档流 ❌ 不脱离,原位置保留 ✅ 脱离,原位置被其他元素占据
定位参照物 自身原本的位置 最近的非 static 祖先(如果没有,则视口)
能否用 top/left 移动 能,相对于自身原位置 能,相对于参照物
常见用途 作为绝对定位的容器、微调位置 浮层、角标、弹出菜单
3.3.5 一个帮你记忆的生活类比
  • **relative**:就像你站在排队的位置上,可以稍微往前探一点身子,但你的脚还在原地(别人不能占你的位)。
  • **absolute**:你从队伍里走出来,站在某个参照物(比如墙壁)旁边。你的原位置立刻被后面的人占了。
  • relative + 子 absolute:你对墙壁(父元素)说:“我要站在你右上角”。墙壁说:“好,我原地不动,你相对于我站。”
3.3.6 一个特殊但重要的点

如果某个祖先元素设置了 transformperspectivefilter 等属性,它会成为 absolute 的参照物(类似 relative),这有时会导致预期外的定位。


总结一句话

  • relative 是“相对自己原位置微调,不脱队”。
  • absolute 是“脱队去找最近的带定位的祖先,没有就找视口”。
  • 组合使用时,父 relative 给子 absolute 当参照物。

3.4 position: fixed(钉在屏幕,不跟页面走)

生活类比:
把便签纸贴在你的手机屏幕上,不管页面怎么滑,便签都在同一个位置。

你会看到的效果:

  • 元素固定在视口(viewport)上
  • 页面滚动时它不动
  • 也脱离文档流,可能遮挡内容

典型场景:

  • 回到顶部按钮
  • 悬浮客服入口

一句话判断:
想“永远在屏幕可见区域”就用 fixed

3.5 position: sticky(先正常滚,后吸顶)

生活类比:
便利贴一开始贴在文档某一行,滚动到顶部后它被“吸”住,不再继续上去。

你会看到的效果:

  • 在阈值前,它和普通元素一样参与文档流
  • 达到阈值(如 top: 0)后,像 fixed 一样吸附
  • 常需要配 z-index 和背景色,避免被内容盖住

典型场景:

  • 吸顶导航
  • 左侧目录跟随

一句话判断:
想要“滚动到某点才固定”,选 sticky


4. 你当前页面里的实战映射

4.1 吸顶导航(sticky)

.topbar {
  position: sticky;
  top: 0;
  z-index: 10;
}

解释:导航滚动到页面顶部后会“钉住”。

4.2 角标(relative + absolute)

.badge-card {
  position: relative;
}

.badge {
  position: absolute;
  top: -10px;
  right: -10px;
}

解释:角标相对卡片定位,而不是相对整个页面乱跑。

4.3 回到顶部按钮(fixed)

.back-top {
  position: fixed;
  right: 16px;
  bottom: 16px;
}

解释:无论页面滚动到哪里,按钮都固定在屏幕右下角。


5. 常见坑(重点)

  1. absolute 位置跑偏
  • 原因:父元素没设 position: relative
  1. sticky 不生效
  • 常见原因:没写 top;或父容器有 overflow 限制
  1. fixed 挡住内容
  • 解决:给主内容留底部空间,或调整按钮位置
  1. 层级覆盖异常
  • 解决:配合 z-index 控制层级(前提是元素有定位)

6. 提交前自测清单

  • 顶部导航滚动时可吸附
  • 角标稳定在卡片右上角
  • 回到顶部按钮始终固定在右下角
  • 手机宽度下无明显遮挡或溢出
  • 你能说清这四种定位的适用场景

7. 今天的学习产出模板(可复制到 README)

## Day4 学习总结(Position)

### 我做了什么
- 实现 sticky 吸顶导航
- 实现 relative + absolute 角标
- 实现 fixed 回到顶部按钮

### 我学会了什么
- 四种常见定位方式的差异
- absolute 的参照物查找规则
- sticky 的触发条件

### 我遇到的问题
- (填写你今天遇到的问题)

### 我如何解决
- (填写解决过程)

8. CSS 选择器速记(结合 .fixed-demo

你在 Day4 页面里看到这段:

.fixed-demo {
  position: fixed;
  top: 12px;
  right: 12px;
}

.fixed-demo 里的 . 是什么?

. 表示 class 选择器,意思是:

  • 选中所有 class="fixed-demo" 的元素
  • 给这些元素应用样式

对应 HTML:

<div class="fixed-demo">我是 fixed 对照条</div>

常见选择器符号(新手高频)

  • .demo:class 选择器(最常用)
  • #demo:id 选择器(页面唯一)
  • div:标签选择器
  • A B:后代选择器(A 内任意层级的 B)
  • A > B:子代选择器(A 的直接子元素 B)

一句话记忆

  • . 找 class
  • # 找 id
  • 空格找后代
  • > 找亲儿子(直接子元素)

9. HTML 多个 class:class="card badge-card"

它是什么意思?

class="card badge-card" 表示这个元素同时拥有两个 class

  • card
  • badge-card

在 CSS 里,只要选择器匹配,就会同时应用:

.card { ... }
.badge-card { ... }

为什么要这样写?

常见原因是“组合样式”:

  • card:通用卡片外观(白底、边框、圆角、内边距)
  • badge-card:额外加 position: relative,给角标当定位参照物

这样拆分后,别的卡片可以只复用 card,不必复制一堆样式。

如果两个规则冲突了怎么办?

如果 .card.badge-card 都写了同一个属性(比如 padding),最终生效取决于:

  1. **选择器优先级(specificity)**更高者优先
  2. 优先级相同,通常 后写的规则覆盖先写的规则(同文件内从上到下)

CSS 里还有一种“链式 class”(可选进阶)

.card.badge-card {
  /* 必须同时有 card 和 badge-card 才会命中 */
}

这和 HTML 写多个 class 是配套的,用来表达“只在特定组合下生效”的样式。

更系统的解释请看下一节:## 10. CSS 优先级 + 链式选择器(进阶但很有用)


10. CSS 优先级 + 链式选择器(进阶但很有用)

10.1 链式选择器是什么?

链式选择器(也叫“复合选择器”的一种常见写法):

.card.badge-card { }

含义:元素必须同时满足

  • class="card"
  • 也有 class="badge-card"

所以它比单独的 .card 更“挑剔”,命中范围更小。

对比:

<section class="card">A</section>
<section class="card badge-card">B</section>
  • .card:A 和 B 都会命中
  • .card.badge-card:只有 B 命中

10.2 链式选择器会不会让优先级变高?

会。链式 class 相当于把多个 class 条件叠在一起,通常比单个 class 更具体

直觉记忆:

  • .card:1 个 class
  • .card.badge-card:2 个 class(更具体)

所以在冲突时,.card.badge-card 往往更容易赢过 .card

10.3 CSS 优先级(specificity)到底比什么?

当两条规则都设置了同一个属性(比如 padding),浏览器要决定用哪条,会先看 优先级,再看 书写顺序(同优先级时,后写覆盖先写)。

新手最常用的优先级直觉(从低到高):

  1. 标签选择器(如 divsection
  2. class 选择器(如 .card
  3. id 选择器(如 #intro
  4. 行内样式style="...",一般不推荐大面积使用)
  5. **!important**(尽量别当常规武器)

说明:真实计算比这个更细(还会统计选择器里 class/id/标签的数量),但上面的顺序足够你日常排错。

10.4 两个很容易踩坑的点

  1. 以为“写在后面就一定赢” 不一定。如果对方选择器优先级更高,你写在后面也可能无效。
  2. 链式选择器写错 .card .badge-card(中间有空格)是后代选择器,不是链式选择器。
    .card.badge-card(中间没空格)才是“同时有两个 class”。

10.5 结合你 Day4 页面的一个判断练习

假设同时存在:

.card { padding: 16px; }
.badge-card { padding: 24px; }
.card.badge-card { padding: 20px; }

对一个 class="card badge-card" 的元素:

  • 三个规则都匹配
  • 最终 padding 通常会落在 .card.badge-card(更具体)

如果你发现结果不符合预期,打开 DevTools 看 Computed 面板,能看到最终生效规则与被覆盖原因。


11. border-radius: 999px 是什么“magic”?

它不是魔法,而是一个常见技巧:
把圆角半径写得非常大,让浏览器自动夹到可用最大值。

11.1 为什么写 999px 也不会“溢出”?

浏览器会做限制:圆角不可能超过元素几何允许的范围。
所以你写 999px,实际会被“裁到最大可行圆角”。

效果通常是:

  • 长条按钮 -> 胶囊形(两端很圆)
  • 接近正方形的按钮 -> 接近圆形

11.2 和 border-radius: 50% 有什么区别?

  • 999px:给“很大固定值”,常用于按钮、标签,尺寸变化时也容易保持圆润
  • 50%:按元素自身尺寸比例计算,常用于正方形头像变圆

一句话理解:
999px 更像“我要尽可能圆”,50% 更像“按比例圆”。

11.3 在你 Day4 页面里的实际用途

你的“回到顶部”按钮如果用了:

.back-top {
  border-radius: 999px;
}

它会变成胶囊风格,更像悬浮操作按钮。

11.4 常见使用场景

  • 悬浮按钮(回到顶部、客服入口)
  • 小标签(Tag / Badge)
  • 导航中的胶囊按钮

11.5 小提醒

border-radius 只影响“圆角外观”,不影响元素布局流。
你仍需要配合 paddingline-heightwidth/height 去控制按钮最终形态。


附录:完整 index.html 代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Day4 Position 练习</title>
    <style>
      *, *::before, *::after {
        box-sizing: border-box
      }

      .fixed-demo {
        position: fixed;
        top: 0;
        right: 20px;
        z-index: 100;
        background: yellow;
        padding: 12px 16px;
        border-bottom: 1px solid #e5e5e5;
      }

      .pre-scroll {
        height: 280px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #475569;
        background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%);
        border-bottom: 1px solid #bfdbfe;
      }

      .topbar {
        position: sticky;
        top: 0;
        z-index: 10;
        background: #ffffff;
        border-bottom: 1px solid #e5e7eb;
      }

      .topbar-inner {
        max-width: 960px;
        margin: 0 auto;
        padding: 12px 16px;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }

      .topbar a {
        text-decoration: none;
        color: #1d4ed8;
        margin-left: 10px;
      }

      .back-top {
        position:fixed;
        right: 16px;
        bottom: 16px;
        border: 1px solid #1d4ed8;
        border-radius: 999px;
        background: #2563eb;
        color: #fff;
        text-decoration: none;
        padding: 10px 14px;
      }

      .back-top:hover {
        background: #1d4ed8;
      }

      .filler {
        min-height: 1200px;
      }

      .container {
        /* 设置了最大宽度,并且居中显示,并且有内边距 */
        max-width: 960px;
        margin: 0 auto;
        padding: 24px 16px 80px;
      }

      /* 卡片样式 */
      .card {
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 10px;
        padding: 16px;
        margin-bottom: 16px;
      }

      /* 角标卡片样式 */
      .badge-card {
        position: relative;
      }

      /* 角标样式 */
      .badge {
        /* 绝对定位,相对于父元素 */
        position: absolute;
        /* 距离父元素上边10px */
        top: -10px;
        /* 距离父元素右边10px */
        right: -10px;
        /* 背景颜色 */
        background: #ef4444;
        /* 文字颜色 */
        color: #fff;
        /* 字体大小 */
        font-size: 12px;
        /* 内边距 */
        padding: 4px 8px;
        /* 圆角 */
        border-radius: 999px;
      }
    </style>
  </head>
  <body>
    <div class="fixed-demo">我是fixed(一直钉在屏幕右上角)</div>

    <section class="pre-scroll">
      <p>先向下滚动,再观察 topbar 何时开始吸顶</p>
    </section>

    <header class="topbar">
      <div class="topbar-inner">
        <strong>Day4 Position 实战</strong>
        <nav>
          <a href="#intro">介绍</a>
          <a href="#absolute-relative">角标</a>
          <a href="#sticky-fixed">笔记</a>
        </nav>
      </div>
    </header>

    <main class="container">
      <section class="card" id="intro">
        <h1>Day4 Position 练习页面</h1>
        <p>本日目标:掌握 relative / absolute / fixed / sticky 的使用场景。</p>
      </section>
      <section  class="card badge-card" id="absolute-relative">
        <span class="badge">NEW</span>
        <h2>absolute + relative 角标示例</h2>
        <p>父元素用 <code>position: relative</code>,角标用 <code>position: absolute</code></p>
      </section>
      <section class="card" id="sticky-fixed">
        <h2>sticky + fixed 说明</h2>
        <p>顶部导航使用 <code>position: sticky</code>,滚动到顶部后保持可见。</p>
        <p>右下角按钮使用 <code>position: fixed</code>,始终固定在视口位置。</p
      </section>
      <section class="filler"></section>

      <a href="#intro" class="back-top">回到顶部</a>
    </main>
    
  </body>

</html>

Promise原理、手写与 async、await

Promise原理、手写与 async、await

1. 为什么需要 Promise?

JavaScript 是单线程语言,为了避免阻塞 UI,大量操作(网络请求、定时器、文件读写)被设计为异步。早期使用回调函数,但多个异步任务嵌套会导致“回调地狱”:

getUser(1, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

小结:回调函数的缺点

  • 嵌套复杂(回调地狱问题)。
  • 难以管理错误(多个异步嵌套后,错误处理困难)。
  • 可读性差(功能逻辑分散,难以理解代码流程)。

Promise 通过状态机链式调用,将异步代码写得像同步一样清晰,解决了回调地狱、错误处理困难和难以组合的问题。


2. Promise 核心概念

2.1 三种状态

状态 含义 触发方式 是否可逆
pending 初始状态,未完成 new Promise 可变为 fulfilled/rejected
fulfilled 操作成功 调用 resolve(value) 不可变
rejected 操作失败 调用 reject(reason) 不可变

小结:Promise 的状态特性

  • pending 转为 fulfilledrejected 后状态不能再变。
  • resolve(value) → 将状态变为 fulfilled
  • reject(reason) → 将状态变为 rejected

2.2 实例方法

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('成功'), 1000);
});

p.then(
  value => console.log(value),  // 成功回调
  reason => console.error(reason) // 失败回调
).catch(error => {
  // 捕获链上任何未处理的错误
}).finally(() => {
  // 无论成败都执行
});

2.3 promise执行流程图

小结:实例方法
  1. then: 接收 fulfilledrejected 回调,返回新的 Promise。
  2. catch: 用于捕获链中的失败(代替 then(null, onRejected))。
  3. finally: 无论成功或失败都执行,不改变返回值。

3. JS事件循环与Promise

Promise 的回调是异步执行的,会被放入微任务队列(Microtask Queue),优先级高于宏任务(如 setTimeout)。

示例:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 4 3 2
小结:事件循环与 Promise 的关系
  1. 同步代码 → 微任务队列 → 宏任务队列。
  2. Promise 的 then 回调属于微任务,优先级高于宏任务。

4. 链式调用与错误处理

4.1 值传递与穿透

每个 then 返回一个新 Promise,上一个 then 的返回值会传递给下一个 then

Promise.resolve(1)
  .then(x => x + 1)       // 返回 2
  .then(x => { throw x }) // 抛出错误
  .catch(err => err + 1)  // 捕获错误,返回 3
  .then(console.log);     // 输出 3

如果 then 没有传入回调,值会穿透:

Promise.resolve(1).then().then(v => console.log(v)); // 输出 1
场景示例:异步加载资源
Promise.resolve('开始加载')
  .then(() => fetch('/user')) // 模拟异步数据请求
  .then(res => res.json())
  .then(data => console.log('加载完成:', data))
  .catch(err => {
    console.error('加载失败:', err);
  });

4.2 错误处理最佳实践

  • 始终使用 .catch 而不是then的第二个参数:
// 推荐做法(catch 更全面)
Promise.resolve()
  .then(() => { throw new Error('失败'); })
  .catch(err => console.log('捕获错误:', err));
  • .catch放在链式末尾,捕获前面所有错误。

5. 静态方法:组合多个 Promise

常见方法与场景:

方法 行为 典型场景
Promise.all 全部成功 → 结果数组;任一失败 → 立即 reject 多个请求必须全部成功
Promise.allSettled 等待所有 settled,返回每个的状态和值/原因 不关心个别失败,只要全部完成
Promise.race 返回最先 settled 的结果(成功或失败) 超时控制
Promise.any 返回第一个成功的结果;全部失败 → AggregateError 多个备用接口
Promise.resolve 将值转为 resolved Promise 统一异步处理
Promise.reject 返回 rejected Promise 快速抛出异步错误
示例:请求超时控制(Promise.race
function fetchWithTimeout(url, timeout = 3000) {
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('请求超时')), timeout)
  );
  return Promise.race([fetch(url), timeoutPromise]);
}

fetchWithTimeout('/data')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

6. 手写 Promise

小结:手写 Promise 的核心要点

  1. 状态管理:pendingfulfilled/rejected
  2. 链式调用:then 返回新的 Promise,并传递返回值。
  3. 微任务队列:通过 queueMicrotask 模拟异步执行。
  4. 错误处理:支持 catch 和错误冒泡。
class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach(fn => fn());
    };

    try { executor(resolve, reject); } catch (err) { reject(err); }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      const runFulfilled = () => {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolve(x);
          } catch (err) { reject(err); }
        });
      };
      const runRejected = () => {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolve(x);
          } catch (err) { reject(err); }
        });
      };
      if (this.state === 'fulfilled') runFulfilled();
      else if (this.state === 'rejected') runRejected();
      else {
        this.onFulfilledCallbacks.push(runFulfilled);
        this.onRejectedCallbacks.push(runRejected);
      }
    });
    return promise2;
  }

  catch(onRejected) { return this.then(null, onRejected); }

  static resolve(value) { return new MyPromise(resolve => resolve(value)); }
  static reject(reason) { return new MyPromise((_, reject) => reject(reason)); }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let count = 0;
      if (promises.length === 0) return resolve(results);
      promises.forEach((p, i) => {
        MyPromise.resolve(p).then(
          val => { results[i] = val; count++; if (count === promises.length) resolve(results); },
          reject
        );
      });
    });
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      promises.forEach(p => MyPromise.resolve(p).then(resolve, reject));
    });
  }
}

7. Async/Await:更优雅的异步方案

示例:用户数据加载与嵌套调用

async function getData(id) {
  try {
    const user = await fetch(`/user/${id}`).then(res => res.json());
    const posts = await fetch(`/posts/${user.id}`).then(res => res.json());
    console.log(`用户:${user.name} 的文章:`, posts);
  } catch (err) {
    console.error('加载失败:', err);
  }
}

小结:async/await 的优势

  1. 语法糖,异步代码像同步代码。
  2. 内置错误处理(try/catch),更自然。
  3. 避免 Promise 链嵌套问题。

8. 常见追问与扩展

  • then为什么返回新 Promise? 保证状态不可变,支持链式调用,避免修改原 Promise 的值。
  • 微任务如何模拟? 可用 queueMicrotaskPromise.resolve().then();手写时也可用 setTimeout 但需说明那是宏任务。
  • allrace 的区别? all 等待全部成功或任一失败;race 返回最先 settled 的结果。
  • 如何实现 finally? 无论成败都执行,且不改变返回值,可用 then 链式调用实现。
  • async/await相比 Promise 有什么优势? 代码更简洁、可读性更高,错误处理使用 try/catch 更自然;但本质上仍是 Promise 的语法糖。
  • await后面的代码何时执行? await表达式本身会立即执行其右侧的 Promise(或非 Promise 值),然后暂停当前 async 函数的执行,将后续代码包装为一个微任务。当 await 的 Promise 决议后,该微任务被推入微任务队列,在所有同步代码执行完毕后执行。

错误处理案例:未捕获承诺错误的危害

async function bad() {
  const data = await fetch('/api'); // 如果 fetch 失败,错误会被吞掉
  return data;
}

解决方案:

  • 准确使用 try/catch 或全局事件:
window.addEventListener('unhandledrejection', (event) => {
  console.error('未捕获的 Promise 错误:', event.reason);
});

9. 全文总结与面试技巧

小结:主要知识点

知识点 核心要点
Promise 状态机 pendingfulfilled/rejected,不可逆。
链式调用 每个 then 返回新 Promise,值穿透,错误冒泡。
微任务 then/catch/finally 回调进入微任务队列,优先级高于宏任务。
async/await 更简洁的异步处理,避免嵌套。

uni-task - 轻量级团队任务管理系统

痛点:团队协作工具太贵了

作为一个小团队的负责人,你是否也有过这样的困扰:

  • Tower、Teambition、Worktile 这些工具确实好用,但每人每月数十块,一年下来团队开支上千甚至上万
  • 免费版功能受限,人数受限,总是用得不痛快
  • 数据存在别人的服务器上,总觉得不踏实
  • 想要个简单的任务管理,结果给你塞一堆用不到的功能

今天给大家介绍一个我开源的项目 uni-task —— 一个基于 uni-app + uniCloud 的轻量级团队任务管理系统,完全开源,自己部署,成本趋近于零


先看效果

PC端

项目列表:

任务列表:

支持子任务和评论:

任务动态自动记录:

快捷查看“我的任务“:

微信小程序


为什么选择 uni-task?

1. 完全开源,永久免费

项目采用 MIT 协议开源,你可以:

  • 免费用于个人或商业项目
  • 自由修改和定制
  • 不用担心哪天涨价或停服

GitHub 地址github.com/hbcui1984/u…

2. 成本极低

来算一笔账:

方案 10 人团队年费用
Tower 专业版 ¥39.9元/人/月
Teambition 企业版 ¥399元/人/月
uni-task 按量计费,低于 1.0元/人/月

uni-task 基于 uniCloud 云开发,使用支付宝云/阿里云/腾讯云的 Serverless 服务:

  • 开发测试阶段:完全免费
  • 小团队日常使用:基本在免费额度内
  • 即使超出免费额度:按量付费,一年可能也就几十块钱

对比一下:Tower 专业版 10 人团队一年 4788 元,uni-task 可能一年只要一杯奶茶钱。

3. 一套代码,全端运行

基于 uni-app 开发,一套代码搞定:

  • H5 网页版
  • 微信小程序
  • 支付宝小程序
  • iOS App
  • Android App
  • 甚至 HarmonyOS

团队成员用什么设备都能用,不用单独开发多个版本。

4. 功能够用,不臃肿

专注于任务管理的核心功能,不搞花里胡哨:

项目管理

  • 创建、编辑、归档项目
  • 成员管理(管理员/普通成员)
  • 邀请码快速加入

任务管理

  • 任务增删改查
  • 状态流转(待处理 → 进行中 → 已完成)
  • 优先级设置(高/中/低)
  • 截止日期与逾期提醒
  • 任务分组与拖拽排序
  • 子任务支持
  • 附件上传

团队协作

  • 任务分配
  • 操作日志自动记录
  • 项目动态实时展示
  • 「我的任务」个人视图

5. 数据完全自主可控

  • 数据存储在你自己的云服务空间
  • 随时可以导出和备份
  • 不用担心第三方服务商跑路
  • 符合企业数据安全合规要求

6. 易于部署和二次开发

技术栈主流且成熟:

类别 技术
前端框架 uni-app (Vue 3)
状态管理 Vuex
UI 组件库 uni-ui
后端服务 uniCloud
用户认证 uni-id

有 Vue 基础的开发者可以轻松上手,根据自己团队的需求进行定制。


快速部署指南

整个部署过程大概 3 分钟搞定。

1. 导入项目

从DCloud插件市场下载,地址:ext.dcloud.net.cn/plugin?id=2…

或者从github上下载源码:

git clone https://github.com/hbcui1984/uni-task.git

2. 关联云服务空间

  1. 右键点击 uniCloud-alipay 目录
  2. 选择「关联云服务空间」
  3. 如没有云服务空间,请先创建一个

3. 上传云函数

  1. 右键点击 uniCloud-aliyun/cloudfunctions 目录
  2. 选择「上传所有云函数、公共模块及 actions」

4. 初始化数据库

  1. 右键点击 uniCloud-aliyun/database 目录
  2. 选择「初始化云数据库」

5. 运行项目

在 HBuilderX 中直接运行到浏览器/模拟器/真机。

搞定!


进阶:个性化定制

更换主题色

项目默认使用 Vue 绿(#42b983),想换成你公司的品牌色?改两行代码:

// common/theme.js
colors: {
    primary: '#你的品牌色',
}

预设主题色供参考

主题 色值 效果
Vue 绿(默认) #42b983 清新专业
天空蓝 #2979ff 科技感
珊瑚橙 #ff6b6b 活力热情
薰衣紫 #7c4dff 优雅神秘

[截图占位:不同主题色效果对比]

功能扩展

项目架构清晰,方便二次开发:

  • 云对象在 uniCloud-alipay/cloudfunctions/ 目录
  • 页面在 pages/ 目录
  • 组件在 components/ 目录

适合谁用?

  • 初创团队:资金有限,但需要正规的项目管理
  • 小型工作室:几个人的团队,不想为工具花太多钱
  • 独立开发者:自己或小团队协作使用
  • 企业内部项目组:对数据安全有要求
  • 技术团队:想要可定制的任务管理工具
  • 学生团队:课程项目、毕设协作

与 Tower 等产品的对比

特性 uni-task Tower/Teambition
价格 开源免费 按人按月付费
数据归属 完全自主 存储在服务商
功能复杂度 轻量够用 功能丰富但可能用不到
定制能力 源码可改 无法定制
多端支持 全端覆盖 主要是 Web
部署方式 自有云空间 SaaS

写在最后

uni-task 不是要取代 Tower 这类成熟产品,它们功能更全面,服务更完善。

但如果你:

  • 预算有限
  • 只需要核心的任务管理功能
  • 希望数据掌握在自己手里
  • 有一定的技术能力进行部署和维护

那 uni-task 可能是个不错的选择。

开源地址github.com/hbcui1984/u…

欢迎 Star、Fork、提 Issue 和 PR!

如果觉得有用,也欢迎分享给你身边有需要的朋友。


Q&A

Q: 需要什么技术基础?

A: 会基本的前端开发即可。如果只是部署使用,跟着文档操作就行。

Q: uniCloud 收费吗?

A: 有免费额度,小团队日常使用基本够了。超出按量计费,价格很便宜。

Q: 支持私有化部署吗?

A: 支持,但需购买uni云开发软件版的商业授权,详见


Vue3+TS 中 this 指向机制全解析(实战避坑版)

Vue3 结合 TypeScript 开发时,this 指向的核心逻辑的是:this 指向由代码编写场景(选项式API/组合式API)决定,TS 的类型校验会进一步约束 this 的可访问范围,其本质是 JavaScript this 绑定规则(隐式绑定、箭头函数无绑定等)在 Vue3 框架中的延伸,同时 Vue3 对不同 API 场景的 this 做了针对性优化,避免开发者踩坑。

与 Vue2+TS 不同,Vue3 支持选项式API和组合式API两种写法,两种写法中 this 指向差异极大,且 TS 的 strict 模式会直接影响 this 的类型推导,这也是开发中最易出错的点,下面分场景详细拆解,搭配 TS 实战代码说明。

一、核心前提:TS 配置对 this 指向的影响

Vue3+TS 项目中,tsconfig.json 的配置会直接决定 this 的类型校验逻辑,其中最关键的是 strict 相关配置,这是避免 this 类型模糊(any)的核心:

// tsconfig.json 关键配置
{
  "compilerOptions": {
    "strict": true, // 开启严格模式(推荐),会自动开启 noImplicitThis
    "noImplicitThis": true, // 禁止隐式 this(单独开启也可),避免 this 被推导为 any
    "isolatedModules": true, // Vite 项目必需,不影响 this 指向,但影响 TS 编译
    "verbatimModuleSyntax": true // 推荐,与 isolatedModules 兼容,优化类型推导
  }
}

strict: falsenoImplicitThis: false时,TS 会将未明确类型的 this 推导为 any,此时即使 this 指向错误,TS 也不会报错,容易引发运行时问题;开启严格模式后,TS 会强制校验 this 的指向和可访问属性,契合 Vue3 的 this 机制。

二、选项式API(Options API)中 this 指向机制(Vue3+TS)

Vue3 选项式API 的 this 指向与 Vue2 基本一致,核心是 this 始终指向当前组件实例(ComponentPublicInstance) ,TS 会自动推导 this 类型,无需手动声明,且所有组件选项(data、methods、computed、watch 等)中的 this 均指向同一实例。

Vue3 官方为选项式API 提供了完善的类型支持,通过 defineComponent 包裹组件,TS 可自动推导 this 的类型,包含组件的所有属性、方法、props、emit 等,无需手动定义。

1. 基础场景:组件选项中的 this 指向

在 data、methods、computed、watch、生命周期钩子(created、mounted 等)中,this 均指向当前组件实例,可直接访问实例上的所有属性和方法,TS 会自动校验属性的合法性。

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

// 用 defineComponent 包裹,TS 自动推导 this 类型
export default defineComponent({
  // props 定义(TS 会自动将 props 挂载到 this 上)
  props: {
    title: {
      type: String,
      required: true
    }
  },
  // data 函数:this 指向组件实例,TS 推导 this 为 ComponentPublicInstance
  data() {
    return {
      count: 0,
      message: 'Vue3+TS this 指向'
    }
  },
  // methods:this 指向组件实例,可访问 data、props、其他 methods
  methods: {
    increment() {
      this.count++ // TS 校验通过,可直接访问 data 中的 count
      console.log(this.title) // TS 校验通过,可直接访问 props 中的 title
      this.logMessage() // 可调用当前组件的其他方法
    },
    logMessage() {
      console.log(this.message)
    }
  },
  // 计算属性:this 指向组件实例
  computed: {
    fullMessage() {
      return `${this.title} - ${this.message}` // TS 自动校验 this 上的属性
    }
  },
  // 生命周期钩子:this 指向组件实例
  mounted() {
    this.increment() // 可直接调用 methods 中的方法
  },
  // watch:this 指向组件实例
  watch: {
    count(newVal) {
      console.log('count 变化:', newVal, this.count) // 可访问当前实例属性
    }
  }
})
</script>

关键说明:

  • data 函数中,this 指向组件实例,且 data 返回的响应式数据会被自动挂载到实例上,可通过 this.$data.xxx 访问,也可直接通过 this.xxx 访问(Vue 自动代理),以 _$ 开头的属性不会被代理,需通过 this.$data 访问。
  • methods、computed、watch 中的 this 均由 Vue 自动绑定为组件实例,即使在方法中嵌套普通函数,只要不修改 this 绑定,this 仍指向实例。
  • 通过 defineComponent 包裹后,TS 会自动推导 this 的类型为 ComponentPublicInstance,包含 Vue 内置的 $props$emit$refs 等属性,避免 this 为 any 类型。

2. 易错场景:this 指向丢失(选项式API)

选项式API 中,this 丢失的核心原因是 手动修改了函数的 this 绑定,常见于嵌套普通函数、定时器、Promise 回调等场景,TS 会在严格模式下报错,提示 this 类型不匹配。

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

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  methods: {
    wrongDemo() {
      // 错误1:普通函数嵌套,this 指向 window(浏览器环境),TS 报错:this 类型为 Window,无 count 属性
      setTimeout(function() {
        this.count++ // ❌ TS 报错:Property 'count' does not exist on type 'Window & typeof globalThis'
      }, 1000)

      // 错误2:箭头函数定义 methods 方法,this 不绑定组件实例,指向外层作用域(undefined)
      const wrongMethod = () => {
        console.log(this.count) // ❌ TS 报错:this 为 undefined,无 count 属性
      }
      wrongMethod()

      // 正确写法1:使用箭头函数作为回调,继承外层 this(组件实例)
      setTimeout(() => {
        this.count++ // ✅ 正确,this 指向组件实例
      }, 1000)

      // 正确写法2:保存 this 到变量,避免绑定丢失
      const self = this
      setTimeout(function() {
        self.count++ // ✅ 正确,self 指向组件实例
      }, 1000)

      // 正确写法3:使用 bind 绑定 this 到组件实例
      setTimeout(function() {
        this.count++
      }.bind(this), 1000) // ✅ 正确,bind 强制绑定 this 为组件实例
    }
  }
})
</script>

补充说明:Vue3 选项式API 中,methods 中的方法会被 Vue 自动绑定 this 为组件实例,因此直接调用方法(如 this.increment())不会出现 this 丢失;但如果将方法作为回调传递(如 btn.addEventListener('click', this.increment)),会导致 this 丢失,需通过 this.increment.bind(this) 绑定。

三、组合式API(Composition API)中 this 指向机制(Vue3+TS)

组合式API(<script setup lang="ts"> 或 setup 函数)是 Vue3 的核心写法,其 this 指向与选项式API 完全不同,核心规则是:setup 函数及其中定义的函数、回调中,this 均为 undefined,TS 会明确推导 this 类型为 undefined,禁止通过 this 访问组件实例。

这是 Vue3 组合式API 的设计初衷——摒弃 this 依赖,通过显式导入 API(ref、reactive、onMounted 等)和返回值,实现逻辑复用和类型安全,避免 this 指向混乱。

1. 基础场景:setup 中的 this 指向

无论是 setup 函数(非语法糖)还是 <script setup lang="ts">(语法糖),this 均为 undefined,TS 会严格校验,禁止通过 this 访问任何属性,所有响应式数据、方法均需显式定义和使用。

<!-- 语法糖写法(推荐):<script setup lang="ts"> -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 定义响应式数据
const count = ref(0)
const message = ref('Vue3+TS 组合式API')

// 定义方法
const increment = () => {
  count.value++ // 直接操作响应式数据,无需 this
  console.log(message.value)
}

// 生命周期钩子:无 this,直接调用方法、操作数据
onMounted(() => {
  increment()
  console.log(this) // undefined,TS 推导 this 为 undefined
})

// 错误写法:试图通过 this 访问数据,TS 报错
const wrongDemo = () => {
  console.log(this.count) // ❌ TS 报错:this is undefined
}
</script>
<!-- 非语法糖写法:setup 函数 -->
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'

export default defineComponent({
  setup() {
    const count = ref(0)
    const increment = () => {
      count.value++
    }

    onMounted(() => {
      increment()
      console.log(this) // undefined
    })

    // 必须返回,模板才能访问
    return {
      count,
      increment
    }
  }
})
</script>

关键说明:

  • setup 函数在组件实例创建前(beforeCreate 之前)执行,此时组件实例尚未初始化,因此 this 为 undefined,这是 Vue3 的设计逻辑,目的是让开发者脱离 this 依赖。
  • <script setup lang="ts"> 语法糖中,无需手动返回数据和方法,TS 会自动推导其类型,模板可直接访问;非语法糖写法需手动返回,否则模板无法访问。
  • 组合式API 中,所有响应式数据(ref、reactive)、方法均为局部变量,无需挂载到 this 上,直接通过变量名访问即可,TS 会严格校验变量的类型和可用性。

2. 特殊场景:需访问组件实例的解决方案

组合式API 中禁止直接使用 this,但实际开发中可能需要访问组件实例的内置属性(如 $refs$emit$route 等),此时可通过 getCurrentInstance API 获取组件实例,而非使用 this,TS 需手动指定类型,避免类型报错。

<script setup lang="ts">
import { ref, getCurrentInstance } from 'vue'
// 导入组件内部实例类型,用于类型断言
import type { ComponentInternalInstance } from 'vue'

// 获取组件内部实例,通过类型断言指定类型
const instance = getCurrentInstance() as ComponentInternalInstance

// 访问实例内置属性(替代 this.$refs、this.$emit 等)
const handleClick = () => {
  // 替代 this.$emit
  instance.emit('change', 'hello')
  // 替代 this.$refs
  console.log(instance.refs)
  // 替代 this.$props
  console.log(instance.props)
}

// 注意:不推荐过度使用 getCurrentInstance,优先通过显式 API 实现需求
// 如 $emit 可直接通过 defineEmits 定义,无需访问实例
const emit = defineEmits(['change'])
const handleEmit = () => {
  emit('change', 'hello') // 更推荐的写法,无需依赖实例
}
</script>

补充说明:getCurrentInstance 返回的是组件内部实例(ComponentInternalInstance),而非选项式API 中的公开实例(ComponentPublicInstance),其部分属性(如 ctx)在生产环境打包后可能失效,因此仅在特殊场景使用,优先通过 Vue3 提供的显式 API(defineEmits、defineProps、useRoute 等)替代。

3. 易错场景:组合式API 中误用 this

组合式API 中,开发者容易习惯性使用 this,尤其是从选项式API 迁移过来的场景,TS 会直接报错,常见易错场景及正确写法如下:

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

// 错误1:试图通过 this 访问响应式数据
const count = ref(0)
const wrong1 = () => {
  this.count.value++ // ❌ TS 报错:this is undefined
}

// 正确1:直接访问变量
const right1 = () => {
  count.value++ // ✅ 正确
}

// 错误2:在 reactive 对象中使用 this
const user = reactive({
  name: '张三',
  // 错误:reactive 对象中的方法,this 指向 user 本身,而非组件实例,TS 推导类型错误
  sayHello: function() {
    console.log(this.name) // 看似可用,但 this 指向 user,无法访问组件其他数据/方法
  }
})

// 正确2:使用箭头函数,避免 this 绑定,直接访问外部变量
const userRight = reactive({
  name: '张三',
  sayHello: () => {
    console.log(userRight.name) // ✅ 正确,直接访问 reactive 对象
  }
})

// 错误3:定时器回调中误用 this
setTimeout(function() {
  this.count.value++ // ❌ TS 报错:this is undefined
}, 1000)

// 正确3:直接访问变量,箭头函数无需考虑 this
setTimeout(() => {
  count.value++ // ✅ 正确
}, 1000)
</script>

四、Vue3+TS 中 this 指向总结(核心对比)

编写场景 this 指向 TS 类型推导 核心注意点
选项式API(defineComponent 包裹) 当前组件实例(ComponentPublicInstance) 自动推导,包含组件所有属性、方法、props 等 避免用箭头函数定义 methods,避免手动修改 this 绑定,否则会丢失实例指向
组合式API(setup/ undefined 明确推导为 undefined,禁止通过 this 访问任何属性 无需依赖 this,直接访问局部变量;需访问实例用 getCurrentInstance,优先显式 API
选项式API + 组合式API 混合使用 选项式API 中 this 指向实例;setup 中 this 为 undefined 各自独立推导,setup 中无法通过 this 访问选项式API 中的数据/方法 混合写法需注意 this 场景区分,避免交叉使用导致指向混乱

五、实战避坑要点(融入正文,不单独罗列)

  1. 始终开启 TS 严格模式(strict: true),强制校验 this 类型,避免 this 为 any 导致的运行时错误,这是 Vue3+TS 开发的基础配置。

  2. 选项式API 中,禁止用箭头函数定义 data、methods、watch、computed 等组件选项,因为箭头函数不绑定 this,会导致 this 指向外层作用域(undefined 或 window),TS 会直接报错。

  3. 组合式API 中,彻底摒弃 this 思维,所有响应式数据、方法均通过显式定义和访问,无需挂载到实例上,避免习惯性使用 this 导致的 TS 报错。

  4. 当需要访问组件实例内置属性时,优先使用 Vue3 提供的显式 API(如 defineEmits、defineProps、useRoute、useRouter 等),而非 getCurrentInstance,减少对内部实例的依赖,避免生产环境兼容问题。

  5. 回调函数(定时器、Promise、原生事件监听等)中,选项式API 需注意 this 绑定,优先使用箭头函数;组合式API 无需考虑 this,直接访问局部变量即可。

  6. 组件 props 定义后,选项式API 中可通过 this 直接访问,TS 会自动校验;组合式API 需通过 defineProps 定义并显式使用,无需通过 this 访问。

SSR页面上的按钮点不了?Nuxt 懒加载水合揭秘💧

写在开头

Hello吖,各位UU们好!👏

今是2026年03月14日,下午,幽静、无人打扰,刚刷了会手机,但有点看腻了。

然后,今天上午,小编将自己的上一台电脑叫了一个转转来上门回收,2021年款,联想小新R7,本来在APP上预估是能卖两千二左右的,结果线下验机后说只能卖1700了,就没卖,想着再找一个爱回收看看价格,🤔不知道能不能涨点。

还有个事,昨天听朋友说,他网恋成功了,说是在Soul上找的对象,已经线下面基过。唉,这年头...这也能成功?🥶 你们说小编要不要也去试试?🤔

好了,回到正题,今天要来分享的是小编上周工作中排查的一个问题,其实也是比较基础的概念问题,只是小编太久没用了,这次也写出来记录一下,请诸君按需食用哈~

需求背景 💡

最近小编正在做一个 SSR 项目,作为一名 Vue 老玩家,自然就选择 Nuxt 来搞,上次用 Nuxt 还是在上次,时间略久了!😗

整体项目开发进展还算顺利,也就是部署稍微麻烦一丢丢。然而,这天测试同学给我提了个问题:

"页面加载出来后,有时按钮点了没啥反应,总要多点几次或者要等一会才能点。"

小编一开始也按常规思路来:先看控制台有没有报错 —— 结果没有明显的红字错误(因为并不是水合错位报错,只是水合还没执行到那块,事件还没绑上)。于是怀疑是事件没绑好或者代码写错了,又查了一圈事件和逻辑,代码确实没问题!🤔

最后才反应过来:原来是 水合(Hydration) 还没完成,那部分组件还没绑上事件,所以有时候才能点。

什么是水合?

上面说了,按钮点不了是因为水合还没完成。那水合到底是什么?🤔

简单说:服务端先返回 HTML,客户端 JS 加载完后,把事件绑上去,让页面能点、能交互——这个过程就叫水合

下面简单用 CSR 和 SSR 对比一下,帮你建立直觉。

传统 CSR(客户端渲染)

普通的 Vue SPA 应用是这样的:

用户访问页面
  ↓
加载空白 HTML + JSJS 执行,渲染页面
  ↓
用户看到内容,可以交互 

缺点:首屏白屏时间长,SEO 也不友好。

SSR(服务端渲染)

SSR 是这样的:

用户访问页面
  ↓
服务端直接返回完整 HTML
  ↓
用户立刻看到内容(快!)
  ↓
加载 JS,执行"水合"
  ↓
页面变得可交互

优点:首屏快,SEO 友好。

很明显,CSR 和 SSR 是两种不同的取舍,没有谁一定更好,咱们得根据业务场景来选,不要一刀切。❌

问题来了

SSR 有个尴尬的地方:HTML 先出来了,但 JS 还没加载完,事件还没绑定上

用户看到页面了
  ↓
想点按钮 → 点不了 ❌(JS 还没准备好)
  ↓
等 1-2 秒...
  ↓
终于能点了

这就是测试同学遇到的问题!页面出来了,但还处于"僵尸"状态,看得见摸不着。😅

懒加载水合是什么?

既然问题是「要等一会儿才能点」,那有没有办法让首屏更快可交互?小编查了一下 Nuxt 的文档,发现有个功能叫 懒加载水合(Lazy Hydration),专门解决这类问题!

懒加载水合:它还是「水合」——还是把事件绑到服务端 HTML 上,只是不再一次性水合整页,而是按需、分优先级地水合。如,首屏先水合,下面的等需要时再水合。

所以呢,用词上要分清:水合 是整个过程,懒加载水合 是水合的一种策略(延迟一部分组件的水合时机)。

在 Vue 3.5 / Nuxt 里,这个策略常和 异步组件 一起用:异步组件负责延迟加载组件 JS(减包体),懒加载水合负责延迟该组件的水合时机(让首屏先可交互),两个搭配着用。

核心思想:不用一次性把所有组件都水合,按需水合!

比如:

  • 首屏可见的组件 → 立刻水合
  • 非首屏的组件 → 用户滚到那里再水合
  • 低优先级的组件 → 浏览器空闲时再水合

这样,首屏的 JS 体积就小了,水合速度就快了,用户点按钮就不会"卡壳"啦!🎯

Nuxt 中怎么用?

Nuxt 已经内置了懒加载水合的支持,用起来非常简单的!🏃

第1️⃣步:认识 Lazy 组件

在 Nuxt 中,所有放在 components/ 目录下的组件都会被自动导入。如果在组件名前加上 Lazy 前缀,就可以延迟加载:

<template>
  <!-- 普通组件 -->
  <MyComponent />

  <!-- 懒加载组件 -->
  <LazyMyComponent />
</template>

但这只是 懒加载,还不是 懒加载水合!区别在于:

  • 懒加载:延迟加载 JS 代码
  • 懒加载水合:延迟执行水合(JS 可能已经加载了,但不急着绑定事件)

第2️⃣步:添加水合策略

Nuxt 提供了多种水合策略,咱们来看几个常用的:

hydrate-on-visible(可见时水合)

组件进入视口时才水合,适合非首屏内容:

<template>
  <div>
    <h1>首屏内容</h1>

    <!-- 下面的组件要用户滚到这里才会水合 -->
    <LazyComments hydrate-on-visible />
  </div>
</template>

🍊 为什么这么做❓

非首屏的组件,用户不一定马上会看到,何必急着水合呢?等用户滚到那里再说,这样首屏更快。

hydrate-on-interaction(交互时水合)

用户点击/悬停组件时才水合:

<template>
  <!-- 用户点击这个区域时才水合 -->
  <LazyExpensiveComponent hydrate-on-interaction="click" />

  <!-- 或者鼠标悬停时水合 -->
  <LazyChart hydrate-on-interaction="mouseover" />
</template>

hydrate-after(延迟水合)

指定毫秒数后自动水合:

<template>
  <!-- 2 秒后水合 -->
  <LazySidebar :hydrate-after="2000" />
</template>

hydrate-on-media-query(媒体查询水合)

匹配特定媒体查询时水合:

<template>
  <!-- 只在移动端水合 -->
  <LazyMobileMenu hydrate-on-media-query="(max-width: 768px)" />
</template>

hydrate-when(条件水合)

根据条件决定是否水合:

<script setup>
const isReady = ref(false)

// 某个条件触发后
function triggerHydration() {
  isReady.value = true
}
</script>

<template>
  <LazyHeavyComponent :hydrate-when="isReady" />
</template>

第3️⃣步:监听水合完成事件

所有懒加载水合组件都会触发 @hydrated 事件:

<template>
  <LazyComments
    hydrate-on-visible
    @hydrated="onHydrated"
  />
</template>

<script setup>
function onHydrated() {
  console.log('组件水合完成!')
}
</script>

第4️⃣步:小编的实际应用

回到咱们的场景,测试反馈按钮点不了,小编的解决方案是这样的:

<template>
  <div>
    <!-- 首屏重要内容,正常水合 -->
    <Header />
    <MainContent />

    <!-- 非首屏的评论区,懒加载水合 -->
    <LazyComments hydrate-on-visible />

    <!-- 底部推荐,用户悬停时才水合 -->
    <LazyRecommendations hydrate-on-interaction="mouseover" />
  </div>
</template>

这样首屏的 JS 体积就小了,水合速度变快,按钮响应更及时!🎉

💡 小贴士

  • 首屏核心交互内容不要用懒加载水合,会影响用户体验。
  • 适合用在非首屏、低优先级的组件上。
  • 如果组件本身就用了 v-if="false",那就不需要懒加载水合了。

Vue 3.5 原生用法

如果你用的不是 Nuxt,而是纯 Vue 3.5 + 自己搭的 SSR,其实也可以用原生的懒加载水合。

底层原理其实是 Vue 3.5 提供的水合策略,Nuxt 只是在上面封装了一层更易用的 API。

官方文档:传送门

第1️⃣步:导入水合策略

import { defineAsyncComponent, hydrateOnVisible } from 'vue'

第2️⃣步:定义异步组件

const LazyComments = defineAsyncComponent({
  loader: () => import('./Comments.vue'),
  hydrate: hydrateOnVisible()
})

可用的水合策略

策略 说明
hydrateOnIdle() 浏览器空闲时水合
hydrateOnVisible() 进入视口时水合
hydrateOnInteraction('click') 点击时水合
hydrateOnMediaQuery('(max-width:768px)') 媒体查询匹配时水合

用法都差不多,小编就不一一列举了,大家看文档就好~😋





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

从静态页面到动态交互:DOM操作的核心API解析

哈喽大家好,我是心连欣。在现代前端开发中,JavaScript的核心价值早已超越了简单的逻辑计算,它赋予了静态HTML页面“生命”。我们今天深入探讨几个关键的DOM操作技术,这些技术构成了前端交互的基础,从简单的元素选择到复杂的动态内容更新,每一步都体现了JavaScript与HTML的深度融合。

DOM元素选择:querySelector与querySelectorAll的深层逻辑

DOM(文档对象模型)是HTML文档的编程接口,它将页面结构转化为树形节点,允许JavaScript动态访问和修改。在众多选择器方法中,querySelectorquerySelectorAll是最常用且功能强大的工具,它们基于CSS选择器语法,提供了灵活的元素定位能力。

querySelector方法返回文档中匹配指定CSS选择器的第一个元素。如果未找到匹配项,则返回null。这个方法的优势在于其简洁性和与现代CSS的兼容性,例如可以使用类选择器(.class)、ID选择器(#id)或复杂的选择器组合(如ul li:first-child)。

示例代码展示了如何使用querySelector选择ul下的第一个li元素:

javascript

编辑

const list = document.querySelector('ul li');
console.log(list); // 输出第一个匹配的li元素

相比之下,querySelectorAll返回一个静态的NodeList,包含所有匹配指定CSS选择器的元素。这个NodeList不是实时的,这意味着如果DOM结构发生变化,NodeList不会自动更新。这对于批量操作元素非常有用,例如修改所有ol下的li元素的样式。

示例代码展示了如何使用querySelectorAll选择所有ol下的li元素:

javascript

编辑

const olist = document.querySelectorAll('ol li');
console.log(olist); // 输出NodeList,包含所有匹配的li元素

动态内容更新:innerHTML与数组操作的结合

在现代Web应用中,动态内容更新是核心需求之一。innerHTML属性允许我们获取或设置元素内部的HTML内容,这使得我们可以轻松地替换或插入新的DOM结构。然而,直接操作innerHTML需要注意XSS(跨站脚本攻击)风险,尤其是在处理用户输入时。

在抽奖案例中,我们通过innerHTML将随机选择的人员名称插入到指定的span元素中。这种操作不仅更新了页面内容,还触发了浏览器的重绘和回流,影响页面性能。因此,在高频更新场景下,应考虑使用更高效的DOM操作方法,如textContent或DocumentFragment。

one.innerHTML = personArr[random]; // 将随机选择的人员名称插入到span元素中

数组操作在动态内容生成中扮演着重要角色。在抽奖案例中,我们使用splice方法从数组中删除已抽取的人员,确保后续抽奖不会重复。这种操作体现了JavaScript数组的灵活性和强大功能,同时也展示了如何通过数组操作实现业务逻辑。

personArr.splice(random, 1); // 从数组中删除已抽取的人员

总的代码演示:

 <div>
    <strong>年会抽奖</strong>
    <h1>一等奖: <span id="one"></span></h1>
    <h2>二等奖: <span id="two"></span></h2>
    <h3>三等奖: <span id="three"></span></h3>
  </div>
  <script>
    //1。声明数组
    const personArr = ['一一', '二二', '三三', '四四', '五五']
    //2.先做一等奖
    //2.1 随机数,数组的下面
    const random = Math.floor(Math.random() * personArr.length)
    //2.2获取one元素
    const one = document.querySelector('#one')
    //2.3把名字给one
    one.innerHTML = personArr[random]
    //2.4删除这个元素
    personArr.splice(random, 1)

    const random2 = Math.floor(Math.random() * personArr.length)
    //2.2获取two元素
    const two = document.querySelector('#two')
    //2.3把名字给two
    two.innerHTML = personArr[random2]
    //2.4删除这个元素
    personArr.splice(random2, 1)

    const random3 = Math.floor(Math.random() * personArr.length)
    //2.2获取three元素
    const three = document.querySelector('#three')
    //2.3把名字给one
    three.innerHTML = personArr[random3]
    //2.4删除这个元素
    personArr.splice(random3, 1)
  </script>

样式动态修改:style属性的驼峰命名规则

CSS样式是网页视觉表现的核心,而JavaScript通过style属性提供了动态修改元素样式的途径。然而,由于JavaScript的语法限制,CSS属性名在JavaScript中需要转换为驼峰命名法。例如,background-color在JavaScript中应写为backgroundColor

这种命名转换是JavaScript与CSS交互的一个重要细节,体现了不同技术栈之间的语法差异。在实际开发中,这种转换需要开发者特别注意,以避免样式修改失败。

示例代码展示了如何通过style属性修改元素的宽度和背景颜色:

box.style.width = '80px';
box.style.height = '80px';
box.style.backgroundColor = 'black'; // 注意驼峰命名

此外,通过JavaScript修改样式不仅影响元素的外观,还可能触发浏览器的重绘和回流。因此,在性能敏感的应用中,应尽量减少直接操作style属性的频率,可以考虑使用CSS类切换或CSS变量来实现样式变化。

事件驱动编程:从静态到动态的转变

虽然今天的案例未直接涉及事件处理,但DOM操作的核心价值在于响应用户交互。通过事件监听器,我们可以将DOM操作与用户行为绑定,实现真正的动态交互。例如,可以为按钮添加点击事件,触发抽奖逻辑或样式修改。

事件驱动编程是现代前端开发的基石,它使得页面能够根据用户输入实时响应,提供丰富的用户体验。在未来的学习中,我们将深入探讨事件冒泡、事件委托等高级事件处理技术,进一步提升前端应用的交互能力。

总结与展望

通过今天的实践,我们掌握了DOM元素选择、动态内容更新和样式修改等核心API,这些技术是构建现代Web应用的基础。然而,前端开发远不止于此,未来的学习将涉及更复杂的主题,如事件处理、动画实现、性能优化等。

在深入理解这些基础API的同时,我们也应关注其背后的原理,如DOM树的构建、浏览器的渲染机制等。只有深入理解这些底层原理,才能在实际开发中做出更优的技术决策,构建高效、稳定的Web应用。

前端开发是一门不断进化的技术,保持学习的热情和探索的精神,才能在这个快速发展的领域中持续成长。我们下期再见!

2 tree-cli 的使用方法

上一小节,我们安装了 tree-cli,是为了方便生成项目树,查看当前目录结构的。

如上小节代码 package.json中的配置

  "scripts": {
    "tree": "treee -a -l 4 --ignore 'node_modules, dist, .git' --directoryFirst -f"
  },

快速使用

# 查看当前项目目录树(默认 4 层深度,忽略 node_modules 等)
pnpm tree

常用命令

# 基础用法 - 列出所有文件和目录
pnpm tree

# 只显示目录结构(不显示文件)
npx treee -d --ignore 'node_modules, dist, .git'

# 控制显示深度(-l 指定层数)
npx treee -l 2 --ignore 'node_modules, dist, .git'
npx treee -l 6 --ignore 'node_modules, dist, .git'

# 输出到文件(-o 指定输出文件)
npx treee -l 4 --ignore 'node_modules, dist, .git' -o docs/tree-output.txt

# 显示隐藏文件(-a)
npx treee -a --ignore 'node_modules, dist, .git'

# 目录优先排列 + 标记文件类型(-f 在目录后加 /)
npx treee --directoryFirst -f --ignore 'node_modules, dist, .git'

# 忽略更多目录
npx treee --ignore 'node_modules, dist, .git, coverage, .pnpm-store'

# 不显示统计报告
npx treee --noreport --ignore 'node_modules, dist, .git'

参数说明

参数 说明
-l <n> 最大显示深度
-d 只显示目录
-f 目录后加 /,socket 文件加 =,FIFO 加 |
-a 显示隐藏文件(以 . 开头的文件)
-o <file> 输出到指定文件
--ignore '<dirs>' 忽略指定目录/文件,逗号分隔
--base <path> 指定根目录
--directoryFirst 目录排在文件前面
--noreport 不打印末尾的统计信息
--fullpath 显示完整路径

Eruda 移动端调试工具使用指南

一、什么是Eruda?

Eruda是一个专为手机网页前端设计的调试面板,类似于Chrome DevTools的迷你版。它解决了移动端开发调试困难的问题,让你在手机上也能享受专业的调试体验。

二、主要功能特性

  1. 控制台(Console)  - 捕获console日志,支持log、error、warn等
  2. 元素检查(Elements)  - 检查DOM元素,查看样式和属性
  3. 网络面板(Network)  - 捕获XHR请求,查看请求详情和响应
  4. 资源面板(Resources)  - 显示本地存储、Cookie、SessionStorage等信息
  5. 信息面板(Info)  - 显示设备信息、浏览器特性、性能指标
  6. 源码查看(Sources)  - 查看页面源码
  7. 性能监控(Performance)  - 显示页面性能指标

三、安装方法

通过NPM安装(推荐)

npm install eruda --save-dev

四、基本使用

1. 在Vue项目中使用

import eruda from 'eruda'

// 只在开发环境启用
if (process.env.NODE_ENV === 'development') {
    eruda.init()
}

2. 在React项目中使用

import eruda from 'eruda'

if (process.env.NODE_ENV !== 'production') {
    eruda.init()
}

3. 手动触发方式

  • 默认方式:页面右下角会出现齿轮图标,点击即可打开调试面板
  • 手势触发:在页面上快速点击三次可自动弹出控制台
  • URL参数:访问页面时添加?eruda=true参数

五、高级配置

1. 自定义配置

eruda.init({
    container: document.getElementById('eruda-container'),
    tool: ['console', 'elements', 'network', 'resources', 'info'],
    autoScale: true,
    useShadowDom: true
})

2. 插件系统

Eruda支持多种插件扩展:

  • eruda-code - 代码编辑器
  • eruda-features - 浏览器特性检测
  • eruda-timing - 性能监控
  • eruda-memory - 内存监控

安装插件:

npm install eruda-features
javascript
import eruda from 'eruda'
import erudaFeatures from 'eruda-features'

eruda.add(erudaFeatures)

六、生产环境控制

为了避免生产环境暴露敏感信息,建议:

1. 环境判断

// 通过环境变量控制
if (process.env.NODE_ENV === 'development') {
    eruda.init()
}

// 或通过URL参数控制
if (window.location.search.indexOf('debug=true')==-1) {
    eruda.init()
}

2. 隐藏式启用

// 通过特定启用
let clickCount = 0
let lastClickTime = 0

document.addEventListener('click', (e) => {
    const now = Date.now()
    if (now - lastClickTime ==300) {
        clickCount = 0
    }
    clickCount++
    lastClickTime = now
    
    if (clickCount == 9) { // 点击9次启用
        eruda.init()
        clickCount = 0
    }
})

七、使用技巧

  1. 元素检查:在元素面板中点击页面元素,可以实时查看和修改样式
  2. 网络监控:查看请求的详细信息,包括请求头、响应头、响应时间等
  3. 控制台调试:支持所有console API,包括console.table等高级功能
  4. 性能分析:使用性能面板监控页面加载时间和资源使用情况
  5. 本地存储:查看和编辑localStorage、sessionStorage、Cookie等

八、注意事项

  1. 文件大小:Eruda压缩后约100KB,建议只在开发和测试环境使用
  2. 安全性:生产环境务必禁用或隐藏调试工具
  3. 兼容性:支持大部分现代移动浏览器
  4. 性能影响:调试工具会占用一定内存,调试完成后建议关闭

九、具体项目中使用

utils文件

/** LET: 是否已启用调试模式 */
let _debug: boolean = false

/**
 * FUN: 是否已启用调试模式
 *
 *
 * @returns {boolean} 是否已启用调试模式
 */
export const isEnableDebug = (): boolean => {
    // 未启用调试模式时
    if (!_debug) {
        _debug = getCurrentUrlQueryValue("debug") === "1"
    }

    return _debug
}

新建文件eruda.ts

/*
 * @FileDesc: 初始化 eruda 调试器
 */

import { isEnableDebug } from "@/utils"

/** LET: 是否初始化 */
let _isSetup = false

/**
 * FUN: 初始化 eruda 调试器
 *
 *
 */
export const setupEruda = () => {
    /** 已经完成初始化时 */
    if (_isSetup) {
        return
    }

    // 未启用调试时
    if (!isEnableDebug()) {
        return
    }

    import("eruda")
        .then(({ default: eruda }) => {
            eruda.init()
            window.eruda = eruda

            console.log("[项目信息]", __PROJECT_INFO__)

            _isSetup = true
        })
        .catch(error => {
            console.error("eruda 加载失败", error)
        })
}

使用

//在连接上拼接上debug=1 
//例如:http://localhost:8080/login?debug=1

十、常见问题

Q: Eruda会影响页面性能吗?  A: 在开发环境影响很小,但生产环境建议禁用。

Q: 如何只在特定设备上启用?  A: 可以通过User-Agent判断设备类型。

Q: 支持TypeScript吗?  A: 是的,Eruda有完整的TypeScript类型定义。

Q: 可以自定义主题吗?  A: 支持通过CSS自定义样式。

Eruda是一个非常强大的移动端调试工具,能极大提升移动端开发效率。建议在项目中根据实际需求选择合适的启用方式,确保开发便利性的同时保证生产环境的安全性。

❌