普通视图

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

【笔记】xxx 技术分享文档模板

作者 林小帅
2026年3月12日 18:56

纯模板,下次需要直接贴给 AI 然后替换内容就行

skills 技术分享文档

一、项目概述

1.1 项目定位

skills 是团队为 Claude Code 打造的通用技能工具集,通过 npm 包的形式进行分发和管理,旨在提升团队开发效率和代码质量。

1.2 核心价值

  1. 知识共享: 将团队最佳实践封装为可复用的技能
  2. 效率提升: 自动化重复性工作流程
  3. 规范统一: 确保团队操作流程一致性
  4. 降低门槛: 新成员快速上手团队工作方式

二、技术架构

2.1 整体架构

skills
├── bin/
│   └── cli.js          # CLI 工具入口
├── skills/             # 技能定义(自动触发或 /调用)
│   ├── brainstorming/
│   ├── merge-to-test/
│   ├── merge-to-develop/
│   └── requirement-to-spec/
├── commands/           # 命令定义(/调用)
│   └── create-branch.md
├── agents/             # 自定义 Agent 定义
└── rules/              # 全局规则约束

2.2 Claude Code 资源类型映射

类型 安装路径 触发方式 文件扩展 是否嵌套
skills ~/.claude/skills/ 自动或 /skill-name
commands ~/.claude/commands/ /command-name .md
agents ~/.claude/agents/ Agent 定义 .md
rules ~/.claude/rules/ 全局应用 .md

三、CLI 工具设计

3.1 核心功能

CLI 工具提供了完整的资源生命周期管理:

# 查看可用资源
skills list
skills list skills

# 查看已安装资源
skills installed
skills installed commands

# 安装资源
skills install --all                    # 安装所有
skills install skills brainstorming    # 安装单个

# 更新资源
skills update --all
skills update commands create-branch

# 卸载资源
skills remove skills brainstorming

3.2 技术实现要点

1. 路径解析

const CLAUDE_HOME = path.join(os.homedir(), '.claude')
const TYPES = {
  skills:   { src: 'skills',   dest: path.join(CLAUDE_HOME, 'skills'),   nested: true,  ext: '' },
  commands: { src: 'commands', dest: path.join(CLAUDE_HOME, 'commands'), nested: false, ext: '.md' },
  agents:   { src: 'agents',   dest: path.join(CLAUDE_HOME, 'agents'),   nested: false, ext: '.md' },
  rules:    { src: 'rules',    dest: path.join(CLAUDE_HOME, 'rules'),    nested: false, ext: '.md' },
}

2. 嵌套目录处理 Skills 需要递归复制整个目录,而其他类型仅需复制单个文件:

if (TYPES[type].nested) {
  fs.cpSync(src, dest, { recursive: true, force: true })
} else {
  fs.copyFileSync(src, dest)
}

3. 更新策略 更新时仅更新已安装的资源,跳过不存在的资源并给出警告:

const installed = listInstalled(type)
const available = new Set(listAvailable(type))
installed.forEach(name => {
  if (available.has(name)) { installOne(type, name); count++ }
  else console.warn(`  ! [${type}] "${name}" 不在包中,跳过`)
})

四、核心技能详解

4.1 brainstorming - 创意设计助手

使用场景: 在进行任何创造性工作之前(创建功能、构建组件、修改行为)

工作流程:

  1. 理解上下文: 检查项目状态(文件、文档、最近提交)
  2. 逐步提问: 每次只问一个问题,避免信息过载
  3. 方案探索: 提出 2-3 种方案并说明权衡
  4. 分段展示: 每段 200-300 字,逐步验证设计
  5. 文档输出: 将确认的设计写入 docs/plans/YYYY-MM-DD-<topic>-design.md

关键原则:

  • 一次只问一个问题
  • 优先使用多选题而非开放题
  • YAGNI 原则:移除不必要的功能
  • 始终提供多种方案

4.2 merge-to-test - 自动合并到测试环境

触发条件: 用户说"推送代码"、"可以推送"、"push"时

保护分支检测:

const protectedBranches = ['master', 'test', 'develop', 'release/*']
// 如果当前分支是保护分支,则不触发

合并流程:

branch=$(git rev-parse --abbrev-ref HEAD) && \
git checkout test && \
git pull && \
git merge $branch && \
git push && \
git checkout $branch

级联触发: 成功合并到 test 后,自动触发 merge-to-develop 询问是否部署到开发环境

4.3 merge-to-develop - 合并到开发环境

触发方式:

  1. merge-to-test 成功后自动触发
  2. 用户主动请求:"Merge to develop environment"、"Deploy to develop"

工作流程: 与 merge-to-test 类似,目标分支为 develop

部署完成摘要:

部署完成:
✅ 测试环境 (test)
✅ 开发环境 (develop)

4.4 requirement-to-spec - 需求转技术规格

核心功能: 通过自然对话收集需求信息,然后调用 /openspec:proposal 生成详细技术规格

分支保护检查 (关键安全机制):

// Step 0: 检查当前分支
branch=$(git branch --show-current)
if [[ "$branch" == "master" || "$branch" == "test" || "$branch" == "develop" ]]; then
  // 显示警告并询问是否切换分支
fi

信息收集流程:

  1. 需求名称(可直接从项目管理系统复制)
  2. 简要描述(做什么、预期什么结果)
  3. 技术变更点
  4. 影响范围(文件或模块)
  5. 接口信息(如有)
  6. 出入参(如有)

格式化输出:

/openspec:proposal 这里有个新需求需要创建提案:【<需求名称>】

简单描述:<做什么,要什么结果>

具体需求:<技术变更点>

影响范围:<文件或模块路径>

接口:<如果有则填写,没有则用占位符>

出入参:<如果有则填写,没有则用 {} 占位>

五、核心命令详解

5.1 create-branch - AI 辅助分支创建

功能: 从项目管理系统 Story 创建功能分支,AI 自动翻译分支名

分支命名规范: feat/<username>/<concise-english-name>-<storyID>

核心功能:

  1. 信息提取: 从项目管理系统 URL 提取 storyID
  2. AI 翻译: 将中文标题翻译为简洁的英文分支名(3-4 词)
  3. 用户名获取: 从 git config user.name 自动获取
  4. 创建模式选择: 支持普通分支和 Worktree 两种模式

Worktree 模式特殊处理:

  • 路径格式: <main-branch-parent>/<project>.worktrees/<branch-type>/<username>/<branch-name>
  • 共享 node_modules: 通过符号链接避免重复安装依赖
  • Windows 符号链接: powershell -Command "cmd /c 'mklink /D <target> <source>'"
  • 可选复制 route-dev.js 文件

AI 翻译规则:

  • 使用小写连字符格式(如 scrm-auto-tag
  • 移除冗余词汇("功能"、"优化"等)
  • 保留重要缩写(SCRM、CRM等)
  • 保持简洁,聚焦核心功能

执行流程:

# 1. 更新 master(必须)
git checkout master
git pull origin master

# 2. 创建分支
git checkout -b feat/<username>/<english-name>-<storyID>

# 3. Worktree 模式额外操作
git worktree add <worktree-path> -b <branch-name>
# 创建 node_modules 符号链接
# 可选复制 route-dev.js

六、集成与协作

6.1 技能间的协作

/create-branch
    ↓
/requirement-to-spec (分支保护检查)
    ↓
/openspec:proposal (生成技术规格)
    ↓
[开发实现]
    ↓
git push
    ↓
merge-to-test (自动触发)
    ↓ (成功后)
merge-to-develop (自动触发)

6.2 安全机制

  1. 分支保护: 所有涉及代码修改的技能都会检查当前分支
  2. 用户确认: 关键操作前都需要用户确认
  3. 错误处理: 提供清晰的错误信息和恢复指导
  4. 状态回滚: 合并失败时自动切换回原分支

七、最佳实践

7.1 发布流程

# 1. 更新版本号
npm version patch/minor/major

# 2. 发布到内部 npm
npm publish --registry http://npm.example.com/

# 3. 用户更新
npx --registry http://npm.example.com/ @company/skills update --all

7.2 本地开发

# 链接到全局
npm link

# 测试命令
skills list
skills install --all
skills installed

7.3 扩展开发指南

新增 Skill:

  1. 创建 skills/<skill-name>/SKILL.mdSkill.md
  2. 编写 frontmatter 元数据
  3. 编写技能逻辑文档
  4. 更新版本号并发布

新增 Command:

  1. 创建 commands/<command-name>.md
  2. 编写命令说明文档
  3. 更新版本号并发布

Skill Frontmatter 模板:

---
name: skill-name
description: 简短描述
category: 分类(可选)
tags: [tag1, tag2](可选)
---

八、常见问题

Q1: 如何调试技能执行过程?

A: Claude Code 会在执行过程中显示详细的日志输出,包括每个步骤的执行情况和结果。

Q2: Worktree 模式下符号链接创建失败?

A: Windows 需要管理员权限或开启开发者模式才能创建符号链接。可以:

  • 以管理员身份运行终端
  • 或启用 Windows 开发者模式

Q3: 如何自定义分支命名规则?

A: 修改 commands/create-branch.md 中的分支格式定义,然后更新发布。

Q4: 技能执行失败如何回滚?

A: 大多数技能都有内置的错误处理和状态恢复机制,会自动切换回原分支。


九、未来规划

  1. 更多技能: 持续添加团队常用的开发技能
  2. 配置化: 支持团队自定义配置(如分支命名规则)
  3. 测试覆盖: 为 CLI 工具添加自动化测试
  4. 文档完善: 提供更详细的使用示例和视频教程
  5. 性能优化: 优化安装和更新速度

十、总结

skills 通过以下方式提升开发效率:

  1. 标准化流程: 将团队最佳实践封装为可复用的技能
  2. 自动化操作: 减少重复性手工操作
  3. 智能辅助: AI 翻译、需求整理等智能功能
  4. 安全保护: 分支保护检查等安全机制
  5. 易于扩展: 清晰的架构便于添加新技能

通过这套工具集,新成员可以快速融入团队工作方式,老成员可以专注于核心业务逻辑,从而整体提升团队的开发效率和代码质量。

版权声明:
本文版权属于作者 林小帅,未经授权不得转载及二次修改。
转载或合作请在下方留言及联系方式。

里程碑4 - 基于Vue3完成动态组件库建设

作者 oo12138
2026年3月12日 18:15

一、目标

在之前的过程中,基于DSL完成了列表页面的领域模型搭建和页面的动态开发,接下来我们是要根据列表页展示的按钮,完成详情的 动态增改查 功能

二、配置项展示

1. xxxOption(字段级配置)

这部分设置的是某个字段在某个页面中的表现方式,用来控制每个字段如何渲染,比如是要隐藏或是禁用。

其中新增、修改页面的控件类型comType是必须要进行配置的,用于后续数据填写;而查看详情页面可以添加控件组件comType,和新增、修改采用同一种样式展示、也可以不配置控件组件,自定义展示样式。

以下是代码用例:

      xxxPageOption: {
        ...elComponentConfig, // 标准 el-component配置
        comType: "", // 控件类型 input/select/input-number
        visible: true, // 是否展示,默认为true
        disabled: false, // 是否禁用,默认为false
        default: "", // 默认值
        enumList: [], // 枚举列表,comType === select时生效
      },

2. componentConfig(组件级配置)

这部分配置是用来控制页面的行为;根据之前按钮配置的eventKey和eventOption中的comName来决定展示哪个页面

适合用来控制一些页面属性

        // 动态组件 相关配置
        componentConfig: {
          xxxPage: {
            title: '', // 表单标题
            saveBtnText: '', // 保存按钮文案
            mainKey: '', // 表单主键,用于唯一标识要修改的数据对象,修改和查看必须进行配置
          },
          // ... 支持用户动态扩展
        }

3. 两者之间的结构关系

        Schema
         ├─ properties (字段级)
         │   ├─ field1
         │   │   ├─ xxxPageOneOption
         │   │   ├─ xxxPageTwoOption
         │   │   └─ xxxPageThreeOption
         │   └─ field2
         │
        componentConfig (页面级)
             ├─ xxxPageOne
             ├─ xxxPageTwo
             └─ xxxPageThree

三、与代码配置页面的比对

代码配置页面 DSL领域模型生成
开发方式 写代码 写配置
页面数量 一个页面一套代码 一个组件根据配置可以生成多个页面
样式风格 难以统一 容易保持一致
  • DSL的核心优势

    一个schema可以生成,达到页面生成自动化,且UI样式统一,降低重复代码

四、最终DSL架构

经过不断的完善和扩展,我们最终的DSL架构样式为:

    schema: {
      properties: {
        key: {
          ...schema,
          label: '',
          type: '',
          searchOption: {
          },
          tableOption: {
          },
          xxxPageOneOption: {
          },
          xxxPageTwoOption: {
          },
        }
      },
      required: ["key"],
    }
    searchConfig: {},
    tableConfig: {},
    component: {
      xxxPageOne: {
      },
      xxxPageTwo: {
      },
    },

五、总结

为解决前端代码开发过程中遇到的 页面重复度高、大量CRUD页面结构相似,每个页面独立完成导致的后续高维护成本和扩展困难等问题,我们从手写每个页面代码 转变为 使用DSL配置描述页面结构。

在开发过程中,通过领域模型model和衍生出的各类project配置,结合统一的schema配置描述页面结构,动态生成页面。从而达到将重复代码沉淀下来,让我们有更多的精力去完成定制化需求。

六、开发过程中遇到的😅事情

vue动态组件 + ref 收集方式

  • 修改前的写法

        <component v-for='item in List"  :ref="handleSearchComList"/>  
    
        const searchComList = ref([]);
        const handleSearchComList = (el) => {
          searchComList.value.push(el);
        }
    
  • 错误的修改

        <component v-for="item in list"  :ref="searchComList"/>  
        const searchComList = ref([]);
    

    因为我定义的是 searchComList = ref([]),vue会认为这是一个ref对象,然后尝试searchComList.value = el;这样会导致 过程中每次ref都会覆盖,不会push,所以在我打印的时候看到的是空的

  • 正确的修改

        <component v-for="item in list"  ref="searchComList"/>  
        const searchComList = ref([]);
    
  • 情况比对

    写法 是否可用 原因
    :ref="schemaComList" 不可用 :ref="XXX"动态ref,是js表达式绑定,vue会理解为 XXX.value = el,会被覆盖,在最后组件更新或卸载的时候,打印出来的结果就是空数组或者null
    :ref="handleSearchComList" + handleSearchComList() 可用 定义的handleSearchComList是函数,vue会执行这段函数,会在函数中进行push
    ref="searchComList" + v-for 可用 ref="XXX" 静态ref,XXX只是一个字符串key,vue会在挂载后自动做 XXX.value = 组件实例;如果是v-for,vue会自动收集 XXX.value = [实例1,实例2...]

告别表单“黄油色”:如何优雅地重置 Chrome 自动填充样式

作者 火车叼位
2026年3月12日 18:13

在前端开发与 UI 还原的过程中,我们经常会遇到一个破坏设计美感的“顽疾”:当用户使用 Chrome 或其他 Webkit 内核浏览器自动填充账号密码时,输入框会被强制渲染上一层淡蓝色或淡黄色的背景。

对于追求完美视觉体验的项目来说,这种突兀的颜色往往会破坏整体的 UI 色调。今天我们就来聊聊,为什么会出现这个问题,以及如何优雅地解决它。

🔍 为什么常规的 background-color 无法覆盖?

当浏览器触发自动填充时,会给 <input> 元素自动加上一个 :-webkit-autofill 伪类。在这个伪类的用户代理样式表(User Agent Stylesheet)中,浏览器使用了 !important 来强制设置背景色和字体颜色。

正因为它的优先级极高,我们在 CSS 中常规编写的 background-colorcolor 属性都会失效。

🛠️ 解决方案大盘点

要对付这个高优先级的默认样式,我们需要采取一些“曲线救国”的 CSS 技巧。以下是目前业内最常用的三种解决方案:

# 方案一:内阴影遮挡法(最通用,适用于纯色背景)

这是目前最主流且兼容性最好的做法。既然背景色改不掉,我们就用一堵“厚厚的墙”把它挡住。利用极其宽大的内部阴影(inset),我们可以完美遮挡住浏览器默认的背景色。

/* 针对 Webkit 浏览器的自动填充样式重置 */
input:-webkit-autofill,
input:-webkit-autofill:hover, 
input:-webkit-autofill:focus, 
input:-webkit-autofill:active {
    /* 1. 使用足够大的内阴影来覆盖背景色,把 #ffffff 替换成你输入框原本的背景色 */
    -webkit-box-shadow: 0 0 0px 1000px #ffffff inset !important;
    
    /* 2. 由于常规的 color 属性也会失效,需要用这个属性修改文字颜色 */
    -webkit-text-fill-color: #333333 !important; 
    
    /* 3. 保留光标的正常颜色 */
    caret-color: #333333;
}

# 方案二:过渡延迟法(适用于透明背景输入框)

如果你设计的输入框是没有背景色的(即透明背景),那么方案一的纯色阴影就不适用了。这时,我们可以利用 CSS 的 transition 属性,给背景色的变化加上一个极长的延迟时间。

这样一来,即使浏览器想变色,也要等上好几个小时,在视觉上就等同于保持了透明。

input:-webkit-autofill,
input:-webkit-autofill:hover, 
input:-webkit-autofill:focus, 
input:-webkit-autofill:active {
    /* 将背景色的过渡时间设置为一个极大的值(如 5000秒),让变色无限延后 */
    transition: background-color 5000s ease-in-out 0s;
    
    /* 依然可以自定义文字颜色 */
    -webkit-text-fill-color: #333333 !important;
}

# 方案三:直接禁用自动填充(视业务场景而定)

如果你开发的系统对安全性要求极高,或者明确不需要浏览器记住密码(例如银行系统的动态验证码输入框),可以直接在 HTML 层面关闭它。

<input type="text" name="username" autocomplete="off">

<input type="password" name="password" autocomplete="new-password">

注意: 禁用自动填充会牺牲一部分用户便利性,在常规的登录注册页面中不建议滥用。通常推荐使用前两种 CSS 方案来兼顾用户体验与 UI 美观。

💡 总结

处理 :-webkit-autofill 是前端页面切图时的经典问题。遇到纯色背景,首选 box-shadow 内阴影覆盖;遇到透明背景,使用 transition 延迟生效;遇到特殊安全场景,再考虑 autocomplete="off"。掌握这三招,足以应对绝大多数表单样式重置的需求。

Volta启动项目自动切换Node版本

作者 _DoubleL
2026年3月12日 17:44

1. 为什么需要 Volta 自动切换 Node 版本

在前端开发中,不同项目往往依赖不同 Node.js 版本

  • 老项目可能只兼容 Node 14/16
  • 新项目需要 Node 18+ 甚至更高
  • 用 nvm 手动切换麻烦、容易忘、容易报错

如果只在电脑上安装 一个全局 Node 版本,就很容易出现以下问题:

  • ❌ 切换项目时需要 手动切换 Node 版本
  • ❌ 忘记切换导致 项目启动失败
  • ❌ 团队成员 Node 版本不一致,出现 环境问题
  • ❌ CI / 本地环境 构建结果不一致

2. 使用流程

  1. 在项目的package.json 里面添加如下配置
  "volta": {
    "node": "16.16.0" // 项目的node 版本号
  }
  1. 安装Volta
curl https://get.volta.sh | bash
  1. 修改配置文件,以 .zshrc 为例
open ~/.zshrc
export NVM_DIR="$HOME/.nvm"

[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm

[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion


# ---- Volta (必须在nvm后面) ----

export VOLTA_HOME="$HOME/.volta"

export PATH="$VOLTA_HOME/bin:$PATH"

  1. 刷新zshrc
source ~/.zshrc
  1. 切换到对应项目目录,输入 node -v 查看是否已经自动切换过来,如果已经切换过来,则可以直接启动项目

image.png

watch Cheatsheet

Basic Syntax

Core watch command forms.

Command Description
watch command Run a command repeatedly every 2 seconds
watch -n 5 command Refresh every 5 seconds
watch -n 1 date Update output every second
watch --help Show available options

Common Monitoring Tasks

Use watch to keep an eye on changing system state.

Command Description
watch free -h Monitor memory usage
watch df -h Monitor disk space
watch uptime Check load averages and uptime
`watch “ps -ef grep nginx”`
watch "ss -tulpn" Monitor listening sockets

Timing and Refresh Control

Control how often watch reruns the command.

Command Description
watch -n 0.5 command Refresh every 0.5 seconds
watch -n 10 command Refresh every 10 seconds
watch -t command Hide the header line
watch -p command Try to run at precise intervals
watch -x command arg1 arg2 Run the command directly without sh -c

Highlighting Changes

Make changing output easier to spot.

Command Description
watch -d command Highlight differences between updates
watch -d free -h Highlight memory changes
watch -d "ip -brief address" Highlight interface state or address changes
watch -d -n 1 "cat /proc/loadavg" Highlight load-average changes

Commands with Pipes and Quotes

Wrap pipelines and shell syntax in quotes unless you use -x.

Command Description
`watch “ps -ef grep apache”`
`watch “ls -lh /var/log tail”`
watch "grep -c error /var/log/syslog" Count matching lines repeatedly
watch -x ls -lh /var/log Run ls directly without shell parsing
`watch “find /tmp -maxdepth 1 -type f wc -l”`

Exit Conditions and Beeps

Stop or alert when output changes.

Command Description
watch -g command Exit when command output changes
watch -b command Beep if the command exits with non-zero status
watch -b -n 5 "systemctl is-active nginx" Alert if the service is no longer active
watch -g "cat /tmp/status.txt" Exit when the file content changes

Troubleshooting

Quick fixes for common watch issues.

Issue Check
Pipes or redirects do not work Quote the whole command or use sh -c
The screen flickers too much Increase the interval or hide the header with -t
Output changes are hard to spot Add -d to highlight differences
The command exits immediately Check the command syntax outside watch first
You need shell expansion and variables Use quoted shell commands instead of -x

Related Guides

Use these guides for broader monitoring and process tasks.

Guide Description
Linux Watch Command Full guide to watch options and examples
ps Command in Linux Inspect running processes
free Command in Linux Check memory usage
ss Command in Linux Inspect sockets and ports
top Command in Linux Monitor processes interactively

Flutter文本框添加图片表情(粗制滥造版)

作者 逍遥咸鱼
2026年3月12日 17:34

效果图:

无标题.png

使用 Unicode 的私有区做表情的占位符

继承重写 TextEditingController,在 buildTextSpan 内显示时调整为表情图片。

表情键值对:

final Map<int, String> emojiMap = {
    0xE001: 'assets/images/1.png',
    0xE002: 'assets/images/2.png',
    0xE003: 'assets/images/3.png',
  };

遍历字符串把占位符显示为表情的方法:

  List<InlineSpan> _parseText(String text, TextStyle? style) {
    final spans = <InlineSpan>[];
    final buffer = StringBuffer();
    for (final codePoint in text.runes) {
      // 如果是表情
      if (codePoint >= 0xE000 && codePoint <= 0xF8FF) {
        // 将 buffer 内的字符加入 spans 并清空 buffer。
        if (buffer.isNotEmpty) {
          spans.add(TextSpan(text: buffer.toString(), style: style));
          buffer.clear();
        }
        // 处理表情
        final asset = emojiMap[codePoint];
        if (asset != null) {
          spans.add(
            WidgetSpan(
              child: Image.asset(
                asset,
                width: style?.fontSize,
                height: style?.fontSize,
              ),
            ),
          );
        } else {
          buffer.writeCharCode(codePoint);
        }
      } else {
        buffer.writeCharCode(codePoint);
      }
    }
    if (buffer.isNotEmpty) {
      spans.add(TextSpan(text: buffer.toString(), style: style));
    }
    return spans;
  }

 

buildTextSpan:

  @override
  TextSpan buildTextSpan({
    required BuildContext context,
    TextStyle? style,
    required bool withComposing,
  }) {
    assert(
      !value.composing.isValid || !withComposing || value.isComposingRangeValid,
    );
    final bool composingRegionOutOfRange =
        !value.isComposingRangeValid || !withComposing;

    if (composingRegionOutOfRange) {
      // 修改为使用 _parseText()
      // 原本的:return TextSpan(style: style, text: text);
      return TextSpan(style: style, children: _parseText(text, style));
    }

    final TextStyle composingStyle =
        style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
        const TextStyle(decoration: TextDecoration.underline);

    // 这里也是修改为使用 _parseText()
    return TextSpan(
      style: style,
      children: <TextSpan>[
        TextSpan(
          children: _parseText(value.composing.textBefore(value.text), style),
        ),
        TextSpan(
          style: composingStyle,
          children: _parseText(value.composing.textInside(value.text), style),
        ),
        TextSpan(
          children: _parseText(value.composing.textAfter(value.text), style),
        ),
      ],
    );
  }

添加表情:

  void addEmoji(int codePoint) {
    // 获取当前选区,如果无效(如未聚焦),则默认光标在文本末尾
    final TextSelection currentSelection = selection.isValid
        ? selection
        : TextSelection.collapsed(offset: text.length);

    // 根据选区情况构造新文本和光标位置
    final String newText;
    final int newCursorOffset;

    if (currentSelection.isCollapsed) {
      // 折叠光标:直接插入
      final int pos = currentSelection.baseOffset;
      newText =
          text.substring(0, pos) +
          String.fromCharCode(codePoint) +
          text.substring(pos);
      newCursorOffset = pos + 1;
    } else {
      // 有选中文本:先删除选中内容,再在开始位置插入
      final int start = currentSelection.start;
      final int end = currentSelection.end;
      newText =
          text.substring(0, start) +
          String.fromCharCode(codePoint) +
          text.substring(end);
      newCursorOffset = start + 1;
    }

    // 更新控制器值,并设置光标折叠在新字符之后
    value = value.copyWith(
      text: newText,
      selection: TextSelection.collapsed(offset: newCursorOffset),
      // 清除组合范围,因为插入操作会中断输入法组合
      composing: TextRange.empty,
    );
  }

资深前端都在用的 9 个调试偏方

作者 冴羽
2026年3月12日 17:31

资深前端都在用的 9 个调试偏方

1. 技巧 1:计算属性名

不要这样写 ❌ :

const user = { name: "Alice", age: 30 };
const product = { id: 123, price: 49.99 };

console.log("user", user);
console.log("product", product);

现在这样写 ✅ :

console.log({ user, product });

使用 ES6 简写对象语法会将你的变量包装在一个对象中,这样你可以在控制台中立即看到变量名和它的值。当你有 20 个日志时,不用再猜测哪个日志对应哪个变量。

console.log 输出示例

2. 技巧 2:console.table()

当你处理对象数组时,console.log 几乎毫无用处。

试试这个 ✅:

const users = [
  { name: "Alice", age: 30, role: "Admin" },
  { name: "Bob", age: 25, role: "User" },
  { name: "Charlie", age: 35, role: "Moderator" },
];

console.table(users);

这会在浏览器控制台中渲染一个漂亮的、可排序的表格。

你可以点击列标题进行排序,它比嵌套对象更易读。

console.table 输出示例

3. 技巧 3:console.trace()

当你发现一个函数被多处调用,却不知道具体执行路径时:

function processPayment(amount) {
  function innerFn() {
    console.trace("Payment processing started");
  }

  innerFn();
}

processPayment(20);

console.trace() 会打印完整的调用堆栈,向你展示代码到达该点的确切路径。

当调试一个可能从 5 个不同地方调用的函数时,这很有用。

console.trace 输出示例

4. 技巧 4:条件断点 console.assert()

不要这样写 ❌ :

if (user.age < 18) {
  console.log("Underage user detected!");
}

现在这样写 ✅ :

console.assert(user.age >= 18, "Underage user detected!", user);

只有当断言失败(条件为 false)时,它才会记录日志。

代码更简洁,控制台噪音更少,而且它会自动包含实际数据。

console.assert 输出示例

5. 技巧 5:性能监控器 console.time()

想知道操作花了多少时间,这样写:

console.time("API Call");

fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => {
    console.timeEnd("API Call");
    return data;
  });

console.timeEnd("API Call");

这能告诉你 console.time()console.timeEnd() 之间经过了多少毫秒。我经常用它来比较不同的实现或寻找性能瓶颈。

输出:

API Call: 342.87ms

6. 技巧 6:样式化日志

让你重要的日志无法被忽视:

console.log("%c CRITICAL ERROR", "color: red; font-size: 20px; font-weight: bold; background: yellow; padding: 10px;");

你可以使用 %c 为控制台日志添加 CSS 样式。这非常适合:

  • 需要立即关注的错误状态

  • 开发中的成功消息

  • 分隔复杂的调试输出

样式化控制台输出示例

7. 技巧 7:分组整理 console.group()

调试信息太多太乱?你可以将它们分组:

console.group("User Authentication");
console.log("Checking credentials...");
console.log("Token:", token);
console.log("Validating...");
console.groupEnd();

console.group("API Response");
console.log("Status:", response.status);
console.log("Data:", response.data);
console.groupEnd();

这会在控制台中创建可折叠的分组,让你在大量调试输出中导航变得更加容易。

如果希望分组默认收起,可以使用 console.groupCollapsed()

console.group 输出示例

8. 技巧 8:对象深度探索 console.dir()

对于 DOM 元素或具有特殊属性的对象:

const element = document.querySelector("#myButton");

console.log(element); // 显示 HTML 结构
console.dir(element); // 显示对象的属性和方法

console.dir() 显示对象属性的交互式列表,

当你想要检查方法和属性而不是 HTML 结构时,这特别适用于 DOM 元素。

console.dir 输出示例

9. 技巧 9:日志级别

别再所有事情都用 console.log()

JavaScript 给你不同的日志级别是有原因的:

console.log("Regular information"); // 普通信息
console.info("ℹ️ User logged in"); // 信息提示
console.warn("⚠️ API rate limit at 80%"); // 警告
console.error("❌ Payment failed"); // 错误
console.debug(" Variable state:", x); // 调试信息

现代浏览器的 DevTools 允许你按日志级别过滤。

在生产环境调试时,你可以隐藏所有的 console.logconsole.debug 语句,只查看警告和错误。

这样能让关键问题在大量的调试输出中不会被忽略。

最后

正确的调试技巧可以为你节省数小时的试错时间。

掌握这些工具,你将减少添加日志的时间,增加实际修复 bug 的时间。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

flex: 1 vs flex: auto 最通俗的解释

作者 我叫蒙奇
2026年3月12日 17:19

flex: 1 vs flex: auto 最通俗的解释

一句话核心区别

  • flex: 1无视内容宽度,强制等分
  • flex: auto尊重内容宽度,按需分配

1. 用实际例子理解

基础代码

<div style="display: flex; width: 600px;">
  <div class="item1"></div>
  <div class="item2">这是一个很长的内容</div>
  <div class="item3">中等</div>
</div>

情况1:flex: 1

.item1, .item2, .item3 {
  flex: 1;
}

结果:三个项目严格等分,各占200px

[    短    ][这是一个很长的内容][   中等   ]
   200px        200px          200px
  • 不管内容多长多短,强制三等分
  • 长内容可能会被截断或换行

情况2:flex: auto

.item1, .item2, .item3 {
  flex: auto;
}

结果:根据内容长度分配空间

[  短  ][这是一个很长的内容][ 中等 ]
  ~150px        ~300px      ~150px
  • 内容长的项目占据更多空间
  • 内容短的项目占据较少空间

2. 本质区别是 flex-basis

属性 flex: 1 flex: auto
flex-grow 1 1
flex-shrink 1 1
flex-basis 0% auto

关键就在 flex-basis

  • flex-basis: 0%:以0为基准,忽略内容宽度
  • flex-basis: auto:以内容宽度为基准

3. 对比表格

特性 flex: 1 flex: auto
基准大小 0(忽略内容) 内容本身大小
分配逻辑 剩余空间按比例分 总空间按比例分
内容影响 不影响宽度 影响初始宽度
典型效果 严格等分 内容多的占的多
适用场景 导航栏、等分卡片 自适应列表、评论区

4. 再看几个对比案例

案例1:空内容 vs 有内容

<div style="display: flex; width: 400px;">
  <div style="flex: 1">A</div>        <!-- 约133px -->
  <div style="flex: 1">BBBBBBBB</div> <!-- 约133px -->
  <div style="flex: 1">C</div>        <!-- 约133px -->
</div>

<div style="display: flex; width: 400px;">
  <div style="flex: auto">A</div>        <!-- 约50px -->
  <div style="flex: auto">BBBBBBBB</div> <!-- 约300px -->
  <div style="flex: auto">C</div>        <!-- 约50px -->
</div>

案例2:固定宽度内容

<div style="display: flex; width: 500px;">
  <!-- 图片固定宽度100px -->
  <div style="flex: 1"><img src="a.jpg" width="100"></div>
  <div style="flex: 1">文字内容</div>
  <div style="flex: 1">文字内容</div>
</div>
<!-- 三个项目仍然等分,图片被压缩 -->

<div style="display: flex; width: 500px;">
  <div style="flex: auto"><img src="a.jpg" width="100"></div>
  <div style="flex: auto">文字内容</div>
  <div style="flex: auto">文字内容</div>
</div>
<!-- 图片项目保留100px,其他项目分剩余空间 -->

5. 实际应用场景

什么时候用 flex: 1

/* 导航菜单 - 希望严格等分 */
.nav-menu {
  display: flex;
}
.nav-item {
  flex: 1;  /* 不管文字多长,按钮一样宽 */
  text-align: center;
}

什么时候用 flex: auto

/* 评论区 - 希望内容多的占更多空间 */
.comment-list {
  display: flex;
}
.comment {
  flex: auto;  /* 长的评论占据更宽区域 */
  margin: 0 10px;
}

6. 计算方式的差异

flex: 1 的计算

总宽度 = 600px
flex-basis = 0
剩余空间 = 600 - 0 - 0 - 0 = 600px

项目1 = 0 + 600 × (1/3) = 200px
项目2 = 0 + 600 × (1/3) = 200px
项目3 = 0 + 600 × (1/3) = 200px

flex: auto 的计算

总宽度 = 600px
内容宽度 = 50px + 200px + 50px = 300px
剩余空间 = 600 - 300 = 300px

项目1 = 50 + 300 × (1/3) = 150px
项目2 = 200 + 300 × (1/3) = 300px
项目3 = 50 + 300 × (1/3) = 150px

7. 调试技巧

在浏览器开发者工具中查看:

  1. 选中flex容器
  2. 查看每个项目的实际宽度
  3. 观察内容长度对宽度的影响
/* 添加边框方便观察 */
.item {
  border: 1px solid red;
  overflow: auto;  /* 防止内容溢出影响观察 */
}

8. 记忆口诀

  • flex: 1 = 平均主义:不管能力(内容)大小,大家都一样
  • flex: auto = 按劳分配:能力(内容)大的占的多

总结

场景 flex: 1 flex: auto
导航菜单
等分卡片
评论区
标签列表
不希望内容影响宽度
希望内容决定初始宽度

最简记忆

  • 想要严格等分flex: 1
  • 想要内容自适应flex: auto

xx.d.ts 文件有什么用,为什么不引入都能生效?

作者 兆子龙
2026年3月12日 17:13

一、从一个现象说起

你有没有遇到过这种情况:

// 没有任何 import
const app = express();  // ✅ 类型正常
const router = Router();  // ✅ 类型正常

// 但是这些类型是哪来的?

打开 node_modules/@types/express/index.d.ts,发现:

declare function express(): Express.Application;
declare namespace Express {
  interface Application {}
}

疑问: 1. 为什么不需要 import 就能用? 2. declare 关键字是什么意思? 3. .d.ts 文件是如何工作的?

今天就来彻底搞懂这些问题。

二、.d.ts 文件是什么

2.1 定义

.d.ts 文件是 TypeScript 的类型声明文件(Type Declaration File),用于描述 JavaScript 代码的类型信息。

作用: - 为 JavaScript 库提供类型定义 - 让 TypeScript 理解 JavaScript 代码 - 提供代码提示和类型检查

2.2 为什么需要 .d.ts?

JavaScript 本身没有类型信息:

// math.js
export function add(a, b) {
  return a + b;
}

TypeScript 不知道 add 的参数和返回值类型:

import { add } from './math';
add(1, 2);  // ❌ TypeScript 不知道类型

解决方案:创建 .d.ts 文件

// math.d.ts
export function add(a: number, b: number): number;

现在 TypeScript 就知道类型了:

import { add } from './math';
add(1, 2);  // ✅ 类型正确
add('1', '2');  // ❌ 类型错误

三、declare 关键字

3.1 declare 的作用

declare 告诉 TypeScript:「这个东西在运行时存在,但我只是声明它的类型,不提供实现」。

// 声明全局变量
declare const API_URL: string;

// 声明全局函数
declare function fetchData(url: string): Promise<any>;

// 声明全局类
declare class User {
  name: string;
  age: number;
}

使用时不需要 import

// 直接使用,TypeScript 知道类型
console.log(API_URL);  // ✅
fetchData('/api/users');  // ✅
const user = new User();  // ✅

3.2 declare 的场景

场景 1:全局变量

// global.d.ts
declare const __DEV__: boolean;
declare const process: {
  env: {
    NODE_ENV: string;
  };
};

// 使用
if (__DEV__) {
  console.log('Development mode');
}

场景 2:第三方库

// jquery.d.ts
declare const $: {
  (selector: string): any;
  ajax(options: any): any;
};

// 使用
$('#app').hide();
$.ajax({ url: '/api' });

场景 3:模块扩展

// express.d.ts
declare namespace Express {
  interface Request {
    user?: User;
  }
}

// 使用
app.get('/', (req, res) => {
  console.log(req.user);  // ✅ TypeScript 知道 user 属性
});

四、为什么不需要 import?

4.1 全局声明 vs 模块声明

.d.ts 文件有两种模式

模式 1:全局声明(没有 import/export)

// global.d.ts
declare const API_URL: string;
declare function fetchData(url: string): Promise<any>;

这些声明是全局的,不需要 import 就能用。

模式 2:模块声明(有 import/export)

// types.d.ts
export interface User {
  name: string;
  age: number;
}

export function getUser(id: string): Promise<User>;

这些声明需要 import 才能用:

import { User, getUser } from './types';

4.2 TypeScript 如何找到 .d.ts 文件?

TypeScript 会自动查找 .d.ts 文件:

查找顺序

1. 项目根目录*.d.ts 2. src 目录src/**/*.d.ts 3. node_modules/@typesnode_modules/@types/*/index.d.ts 4. tsconfig.json 的 types:指定的类型包

// tsconfig.json
{
  "compilerOptions": {
    "types": ["node", "jest", "express"]
  }
}

4.3 自动包含的 .d.ts 文件

TypeScript 会自动包含:

project/
├── src/
│   ├── index.ts
│   └── types.d.ts        # ✅ 自动包含
├── global.d.ts           # ✅ 自动包含
└── node_modules/
    └── @types/
        ├── node/         # ✅ 自动包含
        └── express/      # ✅ 自动包含

不需要手动 import,TypeScript 会自动加载。

五、实战案例

5.1 为第三方库添加类型

假设你用了一个没有类型定义的库:

// awesome-lib.js
export function doSomething(value) {
  return value * 2;
}

创建类型声明:

// awesome-lib.d.ts
declare module 'awesome-lib' {
  export function doSomething(value: number): number;
}

现在可以安全使用:

import { doSomething } from 'awesome-lib';
doSomething(5);  // ✅ 类型正确
doSomething('5');  // ❌ 类型错误

5.2 扩展全局对象

// global.d.ts
declare global {
  interface Window {
    myApp: {
      version: string;
      init(): void;
    };
  }
}

export {};  // 让文件成为模块

使用:

window.myApp.version;  // ✅
window.myApp.init();   // ✅

5.3 环境变量类型

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'test';
    API_URL: string;
    API_KEY: string;
  }
}

使用:

const env = process.env.NODE_ENV;  // ✅ 类型是 'development' | 'production' | 'test'
const url = process.env.API_URL;   // ✅ 类型是 string

5.4 React 组件 Props

// components.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'my-component': {
      value: string;
      onChange: (value: string) => void;
    };
  }
}

使用:

<my-component value="hello" onChange={(v) => console.log(v)} />

六、常见问题

6.1 .d.ts 文件不生效?

原因 1:文件不在 TypeScript 的查找路径

// tsconfig.json
{
  "include": ["src/**/*"],  // 只包含 src 目录
  "exclude": ["node_modules"]
}

解决:把 .d.ts 文件放在 src 目录下,或修改 include

原因 2:文件有 import/export,变成了模块

// global.d.ts
import { Something } from 'somewhere';  // ❌ 变成模块了

declare const API_URL: string;  // 不再是全局声明

解决:使用 declare global

import { Something } from 'somewhere';

declare global {
  const API_URL: string;
}

6.2 如何调试类型问题?

// 查看类型
type Test = typeof API_URL;  // 鼠标悬停查看

// 强制类型检查
const _check: string = API_URL;  // 如果类型不对会报错

6.3 .d.ts 和 .ts 的区别?

特性 .ts .d.ts
包含实现
包含类型
编译成 .js
自动全局 ✅(无 import/export 时)

七、最佳实践

7.1 组织 .d.ts 文件

project/
├── src/
│   ├── types/
│   │   ├── global.d.ts      # 全局类型
│   │   ├── modules.d.ts     # 模块扩展
│   │   └── env.d.ts         # 环境变量
│   └── index.ts
└── tsconfig.json

7.2 命名规范

// ✅ 好的命名
global.d.ts
env.d.ts
express.d.ts

// ❌ 不好的命名
types.d.ts  // 太泛化
index.d.ts  // 不清楚内容

7.3 注释文档

/**
 * 全局 API 配置
 * @example
 * ```ts
 * console.log(API_URL);  // 'https://api.example.com'
 * ```
 */
declare const API_URL: string;

八、总结

.d.ts 文件的核心概念

1. 类型声明文件:只有类型,没有实现 2. declare 关键字:声明类型,不提供实现 3. 全局 vs 模块:无 import/export 是全局,有则是模块 4. 自动加载:TypeScript 自动查找并加载

为什么不需要 import?

- 全局声明的 .d.ts 文件会被 TypeScript 自动加载 - TypeScript 会扫描项目和 node_modules/@types - 全局声明对整个项目可见

使用场景

- 为 JavaScript 库添加类型 - 声明全局变量和函数 - 扩展第三方库的类型 - 定义环境变量类型

最佳实践

- 全局类型放在 global.d.ts - 模块类型使用 export - 添加注释文档 - 合理组织文件结构

如果这篇文章对你有帮助,欢迎点赞收藏。

万字解析 OpenClaw 源码架构:从入门到精通

作者 兆子龙
2026年3月12日 17:10

一、OpenClaw 项目概览

OpenClaw 是一个现代化的 Web 应用框架,专注于提供高性能、可扩展的全栈解决方案。

核心特点: - 全栈 TypeScript,类型安全 - Monorepo 架构,模块化设计 - 插件化系统,易于扩展 - 高性能运行时 - 完善的开发工具链

二、项目结构深度解析

2.1 Monorepo 架构

openclaw/
├── packages/              # 核心包
│   ├── core/             # 框架核心
│   ├── cli/              # 命令行工具
│   ├── server/           # 服务端
│   ├── client/           # 客户端
│   ├── router/           # 路由系统
│   ├── state/            # 状态管理
│   └── utils/            # 工具库
├── examples/             # 示例项目
├── docs/                 # 文档
└── scripts/              # 构建脚本

为什么选择 Monorepo?

1. 代码共享:包之间直接引用,无需发布 2. 统一版本:依赖版本一致 3. 原子提交:跨包修改一次提交 4. 统一工具链:共享配置

工具选择: - pnpm:快速、节省空间 - Turborepo:增量构建

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

2.2 核心包架构

@openclaw/core

packages/core/
├── src/
│   ├── app/              # 应用实例
│   ├── middleware/       # 中间件
│   ├── plugin/           # 插件系统
│   └── lifecycle/        # 生命周期
└── types/                # 类型定义

三、核心模块实现

3.1 Application 类

export class Application {
  private plugins: Plugin[] = [];
  private middleware: Middleware[] = [];

  use(plugin: Plugin): this {
    this.plugins.push(plugin);
    plugin.install(this);
    return this;
  }

  middleware(fn: MiddlewareFunction): this {
    this.middleware.push(fn);
    return this;
  }

  async start(): Promise<void> {
    await this.runLifecycle('beforeStart');
    const composed = compose(this.middleware);
    await this.server.listen(this.options.port);
    await this.runLifecycle('afterStart');
  }
}

3.2 中间件系统(洋葱模型)

export function compose(middleware: Middleware[]): ComposedMiddleware {
  return function (context: Context, next?: Next) {
    let index = -1;

    function dispatch(i: number): Promise<void> {
      if (i <= index) {
        throw new Error('next() called multiple times');
      }
      index = i;
      const fn = middleware[i];
      if (!fn) return Promise.resolve();
      
      return Promise.resolve(fn(context, () => dispatch(i + 1)));
    }

    return dispatch(0);
  };
}

使用示例

app.middleware(async (ctx, next) => {
  console.log('Before');
  await next();
  console.log('After');
});

3.3 插件系统

export interface Plugin {
  name: string;
  install(app: Application): void;
  beforeStart?(context: Context): Promise<void>;
  afterStart?(context: Context): Promise<void>;
}

// 数据库插件示例
export const DatabasePlugin: Plugin = {
  name: 'database',
  
  install(app) {
    app.context.db = createDatabase();
  },
  
  async beforeStart(ctx) {
    await ctx.db.connect();
  }
};

四、路由系统设计

4.1 路由匹配

export class Router {
  private routes: Route[] = [];

  get(path: string, handler: RouteHandler): this {
    return this.register('GET', path, handler);
  }

  private register(method: string, path: string, handler: RouteHandler) {
    this.routes.push({
      method,
      path,
      handler,
      regex: pathToRegex(path)
    });
    return this;
  }

  match(method: string, path: string): RouteMatch | null {
    for (const route of this.routes) {
      if (route.method !== method) continue;
      const match = path.match(route.regex);
      if (match) return { route, params: extractParams(match) };
    }
    return null;
  }
}

路径转正则

function pathToRegex(path: string): RegExp {
  // /users/:id -> /users/([^/]+)
  const pattern = path
    .replace(/\//g, '\\/')
    .replace(/:(\w+)/g, '([^/]+)');
  return new RegExp(`^${pattern}$`);
}

4.2 嵌套路由

const apiRouter = new Router();
apiRouter.get('/users', getUsersHandler);

const app = new Application();
app.middleware(
  new Router().use('/api', apiRouter).middleware()
);
// 结果:GET /api/users

五、状态管理

5.1 Store 实现

export class Store<T> {
  private state: T;
  private listeners: Set<Listener<T>> = new Set();

  getState(): T {
    return this.state;
  }

  setState(updater: Updater<T>): void {
    const prevState = this.state;
    const nextState = typeof updater === 'function'
      ? updater(prevState)
      : updater;

    if (prevState === nextState) return;

    this.state = nextState;
    this.listeners.forEach(listener => {
      listener(nextState, prevState);
    });
  }

  subscribe(listener: Listener<T>): Unsubscribe {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
}

5.2 选择器优化

export function createSelector<T, R>(
  selector: (state: T) => R
): Selector<T, R> {
  let lastState: T;
  let lastResult: R;

  return (state: T): R => {
    if (state === lastState) return lastResult;
    const result = selector(state);
    lastState = state;
    lastResult = result;
    return result;
  };
}

六、构建系统

6.1 Turborepo 配置

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true
    }
  }
}

增量构建: - 只构建变化的包 - 缓存构建结果 - 并行执行任务

6.2 TypeScript 配置

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "composite": true,
    "declaration": true
  },
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/router" }
  ]
}

Project References: - 加快编译速度 - 增量编译 - 更好的类型检查

七、设计模式应用

7.1 工厂模式

export function createApplication(options: ApplicationOptions): Application {
  const app = new Application(options);
  
  // 注册默认插件
  app.use(LoggerPlugin);
  app.use(ErrorHandlerPlugin);
  
  return app;
}

7.2 观察者模式

// Store 的订阅机制
store.subscribe((state) => {
  console.log('State changed:', state);
});

7.3 责任链模式

// 中间件的洋葱模型
app.middleware(middleware1);
app.middleware(middleware2);
app.middleware(middleware3);

7.4 策略模式

// 路由匹配策略
interface MatchStrategy {
  match(path: string): boolean;
}

class ExactMatch implements MatchStrategy {
  match(path: string): boolean {
    return path === this.pattern;
  }
}

class RegexMatch implements MatchStrategy {
  match(path: string): boolean {
    return this.regex.test(path);
  }
}

八、性能优化

8.1 缓存策略

class CacheMiddleware {
  private cache = new Map<string, any>();

  middleware(): Middleware {
    return async (ctx, next) => {
      const key = ctx.request.url;
      
      if (this.cache.has(key)) {
        ctx.json(this.cache.get(key));
        return;
      }

      await next();

      if (ctx.response.status === 200) {
        this.cache.set(key, ctx.response.body);
      }
    };
  }
}

8.2 懒加载

// 路由懒加载
router.get('/admin', async (ctx) => {
  const { AdminController } = await import('./controllers/admin');
  return new AdminController().handle(ctx);
});

8.3 连接池

class DatabasePool {
  private pool: Connection[] = [];
  private maxSize = 10;

  async getConnection(): Promise<Connection> {
    if (this.pool.length > 0) {
      return this.pool.pop()!;
    }
    return await this.createConnection();
  }

  release(conn: Connection): void {
    if (this.pool.length < this.maxSize) {
      this.pool.push(conn);
    } else {
      conn.close();
    }
  }
}

九、测试策略

9.1 单元测试

describe('Router', () => {
  it('should match route', () => {
    const router = new Router();
    router.get('/users/:id', handler);

    const match = router.match('GET', '/users/123');
    expect(match).toBeDefined();
    expect(match.params.id).toBe('123');
  });
});

9.2 集成测试

describe('Application', () => {
  it('should handle request', async () => {
    const app = createApplication();
    app.middleware(async (ctx) => {
      ctx.json({ message: 'Hello' });
    });

    const response = await request(app)
      .get('/')
      .expect(200);

    expect(response.body.message).toBe('Hello');
  });
});

十、最佳实践

10.1 错误处理

app.middleware(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.status || 500;
    ctx.json({
      error: err.message,
      stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
    });
  }
});

10.2 日志记录

app.middleware(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.request.method} ${ctx.request.url} - ${ms}ms`);
});

10.3 安全防护

// CORS
app.middleware(async (ctx, next) => {
  ctx.response.setHeader('Access-Control-Allow-Origin', '*');
  await next();
});

// Rate Limiting
const limiter = new RateLimiter({ max: 100, window: 60000 });
app.middleware(limiter.middleware());

十一、总结

OpenClaw 的架构设计体现了现代 Web 框架的最佳实践:

核心特点: - Monorepo 架构,模块化设计 - 插件化系统,易于扩展 - 中间件洋葱模型,灵活组合 - TypeScript 类型安全 - 高性能运行时

设计模式: - 工厂模式:创建应用实例 - 观察者模式:状态订阅 - 责任链模式:中间件系统 - 策略模式:路由匹配

性能优化: - 缓存策略 - 懒加载 - 连接池 - 增量构建

通过深入理解 OpenClaw 的源码架构,你可以学到: - 如何设计一个可扩展的框架 - 如何实现高性能的运行时 - 如何组织大型项目的代码结构 - 如何应用设计模式解决实际问题

如果这篇文章对你有帮助,欢迎点赞收藏。

深入理解Vue中的插槽:概念、原理与应用

作者 左夕
2026年3月12日 16:58

在Vue.js的开发中,我们经常会遇到这样一个场景:需要创建一个可复用的组件,但组件的某些部分需要根据具体使用场景展示不同的内容。这时候,插槽(Slot)就成为了我们最得力的工具。插槽是Vue实现内容分发的一种机制,它允许我们在调用组件时向组件内部传递自定义内容,从而让组件变得更加灵活和可复用。

什么是插槽

想象一下,我们生活中常见的卡片组件。一张卡片通常有固定的结构——边框、背景色、圆角,但卡片内部的内容却千变万化,可能是文字、图片,也可能是按钮或者更复杂的组合。

如果我们要用Vue实现这样的卡片组件,传统的props传递方式会显得力不从心。props适合传递数据,但不适合传递复杂的HTML结构。让我们来看一个对比:

// 使用props传递HTML内容(不推荐)
<card content="<h2>标题</h2><p>内容</p>" />

// 使用插槽传递内容(推荐)
<card>
  <h2>标题</h2>
  <p>内容</p>
</card>

插槽本质上是一个占位符,它在子组件中预留了一个位置,当父组件使用这个子组件时,可以向这个位置填充自定义的模板内容。这种设计模式被称为"内容分发",它遵循了开放封闭原则——组件对扩展开放,对修改封闭。

插槽的核心作用

1. 实现内容自定义

通过插槽,我们可以创建出具有固定框架但内部内容可变的组件,大大提升组件的复用性。一个写好插槽的卡片组件,可以在项目中的任何地方使用,而每次使用时都可以填充完全不同的内容。

2. 促进职责分离

父组件负责业务逻辑和内容组织,子组件负责结构和样式表现,两者通过插槽进行优雅的协作。这种分离让代码更容易理解和维护。

3. 提供布局灵活性

特别是具名插槽的出现,让组件可以定义多个内容区域,使用者可以精确控制内容填充的位置,实现复杂的布局需求。

4. 实现反向数据流

作用域插槽更进一步,允许子组件向父组件传递数据,让父组件可以根据子组件的数据来渲染内容,实现了双向的交互。

插槽的三种类型及使用方式

1. 默认插槽

默认插槽是最基本的形式,当组件中只使用一个<slot>标签时,所有传递给组件的内容都会显示在这个位置。

我们先创建一个基础的卡片组件:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      卡片标题
    </div>
    <div class="card-body">
      <!-- 插槽占位符,父组件传递的内容将显示在这里 -->
      <slot></slot>
    </div>
    <div class="card-footer">
      底部信息
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  margin: 10px 0;
}
.card-header {
  font-weight: bold;
  border-bottom: 1px solid #ddd;
  padding-bottom: 5px;
}
.card-body {
  padding: 10px 0;
}
.card-footer {
  border-top: 1px solid #ddd;
  padding-top: 5px;
  color: #666;
}
</style>

在父组件中使用这个卡片:

<!-- Parent.vue -->
<template>
  <div>
    <card>
      <!-- 这里的内容会填充到子组件的slot位置 -->
      <p>这是卡片的主体内容</p>
      <button>点击查看详情</button>
    </card>
    
    <card>
      <ul>
        <li>列表项1</li>
        <li>列表项2</li>
        <li>列表项3</li>
      </ul>
    </card>
  </div>
</template>

<script>
import Card from './Card.vue'

export default {
  components: {
    Card
  }
}
</script>

如果希望插槽有默认内容,可以在<slot>标签内设置:

<template>
  <div class="card">
    <slot>
      <!-- 这是默认内容,当父组件没有传递内容时显示 -->
      <p>暂无内容,请稍后查看</p>
    </slot>
  </div>
</template>

2. 具名插槽

当一个组件需要多个内容区域时,就需要使用具名插槽。通过name属性可以区分不同的插槽。

创建一个带有多个区域的布局组件:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <!-- 头部插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main class="main">
      <!-- 默认插槽,不设置name的插槽默认name为"default" -->
      <slot></slot>
    </main>
    
    <aside class="sidebar">
      <!-- 侧边栏插槽 -->
      <slot name="sidebar"></slot>
    </aside>
    
    <footer class="footer">
      <!-- 底部插槽,带默认内容 -->
      <slot name="footer">
        <p>版权所有 © 2024</p>
      </slot>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 200px 1fr;
  gap: 20px;
}
.header { grid-area: header; }
.main { grid-area: main; }
.sidebar { grid-area: sidebar; }
.footer { grid-area: footer; }
</style>

使用具名插槽:

<!-- App.vue -->
<template>
  <layout>
    <!-- v-slot指令指定内容放入哪个插槽,可以简写为# -->
    <template v-slot:header>
      <h1>网站标题</h1>
      <nav>
        <a href="#">首页</a>
        <a href="#">关于</a>
        <a href="#">联系</a>
      </nav>
    </template>

    <!-- 默认插槽的内容 -->
    <article>
      <h2>文章标题</h2>
      <p>这是文章的主要内容...</p>
    </article>

    <!-- 使用简写形式 -->
    <template #sidebar>
      <ul>
        <li>分类1</li>
        <li>分类2</li>
        <li>分类3</li>
      </ul>
    </template>

    <!-- 覆盖默认的底部内容 -->
    <template #footer>
      <p>自定义底部信息 | 备案号XXX</p>
    </template>
  </layout>
</template>

3. 作用域插槽

作用域插槽允许子组件将数据传递给父组件的插槽内容。这在需要根据子组件内部状态来定制渲染内容时特别有用。

创建一个待办事项列表组件:

<!-- TodoList.vue -->
<template>
  <div class="todo-list">
    <h3>待办事项列表</h3>
    <ul>
      <li v-for="item in items" :key="item.id" class="todo-item">
        <!-- 
          通过v-bind将item数据绑定到插槽上
          这样父组件就可以访问到item对象
        -->
        <slot :todo="item" :index="index">
          <!-- 默认的渲染方式 -->
          <span>{{ item.text }}</span>
          <span :class="{ completed: item.done }">
            {{ item.done ? '已完成' : '进行中' }}
          </span>
        </slot>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  }
}
</script>

在父组件中使用作用域插槽自定义渲染:

<!-- Parent.vue -->
<template>
  <div>
    <h2>默认渲染方式</h2>
    <todo-list :items="todos" />

    <h2>自定义渲染方式</h2>
    <todo-list :items="todos">
      <!-- 使用v-slot接收子组件传递的数据,可以解构 -->
      <template v-slot:default="{ todo, index }">
        <div class="custom-todo">
          <input type="checkbox" v-model="todo.done">
          <span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }">
            {{ index + 1 }}. {{ todo.text }}
          </span>
          <button @click="deleteTodo(todo.id)">删除</button>
        </div>
      </template>
    </todo-list>
  </div>
</template>

<script>
import TodoList from './TodoList.vue'

export default {
  components: {
    TodoList
  },
  data() {
    return {
      todos: [
        { id: 1, text: '学习Vue插槽', done: false },
        { id: 2, text: '写博客文章', done: true },
        { id: 3, text: '复习JavaScript', done: false }
      ]
    }
  },
  methods: {
    deleteTodo(id) {
      this.todos = this.todos.filter(todo => todo.id !== id)
    }
  }
}
</script>

作用域插槽的另一种常见用法是用于列表组件的列自定义。以表格组件为例:

<!-- DataTable.vue -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column.key">
          {{ column.title }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, rowIndex) in data" :key="rowIndex">
        <td v-for="column in columns" :key="column.key">
          <!-- 如果该列定义了自定义渲染插槽,则使用插槽 -->
          <template v-if="column.slotName">
            <slot :name="column.slotName" :row="row" :column="column">
              {{ row[column.key] }}
            </slot>
          </template>
          <template v-else>
            {{ row[column.key] }}
          </template>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    columns: Array,
    data: Array
  }
}
</script>

使用表格组件:

<template>
  <data-table :columns="columns" :data="users">
    <!-- 自定义状态列的渲染 -->
    <template #status="{ row }">
      <span :class="['status-badge', row.status]">
        {{ row.status === 'active' ? '启用' : '禁用' }}
      </span>
    </template>
    
    <!-- 自定义操作列的渲染 -->
    <template #actions="{ row }">
      <button @click="editUser(row)">编辑</button>
      <button @click="deleteUser(row)">删除</button>
    </template>
  </data-table>
</template>

插槽的工作原理

理解插槽的工作原理,需要从Vue的编译和渲染过程说起。当Vue编译模板时,它会构建一个虚拟DOM树。在这个过程中,遇到组件标签时,Vue会将该组件实例化,同时处理组件标签内的子节点。

对于普通的HTML元素,子节点会直接作为父节点的children。但对于组件,情况有所不同。组件标签内的内容会被编译为插槽的内容,而组件模板中的<slot>标签则会被编译为插槽的出口。

在渲染阶段,Vue会创建一个渲染函数,这个函数会返回虚拟DOM。当渲染函数执行时,它会解析组件模板中的<slot>标签,并将其替换为父组件传递进来的对应内容。如果父组件没有传递内容,则会渲染插槽中定义的后备内容。

对于作用域插槽,Vue会建立一个从子组件到父组件的数据通道。子组件在渲染插槽时,会将绑定的数据作为参数传递给插槽函数,父组件的插槽内容就可以访问到这些数据。

实际应用场景

场景一:弹窗组件

<!-- Modal.vue -->
<template>
  <div v-if="visible" class="modal-overlay">
    <div class="modal-container">
      <div class="modal-header">
        <slot name="header">
          <h3>{{ title }}</h3>
        </slot>
        <button @click="$emit('close')">×</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
      <div class="modal-footer">
        <slot name="footer">
          <button @click="$emit('close')">关闭</button>
          <button class="primary" @click="$emit('confirm')">确认</button>
        </slot>
      </div>
    </div>
  </div>
</template>

场景二:列表项的多种展示模式

<template>
  <div>
    <!-- 卡片模式 -->
    <item-list :items="products" mode="card">
      <template #item="{ item }">
        <div class="product-card">
          <img :src="item.image" :alt="item.name">
          <h4>{{ item.name }}</h4>
          <p>¥{{ item.price }}</p>
          <button @click="addToCart(item)">加入购物车</button>
        </div>
      </template>
    </item-list>
    
    <!-- 列表模式 -->
    <item-list :items="products" mode="list">
      <template #item="{ item }">
        <div class="product-row">
          <span>{{ item.name }}</span>
          <span>¥{{ item.price }}</span>
          <input type="number" v-model.number="item.quantity">
        </div>
      </template>
    </item-list>
  </div>
</template>

使用技巧与注意事项

1. 合理设置后备内容

<slot name="loading">
  <div class="loading-spinner">加载中...</div>
</slot>

2. 解构作用域插槽的props

<template #item="{ id, name, price, index }">
  <div>{{ index }}. {{ name }} - {{ price }}</div>
</template>

3. 动态插槽名

<template #[dynamicSlotName]>
  动态插槽内容
</template>

4. 多个插槽的复用

如果多个插槽需要相同的内容,考虑提取为组件:

<template>
  <complex-component>
    <template #header>
      <common-content />
    </template>
    <template #sidebar>
      <common-content />
    </template>
  </complex-component>
</template>

5. 注意事项

  • 插槽内容的作用域:插槽内容无法访问子组件的数据,除非使用作用域插槽
  • 具名插槽的简写:v-slot:header 可以简写为 #header
  • 默认插槽的显式使用:当同时使用默认插槽和具名插槽时,建议显式包裹默认插槽

结语

插槽是Vue组件化设计中不可或缺的一部分,它体现了Vue灵活、渐进的设计理念。通过合理使用插槽,我们可以构建出既强大又灵活的组件库,提高开发效率和代码质量。

从简单的默认插槽,到处理复杂布局的具名插槽,再到实现数据反向流动的作用域插槽,每一种插槽类型都有其特定的应用场景。深入理解这些概念,能让我们在组件设计时做出更合理的决策,编写出更优雅的Vue应用。

在实际项目中,插槽的使用往往能反映出开发者对组件化思想的理解深度。掌握好这个工具,相信你的Vue开发之路会更加顺畅。

JeecgBoot低代码平台 Qiankun 微前端集成指南:主应用配置全流程

2026年3月12日 16:39

JeecgBoot AI专题研究 | JeecgBoot低代码微前端架构落地实践


微前端解决了什么问题?

当 JeecgBoot低代码项目发展到一定规模,单体前端的弊端开始显现:模块耦合严重、构建时间激增、团队协作困难。微前端架构允许将不同业务模块拆分为独立的子应用,各自开发、独立部署,通过 Qiankun 框架在运行时动态组合。

本文聚焦于如何将 JeecgBoot-Vue3 配置为 Qiankun 微前端的主应用(基座),接管路由分发和子应用生命周期管理。

第一步:安装 Qiankun 依赖

在 JeecgBoot低代码主应用项目中安装 Qiankun:

pnpm add qiankun

第二步:配置子应用注册信息

JeecgBoot 已经预置了 Qiankun 集成的代码框架,只需要取消注释并配置三个核心文件:

src/qiankun/apps.ts — 子应用注册表

在这个文件中定义每个微应用的元数据:

const apps = [
  {
    name: 'qiankun-app',          // 子应用唯一标识
    entry: '//localhost:8001',     // 子应用入口地址
    container: '#subapp-viewport', // 挂载容器
    activeRule: '/qiankun-app',    // 路由激活规则
  },
];

src/qiankun/state.ts — 全局状态管理

建立主应用与子应用之间的数据共享通道,用于传递用户信息、权限数据、主题配置等全局状态。

src/qiankun/index.ts — 注册与启动

注册所有子应用并配置生命周期钩子(加载前、挂载后、卸载后等),这是微前端运行时的核心调度逻辑。

第三步:配置环境变量

不同环境下子应用的入口地址不同,需要在环境变量文件中分别配置。

开发环境 .env.development

VITE_APP_SUB_qiankun-app = '//localhost:8001/qiankun-app'

生产环境 .env.production

VITE_APP_SUB_qiankun-app = '[生产域名]/qiankun-app'

同时,在环境变量中开启 Qiankun 全局开关:

VITE_GLOB_APP_OPEN_QIANKUN=true

第四步:子应用侧的适配要求

子应用要接入 JeecgBoot低代码主应用,需要满足以下条件:

1. 配置运行时公共路径

创建 public-path.js 文件,确保子应用在 Qiankun 沙箱环境下能正确加载静态资源:

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

2. 导出生命周期函数

子应用必须导出以下四个生命周期函数供主应用调用:

  • bootstrap() — 初始化,仅在首次加载时调用
  • mount() — 挂载,每次进入子应用时调用
  • unmount() — 卸载,每次离开子应用时调用
  • update() — 更新,主应用传递数据变更时调用

3. 构建输出配置

子应用的 vue.config.js 需要配置 UMD 格式输出,并开启 CORS 跨域头:

module.exports = {
  output: {
    library: 'qiankun-app',
    libraryTarget: 'umd',
  },
  devServer: {
    headers: { 'Access-Control-Allow-Origin': '*' },
  },
};

实践中的注意事项

  • 样式隔离:Qiankun 默认的沙箱机制可以隔离 JS 全局变量,但 CSS 隔离需要额外配置 strictStyleIsolationexperimentalStyleIsolation
  • 路由冲突:主应用和子应用的路由前缀不能重叠,建议每个子应用使用独立的路由命名空间
  • 通信机制:简单场景使用 Qiankun 内置的 initGlobalState,复杂场景可以引入 EventBus 或状态管理库

总结

JeecgBoot低代码平台对 Qiankun 微前端的集成已经做了大量预置工作,开发者只需取消注释、配置环境变量即可快速启用。微前端架构特别适合多团队协作的大型项目,能够在保持整体统一性的同时给予各业务团队充分的技术自主权。


本文为 JeecgBoot AI 专题研究系列文章。

虚拟 DOM、Diff 算法与 Fiber

作者 lcy453
2026年3月12日 16:30

一、虚拟 DOM 是什么?

一句话:用 JS 对象来描述真实 DOM 的结构,先在内存里算好差异,再最小化更新真实 DOM。

真实 DOM vs 虚拟 DOM

// 真实 DOM(浏览器里的)
<div class="box">
  <h1>标题</h1>
  <p>内容</p>
</div>

// 虚拟 DOM(JS 对象)
{
  type: 'div',
  props: {
    className: 'box',
    children: [
      { type: 'h1', props: { children: '标题' } },
      { type: 'p', props: { children: '内容' } }
    ]
  }
}

为什么要用虚拟 DOM?

操作真实 DOM 很(涉及浏览器重排重绘),而操作 JS 对象很

数据变化
    ↓
生成新的虚拟 DOM 树
    ↓
新旧虚拟 DOM 对比(Diff)
    ↓
找出最小差异
    ↓
只更新变化的真实 DOM(Patch)
方式 做法 性能
直接操作 DOM 数据一变就全量更新 DOM
虚拟 DOM 先算差异,只更新变化的部分 快(大多数场景)

注意:虚拟 DOM 不是"比直接操作 DOM 快",而是在大量更新时,通过批量 + 最小化更新来优化性能。极简场景下,直接操作 DOM 反而更快。

二、Diff 算法

React 用 Diff 算法对比新旧虚拟 DOM 树,找出需要更新的部分。

三个策略(把 O(n³) 降到 O(n))

策略 说明
同层比较 只比较同一层级的节点,不跨层级比较
类型判断 节点类型不同 → 直接销毁旧树,创建新树
Key 标识 同类型的列表元素用 key 来标识,精准匹配

策略一:同层比较

旧树:         新树:
  A              A
 / \            / \
B   C          B   D    ← 只比较同层:发现 C→D,替换
|               |
D               E

React 只会比较 A-A、B-B、C-D... 不会跨层去比较。如果把节点从一棵子树移到另一棵,React 会销毁+重建,而不是移动。

策略二:类型判断

// 旧          新
<div>         <span>
  <Counter/>    <Counter/>
</div>        </span>

// div → span:类型不同 → 整个销毁旧树(包括 Counter),重建新树
// Counter 的 state 会丢失!

策略三:Key 的作用(列表 Diff)

// 没有 key:插入一项,React 不知道哪个是新的,可能全部更新
// 旧:[A, B, C]
// 新:[A, X, B, C]
// React:A不变,B→X(错),C→B(错),新增C(错)—— 大量无效更新

// 有 key:React 能精准识别
// 旧:[A:1, B:2, C:3]
// 新:[A:1, X:4, B:2, C:3]
// React:A不变,新增X,B不变,C不变 —— 只做一次插入 ✅

Key 的最佳实践

// ❌ 用 index 做 key(增删排序时出问题)
list.map((item, i) => <li key={i}>{item.name}</li>)

// ❌ 用随机数做 key(每次渲染都变,等于没加)
list.map(item => <li key={Math.random()}>{item.name}</li>)

// ✅ 用唯一且稳定的 id
list.map(item => <li key={item.id}>{item.name}</li>)

三、Fiber 架构

旧架构的问题(React 15)

React 15 使用递归遍历虚拟 DOM 树(Stack Reconciler):

开始 Diff → 递归遍历整棵树 → 全部算完 → 更新 DOM
            ↑ 这个过程不能中断!

问题:如果组件树很大,递归遍历耗时超过 16ms(一帧),浏览器来不及渲染 → 页面卡顿

Fiber 是什么?(React 16+)

一句话:把大任务拆成小任务,每个小任务做完看看有没有更重要的事(比如用户输入),有就先去做,没有就继续。

旧(Stack):一口气干完  ████████████████████████ 卡了!
新(Fiber):分段干      ██ 空 ██ 空 ██ 空 ████    不卡!
                         ↑  ↑  ↑ 检查有没有更高优先级的任务

Fiber 的核心思想

概念 说明
可中断 渲染过程可以暂停,让出主线程给浏览器
可恢复 暂停后可以从断点继续,不用从头开始
优先级调度 高优先级(用户输入)优先处理,低优先级(数据请求后的渲染)延后
增量渲染 一帧只做一部分工作,分多帧完成

Fiber 节点结构

每个组件/元素对应一个 Fiber 节点,通过链表关联:

     App (Fiber)
      ↓ child
    Header (Fiber) → sibling → Main (Fiber) → sibling → Footer (Fiber)
      ↓ child                    ↓ child
    Logo (Fiber)              Content (Fiber)
      ↑ returnreturn
    Header                     Main
指针 指向
child 第一个子节点
sibling 下一个兄弟节点
return 父节点

遍历顺序:深度优先 — child → sibling → return。因为是链表,可以随时暂停,记住当前位置,之后继续。

Fiber 的两个阶段

阶段 名称 特点
Render 阶段 协调(Reconciliation) 计算差异,可中断,不操作 DOM
Commit 阶段 提交 把差异应用到真实 DOM,不可中断,同步执行
Render 阶段(可中断)          Commit 阶段(同步)
━━━━━━━━━━━━━━━━━━━         ━━━━━━━━━━━━━━━━━
遍历 Fiber 树                 更新真实 DOM
对比新旧,标记变化             执行生命周期/useEffect
可以暂停、恢复                 一口气做完,不暂停

四、双缓冲机制(Double Buffering)

React 同时维护两棵 Fiber 树:

作用
current 树 当前屏幕上显示的 UI
workInProgress 树 内存中正在构建的新树
current 树(屏幕上)       workInProgress 树(内存中)
      App                        App'
     / \                        / \
  Header Main               Header Main'
                                    |
                                Content'(有更新)

构建完成后 → workInProgress 变成新的 current(指针切换,瞬间完成)

好处:构建过程中用户看到的始终是完整的旧 UI,不会出现"半成品"。跟显卡双缓冲一个道理。

五、优先级模型(Lanes)

React 18 用 Lane 模型 给任务分优先级,高优先级可以打断低优先级:

优先级 场景 例子
同步(最高) 用户直接交互 打字、点击
连续输入 持续交互 拖拽、滚动
普通 数据更新 请求回来后 setState
过渡 不紧急的更新 useTransition 包裹的更新
空闲(最低) 可延后 offscreen 预渲染

核心思想:用户能感知的操作(输入、点击)必须立即响应,数据渲染可以稍等。

六、高频面试题

Q1:虚拟 DOM 一定比真实 DOM 快吗?

不一定。虚拟 DOM 有创建 JS 对象 + Diff 对比的开销。在以下场景,直接操作 DOM 可能更快:

  • 极简单的 UI(一两个元素)
  • 已知确切的 DOM 操作(不需要 Diff)

虚拟 DOM 的优势在于:在复杂应用中,自动帮你找到最小更新范围,开发者不用手动管理 DOM 更新。

Q2:key 为什么不能用 index?

当列表会增删或排序时,index 会变化,React 的 Diff 会把元素搞混:

旧:[A:0, B:1, C:2]   删除A后
新:[B:0, C:1]         key=0A 和 key=0B 对比 → React 认为 A 变成了 B → 错误复用

用唯一 id 做 key 就不会有这个问题。

Q3:Fiber 和之前的区别?

对比 Stack Reconciler (React 15) Fiber Reconciler (React 16+)
数据结构 递归调用栈 Fiber 链表
是否可中断 不可中断 可中断可恢复
调度 同步,一次性完成 按优先级分时间片
大组件树 可能卡顿 不卡顿

Q4:React 的渲染流程?

setState / props 变化
    ↓
触发调度(Scheduler)→ 按优先级安排任务
    ↓
Render 阶段 → 遍历 Fiber 树,Diff 对比,标记需要更新的节点
    ↓(可中断)
Commit 阶段 → 把标记的更新同步应用到真实 DOM
    ↓
浏览器绘制

Q5:什么是双缓冲?为什么需要?

React 在内存中构建 workInProgress 树,完成后一次性替换 current 树(切换指针)。好处是用户始终看到完整 UI,不会看到渲染到一半的中间状态。

Q6:React 怎么决定哪个更新先执行?

通过 Lane 模型。每个更新会被分配一个 Lane(优先级),Scheduler 按优先级调度。用户输入是最高优先级,useTransition 包裹的更新是低优先级,可以被高优先级打断。

JeecgBoot低代码平台从 WPS 切换到 OnlyOffice 的开发配置指南

2026年3月12日 16:29

JeecgBoot AI专题研究 | JeecgBoot低代码在线文档编辑器切换与配置实践


切换背景

JeecgBoot低代码平台同时支持 WPS 和 OnlyOffice 两种在线文档编辑方案。在实际项目中,你可能因为授权成本、部署方式或功能需求等原因,需要从 WPS 切换到 OnlyOffice。

整个切换过程只需要修改前端环境变量和后端配置文件,无需改动任何业务代码。

前端配置修改

切换操作集中在环境变量文件中(.env.development.env.production),涉及两项关键配置:

1. 修改文档编辑器版本标识

VITE_GLOB_ONLINE_DOCUMENT_VERSION=onlyoffice

将值从 wps 改为 onlyoffice,JeecgBoot低代码前端会自动加载对应的编辑器组件和交互逻辑。

2. 更新代理与域名地址

VITE_PROXY=[["/api","http://192.168.1.100:8080/jeecg-boot"]]
VITE_GLOB_DOMAIN_URL=http://192.168.1.100:8080/jeecg-boot

重要提示:这里不能使用 localhost127.0.0.1。OnlyOffice 运行在 Docker 容器中,回调请求发起方是容器内部的服务,localhost 指向的是容器自身而非你的开发机。必须使用开发机的实际 IP 地址。

后端配置修改

在 JeecgBoot低代码后端的 YAML 配置文件中,需要填写 OnlyOffice 服务的访问地址:

onlyoffice:
  doc-service-url: http://192.168.1.100:9000

确保该地址指向已经部署好的 OnlyOffice DocumentServer 实例。如果尚未部署,请先参考 Docker 安装指南完成 OnlyOffice 的部署。

配置核对清单

完成切换后,对照以下清单确认配置正确:

配置项 检查要点
VITE_GLOB_ONLINE_DOCUMENT_VERSION 值为 onlyoffice
VITE_PROXY IP 地址为实际地址,非 localhost
VITE_GLOB_DOMAIN_URL 同上
后端 OnlyOffice 地址 指向正确的 DocumentServer
OnlyOffice 容器 已开启私有 IP 访问权限

常见问题

编辑器加载失败:检查 OnlyOffice 服务是否正常运行,浏览器访问 http://IP:9000 确认。

文档保存失败:大概率是 IP 地址配置问题,确保前后端和 OnlyOffice 三者之间能互相通过 IP 访问。

跨域报错:检查 JeecgBoot低代码前端的代理配置是否正确,以及后端的 CORS 设置。


总结

JeecgBoot低代码平台的文档编辑器切换设计得非常优雅——通过一个环境变量即可完成前端的编辑器切换,配合后端地址配置,整个过程对业务代码零侵入。唯一需要注意的是 IP 地址的配置,务必避免使用 localhost。


本文为 JeecgBoot AI 专题研究系列文章。

前端工程师转 AI Agent 工程师,先把后端能力补上

作者 打酱油的D
2026年3月12日 16:28

得物前端全员转 AI coding 工程师的消息刷屏了。这不仅仅是制造焦虑,而是实实在在的行业风向。要在未来的 AI 时代做全栈应用,掌握扎实的后端核心能力是绕不开的必修课。

作为前端老兵,我们的优势在于语言的熟练度。因此,新开此专栏,不搞大而全,只聊最核心的干货。

这份为前端定制的后端平滑过渡路线图,主打一个降维打击和实用主义:

第一阶段:降维打击 —— 用最熟悉的语言拿下后端 (Node.js/TypeScript)

不要一开始就去死磕 Java 或 Go 的庞大生态,先用你写了十年的 JS/TS 建立起后端的思维模型。

  • 核心基石:Node.js 与底层机制

    • 事件循环 (Event Loop) :别拿浏览器的宏任务/微任务去套 Node.js。弄懂 Timers、Poll、Check 等阶段的真正运行机制。
    • 内存管理与 V8 垃圾回收:前端页面刷新就释放内存了,但后端进程是要跑几个月不重启的。学习如何排查内存泄漏(Memory Leak)和 OOM(Out of Memory)问题。
    • 流 (Streams) 与 Buffer:这是处理大文件、网络 I/O 以及未来对接大模型流式输出(Streaming)的绝对核心。
  • 框架上手:从轻量到工程化

    • Express / Koa:快速搞懂后端的洋葱模型、中间件(Middleware)机制以及请求/响应拦截。
    • NestJS (重中之重) :前端习惯了灵活,但在后端,工程化和规范才是保命的。NestJS 深度使用了 TypeScript、面向对象编程(OOP)、控制反转与依赖注入(IoC/DI)。它的架构风格非常像 Spring Boot,拿下它,你就建立起了企业级后端架构的思维。
  • 视角转换:前端讲究“状态管理”,而后端讲究“无状态 API (Stateless)”。重点攻克鉴权体系:JWT 机制、Session/Cookie 的本质区别、以及 OAuth2.0 授权码模式。

第二阶段:核心战役 —— 搞定数据与并发

前端的存储是 LocalStorage,而后端的命脉在于**“如何高效、安全、一致地存取数据”**。

  • 关系型数据库 (PostgreSQL / MySQL)

    • 基础建设:合理的表结构设计、范式与反范式取舍、复杂 SQL 编写(多表 Join、子查询)。
    • 进阶壁垒:事务的 ACID 特性(读懂脏读、幻读)、索引的底层原理(B+树,明白为什么会“索引失效”)、悲观锁与乐观锁、以及数据库连接池(Connection Pooling)的配置调优。掌握 EXPLAIN 分析慢查询是基本功。
  • 非关系型数据库 & 缓存 (Redis)

    • 把 Redis 当作后端的“全局高频内存”。
    • 不仅要会 SET/GET,还要懂经典的三大缓存坑:缓存穿透、缓存击穿、缓存雪崩及其解决方案。
    • 进阶用法:利用 Redis 实现分布式锁、排行榜(ZSET)、简单的消息队列。
  • ORM (对象关系映射)

    • 在 Node.js 生态中,重点掌握 PrismaTypeORM。用熟悉的 TS 对象去操作数据库,同时要警惕 ORM 带来的 N+1 查询性能问题。

第三阶段:架构与基建 —— 稳如泰山地推向生产环境

这部分前端平时接触过(比如 Nginx 代理和 GitLab CI),但现在需要你从“使用者”变成“掌控者”。

  • 容器化与编排

    • Docker:熟练编写 Dockerfile,懂得利用多阶段构建(Multi-stage Build)缩小镜像体积。把服务、数据库、Redis 全部容器化。
    • Docker Compose:本地拉起一整套微服务环境的利器。
  • 反向代理与网关 (Nginx)

    • 不仅是配个跨域或静态资源转发。要深入学习负载均衡策略、IP 限流、SSL 证书配置。
    • 实战踩坑点:在结合 CI/CD 自动化部署时,经常会用到 envsubst 动态替换 Nginx 配置文件里的环境变量。这里要特别注意,如果配置文件里存在未声明或未传入的自定义变量(比如 mys_mypage_url 等),一旦遗漏,Nginx 启动时会直接报 unknown variable 致命错误,导致整个容器启动失败。
  • Linux 与运维排查

    • 掌握必备的 Linux 组合拳:查看和过滤日志 (tail -f, grep, awk),排查端口占用 (netstat, lsof),以及监控 CPU 和内存飙升 (top, htop)。

第四阶段:拥抱 AI 应用与多语言生态

当你用 Node.js 彻底摸透了后端链路,再去结合当下的 AI 应用开发,学习新语言就会水到渠成。

  • 流式通信与实时协议:做 AI Agent 或语音助手,通常需要对接大模型的打字机输出,或是 LiveKit 这种实时音视频和 STT(语音转文字)服务。此时你需要深入掌握 SSE (Server-Sent Events) 和 WebSocket 的后端鉴权与连接保持。
  • Python (FastAPI) :如果后端需要深度集成 LLM 原生接口、处理 RAG 本地知识库切片、或者写爬虫与复杂自动化脚本,Python 是绕不开的。FastAPI 语法现代,支持异步,对前端开发者非常友好。
  • 高并发架构雏形:当业务从单体走向复杂,你需要了解消息队列(RabbitMQ / Kafka)如何做异步削峰,以及微服务架构(Microservices)中的服务发现与链路追踪。

来自血泪的三条避坑建议:

  1. 警惕“回调地狱”与异步陷阱:前端未捕获的 Promise 异常最多挂掉一个组件;但在 Node.js 后端,一个 Unhandled Promise Rejection 可能会导致整个进程崩溃,让所有用户的请求全部挂起。
  2. 接口的幂等性 (Idempotency) :这是前端极容易忽略的概念。前端网络卡了,用户多点了几次提交按钮,后端如果没有做幂等校验(比如利用防重 Token 或数据库唯一索引),同一笔订单可能就会被重复扣款。
  3. 永远不要信任客户端:前端的正则校验只是防君子。后端是守门员,任何入参都必须在 Controller 层做严格的类型和边界校验(比如结合 class-validator),防范 SQL 注入、XSS 和越权访问。

小白也能懂:Yjs,让多人同时编辑不再“打架”的神器

2026年3月12日 16:11

小白也能懂:Yjs,让多人同时编辑不再“打架”的神器

本文用最通俗的语言,帮你理解 Yjs 到底是什么,以及它为什么能解决多人协作的“打架”问题。

一、 什么是 Yjs?一句话概括

Yjs 是一个能让多个人同时编辑同一份文档,且不会互相覆盖或冲突的 JavaScript 库。

想象一下,你和同事在同一个 Word 文档里打字,你改第一段,他改第二段,互不干扰,这就是 Yjs 能做到的事情。

二、 为什么需要 Yjs?(解决什么问题)

在没有 Yjs 之前,多人协作通常面临两大难题:

  1. “打架”问题:两个人同时修改同一行文字,谁先保存谁就“赢”,后保存的人会覆盖掉前者的修改。
  2. “断网”问题:你在地铁上编辑文档,网络断了,你的修改就丢失了,或者需要手动合并。

Yjs 的核心价值就是:

  • 实时同步:你打字,同事那边立刻能看到。
  • 无冲突合并:即使两个人同时改同一个地方,Yjs 也能自动把两边的修改“拼”起来,而不是粗暴地覆盖。
  • 离线编辑:断网也能继续写,联网后自动同步。

三、 核心原理:CRDT(无冲突复制数据类型)

Yjs 之所以这么厉害,是因为它使用了 CRDT 算法。别被这个术语吓到,我们把它拆解成大白话:

1. 传统方法(OT算法):排队等服务器

以前的技术(如 OT)需要把每个人的操作(比如“插入字符A”)发送给服务器,服务器排好队,再按顺序发给所有人。这就像只有一个收银台的超市,大家必须排队结账,如果网络卡了,队伍就乱了。

2. Yjs 的方法(CRDT算法):自带“身份证”

Yjs 给文档里的每一个字符都打上了唯一的“身份证”(ID 和时间戳)。这样,无论操作从哪个客户端发来,也无论谁先谁后,系统都能根据这个“身份证”把字符放到正确的位置。

通俗比喻:

  • OT(传统):像拼图,必须按顺序拼,顺序错了就拼不上。
  • CRDT(Yjs):像乐高,每块积木都有独特的卡扣,不管先拼哪一块,最终都能严丝合缝地拼在一起。

四、 Yjs 能做什么?(应用场景)

Yjs 不仅仅能处理文字,它支持多种数据结构:

场景 说明 例子
文本编辑 多人同时编辑富文本 Google Docs, Notion
代码协作 多人同时写代码 VS Code Live Share
绘图/白板 多人同时画图 Excalidraw, Figma
数据同步 同步列表、表格数据 待办事项列表, 电子表格

五、 核心概念(技术小白版)

虽然 Yjs 底层很复杂,但开发者只需要理解三个核心概念:

1. Y.Doc(文档)

这是 Yjs 的核心容器,相当于一个“共享数据库”。所有需要同步的数据都放在这里。

2. Shared Types(共享类型)

这是 Yjs 提供的特殊数据结构,它们自带“同步”和“合并”能力:

  • Y.Text:用于同步文本(支持富文本)。
  • Y.Array:用于同步列表(如待办事项)。
  • Y.Map:用于同步键值对(如用户配置)。

3. Providers(连接器)

负责把数据从一个客户端“搬”到另一个客户端。Yjs 支持多种传输方式:

  • WebSocket:通过服务器中转(最常用)。
  • WebRTC:点对点直连(去中心化,延迟低)。

六、 总结

Yjs 是一个“智能的同步引擎”。它把复杂的冲突解决逻辑封装在底层,让开发者可以像操作普通 JavaScript 对象一样操作数据,而不用担心“打架”和“断网”问题。

如果你正在开发需要多人协作的应用(如在线文档、白板、项目管理工具),Yjs 绝对是你的首选利器。

零安装的"云养虾":ArkClaw 使用指南

作者 阮一峰
2026年3月12日 16:01

一、引言

大家这两天,有没有被"龙虾"(OpenClaw)刷屏?

到处是它的新闻,就连两会代表和新华社都在谈论。真让人跌破眼镜,一个 AI 软件竟能引起这么大的反响。

人们的热情高涨,免费的线下安装活动人满为患,网上的"付费安装"生意兴隆。

很多人大概还不知道,现在有一种最简单的龙虾使用方法:ArkClaw

简单到你根本不需要操心安装,因为这是一个免安装的方案,它直接内置了龙虾,开箱即用。

我也是昨天才开始用,迫不及待跟大家分享,初步使用的感受。没有用过的同学,也可以把它当作《龙虾零门槛上手》教程,看看龙虾到底是怎么回事。

二、ArkClaw 是什么

事情是这样的,老读者可能还记得,我在春节前测评了字节最新发布的 Seed 2.0 模型。

我在文章里说,这是字节目前最强的基础模型,手机豆包用的就是它,测试表现很不错。

字节的同学后来就向我赠送了 Coding Plan 套餐,方便继续测试这个模型,各种 AI 编程工具都可以调用它的 API(当然套餐还包含其他国产模型,也是自由使用)。

本周一,我突然发现,字节的这个 Coding Plan 套餐开通了一个捆绑服务,就是 ArkClaw。

我问了客服才知道,只要现在开通 Coding Plan,就能免费使用龙虾

也就是说,只要你用字节的 AI 编程套餐,不用多花一分钱,字节就提供一台远程主机,里面安装好了龙虾,你可以自由使用。

需要说明的是,Coding Plan 分成 lite(首月9.9元)和 Pro(首月49.9元)两种套餐。lite 套餐只能免费体验7天,只有 Pro 套餐可以长期使用 ArkClaw。

三、云养虾

ArkClaw 属于"云养虾"(又称"云龙虾"),就是把龙虾(OpenClaw)安装在火山方舟(字节的 AI 云服务品牌)的云主机上,它名字里的 ark 就是"方舟"的意思。

除了"云养虾",也可以把龙虾安装在本地计算机。

不了解的朋友可能会好奇,两者有什么区别,我简单说一下。

首先,你要知道OpenClaw 属于自动化软件,它的作用就是让用户使用自然语言描述需求,它通过大模型找出满足需求的方法,然后自动去完成。

当它安装在本地计算机(你的笔记本),就方便自动操作本地文件和本地设备,比如"找出拍摄于去年今日的照片"或者"关闭客厅的智能灯,并查询最近一周的耗电量"。

当它安装在云端,就能 7x24 小时跟各种网络服务互动,比如"收到电子邮件时,自动生成30字的内容摘要,向手机发送通知"。

所以,如果你需要自动化操作网络服务,并且需要长时间在线或者每天定时运行,那么就合适使用"云养虾"。

四、ArkClaw 基本操作

4.1 界面

我给大家看一下,ArkClaw 的样子。

进入控制台,点击"立即创建",创建一个龙虾实例。

创建完成后,就已经安装好了,直接使用。

界面非常简洁,就是一个对话框。ArkClaw 对龙虾的官方控制台做了定制,简化了操作界面。

4.2 抓取信息

你可以在对话框里面,跟 AI 模型对话,这跟其他模型的用法并无二致。

举例来说,我们可以让它抓取信息。

可以看到,由于抓取的是动态内容,所以模型想到了很多实施方案,最后顺利完成。

大家要记住,ArkClaw 就是一台远程主机,任何服务器可以用的技术方案,它都能用,这比安装在一般个人工作电脑上的龙虾更强大。

4.3 发送消息

获取信息以后,龙虾可以把这些信息发到手机。

目前,ArkClaw 支持与企业微信、钉钉和飞书绑定。其中,飞书因为是自家的产品,绑定操作最简单,便捷快速,扫码即可。其他两家操作都比较麻烦,具体见官方文档

点击对话框上方的"飞书配对"按钮。(前面的"消息渠道"按钮,用于绑定企业微信和钉钉。)

系统会打开一个终端窗口,输出一个二维码,飞书扫描后可以创建一个机器人,跟当前的 ArkClaw 实例绑定。

通过这个机器人,你就可以在手机上跟当前这台 ArkClaw 实例对话了。

你也可以在电脑上,通过 ArkClaw 网页控制台,向你的手机发消息。

电脑端输入上面指令后,手机端就会推送消息(下图)。

4.4 定时任务

我们还可以规定,龙虾执行某些任务的时间和频率,也就是定时任务。

首先,使用自然语言,在对话框设置定时任务。

设置完成后,你的手机就会每天收到消息了。

如果要删除定时任务,也是使用自然语言发出指令。

五、Skill 和其他设置

5.1 Skill

龙虾本身的能力是有限的,总会遇到一些它不知道如何处理的问题。这时,就可以通过 Skill(技能)扩展它的能力,这大大增加了龙虾的用途。

什么是 Skill?简单理解,它就是一个文件包,里面包含了指令和示例,用来教模型如何完成某些特定的任务。

网上已经有很多别人写好、分享出来的 Skill,只要挑一些自己需要的,让龙虾加载,就能扩展对应的能力。网站 ClawHub.ai 就收集龙虾专用 Skill,已经有近20000个了。

我本来想用小红书 SKill 来举例,演示龙虾如何学会写小红书。但是,官方昨天发公告了,最近这样做的人太多了,现在开始封账号了。

那么就换一个例子。

上面截图就是使用自然语言,让龙虾从 ClawHub 网站下载安装高德地图(amap)的技能

龙虾本来不知道怎么使用高德地图,有了这个技能就学会了,可以从中查询信息。这个技能的具体详细,可以查看它的主页

使用的时候,也是直接用自然语言描述需求,模型会自己加载调用所需的技能。

上图的截图就是通过高德地图,查询实时路况。

5.2 其他设置

ArkClaw 的其他功能,都在"设置"菜单(下图),比如调整底层模型。

只要是 Coding Plan 套餐提供的模型,这里都能使用。

"设置"菜单还有两个很有用的功能。

一个是"打开终端",它会在网页上打开一个终端窗口,让你通过命令行直接操作 ArkClaw 所在的远程主机。

从上面的终端窗口截图可以看到,ArkClaw 底层是 Ubuntu 系统。

另一个是"配置网盘"。某些情况下,你可能需要向 ArkClaw 上传/下载文件,这个功能允许当前主机与火山引擎的对象存储服务 TOS 绑定,相当于有了一个无限容量的网盘。

六、总结

以上就是我昨天第一天使用 ArkClaw 的主要内容。

我的感受是,它确实大大简化了龙虾的使用,免安装、开箱即用,让龙虾的操作变得简单直观。通过自然语言加载调用 Skill,也很自然流畅。

它最大的强项就是跟字节生态深度融合,配合得十分丝滑:底层 Seed 2.0 模型 + 飞书推送 + 火山引擎网盘,完全不必复杂的配置。

它是一个跟字节 Coding Plan 捆绑的服务,不用额外付费。相比自己从头搭建"云龙虾",云主机和 AI 模型的费用就省掉了,这是一笔不小的费用。

作为程序员,这个 AI 编程的 Coding Plan + 云龙虾 ArkClaw 主机的捆绑方案,还是很有吸引力的。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2026年3月12日

最新版vue3+TypeScript开发入门到实战教程之ref与reactive的实战区别用法

作者 angerdream
2026年3月12日 15:54

概述

上节详细 说明ref、reactive如何定义使用响应式数据。总结如下:

  • ref定义基础类型数据
  • reactive定义对象数据
  • 但ref也可以定义对象类型的数据。但底层基于reactive实现 ref定义对象类型数据,底层用reactive实现, 但两者区别在何处,本文将详细说明

ref使用对象类型的数据定义响应式

  • 新建组件Fish
  • 创建响应式对象fish,数组fishs
  • 显示、修改响应式对象fish数据、fishs第一条数据
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
  <h3>鱼的列表</h3>
  <ul>
    <li v-for="item in fishs" :key="item.id">
      {{ item.name }}:{{ item.price }}
    </li>
  </ul>
  <button @click="changeFirstPrice">改变第三条鱼的价格</button>

</template>
<script setup>
import { ref } from 'vue'
let fish = ref({ name: '鲫鱼', price: 10 });
let fishs = ref([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])
function changeName() {
  fish.value.name = '草鱼';
  console.log(fish);
  console.log(fish.value);

}
function addPrice() {
  fish.value.price += 10;

}
function changeFirstPrice() {
  fishs.value[0].price += 10;
}
</script>

用ref定义的响应式对象,需要用.value去访问赋值。访问页面,发现他与reactive定义的响应式对象是一样的,除了reactive不需要用.value访问。如图 在这里插入图片描述 在点击按钮改变鱼,打印输入fish和fish.value,发现fish使用RefImpl定义,但 fish.value使用Proxy定义的对象,这与使用reactive定义是一样的。使用ref定义的响应式对象,底层使用reactive实现的。

ref对比reactive区别

从使用整体看

  • ref定义可以是基础数据、对象类型数据
  • reactive定义只能是对象类型数据

从细微之处看两者区别

  • ref创建的响应式对象,必须使用.value
  • reactive是深层次响应对象
  • reactive重新分配一个新对象,会丢失响应式

ref与reactive实际项目使用原则

  • 基本类型数据,必须使用ref
  • 简单的对象,不需要层级太深,ref、reactive都可以
  • 层级较深的对象,推荐使用reactive 以上使用原则,简单归纳,基本数据使用ref、对象使用reactive。但实际要清楚两者在深层次对象使用区别。

如何正确理解reactive重新分配一个新对象会流失响应式

reactive重新分配一个新对象会流失响应式是官方的说明。猛一看有些懵。我们从事例代码中去理解这句话的含义。事例中,定义let fish = reactive({ name: '鲫鱼', price: 10 }),创建5个按钮

  • 改变鱼的种类,fish.name = '草鱼';
  • 改变鱼的价格,fish.price += 10;
  • 方式一改变整个鱼,fish={ name: '鲤鱼', price: 30 }
  • 方式二改变整个鱼,fish = reactive({ name: '鲤鱼鱼', price: 40 });
  • 方式三改变整个鱼,Object.assign(fish,{ name: '带鱼', price: 50 }) 运行查看效果,发现,点击按钮方式一改变整个鱼、方式二改变整个鱼,页面无任何变。方式一、方式二都是重新给fis分配一个新的对象,导致fish丢失响应式。方式三是将新对象数据重新分配给fish,所以可以。 当fish丢失响应式数据后,再给fish重新赋值,页面也无法改变,也不在具有响应式。 细看图中的操作: 在这里插入图片描述

详细代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼的种类</button>
  <button @click="addPrice">改变鱼的价格</button>
  <div>
    <button @click="changeallfish1">方式一改变整个鱼</button>
    <button @click="changeallfish2">方式二改变整个鱼</button>
    <button @click="changeallfish3">方式三改变整个鱼</button>
  </div>

</template>
<script setup>
import { reactive } from 'vue'
let fish = reactive({ name: '鲫鱼', price: 10 });
function changeName() {
  fish.name = '草鱼';

}
function addPrice() {
  fish.price += 10;

}
function changeallfish1() {
  fish={ name: '鲤鱼', price: 30 }

}
function changeallfish2() {
 fish = reactive({ name: '鲤鱼鱼', price: 40 });


}
function changeallfish3() {
  console.log('sadsd')
  Object.assign(fish, { name: '带鱼', price: 50 })
  console.log(fish);

}
</script>

ref定义的响应式对象重新分配一个新对象会怎样

  • 使用.value赋值,是响应式
  • 直接赋值,流失响应式 看事例
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeallfish1">方式一改变整个鱼</button>
  <button @click="changeallfish2">方式二改变整个鱼</button>
</template>
<script setup>
import { ref } from 'vue'
let fish = ref({ name: '鲫鱼', price: 10 });
function changeallfish1() {
  fish.value={ name: '鲤鱼', price: 30 }

}
function changeallfish2() {
 fish = ref({ name: '鲤鱼鱼', price: 40 });
}
</script>

在这里插入图片描述

最新版vue3+TypeScript开发入门到实战教程之学会vue3真正的响应式数据

作者 angerdream
2026年3月12日 15:52

响应式数据概述

在vue2那个年代,响应式数据是在data里面定义,只要把数据放在data里,然后在模版内引用,数据一变,模版就跟着显示,如下图代码:

<template>
  <h2>我是一条{{ fish }}</h2>
  <button @click="changeFish">改变鱼</button>
</template>
<script lang="ts">
export default {
  name:'Fish',
  data() {
    return {
      fish:'鲫鱼'
    }
  },
  methods: {
    changeFish() {
      this.fish+='!'
    }
  }
}
</script>

在这里插入图片描述

点击按钮改变鱼。fish一变化,模版就跟着变化,这就是响应式数据。

vue3是如何定义响应式数据的

在vue3中,使用的是组合式 API (Composition API)语法,它没有data,数据和方式都定义在script标签里。在定义数据时,有两种方式给数据标记成响应式数据,分别是ref、reactive。

  • ref给基本数据标记成响应式数据,如整数、浮点数、字符串
  • reactive给对象标记成响应式数据,如对象、数组
  • ref也可给对象标记响应式数据,但底层用reactive实现

用ref给基本数据标记成响应式对象

  • 从vue引入ref,才可以使用ref函数
  • 定义响应式变量,用ref赋值
  • 获取、改变响应式数据变量的值,不能直接访问,需要加.value
  • 模版中可直接引用变量
<template>
  <h1>鱼类:{{ fish }}</h1>
  <h2>价格:{{price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
let fish = ref('鲫鱼');
let price =ref(10);
function changeName() {
  fish.value = '草鱼';
  console.log(fish);
  console.log(fish.value);

}
function addPrice() {
  price.value += 10;
  console.log(price)
  console.log(price.value)

}
</script>

在浏览中访问http://localhost:5173/,查看效果 在这里插入图片描述

当点击按钮,鱼与价格都跟着改变。fish与price变成响应式数据,控制台打印 console.log(price)、 console.log(price.value),发现定义fish与price,并不是字符串与数字,而是用RefImpl 类型的定义的数据,fish基本结构如下: 在这里插入图片描述 能够访问的属性,只有vulue。在changeFIsh与addPrice函数中,通过fish.value和price.value赋值

借助vue DevTools 工具查看Fish组件的数据与方法

在这里插入图片描述 想要谁变成响应式数据,在数据外面包一层ref。不需要的,直接定义变量。

reactive定义对象类型的响应式数据

  • 从vue引入reactive
  • 定义响应式对象,用reactive包裹对象
  • 访问、改变响应式对象,直接操作成员变量
  • 模版直接使用响应式对象 分别定义两个响应式数据,一是用对象定义的响应式,一是用数组定义的响应式,来说明响应式对象如何使用。
let fish = reactive({ name: '鲫鱼', price: 10 });
let fishs = reactive([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])

改变鱼的名称与价格

function changeName() {
  fish.name = '草鱼';
}
function addPrice() {
  fish.price += 10;

}

改变fishs第二个鱼的价格

function changeThreePrice() {
  fishs[1].price += 10;
}

具体代码如下:

template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
  <h2>鱼的列表</h2>
  <ul>
    <li v-for="item in fishs" :key="item.id">
      {{ item.name }}:{{ item.price }}
    </li>
  </ul>
  <button @click="changeThreePrice">改变第三条鱼的价格</button>

</template>
<script setup>
import { reactive } from 'vue'
let fish = reactive({ name: '鲫鱼', price: 10 });
let fishs = reactive([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])
function changeName() {
  fish.name = '草鱼';
  console.log(fish);
  console.log(fish.name);

}
function addPrice() {
  fish.price += 10;

}
function changeThreePrice() {
  fishs[1].price += 10;
}
</script>

在浏览器访问http://localhost:5173/,数据渲染和修改都正确,如下图 在这里插入图片描述 通过reactive定义的数据,都是Proxy,这是JavaScript内置对象,它的数据存放在Target中。其结构如下图: 在这里插入图片描述

reactive定义的响应式对象是深层次

对象的深层次,是其成员变量中含有成员变量,其成员变量又含有成员变量,当修改最底层成员变量值时,数据也是响应式的。如下:

<template>
  <h2>鱼类:{{ a.b.c.color }}</h2>
  <button @click="changeC">深度改变</button>
</template>
<script setup>
import { reactive } from 'vue'
let a = reactive({
  b: {
    c: {
      color: 'red'
    }
  }
})
a.b.c.color='black'
function changeC() {
  a.b.c.color = 'black';
}
</script>

访问浏览器,点击深度改变按钮,发现color值是响应式。用reactive定义的响应式对象都是深层次的。

❌
❌