普通视图

发现新文章,点击刷新页面。
今天 — 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)也方便。

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

为什么你的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项目中用过测试吗?遇到了什么挑战?欢迎在评论区分享你的经验!

播放状态与播放序列的关系(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篇持续更新中,欢迎订阅。 链接

昨天 — 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. 性能优化:对于大图,可以采用间隔采样等策略提升处理速度。

第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 个基色变量,这不只是数量的减少,更是设计系统维护理念的质的飞跃。

React Compiler 完全指南:自动化性能优化的未来

作者 少卿
2025年11月19日 11:16

React Compiler 完全指南:自动化性能优化的未来

一、什么是 React Compiler?

React Compiler(曾用名 React Forget)是 React 团队在 2025 年 10 月发布的稳定版 1.0 构建时优化工具。它不是一个传统意义上的"编译器"(将代码转为字节码),而是一个静态分析优化器,能够自动为你的 React 组件插入记忆化(memoization),从而消除不必要的重新渲染。

核心定位:它让你可以忘记(Forget手动使用 useMemouseCallbackReact.memo,专注于编写声明式代码,性能优化由编译器在构建时自动完成。


二、解决了什么痛点?为什么需要它?

传统的性能优化困境

在 React Compiler 出现前,开发者面临两难选择

  1. 性能 vs 代码简洁性:为了防止不必要的重渲染,必须用 useMemo/useCallback 包裹一切,导致代码臃肿、难以维护
  2. 优化不一致性:不同开发者对何时使用记忆化的判断标准不同,导致性能优化碎片化
  3. 高认知负荷:维护复杂的依赖数组 deps 容易出错,遗漏依赖导致 bug,多余依赖导致性能浪费
  4. “死亡千刀” :大型项目中,性能相关的样板代码可能占代码总量的 20-30%

React 团队的观察:即使开发者知道需要优化,手动优化的覆盖率通常不到 30%。绝大多数组件本可以被记忆化,但出于"懒惰"或"风险考虑"被遗漏了。


三、工作原理:如何自动优化?

React Compiler 的技术架构经历了近十年的演进:

1. 核心架构:基于 HIR 的静态分析

编译器并非直接操作 AST,而是将代码降维到高级中间表示(High-Level Intermediate Representation, HIR)的控制流图(CFG)。这让它能够:

  • 精确分析数据流:追踪每个变量的赋值、引用和传递路径
  • 理解可变性:识别哪些数据可能被修改,哪些是纯函数
  • 细粒度记忆化:可以条件性地记忆化值(这是手动 useMemo 无法做到的)

2. React 规则编码

编译器内置了 React 核心规则的验证通道:

  • 幂等性:组件在相同输入下必须返回相同输出
  • 不可变性:props 和 state 值不能被直接修改
  • 副作用隔离:识别不纯的代码并跳过优化

安全策略:遇到不符合规则的代码时,编译器不会冒险优化,而是跳过该部分,确保不引入 bug。

3. 验证与诊断

编译器通过 eslint-plugin-react-hooks 暴露诊断信息。当你违反 React 规则时,它会提示你潜在的 bug,这甚至比优化本身更有价值。

// 编译器能检测并跳过此场景的优化
function BadComponent(props) {
  props.value = 123; // ❌ 直接修改 props
  return <div>{props.value}</div>;
}

四、核心特性与优势

1. 自动记忆化(Automatic Memoization)

编译器自动在组件、hook、计算值层面插入等效的 useMemo/useCallback/React.memo,覆盖率接近 100%

2. 静态分析 & 类型推断

在构建时分析组件的依赖关系,精确判断何时需要重新渲染。

3. 现有代码库零侵入

无需重写组件,编译器无缝集成到构建流程(Babel/Vite/Next.js),对开发者透明。

4. 性能提升显著

  • Meta Quest Store 案例:加载和导航时间提升 12% ,特定交互速度提升 2.5 倍
  • Sanity Studio:大型 CMS 应用性能提升 30%
  • 内存占用:优化后未增加内存消耗

5. 减少样板代码

代码库清晰度提升,开发者可以删除大量性能相关的"噪声代码"。


五、如何开始使用?

前置条件

  1. 启用 React Strict Mode
  2. 配置 ESLint React Hooks 插件eslint-plugin-react-hooks

快速开始

React Compiler 1.0 已与主流框架集成:

Next.js(v15+)
# 创建新项目时启用
npx create-next-app@latest --react-compiler
Vite
npm install babel-plugin-react-compiler
# 在 vite.config.js 中配置
Expo(React Native)
npx create-expo-app --template with-react-compiler
自定义 Babel 配置
{
  "plugins": [
    ["babel-plugin-react-compiler", { "target": "18" }]
  ]
}

渐进式采用

React Compiler 支持逐文件逐组件启用,风险可控:

// 在组件顶部添加指令禁用编译器
'use no memo';

function LegacyComponent() {
  // 这个组件不会被优化
}

六、与 Webpack Compiler 的区别

有人容易混淆 React CompilerWebpack Compiler

维度 React Compiler Webpack Compiler
定位 React 应用性能优化 模块打包与构建
工作层 源码级(AST/JSX) 模块级(依赖图)
核心功能 自动记忆化、静态分析 代码拆分、Tree Shaking、HMR
输出 优化后的 React 代码 打包后的 bundle
关系 互补(React Compiler 在 Webpack 的 loader 阶段工作)

协同工作:React Compiler 作为 Babel 插件,在 Webpack 处理 JS/JSX 之前优化代码。


七、未来展望

React Compiler 1.0 只是开始,路线图包括:

  • 更智能的优化:在构建时预计算常量、优化数据获取模式
  • React DevTools 集成:可视化显示哪些组件被编译器优化
  • Concurrent React 深度整合:让并发渲染更智能地决定更新优先级
  • 生态系统普及:Next.js、Expo、Vite 将默认启用编译器

八、常见问题解答

Q1:会完全取代 useMemo/useCallback 吗?
A:绝大多数场景可以,但手动优化仍是逃生舱,用于极端性能需求。

Q2:会增加构建时间吗?
A:会,但增量构建和缓存机制让影响可控(通常 +10%~20%)。

Q3:会改变代码行为吗?
A:不会,编译器只进行安全的记忆化,不改变逻辑。

Q4:对旧版本 React 兼容吗?
A:支持 React 17+,但推荐 React 18+ 以获得最佳效果。


九、总结

React Compiler 通过自动化消除了一代人的性能优化负担,让开发者回归本质:编写清晰、声明式的 UI 代码。正如 React 团队所说,这是未来十年 React 的新基础

现在就开始尝试:在你的项目中启用它,运行 npm run build,然后享受零手动优化的快感吧!

参考资料

官方来源

  1. React Compiler 1.0发布公告
    zh-hans.react.dev/blog/2025/1…
  2. React Compiler官方文档
    zh-hans.react.dev/learn/react…
  3. React Compiler安装指南
    zh-hans.react.dev/learn/react…
  4. React Compiler配置参考
    zh-hans.react.dev/reference/r…

前端已死,ai当立:gemini3写的火柴人射击小游戏

作者 拜无忧
2025年11月20日 13:59

PixPin_2025-11-20_13-38-38.pngimage.png

image.pngcode.juejin.cn/pen/7574677… 这款《火柴人:终极爆裂 (Stickman: Ultimate Burst)》目前已经具备了相当完整且爽快的 Roguelite 射击与平台跳跃体验。以下是当前版本的功能总结:

1. 核心战斗与操作 (Core Gameplay)

  • 智能火控系统:角色会自动锁定视野内最近的敌人头部/身体并自动射击,玩家只需专注于走位。

  • 流畅跑酷:支持二段跳 (Double Jump)、蹬墙跳 (Wall Jump) 和下穿平台,手感顺滑。

  • 四种特色武器

    • M1911:均衡型初始手枪。
    • VECTOR:极高射速的冲锋枪。
    • AA-12:近战爆发极强的霰弹枪。
    • 磁轨炮 (Railgun) :拥有穿透效果的毁灭性激光武器。
  • 战前整备:游戏开始前可以直接选择心仪的主武器入场。

2. 特色机制 (Unique Mechanics)

  • 无伤接触 (No Contact Damage) :普通敌人的身体碰撞不再造成伤害,鼓励玩家在怪群中穿梭。
  • 尸体爆炸 (Corpse Explosion) :极具策略性的机制。敌人死后会留下尸体,短暂延迟后发生剧烈爆炸。玩家杀敌后必须迅速远离,或者利用位移引诱敌人。
  • 攻击吸血 (Lifesteal) :子弹击中敌人会回复微量生命值,并伴有绿色的治疗数字飘字,增强续航能力。
  • 安全光柱 (Safety Pillar) :一道红色的激光墙会跟随玩家的推进进度(基于地面坐标),防止因地图动态清理而掉落虚空。

3. 敌人与 AI (Enemies)

  • 全员火柴人化:敌人拥有各自独特的火柴人造型和动画。

    • Runner (橙) :快速奔跑的近战单位。
    • Shooter (白) :远程单位,会发射减速的红色光球子弹。
    • Drone (青) :无视地形飞行的机械单位。
    • Shield (蓝盾) :手持大盾,能格挡正面子弹,需绕后攻击。
  • BOSS 战:每10关出现巨大的泰坦级 BOSS,拥有震地跳跃和全屏弹幕暴走技能。

4. 经济与成长 (Progression)

  • 波次系统:无限生成的关卡,敌人数量随波次增加。

  • 黑市商店:击败 BOSS 后触发。

  • 升级项

    • 购买/解锁新武器。
    • DMG:伤害提升。
    • SPD:射速改良。
    • MAG:弹夹扩容。
    • RELOAD:换弹速度加快。
    • HEAL:购买医疗包。
  • 阈值限制:各项属性升级都有最大等级限制,防止数值崩坏。

5. 视听表现 (Juice & Feedback)

  • 程序化动画:主角和敌人拥有基于代码生成的跑步摆腿、瞄准手臂跟随动画。

  • 打击感反馈

    • 屏幕震动 (Screen Shake) :开火、爆炸、受伤时有强烈的震屏感。
    • 顿帧 (Hit Stop) :击杀瞬间有微小的卡顿,强化打击力度。
    • 粒子特效:抛壳、枪口焰、血爆粒子、二段跳烟尘。
  • 动态音效:使用 Web Audio API 实时合成的复古风格枪声、爆炸声和点击反馈。

目前的版本已经是一个非常耐玩的小型动作游戏了!

cloudflare事故报告硬核详解

作者 YuUu
2025年11月20日 13:38

概览

昨晚,500成了全球网站上最醒目的数字。小编在访问atcoder时发现无法正常登录,随后发现推特也报出500错误,航空公司无法在线订票,许多使用Cloudflare的个人网站也跟着受害,并且我的一些朋友已经申请赔偿,Cloudflare这波可谓损失惨重。

团队起初认为这又一次是由攻击导致的,随后意识到是因为数据库的权限设置出了问题,导致配置文件被多次写入,错误的配置文件下放到服务端导致服务端瘫痪,团队在几小时内通过替换配置文件迅速挽救这次灾难。

为什么cloudflare如此重要

1. 加速网站访问

Cloudflare 在全球有 300+ 数据中心(节点),能把静态资源缓存到离用户更近的地方

2. 保护网站不被攻击

Cloudflare 提供业界最强的 DDoS 防御

当黑客发起恶意访问时,它会拦截请求,确保你的服务器不会挂掉。

包括:

  • DDoS 清洗
  • 防爬虫
  • 防火墙规则
  • 机器人识别

3. 隐藏服务器 IP

因为 Cloudflare 是反向代理,用户只看到 Cloudflare 的 IP,看不到你真实服务器地址

,达到防止被人直接攻击你服务器的效果。

4. 提供 HTTPS、SSL 证书

无需自己配置证书,可以自动为网站启用 HTTPS。

5. 更多高级功能

包括但不限于:

  • Workers(无服务器函数)
  • R2 对象存储(S3 替代)
  • Zero Trust 访问控制
  • WAF Web 应用防火墙
  • Turnstile(无验证码人机验证)
  • Pages(静态网站托管)

所以,为避免个人网站受攻击,使用Cloudflare的安全服务是许多开发者的首选,下面这张图形象说明了cloudflare在全球互联网中的重要地位:

事故过程

数据库向配置文件写入过量条目,之后将这些配置文件下放到服务端后,配置文件大小超过了服务端规定的上限,因而引发错误。(这些配置文件描述了cloudflare最新的威胁数据)

从11:30开始,检测报告显示收到大量的5xx响应结果,起初结果呈现波动,这是由于一开始数据库集群只有部分节点会放出错误配置文件,每过5分钟,数据库都会重新生成新的配置文件并下方,当请求被分配到有故障的节点,才会下放错误的配置文件。

一段时间后,13:00后,所有节点均出现了这个错误,因而导致连续的大面积5xx结果,之后,开发人员手动将旧版本的配置文件插入队列,并强制重启配置文件发放服务,在14:30左右令错误情况得到显著缓解,而15:00之后的“长尾巴”,是在逐个重启被此配置文件错误影响到的其他服务。

被影响的服务如下:

Service / Product Impact description
Core CDN and security services HTTP 5xx status codes. The screenshot at the top of this post shows a typical error page delivered to end users.
Turnstile Turnstile failed to load.
Workers KV Workers KV returned a significantly elevated level of HTTP 5xx errors as requests to KV’s “front end” gateway failed due to the core proxy failing.
Dashboard While the dashboard was mostly operational, most users were unable to log in due to Turnstile being unavailable on the login page.
Email Security While email processing and delivery were unaffected, we observed a temporary loss of access to an IP reputation source which reduced spam-detection accuracy and prevented some new-domain-age detections from triggering, with no critical customer impact observed. We also saw failures in some Auto Move actions; all affected messages have been reviewed and remediated.
Access Authentication failures were widespread for most users, beginning at the start of the incident and continuing until the rollback was initiated at 13:05. Any existing Access sessions were unaffected. All failed authentication attempts resulted in an error page, meaning none of these users ever reached the target application while authentication was failing. Successful logins during this period were correctly logged during this incident. Any Access configuration updates attempted at that time would have either failed outright or propagated very slowly. All configuration updates are now recovered.

原理

cloudflare的服务由三层架构组成,当客户端向配置了Cloudflare服务的服务器端发送请求时,请求依次通过: HTTP & TLS Termination、FL(核心代理模块)、缓存\数据库模块。

这次的问题出在了FL,核心代理模块(Core Proxy Module),其中有一个工具,用于检测操作是否由机器人\自动化工具完成——Bot Management,这个反机器人工具,使用一个ML(机器学习)方法,读入配置文件,根据配置文件中定义的近期用户行为,来对此次用户的请求是否由机器人完成进行预测。

由于新的自动化工具和机器人技术手段层出不穷,这个配置文件会被频繁更新给Bot Management,而由于错误的数据库Query语句,导致配置文件被大量写入重复条目,让配置文件超过了固定大小,从而让ML模块读取文件时出错。

如果Cloudflare用户在核心代理模块中启用了Bot Management,新版本FL2会直接抛出5xx错误,而旧版本FL,则会ML失效,返回100%是机器人的错误判断,从而直接认为你的一切操作都是人机!(这就是无法登陆配置了cloudflare人机验证网站的原因,被100%当机器人了呵呵)

错误的数据库请求

Cloudflare使用ClickHouse数据库,这是一种超高速的数据分析数据库,用来做报表、统计、指标查询,而不擅于做业务事务。

ClickHouse使用分布式模式,当用户需要查询数据时,会从每个分片shard查询并将结果合并返回,以提高性能,具体原理是:数据库中有一张表default,向default表提交查询语句,查询交给名为Distributed的引擎,这个引擎唤起每个shard,让shard去查其下的r0表,也就是说,每个shard实际上有一个r0表,所有的数据只在r0中储存,default是一张“代理表”。

Cloudflare团队发现,ClickHouse数据库在执行查询时,并不会用发起查询的用户身份进行查询,在Distributed引擎中,不管是谁的查询,都由一个shared account执行,而这样的方式,让权限控制和历史记录分析变得困难,因此,cloudflare团队计划调整ClickHouse的查询逻辑,将对r0的隐式访问改为显式访问,让用户直接获得对底层shard上的数据库r0的访问权限,这样,所有的操作不经default表代理,直接来到r0,让监控变得容易。

可是!问题就出在了小小的数据库查询语句上,当Cloudflare团队修改了用户对r0的权限为可访问时,此时的查询语句是:

SELECT
  name,
  type
FROM system.columns
WHERE
  table = 'http_requests_features'
order by name;

查询没有指定表!因此,结果从r0和default两张表返回两次,因此才让配置文件的大小翻了一倍!

查询结果类似:

Rust的“设计哲学”

“不安全的情况我直接停机。”

在用于获取供Bot Management使用的配置文件的Rust代码中,有这么一段:

/// Fetch edge features based on `input` struct into [`Features`] buffer.
pub fn fetch_features(
    &mut self,
    input: &dyn BotsInput,
    features: &mut Features,
) -> Result<(), (ErrorFlags, i32)> {
    // update features checksum (lower 32 bits) and copy edge feature names
    features.checksum &= 0xFFFF_FFFF_0000_0000;
    features.checksum |= u64::from(self.config.checksum);
    let (feature_values, _) = features
        .append_with_names(&self.config.feature_names)
        .unwrap();
}

我们看到最后的.unwrap()语句,这个语句在得到Err(失败)的表达时,不会做兜底处理或者打出日志,而是直接抛出Panic,让Rust线程宕机!

因此,当配置文件大小超过限制,引发Err结果时,cloudflare的开发人员没有在这里做兜底处理,导致抛出Panic:

thread fl2_worker_thread panicked: called Result::unwrap() on an Err value

简单总结

一条被忽略的数据库查询语句 + 一次权限调整 → 导致配置重复写入 → 配置文件过大 → Bot Management 崩溃 → FL 模块 panic → 全球范围 5xx。

这说明了一个朴素事实:

庞大的互联网是由无数极小的细节互相牵动的,一个小小的 SQL 查询也可能让半个互联网倒下。

深入 Vue3 响应式系统:手写 Computed 和 Watch 的奥秘

作者 云枫晖
2025年11月20日 13:36

在 Vue3 的响应式系统中,计算属性和监听器是我们日常开发中频繁使用的特性。但你知道它们背后的实现原理吗?本文将带你从零开始,手写实现 computed 和 watch,深入理解其设计思想和实现细节。

引言:为什么需要计算属性和监听器?

在Vue应用开发中,我们经常遇到这样的场景:

  • 派生状态:基于现有状态计算新的数据
  • 副作用处理:当特定数据变化时执行相应操作

Vue3提供了computedwatch来优雅解决这些问题。但仅仅会使用还不够,深入理解其底层原理能让我们在复杂场景下更加得心应手。

手写实现Computed

computed的核心特性包括:

  • 惰性计算:只有依赖的响应式数据变化时才重新计算
  • 值缓存:避免重复计算提升性能
  • 依赖追踪:自动收集依赖关系

computed函数接收一个参数,类型函数或者一个对象,对象包含getset方法,get方法是必须得。基本框架就出来了:

export function computed(getterOrOptions) {
  let getter;
  let setter = undefined;
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
}

当你使用过computed函数时,你会发现会返回一个ComputedRefImpl类型的实例。代码就可以进一步写成下面的样子:

export class ComputedRefImpl {
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : undefined;
  }
}
export function computed(getterOrOptions) {
  /* 上述代码实现省略 */
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
}

ComputedRefImpl的实现

ComputedRefImpl类中有几个主要的属性:

  • _value:缓存的计算结果
  • _v_isRef:表示这是一个ref对象,可以通过.value访问
  • effect 响应式副作用实例
  • _dirty 脏值标记,true表示需要重新计算
  • dep 依赖收集容器,存储依赖当前计算属性的副作用 在初始化的时候,将会创建一个ReactiveEffect实例,此类型在手写Reactive中实现了。
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数 后续处理
    });
  }
}

通过get valueset value手机依赖和触发依赖

class ComputedRefImpl {
  /* 上述代码实现省略 */
  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

当依赖值发生变化后,将触发副作用的调度器,触发计算属性的副作用更新。

constructor(getter, setter) {
  this.getter = getter;
  this.setter = isFunction(setter) ? setter : () => {};

  // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
  this.effect = new ReactiveEffect(getter, () => {
    // 调度器函数:当依赖变化时执行
    this._dirty = true; // 标记为脏值,下次访问时需要重新计算
    triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
  });
}

完整代码及用法示例

import { isFunction } from "./utils";
import {
  activeEffect,
  ReactiveEffect,
  trackEffects,
  triggerEffects,
} from "./effect";

/**
 * 计算属性实现类
 * 负责管理计算属性的getter、setter以及缓存机制
 */
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数:当依赖变化时执行
      this._dirty = true; // 标记为脏值,下次访问时需要重新计算
      triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
    });
  }

  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

/**
 * 创建计算属性的工厂函数
 * @param {Function|Object} getterOrOptions - getter函数或包含get/set的对象
 * @returns {ComputedRefImpl} 计算属性引用实例
 */
export const computed = (getterOrOptions) => {
  let getter; // getter函数
  let setter = undefined; // setter函数

  // 根据参数类型确定getter和setter
  if (isFunction(getterOrOptions)) {
    // 如果参数是函数,则作为getter
    getter = getterOrOptions;
  } else {
    // 如果参数是对象,则分别获取get和set方法
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }

  // 创建并返回计算属性实例
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
};

示例用法:

import { reactive, computed } from "./packages/index";
const state = reactive({
  firstName: "tom",
  lastName: "lee",
  friends: ["jacob", "james", "jimmy"],
});
const fullName = computed({
  get() {
    return state.firstName + " " + state.lastName;
  },
  set(newValue) {
    [state.firstName, state.lastName] = newValue.split(" ");
  },
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${fullName.value} !</div>
  `;
});
setTimeout(() => {
  fullName.value = "jacob him";
}, 1000);
setTimeout(() => {
  console.log(state.firstName, state.lastName); // firstName: jacob lastName: him 
}, 2000);

手写实现Watch和WatchEffect

watch函数接收三个参数:

  • source:要监听的数据源,可以是响应式对象或函数
  • cb:数据变化时执行的回调函数
  • options 配置选项:immediate:是否立即执行,deep:是否深度监听等
export function watch(source, cb, {immediate = false} = {}) {
 // 待后续实现
}

1. watch的实现

首先source是否可以接受多种监听的数据源:响应式对象、多个监听数据源的数组、函数。将不同方式统一起来。

export function watch(source, cb, { immediate = false } = {}) {
  let getter;
  if (isReactive(source)) {
    // 如果是响应式对象 则调用traverse
    getter = () => traverse(source);
  } else if (isFunction(source)) {
    // 如果是函数 则直接执行
    getter = source;
  } else if (isArray(source)) {
    // 处理数组类型的监听源
    getter = () =>
      source.map((s) => {
        if (isReactive(s)) {
          return traverse(s);
        } else if (isFunction(s)) {
          return s();
        }
      });
  }
}
/**
 * 遍历对象及其嵌套属性的函数
 * @param {any} source - 需要遍历的源数据
 * @param {Set} s - 用于记录已访问对象的集合,避免循环引用
 * @returns {any} 返回原始输入数据
 */
export function traverse(source, s = new Set()) {
  // 检查是否为对象类型,如果不是则直接返回
  if (!isObject(source)) {
    return source;
  }
  // 检测循环引用,如果对象已被访问过则直接返回
  if (s.has(source)) {
    return source;
  }
  // 将当前对象加入已访问集合
  s.add(source);
  // 递归遍历对象的所有属性
  for (const key in source) {
    traverse(source[key], s);
  }
  return source;
}

处理完souce参数后,创建一个ReactiveEffect实例,对监听源产生响应式的副作用。

export function watch(source, cb, { immediate = false } = {}) {
  /* 上述代码以实现省略 */
  let oldValue;
  // 定义副作用执行的任务函数
  const job = () => {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  };

  // 创建响应式副作用实例
  const effect = new ReactiveEffect(getter, job);
  if (immediate) {
    job();
  } else {
    oldValue = effect.run();
  }
}

⚠️ 性能注意

traverse函数会递归遍历对象的所有嵌套属性,在大型数据结构上使用深度监听(deep: true)时会产生显著性能开销。建议:

  • 只在必要时使用深度监听
  • 尽量使用具体的属性路径而非整个对象
  • 考虑使用计算属性来派生需要监听的数据

2. watchEffect的实现

实现了watch函数后,watchEffect的实现就容易了。

// watchEffect.js
import { watch } from "./watch";
export function watchEffect(effect, options) {
  return watch(effect, null, options);
}
// watch.js
const job = () => {
  if (cb) {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  } else {
    effect.run(); // 处理watchEffect
  }
};

用法示例

watch([() => state.lastName, () => state.firstName], (oldValue, newValue) => {
  console.log("oldValue: " + oldValue, "newValue: " + newValue);
});
setTimeout(() => {
  state.lastName = "jacob";
}, 1000);
setTimeout(() => {
  state.firstName = "james";
}, 1000);
/*
1秒钟后:oldValue: lee,tom newValue: jacob,tom
2秒钟后:oldValue: jacob,tom newValue: jacob,james
*/

总结

本文核心内容

通过手写实现Vue3的computedwatch,我们深入理解了:

  • 计算属性的惰性计算、值缓存和依赖追踪机制
  • 监听器的多数据源处理和深度监听原理
  • 响应式系统中副作用调度和依赖收集的完整流程

代码地址

📝 本文完整代码
[GitHub仓库链接] | [github.com/gardenia83/…]

下篇预告

在下一篇中,我们将继续深入Vue3响应式系统,手写实现:

《深入 Vue3 响应式系统:从ref到toRefs的完整实现》

  • refshallowRef的底层机制
  • toReftoRefs的响应式转换原理
  • 模板Ref和组件Ref的特殊处理
  • Ref自动解包的神秘面纱

敬请期待! 🚀


掌握底层原理,让我们的开发之路更加从容自信

MarsUI 引入项目的使用记录

作者 isixe
2025年11月20日 13:32

最近准备做数据大屏的项目,找了一些相关的UI控件,顺着 mars3d-vue-example 然后我就找到了它开源的 MarsUI 控件。但是这个控件只有源文件形式的,没有上传到 npm 库,所以我们就得手动引入了。

依赖安装

Mars3d 的开源模板项目 mars3d-vue-example 中,提供有一套完整的控件样板的源码文件,这些基础控件是在 Ant Design Vue 组件库的基础上进行编写的,Mard3d 主要封装了表单控件,所以所有控件依赖于 Ant Design Vue 组件库。

虽然在 mars3d-vue-example 中列出的相关依赖,但是这并不完全

image.png

实际需要的完整依赖还得补充 3 个,缺少了 lodash-es、dayjs 和 less 这三个依赖

  "dependencies": {
    "@icon-park/svg": "^1.4.2",
    "@turf/turf": "^7.2.0",
    "ant-design-vue": "^4.0.7",
    "consola": "^3.2.3",
    "echarts": "^5.4.3",
    "nprogress": "^0.2.0",
    "vite-plugin-style-import": "^2.0.0",
    "vue-color-kit": "^1.0.6"
    // 任意版本安装
    "vue": "^3.5.13",
    "lodash-es": "^4.17.21",
    "dayjs": "^1.11.19",
    "less": "^4.4.2",
  },

我们直接使用 pnpm 快速安装

npm install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

yarn install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

pnpm add @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

组件引入

我们需要将 mars3d-vue-example 的项目文件拉取下来,然后把 components/mars-ui 这个文件夹整个复制到我们的项目中

image.png

然后在 main.js 中进行组件的批量注册

import MarsUIInstall from "@mars/components/mars-ui"

const app = createApp(Application)

MarsUIInstall(app)

配置 Antdv 和 引入 Less 样式文件

前面我们提到 MarsUI 是依赖于 Antdv,并且在组件中使用了 Less,所以我们需要在 vite.config.js 中增加下面的配置

import { createStyleImportPlugin, AndDesignVueResolve } from "vite-plugin-style-import"
import path from 'path';

export default defineConfig({
  css: {
    preprocessorOptions: {
      less: {
        javascriptEnabled: true,
        additionalData: `@import "${path.resolve(
          __dirname,
          "src/components/mars-ui/base.less"
        )}";`,
      },
    },
  },
  plugins: [
    vue(),
    createStyleImportPlugin({
      resolves: [AndDesignVueResolve()],
      libs: [
        {
          libraryName: "ant-design-vue",
          esModule: true,
          resolveStyle: (name) => {
            if (name === "auto-complete") {
              return `ant-design-vue/es/${name}/index`;
            }
            return `ant-design-vue/es/${name}/style/index`;
          },
        },
      ],
    }),
  ],
});

配置完成,重启一下项目我们就能在项目中按需导入 MarsUI 的控件了。

参考

Node.js + puppeteer + chrome 环境部署至linux

作者 zhou770377
2025年11月20日 13:23
踩坑整整5天才和运维大哥部署成功……

先说下具体的思路,因为puppeteer的运行必须依赖于浏览器,这里使用的是chrome,

此处安装浏览器的方法有两种, 是因为puppeteer提供了两种连接调试浏览器的方案 连接已有的浏览器如下所示:

      console.log("🔗 连接到本地Chrome浏览器...");
      const browserURL = 'http://localhost:9222'; //   浏览器服务启动之后可以直接访问
      
     /*   
     
     
     避坑:这里会有个问题,就是页面会一直保存在 对应端口的浏览器页面在浏览器的本地存储,也就是说,如果你的脚本执行的任务有登录的逻辑,那么下次脚本运行之后,会直接访问到第一次登录的用户信息,类似于用户自己操作登录后,后续进入也不用登录是一个逻辑。
     
     这里是本地连接和使用自带的chrome的一个很大的区别,
   请务必在js逻辑中做兼容处理,
   
   否则你会发现,该模式和自带的模式出现不一样的结果,而不知道问题所在。
     
     
        */
      
      browser = await puppeteer.connect({
        browserURL: browserURL,
        defaultViewport: null
      });
      
      
    
      
      
      
      
      
      
      const version = await browser.version();
      console.log(version);

使用puppeteer自带的如下所示:

// 启动新的浏览器实例(默认模式)
      // 使用puppeteer自带的Chromium,避免系统浏览器依赖
      const browserConfig = {
        headless: false, // 使用配置中的无头模式
        // 不指定executablePath,让puppeteer使用自带的浏览器
        args: [  // 相关配置参数
          ...config.browser.args,
          `--window-size=${config.browser.windowSize.width},${config.browser.windowSize.height}`,
          "--no-sandbox",
          "--disable-setuid-sandbox",
          "--disable-dev-shm-usage",
        ],
        ignoreHTTPSErrors: true,
        defaultViewport: null,
        timeout: 6 * 1000,
      };

      console.log(getTimestampedLog("📱 启动浏览器..."));
      browser = await puppeteer.launch(browserConfig);
      const version = await browser.version();
      console.log(version);

第一种就是: 直接使用,puppeteer自己的浏览器,缺点是: 安装慢,部署很容易失败

第二就是:会有什么权限问题导致无法启动 例如这样:(自动化操作失败:Failed to launch the browser process: spawn /root/.cache/puppeteer/chrome/1inux-142.0.7444.61/chrome-1inux64/chrome ENOE)

综上所述:

本文采用第二条路就是,单独安装运行chrome服务, 然后,用puppeteer链接本地的chrome服务。

1, 安装chrome 并运行服务至9222端口

注意:本人的服务器是cent os7(下面两种方案都可以用, 方案二是因为运维的服务器, 系统不支持方案一, 因此提供docker 的方案)

chrome 安装

方案一:

1, liunx 先安装一下谷歌浏览器
sudo yum install https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm -y
2. 启动浏览器 无头模式 开放本地 9222 端口
google-chrome-stable --no-sandbox --headless --disable-gpu --remote-debugging-port=9222

方案二:(提前安装 docker )

拉取镜像
docker pull selenium/standalone-chrome
运行镜像
docker run -d \
  -p 9222:9222 \
  -v /dev/shm:/dev/shm \
  --name chrome-debug \
  --entrypoint google-chrome \
  selenium/standalone-chrome \
  --remote-debugging-address=0.0.0.0 \
  --remote-debugging-port=9222 \
  --no-sandbox \
  --headless
检测是否运行成功
curl http://localhost:9222/json/version

运行成功会输出如下的信息:

{
   "Browser": "Chrome/142.0.7444.162",
   "Protocol-Version": "1.3",
   "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/142.0.0.0 Safari/537.36",
   "V8-Version": "14.2.231.18",
   "WebKit-Version": "537.36 (@c076baf266c3ed5efb225de664cfa7b183668ad6)",
   "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/5f3804d2-146a-475c-a43a-c5e211387413"
}

有如上的信息,恭喜你,已经安装成功了!!!

2,部署一下 puppeteer 的node服务。 这里就看看什么版本的 puppeteer 了。 本人用的新版本的 "puppeteer": "^24.29.1" node > 18 即可。

npm install //安装
node xxx.js // 运行

如果失败了,配置镜像什么的就好了。(不必过多赘述,前端估计都遇到过)

如继续失败,还可以使用cnpm

npm i -g cnpm

cnpm install

即可,cnpm几乎不会失败。

node xxx.js // 就可以运行了。
❌
❌