普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月21日掘金 前端

AI “自动驾驶” 的使用分享

作者 林小帅
2025年11月21日 11:30

AI “自动驾驶” 的使用分享

一、开场:从 "AI 善后工程师" 说起

大家有没有遇到过这样的情况:

满怀期待地让 AI 帮你写代码,结果跑出来一堆问题,然后你花了大量时间去修改、调试、善后。最后发现,还不如自己写来得快。

这时候,你就变成了 "AI 善后工程师"

我自己长时间使用以来的一些思考,就是如何跳出这个困境,让 AI 真正成为你的效率倍增器,而不是麻烦制造者。


二、问题根源:模糊的需求,模糊的结果

让我们看一个真实的对比案例。

模糊版本

我想加个导出 Excel 的功能

结果会怎样?

虽然 AI 和 Spec 也能做,但这个跑出来的结果可想而知。AI 会做大量假设:

  • 导出什么数据?哪些字段?
  • 在哪个页面?什么时候触发?
  • 文件格式怎样?命名规则?
  • 有没有数据量限制?

不断的和 AI 拉扯“需求”,然后 AI 也一直被打断需要你不断的 “澄清” 事实。 

AI 执行完成后,你发现 AI 生成的代码需要你不断的修改、补充、调整,就变成了 "AI 善后工程师"。  

清晰版本

我想在顾问模块的对账单列表页添加导出 Excel 功能 

Ai 工作流:.windsurf\workflows\add-export-function.md
// 这是 “项目” 中定义的 AI 工作流,可以明确的给 AI 提供上下文信息 

业务背景:
业务每月需要将对账单数据导出给组长,现在只能手动复制,容易出错
 
功能描述:
- 在列表页顶部添加"导出 Excel"按钮
- 导出当前筛选条件下的所有数据(不分页)
- 导出字段:账单号、提交时间、状态...
- 文件名格式:x月对账单_YYYYMMDD.xlsx 

用户场景:
- 场景1:用户筛选了某个月份的数据,点击导出,得到该月份的 Excel 文件
- 场景2:用户未筛选,点击导出,得到所有数据的 Excel 文件 

技术偏好:
- 使用项目中已有的 exceljs 库 // 如果没有现成组件,可以明确给 AI
- 导出限制:最多 10000 条数据,超过提示分批导出

结果? 

AI 能够一次性生成 70-80% 可用的代码,你只需要做少量调整和验证。 


三、核心方法:如何跳出 "AI 善后工程师"?

1. 5W1H 原则 

这是产品思维的基础方法,同样适用于和 AI 沟通: 

  • Why(为什么):业务背景、痛点
  • What(做什么):核心功能
  • Who(谁用):目标用户角色
  • Where(在哪):哪个模块/页面
  • When(什么时候):触发时机
  • How(怎么做):技术偏好(可选)

2. 明确边界

告诉 AI 什么要做,什么不要做

做:简单的关键字搜索
不做:复杂的高级筛选、搜索历史

这能避免 AI 过度设计,把简单问题复杂化。 

3. 提供具体场景

不要说 "用户字段可以搜索",而是

场景:
1. 数据列表中,用户输入"张三",点击搜索,看到搜索的结果。
2. 点击 "张三" 的数据项ID,跳转到详情页。

具体的场景描述能让 AI 理解完整的用户操作流程。

4. 说明优先级(如果有多个功能)

P0(必须):基础搜索功能
P1(重要):搜索结果高亮
P2(可选):搜索历史记录

这样 AI 知道先做什么,避免在次要功能上浪费时间。

现在 AI “分析” 能力强了不少,自然也语言可以工作,但如果你掌握一些 "提示技巧",确实能让 AI 生成更准确的提案,减少来回澄清的次数,提高效率


四、理性认知:为什么说迷信 “AI 全自动” 能力是不好的做法?

我们应该追求的是 "效率增益",而不是 "一次完美完成" 的代码。

关键是考虑显著的实现上的时间节省。

AI 最适合的场景:

  • 高重复性/模板化:如 CRUD、DTO转换、前端字段映射
  • 高耗时:纯手工需要超过 2-3 小时的繁琐任务
  • 低风险:非核心生产逻辑
  • 易于验证:容易判断对错,修复成本低  

AI 不适合的场景: 

  • 核心业务逻辑(需要深度理解业务)
  • 复杂的架构设计决策
  • 需要大量上下文的重构工作
  • 安全敏感的代码(需要人工深度审查)

五、最佳实践总结

好的做法

  • 明确定义任务范围 - 用 5W1H 描述清楚
  • 聚焦 AI 擅长场景 - 重复性、模板化任务
  • 鼓励先计划再编码 - 使用 OpenSpec 等工作流
  • 提供项目上下文指令 - 通过工作流文件、项目文档
  • 保留人工复核环节 - AI 生成,人工审查
  • 充分利用自动测试 - 用测试验证 AI 代码的正确性

不好的做法

  • 上下文臃肿,不加取舍 - 把整个项目塞给 AI
  • 不写测试直接上 - 无法验证 AI 代码质量
  • 无急停,任其失控 - AI 跑偏了也不及时纠正
  • 忽视 AI 给出的安全扫描 - AI 提示的安全问题不重视
  • 迷信 AI 全自动能力 - 期望 AI 解决所有问题
  • AI 代码不整理沉淀 - 生成的代码不规范化,技术债累积  

六、结语:AI 是副驾驶,你是司机

AI "自动驾驶" 不是让你做甩手掌柜,而是

  • 你负责方向:明确需求、定义边界、把握质量
  • AI 负责执行:快速生成代码、处理重复性工作
  • 协同提效:在合适的场景下,发挥各自优势

记住:好的需求描述 = 好的 AI 输出

当你从 "AI 善后工程师" 变成 "AI 驾驶员" 时,你会发现效率提升不止一倍。

人机的高效协同,与 AI 结队编程应是当下最优解。、


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

20个CSS实用技巧,10分钟从小白变大神!

作者 刘大华
2025年11月21日 10:48

大家好,我是大华!今天给大家分享一些比较实用的CSS技巧,不管你是前端新手还是老司机,这些技巧都能让你的开发效率翻倍!废话不多说,直接上代码了。

1. 使用变量维护主题色

/* 定义CSS变量 */
:root {
  --primary-color: #3498db;
  --secondary-color: #2ecc71;
  --text-color: #333;
}

/* 使用变量 */
.button {
  background-color: var(--primary-color);
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
}

.card {
  border: 1px solid var(--secondary-color);
  color: var(--text-color);
}

适用场景:需要维护统一设计规范的项目,方便快速切换主题

2. Flex布局居中(终极解决方案)

.center-container {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
  height: 100vh;          /* 视口高度 */
}

/* 如果只需要单方向居中 */
.vertical-center {
  display: flex;
  align-items: center;    /* 仅垂直居中 */
}

.horizontal-center {
  display: flex;
  justify-content: center; /* 仅水平居中 */
}

适用场景:任何需要居中的布局,特别是响应式设计

3. Grid网格布局

.grid-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 3等分列 */
  grid-gap: 20px;                       /* 网格间距 */
  padding: 20px;
}

.grid-item {
  background: #f0f0f0;
  padding: 20px;
  border-radius: 8px;
}

/* 响应式网格 */
@media (max-width: 768px) {
  .grid-container {
    grid-template-columns: repeat(2, 1fr); /* 小屏幕2列 */
  }
}

@media (max-width: 480px) {
  .grid-container {
    grid-template-columns: 1fr; /* 超小屏幕1列 */
  }
}

适用场景:复杂布局、卡片布局、图片画廊

4. 自定义滚动条

.custom-scrollbar {
  width: 300px;
  height: 200px;
  overflow-y: scroll;
  padding: 10px;
}

/* Webkit浏览器滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
  width: 8px; /* 滚动条宽度 */
}

.custom-scrollbar::-webkit-scrollbar-track {
  background: #f1f1f1; /* 轨道背景 */
  border-radius: 4px;
}

.custom-scrollbar::-webkit-scrollbar-thumb {
  background: #c1c1c1; /* 滑块颜色 */
  border-radius: 4px;
}

.custom-scrollbar::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8; /* 悬停颜色 */
}

适用场景:需要统一浏览器滚动条样式的项目

5. 文字渐变效果

.gradient-text {
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  -webkit-background-clip: text; /* 关键:背景裁剪到文字 */
  -webkit-text-fill-color: transparent; /* 关键:文字透明 */
  background-clip: text;
  font-size: 3rem;
  font-weight: bold;
}

适用场景:标题设计、品牌文字、特色展示

6. 毛玻璃效果

.frosted-glass {
  background: rgba(255, 255, 255, 0.25); /* 半透明背景 */
  backdrop-filter: blur(10px);           /* 背景模糊 */
  -webkit-backdrop-filter: blur(10px);   /* Safari支持 */
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 10px;
  padding: 20px;
  color: white;
}

适用场景:现代设计风格、卡片悬浮效果、导航栏

7. 悬浮阴影动画

.card {
  background: white;
  border-radius: 10px;
  padding: 20px;
  transition: all 0.3s ease; /* 所有属性过渡0.3秒 */
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.card:hover {
  transform: translateY(-5px); /* 悬浮上移5px */
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); /* 阴影加深 */
}

适用场景:卡片设计、按钮交互、产品展示

8. 自定义复选框

/* 隐藏原生复选框 */
.custom-checkbox input[type="checkbox"] {
  display: none;
}

/* 自定义复选框样式 */
.checkbox-label {
  display: flex;
  align-items: center;
  cursor: pointer;
  font-size: 16px;
}

.checkmark {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-radius: 4px;
  margin-right: 10px;
  position: relative;
  transition: all 0.3s ease;
}

/* 选中状态 */
.custom-checkbox input[type="checkbox"]:checked + .checkbox-label .checkmark {
  background: #3498db;
  border-color: #3498db;
}

/* 选中后的对勾 */
.custom-checkbox input[type="checkbox"]:checked + .checkbox-label .checkmark::after {
  content: "";
  position: absolute;
  left: 6px;
  top: 2px;
  width: 6px;
  height: 10px;
  border: solid white;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

适用场景:表单美化、统一设计语言

9. 图片悬浮放大

.image-container {
  width: 300px;
  height: 200px;
  overflow: hidden; /* 隐藏溢出部分 */
  border-radius: 8px;
}

.zoom-image {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 保持图片比例 */
  transition: transform 0.5s ease; /* 缩放过渡 */
}

.zoom-image:hover {
  transform: scale(1.1); /* 悬浮放大1.1倍 */
}

适用场景:产品图集、相册、作品展示

10. 文字阴影效果

.text-shadow {
  font-size: 3rem;
  font-weight: bold;
  color: #2c3e50;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); /* 水平 垂直 模糊 颜色 */
}

/* 多层阴影效果 */
.multi-shadow {
  font-size: 3rem;
  font-weight: bold;
  color: #e74c3c;
  text-shadow: 
    1px 1px 0 #c0392b,
    2px 2px 0 #922b21,
    3px 3px 5px rgba(0, 0, 0, 0.6);
}

适用场景:标题设计、文字特效、海报设计

11. 渐变边框

.gradient-border {
  padding: 20px;
  border: 4px solid;
  border-image: linear-gradient(45deg, #ff6b6b, #4ecdc4) 1;
  border-radius: 8px;
  background: white;
}

/* 另一种实现方式 */
.gradient-border-alt {
  padding: 20px;
  background: 
    linear-gradient(white, white) padding-box,
    linear-gradient(45deg, #ff6b6b, #4ecdc4) border-box;
  border: 4px solid transparent;
  border-radius: 8px;
}

适用场景:特色边框、高亮元素、品牌标识

12. 粘性定位

.sticky-header {
  position: sticky;
  top: 0; /* 距离顶部0时固定 */
  background: white;
  padding: 15px 0;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  z-index: 1000; /* 确保在最上层 */
}

.sticky-sidebar {
  position: sticky;
  top: 100px; /* 距离顶部100px时固定 */
  align-self: start; /* Grid布局中对齐 */
}

适用场景:导航栏、侧边栏、表头固定

13. 文字溢出省略

/* 单行文字溢出显示省略号 */
.single-line-ellipsis {
  white-space: nowrap;      /* 不换行 */
  overflow: hidden;         /* 隐藏溢出 */
  text-overflow: ellipsis;  /* 显示省略号 */
  width: 200px;
}

/* 多行文字溢出显示省略号 */
.multi-line-ellipsis {
  display: -webkit-box;
  -webkit-line-clamp: 3;    /* 显示3行 */
  -webkit-box-orient: vertical;
  overflow: hidden;
  width: 300px;
}

适用场景:标题截断、内容摘要、表格单元格

14. 自定义选择文本样式

::selection {
  background: #3498db;    /* 选中背景色 */
  color: white;           /* 选中文字颜色 */
  text-shadow: none;      /* 去除文字阴影 */
}

/* 针对不同浏览器前缀 */
::-moz-selection {
  background: #3498db;
  color: white;
  text-shadow: none;
}

适用场景:提升用户体验、品牌一致性

15. 滤镜效果

.image-filters {
  transition: filter 0.3s ease;
}

/* 灰度效果 */
.grayscale {
  filter: grayscale(100%);
}

.grayscale:hover {
  filter: grayscale(0%);
}

/* 多个滤镜组合 */
.multiple-filters {
  filter: brightness(1.2) contrast(0.8) saturate(1.5);
}

/* 模糊效果 */
.blur-effect {
  filter: blur(2px);
}

适用场景:图片特效、主题切换、视觉设计

16. 动画关键帧

@keyframes bounce {
  0%, 100% {
    transform: translateY(0); /* 起始和结束位置 */
  }
  50% {
    transform: translateY(-20px); /* 跳起位置 */
  }
}

.bouncing-element {
  animation: bounce 2s ease-in-out infinite; /* 动画名称 时长 缓动 重复 */
}

/* 加载动画 */
@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

适用场景:加载动画、交互动效、注意力引导

17. 响应式图片

.responsive-image {
  max-width: 100%;    /* 最大宽度100% */
  height: auto;       /* 高度自动 */
  display: block;     /* 块级元素 */
}

/* 保持比例的容器 */
.aspect-ratio-box {
  width: 100%;
  height: 0;
  padding-bottom: 56.25%; /* 16:9比例 (9/16=0.5625) */
  position: relative;
  overflow: hidden;
}

.aspect-ratio-box img {
  position: absolute;
  width: 100%;
  height: 100%;
  object-fit: cover; /* 覆盖整个容器 */
}

适用场景:响应式网站、图片画廊、产品展示

18. 三角形绘制

.triangle-up {
  width: 0;
  height: 0;
  border-left: 50px solid transparent;
  border-right: 50px solid transparent;
  border-bottom: 100px solid #3498db;
}

.triangle-down {
  width: 0;
  height: 0;
  border-left: 50px solid transparent;
  border-right: 50px solid transparent;
  border-top: 100px solid #e74c3c;
}

.triangle-left {
  width: 0;
  height: 0;
  border-top: 50px solid transparent;
  border-bottom: 50px solid transparent;
  border-right: 100px solid #2ecc71;
}

适用场景:工具提示、下拉箭头、装饰元素

19. 文字描边效果

.text-stroke {
  font-size: 3rem;
  font-weight: bold;
  color: white;
  -webkit-text-stroke: 2px #3498db; /* 描边宽度和颜色 */
  text-stroke: 2px #3498db;
}

/* 兼容写法 */
.text-stroke-alt {
  font-size: 3rem;
  font-weight: bold;
  color: transparent;
  -webkit-text-stroke: 2px #3498db;
  text-shadow: none;
}

适用场景:标题设计、海报文字、特色展示

20. 混合模式

.blend-mode {
  background-image: url('background.jpg');
  background-size: cover;
}

.blend-mode::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: #3498db;
  mix-blend-mode: overlay; /* 叠加混合模式 */
}

/* 图片混合模式 */
.image-blend {
  background: 
    linear-gradient(45deg, #ff6b6b, #4ecdc4),
    url('image.jpg');
  background-blend-mode: overlay;
  background-size: cover;
}

适用场景:创意设计、图片处理、视觉效果


总结

这20个CSS技巧包含了现代最常见的开发需求,从布局到动画,从响应式到视觉效果。掌握这些技巧,你就能应对90%的日常开发需求。

其中有几点比较关键:

  1. 变量管理让维护更轻松
  2. Flex/Grid是布局的首选
  3. 过渡和动画提升用户体验

希望这些技巧能帮助你在开发中事半功倍!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3 + ElementPlus 动态菜单实现:一套代码完美适配多角色权限系统》

别再把对象当“字典”!JS 零基础也能看懂的“属性账本”拆解笔记

2025年11月21日 10:37

前言

目前我的 JS 技能树点亮了“对象”这一格,这篇文章就是一份“新手村”笔记,只用我已掌握的钥匙,把对象这只“收纳盒”的隔层、暗格、标签一次性摸清楚,不越级打怪,也不提前透支未来语法。如果你也刚学完对象,想先把它玩透再往下走,欢迎一起原地复盘,把基础踩成肌肉记忆。

对象

  • 在 js 这门语言中流传着一句话叫 万物皆对象,顾名思义不管你是哪种数据类型都可看作是对象。
    我们知道的数据类型可分为两种——原始类型和引用类型
  1. 原始类型又叫简单类型,有 string number boolean undefined null Symbol bigInt
  2. 引用类型又叫复杂类型,有 function array object
  • 在解释为什么万物都可被称为对象之前我们先聊今天第一个话题——如何创建一个对象

一. 创建对象的方式

1. 对象字面量 只用一个 {} 就能解决

const obj = {
    a : 1
    }    // 这是我们最常看到的

2. new Object()new Object() 创建一个对象后,再在外围添加属性

const obj = new Object()  // 构造函数
obj.name = '饶总'
const abc = 'age'   // 目的添加 key 为 age ,value 为 20
obj[abc] = 20   //当 abc 是变量的时候用[],就可直接访问到 age
delete obj[abc]   //要删掉属性就用 delete
console.log(obj);

3. new 调用自定义的构造函数 关于构造函数

  • 当我们需要批量化创建对象时,可以使用构造函数
    方便理解这句话,首先我们设立一个目标,我们要把公司员工的姓名年龄职位输入到系统里面方便统计管理,按照古板的方法就是创造无数个职位对应的函数,一个一个输入姓名年龄放进去
function Insert(name, age) {
  const obj = {
    name: name,
    age: age,
    job: 'coder'
  }
  return obj
}

function Insert2(name, age) {
  const obj = {
    name: name,
    age: age,
    job: 'manager'
  }
  return obj
}

Insert('饶总', 18)

但是栈的容量有限,函数过多就会导致爆栈,那么这个时候就可以用构造函数来解决

function Insert(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
}
const p = new Insert('晓总', 18, 'coder') // { name: '晓总', age: 18, job: 'coder' }
const p2 = new Insert('佳颖', 19, 'manager')
console.log(p);
console.log(p2);
  • 当一个函数被 new 调用时,我们称之为构造函数
    再来一个关于生产汽车的例子
function Car(color) {
  this.name = 'su7'
  this.height = '1400'
  this.lang = '4800'
  this.weight = '1500'
  this.color = color
}

const car1 = new Car('purple') // 实例化一个对象
const car2 = new Car('yellow')

由于生产的汽车的大部分标签是一样的,只有极个别标签能自行选择,这个时候用 new 构造一个函数就显得尤为高明

二. new 干了什么?

  1. 创建一个 this 对象
  2. 执行构造函数中的代码,就是给 this 对象添加属性值
  3. 返回这个 this 对象
function person() {
  const _this = {}   //构造一个 _this 对象
  _this.name = 'su7'   
  _this.color = 'green'  //给该对象添加属性值
  return _this       //返回该对象
}

const obj = person()

console.log(obj);

三. 什么是包装类?

1. 当用户定义一个字面量,v8默认执行的还是 new Xxx()

var num = 123  //var num = new Number(123)  
var str = 'hello'  //var str = new String('hello')

2. 一个包装类的实例对象,在参与运算时,会自动拆箱成原始类型

var num = 123  // v8 默认执行 var num = new Number(123)
num.a = 'aaa'
//delete num.a   // 发现是原始类型就自动删除
console.log(num.a); // 执行结果为 undefined,既证明了 num 可以是对象,又说明了 num 的属性被删除了

3. 因为 js 是弱类型语言, 所以只有在赋值语句执行时才会判断值的类型(typeof),当值被判定为原始类型时,就会自动将包装对象上添加的属性移除

var str = 'hello' 
//typeof('hello')   判断类型为string
//  v8执行 var str = new String('hello')
str.len = 2
//delete str.len  将包装对象上添加的属性移除

结语

对象既是 JavaScript 的地基,也是后续所有语法的交汇点;先把这块“自留地”的边界、门牌和规则摸透,再学任何新框架都能事半功倍。愿这份最基础的笔记,能在你未来的进阶路上当一块靠谱的垫脚石。感谢阅读!

转转UI自动化走查方案探索

2025年11月21日 10:34

转转UI自动化走查方案探索

前言

UI走查是现在转转C端需求开发流程中相当重要的一环,然而UI走查这一环节难以接入自动化流程中,导致其没有如同测试环节一样的冒烟环节(又称测试准入),因此实际走查阶段时,前端的UI交付质量难以在流程上做硬性管控,UI还原度参差不齐,这也就进一步导致了UI走查效率难以把控。经UI侧同学的不完全统计,2025年5-8月期间,共走查需求20个,总耗时33h,平均单个需求耗时1.6h,在需求规模较大的情况下,走查耗时最高时可达3-4小时,走查轮次3次,异常数量30-50个不等,而走查本身耗时并不高,而大部分的时间都耗费在对UI异常问题的标注上。因此,开发一种自动化比对UI设计稿与HTML结构,自动化标注比对结果的工具,能在根本上提升UI还原度,提升UI走查效率。

走查耗时统计

方案的调研与选择

叠图比对方案

目前现存的大多数UI走查方案走的都是比对UI设计稿与前端页面截图的方案,此方案虽通用性高,但是结果难以量化,难以与标准化研发流程结合,在前言中我们也说明了,走查的耗时成本在与异常数据的标注,而非获取比对结果。因此这种方案显然无法达成目的

prefect pixel实现效果

像素比对方案

像素级的比对方案与叠图比对方案的原理相似,在实现上,叠图方案需要开发者主动判断结果,而像素比对方案则是通过类似于opencv之类的图像处理工具配合类似于scikit-image之类的结构相似度算法进行实现。这种方案虽然能拿到量化的比对数据,以及自动化标注,但是在双方图片的相似度上要求较高,设计稿与UI在文案上有一定的区别都能造成比对的误差,且误差不可控。如果进一步的考虑通过微调、自训练ai模型的方案,则训练、标注的成本也相当高。因此改方案也并非最佳的选择。

像素级比对方案结果

像素级比对方案结果

基于前端与UI节点的比对方案

在现在流行的一些前端化UI设计工具(例如Figma、Master Go)中,UI设计稿在底层其实是一个大型的带有嵌套关系的JSON数据结构,而前端的Dom树也是一个带有嵌套关系的数据结构。基于这两点,很容易就想到如果能将双方的节点数据归一化处理成相同的数据结构,那么在节点间间距的精确匹配与比对上,或许会有不错的效果。

数据结构设计

既然要归一化处理数据,那么首先需要考虑的就是这个数据结构需要什么。

在上文提到的UI走查情况统计数据中,UI走查出现的问题中,间距问题占比约95%,字体相关问题约占3%,其余诸如背景色、边框等问题占比共2%,因此在方案前期,首要需要解决的问题就是间距问题。

要检测出间距问题,首先需要找到当前节点的相邻节点,于是我们对相邻节点做出如下定义。

  1. 定义当前节点A与另一个节点B之间总共有9种环绕关系,按从左到右从上到下编号分别即为1-9号位置。 节点周边的8个位置

  2. 2、4、6、8号位置中,与当前节点的距离最小的节点,即为当前节点的四个方位的相邻节点

  3. 所谓上下左右的间距,即为当前节点与各方位上的相邻节点的间距。

    /** 相对于当前节点的兄弟节点位置枚举 */
    export enum SiblingPosition {
      /** 不在任何位置 */
      NONE = 0,
      /** 左上角 */
      TOP_LEFT = 1,
      /** 正上方 */
      TOP = 2,
      /** 右上角 */
      TOP_RIGHT = 3,
      /** 左侧 */
      LEFT = 4,
      /** 右侧 */
      RIGHT = 6,
      /** 左下角 */
      BOTTOM_LEFT = 7,
      /** 正下方 */
      BOTTOM = 8,
      /** 右下角 */
      BOTTOM_RIGHT = 9,
    }
    /** 节点的唯一ID标识符 */
    type UniqueId = string
    /** 只记录2 4 6 8 四个方向的兄弟节点信息 */
    type SiblingRelativeNodeInfo = Partial<Record<SiblingPosition, UniqueId>>
    

除此之外,我们还需要该节点的boundingRectpaddingmarginborderfont相关的信息,最终设计了如下的数据结构

/** 节点信息 */
export interface NodeInfo extends SiblingRelativeNodeInfo {
  /** 父节点 id */
  parentId: UniqueId
  /** 子节点 id */
  children: UniqueId[]
  /** 兄弟节点 id */
  sibling: UniqueId[]
  uniqueId: UniqueId
  nodeName: string
  /** 节点边界 */
  boundingRect: BoundingRect
  /** padding信息 */
  paddingInfo: PaddingInfo
  /** border信息 */
  borderInfo: BorderInfo
  /** 背景色 */
  backgroundColor: string
  /** 标签名称(设计稿则为节点类型) */
  tagName?: string | SceneNode['type']
  /** 文本样式信息, 只有内部是文本的节点才有这个字段 */
  textStyleInfo?: TextStyleInfo
  /** 节点的中心信息 @description DOM ONLY */
  nodeFlexInfo?: NodeFlexInfo
  /** 相邻节点的边距 */
  neighborMarginInfo: Partial<Record<SiblingPosition, NeighborMarginInfo>>
}

数据归一化处理流程

flowchart TD
    Start([开始: rootNode]) --> GetBounding[获取根节点 getBoundingClientRect]
    GetBounding --> SetBounding[设置 rootBounding 到 domConfigs]
    SetBounding --> CheckTextResize{检查设计稿是否有<br/>textAutoResize}
    CheckTextResize --> RecordInfo[onDomInfoRecorder<br/>记录 DOM 信息]
    
    RecordInfo --> SyncText[syncTextInfoToTextParentNode<br/>同步文本信息到父节点]
    SyncText --> LargeLineHeight[processLargeLineHeight<br/>处理大行高场景]
    
    LargeLineHeight --> ShouldShrink{是否需要处理<br/>shouldShrinkBounding?}
    ShouldShrink -->|是| ProcessShrink[processDomNodeShouldShrinkBounding<br/>处理收缩边界]
    ShouldShrink -->|否| ProcessZZUI[processDomZZUINode<br/>处理 ZZUI 节点]
    ProcessShrink --> ProcessZZUI
    
    ProcessZZUI --> SearchInitial[searchNeighborNodesInitial<br/>搜索初始邻居节点]
    SearchInitial --> RemoveSame1[removeSameSizePositionChildren<br/>去除同位置同大小子节点 #1]
    
    RemoveSame1 --> MarginCollapse[processMarginCollapsing<br/>处理 margin 折叠]
    MarginCollapse --> Padding[processPaddingInfo<br/>处理 padding 信息]
    Padding --> ShrinkRect[shrinkRectBounding<br/>收缩矩形边界]
    
    ShrinkRect --> RemoveSame2[removeSameSizePositionChildren<br/>去除同位置同大小子节点 #2]
    RemoveSame2 --> Match[recordHybridNodeMatchResult<br/>记录混合节点匹配结果]
    
    Match --> SearchNeighbor[searchNeighborNodes<br/>搜索邻居节点]
    SearchNeighbor --> Distance[getNeighborNodeDistance<br/>计算邻居节点距离]
    
    Distance --> End([返回: FlatNodeMap])
    
    style Start fill:#e1f5e1
    style End fill:#ffe1e1
    style ShouldShrink fill:#fff4e1
    style CheckTextResize fill:#fff4e1

Dom侧数据处理与UI侧数据处理逻辑基本一致,上图为整个Dom结构数据处理流程,下面将对该流程中比较核心的部分进行讲解。

processLargeLineHeight处理行高问题

处理行高问题,旨在抹平前端侧与UI侧在相同渲染效果下,因为设置了不同的行高导致节点高度不同造成的异常。行高数据的归一化处理依赖于节点内部的textStyleInfo,其结构如下

export interface TextStyleInfo {
  /** 行高 */
  lineHeight: number
  /** 文本宽度 */
  textWidth: number
  /** 字体大小 */
  fontSize: number
  /** 字体粗细 */
fontWeight: number
  /** 字体 */
  fontFamily: string
  /** 文本行数 */
  textLineCount: number
  /** 文本对齐方向 */
  textAlignment: TextAlignment
  /** 文本内容 */
  textContent: string
/** master go的文本节点的宽高策略 */
  textAutoResize?: TextNode['textAutoResize']
}

其核心逻辑如下:

  1. 创建测量容器
const measureElement = document.createElement('div')
measureElement.style.position = 'absolute'
measureElement.style.visibility = 'hidden'
  1. 克隆关键样式

复制影响文本布局的所有样式到测量元素:

  • 字体样式: fontSize, fontFamily, fontWeight
  • 排版: lineHeight, whiteSpace, wordBreak, textTransform
  • 约束: width(使用原节点宽度)
  • 清零: padding, margin, border(避免干扰)
  1. 首次测量(多行场景)
measureElement.style.width = `${domNode.getBoundingClientRect().width}px`
document.body.appendChild(measureElement)
const measureHeight = measureElementRect.height
  • 设置固定宽度(与原节点一致)
  • 测量文本在此宽度约束下的实际高度
  1. 计算行数
const shouldUseOriginHeight = !isInline && originHeight < measureHeight
const textHeight = shouldUseOriginHeight ? originHeight : measureHeight
const lineCount = Math.max(1, Math.round(textHeight / lineHeightValue))

关键逻辑

  • 如果原节点是 block 且设置了固定高度,且该高度小于测量高度
    • 说明文本被裁剪了(如 height: 50px 但文本实际需要 100px)
    • 使用原始高度计算行数(因为实际显示的就是被裁剪的部分)
  • 否则使用测量高度
  • 行数 = 文本高度 / 行高
  1. 单行特殊处理
if (lineCount > 1) {
  return baseTextStyle
}
// 单行场景,重新计算宽度
measureElement.style.width = 'auto'
document.body.appendChild(measureElement)
const measureElementRect2 = measureElement.getBoundingClientRect()

单行重新测量,根本上是为了确定单行文本的场景下实际占用的最小宽度。

在获取了textStyleInfo之后,就可以进行行高的归一化处理了。

  1. Inline 节点特殊处理
if (isInlineNode && !!parentNodeInfo) {
  nodeInfo.boundingRect.y = parentNodeInfo.boundingRect.y
  nodeInfo.boundingRect.height = parentNodeInfo.boundingRect.height
  return
}

inline节点的处理是一个大坑,在MasterGo的渲染器中,文字始终是在TextNode中居中处理的,但是在前端文本中,文本是以baseline为基准对齐的,文字在lnlineNode中的居中方式,取决于字体本身的设置,而非始终居中渲染。因此此套逻辑只能在非行内元素中使用,而行内元素的宽高则始终与父节点保持一致。

  1. 单行文本行高大于字体大小的场景处理
const realHeight = boundingRect.height - paddingBottom - paddingTop

if (textLineCount > 1 || fontSize === realHeight) {
  return
}

const deltaValue = realHeight - fontSize
nodeInfo.boundingRect.y = boundingRect.y + deltaValue / 2
nodeInfo.boundingRect.height = fontSize + paddingTop + paddingBottom

调整方式

原始高度 = 50px (行高撑开)
字体大小 = 14px
多余空间 = 36px

调整后:
- Y 坐标下移:y + 18px (居中)
- 高度缩小:14px + padding

processMarginCollapsing处理边距合并的场景

要理解这一步骤,首先需要理解CSS中的核心概念:Margin 折叠(Margin Collapsing)

Margin Collapsing介绍

在特定条件下,子元素的 margin 会穿透父元素边界,直接作用到父元素外部。举个例子:

<div class="parent" style="background: lightblue;">
  <div class="child" style="margin-top: 30px; background: pink;">
    子元素
  </div>
</div>

预期效果:子元素距离父元素顶部 30px
实际效果:父元素整体向下移动 30px,子元素紧贴父元素顶部

❌ 预期布局                ✅ 实际布局
┌─────────────┐           ↓ 30px
│  Parent     │           ┌─────────────┐
│  ↓ 30px     │           │  Parent     │
│  ┌────────┐ │           │┌────────┐   │
│  │ Child  │ │           ││ Child  │   │
│  └────────┘ │           │└────────┘   │
└─────────────┘           └─────────────┘
不会发生 Margin 折叠的条件:
  1. BFC区域

  2. 父节点有边界或者背景

  3. Flex、Grid布局

  4. 节点中有内联文本

    <div class="parent">
      内联文本
      <div class="child" style="margin-top: 30px;">子元素</div>
    </div>
    
处理逻辑
  1. 判断是否会发生Margin折叠
  2. 会发生Margin折叠时,对应方向上是否有子节点有margin
  3. margin时,将子节点设置为margin: 0,并将父节点对应方向上的margin设置为子节点的值与父节点的值中的最大值。
export function hoistingNotBfcBoundaryMargin(domNode: HTMLElement) {
  Array.from(domNode.children).forEach((childNode) => {
    // 反向DFS,先走最内部节点,然后往外走
    hoistingNotBfcBoundaryMargin(childNode as HTMLElement)
  })
  // 是否是bfc
  const isBFC = getDomIsBfc(domNode)
  // 是否是flex or grid
  const isFlexOrGridItem = getIsFlexOrGridItem(domNode)
  // 是否有内联元素
  const hasInlineContent = getHasInlineContent(domNode)
  // 是否有clear
  const hasClearance = getHasClearance(domNode)
  const childNodeList = Array.from(domNode.children).filter((childNode) => {
    const isDataTextWrapper = childNode.getAttribute('data-text-wrapper') === '1'
    return !isDataTextWrapper
  })

  // 全部为false才需要提升margin
  const preJudgeResult = [isBFC, isFlexOrGridItem, hasInlineContent, hasClearance, !childNodeList.length].every(it => !it)
  if (!preJudgeResult) {
    return
  }
  const shouldHoistTopMargin = judgeDomNodeMarginHoisting(domNode, 'top')
  const shouldHoistBottomMargin = judgeDomNodeMarginHoisting(domNode, 'bottom')

  if (shouldHoistTopMargin) {
    hostingTargetDirectionMargin(domNode, 'top')
  }
  if (shouldHoistBottomMargin) {
    hostingTargetDirectionMargin(domNode, 'bottom')
  }
}

processPaddingInfo 合并padding逻辑

在UI设计稿中,他们的边距可能是两个节点之间的距离,也可能是自动化布局的时候配置的padding。

不同的边距实现

边距场景2

而在前端页面中,由于时常要考虑到节点存在与不存在的情况下的间距稳定性,因此常会出现各种拼接边距的情况。

拼接边距场景

所以需要将纯粹地用来作为拼接边距的padding,从当前节点的width和height中排除出去,让节点的尺寸中不包含任何多余的无效padding。本处理方法的核心逻辑如下

  1. 对每个节点的上、下、左、右四个方向独立处理
paddingInfoDirectionList.forEach((currentPosition) => {
  // top, right, bottom, left
})
  1. 判断当前方向的 padding 是否可以被合并(可能需要检查子节点是否占用了这个空间)
const targetDirectionPaddingValue = getTargetDirectionPaddingValue({
  currentNodeInfo,
  flatNodeMap,
  position: currentPosition,
})

export function getTargetDirectionPaddingValue({ currentNodeInfo, position }: JudgeMergableConfig) {
  const paddingKey = camel(`padding ${position}`) as keyof PaddingInfo
  // 是否存在目标方向的padding
  const targetDirectionPaddingValue = currentNodeInfo.paddingInfo[paddingKey]
  return targetDirectionPaddingValue || 0
}
  1. 执行合并操作
  2. 扩展边界框:将 padding 空间纳入 boundingRect
  3. 减少 padding 值:从 paddingInfo 中扣除已合并的值
/**
 * 合并padding
 * @param curNodeInfo 当前节点信息
 * @param position 目标方向
 * @param paddingInfo 目标方向的padding值
 * @returns 合并后的节点信息
 */
function handleMergePadding(curNodeInfo: NodeInfo, position: 'left' | 'right' | 'top' | 'bottom', paddingInfo: number) {
  const clonedBoundingRect = clone(curNodeInfo.boundingRect)
  if (position === 'left') {
    clonedBoundingRect.x += paddingInfo
    clonedBoundingRect.width -= paddingInfo
  }
  if (position === 'right') {
    clonedBoundingRect.width -= paddingInfo
  }
  if (position === 'top') {
    clonedBoundingRect.y += paddingInfo
    clonedBoundingRect.height -= paddingInfo
  }
  if (position === 'bottom') {
    clonedBoundingRect.height -= paddingInfo
  }
  return clonedBoundingRect
}

/**
 * 减去已被合并的padding值
 * @param curNodeInfo 当前节点信息
 * @param position 目标方向
 * @param paddingInfo 目标方向的padding值
 * @returns 减去padding值后的节点信息
 */
function handleSubtractPaddingValue(curNodeInfo: NodeInfo, position: 'left' | 'right' | 'top' | 'bottom', paddingInfo: number) {
  const clonedPaddingInfo = clone(curNodeInfo.paddingInfo)
  const paddingKey = camel(`padding ${position}`) as keyof PaddingInfo
  clonedPaddingInfo[paddingKey] -= paddingInfo
  return clonedPaddingInfo
}

function processingPaddingInfo() {
//...
  const paddingMergedBoundingRect = handleMergePadding(
    currentNodeInfo, 
    currentPosition, 
    targetDirectionPaddingValue
  )
  const newPaddingInfo = handleSubtractPaddingValue(
    currentNodeInfo, 
    currentPosition, 
    targetDirectionPaddingValue
  )
//...
}

shrinkRectBounding收缩矩形边界

为什么要收缩矩形边界?

在UI设计稿中,存在一些质量不可控的现象,比如 初始效果

在这个设计稿中,看似左侧的成色+定级标准节点与右侧的上下边距混乱,毫无居中的感觉。但是倘若我们对他进行一下变更:

居中后的效果实际上只需要处理好左侧的高度,右侧的高度完全可以和左侧相同,并处理成为 justify-content: space-between即可。

但是当FE拿到第一张图的时候,并不会一眼就看出来左右两侧的高度是可以完全一样的(因为选中左侧的时候是一个大的rectangle)

这个例子告诉我们,在UI侧常会存在一些凌乱的、难以用直觉判断实际效果的设计节点。这时候如果想要用自动化的方案进行比对,就需要针对这些噪声进行降噪处理。在本文中,采用的就是以实际渲染内容为主的节点收缩方案

主要逻辑就是尽可能的收缩节点的尺寸,让其可以紧凑地包裹在内容之外,尽可能地不包含多余的空间。

设计稿边距收缩效果

HTML边距收缩效果

在做完这些工作后,便可以进行节点与其相邻节点的间距计算了。

Dom与设计稿数据的预处理逻辑

所谓预处理逻辑,旨在解决归一化处理数据时遇到的一些特殊case。包括但不限于:设计稿蒙版节点处理、设计稿使用rectange节点作为背景色节点的逻辑处理、前端px2rem误差精度修正等

设计稿蒙版节点处理

在设计稿中 Mask 的形式如下

GROUP
├─ 图片 A
├─ 图片 B
└─ 圆形 (isMask: true)  ← Mask 图层

其效果为:圆形作为遮罩,图片 A 和 B 只显示圆形范围内的部分。

虽然蒙版节点与被蒙版的节点是同一层的几个节点,但是从效果上来说,被蒙版的节点,可以视为蒙版的子节点。因此,将蒙版节点及被蒙版的节点视为一个父子结构,可以有效简化数据结构的处理。其流程如下:

  1. 查找 Mask 节点
    1. 反转子节点列表(因为设计工具中 mask 在图层堆叠的底部)
    2. 找出所有可见的 isMask: true 节点的索引
const reversedChildNodeList = originChildNodeList.toReversed()

const childMastIndexList = reversedChildNodeList
  .filter(it => !!it.isVisible)
  .reduce((prev, it, index) => {
    if (!nodeCanBeMaskSet.has(curNode.type) || !curNode.isMask) {
      return prev
    }
    return [...prev, index]
  }, [] as number[])
  1. 计算 Mask 影响范围
    1. Mask 节点会影响它上方的所有节点(直到遇到下一个 mask)
    2. 计算每个 mask 的影响范围 [start, end]
const maskChildIndexStartEndList = childMastIndexList
  .map((it, index, originArr) => {
    if (index === 0) {
      return [reversedChildNodeList.length - it, reversedChildNodeList.length]
    }
    return [reversedChildNodeList.length - it, reversedChildNodeList.length - originArr[index - 1] - 1]
  })
  .toReversed()

示例

原始顺序(反转后):
[0: 图片A, 1: 图片B, 2: Mask1, 3: 图片C, 4: Mask2]
                                          
Mask1 影响范围: [0, 2]  (图片A, 图片B)
Mask2 影响范围: [3, 4]  (图片C)

3. 创建新的 FRAME 容器

const newFrameNode = {
  ...emptyFrameNode,           // 基础 FRAME 属性
  id: `${id}${MASK_REPLACE_SUFFIX}`,
  name: `${name}${MASK_REPLACE_SUFFIX}`,
  // 继承 mask 节点的位置和尺寸
  absoluteTransform,
  relativeTransform,
  x, y, width, height,
  cornerRadius,                // 继承圆角
  clipsContent: true,          // 关键:裁剪内容
  children,                    // 被遮罩的内容
}
  1. 优化:单个 Mask 且尺寸一致

    如果只有一个 mask 且和父容器尺寸相同,直接替换父节点,减少层级。

const isSameSize = currentNode.width === firstMaskRelpaceNodeInfo.width 
  && currentNode.height === firstMaskRelpaceNodeInfo.height

if (newChildGroupList.length === 1 && isSameSize) {
  // 直接合并到父节点
  return {
    ...currentNode,
    ...firstMaskRelpaceNodeInfo,
  }
}

设计稿背景节点的提升

由于一些原Adobe软件 or sketch的操作习惯,且在MasterGo工具中,Group节点无法主动设置宽高,因此常使用group节点+rectangle节点的方式去撑开一个节点或者给一个节点设置背景色。而这个rectangle节点在比对过程中则属于一个需要忽略的节点,因此在此预处理逻辑中,需要识别背景色节点,并将背景色提升到父节点的属性上去,简化图层结构。其处理流程如下:

  1. 分离背景图层和内容图层
export function judgeIsBgStyleRectangle(currentNode: PenNode | RectangleNode, parentNode: FrameNode | GroupNode) {
  const { width, height } = currentNode
  const { width: parentWidth, height: parentHeight } = parentNode
  const deltaWidth = Math.abs(width - parentWidth)
  const deltaHeight = Math.abs(height - parentHeight)
  // 大于为图片 + mask 或者 图片+overflow hidden的场景
  const isSameSize = deltaWidth < 2 && deltaHeight < 2
  return isSameSize
}

// 背景图层:RECTANGLE/PEN 且判断为背景样式
const bgStyleNodeList = currentNode.children.filter((it) => {
  if (it.type !== 'RECTANGLE' && it.type !== 'PEN') return false
  return judgeIsBgStyleRectangle(it, currentNode)
})

// 其他图层:非背景的内容
const restNodeChildList = currentNode.children.filter((it) => {
  if (it.type !== 'RECTANGLE' && it.type !== 'PEN') return true
  return !judgeIsBgStyleRectangle(it, currentNode)
})
  1. 合并背景样式
// 合并所有背景图层的填充
const combinedFillList = bgStyleNodeList.flatMap(node => node.fills || [])

前端px2rem误差精度修正

转转App的px2rem方案走的是常规的px2rem方案,root上的font-size配置为了document.clientWidth / 10的值,rem值的取值精度为两位小数。

在前端响应式配置设置为iphone se(375px)的场景下,其页面实际渲染的值并非设计稿的一半。

px2rem误差

单个节点的场景误差尚可以用四舍五入的逻辑修正,但是在多数值拼接的场景下,误差可能会被放大,导致比对异常。因此需要对px2rem精度进行修正,以避免过多的误差出现。

  • 模块结构
📦 css-hacker
├─ main-hacker.ts      # 主流程编排
├─ dom-parser.ts       # 解析页面样式表
├─ css-fetcher.ts      # 获取 CSS 内容
├─ css-modifier.ts     # 修改 CSS 内容
├─ css-injector.ts     # 注入修改后的 CSS
├─ convert-px2rem-deviation.ts  # rem 单位转换插件
└─ types.ts            # 类型定义
  • 核心流程
graph TD
    A[开始] --> B[解析页面获取外部样式表 URL]
    B --> C{有外部样式表?}
    C -->|否| D[结束]
    C -->|是| E[批量获取 CSS 内容]
    E --> F[遍历每个 CSS 文件]
    F --> G[使用 modifier 修改内容]
    G --> H[注入修改后的 CSS]
    H --> I[删除原始 link 标签]
    I --> J{还有文件?}
    J -->|是| F
    J -->|否| K[完成]
  • 核心逻辑:

通过px2rem逻辑,构建一个1-2000数值的哈希Map,key为rem的值,value为原px值。

通过劫持页面CSS文件,用一个自定义的postcss插件,配合预配置的Map,将px值反向映射为设计稿原值。

// 单例1-2000映射Map
class PxConvertMapSingleton {
  private static instance: PxConvertMapSingleton
  private convertMap: Map<number, number>

  private constructor() {
    this.convertMap = this.generateConvertMap()
  }

  public static getInstance(): PxConvertMapSingleton {
    if (!PxConvertMapSingleton.instance) {
      PxConvertMapSingleton.instance = new PxConvertMapSingleton()
    }
    return PxConvertMapSingleton.instance
  }

  private convertPx(originPxValue: number): number {
    const originRemValue = originPxValue / 750 * 10
    const remValue = Math.round(originRemValue * 100) / 100
    return Math.round(remValue * 37.5 * 1000) / 1000
  }

  private generateConvertMap(): Map<number, number> {
    const startValue = 1
    const endValue = 2000

    const entries = Array.from({ length: endValue - startValue + 1 }).map((_, index) => {
      const curValue = index + startValue
      if (curValue === 1) {
        return [1, 1] as const
      }

      return [this.convertPx(curValue), curValue] as const
    })

    return new Map(entries)
  }

  public getConvertMap(): Map<number, number> {
    return this.convertMap
  }
}

export function getPxConvertMap(): Map<number, number> {
  return PxConvertMapSingleton.getInstance().getConvertMap()
}

再通过设计稿数据处理方案转换成前端页面实际的px值,最终形成新的css样式表注入到HTML中。

const remRegex = /(\d+(?:\.\d+)?|\.\d+)rem/gi
/** 处理CSS属性值中的rem单位,转换为px */
export function processRemInValue(value: string, baseFontSize: number): string {
  if (!value || !value.includes('rem')) {
    return value
  }
  const convertMap = getPxConvertMap()
  return value.replace(remRegex, (_match, remValue) => {
    // rem值
    const convertedRemValue = Number(remValue)
    // 四舍五入取4位精度
    const convertedPxValue = Math.round(convertedRemValue * baseFontSize * 10000) / 10000
    const designValue = convertMap.get(convertedPxValue)

    if (!designValue) {
      return `${convertedRemValue}rem`
    }
    return `${convertDesignToPx(designValue)}px`
  })
}

// post-css插件
export const convertPx2RemDeviation: PluginCreator<Px2RemDeviationOptions> = (opts) => {
  const combinedOptions = { ...(opts || {}), ...defaultOptions }
  const { baseFontSize } = combinedOptions

  return {
    postcssPlugin: 'convert-px2rem-deviation',

    Rule(rule: Rule) {
      rule.walkDecls((decl: Declaration) => {
        const value = decl.value

        if (!value || !value.includes('rem')) {
          return
        }

        const newValue = processRemInValue(value, baseFontSize)
        decl.value = newValue
      })
    },
  }
}

convertPx2RemDeviation.postcss = true

export default convertPx2RemDeviation

  • 为什么不通过computedStyle进行反向映射?

    原因在于computedStyle获取到的值无法通过1-2000的Map直接进行映射,比如一个box-sizing:border-box的div节点,需要面临三种场景:

    • 如果其宽度是直接设置的,则需要拆解成paddingborderwidth三部分分别进行映射。
    • 如果其宽度是撑满父容器的,则需要受控于其父容器的宽度。
    • 如果是被子节点撑开的,则又需要分别计算子节点的宽度进行组合。

    其整体判断逻辑复杂程度高,难以理清。所以采用css注入的方式从css文件层面去解决精度问题,是当前最优解。

Dom节点与UI节点的匹配

节点匹配策略为一块较为独立且完整的模块,由于时间、人力等一些问题,目前的节点匹配方案为常规的IOU匹配方案,匹配准确率约为40%~60%左右,有较高的优化空间,并将在后续的开发中探索匹配层面的优化方案,以提高匹配率和检测准确度。

基本匹配策略

节点匹配算法采用欧几里得距离计算的方式,综合考虑节点的位置和尺寸信息:

  • 位置距离:计算两个节点中心点的欧几里得距离
  • 尺寸距离:计算两个节点宽高的欧几里得距离
  • 综合距离:通过加权平均得到最终匹配分数

距离计算公式

// 位置距离计算
const positionDistance = Math.sqrt(
  (x - mgX)  2 + (y - mgY)  2
)
 
// 尺寸距离计算
const sizeDistance = Math.sqrt(
  (width - mgWidth)  2 + (height - mgHeight)  2
)
 
// 综合距离(位置权重 0.7,尺寸权重 0.3)
const totalDistance = positionDistance  0.7 + sizeDistance  0.3
 

匹配阈值机制

  • 最大可接受距离:MAX_ACCEPTABLE_DISTANCE = 100
  • 只有当综合距离小于阈值时,才会被认为是有效匹配
  • 在所有有效匹配中选择距离最小的作为最终匹配结果

算法流程

匹配算法流程

部分区域UI比对逻辑

上述的比对逻辑虽是比较通用的逻辑,但是在初版实现时,以全页面的比对逻辑为主。但是在实际业务开发过程中,对页面部分区域进行修改的需求数量是多余构建全新页面的需求数量的。因此部分区域比对逻辑是比较关键的一环。

这部分开发的重点在于如何快速简单地让用户选中需要比对的区域?

可以通过chrome插件中的devtools相关功能来实现。

chrome插件通信时序图

sequenceDiagram
    participant User as 👤 用户
    participant DevTools as 🔧 DevTools Panel
    participant BG as 🎯 Background Script
    participant CS as 📄 Content Script
    participant Page as 🌐 被检查页面

    Note over DevTools,BG: 1. 初始化阶段
    User->>DevTools: 打开 DevTools 面板
    DevTools->>BG: connect({ name: 'UI_DIFF_PANEL' })
    activate BG
    BG-->>DevTools: Port 连接成功
    DevTools->>BG: PORT_REGISTER<br/>{ tabId: 123 }
    Note right of BG: 存储 DevTools Port<br/>portMap[123] = port
    BG-->>DevTools: 注册成功
    deactivate BG

    Note over CS,BG: 2. Content Script 准备
    User->>Page: 打开/刷新页面
    CS->>CS: 初始化 UI Diff 功能
    
    Note over User,Page: 3. 用户选择元素
    User->>DevTools: 在 Elements 面板<br/>点击选中元素
    Note right of DevTools: 元素被赋值给 $0
    
    Note over CS,Page: 4. 获取选中元素流程
    CS->>BG: 发送消息<br/>GET_ELEMENT_SELECTOR<br/>{ tabId: 123 }
    activate BG
    BG->>BG: 查找对应的 DevTools Port<br/>portMap[123]
    BG->>DevTools: 转发消息<br/>GET_ELEMENT_SELECTOR
    deactivate BG
    
    activate DevTools
    DevTools->>Page: inspectedWindow.eval(<br/>getElementSelector($0)<br/>)
    activate Page
    Page->>Page: 遍历 DOM 树<br/>构建 CSS Selector
    Page-->>DevTools: 返回 Selector 字符串<br/>"#app > div.container"
    deactivate Page
    
    DevTools->>BG: RETURN_ELEMENT_SELECTOR<br/>{ tabId, selector }
    deactivate DevTools
    
    activate BG
    BG->>BG: 查找对应的标签页
    BG->>CS: chrome.tabs.sendMessage<br/>{ selector: "..." }
    deactivate BG
    
    activate CS
    CS->>CS: 使用 Selector 定位元素<br/>document.querySelector(selector)
    CS->>Page: getBoundingClientRect()
    Page-->>CS: { x, y, width, height }
    CS->>CS: 执行 UI Diff 对比逻辑
    deactivate CS

    Note over User,Page: 5. 完成对比
    CS-->>User: 显示对比结果

获取用户当前在element标签下选中的元素

主要通过chrome插件的chrome.devtools.inspectedWindow.eval方法实现。

function handleGetSelectedElement() {
  const getSelectorExpression = `
      (function() {
      // ...生成selector逻辑
        // $0 是 DevTools 中当前选中的元素
        return getElementSelector($0);
      })()
    `
  // 使用 chrome.devtools.inspectedWindow.eval 在页面上下文中执行
  chrome.devtools.inspectedWindow.eval(
    getSelectorExpression,
    (result, exceptionInfo) => {
      // 将获取到的selector发送给background
      const responseMsg: ChromeListenerMessageType = {
        type: ChromeMessageType.RETURN_ELEMENT_SELECTOR,
        data: {
          tabId: chrome.devtools.inspectedWindow.tabId,
          selector: result,
        },
      }
      backgroundPort?.postMessage(responseMsg)
    },
  )
}

生成对应节点的selector逻辑

通过生成对应节点的selector,将结果经由background层发送给content_script(插件主体),在content_script中通过document.querySelector(${selector})重新获取到用户选中的节点。

function getElementSelector(element) {
  if (!element) {
    return null
  }

  // 如果是 document,返回 html
  if (element === document.documentElement) {
    return 'html'
  }

  // 如果元素有 id,使用 id
  if (element.id) {
    return `#${element.id}`
  }

  // 构建 CSS Selector
  const path = []
  let current = element

  while (current && current.nodeType === Node.ELEMENT_NODE) {
    let selector = current.nodeName.toLowerCase()

    // 添加类名(如果有)
    if (current.className && typeof current.className === 'string') {
      const classes = current.className.trim().split(/\\s+/).filter(c => c)
      if (classes.length > 0) {
        selector += `.${classes.join('.')}`
      }
    }

    // 计算同级元素的位置(如果需要)
    if (current.parentNode) {
      const siblings = Array.from(current.parentNode.children).filter(
        sibling => sibling.nodeName === current.nodeName,
      )

      if (siblings.length > 1) {
        const index = siblings.indexOf(current) + 1
        selector += `:nth-of-type(${index})`
      }
    }

    path.unshift(selector)
    current = current.parentNode

    // 如果到达了有 id 的父元素,可以停止
    if (current && current.id) {
      path.unshift(`#${current.id}`)
      break
    }
  }

  return path.join(' > ')
}

写在最后

这套方案从最初的想法到现在的实现,其实走了不少弯路。最开始我们也想过用图像识别,毕竟看起来很"AI"很"高级",但实际跑下来发现根本不靠谱——设计稿改个文案、换个颜色,算法就得重新训练。后来才想明白,既然设计稿和DOM都是结构化数据,为什么不直接对比结构呢?

整个方案的核心其实就做了一件事:把两个看起来完全不同的东西(设计稿的JSON和HTML的DOM树),通过一系列归一化处理,变成可以直接比对的同构数据。这个过程中最大的感受是,前端开发和UI设计之间的gap,本质上是两套不同的渲染规则在互相较劲。CSS的margin折叠、行高计算、盒模型,每一个细节都可能让设计稿"看起来一样"的两个元素在代码层面完全不同。

说实话,当前40%-60%的匹配准确率还远谈不上完美,但至少让我们看到了一个方向:自动化UI走查的关键不在于追求百分百的准确,而在于建立一套可量化、可追溯的比对标准。就像单元测试不能保证代码零bug,但能让我们对代码质量有个基本的信心。

更重要的是,这套方案让我们重新思考了前端工程化的本质。过去我们总说要提效、要自动化,但往往只盯着代码构建、打包部署这些环节。UI还原度这件事一直是个"玄学"——全凭开发的经验和责任心。现在我们把它量化了、标准化了,这意味着整个研发流程可以往前再推一步:不仅要保证代码能跑,还要保证UI能过。

当然,技术方案永远只是工具。真正能提升团队协作效率的,是大家对质量的共识。这套自动化走查如果只是用来"抓bug",那价值就太有限了。我更希望它能成为一面镜子,让前端看到自己对设计的理解偏差,让设计看到自己稿子的不规范之处,最终推动双方在规范上达成更多共识。

技术的意义从来不在于解决所有问题,而在于让问题变得可见、可量化、可改进。

从 Element UI 到 Element Plus:el-table 大数据量性能为何下降了?

2025年11月21日 10:33

很多开发者在使用 Element Plusel-table 渲染大量数据时遇到的一个常见问题:在数据量较大(比如几千行)时,滚动变得非常卡顿,而 Element UI 的 el-table 在相同数据量下却相对流畅。

有人提到:“它们本质不都是渲染成为了 DOM 元素吗?” —— 这个理解从表面上看是对的,两者最终都是通过渲染 DOM 来展示表格内容,但实际上它们在实现机制、虚拟化策略、框架底层优化、以及 Vue 2 与 Vue 3 的差异上都有很大不同,这些因素综合起来就可能导致性能表现上的显著差异。

下面我们从多个角度来分析为什么 Element Plus 的 el-table 在大数据量下更卡顿,而 Element UI 的相对好一些


一、Vue 2 与 Vue 3 的底层差异

✅ Element UI 基于 Vue 2

  • Vue 2 使用的是 Object.defineProperty 实现响应式,虽然在大规模列表上也有性能问题,但在某些场景下(如静态内容较多、更新不频繁)表现还比较可控。
  • Vue 2 的虚拟 DOM diff 算法相对简单,对于某些“不太变”的大列表,浏览器可能还能“撑住”。

❗ Element Plus 基于 Vue 3

  • Vue 3 使用 Proxy 实现响应式,带来了更强大的功能,但在极端数据量下(比如几万行数据的表格),Proxy 的监听机制可能带来额外的性能负担
  • Vue 3 的虚拟 DOM 和渲染机制虽然更先进,但对于超长列表且没有做优化(比如虚拟滚动)的组件,仍然会一次性创建大量的 DOM 节点,导致页面卡顿。

总结:Vue 3 虽然整体性能更好,但在极端场景下(比如超长列表 + 大量响应式数据),如果没有做特别优化,反而更容易出现性能瓶颈。


二、Element Plus 的 el-table 是否实现了虚拟滚动?

❌ 默认情况下,Element Plus 的 el-table 并没有内置虚拟滚动(Virtual Scrolling)支持

  • 也就是说,当你给 el-table 传入几万条数据时,它会一次性渲染所有的 <tr> 行和单元格 <td>,导致页面中有成千上万个 DOM 节点,即使你只看到了其中一小部分。
  • 滚动时,浏览器需要维护大量 DOM,计算样式、布局、重绘等,自然会非常卡顿。

✅ Element UI 的 el-table 也没有官方虚拟滚动,但:

  • 在某些数据量不是极端大的情况下(比如 1000~3000 行),Element UI 的实现方式可能在特定 Vue 2 环境下性能表现还凑合
  • 另外,社区对 Element UI 的优化实践(比如手动分页、减少响应式开销等)也更多,使得在某些场景下“看起来”更流畅。

三、Element Plus 官方已意识到该问题,并提供了优化方向

Element Plus 团队明确知道 el-table 在大数据量下的性能问题,并且有以下几种推荐方案:

1. 使用虚拟滚动表格(推荐)

Element Plus 目前官方没有直接内置虚拟滚动功能的 el-table,但:

  • 官方推荐对于大数据量的表格,使用 虚拟化表格方案,比如:

    • 社区提供的基于虚拟滚动的表格组件,例如 @tanstack/vue-table (原 vue-tables-3) 或者 vxe-table,这些表格组件原生支持虚拟滚动,可以轻松渲染 10万+ 数据且不卡顿
    • 或者等待 Element Plus 官方后续可能推出的虚拟化 Table(目前尚未默认内置)。

2. 手动优化:分页 or 动态渲染

如果暂时无法引入第三方库,可以考虑以下优化手段:

  • 使用分页(Pagination):这是最有效、最简单的减少 DOM 数量的方式,不要一次性加载所有数据。
  • 动态加载/懒加载表格内容:只渲染当前可视区域内的行,滚动时动态替换数据(也就是自己实现或借助虚拟滚动库)。
  • 减少不必要的响应式数据:避免在表格的每一行/每一列绑定过多复杂计算属性或深层响应式对象。

四、为什么“都是 DOM,但一个卡一个不卡”?

你提到:“它们本质不都是渲染成为了 DOM 元素吗?

✅ 是的,最终都是 DOM,但关键在于:

对比项 Element UI (Vue 2) Element Plus (Vue 3)
数据量较大时是否虚拟化 ❌ 否 ❌ 否
默认渲染所有数据行的 DOM ✅ 是(几万行就是几万个 <tr> ✅ 是(同样会渲染全部)
Vue 响应式机制开销 相对较小(Object.defineProperty) 较大(Proxy,尤其大量数据时)
虚拟滚动支持 ❌ 无官方,但某些场景凑合能跑 ❌ 无官方内置,卡顿更明显
浏览器 DOM 操作性能瓶颈 1000~3000 行可能还行 500~1000 行就开始卡了

所以,当数据量一旦变大(比如超过 1000 行,尤其是 3000~10000 行),el-table 如果没有虚拟滚动,就会因为渲染大量 DOM 节点而导致页面卡顿,无论 Vue 2 还是 Vue 3。只是 Vue 3 + Element Plus 的组合,在极端情况下可能性能下降更明显。


五、解决方案建议

✅ 推荐方案:使用支持虚拟滚动的表格组件

如果你必须渲染大量数据(比如 1万行 ~ 10万行)且要求流畅滚动,强烈建议:

  1. 使用专门支持虚拟滚动的表格组件,例如:

    这些表格组件在内部实现了只渲染可见区域的行(即虚拟滚动),因此即使数据量极大,也能保持流畅。

  2. 如果一定要用 Element Plus 的 el-table:

    • 请务必使用 分页(Pagination),不要一次性加载太多数据。
    • 如果想实现“无限滚动”或“懒加载”,可以结合 el-table + 自己控制数据加载逻辑,但需要自行实现虚拟化(较复杂)。
    • 减少表格中复杂的插槽、自定义渲染、多层嵌套等,尽量让每行 DOM 更轻量。

六、总结

问题 原因 解决方案
Element Plus 的 el-table 滚动卡顿,Element UI 的相对好点 1. Vue 3 + Proxy 响应式开销更大 2. 两者都未默认实现虚拟滚动 3. 大量 DOM 导致渲染/滚动性能瓶颈 1. 使用虚拟滚动表格(如 VxeTable、TanStack Table) 2. 减少单次渲染数据量,使用分页 3. 避免复杂单元格渲染,优化表格结构
为什么都是 DOM,但一个卡一个不卡 虽然最终都生成 DOM,但数据量大时,未虚拟化的表格会渲染成千上万的 DOM 节点,导致浏览器卡死 虚拟滚动只渲染可见区域 DOM,极大提升性能

🔧 如果你暂时不想换组件,可以尝试以下小优化:

  1. el-table 设置固定高度,启用内部滚动(而不是整个页面滚动)。
  2. 减少 el-table-column 中使用复杂模板或自定义组件。
  3. 使用 v-if 控制非必要列的显示。
  4. 开启 :row-key 属性,帮助 Vue 更好地追踪节点(但对超大数据量帮助有限)。
  5. 尽可能使用 分页 而非一次性加载全部数据。

🧠 拓展阅读 / 工具推荐


✅ 结论:

Element Plus 的 el-table 在大数据量下比 Element UI 更卡顿,主要原因是:两者默认都不支持虚拟滚动,但 Element Plus 基于 Vue 3,在极端数据量下响应式和渲染性能压力更大。要解决此问题,最佳实践是使用支持虚拟滚动的表格组件,或者对数据进行分页/懒加载处理。

如你目前项目允许,强烈建议 迁移到类似 VxeTable 这样支持虚拟化、高性能的表格组件,它能真正帮你实现“几万行数据,滚动如丝滑”的效果 😄。

Flutter Aop 面向切面编程 aspect_frontend_server 前世今生

作者 孤鸿玉
2025年11月21日 10:30

面向切面编程在java中是很常见的运用,它可以很容易的在不改动已有代码的情况下加入自己的代码逻辑。 一些jdk核心代码,我们没有办法直接编辑,也可以通过这种方式进行修改。在Java中做到这些很容易,因为Java有一整套反射机制。

在Flutter中,做到这些就有些困难,为了更好的性能,Flutter中的Dart阉割了它的反射功能。

在过往的时间里也出现了一些可以用来做Flutter Aop的工具:

1.XianyuTech/aspectd

最早是闲鱼团队发起的,但是已经很久没有更新,SDK支持版本仍然停留在2.5。

2.Beike_AspectD

贝壳团队在Aspectd基础上进行了改造和适配,但最新SDK也只支持到3.10.5

3.sa_flutter_aspectd

神策团队基于aspectd做的一些改造,专门为它们信息采集做服务,但更新记录只停留在2022年。

4.aspect_frontend_server

它是我在得知aspectd不支持2.5后续版本后,借鉴了aspectd代码,并重写的一套Flutter Aop面向切面编程框架,从2021年开始更新一直至今,已经支持最新的flutter 3.38.1。

aspect_frontend_server虽然是借鉴了aspectd代码的代码,但可以说是完全重写,它去掉了很多原本aspectd中的功能,只保留了最基本的aop功能,也就是拦截某个方法的运行,可以在它运行的前后插入自己的运行逻辑。设置上完全替换到原有逻辑。

aspect_frontend_server的集成方法也很简单。只需要替换到原本flutter中的frontend_server_aot.dart.snapshot文件,不需要引入任何额外的库。

通过aspect_frontend_server写入一个注入例子也很简单,下面是一个简单的例子:

在mian.dart文件中我们写入:

import 'inject.dart';

在injiect.dart中写入:

@pragma('vm:entry-point')
@pragma("aopd:inject", {
    //注入的包名
    "importUri": "package:flutter/src/gestures/binding.dart",
    //注入的类名
    "clsName": "GestureBinding",
    //注入的方法名
    "methodName": "-dispatchEvent",
    //是否符号匹配
    "isRegex": false
})
//必须是static,不然不起作用
static dynamic dispatchEvent(
    Object target,
    String functionName,
    List<dynamic> positionalParams,
    Map<dynamic, dynamic> namedParams,
    Function proceed) {
        PointerEvent event = positionalParams[0];
        HitTestResult? hitTestResult = positionalParams[1];
        debugPrint('dispatchEvent - start ${event.kind.name}');
        return proceed.call(event, hitTestResult);
}

上面的例子可以拦截所有Flutter底层传递给应用层的触摸事件,如果我们需要分析用户的行为,通过在这个函数上插桩就可以很容易实现。

aspect_frontend_server的用法还有很多,例如静态方法插桩,mixin插桩,extension插桩等等。可以自己下载项目运行example了解。

aspect_frontend_server相对于aspectd有很大不同:

  1. aspectd不支持flutter 2.5.4以上,本项目最高支持到2.38.1
  2. aspectd使用前需要对flutter tools的代码进行修改,本项目只需要替换flutter sdk对应的frontend_server.dart.snapshot即可
  3. aspectd的实现原理过于复杂,本项目去掉了Call,Inject等用法保留了Execute用法的同时对注入逻辑进行了简化
  4. aspectd还需要aspect_impl等,本项目可以直接在主程序代码中添加注入代码,也可以用plugin的方式添加
  5. 本项目不需要引入任何第三方包,用pragma注解完成对应插桩
  6. 支持hot restart,aspectd的注入代码修改后,需要重启程序才能看到改变,这样很不利于差桩代码的编写,aspect_frontend_server的差桩代码在改变后可以直接通过hot restart就能看到最新的改变。

在维护aspect_frontend_server过程中也碰到了一些bug,感谢反馈bug的小伙伴。目前已知的问题已经修复:

  1. extionsion和mixin方法注入
  2. 实现try-catch的注入
  3. 实现getter方法的注入
  4. 解决函数返回值为Futnre 范型报错的问题
  5. 解决编译添加–obfuscate混淆出错的问题
  6. 解决isRegex参数某些情况不起作用的bug等等

希望有更多的小伙伴使用并反馈问题,我会一直将这个项目维护下去。

JavaScript 异步管理的一种实践

作者 温宇飞
2025年11月21日 09:05

在实际项目中,JavaScript 异步编程往往缺乏统一的管理规范,导致代码难以维护。这篇文章分享一套实践方案:以 Promise/async-await 作为统一的异步编程基础,通过禁止 Floating Promises 来确保所有 Promise 都得到妥善处理。在这个基础上,我们需要区分两种不同的异步执行方式——Fire-and-Forget 和 Await,并分别管理它们。对于前者,用 fireAndForget 工具函数明确标记意图;对于后者,通过 AbortSignal 实现取消控制,同时正确处理 AbortError 以免将其误当作业务异常。最后,借助 Lint 规则来约束团队的编码习惯,避免写出有问题的代码。

统一异步编程方案

要谈异步管理,得先从统一方案说起。如果团队里有人用 Callback,有人用 Promise,还有人在用 Generator,代码就会变得很难维护。这一节我们先回顾一下 JavaScript 异步方案的演进历程,看看为什么要避免 Callback、为什么不推荐用 Generator 处理异步,以及为什么 Promise/async-await 是更好的选择。

JavaScript 异步方案演进

JavaScript 异步编程的发展经历了几个重要阶段,每一代方案都在解决上一代的问题:

1. Callback

最早的异步解决方案,通过函数参数传递回调:

// Callback 方式
fs.readFile('file.txt', (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  console.log(data)
})

2. Promise

ES6 引入,解决回调地狱问题,提供链式调用:

// Promise 方式
fetch('/api/data')
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error))

3. Generator

ES6 同期引入的特性,配合 Promise 可以实现类似同步的异步代码:

// Generator 方式(需要执行器运行)
function* fetchData() {
  const response = yield fetch('/api/data')
  const data = yield response.json()
  return data
}

但 Generator 的本意是生成值序列(Iterator),不是为异步设计的。

4. async/await

ES2017 引入,专为异步设计的语法糖:

// async/await 方式
async function fetchData() {
  const response = await fetch('/api/data')
  const data = await response.json()
  return data
}

为什么提出 async/await?

你可能会问,既然 Generator 能做异步,为什么还要 async/await?其实 Generator 本质上是为迭代器设计的,yield 的语义是"生成下一个值",用来处理异步只是一种巧妙的借用。而 async/await 是专门为异步设计的:

  • 语义更明确:await 就是"等待异步操作",一看就懂
  • 原生支持,不需要额外的执行器库
  • 错误处理更简单:直接用 try/catch 就行
  • 和现代异步控制(如 AbortSignal)配合得更好

避免使用 Callback 处理新的异步操作

为什么要避免 Callback?

Callback 是最早的异步方案,但问题也很明显:

  • 回调地狱(Callback Hell):多层嵌套让代码变成"圣诞树",难读又难改
  • 错误处理繁琐:每个回调都要单独处理错误,稍不留神就漏掉了
  • 无法取消:操作一旦启动就停不下来

示例对比:

// ❌ Callback 嵌套
getData(id, (err, data) => {
  if (err) return handleError(err)
  processData(data, (err, result) => {
    if (err) return handleError(err)
    saveResult(result, (err, saved) => {
      if (err) return handleError(err)
      console.log('Success')
    })
  })
})

// ✅ async/await
try {
  const data = await getData(id)
  const result = await processData(data)
  await saveResult(result)
  console.log('Success')
} catch (err) {
  handleError(err)
}

为什么不能完全禁止 Callback?

不过也不是说要把 Callback 赶尽杀绝,有些场景确实离不开它:

  • DOM 事件监听:addEventListener 必须传回调函数
  • 数组方法:mapfilterforEach 也需要回调

如何避免使用 Callback?

方案 1:Promise 包装遗留 API

// 遗留的 Callback API
function legacyApi(param, callback) {
  /* ... */
}

// ✅ Promise 包装
function modernApi(param) {
  return new Promise((resolve, reject) => {
    legacyApi(param, (err, result) => {
      if (err) reject(err)
      else resolve(result)
    })
  })
}

// 使用
const result = await modernApi('value')

方案 2:使用 signal-timers 替代原生定时器

原生的 setTimeoutsetInterval 也是基于 Callback 的,而且取消起来很麻烦。signal-timers 这个库可以让定时器返回 Promise,还支持 AbortSignal:

import { setTimeout } from 'signal-timers'

// ❌ 原生 setTimeout(Callback + 无法取消)
setTimeout(() => {
  console.log('Timeout')
}, 1000)

// ✅ signal-timers(Promise + 可取消)
await setTimeout(1000, { signal })
console.log('Timeout')

禁止使用 Generator 处理异步

为什么要禁止 Generator 处理异步?

虽然 Generator 理论上能处理异步,但并不推荐:

  • 语义不清晰:看到 yield 会以为在生成值,其实是在等异步,容易混淆
  • 不是为异步设计:Generator 本来是用来做迭代器的,处理异步只是"借用"
  • 已有更好方案:async/await 是专门为异步设计的,何必舍近求远?

反例和正例对比

// ❌ 禁止:用 Generator 处理异步
function* fetchData() {
  const response = yield fetch('/api/data')
  const data = yield response.json()
  return data
}

// ✅ 推荐:用 async/await 处理异步
async function fetchData() {
  const response = await fetch('/api/data')
  const data = await response.json()
  return data
}

通过 ESLint 禁止 Generator 处理异步

可以使用 no-restricted-syntax 规则禁止异步 Generator:

// eslint.config.js
export default [
  {
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector: 'FunctionDeclaration[async=true][generator=true]',
          message: '禁止使用异步 Generator 函数',
        },
        {
          selector: 'FunctionExpression[async=true][generator=true]',
          message: '禁止使用异步 Generator 表达式',
        },
      ],
    },
  },
]

例外:Generator 用于迭代器是正确的

Generator 回归本职工作——生成值序列,这才是它的正确打开方式:

// ✅ 正确的 Generator 用法:迭代器
function* range(start, end) {
  for (let i = start; i < end; i++) {
    yield i
  }
}

// 使用
for (const num of range(1, 5)) {
  console.log(num) // 1, 2, 3, 4
}

// ✅ 正确的 Generator 用法:无限序列
function* fibonacci() {
  let [a, b] = [0, 1]
  while (true) {
    yield a
    ;[a, b] = [b, a + b]
  }
}

推荐使用 Promise/async-await

为什么推荐 Promise/async-await?

说了这么多不推荐的,那推荐什么?答案是 Promise/async-await:

  • 代码清晰:写起来就像同步代码一样,一眼能看懂
  • 错误处理简单:统一用 try/catch,不用到处写错误处理
  • 可组合Promise.allPromise.race 这些组合器很好用
  • 可取消:配合 AbortSignal 可以随时取消操作

标准的异步函数模式

// ✅ 标准异步函数模式
async function fetchData(id, signal) {
  try {
    const response = await fetch(`/api/data/${id}`, { signal })
    const data = await response.json()
    return data
  } catch (error) {
    // 记录错误
    console.error('Failed to fetch data:', error)
    // 重新抛出,让调用方处理
    throw error
  }
}

统一的错误处理

// ✅ 统一错误处理
async function processUserData(userId) {
  try {
    const user = await fetchUser(userId)
    const orders = await fetchOrders(userId)
    const result = await processData(user, orders)
    return result
  } catch (error) {
    // 区分取消错误和业务错误
    if (error.name === 'AbortError') {
      console.log('操作已取消')
      return null
    }
    // 记录错误并重新抛出
    logger.error('处理用户数据失败', { userId, error })
    throw error
  }
}

并行操作

// ✅ 使用 Promise.all 并行执行
async function loadDashboard(signal) {
  const [user, orders, stats] = await Promise.all([
    fetchUser(signal),
    fetchOrders(signal),
    fetchStats(signal),
  ])

  return { user, orders, stats }
}

// ✅ 使用 Promise.race 竞速
async function fetchWithTimeout(url, timeout) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Timeout')), timeout),
    ),
  ])
}

禁止 Floating Promises

统一用 Promise/async-await 只是第一步,还得确保每个 Promise 都被妥善处理。如果一个 Promise 创建后不做任何事情,就会"漂浮"在代码里,导致各种奇怪的问题。这一节我们来聊聊 Floating Promise 是什么、有什么危害,以及如何用 @typescript-eslint/no-floating-promises 规则来杜绝它。

什么是 Floating Promise

简单说,Floating Promise 就是那些创建后没人管的 Promise——既不 await,也不用 .then/.catch 处理。它们就这么"漂"在代码里,出了问题也没人知道。

// ❌ Floating Promise 示例
async function saveData(data) {
  fetchUser() // 返回 Promise,但没有被处理
  processData(data) // 返回 Promise,但没有被处理
  console.log('Done')
}

// 执行流程:
// 1. fetchUser() 启动(返回 Promise)
// 2. processData() 启动(返回 Promise)
// 3. 立即输出 'Done'
// 4. fetchUser() 和 processData() 可能还在执行
// 5. 如果它们失败,错误不会被捕获

在这个例子中,fetchUser()processData() 返回的 Promise 没有被 awaitreturn 或使用 .then()/.catch() 处理,就是 Floating Promise。

Floating Promise 的危害

Floating Promise 看起来无害,实际上会带来不少麻烦。

1. 错误被忽略,导致静默失败

最常见的问题就是错误被"吞"了,用户以为操作成功了,其实早就失败了。

// ❌ 问题:保存失败但没有任何提示
async function saveUser(user) {
  database.save(user) // 如果保存失败,错误被完全忽略
  showSuccessMessage('保存成功')
}

// 实际运行:
// - database.save() 返回 Promise 但没有等待
// - 如果 Promise reject,错误不会被捕获
// - 用户看到"保存成功",但数据可能保存失败

2. 时序不确定,导致竞态条件

// ❌ 问题:通知可能在更新完成前发送
async function updateAndNotify(userId, data) {
  updateUser(userId, data) // 没有 await
  sendNotification(userId, '更新成功') // 立即执行
}

// 执行顺序:
// 1. updateUser() 开始执行(返回 Promise)
// 2. sendNotification() 立即执行
// 3. updateUser() 可能还在进行中
// 结果:用户收到"更新成功",但数据还未更新

3. 难以调试,调用栈丢失

// ❌ 问题:错误发生时难以追溯来源
function processData() {
  fetchData().then((data) => {
    throw new Error('处理失败')
  })
  // 没有 .catch(),错误不会被捕获
}

// 控制台输出:
// Uncaught (in promise) Error: 处理失败
//     at <anonymous>
//
// 问题:调用栈没有显示是在 processData() 中出错

4. 资源泄漏

// ❌ 问题:未处理的 Promise 可能持有资源引用
function startPolling() {
  setInterval(() => {
    fetchData() // 每次都创建新的 Floating Promise
      .then((data) => updateUI(data))
    // 如果没有 .catch(),错误会累积
  }, 1000)
}

// 问题:
// - 每秒创建新的 Promise,但错误没有被处理
// - 可能导致内存泄漏
// - 错误累积可能影响性能

如何禁止 Floating Promise

@typescript-eslint/no-floating-promises 这个规则专门用来检测 Floating Promise。它要求所有 Promise 必须用以下三种方式之一处理:

  1. await 等待 - 等它完成再继续
  2. return 返回 - 让调用方去处理
  3. .then()/.catch().finally() 处理 - 链式处理结果和错误

配置规则

// eslint.config.js
export default [
  {
    rules: {
      '@typescript-eslint/no-floating-promises': [
        'error',
        {
          ignoreVoid: false, // 禁止使用 void 标记 Promise
        },
      ],
    },
  },
]

代码示例

// ❌ 错误:直接调用异步函数,没有处理返回的 Promise
async function bad1() {
  fetchData() // Error: Promises must be awaited, end with a call to .catch, or end with a call to .then with a rejection handler
}

// ❌ 错误:只有 .then() 没有 .catch(),rejection 没有被处理
async function bad2() {
  fetch('/api/data')
    .then((res) => res.json())
    .then((data) => console.log(data)) // Error
}

// ✅ 正确:方式 1 - 使用 await
async function good1() {
  await fetchData()
}

async function good2() {
  try {
    await fetchData()
  } catch (error) {
    console.error('Failed:', error)
  }
}

// ✅ 正确:方式 2 - 使用 return
async function good3() {
  return fetchData()
}

async function good4(shouldFetch: boolean) {
  if (shouldFetch) {
    return fetchData()
  }
  return null
}

// ✅ 正确:方式 3 - 使用 .then()/.catch()
async function good5() {
  fetchData()
    .then((data) => console.log(data))
    .catch((err) => console.error(err))
}

async function good6() {
  fetchData()
    .then((data) => processData(data))
    .catch((err) => {
      console.error('Error:', err)
      return null
    })
    .then((result) => console.log('Result:', result))
}

可控的异步操作

解决了 Floating Promise 的问题,我们还需要关注异步操作的执行方式。JavaScript 里的异步操作大致分两类:Fire-and-Forget(发出去就不管了)和 Await(等结果)。前者包括了 callback、promise.then.catch 和 floating promise,这些建议明确标记一下,省得别人以为你忘了处理。后者会阻塞代码执行,如果不及时取消容易导致内存泄漏或状态混乱,建议用 AbortSignal 来控制。另外,取消操作会抛出 AbortError,但这不是业务错误,只是个取消标记,别当异常处理。

Fire-and-Forget vs Await

异步操作的执行方式分两种,理解它们的区别很重要。

Fire-and-Forget:发出去就不管了

Fire-and-Forget 就是发起一个异步操作,然后立刻继续往下走,不等结果。callback、promise.then.catch、还有前面提到的 floating promise 都属于这一类。

// callback 的 Fire-and-Forget
function logToServer(message) {
  sendLog(message, (error) => {
    if (error) {
      console.error('日志发送失败:', error)
    }
  })
  // 立即返回,不等待 sendLog 完成
}

logToServer('用户登录')
console.log('继续执行') // 立即输出

// promise.then.catch 的 Fire-and-Forget
function trackEvent(event) {
  fetch('/api/track', {
    method: 'POST',
    body: JSON.stringify(event),
  })
    .then(() => console.log('埋点发送成功'))
    .catch((error) => console.error('埋点发送失败:', error))
  // 立即返回,不等待 fetch 完成
}

trackEvent({ action: 'click', target: 'button' })
console.log('继续执行') // 立即输出

// floating promise 的 Fire-and-Forget
function sendNotification(message) {
  // 直接调用返回 Promise 的函数,什么都不做
  fetch('/api/notify', {
    method: 'POST',
    body: JSON.stringify({ message }),
  })
  // 立即返回,不等待 fetch 完成
  // 也不处理结果或错误
}

sendNotification('新消息')
console.log('继续执行') // 立即输出

特点:

  • 注册回调后立即返回,不等结果
  • 不会阻塞后面的代码
  • 一旦发出去就收不回来了(回调必然执行)
  • 适合不关心结果的场景,比如日志上报、埋点之类的

Await:等结果

Await 就不一样了,它会等异步操作完成才继续。用 await 的时候,代码会暂停在那里,等操作完成才往下走。

// await 等待异步操作
async function loadUserData(userId, shouldCancel) {
  // await 暂停在这里,等待 fetch 完成
  const response = await fetch(`/api/users/${userId}`)

  // await 完成后,检查是否需要取消
  if (shouldCancel()) {
    return null // 直接返回,后续代码不执行
  }

  // 继续处理数据
  const user = await response.json()

  // 再次检查
  if (shouldCancel()) {
    return null
  }

  return user
}

// 使用
let cancelled = false
const controller = {
  cancel: () => {
    cancelled = true
  },
}

const userData = loadUserData(123, () => cancelled)

// 用户切换页面时取消
controller.cancel()

const user = await userData
if (user) {
  console.log('用户数据:', user)
} else {
  console.log('操作已取消')
}

特点:

  • 会暂停执行,等结果
  • 阻塞后面的代码(直到拿到结果)
  • await 完成后可以检查条件,决定要不要继续
  • 适合需要结果的场景,比如加载数据、处理业务逻辑这些

对比:Fire-and-Forget vs Await

特性 Fire-and-Forget Await
执行方式 同步流程中注册回调,立即返回 异步流程中等待,暂停执行
是否阻塞 不阻塞后续代码 阻塞后续代码,等待结果
是否可取消 不可取消,回调必然执行 可取消,await 完成后可中断
错误处理 在回调中单独处理 统一使用 try-catch 处理
典型实现 callback、promise.then.catch、floating promise async/await
典型使用场景 日志上报、埋点、通知、预加载 数据加载、业务逻辑、API 调用

明确标记 Fire-and-Forget

Fire-and-Forget 虽然合理,但最好明确标记一下。

为什么需要明确标记?

如果直接调用一个返回 Promise 的函数然后就不管了,代码审查的时候别人会懵:这是有意不等待,还是忘了写 await?

// ❌ 代码意图不清晰:是忘记 await 了,还是有意不等待?
function handleClick() {
  fetch('/api/track', {
    method: 'POST',
    body: JSON.stringify({ action: 'click' }),
  })
  // 继续执行其他逻辑
  navigateToNextPage()
}

明确标记可以解决这个问题:

  • 区分有意和疏忽:告诉审查者,我是故意不等的
  • 统一错误管理:集中处理 Fire-and-Forget 的错误
  • 提高可读性:一眼就知道是咋回事

实现方式:fireAndForget 工具函数

/**
 * 明确标记 Fire-and-Forget 操作
 * 用于不需要等待结果的异步操作(日志、埋点等)
 * Fire-and-Forget 不会阻塞后续代码执行,因此不需要抛出 AbortError
 */
function fireAndForget(promise: Promise<unknown>): void {
  promise.catch((error) => {
    // 忽略 AbortError,因为 Fire-and-Forget 不需要取消
    if (error.name === 'AbortError') {
      return
    }
    // 记录其他错误,但不影响主流程
    console.error('Fire-and-Forget 操作失败:', error)
  })
}

// ✅ 明确标记 Fire-and-Forget
function trackEvent(event) {
  fireAndForget(
    fetch('/api/track', {
      method: 'POST',
      body: JSON.stringify(event),
    }),
  )
}

为什么 Await 需要及时取消

Fire-and-Forget 发出去就不管了,Await 可不一样,它会等结果还要执行后续逻辑。如果不及时取消,问题可就大了。

1. 内存泄漏和资源浪费

// ❌ 问题:轮询没有被取消
async function startPolling() {
  while (true) {
    const data = await fetchData()
    updateUI(data)
    await sleep(5000)
  }
}

// 用户离开页面后,startPolling 仍在运行
// 持续消耗网络和内存资源

2. 状态不一致和竞态条件

// ❌ 问题:快速切换时,旧请求可能后完成
async function loadUser(userId) {
  const user = await fetchUser(userId)
  displayUser(user) // 可能显示错误的用户
}

// 执行顺序:
// 1. loadUser(1) 开始
// 2. loadUser(2) 开始
// 3. loadUser(2) 完成,显示用户 2
// 4. loadUser(1) 完成,显示用户 1 ❌ 错误

3. 执行无效操作

// ❌ 问题:组件卸载后仍在执行
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    ;(async () => {
      const data = await fetchUser(userId)
      // 组件可能已卸载,但仍在 setState
      setUser(data) // 警告:Can't perform a React state update on an unmounted component
    })()
  }, [userId])

  return <div>{user?.name}</div>
}

使用 AbortSignal 取消 Await

AbortController 是 Web 标准的取消机制,不少 API 都支持:

  • fetch API:取消网络请求
  • addEventListener:取消事件监听
  • signal-timers:取消定时器(原生 setTimeout 不支持,得用 signal-timers 库)

推荐的做法是:异步函数接受一个 AbortSignal 参数,在每次 await 之后检查一下要不要取消

async function fetchData(url, signal) {
  // 1. 函数开始时检查
  signal?.throwIfAborted()

  // 2. 传递 signal 给支持的 API
  const response = await fetch(url, { signal })

  // 3. 长时间操作中多次检查
  const data = await response.json()
  signal?.throwIfAborted()

  return data
}

// 使用
const controller = new AbortController()

try {
  const data = await fetchData('/api/data', controller.signal)
  console.log(data)
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('操作已取消')
  }
}

// 取消操作
controller.abort()

正确处理 AbortError

AbortError 不是真正的错误,只是个取消信号。千万别把它当业务错误处理,下面几种处理方式可以参考:

不上报 AbortError:取消操作是正常行为,没必要上报到监控系统:

function reportError(error) {
  // 忽略 AbortError
  if (error.name === 'AbortError') {
    return
  }
  // 上报业务错误
  errorMonitoring.report(error)
}

try-catch 中直接返回:碰到 AbortError 就直接返回,不走错误处理流程:

async function loadData(signal) {
  try {
    const data = await fetchData(signal)
    return data
  } catch (error) {
    // AbortError 直接返回,不处理
    if (error.name === 'AbortError') {
      return null
    }
    // 业务错误才需要处理
    console.error('加载数据失败:', error)
    showErrorToast('加载失败')
    throw error
  }
}

全局错误处理中忽略:全局错误处理器应该忽略 AbortError:

// 全局 Promise 错误处理
window.addEventListener('unhandledrejection', (event) => {
  if (event.reason?.name === 'AbortError') {
    // 忽略 AbortError
    event.preventDefault()
    return
  }
  // 处理其他错误
  reportError(event.reason)
})

fireAndForget 中忽略 AbortError:Fire-and-Forget 不会阻塞后续代码执行,因此不需要抛出 AbortError:

function fireAndForget(promise: Promise<unknown>): void {
  promise.catch((error) => {
    // 忽略 AbortError,因为 Fire-and-Forget 不需要取消
    if (error.name === 'AbortError') {
      return
    }
    // 记录其他错误
    console.error('Fire-and-Forget 操作失败:', error)
  })
}

异步编程的规范

除了上面提到的实践,异步编程里还有一些常见的坑需要注意。这一节介绍几个实用的 Lint 规则,它们能帮你避开 Promise 误用、不必要的包装、callback 和 Promise 混用等问题,让异步代码写得更规范。

no-misused-promises (eslint)

规则说明: 禁止在不该用 Promise 的地方用 Promise。

为什么需要这个规则?

Promise 是异步的,在需要同步值的地方用它会出问题:

// ❌ 错误:条件表达式中使用 Promise
if (await fetchData()) {
  // fetchData 返回 Promise,总是 truthy
}

// ❌ 错误:逻辑运算中使用 Promise
const result = fetchData() || defaultValue
// fetchData 返回 Promise 对象,总是 truthy,永远不会使用 defaultValue

// ❌ 错误:在事件处理器中返回 Promise
button.addEventListener('click', async () => {
  await handleClick()
  return true // 返回 Promise<boolean>,而不是 boolean
})

正确做法:

// ✅ 正确:await 后再使用
const data = await fetchData()
if (data) {
  // 使用数据
}

// ✅ 正确:await 后再进行逻辑运算
const data = (await fetchData()) || defaultValue

// ✅ 正确:不返回值,或返回 void
button.addEventListener('click', async () => {
  await handleClick()
  // 不返回值
})

配置:

// eslint.config.js
export default [
  {
    rules: {
      '@typescript-eslint/no-misused-promises': 'error',
    },
  },
]

no-return-wrap (oxlint)

规则说明: 禁止多余的 Promise 包装。

为什么需要这个规则?

async 函数会自动把返回值包装成 Promise,你再手动包一层就多余了:

// ❌ 错误:多余的 Promise 包装
async function fetchData() {
  return Promise.resolve(data) // 会变成 Promise<Promise<T>>
}

async function getData() {
  return new Promise((resolve) => {
    resolve(data) // 多余的包装
  })
}

// ✅ 正确:直接返回值
async function fetchData() {
  return data // 自动包装成 Promise<T>
}

async function getData() {
  return data
}

no-promise-in-callback (oxlint)

规则说明: 别在 callback 里用 Promise。

为什么需要这个规则?

callback 和 Promise 混在一起,错误追踪起来很头疼。统一用 async/await 就好了:

// ❌ 错误:在 callback 中使用 Promise
function processData(data, callback) {
  fetchData()
    .then((result) => {
      callback(null, result)
    })
    .catch((error) => {
      callback(error)
    })
}

// ✅ 正确:使用 async/await
async function processData(data) {
  try {
    const result = await fetchData()
    return result
  } catch (error) {
    throw error
  }
}

prefer-await-to-then (oxlint)

规则说明: 优先用 await,少用 .then()。

为什么需要这个规则?

await 写起来更简洁,错误处理也更统一:

// ❌ 不推荐:使用 .then()
function loadData() {
  return fetchData()
    .then((data) => processData(data))
    .then((result) => saveData(result))
    .catch((error) => handleError(error))
}

// ✅ 推荐:使用 await
async function loadData() {
  try {
    const data = await fetchData()
    const result = await processData(data)
    await saveData(result)
  } catch (error) {
    handleError(error)
  }
}

prefer-promise-reject-errors (oxlint)

规则说明: reject 的时候必须传 Error 对象。

为什么需要这个规则?

Error 对象会保留堆栈信息,调试的时候能快速定位问题:

// ❌ 错误:reject 字符串
Promise.reject('Error occurred')

async function fetchData() {
  if (!valid) {
    throw 'Invalid data' // 也是错误
  }
}

// ✅ 正确:reject Error 对象
Promise.reject(new Error('Error occurred'))

async function fetchData() {
  if (!valid) {
    throw new Error('Invalid data')
  }
}

require-await (oxlint)

规则说明: async 函数里必须有 await 或者返回 Promise。

为什么需要这个规则?

如果一个 async 函数里连 await 都没有,那 async 关键字就白加了,还不如删掉:

// ❌ 错误:async 函数中没有 await
async function getData() {
  return data // 不需要 async
}

// ✅ 正确:移除 async
function getData() {
  return data
}

// ✅ 正确:包含 await
async function fetchData() {
  const result = await fetch('/api/data')
  return result
}

简单了解 shadowDom

作者 没落英雄
2025年11月21日 08:32

Shadow DOM 的工作原理:

  • 宿主元素的直接子内容确实存在于 DOM 树中(可以通过 DOM API 访问)
  • 但在浏览器渲染时,这些内容被 Shadow DOM 的内容"替换"了
  • 这是 Shadow DOM 的"扁平化"(Flattening)机制
  • 视觉上只显示 Shadow DOM 的内容,但 DOM 树中两者都存在

      // 影子domvideoaudio标签 
      const template = `<div id="qiankun-xxxx">
            <h1 id="inner">bcd</h1>
            <style>h1{color:red}</style>
      </div>`;
      const container = document.createElement("div");
      container.innerHTML = template;
      const appElement = container.firstChild;
      let oldContent = appElement.innerHTML; //老的内容
      appElement.innerHTML = "";
      const shadow = appElement.attachShadow({ mode: "closed" });
      shadow.innerHTML = oldContent; //放到影子dom中
      document.body.appendChild(appElement);

      // 演示 appendChild 的行为
      console.log("=== 演示 appendChild 行为 ===");

      const container1 = document.createElement("div");
      container1.innerHTML = "<h2>容器1</h2>";
      container1.style.border = "2px solid blue";

      const container2 = document.createElement("div");
      container2.innerHTML = "<h2>容器2</h2>";
      container2.style.border = "2px solid green";

      console.log("添加第一个容器");
      document.body.appendChild(container1);
      console.log("body 子元素数量:", document.body.children.length);

      console.log("添加第二个容器");
      document.body.appendChild(container2);
      console.log("body 子元素数量:", document.body.children.length);

      console.log("再次添加第一个容器(应该移动而不是复制)");
      document.body.appendChild(container1);
      console.log("body 子元素数量:", document.body.children.length);
      console.log("第一个容器现在在最后位置");

      // 如果你想创建多个相同的元素,需要创建新的节点
      console.log("=== 创建多个相同元素的方法 ===");
      const container3 = container1.cloneNode(true); // 克隆节点
      container3.style.border = "2px solid red";
      container3.querySelector("h2").textContent = "容器3(克隆)";
      document.body.appendChild(container3);
      console.log("现在有3个容器了");

      // ========== Shadow DOM 核心概念演示 ==========
      //
      // 关键理解:
      // 1. shadowDOMContainer 是"宿主元素"(host element)
      // 2. shadow1 是"ShadowRoot"(影子根),它是通过 attachShadow() 创建的
      // 3. 一旦创建了 Shadow DOM,宿主元素的直接子元素在"视觉上"被 Shadow DOM 覆盖
      //    注意:这些子元素仍然存在于 DOM 树中,可以通过 DOM API 访问,但不会在视觉上显示
      // 4. Shadow DOM 的内容必须通过 ShadowRoot 来设置,而不是通过宿主元素
      //
      // 为什么不能通过 shadowDOMContainer.innerHTML 设置 Shadow DOM 内容?
      // 答:因为 shadowDOMContainer.innerHTML 设置的是"宿主元素"的内容,
      //    而 Shadow DOM 是一个独立的、封装的 DOM 树,它存在于 ShadowRoot 中。
      //    两者是完全隔离的!宿主元素的内容在 DOM 树中存在,但在视觉上被 Shadow DOM "覆盖"。
      //
      // 重要区别:
      // - DOM 树存在:宿主元素的子内容确实存在于 DOM 树中(可以通过 querySelector 等访问)
      // - 视觉渲染:但在浏览器渲染时,这些内容被 Shadow DOM 的内容"替换"了(不显示)
      // 这是 Shadow DOM 的"扁平化"(Flattening)机制

      console.log("=== Shadow DOM 工作原理演示 ===");

      // 创建一个影子dom容器(宿主元素)
      const shadowDOMContainer = document.createElement("div");
      shadowDOMContainer.innerHTML = "<h2>影子dom容器(这个不会显示)</h2>";
      shadowDOMContainer.style.border = "2px solid purple";
      shadowDOMContainer.style.padding = "10px";
      shadowDOMContainer.style.margin = "10px";

      // 创建 Shadow DOM
      const shadow1 = shadowDOMContainer.attachShadow({ mode: "open" });

      // ✅ 正确:通过 ShadowRoot 设置 Shadow DOM 的内容
      shadow1.innerHTML = `
        <style>
          h2 { color: blue; background: yellow; padding: 10px; }
          p { color: green; }
        </style>
        <h2>这是 Shadow DOM 中的内容(会显示)</h2>
        <p>Shadow DOM 的内容是封装的,外部样式不会影响它</p>
      `;

      console.log("shadow1 (ShadowRoot):", shadow1);
      console.log("shadowDOMContainer (宿主元素):", shadowDOMContainer);
      console.log(
        "shadowDOMContainer.innerHTML:",
        shadowDOMContainer.innerHTML
      );
      console.log("shadow1.innerHTML:", shadow1.innerHTML);

      // 演示:即使修改宿主元素的 innerHTML,也不会影响 Shadow DOM
      console.log("\n=== 演示:宿主元素和 Shadow DOM 的隔离性 ===");
      setTimeout(() => {
        shadowDOMContainer.innerHTML = "<h2>我修改了宿主元素,但不会显示</h2>";
        console.log(
          "修改后 shadowDOMContainer.innerHTML:",
          shadowDOMContainer.innerHTML
        );
        console.log("但 Shadow DOM 的内容不变:", shadow1.innerHTML);
      }, 2000);

      // ❌ 错误:ShadowRoot 不是 DOM 节点,不能直接 appendChild
      // document.body.appendChild(shadow1); // 这会报错:Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'

      // ✅ 正确:必须把宿主元素(shadowDOMContainer)添加到 DOM 树
      // ShadowRoot 是宿主元素的一个属性,不是独立的 DOM 节点
      // 只有通过宿主元素,Shadow DOM 才能成为 DOM 树的一部分
      document.body.appendChild(shadowDOMContainer);

      // ========== 对比示例:普通 DOM vs Shadow DOM ==========
      console.log("\n=== 对比:普通 DOM 和 Shadow DOM ===");

      // 普通 DOM:直接通过 innerHTML 设置内容
      const normalDiv = document.createElement("div");
      normalDiv.innerHTML = "<h2>普通 DOM:直接显示</h2>";
      normalDiv.style.border = "2px solid orange";
      normalDiv.style.padding = "10px";
      normalDiv.style.margin = "10px";
      document.body.appendChild(normalDiv);

      // Shadow DOM:必须先创建 ShadowRoot,然后通过 ShadowRoot 设置内容
      const shadowDiv = document.createElement("div");
      shadowDiv.style.border = "2px solid red";
      shadowDiv.style.padding = "10px";
      shadowDiv.style.margin = "10px";

      // ⚠️ 注意:这样设置的内容在 DOM 树中存在,但在视觉上不会显示(被 Shadow DOM 覆盖)
      shadowDiv.innerHTML =
        "<h2>这个在 DOM 树中存在,但视觉上不会显示(被 Shadow DOM 覆盖)</h2>";

      // ✅ 正确:创建 Shadow DOM 后,通过 ShadowRoot 设置内容
      const shadowRoot = shadowDiv.attachShadow({ mode: "open" });
      shadowRoot.innerHTML = "<h2>Shadow DOM:通过 ShadowRoot 设置的内容</h2>";

      document.body.appendChild(shadowDiv);

      console.log("普通 DOM innerHTML:", normalDiv.innerHTML);
      console.log("Shadow DOM 宿主元素 innerHTML:", shadowDiv.innerHTML);
      console.log("Shadow DOM ShadowRoot innerHTML:", shadowRoot.innerHTML);

      // ========== 重要概念澄清:DOM 树存在 vs 视觉渲染 ==========
      console.log("\n=== 重要概念:DOM 树存在 vs 视觉渲染 ===");

      const demoDiv = document.createElement("div");
      demoDiv.id = "shadow-demo";
      demoDiv.style.border = "3px solid #333";
      demoDiv.style.padding = "15px";
      demoDiv.style.margin = "15px";
      demoDiv.style.backgroundColor = "#f0f0f0";

      // 1. 先设置宿主元素的内容(这些内容会存在于 DOM 树中)
      demoDiv.innerHTML = `
        <h3 id="host-child-1">宿主元素的子元素1(DOM 树中存在,但视觉上被覆盖)</h3>
        <p id="host-child-2">宿主元素的子元素2(DOM 树中存在,但视觉上被覆盖)</p>
        <span id="host-child-3">宿主元素的子元素3(DOM 树中存在,但视觉上被覆盖)</span>
      `;

      console.log("\n【步骤1】创建 Shadow DOM 之前:");
      console.log("demoDiv.children.length:", demoDiv.children.length);
      console.log("demoDiv.innerHTML:", demoDiv.innerHTML);
      console.log(
        "可以通过 DOM API 访问子元素:",
        demoDiv.querySelector("#host-child-1")
      );

      // 2. 创建 Shadow DOM
      const demoShadowRoot = demoDiv.attachShadow({ mode: "open" });

      console.log("\n【步骤2】创建 Shadow DOM 之后:");
      console.log(
        "demoDiv.children.length:",
        demoDiv.children.length,
        "(子元素仍然存在!)"
      );
      console.log(
        "demoDiv.innerHTML:",
        demoDiv.innerHTML,
        "(内容仍然存在!)"
      );
      console.log(
        "可以通过 DOM API 访问子元素:",
        demoDiv.querySelector("#host-child-1"),
        "(仍然可以访问!)"
      );
      console.log("但是这些元素在视觉上被 Shadow DOM 覆盖了");

      // 3. 设置 Shadow DOM 的内容
      demoShadowRoot.innerHTML = `
        <style>
          .shadow-content {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            border-radius: 8px;
          }
          .shadow-content h3 {
            margin-top: 0;
            color: #ffd700;
          }
        </style>
        <div class="shadow-content">
          <h3>这是 Shadow DOM 中的内容(视觉上会显示)</h3>
          <p>Shadow DOM 的内容会"覆盖"宿主元素的直接子内容</p>
          <p>但宿主元素的子内容仍然存在于 DOM 树中,只是不参与视觉渲染</p>
        </div>
      `;

      document.body.appendChild(demoDiv);

      // 4. 验证:宿主元素的子内容确实存在于 DOM 树中
      setTimeout(() => {
        console.log("\n【步骤3】验证 DOM 树结构:");
        console.log("=== 宿主元素的子节点(DOM 树中存在)===");
        Array.from(demoDiv.children).forEach((child, index) => {
          console.log(
            `子元素 ${index + 1}:`,
            child.tagName,
            child.id,
            child.textContent
          );
        });

        console.log("\n=== Shadow DOM 的内容(视觉上显示)===");
        console.log(
          "\n=== Shadow DOM 的内容(视觉上显示)===",
          demoDiv.shadowRoot
        );
        console.log("ShadowRoot 的子节点:", demoShadowRoot.children.length);
        Array.from(demoShadowRoot.children).forEach((child, index) => {
          console.log(
            `Shadow 子元素 ${index + 1}:`,
            child.tagName,
            child.className
          );
        });

        // 5. 尝试通过 JavaScript 操作宿主元素的子内容
        console.log("\n【步骤4】尝试操作宿主元素的子内容:");
        const hostChild = demoDiv.querySelector("#host-child-1");
        if (hostChild) {
          console.log("✅ 可以访问宿主元素的子元素:", hostChild);
          console.log("✅ 可以修改宿主元素的子元素:", hostChild.textContent);
          hostChild.style.color = "red"; // 这个样式不会显示,因为元素被 Shadow DOM 覆盖
          hostChild.textContent =
            "我修改了文本,但你看不到(被 Shadow DOM 覆盖)";
          console.log("修改后的文本:", hostChild.textContent);
          console.log("⚠️ 但是视觉上看不到,因为被 Shadow DOM 覆盖了");
        }

        // 6. 演示 Shadow DOM 的"扁平化"(Flattening)机制
        console.log("\n【步骤5】Shadow DOM 的扁平化机制:");
        console.log(`
          Shadow DOM 的工作原理:
          1. 宿主元素的直接子内容确实存在于 DOM 树中(可以通过 DOM API 访问)
          2. 但在浏览器渲染时,这些内容被 Shadow DOM 的内容"替换"了
          3. 这是 Shadow DOM 的"扁平化"(Flattening)机制
          4. 视觉上只显示 Shadow DOM 的内容,但 DOM 树中两者都存在
        `);
      }, 1000);

使用剪贴版复制网页内容

作者 星迷朦龙
2025年11月21日 02:01

新版 API

MDN 文档地址: Clipboard API - Web API | MDN

该 API 被设计用来取代使用 document.execCommand() 的剪贴板访问方式。

write 方法

这里只讨论如何进行复制

假设页面上有这样的 html 代码。

  <div class="md-preview">
    <el-button type="primary" @click="copyDom">复制</el-button>
    <section id="output">
     <!- 需要复制的区域-->
    </section>
  </div>

复制到剪贴版总共需要三个步骤:

  1. 获取 DOM 元素
  2. 构建 ClipboardItem 参数
  3. 写入剪贴版
async function copy() {
      const dom = document.getElementById("output");

      try {
        const htmlContent = dom?.innerHTML;

        const htmlBlob = new Blob([htmlContent || ""], { type: "text/html" });
        const clipboardItem = new ClipboardItem({ "text/html": htmlBlob });
        await navigator.clipboard.write([clipboardItem]);
      } catch (error) {
        console.log(error);
      }
}

可以将这个函数放进按钮的点击事件里。

旧版 API

由于旧版 API 已经在 MDN 上声明 遗弃 了,能不使用就不用。

MDN 文档:document.execCommand - Web API | MDN

已弃用:  不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。请尽量不要使用该特性,并更新现有的代码;参见本页面底部的兼容性表格以指导你作出决定。请注意,该特性随时可能无法正常工作。

这里给出参考代码:旧版 API 不支持 async/await 方式。

const dom = document.getElementById("output");
dom?.focus();
window.getSelection()?.removeAllRanges();

const range = document.createRange();
if (dom && dom.firstChild && dom.lastChild) {
  range.setStartBefore(dom.firstChild);
  range.setEndAfter(dom.lastChild);
  window.getSelection()?.addRange(range);
  document.execCommand("copy");
  window.getSelection()?.removeAllRanges();
}

注意:一旦建立了 Range 对象,在使用它的大多数方法之前需要设置它的边界点。

拓展

由于新版 API 的使用必须处于安全上下文环境中,即只能用 https 或 localhost 进行访问。

读取剪贴版内容,浏览器会弹出权限框,让用户选择是否“允许读取剪贴版”。

TRAE SOLO实战:一个所见即所得的笔记软体

作者 CoolerWu
2025年11月21日 01:13

1.先来唠唠

前段时间TRAE SOLO发布了正式版本,刚好有个几年前自己开发的笔记软体github.com/coolerwu/Fi… ,作为后端程序猿,前端一窍不通,界面属实有点丑,用solo来优化完善一下;

50f1eaf3733650463819ee645834abca.jpg

2.实施流程

  1. 列出自己需要实现的目标或者功能点;

  2. 打开SOLO Coder的Plan模式,这样Trae就能与你进行对齐想法,这一步至关重要

    • 如果你只是给AI一句话,例如“给我实现一个编辑器”,那么AI是无法理解你的期望意图,可能会添加很多你不需要的功能;
    • 如何用好AI,就需要参考常用的AI编程sop规范,例如openspecspec-kit等规范,总结下来就是Plan、Review、Action,在这就不过多赘述了;
    • 新入坑的小白要了解AI编程sop规范的难度系数,如手撕红黑树一样的难,而Trae SOLO的Plan,实现了类似“AI编程sop规范”的流程,算是解决第一步老大难; image.png
  3. 开始实操,与Trae SOLO的Plan,我给到的提示词是:目前所见即所得的编辑,样式比较丑,参考typroa;需要你给出plan、review、action; image.png

    • 确认计划方案没问题,就可以点执行,如果有疑问或者不对的地方可以再次与AI沟通或者直接编辑Plan.md; image.pngimage.png
    • 如果想让AI自己干活几十分钟,可以加一点特殊的提示词:“持续推进10次,直到所有问题解决”,PS:你的TOKEN数量要足够;
  4. 运行过程中,还是免不了出点小问题

  • 随便贴了一个我在启动时的问题,右键给到Trae SOLO;
  • 给出了操作建议,根据提示操作就能解决了,是一个非常靠谱的铁铁,基本解放大脑了;

80da5a3b575ff4764b6b6cb930d57abb.jpg

$ npm install                    
npm warn deprecated uuid@3.4.0: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
npm warn deprecated electron-osx-sign@0.6.0: Please use @electron/osx-sign moving forward. Be aware the API is slightly different
npm error code 1
npm error path /Users/xxxxx/NodeProjects/FireFire/node_modules/electron
npm error command failed
npm error command sh -c node install.js
npm error RequestError: Hostname/IP does not match certificate's altnames: Host: npm.taobao.org. is not in the cert's altnames: DNS:*.tbcdn.cn, DNS:*.1688.com, DNS:*.3c.tmall.com, DNS:*.alibaba.com, DNS:*.alicdn.com, DNS:*.aliexpress.com, DNS:*.alikunlun.com, DNS:*.aliqin.tmall.com, DNS:*.alitrip.com, DNS:*.aliyun.com, DNS:*.cainiao.com, DNS:*.cainiao.com.cn, DNS:*.chi.taobao.com, DNS:*.chi.tmall.com, DNS:*.china.taobao.com, DNS:*.cloudvideocdn.taobao.com, DNS:*.cmos.greencompute.org, DNS:*.dingtalk.com, DNS:*.django.t.taobao.com, DNS:*.etao.com, DNS:*.feizhu.cn, DNS:*.feizhu.com, DNS:*.fliggy.com, DNS:*.fliggy.hk, DNS:*.food.tmall.com, DNS:*.jia.taobao.com, DNS:*.jia.tmall.com, DNS:*.ju.taobao.com, DNS:*.juhuasuan.com, DNS:*.lw.aliimg.com, DNS:*.m.1688.com, DNS:*.m.alibaba.com, DNS:*.m.alitrip.com, DNS:*.m.cainiao.com, DNS:*.m.etao.com, DNS:*.m.taobao.com, DNS:*.m.taopiaopiao.com, DNS:*.m.tmall.com, DNS:*.m.tmall.hk, DNS:*.mei.com, DNS:*.mobgslb.tbcache.com, DNS:*.taobao.com, DNS:*.taopiaopiao.com, DNS:*.tbcache.com, DNS:*.tmall.com, DNS:*.tmall.hk, DNS:*.trip.taobao.com, DNS:*.xiami.com, DNS:1688.com, DNS:alibaba.com, DNS:alicdn.com, DNS:aliexpress.com, DNS:alikunlun.com, DNS:alitrip.com, DNS:aliyun.com, DNS:cainiao.com, DNS:cainiao.com.cn, DNS:cloudvideocdn.taobao.com, DNS:cmos.greencompute.org, DNS:dingtalk.com, DNS:etao.com, DNS:feizhu.cn, DNS:feizhu.com, DNS:fliggy.com, DNS:fliggy.hk, DNS:juhuasuan.com, DNS:m.intl.taobao.com, DNS:mei.com, DNS:taobao.com, DNS:taopiaopiao.com, DNS:tmall.com, DNS:tmall.hk, DNS:xiami.com, DNS:tbcdn.cn
npm error     at ClientRequest.<anonymous> (/Users/xxxxx/NodeProjects/FireFire/node_modules/got/source/request-as-event-emitter.js:178:14)
npm error     at Object.onceWrapper (node:events:632:26)
npm error     at ClientRequest.emit (node:events:529:35)
npm error     at origin.emit (/Users/xxxxx/NodeProjects/FireFire/node_modules/@szmarczak/http-timer/source/index.js:37:11)
npm error     at TLSSocket.socketErrorListener (node:_http_client:501:9)
npm error     at TLSSocket.emit (node:events:517:28)
npm error     at emitErrorNT (node:internal/streams/destroy:151:8)
npm error     at emitErrorCloseNT (node:internal/streams/destroy:116:3)
npm error     at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
npm error A complete log of this run can be found in: /Users/xxxxx/.npm/_logs/2025-11-20T16_07_26_954Z-debug-0.log

image.png

3.此次优化过程中, TRAE SOLO 在架构中的具体角色定位

graph TB
    subgraph "开发阶段"
        Dev[开发者] --> TRAE[TRAE Solo]
        TRAE --> CodeGen[代码生成]
        TRAE --> Quality[质量保证]
        TRAE --> Auto[自动化任务]
    end
    
    subgraph "笔记软件架构"
        Editor[编辑器核心]
        Parser[Markdown解析器]
        Renderer[渲染引擎]
        Storage[存储层]
    end
    
    CodeGen -.->|生成样板代码| Editor
    CodeGen -.->|创建解析规则| Parser
    Quality -.->|代码审查| Renderer
    Auto -.->|构建优化| Storage
    
    style TRAE fill:#ff9800
    style Dev fill:#4caf50

4.优化过程中,与TRAE SOLO探讨的功能

graph TB
    subgraph "用户界面层 (UI Layer)"
        UI[用户界面]
        UI --> Toolbar[工具栏]
        UI --> EditorView[编辑器视图]
        UI --> PreviewPane[预览面板]
        UI --> FileTree[文件树]
        
        EditorView --> Monaco[Monaco Editor]
        PreviewPane --> VirtualDOM[Virtual DOM]
    end
    
    subgraph "前端渲染引擎 (Rendering Engine)"
        RenderPipeline[渲染管道]
        RenderPipeline --> MDParser[Markdown Parser<br/>markdown-it/remark]
        RenderPipeline --> HTMLGen[HTML Generator]
        RenderPipeline --> StyleEngine[样式引擎<br/>主题/高亮]
        RenderPipeline --> MathRenderer[数学公式<br/>KaTeX/MathJax]
        RenderPipeline --> DiagramRenderer[图表渲染<br/>Mermaid/PlantUML]
        
        HTMLGen --> SyncScroll[同步滚动控制器]
        SyncScroll --> PreviewPane
    end
    
    subgraph "编辑器核心 (Editor Core)"
        EditorCore[编辑器内核]
        EditorCore --> DocModel[文档模型<br/>AST/Token Tree]
        EditorCore --> CursorMgr[光标管理器]
        EditorCore --> SelectionMgr[选择管理器]
        EditorCore --> CommandMgr[命令管理器]
        EditorCore --> UndoRedo[撤销/重做栈]
        
        DocModel --> CRDT[CRDT<br/>协同编辑]
        CommandMgr --> Shortcuts[快捷键系统]
        
        EditorCore --> EventBus[事件总线]
        EventBus --> ChangeDetector[变更检测器]
        ChangeDetector --> Debounce[防抖处理]
        Debounce --> RenderPipeline
    end
    
    subgraph "插件系统 (Plugin System)"
        PluginAPI[插件 API]
        PluginAPI --> PluginLoader[插件加载器]
        PluginAPI --> PluginRegistry[插件注册表]
        PluginAPI --> Hooks[生命周期钩子]
        
        Plugins[插件集合]
        Plugins --> AutoComplete[自动补全]
        Plugins --> Snippets[代码片段]
        Plugins --> Linter[语法检查]
        Plugins --> ImageUpload[图片上传]
        
        PluginRegistry --> EditorCore
        Hooks --> EventBus
    end
    
    subgraph "存储层 (Storage Layer)"
        StorageAdapter[存储适配器]
        StorageAdapter --> LocalStorage[本地存储]
        StorageAdapter --> CloudSync[云同步]
        
        LocalStorage --> IndexedDB[IndexedDB<br/>文档数据]
        LocalStorage --> FileSystem[File System API<br/>附件/图片]
        LocalStorage --> LocalCache[缓存层<br/>LRU Cache]
        
        CloudSync --> WebDAV[WebDAV]
        CloudSync --> GitBackend[Git 后端]
        CloudSync --> S3[S3 兼容存储]
        
        StorageAdapter --> Encryption[加密模块<br/>AES-256]
        StorageAdapter --> Compression[压缩模块<br/>LZ4/Gzip]
    end
    
    subgraph "服务层 (Service Layer)"
        Services[核心服务]
        Services --> SearchIndex[搜索索引<br/>FlexSearch]
        Services --> ExportService[导出服务<br/>PDF/Word/HTML]
        Services --> VersionControl[版本控制]
        Services --> ConflictResolver[冲突解决器]
        
        SearchIndex --> DocModel
        VersionControl --> StorageAdapter
    end
    
    subgraph "TRAE Solo 集成层"
        TRAE[TRAE Solo]
        TRAE -.->|代码生成| PluginAPI
        TRAE -.->|性能监控| RenderPipeline
        TRAE -.->|测试自动化| EditorCore
        TRAE -.->|构建优化| Build[构建系统]
        
        Build --> Webpack[Webpack/Vite]
        Build --> CodeSplit[代码分割]
        Build --> TreeShaking[Tree Shaking]
    end
    
    %% 主要数据流
    Monaco ==>|输入文本| EditorCore
    EditorCore ==>|文档变更| EventBus
    EventBus ==>|触发渲染| RenderPipeline
    RenderPipeline ==>|生成HTML| PreviewPane
    
    EditorCore ==>|保存| StorageAdapter
    StorageAdapter ==>|读取| EditorCore
    
    %% 样式定义
    classDef uiClass fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    classDef coreClass fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    classDef storageClass fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
    classDef renderClass fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    classDef traeClass fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
    
    class UI,Toolbar,EditorView,PreviewPane,FileTree,Monaco,VirtualDOM uiClass
    class EditorCore,DocModel,CursorMgr,SelectionMgr,CommandMgr,UndoRedo,CRDT,EventBus,ChangeDetector coreClass
    class StorageAdapter,LocalStorage,CloudSync,IndexedDB,FileSystem,LocalCache,WebDAV,GitBackend,S3 storageClass
    class RenderPipeline,MDParser,HTMLGen,StyleEngine,MathRenderer,DiagramRenderer renderClass
    class TRAE,Build,Webpack traeClass

solo也给到了我功能建设的优化方案,简单展示下

1. 用户界面层

  • Monaco Editor:微软开源的代码编辑器,提供语法高亮、智能提示
  • Virtual DOM:优化预览渲染性能,减少重绘

2. 前端渲染引擎

  • 渲染管道:将 Markdown 转换为富文本的核心流程
  • 同步滚动:确保编辑区和预览区位置对应
  • 扩展渲染器:支持数学公式、流程图等特殊内容

3. 编辑器核心

  • 文档模型:维护文档的结构化表示(AST)
  • CRDT:支持多人实时协作编辑
  • 事件总线:解耦各模块,统一事件处理

4. 插件系统

  • 生命周期钩子:允许插件介入编辑流程
  • 插件隔离:沙箱机制保证安全性

5. 存储层

  • 多端存储:本地为主,云端同步
  • 增量同步:只传输变更部分,节省带宽
  • 端到端加密:保护用户隐私

6. TRAE Solo 集成

  • 虚线表示开发时依赖,不影响运行时
  • 负责代码质量、性能优化、自动化测试

这个架构设计确保了:

  • 高性能:通过虚拟DOM和防抖优化渲染
  • 可扩展:插件系统支持功能扩展
  • 数据安全:本地优先,加密存储
  • 开发效率:TRAE Solo 自动化开发流程

5.最终运行

image.png

6.总结一下小心得

  1. 最近国内外LLM频繁更新,虽说被替代的危机感增加,但AI用好就是把利刃,我们可以把这类AI当资深结对程序员:给方向、给上下文、让它端到端执行并自证;
  2. 另外与Trae SOLO交互时,应该给出一个更完整的prompt,而不是几句话,让AI揣度意思,容易造成我们与AI的battle,以下是我自己使用Claude、Codex、Trae时总结的一点小依据,供大家参考参考:
  • 给上下文而非指令碎片:目录结构、关键文件、依赖版本、环境限制
  • 明确输出格式与维度:是否需要 plan/review/action、是否要代码引用、是否需要可运行验证
  • 约束与边界先行:禁止写入、仅做只读调研、或允许修改;是否需要遵循现有风格与库
  • 提前给验收标准:比如“对齐 Typora 视觉”、“安装无证书错误”、“所有任务以 todo 跟踪并完成”
  • 输出要结构化:计划、评审、行动、验证分层清晰
  • 输出要可执行:具体文件路径与行号、改动粒度、命令行指令、验证方法
  • 输出要可回滚:只改必要处,避免大范围重构;优先样式与配置微调
  • 输出要自检:完成后用测试或可视预览验证,不留“未验证”工作
  1. 上线生产环境之前,一定要进行代码CR,因为AI容易修改其他文件导致整个项目崩溃,这一定要仔细仔细再仔细,不要产生生产事故,而溜溜球了;

088ec4314fca6e9364c24660008cb2c5.jpg

vue3 基于 el-table 的无限滚动自定义指令实现

2025年11月21日 00:26

el-table 虚拟滚动完整实现方案

方案概述

本方案通过自定义指令 v-infinite-scroll 实现 el-table 组件的虚拟滚动功能,优化大数据量下的表格性能。核心原理是只渲染可视区域及缓冲区域的数据,通过 padding 撑开滚动条来模拟完整数据的高度。

核心特性

  • 性能优化:只渲染必要数据,避免大量 DOM 节点创建
  • 正确滚动:通过 padding 技术维持滚动条行为正确
  • 易于集成:通过指令方式简单使用
  • 动态数据:支持异步数据加载和动态更新

实现代码

自定义指令实现

/**
 * el-table 虚拟滚动指令
 * 通过只渲染可视区域+缓冲区域的数据来优化大数据量下的性能
 */
export const infiniteScroll = {
  /**
   * 指令挂载时初始化虚拟滚动功能
   * @param {HTMLElement} el - 绑定指令的元素
   * @param {Object} binding - 指令绑定值
   */
  mounted(el, binding) {
    // 解构指令参数
    const { rowHeight, bufferCount = 5, dataArray, onScroll } = binding.value;
    let ticking = false; // 节流锁,确保每帧只执行一次更新

    // 获取真实的滚动容器和表格元素
    const container = el.querySelector('.el-scrollbar__wrap');
    const table = container.querySelector('table');

    /**
     * 获取当前数据总量
     * 通过传递的数组引用动态获取长度,确保数据更新时能正确响应
     */
    const getTotalCount = () => dataArray ? dataArray.length : 0;

    /**
     * 更新可视区域数据
     * 核心函数:计算当前可视区域索引,设置padding维持滚动条,通知父组件更新数据
     */
    function update() {
      // 获取滚动位置和容器高度
      const scrollTop = container.scrollTop;
      const containerHeight = container.clientHeight;
      const totalCount = getTotalCount();

      // 计算可视区域索引
      // startIndex: 可视区域顶部所在的行索引
      let startIndex = Math.floor(scrollTop / rowHeight);
      // endIndex: 可视区域底部所在的行索引
      let endIndex = Math.ceil((scrollTop + containerHeight) / rowHeight) - 1;

      // 添加缓冲区域,提高滚动体验
      startIndex -= bufferCount;
      endIndex += bufferCount;

      // 基本边界处理
      startIndex = Math.max(0, startIndex);
      endIndex = Math.max(0, Math.min(totalCount - 1, endIndex));

      // 计算 padding 值来维持正确的滚动条高度
      // topPadding: 隐藏在上方的数据所占高度
      const topPadding = Math.max(0, startIndex * rowHeight);
      
      // bottomPadding: 隐藏在下方的数据所占高度
      // 减1是因为要从索引转换为剩余行数
      const bottomPadding = totalCount > 0 && endIndex >= 0 ? 
        (totalCount - endIndex - 1) * rowHeight : 0;

      // 通过 padding 撑开表格,模拟完整数据的高度
      table.style.paddingTop = `${topPadding}px`;
      table.style.paddingBottom = `${bottomPadding}px`;

      // 通知父组件更新显示数据
      if (typeof onScroll === 'function') {
        onScroll(startIndex, endIndex);
      }

      // 释放节流锁
      ticking = false;
    }

    /**
     * 使用 requestAnimationFrame 进行帧级节流
     * 确保每帧只执行一次更新,避免频繁计算影响性能
     */
    function requestTick() {
      if (!ticking) {
        requestAnimationFrame(update);
        ticking = true;
      }
    }

    // 监听滚动事件
    container.addEventListener('scroll', requestTick);

    // 初始化时直接通知父组件显示前20条数据
    // 这是一种实用的默认策略,虽然可能不完全准确但能保证基本显示效果
    if (typeof onScroll === 'function') {
      onScroll(0, 20);
    }
  }
};

使用示例

<template>
  <div class="virtual-table-demo">
    <h2>el-table 虚拟滚动演示</h2>
    
    <div class="controls">
      <el-button @click="loadData" type="primary">加载数据</el-button>
      <el-button @click="clearData" type="danger">清空数据</el-button>
      <el-button @click="generateLargeData" type="success">生成10万条数据</el-button>
    </div>
    
    <!-- 
      使用 v-infinite-scroll 指令实现虚拟滚动
      参数说明:
      - rowHeight: 每行高度(需固定)
      - bufferCount: 缓冲区域行数
      - dataArray: 数据数组引用(用于动态获取长度)
      - onScroll: 滚动时的回调函数
    -->
    <el-table 
      v-infinite-scroll="{
        rowHeight: 40,
        bufferCount: 5,
        dataArray: totalData,  // 传递数组引用而不是固定长度
        onScroll: handleScroll
      }"
      :data="displayData"
      height="400px"
      border
      v-loading="loading">
      
      <el-table-column prop="id" label="ID" width="80"></el-table-column>
      <el-table-column prop="name" label="姓名" width="150"></el-table-column>
      <el-table-column prop="email" label="邮箱"></el-table-column>
      <el-table-column prop="address" label="地址"></el-table-column>
      <el-table-column prop="date" label="日期" width="180"></el-table-column>
    </el-table>
    
    <div class="info">
      <p>总数据量: {{ totalData.length }}</p>
      <p>当前显示: {{ displayData.length }} 条</p>
      <p>显示范围: {{ visibleStartIndex }} - {{ visibleEndIndex }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';
import { infiniteScroll } from './directives/infiniteScroll';

// 注册自定义指令
const vInfiniteScroll = infiniteScroll;

// 完整数据源
const totalData = reactive([]);
// 显示的数据(通过计算属性动态截取可视区域数据)
const displayData = computed(() => {
  return totalData.slice(visibleStartIndex.value, visibleEndIndex.value + 1);
});

// 当前显示范围索引
const visibleStartIndex = ref(0);
const visibleEndIndex = ref(0);
const loading = ref(false);

/**
 * 处理滚动事件回调
 * @param {number} startIndex - 可视区域起始索引
 * @param {number} endIndex - 可视区域结束索引
 */
const handleScroll = (startIndex, endIndex) => {
  visibleStartIndex.value = startIndex;
  visibleEndIndex.value = endIndex;
};

/**
 * 生成测试数据
 * @param {number} count - 数据量
 */
const generateData = (count) => {
  totalData.length = 0;
  
  for (let i = 0; i < count; i++) {
    totalData.push({
      id: i + 1,
      name: `用户${i + 1}`,
      email: `user${i + 1}@example.com`,
      address: `北京市朝阳区某某街道${i + 1}号`,
      date: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toLocaleDateString()
    });
  }
};

/**
 * 加载数据
 */
const loadData = async () => {
  loading.value = true;
  try {
    // 模拟API请求延迟
    await new Promise(resolve => setTimeout(resolve, 800));
    
    // 生成1万条测试数据
    generateData(10000);
    
    // 重置显示范围
    handleScroll(0, Math.min(20, totalData.length - 1));
  } finally {
    loading.value = false;
  }
};

/**
 * 生成大量数据测试性能
 */
const generateLargeData = async () => {
  loading.value = true;
  try {
    // 模拟API请求延迟
    await new Promise(resolve => setTimeout(resolve, 800));
    
    // 生成10万条测试数据
    generateData(100000);
    
    // 重置显示范围
    handleScroll(0, Math.min(20, totalData.length - 1));
  } finally {
    loading.value = false;
  }
};

/**
 * 清空数据
 */
const clearData = () => {
  totalData.length = 0;
  handleScroll(0, 0);
};

// 初始化少量数据用于测试
generateData(50);
handleScroll(0, 20);
</script>

<style scoped>
.virtual-table-demo {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.controls {
  margin-bottom: 20px;
}

.controls .el-button {
  margin-right: 10px;
}

.info {
  margin: 20px 0;
  padding: 15px;
  background-color: #f5f5f5;
  border-radius: 4px;
  font-size: 14px;
}

.info p {
  margin: 8px 0;
}
</style>

使用说明

核心参数

  1. rowHeight: 每行固定高度(必须指定)
  2. bufferCount: 缓冲区域行数(默认5行)
  3. dataArray: 数据数组引用(用于动态获取长度)
  4. onScroll: 滚动回调函数

注意事项

  1. 行高固定:为保证计算准确性,需要固定每行高度
  2. 初始化策略:指令初始化时会默认显示前20条数据,这是一种实用但可能不完全准确的策略

element-plus 二次封装遇到的一点疑惑

作者 ethan_Yin
2025年11月21日 00:14

事情起因很简单,今天看了篇文章 跟着 Vue团队大佬学习在 Vue3 中二次封装组件 ,觉得有趣,遂实践了一下,发现某些情况下 v-model 值UI上不同步更新,本想回复原文章,但是由于评论字数限制不放不下 element-plus playground 的链接,所以单开了这篇文章!

🚑场景复现

原文的一些知识就不多做介绍了,有兴趣的移步原文即可。

介绍一下我的场景,当当当当~~~~~

这里有一个二次封装 el-input 的组件 Comp.vue,因为playground 不能使用导入类型使用,所以仅保留了 modalValue 这一个属性。

<template>
  <div>
    <component :is="h(ElInput, $props, slots)" ref="inputRef"></component>
  </div>
</template>

<script lang="ts" setup>
import { type ExtractPublicPropTypes, ref, h, useAttrs, mergeProps, useSlots } from 'vue'
import { ElInput } from 'element-plus';

type InputProps = ExtractPublicPropTypes<{ modelValue: any }> & {};
const props = withDefaults(defineProps<InputProps>(), {})
const slots = useSlots();
const attrs = useAttrs();
// 以下两种方式都是会丢失响应性的,要将右侧值放到模板里面做解构
const $props = mergeProps(attrs, props)
// const $props = { ...attrs, ...props }

const inputRef = ref()

defineExpose({
  inputRef,
})
</script>

Comp.vue 放在 App.vue 中使用

<template>
  <div>
    <Comp ref="CompEl" v-model="modelVal" />
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import Comp from './Comp.vue';

const CompEl = ref();
const modelVal = ref('ethan')
setTimeout(() => {
  console.log(222222,CompEl.value.inputRef)
  CompEl.value.inputRef?.clear()
  console.log('after clear: ', modelVal.value)
}, 1000)
</script>

主要的功能就是外部调用 el-input 的清空的方法 clear,以达到一秒后清除输入框内容的效果。结果值是被清空了,但是输入框的值没有变!这你能忍?我反正是忍不了😠!

image.png

关键在这🔑

我排查了好一会发现了关键!在下面两种定义 props 的方式中,先定义,之后再拿到模板里去使用,v-model 值变了, 单视图就不会变!!

// 以下两种方式都是会丢失响应性的,要将右侧值放到模板里面做解构
const $props = mergeProps(attrs, props)
// const $props = { ...attrs, ...props }

如果拿赋值右边的值去作为props,一切正常!🎉🎉🎉

<template>
  <div>
    <component :is="h(ElInput, mergeProps(attrs, props), slots )" ref="inputRef"></component>
  </div>
</template>

当然这无关动态组件,el-inputv-bind 也是无效的!

<template>
  <div>
    <el-input v-bind="$props" ref="inputRef"></el-input>
  </div>
</template>

element-plus playground 复现地址

不知道为啥掘金跳转有问题! https://element-plus.run/#eyJBcHAudnVlIjoiPHRlbXBsYXRlPlxuICA8ZGl2PlxuICAgIDxDb21wIHJlZj1cIkNvbXBFbFwiIHYtbW9kZWw9XCJtb2RlbFZhbFwiIC8+XG4gIDwvZGl2PlxuPC90ZW1wbGF0ZT5cblxuPHNjcmlwdCBsYW5nPVwidHNcIiBzZXR1cD5cbmltcG9ydCB7IHJlZiB9IGZyb20gJ3Z1ZSdcbmltcG9ydCBDb21wIGZyb20gJy4vQ29tcC52dWUnO1xuXG5jb25zdCBDb21wRWwgPSByZWYoKTtcbmNvbnN0IG1vZGVsVmFsID0gcmVmKCdldGhhbicpXG5zZXRUaW1lb3V0KCgpID0+IHtcbiAgY29uc29sZS5sb2coMjIyMjIyLENvbXBFbC52YWx1ZS5pbnB1dFJlZilcbiAgQ29tcEVsLnZhbHVlLmlucHV0UmVmPy5jbGVhcigpXG4gIGNvbnNvbGUubG9nKCdhZnRlciBjbGVhcjogJywgbW9kZWxWYWwudmFsdWUpXG59LCAxMDAwKVxuPC9zY3JpcHQ+XG4iLCJlbGVtZW50LXBsdXMuanMiOiJpbXBvcnQgRWxlbWVudFBsdXMgZnJvbSAnZWxlbWVudC1wbHVzJ1xuaW1wb3J0IHsgZ2V0Q3VycmVudEluc3RhbmNlIH0gZnJvbSAndnVlJ1xuXG5sZXQgaW5zdGFsbGVkID0gZmFsc2VcbmF3YWl0IGxvYWRTdHlsZSgpXG5cbmV4cG9ydCBmdW5jdGlvbiBzZXR1cEVsZW1lbnRQbHVzKCkge1xuICBpZiAoaW5zdGFsbGVkKSByZXR1cm5cbiAgY29uc3QgaW5zdGFuY2UgPSBnZXRDdXJyZW50SW5zdGFuY2UoKVxuICBpbnN0YW5jZS5hcHBDb250ZXh0LmFwcC51c2UoRWxlbWVudFBsdXMpXG4gIGluc3RhbGxlZCA9IHRydWVcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGxvYWRTdHlsZSgpIHtcbiAgY29uc3Qgc3R5bGVzID0gWydodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvbnBtL2VsZW1lbnQtcGx1c0BsYXRlc3QvZGlzdC9pbmRleC5jc3MnLCAnaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9lbGVtZW50LXBsdXNAbGF0ZXN0L3RoZW1lLWNoYWxrL2RhcmsvY3NzLXZhcnMuY3NzJ10ubWFwKChzdHlsZSkgPT4ge1xuICAgIHJldHVybiBuZXcgUHJvbWlzZSgocmVzb2x2ZSwgcmVqZWN0KSA9PiB7XG4gICAgICBjb25zdCBsaW5rID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnbGluaycpXG4gICAgICBsaW5rLnJlbCA9ICdzdHlsZXNoZWV0J1xuICAgICAgbGluay5ocmVmID0gc3R5bGVcbiAgICAgIGxpbmsuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlc29sdmUpXG4gICAgICBsaW5rLmFkZEV2ZW50TGlzdGVuZXIoJ2Vycm9yJywgcmVqZWN0KVxuICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmQobGluaylcbiAgICB9KVxuICB9KVxuICByZXR1cm4gUHJvbWlzZS5hbGxTZXR0bGVkKHN0eWxlcylcbn1cbiIsInRzY29uZmlnLmpzb24iOiJ7XG4gIFwiY29tcGlsZXJPcHRpb25zXCI6IHtcbiAgICBcInRhcmdldFwiOiBcIkVTTmV4dFwiLFxuICAgIFwianN4XCI6IFwicHJlc2VydmVcIixcbiAgICBcIm1vZHVsZVwiOiBcIkVTTmV4dFwiLFxuICAgIFwibW9kdWxlUmVzb2x1dGlvblwiOiBcIkJ1bmRsZXJcIixcbiAgICBcInR5cGVzXCI6IFtcImVsZW1lbnQtcGx1cy9nbG9iYWwuZC50c1wiXSxcbiAgICBcImFsbG93SW1wb3J0aW5nVHNFeHRlbnNpb25zXCI6IHRydWUsXG4gICAgXCJhbGxvd0pzXCI6IHRydWUsXG4gICAgXCJjaGVja0pzXCI6IHRydWVcbiAgfSxcbiAgXCJ2dWVDb21waWxlck9wdGlvbnNcIjoge1xuICAgIFwidGFyZ2V0XCI6IDMuM1xuICB9XG59XG4iLCJQbGF5Z3JvdW5kTWFpbi52dWUiOiI8c2NyaXB0IHNldHVwPlxuaW1wb3J0IEFwcCBmcm9tICcuL0FwcC52dWUnXG5pbXBvcnQgeyBzZXR1cEVsZW1lbnRQbHVzIH0gZnJvbSAnLi9lbGVtZW50LXBsdXMuanMnXG5zZXR1cEVsZW1lbnRQbHVzKClcbjwvc2NyaXB0PlxuXG48dGVtcGxhdGU+XG4gIDxBcHAgLz5cbjwvdGVtcGxhdGU+XG4iLCJpbXBvcnQtbWFwLmpzb24iOiJ7XG4gIFwiaW1wb3J0c1wiOiB7XG4gICAgXCJ2dWVcIjogXCJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvbnBtL0B2dWUvcnVudGltZS1kb21AbGF0ZXN0L2Rpc3QvcnVudGltZS1kb20uZXNtLWJyb3dzZXIuanNcIixcbiAgICBcIkB2dWUvc2hhcmVkXCI6IFwiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9AdnVlL3NoYXJlZEBsYXRlc3QvZGlzdC9zaGFyZWQuZXNtLWJ1bmRsZXIuanNcIixcbiAgICBcImVsZW1lbnQtcGx1c1wiOiBcImh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9ucG0vZWxlbWVudC1wbHVzQGxhdGVzdC9kaXN0L2luZGV4LmZ1bGwubWluLm1qc1wiLFxuICAgIFwiZWxlbWVudC1wbHVzL1wiOiBcImh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9ucG0vZWxlbWVudC1wbHVzQGxhdGVzdC9cIixcbiAgICBcIkBlbGVtZW50LXBsdXMvaWNvbnMtdnVlXCI6IFwiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9AZWxlbWVudC1wbHVzL2ljb25zLXZ1ZUAyL2Rpc3QvaW5kZXgubWluLmpzXCJcbiAgfSxcbiAgXCJzY29wZXNcIjoge31cbn0iLCJDb21wLnZ1ZSI6Ijx0ZW1wbGF0ZT5cbiAgPGRpdj5cbiAgICA8Y29tcG9uZW50IDppcz1cImgoRWxJbnB1dCwgeyAuLi4kcHJvcHMgfSwgc2xvdHMgKVwiIHJlZj1cImlucHV0UmVmXCI+PC9jb21wb25lbnQ+XG4gIDwvZGl2PlxuPC90ZW1wbGF0ZT5cblxuPHNjcmlwdCBsYW5nPVwidHNcIiBzZXR1cD5cbmltcG9ydCB7IHR5cGUgRXh0cmFjdFB1YmxpY1Byb3BUeXBlcywgcmVmLCBoLCB1c2VBdHRycywgbWVyZ2VQcm9wcywgdXNlU2xvdHMgfSBmcm9tICd2dWUnXG5pbXBvcnQgeyBFbElucHV0IH0gZnJvbSAnZWxlbWVudC1wbHVzJztcbmltcG9ydCB0eXBlIHsgSW5wdXRQcm9wcyBhcyBFbElucHV0UHJvcHMsIElucHV0SW5zdGFuY2UgYXMgRWxJbnB1dEluc3RhbmNlIH0gZnJvbSAnZWxlbWVudC1wbHVzJztcblxudHlwZSBJbnB1dFByb3BzID0gRXh0cmFjdFB1YmxpY1Byb3BUeXBlczx7IG1vZGVsVmFsdWU6IGFueSB9PiAmIHt9O1xuY29uc3QgcHJvcHMgPSB3aXRoRGVmYXVsdHMoZGVmaW5lUHJvcHM8SW5wdXRQcm9wcz4oKSwge30pXG5jb25zdCBzbG90cyA9IHVzZVNsb3RzKCk7XG5jb25zdCBhdHRycyA9IHVzZUF0dHJzKCk7XG4vLyDku6XkuIvkuKTnp43mlrnlvI/pg73mmK/kvJrkuKLlpLHlk43lupTmgKfnmoTvvIzopoHlsIblj7PkvqflgLzmlL7liLDmqKHmnb/ph4zpnaLlgZrop6PmnoRcbmNvbnN0ICRwcm9wcyA9IG1lcmdlUHJvcHMoYXR0cnMsIHByb3BzKVxuLy8gY29uc3QgJHByb3BzID0geyAuLi5hdHRycywgLi4ucHJvcHMgfVxuXG5jb25zdCBpbnB1dFJlZiA9IHJlZigpXG5cbmRlZmluZUV4cG9zZSh7XG4gIGlucHV0UmVmLFxufSlcbjwvc2NyaXB0PlxuIiwiQ29tcDEudnVlIjoiPHRlbXBsYXRlPlxyXG4gIDxkaXY+XHJcbiAgICA8ZWwtaW5wdXQgdi1iaW5kPVwiJHByb3BzXCIgcmVmPVwiaW5wdXRSZWZcIj48L2VsLWlucHV0PlxyXG4gIDwvZGl2PlxyXG48L3RlbXBsYXRlPlxyXG5cclxuPHNjcmlwdCBsYW5nPVwidHNcIiBzZXR1cD5cclxuaW1wb3J0IHsgdHlwZSBFeHRyYWN0UHVibGljUHJvcFR5cGVzLCByZWYsIGgsIHVzZUF0dHJzLCBtZXJnZVByb3BzLCB1c2VTbG90cyB9IGZyb20gJ3Z1ZSdcclxuaW1wb3J0IHR5cGUgeyBJbnB1dFByb3BzIGFzIEVsSW5wdXRQcm9wcywgSW5wdXRJbnN0YW5jZSBhcyBFbElucHV0SW5zdGFuY2UgfSBmcm9tICdlbGVtZW50LXBsdXMnO1xyXG5cclxudHlwZSBJbnB1dFByb3BzID0gRXh0cmFjdFB1YmxpY1Byb3BUeXBlczx7IG1vZGVsVmFsdWU6IGFueSB9PiAmIHt9O1xyXG5jb25zdCBwcm9wcyA9IHdpdGhEZWZhdWx0cyhkZWZpbmVQcm9wczxJbnB1dFByb3BzPigpLCB7fSlcclxuY29uc3Qgc2xvdHMgPSB1c2VTbG90cygpO1xyXG5jb25zdCBhdHRycyA9IHVzZUF0dHJzKCk7XHJcbi8vIOS7peS4i+S4pOenjeaWueW8j+mDveaYr+S8muS4ouWkseWTjeW6lOaAp+eahO+8jOimgeWwhuWPs+S+p+WAvOaUvuWIsOaooeadv+mHjOmdouWBmuino+aehFxyXG5jb25zdCAkcHJvcHMgPSBtZXJnZVByb3BzKGF0dHJzLCBwcm9wcylcclxuLy8gY29uc3QgJHByb3BzID0geyAuLi5hdHRycywgLi4ucHJvcHMgfVxyXG5cclxuY29uc3QgaW5wdXRSZWYgPSByZWYoKVxyXG5cclxuZGVmaW5lRXhwb3NlKHtcclxuICBpbnB1dFJlZixcclxufSlcclxuPC9zY3JpcHQ+IiwiX28iOnt9fQ==

睡了睡了😪

原因暂时没去深究,看有没有同僚给我解惑😁😁😁

夜已深,我欲入眠!文章就写到这,后面找到原因了再续上。

此时呵欠一连串涌上来🥱,睫毛粘成一片,没等撑住,粗重的呼噜已经断断续续撞进耳朵~~zz~~ZZZ😪

AIGC 时代,用自然语言操作数据库:SQLite + LLM 的轻量级实践

作者 陳陈陳
2025年11月21日 00:13

AIGC 时代,用自然语言操作数据库:SQLite + LLM 的轻量级实践


一、为什么我们需要“自然语言查数据库”?

SQL 是结构化查询语言,精准但门槛不低。对于产品经理、运营、测试甚至初级开发者来说,写一条 JOINGROUP BY 可能比写周报还难。

而如今,大语言模型(如 DeepSeek、GPT、Claude 等)已经具备极强的语义理解与代码生成能力。只要给它清晰的数据库结构(Schema)和一句自然语言问题,它就能生成准确的 SQL。

这不仅是效率工具,更是人机协作范式的升级

你负责“想问什么”,AI 负责“怎么查”。


二、为什么选择 SQLite?

在众多数据库中,SQLite 是轻量级场景的王者:

  • 零配置、单文件:整个数据库就是一个 .db 文件,无需安装服务。
  • 嵌入式友好:微信、Chrome、Android 系统内部都用它做本地存储。
  • 标准 SQL 支持:支持大部分 SQL 语法,足以应对日常 CRUD。
  • Python 原生支持import sqlite3 即可开干,无额外依赖。

💡 对比 MySQL/PostgreSQL:它们适合高并发、分布式场景,但对本地工具、桌面应用、原型开发来说“杀鸡用牛刀”。


三、动手实践:5 分钟搭建“自然语言查库”系统

1. 创建数据库与表

# 轻量级数据库
import sqlite3
from openai import OpenAI

# ========== 1. 连接数据库(仅一次)==========
conn = sqlite3.connect("test1.db")
cursor = conn.cursor()

# ========== 2. 创建表 ==========
cursor.execute("""
CREATE TABLE IF NOT EXISTS employees (
    id INTEGER PRIMARY KEY,
    name TEXT,
    department TEXT,
    salary INTEGER
)
""")

2. 插入示例数据

# ========== 3. 插入示例数据 ==========
sample_data = [    (1, "陈", "开发部", 32000),    (2, "张", "销售部", 20000),    (3, "徐", "开发部", 33000),    (4, "李", "销售部", 15000)]
cursor.executemany('INSERT OR IGNORE INTO employees VALUES (?, ?, ?, ?)', sample_data)
conn.commit()

✅ 使用 INSERT OR IGNORE 避免重复插入导致主键冲突。

3. 获取表结构(Schema)

这是 LLM 理解数据库的关键上下文:

# ========== 4. 获取 Schema(统一用小写表名)==========
schema = cursor.execute("PRAGMA table_info(employees)").fetchall()
schema_str = "CREATE TABLE employees (\n" + "\n".join([f"  {col[1]} {col[2]}" for col in schema]) + "\n);"
print("数据库Schema:")
print(schema_str)

输出:

数据库Schema:
CREATE TABLE employees (
  id INTEGER
  name TEXT
  department TEXT
  salary INTEGER
);

✅ 表名统一为小写 employees,符合 SQLite 实际建表习惯,避免大小写混淆。

4. 接入 LLM(以 DeepSeek 为例)

# ========== 5. 接入 LLM ==========
client = OpenAI(
    api_key='你的 API Key',
    base_url='https://api.deepseek.com/v1'
)

def ask_deepseek(query: str, schema: str) -> str:
    prompt = f"""
这是一个数据库的Schema:
{schema}
根据这个Schema,你能输出一个SQL查询来回答以下问题吗?
只输出SQL查询,不要输出任何其他内容,也不要带任何格式。
问题:{query}
"""
    response = client.chat.completions.create(
        model="deepseek-reasoner",
        max_tokens=256,
        messages=[{"role": "user", "content": prompt}]
    )
    # ✅ 关键:返回纯 SQL 字符串
    return response.choices[0].message.content.strip()

🔑 注意:函数最终返回的是 .content.strip(),确保是干净的 SQL 字符串,而非 ChatCompletionMessage 对象。

5. 自然语言提问 → 执行 SQL

# ========== 6. 自然语言提问 → 生成并执行 SQL ==========
question = "开发部部门员工的姓名和工资是多少?"
generated_sql = ask_deepseek(question, schema_str)
print("\n生成的SQL查询:")
print(generated_sql)

# ✅ 安全提示:此处仅为演示!生产环境需校验SQL
try:
    result = cursor.execute(generated_sql).fetchall()
    print("\n查询结果:")
    for row in result:
        print(row)
except Exception as e:
    print(f"\n执行出错: {e}")

典型输出:

生成的SQL查询:
SELECT name, salary FROM employees WHERE department = '开发部';

查询结果:
('陈', 32000)
('徐', 33000)

✅ 成功!你用中文问问题,AI 自动生成 SQL 并返回结果。

6. 关闭连接(良好习惯)

# ========== 7. 关闭连接 ==========
conn.close()

四、Prompt 工程:让 LLM 更可靠

LLM 不是万能的,好的 Prompt 是稳定输出的关键。我们通过以下设计提升准确性:

技巧 说明
明确 Schema 上下文 必须提供表结构,否则 LLM 会“瞎猜字段”
限制输出格式 “只输出 SQL,不要其他内容” 避免冗余
指定大小写与引号 统一表名小写、字符串用单引号,减少语法错误
示例引导(Few-shot) 可追加 1~2 个输入-输出样例,效果更佳

🔒 安全警告
上述代码中的 cursor.execute(generated_sql) 直接执行 LLM 输出的 SQL,存在严重安全风险(如 SQL 注入、DROP TABLE 等)。
在真实项目中,务必:

  • 使用 白名单机制(只允许 SELECT,禁止 DDL/DML)
  • 对生成的 SQL 进行 AST 解析校验
  • 或采用 参数化查询模板,仅让 LLM 填充 WHERE 条件部分

异步编程三剑客:回调、Promise 与 Async/Await 的奇幻冒险

作者 AAA阿giao
2025年11月20日 23:11

 引言

想象一下,你正在厨房煮一碗泡面。你把水烧上,然后站在灶台前干等——直到水开了才放面。这叫“同步”:一步做完再做下一步。

但现实生活中,我们更聪明:水一烧上,就去刷牙、回消息、逗猫……等水开了再回来。这就是“异步”——不阻塞主线程,高效利用时间。

在 JavaScript 的世界里,异步编程经历了三次“进化”:回调函数 → Promise → Async/Await。今天,我们就用两段代码,带你穿越这场异步革命的奇幻旅程!


第一幕:回调函数 —— “信任的崩塌”

先看这段 Node.js 代码:

// 使用 Node.js 内置的 fs 模块异步读取文件 './1.html'
// 第三个参数是一个回调函数,当文件读取完成(或出错)时被调用
fs.readFile('./1.html', 'utf-8', (err, data) => {
  // 如果发生错误(例如文件不存在),err 会被赋值,此时提前返回并打印错误
  if (err) {
    console.log(err);
    return;
  }
  // 文件成功读取,data 包含文件内容(字符串形式)
  console.log(data);
  // 打印标记,用于观察执行顺序
  console.log(111);
})

这是经典的回调函数写法:你告诉 readFile:“文件读完后,请调用这个函数。”

听起来很美好?但问题来了:

  • 如果要连续读三个文件?
  • 每个操作都依赖上一个的结果?
  • 错误处理层层嵌套?

于是,回调地狱(Callback Hell) 诞生了——代码向右无限缩进,像一座歪斜的塔,随时倒塌。

回调的本质:把“下一步做什么”作为参数传进去。
痛点:控制流混乱、错误难追踪、无法用 try/catch。


第二幕:Promise —— “契约精神的崛起”

为了解决回调的混乱,ES6 带来了 Promise。它像一份“承诺”: “我保证未来会给你一个结果,要么成功(resolve),要么失败(reject)。”

看我们的主角登场:

// 创建一个新的 Promise 实例
// executor 函数接收两个参数:resolve(成功时调用)和 reject(失败时调用)
const p = new Promise((resolve, reject) => {
  // 在 Promise 内部调用异步操作 fs.readFile
  fs.readFile('./1.html', 'utf-8', (err, data) => {
    // 如果读取文件出错,调用 reject 将 Promise 状态变为 rejected
    if (err) {
      reject(err);
      return;
    }
    // 成功读取文件,调用 resolve 将 Promise 状态变为 fulfilled,并传递数据
    resolve(data);
    // 打印标记,用于观察执行时机(注意:此行在 resolve 之后,但仍会执行)
    console.log(111);
    // 再次调用 resolve —— 但无效!Promise 状态一旦确定,无法再次更改
    resolve(data); // 注意:多次 resolve 无效!
  })
})

这里,我们把异步操作包装成一个 Promise 对象。外部可以通过 .then() 接收成功结果,.catch() 捕获错误。

而更妙的是,Promise 支持链式调用

// 当 Promise p 变为 fulfilled 状态时,.then 中的回调会被调用
// 参数 data 即为 resolve(data) 中传入的值
p.then(data => {
  console.log(data);   // 打印文件内容
  console.log(111);    // 打印标记,观察执行顺序
})

Promise 的优势

  • 链式调用,避免嵌套
  • 统一错误处理
  • 状态一旦确定(fulfilled/rejected),不可更改

但…….then().then().catch() 写多了,还是有点啰嗦,不够“同步感”。

Promise详解:Promise:让 JavaScript 异步任务“同步化”的利器- 掘金


第三幕:Async/Await —— “异步即同步”的魔法

终于,ES2017(ES8)带来了终极武器:Async / Await

它让异步代码看起来像同步代码,却丝毫不阻塞主线程。简直是开发者的白月光!

场景一:浏览器中的 GitHub API 调用

// 使用 async 关键字声明一个异步函数
// async 函数总是返回一个 Promise
const main = async () => {
  // await 会暂停函数执行,直到 fetch 返回的 Promise 被 fulfilled
  // res 是 fetch 成功后的 Response 对象
  const res = await fetch('https://api.github.com/users/shunwuyu/repos')
  console.log(res);     // 打印原始响应对象
  console.log(111);     // 标记执行顺序
  // res.json() 也返回一个 Promise,需再次 await 获取解析后的 JSON 数据
  const data = await res.json();
  console.log(data);    // 打印解析后的仓库数据数组
}
// 调用异步函数(不会阻塞后续代码)
main();

看!没有 .then,没有回调,只有 await —— “等这个 Promise 完成,再继续”

场景二:Node.js 中读取文件

// 同样使用 async/await 处理之前定义的 Promise p
const main = async () => {
  // await 等待 p 这个 Promise 完成
  // 如果 p 成功,html 就是 resolve 传入的文件内容
  const html = await p;
  console.log(html);    // 打印文件内容
  console.log(111);     // 标记执行顺序
}
main();

同一个 main 函数,既能在浏览器跑,也能在 Node.js 跑!Async/Await 是跨环境的异步语法糖

Async/Await 的三大魅力

  1. 代码线性可读:从上到下,逻辑清晰
  2. 天然支持 try/catch:错误处理回归传统
  3. 底层仍是 Promise:兼容所有 Promise API

彩蛋:为什么 resolve(data) 写了两次?

眼尖的朋友可能注意到:

resolve(data);
console.log(111);
resolve(data); // ← 这行多余!

Promise 一旦 resolve,状态就锁定,后续的 resolvereject 都会被忽略。所以第二行是无效的——但不会报错。这是 Promise 的“不可逆”特性,确保状态一致性。


总结:异步编程的进化树

方案 优点 缺点 适用场景
回调函数 简单直接 回调地狱、错误难处理 简单单次异步操作
Promise 链式调用、状态管理清晰 语法稍显冗长 复杂异步流程
Async/Await 同步风格、易读易维护 必须在 async 函数中使用 现代项目首选

结语

从回调的信任危机,到 Promise 的契约精神,再到 Async/Await 的魔法降临——JavaScript 的异步编程,正变得越来越优雅、强大。

而你手中的这两段代码,正是这场变革的缩影。

下次当你写下 asyncawait 时,请记住:你不是在写代码,你是在指挥时间——让异步的世界,为你同步运转。

🌟 记住
回调是过去,Promise 是现在,Async/Await 是未来。
而你,正站在未来的起点。


源码

1.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<script>
    // fetch('https://api.github.com/users/shunwuyu/repos')
    // .then(res=> {
    //     //console.log(res.json())
    //     return res.json()
    // }) //函数
    // .then(data=>{
    //     console.log(data)
    // })
    //es8 async 修饰函数
    const main = async ()=>{
        // await等待右边的promise执行完成
        // resolved resolve(data) 交给左边的变量
        const res = await fetch('https://api.github.com/users/shunwuyu/repos')
        console.log(res);
        console.log(111);
        const data = await res.json();
        console.log(data);
    }
    main();
</script>
</body>
</html>

2.mjs

import fs from 'fs';

// es6 之前,回调函数
// fs.readFile('./1.html', 'utf-8', (err, data) => {
//   if (err) {
//     console.log(err);
//     return;
//   }
//   console.log(data);
//   console.log(111);
// })

const p = new Promise((resolve, reject) => {
  fs.readFile('./1.html', 'utf-8', (err, data) => {
    if (err) {
      reject(err);
      return;
    }
    resolve(data);
    console.log(111);
    resolve(data);
  })
})
// p.then(data => {
//   console.log(data);
//   console.log(111);
// })

const main = async () => {
  const html = await p;
  console.log(html);
  console.log(111);
}
main();

从代码示例看 ES8 中的 async/await:简化异步操作的利器

作者 3秒一个大
2025年11月20日 23:07

从代码示例看 ES8 中的 async/await:简化异步操作的利器

在 JavaScript 的异步编程世界里,从回调函数到 Promise,再到 ES8 引入的 async/await,每一次升级都让异步代码的编写和理解变得更加轻松。今天我们就通过一个具体的代码示例,来聊聊 async/await 如何简化异步操作。

传统 Promise 链式调用的局限

在处理异步操作时,Promise 的出现解决了回调地狱的问题,但当异步操作较多时,链式的.then()调用仍可能让代码显得冗长。比如代码中注释掉的这段使用fetch的代码:

fetch('https://api.github.com/users/shunwuyu/repos')
  .then(res => {
    return res.json()
  })
  .then(data => {
    console.log(data);
  })

这段代码通过两次.then()处理异步请求:第一次将响应转为 JSON,第二次打印数据。虽然比回调函数清晰,但多步操作时,链式结构仍不够直观。

async/await:让异步代码 “同步化”

ES8 引入的async/await语法,进一步优化了异步代码的编写方式。它允许我们用同步代码的结构来编写异步操作,极大提升了代码的可读性。

在示例代码中,我们可以看到这种写法的实践:

// es8 async 修饰函数
const main = async() => {
    // await 等待右边的promise, 等待,异步变同步
    // resolved resolve(data) 交给左边的变量
    const res = await fetch('https://api.github.com/users/shunwuyu/repos')
    console.log(res);
    console.log(111);
}
main();

核心语法解析

  1. async 关键字:用于修饰函数(如示例中的main函数),表明该函数内部包含异步操作。被async修饰的函数会自动返回一个 Promise 对象。
  2. await 关键字:只能在async函数内部使用,用于等待一个 Promise 对象。它会暂停当前async函数的执行,直到等待的 Promise 状态变为resolved,然后将 Promise 的结果赋值给左边的变量(如示例中的res),再继续执行后续代码。

代码执行逻辑

在示例中,main函数被async修饰,内部通过await fetch(...)发起网络请求。由于fetch返回一个 Promise 对象,await会等待这个 Promise 完成:

  • fetch请求成功并返回响应后,响应结果会被赋值给res
  • 之后按顺序执行console.log(res)console.log(111),这两行代码的执行顺序和同步代码完全一致,避免了链式调用的嵌套感。

总结

async/await 的出现,让 JavaScript 异步编程进入了一个更简洁的时代。它在 Promise 的基础上,通过 “同步化” 的代码结构,降低了异步逻辑的理解成本。对比传统的.then()链式调用,async/await 更贴近人类的思维习惯,尤其在处理多步依赖的异步操作时,优势更为明显。

从示例代码中可以清晰地看到,原本需要链式处理的异步请求,通过 async/await 变得像同步代码一样直观。这也是为什么 async/await 成为现代 JavaScript 异步编程的主流选择之一。

深入理解 async/await:从原理到实战,彻底掌握 JavaScript 异步编程

作者 玉宇夕落
2025年11月20日 22:40

一、为什么需要 async/await?

在 JavaScript 的异步演进史中,我们经历了:

  • 回调函数(Callback) :嵌套深、难维护(“回调地狱”)
  • Promise(ES6) :链式调用,但 .then().catch() 仍显冗长
  • async/await(ES2017 / ES8) :以同步写法处理异步逻辑,代码更清晰、易读、易调试

核心价值:让异步代码看起来像同步代码,大幅提升可读性与可维护性。


二、async/await 基础语法

1. async 函数

  • 在函数前加 async,该函数自动返回一个 Promise
  • 即使 return 一个普通值,也会被包装成 Promise.resolve(value)
javascript
编辑
async function hello() {
  return "world";
}
// 等价于:
// function hello() {
//   return Promise.resolve("world");
// }

2. await 表达式

  • 只能在 async 函数内部使用
  • 暂停函数执行,等待右侧的 Promise fulfilled,并获取其结果
  • 若 Promise 被 reject,会抛出异常(可用 try/catch 捕获)
javascript
编辑
const data = await fetch('/api/user');
const json = await data.json(); // 继续等待解析

三、实战案例:前端 + Node.js 双场景

场景 1:前端 —— 获取 GitHub 用户仓库(浏览器环境)

html
预览
<!-- index.html -->
<div id="repos"></div>

<script>
const loadRepos = async () => {
  try {
    const res = await fetch('https://api.github.com/users/shunwuyu/repos');
    if (!res.ok) throw new Error(`状态码: ${res.status}`);
    
    const repos = await res.json();
    const html = repos.map(repo => `
      <div class="repo">
        <h3><a href="${repo.html_url}">${repo.name}</a></h3>
        <p>${repo.description || '暂无描述'}</p>
      </div>
    `).join('');
    
    document.getElementById('repos').innerHTML = html;
  } catch (err) {
    console.error('加载失败:', err);
    document.getElementById('repos').innerHTML = `<p>❌ ${err.message}</p>`;
  }
};

loadRepos();
</script>

优势:逻辑线性,错误集中处理,无需 .then().catch() 链。


场景 2:Node.js —— 读取本地 HTML 文件

javascript
编辑
// readHtml.js
import fs from 'fs';
import { promisify } from 'util';

// 方式1:手动封装 Promise
const readFileAsync = (path) => {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      err ? reject(err) : resolve(data);
    });
  });
};

// 方式2(推荐):使用 util.promisify
const readFile = promisify(fs.readFile);

const main = async () => {
  try {
    const html = await readFile('./index.html', 'utf8');
    console.log('文件内容长度:', html.length);
  } catch (err) {
    console.error('读取失败:', err.message);
  }
};

main();

💡 提示:Node.js 内置模块大多支持 promisify,避免重复造轮子。


四、常见误区与注意事项

❌ 误区1:在非 async 函数中使用 await

javascript
编辑
// 报错!SyntaxError: await is only valid in async function
const data = await fetch(...);

❌ 误区2:忽略错误处理

javascript
编辑
// 危险!若 fetch 失败,程序会崩溃
const res = await fetch(url);
const data = await res.json(); // 可能 never reached

正确做法:始终用 try/catch 包裹,或在调用处 .catch()

❌ 误区3:串行执行本可并行的任务

javascript
编辑
// 低效:串行(总耗时 ≈ 2s)
const user = await fetch('/user');
const posts = await fetch('/posts'); 

// 高效:并行(总耗时 ≈ 1s)
const [userRes, postsRes] = await Promise.all([
  fetch('/user'),
  fetch('/posts')
]);
const [user, posts] = await Promise.all([
  userRes.json(),
  postsRes.json()
]);

五、async/await vs Promise:如何选择?

特性 Promise async/await
可读性 中等(链式) 高(类同步)
错误处理 .catch() 分散 try/catch 集中
并行控制 Promise.all() 直观 需配合 Promise.all()
调试体验 断点困难 支持逐行调试
兼容性 ES6+ ES2017+(现代环境基本全覆盖)

建议:新项目优先使用 async/await,复杂并行逻辑辅以 Promise.all / Promise.race


六、总结要点

  • async 函数返回 Promise,await 等待 Promise 结果
  • 必须在 async 函数内使用 await
  • 永远不要忽略错误处理:用 try/catch 或顶层 .catch()
  • 并行任务用 Promise.all() 提升性能
  • Node.js 中善用 util.promisify 封装回调 API

七、拓展思考

  • 如何在不支持 async/await 的老浏览器中使用?→ Babel 编译
  • await 后面如果不是 Promise 会怎样?→ 自动包装为 Promise.resolve(value)
  • 能否在顶层(Top-level)使用 await?→ 可以! (ES2022 支持,Node.js 14.8+ / 现代浏览器)

🌟 终极口诀
async 标函数,await 等结果;
错误要捕获,并行用 all。

C++进阶的智能指针、右值引用、lambda表达式三大核心

2025年11月20日 22:07

C++进阶的“实用突破点”,内容选取高频用到的智能指针、右值引用、lambda表达式三大核心,用生活化类比+精简代码拆解。

一、智能指针:自动收拾烂摊子的“管家”

核心痛点

手动管理 new/delete 容易漏删内存(内存泄漏)、重复删除(野指针崩溃),智能指针能自动释放资源,比裸指针安全10倍。

关键知识点(3种常用类型)

类型 用途 核心特性

 unique_ptr  独占资源(不共享) 不能拷贝,只能移动

 shared_ptr  共享资源(多指针访问) 引用计数,计数为0时释放

 weak_ptr  解决循环引用 不增加引用计数,需锁验证

include <memory>
#include <iostream>
using namespace std;

int main() {
    // 1. unique_ptr:独占所有权
    unique_ptr<int> uptr = make_unique<int>(10); // 推荐用make_unique创建
    cout << *uptr << endl; // 输出10
    // unique_ptr<int> uptr2 = uptr; // 报错:不能拷贝
    unique_ptr<int> uptr2 = move(uptr); // 正确:移动所有权

    // 2. shared_ptr:共享所有权
    shared_ptr<int> sptr = make_shared<int>(20);
    shared_ptr<int> sptr2 = sptr; // 允许拷贝,引用计数变为2
    cout << sptr.use_count() << endl; // 输出2(查看引用计数)

    // 3. 无需手动delete!作用域结束自动释放
    return 0;
}

 

二、右值引用:给代码“提速”的传送门

核心痛点

传统拷贝(如 vector.push_back(大对象) )会复制整个数据,耗时耗内存;右值引用直接“偷”数据,零拷贝提速。

关键概念

  • 左值:能取地址、有名字的变量(如 int a = 5 中的 a )
  • 右值:临时变量、字面量(如 5 、 a+b 的结果)
  • 右值引用符号: && ,专门绑定右值
#include <iostream>
#include <string>
using namespace std;

// 移动构造函数(右值引用实现)
class MyString {
private:
    char* data;
public:
    // 普通构造
    MyString(const char* str) {
        data = new char[strlen(str)+1];
        strcpy(data, str);
        cout << "普通构造:拷贝数据" << endl;
    }

    // 移动构造(&&表示右值引用)
    MyString(MyString&& other) {
        data = other.data; // 直接偷对方的资源
        other.data = nullptr; // 对方置空,避免重复释放
        cout << "移动构造:零拷贝偷数据" << endl;
    }

    ~MyString() { delete[] data; }
};

int main() {
    MyString s1("hello"); // 普通构造:拷贝数据
    // 临时对象是右值,触发移动构造
    MyString s2 = MyString("world"); // 移动构造:零拷贝偷数据
    return 0;
}
 

三、lambda表达式:一行搞定简单函数

核心痛点

写短小的函数(如排序规则、回调函数)时,无需单独定义函数,lambda直接内嵌,代码更简洁。

[捕获列表](参数列表) -> 返回值类型 { 函数体 }
// 捕获列表:获取外部变量([]空捕获,[=]值捕获,[&]引用捕获)
// 返回值类型可省略(编译器自动推导)
  
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> nums = {3, 1, 4, 1, 5};

    // 1. 排序:用lambda代替单独写比较函数
    sort(nums.begin(), nums.end(), [](int a, int b) {
        return a > b; // 降序排序(捕获列表为空,无需外部变量)
    });

    // 2. 遍历:捕获外部变量cout,输出结果
    for_each(nums.begin(), nums.end(), [](int x) {
        cout << x << " "; // 输出:5 4 3 1 1
    });

    return 0;
}

 

总结:3个技巧的核心价值

1. 智能指针:告别内存泄漏,新手也能写安全代码;

2. 右值引用:提升性能,尤其处理大对象/容器时;

3. lambda表达式:简化代码,短小逻辑不用单独定义函数。

❌
❌