普通视图

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

mitt 跨多层组件甚至兄弟组件通信

2025年11月21日 09:21

事件总线 mitt(适合跨层、跨组件传递)

1. 安装 mitt

npm install mitt

2. 建立 eventBus(src/utils/bus.js)

import mitt from 'mitt'
export const bus = mitt()

3. 发送事件

import { bus } from '@/utils/bus'

const handleClick = (row) => {
  bus.emit('row-click', row)
}

4. 接收事件

import { bus } from '@/utils/bus'

bus.on('row-click', (row) => {
  console.log('收到点击数据:', row)
})

芋道实战|34k开源项目的登录功能拆解

作者 三只萌新
2025年11月21日 08:33

今天咱们就扒一扒34k星开源项目「芋道源码」的登录模块,从核心代码到底层原理,一步步拆明白它是怎么实现“安全又优雅”的登录流程的。

为什么选它

  1. 34.1k Star,社区体量足够,遇到问题能搜到现成讨论。
  2. 官方把 RBAC、多租户、工作流、支付、短信、商城等模块做成可插拔 starter,不必自己补业务场景,直接调试即可。
  3. 同一套后端接口同时供给 Vue3 管理端 + UniApp 小程序,前端部分可一次性验证 PC 与移动端,减少重复对接。

技术栈:Spring Boot、MyBatis Plus、Vue3、Element Plus、UniApp。

一、登录核心流程:3步走通账号密码登录 🔑

芋道的登录主方法把复杂流程拆成了清晰的三步,就像组装乐高一样,每一步职责明确,既好维护又方便调试。先看整体骨架:

/**
 * Auth Service 实现类
 * @author 芋道源码
 */
@Service
@Slf4j
public class AdminAuthServiceImpl implements AdminAuthService {
    /**
     * 登录主方法:完整处理账号密码登录流程
     * @param reqVO 登录请求(含用户名、密码、验证码)
     * @return 登录响应(含Token等核心信息)
     */
    @Override
    @DataPermission(enable = false)
    public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
        // 步骤1:先验验证码,防机器暴力破解 🛡️
        validateCaptcha(reqVO);

        // 步骤2:账号密码校验,核心认证环节
        AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());

        // 步骤3:生成Token+记日志,给前端返回结果
        return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
    }
}

下面咱们逐个拆解,看看每个环节里都藏着哪些细节。

1. 步骤1:验证码校验,第一道防护墙 🛡️

验证码的作用不用多说——挡住机器人批量试密码。但芋道的实现很灵活,支持通过配置开关控制是否启用,方便测试环境跳过验证。

/**
 * 验证码功能开关:从配置文件读取,默认开启
 * @Value:Spring注解,读配置项yudao.captcha.enable,没配置就用true
 * @Setter:Lombok生成setter,方便测试时临时关验证码
 */
@Value("${yudao.captcha.enable:true}")
@Setter 
private Boolean captchaEnable;

// 验证码校验核心方法(测试可见)
@VisibleForTesting
void validateCaptcha(AuthLoginReqVO reqVO) {
    ResponseModel response = doValidateCaptcha(reqVO);
    // 验证码不对?直接抛异常+记失败日志
    if (!response.isSuccess()) {
        createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
        throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
    }
}

// 实际执行校验的私有方法
private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {
    // 开关关了就直接通过,太贴心了有没有 😭
    if (!captchaEnable) {
        return ResponseModel.success();
    }
    // 校验请求参数合法性,再调用验证码服务验证
    ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
    CaptchaVO captchaVO = new CaptchaVO();
    captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
    return captchaService.verification(captchaVO);
}

小细节:用@VisibleForTesting注解让测试类能访问这个方法,方便写单元测试;开关变量加@Setter,测试时不用改配置就能关验证码,开发体验拉满!

2. 步骤2:账号密码认证,核心校验环节 🔍

这一步是登录的核心——要确认“你是谁”“你有权限登录吗”。芋道在这里做了三层校验,还加了安全细节(比如统一错误提示,防止泄露用户是否存在)。

// 注入依赖:密码编码器(Spring Security提供)和用户服务
private final PasswordEncoder passwordEncoder;
private final AdminUserService userService;

// 构造器注入:推荐方式,避免循环依赖问题
public AdminAuthServiceImpl(PasswordEncoder passwordEncoder, AdminUserService userService) {
    this.passwordEncoder = passwordEncoder;
    this.userService = userService;
}

/**
 * 核心认证方法:校验用户存在性、密码正确性、账号状态
 */
@Override
public AdminUserDO authenticate(String username, String password) {
    final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;

    // 校验1:用户存在吗?查不到就记日志抛异常
    AdminUserDO user = userService.getUserByUsername(username);
    if (user == null) {
        createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
        // 统一提示“凭证错误”,不泄露“用户不存在”,安全细节拉满 🔒
        throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
    }

    // 校验2:密码对吗?用Spring Security的编码器比对
    if (!isPasswordMatch(password, user.getPassword())) {
        createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
        throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
    }

    // 校验3:账号被禁用了吗?
    if (CommonStatusEnum.isDisable(user.getStatus())) {
        createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
        throw exception(AUTH_LOGIN_USER_DISABLED);
    }

    // 所有校验通过,返回用户信息
    return user;
}

// 密码比对:明文密码 vs 数据库加密密码
@Override
public boolean isPasswordMatch(String rawPassword, String encodedPassword) {
    // 编码器内部会用加密时的盐值重新加密明文,再对比
    return passwordEncoder.matches(rawPassword, encodedPassword);
}

3. 步骤3:生成Token,登录成功的“通行证” 🎫

验证通过后,就得给用户发“通行证”——Token。同时还要记录登录日志,方便后续排查问题。这一步的核心是调用Token服务生成凭证。

/**
 * 登录成功后:生成Token+记日志+构造响应
 */
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
    // 记录成功日志,运营排查问题全靠它
    createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
    // 调用OAuth2服务生成访问令牌
    OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
            OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
    // 转换为前端需要的响应格式
    return AuthConvert.INSTANCE.convert(accessTokenDO);
}

二、Token生成深扒:OAuth2.0简化模式实战 🚀

Token是用户登录后的“身份凭证”,芋道用了OAuth2.0的简化模式实现,核心是生成“访问令牌+刷新令牌”双凭证,既安全又灵活。整个流程分5步:

① 接收参数 → ② 校验客户端 → ③ 创建刷新令牌 → ④ 创建访问令牌 → ⑤ 返回结果

1. 入口方法:串联Token生成全流程

// 位于OAuth2TokenServiceImpl.java,事务注解保证原子性
@Override
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType,
                                             String clientId, List<String> scopes) {
    // ② 校验客户端:必须存在、启用、权限合法(核心校验)
    OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
    // ③ 创建刷新令牌(有效期长,用于换访问令牌)
    OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
    // ④ 创建访问令牌(有效期短,用于接口调用)
    return createOAuth2AccessToken(refreshTokenDO, clientDO);
}

2. 客户端校验:确保请求来源合法 🔐

客户端就像“请求的身份证”,必须校验它是不是系统认可的。这里还用了缓存加速,避免每次都查数据库。

// 位于OAuth2ClientServiceImpl.java,客户端校验核心方法
public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
                                                String grantType, Collection<String> scopes, String redirectUri) {
    // 先查缓存,没命中再查DB(缓存逻辑后面讲)
    OAuth2ClientDO client = getOAuth2ClientFromCache(clientId);
    if (client == null) throw exception(OAUTH2_CLIENT_NOT_EXISTS); // 客户端不存在
    if (CommonStatusEnum.isDisable(client.getStatus())) throw exception(OAUTH2_CLIENT_DISABLE); // 客户端已禁用
    if (!client.getScopes().containsAll(scopes)) throw exception(OAUTH2_CLIENT_SCOPE_OVER); // 权限越界
    // 其余4项校验省略...
    return client;
}

3. 双令牌生成:安全与便捷的平衡 🎯

为什么要搞“访问令牌+刷新令牌”?因为访问令牌有效期短(比如2小时),泄露风险低;刷新令牌有效期长(比如7天),用来在访问令牌过期后“免登录续期”,用户体验好。

image.png

刷新令牌生成

private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType,
                                                      OAuth2ClientDO client, List<String> scopes) {
    OAuth2RefreshTokenDO entity = new OAuth2RefreshTokenDO();
    entity.setId(generateId());          // 雪花ID,唯一标识
    entity.setRefreshToken(randomUUID());// 随机串,安全不易猜
    entity.setUserId(userId);            // 关联用户ID
    entity.setUserType(userType);        // 用户类型
    entity.setClientId(client.getClientId()); // 关联客户端
    entity.setScopes(scopes);            // 权限范围
    // 过期时间:从客户端配置读取(比如7天)
    entity.setExpiresTime(LocalDateTime.now()
                               .plusSeconds(client.getRefreshTokenValiditySeconds()));
    oauth2RefreshTokenMapper.insert(entity); // 存DB+缓存
    return entity;
}

访问令牌生成

private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshToken,
                                                   OAuth2ClientDO client) {
    OAuth2AccessTokenDO entity = new OAuth2AccessTokenDO();
    entity.setId(generateId());
    entity.setAccessToken(randomUUID()); // 访问令牌随机串
    entity.setRefreshTokenId(refreshToken.getId()); // 关联刷新令牌
    entity.setClientId(client.getClientId());
    // 过期时间:客户端配置(比如2小时)
    entity.setExpiresTime(LocalDateTime.now()
                               .plusSeconds(client.getAccessTokenValiditySeconds()));
    oauth2AccessTokenMapper.insert(entity); // 存DB+缓存
    return entity; // 最终返回给前端
}

三、相关知识点 🛠️

除了核心流程,异常处理、缓存、依赖注入这些“底层基建”,才是让登录功能稳定又好维护的关键。

1. 异常处理:用户和开发都省心 😌

登录过程中会遇到各种错误:密码错、验证码错、服务器崩了... 芋道用“自定义异常+全局拦截”让错误处理更优雅。

为什么要自定义异常?

JDK自带的异常(比如NullPointerException)分不清是“用户操作错”还是“系统出故障”。芋道把异常分成两类:

  • ServiceException(业务异常) :用户能理解的错误,比如“密码错误”,带错误码和提示,前端直接展示给用户。
  • ServerException(系统异常) :服务器故障,比如数据库连不上,包装原始异常方便排查,给用户看“系统繁忙”即可。

全局异常拦截:一次配置,处处生效

用Spring的@RestControllerAdvice注解做全局拦截,不用在每个方法里写try-catch,代码超干净!

@RestControllerAdvice // 全局异常处理器
public class GlobalExceptionHandler {
    // 处理业务异常(比如密码错误)
    @ExceptionHandler(ServiceException.class)
    public CommonResult<?> handleBiz(ServiceException ex) {
        log.warn("业务异常: {}", ex.getMessage()); // 只记警告,不打堆栈
        return CommonResult.error(ex.getCode(), ex.getMessage());
    }

    // 处理系统异常(比如DB连接失败)
    @ExceptionHandler(ServerException.class)
    public CommonResult<?> handleSys(ServerException ex) {
        log.error("系统异常", ex); // 打完整堆栈,方便排查
        return CommonResult.error(ex.getCode(), "系统繁忙,请稍后再试");
    }

    // 兜底处理其他未捕获异常
    @ExceptionHandler(Exception.class)
    public CommonResult<?> handleOther(Exception ex) {
        log.error("未捕获异常", ex);
        return CommonResult.error(500999, "系统异常");
    }
}

前端永远收到结构一致的响应:{code, msg, data},不用处理各种乱七八糟的错误格式,前后端协作效率翻倍!

2. 缓存:让登录更快,DB压力更小 ⚡

客户端信息、用户信息这些高频访问的数据,每次都查DB太慢了。芋道用Redis做缓存,一行注解就实现“查缓存→没命中查DB→存缓存”的全流程。

第一步:加依赖+配Redis

# application.yml配置
spring:
  cache:
    type: redis                  # 用Redis做缓存
    redis:
      time-to-live: 1800s        # 默认30分钟过期
      cache-null-values: false   # 不缓存null,防穿透
  redis:
    host: localhost
    port: 6379
    password:                    # 无密码留空

第二步:加个注解,缓存自动生效

// 查客户端信息的方法,加@Cacheable注解即可
@Override
@Cacheable(cacheNames = RedisKeyConstants.OAUTH_CLIENT, key = "#clientId",
        unless = "#result == null") // 返回null不缓存,防穿透
public OAuth2ClientDO getOAuth2ClientFromCache(String clientId) {
    // 第一次调用:查DB;后续调用:直接从Redis拿,方法体都不执行
    return oauth2ClientMapper.selectById(clientId);
}

缓存执行流程,一目了然

  1. 第一次调用getOAuth2ClientFromCache("default") → 查Redis没命中 → 查DB → 结果存Redis(设30分钟过期)→ 返回数据。
  2. 第二次调用 → 查Redis命中 → 直接返回数据 → 方法体不执行(没SQL输出)。
  3. 返回null时 → unless条件生效 → 不存缓存,避免缓存穿透攻击。

3. Spring依赖注入:不用自己new对象

登录功能里用到了UserService、PasswordEncoder等各种服务,要是每次都自己new,代码会乱成一团。Spring的依赖注入帮我们搞定了“对象创建+管理+赋值”的全流程。

常用注入方式

@Service
public class DemoService {
    // 1. @Resource:按名字注入(适合多实现)
    @Resource(name = "customValidator")
    private Validator validator;

    // 2. @Autowired:按类型注入(适合唯一实现)
    @Autowired
    private MailService mailService;

    // 3. @Autowired+@Qualifier:按名字+类型(多实现时精准匹配)
    @Autowired
    @Qualifier("smsSender")
    private Sender sender;

    // 4. 构造器注入:推荐!避免循环依赖,代码更健壮
    private final UserDao userDao;
    public DemoService(UserDao userDao) {
        this.userDao = userDao;
    }
}

注入核心原理

Spring启动时会扫描带@Service、@Component等注解的类,用反射创建对象(这就是Bean),然后把这些Bean“塞”到需要的地方。简单说:你只管要,Spring负责给,不用自己new!

4. YAML配置:环境切换超灵活 🔄

开发环境要关验证码,生产环境要开;开发环境连本地Redis,生产环境连云Redis——这些都靠YAML配置实现,不用改代码就能切换环境。

主配置+环境配置,优雅切换

# 主配置 application.yaml
spring:
  profiles:
    active: local # 激活本地环境配置

yudao:
  captcha:
    enable: true # 全局默认开验证码
# 本地环境配置 application-local.yaml
# 覆盖主配置,本地关验证码
yudao:
  captcha:
    enable: false

# 本地Redis配置
spring:
  redis:
    host: localhost
    port: 6379
// 代码里用@Value读配置,自动适配当前环境
@Value("${yudao.captcha.enable:true}")
private Boolean captchaEnable; // 本地环境是false,生产环境是true

四、总结:芋道登录功能的优秀设计思路 🎯

把芋道的登录功能拆完,不得不说它的设计太值得学习了:

  • 流程清晰:登录拆成“验验证码→认证账号→生成Token”,每个步骤职责单一,好维护。
  • 安全优先:统一错误提示防信息泄露、双Token机制降低风险、密码加密存储。
  • 开发友好:验证码开关、缓存注解、全局异常处理,各种细节提升开发效率。
  • 可扩展性强:基于OAuth2.0,后续加第三方登录(微信、QQ)也方便。

登录功能虽然小,但藏着系统设计的大逻辑。希望这篇拆解能帮你吃透芋道的实战思路,下次自己写登录功能时,也能做到“安全、优雅、好维护”! 🚀

科技爱好者周刊(第 374 期):6GHz 的问题

作者 阮一峰
2025年11月21日 08:10

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

香港湾仔新建成的"水上运动及康乐主题区",是维多利亚港首个没有栏杆的堤岸,游人可拾级而下亲近海水。(via

6GHz 的问题

本周的新闻,欧洲做出决定,6GHz 怎么分配。

欧洲把 6GHz 一分为二,较低的频段给 WiFi 使用,较高的频段留给手机通信。

这跟美国和中国都不一样,美国把整个 6GHz 分配给 WiFi,中国则是全部分配给手机通信。

我来说说,对于这个新闻的感想。

对于不了解的朋友,我先说说 6GHz 是怎么回事。

家庭的无线局域网(WiFi)只能使用固定频率的信号。最早的频率是 2.4GHz,所有设备都用这个频率,就造成了信道拥挤、信号不稳定。

后来,增加了 5GHz。但是这个频率现在也不太够用,在大城市的高层住宅,打开手机,能搜到几十个无线网络。那么多设备都用这个频率,通信就很拥挤了。

大家就想到,再给 WiFi 增加一个频段,目光就瞄准了 6GHz。这个频段还没有指定用途。

如果 6GHz 用作 WiFi,最大的好处就是不会发生拥堵。因为它的波长短,所以穿墙能力差,实际上不能穿墙。也就是说,你在屋里只能连上你自己的 6GHz 信号,别处的信号传不进来。

而且,它的带宽大,网速更快,可以打造高速 WiFi,适合 VR 头盔这类吃带宽的设备。

但是,问题就来了,6GHz 除了用作 WiFi,还可以用作手机通信。手机通信的频段能够供大量人群同时使用,比只供一家人使用的 WiFi,频段利用效率更高,公共效益更大。

前面说了,中国的决定是,整个 6GHz 都留给手机通信,也就是说 WiFi 不能使用这个频段。

所以,有些追求高网速的国内用户,就会去买国外的无线路由器,以及支持 6GHz 的硬件(比如苹果设备),实现家庭的高速 WiFi。

我的想法是,WiFi 只有 2.4GHz 和 5GHz 确实不太够,如果能增加一个高速频段就很好,不仅满足大带宽通信,还能促进设备升级,带动消费。

6GHz 的完整频段是 5925MHz 到 7125MHz,听过国内明确留给手机通信的是 6425MHz 到 7125MHz 这一段,至于剩下的 5925Mhz 到 6425MHz 怎么分配还没明文规定(参见百度百科)。

如果是真的,是否可以考虑放出 5925Mhz 到 6425MHz 这一段,就像美国的规定,任何人无需许可就能使用这个频率。这样的话,个人和企业就有了一个可以自由使用的高速通信频率,为更多的创新创造条件。

科技动态

1、一个光日

1977年9月5日,美国发射宇宙飞船"旅行者1号"。它是目前飞行距离最远的飞行器,已经飞离了太阳系。

根据计算,2026年11月13日,它将距离地球"1光日"(光在一天内传播的距离),成为首个达到这个距离的人造飞行器。

届时,旅行者1号将距离地球259亿公里,这段距离光只需要1天,它耗时近50年。

在当前位置,地球的指令到达它需要23小时29分27秒,过了1光日,就要第二天才能收到。

科学家预计,再过300年,旅行者1号就会进入太阳系旁边的奥尔特云团,穿过该云团需要大约30000年。

2、输电铁塔

奥地利正在改造输电铁塔,让其变得更美观,更像景观。

上图是鹳,另一个已经落成的设计是雄鹿(下图)。

奥地利电网公司打算一共设计9种动物形状的铁塔,象征奥地利的9个州,目前已经完成了两个。

许多人都反对,在村庄旁边修建输电塔。电网公司希望,这些具有视觉吸引力的铁塔,可以让人们更容易接受它。

3、在线会议的 AI 化妆

Google Meet 推出 AI 化妆按钮,帮你在线上会议"虚拟化妆"。

上图右侧是可选择的12种妆容,左侧窗口是预览画画,也就是别人看到的你的样子,数字化妆保证你看上去"光彩照人"。

以后,颜值滤镜将是视频通话软件的标配。

4、钥匙扣相机

柯达公司推出了一扣挂在钥匙扣上的相机,而且样子很复古。

这款相机很小,重量仅30克,但是功能齐全,配有取景器、LCD 屏幕、Type-C 端口、闪光灯、microSD 插槽。

它的画质不行,传感器只有 1/4 英寸,只能拍摄 1,440 x 1,440 的 JPEG 照片。

但是,它的价格只有30美元,加上造型不错,还是有很多人愿意买单。目前,它在柯达官网出售,显示缺货。

文章

1、中国 AI 模型是纸老虎(英文)

一个美国人的文章,批评中国 AI 模型不如看上去那样好。

我认为,他的观点太偏颇,很多论据站不住脚,读上去酸溜溜,但是可以作为参考。

2、WhatsApp 现在使用 WebView(英文)

通信软件 WhatsApp 的 Windows 版,原先是一个原生桌面应用。

作者震惊地发现,它的新版本居然退回了 WebView,成为网页版的包装器,性能急剧下降,内存占用 1GB。原因可能是 Meta 公司裁掉了 Windows 版的开发团队。

3、Vibe Coding 面试感受(中文)

作者团队的面试,开始改为让应聘者用 AI 实现一个功能,作者谈了实施的感受。(@thuwyh 投稿)

4、本地运行 AI 模型的方法(英文)

本文介绍在本地计算机运行 AI 模型的几种方法:LM StudioOllamaLMStudio

5、我们在 Zed 里面办公(英文)

Zed 是一个全新的代码编辑器,正在密集开发。除了文档编辑以外,Zed 团队也用它来开会和讨论,它内置了讨论区和实时协作。

6、中级程序员的标志(英文)

今年是作者从事专业编程的第十年,他认为自己属于中级程序员,总结了自己的工作内容。

他说,做到了这些事,你就达到了中级程序员的标准。

工具

1、LibrePods

在非苹果设备上(比如安卓手机和 Linux),使用 AirPods 耳机的工具。

2、IDEmacs

将 Emacs 配置成 VS Code 样式的一套配置。

3、Kratos

开源的身份认证服务器,支持多种认证方式,可以替代 Auth0 和 Okta。

4、Biu

一个开源的跨平台桌面应用,基于 API 来搜索和播放 Bilibili 平台的音乐,支持登录获取收藏夹歌曲。(@wood3n 投稿)

5、Enjoy Git

中文的 Git 桌面图形客户端,暂时只有 Windows 版。(@huangcs427 投稿)

6、Readdig

开源 RSS 阅读和 Podcasts 播放网站。(@copilot-is 投稿)

7、Tiny SVG

开源的网页版 SVG 压缩,可以在线试用。(@mutou981 投稿)

8、fssh

苹果笔记本的 SSH 私钥保护器,登录服务器时直接指纹认证。(@Mister-leo 投稿)

9、CrossDesk

开源的远程桌面软件,跨平台,支持硬件加速和 Web 访问。(@kunkundi 投稿)

10、Git PR AI

一个命令行工具,跟 JIRA 配套,可以直接从 JIRA Ticket 生成 Git 分支,并带有 AI 功能。(@leochiu-a 投稿)

AI 相关

1、Antigravity

谷歌本周发布的 IDE 产品,用于 AI 编程,也是基于 VS Code。

2、Code Wiki

谷歌新发布的服务,使用 Gemini 模型为代码库生成文档。

3、Open CoreUI

使用 Rust 语言重写的 Open WebUI,降低了内存和资源消耗,有服务器版和桌面版。(@xxnuo 投稿)

4、Continuous Claude

一个命令行工具,可以对同一个任务循环运行 Claude Code,允许指定运行次数。

资源

1、随机性测试指南(英文)

这个网站给出一系列方法,测试某种随机数生成器是否足够随机,所有测试方法都有详细易懂的解释,可以用来学习统计学。

2、强化学习的数学基础(Mathematical Foundations of Reinforcement Learning)

开源的英文电子书,介绍强化学习的基础数学知识。

3、Erlang 初学者教程(learn you some Erlang)

Erlang 是一种函数式语言,适合分布式、高可用环境。这个网站是面向初学者的英文教程。

图片

1、

一家巴基斯坦报纸,不慎将 AI 的对话跟着文章一起发表了。

上图文章结尾的红框处,写着:"如果您愿意,我还可以生成一个更醒目的'首页风格'版本,配以简洁有力的单行统计数据和醒目、信息图表式的布局----完美契合最多读者需求。您希望我接下来生成这个吗?"(If you want, I can also create an even snappier "front-page style" version with punchy one-line stats and a bold, infographic-ready layout -- perfect for maximum reader impact. Do you want me to do that next?)

如果报纸都用 AI 写稿,读者是否还有必要订阅?

1、神秘的土坑带

秘鲁南部的一个山谷,有着一条长长的土坑带,整齐地排列着5000多个土坑,非常神秘。

这明显是人工的,但是没有任何记载,不知道是谁修建的?有什么用途?

上图中间的一长条,都是土坑。

考古学家在土坑中发现了玉米花粉和芦苇。玉米是古代这个地区的主粮,而芦苇可以用来编织篮子。

因此人们猜测,这里是印加帝国的一个大型集市,这些土坑用来存放货物。

文摘

1、世界第一个 App 商店

世界第一个 App 商店,出现在上个世纪80年代的日本,方便用户付费购买软件。

它采用自动售货机的形式,因此也是世界第一台以数字形式出售软件的自动售货机。

1986年的时候,软件都是以磁盘形式出售。一个软件通常就是几百 KB,正好放到一张磁盘里面。

上图左下角就是当时的磁盘。

需要新软件的时候,人们往往删除磁盘的旧数据,拿来拷贝。一家日本公司由此想到,可以制造一种机器,让人们插入磁盘,把选择的软件拷贝在上面。

上面就是这家公司造出来的软件自动售货机。

你把磁盘插入机器,在屏幕上选择自己想要的软件,支付费用后,机器自动把软件拷贝到磁盘上,然后你就可以带着软件回家。

如果软件附带手册,它还会把手册打印出来给你。

这在当时是一项革命性的发明,一经推出就轰动了市场。当时还没有互联网,购买软件都要去实体商店,有了这种机器,你在街角就可以购买软件。

这种机器的致命伤在于,它内部的硬盘不够大,只能储存最热门的几种软件(大部分是游戏)。如果用户想购买其他软件,就必须等这台机器去远程下载。

当时采用电话线拨号下载,网速只有每秒约 1.2 KB,一个游戏的下载时间有时达到20分钟。这段时间内,用户只能在机器旁边等着,其他人也不能使用这台机器。如果下载中途断线,就必须从头来过。

为了解决这个问题,这家公司让机器每晚自动下载最新游戏。但还是不能完全避免用户的等待。

最高峰时,这种机器在日本全国一共安装了300多台。直到1997年,才完全退出历史舞台。

言论

1、

我希望让机器人坐在自动驾驶的出租车里运送包裹。

出租车自动驾驶到达目的地后,机器人负责搬运货物到门口。

-- 马斯克谈对于 Optimus 机器人的发展愿景

2、

亚洲常见的一种攻击方法是,诈骗分子打电话给受害者,冒充银行员工,警告受害者账户已被盗用,并指示他们安装一个应用程序来保护资金安全。

诈骗分子还会蒙骗受害者,让他们在安装应用程序的过程中忽略安全警告。这个应用是伪装成合法应用的恶意软件,会窃取受害者的登录信息,并拦截访问银行账户所需的双因素验证码。

-- 谷歌用这个案例解释,为什么需要实施"安卓开发者认证计划"

3、

既然 AI 可以按需提供你的代码所需的特定功能,为什么还要增加额外的供应链风险,引入另一个依赖项呢?

因此,小型的、低价值的依赖项在未来会消失。

-- 《"小型"开源软件的命运 》

4、

基因疗法可能实现一次治愈病人,这对于公司的持续收入很不利。

相比慢性疗法,治愈病人是一种可持续的商业模式吗?

-- 高盛公司的一份研究报告

5、

去年,互联网上机器人流量第一次超过了人类流量。根据一份报告,自动化系统在2024年占所有网络流量的51%,而且 AI 生成的文章数量也在2024年底首次超过了人类撰写的文章。

-- 《互联网已死》

往年回顾

没有链接的互联网(#327)

工作台副屏的最佳选择(#277)

脸书的公司入职教育(#227)

iPad 的真正用途(#177)

(完)

文档信息

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

为什么你的Vue组件总出bug?可能是少了这份测试指南

2025年11月21日 07:22

你是不是也遇到过这样的场景?新功能上线前一切正常,刚发布就接到用户反馈说页面白屏了。排查半天发现,原来是因为某个组件在特定条件下没有正确处理数据。

更让人头疼的是,每次修改代码都提心吊胆,生怕一不小心就把之前好用的功能搞坏了。这种“拆东墙补西墙”的开发体验,相信不少前端开发者都深有体会。

今天我要跟你分享的,就是如何用Jest和Vue Test Utils为你的Vue组件建立坚实的测试防线。通过这篇文章,你将学会从零开始搭建测试环境,编写有效的测试用例,最终打造出更稳定、可维护的Vue应用。

为什么要给Vue组件写测试?

想象一下,你正在开发一个电商网站的商品详情页。里面有个“加入购物车”按钮组件,逻辑相当复杂:要校验库存、处理用户选项、调用接口等等。

如果没有测试,每次修改这个组件时,你都得手动把各种情况都点一遍:库存为零时按钮要不要禁用?用户选了不支持的组合怎么办?网络请求失败要怎么处理?

而有了自动化测试,你只需要运行一个命令,几秒钟内就能知道这次修改有没有破坏现有功能。这就像是给你的代码买了份保险,让你能放心重构、安心上线。

测试还能起到文档的作用。新同事接手项目时,通过阅读测试用例,能快速理解每个组件在各种场景下应该怎么工作。

测试环境搭建:从零开始配置

现在让我们动手搭建测试环境。假设你正在启动一个新的Vue 3项目,我会带你一步步配置所需的测试工具。

首先创建项目并安装依赖:

// 创建Vue项目
npm create vue@latest my-vue-app

// 进入项目目录
cd my-vue-app

// 安装测试相关的依赖
npm install --save-dev jest @vue/test-utils jest-environment-jsdom

接下来创建Jest配置文件:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['js', 'json', 'vue'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '^.+\\.vue$': '@vue/vue3-jest'
  },
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  testMatch: ['**/__tests__/**/*.spec.js']
}

在package.json中添加测试脚本:

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

现在运行npm test,你应该能看到测试环境正常工作。如果一切顺利,恭喜你,测试环境已经准备就绪!

第一个测试用例:按钮组件实战

让我们从一个简单的按钮组件开始。假设我们有一个基础的按钮组件,它可以根据传入的type属性显示不同的样式。

首先创建按钮组件:

// src/components/BaseButton.vue
<template>
  <button 
    :class="['btn', `btn-${type}`]"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script setup>
defineProps({
  type: {
    type: String,
    default: 'default'
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event)
  }
}
</script>

<style scoped>
.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-default {
  background: #f0f0f0;
  color: #333;
}

.btn-primary {
  background: #007bff;
  color: white;
}

.btn-danger {
  background: #dc3545;
  color: white;
}

.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

现在为这个组件编写测试:

// src/components/__tests__/BaseButton.spec.js
import { mount } from '@vue/test-utils'
import BaseButton from '../BaseButton.vue'

// 描述我们要测试的组件
describe('BaseButton', () => {
  // 测试默认渲染
  it('渲染正确的默认样式', () => {
    // 挂载组件
    const wrapper = mount(BaseButton, {
      slots: {
        default: '点击我'
      }
    })
    
    // 断言:按钮应该包含默认的CSS类
    expect(wrapper.classes()).toContain('btn')
    expect(wrapper.classes()).toContain('btn-default')
    
    // 断言:按钮文本内容正确
    expect(wrapper.text()).toBe('点击我')
    
    // 断言:按钮默认不是禁用状态
    expect(wrapper.attributes('disabled')).toBeUndefined()
  })
  
  // 测试不同类型
  it('根据type属性渲染不同样式', () => {
    const types = ['primary', 'danger']
    
    types.forEach(type => {
      const wrapper = mount(BaseButton, {
        props: { type },
        slots: { default: '测试按钮' }
      })
      
      // 断言:按钮应该包含对应类型的CSS类
      expect(wrapper.classes()).toContain(`btn-${type}`)
    })
  })
  
  // 测试禁用状态
  it('禁用状态下不能点击', () => {
    const wrapper = mount(BaseButton, {
      props: { disabled: true },
      slots: { default: '禁用按钮' }
    })
    
    // 断言:按钮应该有disabled属性
    expect(wrapper.attributes('disabled')).toBe('')
    
    // 断言:按钮应该包含禁用样式类
    expect(wrapper.classes()).toContain('btn')
  })
  
  // 测试点击事件
  it('点击时触发事件', async () => {
    const wrapper = mount(BaseButton, {
      slots: { default: '可点击按钮' }
    })
    
    // 模拟点击按钮
    await wrapper.trigger('click')
    
    // 断言:应该触发了click事件
    expect(wrapper.emitted('click')).toHaveLength(1)
  })
  
  // 测试禁用状态下不触发点击
  it('禁用状态下不触发点击事件', async () => {
    const wrapper = mount(BaseButton, {
      props: { disabled: true },
      slots: { default: '禁用按钮' }
    })
    
    // 模拟点击按钮
    await wrapper.trigger('click')
    
    // 断言:不应该触发click事件
    expect(wrapper.emitted('click')).toBeUndefined()
  })
})

运行npm test,你应该能看到所有测试都通过了。这就是你的第一个Vue组件测试!

测试复杂组件:表单验证实战

现在我们来处理更复杂的场景——一个带验证功能的登录表单。这个组件会涉及用户输入、异步操作和复杂的交互逻辑。

先创建登录表单组件:

// src/components/LoginForm.vue
<template>
  <form @submit.prevent="handleSubmit" class="login-form">
    <div class="form-group">
      <label for="email">邮箱</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        :class="['form-input', { 'error': errors.email }]"
        @blur="validateField('email')"
      />
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <label for="password">密码</label>
      <input
        id="password"
        v-model="form.password"
        type="password"
        :class="['form-input', { 'error': errors.password }]"
        @blur="validateField('password')"
      />
      <span v-if="errors.password" class="error-message">{{ errors.password }}</span>
    </div>
    
    <BaseButton 
      type="primary" 
      :disabled="!isFormValid || loading"
      class="submit-btn"
    >
      {{ loading ? '登录中...' : '登录' }}
    </BaseButton>
    
    <div v-if="submitError" class="submit-error">
      {{ submitError }}
    </div>
  </form>
</template>

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

// 表单数据
const form = reactive({
  email: '',
  password: ''
})

// 错误信息
const errors = reactive({
  email: '',
  password: ''
})

// 加载状态和提交错误
const loading = ref(false)
const submitError = ref('')

// 计算表单是否有效
const isFormValid = computed(() => {
  return form.email && form.password && !errors.email && !errors.password
})

// 字段验证规则
const validationRules = {
  email: (value) => {
    if (!value) return '邮箱不能为空'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确'
    return ''
  },
  password: (value) => {
    if (!value) return '密码不能为空'
    if (value.length < 6) return '密码至少6位'
    return ''
  }
}

// 验证单个字段
const validateField = (fieldName) => {
  const value = form[fieldName]
  errors[fieldName] = validationRules[fieldName](value)
}

// 提交表单
const emit = defineEmits(['success'])

const handleSubmit = async () => {
  // 验证所有字段
  Object.keys(form).forEach(field => validateField(field))
  
  // 如果有错误就不提交
  if (Object.values(errors).some(error => error)) return
  
  loading.value = true
  submitError.value = ''
  
  try {
    // 模拟API调用
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    // 模拟随机失败
    if (Math.random() > 0.5) {
      emit('success', { email: form.email })
    } else {
      throw new Error('登录失败,请检查邮箱和密码')
    }
  } catch (error) {
    submitError.value = error.message
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-form {
  max-width: 400px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 1rem;
}

.form-input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.form-input.error {
  border-color: #dc3545;
}

.error-message {
  color: #dc3545;
  font-size: 0.875rem;
}

.submit-btn {
  width: 100%;
  margin-top: 1rem;
}

.submit-error {
  margin-top: 1rem;
  padding: 8px;
  background: #f8d7da;
  color: #721c24;
  border-radius: 4px;
}
</style>

现在为这个复杂的表单组件编写测试:

// src/components/__tests__/LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from '../LoginForm.vue'

// 模拟定时器
jest.useFakeTimers()

describe('LoginForm', () => {
  // 测试初始状态
  it('初始渲染正确', () => {
    const wrapper = mount(LoginForm)
    
    // 断言:表单元素都存在
    expect(wrapper.find('input[type="email"]').exists()).toBe(true)
    expect(wrapper.find('input[type="password"]').exists()).toBe(true)
    expect(wrapper.find('button').exists()).toBe(true)
    
    // 断言:初始状态下按钮是禁用状态
    expect(wrapper.find('button').attributes('disabled')).toBe('')
    
    // 断言:没有错误信息
    expect(wrapper.find('.error-message').exists()).toBe(false)
    expect(wrapper.find('.submit-error').exists()).toBe(false)
  })
  
  // 测试表单验证
  it('验证邮箱格式', async () => {
    const wrapper = mount(LoginForm)
    const emailInput = wrapper.find('input[type="email"]')
    
    // 输入无效邮箱
    await emailInput.setValue('invalid-email')
    await emailInput.trigger('blur')
    
    // 断言:应该显示错误信息
    expect(wrapper.find('.error-message').text()).toContain('邮箱格式不正确')
    
    // 输入有效邮箱
    await emailInput.setValue('test@example.com')
    await emailInput.trigger('blur')
    
    // 断言:错误信息应该消失
    expect(wrapper.find('.error-message').exists()).toBe(false)
  })
  
  // 测试密码验证
  it('验证密码长度', async () => {
    const wrapper = mount(LoginForm)
    const passwordInput = wrapper.find('input[type="password"]')
    
    // 输入过短密码
    await passwordInput.setValue('123')
    await passwordInput.trigger('blur')
    
    // 断言:应该显示错误信息
    expect(wrapper.find('.error-message').text()).toContain('密码至少6位')
    
    // 输入有效密码
    await passwordInput.setValue('123456')
    await passwordInput.trigger('blur')
    
    // 断言:错误信息应该消失
    expect(wrapper.find('.error-message').exists()).toBe(false)
  })
  
  // 测试表单提交 - 成功情况
  it('成功提交表单', async () => {
    const wrapper = mount(LoginForm)
    
    // 填写有效表单
    await wrapper.find('input[type="email"]').setValue('test@example.com')
    await wrapper.find('input[type="password"]').setValue('123456')
    
    // 断言:按钮应该可用
    expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
    
    // 提交表单
    await wrapper.find('form').trigger('submit.prevent')
    
    // 快速推进定时器
    jest.advanceTimersByTime(1000)
    
    // 等待Vue更新
    await wrapper.vm.$nextTick()
    
    // 断言:应该触发了success事件
    expect(wrapper.emitted('success')).toBeTruthy()
    expect(wrapper.emitted('success')[0][0]).toEqual({
      email: 'test@example.com'
    })
  })
  
  // 测试表单提交 - 失败情况
  it('处理提交失败', async () => {
    // 模拟Math.random返回较小值以确保失败
    const mockMath = Object.create(global.Math)
    mockMath.random = () => 0.1
    global.Math = mockMath
    
    const wrapper = mount(LoginForm)
    
    // 填写有效表单
    await wrapper.find('input[type="email"]').setValue('test@example.com')
    await wrapper.find('input[type="password"]').setValue('123456')
    
    // 提交表单
    await wrapper.find('form').trigger('submit.prevent')
    
    // 快速推进定时器
    jest.advanceTimersByTime(1000)
    await wrapper.vm.$nextTick()
    
    // 断言:应该显示错误信息
    expect(wrapper.find('.submit-error').text()).toContain('登录失败')
    
    // 恢复原始Math对象
    global.Math = Object.getPrototypeOf(mockMath)
  })
  
  // 测试加载状态
  it('提交时显示加载状态', async () => {
    const wrapper = mount(LoginForm)
    
    // 填写有效表单
    await wrapper.find('input[type="email"]').setValue('test@example.com')
    await wrapper.find('input[type="password"]').setValue('123456')
    
    // 提交表单
    wrapper.find('form').trigger('submit.prevent')
    
    // 不需要等待定时器,立即检查加载状态
    await wrapper.vm.$nextTick()
    
    // 断言:按钮应该显示加载文本
    expect(wrapper.find('button').text()).toContain('登录中')
    
    // 断言:按钮应该是禁用状态
    expect(wrapper.find('button').attributes('disabled')).toBe('')
  })
})

这个测试套件覆盖了表单组件的各种场景:初始状态、字段验证、成功提交、失败处理和加载状态。通过这样的测试,你就能确保表单在各种情况下都能正常工作。

高级测试技巧:异步操作和Mock

在实际项目中,我们经常需要处理异步操作,比如API调用。这时候就需要用到Mock和更高级的测试技巧。

让我们看一个调用真实API的用户列表组件:

// src/components/UserList.vue
<template>
  <div class="user-list">
    <div v-if="loading" class="loading">加载中...</div>
    <div v-else-if="error" class="error">{{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id" class="user-item">
        <span>{{ user.name }}</span>
        <span>{{ user.email }}</span>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const users = ref([])
const loading = ref(false)
const error = ref('')

const fetchUsers = async () => {
  loading.value = true
  error.value = ''
  
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users')
    if (!response.ok) throw new Error('获取用户列表失败')
    users.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  fetchUsers()
})
</script>

<style scoped>
.loading, .error {
  padding: 1rem;
  text-align: center;
}

.user-item {
  display: flex;
  justify-content: space-between;
  padding: 0.5rem;
  border-bottom: 1px solid #eee;
}
</style>

为这个组件编写测试时,我们需要Mock fetch API:

// src/components/__tests__/UserList.spec.js
import { mount, flushPromises } from '@vue/test-utils'
import UserList from '../UserList.vue'

// 模拟fetch全局函数
global.fetch = jest.fn()

describe('UserList', () => {
  beforeEach(() => {
    // 在每个测试前重置mock
    fetch.mockClear()
  })
  
  // 测试加载状态
  it('初始显示加载状态', () => {
    const wrapper = mount(UserList)
    
    // 断言:应该显示加载中
    expect(wrapper.find('.loading').exists()).toBe(true)
    expect(wrapper.find('.loading').text()).toContain('加载中')
  })
  
  // 测试成功获取数据
  it('成功获取用户列表', async () => {
    // Mock成功的API响应
    const mockUsers = [
      { id: 1, name: '张三', email: 'zhangsan@example.com' },
      { id: 2, name: '李四', email: 'lisi@example.com' }
    ]
    
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUsers
    })
    
    const wrapper = mount(UserList)
    
    // 等待所有异步操作完成
    await flushPromises()
    
    // 断言:应该显示了用户列表
    expect(wrapper.find('.loading').exists()).toBe(false)
    expect(wrapper.find('.error').exists()).toBe(false)
    
    const userItems = wrapper.findAll('.user-item')
    expect(userItems).toHaveLength(2)
    expect(userItems[0].text()).toContain('张三')
    expect(userItems[1].text()).toContain('李四')
  })
  
  // 测试API失败情况
  it('处理API请求失败', async () => {
    // Mock失败的API响应
    fetch.mockRejectedValueOnce(new Error('网络错误'))
    
    const wrapper = mount(UserList)
    
    // 等待所有异步操作完成
    await flushPromises()
    
    // 断言:应该显示错误信息
    expect(wrapper.find('.loading').exists()).toBe(false)
    expect(wrapper.find('.error').exists()).toBe(true)
    expect(wrapper.find('.error').text()).toContain('网络错误')
  })
  
  // 测试HTTP错误状态
  it('处理HTTP错误状态', async () => {
    // MockHTTP错误响应
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 500
    })
    
    const wrapper = mount(UserList)
    
    // 等待所有异步操作完成
    await flushPromises()
    
    // 断言:应该显示错误信息
    expect(wrapper.find('.error').exists()).toBe(true)
    expect(wrapper.find('.error').text()).toContain('获取用户列表失败')
  })
})

这些测试展示了如何处理异步操作、Mock外部依赖,以及测试组件的各种状态(加载、成功、失败)。

测试最佳实践和常见陷阱

在编写测试时,遵循一些最佳实践可以让你事半功倍:

1. 测试行为,不测试实现

// 不好的做法:测试内部实现细节
it('调用fetchUsers方法', () => {
  const wrapper = mount(UserList)
  const spy = jest.spyOn(wrapper.vm, 'fetchUsers')
  wrapper.vm.$options.mounted[0]()
  expect(spy).toHaveBeenCalled()
})

// 好的做法:测试外部行为
it('组件挂载后显示用户列表', async () => {
  // Mock API响应
  fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => [{ id: 1, name: '测试用户' }]
  })
  
  const wrapper = mount(UserList)
  await flushPromises()
  
  // 断言用户界面是否正确更新
  expect(wrapper.find('.user-item').exists()).toBe(true)
})

2. 使用描述性的测试名称

// 不好的命名
it('测试按钮', () => {
  // 测试代码
})

// 好的命名
it('按钮在禁用状态下不触发点击事件', () => {
  // 测试代码
})

3. 保持测试独立 每个测试都应该能够独立运行,不依赖其他测试的状态。使用beforeEach和afterEach来设置和清理测试环境。

4. 测试边缘情况 除了正常流程,还要测试边界条件和错误情况:

  • 空数据
  • 网络错误
  • 无效的用户输入
  • 极端的数值边界

集成到开发流程

测试不应该只是开发完成后才运行的东西,而应该融入到整个开发流程中。

在Git hooks中运行测试

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "husky": {
    "hooks": {
      "pre-commit": "npm test",
      "pre-push": "npm run test:coverage"
    }
  }
}

配置持续集成 在GitHub Actions中配置自动测试:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '18'
      - run: npm ci
      - run: npm test
      - run: npm run test:coverage

结语:让测试成为你的开发利器

通过今天的分享,相信你已经看到了测试在Vue开发中的巨大价值。从简单的按钮组件到复杂的表单验证,从同步操作到异步API调用,测试都能为我们提供可靠的保障。

记住,写好测试并不是为了追求100%的测试覆盖率,而是为了建立信心——信心让你能够大胆重构,信心让你能够快速迭代,信心让你在深夜部署时也能安心入睡。

开始可能觉得写测试很麻烦,但当你第一次因为测试提前发现了bug,当你第一次放心地重构复杂代码而不用担心破坏现有功能时,你就会真正体会到测试的价值。

现在就去为你的Vue项目添加第一个测试吧!从最简单的组件开始,一步步构建起你的测试防线。相信用不了多久,测试就会成为你开发流程中不可或缺的一部分。

你在Vue项目中用过测试吗?遇到了什么挑战?欢迎在评论区分享你的经验!

每日一题-长度为 3 的不同回文子序列🟡

2025年11月21日 00:00

给你一个字符串 s ,返回 s长度为 3 不同回文子序列 的个数。

即便存在多种方法来构建相同的子序列,但相同的子序列只计数一次。

回文 是正着读和反着读一样的字符串。

子序列 是由原字符串删除其中部分字符(也可以不删除)且不改变剩余字符之间相对顺序形成的一个新字符串。

  • 例如,"ace""abcde" 的一个子序列。

 

示例 1:

输入:s = "aabca"
输出:3
解释:长度为 3 的 3 个回文子序列分别是:
- "aba" ("aabca" 的子序列)
- "aaa" ("aabca" 的子序列)
- "aca" ("aabca" 的子序列)

示例 2:

输入:s = "adc"
输出:0
解释:"adc" 不存在长度为 3 的回文子序列。

示例 3:

输入:s = "bbcbaba"
输出:4
解释:长度为 3 的 4 个回文子序列分别是:
- "bbb" ("bbcbaba" 的子序列)
- "bcb" ("bbcbaba" 的子序列)
- "bab" ("bbcbaba" 的子序列)
- "aba" ("bbcbaba" 的子序列)

 

提示:

  • 3 <= s.length <= 105
  • s 仅由小写英文字母组成

播放状态与播放序列的关系(999篇一线博客第107篇)

作者 余杭子曰
2025年11月20日 22:21

说到今天这个课题之前,想先同步一个点,就是现在b站上会把一个大视频分为很多段。

这样就导致,

1 学习的获得感很强,能很清楚的确定一个视频的知识点

2 但相对的,也很难快速的整理一个系统性的知识

我今天分享的,和这个不直接相关,而是一个简单视频或者音频里,为了保证连续播放,手动分割的音频段,如何保证播放的序列性以及播放状态的同步。

首先,能确定的事,整体的状态,是基于第一个片段的开始,以及最后最后一个片段的播放结束。

但播放的实际开始,其实取决于audio或者video标签的播放事件,更加准确。

因此,我的设计要点分为两个大的方面。

一 播放状态的整体设计放在播放器。

其 收到第一个音频片段,认为是开始加载 ,状态1;

音频控件收到对应的播放事件,状态2 ;

收到播放序列管理器的调用,执行播放结束,状态3 ;

二 播放序列管理器

其是一个音频分割之后,管理序列的工具。

具体来说,其包括几个生命周期。

2.1 收到一个新的音频,重置,并分割得到新序列,进行初始化(状态1)

2.2 将序列中的第一个放到播放控件中(状态2)

2.3 订阅播放器的播放完成事件,将第二个地址同步给播放控件;直到最后一个;

2.4 当最后一个播放完成,发现没有新的片段需要播放时,调用播放器的状态改变,切换播放器的状态为状态3,自己也进入(状态3)

2.5 任何时候,如果收到新的音频,都将重置,并分割得到新序列(重新进入状态1)

之所以播放器和播放序列分开管理是因为两者对外部的影响不同。

比如对于业务,其只关心整个音频是否加载完。

而对于序列管理器,其只关心自己的序列是否正确,是否将正确的片段传给了播放器,以及是否订阅了节点事件来保证序列顺序和节点的正确性。

拓展:如果哪天,我们需要支持暂停,或者支持指定节点的播放,那么道理也是一样的。需要明确的把播放,和播放列表明确的分开,并通过合适的订阅或者事件机制关联起来。

前端笔记999篇持续更新中,欢迎订阅。 链接

长度为 3 的不同回文子序列

2021年7月11日 15:06

方法一:枚举两侧的字符

思路与算法

我们可以枚举回文序列两侧的字符种类。对于每种字符,如果它在字符串 $s$ 中出现,我们记录它第一次出现的下标 $l$ 与最后一次出现的下标 $r$。那么,以该字符为两侧的回文子序列,它中间的字符只可能在 $s[l+1..r-1]$ 中选取;且以该字符为两侧的回文子序列的种数即为 $s[l+1..r-1]$ 中的字符种数。

我们遍历 $s[l+1..r-1]$ 子串计算该子串中的字符种数。在遍历时,我们可以使用哈希集合来维护该子串中的字符种类;当遍历完成后,哈希集合内元素的数目即为该子串中的字符总数。

在枚举两侧字符种类时,我们维护这些回文子序列种数之和,并最终作为答案返回。

代码

###C++

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch){
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s[l] != ch){
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s[r] != ch){
                --r;
            }
            if (r - l < 2){
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            unordered_set<char> charset;
            for (int k = l + 1; k < r; ++k){
                charset.insert(s[k]);
            }
            res += charset.size();
        }
        return res;
    }
};

###Python

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        res = 0
        # 枚举两侧字符
        for i in range(26):
            l, r = 0, n - 1
            # 寻找该字符第一次出现的下标
            while l < n and ord(s[l]) - ord('a') != i:
                l += 1
            # 寻找该字符最后一次出现的下标
            while r >= 0 and ord(s[r]) - ord('a') != i:
                r -= 1
            if r - l < 2:
                # 该字符未出现,或两下标中间的子串不存在
                continue
            # 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            charset = set()
            for k in range(l + 1, r):
                charset.add(s[k])
            res += len(charset)
        return res

###Java

class Solution {
    public int countPalindromicSubsequence(String s) {
        int n = s.length();
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch) {
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s.charAt(l) != ch) {
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s.charAt(r) != ch) {
                --r;
            }
            if (r - l < 2) {
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            Set<Character> charset = new HashSet<>();
            for (int k = l + 1; k < r; ++k) {
                charset.add(s.charAt(k));
            }
            res += charset.size();
        }
        return res;
    }
}

###C#

public class Solution {
    public int CountPalindromicSubsequence(string s) {
        int n = s.Length;
        int res = 0;
        // 枚举两侧字符
        for (char ch = 'a'; ch <= 'z'; ++ch) {
            int l = 0, r = n - 1;
            // 寻找该字符第一次出现的下标
            while (l < n && s[l] != ch) {
                ++l;
            }
            // 寻找该字符最后一次出现的下标
            while (r >= 0 && s[r] != ch) {
                --r;
            }
            if (r - l < 2) {
                // 该字符未出现,或两下标中间的子串不存在
                continue;
            }
            // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
            HashSet<char> charset = new HashSet<char>();
            for (int k = l + 1; k < r; ++k) {
                charset.Add(s[k]);
            }
            res += charset.Count;
        }
        return res;
    }
}

###Go

func countPalindromicSubsequence(s string) int {
    n := len(s)
    res := 0
    // 枚举两侧字符
    for ch := 'a'; ch <= 'z'; ch++ {
        l, r := 0, n-1
        // 寻找该字符第一次出现的下标
        for l < n && rune(s[l]) != ch {
            l++
        }
        // 寻找该字符最后一次出现的下标
        for r >= 0 && rune(s[r]) != ch {
            r--
        }
        if r-l < 2 {
            // 该字符未出现,或两下标中间的子串不存在
            continue
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        charset := make(map[rune]bool)
        for _, c := range s[l+1:r] {
            charset[c] = true
        }
        res += len(charset)
    }
    return res
}

###C

int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    int res = 0;
    // 枚举两侧字符
    for (char ch = 'a'; ch <= 'z'; ++ch) {
        int l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] != ch) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] != ch) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        bool charset[26] = {false};
        for (int k = l + 1; k < r; ++k) {
            charset[s[k] - 'a'] = true;
        }
        int count = 0;
        for (int i = 0; i < 26; ++i) {
            if (charset[i]) {
                count++;
            }
        }
        res += count;
    }
    return res;
}

###JavaScript

var countPalindromicSubsequence = function(s) {
    const n = s.length;
    let res = 0;
    // 枚举两侧字符
    for (let ch = 'a'.charCodeAt(0); ch <= 'z'.charCodeAt(0); ch++) {
        const c = String.fromCharCode(ch);
        let l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] !== c) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] !== c) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        const charset = new Set();
        for (let k = l + 1; k < r; k++) {
            charset.add(s[k]);
        }
        res += charset.size;
    }
    return res;
};

###TypeScript

function countPalindromicSubsequence(s: string): number {
    const n = s.length;
    let res = 0;
    // 枚举两侧字符
    for (let ch = 'a'.charCodeAt(0); ch <= 'z'.charCodeAt(0); ch++) {
        const c = String.fromCharCode(ch);
        let l = 0, r = n - 1;
        // 寻找该字符第一次出现的下标
        while (l < n && s[l] !== c) {
            ++l;
        }
        // 寻找该字符最后一次出现的下标
        while (r >= 0 && s[r] !== c) {
            --r;
        }
        if (r - l < 2) {
            // 该字符未出现,或两下标中间的子串不存在
            continue;
        }
        // 利用哈希集合统计 s[l+1..r-1] 子串的字符总数,并更新答案
        const charset = new Set<string>();
        for (let k = l + 1; k < r; k++) {
            charset.add(s[k]);
        }
        res += charset.size;
    }
    return res;
}

###Rust

use std::collections::HashSet;

impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let mut res = 0;
        // 枚举两侧字符
        for ch in 'a'..='z' {
            // 使用迭代器找到第一个和最后一个出现位置
            let mut chars = s.chars();
            let l = chars.position(|c| c == ch);
            let r = chars.rev().position(|c| c == ch).map(|pos| s.len() - 1 - pos);
            if let (Some(l), Some(r)) = (l, r) {
                if r > l + 1 {
                    // 收集中间字符
                    let unique_chars: HashSet<_> = s[l+1..r].chars().collect();
                    res += unique_chars.len() as i32;
                }
            }
        }
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n|\Sigma| + |\Sigma|^2)$,其中 $n$ 为 $s$ 的长度,$|\Sigma|$ 为字符集的大小。我们总共需要枚举 $|\Sigma|$ 种字符,每次枚举至多需要遍历一次字符串 $s$ 与哈希集合,时间复杂度分别为 $O(n)$ 与 $O(|\Sigma|)$。

  • 空间复杂度:$O(|\Sigma|)$,即为哈希集合的空间开销。

方法二:枚举中间的字符

思路与算法

我们也可以遍历字符串 $s$ 枚举回文子序列中间的字符。假设 $s$ 的长度为 $n$,当我们遍历到 $s[i]$ 时,以 $s[i]$ 为中间字符的回文子序列种数即为前缀 $s[0..i-1]$ 与后缀 $s[i+1..n-1]$ 的公共字符种数。

对于一个任意的子串,由于其仅由小写英文字母组成,我们可以用一个 $32$ 位整数来表示该子串含有哪些字符。如果该整数从低到高第 $i$ 个二进制位为 $1$,那么代表该子串含有字典序为 $i$ 的小写英文字母。在遍历该子串时,我们需要用按位或来维护该整数。

为了简化计算,我们可以参照前文所述的对应关系,用两个 $32$ 位整数的数组 $\textit{pre}, \textit{suf}$ 分别维护 $s$ 中前缀与后缀包含的字符。其中,$\textit{pre}[i]$ 代表前缀 $s[0..i-1]$ 包含的字符种类,$\textit{suf}[i]$ 代表后缀 $s[i+1..n-1]$ 包含的字符种类。那么,以 $s[i]$ 为中间字符的回文子序列中,两侧字符的种类对应的状态即为 $\textit{pre}[i] & \textit{suf}[i]$,其中 $&$ 为按位与运算符。

为了避免重复计算,我们需要在遍历的同时使用按位或来维护每种字符为中间字符的回文子序列种数。最终,我们将不同种类字符对应的回文子序列总数求和作为答案返回。

代码

###C++

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        int res = 0;
        // 前缀/后缀字符状态数组
        vector<int> pre(n), suf(n);
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        vector<int> ans(26);
        for (int i = 1; i < n - 1; ++i) {
            ans[s[i]-'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += __builtin_popcount(ans[i]);
        }
        return res;
    }
};

###Python

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        res = 0
        # 前缀/后缀字符状态数组
        pre = [0] * n
        suf = [0] * n
        for i in range(n):
            # 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (pre[i - 1] if i else 0) | (1 << (ord(s[i]) - ord('a')))
        for i in range(n - 1, -1, -1):
            # 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (suf[i + 1] if i != n - 1 else 0) | (1 << (ord(s[i]) - ord('a')))
        # 每种中间字符的回文子序列状态数组
        ans = [0] * 26
        for i in range(1, n - 1):
            ans[ord(s[i]) - ord('a')] |= pre[i - 1] & suf[i + 1]
        # 更新答案
        for i in range(26):
            res += bin(ans[i]).count("1")
        return res

###Java

class Solution {
    public int countPalindromicSubsequence(String s) {
        int n = s.length();
        int res = 0;
        // 前缀/后缀字符状态数组
        int[] pre = new int[n];
        int[] suf = new int[n];
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charAt(i) - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s.charAt(i) - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        int[] ans = new int[26];
        for (int i = 1; i < n - 1; ++i) {
            ans[s.charAt(i) - 'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += Integer.bitCount(ans[i]);
        }
        return res;
    }
}

###C#

public class Solution {
    public int CountPalindromicSubsequence(string s) {
        int n = s.Length;
        int res = 0;
        // 前缀/后缀字符状态数组
        int[] pre = new int[n];
        int[] suf = new int[n];
        for (int i = 0; i < n; ++i) {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
        }
        for (int i = n - 1; i >= 0; --i) {
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
        }
        // 每种中间字符的回文子序列状态数组
        int[] ans = new int[26];
        for (int i = 1; i < n - 1; ++i) {
            ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1]);
        }
        // 更新答案
        for (int i = 0; i < 26; ++i) {
            res += BitOperations.PopCount((uint)ans[i]);
        }
        return res;
    }
}

###Go

func countPalindromicSubsequence(s string) int {
    n := len(s)
    res := 0
    // 前缀/后缀字符状态数组
    pre := make([]int, n)
    suf := make([]int, n)
    for i := 0; i < n; i++ {
        // 前缀 s[0..i-1] 包含的字符种类
        if i > 0 {
            pre[i] = pre[i-1]
        }
        pre[i] |= 1 << (s[i] - 'a')
    }
    for i := n - 1; i >= 0; i-- {
        // 后缀 s[i+1..n-1] 包含的字符种类
        if i != n - 1 {
            suf[i] = suf[i + 1]
        }
        suf[i] |= 1 << (s[i] - 'a')
    }
    // 每种中间字符的回文子序列状态数组
    ans := make([]int, 26)
    for i := 1; i < n - 1; i++ {
        ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1])
    }
    // 更新答案
    for i := 0; i < 26; i++ {
        res += bits.OnesCount(uint(ans[i]))
    }
    return res
}

###C

int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    int res = 0;
    // 前缀/后缀字符状态数组
    int pre[n], suf[n];
    for (int i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i ? pre[i - 1] : 0) | (1 << (s[i] - 'a'));
    }
    for (int i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i != n - 1 ? suf[i + 1] : 0) | (1 << (s[i] - 'a'));
    }
    // 每种中间字符的回文子序列状态数组
    int ans[26] = {0};
    for (int i = 1; i < n - 1; ++i) {
        ans[s[i] - 'a'] |= (pre[i - 1] & suf[i + 1]);
    }
    // 更新答案
    for (int i = 0; i < 26; ++i) {
        res += __builtin_popcount(ans[i]);
    }
    return res;
}

###JavaScript

var countPalindromicSubsequence = function(s) {
    const n = s.length;
    let res = 0;
    // 前缀/后缀字符状态数组
    const pre = new Array(n).fill(0);
    const suf = new Array(n).fill(0);
    for (let i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    for (let i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i !== n - 1 ? suf[i + 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    // 每种中间字符的回文子序列状态数组
    const ans = new Array(26).fill(0);
    for (let i = 1; i < n - 1; ++i) {
        ans[s.charCodeAt(i) - 97] |= (pre[i-1] & suf[i + 1]);
    }
    // 更新答案
    for (let i = 0; i < 26; ++i) {
        res += ans[i].toString(2).split('1').length - 1;
    }
    return res;
};

###TypeScript

function countPalindromicSubsequence(s: string): number {
    const n = s.length;
    let res = 0;
    // 前缀/后缀字符状态数组
    const pre: number[] = new Array(n).fill(0);
    const suf: number[] = new Array(n).fill(0);
    for (let i = 0; i < n; ++i) {
        // 前缀 s[0..i-1] 包含的字符种类
        pre[i] = (i > 0 ? pre[i - 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    for (let i = n - 1; i >= 0; --i) {
        // 后缀 s[i+1..n-1] 包含的字符种类
        suf[i] = (i !== n - 1 ? suf[i + 1] : 0) | (1 << (s.charCodeAt(i) - 97));
    }
    // 每种中间字符的回文子序列状态数组
    const ans: number[] = new Array(26).fill(0);
    for (let i = 1; i < n - 1; ++i) {
        ans[s.charCodeAt(i) - 97] |= (pre[i - 1] & suf[i + 1]);
    }
    // 更新答案
    for (let i = 0; i < 26; ++i) {
        res += ans[i].toString(2).split('1').length - 1;
    }
    return res;
}

###Rust

impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let n = s.len();
        let mut res = 0;
        // 前缀/后缀字符状态数组
        let mut pre = vec![0u32; n];
        let mut suf = vec![0u32; n];
        
        for (i, c) in s.chars().enumerate() {
            // 前缀 s[0..i-1] 包含的字符种类
            pre[i] = if i > 0 { pre[i-1] } else { 0 } | (1 << (c as u8 - b'a'));
        }
        for (i, c) in s.chars().rev().enumerate() {
            let i = n - 1 - i;
            // 后缀 s[i+1..n-1] 包含的字符种类
            suf[i] = if i != n - 1 { suf[i+1] } else { 0 } | (1 << (c as u8 - b'a'));
        }
        
        // 每种中间字符的回文子序列状态数组
        let mut ans = vec![0u32; 26];
        for (i, c) in s.chars().enumerate() {
            if i > 0 && i < n - 1 {
                ans[(c as u8 - b'a') as usize] |= pre[i-1] & suf[i+1];
            }
        }
        
        // 更新答案
        for &count in &ans {
            res += count.count_ones() as i32;
        } 
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n + |\Sigma|)$,其中 $n$ 为 $s$ 的长度,$|\Sigma|$ 为字符集的大小。预处理前后缀状态数组与遍历 $s$ 更新每种字符状态数组的时间复杂度均为 $O(n)$,初始化每种字符状态数组与更新答案的时间复杂度均为 $O(|\Sigma|)$。

  • 空间复杂度:$O(|\Sigma|)$,即为每种字符状态数组的空间开销。

c++ 寻找回文,关键还是一前一后

作者 lchaok
2021年7月11日 12:17

解题思路

  1. 一开始还想着dp,可以添加一个新的字符,受到影响的变化规律非常奇怪,然后转变思路
  2. 添加一个新的字符能添加多少呢?首先了解回文字符串,就两种,aba和aaa
  3. 那么添加新的字符需要回去找原来同样的字符,然后看看中间卡了多少种不同字符
  4. 到了这一步我就悟了,真正的核心是前后两个相同字符以及它们之间夹了多少个不同字符
  5. 一开始想用前缀和,计数字母的个数,想想空间开销,就离谱,算了
  6. 然后回到思路,只需要一前一后,总共也就26个字母,只要遍历每个字母的一前一后,最多时间也是O(n)
  7. 于是就有了以下代码,思路理解了,最多遍历26次即可

代码

###cpp

class Solution {
public:
    int countPalindromicSubsequence(string s) {
        //找到一前一后
        map<char, int> first;
        map<char, int> last;
        int size = s.size();
        
        for (int i = 0; i < size; ++i){
            if (first.count(s[i])){
                last[s[i]] = i;
            }else{
                first[s[i]] = i;
            }
        }
        
        int res = 0;
        for (char i = 'a'; i < 'a' + 26; i++){
            if (!first.count(i) || !last.count(i)) continue;
            
            int tL = first[i], tR = last[i];
            vector<int> count(26);
            for (int j = tL + 1; j < tR; ++j){
                count[s[j] - 'a'] = 1;
            }
            
            for (int j = 0; j < 26; ++j){
                if(count[j] == 1) res++;
            }
        }
        
        return res;
    }
};

两种方法:枚举两侧 / 枚举中间+前后缀分解+位运算优化(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年7月11日 12:08

前言

本题要找长为 $3$ 的回文子序列,这要求子序列的第一个字母等于第三个字母。

换句话说,确定了子序列的前两个字母,就确定了子序列。

这引出了两类做法:

  • 先枚举两侧的字母,再枚举中间的字母。
  • 先枚举中间的字母,再枚举两侧的字母。

方法一:枚举两侧

枚举子序列的第一、三个字母是 $\texttt{a},\texttt{b},\ldots,\texttt{z}$。

如果第一、三个字母都是 $\texttt{a}$,如何找到尽量多的不同的子序列?

例如 $s = \texttt{abbacad}$。如果选前两个 $\texttt{a}$ 作为子序列的第一、三个字母,我们只能找到子序列 $\texttt{aba}$。而如果选第一个 $\texttt{a}$ 和最后一个 $\texttt{a}$,夹在两个 $\texttt{a}$ 之间的字母都可以是子序列的第二个字母,从而找到第一、三个字母都是 $\texttt{a}$ 的所有子序列,即 $\texttt{aba}$ 和 $\texttt{aca}$。

算法

  1. 枚举 $\alpha = \texttt{a},\texttt{b},\ldots,\texttt{z}$。
  2. 找 $\alpha$ 在 $s$ 中首次出现的下标 $i$ 和最后一次出现的下标 $j$。如果没有这样的下标,回到第一步继续枚举。
  3. 下标在 $[i+1,j-1]$ 中的字母,可以作为回文子序列的中间字母。
  4. 题目要求相同的子序列只计数一次。这可以用哈希集合去重,也可以用长为 $26$ 的布尔数组记录遇到过的中间字母,避免重复统计。
class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        ans = 0
        for alpha in ascii_lowercase:  # 枚举两侧字母 alpha
            i = s.find(alpha)  # 最左边的 alpha 的下标
            if i < 0:  # s 中没有 alpha
                continue
            j = s.rfind(alpha)  # 最右边的 alpha 的下标
            ans += len(set(s[i + 1: j]))  # 统计有多少个不同的中间字母
        return ans
class Solution {
    public int countPalindromicSubsequence(String s) {
        int ans = 0;
        for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
            int i = s.indexOf(alpha); // 最左边的 alpha 的下标
            if (i < 0) { // s 中没有 alpha
                continue;
            }
            int j = s.lastIndexOf(alpha); // 最右边的 alpha 的下标

            boolean[] has = new boolean[26];
            for (int k = i + 1; k < j; k++) { // 枚举中间字母 mid
                int mid = s.charAt(k) - 'a';
                if (!has[mid]) {
                    has[mid] = true; // 避免重复统计
                    ans++;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int ans = 0;
        for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
            int i = s.find(alpha); // 最左边的 alpha 的下标
            if (i == string::npos) { // s 中没有 alpha
                continue;
            }
            int j = s.rfind(alpha); // 最右边的 alpha 的下标

            bool has[26]{};
            for (int k = i + 1; k < j; k++) { // 枚举中间字母 s[k]
                if (!has[s[k] - 'a']) {
                    has[s[k] - 'a'] = true; // 避免重复统计
                    ans++;
                }
            }
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    int ans = 0;
    for (char alpha = 'a'; alpha <= 'z'; alpha++) { // 枚举两侧字母 alpha
        char* p = strchr(s, alpha); // 找最左边的 alpha
        if (p == NULL) { // s 中没有 alpha
            continue;
        }
        int i = p - s; // 最左边的 alpha 的下标
        int j = strrchr(s, alpha) - s; // 最右边的 alpha 的下标

        bool has[26] = {};
        for (int k = i + 1; k < j; k++) { // 枚举中间字母 s[k]
            if (!has[s[k] - 'a']) {
                has[s[k] - 'a'] = true; // 避免重复统计
                ans++;
            }
        }
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
for alpha := byte('a'); alpha <= 'z'; alpha++ { // 枚举两侧字母 alpha
i := strings.IndexByte(s, alpha) // 最左边的 alpha 的下标
if i < 0 { // s 中没有 alpha
continue
}
j := strings.LastIndexByte(s, alpha) // 最右边的 alpha 的下标
if i+1 >= j { // 长度不足 3
continue
}

has := [26]bool{}
for _, mid := range s[i+1 : j] { // 枚举中间字母 mid
if !has[mid-'a'] {
has[mid-'a'] = true // 避免重复统计
ans++
}
}
}
return
}
var countPalindromicSubsequence = function(s) {
    const ordA = 'a'.charCodeAt(0);
    let ans = 0;
    for (let alpha = ordA; alpha <= 'z'.charCodeAt(0); alpha++) { // 枚举两侧字母 alpha
        const ch = String.fromCharCode(alpha);
        const i = s.indexOf(ch); // 最左边的 alpha 的下标
        if (i < 0) { // s 中没有 alpha
            continue;
        }
        const j = s.lastIndexOf(ch); // 最右边的 alpha 的下标

        const has = Array(26).fill(false);
        for (let k = i + 1; k < j; k++) { // 枚举中间字母 mid
            const mid = s.charCodeAt(k) - ordA;
            if (!has[mid]) {
                has[mid] = true; // 避免重复统计
                ans++;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let mut ans = 0;
        for alpha in b'a'..=b'z' { // 枚举两侧字母 alpha
            let i = s.iter().position(|&c| c == alpha); // 找最左边的 alpha
            if i.is_none() { // s 中没有 alpha
                continue;
            }
            let i = i.unwrap(); // 最左边的 alpha 的下标
            let j = s.iter().rposition(|&c| c == alpha).unwrap(); // 最右边的 alpha 的下标

            let mut has = [false; 26];
            for k in i + 1..j { // 枚举中间字母 mid
                let mid = (s[k] - b'a') as usize;
                if !has[mid] {
                    has[mid] = true; // 避免重复统计
                    ans += 1;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

方法二:枚举中间 + 前后缀分解

枚举 $i=1,2,\ldots,n-2$,把 $s[i]$ 当作子序列的第二个字母,那么对于第一、三个字母 $\alpha = \texttt{a},\texttt{b},\ldots,\texttt{z}$,我们需要判断:

  • $s$ 的前缀 $[0,i-1]$ 中有没有 $\alpha$?
  • $s$ 的后缀 $[i+1,n-1]$ 中有没有 $\alpha$?

暴力找是 $\mathcal{O}(n)$ 的,如何加速?

我们可以先遍历 $s$,统计 $s$ 中每个字母的个数,然后再从左到右遍历 $s$,把 $s[i]$ 的个数减一,就得到了后缀 $[i+1,n-1]$ 每个字母的个数。

对于前缀 $[0,i-1]$,在从左到右遍历 $s$ 的同时,记录遇到了哪些字母。

优化前

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        suf_cnt = Counter(s[1:])  # 统计 [1,n-1] 每个字母的个数
        pre_set = set()
        st = set()
        for i in range(1, len(s) - 1):  # 枚举中间字母 mid
            mid = s[i]
            suf_cnt[mid] -= 1  # 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0:  # 后缀 [i+1,n-1] 不包含 mid
                del suf_cnt[mid]  # 从 suf_cnt 中去掉 mid
            pre_set.add(s[i - 1])  # 记录前缀 [0,i-1] 有哪些字母
            for alpha in pre_set & suf_cnt.keys():  # mid 的左右两侧都有字母 alpha
                st.add(alpha + mid)
        return len(st)
class Solution {
    public int countPalindromicSubsequence(String S) {
        char[] s = S.toCharArray();
        int n = s.length;

        // 统计 [1,n-1] 每个字母的个数
        int[] sufCnt = new int[26];
        for (int i = 1; i < n; i++) {
            sufCnt[s[i] - 'a']++;
        }

        boolean[] preHas = new boolean[26];
        boolean[][] has = new boolean[26][26];
        int ans = 0;
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            preHas[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
            for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if (preHas[alpha] && sufCnt[alpha] > 0 && !has[mid][alpha]) {
                    has[mid][alpha] = true;
                    ans++;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        // 统计 [1,n-1] 每个字母的个数
        int suf_cnt[26]{};
        for (int i = 1; i < n; i++) {
            suf_cnt[s[i] - 'a']++;
        }

        bool pre_has[26]{};
        bool has[26][26]{};
        int ans = 0;
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            pre_has[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
            for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if (pre_has[alpha] && suf_cnt[alpha] && !has[mid][alpha]) {
                    has[mid][alpha] = true;
                    ans++;
                }
            }
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    // 统计 [1,n-1] 每个字母的个数
    int suf_cnt[26] = {}; 
    for (int i = 1; s[i]; i++) {
        suf_cnt[s[i] - 'a']++;
    }

    bool pre_has[26] = {};
    bool has[26][26] = {};
    int ans = 0;
    for (int i = 1; s[i + 1]; i++) { // 枚举中间字母 mid
        int mid = s[i] - 'a';
        suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        pre_has[s[i - 1] - 'a'] = true; // 记录前缀 [0,i-1] 有哪些字母
        for (int alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
            // 判断 mid 的左右两侧是否都有字母 alpha
            if (pre_has[alpha] && suf_cnt[alpha] && !has[mid][alpha]) {
                has[mid][alpha] = true;
                ans++;
            }
        }
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
// 统计 s[1:] 每个字母的个数
sufCnt := [26]int{} 
for _, ch := range s[1:] {
sufCnt[ch-'a']++
}

preHas := [26]bool{}
has := [26][26]bool{}
for i := 1; i < len(s)-1; i++ { // 枚举中间字母 mid
mid := s[i] - 'a'
sufCnt[mid]-- // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
preHas[s[i-1]-'a'] = true // 记录前缀 [0,i-1] 有哪些字母
for alpha := range 26 { // 枚举两侧字母 alpha
// 判断 mid 的左右两侧是否都有字母 alpha
if preHas[alpha] && sufCnt[alpha] > 0 && !has[mid][alpha] {
has[mid][alpha] = true
ans++
}
}
}
return
}
var countPalindromicSubsequence = function(s) {
    const n = s.length;
    const ordA = 'a'.charCodeAt(0);

    // 统计 [1,n-1] 每个字母的个数
    const sufCnt = Array(26).fill(0);
    for (let i = 1; i < n; i++) {
        sufCnt[s.charCodeAt(i) - ordA]++;
    }

    const preHas = Array(26).fill(false);
    const has = Array.from({ length: 26 }, () => Array(26).fill(false));
    let ans = 0;
    for (let i = 1; i < n - 1; i++) { // 枚举中间字母 mid
        const mid = s.charCodeAt(i) - ordA;
        sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        preHas[s.charCodeAt(i - 1) - ordA] = true; // 记录前缀 [0,i-1] 有哪些字母
        for (let alpha = 0; alpha < 26; alpha++) { // 枚举两侧字母 alpha
            // 判断 mid 的左右两侧是否都有字母 alpha
            if (preHas[alpha] && sufCnt[alpha] && !has[mid][alpha]) {
                has[mid][alpha] = true;
                ans++;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let n = s.len();

        // 统计 [1,n-1] 每个字母的个数
        let mut suf_cnt = [0; 26]; 
        for &ch in &s[1..] {
            suf_cnt[(ch - b'a') as usize] += 1;
        }

        let mut pre_has = [false; 26];
        let mut has = [[false; 26]; 26];
        let mut ans = 0;
        for i in 1..n - 1 { // 枚举中间字母 mid
            let mid = (s[i] - b'a') as usize;
            suf_cnt[mid] -= 1; // 撤销 s[i] 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            pre_has[(s[i - 1] - b'a') as usize] = true; // 记录前缀 [0,i-1] 有哪些字母
            for alpha in 0..26 { // 枚举两侧字母 alpha
                // 判断 mid 的左右两侧是否都有字母 alpha
                if pre_has[alpha] && suf_cnt[alpha] > 0 && !has[mid][alpha] {
                    has[mid][alpha] = true;
                    ans += 1;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|^2)$。

位运算优化

我们可以把集合或者布尔数组压缩成一个二进制数,二进制数中的 $0$ 表示 $\texttt{false}$,$1$ 表示 $\texttt{true}$。原理请看 从集合论到位运算,常见位运算技巧分类总结!

用与运算可以 $\mathcal{O}(1)$ 求出 $\textit{pre}$ 和 $\textit{suf}$ 的交集。

class Solution:
    def countPalindromicSubsequence(self, s: str) -> int:
        n = len(s)
        ord_a = ord('a')

        # 统计 [1,n-1] 每个字母的个数
        suf_cnt = [0] * 26
        suf = 0
        for ch in s[1:]:
            ch = ord(ch) - ord_a
            suf_cnt[ch] += 1
            suf |= 1 << ch  # 把 ch 记录到二进制数 suf 中,表示后缀有 ch

        pre = 0
        ans = [0] * 26  # ans[mid] = 由 alpha 组成的二进制数
        for i in range(1, n - 1):  # 枚举中间字母 mid
            mid = ord(s[i]) - ord_a
            suf_cnt[mid] -= 1  # 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0:  # 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid  # 从 suf 中去掉 mid
            pre |= 1 << (ord(s[i - 1]) - ord_a)  # 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            ans[mid] |= pre & suf  # 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 ans[mid] 中

        return sum(mask.bit_count() for mask in ans)  # mask 中的每个 1 对应着一个 alpha
class Solution {
    public int countPalindromicSubsequence(String S) {
        char[] s = S.toCharArray();
        int n = s.length;

        // 统计 [1,n-1] 每个字母的个数
        int[] sufCnt = new int[26];
        int suf = 0;
        for (int i = 1; i < n; i++) {
            int ch = s[i] - 'a';
            sufCnt[ch]++;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        int pre = 0;
        int[] has = new int[26]; // has[mid] = 由 alpha 组成的二进制数
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if (sufCnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        int ans = 0;
        for (int mask : has) {
            ans += Integer.bitCount(mask); // mask 中的每个 1 对应着一个 alpha
        }
        return ans;
    }
}
class Solution {
public:
    int countPalindromicSubsequence(string s) {
        int n = s.size();
        // 统计 [1,n-1] 每个字母的个数
        int suf_cnt[26]{};
        int suf = 0;
        for (int i = 1; i < n; i++) {
            int ch = s[i] - 'a';
            suf_cnt[ch]++;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        int pre = 0;
        int has[26]{}; // has[mid] = 由 alpha 组成的二进制数
        for (int i = 1; i < n - 1; i++) { // 枚举中间字母 mid
            int mid = s[i] - 'a';
            suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if (suf_cnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        int ans = 0;
        for (int mask : has) {
            ans += popcount((uint32_t) mask); // mask 中的每个 1 对应着一个 alpha
        }
        return ans;
    }
};
int countPalindromicSubsequence(char* s) {
    int n = strlen(s);
    // 统计 [1,n-1] 每个字母的个数
    int suf_cnt[26] = {};
    int suf = 0;
    for (int i = 1; s[i]; i++) {
        int ch = s[i] - 'a';
        suf_cnt[ch]++;
        suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
    }

    int pre = 0;
    int has[26] = {}; // has[mid] = 由 alpha 组成的二进制数
    for (int i = 1; s[i + 1]; i++) { // 枚举中间字母 mid
        int mid = s[i] - 'a';
        suf_cnt[mid]--; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        if (suf_cnt[mid] == 0) { // 后缀 [i+1,n-1] 不包含 mid
            suf ^= 1 << mid; // 从 suf 中去掉 mid
        }
        pre |= 1 << (s[i - 1] - 'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
        has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
    }

    int ans = 0;
    for (int i = 0; i < 26; i++) {
        ans += __builtin_popcount(has[i]); // has[i] 中的每个 1 对应着一个 alpha
    }
    return ans;
}
func countPalindromicSubsequence(s string) (ans int) {
// 统计 [1,n-1] 每个字母的个数
sufCnt := [26]int{}
suf := 0
for _, ch := range s[1:] {
ch -= 'a'
sufCnt[ch]++
suf |= 1 << ch // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
}

pre := 0
has := [26]int{} // has[mid] = 由 alpha 组成的二进制数
for i := 1; i < len(s)-1; i++ { // 枚举中间字母 mid
mid := s[i] - 'a'
sufCnt[mid]-- // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
if sufCnt[mid] == 0 { // 后缀 [i+1,n-1] 不包含 mid
suf ^= 1 << mid // 从 suf 中去掉 mid
}
pre |= 1 << (s[i-1] - 'a') // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
has[mid] |= pre & suf // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
}

for _, mask := range has {
ans += bits.OnesCount(uint(mask)) // mask 中的每个 1 对应着一个 alpha
}
return
}
var countPalindromicSubsequence = function(s) {
    const n = s.length;
    const ordA = 'a'.charCodeAt(0);

    // 统计 [1,n-1] 每个字母的个数
    const sufCnt = Array(26).fill(0);
    let suf = 0;
    for (let i = 1; i < n; i++) {
        const ch = s.charCodeAt(i) - ordA;
        sufCnt[ch]++;
        suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
    }

    let pre = 0;
    const has = Array(26).fill(0); // has[mid] = 由 alpha 组成的二进制数
    for (let i = 1; i < n - 1; i++) { // 枚举中间字母 mid
        const mid = s.charCodeAt(i) - ordA;
        sufCnt[mid]--; // 撤销 mid 的计数,sufCnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
        if (sufCnt[mid] === 0) { // 后缀 [i+1,n-1] 不包含 mid
            suf ^= 1 << mid; // 从 suf 中去掉 mid
        }
        pre |= 1 << (s.charCodeAt(i - 1) - ordA); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
        has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
    }

    let ans = 0;
    for (const mask of has) {
        ans += bitCount32(mask); // mask 中的每个 1 对应着一个 alpha
    }
    return ans;
};

// 参考 Java 的 Integer.bitCount
function bitCount32(i) {
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}
impl Solution {
    pub fn count_palindromic_subsequence(s: String) -> i32 {
        let s = s.as_bytes();
        let n = s.len();

        // 统计 [1,n-1] 每个字母的个数
        let mut suf_cnt = [0; 26];
        let mut suf = 0;
        for &ch in &s[1..] {
            let ch = (ch - b'a') as usize;
            suf_cnt[ch] += 1;
            suf |= 1 << ch; // 把 ch 记录到二进制数 suf 中,表示后缀有 ch
        }

        let mut pre = 0;
        let mut has = [0u32; 26]; // has[mid] = 由 alpha 组成的二进制数
        for i in 1..n - 1 { // 枚举中间字母 mid
            let mid = (s[i] - b'a') as usize;
            suf_cnt[mid] -= 1; // 撤销 mid 的计数,suf_cnt 剩下的就是后缀 [i+1,n-1] 每个字母的个数
            if suf_cnt[mid] == 0 { // 后缀 [i+1,n-1] 不包含 mid
                suf ^= 1 << mid; // 从 suf 中去掉 mid
            }
            pre |= 1 << (s[i - 1] - b'a'); // 把 s[i-1] 记录到二进制数 pre 中,表示前缀有 s[i-1]
            has[mid] |= pre & suf; // 计算 pre 和 suf 的交集,|= 表示把交集中的字母加到 has[mid] 中
        }

        // mask 中的每个 1 对应着一个 alpha
        has.into_iter().map(|mask| mask.count_ones() as i32).sum()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n + |\Sigma|)$,其中 $n$ 是 $s$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

专题训练

  1. 数据结构题单的「§0.2 枚举中间」。
  2. 动态规划题单的「专题:前后缀分解」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2025年11月20日技术

yalc,yyds!

2025年11月20日 20:54

npm link

最近开发一个组件库假设叫rn-skeleton,想着组件库就该有组件库的样子,于是我想着找个宿主项目(假设叫rn-app)通过npm link的方式进行本地调试,谁知道拉了坨大的。。。

事情是这样的:众所周知,npm link的使用就很简单。在rn-skelton执行npm link,在宿主项目rn-app安装npm link rn-skeleton,到这里其实已经完事了,结果引入的时候,一直显示:找不到模块“rn-skeleton”或其相应的类型声明,更过分的是告诉我rn-app中找不到react-native....

于是我进行了好一会的问题解决,我寻思着node_module也是能看到rn-skeleton这个包的,怎么就找不到? 先解决react-native的依赖问题,我把rn-skeleton项目中package.jsonoverrides字段删除:

 "overrides": {
    "react": "18.3.1",
    "react-native": "0.77.2"
  }

因为宿主项目也有这玩意,但是无济于事,尝试好几次,还是无用。于是尝试:

  • npm install /Projects/drn-dialog --legacy-peer-deps
  • ln -s /Projects/rn-skeleton node_modules/rn-skeleton

还是无济于事,于是放弃了,另辟蹊径去。结果发现:

在 React Native 项目中,npm link 和软连接(ln -s)一般无法用于组件库的本地调试,主要因为
Metro bundler(RN 打包器)默认不支持 symlink,所以常规的 npm link 方案不生效。

metroReact Native 使用的 Facebook打包器不支持符号链接,这严重阻碍了本地代码的共享。

wml

WML(Webpack Module Linker)是一款文件同步工具,基于watchman实现文件监听 用于自定义metro打包器配置的实用程序,以解决其不支持符号链接的问题。

该软件包的主要用途是支持使用yarn link或 开发 React Native 依赖项npm link

npm install -g wml

// 命令
wml ls  // 查看当前link
wml add <ProjectA Name> <ProjectA NameB>/node_modules/<ProjectA Package Name>
wml rm <LinkId>
wml rm all
wml start // 启动生效
wml start --verbose
wml stop
  1. ProjectA Name需要引入的包目录
  2. ProjectB Name需要引入包的宿主项目
  3. ProjectA Package NameProjectApackage.json中的name

优点:

  • 完全实时同步
  • 轻量无配置:无需修改包的 package.json,仅需建立一次映射即可长期使用
  • 支持任意文件同步:不限npm包
  • 跨平台:支持mac/windows/linux,依赖watchman

缺点:

  • 仅做文件同步,不处理包的依赖解析
  • 依赖watchman

原以为简简单单,还自带热更新,没想到执行wml start一直卡着不动,也没日志输出,闹麻了,接着换~

yalc

npm install -g yalc

yalc publish  // 将本地包发布到yalc本地仓库
yalc add <Package Name> // 从yalc仓库引入包到当前项目
yalc update <Package Name> // 更新当前项目的本地包到最新版本
yalc push // 将本地包的修改同步到所有引入的项目(热更新)
yalc remove <Package Name> // 从当前项目删除yalc引入的包
yalc clean // 清空yalc本地仓库缓存

执行yalc后可以看到项目中的node_modules出现该包,而且多了一个文件yalc.lock。 如果不是标准的npm包项目,可能还需修改一些内容:

ProjectA

// package.json
{
  "name": "rn-dialog",
  "main": "src/index.tsx", // 入口文件路径
  "types": "src/index.d.ts" // index.d.ts文件路径
  // ...
 }

ProjectB

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
       // 手动映射模块路径,强化 TS 解析
      "rn-dialog": ["node_modules/rn-dialog/src/index.tsx"]
    },
}

缺点:

  • 不支持热更新,需要手动执行yalc update
  • 会有新文件生成,记得添加到.gitignore
  • 主要针对npm 包

优点:

  • 绕开npm linkpeerDependencies/overrides校验冲突,因为yalc是模拟安装而不是软链
  • 支持多项目同步
  • 轻量无侵入

至于@carimus/metro-symlinked-deps,略略略

我宣布,以后yalc是我的首选项~

ESLint报错无具体信息:大型代码合并中的内存与性能问题排查

作者 eason_fan
2025年11月20日 20:49

ESLint报错无具体信息:大型代码合并中的内存与性能问题排查

问题描述

在最近的一次大型代码合并中,遇到了一个令人困惑的ESLint问题:

先是提示内存溢出

img_v3_02s7_537bfaa6-6a75-48d7-a4d2-5bfc5fe092hu.png

然后出现报错,但是没有报错信息,只展示检测的文件路径。

PixPin_2025-11-20_20-38-26.png

  • 现象:ESLint执行失败,但终端只显示 ✖ pnpm eslint --quiet,没有任何具体的错误信息
  • 背景:这次合并涉及1968个文件,其中1744个是TypeScript/JavaScript文件
  • 之前提示:合并前曾出现"git emit超过内存"的警告

这种"静默失败"让问题排查变得异常困难,本文将详细分析这个问题的原因和解决方案。

原因分析

1. 代码量爆炸性增长

通过分析发现,这次合并的规模远超寻常:

# 查看合并涉及的文件数量
$ git diff --name-only HEAD~1 | wc -l
1968

# 其中TypeScript/JavaScript文件数量
$ git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | wc -l
1744

eslint需要检测大量的文件,检测本身是没有问题的,只是检测完的结果展示被遮住了,展示不全。

2. 大型IDL文件的性能瓶颈

进一步分析发现,存在大量大型自动生成的IDL文件:

# 查看超过100KB的文件
$ git diff --name-only HEAD~1 | xargs wc -c 2>/dev/null | awk '$1 > 100000 {print $1/1024 "KB", $2}' | wc -l
26

# 最大的文件达到1.2MB
$ git diff --name-only HEAD~1 | xargs wc -c 2>/dev/null | sort -n | tail -5
1209435 apps/hub/src/idls/app_idl/namespaces/all_req_data.ts
1399027 pnpm-lock.yaml
14093097 total

3. ESLint处理机制的问题

虽然这些IDL文件顶部都有 /* eslint-disable */ 注释,但ESLint仍然需要:

  1. 解析每个文件来确定是否应用规则
  2. 构建AST来理解文件结构
  3. 处理大型文件(如770KB的IDL文件)

单个大型IDL文件的处理时间测试:

$ time NODE_OPTIONS="--max-old-space-size=16384" emox eslint --quiet apps/creative-hub/src/idls/creation_bff/index.ts
# 耗时5.66秒

4. 内存压力

当1744个文件同时处理时,Node.js默认的内存限制(约1.4GB)很容易被突破,导致进程崩溃或异常行为。

解决方案

方案一:增加Node.js内存限制

# 临时解决方案
export NODE_OPTIONS="--max-old-space-size=16384"
emox eslint --quiet your-files

# 或者永久设置
echo "export NODE_OPTIONS=\"--max-old-space-size=16384\"" >> ~/.zshrc
source ~/.zshrc

方案二:使用ESLint缓存机制

# 启用缓存避免重复处理
emox eslint --quiet --cache your-files

方案三:分批处理

创建分批处理脚本,避免同时处理过多文件:

#!/bin/bash
# 分批处理文件,每批50个
batch_size=50
files=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | grep -v idls)

# 分批处理
echo "$files" | split -l $batch_size - /tmp/eslint_batch_
for batch_file in /tmp/eslint_batch/batch_*; do
    echo "处理第 $batch_num 批文件..."
    emox eslint --quiet --cache $(cat $batch_file)
done

方案四:排除IDL文件

IDL文件通常是自动生成的,可以安全排除:

# 排除IDL文件进行检查
emox eslint --quiet $(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|js|jsx)$' | grep -v idls)

最终解决步骤

在尝试了增加node内存和eslint缓存发现无济于事。并且排除idl文件也有一定的安全隐患,虽然idl是自动生成的,但是如果不小心改动了也会引起编译不通过。最终为了快速提交代码,使用最简单粗暴的方法,分段提交。

1. 逐步添加文件到暂存区

# 分批添加文件,避免一次性处理过多
git add apps/edit/src/ -A
git add apps/app-hub/src/ -A
# ... 其他目录分批添加

2. 执行lint-staged检查

# 使用pnpm执行lint-staged
pnpm lint-staged

3. 处理具体报错

根据lint-staged的输出,逐一解决具体的ESLint错误:

# 如果有错误,会显示具体的文件和行号
# 例如:
# apps/your-file.ts
#   45:10  error  Missing semicolon  @typescript-eslint/semi

# 修复后重新检查
pnpm lint-staged

4. 继续合并流程

# 所有问题解决后,继续合并
git merge --continue

总结

这次ESLint"静默失败"问题的根本原因是大型代码合并导致的内存和性能压力。在尝试了增加内存限制和缓存之后也无济于事,那么化繁为简,用最简单的逐步提交就行了。

希望这个经验能帮助你在未来的大型代码合并中避免类似问题。

全面实测 Gemini 3.0,前端这回真死了吗?

作者 ConardLi
2025年11月20日 20:07

本期视频:www.bilibili.com/video/BV1gP…

众所周知,每次有新的模型发布前端都要失业一次,前端已经成为了大模型编程能力的计量单位,所以广大前端朋友不要破防哈!至于这次是不是真的,我们实战测评后再见分晓。

大家好,欢迎来到 code秘密花园,我是花园老师(ConardLi)。

就在我们还在回味上周 OpenAI 发布的 GPT-5.1 如何用“更有人情味”的交互惊艳全场,还在感叹9月底 Claude 4.5 Sonnet 在编程领域的统治力时,Google 在昨夜(11月18日)终于丢出了它的重磅炸弹 —— Gemini 3.0

“地表最强多模态”、“推理能力断层领先”、“LMArena 首个突破 1500 分的模型” …… Google 这次不仅是来“交作业”的,更是直接奔着“砸场子”来的。

Sundar Pichai 在 X 上自信宣称:“Gemini 3.0 是世界上最好的多模态理解模型,迄今为止最强大的智能体 + Vibe Coding 模型。它能将任何想法变为现实,快速掌握上下文和意图,让您无需过多提示即可获得所需信息。”

这个牛吹的还是挺大的。Gemini 3.0 真的有这么强吗?我熬夜实测了 Gemini 3.0 Pro 的编程能力,挖掘了大量细节,为你带来这篇最全解读。以下是本期内容概览:

榜单解读

盲测打分

我们先来看一下官方放出的榜单,是不是非常炸裂,除了 SWE-Bench 没能打过 Claude Sonnet 4.5,大部分测试简直是全面屠榜,甚至有些是断崖式领先:

https://lmarena.ai/leaderboard

在 LMArena(大模型竞技场) 榜单中,Gemini 3.0 Pro 以 1501 Elo 的积分空降第一,这是人类历史上首个突破 1500 分大关的 AI 模型!

LMArena 是由 LMSYS 组织的大众盲测竞技场。用户输入问题,两个匿名模型回答,用户凭感觉选哪个好。它代表了 “用户体验”和“好用程度”。 很多榜单跑分高的模型不一定真的好用,但 Arena 分高一定好用,因为它是大众凭真实感觉选出来的。Gemini 3.0 突破 1500 分,说明在大众眼中,它的体感确实有了质的飞跃。

推理能力

GPQA Diamond 91.7% 的分数非常恐怖,这代表它在生物、物理、化学等博士级别的专业问题上,正确率极高。在 Humanity’s Last Exam(当前最难的测试基准,号称 AI 的 "终极学术考试")中,在不使用任何工具的情况下达到 37.5% 。

https://www.vals.ai/benchmarks/gpqa

GPQA Diamond (Graduate-Level Google-Proof Q&A) 是一套由领域专家编写的、Google 搜不到答案的博士级难题。它是目前衡量AI“智商”的最硬核指标。 只有真正的推理能力,才能在这里得分。Gemini 3.0 能跑到 90% 以上,意味着它在很多专业领域的判断力已经超过了普通人类专家。

视觉理解

Gemini 系列一直以原生多模态(Native Multimodal)著称,Gemini 3.0 更是将这一优势发挥到了极致,它在 MMMU-Pro 和 Video-MMMU 上分别斩获了 81% 87.6% 的高分,全面领先其他模型。

MMMU 是聚焦大学水平的多学科多模态理解与推理基准。MMMU-proMMMU 的升级强化版,通过过滤纯文本问题、将选项增至10个、引入问题嵌于图像的纯视觉输入设置,大幅降低模型猜测空间,是更贴近真实场景的严格多模态评估基准。

其他基准

另外,在 ARC-AGI-2、ScreenSpot-Pro、MathArena Apex 等基准上更是数倍领先其他模型:

  • MathArena Apex 的题目是年全球顶级奥数比赛的压轴题,难度和 IMO(国际数学奥林匹克)最高级别相当。之前主流 AI 模型做这些题,得分都低于 2%,直到 Gemini 3 Pro 交出 23.4% 的成绩。
  • ARC-AGI-2 是 ArcPrize 基金会 2025 年推出的通用智能测试,能重点考察 AI 的组合推理能力和高效解题思路,还通过成本限制避免 AI 靠 “暴力破解” 得分。
  • ScreenSpot-Pro 是 2025 年新出的专业 GUI 视觉定位测试工具。它的核心任务是让 AI 精准找到界面上的 UI 元素,比如按钮、输入框等。目前多数模型的原始准确率不到 10%,而 Gemini 3 Pro 凭借 72.7% 的准确率创下了当前纪录。

这个榜单看着确实挺恐怖的,实际效果如何,我们一起来测试一下。

使用方法

以下四个位置目前均可以免费使用 Gemini 3.0:

  1. 打开 Google Gemini App 或网页版,可以直接体验 Gemini 3.0,仅限基础对话和简单工具调用,普通 Google 账号即可:

gemini.google.com/app

  1. Google AI Studio Playground ,API 已经开放 Preview 版本(gemini-3-pro-preview)可以更改模型参数,进行基础对话和工具调用:

aistudio.google.com/prompts/new…

  1. Google AI Studio Build ,一个专业的 AI 建站平台,类似 V0,可以编写复杂的前端应用:

aistudio.google.com/apps

  1. Google Antigravity,Google 推出的全新 AI IDE,对标 Cursor。

目前可以直接白嫖 Gemini 3 ProClaude Sonnet 4.5(不过需要美区 Google 账号):

中文写作

我们先来进入 Google Gemini 网页版,测试一下最基础的中文写作能力,我们在右下角切换到 Thinking 模式,即可使用最新的 Gemini 3.0 的推理能力:

我们来让他调研一下昨天比较火的 Cloudflare 宕机事件,并且生成一篇工作号文章,输入如下提示词:

调研最新的 Cloudflare 崩溃事件,然后编写一篇公众号文章来介绍这个事件。注意文章信息的真实性、完整性、可读性。

可以看到,它进行了非常长并且有条理的推理:

然后开始输出正文,先给出了公众号的推荐标题和摘要:

以下是完整的文章,基本没什么 AI 味:

接下来,我们再看看我们的老朋友豆包的生成效果:

大家觉得哪个文笔好一点呢,可以自行评判一下。

开发实测

下面,我们开始测试开发能力,这时我们可以到 Google AI Studio 的 Build 功能,这其实是一个在线的 AI Coding 工具,帮你快速把想法变成可运行的网页。

测试1:物理规律理解

我们先来一个非常经典的测试:

::: block-1 实现一个弹力小球游戏:

  • 环境设置:创建一个旋转的六边形作为小球的活动区域。
  • 物理规律:小球需要受到重力和摩擦力的影响。
  • 碰撞检测:小球与六边形墙壁碰撞时,需要按照物理规律反弹。 :::

理解物理规律一直是众多模型的最大难题之一,所以每次有新的模型出现这都是我首要测试的题目。可以看到,Gemini 依然首先给出了非常详细且有条理的思考:

然后开始编写代码,我们可以切换到 Code,可以看到实时的代码生成,输出速度还是非常快速。一个很明显的区别,在 Build 模式下生成的代码并不是简单的 HTML,而是一个含有多个文件的 React + TS 的应用,这就给了它更高的上限,可以编写非常复杂的网页应用,并且写出的代码也会更容易维护。

生成完成了,我们来看一下效果,可以发现 Gemini 对物理规律的理解是非常不错的,而且页面样式和交互体验也不错。

在生成完成后,我们可以继续对网站提出改进意见让它继续迭代,还可以直接更改网页的代码,还是非常方便的。

测试2:小游戏开发

提示词:请你帮我编写一款赛博朋克风格的马里奥小游戏,要求界面炫酷、可玩性高、功能完整。

最终效果(经过三轮迭代,耗时 8 分钟左右):

游机制还原度还是非常高的,运行效果也很流畅,文章里就不放视频了,具体效果大家可以到 B 站视频中去看。

测试3:3D效果开发

开发一个拥有逼真效果的 3D 风扇 网页,可以真实模拟风扇的运行

最终效果(经过两轮迭代,耗时 5 分钟左右)

这个风扇生成的还是很逼真的,支持开关、调整风扇转速、摇头。甚至还是个 AI 智能风扇,可以直接跟风扇语音对话让他自己决定如何调整转速 ...

测试4:UI还原能力

提示词:帮我编写一个网站,要求尽可能的还原给你的这两张设计图

设计稿原图:

一轮对话直接完成,耗时 3 分钟左右:

最终还原效果:

这效果,基本上算是 1:1 直接还原了,并且界面上的组件都是可交互的,这个必须点赞。

测试5:使用插件开发

在 Build 模式下,我们还可以直接选择官方提供的各种插件,比如前段时间比较火的 Nano Banana(Gemini 的生图模型),以及 Google Map、Veo 等服务:

我们来尝试使用 Nano Banana 生成一个在线的 AI 图片处理网站:

提示词:创建一个在线的 AI 图片处理应用,可以支持多项图片处理能力,页面炫酷、交互友好。

最终效果(经过三轮迭代,耗时 6 分钟左右)

效果非常不错,支持拖动对比图片处理前后的效果,还支持对图片局部进行处理:

测试6:I'm feeling lucky

在 Build 模式下,还有个非常有意思的功能,I'm feeling lucky,点击这个按钮,它会自动帮我生成一些项目灵感,如果你支持想尝试一下 Gemini 3.0 的强大能力,但不知道要做点啥,这就是一个不错的选择:

比如下面这个项目,就是我基于 AI 生成的灵感而创建的:

这是一个 AI 写作工具:支持通过输入提示词和文件附件,让 AI 协助创作内容;并要求 AI 对任意段落、句子等进行迭代优化;AI 也会智能主动介入 —— 当它判断时机合适时,主动提供反馈建议,支持嵌入式修改;

经过这几轮测试我们发现,Gemini 3.0 编写网站的能力确实非常强,不过这也离不开 Build 工具的加持,那脱离了这个工具后究竟效果如何呢,下面我们在本地 AI IDE 环境中来进行测试。

Gemini 3.0 PK Claude Sonnet 4.5

我们让 Gemini 3.0 来 PK 一下目前公认最强的编码模型 Claude Sonnet 4.5

为了保证公平的测试环境,我们使用本地的 AI IDE 来进行测试,可让两个模型拥有同样的调度机制和工具。

我们直接用 Google 这次和 Gemini 3.0 一起发布的 Antigravity 编辑器,这是一款直接对标 Cursro、Windsurf 的本地 AI 编辑器,可以直接白嫖 Gemini 3 ProClaude Sonnet 4.5

Antigravity 也是基于 VsCode 二次开发的,使用体验感觉也和 Cursor 差不多:

  • 输入 @ 可以选择文件、配置 MCP Server、配置 Global Rules 等功能;
  • Coding Agent 可以选择 PlanningFast 两种模式

目前支持选择以下五个模型,都是免费的:

  • Gemini 3 Pro (High)、Gemini 3 Pro (Low)
  • Claude Sonnet 4.5、Claude Sonnet 4.5 (Thinking)
  • GPT-OSS 120B (Medium)

题目1:项目理解能力:大型项目优化分析

第一局,我们来测试一下模型的项目理解能力,我们让他对一个大型的项目,进行整体的分析和产出优化建议,我们选择 Easy Dataset 这个项目。

理解当前项目架构,并告诉我本项目还有哪些需要改进的地方?(无需改动代码,先输出结论)

Gemini 3.0

这是 Gemini 3.0 的情况,它先进行了非常全面的分析,然后为最终的结论创作了一个单独的文件,使用英文编写:

Claude Sonnet 4.5

然后是 Claude 4.5 的分析过程:

最终结论直接输出到了聊天窗口:

对比结果

凭我个人对这个项目的理解,乍一看还是 Claude 4.5 生成的结果更准确,而且查看的文件也很关键,给出的建议也都是正确的。

为了公平的评判,下面我们有请 DeepSeek 老师来担当裁判:

最终结论,Claude Sonnet 4.5 胜出:

其实这里对 Claude 来讲还稍微有点不公平的,因为 Gemini 3.0 我们使用的是长思考模式,而 Claude 4.5 我们选择的是非思考模型,如果是 Claude 4.5 Thinking 模式,最终效果肯定还要更好一点。

题目2:架构设计能力:全栈项目编写

下面,我们再来测试一下综合的架构设计和编码能力,让它帮我们生成一个完整的全栈项目,既要兼顾某一个具体的技术设计,又要兼顾前后端的协作,需求如下:

设计并实现一个 Node.js 的 JWT 认证中间件,考虑安全性和易用性;设计对应的前端页面、业务接口来演示中间件的调用效果;创建 Readme 文档,并编写此中间件的架构设计、使用方式等。

Gemini 3.0

过程省略(感兴趣可以到视频里去看),直接上结果吧:

最后只生成了两个页面,一个登录页,一个登录之后的接口验证:

Claude Sonnet 4.5

Claude Sonnet 4.5 的结果明显就要更好一点了:

首先包含了完整的注册登录功能,在登录后,可以进行多种维度的接口验证:

对比结果

为了保证公平,我们还是要看一下代码具体写的怎么样,下面我们还是让 AI 来分析对比下这两个工程的代码:

最终对比结论还是 Claude Sonnet 4.5 完胜

题目3:前端编写能力:项目官网编写

第三局,我们偏心一点,来对比一下两者的纯前端编码的能力,因为毕竟是 Gemini 3.0 的实测,都输了也不太好,我们这次让他们从零调研并生成一个 Easy Dataset 的官网。

提示词:请你调研并分析这个项目的主要功能 github.com/ConardLi/ea… ,并为它编写一个企业级的官方网站。

Gemini 3.0

首先看 Gemini 3.0 的生成效果,列出的项目计划是这样的,然后中间中断,手动继续了一次,后使用 tailwindcss 的脚手架模版创建了这个项目,在最后的自动化测试环节也是没有完成的。

最终生成的效果是这样的,审美还是挺在线的,不过内容略显单薄了。

Claude Sonnet 4.5

然后我们来看 Claude 4.5 生成的结果,首先他生成的一份非常详细的开发计划,然后对 Easy Dataset 项目进行了调研,然后产出了一份调研报告后才开始开发。任务是一次就完成了,中间没有任何中断,然后他没有选择使用脚手架,而是从零创建了项目代码,最终也顺利完成了自动化测试。

然后我们来看最终的生成效果,这个看起来在视觉体验上就明显不如 Gemini 3.0 了。

但是,因为前期进行了非常充分的调研,所以网站的内容非常充实,基本上涵盖了所有关键信息。

对比结果

所以这最后一局可以说是各有优劣:

  • 视觉体验、项目代码的可维护性 Gemini 3.0 胜出;
  • 网站的内容丰富度,整个编写过程的丝滑程度 Claude 4.5 胜出;

所以这一局,我们判定为平局。

总结

最后我们来根据今天的实测结果总结一下结论。

Gemini 3.0 的前端能力确实超标,在小游戏开发,UI 设计稿还原,视觉效果开发这种对审美能力要求极高的需求中更是强的可怕。得益于 Gemini 原生多模态,以及强大的视觉理解能力,让他这种优势进一步放大了出来。

特别是在有了 AI Studio Build 这种工具的加持,让他在从零生成一个 Web 应用这个场景下更是是如虎添翼。另外,在指令遵循,需求理解的能力上,相比上一代的 Gemini 2.5 确实是有了很大幅度的增强。

但是,这足以让前端失业吗?

在实际的开发中,绘制 UI 可能只占很小一部分的工作。说到这,就不得不说我们的前端祖师爷,最近刚靠开发前端工具链融资了 8000 万啊,当之无愧的前端天花板了。

在后面的实战对比中,我们发现,在复杂项目上下文理解,全栈项目的架构设计和编写等实际开发工作中需要考虑的环节上,相比 ClaudeGemini 3.0 还是略逊一筹的,他依然无法撼动 ClaudeVibe Coding 领域的的霸主地位。

这个其实我们看榜单的 SWE Bentch 就看出来了,这是唯一一个被 Claude超越的指标,这个 Bentch 中包含了大量真实项目开发中要解决的 Issue ,能够衡量模型在真实编程环境中解决问题的能力。

所以这也能体现 Gemini 3.0 在真实的编程工作中并没有带来多大的提升,不过对于完全不会编程的小白来讲,确实可以让你们的想法更快也更好的变成现实了。

所以广大前端程序员不要慌,淘汰的是切图仔,关我前端程序员什么事呢?

不过这是玩笑话,广大程序员们确实应该居安思危了,就算不会在短时间内立刻失业,你们的竞争力确实是在实打实的流失的,其实很多行业也都一样,如果一直是在做简单的重复性工作,那未来被 AI 淘汰已是必然了。

最后

关注《code秘密花园》从此学习 AI 不迷路,相关链接:

如果本期对你有所帮助,希望得到一个免费的三连,感谢大家支持

开发了几个app后,我在React Native用到的几个库的推荐

作者 天平
2025年11月20日 18:49

1. 样式开发:nativewind + clsx + tailwind-merge

此前尝试过 StyleSheet 原生写法与 styled-components 方案,均觉得语法冗余、编写繁琐。而采用 nativewind 实现类 Tailwind CSS 的 className 样式编写方式,再配合 clsx 与 tailwind-merge 封装的 cn 函数,处理不同状态下的样式切换时,体验极为便捷高效 —— 无需冗余嵌套,就能灵活组合样式,大幅简化了样式开发流程。

2. 请求管理:@tanstack/react-query

想必不少人都遇到过这样的痛点:A 组件更新数据后需同步刷新 B 组件数据,此时请求函数往往要层层透传,流程繁琐且不易维护。而 @tanstack/react-query 的出现彻底解决了这一问题,堪称前端请求管理的 “神级工具”。它能集中管理所有请求逻辑与返回数据,无需层层透传请求函数,仅需一行代码即可触发请求重发,极大简化了跨组件数据同步的复杂度,实用性拉满。

3. 本地存储:expo-sqlite + drizzle-orm + drizzle-zod + zod

经过对多种本地存储方案与 ORM 工具的调研对比,我最终选定 expo-sqlite 作为核心存储方案。恰好 drizzle-orm 已原生支持 expo-sqlite,且其官网内置了 AI 答疑功能,遇到疑问可直接咨询,体验与 Prisma 类似;但 Prisma 暂不支持 expo-sqlite,因此 drizzle-orm 成为了更适配的选择。再搭配 drizzle-zod 与 zod 实现参数校验,整套方案从存储操作到数据校验无缝衔接,类型安全且易用性强,用起来十分顺手。

4. 表单处理:react-hook-form + @hookform/resolvers + zod

React Native 生态中,好用的表单组件库相对稀缺。因此我选用 react-hook-form 作为表单管理核心,其简洁的 API 设计、高效的状态管理能力,大幅提升了表单开发效率。同时结合 @hookform/resolvers 与 zod 实现表单校验,无需手动编写复杂的校验逻辑,就能实现类型安全的参数校验,显著节省了开发时间与后期调试成本。

umi4暗黑模式设置

作者 七淮
2025年11月20日 18:00

umi4 max + antd5 全局暗黑模式设置

方案一 (错误-只能设置layout content内部 模式切换)

效果

Snipaste_2025-11-20_17-59-05.PNG

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  return {
    layout: 'mix',
    logo: 'https://img.alicdn.com/tfs/TB1YHEpwUT1gK0jSZFhXXaAtVXa-28-27.svg',
    menu: {
      locale: false
    },
    childrenRender: (children) => {
      return (
        <ConfigProvider
          theme={{ algorithm: initialState?.theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
          {children}
        </ConfigProvider>
      );
    }
  };
};

方案二通过自定义切换逻辑全局配置 (可实现整体layout区域暗黑)

效果

Snipaste_2025-11-20_17-48-49.PNG

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  return {
    layout: 'mix',
    logo: 'https://img.alicdn.com/tfs/TB1YHEpwUT1gK0jSZFhXXaAtVXa-28-27.svg',
    menu: {
      locale: false
    },
    rightRender: () => <RightRender/>, //这里内部控制切换
    childrenRender: (children) => {
      return (
        <ConfigProvider
          theme={{ algorithm: initialState?.theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
          {children}
        </ConfigProvider>
      );
    }
  };
};
import type { IUserInfo } from '@/types';
import storage from '@/utils/storage';
import {
  LogoutOutlined,
  MoonOutlined,
  SunOutlined,
  UserOutlined
} from '@ant-design/icons';
import { history, useModel } from '@umijs/max';
import {
  Avatar,
  Dropdown,
  Switch,
  message,
  theme
} from 'antd';
import React, { useState } from 'react';
import { useAntdConfigSetter } from '@umijs/max'; //  需要打开配置才不会报错 antd: { configProvider: {} },

const { darkAlgorithm, defaultAlgorithm } = theme;

const RightRender = () => {
  const { initialState, setInitialState } = useModel('@@initialState');
  // 从 initialState 获取用户信息
  const userInfo = initialState?.person as IUserInfo;

  const setAntdConfig = useAntdConfigSetter();
  console.log('setAntdConfig', setAntdConfig);

  const [dark, setDark] = useState(false);

  // 重点处理逻辑
  const handleThemeChange = (checked: boolean) => {
    console.log('checked', checked);
    setDark(checked);
    setAntdConfig({
      theme: {
        algorithm: [
          checked ? darkAlgorithm : defaultAlgorithm
        ]
      }
    });
  };

  // 退出登录处理函数
  const handleLogout = async () => {
    try {
      storage.clearAll();
      // 更新初始状态
      setInitialState(undefined);
      history.push('/login');
      message.success('退出登录成功');
    } catch (error) {
      message.error('退出登录失败');
    }
  };

  // 下拉菜单项
  const menuItems = [
    {
      key: 'logout',
      icon: <LogoutOutlined />,
      label: '退出登录',
      onClick: handleLogout
    }
  ];

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
      {/* 暗黑模式切换 */}
      <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
        {dark ? <MoonOutlined /> : <SunOutlined />}
        <Switch
          checked={dark}
          onChange={handleThemeChange}
          size="small"
        />
      </div>
      {/* 用户头像和下拉菜单 */}
      <Dropdown
        menu={{ items: menuItems }}
        placement="bottomRight"
        trigger={['hover']}
      >
        <div
          style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
        >
          {userInfo.headImg ? (
            <Avatar
              src={
                <img draggable={false} src={userInfo.headImg} alt="avatar" />
              }
            />
          ) : (
            <Avatar
              style={{ backgroundColor: 'skyblue' }}
              icon={<UserOutlined />}
            />
          )}

          <span style={{ marginLeft: '8px', fontSize: '14px' }}>
            {userInfo?.nickName || userInfo?.name || '未知用户'}
          </span>
        </div>
      </Dropdown>
    </div>
  );
};

export default RightRender;

浅谈useRef的使用和渲染机制

2025年11月20日 17:26

前言

刚开始使用react时,由于对react的hook不太了解,导致在使用useState时,出现了闭包的问题,当时搜索解决方法时,发现了useRef这个hook可以很快的解决这个问题。这里用来记录下自己对useRef这个hook的理解。

useRef的渲染机制

先要了解react中useRef和useState的区别,useState是用来管理组件状态的,而useRef是用来管理组件引用的。useState会导致组件重新渲染,而useRef不会。对应上面说的useRef解决闭包问题,其实不是react设置该hook的初衷,useRef这个hook的初衷是用来解决DOM操作问题的。能够解决闭包问题也只是其副作用之一。对于useRef的渲染机制我们可以总结以下几个关键点:

  • useRef在组件首次渲染时创建一个对象 { current: initialValue }
  • 整个组件的生命周期,不会创建新对象,返回的都是首次创建对象的引用
  • 无论如何赋值,都不会导致组件重新渲染(React通过Object.is比较检测不到变化,因此不会触发渲染)

以下是基于useRef的渲染机制的代码示例,组件使用了antd的Button和Card组件,从代码运行中我们可以看出,点击更新Ref值按钮,组件没有重新渲染,但是点击更新State值按钮,组件会重新渲染。

CD644814-FDB9-4da7-8C77-431524328C0B.png

import { Button, Card } from "antd";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";

const DemoRef = () => {
    const ref = useRef<any>(null)
    // 渲染次数
    const renderCountRef = useRef(1);
    const renderValueRef = useRef<any>(0);
    const [renderValue, setRenderValue] = useState<any>(0);

    useEffect(() => {
        renderCountRef.current = renderCountRef.current + 1;
        console.log(`🔄 组件第 ${renderCountRef.current + 1} 次渲染`);
    });

    return <div className="flex w-full">
        <Card title="useRef的渲染机制" className="ml-12px" hoverable={true}>
            <div className="mt-12px max-w-200px text-[#e74c3c] bg-[#fffacd] p-4 text-[18px] font-bold flex w-full flex-row w-400px">
                组件渲染此时:<span className="font-bold">{renderCountRef.current}</span>
            </div>
            <Button onClick={() => {
                renderValueRef.current = renderCountRef.current + 1;
            }} className="mt-12px">更新Ref值</Button>
            <div className="mt-12px font-bold mb-12px">当前Ref值:{renderValueRef.current}(点击虽然新增了,但是组件没有重新渲染,导致此处仍然时老的值)</div>
            <Button onClick={() => {
                setRenderValue((pre: number) => pre + 1);
            }}>更新State值</Button>
            <div className="mt-12px font-bold">当前State值:{renderValue}(点击会触发组件重新渲染,导致此处的值会更新)</div>
        </Card>

    </div>
}

useRef解决的问题

  • 解决闭包问题:useRef能够在闭包函数中访问到最新的状态或属性是因为.current属性的引用不会改变。实际编码中以下两个场景会产生闭包,计时器显示和事件函数监听,下面分享下useRef在这两种场景的应用

    • 计时器中使用最新的状态或属性
    const [duration, setDuration] = useState(0);
    const durationRef = useRef(duration);
    useEffect(() => {
      const interval = setInterval(() => {
        durationRef.current = durationRef.current + 1;
        setDuration(durationRef.current);
      }, 1000);
      return () => clearInterval(interval);
    }, []);
    
    • 事件处理函数中使用最新的状态或属性,此处不再列举代码和说明,因为和计时器的场景类似。
  • react组件中DOM操作:useRef可以用来操作DOM元素,例如获取输入框的值、滚动到指定位置等。 const inputRef = useRef(null); const handleClick = () => { inputRef.current.focus(); };

  • 解决性能问题,方便避免重复创建ref的内容

useRef的好兄弟forwardRef

在日常的开发中,多层组件嵌套是常有的场景,例如父组件中嵌套子组件,子组件中又嵌套孙子组件等。在这种场景下,我们偶尔会需要在父组件中操作子组件的DOM。这时候,如果直接在子组件上使用useRef,会获取不到子组件的DOM并且控制还会报错,原因是react为了保证组件的封装性,默认情况下自定义的组件是不会暴漏其内部DOM节点的ref,具体错误大家可以自己试试。报错提示中会提示我们在子组件中需要使用forwardRef来转发ref。

  • 以下是基于forwardRef的代码示例,从代码运行中我们可以看出,点击设置子组件的年龄为18按钮,子组件的年龄会更新为18。
  • 使用方式:子组件使用forwardRef包裹,需要转发的方式使用useImperativeHandle
import { Button, Card } from "antd";
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";

const LoginForm = forwardRef((props: any, ref: React.ForwardedRef<{ reset: (flag: boolean) => void; }>) => {
    const { name } = props;
    const [formData, setFormData] = useState({
        username: '',
        password: ''
    });
    useImperativeHandle(ref, () => ({
        // 重置表单内容
        reset: () => {
            setFormData({
                username: '',
                password: ''
            })
        },
    }));
    return <div className="mt-12px font-bold">
        <h2>{name}</h2>
        <div>
            <div className="mt-12px">
                用户名:<Input type="text" value={formData.username} onChange={(e) => {
                    setFormData({
                        ...formData,
                        username: e.target.value
                    })
                }} />
            </div>
            <div className="mt-12px">
                密码:<Input type="password" value={formData.password} onChange={(e) => {
                    setFormData({
                        ...formData,
                        password: e.target.value
                    })
                }} />
            </div>
        </div>
    </div>
})
//父组件
  <Card title="useRef和useForwardRef的组合" className="ml-12px" hoverable={true}>
            <Button onClick={() => {
                childRef.current?.reset();
            }} type="primary">重置</Button>
            <Divider></Divider>
            <LoginForm name="登录表单" ref={childRef} />
        </Card>
  • forwardsRef注意点
    • forwardsRef和useRef组合很方便操作子组件的DOM,但是我们尽量避免在父组件中直接操作子组件的DOM,因为这会破坏组件的封装性,导致代码难以维护。
    • forwardsRef和useRef组合通过useImperativeHandle转发的方法,我们可以在父组件中控制子组件的状态,但是如非必要此种也尽量少用,优先用 useState + props 传递状态(如父组件通过 isVisible props 控制子组件弹窗),而非用 ref 调用方法(ref 仅用于 “必须操作 DOM / 内部方法” 的场景)

从图片到点阵:用JavaScript重现复古数码点阵艺术图

作者 军军360
2025年11月20日 17:24

从图片到点阵:用JavaScript重现复古数码点阵艺术图

在数字图像的世界里,我们有时会痴迷于一种复古的美学——点阵图。从早期的打印机输出到LED广告牌,那种由无数小圆点构成的图像,散发着独特的科技感和艺术气息。今天,我们将一起探索如何使用现代Web技术(HTML5 Canvas和JavaScript),在浏览器中实现将普通图片实时转换为点阵图的效果。

image.png

一、效果展示与核心思路

最终效果:在网页上上传一张图片,它将立刻被转换成一个由许多小圆点组成的、具有黑白版画风格的图像。你可以通过调整参数来控制点阵的疏密和大小。

核心思路

  1. 绘制原图:将用户上传的图片绘制到一个隐藏的Canvas上。
  2. 网格采样:将这个Canvas划分成均匀的网格。每个网格单元最终会对应点阵图中的一个“点”(或留白)。
  3. 计算灰度:对于每个网格单元,我们计算其内部所有像素的平均亮度(或称灰度值)。
  4. 阈值判定:根据一个预设的阈值,决定当前网格是“画点”还是“不画点”。如果该区域的平均亮度低于阈值(表示较暗),我们就画一个实心圆点;如果亮度较高,则留白。
  5. 绘制点阵:在另一个Canvas上,根据步骤4的判定结果,在对应的网格位置绘制圆点。

二、代码实现(附详细注释)

让我们直接看代码,这是理解整个过程最直观的方式。

HTML结构

创建一个简单的上传界面和两个Canvas:一个用于幕后处理原图,一个用于展示最终的点阵艺术。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>图片点阵化工具</title>
    <style>
        body {
            font-family: sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 20px;
        }
        .controls {
            margin-bottom: 20px;
        }
        #originalCanvas {
            display: none; /* 隐藏处理用的Canvas */
        }
        #dotMatrixCanvas {
            border: 1px solid #ccc;
            margin-top: 20px;
        }
    </style>
</head>
<body>
    <h1>图片点阵化效果</h1>
    
    <div class="controls">
        <input type="file" id="imageUpload" accept="image/*">
        <br>
        <label>点大小: <input type="range" id="dotSizeSlider" min="2" max="20" value="6"></label>
        <span id="dotSizeValue">6</span>
        <br>
        <label>点间距: <input type="range" id="spacingSlider" min="5" max="50" value="15"></label>
        <span id="spacingValue">15</span>
        <br>
        <label>阈值 (值越小点越少): <input type="range" id="thresholdSlider" min="0" max="255" value="128"></label>
        <span id="thresholdValue">128</span>
    </div>

    <!-- 用于处理原始图像的Canvas,不显示 -->
    <canvas id="originalCanvas"></canvas>
    
    <!-- 用于显示点阵效果的Canvas -->
    <canvas id="dotMatrixCanvas"></canvas>

    <script src="script.js"></script>
</body>
</html>

JavaScript核心逻辑 (script.js)

这是实现点阵化效果的核心代码。

// 获取DOM元素
const fileInput = document.getElementById('imageUpload');
const dotSizeSlider = document.getElementById('dotSizeSlider');
const spacingSlider = document.getElementById('spacingSlider');
const thresholdSlider = document.getElementById('thresholdSlider');
const dotSizeValue = document.getElementById('dotSizeValue');
const spacingValue = document.getElementById('spacingValue');
const thresholdValue = document.getElementById('thresholdValue');
const originalCanvas = document.getElementById('originalCanvas');
const dotMatrixCanvas = document.getElementById('dotMatrixCanvas');

const ctxOriginal = originalCanvas.getContext('2d');
const ctxDotMatrix = dotMatrixCanvas.getContext('2d');

// 初始化变量
let dotSize = parseInt(dotSizeSlider.value);
let spacing = parseInt(spacingSlider.value);
let threshold = parseInt(thresholdSlider.value);

// 更新显示值的函数
function updateSliderValues() {
    dotSizeValue.textContent = dotSize;
    spacingValue.textContent = spacing;
    thresholdValue.textContent = threshold;
}

// 监听滑块变化
dotSizeSlider.addEventListener('input', (e) => {
    dotSize = parseInt(e.target.value);
    updateSliderValues();
    if (currentImage) convertToDotMatrix(currentImage);
});

spacingSlider.addEventListener('input', (e) => {
    spacing = parseInt(e.target.value);
    updateSliderValues();
    if (currentImage) convertToDotMatrix(currentImage);
});

thresholdSlider.addEventListener('input', (e) => {
    threshold = parseInt(e.target.value);
    updateSliderValues();
    if (currentImage) convertToDotMatrix(currentImage);
});

let currentImage = null;

// 监听文件上传
fileInput.addEventListener('change', function(e) {
    const file = e.target.files[0];
    if (!file || !file.type.match('image.*')) return;

    const reader = new FileReader();
    
    reader.onload = function(event) {
        const img = new Image();
        img.onload = function() {
            currentImage = img;
            convertToDotMatrix(img);
        };
        img.src = event.target.result;
    };
    reader.readAsDataURL(file);
});

// 核心函数:将图片转换为点阵
function convertToDotMatrix(image) {
    // 1. 设置原始Canvas尺寸为图片尺寸,并绘制图片
    originalCanvas.width = image.width;
    originalCanvas.height = image.height;
    ctxOriginal.clearRect(0, 0, originalCanvas.width, originalCanvas.height);
    ctxOriginal.drawImage(image, 0, 0);

    // 2. 计算点阵Canvas的尺寸
    // 点阵图的宽高由网格数量(原图尺寸/间距)和点的大小决定
    const cols = Math.ceil(originalCanvas.width / spacing);
    const rows = Math.ceil(originalCanvas.height / spacing);
    
    dotMatrixCanvas.width = cols * spacing;
    dotMatrixCanvas.height = rows * spacing;

    // 3. 清除点阵Canvas,设置白色背景
    ctxDotMatrix.fillStyle = 'white';
    ctxDotMatrix.fillRect(0, 0, dotMatrixCanvas.width, dotMatrixCanvas.height);
    ctxDotMatrix.fillStyle = 'black'; // 设置点的颜色为黑色

    // 4. 获取原始Canvas的像素数据
    // ImageData.data 是一个一维数组,包含 [R, G, B, A, R, G, B, A, ...] 格式的数据
    const imageData = ctxOriginal.getImageData(0, 0, originalCanvas.width, originalCanvas.height);
    const data = imageData.data;

    // 5. 遍历网格,进行采样和绘制
    for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
            // 计算当前网格在原始图像上的起始像素位置
            const startX = x * spacing;
            const startY = y * spacing;

            // 6. 计算当前网格区域的平均亮度
            let totalBrightness = 0;
            let sampleCount = 0;

            // 在网格内采样像素(可以优化为间隔采样以提高性能)
            for (let subY = startY; subY < startY + spacing && subY < originalCanvas.height; subY++) {
                for (let subX = startX; subX < startX + spacing && subX < originalCanvas.width; subX++) {
                    // 计算当前像素在ImageData数组中的索引
                    const pixelIndex = (subY * originalCanvas.width + subX) * 4;
                    const r = data[pixelIndex];     // 红色值 (0-255)
                    const g = data[pixelIndex + 1]; // 绿色值 (0-255)
                    const b = data[pixelIndex + 2]; // 蓝色值 (0-255)

                    // !!!核心知识点:计算像素的亮度(灰度值)
                    // 使用标准亮度公式,模拟人眼对不同颜色的敏感度
                    // 权重:绿色最敏感(0.587) > 红色次之(0.299) > 蓝色最不敏感(0.114)
                    const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
                    
                    totalBrightness += brightness;
                    sampleCount++;
                }
            }

            // 计算网格区域的平均亮度
            const averageBrightness = totalBrightness / sampleCount;

            // 7. 阈值判定:如果平均亮度低于阈值,则绘制圆点
            if (averageBrightness < threshold) {
                // 计算圆点在点阵Canvas上的中心坐标
                const posX = x * spacing + spacing / 2;
                const posY = y * spacing + spacing / 2;

                // 绘制实心圆
                ctxDotMatrix.beginPath();
                ctxDotMatrix.arc(posX, posY, dotSize / 2, 0, Math.PI * 2);
                ctxDotMatrix.fill();
            }
            // 否则(亮度高于阈值),留白,不进行绘制
        }
    }
}

// 初始化滑块显示值
updateSliderValues();

三、核心原理解析:亮度公式

代码中最关键的一行是:

const brightness = 0.299 * r + 0.587 * g + 0.114 * b;

这行代码使用的是灰度转换的标准算法(ITU-R BT.601) 。为什么不能简单地将RGB值平均 (r + g + b) / 3呢?

因为人眼视网膜上的三种感光细胞对不同颜色的敏感度是不同的:对绿色最敏感,红色次之,蓝色最不敏感。这个加权平均公式(绿色权重0.587最高,蓝色0.114最低)能够计算出更符合人眼主观亮度感知的灰度值,使得转换后的点阵图明暗关系更加自然和准确。

四、参数调整与效果优化

通过操作界面上的三个滑块,你可以创造出风格迥异的点阵效果:

  • 点大小 (dotSize) :控制每个圆点的半径。点越大,图像越粗犷,细节越少;点越小,则越精细。

  • 点间距 (spacing) :控制网格的密度。间距越大,点阵越稀疏,图像越抽象;间距越小,点阵越密集,保留的细节越多。

  • 阈值 (threshold) :这是控制图像对比度的关键参数。

    • 调低阈值 (如 50) :只有非常暗的区域才会画点,生成的图像点很少,整体很“淡”。
    • 调高阈值 (如 200) :大量灰色区域也会被判定为需要画点,生成的图像点很密集,整体很“浓”,对比度降低。

小技巧:尝试使用高间距 + 大点尺寸来创造抽象的艺术海报效果,或者使用低间距 + 小点尺寸来制作精细的肖像邮票效果。

五、总结

通过这个项目,仅实现了一个有趣的图像处理工具,还深入理解了像素操作、灰度转换和采样等基本图形学概念。这个基础版本还有巨大的拓展空间:

  1. 彩色点阵:可以为暗、中、亮部区域分配不同的颜色,而不是只用黑色。
  2. 异形点:将圆点替换为方形、三角形甚至自定义形状。
  3. 动态化:将点阵化效果应用于视频流,实现实时点阵摄像头。
  4. 性能优化:对于大图,可以采用间隔采样等策略提升处理速度。

一文解析得物自建 Redis 最新技术演进

作者 得物技术
2025年11月20日 14:42

一、前 言

自建 Redis 上线 3 年多以来,一直围绕着技术架构、性能提升、降低成本、自动化运维等方面持续进行技术演进迭代,力求为公司业务提供性能更高、成本更低的分布式缓存集群,通过自动化运维方式提升运维效率。

本文将从接入方式、同城双活就近读、Redis-server 版本与能力、实例架构与规格、自动化运维等多个方面分享一下自建 Redis 最新的技术演进。

二、规模现状

随着公司业务增长,自建 Redis 管理的 Redis 缓存规模也一直在持续增长,目前自建 Redis 总共管理 1000+集群,内存总规格 160T,10W+数据节点,机器数量数千台,其中内存规格超过 1T 的大容量集群数十个,单个集群最大访问 QPS 接近千万。

三、技术演进介绍

3.1 自建Redis系统架构

下图为自建Redis系统架构示意图:

自建 Redis 架构示意图

自建Redis集群由Redis-server、Redis-proxy、ConfigServer 等核心组件组成。

  • Redis-server 为数据存储组件,支持一主多从,主从多可用区部署,提供高可用、高性能的服务;
  • Redis-proxy 为代理组件,业务通过 proxy 可以像使用单点实例一样访问 Redis 集群,使用更简单,并且在Redis-proxy 上提供同区优先就近读、key 维度或者命令维度限流等高级功能;
  • ConfigServer 为负责 Redis 集群高可用的组件。

自建 Redis 接入方式支持通过域名+LB、service、SDK 直连(推荐)等多种方式访问 Redis 集群。

自建 Redis 系统还包含一个功能完善的自动化运维平台,其主要功能包括:

  • Redis 集群实例从创建、proxy 与 server 扩缩容、到实例下线等全生命周期自动化运维管理能力;
  • 业务需求自助申请工单与工单自动化执行;
  • 资源(包含 ECS、LB)精细化管理与自动智能分配能力、资源报表统计与展示;
  • ECS 资源定期巡检、自动均衡与节点智能调度;
  • 集群大 key、热 key 等诊断与分析,集群数据自助查询。

下面将就一些重要的最新技术演进进行详细介绍。

3.2 接入方式演进

自建 Redis 提升稳定性的非常重要的一个技术演进就是自研 DRedis SDK,业务接入自建 Redis 方式从原有通过域名+LB 的方式访问演进为通过 DRedis SDK 连接 proxy 访问。

LB接入问题

在自建 Redis 初期,为了方便业务使用,使用方式保持与云 Redis 一致,通过 LB 对 proxy 做负载均衡,业务通过域名(域名绑定集群对应 LB)访问集群,业务接入简单,像使用一个单点 Redis 一样使用集群,并且与云 Redis 配置方式一致,接入成本低。

随着自建 Redis 规模增长,尤其是大流量业务日渐增多,通过 LB 接入方式的逐渐暴露出很多个问题,部分问题还非常棘手:

  • 自建 Redis 使用的单个 LB 流量上限为5Gb,阈值比较小,对于一些大流量业务单个 LB 难以承接其流量,需要绑定多个LB,增加了运维复杂度,而且多个 LB 时可能会出现流量倾斜问题;
  • LB组件作为访问入口,可能会受到网络异常流量攻击,导致集群访问受损;
  • 由于Redis访问均是TCP连接,LB摘流业务会有秒级报错。

DRedis接入

自建Redis通过自研DRedis SDK,通过SDK直连 proxy,不再强依赖 LB,彻底解决 LB 瓶颈和稳定性风险问题,同时,DRedis SDK 默认优先访问同可用区 proxy,天然支持同城双活就近读。

DRedis SDK系统设计图如下所示:

Redis-proxy 启动并且获取到集群拓扑信息后,自动注册到注册中心;可通过管控白屏化操作向配置中心配置集群使用的 proxy 分组与权重、就近读规则等信息;DRedis SDK 启动后,从配置中心获取到 proxy 分组与权重、就近读规则,从注册中心获取到 proxy 节点信息,然后与对应 proxy 节点建立连接;应用通过 DRedis SDK 访问数据时,DRedis SDK 通过加权轮询算法获取一个 proxy 节点(默认优先同可用区)及对应连接,进行数据访问。

DRedis SDK并且对原生 RESP 协议进行了增强,添加了一部分自定义协议,支持业务灵活开启就近读能力,对于满足就近读规则的 key 访问、或者通过注解指定的就近读请求,DRedis SDK通过自定义协议信息,通知 proxy 在执行对应请求时,优先访问同可用区 server 节点。

DRedis SDK 目前支持 Java、Golang、C++(即将上线)三种开发语言。

  • Java SDK 基于 Redisson 客户端二次开发,后续还会新增基于 Jedis 二次开发版本,供业务灵活选择,并且集成到 fusion 框架中
  • Golang SDK 基于 go-Redis v9 进行二次开
  • C++ SDK 基于 brpc 二次开发

DRedis 接入优势

业务通过 DRedis SDK 接入自建 Redis,在稳定性、性能等方面都能得到大幅提升,同时能降低使用成本。

社区某应用升级后,业务 RT 下降明显,如下图所示:

DRedis 接入现状

DRedis SDK目前在公司内部大部分业务域的应用完成升级。

Java 和 Golang 应用目前接入上线超过300+

3.3 同城双活就近读

自建 Redis 同城双活采用中心写就近读的方案实现,可以降低业务多区部署时访问 Redis RT。

同城双活就近读场景下,业务访问 Redis 时,需要 SDK 优先访问同可用区proxy,proxy 优先访问同可用区 server节点,其中proxy优先访问同区 server 节点由 proxy 实现,但是在自研 DRedis SDK 之前,LB 无法自动识别应用所在同区的 proxy 并自动路由,因此需要借助service 的同区就近路由能力,同城双活就近读需要通过容器 proxy+service 接入。

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy。

service接入问题

目前,自建 Redis server 和 proxy 节点基本都是部署在 ECS 上,并且由于 server 节点主要消耗内存,而 proxy 节点主要消耗 CPU,因此默认采用 proxy + server 节点混部的方式,充分利用机器的 CPU 和内存,降低成本。

而为了支持同城双活就近读,需要在容器环境部署 proxy,并创建 service,会带来如下问题:

  • 运维割裂,运维复杂度增加,除了需要运维 ECS 环境部署节点,额外增加了容器环境部署方式。
  • 成本增加,容器环境 proxy 需要独立机器部署,无法与 server 节点混部,造成成本增加。
  • RT上升,节点 CPU 更高,从实际使用效果来看,容器环境 proxy 整体的 CPU 和响应 RT 都明显高于 ECS 环境部署的节点。
  • 访问不均衡,service 接入时,会出现连接和访问不均衡现象。
  • 无法定制化指定仅仅少量特定key 或者 key 前缀、指定请求开启就近读。

DRedis接入

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy;当同可用区可用 proxy 数量小于等于1个时,启用调用保护,DRedis会主动跨区访问其他可用区 proxy 节点。

通过service接入方式支持同城双活就近读,是需要在 proxy 上统一开启就近读配置,开启后,对全局读请求均生效,所有读请求都默认优先同区访问。

由于 Redis 主从复制为异步复制,主从复制可能存在延迟,理论上在备可用区可能存在读取到的不是最新数据

某些特定业务场景下,业务可能在某些场景能够接受就近读,但是其他一些场景需要保证强一致性,无法接受就近读,通过 service 接入方式时无法灵活应对这种场景。

DRedis SDK 提供了两种方式供这种场景下业务使用:

  • 支持指定 key 精确匹配或者 key 前缀匹配的方式,定向启用就近读。
  • Java 支持通过声明式注解(@NearRead)指定某次请求采用就近读;Golang 新增 80 个类似 xxxxNearby 读命令,支持就近读。

使用以上两种方式指定特定请求使用就近读时,无需 proxy 上统一配置同区优先就近读。默认情况下,所有读请求访问主节点,业务上对 RT 要求高、一致性要求低的请求可以通过以上两种方式指定优先同区就近读。

3.4 Redis-server版本与能力

在自建Redis 初期,由于业务在前期使用云Redis产品时均是使用Redis4.0 版本,因此自建 Redis 初期也是选择 Redis4.0 版本作为主版本,随着 Redis 社区新版本发布,结合当前业界使用的主流版本,自建Redis也新增了 Redis6.2 版本,并且将 Redis6.2 版本作为新集群默认版本。

不管是 Redis4.0 还是 Redis6.2 版本,均支持了多线程特性、实时热 key 统计能力、水平扩容异步迁移 slot 能力,存量集群随着日常资源均衡迁移调度,集群节点版本会自动升级到同版本的最新安装包。

  • 多线程特性

Redis6.2 版本支持 IO 多线程,在 Redis 处理读写业务请求数据时使用多线程处理,提高 IO 处理能力,自建 Redis 将多线程能力也移植到了 Redis4.0 版本,测试团队测试显示,开启多线程,读写性能提升明显。

多线程版本 VS 普通版本

多线程版本 VS 云产品5.0版本

  • 实时热 key 统计

自建 Redis4.0 和 Redis6.2 版本均支持 Redis 服务端实时热 key 统计能力,管控台白屏化展示,方便快速排查热 key 导致的集群性能问题。方案详细可阅读《基于Redis内核的热key统计实现方案》

  • 水平扩容异步迁移

自建 Redis 支持水平扩容异步数据迁移,解决大 key 无法迁移或者迁移失败的稳定性问题,支持多 key 并发迁移,几亿 key 数据在默认配置下水平扩容时间从平均 4 小时缩短到 10 分钟性能提升 20 倍,对业务RT影响下降 90% 以上

算法某实例 2.5 亿 key 水平扩容花费时间和迁移过程对业务 RT 影响

3.5 实例架构与规格

Redis单点主备模式

自建 Redis 实例默认均采用集群架构,但是通过 proxy 代理屏蔽集群架构细节,集群架构对业务透明,业务像使用一个单点 Redis 实例一样使用 Redis 集群。

但是集群架构下,由于底层涉及多个分片,不同 key 可能存在在不同分片,并且随着水平扩容,key所在分片可能会发生变化,因此,集群架构下,对于一些多 key 命令(如 eval、evalsha、BLPOP等)要求命令中所有 key 必须属于同一个slot。因此集群架构下,部分命令访问与单点还是有点差异。

实际使用中,有少数业务由于依赖了一些开源的三方组件,其中可能由于存储非常少量的数据,所以使用到 Redis 单点主备模式实例,因此,考虑到这种场景,自建 Redis 在集群架构基础上,也支持了Redis 单点主备模式可供选择。

一主多从规格

自建 Redis 支持一主多从规格用于跨区容灾,提供更快的 HA 效率,当前支持一主一从(默认),一主两从、一主三从 3 种副本规格,支持配置读写分离策略提升系统性能(一主多从规格下,开启读写分离,可以有多个分片承接读流量)

一主一从

一主两从

一主三从

  • 一主一从时默认主备可用区各部署一个副本(master在主可用区)
  • 一主两从时默认主可用区部署一主一从,备可用区部署一从副本
  • 一主三从时默认主可用区部署一主一从,备可用区部署两从副本

3.6 proxy限流

为了应对异常突发流量导致的业务访问性能下降,自建 Redis-proxy 支持限流能力

有部分业务可能存在特殊的已知大key,业务中正常逻辑也不会调用查询大 key 全量数据命令,如 hgetall、smembers 等,查询大 key 全量数据会导致节点性能下降,极端情况下会导致节点主从切换,因此,自建Redis 也支持配置命令黑名单,在特定的集群,禁用某些特定的命令

  • 支持 key 维度限流,指定 key 访问 QPS 阈值
  • 支持命令维度限流,指定命令访问 QPS 阈值
  • 支持命令黑名单,添加黑名单后,该实例禁用此命令

3.7 自动化运维

自建 Redis 系统还包含一个功能完善的自动化运维平台,一直以来,自建Redis一直在完善系统自动化运维能力,通过丰富的自动化运维能力,实现集群全生命周期自动化管理,资源管理与智能调度,故障自动恢复等,提高资源利用率、降低成本,提高运维效率。

  • 资源池自动化均衡调度

自建 Redis 资源池支持按内存使用率自动化均衡调度、按内存分配率自动化均衡调度、按 CPU 使用率均衡调度、支持指定机器凌晨迁移调度(隐患机器提前维护)等功能,均衡资源池中所有资源的负载,提高资源利用率。

  • 集群自动部署与下线

当业务提交集群申请工单审批通过后,判断是否支持自建,如符合自建则自动化进行集群部署和部署结果校验,校验集群可用性后自动给业务交付集群信息,整个过程高效快速。

业务提交集群下线工单后,自动检测是否满足下线条件,比如是否存在访问连接,如满足下线条件,则自动释放 proxy 资源,保留 7 天后自动回收 server 节点资源,在7 天内,如果存在特殊业务仍在使用的情况,还支持快速恢复使用。

  • 资源管理

对 ECS 机器资源和 LB 资源进行打标,根据特殊业务需要做不同资源池的隔离调度,支持在集群部署与扩容时,资源自动智能化分配。

  • 集群扩缩容

自建 Redis 支持 server 自动垂直扩容,业务申请集群时,可以选择是否开启自动扩容,如果开启自动扩容,当集群内存使用率达到80%时,系统会自动进行垂直扩容,对业务完全无感,快速应对业务容量上涨场景。

ecs-proxy,docker-proxy扩容,server节点的扩缩容也支持工单自动化操作,业务提交工单后,系统自动执行。

  • 工单自动化

当前80%以上的运维场景已完成工单自动化,如 Biz 申请、创建实例、密码申请、权限申请、删除key、实例升降配,集群下线等均完成工单自动化。业务提单审批通过后自动校验执行,执行完成后自动发送工单执行结果通知。

  • 告警自动化处理

系统会自动检测机器宕机事件,如发现机器宕机重启,会自动拉起机器上所有节点,快速恢复故障,提高运维效率。

关于自建 Redis 自动化运维能力提升详细设计细节,后续会专门分享,敬请期待。

四、总结

本文详细介绍了自建 Redis 最新技术演进,详细介绍了自研 DRedis SDK优势与目前使用现状,以及 DRedis 在同城双活就近读场景下,可以更精细化的控制部分请求采用优先同区就近读。

介绍了自建 Redis 目前支持最新的 Redis6.2版本,以及在 Redis4.0 和 Redis6.2 版本均支持多线程 IO 能力、实时热 key 统计能力、水平扩容异步迁移能力。自建 Redis 除了支持集群架构,也支持单点主备架构实例申请,同时支持一主多从副本规格,可以提供可靠性和读请求能力(读写分离场景下)。自建 Redis-proxy 也支持多种限流方式,包括 key 维度、命令维度等。

自建 Redis 自动化运维平台支持强大的自动化运维能力,提高资源利用率,降低成本,提高运维效率。

自建 Redis 经过长期的技术迭代演进,目前支持的命令和功能上完全对比云 Redis,同时,自建 Redis 拥有其他一些特色的能力与优势,比如不再依赖LB、支持自动垂直扩容、支持同区优先就近读等。

往期回顾

1. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

2. RN与hawk碰撞的火花之C++异常捕获|得物技术

3. 得物TiDB升级实践

4. 得物管理类目配置线上化:从业务痛点到技术实现

5. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

文 /竹径

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

第4章:布局类组件 —— 4.5 流式布局(Wrap、Flow)

作者 旧时光_
2025年11月19日 17:00

4.5 流式布局(Wrap、Flow)

📚 章节概览

流式布局是指超出屏幕范围会自动换行的布局方式,本章节将学习:

  • Row/Column溢出问题 - 为什么需要流式布局
  • Wrap - 自动换行布局
  • spacing - 主轴方向间距
  • runSpacing - 纵轴方向间距
  • alignment - 对齐方式
  • Flow - 高性能自定义布局
  • FlowDelegate - 自定义布局策略

🎯 核心知识点

什么是流式布局

当子组件超出父容器范围时,自动换行的布局方式称为流式布局。

// ❌ Row会溢出
Row(
  children: [
    Text('很长的文本' * 100),  // 超出屏幕 → 报错
  ],
)

// ✅ Wrap自动换行
Wrap(
  children: [
    Text('很长的文本' * 100),  // 超出屏幕 → 自动换行
  ],
)

Wrap vs Flow

特性 Wrap Flow
易用性 ⭐⭐⭐⭐⭐ ⭐⭐
性能 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
灵活性 ⭐⭐⭐ ⭐⭐⭐⭐⭐
推荐度 ⭐⭐⭐⭐⭐ ⭐⭐

建议: 90%的场景用 Wrap 即可


1️⃣ Row/Column的溢出问题

1.1 溢出示例

Row(
  children: [
    Text('xxx' * 100),  // 超长文本
  ],
)

运行效果:

xxxxxxxxxxxxxxxxxxxxx...  ⚠️ OVERFLOW

错误信息:

A RenderFlex overflowed by XXX pixels on the right.

1.2 传统解决方案

方案1:SingleChildScrollView(滚动)
SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Row(
    children: [
      Text('很长的文本'),
      Text('很长的文本'),
    ],
  ),
)

缺点: 需要手动滚动,不适合多行展示

方案2:Expanded(截断)
Row(
  children: [
    Expanded(
      child: Text(
        '很长的文本',
        overflow: TextOverflow.ellipsis,  // 省略号
      ),
    ),
  ],
)

缺点: 内容被截断,信息不完整

方案3:Wrap(自动换行)✅
Wrap(
  children: [
    Text('很长的文本'),
    Text('很长的文本'),
  ],
)

优点: 自动换行,内容完整显示


2️⃣ Wrap(自动换行布局)

2.1 构造函数

Wrap({
  Key? key,
  Axis direction = Axis.horizontal,              // 主轴方向
  WrapAlignment alignment = WrapAlignment.start, // 主轴对齐
  double spacing = 0.0,                          // 主轴间距
  WrapAlignment runAlignment = WrapAlignment.start, // 纵轴对齐
  double runSpacing = 0.0,                       // 纵轴间距
  WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start, // 交叉轴对齐
  TextDirection? textDirection,                  // 文本方向
  VerticalDirection verticalDirection = VerticalDirection.down, // 垂直方向
  List<Widget> children = const <Widget>[],      // 子组件
})

2.2 主要属性

属性 类型 默认值 说明
direction Axis horizontal 主轴方向
alignment WrapAlignment start 主轴对齐方式
spacing double 0.0 主轴方向子组件间距
runAlignment WrapAlignment start 纵轴对齐方式
runSpacing double 0.0 纵轴方向行间距
crossAxisAlignment WrapCrossAlignment start 交叉轴对齐

2.3 基础用法

Wrap(
  children: [
    Chip(label: Text('Flutter')),
    Chip(label: Text('Dart')),
    Chip(label: Text('iOS')),
    Chip(label: Text('Android')),
    Chip(label: Text('Web')),
  ],
)

效果: 超出宽度自动换行


3️⃣ spacing 和 runSpacing

3.1 spacing(主轴间距)

控制同一行内子组件之间的间距。

Wrap(
  spacing: 8.0,  // 水平间距8
  children: [
    Chip(label: Text('A')),
    Chip(label: Text('B')),
    Chip(label: Text('C')),
  ],
)

效果:

[A] 8px [B] 8px [C]

3.2 runSpacing(纵轴间距)

控制不同行之间的间距。

Wrap(
  runSpacing: 12.0,  // 行间距12
  children: [
    Chip(label: Text('A')),
    Chip(label: Text('B')),
    Chip(label: Text('C')),
    // ... 更多组件,自动换行
  ],
)

效果:

[A] [B] [C]
↕️ 12px
[D] [E] [F]

3.3 同时使用

Wrap(
  spacing: 8.0,     // 水平间距
  runSpacing: 12.0, // 垂直间距
  children: [
    Chip(label: Text('Flutter')),
    Chip(label: Text('Dart')),
    Chip(label: Text('iOS')),
    Chip(label: Text('Android')),
  ],
)

可视化效果:

[Flutter] 8px [Dart] 8px [iOS]
↕️ 12px
[Android]

4️⃣ alignment(对齐方式)

4.1 WrapAlignment枚举值

枚举值 说明 效果
start 起始对齐(默认) 从左到右
end 末尾对齐 从右到左
center 居中对齐 居中排列
spaceBetween 两端对齐 两端贴边,均分间距
spaceAround 间距环绕 每个组件两侧间距相等
spaceEvenly 间距均分 所有间距完全相等

4.2 示例对比

start(默认)
Wrap(
  alignment: WrapAlignment.start,
  children: [
    Chip(label: Text('A')),
    Chip(label: Text('B')),
    Chip(label: Text('C')),
  ],
)

效果:

[A][B][C]_____________________
center
Wrap(
  alignment: WrapAlignment.center,
  children: [
    Chip(label: Text('A')),
    Chip(label: Text('B')),
    Chip(label: Text('C')),
  ],
)

效果:

__________[A][B][C]___________
spaceBetween
Wrap(
  alignment: WrapAlignment.spaceBetween,
  spacing: 8,
  children: [
    Chip(label: Text('A')),
    Chip(label: Text('B')),
    Chip(label: Text('C')),
  ],
)

效果:

[A]___________[B]___________[C]

4.3 runAlignment(纵轴对齐)

控制多行之间的对齐方式。

SizedBox(
  height: 200,
  child: Wrap(
    runAlignment: WrapAlignment.center,  // 垂直居中
    children: [...],
  ),
)

5️⃣ Wrap实际应用

应用1:标签云(Tag Cloud)

class TagCloud extends StatelessWidget {
  final List<String> tags;

  const TagCloud({required this.tags});

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: tags.map((tag) {
        return Chip(
          label: Text(tag),
          avatar: CircleAvatar(
            backgroundColor: Colors.blue,
            child: Text(tag[0]),
          ),
        );
      }).toList(),
    );
  }
}

// 使用
TagCloud(
  tags: ['Flutter', 'Dart', 'iOS', 'Android', 'Web'],
)

应用2:可选择标签

class SelectableTags extends StatefulWidget {
  @override
  _SelectableTagsState createState() => _SelectableTagsState();
}

class _SelectableTagsState extends State<SelectableTags> {
  final List<String> _allTags = ['前端', '后端', '移动端', '算法'];
  final List<String> _selected = [];

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: _allTags.map((tag) {
        final isSelected = _selected.contains(tag);
        return FilterChip(
          label: Text(tag),
          selected: isSelected,
          onSelected: (selected) {
            setState(() {
              if (selected) {
                _selected.add(tag);
              } else {
                _selected.remove(tag);
              }
            });
          },
        );
      }).toList(),
    );
  }
}

应用3:图片网格(自适应列数)

Wrap(
  spacing: 8,
  runSpacing: 8,
  children: List.generate(
    20,
    (index) => Container(
      width: 100,
      height: 100,
      color: Colors.blue,
      child: Center(child: Text('$index')),
    ),
  ),
)

优点: 根据屏幕宽度自动调整列数


6️⃣ Flow(高性能自定义布局)

6.1 什么是Flow

Flow 是一个对子组件尺寸和位置调整非常高效的控件。

6.2 Flow的优缺点

✅ 优点
  1. 性能好

    • 使用转换矩阵(Transform Matrix)优化
    • 重绘时不实际调整组件位置
    • 适合动画场景
  2. 灵活

    • 自定义布局策略
    • 完全控制子组件位置
❌ 缺点
  1. 使用复杂

    • 需要实现 FlowDelegate
    • 手动计算每个子组件位置
  2. 不能自适应

    • Flow不能自适应子组件大小
    • 必须指定固定大小

6.3 FlowDelegate

需要继承 FlowDelegate 并实现三个方法:

class MyFlowDelegate extends FlowDelegate {
  // 1. 绘制子组件(必需)
  @override
  void paintChildren(FlowPaintingContext context) {
    // 计算并绘制每个子组件
  }

  // 2. 返回Flow大小(必需)
  @override
  Size getSize(BoxConstraints constraints) {
    // 返回Flow的大小
    return Size(width, height);
  }

  // 3. 是否需要重绘(必需)
  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}

6.4 完整示例

class TestFlowDelegate extends FlowDelegate {
  final EdgeInsets margin;

  TestFlowDelegate({this.margin = EdgeInsets.zero});

  @override
  void paintChildren(FlowPaintingContext context) {
    var x = margin.left;
    var y = margin.top;

    // 遍历所有子组件
    for (int i = 0; i < context.childCount; i++) {
      var childSize = context.getChildSize(i)!;
      var w = childSize.width + x + margin.right;

      // 判断是否需要换行
      if (w < context.size.width) {
        // 当前行能放下
        context.paintChild(
          i,
          transform: Matrix4.translationValues(x, y, 0.0),
        );
        x = w + margin.left;
      } else {
        // 需要换行
        x = margin.left;
        y += childSize.height + margin.top + margin.bottom;
        context.paintChild(
          i,
          transform: Matrix4.translationValues(x, y, 0.0),
        );
        x += childSize.width + margin.left + margin.right;
      }
    }
  }

  @override
  Size getSize(BoxConstraints constraints) {
    // 返回固定大小
    return Size(double.infinity, 200.0);
  }

  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}

// 使用
Flow(
  delegate: TestFlowDelegate(margin: EdgeInsets.all(10)),
  children: [
    Container(width: 80, height: 80, color: Colors.red),
    Container(width: 80, height: 80, color: Colors.green),
    Container(width: 80, height: 80, color: Colors.blue),
  ],
)

6.5 FlowPaintingContext方法

方法 说明
childCount 子组件数量
getChildSize(int i) 获取第i个子组件的尺寸
paintChild(int i, {...}) 绘制第i个子组件
size Flow的尺寸

6.6 何时使用Flow

场景 推荐
简单流式布局 ❌ 用Wrap
需要动画 ✅ 用Flow
需要精确控制位置 ✅ 用Flow
性能要求极高 ✅ 用Flow
大多数情况 ❌ 用Wrap

🤔 常见问题(FAQ)

Q1: Wrap和Flow的区别?

A:

特性 Wrap Flow
易用性 简单,开箱即用 复杂,需要自定义
性能 更好(转换矩阵优化)
灵活性 固定规则 完全自定义
自适应 ✅ 自动适应子组件 ❌ 需要指定大小
推荐场景 大多数场景 动画、高性能需求

建议: 优先使用Wrap,只有在特殊需求下才用Flow

Q2: spacing和runSpacing的区别?

A:

  • spacing:主轴方向间距(同一行/列内)
  • runSpacing:纵轴方向间距(不同行/列之间)
Wrap(
  spacing: 8,     // 水平间距(同行内)
  runSpacing: 12, // 垂直间距(行之间)
  children: [
    Text('A'), Text('B'), Text('C'),
    Text('D'), Text('E'), Text('F'),
  ],
)

可视化:

[A] 8px [B] 8px [C]
↕️ 12px (runSpacing)
[D] 8px [E] 8px [F]

Q3: Wrap如何实现等宽子组件?

A: Wrap的子组件是自然宽度,不支持等宽。可以用以下方案:

方案1:固定宽度
Wrap(
  spacing: 8,
  runSpacing: 8,
  children: List.generate(10, (i) {
    return SizedBox(
      width: 100,  // 固定宽度
      child: Chip(label: Text('Item $i')),
    );
  }),
)
方案2:计算宽度
LayoutBuilder(
  builder: (context, constraints) {
    // 计算每行3个,自动计算宽度
    final itemWidth = (constraints.maxWidth - 16) / 3;
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: List.generate(10, (i) {
        return SizedBox(
          width: itemWidth,
          child: Chip(label: Text('$i')),
        );
      }),
    );
  },
)

Q4: Flow的性能为什么更好?

A: Flow使用转换矩阵(Transform Matrix)而不是实际移动组件:

// Wrap:实际改变组件位置(重新布局)
Container(
  margin: EdgeInsets.only(left: 100),  // 实际移动
  child: Widget(),
)

// Flow:使用转换矩阵(不重新布局)
context.paintChild(
  i,
  transform: Matrix4.translationValues(100, 0, 0),  // 矩阵变换
)

转换矩阵优势:

  • 不触发布局(Layout)阶段
  • 只触发绘制(Paint)阶段
  • GPU加速
  • 适合动画

Q5: Wrap如何限制最大行数?

A: Wrap本身不支持限制行数,可以结合其他组件:

// 方案1:用LimitedBox限制高度
LimitedBox(
  maxHeight: 100,  // 限制最大高度
  child: SingleChildScrollView(
    child: Wrap(
      children: [...],
    ),
  ),
)

// 方案2:手动截取子组件
Wrap(
  children: items.take(12).toList(),  // 只显示前12个
)

// 方案3:用ClipRect裁剪
ClipRect(
  child: Container(
    height: 100,
    child: Wrap(
      children: [...],
    ),
  ),
)

🎯 跟着做练习

练习1:实现一个技能标签云

目标: 创建可点击的技能标签,点击后切换选中状态

步骤:

  1. 使用Wrap布局
  2. 用ChoiceChip实现选择效果
  3. 维护选中状态
💡 查看答案
class SkillTags extends StatefulWidget {
  const SkillTags({super.key});

  @override
  State<SkillTags> createState() => _SkillTagsState();
}

class _SkillTagsState extends State<SkillTags> {
  final List<String> _skills = [
    'Flutter', 'Dart', 'iOS', 'Android',
    'React', 'Vue', 'Node.js', 'Python',
    'Java', 'Kotlin', 'Swift', 'TypeScript',
  ];
  final Set<String> _selected = {};

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          '请选择您擅长的技能:',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: _skills.map((skill) {
            final isSelected = _selected.contains(skill);
            return ChoiceChip(
              label: Text(skill),
              selected: isSelected,
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _selected.add(skill);
                  } else {
                    _selected.remove(skill);
                  }
                });
              },
            );
          }).toList(),
        ),
        if (_selected.isNotEmpty) ...[
          const SizedBox(height: 16),
          Text(
            '已选择 ${_selected.length} 项:${_selected.join('、')}',
            style: const TextStyle(color: Colors.blue),
          ),
        ],
      ],
    );
  }
}

练习2:实现一个自定义Flow动画

目标: 创建一个圆形排列的Flow布局

步骤:

  1. 继承FlowDelegate
  2. 在paintChildren中计算圆形位置
  3. 使用三角函数计算坐标
💡 查看答案
import 'dart:math';

class CircleFlowDelegate extends FlowDelegate {
  final double radius;

  CircleFlowDelegate({this.radius = 80});

  @override
  void paintChildren(FlowPaintingContext context) {
    final centerX = context.size.width / 2;
    final centerY = context.size.height / 2;

    for (int i = 0; i < context.childCount; i++) {
      final angle = (2 * pi / context.childCount) * i;
      final x = centerX + radius * cos(angle) - 20;  // 20是子组件宽度的一半
      final y = centerY + radius * sin(angle) - 20;

      context.paintChild(
        i,
        transform: Matrix4.translationValues(x, y, 0.0),
      );
    }
  }

  @override
  Size getSize(BoxConstraints constraints) {
    return Size(200, 200);
  }

  @override
  bool shouldRepaint(CircleFlowDelegate oldDelegate) {
    return radius != oldDelegate.radius;
  }
}

// 使用
Flow(
  delegate: CircleFlowDelegate(radius: 80),
  children: List.generate(
    8,
    (i) => Container(
      width: 40,
      height: 40,
      decoration: BoxDecoration(
        color: Colors.blue,
        shape: BoxShape.circle,
      ),
      child: Center(
        child: Text(
          '${i + 1}',
          style: const TextStyle(color: Colors.white),
        ),
      ),
    ),
  ),
)

📋 小结

核心概念

组件 说明 使用场景
Wrap 自动换行布局 标签云、按钮组、图片网格
Flow 高性能自定义布局 动画、复杂布局策略

Wrap常用属性

属性 说明 常用值
spacing 主轴间距 8.0
runSpacing 纵轴间距 8.0
alignment 主轴对齐 start/center
runAlignment 纵轴对齐 start/center

Flow关键方法

方法 说明
paintChildren() 计算并绘制子组件位置
getSize() 返回Flow的尺寸
shouldRepaint() 是否需要重绘

记忆技巧

  1. Wrap首选:90%场景用Wrap
  2. spacing记忆:spacing = 同行间距,runSpacing = 行间距
  3. Flow性能好:转换矩阵优化
  4. Flow很少用:除非特殊需求

🔗 相关资源


CSS 相对颜色:告别 180 个颜色变量的设计系统噩梦

作者 大知闲闲i
2025年11月19日 15:51

当组件库中的颜色变量达到 180 个时,一次品牌色变更就成了前端开发的噩梦。CSS 相对颜色语法将彻底改变这一现状。

一个让人沉默的现实

最近在排查一个组件库的主题 BUG 时,我们发现了令人震惊的事实:这个看似成熟的设计系统中,竟然定义了 180 个颜色变量

更可怕的是,每次品牌主色调整,都需要在 3 个不同的文件中同步修改 15 种深浅变化、hover 状态、透明度变体……设计同学轻描淡写的一句"主色想从偏蓝调成更紫一点",意味着工程侧需要:

  • 手动修改 15+ 个变量

  • 反复对比 hover、active 状态是否协调

  • 仔细检查半透明背景是否漏改

  • 确保整个颜色体系保持和谐

漏改一个变量,hover 状态显得怪异;漏改两个,整套主题就开始"发脏"。

传统颜色系统的困境

当前绝大多数设计系统的配色方案可以概括为:靠人肉复制的"颜色农场"

:root {
  /* 主色系 */
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-primary-active: #1d4ed8;
  --color-primary-light: #93c5fd;
  --color-primary-dark: #1e40af;
  
  /* 辅助色系 */
  --color-secondary: #8b5cf6;
  --color-secondary-hover: #7c3aed;
  --color-secondary-active: #6d28d9;
  
  /* 继续衍生... */
}

这种模式的痛点显而易见:

  • 维护成本高:一个主色系需要十几二十个变量

  • 同步困难:多处定义的变量容易遗漏

  • 不可靠:手动调色依赖个人感觉,缺乏系统性

CSS 相对颜色:革命性的解决方案

CSS 相对颜色语法引入的 from 关键字,让颜色从"死值"变成"活公式"。

基础语法

color-function(from origin-color channel1 channel2 channel3 / alpha)

拆解说明:

  • color-function:输出格式,如 rgb()hsl()oklch()

  • from:关键字符,声明颜色来源

  • origin-color:基准颜色,支持 hex、RGB、HSL 等格式

  • channel1 ~ 3:可访问和修改的通道值

  • alpha:可选透明度通道

实际应用示例

:root {
  --primary: #3b82f6;
}

.button {
  background: var(--primary);
}

.button:hover {
  /* 基于主色自动计算 hover 状态 */
  background: hsl(from var(--primary) h s calc(l - 10));
}

这一行 hsl(from ...) 的改变,将 hover 效果从"写死"变成了"相对基色、自动联动"。从此,品牌色只需修改一个 --primary 变量,所有衍生状态自动跟随。

from 关键字的魔力

from 的核心作用是将颜色分解为通道值,让我们能够像搭乐高一样重新组合:

/* 将绿色分解为 RGB 通道 */
rgb(from green r g b)  /* 输出: rgb(0 128 0) */

/* 用绿色通道创建灰度 */
rgb(from green g g g)  /* 输出: rgb(128 128 128) */

/* 随意调换通道顺序 */
rgb(from green b r g)  /* 输出: rgb(0 0 128) */

跨色彩空间转换

from 自动处理色彩空间转换,让颜色格式不再成为障碍:

/* RGB 转 HSL */
hsl(from rgb(255 0 0) h s l)

/* Hex 转 OKLCH */
oklch(from #3b82f6 l c h)

这对设计系统意义重大:源头存储格式不再重要,使用端始终使用统一的可计算空间。

calc():颜色计算的引擎

真正的威力在于将 calc() 与颜色通道结合:

/* 变亮:提高亮度 */
hsl(from blue h s calc(l + 20))

/* 变暗:降低亮度 */  
hsl(from blue h s calc(l - 20))

/* 半透明:调整透明度 */
rgb(from blue r g b / calc(alpha * 0.5))

/* 调色:旋转色相 */
hsl(from blue calc(h + 180) s l)

大部分颜色衍生逻辑都可以归结为:通道 + 偏移量通道 × 系数

OKLCH:更智能的色彩空间

虽然 HSL 很流行,但它有个致命缺陷:亮度感知不均

hsl(220 80% 50%)  /* 蓝色 */
hsl(120 80% 50%)  /* 绿色 */

理论上两者亮度相同,但人眼感知中绿色明显更亮。OKLCH 解决了这个问题:

  • L(Lightness):0-1,感知亮度,更符合人眼

  • C(Chroma):0-约0.37,颜色纯度

  • H(Hue):0-360,色相角度

    oklch(0.55 0.15 260) /* 蓝色 / oklch(0.55 0.15 140) / 绿色 */

在 OKLCH 中,相同的 L 值在不同色相间具有一致的亮度感知,这让程序化调色更加可靠。

构建智能颜色系统

第一步:定义品牌基色

:root {
  /* 只用定义 4 个基础品牌色 */
  --brand-primary: oklch(0.55 0.2 265);
  --brand-success: oklch(0.65 0.18 145);
  --brand-error: oklch(0.6 0.25 25);
  --brand-warning: oklch(0.75 0.15 85);
}

第二步:按规则生成完整色板

:root {
  /* Primary 色系 - 全部从基色派生 */
  --primary: var(--brand-primary);
  --primary-hover: oklch(from var(--brand-primary) calc(l - 0.1) c h);
  --primary-active: oklch(from var(--brand-primary) calc(l - 0.15) c h);
  --primary-light: oklch(from var(--brand-primary) calc(l + 0.2) calc(c * 0.5) h);
  --primary-lighter: oklch(from var(--brand-primary) calc(l + 0.3) calc(c * 0.3) h);
  --primary-alpha-10: oklch(from var(--brand-primary) l c h / 0.1);
  --primary-alpha-20: oklch(from var(--brand-primary) l c h / 0.2);
  
  /* 其他色系采用相同模式 */
  --success: var(--brand-success);
  --success-hover: oklch(from var(--brand-success) calc(l - 0.1) c h);
  --success-light: oklch(from var(--brand-success) calc(l + 0.2) calc(c * 0.5) h);
}

四个基色变量,扩展出完整的颜色体系。品牌色调整时,只需修改四个基础值。

暗色模式的革命

传统暗色模式需要维护两套 token,现在只需一个公式:

:root {
  --surface: oklch(0.98 0.02 240);
  --text: oklch(0.25 0.03 240);
}

[data-theme="dark"] {
  /* 亮度反转实现暗色模式 */
  --surface: oklch(from var(--surface) calc(1 - l) c h);
  --text: oklch(from var(--text) calc(1 - l) c h);
}

实战高级技巧

1. 智能阴影系统

.card {
  --card-bg: var(--primary);
  background: var(--card-bg);
  box-shadow: 
    0 4px 6px oklch(from var(--card-bg) l c h / 0.2),
    0 10px 15px oklch(from var(--card-bg) l c h / 0.15);
}

阴影自动适应背景色,主题切换时自然过渡。

2. 确保可读性的文本颜色

.tag {
  --tag-bg: var(--primary);
  background: var(--tag-bg);
  /* 文本亮度比背景高 0.6,确保对比度 */
  color: oklch(from var(--tag-bg) calc(l + 0.6) c h);
}

3. 品牌化半透明遮罩

.modal-backdrop {
  background: oklch(from var(--brand-primary) l c h / 0.7);
}

浏览器支持与渐进增强

截至 2025 年,相对颜色已获得良好支持:

  • Chrome 119+ ✅

  • Firefox 128+ ✅

  • Safari 16.4+ ✅

  • Edge 119+ ✅

覆盖率约 83%,对于不支持的环境可提供静态回退:

.button {
  background: #2563eb; /* 回退值 */
  background: oklch(from var(--primary) calc(l - 0.1) c h);
}

避坑指南

  1. 避免过深派生链:从基色直接推导,最多两层

  2. 控制 Chroma 范围:OKLCH 中 Chroma 超过 0.37 可能导致颜色溢出

  3. 正确使用 Alphaoklch(0.6 0.2 265 / 0.5) 而非 oklch(0.6 0.2 265 0.5)

总结:从小升级到大变革

CSS 相对颜色解决的不仅是技术问题,更是设计系统维护的哲学变革:

  • 主题切换不再是灾难:改一个变量,全站自洽

  • 颜色 Token 真正集中管理:从分散定义到统一源头

  • 设计规则化:深浅、状态、透明度都成为可复用的公式

  • 开发体验提升:从机械调色到智能推导

下一次重构配色系统时,不妨尝试将基准色、状态色、暗色模式、半透明层全部交给相对颜色计算。那种"改一处,全局联动"的流畅体验,确实让人上头。

从 180 个颜色变量到 4 个基色变量,这不只是数量的减少,更是设计系统维护理念的质的飞跃。

❌
❌