普通视图

发现新文章,点击刷新页面。
昨天 — 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

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

ProjectB

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

缺点:

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

优点:

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

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

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

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

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

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

问题描述

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

先是提示内存溢出

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

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

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

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

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

原因分析

1. 代码量爆炸性增长

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

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

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

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

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

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

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

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

3. ESLint处理机制的问题

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

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

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

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

4. 内存压力

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

解决方案

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

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

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

方案二:使用ESLint缓存机制

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

方案三:分批处理

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

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

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

方案四:排除IDL文件

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

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

最终解决步骤

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

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

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

2. 执行lint-staged检查

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

3. 处理具体报错

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

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

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

4. 继续合并流程

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

总结

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

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

前端程序员原地失业?全面实测 Gemini 3.0,附三个免费使用方法!

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

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

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

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

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

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

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

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

榜单解读

盲测打分

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

https://lmarena.ai/leaderboard

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

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

推理能力

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

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

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

视觉理解

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

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

其他基准

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

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

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

使用方法

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

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

gemini.google.com/app

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

aistudio.google.com/prompts/new…

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

aistudio.google.com/apps

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

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

中文写作

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

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

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

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

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

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

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

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

开发实测

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

测试1:物理规律理解

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

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

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

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

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

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

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

测试2:小游戏开发

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

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

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

测试3:3D效果开发

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

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

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

测试4:UI还原能力

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

设计稿原图:

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

最终还原效果:

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

测试5:使用插件开发

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

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

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

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

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

测试6:I'm feeling lucky

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

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

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

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

Gemini 3.0 PK Claude Sonnet 4.5

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

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

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

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

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

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

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

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

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

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

Gemini 3.0

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

Claude Sonnet 4.5

然后是 Claude 4.5 的分析过程:

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

对比结果

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

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

最终结论,Claude Sonnet 4.5 胜出:

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

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

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

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

Gemini 3.0

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

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

Claude Sonnet 4.5

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

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

对比结果

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

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

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

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

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

Gemini 3.0

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

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

Claude Sonnet 4.5

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

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

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

对比结果

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

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

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

总结

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

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

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

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

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

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

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

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

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

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

最后

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

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

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

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

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

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

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

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

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

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

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

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

umi4暗黑模式设置

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

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

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

效果

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

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

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

效果

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

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

const { darkAlgorithm, defaultAlgorithm } = theme;

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

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

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

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

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

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

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

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

export default RightRender;

浅谈useRef的使用和渲染机制

2025年11月20日 17:26

前言

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

useRef的渲染机制

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

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

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

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

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

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

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

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

    </div>
}

useRef解决的问题

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

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

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

useRef的好兄弟forwardRef

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

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

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

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

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

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

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

image.png

一、效果展示与核心思路

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

核心思路

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

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

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

HTML结构

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

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

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

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

JavaScript核心逻辑 (script.js)

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

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

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

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

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

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

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

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

let currentImage = null;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

代码中最关键的一行是:

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

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

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

四、参数调整与效果优化

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

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

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

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

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

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

五、总结

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

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

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

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

一、前 言

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

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

二、规模现状

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

三、技术演进介绍

3.1 自建Redis系统架构

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

自建 Redis 架构示意图

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

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

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

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

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

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

3.2 接入方式演进

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

LB接入问题

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

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

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

DRedis接入

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

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

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

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

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

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

DRedis 接入优势

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

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

DRedis 接入现状

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

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

3.3 同城双活就近读

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

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

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

service接入问题

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

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

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

DRedis接入

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

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

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

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

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

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

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

3.4 Redis-server版本与能力

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

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

  • 多线程特性

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

多线程版本 VS 普通版本

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

  • 实时热 key 统计

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

  • 水平扩容异步迁移

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

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

3.5 实例架构与规格

Redis单点主备模式

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

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

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

一主多从规格

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

一主一从

一主两从

一主三从

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

3.6 proxy限流

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

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

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

3.7 自动化运维

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

  • 资源池自动化均衡调度

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

  • 集群自动部署与下线

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

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

  • 资源管理

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

  • 集群扩缩容

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

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

  • 工单自动化

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

  • 告警自动化处理

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

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

四、总结

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

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

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

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

往期回顾

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

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

3. 得物TiDB升级实践

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

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

文 /竹径

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

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

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

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

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

4.5 流式布局(Wrap、Flow)

📚 章节概览

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

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

🎯 核心知识点

什么是流式布局

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

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

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

Wrap vs Flow

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

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


1️⃣ Row/Column的溢出问题

1.1 溢出示例

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

运行效果:

xxxxxxxxxxxxxxxxxxxxx...  ⚠️ OVERFLOW

错误信息:

A RenderFlex overflowed by XXX pixels on the right.

1.2 传统解决方案

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

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

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

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

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

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


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

2.1 构造函数

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

2.2 主要属性

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

2.3 基础用法

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

效果: 超出宽度自动换行


3️⃣ spacing 和 runSpacing

3.1 spacing(主轴间距)

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

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

效果:

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

3.2 runSpacing(纵轴间距)

控制不同行之间的间距。

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

效果:

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

3.3 同时使用

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

可视化效果:

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

4️⃣ alignment(对齐方式)

4.1 WrapAlignment枚举值

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

4.2 示例对比

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

效果:

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

效果:

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

效果:

[A]___________[B]___________[C]

4.3 runAlignment(纵轴对齐)

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

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

5️⃣ Wrap实际应用

应用1:标签云(Tag Cloud)

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

  const TagCloud({required this.tags});

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

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

应用2:可选择标签

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

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

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

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

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

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


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

6.1 什么是Flow

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

6.2 Flow的优缺点

✅ 优点
  1. 性能好

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

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

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

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

6.3 FlowDelegate

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

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

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

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

6.4 完整示例

class TestFlowDelegate extends FlowDelegate {
  final EdgeInsets margin;

  TestFlowDelegate({this.margin = EdgeInsets.zero});

  @override
  void paintChildren(FlowPaintingContext context) {
    var x = margin.left;
    var y = margin.top;

    // 遍历所有子组件
    for (int i = 0; i < context.childCount; i++) {
      var childSize = context.getChildSize(i)!;
      var w = childSize.width + x + margin.right;

      // 判断是否需要换行
      if (w < context.size.width) {
        // 当前行能放下
        context.paintChild(
          i,
          transform: Matrix4.translationValues(x, y, 0.0),
        );
        x = w + margin.left;
      } else {
        // 需要换行
        x = margin.left;
        y += childSize.height + margin.top + margin.bottom;
        context.paintChild(
          i,
          transform: Matrix4.translationValues(x, y, 0.0),
        );
        x += childSize.width + margin.left + margin.right;
      }
    }
  }

  @override
  Size getSize(BoxConstraints constraints) {
    // 返回固定大小
    return Size(double.infinity, 200.0);
  }

  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}

// 使用
Flow(
  delegate: TestFlowDelegate(margin: EdgeInsets.all(10)),
  children: [
    Container(width: 80, height: 80, color: Colors.red),
    Container(width: 80, height: 80, color: Colors.green),
    Container(width: 80, height: 80, color: Colors.blue),
  ],
)

6.5 FlowPaintingContext方法

方法 说明
childCount 子组件数量
getChildSize(int i) 获取第i个子组件的尺寸
paintChild(int i, {...}) 绘制第i个子组件
size Flow的尺寸

6.6 何时使用Flow

场景 推荐
简单流式布局 ❌ 用Wrap
需要动画 ✅ 用Flow
需要精确控制位置 ✅ 用Flow
性能要求极高 ✅ 用Flow
大多数情况 ❌ 用Wrap

🤔 常见问题(FAQ)

Q1: Wrap和Flow的区别?

A:

特性 Wrap Flow
易用性 简单,开箱即用 复杂,需要自定义
性能 更好(转换矩阵优化)
灵活性 固定规则 完全自定义
自适应 ✅ 自动适应子组件 ❌ 需要指定大小
推荐场景 大多数场景 动画、高性能需求

建议: 优先使用Wrap,只有在特殊需求下才用Flow

Q2: spacing和runSpacing的区别?

A:

  • spacing:主轴方向间距(同一行/列内)
  • runSpacing:纵轴方向间距(不同行/列之间)
Wrap(
  spacing: 8,     // 水平间距(同行内)
  runSpacing: 12, // 垂直间距(行之间)
  children: [
    Text('A'), Text('B'), Text('C'),
    Text('D'), Text('E'), Text('F'),
  ],
)

可视化:

[A] 8px [B] 8px [C]
↕️ 12px (runSpacing)
[D] 8px [E] 8px [F]

Q3: Wrap如何实现等宽子组件?

A: Wrap的子组件是自然宽度,不支持等宽。可以用以下方案:

方案1:固定宽度
Wrap(
  spacing: 8,
  runSpacing: 8,
  children: List.generate(10, (i) {
    return SizedBox(
      width: 100,  // 固定宽度
      child: Chip(label: Text('Item $i')),
    );
  }),
)
方案2:计算宽度
LayoutBuilder(
  builder: (context, constraints) {
    // 计算每行3个,自动计算宽度
    final itemWidth = (constraints.maxWidth - 16) / 3;
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: List.generate(10, (i) {
        return SizedBox(
          width: itemWidth,
          child: Chip(label: Text('$i')),
        );
      }),
    );
  },
)

Q4: Flow的性能为什么更好?

A: Flow使用转换矩阵(Transform Matrix)而不是实际移动组件:

// Wrap:实际改变组件位置(重新布局)
Container(
  margin: EdgeInsets.only(left: 100),  // 实际移动
  child: Widget(),
)

// Flow:使用转换矩阵(不重新布局)
context.paintChild(
  i,
  transform: Matrix4.translationValues(100, 0, 0),  // 矩阵变换
)

转换矩阵优势:

  • 不触发布局(Layout)阶段
  • 只触发绘制(Paint)阶段
  • GPU加速
  • 适合动画

Q5: Wrap如何限制最大行数?

A: Wrap本身不支持限制行数,可以结合其他组件:

// 方案1:用LimitedBox限制高度
LimitedBox(
  maxHeight: 100,  // 限制最大高度
  child: SingleChildScrollView(
    child: Wrap(
      children: [...],
    ),
  ),
)

// 方案2:手动截取子组件
Wrap(
  children: items.take(12).toList(),  // 只显示前12个
)

// 方案3:用ClipRect裁剪
ClipRect(
  child: Container(
    height: 100,
    child: Wrap(
      children: [...],
    ),
  ),
)

🎯 跟着做练习

练习1:实现一个技能标签云

目标: 创建可点击的技能标签,点击后切换选中状态

步骤:

  1. 使用Wrap布局
  2. 用ChoiceChip实现选择效果
  3. 维护选中状态
💡 查看答案
class SkillTags extends StatefulWidget {
  const SkillTags({super.key});

  @override
  State<SkillTags> createState() => _SkillTagsState();
}

class _SkillTagsState extends State<SkillTags> {
  final List<String> _skills = [
    'Flutter', 'Dart', 'iOS', 'Android',
    'React', 'Vue', 'Node.js', 'Python',
    'Java', 'Kotlin', 'Swift', 'TypeScript',
  ];
  final Set<String> _selected = {};

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          '请选择您擅长的技能:',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: _skills.map((skill) {
            final isSelected = _selected.contains(skill);
            return ChoiceChip(
              label: Text(skill),
              selected: isSelected,
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _selected.add(skill);
                  } else {
                    _selected.remove(skill);
                  }
                });
              },
            );
          }).toList(),
        ),
        if (_selected.isNotEmpty) ...[
          const SizedBox(height: 16),
          Text(
            '已选择 ${_selected.length} 项:${_selected.join('、')}',
            style: const TextStyle(color: Colors.blue),
          ),
        ],
      ],
    );
  }
}

练习2:实现一个自定义Flow动画

目标: 创建一个圆形排列的Flow布局

步骤:

  1. 继承FlowDelegate
  2. 在paintChildren中计算圆形位置
  3. 使用三角函数计算坐标
💡 查看答案
import 'dart:math';

class CircleFlowDelegate extends FlowDelegate {
  final double radius;

  CircleFlowDelegate({this.radius = 80});

  @override
  void paintChildren(FlowPaintingContext context) {
    final centerX = context.size.width / 2;
    final centerY = context.size.height / 2;

    for (int i = 0; i < context.childCount; i++) {
      final angle = (2 * pi / context.childCount) * i;
      final x = centerX + radius * cos(angle) - 20;  // 20是子组件宽度的一半
      final y = centerY + radius * sin(angle) - 20;

      context.paintChild(
        i,
        transform: Matrix4.translationValues(x, y, 0.0),
      );
    }
  }

  @override
  Size getSize(BoxConstraints constraints) {
    return Size(200, 200);
  }

  @override
  bool shouldRepaint(CircleFlowDelegate oldDelegate) {
    return radius != oldDelegate.radius;
  }
}

// 使用
Flow(
  delegate: CircleFlowDelegate(radius: 80),
  children: List.generate(
    8,
    (i) => Container(
      width: 40,
      height: 40,
      decoration: BoxDecoration(
        color: Colors.blue,
        shape: BoxShape.circle,
      ),
      child: Center(
        child: Text(
          '${i + 1}',
          style: const TextStyle(color: Colors.white),
        ),
      ),
    ),
  ),
)

📋 小结

核心概念

组件 说明 使用场景
Wrap 自动换行布局 标签云、按钮组、图片网格
Flow 高性能自定义布局 动画、复杂布局策略

Wrap常用属性

属性 说明 常用值
spacing 主轴间距 8.0
runSpacing 纵轴间距 8.0
alignment 主轴对齐 start/center
runAlignment 纵轴对齐 start/center

Flow关键方法

方法 说明
paintChildren() 计算并绘制子组件位置
getSize() 返回Flow的尺寸
shouldRepaint() 是否需要重绘

记忆技巧

  1. Wrap首选:90%场景用Wrap
  2. spacing记忆:spacing = 同行间距,runSpacing = 行间距
  3. Flow性能好:转换矩阵优化
  4. Flow很少用:除非特殊需求

🔗 相关资源


CSS 相对颜色:告别 180 个颜色变量的设计系统噩梦

作者 大知闲闲i
2025年11月19日 15:51

当组件库中的颜色变量达到 180 个时,一次品牌色变更就成了前端开发的噩梦。CSS 相对颜色语法将彻底改变这一现状。

一个让人沉默的现实

最近在排查一个组件库的主题 BUG 时,我们发现了令人震惊的事实:这个看似成熟的设计系统中,竟然定义了 180 个颜色变量

更可怕的是,每次品牌主色调整,都需要在 3 个不同的文件中同步修改 15 种深浅变化、hover 状态、透明度变体……设计同学轻描淡写的一句"主色想从偏蓝调成更紫一点",意味着工程侧需要:

  • 手动修改 15+ 个变量

  • 反复对比 hover、active 状态是否协调

  • 仔细检查半透明背景是否漏改

  • 确保整个颜色体系保持和谐

漏改一个变量,hover 状态显得怪异;漏改两个,整套主题就开始"发脏"。

传统颜色系统的困境

当前绝大多数设计系统的配色方案可以概括为:靠人肉复制的"颜色农场"

:root {
  /* 主色系 */
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-primary-active: #1d4ed8;
  --color-primary-light: #93c5fd;
  --color-primary-dark: #1e40af;
  
  /* 辅助色系 */
  --color-secondary: #8b5cf6;
  --color-secondary-hover: #7c3aed;
  --color-secondary-active: #6d28d9;
  
  /* 继续衍生... */
}

这种模式的痛点显而易见:

  • 维护成本高:一个主色系需要十几二十个变量

  • 同步困难:多处定义的变量容易遗漏

  • 不可靠:手动调色依赖个人感觉,缺乏系统性

CSS 相对颜色:革命性的解决方案

CSS 相对颜色语法引入的 from 关键字,让颜色从"死值"变成"活公式"。

基础语法

color-function(from origin-color channel1 channel2 channel3 / alpha)

拆解说明:

  • color-function:输出格式,如 rgb()hsl()oklch()

  • from:关键字符,声明颜色来源

  • origin-color:基准颜色,支持 hex、RGB、HSL 等格式

  • channel1 ~ 3:可访问和修改的通道值

  • alpha:可选透明度通道

实际应用示例

:root {
  --primary: #3b82f6;
}

.button {
  background: var(--primary);
}

.button:hover {
  /* 基于主色自动计算 hover 状态 */
  background: hsl(from var(--primary) h s calc(l - 10));
}

这一行 hsl(from ...) 的改变,将 hover 效果从"写死"变成了"相对基色、自动联动"。从此,品牌色只需修改一个 --primary 变量,所有衍生状态自动跟随。

from 关键字的魔力

from 的核心作用是将颜色分解为通道值,让我们能够像搭乐高一样重新组合:

/* 将绿色分解为 RGB 通道 */
rgb(from green r g b)  /* 输出: rgb(0 128 0) */

/* 用绿色通道创建灰度 */
rgb(from green g g g)  /* 输出: rgb(128 128 128) */

/* 随意调换通道顺序 */
rgb(from green b r g)  /* 输出: rgb(0 0 128) */

跨色彩空间转换

from 自动处理色彩空间转换,让颜色格式不再成为障碍:

/* RGB 转 HSL */
hsl(from rgb(255 0 0) h s l)

/* Hex 转 OKLCH */
oklch(from #3b82f6 l c h)

这对设计系统意义重大:源头存储格式不再重要,使用端始终使用统一的可计算空间。

calc():颜色计算的引擎

真正的威力在于将 calc() 与颜色通道结合:

/* 变亮:提高亮度 */
hsl(from blue h s calc(l + 20))

/* 变暗:降低亮度 */  
hsl(from blue h s calc(l - 20))

/* 半透明:调整透明度 */
rgb(from blue r g b / calc(alpha * 0.5))

/* 调色:旋转色相 */
hsl(from blue calc(h + 180) s l)

大部分颜色衍生逻辑都可以归结为:通道 + 偏移量通道 × 系数

OKLCH:更智能的色彩空间

虽然 HSL 很流行,但它有个致命缺陷:亮度感知不均

hsl(220 80% 50%)  /* 蓝色 */
hsl(120 80% 50%)  /* 绿色 */

理论上两者亮度相同,但人眼感知中绿色明显更亮。OKLCH 解决了这个问题:

  • L(Lightness):0-1,感知亮度,更符合人眼

  • C(Chroma):0-约0.37,颜色纯度

  • H(Hue):0-360,色相角度

    oklch(0.55 0.15 260) /* 蓝色 / oklch(0.55 0.15 140) / 绿色 */

在 OKLCH 中,相同的 L 值在不同色相间具有一致的亮度感知,这让程序化调色更加可靠。

构建智能颜色系统

第一步:定义品牌基色

:root {
  /* 只用定义 4 个基础品牌色 */
  --brand-primary: oklch(0.55 0.2 265);
  --brand-success: oklch(0.65 0.18 145);
  --brand-error: oklch(0.6 0.25 25);
  --brand-warning: oklch(0.75 0.15 85);
}

第二步:按规则生成完整色板

:root {
  /* Primary 色系 - 全部从基色派生 */
  --primary: var(--brand-primary);
  --primary-hover: oklch(from var(--brand-primary) calc(l - 0.1) c h);
  --primary-active: oklch(from var(--brand-primary) calc(l - 0.15) c h);
  --primary-light: oklch(from var(--brand-primary) calc(l + 0.2) calc(c * 0.5) h);
  --primary-lighter: oklch(from var(--brand-primary) calc(l + 0.3) calc(c * 0.3) h);
  --primary-alpha-10: oklch(from var(--brand-primary) l c h / 0.1);
  --primary-alpha-20: oklch(from var(--brand-primary) l c h / 0.2);
  
  /* 其他色系采用相同模式 */
  --success: var(--brand-success);
  --success-hover: oklch(from var(--brand-success) calc(l - 0.1) c h);
  --success-light: oklch(from var(--brand-success) calc(l + 0.2) calc(c * 0.5) h);
}

四个基色变量,扩展出完整的颜色体系。品牌色调整时,只需修改四个基础值。

暗色模式的革命

传统暗色模式需要维护两套 token,现在只需一个公式:

:root {
  --surface: oklch(0.98 0.02 240);
  --text: oklch(0.25 0.03 240);
}

[data-theme="dark"] {
  /* 亮度反转实现暗色模式 */
  --surface: oklch(from var(--surface) calc(1 - l) c h);
  --text: oklch(from var(--text) calc(1 - l) c h);
}

实战高级技巧

1. 智能阴影系统

.card {
  --card-bg: var(--primary);
  background: var(--card-bg);
  box-shadow: 
    0 4px 6px oklch(from var(--card-bg) l c h / 0.2),
    0 10px 15px oklch(from var(--card-bg) l c h / 0.15);
}

阴影自动适应背景色,主题切换时自然过渡。

2. 确保可读性的文本颜色

.tag {
  --tag-bg: var(--primary);
  background: var(--tag-bg);
  /* 文本亮度比背景高 0.6,确保对比度 */
  color: oklch(from var(--tag-bg) calc(l + 0.6) c h);
}

3. 品牌化半透明遮罩

.modal-backdrop {
  background: oklch(from var(--brand-primary) l c h / 0.7);
}

浏览器支持与渐进增强

截至 2025 年,相对颜色已获得良好支持:

  • Chrome 119+ ✅

  • Firefox 128+ ✅

  • Safari 16.4+ ✅

  • Edge 119+ ✅

覆盖率约 83%,对于不支持的环境可提供静态回退:

.button {
  background: #2563eb; /* 回退值 */
  background: oklch(from var(--primary) calc(l - 0.1) c h);
}

避坑指南

  1. 避免过深派生链:从基色直接推导,最多两层

  2. 控制 Chroma 范围:OKLCH 中 Chroma 超过 0.37 可能导致颜色溢出

  3. 正确使用 Alphaoklch(0.6 0.2 265 / 0.5) 而非 oklch(0.6 0.2 265 0.5)

总结:从小升级到大变革

CSS 相对颜色解决的不仅是技术问题,更是设计系统维护的哲学变革:

  • 主题切换不再是灾难:改一个变量,全站自洽

  • 颜色 Token 真正集中管理:从分散定义到统一源头

  • 设计规则化:深浅、状态、透明度都成为可复用的公式

  • 开发体验提升:从机械调色到智能推导

下一次重构配色系统时,不妨尝试将基准色、状态色、暗色模式、半透明层全部交给相对颜色计算。那种"改一处,全局联动"的流畅体验,确实让人上头。

从 180 个颜色变量到 4 个基色变量,这不只是数量的减少,更是设计系统维护理念的质的飞跃。

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 // 就可以运行了。

🔥 参数归一化:告别多态参数地狱,用统一输入让API更稳定易用

2025年11月20日 12:20

🎯 学习目标:掌握在复杂场景下将多态参数统一成稳定输入的系统方法

📊 难度等级:中级
🏷️ 技术标签:#参数归一化 #API设计 #默认值 #类型守卫
⏱️ 阅读时间:约10-12分钟


🌟 引言

复杂的前端/Node工具函数和组件经常支持“多种调用方式”:既允许位置参数,也允许传对象;既允许回调,也支持Promise;既能传url字符串,也能传完整options。这类“多态参数”短期看起来灵活,长期却让实现和维护越来越困难:

  • 调用不一致,阅读成本高;
  • 兼容旧签名,代码到处是if/else分支;
  • 默认值散落各处,升级容易破坏兼容;
  • 错误返回格式不统一,难以在上层复用。

参数归一化的目标是:无论用户如何传参,内部始终转为一份“规范化输入”,从而让实现、错误处理、日志、测试都更简单、更稳定。


💡 核心技巧详解

1. 统一为options对象:位置参数、别名与默认值一次搞定

🔍 应用场景

函数既支持fn(url, method, data)也支持fn({ url, method, data });或参数名有历史别名,如timeoutts

❌ 常见问题

逻辑里充满“如果是字符串就当url,如果是对象就当options”,默认值到处设置,难以维护。

// ❌ 传统写法/错误示例
const request = (urlOrOptions, method = 'GET', data = undefined) => {
  const isString = typeof urlOrOptions === 'string';
  const url = isString ? urlOrOptions : urlOrOptions?.url;
  const finalMethod = isString ? method : (urlOrOptions?.method || 'GET');
  const finalData = isString ? data : urlOrOptions?.data;
  // ...更多分支与默认值,越写越乱
};

✅ 推荐方案

将所有输入统一映射为options对象,并在一个地方完成合并与默认值设置。

/**
 * 参数归一化:请求选项
 * @description 接受字符串或对象输入,统一生成规范化options
 * @param {string|object} input - 字符串url或包含url的对象
 * @returns {{ url: string, method: string, data: any, timeout: number }} 规范化请求选项
 */
const normalizeRequestOptions = (input) => {
  const base = { url: '', method: 'GET', data: undefined, timeout: 8000 };
  const fromString = (v) => ({ url: String(v) });
  const fromObject = (v) => {
    // 支持历史别名
    const timeout = v.timeout ?? v.ts ?? base.timeout;
    return { url: v.url ?? base.url, method: v.method ?? base.method, data: v.data ?? base.data, timeout };
  };
  const opts = typeof input === 'string' ? fromString(input) : fromObject(input || {});
  return { ...base, ...opts };
};

💡 核心要点

  • 入口只做一件事:把所有输入转为统一options对象;
  • 默认值集中定义,别名在归一化处处理;
  • 调用端自由,实现端稳定。

🎯 实际应用

/**
 * 简化封装的请求函数
 * @description 内部始终接收规范化options,便于拦截、重试、日志
 * @param {string|object} input - url字符串或对象
 * @returns {Promise<{ok:boolean, data:any, status:number}>} 标准化响应
 */
const request = async (input) => {
  const opts = normalizeRequestOptions(input);
  // 统一日志与拦截
  // 这里用fetch占位,真实项目替换为你的请求层
  const res = await fetch(opts.url, { method: opts.method, body: JSON.stringify(opts.data) });
  const data = await res.json().catch(() => ({}));
  return { ok: res.ok, data, status: res.status };
};

2. 兼容旧签名的适配层:集中处理历史输入

🔍 应用场景

早期版本支持doTask(name, cb), 新版希望统一为doTask({ name, onSuccess })

❌ 常见问题

处处判断“如果cb存在就当旧版”,导致逻辑分散、难以移除旧代码。

// ❌ 到处判断旧签名
const doTask = (nameOrOptions, cb) => {
  if (typeof nameOrOptions === 'string') {
    // 旧版
    // ...
  } else {
    // 新版
    // ...
  }
};

✅ 推荐方案

集中一个适配函数把旧输入映射到新options,主流程只认新格式。

/**
 * 旧签名适配器
 * @description 将 (name, cb) 归一化为 { name, onSuccess }
 * @param {string|object} input - 任务名或新格式对象
 * @param {Function} [cb] - 旧版回调
 * @returns {{ name: string, onSuccess: Function }} 新版选项
 */
const normalizeTaskOptions = (input, cb) => {
  if (typeof input === 'string') return { name: input, onSuccess: cb ?? (() => {}) };
  return { name: input?.name ?? '', onSuccess: input?.onSuccess ?? (() => {}) };
};

/**
 * 只认统一options的主流程
 * @param {string|object} input - 任务名或新格式对象
 * @param {Function} [cb] - 旧版回调
 * @returns {Promise<string>} 结果
 */
const doTask = async (input, cb) => {
  const opts = normalizeTaskOptions(input, cb);
  // ...核心实现只使用 opts
  opts.onSuccess?.(opts.name);
  return `done:${opts.name}`;
};

3. 返回值归一化:统一 Promise,消除回调/同步差异

🔍 应用场景

既支持回调,也能同步返回;结果格式不统一,调用方难以复用。

❌ 常见问题

部分路径返回undefined或抛错;部分路径走回调;难以统一上层流程。

// ❌ 路径不一致,难以复用
const work = (options, cb) => {
  if (cb) {
    setTimeout(() => cb(null, { ok: true }), 0);
    return;
  }
  return { ok: true };
};

✅ 推荐方案

所有分支都转为Promise,错误统一为异常或Result对象。

/**
 * 结果归一化
 * @description 统一返回 Promise<Result>
 * @param {object} options - 输入选项
 * @returns {Promise<{ ok: boolean, data?: any, error?: string }>} 标准结果
 */
const doWork = async (options) => {
  try {
    const shouldFail = options?.fail === true;
    if (shouldFail) throw new Error('fail');
    return { ok: true, data: options };
  } catch (e) {
    return { ok: false, error: e.message };
  }
};

4. 类型守卫与轻量校验:入口处把错挡住,把值校正好

🔍 应用场景

调用方可能传"5"这类字符串数字、null、或无效布尔;需要在入口统一校正。

❌ 常见问题

在业务流程里到处做typeof判断;错误提示不一致;测试覆盖困难。

// ❌ 在流程中穿插类型判断,噪音多
const calc = (count, enabled) => {
  const c = typeof count === 'string' ? Number(count) : count;
  const ok = enabled === true || enabled === 'yes';
  // ...
};

✅ 推荐方案

提供独立的 normalize 层,集中进行类型守卫与校正。

/**
 * 数字归一化
 * @description 接受 string/number,统一为合法 number
 * @param {string|number} v - 值
 * @returns {number} 归一化数字(非法时为0)
 */
const normalizeNumber = (v) => {
  const n = typeof v === 'string' ? Number(v) : v;
  return Number.isFinite(n) ? n : 0;
};

/**
 * 布尔归一化
 * @description 接受 boolean/string,统一为 true/false
 * @param {boolean|string} v - 值
 * @returns {boolean} 归一化布尔
 */
const normalizeBoolean = (v) => {
  if (typeof v === 'boolean') return v;
  const truthy = ['true', '1', 'yes', 'on'];
  return truthy.includes(String(v).toLowerCase());
};

/**
 * 综合选项归一化
 * @param {{ count?: number|string, enabled?: boolean|string }} input - 原始输入
 * @returns {{ count: number, enabled: boolean }} 规范化选项
 */
const normalizeOptions = (input) => {
  const base = { count: 0, enabled: false };
  const c = normalizeNumber(input?.count ?? base.count);
  const e = normalizeBoolean(input?.enabled ?? base.enabled);
  return { count: c, enabled: e };
};

5. 归一化管线:把“输入→校验→默认值→适配→输出”串成可测试流程

🔍 应用场景

复杂函数需要同时支持多入口、多别名、多默认值,还要记录来源与警告。

✅ 推荐方案

用“管线函数”把步骤串起来,便于单元测试和复用。

/**
 * 归一化管线
 * @description 将原始输入依次映射为标准输出
 * @param {any} raw - 原始输入
 * @returns {{ url:string, method:string, meta:{ source:string } }} 标准选项
 */
const normalizePipeline = (raw) => {
  const source = typeof raw === 'string' ? 'string' : 'object';
  const step1 = normalizeRequestOptions(raw); // 统一options
  const method = step1.method.toUpperCase(); // 统一大小写
  return { ...step1, method, meta: { source } };
};

6. 表单输入归一化:把用户输入转为可用数据(字符串/数字/布尔)

🔍 应用场景

表单字段常出现空字符串、字符串数字、大小写不一致、语义化布尔等,需要统一转换为可用数据。

❌ 常见问题

直接将 v-model 的值用于业务,出现空串、"true""5" 等导致逻辑分支混乱。

// ❌ 直接使用原始值,逻辑易错
const submit = (form) => {
  // age 是字符串,enabled 是 "true"
  if (form.enabled === 'true' && Number(form.age) > 18) {
    // ...
  }
};

✅ 推荐方案

提供字段级与整体表单的归一化函数,集中处理空串、数字、布尔。

/**
 * 字符串归一化
 * @description 去除空白,空字符串转为 null
 * @param {string} v - 输入值
 * @returns {string|null} 规整字符串
 */
const normalizeString = (v) => {
  const s = String(v ?? '').trim();
  return s.length ? s : null;
};

/**
 * 表单归一化
 * @description 统一字符串数字、布尔与空字符串
 * @param {{ name?: string, age?: string|number, enabled?: string|boolean }} input - 原始表单
 * @returns {{ name: string|null, age: number, enabled: boolean }} 规整表单
 */
const normalizeFormValues = (input) => {
  const name = normalizeString(input?.name);
  const age = normalizeNumber(input?.age ?? 0);
  const enabled = normalizeBoolean(input?.enabled ?? false);
  return { name, age, enabled };
};

💡 核心要点

  • 空字符串与仅空白统一为 null
  • 数字/布尔集中转换,避免业务散落判断;
  • 组合一个“表单归一化”入口。

🎯 实际应用

// 组件内处理(示意)
const handleSubmit = (raw) => {
  const form = normalizeFormValues(raw);
  // 使用规整数据执行业务逻辑
};

7. 路由/URL 查询参数归一化:string|object → typed options

🔍 应用场景

URL 查询参数来源多样:手写字符串、window.location.search、路由库 route.query,需要统一为 typed options。

❌ 常见问题

在业务中直接使用字符串参数,出现数字/布尔类型错误、空值与缺省处理不一致。

// ❌ 直接用字符串参数
const page = Number(new URL(location.href).searchParams.get('page'));

✅ 推荐方案

将字符串/对象统一解析为具备类型的 QueryOptions。

/**
 * 查询参数归一化
 * @description 将 string|object 统一为 typed options
 * @param {string|Record<string, any>} input - 查询输入
 * @returns {{ page:number, sort:string|null, active:boolean, tags:string[] }} 规整查询
 */
const normalizeQueryParams = (input) => {
  const fromString = (s) => new URLSearchParams(String(s));
  const fromObject = (o) => new URLSearchParams(Object.entries(o || {}));
  const sp = typeof input === 'string' ? fromString(input) : fromObject(input);
  const page = normalizeNumber(sp.get('page') ?? 1);
  const sort = normalizeString(sp.get('sort'));
  const active = normalizeBoolean(sp.get('active') ?? false);
  const tags = (sp.getAll('tags') || (sp.get('tags')?.split(',') ?? [])).map((t) => String(t).trim()).filter(Boolean);
  return { page, sort, active, tags };
};

💡 核心要点

  • 支持字符串与对象两类输入;
  • 用 URLSearchParams 保持解析一致性;
  • 所有字段都按类型归一化。

🎯 实际应用

const query = normalizeQueryParams(location.search);
// 用于接口或列表查询

8. 接口响应归一化:统一 { ok, data?, error?, status }

🔍 应用场景

不同接口返回结构差异大,错误表示各不相同,上层复用困难。

❌ 常见问题

有的接口返回 { code, msg, data },有的返回 { success, result },错误捕获与重试逻辑复杂。

// ❌ 手动适配每个接口
const loadUser = async () => {
  const r = await fetch('/api/user');
  const j = await r.json();
  // if (j.code === 0) ... else ...
};

✅ 推荐方案

统一响应为 { ok, data?, error?, status },错误信息走同一字段。

/**
 * 响应归一化
 * @description 统一为 { ok, data?, error?, status }
 * @param {{ ok:boolean, status:number, json:() => Promise<any> }} res - 原始响应
 * @returns {Promise<{ ok:boolean, data?:any, error?:string, status:number }>} 标准响应
 */
const normalizeResponse = async (res) => {
  try {
    const data = await res.json();
    return { ok: res.ok, data, status: res.status };
  } catch (e) {
    return { ok: false, error: e.message, status: res.status ?? 0 };
  }
};

💡 核心要点

  • 成功/失败统一结构,减少上层分支复杂度;
  • 错误信息集中在 error 字段;
  • 可与重试/日志/拦截器复用。

🎯 实际应用

const run = async () => {
  const res = await fakeFetch('/x');
  const r = await normalizeResponse(res);
  if (!r.ok) {
    // 统一错误处理
  }
};

📊 技巧对比总结

技巧 使用场景 优势 注意事项
统一为options 多入口/别名/默认值 调用自由,实现稳定 别名集中处理,默认值合一
旧签名适配层 历史调用保留兼容 主流程只认新格式 逐步标记弃用与告警
返回值归一化 回调/同步混用 上层只处理一种结果 错误统一为异常或Result
类型守卫校验 输入不可信 流程更干净,测试更容易 入口集中做校正
管线化流程 场景复杂 可组合、可测试 保持函数短小、职责单一
表单输入归一化 表单字段混乱 业务更稳、逻辑更简 空串转null、统一布尔/数字
URL查询归一化 路由/查询多来源 解析一致、类型明确 使用URLSearchParams,防空值
响应归一化 多接口返回不一 统一上层处理 错误集中在error字段

🎯 实战应用建议

最佳实践

  1. API只认一种内部格式:入口先归一化;
  2. 默认值只写一处:base对象集中维护;
  3. 旧签名集中适配:统一发警告并统计调用;
  4. 错误与返回统一:Promise+Result对象更易复用;
  5. 写测试针对归一化:不依赖网络与外部环境。

性能与兼容性

  • 归一化逻辑尽量纯函数,便于缓存与并行;
  • 小心对象合并的“浅合并/深合并”差异,按需实现;
  • 对大小写、别名、单位等细节统一规则,避免隐式差异。

💡 总结

这8个参数归一化技巧能让你的API更稳定、更易维护:

  1. 统一入口为options对象;
  2. 旧签名集中适配;
  3. 返回值统一为Promise+Result;
  4. 类型守卫与校正放在入口;
  5. 用管线串联步骤,保持函数短小可测试;
  6. 表单字段归一化,空串/数字/布尔集中转换;
  7. URL 查询参数归一化为 typed options;
  8. 接口响应统一结构,便于上层复用。

🔗 相关资源


💡 今日收获:掌握了8个参数归一化技巧,能在复杂场景下统一输入、稳定输出,大幅降低维护成本。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

创建VUE3项目

2025年11月20日 12:10

一、本地创建VUE3项目

  1. 使用Vite创建VUE3项目。打开CMD或者Windows PowerShell,切换到项目文件夹下,比如:E:\dengxl\projects
  2. 输入命令

pnpm create vite

  1. 按照提示,输入项目名称vue-project-name,选择框架VUE, 语言TypeScript,构建工具Vite等。
  2. 切换到项目文件夹vue-project-name下,执行命令安装项目依赖的包。

pnpm install

  1. 安装成功后,在开发环境启动项目。

pnpm dev

在控制台中,可以看到启动成功的信息,并有浏览器访问的地址。

  1. 默认端口号5173。浏览器中访问 http://localhost:5173 可看到项目启动成功。

二、上传到GIT远程仓库

  1. 登录gitee后,创建一个空的仓库,里面什么文件都没有。
  2. 在本地项目文件vue-project-name下,打开Git Bash。
  3. git初始化。输入命令

git init

  1. 绑定远程仓库。

git remote add origin 远程仓库的SSH地址

  1. 提交到本地仓库。
  2. push到git 远程仓库。

git push -u origin master

三、设置项目启动成功后,浏览器自动打开

默认情况下,本地启动VUE3项目成功后,浏览器不会自动打开。

以下设置可以实现项目启动成功后,浏览器自动打开。

在package.json文件中,dev启动命令中,添加open选项。

"scripts": {
    "dev": "vite --open",
  },

玩转 AI 应用开发|30行代码实现聊天机器人🤖

作者 Sentry5
2025年11月20日 11:23

在我的上一篇文章《Cursor 一年深度开发实践:前端开发的效率革命🚀》结尾,我曾展望AI时代可能会催生“超级个体”,取代传统的产品经理+前后端协作模式。但坦白说,作为一名前端开发者,当AI浪潮来临之初,我的第一反应和大多数同行一样:这是算法工程师的领域,离我很远。

面对那些充斥着复杂公式的技术论文,我一度认为:即便花时间学习这些知识,我也不可能转行去和科班出身的算法工程师竞争。与其钻研这些“用不上”的AI技术,不如继续深耕老本行,把React原理、性能优化和工程化这些看家本领练得更扎实。

直到某天,我决定亲自探究一下所谓的 AI 应用开发到底有多复杂,却意外地发现:一个功能完整的聊天机器人(如下图),其核心逻辑竟然只需要30行代码就能实现。

image.png

这一发现让我瞬间意识到:在 AI 应用的浪潮中,能够深刻理解产品、具备工程化思维和全链路能力的前端开发者,不仅不会掉队,反而很可能在 AI 应用层开发中焕发职业生涯的第二春

理解 AI 应用核心实现

30 行代码实现聊天机器人

可以先快速浏览以获得大致印象,如有疑惑再继续深入阅读。

import readline from 'readline';
const API_KEY = process.env.API_KEY;
const messages = [{ role: 'system', content: '你是一个前端高手,能帮我解答前端开发中遇到的问题。' }];
while (true) {
  const input = await new Promise((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('用户: ', (msg) => {
      resolve(msg);
      rl.close();
    });
  });
  messages.push({ role: 'user', content: input });
  const res = await fetch(
    'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${API_KEY}`,
      },
      body: JSON.stringify({ model: 'qwen-plus', messages }),
    }
  );
  const reply = (await res.json()).choices[0].message.content;
  messages.push({ role: 'assistant', content: reply });
  console.log('AI助手:', reply + '\n');
}

获取模型服务

所谓 AI 应用(现在也多称为“大模型应用”),其核心就是通过 API 调用大模型服务。对于个人开发者而言,入门门槛已大大降低,阿里云、火山引擎、智谱AI等平台均提供了免费的 tokens 额度,足以用于学习和原型开发。

各平台官网都提供了详尽的开发文档。本文将以阿里云的千问(qwen)模型为例进行演示。在开始编码前,我们只需理解两个最核心的概念:API Key 与 baseUrl

1. API Key:你的身份凭证

API Key 相当于你调用大模型服务的“密码”或“令牌”。它用于在 HTTP 请求头中进行身份认证,确保只有授权的用户才能访问服务。

  • 获取方式:在对应云平台注册并开通服务后,通常可以在控制台的“密钥管理”页面创建。
  • 安全须知:这是一个高度敏感的字符串,绝不能直接硬编码在前端代码或公开的仓库中。正确的做法是将其设置为环境变量:const API_KEY = process.env.API_KEY;

2. baseUrl:服务的地址

baseUrl 是你所要调用的 API 服务的接口地址。不同平台的 API 地址各不相同。比如本文代码中使用的通义千问的接口地址为: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions

Node.js实现基础对话能力

在引入AI能力之前,我们先构建一个纯本地的对话系统框架:

image.png

import readline from 'readline';
// 主对话循环
while (true) {
  // 获取用户输入
  const input = await new Promise((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('用户: ', (msg) => {
      resolve(msg);
      rl.close();
    });
  });
  // 模拟AI回复(后续会替换为真实API调用)
  const mockReply = `已收到您的输入: "${input}"`;

  // 输出AI回复
  console.log('AI助手:', mockReply + '\n');
}

运行步骤

  1. 将代码保存为 hello_ai.mjs 文件:因为需要直接在顶层使用await,所以需要ES Module。
  2. 在终端执行 node hello_ai.mjs
  3. 开始与模拟AI对话

技术要点解析

  1. readline 模块:命令行交互的核心

    • Node.js内置模块,专门处理命令行输入输出
    • createInterface创建读写接口,question方法实现问答式交互
  2. while(true):持续对话的引擎

    • 无限循环确保对话可以一直进行
    • 每次迭代完成一次完整的"输入-处理-输出"周期
    • 这是所有交互式命令行应用的经典架构模式
  3. 异步流程控制

    • 使用await等待用户输入完成
    • 确保代码执行顺序符合交互逻辑

✨调用大模型服务

至此,我们已经完成了前期准备:申请了大模型服务,并构建了基础的对话循环。现在只需将两部分连接起来——将对话上下文和模型参数发送到服务端

对于通义千问模型,必选的核心参数只有两个:

image.png

1. 模型选择 (model)
我们选择 qwen-plus 作为本次演示的模型。

2. 对话上下文 (messages)
这是AI应用的核心机制,通过维护完整的对话历史来实现上下文理解:

const messages = [
  {
    role: 'system',
    content:
      '你是一个前端高手,能帮我解答前端开发中遇到的问题。我希望你的回答精简干练有技术范',
  },
];

消息格式说明:

  • system: 系统级指令,设定AI的基础行为和角色
  • user: 用户输入的消息
  • assistant: AI的回复消息

完整的API调用流程:

// 用户输入后,将用户输入添加到上下文中
messages.push({ role: 'user', content: input });
// 调用AI助手
const res = await fetch(
  'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${API_KEY}`, // 使用API_KEY进行授权
    },
    body: JSON.stringify({ model: 'qwen-plus', messages }),
  }
);
// 解析AI助手的回复
const reply = (await res.json()).choices[0].message.content;
// 将AI助手的回复添加到上下文中
messages.push({ role: 'assistant', content: reply });
console.log('AI助手:', reply + '\n');

现在,通过以下命令启动项目,就能得到本文开头展示的智能对话效果了:

node --env-file=.env hello_ai.mjs

注意--env-file 参数需要 Node.js 20.0.0 或更高版本。如果你的 Node.js 版本较低,可以使用 dotenv 库加载环境变量。

思考

影响大模型应用体验的因素

我们实现的聊天机器人界面确实相对简陋,但这并不妨碍我们理解AI应用的核心工作原理。从实现过程中可以看到,与模型服务交互的关键要素集中在三个方面:

1. 模型

从笔者最早体验的模型 GPT-2 到现在的 Gemini 3,大语言模型已经完成了从"人工智障"到"超级智能助手"的质变。模型基座的能力决定了应用体验的上限——再优秀的产品设计也无法让落后模型产出高质量内容。

2. 提示词工程

在我们的示例中,仅用一行简单的系统提示词设定了AI的角色。然而在真实的AI应用中,提示词工程(Prompt Engineering)  远非如此简单,它是一门直接决定模型输出质量的深奥学问。

事实上,我个人在相当长一段时间里都认为提示词“意义不大”——坚信只要我能清晰、准确地描述需求,就能用好大模型。直到两件事彻底改变了我的看法:

一是看到了开源项目 System Prompts and Models of AI Tools,其中收集了众多知名AI应用的系统提示词。阅读后我才恍然大悟:这些成熟产品的“智能”,很大程度上正是依赖于这些精心设计、细节丰富的“说明书”。它们不是简单的角色设定,而是包含了复杂的行为规范、输出格式约束、安全边界设定等一整套控制逻辑。

二是我老板聊起他家孩子与豆包APP中预设的奥特曼语音聊得热火朝天。这个看似简单的产品功能让我从市场需求的角度认识到:绝大多数用户并不具备“精确描述需求”的能力,他们需要的是开箱即用的、预设好角色和场景的智能体验。

这两个例子让我从技术实现产品设计两个维度,重新认识了提示词的价值:它不仅是技术人员挖掘模型潜力的工具,更是产品团队将AI能力转化为用户价值的核心桥梁。

3. 上下文管理

在简短对话中,上下文的影响并不明显。但随着对话轮次增加,历史信息的有效存储、检索和压缩将成为关键挑战:

  • 如何从长对话中准确提取相关信息?
  • 面对模型的上下文长度限制,如何智能压缩历史记录?
  • 多轮对话中的信息一致性如何保证?

前端工程师的AI时代机遇

需要明确的是,一名前端工程师,首先应是一名合格的软件工程师。如果你的技能栈长期局限于“使用框架编写管理后台页面”,而对计算机网络、操作系统、数据库等计算机基础知之甚少,那么你将面临的挑战可能并非来自AI,而是来自每年涌入就业市场的、具备扎实科班基础的应届生。

在此共识之上,我们再来看前端开发者在AI时代的独特机遇。与传统应用(如电商、直播)将业务逻辑和高并发压力集中于后端不同,AI原生应用下的游戏规则发生了改变:

在传统架构中,像电商秒杀、直播弹幕这类场景,核心复杂度在于后端的高并发、分布式事务和数据一致性,这通常是Java/Go等语言的强场,Node.js在其中确实存在生态和性能的局限性。

但在AI应用架构中:

  • 计算压力转移:最消耗计算资源的模型推理由云服务商(如阿里云、OpenAI)承担
  • 后端角色转变:应用自身的后端被重构为轻量中台,核心职责是路由API请求、管理对话上下文、处理简单的业务状态
  • 技术栈鸿沟消失:对于这类I/O密集型的轻量后台,Node.js的性能和开发效率反而成为优势

如此一来,一个计算机基础良好的前端工程师,实现全栈AI应用的技术门槛已大幅降低。

此外,在笔者所处的电商行业,无论是面向用户的推荐、广告、秒杀系统,还是后台的素材、订单管理,前端往往被定位为"界面的实现者"——核心业务逻辑完全由后端掌控。即便存在少数重前端的业务场景(如在线文档、设计工具、互动游戏),其对核心业务指标的影响也相对有限。

而AI应用有望改写这一传统模式。当所有开发者的底层都是调用相同的大模型服务时,产品的差异化竞争力就转移到了应用层——谁能为模型能力套上更优秀的“壳”,谁就能赢得用户。

因此,前端工程师的机遇或许在于:将对交互与体验的深刻理解,转化为设计模型能力“交互架构”的优势,并凭借全栈技能,独立完成从创意到产品的端到端实现。这正是前端角色从界面实现者,向超级个体演进的关键一步。

结语

当然,生产级AI应用远非如此简单。后续我们将深入架构、上下文与提示词等核心领域,共同将原型演进为一个健壮的AI应用。这是一个系统工程,我们下一篇文章见。

❌
❌