普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月12日掘金 前端

JavaScript 对象与属性描述符:从原理到实战

作者 swipe
2026年3月12日 22:50

背景:为什么要深入理解对象?

在日常开发中,我们经常会遇到这样的困惑:

  • 为什么有些对象属性用 for-in 遍历不出来?
  • 为什么 delete 有时能删除属性,有时却失效?
  • Vue2 的响应式原理到底是怎么"劫持"属性访问的?

这些问题的答案都指向同一个核心概念:属性描述符。它是 JavaScript 对象系统的底层机制,掌握它不仅能让你理解框架源码,还能写出更精准、更可控的代码。

本文将从面向对象的本质出发,逐步深入到属性描述符的细节,并结合实际场景帮你建立完整的知识体系。

你将收获:

  • 理解 JavaScript 面向对象的设计思想
  • 掌握属性描述符的 6 种特性及应用场景
  • 学会用 Object.defineProperty 精准控制对象行为
  • 具备阅读 MDN 文档和框架源码的基础能力

一、面向对象:用代码模拟现实世界

1.1 什么是面向对象?

面向对象编程(OOP)的核心思想是:用包含数据和行为的对象来模拟现实世界的实体

举个例子:

  • 一辆车(Car):有颜色、速度、品牌、价格等属性,有行驶、刹车等方法
  • 一个人(Person):有姓名、年龄、身高等属性,有吃饭、跑步等方法

这种抽象方式让代码结构更清晰,也更贴近人类的思维方式。在 JavaScript 中,面向对象主要体现在两个方面:

  1. 封装:把相关数据和方法组织在一起(函数、模块、对象都是封装)
  2. 继承:通过原型链实现代码复用(这是 JS 的重点,后续会详细讲解)

1.2 JavaScript 中的对象设计

JavaScript 支持多种编程范式,对象被设计成属性的无序集合,类似哈希表:

{
  key: value
}
  • key:标识符名称(字符串或 Symbol)
  • value:任意类型(基本类型、对象、函数等)
  • 如果 value 是函数,我们称之为方法

1.3 创建对象的两种方式

方式一:new Object()(构造函数方式)

var person1 = new Object();
person1.name = "小吴";
person1.age = 18;
person1.greet = function() {
  console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
};

person1.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

适用场景:

  • 需要动态添加属性的复杂逻辑
  • 有 Java/C++ 等面向对象语言背景的开发者

历史背景: JavaScript 早期为了蹭 Java 的热度,在命名和语法上刻意模仿,导致很多 Java 开发者习惯用这种方式。

方式二:对象字面量(推荐)

var person2 = {
  name: "小吴",
  age: 18,
  greet: function() {
    console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
  }
};

person2.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

优势:

  • 代码简洁,结构清晰
  • 属性和方法内聚性强
  • 性能略优(省略函数调用开销)

二、属性描述符:精准控制对象行为

2.1 为什么需要属性描述符?

通常我们直接定义属性:

var obj = {
  name: "小吴",
  age: 20,
  sex: "男"
};

// 获取属性
console.log(obj.name);  // 小吴

// 修改属性
obj.name = "XiaoWu";
console.log(obj.name);  // XiaoWu

// 删除属性
delete obj.name;
console.log(obj);  // { age: 20, sex: '男' }

但这种方式无法控制:

  • 这个属性能否被 delete 删除?
  • 这个属性能否被 for-in 遍历?
  • 这个属性能否被重新赋值?

属性描述符就是用来解决这些问题的工具。

2.2 Object.defineProperty 基础用法

Object.defineProperty(obj, prop, descriptor)

参数说明:

  • obj:目标对象
  • prop:属性名(字符串或 Symbol)
  • descriptor:属性描述符对象(核心)

返回值: 修改后的原对象(非纯函数)

示例:

var obj = {
  name: "XiaoWu",
  age: 20
};

Object.defineProperty(obj, "height", {
  value: 1.75
});

console.log(obj);  // Node 环境:{ name: 'XiaoWu', age: 20 }

疑问:为什么 height 没显示出来?

图 1:浏览器控制台显示了 height 属性

原因分析:

  • height 默认是不可枚举的(enumerable: false
  • Node.jsconsole.log 使用 util.inspect(),默认只显示可枚举属性(遵循 ECMAScript 标准)
  • 浏览器控制台 为了调试方便,会显示所有属性(包括不可枚举属性)

验证属性确实存在:

console.log(obj.height);  // 1.75(可以访问)

让属性可枚举:

Object.defineProperty(obj, "height", {
  value: 1.75,
  enumerable: true  // 设置为可枚举
});

console.log(obj);  // { name: 'XiaoWu', age: 20, height: 1.75 }

三、属性描述符的两种类型

属性描述符分为两大类,它们不能混用

类型 configurable enumerable value writable get set
数据描述符
存取描述符

记忆口诀: 2 共用 + 2 可选,同时生效最多 4 种

3.1 为什么不能混用?

本质原因: 它们代表了两种完全不同的属性管理方式

  • 数据描述符(静态):属性持有一个具体的值,可以直接读写
  • 存取描述符(动态):属性值通过函数动态计算,每次访问可能不同

如果同时定义,JavaScript 引擎无法判断应该直接操作值还是调用函数,因此规范禁止混用。

类比理解:

  • 数据描述符 = 名词(静态的"数据")
  • 存取描述符 = 动词(动态的"存取"操作)

四、数据描述符详解

4.1 四大特性

[[Configurable]]:可配置性

控制属性是否可以:

  • delete 删除
  • 修改其他描述符特性
  • 转换为存取描述符

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Enumerable]]:可枚举性

控制属性是否可以:

  • for-in 遍历
  • Object.keys() 返回

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Writable]]:可写性

控制属性值是否可以被修改。

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Value]]:属性值

属性的实际值。

默认值: undefined

4.2 实战案例

var obj = {
  name: "XiaoWu",
  age: 18
};

// 定义一个受控属性
Object.defineProperty(obj, "address", {
  value: "福建省",
  configurable: false,  // 不可删除、不可重新配置
  enumerable: true,     // 可枚举
  writable: false       // 不可修改
});

// 测试 configurable
delete obj.name;
console.log(obj);  // { age: 18, address: '福建省' }(name 被删除)

delete obj.address;
console.log(obj.address);  // 福建省(删除失败)

// 测试 enumerable
console.log(Object.keys(obj));  // [ 'age', 'address' ]

for (var key in obj) {
  console.log(key);  // age, address
}

// 测试 writable
obj.address = "上海市";
console.log(obj.address);  // 福建省(修改失败)

关键点:

  • 直接定义的属性(nameage)默认所有特性都是 true
  • 通过描述符定义的属性(address)默认所有特性都是 false

五、存取描述符详解

5.1 四大特性

  • [[Configurable]]:同数据描述符
  • [[Enumerable]]:同数据描述符
  • [[Get]]:获取属性时执行的函数,默认 undefined
  • [[Set]]:设置属性时执行的函数,默认 undefined

5.2 应用场景

场景一:隐藏私有属性

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"  // _ 开头表示私有属性(约定俗成)
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    return this._address;  // 通过 address 访问 _address
  },
  set: function(value) {
    this._address = value;
  }
});

console.log(obj.address);  // 泉州市
obj.address = "厦门市";
console.log(obj.address);  // 厦门市

注意: ES6 后可以用 # 定义真正的私有属性(后续会讲)。

场景二:拦截属性访问(Vue2 响应式原理)

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    console.log("获取了一次 address 的值");  // 拦截读取
    return this._address;
  },
  set: function(value) {
    console.log("设置了一次 address 的值");  // 拦截写入
    this._address = value;
  }
});

console.log(obj.address);
// 输出:获取了一次 address 的值
//      泉州市

obj.address = "why";
// 输出:设置了一次 address 的值

console.log(obj.address);
// 输出:获取了一次 address 的值
//      why

核心价值: 这就是 Vue2 响应式系统的底层原理——通过 get/set 拦截属性访问,实现依赖收集和派发更新。


六、学习属性描述符的实战意义

6.1 理解原生 API 的能力边界

所有原生对象的 API 都有属性描述符,这决定了它们的行为:

  • 为什么 Array.prototype 上的方法用 for-in 遍历不出来?(enumerable: false
  • 为什么 Object.prototype.toString 不能被删除?(configurable: false

6.2 读懂技术文档

MDN 文档中大量使用属性描述符来描述 API 特性:

图 2:MDN 文档对 API 能力边界的描述

掌握这些概念后,你能:

  • 快速理解 API 的使用限制
  • 预判代码的行为边界
  • 避免踩坑(比如误删不可配置的属性)

6.3 降低框架学习门槛

React、Vue 等框架文档中会用到这些术语:

图 3:React 文档中的专业术语

学完 JavaScript 高级后,这些词汇对你来说将不再陌生。


七、关键要点总结

  1. 属性描述符分两类:数据描述符(静态值)和存取描述符(动态函数),不能混用
  2. 默认值差异:直接定义的属性默认可配置/可枚举/可写,通过描述符定义的默认都是 false
  3. 核心应用场景
    • 隐藏私有属性(用 get/set 代理访问)
    • 拦截属性访问(实现响应式、日志、校验等)
    • 精准控制对象行为(防删除、防修改、防遍历)
  4. 实战价值:理解原生 API、读懂技术文档、掌握框架原理

八、下一步建议

团队落地建议:

  • 在工具函数库中封装常用的属性控制逻辑(如冻结对象、只读属性等)
  • Code Review 时关注属性描述符的使用是否合理
  • 在复杂对象设计中主动使用描述符提升代码健壮性

后续学习方向:

  • 批量定义属性描述符(Object.defineProperties
  • 对象方法补充(Object.freezeObject.seal 等)
  • 工厂函数与构造函数
  • 原型链与继承机制

下一篇我们将深入构造函数,探索更高效的对象创建方案。

【笔记】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

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 绝对是你的首选利器。

最新版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定义的响应式对象都是深层次的。

最新版vue3+TypeScript开发入门到实战教程之学会vue3第一步必是setup语法糖

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

setup 概述

在vue3中,若没有学好setup函数,后面学习vue3将会越学越乱。 setup是一个函数,它在vue3是一个新的配置项,是组合式语法 (Composition API)表演的舞台,组件中所用的属性、计算属性、方法、监视等等,均需要在setup中配置。 setup特性:

  • setup函数中没有this,它是undefined
  • setup函数返回的对象内容,可直接在模版中调用
  • setup函数在beforeCreate之前调用,领先所有生命周期钩子。

最简单setup事例语法

上节 vue3与vue2语法优劣对比中,vue2语法是选项式(OptionsAPI),如下图: 在这里插入图片描述 既然vue3是组合式语法 (Composition API),它就不应该有data,methods,而是所有内容都合并在一起,删除data与methos,建立setup函数,定义数据与方法,流程如下

  • 删除data内容
  • 删除methos内容
  • 创建setup函数
  • 在setup函数中定义变量与方法
  • setup函数要返回数据与方法供模版使用
<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  setup() {
    console.log(this)
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

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

    }
    function showLowPrice() {
      alert(lowPrice);
    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

浏览器输入http://localhost:5173,效果如下: 在这里插入图片描述

  • 首次打开页面,控制台首先输出this为undefined,setup没有this
  • 页面能够渲染品牌与价格
  • 点击修改品牌,name值能够修改,但页面没有变化
  • 点击修改价格,price值能够修改,但页面没有变化
  • 点击查看低价,控制台正确输出 在vue2中data定义的数据是响应式数据,但在vue3这种方式定义的数据不是响应式。在vue3中有五中类型的响应式数据。在下期细讲明,避免与setup知识点理解不清,暂不提及。name、price、lowPrice都不是响应式数据。

setup与data、methods常常被问到面试题

在vue组件中,常常有人将vue2语法与vue3语法混着写,既在data定义数据,又在setup定义数据。当使用函数访问数据中,问题出现。setup数据能否访问data数据,反之亦能否?页面属性与方法非常混乱,所以在vue3中,不要去写vue2语法,实在搞不定再去写。

  • setup与data、methods可以共存
  • data、methods能访问setup数据与方法
  • setup不能访问data中的数据与方法

setup与data、methods可以共存

继上面的事例,给car新增一个color颜色属性,用vue2语法编写

<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <h2>颜色:{{ color }}</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>
    <button @click="changeColor">修改颜色</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  data() {
    return {
      color:'红色'
    }
  },
  methods: {
    changeColor() {
      this.color='蓝色'
    }
  },
  setup() {
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

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

    }
    function showLowPrice() {
      console.log(lowPrice)

    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

在浏览器访问,发现可以共存,且color是响应式数据。如图:在这里插入图片描述

data、methods能访问setup数据与方法

在data中修改color默认赋值为name+'color',修改methods函数changeColor,让它访问name属性,调用修改价格函数

 data() {
    return {
      color:name+'color'
    }
  },
  methods: {
    changeColor() {
      this.color = '蓝色'
      console.log(this.name);
      this.changePrice();
    }
  },

在浏览器访问,效果如下图 在这里插入图片描述

  • 属性颜色,显示奔驰红色
  • 修改颜色函数,控制台输出name,并调用修改价格函数 以下是具体代码
<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <h2>颜色:{{ color }}</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>
    <button @click="changeColor">修改颜色</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  data() {
    return {
      color: this.name + '红色'
    }
  },
  methods: {
    changeColor() {
      this.color = '蓝色'
      console.log(this.name);
      this.changePrice();
    }
  },
  setup() {
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

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

    }
    function showLowPrice() {
      console.log(lowPrice)

    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

setup数据与方法不能访问data数据与methods方法

  • 在setup函数中打印color属性,提示异常:Uncaught ReferenceError: color is not defined,页面无法渲染
  • 在setup方法中访问color属性,提示异常:Uncaught ReferenceError: color is not defined 在这里插入图片描述

让setup函数更优雅

set函数需要返回值,否则模版中无法访问setup定义的属性

setup() {
    let name = '奔驰';
    return { name }
  }

如若新增一个属性weight重量,,就需要返回weight属性

setup() {
    let name = '奔驰';
    return { name,weight }
  }

当有许多个属性时,代码很繁琐,且return { name,weight }是无必要的。只需要在script标签中添加setup可优雅解决

创建一个最简setup函数

<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <button @click="changeName">修改品牌</button>
  </div>
</template>
<script setup lang="ts">
let name = '奔驰';
function changeName() {
  name = '宝马'
}
</script>

注意script有setup标识,在setup里声明的属性和方法,模版都可以访问。

❌
❌