普通视图

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

每日一题-删列造序🟢

2025年12月20日 00:00

给你由 n 个小写字母字符串组成的数组 strs,其中每个字符串长度相等。

这些字符串可以每个一行,排成一个网格。例如,strs = ["abc", "bce", "cae"] 可以排列为:

abc
bce
cae

你需要找出并删除 不是按字典序非严格递增排列的 列。在上面的例子(下标从 0 开始)中,列 0('a', 'b', 'c')和列 2('c', 'e', 'e')都是按字典序非严格递增排列的,而列 1('b', 'c', 'a')不是,所以要删除列 1 。

返回你需要删除的列数。

 

示例 1:

输入:strs = ["cba","daf","ghi"]
输出:1
解释:网格示意如下:
  cba
  daf
  ghi
列 0 和列 2 按升序排列,但列 1 不是,所以只需要删除列 1 。

示例 2:

输入:strs = ["a","b"]
输出:0
解释:网格示意如下:
  a
  b
只有列 0 这一列,且已经按升序排列,所以不用删除任何列。

示例 3:

输入:strs = ["zyx","wvu","tsr"]
输出:3
解释:网格示意如下:
  zyx
  wvu
  tsr
所有 3 列都是非升序排列的,所以都要删除。

 

提示:

  • n == strs.length
  • 1 <= n <= 100
  • 1 <= strs[i].length <= 1000
  • strs[i] 由小写英文字母组成

工程化必备!SVG 雪碧图的最佳实践:ID 引用 + 缓存友好,无需手动算坐标

2025年12月19日 22:57

✂️ 图标“合体”大法:SVG 雪碧图如何终结 HTTP 请求地狱,让你的图标秒速加载?

前端性能优化专栏 - 第八篇

在上一篇中,我们探讨了图片加载策略,解决了大图的性能问题。但对于网页中那些零散的小图标,比如点赞、分享、设置等,它们虽然体积小,却有一个致命的性能痛点:每个图标都会发起一个独立的 HTTP 请求!

当页面有几十甚至上百个图标时,浏览器就会发起几十或上百个请求,这在 HTTP/1.1 时代是性能杀手,即使在 HTTP/2 中,过多的请求也会增加 TCP/TLS 握手的开销。

今天,我们就来学习一个古老而又现代的优化技巧——雪碧图(CSS Sprites) ,以及它在现代 SVG 图标体系中的最佳实践。


什么是雪碧图(CSS Sprites)?

雪碧图,最早是为 PNG 这类位图图标的优化而生,在 HTTP/1.1 的时代,它几乎是前端性能优化的标配

  • 定义:将多个小图标合并为一张大图,通过 CSS 的 background-position 属性来显示指定区域。
  • 目的:将多次 HTTP 请求合并为一次,大幅减少网络开销,提高网页加载速度。

为什么要用雪碧图优化 SVG 图标?

虽然 SVG(Scalable Vector Graphics)本身是 XML 文本,具有可缩放、清晰度高、可被 CSS 样式控制等优点,但它依然面临请求过多的性能挑战。

SVG 雪碧图的优化效果体现在:

  1. 减少 HTTP 请求:将多个图标合并成一个文件,实现一次加载,全面优化
  2. 提升缓存效率:浏览器可缓存整个图标集,加快后续页面加载。
  3. 减小文件体积:合并后的文件更容易被服务器进行 Gzip/Brotli 压缩,传输更快。

image.png

🛠️ SVG 雪碧图的两种实现方式

与传统的 PNG 雪碧图不同,SVG 雪碧图拥有更灵活、更强大的实现方式。

1. ✨ <symbol> + <use> 方式(强烈推荐)

这是现代 Web 开发中优化 SVG 图标的最佳实践,它兼具性能、可维护性和样式灵活性。

核心原理:

  1. 将所有图标定义在一个隐藏的 <svg> 容器中,每个图标用 <symbol> 标签包裹,并赋予一个唯一的 id
  2. 在页面需要使用图标的地方,通过 <use> 标签引用 <symbol>id

代码示例:

<!-- 1. 雪碧图文件(通常放在页面顶部或外部引入) -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
  <!-- 这个svg容器本身不会在页面上被渲染出来,作用:作为图标模板的定义库 -->
  <symbol id="icon-heart" viewBox="0 0 24 24">
    <!-- path data for heart -->
  </symbol>
  <symbol id="icon-star" viewBox="0 0 24 24">
    <!-- path data for star -->
  </symbol>
</svg>

<!-- 2. 页面中使用 -->
<svg class="icon icon-red">
  <!-- 通过 xlink:href 引用雪碧图中的图标 ID -->
  <use xlink:href="#icon-heart" /> 
</svg>

优点:

  • 可样式化:可使用 CSS 轻松调整图标的 fill(颜色)和 stroke(描边),实现主题切换。
  • 可访问性:支持 <title><desc> 标签,提升可访问性。
  • 缓存友好:可内联或外部引入,利用浏览器缓存。
  • 维护性高:无需手动计算坐标,只需引用 ID。

2. 🔧 CSS 背景图方式(传统方式)

这种方式与传统的 PNG 雪碧图类似,将 SVG 文件作为背景图,通过 background-position 来控制显示区域。

代码示例:

.icon-heart {
  background-image: url('sprite.svg'); /* 指定了 svg 雪碧图 */
  background-position: 0 0; /* 将图片移动到合适的位置 */
  width: 24px; /* 必须要制定元素的宽高 */
  height: 24px;
}

特点与局限:

  • 优点:实现方式简单,兼容性好。

  • 缺点

    • 样式控制受限:无法用 CSS 改变图标颜色,失去了 SVG 的最大优势。
    • 维护困难:需要手动计算每个图标的 background-position,不适合动态图标或主题切换。

✅ 小结:SVG 雪碧图的最佳实践

方式 性能 样式控制 维护难度 推荐度
<symbol> + <use> 极佳(单次请求,可缓存) 极佳(CSS 可控颜色) 低(ID 引用) ⭐⭐⭐⭐⭐
CSS 背景图 良好(单次请求,可缓存) 差(无法改变颜色) 高(手动定位) ⭐⭐

结论: SVG 雪碧图是现代 Web 图标优化的最佳实践之一,而 <symbol> + <use> 方式兼具性能与可维护性,是图标优化的首选方案。


下一篇预告: 网页中的文字虽然是文本,但自定义字体(Web Font)的加载却是一个巨大的性能黑洞。下一篇我们将探讨如何优化字体加载,避免“FOIT(Flash of Invisible Text) ”和“FOUT(Flash of Unstyled Text) ”等问题,敬请期待!

「chrome extensions🛠️」我写了一个超级简单的浏览器插件Vue开发模板

作者 JustHappy
2025年12月19日 21:28

Hi!这里是JustHappy🚀🚀,一时兴起想开发一个浏览器插件,但是找来找去发现在Vue生态下好像没有一个超轻的简单的模板或者脚手架,看了一圈感觉antFu大佬的vitesse-webext还不错,但是感觉还不够轻,于是我打算手撸仿写一个简单版本

我想要一个什么样的模板

  • 技术栈轻盈:Vue + JS 越简单越好
  • 支持“热更新”:修改后立马更新视图

于是有了这个模板...

image.png

仓库地址是这个: github.com/Simonmie/vu…

如何使用?很简单

你只需要在仓库中点击 use this template 就可以使用该模板去构建插件

image.png

开始开发吧!

安装依赖

npm install

模板结构

├── assets
├── background
│   ├── dev-hmr.js  // 开发环境下的热更新脚本
│   └── main.js  // 背景脚本
├── logic
│   └── common-setup.js  // 公共设置脚本
├── manifest.js // manifest.json 生成脚本
├── options // 选项页
│   ├── OptionsPage.vue 
│   ├── index.html
│   └── main.js
├── popup // 弹窗
│   ├── PopupComponent.vue
│   ├── index.html
│   └── main.js
├── sidepanel // 侧边栏
│   ├── SidePanel.vue
│   ├── assets
│   │   └── logo.png
│   ├── index.html
│   └── main.js
└── utils
    ├── base.js // 基础工具函数
    └── config.js // 配置文件函数

如何开发?

启动热更新

npm run dev:ext

安装扩展

  1. 打开 Chrome 浏览器。
  2. 点击浏览器菜单(通常是三个垂直点图标),选择“更多工具”>“扩展程序”。
  3. 在扩展程序页面,打开“开发者模式”。
  4. 点击“加载已解压的扩展程序”,选择项目根目录下的 extension 文件夹。

然后你就可以愉快的开始开发浏览器插件了。你几乎只需要会Vue和JS就可以开发,或者结合大模型快速生成一个插件

这是我用Gemini 3 pro结合这个模板生成的其中一个插件的效果,基本上完全可用

image.png

如果你也想尝试,这是这个插件的github仓库地址 github.com/Simonmie/Te…


下面我们来聊聊这个框架的“热更新”原理吧....

”热更新“原理

有人问我:“为什么这个模板能做到类似 HMR 的体验?浏览器插件不是不能热更新吗”

答案其实很简单:

不是模块级热替换,而是自动重建 + 自动刷新。

当你修改代码时:

  • 构建器会重建产物
  • 热更新服务会给扩展发送通知
  • 前台视图刷新、后台脚本重载
  • 浏览器扩展整体更新

完全不需要手动刷新窗口,不需要重新点击扩展图标。

更关键的是:整个机制非常轻,非常干净。

一个极小的热更新服务

模板启动后会同时启动一个本地服务,用于监听构建变化并向扩展发送消息。

这个服务通过 SSE(Server-Sent Events)工作:

SSE 的好处是:

  • 轻量
  • 无需额外依赖
  • 无需轮询
  • 特别稳定

你甚至可以把它理解为:一个特别简单的“更新广播器”。

前台页面如何刷新?

扩展里的 popup、options、sidepanel 页面都会自动注入一个监听器:

  1. 通过 EventSource 连接 SSE 服务
  2. 收到 reload 信号
  3. window.location.reload()

所以改完代码保存后:
→ UI 会立即重新加载
→ 新的代码会直接生效

不用点击,不用重打开 popup 页面,连 DevTools 都不用动。

后台脚本如何更新?

在开发模式下,后台脚本并不会直接运行正式的 background 逻辑,而是先接入一个开发专用的脚本。

这个脚本专门负责监听 SSE:

  1. 收到消息
  2. chrome.runtime.reload()

这会让整个扩展瞬间重载:

  • UI 刷新
  • 脚本刷新
  • 状态重置

这种方式非常适合开发场景,因为不用担心缓存、不一致、后台仍在运行等问题。

自动重连机制

SSE 连接如果断开,比如:

  • 小断网
  • 浏览器切换标签
  • 系统休眠
  • 构建器重启

扩展会自动重试连接。

这意味着:
你只需要改代码 → 保存 → 浏览器自动更新
不用关心底层连接是否断过、重连过。

它就是一直能用。

如果这对你有帮助,哈哈求个star✨,模板大概率还有很多不足,欢迎大家提交issue、pr等,或者单纯骚扰我😜

阿里邮件下载器使用说明

2025年12月19日 19:09

邮件下载器使用说明

📋 项目简介

这是一个基于Node.js的邮件自动下载和分类工具,能够通过IMAP协议连接到邮箱服务器,自动下载所有邮件并按照联系人进行分类保存。

🗂️ 目录结构

程序运行后会自动创建以下目录结构:

E:\邮箱下载\pds_server\分类邮件库\
├── 收件箱\                           # 所有收到的邮件
   ├── 来自_联系人A\                  # 某个联系人发来的邮件
      ├── 2024-01-01_邮件主题.eml   # 邮件原始文件
      ├── 附件\                      # 该联系人的所有附件
         ├── 1640995200000_文件1.pdf
         └── 1640995200000_图片.jpg
      └── _index.json               # 该联系人邮件索引
   ├── 来自_联系人B\
   └── ...
├── 已发送\                           # 所有发送的邮件
   ├── 发给_联系人A\                  # 发送给某个联系人的邮件
      ├── 2024-01-01_回复邮件.eml
      ├── 附件\
      └── _index.json
   └── ...
└── _下载记录.json                    # 全局下载记录文件

📁 文件说明

邮件文件 (.eml)

  • 格式:标准RFC 822邮件格式
  • 命名规则日期_主题.eml
    • 日期格式:YYYY-MM-DD
    • 主题:自动替换特殊字符,最多50个字符
  • 用途:可用Outlook、Foxmail等邮件客户端打开

附件文件

  • 存储位置:各联系人目录下的附件文件夹
  • 命名规则时间戳_原文件名
  • 时间戳:防止重名文件冲突
  • 支持格式:所有类型的邮件附件

索引文件 (_index.json)

每个联系人目录下都会生成一个索引文件,包含:

{
  "emails": [
    {
      "seqno": 123,
      "subject": "邮件主题",
      "date": "2024-01-01T12:00:00.000Z",
      "emlFile": "2024-01-01_邮件主题.eml",
      "infoFile": "邮件信息.json",
      "hasAttachments": true
    }
  ]
}

下载记录文件 (_下载记录.json)

全局下载记录,记录所有已下载邮件的UID:

{
  "downloadedUids": [12345, 12346, 12347],
  "inboxTotal": 364,
  "sentTotal": 50,
  "inboxProcessed": 100,
  "sentProcessed": 20,
  "lastUpdate": "2024-01-01T12:00:00.000Z"
}

🚀 使用方法

1. 环境准备

# 安装Node.js依赖
npm install

# 确保安装了必要的包
npm install imap mailparser cli-progress

2. 配置邮箱信息

在代码中修改邮箱配置:

const EMAIL_USER = 'your-email@example.com';
const EMAIL_PASSWORD = 'your-password';

3. 运行程序

cd E:\邮箱下载\pds_server
node routes/email.js

4. 查看下载进度

程序会实时显示:

  • 连接状态
  • 处理进度
  • 下载速度
  • 错误信息

⚙️ 功能特性

✨ 主要功能

  • 自动分类:按发件人/收件人自动分类
  • 断点续传:支持中断后继续下载
  • 附件处理:自动保存所有附件
  • 进度显示:实时显示下载进度和速度
  • 错误处理:自动跳过问题邮件,继续处理

🔧 技术特点

  • 批量处理:支持批次下载,提高效率
  • 超时保护:30秒超时机制,防止卡死
  • 并发控制:合理控制并发数量,避免服务器压力
  • 增量更新:只下载新邮件,跳过已下载邮件

📊 性能参数

  • 批次大小:5封邮件/批次(可调整)
  • 超时时间:30秒/邮件
  • 休息间隔:每20封邮件休息1秒
  • 记录保存:每10封邮件保存一次记录

🛡️ 安全说明

邮箱安全

  • 建议使用应用专用密码而非主密码
  • 支持SSL/TLS加密连接
  • 不会标记邮件为已读

数据安全

  • 所有数据保存在本地
  • 不会上传到任何云服务
  • 建议定期备份邮件文件

🔧 故障排除

常见问题

  1. 连接失败

    • 检查邮箱密码是否正确
    • 确认IMAP服务是否开启
    • 检查网络连接
  2. 下载卡住

    • 程序会自动超时跳过
    • 可以Ctrl+C中断,下次运行会继续
  3. 文件保存失败

    • 检查磁盘空间
    • 确认写入权限
    • 检查文件名是否包含特殊字符

日志分析

程序会输出详细日志:

  • ✅ 成功下载:邮件下载成功
  • ⏭️ 已跳过:邮件已存在或超时
  • ❌ 失败:处理出错,可手动查看

📈 统计信息

程序运行完成后会显示:

  • 总邮件数量
  • 成功下载数量
  • 跳过重复数量
  • 处理失败数量
  • 总耗时和平均速度
  • 联系人邮件分布统计

💡 使用建议

  1. 首次运行:建议在网络良好时运行
  2. 大量邮件:分时段运行,避免服务器限制
  3. 定期备份:定期备份分类邮件库文件夹
  4. 查看邮件:使用邮件客户端打开.eml文件

📞 技术支持

如遇到问题,请检查:

  1. Node.js版本(建议14+)
  2. 网络连接状态
  3. 邮箱IMAP设置
  4. 磁盘空间和权限

版本:1.0 更新日期:2025-12-19 开发者:江城开朗的豌豆

HarmonyOS应用开发之瀑布流、上拉加载、无限滚动一文搞定

作者 Quarkn
2025年12月19日 18:15

瀑布流(WaterFlow)

瀑布流常用于展示图片信息,尤其在购物和资讯类应用中。ArkUI提供了WaterFlow容器组件,用于构建瀑布流布局。WaterFlow组件支持条件渲染、循环渲染和懒加载等方式生成子组件。

瀑布流支持横向和纵向布局。

  • 在纵向布局中,可以通过columnsTemplate设置列数。
  • 在横向布局中,可以通过rowsTemplate设置行数。

在瀑布流的纵向布局中,第一行的子节点按从左到右顺序排列,从第二行开始,每个子节点将放置在当前总高度最小的列。如果多个列的总高度相同,则按照从左到右的顺序填充。如下图:

在这里插入图片描述

在瀑布流的横向布局中,每个子节点都会放置在当前总宽度最小的行。若多行总宽度相同,则按照从上到下的顺序进行填充。

在这里插入图片描述

基本使用

瀑布流常用于无限滚动的信息流。可以在瀑布流组件到达末尾位置时触发的onReachEnd事件回调,配合LazyForEach增加新数据,并将footer做成正在加载新数据的样式。

如下图所示

在这里插入图片描述

接下来,按照以下步骤实现上图的效果。

准备数据源

需要使用LazyForEach渲染子组件时,数据源必须是IDataSource的实现类。创建WaterFlowDataSource.ets,用于给WaterFlow瀑布流组件加载数据。

在构造函数中初始化100条数据,并提供获取数据、修改数据、添加数据、删除数据、获取数据总数据等函数。

// WaterFlowDataSource.ets

// 实现IDataSource接口的对象,用于瀑布流组件加载数据
export class WaterFlowDataSource implements IDataSource {
  private dataArray: number[] = [];
  private listeners: DataChangeListener[] = [];

  constructor() {
    for (let i = 0; i < 100; i++) {
      this.dataArray.push(i);
    }
  }

  // 获取索引对应的数据
  public getData(index: number): number {
    return this.dataArray[index];
  }

  // 通知控制器数据重新加载
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    })
  }

  // 通知控制器数据增加
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    })
  }

  // 通知控制器数据变化
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    })
  }

  // 通知控制器数据删除
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
    })
  }

  // 通知控制器数据位置变化
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
    })
  }

  //通知控制器数据批量修改
  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    })
  }

  // 获取数据总数
  public totalCount(): number {
    return this.dataArray.length;
  }

  // 注册改变数据的控制器
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  // 注销改变数据的控制器
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // 增加数据
  public add1stItem(): void {
    this.dataArray.splice(0, 0, this.dataArray.length);
    this.notifyDataAdd(0);
  }

  // 在数据尾部增加一个元素
  public addLastItem(): void {
    this.dataArray.splice(this.dataArray.length, 0, this.dataArray.length);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  // 在指定索引位置增加一个元素
  public addItem(index: number): void {
    this.dataArray.splice(index, 0, this.dataArray.length);
    this.notifyDataAdd(index);
  }

  // 删除第一个元素
  public delete1stItem(): void {
    this.dataArray.splice(0, 1);
    this.notifyDataDelete(0);
  }

  // 删除第二个元素
  public delete2ndItem(): void {
    this.dataArray.splice(1, 1);
    this.notifyDataDelete(1);
  }

  // 删除最后一个元素
  public deleteLastItem(): void {
    this.dataArray.splice(-1, 1);
    this.notifyDataDelete(this.dataArray.length);
  }

  // 在指定索引位置删除一个元素
  public deleteItem(index: number): void {
    this.dataArray.splice(index, 1);
    this.notifyDataDelete(index);
  }

  // 重新加载数据
  public reload(): void {
    this.dataArray.splice(1, 1);
    this.dataArray.splice(3, 2);
    this.notifyDataReload();
  }

  // 在数据尾部增加count个元素
  public addNewItems(count: number): void {
    let len = this.dataArray.length;
    for (let i = 0; i < count; i++) {
      this.dataArray.push(this.dataArray[len - 1] + i + 1);
      this.notifyDataAdd(this.dataArray.length - 1);
    }
  }

  // 刷新所有元素
  public refreshItems(): void {
    let newDataArray: number[] = [];
    for (let i = 0; i < 100; i++) {
      newDataArray.push(this.dataArray[0] + i + 1000);
    }
    this.dataArray = newDataArray;
    this.notifyDataReload();
  }
}

使用LazyForEach循环渲染

为了使WaterFlow的每一个FlowItem尺寸不同,以达到交错的效果,采用随机数生成[80,180)的宽高并使用itemWidthArrayitemHeightArray保存。

具体代码如下

import { WaterFlowDataSource } from '../datasource/WaterFlowDataScource';

@Entry
@Component
struct Index {
  //瀑布流的数据源
  private dataSource: WaterFlowDataSource = new WaterFlowDataSource();
  //每一个FlowItem的高度
  private itemWidthArray: number[] = [];
  private itemHeightArray: number[] = [];

  // 计算FlowItem宽/高
  getSize() {
    let ret = Math.floor(Math.random() * 180);
    return (ret > 80 ? ret : 80);
  }

  // 设置FlowItem的宽/高数组
  setItemSizeArray() {
    for (let i = 0; i < 100; i++) {
      this.itemWidthArray.push(this.getSize());
      this.itemHeightArray.push(this.getSize());
    }
  }
  
  aboutToAppear() {
    this.setItemSizeArray();
  }

  build() {
    Column() {
      WaterFlow() {
        LazyForEach(this.dataSource, (item: number, index: number) => {
          FlowItem() {
            Column() {
              Text(`${item}`).fontSize(20).fontWeight(FontWeight.Bold)
            }.width("100%")
            .height("100%")
            .justifyContent(FlexAlign.Center)
            .backgroundColor(Color.White)
          }
          .width('100%')
          .height(this.itemHeightArray[index])
        })
      }.columnsTemplate("1fr 1fr")
      .columnsGap(10)
      .rowsGap(10)
      .padding(10)
    }.width("100%")
    .height("100%")
    .backgroundColor("#9ACEED")
  }
}

上拉加载

添加尾部组件

在创建WaterFlow时,通过footer参数设置尾部组件,当上拉到底部时显示。

由于加载数据需要时间,在加载时需要给用户视觉上的反馈。我们给加载尾部组件定义2种状态LadingEnd表示加载中和到底了。

在这里插入图片描述

// Index.ets
import { WaterFlowDataSource } from './WaterFlowDataSource';

//尾部组件状态
enum FooterState {
  Loading = 0,
  End = 1
}

//尾部组件
@Builder
itemFooter() {
    // 不要直接用IfElse节点作为footer的根节点。
    Column() {
      if (this.footerState == FooterState.Loading) {
        Text(`加载中...`)
          .fontSize(10)
          .backgroundColor(Color.Red)
          .width(50)
          .height(50)
          .align(Alignment.Center)
          .margin({ top: 2 })
      } else if (this.footerState == FooterState.End) {
        Text(`到底啦...`)
          .fontSize(10)
          .backgroundColor(Color.Red)
          .width(50)
          .height(50)
          .align(Alignment.Center)
          .margin({ top: 2 })
      } else {
        Text(`Footer`)
          .fontSize(10)
          .backgroundColor(Color.Red)
          .width(50)
          .height(50)
          .align(Alignment.Center)
          .margin({ top: 2 })
      }
    }
}

itemFooter绑定个WaterFlow

WaterFlow({
  footer: this.itemFooter()
}){
    //...
}

添加尾部监听

WaterFlow设置尾部监听的回调,onReachEnd(event: () => void) 瀑布流内容到达末尾位置时触发。

WaterFlow({
  footer: this.itemFooter()
}){
    //...
}
// 触底加载数据
.onReachEnd(() => {
    //每次到到底部,检查是否还有数据可加载(这里模拟到达200条数据时,无数据可加载)
    if (this.dataSource.totalCount() >= 200) {
        this.footerState = FooterState.End
        return
    }

    //2s后,添加100条数据
    setTimeout(() => {
    for (let i = 0; i < 100; i++) {
      this.dataSource.addLastItem()
    }
    }, 2000)
})

此时测试滑到底部时,加载100条数据,2秒后更新列表数据。

提前加载数据

虽然在onReachEnd()触发时加载数据可以实现无限加载,但在滑动到底部会出现明显的停顿。

为了实现更加流畅的无限滑动,需要调整增加新数据的时机。比如可以在LazyForEach还剩余若干个数据未遍历的情况下提前加载新数据。

如下图所示,在触底前20条时开始加载数据

在这里插入图片描述

代码如下

WaterFlow({
  footer: this.itemFooter()
}){
    //...
}
//提前20条加载数据
.onScrollIndex((first: number, last: number) => {
  if (last + 20 >= this.dataSource.totalCount()) {
    setTimeout(() => {
      this.dataSource.addNewItems(100);
    }, 1000);
  }
})

对鸿蒙感兴趣的同学,免费考取鸿蒙开发者认证

鸿蒙应用开发之@Builder自定义构建函数:值传递与引用传递与UI更新

作者 Quarkn
2025年12月19日 18:13

@Builder装饰器:自定义构建函数

@Builder装饰的函数称为自定义构建函数,它是一种轻量化 UI 复用机制,它允许开发者将重复使用的 UI 元素抽象为函数,这些函数可以在 build 函数中被调用以实现 UI 复用。

自定义构建函数根据定义的位置不同分为私有自定义函数全局自定义函数

私有自定义构建函数

私有自定义构建函数定义在@Component组件内,属于该组件私有的,只能在该组件内build函数或者其他自定义构建函数中调用。

@Component
struct MyComponent {
  build() {
    Column() {
      this.myBuilder()
    }
  }

  @Builder
  myBuilder() {
    Text("私有@Builder")
  }
}

@Entry
@Component
struct Index {
  build() {
    Column({ space: 10 }) {
      MyComponent()
    }
    .width("100%")
    .height("100%")
  }
}

全局自定义构建函数

全局自定义函数定义在@Component组件外,可以在其他自定义函数或者其他自定义组件中调用。

@Builder
function myBuilder() {
  Text("全局@Builder")
}

@Entry
@Component
struct Index {
  build() {
    Column({ space: 10 }) {
      myBuilder()
    }
    .width("100%")
    .height("100%")
  }
}

@Builder与@Component的区别

经过上面的描述可能会发现@Component和@Builder都可以用于UI复用,他们的区别如下:

  • @Builder:纯UI逻辑复用,无独立状态管理与生命周期,必须通过参数传递的方式与调用方完成数据交互。
  • @Component:完整组件封装,拥有独立状态管理与生命周期。

自定义构建函数参数传递规则

自定义构建函数的参数传递有按值传递按引用传递两种,均需遵守以下规则:

  • 参数的类型必须与参数声明的类型一致,不允许undefined、null和返回undefined、null的表达式。
  • @Builder装饰的函数内部,不允许改变参数值。
  • 只有当传入一个参数且该参数直接传入对象字面量时,才会按引用传递,其他传递方式均为按值传递。

按值传递参数

定义一个全局自定义构建函数textBuilder(text: string),参数只有一个且不是字面量对象,所以按照值传递。

@Builder
function textBuilder(text: string) {
  Text("Hello,"+text).fontSize(30)
}

调用@Builder装饰的函数默认按值传递。当传递的参数为状态变量时,状态变量的改变不会引起@Builder函数内的UI刷新


@Entry
@Component
struct Index {
  @State text: string = 'World'

  build() {
    Column() {
      textBuilder(this.text)
      Button("修改text").onClick(() => {
        //修改状态变量,不会引用@Builder函数中UI的更新
        this.text = "ArkUI"
      })
    }
  }
}

如下图,经测试状态变量的改变不会引起@Builder函数内UI刷新

在这里插入图片描述

按引用传递参数

定义一个@Builder自定义构建函数,参数为自定义对象类型。

class Params {
  text: string = ''
}

@Builder
function textBuilder(params: Params) {
  Text(`Hello, ${params.text}`).fontSize(30)
}

调用按引用传递参数时,传递的参数可为状态变量,且状态变量的改变会引起@Builder函数内的UI刷新。

@Entry
@Component
struct Index {
  @State text: string = 'World'

  build() {
    Column() {
      textBuilder({
        text: this.text
      })
      Button("修改text").onClick(() => {
        //修改状态变量,不会引用@Builder函数中UI的更新
        this.text = "ArkUI"
      })
    }
  }
}

预览效果如下图所示:

在这里插入图片描述

限制条件

  • @Builder自定义函数内部不允许修改参数值,否则框架会抛出运行时异常。
  • @Builder自定义构建函数存在两个或两个以上的参数时,即使通过对象字面量形式传递,值的改变也不会触发UI刷新。

示例1:在@Builder中修改参数值

class Params {
  text: string = ''
}

@Builder
function textBuilder(params: Params) {
  Text(`Hello, ${params.text}`).fontSize(30)
    .onClick(() => {
      params.text = "哈哈哈"//【错误】,禁止改参数的值
    })
}

如下图所示,出现预览错误

在这里插入图片描述

示例2:在@Builder中接收两个参数,不会触发UI更新。

import { promptAction } from "@kit.ArkUI"

class Params {
  text: string = ''
}

@Builder
function textBuilder(params: Params,num:number) {
  Column(){
    Text(`Hello, ${params.text}`).fontSize(30)
      .onClick(() => {
        params.text = "哈哈哈"
      })

    Text(`${num}`)
  }

}

@Entry
@Component
struct Index {
  @State text: string = 'World'

  build() {
    Column() {
      textBuilder({
        text: this.text
      },100)
      Button("修改text").onClick(() => {
        //修改状态变量,不会引用@Builder函数中UI的更新
        this.text = "ArkUI"
      })
    }
  }
}

如下图,当@Builder接收2个参数时,UI更新不生效。 在这里插入图片描述 对鸿蒙感兴趣的同学,免费考取鸿蒙开发者认证

HarmonyOS应用开发之滚动容器Scroll

作者 Quarkn
2025年12月19日 18:10

滚动容器(Scroll)

Scroll表示可滚动的容器组件,当子组件的布局尺寸超过父组件的尺寸时,内容可以滚动。

[!warning]

注意两个要点:

  1. Scroll子组件的布局尺寸超过父组件尺寸时,才可以滚动
  2. Scroll只能有一个子组件

基本使用

如图所示,在Column中包含10Text文本,每个Text文本的高度为100,间距为10

Scroll内子组件的总高度大于Scroll父组件的高度,所以可以滚动。

在这里插入图片描述

代码如下

@Entry
@Component
struct Index {
  @State array: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  build() {
    Column(){
      Scroll(){
        Column({ space: 20 }) {
          ForEach(this.array, (item: number) => {
            Text(`${item}`)
              .width("100%")
              .textAlign(TextAlign.Center)
              .width("100%")
              .height(100)
              .borderRadius(15)
              .backgroundColor(Color.White)
          })
        }
        .padding(10)
      }
    }.width("100%")
    .height("100%")
    .backgroundColor("#dedede")
  }
}

关闭滚动条

通过.scrollBar(barState: BarState)设置滚动条的显示模式

  • BarState.Off 关闭滚动条
  • BarState.On 显示滚动条
  • BarState.Auto 滑动时显示滚动条,3秒后消失
Scroll(){
    ...
}.scrollBar(BarState.Off)   //关闭滚动条

在这里插入图片描述

滚动方向

通过.scrollable(value: ScrollDirection)属性用来设置Scroll的滚动方向。

参数

  • ScrollDirection.Vertical 垂直滚动
  • ScrollDirection.Horizontal 水平滚动

示例:在水平方向显示10个Text,沿水平方向滚动。

在这里插入图片描述

代码如下

@Entry
@Component
struct Index {
  @State array: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  build() {
    Column(){
      Scroll(){
        Row({ space: 20 }) {
          ForEach(this.array, (item: number) => {
            Text(`${item}`)
              .width("100%")
              .textAlign(TextAlign.Center)
              .width(100)
              .height(100)
              .borderRadius(15)
              .backgroundColor(Color.White)
          })
        }.padding(10)
      }
      .scrollBar(BarState.Off)//关闭滚动条
      .scrollable(ScrollDirection.Horizontal) //水平滚动方向
    }.width("100%")
    .height(120)
    .backgroundColor("#dedede")
  }
}

滚动条样式

通过scrollBarWidth(20)和scrollBarColor(Color.Red)设置滚动条的宽度与颜色。

在这里插入图片描述

Scroll(){
       ...
}
.scrollBar(BarState.Auto)
.scrollable(ScrollDirection.Horizontal) //滚动方向
.scrollBarWidth(10)//滚动条宽度
.scrollBarColor(Color.Red) //滚动条颜色

滚动控制器

通过给Scroll配置Scroller滚动控制器,来控制Scroll的滚动。

Scroller有如下方法控制Scroll滚动

  • scrollEdge(value: Edge, options?: ScrollEdgeOptions | undefined) 滚动到容器边缘
  • scrollTo(options: ScrollOptions) 滚动到指定位置
    • xOffset: 水平偏移量(相对于容器开始位置)
    • yOffset: 垂直偏移量(相对于容器开始位置)
  • scrollBy(dx: Length, dy: Length) 滚动指定距离。
    • dx: 水平滚动距离(相对于当前位置)
    • dy: 垂直滚动距离(相对于当前位置)

如下图所示,点击按钮时控制Scroll滚动到底部、顶部、中间位置,此时就需要用到Scroller滚动控制器。

在这里插入图片描述

代码如下

@Entry
@Component
struct Index {
  @State array: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  private scorller: Scroller = new Scroller()

  build() {
    Column() {
      Scroll(this.scorller) {
        Column({ space: 20 }) {
          ForEach(this.array, (item: number) => {
            Text(`${item}`)
              .width("100%")
              .textAlign(TextAlign.Center)
              .width("100%")
              .height(100)
              .borderRadius(15)
              .backgroundColor(Color.White)
          })
        }.padding(10)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Auto)
      .scrollable(ScrollDirection.Vertical) //滚动方向
      .scrollBarWidth(10)
      .scrollBarColor(Color.Red)

      Row() {
        Button("到底部").onClick(() => {
          this.scorller.scrollEdge(Edge.Bottom)
        })

        Button("到顶部").onClick(() => {
          this.scorller.scrollEdge(Edge.Top)
        })

        Button("到中间").onClick(() => {
          animateToImmediately({
            duration: 300,
            playMode: PlayMode.Normal,
            curve: Curve.Ease
          }, () => {
            //滑动到指定位置
            this.scorller.scrollTo({
              xOffset: 0,
              yOffset: 300
            })
          })
        })

        Button("滚动一段").onClick(() => {
          //滑动到指定位置
          animateToImmediately({
            duration: 300,
            playMode: PlayMode.Normal,
            curve: Curve.Ease
          }, () => {
            //滑动到指定位置
            this.scorller.scrollBy(0, 100)
          })
        })
      }.width("100%")
      .height(200)
      .alignItems(VerticalAlign.Center)
      .justifyContent(FlexAlign.SpaceEvenly)

    }.width("100%")
    .height("100%")
    .backgroundColor("#dedede")
  }
}

滚动监听

有时候需要监听当前Scroll滚动的状态,根据状态做出相应的处理,此时需要用到滚动监听。

  • onDidScroll(handler: ScrollOnScrollCallback) 滚动事件回调,Scroll滚动时触发。返回当前帧滚动的偏移量和当前滚动状态。
    • xOffset: 每一帧的水平偏移量
    • yOffset: 每一帧的垂直偏移量

触发该事件的条件:

  1. 滚动组件触发滚动时触发,支持键鼠操作等其他触发滚动的输入设置。
  2. 通过滚动控制器API接口调用。
  3. 越界回弹。

示例代码: 监听Scroll滚动的距离,当向下滚动200像素时,弹出提示,效果如下图所示

在这里插入图片描述

代码如下

import { promptAction } from '@kit.ArkUI'

@Entry
@Component
struct Index {
  @State array: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  private scorller: Scroller = new Scroller()
  //记录相对于Scroll开始位置的偏移量
  private yOffset_total: number = 0

  build() {
    Column() {
      Scroll(this.scorller) {
        Column({ space: 20 }) {
          ForEach(this.array, (item: number) => {
            Text(`${item}`)
              .width("100%")
              .textAlign(TextAlign.Center)
              .width("100%")
              .height(100)
              .borderRadius(15)
              .backgroundColor(Color.Pink)
          })
        }.padding(10)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Auto)
      .scrollable(ScrollDirection.Vertical) 
      .scrollBarWidth(10)
      .scrollBarColor(Color.Red)
      .backgroundColor("#dedede")
       //监听Scorll滚动,计算总偏移量
      .onDidScroll((xOffset: number, yOffset) => {
        this.yOffset_total += yOffset
        if (this.yOffset_total >= 200 && yOffset > 0) {
          promptAction.openToast({ message: "滚动到200了" })
        }
        console.log("偏移量:" + this.yOffset_total)
      })
    }.width("100%")
    .height("100%")
  }
}

在滚动时,监听滚动偏移量并打印,如下图所示,总偏移量yOffset_total是依次递增的。

在这里插入图片描述

到此,Scroll的使用以及Scroller滚动控制器就介绍完了。Scroller滚动控制器在其他可滚动组件也同样适用。

对鸿蒙感兴趣的同学,免费考取鸿蒙开发者认证

🔥 纯 JS 实现 SQL 字段智能解析工具类,前端也能玩转 SQL 解析

作者 酸菜土狗
2025年12月19日 18:08

🔥 纯 JS 实现 SQL 字段智能解析工具类,前端也能玩转 SQL 解析

在前端开发中,我们偶尔会遇到需要解析 SQL 语句的场景 —— 比如可视化 SQL 编辑器、数据大屏字段映射、低代码平台的 SQL 配置解析等。如果每次都靠后端返回字段信息,不仅增加联调成本,还会降低前端交互的灵活性。

今天给大家分享一个我封装的纯 JavaScript SQL 字段解析工具类,无需依赖任何第三方库,就能快速提取 SQL 中的查询字段、表名、字段别名等核心信息,兼容绝大多数常见的 SELECT 语法场景。

image.png

🎯 工具类核心能力

这个工具类专为前端场景设计,主打轻量、易用、兼容性强,核心功能包括:

  • 📝 SQL 预处理:自动清理注释、多余空格,统一语法格式
  • 🔍 字段提取:支持提取原始字段、纯字段名(剔除表别名 / 函数包裹)
  • 📌 别名解析:兼容AS 别名和直接字段 别名两种写法,生成别名映射表
  • 📊 表名识别:从 FROM 子句中提取表名,自动跳过表别名和嵌套查询
  • 🚨 嵌套查询标记:快速识别 SQL 中是否包含子查询,便于特殊处理
  • ✨ 零依赖:纯原生 JS 实现,无需引入 SQL 解析库

🛠 核心代码实现

完整工具类代码

javascript

运行

/**
 * SQL解析工具类
 * 特性:
 * 1. data_field 优先使用别名,无别名时用实际字段(物理字段去前缀/字符串常量原始值)
 * 2. 物理字段(如"ERP_Provider"."No")无别名时提取纯字段名(No)
 * 3. 字符串常量字段(如'入库单')无别名时保留原始值作为字段名
 * 4. 支持 PostgreSQL 特有语法(DISTINCT ON)和嵌套子查询
 * 5. 完全按SQL原始内容解析,不做自定义生成
 */
class SqlParser {
    /**
     * 从SQL语句中提取字段信息
     * @param {String} sql - 待解析的SQL语句
     * @returns {Array} 格式化后的字段列表
     */
    static extractFormattedFields(sql) {
        // 防御:处理空SQL或非字符串输入
        if (!sql || typeof sql !== 'string') return [];

        // 清理SQL:移除注释、多余空格/换行,保留核心结构
        const cleanedSql = this.cleanSQL(sql);

        // 提取目标SQL(优先解析子查询,若外层是 select * from (...))
        const targetSql = this.extractTargetSql(cleanedSql);

        // 验证SQL有效性(必须含SELECT/FROM,且SELECT在FROM前)
        if (!this.isValidSql(targetSql)) {
            console.warn('无效SQL:缺少SELECT/FROM或顺序错误');
            return [];
        }

        // 提取SELECT和FROM之间的字段部分(兼容DISTINCT ON)
        const upperSql = targetSql.toUpperCase();
        const selectStart = upperSql.indexOf('SELECT') + 6;
        // 处理DISTINCT ON:跳过ON后的括号内容
        const distinctOnEnd = this.findDistinctOnEnd(targetSql, selectStart);
        const fromStart = upperSql.indexOf('FROM');
        const fieldsPart = targetSql.substring(distinctOnEnd, fromStart).trim();

        // 分割字段并解析(处理括号内逗号)
        return this.splitFields(fieldsPart)
            .map(token => token.trim())
            .filter(token => token)
            .map(token => this.parseToFormattedField(token))
            .filter(field => field); // 过滤解析失败的字段
    }

    /**
     * 清理SQL:移除注释和多余空格
     */
    static cleanSQL(sql) {
        return sql
            .replace(/--.*$/gm, "") // 移除单行注释
            .replace(/\/\*[\s\S]*?\*\//g, "") // 移除多行注释
            .replace(/\s+/g, ' ') // 压缩空格
            .trim();
    }

    /**
     * 提取目标SQL:若外层是 select * from (子查询),则解析子查询
     */
    static extractTargetSql(cleanedSql) {
        const subqueryRegex = /SELECT\s+\*\s+FROM\s+\(([\s\S]*)\)\s+AS\s+\w+/i;
        const subqueryMatch = cleanedSql.match(subqueryRegex);
        if (subqueryMatch && subqueryMatch[1]) {
            return subqueryMatch[1].trim(); // 子查询作为目标
        }
        return cleanedSql; // 否则用原始SQL
    }

    /**
     * 定位DISTINCT ON的结束位置(跳过括号内容)
     */
    static findDistinctOnEnd(sql, selectStart) {
        const distinctOnRegex = /DISTINCT\s+ON\s*\(/i;
        const match = sql.substring(selectStart).match(distinctOnRegex);
        if (!match) return selectStart; // 无DISTINCT ON,直接返回SELECT起始位置

        // 计算DISTINCT ON(...)的结束索引(跳过括号内内容)
        const onStart = selectStart + match.index + match[0].length;
        let bracketCount = 1; // 已进入一个括号
        let currentIndex = onStart;

        while (currentIndex < sql.length && bracketCount > 0) {
            const char = sql[currentIndex];
            if (char === '(') bracketCount++;
            if (char === ')') bracketCount--;
            currentIndex++;
        }

        return currentIndex; // 返回DISTINCT ON(...)后的位置
    }

    /**
     * 智能分割字段(处理括号内/字符串内的逗号,避免误分割)
     */
    static splitFields(fieldsPart) {
        const fields = [];
        let currentField = '';
        let bracketCount = 0;
        let inQuote = false; // 标记是否在字符串引号内

        for (const char of fieldsPart) {
            // 处理单/双引号切换(字符串内逗号不分割)
            if (char === '"' || char === "'") {
                inQuote = !inQuote;
                currentField += char;
                continue;
            }

            // 仅当:不在引号内 + 括号计数为0 → 用逗号分割字段
            if (char === ',' && !inQuote && bracketCount === 0) {
                fields.push(currentField);
                currentField = '';
                continue;
            }

            // 更新括号计数(仅当不在引号内)
            if (!inQuote) {
                if (char === '(') bracketCount++;
                if (char === ')') bracketCount = Math.max(0, bracketCount - 1);
            }

            currentField += char;
        }

        // 添加最后一个未分割的字段
        if (currentField.trim()) fields.push(currentField.trim());
        return fields;
    }

    /**
     * 解析单个字段令牌(核心:data_field优先用别名)
     */
static parseToFormattedField(token) {
    try {
        // 移除引号(不改变原始逻辑,仅清理格式)
        const cleanToken = token
            .replace(/"([^"]+)"/g, '$1')  // 移除字段名双引号(如 "No" → No)
            .replace(/'([^']+)'/g, '$1')  // 移除字符串单引号(如 '入库单' → 入库单)
            .trim();

        // 1. 区分物理字段和字符串常量字段
        const isStringConst = !cleanToken.includes('.') && !cleanToken.includes('(') && !cleanToken.includes(')') && isNaN(cleanToken);

        // 2. 解析别名(支持 AS 别名、空格别名)
        let fieldExpr, alias;
        const asRegex = /\s+as\s+/i;

        if (asRegex.test(cleanToken)) {
            // 场景1:带AS的别名(如 No AS 单号、"ERP_Provider"."No" AS 供应商编号)
            [fieldExpr, alias] = cleanToken.split(asRegex).map(item => item.trim());
        } else {
            // 场景2:空格分隔的别名(如 No 单号、SUM(Amount) 总金额)
            const lastSpaceIndex = cleanToken.lastIndexOf(' ');
            if (lastSpaceIndex > -1) {
                const potentialAlias = cleanToken.substring(lastSpaceIndex + 1).trim();
                // 排除别名含括号/运算符的情况(避免误判函数内空格)
                if (!potentialAlias.includes('(') && !potentialAlias.includes(')') && !/[+\-*/=<>]/.test(potentialAlias)) {
                    fieldExpr = cleanToken.substring(0, lastSpaceIndex).trim();
                    alias = potentialAlias;
                } else {
                    // 无有效别名:字段表达式=完整令牌,别名为空
                    fieldExpr = cleanToken;
                    alias = '';
                }
            } else {
                // 场景3:无空格(无别名,如 No、'入库单'、SUM(Amount))
                fieldExpr = cleanToken;
                alias = '';
            }
        }

        // 变量:tableWithRealField → 专门存储“表名+该字段真实名称”(有别名时有效,无别名时为null)
        let tableWithRealField = null;
        // 仅当“有别名”且“是物理字段”时,才提取表名和真实字段名
        if (alias && !isStringConst) {
            // 从 fieldExpr 中提取表名和真实字段名(处理函数包裹场景,如 SUM(ERP_Order.Amount) → ERP_Order.Amount)
            let realFieldExpr = fieldExpr;
            const funcMatch = fieldExpr.match(/\(([^)]+)\)/);
            if (funcMatch && funcMatch[1]) {
                realFieldExpr = funcMatch[1].trim(); // 去除函数包裹(如 SUM(...) → ...)
            }

            // 分割表名和真实字段名(如 ERP_Provider.No → 表名=ERP_Provider,真实字段名=No)
            const exprParts = realFieldExpr.split('.').filter(part => part.trim());
            if (exprParts.length >= 2) {
                const tableName = exprParts[0].trim(); // 表名
                const realFieldName = exprParts.slice(1).join('.').trim(); // 真实字段名(兼容字段名含“.”的极端场景)
                tableWithRealField = `${tableName}.${realFieldName}`; // 格式:表名.真实字段名
            }
        }

        // 3. 核心逻辑:data_field 优先用别名,无别名用实际字段
        let dataField;
        if (alias) {
            // 有别名 → data_field = 别名
            dataField = alias;
        } else {
            // 无别名 → 按字段类型取实际值
            dataField = isStringConst
                ? fieldExpr  // 字符串常量→原始值(如 '入库单' → 入库单)
                : this.getPureFieldName(fieldExpr); // 物理字段→去前缀(如 ERP_Provider.No → No)
        }

        // 4. data_title 保持原逻辑(显示名称,优先用别名)
        const dataTitle = alias || this.extractSimpleName(fieldExpr);

        // 5. 推断数据类型和枚举选项
        const dataType = this.inferDataType(dataField, dataTitle, token);
        const enumOptions = dataType === 'enum' ? this.extractEnumOptions(token) : [];

        return {
            data_title: dataTitle,    // 显示名称(原逻辑不变)
            data_field: dataField,    // 字段标识(别名优先!)
            FormType: this.getFormTypeByDataType(dataType),
            enumOptions: enumOptions,
            search_data_field: fieldExpr,
            tableWithRealField: tableWithRealField // 专门存储“表名+该字段真实名称”(有别名时有效)
        };
    } catch (error) {
        console.warn(`字段解析失败,跳过:${token}`, error);
        return null;
    }
}

    /**
     * 物理字段提取纯字段名(无别名时用:去除表名前缀、函数包裹)
     */
    static getPureFieldName(fieldExpr) {
        // 处理函数包裹(如 SUM(ERP_StorageIn.Amount) → ERP_StorageIn.Amount)
        const funcMatch = fieldExpr.match(/\(([^)]+)\)/);
        if (funcMatch && funcMatch[1]) {
            fieldExpr = funcMatch[1].trim();
            // 递归处理嵌套函数(如 SUM(COALESCE(Amount, 0)) → Amount)
            if (/\w+\(/.test(fieldExpr)) {
                return this.getPureFieldName(fieldExpr);
            }
        }

        // 去除表名前缀(如 ERP_Provider.No → no、"ERP_StorageIn"."Amount" → Amount)
        const dotIndex = fieldExpr.lastIndexOf('.');
        return dotIndex > -1
            ? fieldExpr.substring(dotIndex + 1).trim()
            : fieldExpr.trim();
    }

    /**
     * 提取简单名称(无别名时用于 data_title)
     */
    static extractSimpleName(fieldExpr) {
        // 处理函数(如 SUM(Amount) → Amount)
        const funcMatch = fieldExpr.match(/\(([^)]+)\)/);
        if (funcMatch && funcMatch[1]) {
            fieldExpr = funcMatch[1].trim();
        }

        // 处理表名前缀(如 ERP_Provider.No → No)
        const dotIndex = fieldExpr.lastIndexOf('.');
        if (dotIndex > -1) {
            return fieldExpr.substring(dotIndex + 1).trim();
        }

        return fieldExpr.trim();
    }

    /**
     * 从CASE语句提取枚举选项
     */
    static extractEnumOptions(token) {
        const options = [];
        const upperToken = token.toUpperCase();

        if (upperToken.includes('CASE') && upperToken.includes('WHEN') && upperToken.includes('THEN')) {
            const caseStart = upperToken.indexOf('CASE');
            const caseEnd = upperToken.indexOf('END');
            if (caseStart !== -1 && caseEnd !== -1) {
                const caseContent = token.substring(caseStart + 4, caseEnd).trim();
                const whenParts = caseContent.split(/WHEN/i).filter(part => part.trim());

                for (const part of whenParts) {
                    const thenIndex = part.toUpperCase().indexOf('THEN');
                    if (thenIndex !== -1) {
                        const optionText = part.substring(thenIndex + 4).trim()
                            .replace(/['"]/g, '')
                            .replace(/[,;]/g, '')
                            .split(/\s+/)[0];

                        if (optionText) options.push({ label: optionText, value: optionText });
                    }
                }
            }
        }

        return options;
    }

    /**
     * 推断数据类型
     */
    static inferDataType(fieldName, alias, originalToken) {
        const upperField = fieldName.toUpperCase();
        const upperAlias = alias.toUpperCase();
        const upperToken = originalToken.toUpperCase();

        // CASE语句→枚举
        if (upperToken.includes('CASE') && upperToken.includes('WHEN') && upperToken.includes('THEN')) {
            return 'select';
        }

        // 日期类型
        const dateKeys = ['DATE', 'TIME', 'DAY', 'MONTH', 'YEAR', '日期', '时间', '天'];
        const dateFuncs = ['DATE_PART', 'CURRENT_DATE', 'DATE_TRUNC', 'CREATETIME'];
        if (dateFuncs.some(f => upperToken.includes(f)) || dateKeys.some(k => upperField.includes(k) || upperAlias.includes(k))) {
            return 'date';
        }

        // 数字类型
        const numKeys = ['NUM', '数量', '金额', '天数', 'INT','DOUBLE','单价','分数','总数','余额','价格','成本','重量','指数','率','额'];
        const numFuncs = ['SUM', 'COUNT', 'AVG', 'COALESCE'];
        if (numFuncs.some(f => upperToken.includes(f)) || /[+\-*/]/.test(upperToken) || numKeys.some(k => upperField.includes(k) || upperAlias.includes(k))) {
            return 'double';
        }

        // 枚举类型
        const enumKeys = ['STATE', 'TYPE', '状态', '类型','enum','等级','种类','类别','级别','等级','性别','分类'];
        if (enumKeys.some(k => upperField.includes(k) || upperAlias.includes(k))) {
            return 'select';
        }

        // 默认字符串
        return 'string';
    }

    /**
     * 获取表单组件类型
     */
    static getFormTypeByDataType(dataType) {
        const typeMap = {
            'date': 'date',
            'number': 'double',
            'double': 'double',
            'string': 'string',
            'enum': 'select',
            'select': 'select'
        };
        return typeMap[dataType] || 'string';
    }

    /**
     * 验证SQL有效性
     */
    static isValidSql(sql) {
        const upperSql = sql.toUpperCase();
        const selectIndex = upperSql.indexOf('SELECT');
        const fromIndex = upperSql.indexOf('FROM');
        // 排除SELECT * 无具体字段的场景
        const hasFields = selectIndex + 6 < fromIndex && sql.substring(selectIndex + 6, fromIndex).trim() !== '*';
        return selectIndex !== -1 && fromIndex !== -1 && selectIndex < fromIndex && hasFields;
    }
}

export default SqlParser;

🚀 快速使用示例

基础使用

javascript

运行

// 测试SQL(包含别名、函数、表别名)
const testSql = `
  SELECT 
    t.id AS user_id, 
    t.name, 
    t.age, 
    MAX(t.score) AS max_score,
    t.address
  FROM 
    user_info t
  WHERE 
    t.age > 18
  GROUP BY 
    t.id, t.name
`;

// 一键解析
const result = SqlFieldParser.quickParse(testSql);

// 输出结果
console.log('原始字段列表:', result.fields); // ["t.id", "t.name", "t.age", "t.score", "t.address"]
console.log('纯字段名:', result.pureFields); // ["id", "name", "age", "score", "address"]
console.log('字段别名映射:', result.fieldAliasMap); // { user_id: "t.id", max_score: "t.score" }
console.log('涉及表名:', result.tables); // ["user_info"]
console.log('是否有嵌套查询:', result.hasNestedQuery); // false

输出结果说明

字段 类型 说明
fields Array 原始字段列表(含表别名 / 函数包裹前的字段)
pureFields Array 纯字段名(剔除表别名、函数,仅保留字段本身)
fieldAliasMap Object 别名映射表(别名 → 原始字段)
tables Array 去重后的表名列表
hasNestedQuery Boolean 是否包含嵌套查询
reverseAliasMap Object 反向别名映射(原始字段 → 别名)

🎨 适用场景

  1. 可视化 SQL 编辑器:解析用户输入的 SQL,自动提取字段用于表单 / 表格渲染
  2. 低代码平台:解析配置的 SQL 语句,实现字段映射、数据预览
  3. 数据大屏 / 报表工具:自动识别 SQL 中的维度 / 指标字段,简化配置
  4. 前端数据校验:校验 SQL 中是否包含指定字段,避免非法查询
  5. SQL 格式化工具:辅助提取核心信息,优化格式化效果

📈 扩展方向

这个工具类是基础版,满足大部分前端场景需求,你可以根据业务扩展:

  1. 支持 JOIN 表解析:扩展parseTables方法,解析 JOIN 子句中的关联表
  2. 递归解析嵌套查询:对(SELECT ...)形式的子查询做递归解析
  3. 支持 INSERT/UPDATE 语法:新增parseInsertFields/parseUpdateFields方法
  4. 语法错误提示:增加 SQL 语法合法性校验,返回错误位置
  5. 结合专业解析库:如需更精准的语法分析,可集成sql-parser等库增强能力

💡 核心设计思路

  1. 预处理优先:先清理注释、统一格式,避免因 SQL 写法不规范导致解析失败
  2. 正则精准匹配:针对 SELECT/FROM 核心子句设计专属正则,兼顾兼容性和性能
  3. 分层解析:先提取核心片段,再拆分字段 / 表名,降低解析复杂度
  4. 轻量优先:前端场景下,避免引入重量级解析库,用原生 JS 实现核心能力

🎯 兼容性说明

✅ 支持的 SQL 语法:

  • 基础 SELECT 查询(含 DISTINCT/TOP 关键字)
  • 字段别名(AS 别名 / 直接别名)
  • 表别名(如 user_info t
  • 函数包裹字段(MAX/COUNT/CONCAT 等)
  • 多表查询(FROM 后多表逗号分隔)
  • 含 WHERE/GROUP BY/ORDER BY 的复杂查询

❌ 暂不支持(可扩展):

  • 复杂嵌套子查询的深度解析
  • INSERT/UPDATE/DELETE 语句解析
  • 非常规 SQL 语法(如存储过程、自定义函数)

📝 总结

这个工具类的核心价值在于前端自主解析 SQL,摆脱对后端的依赖,提升交互体验。代码结构清晰,易于扩展,适合作为前端 SQL 解析的基础组件。

如果你有类似的业务场景,直接复制代码就能用,也可以根据自己的需求扩展功能。如果觉得有用,欢迎点赞收藏,也欢迎在评论区交流更多扩展思路~

完整代码已整理好,可直接复制到项目中使用,建议根据实际业务场景做个性化调整。

脚手架步骤流程

2025年12月19日 18:05

搭建脚手架步骤流程:

  • 在自己的目录新建一个文件夹,命名为test-hp-cli
  • 进入文件夹,执行命令npm init -y
  • 根目录新建bin文件夹,在目录下新建index.js文件
  • 在index.js文件中文件最上方写入以下代码
#!/usr/bin/env node

console.log('hello world');
  • 在package.json文件中,修改bin字段为"bin": "bin/index.js"
  • 执行命令npm link,在全局环境下创建一个软链接到当前目录的node_modules文件夹下。这样就可以在任何地方通过命令行使用该脚手架了。例如:test-hp-cli hello world
  • 如果要发布到npm上,需要先注册一个账号,然后登录npm,再执行以下命令即可上传包:npm publish --access public

发布npm包的方法:

  • 登录npm账号:npm login, 输入用户名、密码和邮箱
  • 发布包失败,可能是因为包名已经被占用或者不符合npm的命名规范。可以尝试修改包名为其他名称,例如将test-hp-cli改为@test/hp-cli

  • npm全局安装脚手架:npm install -g test-hp-cli

  • 执行脚手架命令:test-hp-cli,执行失败提示没有这个命令,检查package.json中的bin字段是否正确,key值需要和包名一致。例如"test-hp-test": "bin/index.js"

  • 修改包名后重新发布,需要更新版本号,否则也会发布失败。例如npm version patch,然后重新发布:npm publish 代表执行成功的提示信息:

image.png

  • 如何安装远程版本,不使用本地的软链接

调试本地脚手架:

  • 进入到脚手架目录执行npm i -g test-hp-cli,查看bin目录下文件链接的路径可以看出来是链接到本地的
  • 执行npm link后,会在Node_modules中创建一个test-hp-cli的命令,命令链向的是node_modules下的test-hp-cli目录,node_modules下的test-hp-cli又链向本地的hp-test。

未发布的包如何调试:

  • 进入到脚手架目录执行npm link,此时会在全局环境下创建一个软链接到当前目录的node_modules文件夹下。这样就可以在任何地方通过命令行使用该脚手架了

注册命令&参数解析

  • process.argv可以获取到命令行参数,执行结果

  • b3e664c53250df61ac5cda230fa64b22.png

  • 选项和参数解析

17f17f719e1efd329e084c31a980ca34.png

lerna

是一个基于git+npm的多package项目的管理工具 为了解决什么问题?

  • 解决脚手架开发过程中的一些重复性操作
    • 场景一:例如开发一个大型项目,依赖一个工具类的包,如果这个包更新后怎么更新其他的包(需要npm link)n遍进行验证
    • 场景二:依赖版本全部更新,需要手动删除node_modules并且重新执行npm i,并且package.json是自动增减的不可能手动增加
    • 场景三:多package单元测试
    • 代码提交,大型项目都共用的同给一个仓库
    • 代码发布,每个包都要发一个单独的npm包,发布难以管理
  • 版本一致性问题
  • 发布时版本一致性,版本一致性的好处:升级时一起升级,否则开发时需要不停的版本调试,版本一致性时只需要保证当前的版本即可,不会存在向上和向下兼容问题
  • 发布后项目依赖版本升级 优势:
  • 大幅度减少重复操作
  • 提升操作标准化

架构优化目的:项目复杂度提升后,需要对项目进行架构优化,架构优化的主要目标往往以提升效能为核心。 使用lerna的项目参考:

创建脚手架

  • 创建文件夹test-cli-dev
  • 在test-cli-dev下再创建test-cli-dev目录
  • 执行cnpm i -D lerna
  • 执行lerna init 可能会报错 解决方法:
  • 在package.json中添加如下代码:

81cc6ec76c09bee49d7a49e8e480e9c7.png

"workspaces": [
   "packages/*"
 ],

重新执行lerna init会生成如下内容: 帮我们自动生成了gitignore,lerna.json等文件

0fd3c6b2b0c81b0d4398c511114d6550.png

  • 创建package -> lerna create 创建core包lerna create core 修改package name为@test-cli-dev/core

    这样命名 "name": "@test-cli-dev/utils",的好处是在npm上可以防止目录重复,npm i安装后的目录结构如下:

aec397ece0673196e47938ab74469b87.png

  • lerna addv7之前的版本使用,对照表

212cdc9f1840cd2b918baa6a1ce742d4.png 使用lerna add命令会报如下错误:

2aedb24f1a57b9d282433f0253c85f23.png v8版本需要结合pnpm使用,教程参考:lerna.js.org/docs/recipe… 本地依赖,教程参考:pnpm.io/workspaces

  • 将包安装在某个特定的空间
  • bb9b8f8c80bb9d7efc4a6d9445031f87.png 命令pnpm add @test-cli-dev/utils --filter @test-cli-dev/core,关于filter命令参考:pnpm.io/zh/filterin…
  • 全局安装依赖pnpm add -w @test-cli-dev/utils会在和lerna.json平级的packages.json中新增如下配置:
"dependencies": {
    "@test-cli-dev/utils": "workspace:^"
  }

发布流程: lerna publish,发布之前必须要进行一次代码提交,否则会报错 发布后默认没有公开发布时私有的,可以通过命令进行配置文档参考:pnpm.io/zh/cli/publ…

lerna 源码分析

调试代码:lerna仓库:github.com/lerna/lerna 项目结构:从项目介绍中可以知道主要的目录为一下6个

  • e2e 端到端测试,用来模拟用户场景,验证程序能否正常运行
  • integration 一些遗留代码,包含端到端和单元测试内容,新的e2e测试已经覆盖这些场景,应该优先用e2e
  • libs 源码库,用于组合发布包
  • packages 要发布到npm上的包
  • tools 一些js工具类
  • website 站点展示也就是lerna官网 入口文件:packages\lerna\package.json,可以查看其中的bin配置,找到入口文件,发现是
  "bin": {
    "lerna": "dist/cli.js"
  },

而不是src/cli.js,为什么要这么配呢? 原因:lerna的源码是ts项目(nodejs无法运行),而npm包需要发布的是编译后的、可执行的js代码,dist目录就是准备被发布的最终产品目录。 如果是这样我们怎么进行调试呢?

  • 添加调试文件,可以让ai读整个项目自己生成一份
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Lerna CLI",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "node",
      "program": "${workspaceFolder}/packages/lerna/src/index.ts",
      "args": ["init"],
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "restart": true,
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    },
    {
      "name": "Debug Lerna Build",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "build"],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen"
    },
    {
      "name": "Debug Specific Command",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "node",
      "program": "${workspaceFolder}/packages/lerna/src/index.ts",
      "args": ["version", "--no-git-tag-version", "--no-push"],
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "restart": true,
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    }
  ]
}
  • 执行npm run build命令,生成dist文件,否则运行时bin文件配置会找不到

Chrome DevTools 详解系列之 Elements面板

2025年12月19日 17:43

接触前端的第一天,你可能就会查看该面板了,但你知道其他的用法功能吗?

引言

作为前端开发者,Elements 面板是我们日常工作中使用最频繁的工具之一。然而,大多数开发者仅停留在基础的 DOM 查看和样式修改层面,未能充分发挥其强大的高级功能。本文将带你深入探索 Elements 面板的高级特性,从 DOM 结构调试到样式优化,从布局分析到性能调优,全方位提升你的调试效率和问题解决能力。


一、Elements 面板架构与工作原理

1.1 面板底层机制

Elements 面板并非简单地展示 DOM 树,它背后有着复杂的实现机制:

┌─────────────────────────────────────────────────────────┐
│                     Chrome 渲染引擎                      │
├───────────┬─────────────────────────────────────────────┤
│ DOM 树    │ 样式计算树 (Style Calculation Tree)          │
└────┬──────┴────────────────┬────────────────────────────┤
     │                       │                            │
     ▼                       ▼                            │
┌────────────────┐ ┌─────────────────────┐                │
│ DOM 节点操作    │ │ 样式规则匹配与计算   │                │
└────┬───────────┘ └────────┬────────────┘                │
     │                      │                             │
     └─────────────┬────────┘                             │
                   ▼                                      │
┌─────────────────────────────────────────────────────────┐
│                 Elements 面板界面                        │
├─────────────────────────────────────────────────────────┤
│ 左侧:DOM 树结构  ┃ 右侧:样式、布局、事件、断点面板        │
└─────────────────────────────────────────────────────────┘

关键机制

  • Elements 面板实时同步渲染引擎的 DOM 树和样式计算结果
  • 所有操作都是双向的:修改面板内容会同步到页面,页面变化也会实时反映到面板
  • 样式计算基于 CSSOM(CSS 对象模型)和 DOM 树的结合

1.2 性能影响分析

使用 Elements 面板时需要注意其对页面性能的影响:

  • 频繁的样式修改会触发重排(Reflow)和重绘(Repaint)
  • DOM 结构修改会影响页面的渲染流程
  • 高级功能(如 DOM 断点)会增加 JavaScript 执行开销

优化建议

// 批量修改 DOM 时,使用 DocumentFragment 减少重排
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    fragment.appendChild(div);
}
document.body.appendChild(fragment);

二、DOM 高级操作与调试技巧

2.1 DOM 树的高级导航

2.1.1 搜索与过滤高级技巧

// CSS 选择器搜索
#app > .container .item

// XPath 搜索
//div[@class='container']//span[text()='Hello']

// 文本内容搜索
:contains("Error")

在这里插入图片描述

高级搜索功能

  • 使用 Ctrl + F 打开搜索框,支持正则表达式
  • 搜索结果高亮显示,可使用上下箭头导航
  • 支持多种搜索类型:CSS 选择器、XPath、文本内容

鄙人喜欢使用 css 选择器、文本内容去搜(简单、快捷)

2.1.2 DOM 节点映射与定位

// 在 Console 中直接引用 Elements 面板选中的节点
$0 // 当前选中的节点
$1 // 上一个选中的节点
$2 // 上上一个选中的节点

// 使用 copy 命令复制节点
copy($0) // 复制节点的 HTML 结构
copy($0.outerHTML) // 复制包括节点本身的 HTML

实用技巧

  • 在 Elements 面板中右键点击节点 → 选择 "Store as global variable",可将节点保存为全局变量
  • 使用 inspect($0) 命令在 Elements 面板中定位 Console 中的 DOM 节点

2.2 DOM 动态变化调试

2.2.1 DOM 断点深度解析

断点类型 触发条件 使用场景
子树修改 子节点添加、删除、修改 调试动态组件渲染、列表更新
属性修改 属性值变化 调试样式变化、状态更新
节点移除 节点被删除 调试组件卸载、动态清理

高级用法

// 结合 Console API 调试 DOM 变化
const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        console.log('DOM 变化类型:', mutation.type);
        console.log('变化的节点:', mutation.target);
    });
});

observer.observe(document.body, {
    childList: true,
    attributes: true,
    subtree: true
});

2.2.2 Mutation Observer 与 Elements 面板结合

  1. 在 Elements 面板中设置 DOM 断点
  2. 当断点触发时,在 Sources 面板中查看调用栈
  3. 结合 Mutation Observer API 追踪所有 DOM 变化

在这里插入图片描述

三、样式系统深度调试

3.1 样式优先级与继承机制

3.1.1 样式优先级计算

Elements 面板会自动计算样式优先级,显示为:

内联样式 (1000) > ID 选择器 (100) > 类选择器 (10) > 标签选择器 (1)

高级技巧

  • 在样式面板中,被覆盖的样式会显示为灰色并带有删除线
  • 悬停在样式规则上,可查看该规则的具体优先级数值
  • 使用 !important 会将优先级提升到最高,但应尽量避免使用

在这里插入图片描述

3.1.2 CSS 变量与自定义属性高级调试

:root {
    --primary-color: #007bff;
    --secondary-color: #6c757d;
    --spacing-unit: 8px;
}

.card {
    background-color: var(--primary-color);
    padding: calc(var(--spacing-unit) * 2);
    border: 1px solid var(--secondary-color);
}

调试技巧

  • 在 Elements 面板中,CSS 变量会显示为蓝色,可点击查看其定义
  • 支持实时修改 CSS 变量,所有使用该变量的元素会立即更新
  • 在 "计算样式" 面板中,可查看 CSS 变量的最终计算值

在这里插入图片描述

3.2 样式性能分析与优化

3.2.1 重排与重绘追踪

// 在 Console 中启用渲染性能指标
console.time('reflow');
// 执行可能触发重排的操作
document.querySelector('.card').style.width = '200px';
console.timeEnd('reflow');

Elements 面板辅助功能

  • 打开 "Rendering" 面板(Cmd + Shift + P → 搜索 "Rendering")
  • 勾选 "Paint flashing":重绘区域会显示为绿色
  • 勾选 "Layout Shift Regions":布局偏移区域会显示为蓝色

e6e9e65a-d3df-4fd5-ba40-ed9460a9bfbb.gif

3.2.2 样式计算优化

/* 低效的选择器链 */
body > div.container > ul.list > li.item > a.link {
    color: #007bff;
}

/* 优化后的选择器 */
.item .link {
    color: #007bff;
}

优化建议

  • 使用更简洁的选择器,减少样式计算时间
  • 避免使用复杂的后代选择器和兄弟选择器
  • 将常用样式提取为单独的类,提高复用性

四、布局系统深度分析

4.1 盒模型与定位机制

4.1.1 盒模型高级调试

.box {
    width: 200px;
    height: 100px;
    padding: 20px;
    border: 2px solid #007bff;
    margin: 10px;
    /* 盒模型计算方式 */
    box-sizing: border-box; /* 推荐 */
    /* box-sizing: content-box;  默认 */
}

盒模型查看技巧

  • 在 Elements 面板的 "布局" 选项卡中查看盒模型可视化
  • 支持切换 content-boxborder-box 模式
  • 显示元素的实际占用空间(包括 margin)

4.1.2 定位与层叠上下文

.absolute-element {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 10;
}

.relative-element {
    position: relative;
    z-index: 5;
}

层叠上下文调试

  • 在 "Computed" 面板中查看元素的 z-index 和层叠上下文
  • 使用 "Layers" 面板(Cmd + Shift + P → 搜索 "Layers")查看元素的渲染层
  • 分析元素的层叠顺序,解决层叠冲突问题

调试的时候主要关注以下信息

50110596-0a1f-4e0a-8e40-be998501c3d6.png

当前网页渲染的时候其实是分了多个图层的,请看下面的动画展示

9c512ff9-03fc-4a12-8e9e-e9a52cd913fa.gif

可以明显的看到上下层级关系

4.2 Flexbox 与 Grid 布局高级调试

4.2.1 Flexbox 布局分析

.flex-container {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
}

.flex-item {
    flex: 1 1 200px;
    min-width: 150px;
}

Flexbox 调试功能

  • 在 "Layout" 选项卡中勾选 "Flexbox",页面会显示弹性布局网格线
  • 显示 justify-contentalign-items 等属性的可视化效果
  • 实时调整 flex 属性,查看布局变化

4.2.2 Grid 布局分析

.grid-container {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: auto 1fr auto;
    grid-template-areas: 
        "header header header"
        "main main sidebar"
        "footer footer footer";
    gap: 20px;
}

.header { grid-area: header; }
.main { grid-area: main; }
.sidebar { grid-area: sidebar; }
.footer { grid-area: footer; }

Grid 调试功能

  • 在 "Layout" 选项卡中勾选 "Grid",页面会显示网格线和区域
  • 显示网格线编号和轨道大小
  • 支持实时修改网格属性,查看布局变化

ea345686-fed0-4f35-8e39-2981afdbcadc.png

五、事件系统与交互调试

5.1 事件监听器深度分析

5.1.1 事件捕获与冒泡机制

const button = document.querySelector('.btn');

// 捕获阶段触发
button.addEventListener('click', () => {
    console.log('捕获阶段');
}, true);

// 冒泡阶段触发
button.addEventListener('click', () => {
    console.log('冒泡阶段');
}, false);

事件监听器查看技巧

  • 在 "Event Listeners" 选项卡中查看元素的所有事件监听器
  • 显示事件类型、处理函数、捕获/冒泡阶段
  • 支持过滤特定类型的事件

5.1.2 事件委托与性能优化

// 低效:为每个元素添加事件监听器
const items = document.querySelectorAll('.item');
items.forEach(item => {
    item.addEventListener('click', () => {
        console.log('Item clicked');
    });
});

// 高效:使用事件委托
const container = document.querySelector('.container');
container.addEventListener('click', (e) => {
    if (e.target.classList.contains('item')) {
        console.log('Item clicked');
    }
});

调试建议

  • 使用 Elements 面板查看事件监听器的数量和分布
  • 识别不必要的事件监听器,进行清理
  • 使用事件委托减少事件监听器数量

9d2b99ef-7784-46a8-a7ba-03223abaa18d.png

5.2 交互状态与动画调试

5.2.1 伪类状态调试

.button {
    padding: 10px 20px;
    background-color: #007bff;
    color: white;
    transition: all 0.3s ease;
}

.button:hover {
    background-color: #0056b3;
    transform: translateY(-2px);
}

.button:active {
    transform: translateY(0);
}

伪类调试技巧

  • 在 Elements 面板中右键点击元素 → 选择 "Force State",可强制激活伪类状态
  • 支持 :hover:active:focus:visited 等伪类
  • 实时查看伪类状态下的样式变化

bc535b49-5bcc-48af-8531-f339c3b7fa99.gif

这个调试技巧很实用,不知道的话,你调试hover,active样式的时候很抓狂!怎么样,收下吧!

5.2.2 CSS 动画与过渡调试

@keyframes slideIn {
    from {
        transform: translateX(-100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

.animated-element {
    animation: slideIn 0.5s ease-out;
}

动画调试功能

  • 在 "Styles" 面板中查看动画属性和关键帧
  • 使用 "Animations" 面板(Cmd + Shift + P → 搜索 "Animations")分析动画
  • 支持暂停、重播、调整动画速度等操作

24c86173-d20e-4f32-9cf6-da0a7b234afe.png


六、Elements 面板性能优化

6.1 面板使用性能最佳实践

  1. 减少不必要的实时编辑:批量修改样式,减少重排重绘次数
  2. 关闭不需要的面板:只打开当前需要的面板,减少内存占用
  3. 合理使用断点:避免在高频变化的 DOM 节点上设置断点
  4. 定期清理全局变量:避免在 Console 中创建过多全局变量

6.2 Elements 面板与其他面板协同工作

┌─────────────────────────────────────────────────────────┐
│                     Elements 面板                       │
└───────────────┬─────────────────────────────────────────┘
                │
┌───────────────┼─────────────────────────────────────────┐
│  性能分析     │  Sources 面板:调试 DOM 断点              │
│  性能面板:追踪重排重绘时间                               │
├───────────────┼─────────────────────────────────────────┤
│  内存分析     │  Memory 面板:检测 DOM 内存泄漏           │
├───────────────┼─────────────────────────────────────────┤
│  网络分析     │  Network 面板:分析样式资源加载            │
└───────────────┴─────────────────────────────────────────┘

协同工作流程

  1. 在 Elements 面板中定位问题节点
  2. 使用 Performance 面板分析渲染性能
  3. 在 Sources 面板中调试相关代码
  4. 使用 Memory 面板检测内存泄漏

七、高级案例分析与实战

7.1 复杂布局问题调试

问题:响应式布局在某些设备上出现元素重叠

解决方案

  1. 定位问题

    • 使用 Elements 面板的设备工具栏切换到问题设备视图
    • 选中重叠的元素,查看其盒模型和定位属性
    • 分析元素的 z-index 和层叠上下文
  2. 调试过程

    /* 问题代码 */
    .sidebar {
        width: 300px;
        position: absolute;
        right: 0;
    }
    
    .main-content {
        width: 100%;
        padding-right: 300px;
    }
    
    /* 修复代码 */
    .container {
        display: flex;
        flex-wrap: wrap;
    }
    
    .sidebar {
        flex: 0 0 300px;
        max-width: 100%;
    }
    
    .main-content {
        flex: 1;
        min-width: 0;
    }
    
  3. 验证修复

    • 使用 Elements 面板实时修改样式,验证修复效果
    • 切换不同设备视图,确保布局在所有设备上正常
    • 使用 "Layout Shift Regions" 检查是否存在布局偏移

7.2 样式冲突问题解决

问题:第三方库样式与自定义样式冲突

解决方案

  1. 定位冲突

    • 在 Elements 面板中查看元素的样式规则,识别冲突的样式
    • 查看样式来源,确定是第三方库还是自定义样式
    • 分析样式优先级,确定冲突原因
  2. 解决冲突

    /* 使用 CSS 变量隔离样式 */
    :root {
        --primary-color: #007bff;
        --secondary-color: #6c757d;
    }
    
    /* 使用更具体的选择器 */
    .app-container .button {
        background-color: var(--primary-color);
        /* 覆盖第三方库样式 */
    }
    
    /* 使用 !important(谨慎使用) */
    .critical-button {
        background-color: #dc3545 !important;
    }
    
  3. 预防冲突

    • 使用 CSS 变量和命名空间隔离样式
    • 采用 BEM 等命名规范
    • 使用 CSS Modules 或 CSS-in-JS 方案

八、总结与进阶建议

Chrome DevTools Elements 面板是前端开发者的瑞士军刀,从基础的 DOM 查看和样式修改到高级的布局分析和性能调试,它提供了全方位的功能支持。作为前端开发者,我们应该:

  1. 深入理解底层机制:了解 DOM 树、样式计算、布局渲染的工作原理
  2. 掌握高级调试技巧:熟练使用 DOM 断点、事件监听器、布局分析等高级功能
  3. 注重性能优化:在调试过程中关注性能影响,优化调试效率
  4. 结合其他面板:与 Performance、Memory、Sources 等面板协同工作
  5. 持续学习和探索:Chrome DevTools 不断更新,保持对新功能的关注

进阶学习资源


感谢阅读!如果您有任何问题或建议,欢迎在评论区留言讨论。 如果你觉得本文对你有帮助,欢迎点赞、收藏、分享,也欢迎关注我,获取更多前端技术干货!

如何在Taro项目中使用axios

作者 静待雨落
2025年12月19日 17:25

Axios 默认使用 XMLHttpRequest 或 Node.js 的 http 模块,这在某些小程序端可能不支持:

  • ✅ H5 端:完全支持
  • ✅ React Native 端:需要配置适配器
  • ❌ 微信/支付宝等小程序端不支持(因为小程序环境没有 XMLHttpRequest

对于需要在多端使用 Axios 的项目,可以配置适配器:

pnpm install axios @tarojs/taro
// utils/axiosAdapter.js
import axios from 'axios'
import Taro from '@tarojs/taro'

// 创建自定义适配器
const taroAdapter = (config) => {
  return new Promise((resolve, reject) => {
    Taro.request({
      url: config.url,
      method: config.method?.toUpperCase() || 'GET',
      data: config.data || config.params,
      header: config.headers,
      success: (response) => {
        resolve({
          data: response.data,
          status: response.statusCode,
          statusText: 'OK',
          headers: response.header,
          config: config,
          request: null
        })
      },
      fail: (error) => {
        reject(error)
      }
    })
  })
}

// 创建 Axios 实例
const instance = axios.create({
  adapter: taroAdapter,
  baseURL: 'https://api.example.com',
  timeout: 10000,
})

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 添加 token
    const token = Taro.getStorageSync('token')
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    return response.data
  },
  (error) => {
    // 统一错误处理
    Taro.showToast({
      title: '请求失败',
      icon: 'error'
    })
    return Promise.reject(error)
  }
)

export default instance

从零到一:彻底搞定面试高频算法——“列表转树”与“爬楼梯”全解析

作者 San30
2025年12月19日 17:25

在前端面试中,算法往往是决定能否拿高薪的关键。很多同学一听到“算法”就头大,觉得那是天才玩的游戏。其实,大多数面试算法题考察的不是你的数学造诣,而是你对递归(Recursion)和逻辑处理的理解。

今天,我们就通过两个非常经典的面试真题—— “列表转树(List to Tree)”和“爬楼梯(Climbing Stairs)” ,带你从小白视角拆解算法的奥秘。

第一部分:列表转树 —— 业务中的“常青树”

1. 为什么要学这个?

在实际开发中,后端返回给我们的数据往往是“扁平化”的。比如一个省市区选择器,或者一个后台管理系统的左侧菜单导航。为了存储方便,数据库通常会存储为如下结构:

id parentId name
1 0 中国
2 1 北京
3 1 上海
4 2 东城区

但前端 UI 组件(如 Element UI 的 Tree 组件)需要的是一个嵌套的树形对象。如何把上面的表格数据转换成包含 children 的树?这就是面试官考察你的数据结构处理能力。

2. 解法一:暴力递归(最符合人类直觉)

核心逻辑:

  1. 遍历列表,找到根节点(parentId === 0)。
  2. 对于每一个节点,再去列表里找谁的 parentId 等于我的 id
  3. 递归下去,直到找不到子节点。
// 代码参考
function list2tree(list, parentId = 0) {
  const result = []; 
  list.forEach(item => {
    if (item.parentId === parentId) {
      // 这里的递归就像是在问:谁是我的孩子?
      const children = list2tree(list, item.id);
      if (children.length) {
        item.children = children;
      }
      result.push(item);
    }
  });
  return result;
}

小白避坑指南:

这种方法的复杂度是 O(n2)O(n^2)。如果列表有 1000 条数据,最坏情况下要跑 100 万次循环。面试官此时会问:“有没有更优的方法?”

3. 解法二:优雅的 ES6 函数式写法

如果你想让代码看起来更“高级”,可以利用 filtermap

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId) // 过滤出当前的子节点
    .map(item => ({
      ...item, // 展开原有属性
      children: list2tree(list, item.id) // 递归寻找后代
    }));
}

4. 解法三:空间换时间(面试官最爱)

为了把时间复杂度降到 O(n)O(n),我们可以利用 Map 对象。Map 的查询速度极快,像是一个“瞬移器”。

思路:

  1. 先遍历一遍列表,把所有节点存入 Map 中,以 id 为 Key。
  2. 再遍历一遍,根据 parentId 直接从 Map 里把父节点“揪”出来,把当前节点塞进父节点的 children 里。
// 代码参考
function listToTree(list) {
    const nodeMap = new Map();
    const tree = [];

    // 第一遍:建立映射表
    list.forEach(item => {
        nodeMap.set(item.id, { ...item, children: [] });
    });

    // 第二遍:建立父子关系
    list.forEach(item => {
        const node = nodeMap.get(item.id);
        if (item.parentId === 0) {
            tree.push(node); // 根节点入队
        } else {
            // 直接通过 parentId 找到父亲,把儿子塞进去
            nodeMap.get(item.parentId)?.children.push(node);
        }
    });
    return tree;
}

优点: 只遍历了两遍列表。无论数据有多少,速度依然飞快。

第二部分:爬楼梯 —— 掌握算法的“分水岭”

如果说“列表转树”考察的是业务能力,那“爬楼梯”考察的就是编程思维

题目描述: 假设你正在爬楼梯。需要 nn 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

1. 自顶向下:递归的艺术

我们站在第 nn 阶往回看:

  • 要到达第 10 阶,你只能从第 9 阶跨 1 步上来,或者从第 8 阶跨 2 步上来。
  • 所以:f(10)=f(9)+f(8)f(10) = f(9) + f(8)

这就是著名的斐波那契数列公式

// 基础版
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
}

致命缺陷: 这个代码会跑死电脑。算 f(10)f(10) 时要算 f(9)f(9)f(8)f(8);算 f(9)f(9) 时又要算一遍 f(8)f(8)。大量的重复计算导致“爆栈”。

2. 优化:带备忘录的递归(记忆化)

我们可以准备一个“笔记本”(memo),算过的值就记下来,下次直接拿。

const memo = {};
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n]; // 翻翻笔记,有就直接给
    memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
    return memo[n];
}

3. 自底向上:动态规划(DP)

动态规划(Dynamic Programming)听起来很高大上,其实就是倒过来想。

我们不从 f(n)f(n) 往回找,而是从 f(1)f(1) 开始往后推:

  • f(1)=1f(1) = 1
  • f(2)=2f(2) = 2
  • f(3)=1+2=3f(3) = 1 + 2 = 3
  • f(4)=2+3=5f(4) = 2 + 3 = 5
function climbStairs(n) {
  if (n <= 2) return n;
  const dp = new Array(n + 1);
  dp[1] = 1;
  dp[2] = 2;
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // 每一个结果都是前两个的和
  }
  return dp[n];
}

4. 极致优化:滚动变量

既然 f(n)f(n) 只依赖前两个数,那我们连数组都不需要了,只需要三个变量在手里“滚”起来。

function climbStairs(n) {
    if(n <= 2) return n;
    let prePrev = 1; // f(n-2)
    let prev = 2;    // f(n-1)
    let current;
    for(let i = 3; i <= n; i++){
        current = prev + prePrev;
        prePrev = prev;
        prev = current;
    }
    return current;
}

这时的空间复杂度降到了 O(1)O(1),几乎不占用额外内存。

总结:小白如何精进算法?

通过这两道题,你应该能发现算法学习的规律:

  1. 先画图,后写码: 不管是树状结构还是楼梯台阶,画出逻辑图比直接写代码重要得多。
  2. 寻找重复子问题: 递归和 DP 的核心都在于把大问题拆解成一样的小问题。
  3. 从暴力到优化: 别指望一步写出最优解。先用最笨的方法写出来,再去思考如何减少重复计算。

windows系统使用nvm配置自动切换node版本

2025年12月19日 17:24

当前端在开发久了之后,我们可能会有很多个项目存在,然而由于node版本的兼容性问题,我们不同的项目使用的node.js版本完全不同。

这个时候我们想到用nvm来做版本管理,但是发现每次都需要使用nvm use "node.js --version"来手动切换,有没有方法可以让nvm对于不同项目指定不同版本,并且自动切换。

我研究了一下午,找到了配置的方法,通过我们windows的powerShell,用其内置命令去配置执行策略,执行我们自定义的函数方法,让它自动实现切换版本

// 首先,我们对于不同的项目,可以在powerShell中cd "项目路径",在项目的根路径中执行下面的命令
    echo "此项目指定的node版本" > .nvmrc
// 它会在我们的项目根目录下生成一个.nvmrc文件,其中存放了我们指定的node.js版本
// 第二步:在powerShell中执行以下命令
    $PROFILE
// 为了防止不同用户电脑自定义配置不同而出现错误,执行这个命令找到windows读取配置的真实路径
// 输出结果类似   'D:\BaseInfoMove\文档\WindowsPowerShell\Microsoft.PowerShell_profile.ps1'

// 第三步:有点同学可能像我一样,因为自己改动的电脑配置导致读取位置不在默认C盘,因此需要创建目标目录文件
New-Item -Path "D:\BaseInfoMove\文档\WindowsPowerShell" -ItemType Directory -Force
// 执行这行脚本来创建目标文件,      注意:自己第二步输出的路径,不要硬抄,要用自己的(最后的文件名不要)

// 第四步:一次性创建并写入脚本,执行以下命令,一次性复制,如果有提示,直接确定即可

@"
# Auto-load .nvmrc on directory change
function Enter-Directory {
    `$currentDir = Get-Location
    `$nvmrcPath = "`$currentDir\.nvmrc"
    
    if (Test-Path `$nvmrcPath) {
        `$version = Get-Content `$nvmrcPath -First 1 | Where-Object { `$_ -match '^\d' }
        if (`$version) {
            Write-Host "🔄 Switching to Node.js `$version..." -ForegroundColor Cyan
            nvm use `$version --silent
        }
    }
}

`$lastDir = ""
Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action {
    if (`$lastDir -ne (Get-Location).Path) {
        `$lastDir = (Get-Location).Path
        Enter-Directory
    }
} | Out-Null

# Run on startup
Enter-Directory
"@ | Set-Content -Path "$PROFILE" -Encoding UTF8

// 这段脚本会自动创建在第三步创建目标目录时省略的文件,并写入执行脚本


// 最后一步:设置执行策略
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
// 这一步是在更改我们windows的powerShell的执行策略,让它打开的时候自动执行我们的脚本,以监听目录变动,自动查询读取项目中的.nvmrc配置文件,切换指定node.js(有的话,没有指定不会切换,不会报错,勿忧)

以上步骤执行完之后,重启powerShell即可,cd进入项目目录,会有自定义提示

🔄 Switching to Node.js `$version...
// 这是我们的脚本自定义提示,切换node版本提示,不必执行任何操作
// 提示出现后回车执行nvm current即可查看当前node版本,此时node版本应该已经切换成功了

🚀 高并发场景下的救星:BullMQ如何实现智能流量削峰填谷

2025年12月19日 17:22

bullmq是如何针对流量峰值的时候做调度的,比如我的qps最大限制是10,但是请求并不能知道qps是否达到峰值。是如何跟网管的qps限制结合来实现任务队列调控的?

BullMQ 流量控制的工作原理

1. BullMQ 限流机制(Worker 端控制)

const worker = new Worker('api-calls', async (job) => {
  // 调用第三方 API
  const result = await fetch('https://third-party-api.com/data');
  return result.json();
}, {
  connection,
  limiter: {
    max: 10,        // 每个时间窗口最多处理 10 个任务
    duration: 1000  // 时间窗口 1 秒
  }
});

关键理解BullMQ 的限流是在任务消费端(Worker)进行的,不是在任务生产端。

2. 实际的流量控制流程

// 场景:调用第三方 API,对方限制 QPS = 10

// Step 1: 请求进入时不做限制,全部加入队列

app.post('/process-data', async (req, res) => {
  // 🔴 这里不管有多少请求,都会加入队列
  await apiQueue.add('call-third-party', req.body);
  res.json({ message: 'Task queued' });
});

// Step 2: Worker 按限流规则处理任务

const worker = new Worker('api-calls', async (job) => {
  // ✅ 这里才会按 QPS 限制执行
  // BullMQ 确保每秒最多执行 10 个任务
  const result = await callThirdPartyAPI(job.data);
  return result;
}, {
  limiter: {
    max: 10,        // 对应第三方 API 的 QPS 限制
    duration: 1000
  }
});

3. 与网关 QPS 限制的区别

层面 网关 QPS 限制 BullMQ 流量控制
作用点 请求入口处 任务处理时
限制对象 HTTP 请求 队列任务执行
超限行为 直接拒绝请求 (429) 任务排队等待
适用场景 保护系统不被打垮 保护下游服务

4. 实际应用场景示例

场景:电商系统调用支付接口

// 网关层:保护自己的系统 // 限制:每秒最多接收 1000 个支付请求

app.use('/api/payment', rateLimit({
  windowMs: 1000,
  max: 1000,
  message: '请求过于频繁,请稍后重试'
}));

// 应用层:接收请求并加入队列

app.post('/api/payment/process', async (req, res) => {
  // 所有通过网关的请求都加入队列
  await paymentQueue.add('process-payment', req.body);
  res.json({ orderId: req.body.orderId, status: 'processing' });
});

// BullMQ Worker:保护下游支付服务
const worker = new Worker('payment-queue', async (job) => {
  // 支付服务限制 QPS = 50,这里严格控制
  const result = await paymentService.process(job.data);
  return result;
}, {
  limiter: {
    max: 50,        // 匹配支付服务的 QPS 限制
    duration: 1000
  }
});

5. 动态调控策略

基于监控的动态调整:

class AdaptiveWorker {
  private currentLimit = 10;
  
  constructor() {
    this.setupWorker();
    this.monitorAndAdjust();
  }
  
  private setupWorker() {
    this.worker = new Worker('adaptive-queue', async (job) => {
      try {
        const result = await this.callExternalAPI(job.data);
        this.onSuccess();
        return result;
      } catch (error) {
        this.onError(error);
        throw error;
      }
    }, {
      limiter: {
        max: this.currentLimit,
        duration: 1000
      }
    });
  }
  
  private onError(error) {
    if (error.status === 429) { // 限流错误
      // 动态降低处理速度
      this.currentLimit = Math.max(1, this.currentLimit - 2);
      this.updateWorkerLimiter();
    }
  }
  
  private onSuccess() {
    // 成功时逐步提升处理速度
    this.currentLimit = Math.min(20, this.currentLimit + 1);
    this.updateWorkerLimiter();
  }
}

6. 多层防护架构

// 第1层:网关限流(保护自己) // Nginx/Kong: limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;

// 第2层:应用层限流(保护数据库)

app.use(rateLimit({ windowMs: 1000, max: 200 }));

// 第3层:队列限流(保护下游服务)

const worker = new Worker('external-api', handler, {
  limiter: { max: 10, duration: 1000 } // 匹配外部 API 限制
});

// 第4层:熔断器(故障保护)

const circuitBreaker = new CircuitBreaker(externalAPICall, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000
});

总结 BullMQ 的流量控制主要解决的是"如何按照下游服务的承受能力来处理任务",而不是"如何限制用户请求"。

关键点:

  • 请求入队不限制:用户请求可以快速响应并加入队列
  • 任务执行限制:Worker 严格按照下游服务的 QPS 能力处理任务
  • 削峰填谷:高峰期请求排队,低峰期逐步处理
  • 保护下游:避免因突发流量导致下游服务崩溃

这样设计的好处是用户体验好(快速响应),系统稳定(不会压垮下游),资源利用充分(队列缓冲机制)。

【高斯泼溅】如何将“歪头”的3DGS模型精准“钉”在地图上,杜绝后续误差?

作者 Mapmost
2025年12月19日 17:16

“拍”一个GS模型只要几分钟,建一个GS模型可选的工具也很多,但是模型放进网页里难以旋转,永远卡不到你想看的那个角度,应该怎么办?

更进一步,如果我想把模型放到地图上的实际位置,对模型和周围地理环境有更深入的联动,这时候又该怎么办?

相信看完这篇文章,你不仅能轻松拿捏小模型的各个角度大范围的高斯模型配准到地图也是能手到擒来。

为什么小熊的角度奇奇怪怪的?

了解两个坐标系,轻松旋转查看模型

想要能自如的旋转模型,只需要了解两个坐标系

以后不管是高斯模型、点云或OBJ等各类数据,在各式查看器中的自如观看都不在话下。

现在解密一下这两个坐标系:

  • **物体坐标系:**即模型自身有个XYZ三轴
  • **世界坐标系:**即查看器预先定义好的XYZ三轴

是不是看懵了,说啥呢。别急,让我以下面的小熊模型来详细讲解一下。

首先我们一般可以看到,查看器右上角有个很明显的XYZ轴。我们刚进入查看器后什么都别动XYZ这个初始的方向就是世界坐标系的指向,我们只需要关心指向上方的是哪根轴就行。这个场景中就是Y轴

下面就是关键了,我们要将模型的Y轴对齐世界坐标系的Y轴

有人可能要问了:小熊又没有这些红红绿绿的轴,我又不知道哪个是Y轴,还对齐呢?

我们可以想象一下,Y是向上的,那这个小熊模型是不是也应该向上,那什么应该是向上的呢?

很明显吧,我们只需要把小熊的红帽子旋转向上,那小熊肯定就自然和世界坐标系对齐了

大模型叠加地图

这小熊模型我随便转转看看,没啥精度要求,难度太低。要是想把东方之门、阳澄湖那么大区域的高斯模型对齐到地图,融入到GIS的测量、分析、决策与运维流程该怎么办呢?

我们首先要做的工作是配准,将模型配准到地图上。

进行配准需要了解坐标系:

众所周知,地球是个三维椭球,而我们平时看的地图都是二维平面,这里涉及到两个坐标系了;再加上模型的坐标系,就是三个坐标系了。

平常无人机图像自带的经纬度就是三维椭球的坐标系,一般来说常用的为WGS84坐标系;平面地图的坐标系一般来说常用的是墨卡托投影坐标系。在国际标准下,这两者常用的代码是EPSG:4326EPSG:3857

而配准一般步骤如下:

01地图选点

从地图上选取一些特征点,地图上获取的点如果是经纬度,需要将其转换到EPSG:3857坐标系下的平面坐标。

平面地图选点

02模型选点

模型侧则首先要将colmap结果点云导入进CloudCompare之类的点云处理工具,然后选取与地图上选择的相同的点,记录其坐标,即模型坐标系下坐标。

CloudCompare工具选取模型点

03变换矩阵计算

之后我们可以使用最小二乘等算法,计算两组坐标间最佳的旋转变换矩阵(上一步选取的点越多,最终配准结果越准确)。

04变换模型

最终我们还要对高斯模型的位置、旋转等相关属性应用这个矩阵才能完成配准,将模型放上地图。

模型叠加地图

如何一步完成叠加

把模型“摆”到地图上,传统做法得先闯三关坐标系、再踩四步配准坑;来一个新模型就全套重来,人工量直接拉满。

Mapmost 高斯泼溅建模平台,让这套苦差事变成“无人区”:

照片带 GPS?

平台自动读取,建完模型一秒缩放+旋转,稳稳落到真实地理位置;

照片没 GPS?

空三后的随机坐标系也能被扳正,再也不怕像“小熊”弯头转向的。

无人机拍完→上传→自动配准,整条链路零手工、零代码,真正的“一键到地”。

申请试用,请至Mapmost官网联系客服

Mapmost 3DGS Builder在线体验版已上线~

欢迎体验: studio.mapmost.com/3dgs

JavaScript中this指向机制与异步回调解决方案详解

作者 UIUV
2025年12月19日 17:02

JavaScript中this指向机制与异步回调解决方案详解

JavaScript中的this指向是一个动态绑定的机制,而非静态确定。它会根据函数的调用方式在运行时动态变化,这使得在异步回调函数中保持正确的this指向变得尤为重要。本文将深入探讨this的四种绑定规则,分析异步回调中this指向丢失的原因,并提供三种有效解决方案。同时,本文还会解释return function()为什么会异步执行,帮助读者全面理解JavaScript的事件循环机制。

一、this的基本概念与四种绑定规则

this是JavaScript中最特殊且最令人困惑的关键词之一。它代表函数被调用时的执行上下文,即"谁调用了这个函数"。this的指向不是在函数定义时确定的,而是在函数调用时动态确定的 。理解这一点是掌握this机制的关键。根据调用方式的不同,this的绑定遵循四种规则,按优先级从高到低排列为:new绑定、显式绑定、隐式绑定和默认绑定 。

new绑定发生在使用new关键字调用构造函数时。此时,this指向新创建的实例对象。例如:

function User(name) {
    this.name = name;
    this.greet = function() {
        console.log(`Hello, I'm ${this.name}`);
    };
}
const alice = new User("Alice");
alice.greet(); // 输出: Hello, I'm Alice

显式绑定通过call、apply或bind方法强制指定this的值。call和apply会立即执行函数并指定this,而bind会返回一个新函数,this被永久绑定 :

const person = { name: "Bob" };
function introduce() {
    console.log(`My name is ${this.name}`);
}
introduce.call(person); // 立即执行,输出: My name is Bob
introduce.apply(person); // 同上
const boundIntroduce = introduce.bind(person); // 返回新函数
boundIntroduce(); // 输出: My name is Bob

隐式绑定发生在函数作为对象的方法调用时。此时,this指向调用该方法的对象 :

const user = {
    name: "Charlie",
    greet: function() {
        console.log(`Hello, ${this.name}!`);
    }
};
user.greet(); // 输出: Hello, Charlie!

默认绑定适用于独立函数调用的情况。在非严格模式下,this指向全局对象(如浏览器中的window);在严格模式下,this指向undefined :

function showThis() {
    console.log(this); // 非严格模式下指向window,严格模式下指向undefined
}
showThis(); // 输出: Window { ... }

这四种绑定规则的优先级顺序非常重要:new绑定 > 显式绑定 > 隐式绑定 > 默认绑定 。在实际开发中,理解并应用这一优先级顺序可以避免许多this指向的问题。

二、异步回调中this指向丢失的原因

在异步回调函数(如setTimeout、事件监听器等)中,this指向丢失是一个常见问题。要理解这一问题,我们需要深入分析JavaScript的执行机制和事件循环。

异步回调函数的执行时机与同步代码不同。当调用setTimeout时,JavaScript引擎会立即注册一个定时器,并将控制权交还给主线程继续执行后续的同步代码。当指定的时间到期后,回调函数不会立即执行,而是被放入任务队列中等待 。只有当前所有同步代码执行完毕,主线程空闲时,事件循环才会从任务队列中取出回调函数并执行。

如以下的代码示例中:

a.fcnc2 = function() {
    // 调用时,this指向a
    console.log(this);
    setTimeout(function() {
        // this指向window或undefined
        console.log(this);
    }, 3000);
};

当调用a.fcnc2()时,fcnc2方法的this确实指向对象a,因此第一个console.log(this)会正确输出对象a。然而,setTimeout的回调函数是在3秒后作为独立函数调用的,因此触发默认绑定规则。在非严格模式下,回调函数的this指向全局对象window;在严格模式下,this指向undefined 。

this的动态绑定特性是导致异步回调中this丢失的根本原因。当回调函数被延迟执行时,它不再与原对象a有隐式调用关系,而是被全局环境(或严格模式下的undefined)调用。这种情况下,即使回调函数是在对象a的方法内部定义的,其this也不会自动指向a。

三、解决方案一:使用that变量保存外层this

最简单且兼容性最好的解决方案是使用一个变量(如that或self)来保存外层函数的this指向,然后在回调函数中使用这个变量 :

a.fcnc2 = function() {
    const that = this; // 保存外层this
    setTimeout(function() {
        console.log(that.name); // 使用保存的that变量
    }, 3000);
};

这种方法利用了闭包特性 。闭包允许函数访问其词法作用域中的变量,即使函数是在词法作用域外执行的。因此,即使回调函数在3秒后执行,它仍然可以访问到外层函数中定义的that变量,从而获取正确的this指向。

优点:兼容性好,适用于所有JavaScript环境;实现简单直观,即使在不支持ES6特性的旧浏览器中也能正常工作 。

缺点:代码中会出现大量that或self变量,影响代码整洁度;需要开发者手动保存this,容易忘记或出错 ;无法处理深层嵌套的回调函数,可能导致作用域链过长。

四、解决方案二:使用bind方法显式绑定this

第二种解决方案是使用Function原型上的bind方法,它允许我们创建一个新函数,并永久绑定this的值 :

a.fcnc2 = function() {
    setTimeout(
        function() {
            console.log(this.name);
        }.bind(this), // 显式绑定this
        3000
    );
};

bind方法返回一个新函数,这个新函数的this被永久绑定为调用bind时传入的第一个参数。与call和apply不同,bind不会立即执行函数,而是返回一个准备好的函数,可以在之后的任何时间调用 。

在用户提供的代码示例中:

console.log(a.fcnc1.call(a)); // 立即执行,this指向a
console.log(a.fcnc1.bind(a));  // 返回新函数,this绑定为a
const fcnc1Bind = a.fcnc1.bind(a);
fcnc1Bind(); // 正确执行,this指向a

call方法立即执行函数并指定this,而bind方法返回一个新函数,不会立即执行。这使得bind特别适合异步场景,因为我们可以将绑定后的函数传递给setTimeout等异步API。

优点:显式控制this指向,代码意图明确;适用于需要动态绑定不同this的场景;不会污染外层作用域。

缺点:需要额外调用bind方法,增加代码量;返回的新函数是函数的浅拷贝,可能影响性能;在旧浏览器中可能需要polyfill支持。

五、解决方案三:使用箭头函数继承this

第三种解决方案是使用ES6的箭头函数,它没有自己的this指向,而是继承自外层作用域 :

a.fcnc2 = function() {
    setTimeout(() => {
        console.log(this.name); // 继承外层this,指向a
    }, 3000);
};

箭头函数的this是在定义时绑定的,而非运行时 。这意味着箭头函数会继承其词法作用域(即定义时所在的作用域)的this值。在用户提供的代码示例中:

const func = () => {
    console.log(this);
    console.log(arguments);
};
func(); // 输出window(非严格模式)
new func(); // 报错,箭头函数不能作为构造函数

箭头函数没有自己的this和arguments对象 ,这使得它们无法被用作构造函数,但非常适合用于需要保持this指向的回调函数。

优点:代码简洁,无需额外变量或方法;this指向在定义时确定,避免运行时变化;与现代JavaScript开发实践无缝衔接。

缺点:不兼容旧浏览器(如IE11及更早版本);无法作为构造函数使用;在某些需要动态this绑定的场景中不够灵活。

六、三种解决方案的对比与选择

方案 兼容性 代码简洁度 this绑定方式 适用场景
that变量 极好(所有环境) 一般(需要额外变量) 运行时隐式绑定 旧项目、需要兼容旧环境
bind方法 良好(ES5及以上) 较好(需要额外调用bind) 运行时显式绑定 需要动态绑定this的场景
箭头函数 差(仅ES6及以上) 优秀(无需额外代码) 定义时词法绑定 新项目、现代JavaScript环境

在实际开发中,应根据项目需求和环境选择合适的解决方案

  1. 如果项目需要支持旧浏览器(如IE11),应优先考虑that变量或bind方法。
  2. 在现代JavaScript环境中,箭头函数是首选方案,因为它代码简洁且避免了this指向问题。
  3. 对于需要动态绑定不同this值的场景,bind方法更为灵活。
  4. 在深层嵌套的回调函数中,箭头函数可以简化this的传递,避免作用域链过长的问题。

七、异步编程中的this最佳实践

基于对this指向机制和异步回调问题的深入理解,以下是异步编程中的this最佳实践:

1. 在ES6环境中优先使用箭头函数

箭头函数没有自己的this,而是继承自外层作用域,这使得它们在异步回调中特别有用:

const user = {
    name: "Alice",
    greet: () => {
        setTimeout(() => {
            console.log(`Hello, ${this.name}!`); // 正确输出Hello, Alice!
        }, 1000);
    }
};

2. 使用bind方法显式绑定this

当需要在异步回调中使用普通函数,并且需要保持this指向时,可以使用bind方法:

const user = {
    name: "Bob",
    greet: function() {
        setTimeout(
            function() {
                console.log(`Hello, ${this.name}!`); // 正确输出Hello, Bob!
            }.bind(this),
            1000
        );
    }
};

3. 使用this保存变量(如that或self)

在不支持ES6的环境中,可以使用闭包保存this:

const user = {
    name: "Charlie",
    greet: function() {
        const that = this; // 保存this到闭包变量
        setTimeout(function() {
            console.log(`Hello, ${that.name}!`); // 正确输出Hello, Charlie!
        }, 1000);
    }
};

4. 避免在异步回调中使用this

如果可能,尽量避免在异步回调中依赖this,而是使用参数传递或对象解构:

const user = {
    name: "Diana",
    greet: function() {
        const name = this.name; // 使用参数传递
        setTimeout(() => {
            console.log(`Hello, ${name}!`); // 正确输出Hello, Diana!
        }, 1000);
    }
};

5. 在类方法中使用箭头函数或bind

在类方法中,可以通过构造函数使用bind来绑定this:

class User {
    constructor(name) {
        this.name = name;
        // 显式绑定greet方法的this
        this.greet = this.greet.bind(this);
    }

    greet() {
        setTimeout(() => {
            console.log(`Hello, ${this.name}!`); // 正确输出
        }, 1000);
    }
}

或者使用箭头函数:

class User {
    constructor(name) {
        this.name = name;
    }

    // 使用箭头函数,this绑定为构造函数中的this
    greet = () => {
        setTimeout(() => {
            console.log(`Hello, ${this.name}!`); // 正确输出
        }, 1000);
    };
}

八、事件循环与任务队列详解

要深入理解异步回调中this指向丢失的原因,我们需要了解JavaScript的事件循环机制和任务队列系统。

JavaScript引擎是单线程的 ,这意味着它一次只能执行一个任务。为了处理异步操作(如定时器、事件监听、网络请求等),JavaScript使用了事件循环和任务队列机制。

事件循环是一个持续运行的循环,负责将任务从队列中取出并执行。任务队列分为两种:微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue) 。

微任务队列包括:

  • Promise的then/catch回调
  • MutationObserver回调
  • setImmediate(Node.js环境)

宏任务队列包括:

  • setTimeout回调
  • setInterval回调
  • I/O操作回调
  • DOM事件回调

执行流程如下

  1. 执行主线程中的同步代码。
  2. 当遇到异步操作(如setTimeout)时,注册任务并继续执行后续同步代码。
  3. 当所有同步代码执行完毕,主线程空闲时,事件循环开始处理任务队列。
  4. 优先处理微任务队列中的所有任务。
  5. 然后处理宏任务队列中的一个任务。
  6. 重复这一过程,直到所有任务处理完毕。

在用户提供的代码示例中:

a.fcnc2 = function() {
    // 同步代码,this指向a
    console.log(this);
    setTimeout(function() {
        // 3秒后作为宏任务执行,this指向window或undefined
        console.log(this);
    }, 3000);
};

当调用a.fcnc2()时,函数内部的同步代码立即执行,此时this指向对象a。setTimeout注册了一个延迟3秒的宏任务,然后立即返回。3秒后,事件循环将回调函数从宏任务队列中取出执行,此时回调函数是作为独立函数调用的,因此触发默认绑定规则,this指向全局对象或undefined。

这就是为什么在异步回调中this指向会丢失的根本原因 :回调函数是在不同的执行上下文中被调用的,失去了与原对象a的隐式关联。

九、this指向问题的现代解决方案

随着JavaScript语言的发展和工具链的进步,处理this指向问题的方案也在不断演进。

1. 箭头函数的普及

ES6引入的箭头函数已经成为处理this指向问题的首选方案。箭头函数没有自己的this,而是继承自外层作用域,这使得它们在异步回调中特别有用:

const user = {
    name: "Eve",
    greet: () => {
        setTimeout(() => {
            console.log(`Hello, ${this.name}!`); // 正确输出
        }, 1000);
    }
};

2. Class Properties的使用

ES2015引入的Class Properties语法允许我们在类中直接定义箭头函数属性:

class User {
    name = "Frank";
    // 使用箭头函数,this绑定为实例
    greet = () => {
        setTimeout(() => {
            console.log(`Hello, ${this.name}!`); // 正确输出
        }, 1000);
    };
}

3. 使用this保存变量的改进

在某些情况下,仍然需要使用this保存变量,但可以通过更现代的方式实现:

const user = {
    name: "Grace",
    greet() {
        const { name } = this; // 使用对象解构保存需要的值
        setTimeout(() => {
            console.log(`Hello, ${name}!`); // 正确输出
        }, 1000);
    }
};

4. 使用async/await处理异步操作

对于复杂的异步操作,可以使用async/await语法:

const user = {
    name: "Heidi",
    async greet() {
        await new Promise(resolve => setTimeout(resolve, 1000));
        console.log(`Hello, ${this.name}!`); // 正确输出
    }
};

// 使用时
user.greet(); // 注册异步操作

5. 使用现代工具链(如Babel)

对于需要支持旧环境的项目,可以使用Babel等工具将箭头函数转换为普通函数,并自动绑定this:

// Babel转换后的代码
const user = {
    name: "Ivan",
    greet: function greet() {
        var _this = this;
        setTimeout(function() {
            console.log(`Hello, ${_this.name}!`); // 正确输出
        }, 1000);
    }
};

十、总结与实践建议

JavaScript中的this指向是一个动态绑定的机制,理解并掌握这一机制对于写出可靠的异步代码至关重要 。在异步回调中,this指向丢失是一个常见问题,但有多种有效解决方案。

最佳实践总结

  1. 在现代JavaScript环境中,优先使用箭头函数处理异步回调,因为它们自动继承外层this。
  2. 对于需要支持旧环境的项目,使用bind方法显式绑定this,或使用that变量保存外层this。
  3. 避免在异步回调中过度依赖this,可以通过参数传递或对象解构获取所需数据。
  4. 在类方法中,可以通过构造函数使用bind显式绑定this,或使用Class Properties定义箭头函数。
  5. 理解事件循环和任务队列机制,有助于更好地把握异步代码的执行时机和this的绑定规则。

随着JavaScript语言的发展和工具链的进步,处理this指向问题的方案也在不断完善。在实际开发中,应根据项目需求、目标环境和个人偏好选择合适的解决方案。无论选择哪种方案,理解this的动态绑定机制都是写出可靠异步代码的基础。

异步编程是JavaScript的核心特性之一,掌握this指向的处理方法将大大提升代码的可靠性和可维护性。通过本文的学习,希望读者能够深入理解JavaScript中this的指向机制,并在实际开发中有效应用这些解决方案。

IndexedDB 实战:封装一个通用工具类,搞定所有本地存储需求

作者 momo100
2025年12月19日 16:58

IndexedDB 完全指南:从基础使用到封装实战(含完整 API 与最佳实践)

在前端开发中,本地存储是实现数据持久化、提升用户体验的核心技术之一。常见的浏览器存储方案各有优劣,而 IndexedDB 作为一种高性能的本地数据库,凭借其大容量、异步操作、支持复杂数据类型等特性,成为处理大量结构化数据的首选方案。本文将系统讲解 IndexedDB 的核心概念、基础用法、完整封装以及最佳实践,弥补基础用法的遗漏点,帮助开发者快速上手并灵活运用。

一、前端存储方案对比(补充细节)

在深入 IndexedDB 之前,先明确它与其他存储方案的差异,方便根据场景选型:

存储方案 存储容量 时效性 数据类型 核心特性 适用场景
LocalStorage 5MB-10MB(浏览器差异) 永久存储(手动清除) 仅字符串(需序列化) 同步操作,简单键值对 少量用户配置、token 存储
SessionStorage 5MB-10MB(浏览器差异) 会话级(页面关闭清除) 仅字符串(需序列化) 同步操作,页面隔离 临时表单数据、会话状态
Cookie 4KB 可设置过期时间(默认会话) 仅字符串 随请求携带,同域共享 用户身份标识、跟踪统计
IndexedDB 无固定上限(依赖设备存储空间,通常 >250MB) 永久存储(手动清除) 字符串、数字、对象、二进制数据(Blob/ArrayBuffer) 异步操作,事务支持,索引查询 大量结构化数据、离线应用、文件缓存

关键补充

  • LocalStorage/SessionStorage 同步操作会阻塞主线程,处理大量数据时可能导致页面卡顿;IndexedDB 异步操作不会阻塞 UI,性能更优。
  • Cookie 每次请求都会携带到服务器,增加带宽消耗;IndexedDB 仅在本地操作,不与服务器交互。
  • IndexedDB 支持事务(Transaction),确保数据操作的原子性(要么全部成功,要么全部失败),这是其他存储方案不具备的核心优势。

二、IndexedDB 核心概念(补充基础认知)

在使用前需理解以下核心术语,避免混淆:

  1. 数据库(Database) :IndexedDB 的顶层容器,每个数据库有唯一名称和版本号,版本号升级时会触发 onupgradeneeded 事件。
  2. 对象仓库(Object Store) :类似关系型数据库的“表”,用于存储结构化数据,每个对象仓库有唯一主键(keyPath)。
  3. 事务(Transaction) :所有数据操作(增删查改)必须通过事务执行,支持 readonly(只读)和 readwrite(读写)两种模式,确保数据一致性。
  4. 索引(Index) :基于对象仓库的某个属性创建,用于快速查询数据(类似数据库索引),支持唯一索引(unique: true)和非唯一索引。
  5. 主键(KeyPath) :对象仓库中每条数据的唯一标识,可手动指定(如 deviceId)或自动生成(autoIncrement: true)。
  6. 游标(Cursor) :用于遍历对象仓库中的数据,支持条件筛选、排序等复杂查询(基础用法中未提及,下文补充)。

三、IndexedDB 基础用法(完善遗漏 API 与场景)

1. 环境兼容处理

不同浏览器对 IndexedDB 的前缀支持不同,需先做兼容处理(补充完整前缀):

// 完整兼容方案
const indexedDB = window.indexedDB || 
                  window.webkitIndexedDB || 
                  window.mozIndexedDB || 
                  window.msIndexedDB; // IE 浏览器支持
const IDBTransaction = window.IDBTransaction || 
                       window.webkitIDBTransaction || 
                       window.mozIDBTransaction;
const IDBCursor = window.IDBCursor || 
                  window.webkitIDBCursor || 
                  window.mozIDBCursor;

// 检测浏览器是否支持
if (!indexedDB) {
  console.error('当前浏览器不支持 IndexedDB,请升级浏览器');
}

2. 打开/创建数据库

使用 indexedDB.open(dbName, version) 打开数据库,版本号必须为正整数,升级版本时会触发 onupgradeneeded 事件(补充错误处理细节):

// 打开数据库(数据库名:deviceDB,版本号:2)
const request = indexedDB.open('deviceDB', 2);

// 数据库打开失败(如权限不足、存储满)
request.onerror = (event) => {
  console.error('数据库打开失败:', event.target.error.message);
};

// 数据库打开成功
request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('数据库打开成功,版本号:', db.version);
  
  // 操作完成后关闭数据库(避免资源占用)
  // db.close();
};

// 数据库创建或版本升级时触发(仅一次)
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  console.log('数据库升级,旧版本:', event.oldVersion, '新版本:', event.newVersion);
  
  // 此处可执行建表、删表、创建索引等操作
};

3. 操作对象仓库(补充完整场景)

(1)创建对象仓库(表)

onupgradeneeded 事件中创建对象仓库,支持两种主键模式(补充自动生成主键示例):

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  
  // 方式1:手动指定主键(如 deviceId)
  if (!db.objectStoreNames.contains('cameraDevice')) {
    const cameraStore = db.createObjectStore('cameraDevice', { 
      keyPath: 'deviceId' // 主键字段
    });
    
    // 创建索引:基于 deviceName(非唯一)、status(唯一)
    cameraStore.createIndex('idx_deviceName', 'deviceName', { unique: false });
    cameraStore.createIndex('idx_status', 'status', { unique: false });
  }
  
  // 方式2:自动生成主键(autoIncrement: true)
  if (!db.objectStoreNames.contains('user')) {
    const userStore = db.createObjectStore('user', { 
      autoIncrement: true // 主键自动递增(默认字段名:id)
    });
    userStore.createIndex('idx_username', 'username', { unique: true }); // 用户名唯一索引
  }
  
  // 删除旧表(如需)
  if (db.objectStoreNames.contains('oldDevice')) {
    db.deleteObjectStore('oldDevice');
    console.log('旧表已删除');
  }
};
(2)新增数据(补充批量新增与重复主键处理)

通过 add() 方法新增数据,主键重复会报错;若需覆盖重复数据,可使用 put() 方法(后续讲解):

// 数据库打开成功后执行新增
request.onsuccess = (event) => {
  const db = event.target.result;
  
  // 开启读写事务(指定操作的表名)
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 单个新增
  cameraStore.add({
    deviceId: 'cam_888',
    deviceName: '成都匝道摄像头',
    status: 1, // 1:在线,0:离线
    resolution: '1080P',
    createTime: new Date().toISOString()
  });
  
  // 批量新增(补充)
  const batchData = [
    { deviceId: 'cam_999', deviceName: '北京路口摄像头', status: 1, resolution: '4K', createTime: new Date().toISOString() },
    { deviceId: 'cam_777', deviceName: '上海隧道摄像头', status: 0, resolution: '720P', createTime: new Date().toISOString() }
  ];
  batchData.forEach(data => cameraStore.add(data));
  
  // 事务完成回调
  transaction.oncomplete = () => {
    console.log('数据新增成功');
    db.close(); // 关闭数据库
  };
  
  // 事务失败回调(如主键重复)
  transaction.onerror = (event) => {
    console.error('数据新增失败:', event.target.error.message);
    db.close();
  };
};
(3)查询数据(补充游标查询、条件筛选)

IndexedDB 支持主键查询、索引查询、全量查询和游标查询,满足不同场景需求:

request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readonly');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 1. 主键查询(精准匹配)
  const keyRequest = cameraStore.get('cam_888');
  keyRequest.onsuccess = () => {
    console.log('主键查询结果:', keyRequest.result);
  };
  
  // 2. 索引查询(基于索引字段匹配)
  const indexRequest = cameraStore.index('idx_deviceName').get('北京路口摄像头');
  indexRequest.onsuccess = () => {
    console.log('索引查询结果:', indexRequest.result);
  };
  
  // 3. 全量查询(获取所有数据)
  const allRequest = cameraStore.getAll();
  allRequest.onsuccess = () => {
    console.log('全量查询结果:', allRequest.result);
  };
  
  // 4. 游标查询(补充:遍历数据、条件筛选、排序)
  const cursorRequest = cameraStore.openCursor(null, 'next'); // next:正序,prev:倒序
  const onlineCameras = [];
  
  cursorRequest.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      // 条件筛选:只保留在线设备(status: 1)
      if (cursor.value.status === 1) {
        onlineCameras.push(cursor.value);
      }
      cursor.continue(); // 继续遍历下一条
    } else {
      console.log('游标查询(在线设备):', onlineCameras);
    }
  };
  
  transaction.oncomplete = () => {
    db.close();
  };
};
(4)修改数据(补充批量修改、索引定位修改)

使用 put() 方法修改数据,存在主键则更新,不存在则新增(与 add() 的核心区别):

request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 方式1:主键定位修改
  const getRequest = cameraStore.get('cam_777');
  getRequest.onsuccess = () => {
    const data = getRequest.result;
    data.status = 1; // 状态改为在线
    data.resolution = '1080P'; // 更新分辨率
    cameraStore.put(data); // 提交修改
  };
  
  // 方式2:索引定位修改(补充)
  const indexGetRequest = cameraStore.index('idx_deviceName').get('成都匝道摄像头');
  indexGetRequest.onsuccess = () => {
    const data = indexGetRequest.result;
    data.createTime = new Date().toISOString(); // 更新时间
    cameraStore.put(data);
  };
  
  transaction.oncomplete = () => {
    console.log('数据修改成功');
    db.close();
  };
};
(5)删除数据(补充批量删除、索引定位删除)

支持主键删除和索引定位删除,批量删除需结合游标实现:

request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 1. 主键删除
  cameraStore.delete('cam_999');
  
  // 2. 索引定位删除(补充)
  const indexGetRequest = cameraStore.index('idx_deviceName').get('上海隧道摄像头');
  indexGetRequest.onsuccess = () => {
    const data = indexGetRequest.result;
    if (data) {
      cameraStore.delete(data.deviceId); // 通过主键删除
    }
  };
  
  // 3. 批量删除(补充:删除所有离线设备)
  const cursorRequest = cameraStore.openCursor();
  cursorRequest.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      if (cursor.value.status === 0) {
        cursor.delete(); // 删除当前游标指向的数据
      }
      cursor.continue();
    }
  };
  
  transaction.oncomplete = () => {
    console.log('数据删除成功');
    db.close();
  };
};
(6)清空表与删除数据库(补充)
// 1. 清空表数据
request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  cameraStore.clear(); // 清空表
  
  transaction.oncomplete = () => {
    console.log('表数据清空成功');
    db.close();
  };
};

// 2. 删除数据库(需先关闭所有连接)
const deleteRequest = indexedDB.deleteDatabase('deviceDB');
deleteRequest.onsuccess = () => {
  console.log('数据库删除成功');
};
deleteRequest.onerror = (event) => {
  console.error('数据库删除失败:', event.target.error.message);
};

四、IndexedDB 封装(优化健壮性与易用性)

基础用法存在代码冗余、错误处理繁琐等问题,下面封装一个通用的 IndexedDB 工具类,支持 Promise 链式调用,简化开发:

封装后的 IndexedDB 工具类(indexedDB.js)

/**
 * IndexedDB 工具类(优化版)
 * 支持 Promise 链式调用、自动兼容、事务管理、完整 API
 */
class IndexedDB {
  constructor(dbName, dbVersion = 1) {
    this.dbName = dbName;
    this.dbVersion = dbVersion;
    this.db = null; // 数据库实例
    this.supported = !!window.indexedDB; // 检测浏览器支持
    
    // 兼容前缀
    this.indexedDB = window.indexedDB ||
                    window.webkitIndexedDB ||
                    window.mozIndexedDB ||
                    window.msIndexedDB;
    this.IDBTransaction = window.IDBTransaction ||
                          window.webkitIDBTransaction ||
                          window.mozIDBTransaction;
  }

  /**
   * 初始化数据库(创建表、索引)
   * @param {Array} tableConfigs 表配置:[{ tableName, keyPath, autoIncrement, indexList }]
   * @returns {Promise}
   */
  init(tableConfigs = []) {
    if (!this.supported) {
      return Promise.reject(new Error('当前浏览器不支持 IndexedDB'));
    }

    return new Promise((resolve, reject) => {
      const request = this.indexedDB.open(this.dbName, this.dbVersion);

      // 数据库打开成功
      request.onsuccess = (event) => {
        this.db = event.target.result;
        console.log(`数据库 ${this.dbName} 打开成功,版本号:${this.db.version}`);
        resolve(this.db);
      };

      // 数据库打开失败
      request.onerror = (event) => {
        reject(new Error(`数据库打开失败:${event.target.error.message}`));
      };

      // 数据库创建/升级
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        console.log(`数据库升级:旧版本 ${event.oldVersion} → 新版本 ${event.newVersion}`);

        // 创建表和索引
        tableConfigs.forEach(({ tableName, keyPath, autoIncrement = false, indexList = [] }) => {
          if (!db.objectStoreNames.contains(tableName)) {
            // 配置主键
            const storeOptions = keyPath 
              ? { keyPath, autoIncrement } 
              : { autoIncrement: true }; // 无 keyPath 时自动生成主键 id

            const objectStore = db.createObjectStore(tableName, storeOptions);

            // 创建索引
            indexList.forEach(({ indexName, propName, unique = false }) => {
              objectStore.createIndex(indexName, propName, { unique });
            });

            console.log(`表 ${tableName} 创建成功`);
          }
        });

        resolve(db);
      };
    });
  }

  /**
   * 开启事务
   * @param {String|Array} tableNames 表名(单个或多个)
   * @param {String} mode 事务模式:readonly / readwrite
   * @returns {Object} objectStore 实例
   */
  _transaction(tableNames, mode = 'readonly') {
    if (!this.db) {
      throw new Error('数据库未初始化,请先调用 init 方法');
    }

    const transaction = this.db.transaction(tableNames, mode);
    // 事务失败处理
    transaction.onerror = (event) => {
      throw new Error(`事务失败:${event.target.error.message}`);
    };

    // 支持单个表名直接返回 objectStore,多个表名返回事务实例
    return Array.isArray(tableNames) && tableNames.length > 1 
      ? transaction 
      : transaction.objectStore(Array.isArray(tableNames) ? tableNames[0] : tableNames);
  }

  /**
   * 新增数据
   * @param {String} tableName 表名
   * @param {Object|Array} data 单个数据对象或数组
   * @returns {Promise}
   */
  add(tableName, data) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const isBatch = Array.isArray(data);

        // 批量新增
        if (isBatch) {
          data.forEach(item => objectStore.add(item));
        } else {
          objectStore.add(data);
        }

        // 事务完成
        objectStore.transaction.oncomplete = () => {
          resolve(isBatch ? `批量新增 ${data.length} 条数据成功` : '数据新增成功');
        };

        // 事务失败
        objectStore.transaction.onerror = (event) => {
          reject(new Error(`数据新增失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 查询数据
   * @param {String} tableName 表名
   * @param {Object} options 查询配置:{ key, indexName, indexValue, cursorFilter }
   * @returns {Promise}
   */
  get(tableName, options = {}) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName);
        const { key, indexName, indexValue, cursorFilter } = options;
        let request;

        // 1. 主键查询
        if (key !== undefined) {
          request = objectStore.get(key);
        }
        // 2. 索引查询
        else if (indexName && indexValue !== undefined) {
          request = objectStore.index(indexName).get(indexValue);
        }
        // 3. 游标查询(支持筛选)
        else if (cursorFilter && typeof cursorFilter === 'function') {
          request = objectStore.openCursor();
          const result = [];

          request.onsuccess = (event) => {
            const cursor = event.target.result;
            if (cursor) {
              if (cursorFilter(cursor.value)) {
                result.push(cursor.value);
              }
              cursor.continue();
            } else {
              resolve(result);
            }
          };

          return;
        }
        // 4. 全量查询
        else {
          request = objectStore.getAll();
        }

        // 普通查询结果处理
        request.onsuccess = () => {
          resolve(request.result);
        };

        request.onerror = (event) => {
          reject(new Error(`数据查询失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 修改数据
   * @param {String} tableName 表名
   * @param {Object} data 要修改的数据(必须包含主键)
   * @returns {Promise}
   */
  update(tableName, data) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.put(data); // 存在则更新,不存在则新增

        request.onsuccess = () => {
          resolve('数据修改成功');
        };

        request.onerror = (event) => {
          reject(new Error(`数据修改失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 删除数据
   * @param {String} tableName 表名
   * @param {String|Number} key 主键值
   * @returns {Promise}
   */
  delete(tableName, key) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.delete(key);

        request.onsuccess = () => {
          resolve('数据删除成功');
        };

        request.onerror = (event) => {
          reject(new Error(`数据删除失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 清空表数据
   * @param {String} tableName 表名
   * @returns {Promise}
   */
  clear(tableName) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.clear();

        request.onsuccess = () => {
          resolve('表数据清空成功');
        };

        request.onerror = (event) => {
          reject(new Error(`表数据清空失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 删除数据库
   * @returns {Promise}
   */
  deleteDatabase() {
    return new Promise((resolve, reject) => {
      if (this.db) {
        this.db.close(); // 先关闭数据库连接
      }

      const request = this.indexedDB.deleteDatabase(this.dbName);
      request.onsuccess = () => {
        resolve(`数据库 ${this.dbName} 删除成功`);
      };
      request.onerror = (event) => {
        reject(new Error(`数据库删除失败:${event.target.error.message}`));
      };
    });
  }

  /**
   * 关闭数据库连接
   */
  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
      console.log(`数据库 ${this.dbName} 已关闭`);
    }
  }
}

export default IndexedDB;

工具类使用示例

// 1. 初始化数据库
import IndexedDB from './indexedDB';

// 配置表结构
const tableConfigs = [
  {
    tableName: 'cameraDevice',
    keyPath: 'deviceId', // 主键
    autoIncrement: false,
    indexList: [
      { indexName: 'idx_deviceName', propName: 'deviceName', unique: false },
      { indexName: 'idx_status', propName: 'status', unique: false }
    ]
  },
  {
    tableName: 'user',
    autoIncrement: true, // 自动生成主键 id
    indexList: [
      { indexName: 'idx_username', propName: 'username', unique: true }
    ]
  }
];

// 创建数据库实例(数据库名:deviceDB,版本号:2)
const db = new IndexedDB('deviceDB', 2);

// 初始化并操作数据
db.init(tableConfigs)
  .then(() => {
    // 2. 新增数据
    return db.add('cameraDevice', {
      deviceId: 'cam_1001',
      deviceName: '广州大桥摄像头',
      status: 1,
      resolution: '4K',
      createTime: new Date().toISOString()
    });
  })
  .then((msg) => {
    console.log(msg);
    // 3. 查询数据(游标筛选在线设备)
    return db.get('cameraDevice', {
      cursorFilter: (item) => item.status === 1
    });
  })
  .then((onlineCameras) => {
    console.log('在线设备:', onlineCameras);
    // 4. 修改数据
    return db.update('cameraDevice', {
      deviceId: 'cam_1001',
      deviceName: '广州大桥摄像头',
      status: 1,
      resolution: '8K', // 更新分辨率
      createTime: new Date().toISOString()
    });
  })
  .then((msg) => {
    console.log(msg);
    // 5. 删除数据
    return db.delete('cameraDevice', 'cam_1001');
  })
  .then((msg) => {
    console.log(msg);
    // 6. 关闭数据库
    db.close();
  })
  .catch((error) => {
    console.error('操作失败:', error.message);
    db.close();
  });

五、最佳实践与注意事项(补充关键细节)

  1. 版本号管理:数据库版本号必须为正整数,升级后无法回退,修改表结构时需递增版本号。

  2. 事务生命周期:事务会在操作完成后自动提交,若长时间不操作(如超过 5 秒)会被浏览器终止,建议操作完成后立即关闭数据库。

  3. 错误处理:所有操作都需捕获错误(如主键重复、权限不足、存储满),避免影响页面正常运行。

  4. 性能优化

    1. 批量操作时尽量合并为一个事务,减少事务创建次数。
    2. 大量数据查询使用游标(Cursor),避免使用 getAll() 导致内存占用过高。
    3. 合理创建索引,提升查询效率,但避免过多索引(会影响新增/修改性能)。
  5. 数据序列化:虽然 IndexedDB 支持存储对象,但复杂对象(如函数、循环引用对象)无法存储,需提前序列化(如 JSON.stringify)。

  6. 浏览器兼容性:主流浏览器(Chrome、Firefox、Edge、Safari 10.1+)均支持 IndexedDB,如需兼容旧浏览器(如 IE9-),需使用 localForage 等兼容库。

  7. 安全限制:IndexedDB 受同源策略限制,不同域名无法访问彼此的数据库;本地文件(file:// 协议)无法使用 IndexedDB,需通过服务器(http:///https://)访问。

六、总结

IndexedDB 作为前端高性能本地数据库,适用于需要存储大量结构化数据、实现离线功能的场景(如离线应用、数据缓存、本地日志存储等)。本文从存储方案对比、核心概念、基础用法、完整封装到最佳实践,全面覆盖了 IndexedDB 的关键知识点,弥补了基础用法的遗漏(如游标查询、批量操作、错误处理),封装的工具类简化了开发流程,提升了代码复用性。

在Angular中实现基于nz-calendar的日历甘特图

作者 再花
2025年12月19日 16:55

前言

最近有一个日历相关的功能需求,用于记录主站的各类促销活动。

其中比较棘手的需求是日历需要拥有甘特图那样的功能,持续一段时间的活动需要在日历中以长条形态显示,而不同活动的持续时间不同、排序顺序也不同,互相拼凑留空的逻辑非常复杂。

产品给的参考图如下(涉及公司隐私就全打码了):

思路分析

ng-zorro的日历组件(nz-calendar)我们可以通过控制台源码看出来实际上日历的原理就是一个表格(table),要在日历受限的单元格空间内实现甘特图的长条跨度效果,核心难点在于跨行对齐

我本打算寻找一个拥有这样功能的Angular日历组件,但Github上的Angular日历简陋得像七八年前的样式(仔细一看更新日期,真的是七八年前更新的!)

常规日历组件的 dateCellRender 是以“天”为单位渲染的,如果单纯在每一天里渲染各自的活动,长条活动会被切断。为了让长条在视觉上连贯且不出现错位,必须引入轨道算法(Track Algorithm)

按周分类:将日历视图分为6周,每周作为一个逻辑块,如果活动换行,就固定在第一列(周一)显示活动名称即可,解决了比较棘手的换行样式逻辑问题。

轨道索引:为每个活动分配一个垂直方向的索引(Track Index) 。如果活动 A 占用了第 0 层轨道,那么同一时间段内的活动 B 只能排在第 1 层轨道。

等高占位:如果周一有三个活动(占用3层轨道),而周二只有一个活动(占用第2层轨道),那么周二的第 0、1 层必须渲染隐藏的占位符(Placeholder),以确保周二的活动能与周一的第三个活动在水平高度上对齐。

数据结构

后端返回的数据是基于日期的聚合结构(例如 DateData[]),即每一天下面挂载一个活动列表。

interface DateData {
  date: string;         // 日期
  events: EventItem[];  // 当天的活动列表
}

interface EventItem {
  products_activity_id: string; // 活动ID
  title: string;                // 活动名称
  color_type: string;           // 活动颜色
}

但在甘特图中,我们需要知道活动的完整生命周期。因此,首先需要将数据“平铺”,生成以活动 ID 为主键的结构。

(如果你的数据格式已经是这样了,就不需要转换)

interface FlattenedEvent {
  start: string;// 活动起始日期
  end: string;  // 活动结束日期
  id: string;   // ID
  title: string;// 活动标题
  color: string;// 活动颜色(根据活动状态改变)
}

flattenEvents(data: DateData[]): FlattenedEvent[] {
  const eventMap = new Map<string, FlattenedEvent>();
  // 确保日期升序排列
  const sortedData = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());

  sortedData.forEach((dateData) => {
    dateData.events.forEach((event) => {
      const eventId = event.products_activity_id;
      if (eventMap.has(eventId)) {
        // 更新已有活动的结束日期
        eventMap.get(eventId)!.end = dateData.date;
      } else {
        // 记录新活动
        eventMap.set(eventId, {
          start: dateData.date,
          end: dateData.date,
          id: eventId,
          title: event.title,
          color: event.color_type,
        });
      }
    });
  });
  return Array.from(eventMap.values());
}

代码实现

轨道算法实现

generateCalendarMatrix 方法中,我们计算出每一天对应的 daySlots

// 事件映射表,key为日期字符串,value为该日期的活动数组
eventsMap: Record<string, any[]> = {};

// 日历矩阵数据,key为日期字符串,value为当天的槽位数组(事件+占位符)
calendarMatrix: Record<string, any[]> = {};

// 当前悬停的活动ID,用于高亮显示
hoveredEventId: string | null = null; 

/**
   * 核心逻辑:甘特图矩阵生成
   * 负责计算每一天应该显示哪些活动条,以及它们所在的“轨道”层级
   */
generateCalendarMatrix(events: FlattenedEvent[]) {
  this.calendarMatrix = {};
  const viewStart = this.getCalendarViewStart(this.month); // 获取日历第一格日期

  // 6周循环
  for (let week = 0; week < 6; week++) {
    const weekStart = new Date(viewStart);
    weekStart.setDate(viewStart.getDate() + week * 7);
    const weekEnd = new Date(weekStart);
    weekEnd.setDate(weekStart.getDate() + 6);

    // 筛选并排序:开始早且跨度长的优先占据上方轨道
    const weekEvents = processedEvents.filter(e => e._end >= weekStart && e._start <= weekEnd)
      .sort((a, b) => a._start.getTime() - b._start.getTime() || (b._end - b._start) - (a._end - a._start));

    const tracks: any[][] = [];
    let weekMaxTrackIndex = -1;

    weekEvents.forEach(event => {
      const eventStartDay = Math.max(0, Math.floor((event._start - weekStart) / 86400000));
      const eventEndDay = Math.min(6, Math.floor((event._end - weekStart) / 86400000));

      let trackIndex = 0;
      while (true) {
        if (!tracks[trackIndex]) tracks[trackIndex] = new Array(7).fill(null);
        // 碰撞检测
        if (tracks[trackIndex].slice(eventStartDay, eventEndDay + 1).every(v => v === null)) {
          for (let d = eventStartDay; d <= eventEndDay; d++) {
            tracks[trackIndex][d] = {
              event,
              showTitle: d === eventStartDay || d === 0, // 仅在起点或周一显示标题
              isRealStart: d === Math.floor((event._start - weekStart) / 86400000),
              isRealEnd: d === Math.floor((event._end - weekStart) / 86400000)
            };
          }
          weekMaxTrackIndex = Math.max(weekMaxTrackIndex, trackIndex);
          break;
        }
        trackIndex++;
      }
    });

    // 填充矩阵:缺失轨道补 Placeholder
    for (let day = 0; day < 7; day++) {
      const dateStr = this.formatDateKey(new Date(weekStart.getTime() + day * 86400000));
      this.calendarMatrix[dateStr] = Array.from({ length: weekMaxTrackIndex + 1 }, (_, i) => 
        tracks[i]?.[day] ? { type: 'event', ...tracks[i][day] } : { type: 'placeholder' }
      );
    }
  }
}

/**
   * 修正日历起始日期:由于 nz-calendar 通常从周一开始显示,
   * 需要计算当前月第一天距离该周周一的偏移量。
   */
getCalendarViewStart(currentMonth: Date): Date {
    const startOfMonth = new Date(
        currentMonth.getFullYear(),
        currentMonth.getMonth(),
        1
    );
    let day = startOfMonth.getDay(); // 0 是周日

    // 计算偏移量:如果是周日(0),前面有6天;其他情况则是 day-1
    const offset = day === 0 ? 6 : day - 1;

    const startView = new Date(startOfMonth);
    startView.setDate(startOfMonth.getDate() - offset);
    return startView;
}

/**
   * 根据颜色类型返回对应的CSS颜色值
   */
private getColor(type: string): string {
    const map: Record<string, string> = {
        '1': '#f5f5f5', // 浅灰 (草稿/未开始)
        '2': '#F7A4A4', // 浅红 (促销类型A)
        '3': '#B6E2A1', // 浅绿 (促销类型B)
        '4': '#bae7ff', // 浅蓝 (其他)
    };
    return map[type] || '#B6E2A1'; // 默认浅绿
}

/**
   * 日期格式转换
   */
formatDateKey(date: Date): string {
    return `${date.getFullYear()}-${(date.getMonth() + 1)
        .toString()
        .padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}

/**
   * 鼠标移入事件处理
   */
onMouseEnter(eventId: string) {
    this.hoveredEventId = eventId;
}

/**
   * 鼠标移出事件处理
   */
onMouseLeave() {
    this.hoveredEventId = null;
}

样式支撑

为了实现视觉上的长条效果,需要对 nz-calendar 的默认样式进行深度覆盖,并利用 CSS 类的配合处理圆角。

/* 容器:垂直排列 */
.calendar-slot-container {
    display: flex;
    flex-direction: column;
}

/* 活动条与占位符:高度必须一致 */
.event-slot, .placeholder {
    height: 22px;
    margin-bottom: 2px;
}

.event-bar {
    border-radius: 0; /* 默认直角实现无缝连接 */
    border-top: 0.5px lightgray solid;
    border-bottom: 0.5px lightgray solid;
}

/* 仅在活动逻辑起点显示左圆角 */
.event-bar.is-real-start {
    border-top-left-radius: 4px;
    border-bottom-left-radius: 4px;
    margin-left: 3px;
    border-left: 0.5px lightgray solid;
}

/* 仅在活动逻辑终点显示右圆角 */
.event-bar.is-real-end {
    border-top-right-radius: 4px;
    border-bottom-right-radius: 4px;
    margin-right: 3px;
    border-right: 0.5px lightgray solid;
}

HTML 模板渲染

利用 ng-template 渲染矩阵数据,通过 item.type 区分渲染实体活动还是占位符。

<nz-card [nzTitle]="getYearMonth(date)">
    <nz-calendar [(ngModel)]="date" (ngModelChange)="onCalendarChange($event)" [nzDateCell]="tpl"
                 [nzCustomHeader]="customHeader">
    </nz-calendar>

    <ng-template #customHeader>
        <div style="padding: 4px;width: 100%;" nz-flex nzJustify="end"></div>
    </ng-template>

    <ng-template #tpl let-date>
        <div class="calendar-slot-container">
            @for (item of dateCellRender(date); track $index) {
            @if (item.type === 'placeholder') {
            <div class="placeholder"></div>
            } @else {
            <div class="event-slot event-bar"
                 [style.background-color]="hoveredEventId === item.event.id ? '#1890FF' : item.event.color"
                 [class.is-real-start]="item.isRealStart" [class.is-real-end]="item.isRealEnd"
                 [class.is-hovered]="hoveredEventId === item.event.id" (mouseenter)="onMouseEnter(item.event.id)"
                 (mouseleave)="onMouseLeave()">

                @if (item.showTitle) {
                <span class="event-title" [style.color]="hoveredEventId === item.event.id ? 'white' : '#000'">
                    {{ item.event.title }}
                </span>
                }
            </div>
            }
            }
        </div>
    </ng-template>
</nz-card>

成品

图中均为测试数据,不具备任何真实性和隐私。

如图中展示,每个活动的轨道展示正常,空白符渲染合理,并且会根据状态渲染对应的颜色。

如图中展示,鼠标移入后,日历中对应的活动均会高亮,即使跨周也会显示。

【性能优化】给Vue应用“瘦身”:让你的网页快如闪电的烹饪秘籍

作者 JS_Likers
2025年12月19日 16:26

欢迎使用我的小程序👇👇👇👇

small.png


想象一下:你精心烹制的Vue应用端上桌,用户却因加载慢而转身离开...别担心!今天我来教你几招性能优化“烹饪技巧”,让你的应用“色香味”俱全!

🍳 前菜:为什么需要性能优化?

你的Vue应用就像一道菜,用户希望:

  • 快速上菜(首屏加载快)
  • 口感顺滑(交互流畅)
  • 回味无穷(使用体验好)

性能差的网站就像冷掉的披萨,再好吃也没人爱!

🔪 主菜:Vue性能优化七大秘籍

1. 代码打包:给食材“瘦身”

// vue.config.js - 就像主厨的调味秘诀
const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
  chainWebpack: config => {
    // 开启Gzip压缩 - 像真空压缩食材
    config.plugin('compression').use(require('compression-webpack-plugin'))
    
    // 拆分包 - 分开装盘更优雅
    config.optimization.splitChunks({
      chunks: 'all',
      maxSize: 244 * 1024, // 每个“餐盘”不超过244KB
    })
  }
})

小技巧:使用 vue-cli--report 参数生成打包分析报告,像X光一样看清你的“脂肪”分布!

2. 懒加载:像自助餐一样按需取用

<template>
  <div>
    <!-- 常规加载 - 一次全上桌 -->
    <!-- <HeavyComponent /> -->
    
    <!-- 懒加载 - 客人需要时才上菜 -->
    <button @click="showComponent = true">点这道菜</button>
    <component v-if="showComponent" :is="HeavyComponent" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      showComponent: false,
      HeavyComponent: () => import('./components/HeavyComponent.vue')
    }
  }
}
</script>

路由懒加载更是神器:

// 路由配置 - 每道菜单独包装
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue') // 客人进入餐厅才准备这道菜
  }
]

3. 虚拟滚动:长列表的“折叠椅”

想象一下10000个项目的列表——就像要同时展示10000道菜,不可能!虚拟滚动只渲染可视区域:

<template>
  <!-- 普通列表 - 所有菜都摆出来 -->
  <!-- <div v-for="item in 10000" :key="item.id">{{ item.name }}</div> -->
  
  <!-- 虚拟滚动 - 只摆客人能看到的几道 -->
  <VirtualList :items="largeList" :item-height="50">
    <template #default="{ item }">
      <ListItem :item="item" />
    </template>
  </VirtualList>
</template>

4. 计算属性 vs 方法:聪明的“预制菜”

<template>
  <div>
    <!-- 方法调用 - 每次点单都现做(性能差) -->
    <!-- <p>{{ calculateTotal() }}</p> -->
    
    <!-- 计算属性 - 提前准备好的预制菜(性能好) -->
    <p>{{ totalPrice }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { price: 100, quantity: 2 },
        { price: 200, quantity: 1 }
      ]
    }
  },
  computed: {
    // 依赖变化时才重新计算
    totalPrice() {
      return this.items.reduce((sum, item) => 
        sum + item.price * item.quantity, 0
      )
    }
  },
  methods: {
    // 每次渲染都会执行
    calculateTotal() {
      return this.items.reduce((sum, item) => 
        sum + item.price * item.quantity, 0
      )
    }
  }
}
</script>

5. Keep-Alive:给组件盖“保温盖”

<template>
  <!-- 常规组件 - 离开就倒掉 -->
  <!-- <TabContent /> -->
  
  <!-- Keep-Alive - 盖上保温盖,回来还是热的 -->
  <keep-alive>
    <component :is="currentTab" />
  </keep-alive>
</template>

6. 图片优化:给视觉“减负”

懒加载图片:

<template>
  <img 
    v-lazy="imageUrl" 
    alt="美味佳肴"
    loading="lazy"  <!-- 原生懒加载 -->
  />
</template>

<script>
import VueLazyload from 'vue-lazyload'

Vue.use(VueLazyload, {
  preLoad: 1.3, // 提前1.3屏加载
  attempt: 3    // 尝试3次加载
})
</script>

现代图片格式:

  • WebP:比JPEG小25-35%
  • AVIF:下一代格式,压缩率更高

7. 监控与分析:安装“厨房摄像头”

// 性能监控
export default {
  mounted() {
    // 测量组件加载时间
    const start = performance.now()
    
    this.$nextTick(() => {
      const end = performance.now()
      console.log(`组件渲染耗时: ${end - start}ms`)
      
      // 发送到监控平台
      this.sendMetrics('component_render_time', end - start)
    })
  }
}

🍰 甜点:快速优化清单

立即能做的:

  1. 开启Gzip压缩(服务器配置)
  2. 使用路由懒加载
  3. 压缩图片(Tinypng.com)
  4. 移除未使用的代码

进阶技巧:

  1. 使用CDN分发静态资源
  2. 服务端渲染(SSR)改善首屏
  3. PWA提升离线体验
  4. Web Workers处理繁重计算

📊 成果展示:优化前后对比

指标 优化前 优化后 提升
首屏加载 4.2s 1.8s ⬇️ 57%
打包体积 2.1MB 890KB ⬇️ 58%
Lighthouse评分 62 92 ⬆️ 30分

🎯 结语:优化是持续的过程

性能优化就像保持身材——不是一次性节食,而是养成健康习惯。每周花15分钟检查你的Vue应用:

  1. 运行 npm run build -- --report
  2. 查看Lighthouse报告
  3. 优化最慢的3个组件

记住:每毫秒都很重要——100毫秒的延迟就能让转化率下降7%!

今日主厨推荐:从路由懒加载开始,这是性价比最高的优化方式!


💡 小测验:你的Vue应用现在“体重”多少?运行 npm run build 看看打包后的体积,在评论区分享你的“减肥”成果吧!

优化愉快,让你的Vue应用飞起来!🚀

❌
❌