阅读视图

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

TDMQ CKafka 版客户端实战指南系列之二:消费消息最佳实践

导语

在数字化时代,消息队列系统已成为企业架构中不可或缺的一部分,其中,TDMQ CKafka 版作为一种高效、可扩展的分布式消息系统,广泛应用于各类业务场景中。上一篇我们深入探讨了 TDMQ CKafka 版的生产实践,从消息发送、分区策略到高可用性保障,全方位解析了如何在生产环境中高效利用 TDMQ CKafka 版。本文将接续前文,聚焦于 TDMQ CKafka 版的消费实践,探讨如何稳扎稳打、精准消费,确保消息从生产到消费的完整链条顺畅无阻。

在消费篇中,我们将详细阐述消费消息的基本流程、负载均衡机制、应对重平衡的策略、订阅关系的管理、消费位点的控制、消息重复与消费幂等性的处理、消费失败的应对、消费延迟与堆积的解决,以及如何通过调整套接字缓冲区、模拟消息广播、实现消息过滤等高级技巧,优化 TDMQ CKafka 版的消费性能。

接下来,让我们一同深入探索 TDMQ CKafka 版的消费实践,解锁高效消息处理的秘诀。

消费篇:稳扎稳打,精准消费

消费消息流程

消费消息的基本流程并不复杂,首先是 Poll 数据,消费者从消息队列中拉取消息;接着执行消费逻辑,对拉取到的消息进行处理;处理完成后再次 Poll 数据,如此循环往复。例如,在一个电商订单处理系统中,消费者从消息队列中拉取订单消息,然后根据订单信息进行库存扣减、订单状态更新等操作,完成后继续拉取下一批订单消息。

负载均衡机制

负载均衡

负载均衡在消费过程中起着关键作用。每个 Consumer Group 可以包含多个 Consumer ,只要将参数 group.id 设置成相同的值,这些 Consumer 就属于同一个 Consumer Group,共同负责消费订阅的 Topic。

例如:Consumer Group A 订阅了 Topic A,并开启三个消费实例 C1、C2、C3,则发送到 Topic A 的每条消息最终只会传给 C1、C2、C3 的某一个。TDMQ CKafka 版默认会均匀地把消息传给各个消费实例,以做到消费负载均衡。

TDMQ CKafka 版负载均衡的内部原理是:把订阅的 Topic 的分区,平均分配给各个 Consumer。因此,Consumer 的个数不要大于分区的数量,否则会有消费实例分配不到任何分区而处于空跑状态,尽量保证消费者数量能被分区总数整除。除了第一次启动上线之外,后续消费实例发生重启、增加、减少,分区数发生增加等变更时,都会触发一次重均衡。

应对重均衡

如果频繁出现 Rebalance,可能有多种原因。

1、  消费者消费处理耗时很长,比如在处理一些复杂的业务逻辑时,可能需要进行多次数据库查询或远程接口调用,这会导致消费速度变慢;

2、  消费某一个异常消息也可能导致消费者阻塞或者失败,例如消息格式错误,消费者无法解析;

3、  心跳超时同样会引发 Rebalance 。

4、  在 v0.10.2 之前版本的客户端,Consumer 没有独立线程维持心跳,而是把心跳维持与 Poll 接口耦合在一起,若用户消费出现卡顿,就会导致 Consumer 心跳超时,引发 Rebalance;在 v0.10.2 及之后版本的客户端,如果消费时间过慢,超过一定时间(max.poll.interval.ms 设置的值,默认5分钟)未进行 Poll 拉取消息,则会导致客户端主动离开队列,而引发 Rebalance。

可以通过优化消费处理提高消费速度和参数调整等方法解决:

1、  消费端需要和 Broker 版本保持一致。

2、  可以参考以下说明调整参数值:

● session.timeout.ms:在 v0.10.2 之前的版本可适当提高该参数值,需要大于消费一批数据的时间,但不要超过 30s,建议设置为25s ,而 v0.10.2 及其之后的版本,保持默认值10s即可;

● max.poll.records:降低该参数值,建议远远小于单个线程每秒消费的条数 * 消费线程的个数 * max.poll.interval.ms / 1000 的值;

● max.poll.interval.ms :该值要大于 max.poll.records / (单个线程每秒消费的条数 * 消费线程的个数 ) 的值。

3、  尽量提高客户端的消费速度,将消费逻辑另起线程进行处理,并针对耗时进行监控。

4、  减少 Group 订阅 Topic 的数量,一个 Group 订阅的 Topic 最好不要超过5个,建议一个 Group 只订阅一个 Topic。

主题订阅关系

在订阅关系方面,同一个 Consumer Group 内,建议客户端订阅的 Topic 保持一致,即一个 Consumer Group 订阅一个 Topic,这样可以避免给排查问题带来更多复杂度。

Consumer Group 订阅多个 Topic

一个 Consumer Group 可以订阅多个 Topic ,此时多个 Topic 的消息会被 Cosumer Group 中的 Consumer 均匀消费。例如 Consumer Group A 订阅了 Topic A、Topic B、Topic C,则这三个 Topic 中的消息,被 Consumer Group 中的 Consumer 均匀消费。

Consumer Group 订阅多个 Topic 的示例代码如下:

String topicStr = kafkaProperties.getProperty("topic");
String[] topics = topicStr.split(",");
for (String topic: topics) {
    subscribedTopics.add(topic.trim());
}
consumer.subscribe(subscribedTopics);

Topic 被多个 Consumer Group 订阅

一个 Topic 可以被多个 Consumer Group 订阅,且各个 Consumer Group 独立消费 Topic 下的所有消息。例如 Consumer Group A 订阅了 Topic A,Consumer Group B 也订阅了 Topic A,则发送到 Topic A 的每条消息,不仅会传一份给 Consumer Group A 的消费实例,也会传一份给 Consumer Group B 的消费实例,且这两个过程相互独立,相互没有任何影响。

一个 Consumer Group 对应一个应用

建议一个 Consumer Group 对应一个应用,即不同的应用对应不同的代码。如果您需要将不同的代码写在同一个应用中,请准备多份不同的 kafka.properties。例如:kafka1.properties、kafka2.properties。

消费位点解析

每个 Topic 会有多个分区,每个分区会统计当前消息的总条数,这个称为最大位点 MaxOffset。

TDMQ CKafka 版的 Consumer 会按顺序依次消费分区内的每条消息,记录已经消费了的消息条数,称为消费位点 ConsumerOffset。

剩余的未消费的条数(也称为消息堆积量)=MaxOffset-ConsumerOffset。

Offset 提交

TDMQ CKafka 版的 Consumer 有两个相关参数:

● enable.auto.commit:默认值为 True。

● auto.commit.interval.ms: 默认值为5000,即5s。

这两个参数组合的结果为:每次 Poll 数据前会先检查上次提交位点的时间,如果距离当前时间已经超过参数 auto.commit.interval.ms 规定的时长,则客户端会启动位点提交动作。

因此,如果将 enable.auto.commit 设置为 True,则需要在每次 Poll 数据时,确保前一次 Poll 出来的数据已经消费完毕,否则可能导致位点跳跃。

如果想自己控制位点提交,请把 enable.auto.commit 设为 False,并调用 Commit(Offsets) 函数自行控制位点提交。

注意:

尽量避免提交位点请求过于频繁,否则容易导致 Broker CPU 很高,影响正常的服务。例如自动提交位点设置 auto.commit.interval.ms 为100ms,手动提交位点,在高吞吐场景下,每消费一条消息提交一个位点。

重置 Offset

以下两种情况,会发生消费位点重置:

● 当服务端不存在曾经提交过的位点时(例如客户端第一次上线)。

● 当从非法位点拉取消息时(例如某个分区最大位点是10,但客户端却从11开始拉取消息)。

Java 客户端可以通过 auto.offset.reset 来配置重置策略,主要有三种策略:

● Latest:从最大位点开始消费。

● Earliest:从最小位点开始消费。

● None:不做任何操作,即不重置。

说明:

建议设置成 Latest,而不要设置成 Earliest,避免因位点非法时从头开始消费,从而造成大量重复。

如果是您自己管理位点,可以设置成 None。

拉取消息优化

拉取消息

消费过程是由客户端主动去服务端拉取消息的,在拉取大消息时需要控制拉取速度,注意以下参数设置:

● max.poll.records:如果单条消息超过1MB,建议设置为1。

● max.partition.fetch.bytes:设置为比单条消息的大小略大一点。

● fetch.max.bytes:设置为比单条消息的大小略大一点。

通过公网消费消息时,通常会因为公网带宽的限制导致连接被断开,此时需要注意控制拉取速度,修改配置:

● fetch.max.bytes:建议设置成公网带宽的一半(注意该参数的单位是 bytes,公网带宽的单位是 bits)。

● max.partition.fetch.bytes:建议设置成 fetch.max.bytes 的三分之一或者四分之一。

拉取大消息

消费过程是由客户端主动去服务端拉取消息的,在拉取大消息时,需要注意控制拉取速度,注意修改配置:

● max.poll.records:每次 Poll 获取的最大消息数量。如果单条消息超过1MB,建议设置为1。

● fetch.max.bytes:设置比单条消息的大小略大一点。

● max.partition.fetch.bytes:设置比单条消息的大小略大一点。

拉取大消息的核心是逐条拉取。

消息异常处理

消息重复和消费幂等

TDMQ CKafka 版消费的语义是 at least once, 也就是至少投递一次,保证消息不丢失,但是无法保证消息不重复。在出现网络问题、客户端重启时均有可能造成少量重复消息,此时应用消费端如果对消息重复比较敏感(例如订单交易类),则应该做消息幂等。

以数据库类应用为例,常用做法为:

  • 发送消息时,传入 Key 作为唯一流水号 ID。

  • 消费消息时,判断 Key 是否已经消费过,如果已经消费过了,则忽略,如果没消费过,则消费一次。

当然,如果应用本身对少量消息重复不敏感,则不需要做此类幂等检查。

消费失败

TDMQ CKafka 版是按分区消息顺序逐条向前推进消费的,如果消费端拿到某条消息后执行消费逻辑失败,例如应用服务器出现了脏数据,导致某条消息处理失败,等待人工干预,那么有以下两种处理方式:

失败后一直尝试再次执行消费逻辑。这种方式有可能造成消费线程阻塞在当前消息,无法向前推进,造成消息堆积。

由于 Kafka 没有处理失败消息的设计,实践中通常会打印失败的消息或者存储到某个服务(例如创建一个 Topic 专门用来放失败的消息),然后定时检查失败消息的情况,分析失败原因,根据情况处理。

消费延迟

消费过程是由客户端主动去服务端拉取消息。一般情况下,如果客户端能够及时消费,则不会产生较大延迟。若产生了较大延迟,请先关注是否有堆积,并注意提高消费速度。

消费堆积

通常造成消息堆积的原因是:

● 消费速度跟不上生产速度,此时应该提高消费速度。

● 消费端产生了阻塞。

● 消费端拿到消息后,执行消费逻辑,通常会执行一些远程调用,如果这个时候同步等待结果,则有可能造成一直等待,消费进程无法向前推进。

消费端应该尽量避免堵塞消费线程,如果存在等待调用结果的情况,建议设置等待的超时时间,超时后作为消费失败进行处理。

提高消费速度

方式1:增加 Consumer 实例个数提高并行处理能力,如果消费者和分区数已经1:1,可以考虑增加分区数(注意:对于 Flink 自动维护分区的场景不会自动感知新增分区后可能需要修改相关代码后重启)。 可以在进程内直接增加(需要保证每个实例对应一个线程),也可以部署多个消费实例进程。

说明:

实例个数超过分区数量后就不再能提高速度,将会有消费实例不工作。

方式2:增加消费线程。

  1. 定义一个线程池。

  2. Poll 数据。

  3. 把数据提交到线程池进行并发处理。

  4. 等并发结果返回成功后,再次 poll 数据执行。

消费某些分区不消费

消费者在消费过程中,可能遇到消费者在线,但是某些分区的位点一致不前进,可能原因如下:

  1. 遇到一条异常消息,可能是超大消息,格式异常,导致消费者拉取消息时候,转换成业务位点。

  2. 使用公网带宽,带宽较小,拉取大消息时候直接把带宽打满,导致在超时时间内拉取不到消息。

  3. 消费者假死,导致不去拉取。

解决方式:

关掉消费者,在 TDMQ CKafka 版控制台设置位点,跳过某些异常消息,或者优化消费代码,然后重启消费者消费。

消息订阅模式

消息广播

Kafka 目前没有消息广播的语义,可以通过创建不同的 Group 来模拟实现。

消息过滤

Kafka 自身没有消息过滤的语义。实践中可以采取以下两个办法:

● 如果过滤的种类不多,可以采取多个 Topic 的方式达到过滤的目的。

● 如果过滤的种类多,则最好在客户端业务层面自行过滤。

实践中请根据业务具体情况进行选择,也可以综合运用上面两种办法。

总结:回顾要点,展望应用

通过前面的学习,我们系统地了解了生产消费的关键要点。在生产方面,从 Topic 的使用与创建,到分区数的估算、重试策略的设置,再到发送模式和参数的优化,以及 Key、Value 和分区策略的应用,每一个环节都需要我们精心配置,以确保高效、稳定的生产。在消费方面,消费的基本流程、负载均衡的实现、应对重均衡的策略、订阅关系的管理、Offset 的管理与拉取策略,以及处理消息重复和消费失败的方法,这些都是我们在消费过程中需要重点关注的内容。

这些生产消费知识在实际应用中具有巨大的价值。希望大家能将所学的生产消费知识运用到实际工作和生活中,不断探索和实践。也期待大家在实践过程中,能总结出更多宝贵的经验,欢迎在留言区分享你们的实践故事和心得,让我们一起共同成长,共同进步 。

useSse 开源:如何把流式数据请求/处理简化到极致

ai-hooks 开源地址:github.com/Gijela/ai-h…

你是否也曾想在项目中轻松实现类似 ChatGPT 的流式对话界面,却被繁琐的状态管理、消息拼接、错误处理和生命周期控制所困扰?上期我们精读了 fetch-event-source 库的源码,其虽为我们提供了坚实的底层能力,但直接在业务组件中使用它,就如同给了你一台强大的引擎,却还需要自己动手组装车身、方向盘和仪表盘。

useSse hook 正是为此而生。它是一把精心打造的“瑞士军刀”,专门用于在 Vue3/React 环境下,优雅地处理服务器推送事件 (SSE)。它不仅封装了 fetch-event-source 的所有功能,更在 Vue/React 的响应式系统之上,构建了一套完整的、专为对话式 AI 应用设计的状态管理和交互模型。

本文将以 Vue3 为例,介绍 useSse,你将深入理解:

  • 为何需要 useSse:了解它如何从 fetch-event-source 的底层能力中升华,解决在 Vue 应用中的实际痛点。
  • “响应式状态机”的设计:剖析 useSse 如何巧妙地利用 ref 将连接状态 (connecting, streaming, completed, error 等) 与 UI 渲染无缝对接。
  • 消息流的生命周期管理:学习 append, stop 等核心方法如何精巧地控制从用户发送消息到接收、渲染、完成或中断的整个流程。
  • 高阶抽象的威力:通过对比原生 fetch-event-source 的用法,体会 useSse 这个高阶 Hook 如何大幅简化代码、提升开发效率和可维护性。
  • 最佳实践范例:获得一个即插即用的实战指南,快速在你的项目中集成 AI 对话能力。

准备好用一种更优雅的方式来驾驭 SSE 了吗?让我们开始吧!

缘起:直接用 fetch-event-source 不够香吗?

fetch-event-source 是一个出色的库,它解决了原生 EventSource 的诸多限制。然而,在构建一个功能完备的 Vue 对话界面时,我们面临的挑战远不止建立一个 SSE 连接那么简单:

  1. 状态管理之痛:加载状态 (loading)、错误信息 (error)、消息列表 (messages),这些都需要在 Vue 的响应式系统中进行管理,并与组件的生命周期同步。
  2. 消息格式化之繁:从服务器收到的原始数据块 (data chunk) 需要被解析、拼接,并转换成结构化的消息对象,才能被 UI 正确渲染。
  3. 用户交互之杂:需要处理用户输入、发送消息、中断流式响应、重试等多种交互逻辑。
  4. 生命周期之忧:当组件被卸载时,必须确保 SSE 连接被可靠地关闭,以防止内存泄漏和不必要的后台请求。

直接在组件中处理这些逻辑,会导致代码臃肿、状态分散、难以维护。useSse 的核心使命,就是将这些通用的、复杂的逻辑封装起来,提供一个干净、声明式且高度可复用的接口。

核心思想:一个 Hook,承包整个 AI 对话界面的状态管理

useSse 的设计哲学非常清晰:它不仅仅是一个网络请求的封装,而是一个完整的、面向对话场景的状态管理器。

它将一个典型的 AI 对话流程抽象为以下几个核心要素:

  • messages: 一个响应式的消息数组,是整个对话历史的“唯一事实来源”。
  • input: 一个响应式的字符串,用于双向绑定输入框。
  • loading: 一个响应式的布尔值,用于控制加载状态的 UI(如禁用发送按钮、显示加载动画)。
  • error: 一个响应式的错误对象,用于展示连接或解析过程中的异常。
  • append / stop / handleSubmit: 一系列精心设计的方法,用于驱动状态的流转和处理用户交互。

通过将这些状态和方法聚合在一个独立的 Hook 中,我们将复杂的业务逻辑从视图组件中剥离,使得组件只需关注如何渲染这些状态,从而实现了“逻辑”与“视图”的完美分离。

架构之美:响应式状态机与生命周期管理

useSse 的内部实现,可以看作一个围绕 SSE 连接生命周期构建的“响应式状态机”。其状态转换的核心,体现在 AssistantSseMessagestatus 字段上。

status (定义于 SseStatusEnum) 是驱动 UI 动态变化的关键。它精确地描述了助理消息从诞生到终结的每一个阶段:

  • idle: 初始状态。
  • connecting: 连接已发起,等待服务器响应。
  • streaming-content: 正在接收并渲染内容。
  • completed: 消息流正常结束。
  • aborted: 被用户手动中断。
  • error: 发生错误。

useSse 内部的 _sseFetch 函数通过监听 fetch-event-sourceonopen, onmessage, onclose, onerror 等事件,在关键节点更新最后一条助理消息的 status,从而自动触发 Vue 组件的重新渲染。

此外,通过 onUnmounted 生命周期钩子,useSse 确保了当使用它的组件被销毁时,会自动调用 stop() 方法来中止任何正在进行的连接,有效防止了资源泄漏。

API 详解

useSse 的 API 设计简洁而强大,分为“配置选项”和“返回值”两部分。

useSse(options: UseSseOptions)

初始化 Hook 时,你需要传入一个配置对象。

参数 类型 必需 描述
initialInput string 输入框的初始值。
initialMessages SseMessage[] 初始的消息列表。
formatMessage (data, message) => AssistantSseMessage 核心函数。用于将 SSE onmessage 事件中的原始 data 字符串,解析并合并到上一条助理消息中,返回更新后的消息对象。
onOpen (response: Response) => void 连接成功建立时的回调,可用于检查响应头。
onOpenJson (json: any) => void 当服务器返回非流式 JSON 响应(通常是错误信息)时的回调。
onMessage (data: string) => void 每次收到原始 data 字符串时的回调。
onFinish (message: SseMessage) => void 消息流正常结束 ([DONE]) 时的回调。
onClose () => void 连接关闭时的回调(无论正常或异常)。
onError (error: any) => void 发生任何错误时的回调。

返回值 (Returned Values)

调用 useSse 会返回一个包含响应式状态和方法的对象。

名称 类型 描述
messages Ref<SseMessage[]> 响应式的消息数组,可以直接在模板中 v-for 渲染。
error Ref<any> 响应式的错误对象。
loading Ref<boolean> 响应式的加载状态。
input Ref<string> 响应式的输入框内容,可使用 v-model 进行绑定。
append (prompt, options) => Promise<void> 核心方法。追加一条用户消息和一条空的助理消息,并立即开始 SSE 请求。
stop () => void 核心方法。手动中止当前的 SSE 连接。
setMessages (messages: SseMessage[]) => void 手动设置消息列表。
handleInputChange (e: Event) => void 用于原生 <input>onchange 事件处理器,与 input Ref 配合使用。
handleSubmit (e: Event, options) => void 便捷的表单提交处理器。它会阻止默认事件,调用 append,并清空输入框。

快速上手:构建一个简单的 AI 聊天机器人

下面是一个在 Vue 3 <script setup> 组件中使用 useSse 的完整示例。

<script setup lang="ts">
import { useSse } from '@/hooks/useSse'
import type { AssistantSseMessage } from '@/hooks/useSse/types'

// 1. 初始化 useSse Hook
const { messages, input, loading, handleSubmit, stop, handleInputChange } = useSse({
  // 2. 提供核心的消息格式化逻辑
  formatMessage: (data, lastMessage) => {
    try {
      // 假设服务器返回的是 JSON 字符串
      const jsonData = JSON.parse(data)

      // 将新的数据块追加到上一条消息的内容上
      const updatedContent = lastMessage.content + (jsonData.content || '')

      return {
        ...lastMessage,
        content: updatedContent,
        status: 'streaming-content', // 更新状态
      }
    } catch (e) {
      console.error('JSON parse error:', e)
      return {
        ...lastMessage,
        status: 'error',
      }
    }
  },
  // 可选:添加其他回调
  onError: (err) => {
    console.error('SSE Error:', err)
  },
})

// 3. 定义提交时需要传递给后端的参数
const getSseOptions = () => ({
  url: '/api/v1/chat/completions',
  method: 'POST',
  body: {
    // 假设你的 API 需要历史消息
    messages: messages.value.map(({ role, content }) => ({ role, content })),
    stream: true,
  },
})
</script>

<template>
  <div class="chat-container">
    <div class="message-list">
      <div v-for="msg in messages" :key="msg.id" class="message" :class="`message--${msg.role}`">
        <p>{{ msg.content }}</p>
        <!-- 根据 status 显示不同的 UI -->
        <div
          v-if="
            msg.role === 'assistant' && (msg as AssistantSseMessage).status === 'streaming-content'
          "
          class="typing-indicator"
        ></div>
      </div>
    </div>

    <!-- 用户可以随时中止 -->
    <button v-if="loading" @click="stop">Stop</button>

    <form @submit="(e) => handleSubmit(e, getSseOptions())">
      <input
        :value="input"
        @input="handleInputChange"
        placeholder="Type your message..."
        :disabled="loading"
      />
      <button type="submit" :disabled="loading">Send</button>
    </form>
  </div>
</template>

<style scoped>
/* ... 添加一些美化样式 ... */
.message {
  padding: 8px;
  margin-bottom: 8px;
  border-radius: 4px;
}
.message--user {
  background-color: #e1f5fe;
  text-align: right;
}
.message--assistant {
  background-color: #f1f1f1;
}
.typing-indicator {
  width: 8px;
  height: 8px;
  background-color: #333;
  border-radius: 50%;
  animation: typing 1s infinite;
}
@keyframes typing {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}
</style>

流程图示

下面是调用 append 方法后,useSse 内部的典型工作流程图,帮助你更直观地理解其运行机制。

sequenceDiagram
    participant Component as Vue组件
    participant UseSse as useSse Hook
    participant FetchEventSource as fetch-event-source
    participant Server as 服务器

    Component->>UseSse: 调用 append(prompt, sseOptions)
    activate UseSse

    UseSse->>UseSse: 更新 messages (添加用户和空的助理消息)
    UseSse->>UseSse: 设置 loading.value = true
    Note right of UseSse: 触发UI更新

    UseSse->>FetchEventSource: 调用 fetchEventSource(url, options)
    activate FetchEventSource

    FetchEventSource->>Server: 发起 HTTP 请求
    Server-->>FetchEventSource: 响应流 (Response Stream)

    FetchEventSource->>UseSse: onopen 回调
    UseSse->>UseSse: 更新最后一条消息 status = 'connecting'
    Note right of UseSse: 触发UI更新

    loop 接收数据流
        Server-->>FetchEventSource: 推送数据块 (chunk)
        FetchEventSource->>UseSse: onmessage 回调 (携带 data)
        UseSse->>UseSse: 调用 formatMessage(data, lastMessage)
        UseSse->>UseSse: 更新 messages (合并内容并更新 status)
        Note right of UseSse: 实时打字机效果
    end

    alt 正常结束
        Server-->>FetchEventSource: 推送 [DONE]
        FetchEventSource->>UseSse: onmessage 回调
        UseSse->>UseSse: 更新最后一条消息 status = 'completed'
        UseSse->>UseSse: 调用 onFinish 回调
    else 异常/中断
        alt 用户中断
            Component->>UseSse: 调用 stop()
            UseSse->>FetchEventSource: controller.abort()
            FetchEventSource->>UseSse: onerror/onclose 回调
            UseSse->>UseSse: 更新最后一条消息 status = 'aborted'
        end
        alt 服务器/网络错误
            FetchEventSource->>UseSse: onerror 回调
            UseSse->>UseSse: 更新 error.value
            UseSse->>UseSse: 更新最后一条消息 status = 'error'
        end
    end

    UseSse->>UseSse: 设置 loading.value = false
    deactivate FetchEventSource
    deactivate UseSse

    Note over Component: UI 最终状态渲染

通过这个精心设计的 useSse Hook,我们可以用一种极其声明式和可维护的方式,在 Vue 应用中构建出功能强大、体验流畅的 AI 对话产品。

手写Promise-then的基础实现

手写 Promise 构造函数一文中,我们已经搭建了 Promise 的基本结构。本文将深入探讨 Promise 的核心——then 方法的实现。在面试中,能否写出符合规范的 then 方法往往是区分开发者对 Promise 理解深浅的关键。让我们沿着 Promise A+ 规范的要求,逐步实现一个完整的 then 方法。

then 方法的基本要求

在开始编码之前,我们需要明确 then 方法的几个核心特性:

  • 原型方法then 是 Promise 原型上的方法,且不可枚举
  • 参数处理:接收两个可选参数(onFulfilled 和 onRejected),均为函数类型
  • 链式调用:必须返回一个新的 Promise 实例,支持链式调用
  • 调用限制:必须通过 Promise 实例调用,直接通过原型调用会报错

基于这些要求,我们可以搭建出 then 方法的基本框架:

(function () {
  "use strict";
  
  // 状态常量定义
  var PENDING = "pending";
  var FULFILLED = "fulfilled";
  var REJECTED = "rejected";
  
  function Promise(executor) {
    // 构造函数实现(详见上篇文章)
  }
  
  var prototype = Promise.prototype;
  
  // 定义对象属性的配置项(不可枚举)
  function definePropertyConfig(object, key, value) {
    Object.defineProperty(object, key, {
      value: value,
      writable: true,
      enumerable: false,
      configurable: true,
    });
  }
  
  // 添加 Symbol.toStringTag 属性(增强调试体验)
  if (typeof Symbol !== "undefined") {
    definePropertyConfig(prototype, Symbol.toStringTag, "Promise");
  }
  
  // 检查调用者是否为 Promise 实例
  function checkPromiseInstance(value) {
    if (!(value instanceof Promise)) {
      throw new TypeError(
        "Method Promise.prototype.then called on incompatible receiver #<Promise>"
      );
    }
  }
  
  // 定义 then 方法
  definePropertyConfig(prototype, "then", function (onFulfilled, onRejected) {
    checkPromiseInstance(this);
    var self = this;
    
    return new Promise(function (resolve, reject) {
      // 具体实现将在下文展开
    });
  });
})();

处理异步场景

当 Promise 处于异步操作中时(状态为 pending),我们需要将回调函数存储起来,待状态改变后再执行:

function Promise(executor) {
  var self = this;
  self.state = PENDING;
  self.result = void 0;
  self.onFulfilledCallbacks = []; // 存储成功回调
  self.onRejectedCallbacks = [];  // 存储失败回调
  
  // 状态转换函数
  var changeState = function (state, value) {
    if (self.state !== PENDING) return;
    
    self.state = state;
    self.result = value;
    
    var callbacks = state === FULFILLED 
      ? self.onFulfilledCallbacks 
      : self.onRejectedCallbacks;
    
    // 异步执行所有存储的回调
    setTimeout(function () {
      callbacks.forEach(function (callback) {
        typeof callback === "function" && callback(self.result);
      });
    });
  };
  
  var resolve = function (value) {
    changeState(FULFILLED, value);
  };
  
  var reject = function (reason) {
    changeState(REJECTED, reason);
  };
  
  try {
    executor(resolve, reject);
  } catch (error) {
    reject(error);
  }
}

在 then 方法中对应处理 pending 状态:

definePropertyConfig(prototype, "then", function (onFulfilled, onRejected) {
  checkPromiseInstance(this);
  var self = this;
  
  return new Promise(function (resolve, reject) {
    if (self.state === FULFILLED) {
      // 处理已完成状态
    } else if (self.state === REJECTED) {
      // 处理已拒绝状态
    } else {
      // 存储回调,等待状态改变
      self.onFulfilledCallbacks.push(function () {
        onFulfilled(self.result);
      });
      
      self.onRejectedCallbacks.push(function () {
        onRejected(self.result);
      });
    }
  });
});

异步执行与错误处理

根据 Promise A+ 规范,then 的回调应该异步执行。我们使用 setTimeout 来模拟微任务队列:

definePropertyConfig(prototype, "then", function (onFulfilled, onRejected) {
  checkPromiseInstance(this);
  var self = this;
  
  // 统一处理回调函数
  function handleCallback(value, callback, resolve, reject) {
    setTimeout(function () {
      try {
        var result = callback(value);
        resolve(result); // 暂时简单处理
      } catch (error) {
        reject(error);   // 捕获同步错误
      }
    }, 0);
  }
  
  return new Promise(function (resolve, reject) {
    if (self.state === FULFILLED) {
      handleCallback(self.result, onFulfilled, resolve, reject);
    } else if (self.state === REJECTED) {
      handleCallback(self.result, onRejected, resolve, reject);
    } else {
      self.onFulfilledCallbacks.push(function (value) {
        handleCallback(value, onFulfilled, resolve, reject);
      });
      
      self.onRejectedCallbacks.push(function (value) {
        handleCallback(value, onRejected, resolve, reject);
      });
    }
  });
});

实现值穿透特性

值穿透是指在 Promise 链中.then方法没有提供相应的回调函数(onFulfilled或者onRejected)或者不是一个函数类型,Promise会自动创建一个回调函数将这个值传递下去。

definePropertyConfig(prototype, "then", function (onFulfilled, onRejected) {
  checkPromiseInstance(this);
  var self = this;
  
  // 值穿透处理
  onFulfilled = typeof onFulfilled === "function" 
    ? onFulfilled 
    : function (value) { return value; }; // 传递值
    
  onRejected = typeof onRejected === "function" 
    ? onRejected 
    : function (reason) { throw reason; }; // 抛出异常
  
  // 其余代码保持不变
});

完整代码

(function () {
  "use strict";
  
  var PENDING = "pending";
  var FULFILLED = "fulfilled";
  var REJECTED = "rejected";
  
  function Promise(executor) {
    var self = this;
    
    // 参数校验
    if (typeof executor !== "function") {
      throw new TypeError(`Promise resolver ${executor} is not a function`);
    }
    if (!(self instanceof Promise)) {
      throw new TypeError("Promise constructor must be called with 'new'");
    }
    
    self.state = PENDING;
    self.result = void 0;
    self.onFulfilledCallbacks = [];
    self.onRejectedCallbacks = [];
    
    var changeState = function (state, value) {
      if (self.state !== PENDING) return;
      
      self.state = state;
      self.result = value;
      
      var callbacks = state === FULFILLED 
        ? self.onFulfilledCallbacks 
        : self.onRejectedCallbacks;
      
      setTimeout(function () {
        callbacks.forEach(function (callback) {
          typeof callback === "function" && callback(self.result);
        });
      });
    };
    
    var resolve = function (value) {
      changeState(FULFILLED, value);
    };
    
    var reject = function (reason) {
      changeState(REJECTED, reason);
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  var prototype = Promise.prototype;
  
  function definePropertyConfig(object, key, value) {
    Object.defineProperty(object, key, {
      value: value,
      writable: true,
      enumerable: false,
      configurable: true,
    });
  }
  
  function checkPromiseInstance(value) {
    if (!(value instanceof Promise)) {
      throw new TypeError(
        "Method Promise.prototype.then called on incompatible receiver #<Promise>"
      );
    }
  }
  
  definePropertyConfig(prototype, "then", function (onFulfilled, onRejected) {
    checkPromiseInstance(this);
    var self = this;
    
    // 值穿透处理
    onFulfilled = typeof onFulfilled === "function" 
      ? onFulfilled 
      : function (value) { return value; };
      
    onRejected = typeof onRejected === "function" 
      ? onRejected 
      : function (reason) { throw reason; };
    
    function handleCallback(value, callback, resolve, reject) {
      setTimeout(function () {
        try {
          var result = callback(value);
          resolve(result);
        } catch (error) {
          reject(error);
        }
      }, 0);
    }
    
    return new Promise(function (resolve, reject) {
      if (self.state === FULFILLED) {
        handleCallback(self.result, onFulfilled, resolve, reject);
      } else if (self.state === REJECTED) {
        handleCallback(self.result, onRejected, resolve, reject);
      } else {
        self.onFulfilledCallbacks.push(function (value) {
          handleCallback(value, onFulfilled, resolve, reject);
        });
        
        self.onRejectedCallbacks.push(function (value) {
          handleCallback(value, onRejected, resolve, reject);
        });
      }
    });
  });
  
  // 环境检测与导出
  if (typeof window !== "undefined") {
    window.SelfPromise = Promise;
  }
  if (typeof module === "object" && typeof module.exports === "object") {
    module.exports = Promise;
  }
})();

小结

本文详细实现了 Promise 的 then 方法,涵盖了:

  1. 基本结构:正确的原型方法定义和链式调用
  2. 状态处理:针对不同状态(fulfilled/rejected/pending)采取不同的处理策略
  3. 异步执行:使用 setTimeout 模拟微任务队列
  4. 错误处理:妥善处理回调函数中的同步错误
  5. 值穿透:实现参数非函数时的默认行为

这个实现已经涵盖了 Promise then 方法的核心功能,但需要注意的是,真正的 Promise 实现还需要处理更复杂的情况,如 thenable 对象的递归解析、循环引用检测等。这些内容我们将在后续文章中继续探讨。

通过手写实现,我们不仅能够更深入地理解 Promise 的工作原理,也能够在面试中展现出对 JavaScript 异步编程的深刻理解。

从webpack到vite——配置与特性全面对比

前言

本文将从webpack出发,探索其配置和特性在vite中的对应配置,为webpack迁移到vite提供参考,因此不过多介绍webpack中该配置项的作用。本文比对的配置项涵盖webpack中的绝大部分配置项,但也会忽略部分极少用到的配置。

webpack打包的目标环境,既支持浏览器环境,也支持node环境,而vite整体上以web环境优先。实际开发中,如果打包的目标环境以node为主,并不建议从webpack迁移vite。因此本文对两者配置项或特性的对比,也主要考虑web环境下的差异。

webpack配置/特性在vite中的对应

mode和环境变量

webpack vite 说明
mode mode 效果基本一致,vite无需手动设置
DefinePlugin .env文件 暴露对象不一致(process.envimport.meta.env

mode

webpack和vite都有mode字段,设置的值为developmentproduction,作用基本一致。区别主要是webpack需要手动设置,vite由dev和build命令自动设置。

环境变量

两者都自动将mode的值设置到process.env.NODE_ENV,其他环境变量webpack通过DefinePlugin设置到process.env上,而vite通过.env文件设置到import.meta.env

构建目标环境(target)和兼容性

webpack vite 说明
target build.target 默认值不一致

webpack中默认是browserslist,且支持node环境,而vite默认是baseline-widely-available(['chrome107', 'edge107', 'firefox104', 'safari16']),可以设置为合法的esbuild的target选项esbuild.github.io/api/#target

入口entry和多入口打包

webpack vite 说明
entry index.html中指定 多入口需要通过rollup配置

vite没有webpack的entry配置,对应的是index.html中通过script的src指定入口文件(库打包模式下在build.lib中指定),如果是多入口,则需要通过rollup进行配置

 rollupOptions: {  
      input: {  
        main: resolve(__dirname, 'index.html'),  
        admin: resolve(__dirname, 'admin.html')  
      }  
    }

vite自动处理多入口共享依赖(不需要类似webpack配置dependOn)`

输出output

webpack vite 说明
publicPath base
path build.outDir
hash文件名 默认不需要配置 vite可通过 rollupOptions.output 设置文件名
clean 内置
hash生成算法 仅支持hash长度配置
iife 自身无对应配置 可参考库打包中的iife模式
importFunctionName 不支持 一般不需要设置
library 基本覆盖 见下文见库打包模式
pathinfo 不支持 source-map已覆盖该需求
wasmLoading 不支持 见下文 worker和wasm
workerChunkLoading 不支持 见下文 worker和wasm

对一些配置项的特殊说明

  • filename/chunkFilename/assetModuleFilename: 对应vite的rollupOptions.output中的entryFileNames/chunkFileNames/assetFileNames,vite一般不需要配置

  • clean:对应vite的build.emptyOutDir,vite默认清理,不需要设置

  • auxiliaryComment: 类似rollup中的banner

  • globalObject: vite自动判断,不需要设置

  • hash: webpack相关配置或插件有hashDigest/hashDigestLength/hashFunction/hashSalt/webpack.ids.HashedModuleIdsPlugin,但一般不需要配置。vite中可以使用[hash:8]形式调整长度,不支持调整算法

  • importFunctionName: webpack可以调整import的名字,主要为了使用dynamic import的polyfill时可能会用到。目前dynamic import已经被广泛支持了,该配置项已过期。

  • pathinfo:webpack能够对代码添加类似/*! ./src/utils.js */的路径信息注释, 方便调试,vite中无法配置,但该需求实际上被source-map覆盖,也无需配置

devServer

webpack vite 说明
devServer server
devServer.port server.port
devServer.open server.open
devServer.allowedHosts server.allowedHosts
devServer.proxy server.proxy 除pathRewrite配置不一样以及不支持compress外,其他基本一致
devServer.server server.https
devServer.watchFiles server.watch 不要监听node_modules目录下文件
devServer.compress
devServer.bonjour 开发环境一般都能ip访问,该配置使用较少
devServer.client logLevel + server.hmr vite不支持progress,该配置也比较鸡肋
devServer.liveReload 该配置和热更新冲突,一般使用热更新而关闭该配置
devServer.hot vite默认热更新,无需配置
devServer.static public目录

mock

webpack一般通过devserver的setupMiddlewares配置mock,vite有多个mock相关插件,如vite-plugin-mock-dev-server

热更新

webpack vite 说明
配置复杂 开箱即用
按chunk编译 文件级按需编译 vite启动快,加载慢;webpack启动慢,加载快

webpack仅提供了热更新的能力,但热更新的具体行为,还是需要通过配置实现。

比如React项目热更新,webpack需要额外配置react-refresh@pmmmwh/react-refresh-webpack-plugin ,vite中由@vitejs/plugin-react提供热更新能力,无需额外配置。

库打包模式

webpack vite 说明
library lib + rollupOptions 支持目标格式较webpack少,但基本能满足需求

webpack相关配置有library/libraryTarget(library.type)/libraryExport(library.export),对应vite库打包模式,build配置项demo如下

 lib: {
      entry: 'src/index.js',    // 需要指定库入口文件
      name: 'MyLibrary',        // 对应 Webpack 的 library.name
      formats: ['umd', 'es'],   // 对应 libraryTarget(支持多格式)
      fileName: (format) => `my-lib.${format}.js`
    },
    rollupOptions: {
      output: {
        exports: 'default',     // 对应 libraryExport: 'default'
        external: ['vue'],
        globals: {              // 外部依赖的全局变量映射(如 Vue -> window.Vue)
          vue: 'Vue'
        }
      }
    }

webpack的libraryTarget考虑多种环境(浏览器/worker/node)的兼容问题,因此有各种细分类型: assign/assign-properties/commjs2/var/this/global/self等,rollup针对现代标准规范,支持'es' | 'cjs' | 'umd' | 'iife',基本满足需求,对assign等特殊场景,需要通过自定义插件实现

cache

webpack vite 说明
cache 支持 默认开启无需配置

webpack中cache默认是内存缓存,一般需要手动设置typefilesystem,可设置缓存的保存目录/压缩/过期/间隔等,对应vite中的cacheDir,一般不需要配置

resolve

webpack vite 说明
resolve resolve
resolve.alias resolve.alias 一致
resolve.mainFields resolve.mainFields 一致
resolve.extensions resolve.extensions 一致
resolve.symlinks resolve.preserveSymlinks 默认值相反,效果一致
resolve.modules 不支持 webpack中极少配置
resolve.mainFiles 不支持 webpack中极少配置

特殊说明

以下配置项webpack与vite基本一致

  • alias: 路径别名,如'@/pages'
  • mainFields: package.json入口字段优先级
  • extensions:按顺序解析文件后缀名

以下配置项存在差异:

  • symlinks: webpack默认值为true,vite对应配置项为preserveSymlinks,默认false,效果一致

其余配置项,基本用于如何确定import引入对象文件,webpack中一般采用默认值,vite并无对应,但也无需配置,比如

  • modules: 模块查找目录,默认['node_modules'],webpack一般不需要配置,vite无该配置
  • mainFiles:模块入口文件,默认['index'],webpack一般不需要配置,vite无该配置

文件解析编译

webpack vite 说明
js/ts babel esbuild vite默认支持,无需配置
jsx/tsx @babel/preset-react @vitejs/plugin-react
ts类型检查 fork-ts-checker-webpack-plugin vite-plugin-checker vite默认不处理,由IDE检查
eslint eslint-webpack-plugin vite-plugin-eslint
css css-loader/style-loader/mini-css-extract-plugin css vite默认支持,无需配置
less/sass less-loader/sass-loader css.preprocessorOptions
postcss postcss-loader css.postcss

j(t)s(x)文件解析和编译

vite中对jsx/tsx的解析,需要额外使用@vitejs/plugin-react,对js/ts的解析默认不需要配置。具体差异见babel/terser vs esbuild

样式文件编译抽离

webpack需要配置css-loader,less-loader等loader进行样式文件解析,通过style-loader注入style标签,mini-css-extract-plugin抽离为css文件以link引入,vite中无需配置,开发环境将样式注入style标签,生产环境则抽离为css文件。

webpack可通过loader选项进行精细化控制,vite对应的是css配置项:

  • module: postcss-modules
  • postcss: 格式同postcss.config.js,相当于webpack的postcss-loader
  • preprocessorOptions: 对不同预处理器进行配置,类似less-loader/sass-loader中的选项
  • preprocessorMaxWorkers:CSS 预处理器可以使用的最大线程数,类似webpack中的thread-loader
  • devSourcemap:启用sourcemap,对应webpack css-loader中的sourcemap配置

此外vite可以通过transformer配置项,设置lightningcss 以代替postcss,处理速度更快,不过生态不如postcss,目前不建议。

vite 生产打包时的额外可配置项如下:

  • cssCodeSplit:css拆分,相当于webpack中的mini-css-extract-plugin
  • cssTarget: css兼容性,webpack中通过postcss-loader + postcss-preset-env实现
  • cssMinify: 可以指定esbuild还是lightningcss压缩,webpack中采用mini-css-extract-plugin
  • sourcemap:同上devSourcemap

静态文件处理

webpack vite 说明
普通静态文件asset asset module assetsDir + assetsInlineLimit 功能基本一致,vite配置简单
json5 json5-loader vite-plugin-json5 普通json都可以直接import
svg @svgr/webpack vite-plugin-svgr

asset

webpack5通过asset module处理,

  • asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
  • asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
  • asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
  • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。

vite中配置项只有两个

  • assetsDir: 静态文件目录,webpack中通过assetModuleFilename实现
  • assetsInlineLimit:相当于webpack的asset中设置dataUrlCondition

JSON

webpack和vite都可以直接引入json文件,但如果需要在json中添加注释,webpack需要配置json5-loader,vite对应插件为vite-plugin-json5

svg引用

webpack中可以使用@svgr/webpack将svg转为组件,对应vite插件vite-plugin-svgr,import url上需要额外加查询参数reactimport Logo from "./logo.svg?react";

webpack常用插件在vite中的对应配置

webpack vite 说明
terser-webpack-plugin 内置默认esbuild
css-minimizer-webpack-plugin 内置默认esbuild
html-webpack-plugin vite-plugin-html
fork-ts-checker-webpack-plugin
eslint-webpack-plugin vite-plugin-eslint
webpack-manifest-plugin build.manifest
copy-webpack-plugin vite-plugin-static-copy 默认复制public目录下的内容
code-inspector-plugin code-inspector-plugin
webpack-bundle-analyzer 本身不支持 可通过roll配置rollup-plugin-visualizer
case-sensitive-paths-webpack-plugin 本身不支持 可通过自定义vite plugin实现

html模板编译

webpack中的html-webpack-plugin对应vite-plugin-html,但vite中可以向html中注入环境变量,一般情况也不需要配置该插件

ts类型检查

webpack通常用fork-ts-checker-webpack-plugin进行类型检查,vite默认不做类型检查,检查功能由IDE提供,可以通过插件vite-plugin-checker实现类似效果

eslint

webpack通过eslint-webpack-plugin配置,vite对应的是vite-plugin-eslint,eslint配置.eslintrc.js内容一致

manifest

webpack中的webpack-manifest-plugin对应vite中的build.manifest

文件复制

webpack中的copy-webpack-plugin对应到vite中可以使用vite-plugin-static-copy,但默认下,vite会将public目录下的文件复制到构建产物根目录中( copyPublicDir)

code-inspector-plugin

code-inspector-plugin同时支持webpack和vite

bundle分析

webpack-bundle-analyzer,vite默认提供了build.reportCompressedSize,需要分析bundle,可以配置rollup-plugin-visualizer

大小写敏感

webpack中的case-sensitive-paths-webpack-plugin,vite没有对应插件,需要自行编写插件实现

Optimization优化压缩与分包

webpack vite 说明
minimize/minimizer build.minify vite默认使用esbuild压缩,而webpack一般配置css-minimizer-webpack-plugin和terser-webpack-plugin
sideEffects rollup与webpack对sideEffect的判断不一样,见下文 tree shaking 和 sideEffects
splitChunks vite依赖rollup的manualChunks,配置能力较弱

webpack常用的配置项(minimize、minimizer、splitChunks、sideEffects),vite或rollup 都有对应的配置,其他比如chunkId等等,vite无对应配置,不过这些配置项在webpack中也极少用到。

这里最显著的差异是压缩插件,webpack大多数都是配置terser和css-minimizer-webpack-plugin,而vite默认esbuild,下文会对此作更具体比较。

另一个需要注意的点是分包。rollup的manualChunks的配置仅相当于webpack的splitChunks.cacheGroups的部分功能,对于运行时访问速度较高的C端项目,以及一些超大型项目,可能需要webpack的splitChunks的能力,如果仍然想用vite,可以尝试rolldown。

devtool和sourcemap

webpack vite 说明
devtool build.sourcemap vite可选类型较少

webpack的devtool对应vite的build.sourcemap,vite只有true/false/inline/hidden, true相当于webpack的source-map,vite配置虽少,但可以满足开发和生产需求

babel/terser vs esbuild

esbuild优势主要在性能上,虽然同时也具备了打包(转义+压缩)能力,但是生态与覆盖场景较babel少很多。

babel vs esbuild

esbuild相比babel,需要关注的点是它的转译能力:

情况 Babel esbuild
新语法(如 ?.、??、class fields) ✔️ 转换成旧语法等价物 ✔️ 也能转换,但仅限标准语法
实验性语法(如 decorators) ✔️ 有插件支持 见下文装饰器问题
运行时 API(如 Promise、Map) ✔️ 可自动注入 polyfill(core-js) ❌ 不注入,需外部手动引 polyfill
async/await ✔️ 转换为 generator + regenerator-runtime ✔️ 转换为 Promise 链,但不提供 Promise polyfill

如果要兼容旧浏览器,比如IE11,esbuild需要自行额外引入polyfill,而babel则通过@babel/preset-env自动注入。

{  
    "presets": [  
        [  
            "@babel/preset-env",  
            {  
                "useBuiltIns": "entry",  
                "corejs": "3.22"  
            }  
        ]  
    ]  
}

常用babel插件与vite/esbuild对照

babel vite/esbuild 说明
@babel/preset-react @vitejs/plugin-react @vitejs/plugin-react包括了jsx编译和热更新
@babel/preset-env esbuild 不支持自动polyfill
@babel/preset-typescript esbuild 不支持d.ts文件生成
babel-plugin-transform-typescript-metadata 不支持 详见下文装饰器问题
@babel/plugin-proposal-decorators esbuild 详见下文装饰器问题
@babel/plugin-proposal-class-properties esbuild 现代浏览器和 esbuild 已原生支持类属性语法,无需额外配置。若需兼容旧浏览器,通过 build.target 控制降级
transform-react-remove-prop-types 不支持 生产构建时移除 React 组件的 PropTypes 检查代码,减小体积,vite不支持

对esbuild或vite不支持的配置,可以采用以下方式使用babel插件(使用babel插件会影响打包速度)

import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          process.env.NODE_ENV === 'production' && 'transform-react-remove-prop-types'
        ].filter(Boolean)
      }
    })
  ]
});

terser vs esbuild

terser常用特性对比esbuild如下:

terser esbuild 说明
target ecma target terser 支持解析/压缩/输出各自设定target
name保留 keep_classnames/keep_fnames keepNames
删除console和debugger drop_console/drop_debugger drop
压缩 针对不同case的丰富的配置项 minify esbuild配置简单,但粒度较粗
混淆 mangle mangleProps支持正则匹配 esbuild远弱于terser
tree shaking 不支持,但可以删除dead code 支持 terser由bundler处理tree shaking
兼容性 esbuild目标是现代浏览器,不具备类似terser的safari10/ie8配置项

从功能上来说,无论是配置能力,还是兼容性,terser完胜esbuild;在压缩体积上,由于terser更精细化的配置,其结果也比esbuild略小。

不过只要不考虑兼容性,对大多数项目来说,esbuild的性能收益要远大于功能和体积上的代价了。

装饰器问题

esbuild能转换当前的装饰器提案(stage3)和ts的旧版装饰器(stage1)语法,如果在tsconfig中配置了experimentalDecorators,则按照ts的旧版装饰器语法进行转译。

esbuild不支持emitDecoratorMetadata。因此如果代码存在元数据编程,不要使用esbuild。

装饰器如果使用了反射,必须保留类名,因此在压缩上需要额外配置 keep names。

装饰器历史问题

现行主流装饰器语法有两套:当前es中的装饰器(stage3)与ts 对stage1装饰器的实现(tsconfig中的experimentalDecorators)。

babel编译插件@babel/plugin-proposal-decorators的legacy模式 ,针对的不是ts的装饰器的实现,而是babel自己对es装饰器规范stage1的实现。

其区别体现在:ts属性装饰器,接收两个参数,且不需要返回属性描述符,而babel的@babel/plugin-proposal-decorators 的legacy,属性装饰器接收三个参数,第三个参数为属性描述符,且必须返回属性描述符。

vite中esbuild对装饰器的解析,默认会根据tsconfig中experimentalDecorators字段处理,为true时,是对ts的装饰器stage1的实现进行的转换。

tree shaking 和 sideEffects

sideEffects字段并不是package.json中的标准字段,但webpack的tree shaking依赖该字段,因此大多数npm包也会声明sideEffects。vite生产环境由rollup打包,它主要依赖于静态分析代码的导入和导出,本身并不处理sideEffects字段,但可能有部分插件会处理该字段。

vite的devserver真的比webpack更快吗

一般情况下,vite的开发服务器启动速度确实要比webpack更快,然而对一个超大型项目(或超大型ui库)而言,vite的esm按需加载机制,可能在首屏加载数千乃至数万个文件,这会显著增加页面加载时间,甚至有可能超过webpack的bundle时间。

vite对此做了至少两种优化:

  1. 预构建node_modules中的依赖文件,用esbuild将其打包为esm;
  2. server.warmup 预热常用文件

因此对于单个大型项目来说,要尽可能考虑将通用模块或独立的聚合业务模块封装为npm包,而超大型项目,应该向底座化或平台化方向发展,以微前端或微模块做内容加载。

worker和wasm

这两个单独列出来,是因为两者原生的文件引入方式和在bundler中的引入方式不一样——在webpack和vite中如果按照原生写法,打包后会报错404。

在bundler中,通用的写法是通过new Url(path,import.meta.url),webpack5和vite都会将这个path打包为单个chunk,并将path编译为引用路径

worker

原生

 const worker = new Worker('./worker.js');

webpack

const worker = new Worker(new URL('./worker.js', import.meta.url));

webpack4中可以使用worker-loader或多入口打包,将worker单独打包为一个chunk

vite

const worker = new Worker(new URL('./worker.js', import.meta.url))

import MyWorker from './worker?worker'

const worker = new MyWorker()

wasm

原生

fetch('./module.wasm').then((response) => response.arrayBuffer()).then((bytes) => WebAssembly.instantiate(bytes)).then((result) => {});

webpack

import('./module.wasm').then((wasmModule) => { wasmModule.doSomething(); });

vite

import init from './example.wasm?init'

init().then((instance) => {
  instance.exports.test()
})

import wasmUrl from 'foo.wasm?url'

const main = async () => {
  const responsePromise = fetch(wasmUrl)
  const { module, instance } =
    await WebAssembly.instantiateStreaming(responsePromise)
  /* ... */
}

main()

模块联邦

vite本身不支持,但可以尝试@module-federation/vite(有不少bug)。

这里多说一句,在实践中,远程组件的最佳技术方案不是模块联邦,而是微模块。和微前端一样,微模块的实施并不是单个技术(比如hel-micro)的实践,而是应作为公司前端基础设施平台之一进行建设。

结论

如果没有兼容性负担,没有使用模块联邦,并且项目中也没有深度实践元数据编程,可以从webpack安全地迁移到vite。

Android & IOS兼容性问题

一、iOS 相关

1. 链接跳转

问题window.open 在 iOS Safari 中经常被拦截或失效。
建议

  • 使用 <a target="_blank"> 并触发真实点击。
  • 或者使用window.location.href跳转

2. iOS 18 复制失败

现象:点击按钮先发请求再复制文本,复制失败。
原因:苹果安全策略要求复制操作必须来自同步且直接的用户手势(click、touchstart、keydown 等),
一旦中途有 await fetchPromise.thensetTimeout 等异步步骤,就会被判定为非用户手势。

解决方案

  1. 预拉取数据:提前请求接口,在用户点击时直接同步 navigator.clipboard.writeText
  2. 二次点击:先弹框展示文案,用户再点“复制”按钮。
  3. App 内方案:如果是自家 App,可在 WebView 暴露原生复制方法。

3. 弹框滚动穿透(橡皮筋回弹)

现象:弹框内部滚动时,底部页面仍能被拖动或回弹。
原因:iOS WebKit 的系统级回弹,不受 overflow:hidden 单独控制。

解决方案:在弹框显示时锁定 body 滚动,隐藏时恢复。

// Vue 示例:监听 visible
let scrollTop = 0
watch(() => props.visible, (val) => {
  if (val) {
    scrollTop = window.scrollY
    document.body.style.position = 'fixed'
    document.body.style.top = `-${scrollTop}px`
    document.body.style.left = '0'
    document.body.style.right = '0'
    document.body.style.width = '100%'
    document.body.style.overflow = 'hidden'
  } else {
    document.body.style.position = ''
    document.body.style.top = ''
    document.body.style.left = ''
    document.body.style.right = ''
    document.body.style.width = ''
    document.body.style.overflow = ''
    window.scrollTo(0, scrollTop)
  }
})

弹框内容容器需设置:

.modal-body {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

二、Android 相关

1. OPPO 手机 flex:1 无效

现象input 使用 flex:1 不能自适应宽度。
解决方案

  • 方案一:直接给 input 固定宽度。
  • 方案二:为 input 包一层父容器并设置 flex:1input 自身 width:100%
<div class="content">
  <div class="left">left</div>
  <div class="input-box">
    <input />
  </div>
  <div class="right">right</div>
</div>

<style>
.content {
  display: flex;
}
.left, .right {
  width: 100px;
}
.input-box {
  flex: 1;
}
.input-box input {
  width: 100%;
}
</style>

2. line-height 偏上

现象:文本无法垂直居中。
建议:使用 flex 布局代替 line-height 控制:

.parent {
  display: flex;
  align-items: center;
}

3. 系统字体缩放导致 REM 计算异常

现象:H5 页面在 App WebView 中,系统字体变大会影响 rem 布局。

要点rem 计算依据 window.getComputedStyle(document.documentElement).fontSize
系统字体变化会使其变大。

参考方案


4. 微信公众号 H5 登录 Cookie 丢失

现象:部分用户再次进入页面需重新登录。
原因:微信内置浏览器的 Cookie 隔离或跨域策略。
建议

  • 使用后端 Session + Token 替代纯 Cookie。
  • 或在授权回调中带上自定义参数并刷新 Token。

以上内容可直接作为移动端适配问题速查表,涵盖 iOS 与 Android 的常见坑、原因及对应解决方案。

CSS 文本换行控制:text-wrap、white-space 和 word-break 详解

在网页设计中,文本内容的显示方式对可读性和整体视觉效果至关重要。CSS 提供了多个属性来控制文本的换行和空白处理,其中最常用的是 text-wrapwhite-spaceword-break。本文将详细介绍这三个属性,并比较它们的异同。

属性详解

1. white-space 属性

white-space 属性控制元素内空白的处理方式,同时也会影响元素的自动换行行为。

.element {
  white-space: normal | nowrap | pre | pre-wrap | pre-line | break-spaces;
}
  • normal:默认值,空白会被忽略,文本自动换行
  • nowrap:文本不换行,直到遇到 <br> 标签
  • pre:保留空白和换行,类似 <pre> 标签
  • pre-wrap:保留空白序列,但正常地进行换行
  • pre-line:合并空白序列,但保留换行符
  • break-spaces:与 pre-wrap 类似,但任何保留的空白序列都会占用空间

2. word-break 属性

word-break 属性指定了如何在单词内进行换行。

.element {
  word-break: normal | break-all | keep-all | break-word;
}
  • normal:使用默认的断行规则
  • break-all:对于非中文/日文/韩文文本,可在任意字符间断行
  • keep-all:中文/日文/韩文文本不断行,非CJK文本行为同normal
  • break-word:已废弃,建议使用 overflow-wrap: anywhere

3. text-wrap 属性

text-wrap 属性是较新的CSS属性,用于控制文本的换行方式。

.element {
  text-wrap: auto | balance | pretty | stable | wrap | nowrap;
}
  • auto:默认值,浏览器自动决定换行策略
  • balance:尝试平衡文本行的长度,使多行文本看起来更均衡
  • pretty:在换行时尽量不断开重要的标点符号连接
  • stable:优先保持布局稳定性
  • wrap:允许换行
  • nowrap:不允许换行

三者比较

属性 主要功能 适用场景 浏览器支持
white-space 控制空白处理和整体换行行为 处理代码显示、保持文本格式 全支持
word-break 控制单词内换行方式 长单词或URL换行、CJK文本处理 全支持
text-wrap 控制文本换行的质量和平衡 提高排版质量、标题文本平衡 较新,部分支持

实际应用示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS文本换行属性比较</title>
    <style>
        .container {
            max-width: 800px;
            margin: 0 auto;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
        }
        
        .example {
            border: 1px solid #ccc;
            padding: 15px;
            margin: 20px 0;
            border-radius: 5px;
            background-color: #f9f9f9;
        }
        
        .code {
            background-color: #eee;
            padding: 10px;
            border-radius: 3px;
            font-family: monospace;
            margin: 10px 0;
        }
        
        .nowrap-example {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        
        .pre-example {
            white-space: pre;
        }
        
        .break-all-example {
            word-break: break-all;
            width: 200px;
        }
        
        .balance-example {
            text-wrap: balance;
            width: 250px;
            font-weight: bold;
            font-size: 1.2em;
        }
        
        @supports not (text-wrap: balance) {
            .balance-example::after {
                content: " (您的浏览器不支持text-wrap: balance)";
                font-size: 0.7em;
                color: #ff0000;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>CSS文本换行属性比较</h1>
        
        <div class="example">
            <h2>white-space: nowrap</h2>
            <div class="code">white-space: nowrap;</div>
            <div class="nowrap-example">
                这是一段非常长的文本内容,当设置了white-space: nowrap时,文本不会换行,而是会一直延伸直到遇到br标签或者容器边界,超出部分会被隐藏。
            </div>
        </div>
        
        <div class="example">
            <h2>white-space: pre</h2>
            <div class="code">white-space: pre;</div>
            <div class="pre-example">
                这是一段使用   pre   属性的文本,
                它会保留    空格和
                换行符,
                就像pre标签一样。
            </div>
        </div>
        
        <div class="example">
            <h2>word-break: break-all</h2>
            <div class="code">word-break: break-all;</div>
            <div class="break-all-example">
                这是一个非常长的英文单词:Pneumonoultramicroscopicsilicovolcanoconiosis,以及一个URL:https://www.example.com/very-long-path-name/index.html
            </div>
        </div>
        
        <div class="example">
            <h2>text-wrap: balance</h2>
            <div class="code">text-wrap: balance;</div>
            <div class="balance-example">
                这是一个使用text-wrap: balance的长标题,它会尝试平衡多行文本的长度
            </div>
        </div>
    </div>
</body>
</html>

总结

  • white-space 主要控制空白字符的处理和整体换行行为
  • word-break 专注于控制单词内部的换行方式,特别是长单词和URL
  • text-wrap 是较新的属性,专注于提高文本换行的质量和美观度

在实际开发中,这三个属性常常需要结合使用,才能达到最佳的文本显示效果。理解它们的区别和适用场景,可以帮助我们更好地控制网页排版,提升用户体验。

TDMQ CKafka 版客户端实战指南系列之一:生产最佳实践

导语

在当今数字化时代,数据的产生和流动呈爆发式增长,消息队列作为一种高效的数据传输和处理工具,在各种应用场景中发挥着关键作用。TDMQ CKafka 版作为一款分布式、高吞吐量、高可扩展性的消息系统,100% 兼容开源 Kafka API 2.4、2.8、3.2 版本 ,基于发布 / 订阅模式,通过消息解耦,使生产者和消费者异步交互,无需彼此等待。凭借高可用、数据压缩、同时支持离线和实时数据处理等优点,TDMQ CKafka 版广泛应用于日志压缩收集、监控数据聚合、流式数据集成等场景。

对于开发者而言,深入了解并熟练掌握 TDMQ CKafka 版的生产消费实践至关重要。它不仅能够帮助我们构建高效、稳定的数据传输和处理系统,还能在面对海量数据时,确保系统的性能和可靠性。本文将详细介绍 TDMQ CKafka 版的生产实践教程,包括生产消息的各个环节以及相关的参数配置和最佳实践,希望能为大家在实际项目中应用 TDMQ CKafka 版提供有益的参考和指导。

生产篇:步步为营,高效生产

Topic 使用与创建

配置要求:推荐节点的整倍数副本,减少数据倾斜问题,同步复制最小同步副本数为2,且同步副本数不能等于 Topic 副本数,否则宕机1个副本会导致无法生产消息。

创建方式:支持选择是否开启 CKafka 自动创建 Topic 的开关。选择开启后,表示生产或消费一个未创建的 Topic 时,会自动创建一个默认值包含3个分区和2个副本的 Topic,控制台支持修改默认值。

分区数估计

分区数的准确估算能实现数据的均衡分布。为了达到这个目的,分区数建议为节点数的整倍数。同时,还需结合预估流量来设置,按照 10MB/s 一个分区的标准来计算。例如,若一个 Topic 的预估吞吐为 100MB/s,那么建议设置分区数为 10。这样可以确保在高流量情况下,消息能均匀地分布在各个分区,避免某个分区负载过高。

失败重试

在分布式环境中,由于网络等原因,消息发送偶尔会失败,其原因可能是消息已经发送成功但是 ACK 机制失败或者是消息确实没有发送成功,这就需要设置合理的重试策略,您可以根据业务需求,设置以下重试参数:

  • Retries:用于设置重试次数,默认值为 3。重试不成功会触发报错,如果客户不接受消息丢失,建议改重试次数或者手动重试。

  • Retry.backoff.ms:设置重试间隔,建议设置为 1000。这个间隔时间可以让生产者在重试前等待一段时间,避免在短时间内频繁重试。

这样将能应对 Broker 的 Leader 分区出现无法立刻响应 Producer 请求的情况。

异步发送

消息发送接口通常是异步的,这意味着生产者在发送消息后不需要等待消息被完全处理就可以继续执行其他任务。如果想要接收发送的结果,可以使用 Send 方法中的 Callback 接口获取发送结果。

一个 Producer 对应一个应用

Producer 是线程安全的,且可以往任何 Topic 发送消息。通常情况下,建议一个应用对应一个 Producer。

Acks

Kafka 的 ACK 机制,指 Producer 的消息发送确认机制,Acks 参数决定了生产者在发送消息后等待服务端响应的方式,对 Kafka 集群的吞吐量和消息可靠性有直接影响。

Acks 的参数说明如下:

  • Acks=0 时,当生产者采用无确认机制时,消息发送后无需等待任何 Broker 节点的响应即可继续执行,这种模式可获得最高的吞吐性能,但因缺乏写入保障机制,存在较高的数据丢失风险;

  • Acks=1 时,采用主节点单确认机制时,生产者仅需等待 Leader 副本完成消息写入即会收到确认响应。该模式在性能与可靠性间取得平衡,但需注意:若 Leader 节点在同步完成前发生故障,已发送但未同步的消息存在部分丢失的可能性;

  • Acks=all 时,启用全副本确认机制时,生产者必须等待 Leader 副本及所有同步副本(ISR 集合)均完成消息持久化后才会收到确认。虽然该模式通过多重冗余保障实现了最高级别的数据安全性(仅当整个 ISR 集群同时失效时才会丢失数据),但跨节点同步带来的延迟使其吞吐性能相对较低。

一般建议选择 Acks=1,对于重要的服务可以设置 Acks=all 。

Batch

通常情况下,TDMQ CKafka 版的 Topic 会设置多个分区。Producer 客户端向服务端发送消息时,要先明确消息要发送到哪个 Topic 的哪个分区。当向同一分区发送多条消息时,Producer 客户端会把这些消息整合成一个 Batch,批量发送至服务端。不过,Producer 客户端在处理 Batch 时会产生额外开销。一般来说,小 Batch 会使 Producer 客户端产生大量请求,致使请求在客户端和服务端堆积排队,还会提高相关机器的 CPU 使用率,进而整体抬高消息发送和消费的延迟。而设置一个合适的 Batch 大小,能减少客户端向服务端发送消息时的请求次数,从整体上提升消息发送的吞吐量。

以下是 Batch 相关参数的说明:

  • Batch.size:这是发往每个分区的消息缓存量阈值,当缓存的消息量达到这个设定值时,就会触发一次网络请求,随后 Producer 客户端会把消息批量发送到服务器。

  • Linger.ms:它规定了每条消息在缓存中的最长停留时间,如果消息在缓存中的时间超过这个值,Producer 客户端就会不再遵循 Batch.size 的限制,直接把消息发送到服务器。

  • Buffer.memory:当所有缓存消息的总体大小超过这个数值时,就会触发消息发送到服务器的操作,此时会忽略 Batch.size 和 Linger.ms 的限制。Buffer.memory 的默认值是 32MB,对于单个 Producer 而言,这个数值足以保障其性能。

Key 和 Value

消息队列中的消息有 Key(消息标识)和 Value(消息内容)两个字段。为消息设置一个唯一的 Key 便于追踪消息,通过打印发送日志和消费日志,就能了解该消息的生产和消费情况。比如在电商订单系统中,将订单号作为 Key,就可以轻松追踪订单消息的流转过程。如果消息发送量较大,建议不要设置 Key,并使用黏性分区策略。

黏性分区

在消息队列 Kafka 中,只有被发送至同一分区的消息才会被归入同一个 Batch,所以 Kafka Producer 端配置的分区策略是影响 Batch 形成的关键因素之一。Kafka Producer 支持用户通过自定义 Partitioner 实现类,来契合业务需求选择合适的分区方式。

当消息指定了 Key 时,Kafka Producer 默认会先对消息的 Key 进行哈希计算,再依据哈希结果选定分区,以此确保相同 Key 的消息能够发送到同一分区。

当消息未指定 Key 时,在 Kafka 2.4 版本之前,其默认分区策略是按顺序循环遍历主题下的所有分区,以轮询形式把消息依次发送到各分区。不过,这种默认策略在 Batch 聚合方面表现不佳,实际应用中容易生成大量小 Batch,进而导致消息处理的实际延迟上升。为改善无 Key 消息分区效率低的问题,Kafka 在 2.4 版本推出了黏性分区策略(Sticky Partitioning Strategy)。

黏性分区策略重点针对无 Key 消息被分散到不同分区、进而产生众多小 Batch 的问题。它的核心机制是,当某个分区的 Batch 处理完毕,会随机挑选另一个分区,之后尽可能让后续消息都发送到这个新选定的分区。从短期视角看,消息会集中发送到同一分区;但从长期运行来看,消息仍能均匀分布到各个分区。如此一来,既避免了消息在分区上分布不均(分区倾斜),又能降低延迟,提升整个服务的性能。

要是你用的 Kafka Producer 客户端版本是 2.4 及以上,那默认就会采用黏性分区策略。要是客户端版本低于 2.4,你可以依据黏性分区策略的原理,自己动手实现分区策略,再通过参数 Partitioner.class 来指定所设置的分区策略。

关于黏性分区策略的实现,下面给出了 Java 版的代码示例,其核心逻辑是按照一定时间间隔来切换分区。

public class MyStickyPartitioner implements Partitioner {
    // 记录上一次切换分区时间。
    private long lastPartitionChangeTimeMillis = 0L;
    // 记录当前分区。
    private int currentPartition = -1;
    // 分区切换时间间隔,可以根据实际业务选择切换分区的时间间隔。
    private long partitionChangeTimeGap = 100L;
    public void configure(Map<String, ?> configs) {}
    /**      * Compute the partition for the given record.      *
     * @param topic The topic name      * @param key The key to partition on (or null if no key)      * @param keyBytes serialized key to partition on (or null if no key)      * @param value The value to partition on or null      * @param valueBytes serialized value to partition on or null      * @param cluster The current cluster metadata
     */
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        // 获取所有分区信息。
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (keyBytes == null) {
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            int availablePartitionSize = availablePartitions.size();
            // 判断当前可用分区。
            if (availablePartitionSize > 0) {
                handlePartitionChange(availablePartitionSize);
                return availablePartitions.get(currentPartition).partition();
            } else {
                handlePartitionChange(numPartitions);
                return currentPartition;
            }
        } else {
            // 对于有key的消息,根据key的哈希值选择分区。
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }
    private void handlePartitionChange(int partitionNum) {
        long currentTimeMillis = System.currentTimeMillis();
        // 如果超过分区切换时间间隔,则切换下一个分区,否则还是选择之前的分区。
        if (currentTimeMillis - lastPartitionChangeTimeMillis >= partitionChangeTimeGap
            || currentPartition < 0 || currentPartition >= partitionNum) {
            lastPartitionChangeTimeMillis = currentTimeMillis;
            currentPartition = Utils.toPositive(ThreadLocalRandom.current().nextInt()) % partitionNum;
        }
    }
    public void close() {}
}

分区顺序

单个分区(Partition)内,消息是按照发送顺序储存的,是基本有序的。每个主题下面都有若干分区,如果消息被分配到不同的分区中,不同 Partition 之间不能保证顺序。

如果需要消息具有消费顺序性,可以在生产端指定这一类消息的 Key,这类消息都用相同的 Key 进行消息发送,CKafka 就会根据 Key 哈希取模选取其中一个分区进行存储,由于一个分区只能由一个消费者进行监听消费,此时消息就具有消息消费的顺序性了。

TDMQ CKafka 版顺序消息场景实践教程

顺序消息场景

在 TDMQ CKafka 版中,确保消息顺序性的主要手段依赖于其分区(Partition)设计以及消息 Key 的使用。客户端所涉及的顺序消息使用场景可分为两类:一是全局顺序场景,二是分区顺序场景。针对这两种场景,CKafka 的实践教程如下:

  1. 全局顺序:为保证全局顺序,在 CKafka 控制台,您需设置 Topic 分区为1,副本数可以根据具体使用场景和可用性要求以及平衡成本指定,建议设置为2。

    全局顺序由于单分区存在吞吐上限,因此整体吞吐不会太高,单分区吞吐指标请参见:cloud.tencent.com/document/pr…

  2. 分区顺序:为保证分区顺序,您可以根据预估 Topic 的业务流量,除以单分区流量,取整后获得分区数,同时为避免数据倾斜,分区数尽量向节点整倍数取整,从而确定最终合理的分区数。单分区的吞吐量可参见:cloud.tencent.com/document/pr…

    在发送 CKafka 消息时候,需要指定 Key,CKafka 会根据 Key 计算出一个哈希值,确保具有相同 Key 的消息会被发送到同一个分区,从而确保这些消息在分区内部是有序的。同时建议尽可能让业务 Key 分散,如果生产消息都指定同一个 Key,那么分区顺序会退化为全局顺序,从而降低整体的写入吞吐。

参数实践教程

由于顺序消息,要求消息有序、不重复,默认的 Kafka 生产者发送参数当遇到网络抖动、Kafka Broker 节点变化、分区 Leader 选举等场景,容易出现消息重复、乱序问题,因此顺序场景,必须对 Kafka 生产者参数进行特别设置,关键参数设置如下:

  • Enable.idempotence

Enable.idempotence 表示是否开启幂等功能。顺序场景建议开启幂等功能,应对上述场景出现的分区消息乱序、消息重复等问题。建议 Kafka 的 Producer 设置:Enable.idempotence 为 True。需要注意,该功能要确保 Kafka 的 Broker 版本大于等于0.11,即 Kafka versions >= 0.11,同时:从 Kafka 3.0开始包括3.0,Kafka 的 Producer 默认 Enable.idempotence=True 和 Acks=All ,而对于 Kafka 版本>=0.11且 Kafka<3.0 的版本,默认是关闭幂等的,因此建议顺序场景显式指定该参数值确保开启幂等。

  • Acks

在开启幂等后,Acks 需要显式指定为 All,如果不指定为 All 的话,则会无法通过参数校验从而报错。

  • Max.in.flight.requests.per.connection

默认情况下,Kafka 生产者会尝试尽快发送记录,Max.in.flight.requests.per.connection 表示一个 Connection 同时发送的最大请求数,默认值是5。Kafka 在 0.11 版本之后包括 0.11,小于 1.1 的版本,即(Kafka >= 0.11 & < 1.1),Kafka Broker 没有针对该方面优化,需要设置

Max.in.flight.requests.per.connection 为1,在 Kafka>=1.1 后,针对幂等场景的吞吐进行优化,在 Broker 端会维持一个队列对5个并发批次的消息的顺序进行顺序校验,允许 Max.in.flight.requests.per.connection 设置5,但不能大于5。

因此建议:

  • Kafka >= 0.11 & < 1.1:显式设置 Max.in.flight.requests.per.connection 为1。

  • Kafka>=1.1:显式设置 Max.in.flight.requests.per.connection 可以为 1<=Max.in.flight.requests.per.connection<=5;建议设置为5。

  • Retries

在顺序场景下,建议指定重试参数,Retries 在不同版本,有不同的默认行为,在 Kafka <= 2.0,默认为0;Kafka >= 2.1,默认为 Integer.MAX_VALUE,即2147483647;建议顺序场景,显式设置为 Integer.MAX_VALUE。

总结

在顺序场景中,需要开启的生产者参数示例如下:

Kafka >= 0.11 & < 1.1:

// create Producer properties
Properties properties = new Properties();
properties.setProperty("enable.idempotence", "true");
properties.setProperty("acks", "all");
properties.setProperty("max.in.flight.requests.per.connection", "1");
properties.setProperty("retries", Integer.toString(Integer.MAX_VALUE));

Kafka>=1.1:

// create Producer properties
Properties properties = new Properties();
properties.setProperty("enable.idempotence", "true");
properties.setProperty("acks", "all");
properties.setProperty("max.in.flight.requests.per.connection", "5");
properties.setProperty("retries", Integer.toString(Integer.MAX_VALUE));

数据倾斜

Kafka Broker 数据倾斜问题通常是由于分区分布不均匀或者生产者发送数据的 Key 分布不均匀导致的,会引发几类问题:

  1. 整体流量没有限流,但是节点局部限流;

  2. 某些节点负载过大,导致整体 Kafka 使用率不高,影响整体吞吐。

针对该类问题可以通过以下方式进行优化:

  1. 使用合理分区数,分区数保障为节点数的整倍数。

  2. 合理的分区策略,例如:RoundRobin(轮询)、Range(范围)和 Sticky(粘性)或者自定义的分区策略,均衡发送消息。

  3. 查是否使用 Key 进行发送,如果使用了 Key 进行发送,尽量设计策略让 Key 更加分区均衡。

总结

在消息队列的消息生产环节中,“高效” 不仅是吞吐的追求,更是稳定性与可靠性的平衡。无论是 Topic 的副本与分区设计、重试策略的精细调优,还是顺序消息的场景化实现,每一个配置细节都可能影响集群的整体性能。本文围绕 TDMQ CKafka 版的生产实践,详解如何通过合理的参数设置与策略选择,构建高可靠、低延迟的消息生产链路,避免数据倾斜、消息乱序等典型问题,为业务流量的平稳流转奠定基础。下一篇,我们将会为大家详细介绍 TDMQ CKafka 版的消费实践,敬请期待!

前端私有化变量还只会加前缀嘛?保姆级教程教你4种私有化变量方法

在 JavaScript 开发中,封装是一个至关重要的编程概念。它通过隐藏对象内部的实现细节,只暴露必要的接口,来保证代码的稳定性、安全性和可维护性。私有化变量作为封装的核心手段,在 JavaScript 中的实现方式经历了显著的演进。

本文将系统梳理 JavaScript 中实现私有化的四种主要方式,分析其原理、优缺点和适用场景,帮助开发者做出正确的技术选型。

1. 约定俗成版:下划线命名约定

实现方式与原理

在 ES6 之前,JavaScript 没有语法层面的私有成员,社区形成了使用下划线 _ 作为前缀的命名约定。

class MyClass {
  constructor() {
    this._privateValue = 10; // "假装"是私有属性
  }

  _privateMethod() {
    console.log('这是一个私有方法');
  }

  getValue() {
    return this._privateValue; // 通过公共方法访问
  }
}

const instance = new MyClass();
console.log(instance.getValue()); // 10 (正确方式)
console.log(instance._privateValue); // 10 (仍然可以直接访问)
instance._privateMethod(); // 仍然可以直接调用

优缺点分析

优点:

  • 实现简单,无需额外语法
  • 兼容性极好,所有 JavaScript 环境都支持

缺点:

  • 毫无强制约束力,全靠开发者自觉
  • 无法真正保证封装性,外部代码仍可随意访问和修改

2. 闭包版:真正的私有化方案

实现原理

利用函数作用域闭包特性模拟真正的私有变量,是 ES6 之前最可靠的方案。

两种实现方式

方式一:构造函数中实现

function MyClass() {
  // 真正的私有变量,只在构造函数作用域内可访问
  let privateValue = 10;

  this.getPrivateValue = function() {
    return privateValue; // 公共方法通过闭包访问私有变量
  };

  this.setPrivateValue = function(val) {
    privateValue = val;
  };
}

const instance = new MyClass();
console.log(instance.getPrivateValue()); // 10
console.log(instance.privateValue); // undefined (真正私有)

方式二:模块模式(IIFE)

const MyModule = (function() {
  let privateCounter = 0; // 私有变量

  return {
    increment: function() {
      privateCounter++;
    },
    getValue: function() {
      return privateCounter;
    }
  };
})();

MyModule.increment();
console.log(MyModule.getValue()); // 1
console.log(MyModule.privateCounter); // undefined (无法访问)

优缺点分析

优点:

  • 实现真正的私有化,外部无法直接访问
  • 兼容性好,支持所有 JavaScript 环境

缺点:

  • 写法繁琐,代码组织不够清晰
  • 构造函数方式中,私有方法需要在每个实例上都创建一遍,占用更多内存

3. Symbol 版:半私有化方案

实现原理

利用 Symbol 的唯一性来创建"不易被外部访问"的属性。

const _privateKey = Symbol('privateKey');
const _privateMethodKey = Symbol('privateMethodKey');

class MyClass {
  constructor() {
    this[_privateKey] = 10; // 使用 Symbol 作为键
  }

  publicMethod() {
    return this[_privateKey];
  }

  // "私有"方法
  [_privateMethodKey]() {
    console.log('私有方法被调用');
  }
}

const instance = new MyClass();
console.log(instance.publicMethod()); // 10

// 仍然可以被"绕过"
const symbolKey = Object.getOwnPropertySymbols(instance)[0];
console.log(instance[symbolKey]); // 10

优缺点分析

优点:

  • 比下划线方式更隐蔽
  • 语法相对简洁

缺点:

  • 并非真正私有,可通过 Object.getOwnPropertySymbols() API 获取到 Symbol 键
  • 只是增加了访问难度,无法保证真正的封装性

4. ES13+ 正式版:语法级私有字段

实现方式

ES2019 引入了 # 前缀 来在类中声明真正的私有字段,这是目前官方推荐的方案。

class MyClass {
  // 声明私有字段(必须声明)
  #privateValue;
  #anotherPrivateValue = 42;

  // 私有方法
  #privateMethod() {
    console.log('我是私有方法');
    return this.#privateValue;
  }

  constructor(value) {
    this.#privateValue = value;
  }

  // 公共方法接口
  getPrivateValue() {
    return this.#privateMethod();
  }

  setPrivateValue(value) {
    if (typeof value === 'number') {
      this.#privateValue = value;
    }
  }
}

const instance = new MyClass(10);
console.log(instance.getPrivateValue());

// 以下访问都会抛出错误
console.log(instance.#privateValue); // SyntaxError
console.log(instance['#privateValue']); // TypeError
instance.#privateMethod(); // SyntaxError

优缺点分析

优点:

  • 真正私有:语法层面强制,外部无法以任何方式访问
  • 语法简洁:使用简单直观的 # 前缀
  • 强大的封装:可在 setter 中加入验证逻辑,保证数据有效性

缺点:

  • 需要先声明私有字段
  • 较老环境不支持,需要 Babel 等工具转换

总结与对比

方式 优点 缺点 推荐度
_ 约定 简单,兼容性极好 无强制力,形同虚设 ⭐⭐
闭包 真正私有,兼容性好 写法繁琐,内存占用高 ⭐⭐⭐
Symbol _ 更隐蔽 仍可被反射 API 破解 ⭐⭐
# 语法 真正私有,语法简洁,官方标准 需要声明,老环境需编译 ⭐⭐⭐⭐⭐

您好,我是肥晨。 欢迎关注我获取前端学习资源,日常分享技术变革,生存法则;行业内幕,洞察先机。

鸿蒙应用开发——Repeat组件的使用

【高心星出品】

Repeat组件的使用

概念

Repeat基于数组类型数据来进行循环渲染,一般与容器组件配合使用。

Repeat根据容器组件的有效加载范围(屏幕可视区域+预加载区域)加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件的布局过程重新计算有效加载范围,并管理列表子组件节点的创建与销毁。

  • Repeat必须在滚动类容器组件内使用,仅有List、ListItemGroup、Grid、Swiper以及WaterFlow组件支持Repeat懒加载场景。

    循环渲染只允许创建一个子组件,子组件应当是允许包含在容器组件中的子组件。例如:Repeat与List组件配合使用时,子组件必须为ListItem组件。

  • Repeat不支持V1装饰器,混用V1装饰器会导致渲染异常。

  • Repeat当前不支持动画效果。

  • 滚动容器组件内只能包含一个Repeat。以List为例,不建议同时包含ListItem、ForEach、LazyForEach,不建议同时包含多个Repeat。

  • 当Repeat与自定义组件或@Builder函数混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化。详见Repeat与@Builder混用。

Repeat子组件由.each()和.template()属性定义,只允许包含一个子组件。当页面首次渲染时,Repeat根据当前的有效加载范围(屏幕可视区域+预加载区域)按需创建子组件。如下图所示:

yyy.png

repeat默认会分配1个预加载节点,通过cachecount可以认为调整预加载节点个数。

案例

repeat全量加载数组案例:

下面案例使用list加载全量数组元素,第一运行的时候就会把100个listitem都渲染出来,耗费时间和内存。

image-20250919142403313.png

日志输入结果:

image-20250919142437859.png

// 父组件使用Repeat渲染列表
@Entry
@Component
struct repeatpage {
  @State items: string[] = [];
  aboutToAppear() {
    // 初始化数据
    for (let i = 0; i < 100; i++) {
      this.items.push(`列表项 ${i}`);
    }
  }

  build() {
    List() {
      Repeat(this.items)
        // 遍历每个数组元素
        .each((item: RepeatItem<string>) => {
          ListItem() {
           Text(item.item)
             .fontSize(20)
             .width('100%')
             .textAlign(TextAlign.Center)
          }.onAppear(()=>{
            // 当listitem渲染的时候调用
            console.log('gxxt ',item.item+' 出现了')
          })
        })
    }
    .width('100%')
  }
}
repeat开启懒加载和设置预加载数量

下面案例开启了virtualScroll懒加载和cachedCount预加载数量,可以看到第一次只渲染了可见区域的listitem,随着滑动重用预加载的节点。第一次渲染了30的listem,缓存了两个节点,所以加载的数据为32个。

image-20250919143018892.png

日志输出结果:

image-20250919143045847.png

// 父组件使用Repeat渲染列表
@Entry
@Component
struct repeatpage {
  @State items: string[] = [];
  aboutToAppear() {
    // 初始化数据
    for (let i = 0; i < 100; i++) {
      this.items.push(`列表项 ${i}`);
    }
  }

  build() {
    List() {
      Repeat(this.items)
        // 遍历每个数组元素
        .each((item: RepeatItem<string>) => {
          ListItem() {
           Text(item.item)
             .fontSize(20)
             .width('100%')
             .textAlign(TextAlign.Center)
          }.onAppear(()=>{
            // 当listitem渲染的时候调用
            console.log('gxxt ',item.item+' 出现了')
          })
        })// 开启懒加载
        .virtualScroll()
    }
    .width('100%')
    .cachedCount(2) //缓存两个节点
  }
}
repeat设置加载模板

下面案例中给repeat设置通用模板和huang模板和hong模板,根据index设置不同的显示模板。

image-20250919144948716.png

// 父组件使用Repeat渲染列表
@Entry
@Component
struct repeatpage {
  @State items: string[] = [];

  aboutToAppear() {
    // 初始化数据
    for (let i = 0; i < 100; i++) {
      this.items.push(`列表项 ${i}`);
    }
  }

  build() {
    List() {
      Repeat(this.items)// 遍历每个数组元素
        .each((item: RepeatItem<string>) => {
          ListItem() {
            Text(item.item)
              .fontSize(20)
              .width('100%')
              .textAlign(TextAlign.Center)
          }.onAppear(() => {
            // 当listitem渲染的时候调用
            console.log('gxxt ', item.item + ' 出现了')
          })
        })
        .template('huang', (item: RepeatItem<string>) => {
          ListItem() {
            Text(item.item)
              .fontSize(20)
              .width('100%')
              .textAlign(TextAlign.Center)
              .backgroundColor(Color.Yellow)
          }.onAppear(() => {
            // 当listitem渲染的时候调用
            console.log('gxxt ', item.item + ' 出现了')
          })
        })
        .template('hong', (item: RepeatItem<string>) => {
          ListItem() {
            Text(item.item)
              .fontSize(20)
              .width('100%')
              .textAlign(TextAlign.Center)
              .backgroundColor(Color.Red)
          }.onAppear(() => {
            // 当listitem渲染的时候调用
            console.log('gxxt ', item.item + ' 出现了')
          })
        })
        .templateId((item: string, index: number) => {
          // 下标被3除余1 加载huang模板  被3除余2 加载hong模板 其他的加载each的通用模板
          if (index % 3 == 1) {
            return 'huang'
          } else if (index % 3 == 2) {
            return 'hong'
          } else {
            return ''
          }
        })// 开启懒加载
        .virtualScroll()
    }
    .width('100%')
    .cachedCount(2) //缓存两个节点
  }
}

飞书三方登录功能实现与行业思考

需求背景

近期在原有后台账号密码登录功能基础上,增加了飞书三方登录功能,主要解决两个问题:

  1. 方便经常忘记密码的同事快速登录系统
  2. 新同事入职时可通过飞书自动创建账号,只需上级分配权限即可
  3. 同事离职后,无需手动禁用账号,通过飞书权限控制自然拦截

飞书三方登录实现

流程图解

image.png

详细流程参考 飞书官方文档

实现特点

创建企业自建应用后,在安全设置中配置重定向地址即可使用,甚至支持 https://localhost:6120 本地测试,这与其他三方登录平台相比更加便捷。

用户信息处理方案

  • 获取用户唯一ID作为账号
  • 密码设置为随机数
  • 沿用原有创建新账号流程

老用户绑定方案思考

最初考虑最小化变动,但在流程设计中发现问题。最终采用 登录与绑定分离 的方案:

  1. 登录是登录流程绑定是绑定流程
  2. 增加专门入口供老用户绑定飞书账号
  3. 数据库增加 third_party_id 字段存储用户唯一ID
  4. 登录时优先查询 third_party_id 字段

移动端兼容性问题与解决方案

问题发现

第一版方案:

登录页A点击飞书登录 → 打开B页面 → 完成登录 → 通过 window.opener.postMessage 通知父页面 → 关闭当前页 → A页面提示登录成功

PC端正常,但移动端出现跨源问题

解决方案

function listenEvent(event) {
    // 安全检查:验证消息来源(兼容http和https)
    const currentOrigin = window.location.origin;
    const eventOrigin = event.origin;
    // 检查是否为同域不同协议的情况
    const isSameDomain = eventOrigin.replace(/^https?:/, '') === currentOrigin.replace(/^https?:/, '');
    
    if (!isSameDomain) {
        console.warn('收到来自不受信任域名的消息:', event.origin);
        return;
    }
    
    handleThirdLoginResult(event.data);
}

同时增加 BroadcastChannel 通信作为兜底方案

iOS Safari特殊处理

针对 iOS Safari 不支持 window.open 方法的问题,采用 localStorage 存储源页面地址,登录完成后跳转回原页面。

行业变化与AI发展思考

Cursor 等AI编码助手已经达到高级前端工程师水平,能够:

  • 🚀 快速解决积累的技术问题
  • 💡 提供优质的技术方案
  • 🌐 扩大技术视野,提高开发下限

这种变化意味着:

  • ⚖️ 技术门槛降低,技术垄断被打破
  • 🔥 行业竞争加剧,用工成本下降
  • 📚 开发者需要更加关注自身健康持续学习能力

健康 > 心态 > 赚钱 —— 这才是技术人员应当坚持的价值排序。

1. 使用VueCli编译生产环境代码以及创建不同模式

为生产环境构建代码

应用部署的流程

  • 构建
    Javascript 语言本身是不需要编译的。
    但是现代的前端项目使用的语言和或者的模块系统都无法在浏览器中使用,都需要使用特定的 bundler 将源代码最终转换为浏览器支持的 Javascript 代码。
  • 不同的环境

开发环境

  • 会添加丰富的错误提示
  • 可以使用 mock server 或者本地后端环境
  • 添加各种便利的功能 - 比如 hot reload,自动刷新
  • 不太关心静态资源的大小,最好提供最丰富的调试信息(sourcemap)等。

生产环境

  • 稳定是最重要的原则
  • 速度是第一要务

生产环境和测试环境的区别

  • 高度相似
  • 使用的后端服务不一样

Vue Cli 的模式

文档地址:cli.vuejs.org/zh/guide/mo…

  • development 模式用于 vue-cli-service serve
  • test 模式用于 vue-cli-service test:unit
  • production 模式用于 vue-cli-service build

模式将决定您的应用运行的模式,是开发,生产还是测试,因此也决定了创建哪种对不同环境优化过的 webpack 配置。

test 模式:Vue CLI 会创建一个优化过后的,并且旨在用于单元测试的 webpack 配置,它并不会处理图片以及一些对单元测试非必需的其他资源。

development 模式:会创建一个 webpack 配置,该配置启用热更新,不会对资源进行 hash 也不会打出 vendor bundles,目的是为了在开发的时候能够快速重新构建。

在运行命令的时候设置环境变量的工具:cross-env
文档地址:github.com/kentcdodds/…

环境变量文件

cli.vuejs.org/zh/guide/mo…
在你的项目根目录中放置下列文件来指定环境变量

.env               # 在所有的环境中被载入
.env.local         # 在所有的环境中被载入,但会被 git 忽略
.env.[mode]        # 只在指定的模式中被载入
.env.[mode].local  # 只在指定的模式中被载入,但会被 git 忽略

一个环境文件只包含环境变量的 “键 = 值” 对:

  • VUE_APP_ 开头的变量
  • NODE_ENV : 当前使用的模式
  • BASE_URL : 部署到的基础路径
NODE_ENV=development
VUE_APP_FOO=bar
BASE_URL='/'

读取变量

console.log(process.env.NODE_ENV) // development
console.log(process.env.VUE_APP_FOO) // bar
console.log(process.env.BASE_URL) // '/'

Flutter用户体验之01-避免在 build() 或 initState() 内直接做耗时 blocking

提醒:不要在 Flutter widget 生命周期的早期(build()initState())里直接做耗时/阻塞操作


🔎 背景:为什么 build / initState 不能阻塞?

  • Flutter 的渲染是单线程事件驱动(Main Isolate)。
  • build() 调用非常频繁(每次 setState() 都可能触发),要求 极快执行
  • initState() 是组件初始化的生命周期起点,此时也在 UI 线程里,如果你这里卡住,UI 会“白屏”或掉帧。

所以:

  • 在这两个地方直接 await 耗时任务 (比如数据库查询 / 硬盘 IO / 网络请求),就会导致 UI 主线程阻塞。
  • 表现 → “界面迟迟不显示”、“UI卡住动画不动”。

✅ 合理做法

1. 用 延迟到下一帧的机制 (addPostFrameCallback)

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) async {
    await loadAsyncStuff();  // 在 build 绘制到屏幕之后再执行
  });
}

这样能保证 先把 UI 渲染出来(哪怕是空壳子 Skeleton),再执行耗时逻辑 → 用户体验明显好。


2. 用 FutureBuilder / StreamBuilder

@override
Widget build(BuildContext context) {
  return FutureBuilder<List<Item>>(
    future: _loadItems(),   // 异步加载
    builder: (context, snapshot) {
      if (!snapshot.hasData) return CircularProgressIndicator();
      return ListView(...);
    },
  );
}

优点:

  • UI 与数据的生命周期解耦,异步数据返回前 UI 也能安全显示 loading。
  • 不会阻塞 build。

3. 提前放到 外部 Provider/Bloc 里做

很多时候,widget 不应该承担数据加载逻辑 → 交给 Provider/Bloc:

  • initState 里只 dispatch 一个事件 context.read<MyBloc>().add(LoadData());
  • 真正的数据拉取在 Bloc/Repository 层运行 → widget 不会阻塞。

4. 避免同步 IO

  • Dart 的某些 IO API(如 File.readAsBytesSync)是同步阻塞的 → 千万不要放在 build/initState。
  • 一定要用 async 版本 (readAsBytes)。

📌 实际应用到 storeAssets 的场景

现在 storeAssets 做了 本地 IO + 网络下载(耗时极长),如果在 initState 里直接调用:

@override
void initState() {
  super.initState();
  storeAssets(actions, (progress) {
    setState(() { ... });
  });
}

⚠️ 问题:UI 会在 storeAssets 第一个 await 卡住(黑屏、假死)。

优化:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    // 这里 UI 已经渲染出来了
    _loadAssets();
  });
}

Future<void> _loadAssets() async {
  await storeAssets(actions, (progress) {
    setState(() => _progress = progress);
  });
}

这样好处:

  • 界面能立刻出来(比如显示“正在准备下载”)。
  • 下载在后台执行,进度条随时刷新。

🎯 总结最佳实践

  1. 不要build / initState 里直接 await 耗时操作。
  2. addPostFrameCallback:保证渲染优先。
  3. 或者 FutureBuilder/StreamBuilder:UI → 数据异步绑定。
  4. 对于复杂业务 → 用状态管理工具 (Provider/Bloc/Cubit),UI 只 listen

前端 Class 不是花架子!3 个大厂常用场景,告诉你它有多实用

Class:让前端代码“写一次,到处用”的实操指南

不想再看枯燥的概念?下面直接告诉你:Class 到底是什么、怎么写、在哪些场景立刻能用、能帮你省多少事。


一、Class 到底是什么?一句话记住

“把数据和方法打包成一个可复制的小机器,随用随 new,改一次全站生效。”


二、5 分钟上手:怎么写?

1. 最简模板——照抄就能用

class 业务名 {
  constructor(必要参数) {
    this.属性 = 值;          // 实例自己的数据
  }
  
  方法1() { /* 业务逻辑 */ }
  方法2() { /* 业务逻辑 */ }
}

真实例子:表单校验器
需求:3 个页面都有“手机号+验证码”输入,规则一样。

class MobileValidator {
  constructor() {
    this.rules = {
      mobile: /^1[3-9]\d{9}$/,
      code: /^\d{6}$/
    };
  }
  
  validate(field, val) {
    return this.rules[field]?.test(val) || false;
  }
  
  getErrorMsg(field) {
    const map = { mobile: '手机号格式错误', code: '验证码6位数字' };
    return map[field];
  }
}

// 任意页面直接丢进去
const v = new MobileValidator();
v.validate('mobile', '13800138000'); // true

收益:以后规则一改,只改这一处,3 个页面同步生效。


2. 继承——“老代码不动,新功能叠加”

需求:后台两个弹窗,基础弹窗 只负责居中 + 蒙层;确认弹窗 额外需要“确定/取消”按钮。

// 老代码
class BaseModal {
  constructor(content) {
    this.content = content;
  }
  show() {
    // 生成蒙层+居中
  }
  hide() {
    // 移除蒙层
  }
}

// 新需求:不碰老代码,直接继承
class ConfirmModal extends BaseModal {
  constructor(content, onOk) {
    super(content);   // 复用蒙层逻辑
    this.onOk = onOk;
  }
  show() {
    super.show();     // 先复用
    this.renderBtn(); // 再追加按钮
  }
  renderBtn() {
    // 生成确定/取消按钮
  }
}

// 用
new ConfirmModal('确定删除?', () => fetch('/api/del')).show();

收益:老弹窗稳如老狗,新弹窗 10 行代码搞定,零回归测试。


3. 静态方法——直接把类当工具包

需求:项目里到处要转“价格分 → 元”,全局用。

class Price {
  static toYuan(fen) {
    return (fen / 100).toFixed(2);
  }
  static toFen(yuan) {
    return Math.round(yuan * 100);
  }
}

// 任何文件直接调,不用 new
Price.toYuan(1999); // "19.99"

收益:告别全局乱飘的 formatMoney 函数,统一收口,改单位只改 1 行。


三、开发中最常落地的 4 个场景

场景 不用 Class 的痛点 用了 Class 的爽点 实现成本
1. 表单校验 每份表单复制一套 if/else 一套规则多处复用 20 行
2. 轮询/倒计时 setInterval 散落各处,忘记清定时器 封装一次,自动回收定时器 30 行
3. 接口缓存 同一份数据多次请求 类里加 Map 做内存缓存,5 分钟搞定 15 行
4. 组件库 每个按钮重复 disabled 逻辑 抽象 BaseComponent 继承即可 1 次

四、完整小项目:带缓存的 HTTP 请求器

需求:

  1. 页面 A、B 都要拿“字典数据”;
  2. 10 分钟内不再重复请求;
  3. 任意组件都能用。
class DictService {
  static #cache = new Map();        // 私有缓存
  static TTL = 10 * 60 * 1000;      // 10 分钟

  static async get(code) {
    const key = `dict_${code}`;
    const hit = this.#cache.get(key);
    if (hit && Date.now() - hit.time < this.TTL) {
      return hit.data;              // 直接返回缓存
    }
    const { data } = await fetch(`/api/dict/${code}`).then(r => r.json());
    this.#cache.set(key, { data, time: Date.now() });
    return data;
  }
}

// 任意组件
const list = await DictService.get('city');

收益

  • 业务代码 0 改动,网络请求瞬间砍半;
  • 缓存逻辑封装在类里,外部无感,测试只测这一处即可。

五、快速 checklist:什么时候该用 Class

  1. 3 次以上重复出现的 数据 + 行为 → 直接封装;
  2. 老功能要扩展,又不想改原文件 → 继承;
  3. 全局通用、无状态的工具 → 静态方法;
  4. 需要保护内部变量不被外部篡改 → 私有属性 #

六、一句话总结

Class = “复制粘贴克星”
把重复代码变成可 new、可继承、可缓存、可组合的小积木,写一次,以后只加功能不加班。

耗时1年,终于我也拥有属于自己的AI工作流

这篇文章其实蓄谋已久,只是一直没有时间撰写;今天终于可以将我这一年来学习AI相关的知识简单分享给大家。

#1. 缘起

去年AI 爆火,Agent开发框架 如雨后春笋般曝出;langchain + langgraph 由于对Python + TS 版本支持的较好,在社区已然点赞颇高。

langgraph 可以 基于 图的方式对Agent进行编排;此时我萌生出了 为什么 不能通过 可视化编排的方式将大模型 和 传统的工作流结合起来 的想法

于是抽周末的时间,搭建了一个「AI 工作流」,支持在线编辑/调试/预览 工作工作流

工作流

效果预览

image.png

image.png

#2. 一个人的初心

看到这里很多朋友肯定会问,coze 和 dify 不就是做这个事情的吗?没错,coze 和 dify 从现在的角度确实比我更快的提供出来;coze 在前2个月 开源了 coze-studio,dify 在 24年初开源Agent,25年7月支持 MCP;而我产生AI工作流的想法其实早已萌生,只是一个在上班后的业余项目,进度总归是慢了一点。

看了上面预览图的同学肯定怀疑:你不会是copy的coze 开源项目吧?我可以很正式的回答:没有使用coze的一行代码

coze产品和我之前的想法是那么的契合,以至于 我都在怀疑自己,为什么不直接跳槽过去; coze 是字节大量优秀的开发人员智慧的结晶;而我的项目是一个人奋斗1年的结果,可能也只是coze 产品模型内的一小块(PS: 蚍蜉撼大树)。

但我想说的是 《虽迟但到》。已经不记得多少个10点多下班后的日夜,一个人猫在书房 琢磨着这个项目;我的想法很简单:做一个简单易用的工作流,支持各类扩展,帮助自己快速生成API/AI图片等

#3. 取名Agentflow

#3.1 技术架构

技术侧核心分为2块

  1. 前端:基于 Next.js + Antd + xyflow 实现 工作流的可视化编辑/调试/预览;以及权限管理/用户管理/工作流管理等功能;
  2. 后端:基于 LangChain + LangGraph + Redis + Mysql + Prisma + Docker 实现 流程的执行及大模型的调用等;

AgentFlow-整体的架构图.drawio.png

#3.2 支持能力

  1. 支持完全的 私有化部署: 整个项目基于 Docker + Docker-compose 进行部署,支持完全的私有化部署;只需修改自己的 API Key即可 快速接入 openai/gemini/deepseek等大模型;
  2. 面向不同人群的快速使用
    • 针对普通用户: 可以基于现有的模板快速生成工作流并预览效果;
    • 针对专业使用者: 完全支持自定义工作流: 支持自定义节点/边/属性等
  3. 可视化编排流程: 支持DeepSeek+gpt4/5+gemimi模型支持,MCP Servers 支持 image.pngimage.png
  4. 工作流在线Debug image.png
  5. 工作流管理:支持工作流的创建/编辑/删除/预览/导出等功能 image.png
  6. 工作流效果预览 image.png

#3.2 使用流程

使用流程非常简单,只需以下2个步骤即可

  1. 编写或者使用已有的工作流
  2. 点击运行按钮,即可预览工作流效果

image.png

#3.3 我要体验

Rollup构建JavaScript核验库,并发布到NPM

本文详细介绍了如何使用Rollup构建一个支持UMD、ES Module、CommonJS三种格式的JavaScript校验库,并完成NPM发布的全流程。

🎯 概述

本项目是一个JavaScript校验工具库,提供了字符串、数字、网络、联系方式、身份信息、金额、地理位置、业务和高级校验等9大类共50+个校验函数。项目使用Rollup进行打包。

📁 项目结构设计

validate/
├── src/                    # 源码目录
│   ├── index.js           # 主入口文件
│   └── validators/        # 校验函数模块
│       ├── string.js      # 字符串校验
│       ├── number.js      # 数字校验
│       ├── network.js     # 网络校验
│       ├── contact.js     # 联系方式校验
│       ├── identity.js    # 身份信息校验
│       ├── money.js       # 金额校验
│       ├── location.js    # 地理位置校验
│       ├── business.js    # 业务校验
│       └── advanced.js    # 高级校验
├── dist/                  # 构建输出目录
│   ├── index.js          # UMD格式
│   ├── index.esm.js      # ES Module格式
│   ├── index.cjs.js      # CommonJS格式
│   ├── index.d.ts        # TypeScript类型定义
│   └── *.js.map          # Source Map文件
├── test/                  # 测试目录
│   └── test.js           # 测试文件
├── rollup.config.js       # Rollup配置文件
└── package.json          # 项目配置

⚙️ Rollup配置

1. UMD格式配置

{
  input: 'src/index.js',           // 入口文件
  output: {
    file: 'dist/index.js',         // 输出文件
    format: 'umd',                 // 格式类型
    name: 'ValidateUtils',         // 全局变量名
    sourcemap: true                // 生成Source Map
  },
  plugins: [
    nodeResolve(),                 // 解析node_modules中的模块
    terser()                       // 代码压缩
  ]
}

UMD特点:

  • 兼容AMD、CommonJS和全局变量
  • 可在浏览器直接使用: <script src="dist/index.js"></script>
  • 全局变量: window.ValidateUtils

2. ES Module格式配置

{
  input: 'src/index.js',
  output: {
    file: 'dist/index.esm.js',
    format: 'esm',                 // ES Module格式
    sourcemap: true
  },
  plugins: [nodeResolve(), terser()]
}

ES Module特点:

  • 现代JavaScript标准
  • 支持Tree Shaking
  • 静态导入/导出
  • 适用于现代打包工具(Vite、Webpack 5+)

3. CommonJS格式配置

{
  input: 'src/index.js',
  output: {
    file: 'dist/index.cjs.js',
    format: 'cjs',                 // CommonJS格式
    sourcemap: true
  },
  plugins: [nodeResolve(), terser()]
}

CommonJS特点:

  • Node.js原生支持
  • 动态导入/导出
  • 适用于服务端开发

📦 NPM发布流程

# 发布前构建
npm run build

1. 发布前检查

检查NPM登录状态

npm whoami
# 输出: yun_xcx

检查包名可用性

npm view yu-validate-utils
# 如果返回404,说明包名可用

检查包内容

npm pack --dry-run

2. 发布执行

npm publish

发布成功输出:

npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
+ yu-validate-utils@1.0.0

3. 发布验证

npm view yu-validate-utils --json

验证结果包含完整的包信息:

  • 包名、版本、描述
  • 维护者信息
  • 仓库地址
  • 文件列表
  • 依赖信息
  • 发布时间等

版本管理

# 补丁版本 (1.0.0 -> 1.0.1)
npm version patch
npm publish

# 小版本 (1.0.0 -> 1.1.0)
npm version minor
npm publish

# 大版本 (1.0.0 -> 2.0.0)
npm version major
npm publish

🚀 使用示例

浏览器环境 (UMD)

<script src="https://unpkg.com/yu-validate-utils@1.0.0/dist/index.js"></script>
<script>
  console.log(ValidateUtils.isEmail('test@example.com')); // true
</script>

ES Module环境

import { isEmail, isPhone, isIP } from 'yu-validate-utils'

console.log(isEmail('test@example.com')); // true
console.log(isPhone('13812345678'));      // true
console.log(isIP('192.168.1.1'));        // true

CommonJS环境

const { isEmail, isPhone, isIP } = require('yu-validate-utils')

console.log(isEmail('test@example.com')); // true
console.log(isPhone('13812345678'));      // true
console.log(isIP('192.168.1.1'));        // true

🔗 相关链接

极致灵活:如何用一个输入框,满足后台千变万化的需求

作者:张世萌(汽车之家:APP 架构前端工程师)

一个输入框,满足后台千变万化的需求

在后台管理系统中,我们经常遇到各种复杂多变的业务需求。传统的配置方式往往难以应对这种复杂要求。本文介绍一种灵活的解决方案:通过一个函数输入框,结合参数插值和沙箱执行机制,让用户能够在输入框中编写 JavaScript 代码来满足各种复杂的业务场景。

为什么需要直接在输入框写函数?

传统表单输入的局限性

传统的后台配置通常采用表单填写的方式,用户通过下拉框、输入框、复选框等组件来配置参数。这种方式在处理简单场景时效果良好,但面对以下情况时就显得捉襟见肘:

  • 动态逻辑处理:需要根据不同条件生成不同的内容
  • 复杂数据转换:需要对输入数据进行复杂的计算和转换
  • 条件分支:需要根据参数值执行不同的逻辑分支
  • 数据联动:多个参数之间存在复杂的依赖关系

输入框函数的优势

通过让用户直接编写函数,我们可以获得以下优势:

  1. 无限的灵活性:JavaScript 的表达能力几乎没有限制
  2. 动态执行:可以根据运行时参数动态生成结果
  3. 逻辑复用:可以将复杂逻辑封装成可复用的函数
  4. 易于调试:可以通过 console.log 等方式进行调试

如何定义一个输入框函数

如下图所示,我们定义了一个名为 output 的函数输入框,用户可以在其中编写 JavaScript 代码,在后台执行后,生成动态 html 标签保存。

界面展示

output-function.jpg

自定义组件内容输出

组件内容:一个异步的 JS 函数
函数名称output
函数参数:(自动注入)

  • config:通用参数集合对象
  • option:可定制参数集合对象

函数返回:对象数组,每一个对象用来描述一个 HTML 标签(一个组件可以由多个标签组成)

  • type:标签类型( script / style / link / meta 等)
  • name:组件名称,用于说明
  • attrs:标签属性集合
  • content:标签内容

基础示例

在我们的系统中,主要应用场景是动态生成 Web 组件(由 script、style、link、meta 等一个或多个标签组成)。用户通过编写函数来定义组件的结构、样式和行为:

async function output(config, option, query) {
  const content = await fetchData({
    url: config.url,
  });

  return [
    {
      type: 'script',
      name: 'example-component',
      attrs: [{ name: 'type', value: 'text/javascript' }],
      content: content,
    },
  ];
}

安全性:沙箱隔离

沙箱环境设计

为了确保用户代码的安全执行,我们使用 Node.js 的 vm 模块创建了一个沙箱环境:

import vm from 'node:vm';

// 创建受限的沙箱上下文
const sandbox = {
  _, // 允许使用 lodash
  console, // 允许使用 console 输出
  fetchData, // 允许使用数据请求方法
  transformCustomVariables, // 允许使用定制参数转换方法
  URL,
};

// 创建沙盒上下文
const context = vm.createContext(sandbox);

安全措施

  1. 受限的全局对象:只暴露必要的 API,避免访问敏感的系统资源
  2. 代码执行隔离:用户代码在独立的上下文中执行,无法影响主进程
  3. 错误捕获:完善的错误处理机制,防止恶意代码导致系统崩溃
  4. 资源限制:可以设置执行时间和内存限制,防止无限循环等问题

执行输入框函数

  • userFunctionString 后台读取到的输入框中的函数字符串
const handleTemplate = async (userFunctionString, config, option) => {
  try {
    // 1. 参数插值处理
    const replacedUserFunctionString = replaceInterpolate(userFunctionString, {
      config,
      option,
    });

    // 2. 在沙盒环境中执行用户代码
    const userFunction = vm.runInContext(
      `(${replacedUserFunctionString})`,
      context,
    );

    // 3. 执行函数并获取结果
    const result = await userFunction(config, option);

    // 4. 后处理:压缩代码等
    const processedResult = result.map((item) => ({
      ...item,
      content: uglifyCode(item.content),
    }));

    return processedResult;
  } catch (error) {
    throw new Error(`函数执行错误: ${error.message}`);
  }
};

对外提供的对象和方法参考

  • lodash (_)

lodash 对象,提供丰富的工具函数

  • fetchData

通用的 HTTP 请求方法,封装了 axios 请求并支持重试、缓存、超时等功能:

灵活性:参数插值

两种参数类型

我们的系统定义了两种不同类型的参数,每种都有其特定的用途:

1. config(通用参数)
  • 来源:前端参数输入框,用户手动配置
  • 特点:自增的,可以动态添加新的配置项
  • 用途:存储通用的配置信息,如项目 ID、环境变量等
2. option(定制参数)
  • 来源:前端参数输入框,用户手动配置
  • 特点:自增的,针对特定业务场景的参数
  • 用途:存储业务相关的定制化配置

参数类型支持

参数输入框支持多种 JavaScript 数据类型,可以像写 JS 代码一样输入,后台能正确处理它们的插值:

基础类型
  • 数字类型

    input 输入框:42

  • 字符串类型:

    input 输入框:这是一个描述

  • 布尔类型:

    checkbox 输入框:选中/未选中

复合类型
  • 数组类型 input 输入框:['前端', '后端', '全栈', /\d+/]
  • 对象类型 input 输入框:{api: {baseUrl: '<https://api.example.com>', timeout: 5000, headers: {'Content-Type': 'application/json'}}, features: \['feature1', 'feature2']}
  • 函数类型 input 输入框:function (value) { return value.toString().padStart(2, '0'); }

插值机制实现

插值语法

我们使用 {{}} 作为插值标识符,支持 configoption 参数的插值:

// 在用户函数中使用插值
function output(config, option) {
  // 这些插值会在执行前被替换
  const theme = '{{config.theme}}';
  const buttonColor = '{{option.buttonColor}}';
  const features = {{option.features}};

  return [{
    type: 'style',
    name: 'dynamic-style',
    attrs: [{ name: 'type', value: 'text/css' }],
    content: `
      .container {
        theme: ${theme};
        --button-color: ${buttonColor};
      }
      .features::after {
        content: '${features.join(', ')}';
      }
    `
  }];
}
插值处理逻辑
  1. 先将参数输入框中的值,按照类型转换为对应的 JS 值,确保输入是合法的
/**
 * 将 { 参数名一: { type: xx,value: xx,desc: xx}, ...} 中的指定类型的 value 做 JSON.parse
 * @param {*} obj
 * @returns
 */
function parseValueField(obj: IUnParsedObject): IParsedObject {
  return Object.entries(obj).reduce(
    (acc: IParsedObject, [key, valueObj]: [string, IValueObject]) => {
      // 只处理 type 为 'object' 或 'array' 的项(新增了 arrayByItems 类型,值也是数组,但是前端处理方式不同)
      if (
        valueObj.type === 'object' ||
        valueObj.type === 'array' ||
        valueObj.type === 'arrayByItems' ||
        valueObj.type === 'boolean' ||
        valueObj.type === 'function' ||
        valueObj.type === 'number'
      ) {
        try {
          // 解析值为 js 对象
          valueObj.value = evalStrToObj(valueObj.value);

          // 解析成功后才添加到 acc
          acc[key] = valueObj;
        } catch (e) {
          console.error(`解析 ${key} 的值失败: `, e);
        }
      } else {
        acc[key] = valueObj;
      }

      return acc;
    },
    {},
  );
}

/**
 * 转换字符串为 js 表达式
 * @param str string
 * @returns
 */
const evalStrToObj = (str: string) => new Function(`return (${str})`)();
  1. 将具体的参数值再转换成字符串,替换到函数模板字符串中,确保输入函数能够在后台正确执行
/**
 * 替换模板中的插值参数
 */
function replaceInterpolate(template, { config, option }) {
  const reg = /\{\{\s*(config\.[^}]+|option\.[^}]+)\s*\}\}/g;
  // 如果模板不存在插值字符串,直接返回函数字符串
  if (!reg.test(template)) {
    return template;
  }

  // 使用 lodash.template 进行替换
  return _.template(template, { interpolate: reg })({
    config: formatParams(config),
    option: formatParams(option),
  });
}

/**
 * 递归格式化对象
 * @param {object} obj
 * @returns {object}
 */
function formatParams(obj: any) {
  if (typeof obj !== 'object' || obj === null) return obj; // 非对象直接返回
  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => [
      key,
      stringifyValueAsExpression(value),
    ]),
  );
}

/**
 * 将 JavaScript 值转换为合法的 JavaScript 表达式字符串
 */
function stringifyValueAsExpression(value) {
  if (value instanceof RegExp) {
    return value.toString(); // /pattern/flags
  } else if (Array.isArray(value)) {
    return `[${value.map(stringifyValueAsExpression).join(',')}]`;
  } else if (typeof value === 'object' && value !== null) {
    return `{${Object.entries(value)
      .map(([key, val]) => `${key}:${stringifyValueAsExpression(val)}`)
      .join(',')}}`;
  } else if (typeof value === 'string') {
    return `'${value.replace(/'/g, "\\'")}'`; // 转义单引号
  } else if (value == null) {
    return 'null';
  }
  return String(value);
}
插值示例

假设我们有以下参数:

// option 参数
const option = {
  buttonColor: 'blue',
  features: ['login', 'register'],
  config: {
    timeout: 5000,
    retries: 3,
  },
};

用户编写的函数:

function output(config, option) {
  const color = '{{option.buttonColor}}';
  const features = {{option.features}};
  const timeout = {{option.config.timeout}};

  return [{
    type: 'script',
    content: `
      console.log('颜色:${color}');
      console.log('功能:', ${JSON.stringify(features)});
      console.log('超时:${timeout}ms');
    `
  }];
}

经过插值替换处理后:

function output(config, option, query) {
  const color = 'blue';
  const features = ['login', 'register'];
  const timeout = 5000;

  return [
    {
      type: 'script',
      name: 'debug-info',
      attrs: [{ name: 'type', value: 'text/javascript' }],
      content: `
      console.log('颜色:blue');
      console.log('功能:', ["login","register"]);
      console.log('超时:5000ms');
    `,
    },
  ];
}

实际应用案例

案例一:条件式字体管理组件

这个案例展示了如何根据用户配置动态加载字体资源,并生成对应的 CSS 变量和工具类。该组件会根据 option 参数中的字体开关来决定加载哪些字体文件:

function output(config, option) {
  const {
    'custom-regular': enableRegular,
    'custom-medium': enableMedium,
    'custom-bold': enableBold,
  } = option;

  // 字体配置列表
  const fontList = [
    {
      name: 'custom-regular',
      content:
        '@font-face { font-family: custom-regular; src: local("CustomFont"), local("CustomFont_Regular"), url("https://cdn.example.com/fonts/CustomFont_Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }',
    },
    {
      name: 'custom-medium',
      content:
        '@font-face { font-family: custom-medium; src: local("CustomFont_Medium"), url("https://cdn.example.com/fonts/CustomFont_Medium.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; }',
    },
    {
      name: 'custom-bold',
      content:
        '@font-face { font-family: custom-bold; src: local("CustomFont_Bold"), url("https://cdn.example.com/fonts/CustomFont_Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; }',
    },
  ];

  // 根据 option 参数筛选启用的字体
  const activeKeys = Object.keys(option).filter((item) => option[item]);
  const filterList = fontList.filter((item) => activeKeys.includes(item.name));

  // 使用 lodash 模板生成 CSS 内容
  const content = _.template(
    `  
    <% _.forEach(list, function(item) { %>
      {{ item.content }}
    <% }); %>

    :root {
      <% _.forEach(list, function(item) { var cleanItem = item.name.replace('custom-', '');%>
        --font-{{ cleanItem }}: custom-{{ cleanItem }};
      <% }); %>
    }

    <% _.forEach(list, function(item) { var cleanItem = item.name.replace('custom-', '');%>
      .f-{{ cleanItem.split('-').map(word => word[0]).join('') }} {
        font-family: var(--font-{{ cleanItem }});
      }
    <% }); %>
  `,
  )({ list: filterList });

  return [
    {
      type: 'style',
      name: '字体管理 CSS 变量版',
      attrs: [{ name: 'type', value: 'text/css' }],
      content: content,
    },
  ];
}

功能说明:

  1. 条件加载:只有当 option 中对应的字体开关为 true 时,才会加载该字体
  2. CSS 变量:为每个启用的字体生成 CSS 变量,如 --font-regular--font-medium
  3. 工具类:自动生成简化的 CSS 类名,如 .f-r(custom-regular)、.f-m(custom-medium)、.f-b(custom-bold)
  4. 字体文件:使用 CDN 上的自定义字体文件,支持现代 woff2 格式

生成的 CSS 示例:

/* 当启用 custom-regular 时 */
@font-face {
  font-family: custom-regular;
  src:
    local('CustomFont'),
    local('CustomFont_Regular'),
    url('https://cdn.example.com/fonts/CustomFont_Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  --font-regular: custom-regular;
}

.f-r {
  font-family: var(--font-regular);
}

案例二:网页置灰组件

async function output(config, option, query) {
  const customCss = option.customCss || '';
  return [
    {
      type: 'script',
      name: '网页置灰',
      attrs: [],
      content: `
          (function () {
            // 启用灰度效果的函数
            let grayscaleStyleElement = null;
            let intervalId = null; // 用于保存定时器的标识
    
            // 启用灰度效果(除视频播放外全部置灰)
            function enableGrayscale() {
              if (grayscaleStyleElement) return; // 避免重复添加 style 元素

              const style = document.createElement('style');
              
              style.innerHTML = \`html {filter: grayscale(0.95);-webkit-filter: grayscale(0.95);}${customCss}\`;
              document.documentElement.insertBefore(style, document.head);
              grayscaleStyleElement = style; // 保存引用,方便之后移除
            }
    
            // 禁用灰度效果
            function disableGrayscale() {
              if (grayscaleStyleElement) {
                document.documentElement.removeChild(grayscaleStyleElement); // 移除添加的 style 元素
                grayscaleStyleElement = null; // 清空引用
              }
            }
    
            // 获取毫秒数
            function getMillTime(d) {
              return d ? new Date(d).getTime() : Date.now();
            }
    
            function handleGrayscale(startTime, endTime) {
              const currentTime = getMillTime();
              console.log('开始检测');
    
              if (currentTime >= startTime && currentTime <= endTime) {
                enableGrayscale();
              } else {
                disableGrayscale();
              }
    
              // 超过结束时间后清除定时器
              if (currentTime > endTime && !grayscaleStyleElement) {
                clearInterval(intervalId); // 清除定时器,避免重复执行检测逻辑
                intervalId = null;
              }
            }
    
            // 定时上线和下线灰度效果
            function scheduleGrayscaleEffect() {
              const startTime = getMillTime({{option.startDate}});
              const endTime = getMillTime({{option.endDate}});
              
              handleGrayscale(startTime, endTime);
    
              // 每分钟检查一次时间,确保灰度效果按时开启/关闭
              intervalId = setInterval(() => {
                handleGrayscale(startTime, endTime);
              }, 10000); // 每30秒执行一次
            }
    
            // 启动定时任务
            scheduleGrayscaleEffect();
          })();
      `,
    },
  ];
}

总结

通过函数输入框的方式,我们成功地解决了后台配置系统在面对复杂业务需求时的灵活性问题。这种方案具有以下优势:

  1. 极高的灵活性:用户可以编写任意复杂的逻辑
  2. 强大的表达能力:支持条件判断、循环、函数调用等所有 JavaScript 特性
  3. 安全的执行环境:通过沙箱隔离确保系统安全
  4. 丰富的参数支持:支持多种数据类型和插值机制
  5. 良好的扩展性:可以轻松添加新的对外暴露的 API 和功能

当然,这种方案也有一定的学习成本,需要用户具备基本的 JavaScript 编程能力。但对于需要处理复杂业务逻辑的场景来说,这种投入是非常值得的。

未来,我们还可以考虑添加以下功能来进一步提升用户体验:

  • 模板库:预置常用的代码模板供用户参考
  • 可视化调试:提供更直观的调试工具,查看输入函数的输出结果
  • 版本管理:支持函数代码的版本控制和回滚

通过不断的优化和完善,这个函数输入框系统将能够更好地满足各种复杂的业务需求,真正做到"一个输入框,满足后台千变万化的需求"。

鸿蒙应用开发——AppStorageV2和PersistenceV2的使用

【高心星出品】

AppStorageV2和PersistenceV2的使用

概念

在HarmonyOS鸿蒙开发中,AppStorageV2和PersistenceV2是状态管理V2版本的核心工具,用于实现应用全局状态管理和持久化存储。以下是两者的关键特性和使用指南:

特性 AppStorageV2 PersistenceV2
作用 应用全局UI状态存储(运行时内存) UI状态持久化存储(设备磁盘)
数据生命周期 应用运行期间保留 应用重启后仍然保留
数据同步 实时同步到内存 自动/手动同步到磁盘
典型场景 跨页面共享临时数据(如用户登录状态) 需长期保存的配置(如用户偏好设置)
AppStorageV2常用方法

AppStorageV2是在应用UI启动时会被创建的单例。它的目的是为了提供应用状态数据的中心存储,这些状态数据在应用级别都是可访问的。AppStorageV2将在应用运行过程保留其数据。数据通过唯一的键字符串值访问。需要注意的是,AppStorage与AppStorageV2之间的数据互不共享。

AppStorageV2可以修改connect的返回值,实现与UI组件的同步。

AppStorageV2只能保存class类型的数据。

AppStorageV2支持应用的主线程内多个UIAbility实例间的状态共享。

connect:创建或获取存储的数据。

remove:删除指定key的存储数据。

keys:返回所有AppStorageV2中的key。

PersistenceV2常用方法

PersistenceV2是继承自AppStorageV2,在应用UI启动时会被创建的单例。它的目的是提供应用状态数据的中心存储,这些状态数据在应用级别都是可访问的。数据通过唯一的键值字符串访问。不同于AppStorageV2,PersistenceV2还将最新数据存储在设备磁盘上(持久化)。这意味着,应用退出再次启动后,依然能保存选定的结果。

对于与PersistenceV2关联的@ObservedV2对象,该对象的@Trace属性的变化,会触发整个关联对象的自动持久化;非@Trace属性的变化则不会,如有必要,可调用PersistenceV2 API手动持久化。请注意:被PersistenceV2持久化的类属性必须要有初值,否则不支持持久化。

PersistenceV2可以和UI组件同步,且可以在应用业务逻辑中被访问。

PersistenceV2支持应用的主线程内多个UIAbility实例间的状态共享。

connect:创建或获取存储的数据。

globalConnect:创建或获取存储的数据。

remove:删除指定key的存储数据。删除PersistenceV2中不存在的key会报警告。

keys:返回所有PersistenceV2中的key。包括module级别存储路径和应用级别存储路径中的所有key。

save:手动持久化数据。

notifyOnError:响应序列化或反序列化失败的回调。将数据存入磁盘时,需要对数据进行序列化;当某个key序列化失败时,错误是不可预知的;可调用该接口捕获异常。

在HarmonyOS鸿蒙开发中,PersistenceV2connectglobalConnect接口均用于持久化数据的管理,但两者在存储路径和应用场景上有显著差异。以下是核心区别和使用建议:

特性 connect globalConnect
存储路径级别 模块(module)级别 应用级别
数据隔离性 不同模块使用相同key时会创建独立数据副本 跨模块共享同一份数据
适用场景 模块内部数据隔离存储 跨模块共享数据
API版本支持 API version 12+ API version 18+(需注意版本兼容性)
数据副本风险 若Ability被多个模块拉起,可能产生多份 全局唯一副本,避免冗余

案例

appstoragev2数据存储案例:

包括获取数据,同步更新数据,遍历数据,移除数据等。

GIF 2025-9-19 11-44-08.gif

import { AppStorageV2 } from '@kit.ArkUI';

// appstorage保存的数据必须是对象
// 后面使用的过程中 每个属性一般都有默认值
@ObservedV2
class Userinfo{
 @Trace name:string='gxx'
  age:number=30
}

@Entry
@ComponentV2
struct Appstoragepage {
  // 获取key为ui 类型为Userinfo的数据,如果appstorage中不存在则使用默认构造方法初始化的值
  @Local user:Userinfo=AppStorageV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!
  // 获取key为 Userinfo的数据 如果appstorage中不存在则使用默认构造方法初始化的值
  // @Local user:Userinfo=AppStorageV2.connect<Userinfo>(Userinfo,()=>{return new Userinfo()})!
  // 获取key为Userinfo的数据 保证Appstorage中一定有该数据 否则出问题
  // @Local user:Userinfo=AppStorageV2.connect<Userinfo>(Userinfo)!
  @Local keys:string=''
  @Local datas:string=''
  build() {
    Column({space:20}){
      Button('保存的数据为: '+`name: ${this.user.name}`)
        .width('60%')
        .onClick(()=>{
          // 数据更新之后 name会刷新ui 而age不会刷新,但是都会同步到appstorage中
          this.user.name='ggl'
          // 重新获取一下appstorage中存储的数据
          this.datas=JSON.stringify(AppStorageV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!)
        })
      Button('保存的数据为: '+`age: ${this.user.age}`)
        .width('60%')
        .onClick(()=>{
          // 数据更新之后 name会刷新ui 而age不会刷新,但是都会同步到appstorage中
          this.user.age+=1
          // 重新获取一下appstorage中存储的数据
          this.datas=JSON.stringify(AppStorageV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!)
        })
      Text('当前的数据'+this.datas)
      Button('所有的key')
        .width('60%')
        .onClick(()=>{
        //   拿到所有的key
        this.keys= AppStorageV2.keys().join(' ')
        })
      Text(this.keys)
      Button('移除某个数据')
        .width('60%')
        .onClick(()=>{
         //  appstorage 移除key为ui的数据
         AppStorageV2.remove('ui')
        })
    }
    .height('100%')
    .width('100%')
  }
}
PersistenceV2数据持久化案例:

包括数据模块级别的持久化保存,更新和移除功能。

GIF 2025-9-19 11-43-05.gif

import {  PersistenceV2 } from '@kit.ArkUI';

// persidencev2保存的数据必须是对象
// 后面使用的过程中 每个属性一般都有默认值
@ObservedV2
class Userinfo{
 //  name属性由@Trace装饰,name更新会引起自动持久化
 @Trace name:string='gxx'
  // age 属性不会引起自动持久化,需要手动持久化
  age:number=30
}

@Entry
@ComponentV2
struct Persistencev2 {
  // 获取key为ui 类型为Userinfo的数据,如果persistencev2中不存在则使用默认构造方法初始化的值
  @Local user:Userinfo=PersistenceV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!
  // 获取key为 Userinfo的数据 如果persistencev2中不存在则使用默认构造方法初始化的值
  // @Local user:Userinfo=PersistenceV2.connect<Userinfo>(Userinfo,()=>{return new Userinfo()})!
  // 获取key为Userinfo的数据 保证PersistenceV2中一定有该数据 否则出问题
  // @Local user:Userinfo=PersistenceV2.connect<Userinfo>(Userinfo)!
  @Local keys:string=''
  @Local datas:string=''
  build() {
    Column({space:20}){
      Button('保存的数据为: '+`name: ${this.user.name}`)
        .width('60%')
        .onClick(()=>{
          // 数据更新之后 name会刷新ui 而age不会刷新,name会同步到PersistenceV2中而age不会
          this.user.name='ggl'
          // 重新获取persidence中的数据
          this.datas=JSON.stringify(PersistenceV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!)
        })
      Button('保存的数据为: '+`age: ${this.user.age}`)
        .width('60%')
        .onClick(()=>{
          // 数据更新之后 name会刷新ui 而age不会刷新,name会同步到PersistenceV2中而age不会
          this.user.age+=1
          // 手动保存数据
          PersistenceV2.save('ui')
          // 重新获取persidence中的数据
          this.datas=JSON.stringify(PersistenceV2.connect<Userinfo>(Userinfo,'ui',()=>new Userinfo())!)
        })
      Text('当前数据为: '+this.datas)
      Button('所有的key')
        .width('60%')
        .onClick(()=>{
        //   拿到所有的key
        this.keys= PersistenceV2.keys().join(' ')
        })
      Text(this.keys)
      Button('移除某个数据')
        .width('60%')
        .onClick(()=>{
         //  PersistenceV2 移除key为ui的数据
          try {
            PersistenceV2.remove('ui')
          } catch (err) {
            console.error('Remove failed:', err.code, err.message)
          }
        })
    }
    .height('100%')
    .width('100%')
  }
}

后端转全栈之Next.js后端及配置

本文概括:

  1. 在 Next.js 中定义 GET/POST 接口及动态路由、缓存控制。
  2. middleware.ts 用法,包括重定向、Cookie、请求/响应头、CORS 设置。
  3. 环境变量用法及前端访问前缀 NEXT_PUBLIC_
  4. next.config.js 配置技巧,如代理、图片优化、构建检查和输出模式。

接口

Next.js里可以直接写后端接口:nextjs.org/docs/app/ap…

一般接口会放在 app/api/xxx/route.ts 文件里

在 app/api/test/route.ts 中:

import { NextResponse, type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
    const res = {
        code: 0,
        message: 'Hello, world!',
        data: {
            name: 'cxk',
        },
    }
    return NextResponse.json(res)
}

这样就可以定义一个GET接口,接口接受的参数如下:

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
    // 访问/test页面,返回 /test
    const pathname = request.nextUrl.pathname
    // 访问/test?name=cxk页面,返回 {name: 'cxk'}
    const searchParams = request.nextUrl.searchParams
    const res = {
        code: 0,
        message: 'Hello, world!',
        data: {
            name: searchParams.get('name'),
            pathname: pathname,
        },
    }
    return NextResponse.json(res)
}

具体内容可以看:nextjs.org/docs/app/ap…

第二个参数是context,取决于动态路由当前的route

例如如果新建 app/test/[id]/route.ts 那么就可以从这里拿到id了

缓存问题:

默认情况下,如果GET接口会缓存,如果使用了Request对象,或者POST接口,那么就不会缓存了,如果设置了:

// 强制为动态渲染
export const dynamic = 'force-dynamic'
// 置重新验证频率为 10s , 在10s之后第一次访问开始过期数据,第二次访问拿到新的数据
export const revalidate = 10

中间件

参考地址:nextjs.org/docs/app/ap…

中间件可以拦截请求和响应,做登录鉴权等很多事情

在 app同级目录中定义一个 middleware.ts 文件

import { NextRequest, NextResponse } from 'next/server'

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
    return NextResponse.redirect(new URL('/', request.url))
}

export const config = {
    matcher: '/test/:path*',
}

如上代码,可以将 /test/xxx 重新向到 /

Cookie操作:

下面是Next.js文档对Cookie的操作,可以使用 NextResponse.next() 拿到Response,最后需要返回

nextjs.org/docs/app/ap…

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
    // Assume a "Cookie:nextjs=fast" header to be present on the incoming request
    // Getting cookies from the request using the `RequestCookies` API
    let cookie = request.cookies.get('nextjs')
    console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
    const allCookies = request.cookies.getAll()
    console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]

    request.cookies.has('nextjs') // => true
    request.cookies.delete('nextjs')
    request.cookies.has('nextjs') // => false

    // Setting cookies on the response using the `ResponseCookies` API
    const response = NextResponse.next()
    response.cookies.set('vercel', 'fast')
    response.cookies.set({
        name: 'vercel',
        value: 'fast',
        path: '/',
    })
    cookie = response.cookies.get('vercel')
    console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/' }
    // The outgoing response will have a `Set-Cookie:vercel=fast;path=/` header.

    return response
}

Header操作:

可以对请求头进行一些操作:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  // Clone the request headers and set a new header `x-hello-from-middleware1`
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-hello-from-middleware1', 'hello')
 
  // You can also set request headers in NextResponse.next
  const response = NextResponse.next({
    request: {
      // New request headers
      headers: requestHeaders,
    },
  })
 
  // Set a new response header `x-hello-from-middleware2`
  response.headers.set('x-hello-from-middleware2', 'hello')
  return response
}

CORS操作:

import { NextRequest, NextResponse } from 'next/server'
 
const allowedOrigins = ['<https://acme.com>', '<https://my-app.org>']
 
const corsOptions = {
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
 
export function middleware(request: NextRequest) {
  // Check the origin from the request
  const origin = request.headers.get('origin') ?? ''
  const isAllowedOrigin = allowedOrigins.includes(origin)
 
  // Handle preflighted requests
  const isPreflight = request.method === 'OPTIONS'
 
  if (isPreflight) {
    const preflightHeaders = {
      ...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin }),
      ...corsOptions,
    }
    return NextResponse.json({}, { headers: preflightHeaders })
  }
 
  // Handle simple requests
  const response = NextResponse.next()
 
  if (isAllowedOrigin) {
    response.headers.set('Access-Control-Allow-Origin', origin)
  }
 
  Object.entries(corsOptions).forEach(([key, value]) => {
    response.headers.set(key, value)
  })
 
  return response
}
 
export const config = {
  matcher: '/api/:path*',
}

环境变量

Next.js的环境变量可以放到 .env*文件里, .env.local的优先级最高,例如文件内容:

DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword

使用的时候:

export async function GET() {
  const db = await myDB.connect({
    host: process.env.DB_HOST,
    username: process.env.DB_USER,
    password: process.env.DB_PASS,
  })
  // ...
}

默认的变量都应该是在后端使用的,如果需要浏览器也可以使用,那么需要加前缀: NEXT_PUBLIC_ ,有这个前缀的会被浏览器使用。

NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk

配置

Next.js 可以通过根目录的 next.config.js 进行配置:

官方文档:nextjs.org/docs/app/ap…

比较有用的几个配置技巧:

代理:

  rewrites: async () => {
    if (process.env.NODE_ENV !== "development") {
      console.log("Not in development mode, skipping rewrites");
      return [];
    }
    return [
      {
        source: "/api/:path*",
        destination: `${apiUrl}/:path*`,
      },
    ];
  },

禁用图片优化:

  images: {
    unoptimized: true, // 禁用优化
  },

构建禁用ts和eslint检查:

  eslint: {
    ignoreDuringBuilds: true,
  },
  typescript: {
    ignoreBuildErrors: true,
  },

构建是否需要后端:

  output: "standalone", // 不需要后端,就用 export 静态页面即可, 需要后端,就standalone

前端【数据类型】 No.1 Javascript的数据类型与区别

JavaScript 有哪些数据类型

JavaScript 有两种主要的数据类型分类: 原始类型和对象类型
原始类型是 JavaScript 中最基本的数据类型,它们是不可变的,按值存储。
原始类型有Number(数字)、String(字符串)、Boolean(布尔)、Undefined(未定义)、Null(空值)、Symbol(符号类型)、BigInt(大整数类型)

其中 Symbol 和 BigInt 是ES6 中新增的数据类型:
Symbol 代表创建后独一无二且不可变的数据类型,它主要是为了解决可能出现的全局变量冲突的问题。

let symbol1 = Symbol('test');
let symbol2 = Symbol('test');
console.log(symbol1 === symbol2); // false,每个 Symbol 都是唯一的

BigInt 是一种数字类型的数据,它可以表示任意精度格式的整数,使用 BigInt 可以安全地存储和操作大整数, 即使这个数已经超出了 Number 能够表示的安全整数范围。

对象类型是复杂的数据类型,可以包含多个值或方法。它们是可变的,按引用存储。 常见的对象类型有:Object、Array、Function、Data、RegExp

主要区别

1.存储方式:存储位置的不同

  • 原始类型 :按值存储在栈内存中
  • 对象类型 :按引用存储,实际对象在堆内存中,变量存储的是引用地址

2. 可变性

  • 原始类型 :不可变
  • 对象类型 :可变

3. 比较方式

  • 原始类型 :按值比较
  • 对象类型 :按引用比较
// 原始类型比较值
let a = 10;
let b = 10;
console.log(a === b); // true

// 对象类型比较引用
let obj1 = { name: "张三" };
let obj2 = { name: "张三" };
console.log(obj1 === obj2); // false,不同的对象实例

let obj3 = obj1;
console.log(obj1 === obj3); // true,相同的引用

4. 传递方式:

  • 原始类型 :按值传递
  • 对象类型 :按引用传递
// 原始类型按值传递
function changeValue(val) {
  val = 100;
}
let num = 50;
changeValue(num);
console.log(num); // 50,原始值未改变

// 对象类型按引用传递
function changeObject(obj) {
  obj.name = "李四";
}
let person = { name: "张三" };
changeObject(person);
console.log(person.name); // "李四",对象被修改了

5. 类型检测

// typeof 用于原始类型
console.log(typeof 123);        // "number"
console.log(typeof "hello");    // "string"
console.log(typeof true);       // "boolean"
console.log(typeof undefined);  // "undefined"
console.log(typeof Symbol());   // "symbol"
console.log(typeof BigInt(123)); // "bigint"

// typeof 对于 null 和对象的特殊情况
console.log(typeof null);       // "object" (这是 JavaScript 的一个历史遗留问题)
console.log(typeof {});         // "object"
console.log(typeof []);         // "object"

// 更准确的对象类型检测
console.log(Object.prototype.toString.call(null));     // "[object Null]"
console.log(Object.prototype.toString.call([]));       // "[object Array]"
console.log(Array.isArray([]));                        // true

在操作系统中,内存被分为栈区和堆区:
栈区内存由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。 堆区内存一般由开发着分配释放,若开发者不释放,程序结束时可能由垃圾回收机制回收。

❌