普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月3日掘金 前端

项目启动三阶段:从思到形

作者 LeonGao
2026年2月3日 11:55

🚀 一、项目启动三阶段:从思到形

项目启动可以拆成三个阶段,每个阶段对应上文的思维要点和执行准则。

阶段 1:思考与设计(脑力阶段)

“行万里路,不如脑子想清楚。”

目标:确定性要大于行动速度。

关键输出:

  • ✅ 业务目标清单(核心可交付是什么?)
  • ✅ 技术选型 & 模块划分(基于模板启动)
  • ✅ 组件粒度 & 数据流方向(决定架构风格)
  • ✅ 风险点与预案(接口不确定、UI延迟设计等)

方法落地:

  • 使用 oa-start-templatemachine-start-template 创建基础目录结构。
  • 先写出基础 store 结构与 route 命名方案。
  • output 一个简洁的架构草图或 README,让团队“心里有图”。

阶段 2:构建与闭环(执行阶段)

“快速出成果,但思考不放松。”

对应前文开发阶段 a → i:

阶段 实际应用 关键产出
a 梳理确定需求 项目 README, todo, milestone
b 构建静态页面 skeleton 页面或 Figma 转换样本
c 设计基础数据结构 定义 storetypes.ts
d 扩展业务结构 组织业务层数据流/adapter
e 构建 mock 服务 mock-server / msw / api sandbox
f 优化视图 规范 UI 风格(主题色、按钮设计)
g 优化交互 添加骨架屏、loading、状态提示
h 接入真实 API 严格字段匹配,确保无隐患
i 自测收尾 组件单测、E2E 或手动冒烟测试

落地技巧:

  • 在前 3 天内形成一个可“演示”的雏形(mock 驱动展示)。
  • 每次 merge 前保持自测闭环。
  • 使用 Git 分支命名规范(feature/xxxfix/xxx)。

阶段 3:沉淀与复盘(强化阶段)

“把握意味着能总结复用。”

要做的:

  1. ✍️ 整理出可复用模块(store、hooks、组件)。
  2. 🧩 提炼命名体系(统一命名风格与文件命名格式)。
  3. 📚 把项目经验补到团队 wiki 或模板中。
  4. ✨ 依据设计主题整理统一样式变量(主题色 / spacing / typography)。
  5. ⏱ 安排一次复盘会议——讨论“哪些思考提前做会更好”。

目标:

  • 下一次项目启动,可以直接从模板启动;
  • 启动文档具备复用性和方向感;
  • 团队在执行中提升“思考的肌肉”。

🧠 二、角色代入法:谁应该在什么时刻思考什么?

角色 聚焦点 关键问题
💡 架构师 结构、流转、命名、复用 “这个结构3个月后还能用吗?”
⚙️ 开发者 实现路径与闭环 “这功能最小化可行交付是什么?”
🧭 产品/负责人 节奏与清晰度 “项目现在的确定性够吗?”

🪞代入角色意味着——在执行前问一句:

“如果我是产品 / 使用者 / 接口方,我希望什么样的结果?”

这种内化的思维切换,就是“思考习惯”的落地方式。


🧩 三、架构主题与视觉的一致性打法

软件架构不是只在代码里体现,也体现在用户体验中。

实践建议:

  1. 提前定义主题 token(颜色、间距、圆角、阴影)。
  2. 根据团队品牌色(例如蓝、黑、灰系列)在 tailwind.config.js 或样式系统中建主题。
  3. 对标大厂组件风格(Google Material、GitHub Primer)。
  4. 制作 1 页 “UI标准卡”——即芯片、按钮、表单、输入框等样式对照。

这样从启动开始,视觉和架构就有“一致节奏感”。


🔁 四、以结果为导向的任务节奏

“如果结果不推动业务,就延后。”

  • 每次迭代定义“能演示的成果”(mock 也行)
  • 所有功能须验收:可复现、可扩展、可理解
  • 延迟不如简化,不要先做带不动的功能

通过这种节奏,团队的执行力会自动与“架构思维”对齐。


✨ 五、最终产物:一个项目启动模板应包含

模块 描述
/src/stores 核心 store + 业务结构化数据流
/src/routes 路由命名规则一致,文件结构即系统结构
/src/components 复用基础组件库(按模板标准命名)
/src/mocks mock 服务模拟真实交互
/theme.config 系统主题与品牌样式定义
/docs 项目架构文档 + 执行准则
/README.md 项目启动简述与执行清单

这就是一个真正“用架构思维启动项目”的标准蓝本。


🧭 结语

将“脑力”和“流水线”结合,就是让项目更有节奏感地前进。
架构的思维不是让流程更复杂,而是让每个人少走弯路

想清楚再行动,结构化代替堆砌,
模板化提升速度,复盘式积累成长。

Dart ——??运算符

作者 Haha_bj
2026年2月3日 11:31

你想了解 Dart 中的 ?? 运算符,它是 Dart 里处理 ** 可选值(可空类型)** 的常用运算符,核心作用是「空值合并」,我们从基础用法、核心特点、拓展用法到实际场景,一步步讲清楚,让你快速掌握。

一、先明确 ?? 运算符的核心定义

?? 被称为 「空值合并运算符(Null Coalescing Operator)」,作用是:判断运算符左侧的表达式是否为 null

  • 如果左侧不为 null,直接返回左侧的值
  • 如果左侧为 null,返回右侧的备选值

简单理解:「选左边的,左边是空的就选右边的」,用于给可空变量设置默认值,避免空值引发错误。

二、基础用法(最常用场景)

1. 基本语法格式

// 语法:左侧表达式 ?? 右侧备选值
variable ?? defaultValue

2. 示例(基础场景)

void main() {
  // 场景 1:左侧不为 null,返回左侧值
  String? name1 = "张三";
  String result1 = name1 ?? "未知用户";
  print("result1: $result1"); // 输出:result1: 张三

  // 场景 2:左侧为 null,返回右侧默认值
  String? name2 = null;
  String result2 = name2 ?? "未知用户";
  print("result2: $result2"); // 输出:result2: 未知用户

  // 场景 3:结合数值类型使用
  int? age1 = 20;
  int ageResult1 = age1 ?? 18;
  print("ageResult1: $ageResult1"); // 输出:ageResult1: 20

  int? age2 = null;
  int ageResult2 = age2 ?? 18;
  print("ageResult2: $ageResult2"); // 输出:ageResult2: 18
}

3. 关键注意点

  • ?? 只判断「左侧是否为 null」,不判断空字符串、0 等「空值但非 null」的情况

    void main() {
      // 左侧是空字符串(不是 null),返回左侧空字符串,而非右侧默认值
      String? emptyStr = "";
      String result = emptyStr ?? "默认字符串";
      print("result: '$result'"); // 输出:result: ''
    }
    
  • 右侧的备选值只有在左侧为 null 时才会被执行(惰性求值),提升性能:

    void main() {
      String? name = "李四";
      // 因为左侧不为 null,右侧的 print 不会执行,也不会调用 getDefaultName()
      String result = name ?? (print("右侧执行了") + getDefaultName());
      print("result: $result");
    }
    
    String getDefaultName() => "未知用户";
    

    运行结果:仅输出 result: 李四,右侧的打印和函数调用都未执行。

三、拓展用法:和其他运算符结合

1. ??=:空值赋值运算符(给变量本身设置默认值)

??=?? 的衍生运算符,作用是:如果变量本身为 null,就给它赋值右侧的值;如果变量不为 null,则不做任何修改(相当于「给变量设置默认值,仅在变量为空时生效」)。

void main() {
  // 场景 1:变量为 null,赋值右侧值
  String? name1 = null;
  name1 ??= "王五";
  print("name1: $name1"); // 输出:name1: 王五

  // 场景 2:变量不为 null,不修改
  String? name2 = "赵六";
  name2 ??= "未知用户";
  print("name2: $name2"); // 输出:name2: 赵六
}

对比 ????=

  • name ?? "未知":返回一个值,不修改 name 本身
  • name ??= "未知":直接修改 name 本身(仅当 namenull 时)。

2. ?. + ??:安全访问 + 空值兜底(高频组合)

?. 是「安全访问运算符」,作用是:如果对象不为 null,就访问对象的属性 / 方法;如果对象为 null,则返回 null(避免空指针异常)。

两者结合,可实现「安全访问属性,若对象为空或属性为空,就返回默认值」,是业务开发中的高频组合。

// 定义一个用户类
class User {
  String? name;
  User(this.name);
}

void main() {
  // 场景 1:对象不为 null,属性也不为 null
  User? user1 = User("钱七");
  String result1 = user1?.name ?? "未知用户";
  print("result1: $result1"); // 输出:result1: 钱七

  // 场景 2:对象不为 null,但属性为 null
  User? user2 = User(null);
  String result2 = user2?.name ?? "未知用户";
  print("result2: $result2"); // 输出:result2: 未知用户

  // 场景 3:对象本身为 null
  User? user3 = null;
  String result3 = user3?.name ?? "未知用户";
  print("result3: $result3"); // 输出:result3: 未知用户
}

四、实际业务场景示例

1. 接口返回数据兜底(避免空值展示)

// 模拟接口返回的用户数据(可能为 null)
Map<String, dynamic>? apiResponse = {
  "username": "小明",
  "age": null,
  "address": ""
};

void main() {
  // 提取用户名,为空则显示「游客」
  String username = apiResponse?["username"] ?? "游客";
  // 提取年龄,为空则默认 18
  int age = apiResponse?["age"] ?? 18;
  // 提取地址,为空字符串(非 null)则显示「未填写地址」(需额外判断)
  String address = apiResponse?["address"] ?? "未填写地址";
  address = address.isEmpty ? "未填写地址" : address;

  print("用户名:$username,年龄:$age,地址:$address");
  // 输出:用户名:小明,年龄:18,地址:未填写地址
}

2. 初始化可空变量的默认值

// 定义可空的配置变量
String? appTitle;
int? appVersionCode;

void initAppConfig() {
  // 给配置设置默认值(仅当变量为 null 时生效)
  appTitle ??= "我的Dart应用";
  appVersionCode ??= 1;
}

void main() {
  initAppConfig();
  print("应用标题:$appTitle,版本号:$appVersionCode");
  // 输出:应用标题:我的Dart应用,版本号:1
}

总结

  1. ??空值合并运算符,核心逻辑:「左侧非空返左侧,左侧为空返右侧」,用于给可空变量兜底。
  2. 关键特性:只判断 null、右侧惰性求值、不修改原变量。
  3. 常用拓展:??= 用于给变量本身设置默认值(仅为空时生效);?. + ?? 用于安全访问对象属性并兜底。
  4. 实际场景:接口数据兜底、变量默认值初始化、避免空指针异常,是 Dart 开发中的必备语法。

Python API 调用保姆级教程:从原理到第一次成功请求(附完整代码)

2026年2月3日 11:18

Python API 调用保姆级教程:从原理到第一次成功请求(附完整代码)

在当今的软件开发中,API(应用程序编程接口) 几乎无处不在。从获取天气数据、支付接口,到调用 ChatGPT 进行对话,一切的核心都是“API 调用”。

很多初学者觉得 API 很高深,但实际上,只要你掌握了**“请求(Request)”“响应(Response)”**这两个核心概念,你就已经迈过了门槛。

本文将以 Python 语言为例,手把手教你如何发起一次标准的 API 调用。为了方便演示和保证连接稳定性,本教程将使用兼容性极佳的 4SAPI 接口作为测试环境。

一、 核心概念:API 到底在做什么?

在写代码之前,你需要理解 API 交互的四个要素:

  1. Endpoint(端点) :你要去哪里取数据?通常是一个 URL(例如 https://api.4sapi.com/v1)。

  2. Method(方法) :你要做什么?

    • GET:查数据(例如:查询余额)。
    • POST:提交数据(例如:发一段话给 AI,让它回复)。
  3. Headers(请求头) :你是谁?通常包含 Authorization(你的 API Key)和 Content-Type(数据格式)。

  4. Body(请求体) :具体内容是什么?(例如:你的 Prompt 提示词)。

二、 环境准备

我们将使用 Python 中最流行的 openai 库来完成这次调用。

为什么不用 requests 库?

虽然 requests 是基础,但在 AI 开发领域,使用官方 SDK 能自动处理重试、JSON 解析等繁琐工作。由于 4SAPI 完美兼容 OpenAI 协议,我们可以直接使用这个成熟的库。

1. 安装库

打开你的终端(Terminal)或 CMD,输入:

Bash

pip install openai

2. 获取 API Key

你需要一个“通行证”。

  • 如果你使用官方 OpenAI,去官网后台。

  • 本教程推荐演示环境:前往 4SAPI 控制台 申请一个令牌。

    • 推荐理由:4SAPI 的网络线路针对国内优化(CN2直连),在调试代码时不会因为网络超时(Timeout)而报错,非常适合新手学习和企业生产环境。

三、 代码实战:编写你的第一个 API 脚本

新建一个文件 main.py,并将以下代码复制进去。为了让你看懂每一行,我添加了详细的注释。

Python

import os
from openai import OpenAI

# ==========================================
# 步骤 1:客户端配置 (Client Configuration)
# ==========================================

# 初始化客户端
client = OpenAI(
    # 【关键配置】
    # 这里的 API Key 建议从环境变量读取,或者直接填入你的 4SAPI 令牌
    api_key="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 
    
    # 【核心技巧】
    # 将 base_url 指向中转站地址。
    # 如果不改这里,代码会默认去连 OpenAI 美国服务器(容易超时)。
    # 使用 4SAPI 可以利用其企业级 CN2 线路加速,确保握手成功。
    base_url="https://api.4sapi.com/v1" 
)

# ==========================================
# 步骤 2:构建请求 (Build Request)
# ==========================================

def call_ai_model():
    print(">>> 正在发送请求...")
    
    try:
        # 发起 Chat Completions 请求(这是目前最通用的 AI 接口标准)
        response = client.chat.completions.create(
            # 指定模型:4SAPI 后端支持映射,你可以填 gpt-4o, claude-3-5-sonnet 等
            model="gpt-4o-mini", 
            
            # 消息列表:模拟对话历史
            messages=[
                {"role": "system", "content": "你是一个Python代码助手,回答要简洁。"},
                {"role": "user", "content": "请写一个打印 Hello World 的函数"}
            ],
            
            # 参数设置
            temperature=0.7, # 创意度:0.7 是比较平衡的值
            stream=False     # 是否流式输出(初学者建议先设为 False)
        )
        
        # ==========================================
        # 步骤 3:解析响应 (Parse Response)
        # ==========================================
        
        # API 返回的是一个复杂的对象,我们需要提取核心内容
        content = response.choices[0].message.content
        
        print(">>> API 调用成功!返回结果如下:")
        print("-" * 30)
        print(content)
        print("-" * 30)
        
        # 打印一下消耗的 Token 数(4SAPI 后台也能看到详细日志)
        print(f"Token 消耗: {response.usage.total_tokens}")

    except Exception as e:
        print(f"❌ 调用失败: {e}")

# 执行函数
if __name__ == "__main__":
    call_ai_model()

四、 常见报错与排查 (Troubleshooting)

在调试 API 时,报错是家常便饭。以下是三种最常见的错误代码及其含义:

  1. 401 Unauthorized

    • 含义:身份验证失败。
    • 解决:检查 api_key 是否填错,或者是否有多余的空格。
  2. 429 Too Many Requests

    • 含义:请求太快,或者额度耗尽。
    • 解决:如果你用的是官方免费号,很容易触发这个。建议切换到 4SAPI 这样的企业级中转,由于其底层采用了 MySQL 8.2 高并发架构,能承载极高的并发量,几乎不会遇到误报的 429 错误。
  3. Connection Timeout / APIConnectionError

    • 含义:连不上服务器。
    • 解决:这是国内开发者最头疼的问题。通常是因为网络墙的原因。解决办法就是修改代码中的 base_url,指向一个国内访问友好的中转节点(如代码中演示的 api.4sapi.com)。

五、 进阶:如何像高手一样使用“流式输出”?

你注意过 ChatGPT 网页版是一个字一个字蹦出来的吗?这叫 Streaming(流式)

在 API 中实现这个功能非常简单,只需要改两个地方:

Python

# 1. 将 stream 设置为 True
response = client.chat.completions.create(
    model="gpt-4",
    messages=[...],
    stream=True  # <--- 修改这里
)

# 2. 循环处理生成器
print("AI 回复: ", end="")
for chunk in response:
    if chunk.choices[0].delta.content:
        # 即时打印每一个片段
        print(chunk.choices[0].delta.content, end="", flush=True)

提示:流式输出对网络稳定性要求极高。如果中间丢包,字就会断。这也是为什么生产环境推荐使用 4SAPI 的原因——其物理线路紧邻上游核心节点,能保证长连接不断开,让“打字机效果”丝般顺滑。

结语

恭喜你!到这里,你已经完成了一次标准的 API 调用。

你会发现,代码的核心逻辑其实非常固定。真正的难点往往在于网络环境配置账号维护。对于初学者和企业开发者,选择一个配置简单、兼容性好的基础设施(如文中演示的 4SAPI),能帮你节省 90% 的调试时间,让你专注于编写业务逻辑,而不是去修网络连接。

现在,去运行代码,开启你的 AI 开发之旅吧!

2026 年AI 能生成代码,但都是一坨屎😒

作者 ErpanOmer
2026年2月3日 11:10

featuredImage.webp

现在的 IDE 已经进化到了不可思议的地步。昨天,产品经理在群里火急火燎地提了一个需求:给我们的 web 老年版 App ,要大按钮,要醒目,要快。

我把这句话喂给了最新的 Google Antigravity。

3 秒。真的只有 3.5 秒 😲。

它直接吐出了 300 行完美无瑕的 React 代码。Tailwind 样式美观,TypeScript 类型定义严丝合缝,甚至还贴心地自动补全了单元测试。

代码跑起来了,测试全绿,没有任何报错。

如果是三年前,我可能会惊叹自己要失业了。但现在,我看着屏幕上那个密密麻麻的按钮,内心毫无波澜,甚至想骂人。🤬🤬🤬

这写的是一坨什么屎????

代码都是冷冰冰的

AI 生成的代码,从计算机科学的角度看是满分的。但它根本不知道,使用这个产品的用户,是一位 75 岁、患有白内障、手抖得厉害的独居老人。

试想一下,当他在深夜突发心脏不适,颤抖着手打开手机时,他会遭遇什么?

2 (1).png

在老人模糊且敏感的视野里,不是科技感,而是一团晃眼的光斑,甚至可能诱发眩晕。

那个严丝合缝的 点击逻辑,要求手指必须精准落在 40px 的圆心内。而老人在慌乱中,手指接触屏幕的面积往往是整个指腹,甚至是大鱼际。AI 写的代码会判定这是误触,然后无动于衷。

那个毫无报错的 API 调用,在老人那微弱的 2G 信号地下室里,会直接超时。AI 很负责任地弹出了一个标准的 Ant Design 风格提示框:Error: 504 Gateway Timeout😖

这一刻,AI 完成了写代码的任务,但它彻底搞砸了救命这件事 🤦‍♂️。

我毫不犹豫地删掉了那 300 行所谓的完美代码。

我手写了 50 行最笨拙的 CSS。我去掉了所有特效,把按钮改成了高对比度的红黄配色,土是土了点,但能在黑暗里一眼看到。我把点击热区扩大到了半个屏幕,哪怕他用拳头砸都能触发。

screenshot-20260203-110207.png

/* style.css */

* {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
}

body {
  background: #000; /* 纯黑,减少眩光 */
}

/* 整个屏幕就是一个按钮 */
#emergency-btn {
  width: 100vw;
  height: 100vh;

  background: #ff0000; /* 纯红 */
  color: #ffff00;      /* 纯黄 */

  font-size: 48px;
  font-weight: bold;
  line-height: 1.4;

  border: none;
  outline: none;

  /* 防止误操作带来的系统延迟 */
  touch-action: manipulation;
}

/* 防止手机系统点击闪烁 */
#emergency-btn:active {
  background: #cc0000;
}

// main.js

const btn = document.getElementById('emergency-btn');

btn.addEventListener('click', () => {
  // 尝试先走“智能路径”
  trySendEmergencySignal();
});

function trySendEmergencySignal() {
  const controller = new AbortController();
  const timeout = setTimeout(() => {
    controller.abort();
  }, 3000); // 3 秒,不能再多了

  fetch('https://example.com/api/emergency', {
    method: 'POST',
    signal: controller.signal,
  })
    .then(() => {
      // 成功也直接拨号,不等服务器“确认”
      dial120();
    })
    .catch(() => {
      // ⚠️ 任何错误,直接走最原始方案
      dial120();
    })
    .finally(() => {
      clearTimeout(timeout);
    });
}

function dial120() {
  // 不提示、不解释、不弹窗
  window.location.href = 'tel:120';
}

我还在 catch 语句里加了一段逻辑:如果网络超时,不要弹窗报错,而是直接调用系统的电话拨号盘,填入 120。

这段代码,没有任何设计模式,甚至有点丑。但它能在关键时刻,真的把求救信号发出去。

2026 年,我们缺的是代码吗?

完全不缺。GitHub 上的代码量早就指数级爆炸了。现在的初级程序员,或者说是 Prompt 工程师,最擅长的事情就是:生成、复制、粘贴。

只要跑得通,只要 UI 看起来一样,就提交上线。

于是,我们的项目里充斥着这种屎代码。它们没有明显的 Bug,但它们极其臃肿、逻辑割裂、毫无上下文关联。它们就像超市里的预制菜,看着像那么回事,吃起来全是工业糖精味 😮‍💨。

什么叫正确的事?

正确不是 Type Check Passed。

正确是知道这里为什么不能用无限滚动,因为用户需要到底部找联系我们。正确是知道在表单提交失败时,不要只红框提示,而是要自动保留用户刚才敲了 500 字的评论,别让他重写。

AI 永远学不会心疼用户。它没有痛觉,没有焦虑,没有爱。它不会因为页面白屏了 3 秒而感到愧疚,但你会。

程序员的护城河,是同理心

很多人问我,2026 年了,我还需要学写代码吗?

我的回答是:你不需要学怎么写代码,但你需要学为什么写代码

以前,我们的价值是翻译——把人类的需求翻译成机器能懂的 JavaScript。现在,AI 把翻译的活儿干了。我们的价值变成了共情——去感受屏幕背后那个真实人类的喜怒哀乐。

当 AI 能够一键生成整个商城系统时,你能做的,是去思考:

在这个支付环节,用户会不会因为犹豫而流失?这个报错提示,会不会让用户觉得自己很蠢?如果是色弱用户,能不能看清这个图表?(比如我😭)

这些细微的、关乎人性的决策,才是 1 和 0 之间那道无法跨越的鸿沟。


在这个代码泛滥的年代,克制反而成了一种美德。

别因为 AI 能生成,你就疯狂堆砌功能。别因为 AI 能优化,你就忽略了真实的体感。

请停下来想一想:这行代码背后,是一个活生生的人。他可能是焦急的父亲,可能是疲惫的打工人,也可能是你年迈的父母。

AI 可以帮你建起万丈高楼,但只有你能决定,要不要在楼道里,为晚归的人留一盏温暖的灯 ❤️。

这才是我们程序员在 2026 年,依然无可替代的理由。

谢谢大家.gif

前端代码安全 - 你的代码真的安全吗?

作者 梅川_酷子
2026年2月3日 10:45

安全检测

  近期线上代码安全检测遇到一些问题反馈,都是一些开发中不常见、不显现的问题。所以记录一下,了解一些新的安全防护的方法,方便日后开发中能够未雨绸缪。

SRI 检查

Subresource Integrity (子资源完整性)

问题代码:

<script src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js"></script>

修改后的代码

<!-- 此代码中 integrity 值不可直接使用 -->
<script
  src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js"
  integrity="sha384-FwzUI3CDd**************FBTolvoj"
  crossorigin="anonymous"
></script>

修改方式

1. 在线工具 SRI Hash Generator

地址:srihash.org/

2. 命令行
# 下载外部资源
curl -O https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js
# 生成 integrity 的值
node -e "const fs = require('fs'); const crypto = require('crypto'); const hash = crypto.createHash('sha384').update(fs.readFileSync('jsencrypt.min.js')).digest('base64'); console.log('sha384-' + hash)"

1跳转至代码翻译

使用注意事项

integrity:核心校验属性,格式为 算法名-Base64哈希值,支持的算法:sha256、sha384、sha512(推荐 sha384/sha512,安全性更高);

crossorigin:跨域资源必填,不填的话浏览器不校验。常用 anonymous(不发送凭据),几乎不用 use-credentials(发送凭据)。

详细原因

用于资源完整性校验,保证用户浏览器每次获取外部资源时,得到的都是原封不动资源,防止恶意篡改

一本正经:
原理:intergrity 的值,是算法名,加上根据资源源代码,通过算法,生成的 hash 值
算法的目标代码不同,生成的 hash 值也就不同
在 script 标签中加入这个属性后,标签在获取到外部资源时,会自动对获取的资源通过算法生成 hash 值
用新生成的 hash 值,与 intergrity 的属性值做对比:
相同,表示资源无变动,安全;不同,表示资源有变动,丢弃该资源

通俗大白话:
通俗讲,就是提前告诉浏览器:我要的东西,用这个算法,会得到这个结果。
浏览器:知道了,等我运行的时候,拿到东西我先算一下,看结果一样不,一样我再用,不一样我就扔了。

Footnotes

  1. 代码翻译

    整个命令是 Node.js 的-e参数用法(-e = --eval,表示执行后面的字符串代码),核心依赖 Node.js 内置的fs(文件系统)和crypto(加密)模块,无需额外安装依赖:

    // 1. 引入Node.js内置模块,无需npm install
    const fs = require('fs') // 用于读取本地文件内容
    const crypto = require('crypto') // 用于生成加密哈希值
    
    // 2. 生成SHA-384哈希并Base64编码
    const hash = crypto
      .createHash('sha384') // 创建SHA-384哈希计算实例
      .update(fs.readFileSync('jsencrypt.min.js')) // 读取文件内容并传入哈希实例
      .digest('base64') // 计算哈希并输出Base64格式(SRI要求的编码格式)
    
    // 3. 拼接SRI规范格式并打印
    console.log('sha384-' + hash)
    

如何自定义 Pipe?

作者 前端付豪
2026年2月3日 10:42

Pipe 是在参数传给 Controller 之前做一些验证和转换的,有 多个内置的 Pipe 可以直接用

Nest 一次请求的顺序是:

Middleware
  ↓
Guard
  ↓
Interceptor (before)
  ↓
Pipe   ✅(就在这里)
  ↓
Controller handler
  ↓
Interceptor (after)
  ↓
ExceptionFilter

👉 Pipe 发生在 handler 真正被调用之前,而且是“参数级别”执行

Pipe 的接口定义

export interface PipeTransform<T = any, R = any> {
  transform(value: T, metadata: ArgumentMetadata): R;
}

ArgumentMetadata 是关键:

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<any>;
  data?: string;
}

也就是说,Pipe 能知道:

  • 这个参数来自哪里(body / query / param)
  • DTO 是什么 class
  • 装饰器里写的是 @Body('id') 还是整个 body

内置的 Pipe 有这些:

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe
  • ParseEnumPipe
  • ParseFloatPipe
  • ParseFilePipe

看一下使用

nest new pipe-inner -p npm

ParseIntPipe

image.png

如果可以转正常展示

image.png

如果不可以转 报错

image.png

状态码和信息也可以改

image.png

image.png

可以自己抛一个异常出来,然后让 exception filter 处理

image.png

image.png

ParseFloatPipe 是把参数转换为 float 类型

image.png

image.png

也可以 new ParseFloatPipe 的形式,传入 errorHttpStatusCode 和 exceptionFactory

ParseBoolPipe

image.png

image.png

ParseArrayPipe

image.png

需要安装

image.png

npm install -D class-validator class-transformer

没有转为数字

image.png

指定 item 的类型

这样就把数组每一项处理为 number

image.png

image.png

ParseEnumPipe

image.png

image.png

image.png

参数不是枚举值内

image.png

ParseUUIDPipe

UID 是一种随机生成的几乎不可能重复的字符串,做 id

image.png

image.png

image.png

image.png

不是 uuid 会报错

image.png

DefaultValuePipe 设置默认值

image.png

image.png

实现个 Pipe

自己写一个 pipe 也很简单,实现 PipeTransform 接口的 transform 方法,返回值就是传给 handler 的值

nest g pipe aaa --flat --no-spec

生成一个 pipe,打印下参数值,返回 aaa

image.png

使用

image.png

image.png

返回的值是 aaaaaa,也就是说 pipe 的返回值就是传给 handler 的参数值。

打印的 value 就是 query、param 的值,而 metadata 里包含 type、metatype、data

image.png

纯CSS实现发送验证码读秒倒数 - if函数妙用

作者 juejin_cn
2026年2月3日 10:39

点击发送短信验证码倒数60秒后可重新发送,借助 if() 纯 CSS 也能实现这样的效果。

要求 Chrome/Edge ≥ 137

  • @property 是 CSS Houdini API 的一部分,用来定义 CSS 自定义属性。

    @property --num {
      syntax: "<integer>";
      initial-value: 0;
      inherits: true;
    }
    
  • CSS 函数 if() 允许根据条件测试的结果为属性设置不同的值。

    label {
      --num: var(--sec);
      counter-set: count var(--num);
      color: if(style(--num: var(--sec)): blue; style(--num: 0): blue; else: #999; );
      pointer-events: if(style(--num: var(--sec)): initial; style(--num: 0): initial; else: none; );
    }
    
  • CSS 函数 counter() 返回一个代表计数器的当前值的字符串。

    label:has(input:checked)::after {
      content: if(style(--num: 0): "重新发送"; else: counter(count) "秒后可重新发送"; );
    }
    
  • CSS 函数 steps() 定义了一个过渡效果,将输入时间划分为指定数量的等长间隔。

    label:has(input:checked) {
      --num: 0;
      transition: --num calc(var(--sec) * 1s) steps(var(--sec), jump-start);
    }
    

完整代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>纯CSS实现发送验证码读秒倒数 - if函数妙用</title>
  <style>
    @property --num {
      syntax: "<integer>";
      initial-value: 0;
      inherits: true;
    }
    :root {
      --sec: 11;
    }
    label {
      --num: var(--sec);
      counter-set: count var(--num);
      color: if(style(--num: var(--sec)): blue; style(--num: 0): blue; else: #999; );
      pointer-events: if(style(--num: var(--sec)): initial; style(--num: 0): initial; else: none; );
      cursor: pointer;
    }
    label::after {
      --num: inherit;
      content: "发送验证码";
    }
    label:has(input:checked)::after {
      content: if(style(--num: 0): "重新发送"; else: counter(count) "秒后可重新发送"; );
    }
    label:has(input:checked) {
      --num: 0;
      transition: --num calc(var(--sec) * 1s) steps(var(--sec), jump-start);
    }
    label input {
      display: none;
    }
  </style>
</head>
<body>
  <label for="send-code"><input type="checkbox" id="send-code"></label>
</body>
</html>

鸿蒙Next开发实战:如何打造丝滑的运动节拍器——高频音效播放与后台保活避坑指南

2026年2月3日 10:38

摘要:看似简单的“节拍器”功能,在鸿蒙Next(OpenHarmony)系统上却暗藏玄机。从节奏不稳到后台断连,从单线程瓶颈到资源竞争,本文深度复盘了在开发运动类App高频音效功能时遇到的“九九八十一难”,并提供了一套包含多实例轮询SoundPoolDart端异步解耦以及静音保活黑科技的完整解决方案。


一、 背景:当“嘀嗒”声遇上新系统

在最近的Flutter项目开发中,我们需要移植一个运动节拍器功能到鸿蒙Next平台。需求很明确:高BPM(每分钟180次以上)稳定播放极低延迟支持后台运行

起初我们以为这只是简单的 play() 调用,结果现实狠狠打了一巴掌:

  1. 节奏像醉汉:声音忽快忽慢,完全卡不住拍子。
  2. 高频就卡死:调到高BPM时,声音直接消失或各种爆音。
  3. 切后台即挂:应用一退到后台,节拍器立刻哑火,系统通知栏也空空如也。

这篇文章就是为了记录我们是如何一步步填平这些坑的。

二、 挑战一:告别 AVPlayer,拥抱 SoundPool

1.1 问题现象

最开始我们直接复用了用于播放背景音乐的 AVPlayer。但在测试中发现,AVPlayer 是为长音频设计的,它有一个完整的状态机(Idle -> Initialized -> Prepared -> Playing),每次播放前的准备时间在几十毫秒级。对于音乐播放器这没问题,但对于间隔只有300ms(200 BPM)的节拍器,这几十毫秒的波动就是致命的。

1.2 解决方案

我们在原生插件层(Plugin)引入了 SoundPoolSoundPool (鸿蒙对应 media.createSoundPool) 的设计初衷就是为了游戏音效和按键音,它将音频解码后加载到内存中,播放时几乎是零延迟。

关键代码调整: 我们在 Flutter 端定义了 isShort 标志位,告诉原生层:“这是一段短音效,别用重型武器,用轻量级的 SoundPool。”

// TtsSdkPlugin.ets
if (isShort === true) {
  this.playShort(path, isAsset); // 走 SoundPool 通道
} else {
  this.play(path, isAsset);      // 走 AVPlayer 通道
}

三、 挑战二:Dart 与原生的“异地恋”延迟

2.1 问题现象

换了 SoundPool 后,单次播放快了,但连续播放还是不稳。排查发现,Flutter 端的代码是这样的:

// Flutter 端
await _channel.invokeMethod('play', ...); // 等待原生返回

Dart 的 Timer 定时触发,但 invokeMethod 是跨端通信(Platform Channel)。如果使用了 await,Dart 线程就会等待原生层执行完毕并返回结果。一旦原生层稍微卡顿(比如主线程繁忙),Dart 的下一次 Timer 回调就会被推迟,导致误差不断累积。

2.2 解决方案:发后即忘(Fire-and-Forget)

对于节拍器这种场景,准时发送指令知道指令执行结果更重要。我们果断去掉了 await

// 优化后的 Flutter 端
// 不使用 await,避免阻塞 Dart 线程导致节奏不稳
_channel.invokeMethod('play', {'path': fullPath, 'isAsset': true, 'isShort': true});

这一改动,直接让 Dart 端的计时器摆脱了原生层的性能束缚,节奏感瞬间提升了一个档次。

四、 挑战三:单核难敌千军,多实例轮询战术

3.1 问题现象

当 BPM 飙升到 180 甚至更高时,我们发现日志里开始疯狂报错,偶尔还会出现丢音。 原因在于,虽然 SoundPool 是为短音频设计的,但在极高频的触发下,单实例内部的锁竞争和资源调度依然捉襟见肘。就像只有一把枪,扣动扳机的速度快过换弹的速度时,卡壳是必然的。

3.2 解决方案:加特林模式(Multi-Instance Round-Robin)

我们借鉴了服务器负载均衡的思路,在原生层实现了一个 SoundPool 连接池

  1. 创建多个实例:初始化时一口气创建 4 个 SoundPool 实例。
  2. 轮询播放:每次播放请求到来时,依次使用 Pool 1 -> Pool 2 -> Pool 3 -> Pool 4。

这样,即使每秒点击 20 次,分摊到每个 SoundPool 上也只有 5 次,负载大大降低。

// TtsSdkPlugin.ets 核心逻辑
private soundPools: media.SoundPool[] = [];
private currentPoolIndex: number = 0;
private readonly POOL_COUNT = 4;

async playShort(path: string) {
  // 轮询算法
  const pool = this.soundPools[this.currentPoolIndex];
  // ... 播放逻辑 ...
  // 指向下一个池子
  this.currentPoolIndex = (this.currentPoolIndex + 1) % this.POOL_COUNT;
}

此外,我们还加上了 Loading Lock(加载锁),防止同一个音效文件在未加载完成前被重复触发 IO 操作,进一步减少了 CPU 消耗。

五、 挑战四:后台保活的“静音”守护者

4.1 问题现象

运动App最常用的场景就是锁屏听声音。但鸿蒙系统对后台资源管控非常严格。我们的 SoundPool 播放是间歇性的(响一下,停一下)。在停止的那几百毫秒空隙里,系统会认为“该应用没有在播放音频”,进而挂起后台任务。 结果就是:App 一退后台,响两声就没动静了。

4.2 解决方案:AVSession + 静音保活

要在鸿蒙后台持续运行,必须满足三个条件:

  1. AVSession 激活:告诉系统我是媒体应用。
  2. BackgroundTask 申请:申请 AUDIO_PLAYBACK 长时任务。
  3. 持续的音频输出:这是最关键的一点。

我们设计了一个 Keep-Alive Renderer。它是一个独立的 AudioRenderer,它的唯一工作就是循环播放一段全为 0 的静音数据

// 这里的 buffer 全是 0,听不见,但系统认为你在“努力工作”
async writeSilence() {
  if (this.keepAliveRenderer.state !== audio.AudioState.STATE_RUNNING) return;
  let bufferSize = 17640; 
  let buffer = new Uint8Array(bufferSize); // 静音数据
  await this.keepAliveRenderer.write(buffer);
  this.writeSilence(); // 递归调用,永不停歇
}

组合拳逻辑

  • 当节拍器开始时 -> 激活 AVSession -> 启动 BackgroundTask -> 开始播放静音流
  • 系统检测到有持续的音频输出,就不会杀掉 App。
  • 用户听到的:清晰的节拍声(来自 SoundPool)。
  • 系统看到的:一个持续输出音频流的媒体应用。

六、 总结

通过这一系列优化,我们终于在鸿蒙Next上实现了一个可用的专业级节拍器:

优化点 解决问题 核心手段
SoundPool 降低延迟 替代 AVPlayer,内存直读
异步调用 消除抖动 Dart 端移除 await,解耦主线程
多实例轮询 解决高频卡顿 4个 SoundPool 轮流工作,负载均衡
AVSession 系统媒体控制 注册媒体会话,支持通知栏控制
静音保活 后台持续运行 独立的 AudioRenderer 持续输出静音流

鸿蒙Next的音频开发虽然坑多,但只要理解了其底层的资源调度逻辑,依然能写出高性能的代码。希望这篇踩坑指南能帮到同样在探索鸿蒙开发的你。


本文基于 Flutter + HarmonyOS Next (API 12+) 开发环境。

一看就懂的 Haskell 教程 - 类型系统基础与类型推断

作者 Anita_Sun
2026年2月3日 10:28

Haskell 作为纯函数式静态强类型编程语言,其类型系统是设计的核心基石,核心设计思想围绕编译期类型安全、无副作用的纯函数抽象、泛型代码的高度复用、最小化显式类型标注展开——通过静态强类型在编译期拦截几乎所有类型相关错误,结合强大的类型推断机制减少冗余的类型代码,以多态类型实现跨类型的代码复用,同时通过灵活的类型扩展特性平衡“类型安全”与“开发效率”,最终让代码兼具严谨性、复用性和简洁性,这也是Haskell能成为函数式编程典范、广泛应用于高可靠系统开发的核心原因。

类型与类型签名

类型是Haskell中描述值的特征与行为边界的核心标识,类型签名则是显式标注变量、函数类型的语法规范,是沟通程序员与编译器的重要桥梁,也是静态类型检查的基础,核心包含三个关键知识点,所有知识点均附可运行代码示例:

:: 含义

:: 是Haskell的类型标注符,核心作用是显式指定“标识符(变量/函数/表达式)对应的类型”,语法格式为标识符 :: 类型。编译器会根据::标注的类型进行严格校验,若代码逻辑与标注类型冲突则直接报编译错误;同时::也可辅助编译器完成复杂场景的类型推断,避免歧义。

-- 1. 变量类型标注
age :: Int          -- 整数类型
age = 20
name :: String      -- 字符串类型(等价于[Char])
name = "Haskell"
nums :: [Double]    -- 浮点数列表类型
nums = [1.2, 3.4, 5.6]
person :: (String, Int)  -- 二元元组类型
person = ("Tom", 25)

-- 2. 函数类型标注
add :: Int -> Int -> Int
add x y = x + y

-- 3. 表达式类型标注(解决推断歧义)
num :: Double
num = (1 + 2 * 3) :: Double  -- 显式指定表达式结果为Double

函数类型-> 右结合

-> 是Haskell函数类型的核心表示符,用于描述“函数的输入类型与输出类型的映射关系”,且->具有天然的右结合特性(编译器默认按从右到左的顺序嵌套解析),即a -> b -> c等价于a -> (b -> c),这一特性与Haskell的柯里化(Currying) 特性深度绑定——Haskell中所有多参数函数的本质,都是“接收一个参数、返回一个新函数”的单参数函数,新函数再接收下一个参数,直至返回最终结果。

-- 1. 右结合示例1:双参数函数的本质是返回函数的单参数函数
f :: Int -> String -> Bool
f x y = x > length y  -- 等价于 f :: Int -> (String -> Bool)
-- 调用方式1:常规调用(隐式柯里化)
f1 = f 5 "abc"  -- 结果:True(5 > 3)
-- 调用方式2:分步调用(显式柯里化,体现右结合本质)
f' :: String -> Bool
f' = f 5        -- f接收Int参数5,返回一个"String -> Bool"的新函数
f2 = f' "abc"   -- 结果:True

-- 2. 右结合示例2:三参数函数的嵌套解析
g :: a -> b -> c -> (b, c, a)
g x y z = (y, z, x)  -- 等价于 g :: a -> (b -> (c -> (b, c, a)))
-- 分步调用:每层接收一个参数,返回新函数
g1 :: b -> c -> (b, c, Int)
g1 = g 10          -- 接收a=Int,返回b->c->(b,c,Int)
g2 :: c -> (String, c, Int)
g2 = g1 "hello"    -- 接收b=String,返回c->(String,c,Int)
g3 = g2 3.14       -- 接收c=Double,返回最终结果 ("hello",3.14,10)

多参数函数类型

基于Haskell的柯里化特性和->的右结合规则,不存在专门的多参数函数类型表示,所有多参数函数的类型,均通过->的嵌套(右结合)实现——参数的数量对应->的数量(n个参数对应n个->),最右侧为函数的最终返回类型,左侧依次为各参数的类型。

-- 1. 一元函数(1个参数,1->)
square :: Int -> Int
square x = x * x
showInt :: Int -> String
showInt = show

-- 2. 二元函数(2个参数,2->)
plus :: Num a => a -> a -> a
plus x y = x + y
compareStr :: String -> String -> Ordering
compareStr s1 s2 = compare (length s1) (length s2)

-- 3. 三元函数(3个参数,3->)
addThree :: Int -> Int -> Int -> Int
addThree x y z = x + y + z
lookupByKey :: Eq k => k -> [(k, v)] -> Maybe v
lookupByKey k [] = Nothing
lookupByKey k ((k',v):xs) | k == k' = Just v
                          | otherwise = lookupByKey k xs

-- 4. 带类型类约束的多参数函数
printIfEq :: (Eq a, Show a) => a -> a -> IO ()
printIfEq x y = if x == y then print x else print "Not Equal"

类型推断机制

类型推断是Haskell的核心特性之一,指编译器无需显式的类型标注,通过分析表达式的语法结构、运算关系和类型类约束,自动推导出变量/函数/表达式的最通用类型,这一特性让Haskell兼具“静态类型的安全性”和“动态类型的简洁性”,其底层依托经典算法实现,同时存在明确的推断规则和限制,所有知识点均附代码示例:

Hindley-Milner 算法

Hindley-Milner(HM)算法是Haskell类型推断的底层核心算法,也是绝大多数纯函数式语言(OCaml、Scala等)类型推断的基础,该算法于1969年提出,核心优势是能在无显式标注时,推导出表达式的“最一般通用类型(Most General Unifier, MGU)” ,且推导过程高效、无歧义,兼顾通用性和编译效率。

核心原理:通过为表达式中的未知类型分配类型变量(如a、b、c),再根据表达式的运算规则建立类型等式,最终通过合一(Unification) 算法求解类型变量,得到最通用的类型。

-- 无需任何类型标注,HM算法自动推导最通用类型
-- 1. 恒等函数:推导为 a -> a(a为任意类型变量)
id x = x
-- 可适配任意类型,无约束
id1 = id 10        -- Int -> Int
id2 = id "abc"     -- String -> String
id3 = id [1,2,3]   -- [Int] -> [Int]

-- 2. 列表映射函数:推导为 (a -> b) -> [a] -> [b]
map' f [] = []
map' f (x:xs) = f x : map' f xs
-- 自动适配不同的类型映射
map1 = map' (+1) [1,2,3]  -- (Int->Int) -> [Int]->[Int],结果[2,3,4]
map2 = map' show [1,2,3]  -- (Int->String) -> [Int]->[String],结果["1","2","3"]

-- 3. 元组交换函数:推导为 (a, b) -> (b, a)
swap (x, y) = (y, x)
swap1 = swap (10, "abc")  -- (Int,String) -> (String,Int),结果("abc",10)
swap2 = swap ([1,2], 3.14)-- ([Int],Double) -> (Double,[Int]),结果(3.14,[1,2])

无约束/约束推断

根据表达式是否涉及类型类操作,Haskell的类型推断分为无约束推断约束推断两类,二者均基于HM算法,核心差异是推导结果是否带有类型类约束,覆盖所有常规编码场景:

无约束推断

针对无类型类依赖、无特殊行为限制的表达式,推导出纯泛型类型(仅包含类型变量,无任何约束),该类型可适配任意具体类型,是Haskell参数多态的核心实现。

-- 无需类型标注,编译器无约束推断纯泛型类型
-- 1. 单参数泛型函数:推断为 a -> [a]
repeatOne x = [x]
r1 = repeatOne 10    -- [Int]
r2 = repeatOne 'a'   -- [Char]
r3 = repeatOne [1,2] -- [[Int]]

-- 2. 双参数泛型函数:推断为 a -> b -> (a, b, [a])
combine x y = (x, y, [x, x])
c1 = combine 5 "abc"  -- (Int, String, [Int])
c2 = combine 'a' 3.14 -- (Char, Double, [Char])

-- 3. 列表操作泛型函数:推断为 [a] -> Int
listLen xs = length xs
l1 = listLen [1,2,3]  -- Int
l2 = listLen "hello"  -- Int

约束推断

针对涉及类型类操作(如+、==、show) 的表达式,推导结果会带上对应的类型类约束,限制类型变量的适用范围——仅实现了该类型类的具体类型,才能使用该表达式,兼顾泛型和类型安全,是Haskell约束多态的核心实现。

-- 无需类型标注,编译器自动推导带类型类约束的类型
-- 1. 涉及Num类型类(+、*、0):推断为 Num a => a -> a -> a
add' x y = x + y * 2
a1 = add' 10 5    -- Int(10+5*2=20)
a2 = add' 1.2 3.4 -- Double(1.2+3.4*2=8.0)

-- 2. 涉及Eq类型类(==、/=):推断为 Eq a => a -> a -> Bool
isEqual x y = x == y
e1 = isEqual 5 5    -- Bool(Truee2 = isEqual "abc" "def" -- Bool(False)

-- 3. 涉及Show类型类(show):推断为 Show a => a -> String
show' x = "Value: " ++ show x
s1 = show' 100    -- String("Value: 100")
s2 = show' 3.14   -- String("Value: 3.14")

-- 4. 多类型类约束:推断为 (Num a, Show a) => a -> String
calcAndShow x = show (x * x + 1)
cs1 = calcAndShow 5   -- "26"
cs2 = calcAndShow 2.5 -- "7.25"

类型推断限制

Haskell的类型推断能力强大,但并非万能,受HM算法本身和语言设计的限制,在部分复杂场景下无法完成自动推断,此时编译器会报“类型歧义”或“无法推导类型”错误,需要程序员添加显式类型标注辅助编译器完成推断,核心限制场景均附代码示例:

-- 1. 复杂递归函数:嵌套递归导致推断失败,需标注类型
-- 未标注:编译报错,标注后正常
fact :: Int -> Int  -- 显式标注类型
fact 0 = 1
fact n = n * fact (n-1)

-- 2. 多参数类型类的歧义场景:无函数依赖导致无法推断返回类型
class Convert a b where
  convert :: a -> b
-- 实现Int到String的转换
instance Convert Int String where
  convert = show
-- 调用时无标注:编译报错(无法推断b的类型),需显式标注
cvt :: String
cvt = convert 123 :: String  -- 显式指定返回类型为String

-- 3. 数值类型的极端歧义:无上下文的泛型数值,需标注
-- 未标注:编译报错(Num a => a 存在歧义)
num1 :: Int
num1 = 10 :: Int
-- 或通过上下文约束(如赋值给指定类型变量)
num2 :: Double
num2 = 3.14

-- 4. 高阶类型的复杂组合:多构造器嵌套导致推断歧义
import Data.Maybe
-- 未标注:编译警告,标注后清晰
process :: Maybe [Int] -> [Int]
process mxs = maybe [] (map (+1)) mxs
p1 = process (Just [1,2,3]) -- [2,3,4]
p2 = process Nothing        -- []

多态类型

多态(Polymorphism)意为“一个接口,多种实现”,是Haskell实现泛型编程、代码高度复用的核心机制,让代码脱离具体类型的限制,适配不同的类型场景。Haskell中的多态类型分为参数多态、约束多态、Ad-hoc 多态三类,三者层层递进,覆盖从“纯通用抽象”到“类型特化实现”的所有泛型需求,所有知识点均附可运行代码示例:

参数多态

参数多态是Haskell最基础、最核心的泛型多态,也被称为“泛型多态”或“无约束多态”,通过类型变量(如a、b、c) 实现“一份代码适配所有类型”,无任何类型约束和行为限制,仅依赖类型的结构特征(如列表、元组的嵌套结构),是Haskell代码复用的最主要手段。

-- 1. 基础参数多态:恒等函数(a -> a),适配任意类型
id :: a -> a
id x = x
id1 = id 10        -- Int
id2 = id "abc"     -- String
id3 = id [1,2,3]   -- [Int]
id4 = id (Just 5)  -- Maybe Int

-- 2. 列表通用操作:(a -> b) -> [a] -> [b],适配任意类型列表
map' :: (a -> b) -> [a] -> [b]
map' f [] = []
map' f (x:xs) = f x : map' f xs
-- 适配Int->Int
m1 = map' (+1) [1,2,3]  -- [2,3,4]
-- 适配Int->String
m2 = map' show [1,2,3]  -- ["1","2","3"]
-- 适配String->Int
m3 = map' length ["a", "ab", "abc"]  -- [1,2,3]

-- 3. 元组通用操作:(a, b) -> (b, a),适配任意二元元组
swap' :: (a, b) -> (b, a)
swap' (x, y) = (y, x)
s1 = swap' (10, "abc")  -- ("abc", 10)
s2 = swap' (3.14, [1,2])-- ([1,2], 3.14)

约束多态

约束多态是带类型类约束的参数多态,也被称为“有界多态”,在参数多态的基础上,通过=>为类型变量添加类型类约束,限制类型变量的适用范围——仅实现了该类型类的具体类型,才能使用该代码,让泛型代码可以调用类型类定义的抽象行为(如+、==、show),兼顾“泛型复用”和“类型专属行为”。

-- 1. 带Num约束:Num a => a -> a -> a,仅适配数值类型
sumTwo :: Num a => a -> a -> a
sumTwo x y = x + y
s1 = sumTwo 10 20    -- Int(30s2 = sumTwo 1.2 3.4  -- Double(4.6s3 = sumTwo 5 3.14   -- Double(8.14,自动类型统一)

-- 2. 带Eq约束:Eq a => [a] -> a -> Bool,仅适配可比较相等的类型
contains :: Eq a => [a] -> a -> Bool
contains [] _ = False
contains (x:xs) y | x == y    = True
                  | otherwise = contains xs y
c1 = contains [1,2,3] 2    -- True
c2 = contains ["a","b"] "c"-- False
c3 = contains [Just 1, Nothing] Nothing -- True

-- 3. 带Num+Show约束:(Num a, Show a) => a -> String,多约束组合
numToStr :: (Num a, Show a) => a -> String
numToStr x = "Number: " ++ show (x * 2)
n1 = numToStr 5    -- "Number: 10"
n2 = numToStr 2.5  -- "Number: 5.0"

-- 4. 列表通用求和:Num a => [a] -> a,仅适配数值列表
sum' :: Num a => [a] -> a
sum' [] = 0
sum' (x:xs) = x + sum' xs
su1 = sum' [1,2,3,4]    -- Int(10)
su2 = sum' [1.1,2.2,3.3]-- Double(6.6

Ad-hoc 多态

Ad-hoc 多态也被称为特化多态,指“同一函数名(或操作符),根据参数的具体类型不同,拥有完全不同的实现逻辑”,即“接口统一,实现特化”。在Haskell中,Ad-hoc 多态完全通过类型类和实例实现,区别于参数多态的“一份通用实现适配所有类型”,是实现“类型专属行为”的核心方式。

-- 1. 自定义类型类实现Ad-hoc多态:同一接口,不同类型不同实现
class Printable a where
  printVal :: a -> String  -- 统一接口

-- 为Int实现实例:特化实现1
instance Printable Int where
  printVal x = "Integer Value: " ++ show x

-- 为String实现实例:特化实现2
instance Printable String where
  printVal x = "String Value: "" ++ x ++ """

-- 为Double实现实例:特化实现3
instance Printable Double where
  printVal x = "Double Value: " ++ show (x * 100) ++ "%"

-- 统一调用,编译器自动选择对应实现
p1 = printVal 100    -- "Integer Value: 100"(Int实例)
p2 = printVal "Haskell" -- "String Value: "Haskell""(String实例)
p3 = printVal 0.95   -- "Double Value: 95.0%"(Double实例)

-- 2. 标准库类型类的Ad-hoc多态:show函数(统一接口,特化实现)
s1 = show 123        -- "123"(Int的Show实例)
s2 = show 'a'        -- "'a'"(Char的Show实例)
s3 = show [1,2,3]    -- "[1,2,3]"([Int]的Show实例)
s4 = show (Just 5)   -- "Just 5"(Maybe Int的Show实例)

-- 3. 操作符的Ad-hoc多态:+号(Num类型类的特化实现)
add1 = 1 + 2         -- 整数加法(Int的Num实例)
add2 = 1.2 + 3.4     -- 浮点数加法(Double的Num实例)
add3 = 5 + 3.14      -- 混合类型加法(自动统一为Double实例)

类型别名与新类型

在实际开发中,常会遇到“复杂类型冗余”“基础类型混用导致歧义”“需要为类型添加专属标识”的问题,Haskell提供了typenewtype两个核心关键字,分别用于类型别名定义零成本类型包装,同时newtype与常用的data关键字存在明确差异,三者各司其职,解决类型设计中的不同问题:

type 关键字简化类型

type关键字用于定义类型别名,核心作用是对复杂、冗长的原有类型进行重命名,仅做语法层面的简化和可读性提升,不生成任何新的类型标识,编译期编译器会将类型别名自动还原为原类型,无任何运行时成本,也无类型安全的隔离效果。

-- 1. 简化基础嵌套类型
type IntList = [Int]
type StringMap = [(String, String)]  -- 字符串键值对列表
type Person = (String, Int, Bool)    -- 姓名、年龄、是否成年

-- 直接使用类型别名,与原类型完全等价
intList :: IntList
intList = [1,2,3,4]
-- 原类型可直接赋值给别名类型,无隔离
intList' :: [Int]
intList' = intList

person :: Person
person = ("Tom", 25, True)
-- 解包时按原元组方式,无差异
getName :: Person -> String
getName (n, _, _) = n

-- 2. 简化高阶/业务相关类型
type Request = String
type Response = String
-- 业务处理函数:原类型 (String -> IO String),别名更易读
type Handler = Request -> IO Response

-- 实现处理函数,使用别名提升可读性
helloHandler :: Handler
helloHandler req = return $ "Hello, " ++ req

-- 3. 标准库经典别名:String = [Char]
type String = [Char]  -- Haskell标准库中的定义
str :: String
str = "Haskell"
-- 与原类型[Char]完全等价
str' :: [Char]
str' = str

newtype 零成本包装

newtype关键字用于创建新类型,核心作用是将现有的单个基础类型进行“包装”,生成一个全新的、与原类型完全独立的类型标识,且编译器会对newtype做极致优化——编译期消除包装层,无任何运行时成本(零成本) ,既实现了类型的隔离和安全,又不影响程序执行效率。

-- 1. 基础定义:newtype 新类型 = 构造器 原类型(单构造器+单字段)
newtype Age = Age Int          -- 年龄:包装Int
newtype Score = Score Int      -- 分数:包装Int
newtype UserID = UserID String -- 用户ID:包装String
newtype Weight = Weight Double -- 体重:包装Double

-- 2. 创建新类型值:通过构造器包装
age :: Age
age = Age 25  -- 必须通过Age构造器,直接写25会报类型错误

score :: Score
score = Score 95

uid :: UserID
uid = UserID "user_123456"

-- 3. 解包使用:模式匹配或构造器直接调用
getAge :: Age -> Int
getAge (Age a) = a  -- 模式匹配解包

getScore :: Score -> Int
getScore (Score s) = s

-- 4. 类型安全:不同newtype无法混用,编译期拦截错误
-- 合法:同类型操作
ageAdd :: Age -> Int -> Age
ageAdd (Age a) n = Age (a + n)
-- 非法:不同newtype混用(编译报错)
-- wrong = Age 25 + Score 95  -- 编译器直接报错,避免类型混淆

-- 5. 零成本特性:运行时无包装层,与原类型执行效率一致
-- 编译后,Age 25 直接等价于 25,无额外开销

newtype vs data

newtypedata都是Haskell中定义自定义类型的核心关键字,二者均可实现类型包装和自定义类型设计,但在语法限制、运行时成本、类型类实例、使用场景上存在核心差异,不可混用,通过代码示例对比核心区别:

-- ==================== newtype 实现 ====================
-- 仅支持:单构造器 + 单字段
newtype Age = Age Int  -- 合法
-- newtype Point = Point Int Int  -- 非法:多字段,newtype不支持

-- 零成本:编译期无包装层
age :: Age
age = Age 25
-- 解包简单
unAge :: Age -> Int
unAge (Age a) = a

-- ==================== data 实现 ====================
-- 无限制:多构造器、多字段、递归类型均支持
-- 单构造器单字段(合法,但有运行时开销)
data Score = Score Int deriving (Show)
-- 多构造器(合法,描述不同状态)
data Shape = Circle Double | Rectangle Double Double deriving (Show)
-- 多字段(合法,描述复杂结构)
data Point = Point Double Double deriving (Show)
-- 递归类型(合法,描述树形结构)
data Tree a = Empty | Node a (Tree a) (Tree a) deriving (Show)

-- 创建data类型值
score :: Score
score = Score 95

circle :: Shape
circle = Circle 5.0  -- 圆:半径5.0

rect :: Shape
rect = Rectangle 3.0 4.0  -- 矩形:长3.0,宽4.0

point :: Point
point = Point 10.0 20.0

tree :: Tree Int
tree = Node 1 (Node 2 Empty Empty) (Node 3 Empty Empty)

-- 解包data类型:模式匹配
getCircleRadius :: Shape -> Double
getCircleRadius (Circle r) = r
getCircleRadius _ = 0.0

getPointX :: Point -> Double
getPointX (Point x _) = x

-- ==================== 核心差异对比 ====================
-- 1. 类型类实例:newtype可自动继承,data需手动实现
-- newtype:为Age实现Num实例,可直接操作
instance Num Age where
  (+) (Age a1) (Age a2) = Age (a1 + a2)
  (*) (Age a1) (Age a2) = Age (a1 * a2)
  fromInteger n = Age (fromInteger n)
  -- 其他方法可默认推导

-- data:为Score实现Num实例,需手动实现所有方法
instance Num Score where
  (+) (Score s1) (Score s2) = Score (s1 + s2)
  (*) (Score s1) (Score s2) = Score (s1 * s2)
  (-) (Score s1) (Score s2) = Score (s1 - s2)
  abs (Score s) = Score (abs s)
  signum (Score s) = Score (signum s)
  fromInteger n = Score (fromInteger n)

-- 2. 运行时成本:newtype无开销,data有轻微开销
-- 编译后:Age 25 → 25(无包装),Score 95 → 包装为Score构造器(有开销)

10.5 类型系统基础特性

在类型推断和实际使用中,Haskell提供了两个实用的基础特性,分别解决数值类型的默认推导歧义编译器的类型推断歧义问题,平衡“类型推断的简洁性”和“类型指定的灵活性”,是日常开发中高频使用的特性,所有知识点均附可运行代码示例:

类型默认规则

Haskell中数值类型基于Num类型类实现泛型(如IntIntegerDoubleFloat均实现Num),当表达式的类型为泛型数值类型(Num a => a)无上下文明确指定具体类型时,会出现数值类型歧义,编译器无法确定应推导为哪种具体数值类型,此时Haskell的类型默认规则会自动将其推导为预设的具体数值类型(默认优先Integer > Double > Int > Float),避免编译错误,同时支持自定义默认类型。

-- 1. 默认规则:无上下文时,Num a => a 优先推导为Integer/Double
-- 示例1:整数泛型 → 默认推导为Integer
num1 = 100  -- 类型:Integer
-- 示例2:浮点数泛型 → 默认推导为Double
num2 = 3.14 -- 类型:Double
-- 示例3:列表求和 → 默认推导为Integer
sumInt = sum [1,2,3,4]  -- 类型:Integer,结果10
-- 示例4:混合数值 → 默认推导为Double(精度更高)
mixNum = 1 + 2.0  -- 类型:Double,结果3.0

-- 2. 上下文约束:覆盖默认规则,按上下文推导具体类型
-- 赋值给Int变量 → 推导为Int
intNum :: Int
intNum = 100  -- 类型:Int,而非默认Integer

-- 作为Int参数传入函数 → 推导为Int
addInt :: Int -> Int -> Int
addInt x y = x + y
addRes = addInt 10 20  -- 10和20均推导为Int

-- 3. 自定义默认类型:通过default语句修改默认优先级
-- 自定义:优先推导为Int → Float → Double
default (Int, Float, Double)

-- 自定义后,列表求和推导为Int(而非默认Integer)
sumInt' = sum [1,2,3]  -- 类型:Int,结果6

-- 浮点数推导为Float(而非默认Double)
floatNum = 3.14 :: Float  -- 类型:Float

显式类型应用TypeApplications 扩展

TypeApplications(类型应用)是Haskell的扩展特性(非默认开启),核心作用是允许程序员在调用函数时,显式指定类型推断中的类型变量,直接为函数的泛型类型变量绑定具体类型,彻底解决编译器的类型推断歧义问题,替代繁琐的显式类型标注,让代码更简洁、更易读。

-- 必须在代码最顶部开启扩展
{-# LANGUAGE TypeApplications #-}

import Data.Maybe
import Data.Eq

-- 1. 解决read函数的类型歧义:read :: Read a => String -> a
-- 无TypeApplications:需显式标注返回类型,繁琐
readInt1 :: Int
readInt1 = read "123" :: Int

readDouble1 :: Double
readDouble1 = read "3.14" :: Double

-- 有TypeApplications:直接在函数后@指定类型,简洁
readInt2 = read @Int "123"          -- 类型:Int,结果123
readDouble2 = read @Double "3.14"  -- 类型:Double,结果3.14
readList = read @[Int] "[1,2,3,4]" -- 类型:[Int],结果[1,2,3,4]

-- 2. 解决pure函数的容器类型歧义:pure :: Applicative f => a -> f a
-- 无TypeApplications:需标注容器类型,繁琐
pureList1 :: [Int]
pureList1 = pure 5 :: [Int]

pureMaybe1 :: Maybe Int
pureMaybe1 = pure 5 :: Maybe Int

-- 有TypeApplications:@指定容器类型f,直接明了
pureList2 = pure @[] 5        -- 容器:[](列表),结果[5]
pureMaybe2 = pure @Maybe 5   -- 容器:Maybe,结果Just 5
pureIO = pure @IO 5          -- 容器:IO,结果IO 5

-- 3. 多类型变量:按类型签名顺序@指定多个类型变量
-- 示例:lookup :: Eq k => k -> [(k, v)] -> Maybe v
-- 显式指定k=String,v=Int
lookupRes = lookup @String @Int "name" [("name", 123), ("age", 25)]
-- 结果:Just 123

-- 4. 结合类型类约束:精准指定约束中的类型变量
-- 示例:show . read :: (Read a, Show a) => String -> String
-- 指定a=Double,将字符串转Double再转字符串
showRead = show . read @Double $ "3.1415926"
-- 结果:"3.1415926"

-- 5. GHCi中使用:先开启扩展,再调用
-- :set -XTypeApplications
-- > read @Int "456"
-- 456
-- > pure @Maybe 10
-- Just 10

前端向架构突围系列 - 性能观测 [7 - 1]:以用户为中心的核心性能指标体系

2026年2月3日 10:26

写在前面

场景重现: 老板:“咱们的网站怎么感觉有点卡?” 前端:“不卡啊,我本地测 DOMContentLoaded 只有 300ms。” 老板:“我不管什么 Load,反正我点按钮没反应,刷出来图片要半天。”

这就是典型的**“视角错位” 。 传统的性能指标(Load, DOMReady)是机器视角**,它们告诉我们代码什么时候跑完。 而现代架构师需要的是用户视角,我们需要知道用户什么时候看到内容?什么时候能交互?页面是不是在乱跳?

本篇我们将抛弃过时的指标,基于 Google 的 Web Vitals 标准,建立一套能真实反映用户体验的度量衡。

image.png


一、 为什么传统的指标失效了?

在 jQuery 时代,window.onload 是神。因为那时的网页大多是服务端渲染(SSR),HTML 下载完,页面基本就出来了。

但在 SPA(单页应用) 时代,onload 触发时,页面可能只渲染了一个白色的 <div id="root"></div>,真正的业务组件还在转圈圈(Loading)。

  • 技术指标 (Technical Metrics): TCP 建连时间、TTFB、Download Size。它们对运维有用,但无法代表用户体验。
  • 用户指标 (User-Centric Metrics): 页面多久画出来?点击有反馈吗?
  • 结论: 优化的目标是**“感知的快”**,而不仅仅是“物理的快”。

二、 核心三支柱:Core Web Vitals (CWV)

Google 提出了一套核心指标(Core Web Vitals),这不仅是体验标准,更直接影响 SEO 排名。这是架构师必须死磕的三个指标。

2.1 加载体验:LCP (Largest Contentful Paint)

“页面主要内容多久能看到?”

  • 定义: 视口内最大的那块可见内容(通常是大图、视频封面或 H1 标题)渲染完成的时间点。

  • 为什么不是 FCP (First Contentful Paint)? FCP 可能只画了一个菜单栏或者 Loading 图标,用户并不关心。用户进来是看正文/商品的,LCP 代表了“正文已就位”。

  • 标准:

    • 🟢 < 2.5s: 优秀
    • 🔴 > 4.0s: 糟糕

2.2 交互响应:INP (Interaction to Next Paint)

“点下去多久有反应?”

  • 架构师注意: FID (First Input Delay) 已于 2024 年 3 月正式退役。INP 是新的王者。

  • 定义: 观察用户在页面停留期间的所有点击、按键、触摸操作,计算从“操作发生”到“下一帧绘制”的延迟。

  • INP vs FID: FID 只看第一次点击(第一印象);INP 看全生命周期中最慢的那次交互(木桶效应)。如果你的页面长列表滚动很卡,INP 会教你做人。

  • 标准:

    • 🟢 < 200ms: 优秀
    • 🔴 > 500ms: 糟糕

2.3 视觉稳定性:CLS (Cumulative Layout Shift)

“页面是不是在乱跳?”

  • 定义: 衡量页面在加载过程中意外位移的程度。

  • 场景: 用户正准备点“取消”,突然上方加载出一个广告 Banner,把“取消”挤下去了,用户误触了“确认/购买”。这是最让用户愤怒的体验。

  • 标准:

    • 🟢 < 0.1: 优秀
    • 🔴 > 0.25: 糟糕

三、 辅助指标:诊断问题的线索

除了核心的 CWV,我们还需要一些辅助指标来帮助定位问题:

  1. TTFB (Time To First Byte):

    • 含义: 从发出请求到接收到第一个字节的时间。
    • 诊断: 如果 TTFB 高,说明后端数据库慢或者网络差,跟前端代码没关系。
  2. FCP (First Contentful Paint):

    • 含义: 屏幕上渲染出第一个像素(文字、图片、Canvas)。
    • 诊断: 如果 FCP 慢,说明 HTML 文件太大,或者阻塞渲染的 CSS/JS 太多(白屏时间长)。
  3. TBT (Total Blocking Time):

    • 含义: 主线程被长任务(Long Task > 50ms)阻塞的总时长。
    • 诊断: TBT 高通常意味着 JS 执行逻辑太重,会导致 INP 变差。TBT 是 INP 的“实验室替身”。

四、 数据的两面性:Lab vs. Field

架构师在汇报性能时,经常会被问:“为什么 Lighthouse 跑了 90 分,用户还是投诉卡?” 这是因为你混淆了两种数据源。

4.1 实验室数据 (Lab Data / Synthetic)

  • 来源: Lighthouse, WebPageTest。
  • 环境: 你的高配 MacBook Pro + 公司千兆光纤 + 并没有登录态。
  • 优点: 环境可控,适合调试回归测试(发版前跑一遍,防止变差)。
  • 缺点: 这就是“自嗨”。 它代表不了用低端安卓机、在地铁弱网下访问的用户。

4.2 现场数据 (Field Data / RUM - Real User Monitoring)

  • 来源: Chrome UX Report (CrUX),自建埋点系统。
  • 环境: 真实用户的设备和网络。
  • 优点: 这就是“真相”。
  • 缺点: 噪音大,不可复现(你不知道那个用户为什么卡,也许他手机当时在升级系统)。

架构策略: “用 Lab Data 守底线(CI/CD),用 Field Data 定目标(KPI)。”


五、 业务价值:如何向老板兜售性能?

如果你直接跟老板说:“我们要把 LCP 从 3s 优化到 2.5s”,老板可能无感。 你需要建立 性能与业务指标 (Conversion Rate) 的关联。

  • 沃尔玛案例: 页面加载每快 1 秒,转化率提升 2%。
  • Pinterest 案例: 移动端等待时间减少 40%,搜索引擎流量和注册量提升 15%。
  • Google 权重: CWV 达标的网站,搜索排名权重更高。

公式: 性能优化 = 提升用户留存 + 降低跳出率 + 节省云服务器带宽成本


结语:先有度量,再有优化

在这一节,我们统一了度量衡。 我们不再说“有点卡”,而是说“INP 超过了 200ms”;我们不再说“白屏久”,而是说“LCP 还是 4秒”。

确立了标准之后,接下来的问题是:数据从哪来? 我们不能指望用户每个人都打开控制台截图发给我们。我们需要一套自动化的、全链路的监控系统,像天眼一样实时注视着每一个用户的每一次访问。

Next Step: 下一节,我们将进入基建环节。 架构师将化身“监控探头安装员”

学习Three.js–风车星系

2026年2月3日 10:23

学习Three.js--风车星系

前置核心说明

开发目标

基于Three.js的粒子系统+自定义着色器实现真实感M101风车星系效果,还原星系的核心结构与视觉特征,核心能力包括:

  1. 模拟星系分层结构:核心核球(Bulge)+ 螺旋旋臂(Spiral Arms),还原真实星系的形态;
  2. 采用对数螺旋算法生成旋臂,实现自然流畅的风车状螺旋结构,符合真实星系的旋臂规律;
  3. 粒子分层差异化配置:核球与旋臂使用不同的粒子分布、颜色、尺寸,模拟真实恒星的视觉差异;
  4. 借助ShaderMaterial实现圆形抗锯齿粒子与加法混合光晕,提升星系的细腻度与真实感;
  5. 实现微弱粒子脉动+星系整体自转,营造动态的宇宙星系氛围,支持轨道交互查看。

606115fd-7227-41f4-993b-02b6592919ab.png

核心技术栈(关键知识点)

技术点 作用
THREE.BufferGeometry + 自定义attribute 高效存储10万级粒子数据(顶点、颜色、尺寸),为每个粒子提供独立属性,支撑分层差异化效果
对数螺旋公式(r = a * e^(b*θ) 生成自然流畅的星系旋臂,实现风车状螺旋结构,是模拟螺旋星系的核心算法
粒子分层生成策略(核球+旋臂) 不同区域采用不同的分布逻辑、视觉参数,还原星系的真实分层结构,提升场景真实感
THREE.ShaderMaterial(顶点/片元着色器) 1. 实现圆形抗锯齿粒子,替代默认方形粒子;2. 传递粒子独立颜色/尺寸,实现差异化视觉;3. 实现微弱脉动动画,兼顾性能与效果
THREE.AdditiveBlending(加法混合) 粒子颜色亮度叠加,营造星系的朦胧光晕感,提升旋臂与核球的层次感,模拟宇宙星尘的发光效果
星系双动画逻辑(粒子脉动+整体自转) 微观粒子微弱脉动+宏观星系缓慢自转,营造动态且真实的宇宙氛围,避免场景静态生硬
THREE.OrbitControls(轨道控制器) 支持拖拽旋转/滚轮缩放,全方位查看3D星系结构,便捷观察旋臂与核球的细节

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

搭建Three.js 3D场景的基础框架,为星系提供展示载体,保证场景的流畅交互与高清渲染。

1.1 核心代码
// 导入Three.js核心库与轨道控制器
import * as THREE from 'https://esm.sh/three@0.174.0';
import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

// 1. 场景初始化(纯黑背景,最大化衬托星系的光晕效果,模拟宇宙真空环境)
const scene = new THREE.Scene();

// 2. 透视相机(适配3D星系场景,兼顾核球细节与旋臂整体查看)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV):60°视野适中,无星系变形
  innerWidth / innerHeight, // 宽高比:适配浏览器窗口
  0.1, // 近裁切面:过滤过近无效对象,提升性能
  2000 // 远裁切面:保证星系完整处于可见范围,支持远距离缩放查看
);
camera.position.set(0, 20, 80); // 高位侧视:既清晰查看旋臂的风车结构,又能观察核球细节

// 3. 渲染器(抗锯齿,提升星系粒子边缘细腻度,避免光晕锯齿感)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配:Retina屏幕无模糊
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(支持拖拽旋转/滚轮缩放,便捷查看3D星系结构)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 启用阻尼:拖拽旋转有惯性,交互更顺滑自然
1.2 关键说明
  • 相机位置(0, 20, 80) 采用「高位+稍远」视角,既可以完整捕捉风车星系的4条旋臂结构,又能清晰观察核心核球的细节,避免视角过近导致旋臂变形、光晕过曝。
  • 渲染器antialias: true:开启抗锯齿,配合后续片元着色器的smoothstep抗锯齿逻辑,让星系粒子的边缘和光晕更细腻,这对明亮的核球与旋臂尤为重要。
  • 控制器阻尼:启用阻尼后,交互体验更贴近真实3D场景,适合长时间查看星系的自转与脉动效果,避免拖拽后瞬间停止的生硬感。

步骤2:粒子数据分层生成(核球+旋臂,星系核心形态实现)

这是风车星系的核心步骤,采用分层生成策略,分别生成核球与旋臂的粒子数据,通过不同的分布逻辑、颜色、尺寸,还原星系的真实结构,其中对数螺旋算法是旋臂实现的关键。

2.1 核心代码
// 初始化粒子数据数组(集中存储,后续绑定到BufferGeometry)
const pointsArr = []; // 粒子顶点坐标数组
const colors = []; // 粒子颜色数组(每个粒子独立颜色,实现分层视觉差异)
const sizes = []; // 粒子尺寸数组(每个粒子独立尺寸,提升星系细腻度)

// ---- 分层1:核球(Bulge)- 星系中心密集区域,模拟老年恒星集群 ---- 
const bulgeCount = 8000; // 核球粒子数(8000,保证密集度且不卡顿)
for (let i = 0; i < bulgeCount; i++) {
  // 幂次采样(Math.pow(Math.random(), 3)):让粒子高度集中在中心,模拟真实核球的恒星分布
  // 3次方让随机值更偏向0,粒子密集在中心区域,外层逐渐稀疏
  const radius = Math.pow(Math.random(), 3) * 6; 
  // 随机方向生成点,再乘以半径,得到核球内的均匀分布粒子
  const point = new THREE.Vector3().randomDirection().multiplyScalar(radius);
  pointsArr.push(point);

  // 核球颜色:黄白色(模拟老年恒星,亮度高、偏暖色调)
  colors.push(1.0, 0.9, 0.7);
  // 核球粒子尺寸:0.2~1.0,较小且密集,体现核球的紧凑感
  sizes.push(Math.random() * 0.8 + 0.2);
}

// ---- 分层2:旋臂(Spiral Arms)- 风车状螺旋结构,模拟新生恒星/星云 ----
const armCount = 92000; // 旋臂粒子数(92000,保证旋臂的细腻度与流畅感)
const arms = 4; // 旋臂数量:4条,还原M101风车星系的经典形态
const armSpread = 0.4; // 旋臂松紧度(越小越紧,越大越松散)
const maxRadius = 60; // 旋臂最大半径,决定星系的整体大小

for (let i = 0; i < armCount; i++) {
  // 随机分配旋臂,保证4条旋臂的粒子分布均匀
  const armIndex = Math.floor(Math.random() * arms);
  // 旋臂角度偏移:让4条旋臂均匀分布在360°范围内,无重叠
  const angleOffset = (armIndex / arms) * Math.PI * 2;

  // 核心算法:对数螺旋(r = a * e^(b*θ)),生成自然流畅的螺旋结构
  const theta = Math.random() * Math.PI * 8; // 螺旋角度:8π对应4圈,保证旋臂的长度与流畅度
  // 归一化计算螺旋半径,让旋臂刚好延伸到maxRadius,避免超出边界
  const radius = Math.exp(theta * armSpread) * (maxRadius / Math.exp(Math.PI * 8 * armSpread));
  
  // 径向扰动:让旋臂有“宽度”,避免旋臂过于纤细、生硬,模拟真实旋臂的厚度
  const radialJitter = (Math.random() - 0.5) * 3;
  const finalRadius = Math.min(radius + radialJitter, maxRadius); // 限制最大半径,避免粒子超出星系范围

  // 角度扰动:让旋臂的粒子分布更自然,避免螺旋线过于规整,提升真实感
  const angle = theta + angleOffset + (Math.random() - 0.5) * 0.3;
  // 旋臂高度:-1~1,形成薄盘结构,模拟真实星系的旋臂平面特性
  const height = (Math.random() - 0.5) * 2;

  // 从极坐标转换为直角坐标,生成旋臂粒子的坐标
  const x = Math.cos(angle) * finalRadius;
  const z = Math.sin(angle) * finalRadius;
  pointsArr.push(new THREE.Vector3(x, height, z));

  // 旋臂颜色:蓝白色(模拟新生恒星/星云,偏冷色调,与核球形成视觉差异)
  colors.push(0.7, 0.85, 1.0);
  // 旋臂粒子尺寸:0.3~1.5,比核球稍大,体现旋臂的视觉层次感
  sizes.push(Math.random() * 1.2 + 0.3);
}
2.2 关键技术点解析
  1. 粒子分层生成策略(核球vs旋臂)

    • 核球:采用randomDirection()+幂次采样,粒子高度集中在中心,颜色为黄白色(老年恒星),尺寸较小且密集,体现核球的紧凑、温暖、明亮的特性;
    • 旋臂:采用对数螺旋算法,粒子沿螺旋线分布,颜色为蓝白色(新生恒星/星云),尺寸稍大且有厚度,体现旋臂的舒展、冷艳、有层次的特性;
    • 分层设计的核心是还原真实星系的结构差异,通过视觉参数(颜色、尺寸)与分布逻辑的不同,让星系更具真实感。
  2. 对数螺旋算法(旋臂实现的核心)

    • 公式:r = a * e^(b*θ),其中r为螺旋半径,θ为螺旋角度,b为螺旋松紧度(对应代码中的armSpread),a为比例系数;
    • 代码中通过Math.exp(theta * armSpread)计算螺旋半径,再通过归一化处理让旋臂刚好延伸到maxRadius,避免超出边界;
    • armSpread越小,螺旋越紧凑,旋臂越“卷”;越大,螺旋越松散,旋臂越“舒展”,这是调整旋臂形态的核心参数。
  3. 扰动优化(旋臂真实感提升)

    • 径向扰动radialJitter让粒子在半径方向有轻微偏移,形成旋臂的“宽度”,避免旋臂成为一条纤细的线;
    • 角度扰动:让粒子在螺旋角度上有轻微偏移,避免旋臂的螺旋线过于规整,模拟真实星系旋臂的不规则性;
    • 高度限制:旋臂粒子的Y轴高度限制在-1~1,形成薄盘结构,符合真实星系旋臂的平面特性,避免旋臂呈现立体球状。

步骤3:构建BufferGeometry(绑定粒子数据,支撑着色器渲染)

将步骤2生成的粒子数据(顶点、颜色、尺寸)绑定到BufferGeometry,并通过自定义attribute向着色器传递粒子的独立颜色与尺寸,为后续着色器渲染提供数据支撑。

3.1 核心代码
// 1. 构建BufferGeometry,绑定粒子顶点坐标(星系粒子的基础位置数据)
const geometry = new THREE.BufferGeometry().setFromPoints(pointsArr);

// 2. 添加自定义attribute:color(粒子颜色,每个粒子3个分量:R/G/B)
// 第二个参数「3」表示每个顶点的分量数为3,与colors数组的每3个元素对应一个粒子
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));

// 3. 添加自定义attribute:size(粒子尺寸,每个粒子1个值)
// 第二个参数「1」表示每个顶点的分量数为1,与sizes数组的每个元素一一对应
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
3.2 关键技术点解析
  • BufferGeometry的高效性:直接操作二进制数组存储数据,渲染时减少CPU与GPU之间的数据传输开销,适合10万级粒子场景(本次总粒子数10万),性能远优于已被废弃的普通Geometry
  • 自定义attribute的核心作用:向着色器传递「每个粒子的独立数据」,本次传递了color(颜色)与size(尺寸),让着色器能够为每个粒子渲染不同的颜色与尺寸,实现星系的分层视觉差异。
  • Float32BufferAttribute:最常用的BufferAttribute类型,存储32位浮点型数据,兼顾精度与性能,适合传递粒子颜色、尺寸等数据,着色器中需声明同名attribute变量才能访问对应数据。

步骤4:创建自定义ShaderMaterial(星系视觉效果的核心)

通过ShaderMaterial自定义顶点着色器与片元着色器,实现圆形抗锯齿粒子、粒子独立颜色/尺寸渲染、微弱脉动动画,同时通过加法混合营造星系的朦胧光晕,提升真实感。

4.1 核心代码
// 构建ShaderMaterial,配置全局uniforms和着色器,实现星系的核心视觉效果
const material = new THREE.ShaderMaterial({
  // 1. 全局uniforms(向着色器传递全局统一数据,此处为时间,驱动脉动动画)
  uniforms: {
    uTime: { value: 0 } // 全局时间:驱动所有粒子的脉动动画同步更新
  },

  // 2. 顶点着色器(处理粒子位置、颜色、尺寸,实现微弱脉动与透视变换)
  vertexShader: `
    uniform float uTime;
    attribute vec3 color; // 粒子颜色(自定义attribute,每个粒子独立)
    attribute float size; // 粒子尺寸(自定义attribute,每个粒子独立)
    varying vec3 vColor; // 传递给片元着色器的颜色(varying变量,实现平滑插值)

    void main() {
      vec3 pos = position;

      // 微弱脉动动画:基于粒子距离与时间的球面扰动,提升星系的动态感
      // sin函数实现周期性脉动,0.1为脉动幅度,避免过于剧烈影响星系形态
      float pulse = sin(uTime * 0.5 + length(pos)) * 0.1;
      pos += normalize(pos) * pulse; // 沿粒子径向脉动,保持星系整体形态不变

      // 透视变换:将3D粒子坐标转换为2D屏幕坐标,3D渲染必备
      vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); // 模型视图矩阵:局部坐标→相机视角坐标
      gl_Position = projectionMatrix * mvPosition; // 投影矩阵:相机视角坐标→屏幕裁剪坐标

      // 粒子尺寸计算:透视缩放(近大远小),实现真实的3D视觉效果
      gl_PointSize = size * (80.0 / -mvPosition.z); // 80.0为缩放系数,控制粒子的整体大小
      gl_PointSize = min(gl_PointSize, 100.0); // 限制最大尺寸,避免远处粒子过大导致光晕过曝

      // 传递粒子颜色到片元着色器,实现每个粒子的独立颜色渲染
      vColor = color;
    }
  `,

  // 3. 片元着色器(处理粒子像素颜色、形状,实现圆形抗锯齿与柔和光晕)
  fragmentShader: `
    varying vec3 vColor; // 从顶点着色器传递过来的粒子颜色

    void main() {
      // 步骤1:绘制圆形粒子(基于点坐标的UV计算,替代默认方形粒子)
      vec2 uv = gl_PointCoord - 0.5; // 将点坐标从(0,0)~(1,1)转换为(-0.5,-0.5)~(0.5,0.5)
      float d = length(uv); // 计算当前像素到粒子中心的距离

      // 步骤2:圆形裁剪(丢弃超出圆心0.5范围的像素,形成圆形粒子)
      if (d > 0.5) discard; // discard:丢弃当前像素,不渲染,实现圆形轮廓

      // 步骤3:抗锯齿边缘(smoothstep实现渐隐,避免圆形边缘锯齿,提升光晕质感)
      float alpha = 1.0 - smoothstep(0.45, 0.5, d); // 从0.45到0.5,alpha从1渐变到0,边缘渐隐

      // 步骤4:设置最终像素颜色(粒子独立颜色+渐变Alpha,实现柔和光晕)
      gl_FragColor = vec4(vColor, alpha);
    }
  `,

  // 4. 材质附加配置(提升星系视觉效果,核心是加法混合与透明设置)
  transparent: true, // 启用透明:支持粒子边缘渐隐,实现柔和光晕效果
  blending: THREE.AdditiveBlending, // 加法混合:粒子颜色亮度叠加,呈现朦胧光晕,提升星系层次感
  depthWrite: false // 关闭深度写入:允许粒子叠加,避免旋臂与核球互相遮挡,光晕更连贯
});

// 5. 创建Points粒子对象,添加到场景(将几何体与材质结合,形成最终的M101风车星系)
const points = new THREE.Points(geometry, material);
scene.add(points);
4.2 关键技术点解析
  1. 顶点着色器核心逻辑

    • 微弱脉动动画:基于sin(uTime * 0.5 + length(pos))实现周期性脉动,length(pos)让不同位置的粒子脉动相位不同,避免同步脉动的生硬感,0.1的脉动幅度保证星系整体形态不变,仅提升动态感;
    • 透视缩放gl_PointSize = size * (80.0 / -mvPosition.z)实现「近大远小」的真实透视效果,让星系更具3D立体感,min函数限制最大尺寸,避免光晕过曝;
    • 颜色传递:通过varying vec3 vColor将粒子的独立颜色传递到片元着色器,Three.js会自动进行平滑插值,保证颜色过渡自然,无明显断层。
  2. 片元着色器核心逻辑

    • 圆形粒子绘制:通过gl_PointCoord转换UV坐标,计算像素到粒子中心的距离,丢弃超出圆心的像素,形成圆形粒子,替代默认的方形粒子,提升星系的细腻度;
    • 抗锯齿边缘smoothstep(0.45, 0.5, d)实现边缘渐隐,避免圆形粒子的锯齿感,同时为粒子营造柔和的光晕,这对星系的旋臂与核球尤为重要;
    • 独立颜色渲染:直接使用从顶点着色器传递的vColor,实现每个粒子的差异化颜色,还原星系核球与旋臂的视觉差异。
  3. 材质配置优化

    • AdditiveBlending(加法混合):粒子颜色亮度叠加,越密集的地方越亮,形成自然的朦胧光晕,模拟宇宙星尘的发光效果,提升星系的真实感与层次感;
    • depthWrite: false:关闭深度写入,允许旋臂与核球的粒子互相叠加,避免粒子之间的遮挡,保证光晕的连贯性,同时提升渲染性能;
    • transparent: true:启用透明,支持粒子边缘的渐隐效果,配合加法混合,让星系的光晕更柔和、更自然。

步骤5:动画循环(驱动星系动态效果,实现流畅渲染)

每帧更新全局时间uTime,驱动粒子微弱脉动,同时实现星系整体自转,更新控制器阻尼,保证星系的动态效果流畅、自然,提升视觉体验。

5.1 核心代码
const clock = new THREE.Clock(); // 时钟:用于获取累计运行时间,不受帧率影响,避免动画累积误差

function animate() {
  requestAnimationFrame(animate); // 绑定浏览器刷新率(通常60帧/秒),实现流畅无卡顿的动画

  // 1. 获取累计运行时间,驱动着色器脉动动画
  const t = clock.getElapsedTime();
  material.uniforms.uTime.value = t;

  // 2. 星系整体自转:绕Y轴缓慢旋转,营造真实的宇宙星系氛围
  // 0.02为自转速度,缓慢旋转便于观察星系的旋臂结构
  points.rotation.y = t * 0.02;

  // 3. 更新轨道控制器阻尼(必须在动画循环中调用,保证阻尼效果生效)
  controls.update();

  // 4. 渲染场景(将场景和相机的3D信息渲染为2D画布,呈现最终的M101风车星系)
  renderer.render(scene, camera);
}

// 启动动画循环,开始运行星系的脉动、自转与渲染
animate();
5.2 关键说明
  • clock.getElapsedTime():获取从时钟启动到当前的累计运行时间(单位:秒),相比getDelta()更适合驱动全局循环动画,避免动画因帧率波动出现累积误差,保证星系的脉动与自转效果在不同设备上一致。
  • 双动画逻辑(微观脉动+宏观自转)
    • 微观:粒子的微弱脉动,提升星系的动态感,避免场景过于静态;
    • 宏观:星系绕Y轴缓慢自转,还原真实星系的自转特性,营造宇宙星系的氛围;
    • 双动画逻辑的核心是「动静结合」,既保证星系的整体形态不变,又提升视觉体验的丰富性。
  • 自转速度优化0.02的自转速度较为缓慢,便于用户观察星系的旋臂与核球细节,若想提升动态感,可适当增大该值(如0.05)。

步骤6:窗口适配(响应式调整,适配不同屏幕尺寸)

保证M101风车星系在不同屏幕尺寸下都能全屏显示,且不会出现拉伸变形,适配桌面端、移动端等不同设备。

6.1 核心代码
window.addEventListener('resize', () => {
  // 1. 更新相机宽高比(适配新的窗口尺寸,避免场景拉伸)
  camera.aspect = window.innerWidth / window.innerHeight;
  // 2. 更新相机投影矩阵(必须调用,否则宽高比修改不生效,场景会出现拉伸变形)
  camera.updateProjectionMatrix();
  // 3. 更新渲染器尺寸(适配新的窗口尺寸,保证星系全屏显示)
  renderer.setSize(window.innerWidth, window.innerHeight);
});
6.2 关键说明
  • 窗口大小变化时,同步更新相机宽高比和渲染器尺寸,保证星系在不同屏幕尺寸下都能全屏显示,且透视效果正常,不会出现拉伸变形。
  • camera.updateProjectionMatrix():相机参数(如宽高比)修改后,必须调用该方法更新投影矩阵,否则宽高比的修改不会生效,场景会出现明显的拉伸变形,影响星系的视觉效果。

核心技术深度解析

1. 对数螺旋算法(旋臂实现的核心)

对数螺旋是自然界中螺旋星系的普遍形态,其核心公式为:$r = a \cdot e^{b \cdot \theta}$

  • 各参数含义:
  • $r$:螺旋线上某点到原点的半径(对应代码中的radius);
  • $\theta$:螺旋线上某点的极角(对应代码中的theta);
  • $b$:螺旋松紧度(对应代码中的armSpread),$b$越小,螺旋越紧凑,旋臂越“卷”;$b$越大,螺旋越松散,旋臂越“舒展”;
  • $a$:比例系数,用于调整螺旋的整体大小(代码中通过归一化处理实现,让旋臂刚好延伸到maxRadius)。
  • 代码落地技巧:通过Math.exp(theta * armSpread)计算螺旋半径,再通过maxRadius / Math.exp(Math.PI * 8 * armSpread)进行归一化,保证旋臂的长度与边界可控,避免超出星系范围。

2. 粒子分层生成策略(核球vs旋臂)

对比项 核球(Bulge) 旋臂(Spiral Arms)
分布逻辑 randomDirection()+幂次采样,高度集中在中心 对数螺旋算法+径向/角度扰动,沿螺旋线分布
粒子数量 8000(密集紧凑) 92000(舒展细腻)
颜色 黄白色(1.0, 0.9, 0.7),模拟老年恒星 蓝白色(0.7, 0.85, 1.0),模拟新生恒星/星云
尺寸 0.2~1.0,较小且均匀 0.3~1.5,稍大且有差异
空间形态 球形(3D分布) 薄盘形(2D平面分布,Y轴高度限制在-1~1)

3. ShaderMaterial的性能优势

本次星系总粒子数为10万,采用ShaderMaterial能够实现流畅渲染,其核心优势在于:

  • GPU并行处理:着色器逻辑运行在GPU上,具备强大的并行处理能力,可轻松应对10万级甚至百万级粒子,性能远超普通JS驱动的粒子系统;
  • 减少数据传输:通过BufferGeometry将粒子数据一次性传递到GPU,后续动画仅需更新少量全局uniform(如uTime),减少CPU与GPU之间的数据传输开销;
  • 灵活的视觉定制:通过自定义着色器,可实现圆形粒子、抗锯齿、光晕等复杂视觉效果,且无需额外的几何体开销,提升渲染效率。

核心参数速查表(快速调整星系效果)

参数名 当前取值 作用 修改建议
bulgeCount 8000 核球粒子数,决定核球的密集程度 改为5000:核球更稀疏;改为10000:核球更密集,亮度更高
armCount 92000 旋臂粒子数,决定旋臂的细腻程度 改为50000:旋臂更稀疏,低配设备更流畅;改为150000:旋臂更细腻,光晕更丰富
arms 4 旋臂数量,决定星系的风车形态 改为2:双旋臂星系(如银河系);改为6:六旋臂星系,更具视觉冲击力
armSpread 0.4 旋臂松紧度,决定螺旋的紧凑程度 改为0.3:旋臂更紧凑,更“卷”;改为0.5:旋臂更松散,更“舒展”
maxRadius 60 旋臂最大半径,决定星系的整体大小 改为40:星系更小更紧凑;改为80:星系更大更舒展,支持远距离缩放查看
核球颜色 (1.0, 0.9, 0.7) 核球视觉色调,模拟老年恒星 改为(1.0, 0.8, 0.5):更暖的金黄色,核球更明亮;改为(1.0, 1.0, 0.9):更浅的黄白色,核球更柔和
旋臂颜色 (0.7, 0.85, 1.0) 旋臂视觉色调,模拟新生恒星/星云 改为(0.6, 0.8, 1.0):更冷的蓝色,旋臂更鲜明;改为(0.8, 0.9, 1.0):更浅的蓝白色,旋臂更柔和
自转速度 t * 0.02 0.02 星系整体自转速度,决定动态氛围 改为0.01:自转更缓慢,便于观察细节;改为0.05:自转更快,动态感更强

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>M101 风车星系 - Three.js</title>
  <style>body { margin: 0; overflow: hidden; background: #000; }</style>
</head>
<body>
  <script type="module">
  // 导入Three.js核心库与轨道控制器
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // ========== 1. 基础环境初始化(场景/相机/渲染器/控制器) ==========
  const scene = new THREE.Scene();

  // 透视相机:高位侧视,清晰查看风车星系的旋臂结构与核球细节
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);
  camera.position.set(0, 20, 80);

  // 渲染器:抗锯齿,提升星系粒子边缘与光晕的细腻度,适配高清屏幕
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:启用阻尼,实现顺滑的3D交互体验
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;

  // ========== 2. 粒子数据分层生成(核球+旋臂,还原星系真实结构) ==========
  const pointsArr = []; // 粒子顶点坐标数组
  const colors = []; // 粒子颜色数组:分层差异化,实现核球与旋臂的视觉差异
  const sizes = []; // 粒子尺寸数组:分层差异化,提升星系细腻度

  // ---- 分层1:核球(Bulge)- 中心密集区域,模拟老年恒星集群 ---- 
  const bulgeCount = 8000;
  for (let i = 0; i < bulgeCount; i++) {
    // 幂次采样:Math.pow(Math.random(), 3)让粒子高度集中在中心,模拟真实核球分布
    const radius = Math.pow(Math.random(), 3) * 6;
    const point = new THREE.Vector3().randomDirection().multiplyScalar(radius);
    pointsArr.push(point);

    // 核球颜色:黄白色(老年恒星,偏暖色调,亮度高)
    colors.push(1.0, 0.9, 0.7);
    // 核球尺寸:0.2~1.0,较小且密集,体现核球的紧凑感
    sizes.push(Math.random() * 0.8 + 0.2);
  }

  // ---- 分层2:旋臂(Spiral Arms)- 风车状螺旋结构,模拟新生恒星/星云 ----
  const armCount = 92000;
  const arms = 4; // 旋臂数量:4条,还原M101风车星系的经典形态
  const armSpread = 0.4; // 旋臂松紧度:越小越紧,越大越松散
  const maxRadius = 60; // 旋臂最大半径,决定星系整体大小

  for (let i = 0; i < armCount; i++) {
    // 随机分配旋臂,保证4条旋臂均匀分布
    const armIndex = Math.floor(Math.random() * arms);
    const angleOffset = (armIndex / arms) * Math.PI * 2;

    // 核心算法:对数螺旋(r = a * e^(b*θ)),生成自然流畅的螺旋结构
    const theta = Math.random() * Math.PI * 8; // 螺旋角度:8π对应4圈,保证旋臂长度与流畅度
    const radius = Math.exp(theta * armSpread) * (maxRadius / Math.exp(Math.PI * 8 * armSpread));
    
    // 径向扰动:让旋臂有宽度,避免过于纤细、生硬
    const radialJitter = (Math.random() - 0.5) * 3;
    const finalRadius = Math.min(radius + radialJitter, maxRadius);

    // 角度扰动:让旋臂粒子分布更自然,避免螺旋线过于规整
    const angle = theta + angleOffset + (Math.random() - 0.5) * 0.3;
    // 薄盘结构:Y轴高度限制在-1~1,符合真实星系旋臂的平面特性
    const height = (Math.random() - 0.5) * 2;

    // 极坐标→直角坐标,生成旋臂粒子坐标
    const x = Math.cos(angle) * finalRadius;
    const z = Math.sin(angle) * finalRadius;
    pointsArr.push(new THREE.Vector3(x, height, z));

    // 旋臂颜色:蓝白色(新生恒星/星云,偏冷色调,与核球形成视觉差异)
    colors.push(0.7, 0.85, 1.0);
    // 旋臂尺寸:0.3~1.5,比核球稍大,体现旋臂的视觉层次感
    sizes.push(Math.random() * 1.2 + 0.3);
  }

  // ========== 3. 构建BufferGeometry(绑定粒子数据,支撑着色器渲染) ==========
  const geometry = new THREE.BufferGeometry().setFromPoints(pointsArr);
  // 添加自定义attribute:color(粒子独立颜色,3个分量)
  geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  // 添加自定义attribute:size(粒子独立尺寸,1个分量)
  geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));

  // ========== 4. 创建ShaderMaterial(核心:圆形粒子+光晕+脉动动画) ==========
  const material = new THREE.ShaderMaterial({
    // 全局uniforms:传递时间,驱动粒子微弱脉动动画
    uniforms: {
      uTime: { value: 0 }
    },

    // 顶点着色器:处理粒子位置、颜色、尺寸,实现微弱脉动与透视变换
    vertexShader: `
      uniform float uTime;
      attribute vec3 color;
      attribute float size;
      varying vec3 vColor;

      void main() {
        vec3 pos = position;

        // 微弱脉动动画:周期性球面扰动,提升星系动态感,保持整体形态不变
        float pulse = sin(uTime * 0.5 + length(pos)) * 0.1;
        pos += normalize(pos) * pulse;

        // 透视变换:3D坐标→2D屏幕坐标,保证星系正确显示
        vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
        gl_Position = projectionMatrix * mvPosition;

        // 透视缩放:近大远小,实现真实3D视觉效果,限制最大尺寸避免光晕过曝
        gl_PointSize = size * (80.0 / -mvPosition.z);
        gl_PointSize = min(gl_PointSize, 100.0);

        // 传递粒子颜色到片元着色器,实现分层差异化视觉
        vColor = color;
      }
    `,

    // 片元着色器:处理粒子形状、抗锯齿、光晕,实现圆形柔和粒子
    fragmentShader: `
      varying vec3 vColor;

      void main() {
        // 圆形粒子绘制:转换UV坐标,计算到粒子中心的距离
        vec2 uv = gl_PointCoord - 0.5;
        float d = length(uv);

        // 圆形裁剪:丢弃超出圆心的像素,形成圆形轮廓
        if (d > 0.5) discard;

        // 抗锯齿边缘:smoothstep实现渐隐,提升光晕质感,避免锯齿感
        float alpha = 1.0 - smoothstep(0.45, 0.5, d);

        // 最终像素颜色:粒子独立颜色+渐变Alpha,实现柔和光晕效果
        gl_FragColor = vec4(vColor, alpha);
      }
    `,

    // 材质配置:提升星系视觉效果,实现朦胧光晕与流畅叠加
    transparent: true, // 启用透明,支持边缘渐隐
    blending: THREE.AdditiveBlending, // 加法混合,颜色亮度叠加,营造光晕
    depthWrite: false // 关闭深度写入,避免粒子互相遮挡,光晕更连贯
  });

  // 创建Points粒子对象,添加到场景,形成最终的M101风车星系
  const points = new THREE.Points(geometry, material);
  scene.add(points);

  // ========== 5. 动画循环(驱动脉动+自转,实现流畅动态星系) ==========
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);

    // 更新全局时间,驱动粒子微弱脉动动画
    const t = clock.getElapsedTime();
    material.uniforms.uTime.value = t;

    // 星系整体自转:绕Y轴缓慢旋转,营造真实宇宙氛围
    points.rotation.y = t * 0.02;

    // 更新轨道控制器阻尼,保证顺滑交互
    controls.update();

    // 渲染场景,呈现最终的M101风车星系效果
    renderer.render(scene, camera);
  }

  // 启动动画循环,开始运行星系的脉动、自转与渲染
  animate();

  // ========== 6. 窗口适配(响应式调整,适配不同屏幕尺寸) ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
  </script>
</body>
</html>

总结与扩展建议

核心总结

  1. 形态核心:对数螺旋算法是实现螺旋星系旋臂的关键,配合径向/角度扰动,可生成自然流畅的风车状结构,还原真实星系的形态特征。
  2. 视觉核心:粒子分层生成策略(核球+旋臂)+ 差异化视觉参数(颜色、尺寸),是提升星系真实感的核心,能够还原真实星系的结构与视觉差异。
  3. 性能核心ShaderMaterial+BufferGeometry的组合,能够高效处理10万级粒子,借助GPU并行处理,实现流畅渲染,同时支持复杂的视觉定制(圆形粒子、光晕、脉动)。
  4. 动态核心:微观粒子微弱脉动+宏观星系整体自转的双动画逻辑,动静结合,既保证星系的整体形态不变,又提升视觉体验的丰富性,营造真实的宇宙氛围。

扩展建议

  1. 星系效果增强
    • 添加暗物质晕:在星系外围生成一层稀疏、暗淡的粒子,模拟暗物质晕,提升星系的真实感;
    • 添加星云效果:通过Texture纹理结合片元着色器,在旋臂之间添加朦胧的星云,丰富星系的视觉层次;
    • 动态颜色变化:通过uniform传递颜色参数,让核球与旋臂的颜色随时间缓慢变化,提升场景的动态感。
  2. 功能扩展
    • 交互增强:绑定鼠标位置,让星系跟随鼠标旋转、缩放,提升交互体验;
    • 参数控制面板:提供可视化面板,允许用户调整旋臂数量、松紧度、自转速度等参数,实时预览效果;
    • 相机自动漫游:实现相机的自动路径漫游,让用户无需手动操作即可全方位查看星系的细节。
  3. 性能优化
    • 使用InstancedBufferGeometry替代BufferGeometry,进一步减少DrawCall,支持更多粒子(百万级);
    • 开启渲染器的powerPreference: "high-performance",优先使用高性能GPU,提升渲染效率;
    • 剔除不可见粒子:通过视锥体裁剪,剔除屏幕外的粒子,减少GPU渲染开销。
  4. 场景扩展
    • 添加背景星空:通过THREE.TextureLoader加载星空纹理,作为场景背景,营造更真实的宇宙环境;
    • 添加其他天体:在场景中添加恒星、行星等天体,丰富宇宙场景的内容;
    • 实现多星系并存:创建多个不同形态的星系,形成星系集群,提升场景的壮观感。

Pencil + AI 生成原型图

作者 HBLOG
2026年2月3日 10:16

一、你需要准备什么

  1. VS Code

  2. Pencil 插件(你前面那个报

    Built assets not found

    的,就是它)

  3. Claude(Claude Code 或 API)

  4. Codex 插件安装

二、开始画图

claude效果

codex效果

综合比较codex的效果还是没有claude模型效果好

不用pencil画布,我直接生成的html原型效果如下,我更倾向html格式的原型

简单微信小程序开发案例——瞬间听力测试

作者 虚惊一场
2026年2月3日 10:04

项目名称

微信搜索:瞬间听力测试

项目概述

这是一个基于 Vue 3 + uni-app 开发的瞬间听力测试游戏,玩家需要在听到一串数字后准确输入。游戏从 7 位数字开始,最高可挑战 20 位数字,支持三种难度模式(舒缓、正常、急促),具有精美的 UI 设计和流畅的交互体验。

核心功能特性

  • 渐进式难度:从 7 位数字开始,成功后自动升级到下一关
  • 多难度模式:三种语音播放速度,适应不同用户需求
  • 实时音效反馈:按钮点击音效和声波可视化
  • 智能语音播报:基于微软 TTS 的高质量语音合成
  • 响应式设计:适配多种屏幕尺寸
  • 社交分享:支持微信分享,动态生成个性化标题

技术架构

前端技术栈

  • 框架:Vue 3 Composition API + uni-app
  • 样式:SCSS + 主题系统
  • 状态管理:Composables 模式
  • 音频处理:微信小程序音频 API

后端服务

  • TTS 服务:微软开源 TTS 镜像部署
  • API 接口:RESTful API 设计
  • 音频格式:MP3 流式传输

CSS 巧妙用法详解

1. 主题系统设计

项目采用了基于 SCSS Map 的主题系统,支持三种视觉风格:

$themes: (
  easy: (
    start: #d6d3ac,
    secondary: #d5d693,
    accent: #a5d6a7,
    primary: #81c784,
    end: #81c784,
    shadow: rgba(129, 199, 132, 0.35),
  ),
  normal: (
    start: #f8bbd9,
    secondary: #e1bee7,
    accent: #81d4fa,
    primary: #4fc3f7,
    end: #29b6f6,
    shadow: rgba(41, 182, 246, 0.4),
  ),
  hard: (
    start: #fcbc81,
    secondary: #ffac75,
    accent: #ffabad,
    primary: #ff9393,
    end: #ff7072,
    shadow: rgba(255, 112, 114, 0.45),
  ),
);

@mixin theme-gradient($theme-name) {
  $theme: map-get($themes, $theme-name);
  background: linear-gradient(
    135deg,
    map-get($theme, start),
    map-get($theme, secondary),
    map-get($theme, accent),
    map-get($theme, primary),
    map-get($theme, end)
  );
}

@each $theme-name, $theme in $themes {
  &.theme-#{$theme-name} {
    @include theme-gradient($theme-name);
  }
}

技术亮点

  • 使用 @each 循环自动生成主题变体
  • 通过 map-get() 函数动态获取主题色彩
  • 支持运行时主题切换,无需重新编译

2. 声波动画效果

@keyframes playingWave {
  0%,
  100% {
    height: 15rpx;
    transform: scaleY(1);
  }
  50% {
    height: 80rpx;
    transform: scaleY(1.2);
  }
}

$wave-colors: (
  1: linear-gradient(to top, #4fc3f7, #81d4fa),
  2: linear-gradient(to top, #ce93d8, #e1bee7),
  3: linear-gradient(to top, #ffcc80, #ffb74d),
  4: linear-gradient(to top, #a5d6a7, #81c784),
  5: linear-gradient(to top, #f8bbd9, #f48fb1),
);

@each $index, $gradient in $wave-colors {
  &:nth-child(#{$index}) {
    background: $gradient;
    &.playing {
      animation-delay: #{$index * -0.2}s;
    }
  }
}

动画亮点

  • 每个波形条独立的渐变色彩
  • 错位的动画延迟创建波浪效果
  • 加载和播放两种不同的动画状态

音效生成技术

1. 程序化音效合成

项目采用 Web Audio API 原理,通过数学算法生成按钮点击音效:

const initClickSound = () => {
  const sampleRate = 44100;
  const duration = 0.02;
  const samples = Math.floor(sampleRate * duration);
  const buffer = new ArrayBuffer(44 + samples * 2);
  const view = new DataView(buffer);

  // WAV 文件头生成
  const writeString = (offset, string) => {
    for (let i = 0; i < string.length; i++) {
      view.setUint8(offset + i, string.charCodeAt(i));
    }
  };

  // 音频数据生成
  for (let i = 0; i < samples; i++) {
    const t = i / sampleRate;
    const progress = t / duration;
    const frequency = 1200; // 清脆的点击声频率
    const envelope = Math.exp(-progress * 15); // 快速衰减包络
    const sample = Math.sin(2 * Math.PI * frequency * t) * envelope * 0.3;
    const intSample = Math.max(
      -32768,
      Math.min(32767, Math.floor(sample * 32767))
    );
    view.setInt16(44 + i * 2, intSample, true);
  }
};

技术优势

  • 零延迟:无需网络请求,本地生成
  • 体积小:20ms 音效仅几 KB
  • 可定制:频率、包络、音量可调
  • 兼容性好:标准 WAV 格式,全平台支持

2. 音频池优化策略

const initAudioPool = (audioPath) => {
  audioPool = [];
  for (let i = 0; i < poolSize; i++) {
    const audio = wx.createInnerAudioContext();
    audio.src = audioPath;
    audio.volume = 0.45;
    audio.autoplay = false;
    audioPool.push(audio);
  }
};

const playClickSound = () => {
  if (audioPool.length > 0) {
    const audio = audioPool[currentPoolIndex];
    audio.play();
    currentPoolIndex = (currentPoolIndex + 1) % poolSize;
  }
};

优化原理

  • 预加载:避免播放时的延迟
  • 轮询使用:防止音频实例冲突
  • 内存管理:固定池大小,控制资源占用

语音播报后端实现

1. 微软 TTS 服务部署

项目使用微软开源的 TTS 镜像

Docker 部署命令

docker run -d \
  --name microsoft-tts \
  -p 8080:8080 \
  -e AZURE_SPEECH_KEY=your_key \
  -e AZURE_SPEECH_REGION=your_region \
  microsoft/cognitive-services-speech-to-text

API 接口设计

const playTTS = (text, voice, speed) => {
  return uni.request({
    url: "https://xxx.com/tts/v1/audio/speech",
    method: "POST",
    data: {
      input: text,
      voice: "zh-CN-XiaoyiNeural", // 微软小艺语音
      response_format: "mp3",
      speed: speed || 1.0,
    },
    responseType: "arraybuffer",
  });
};

2. 音频流处理

const playAudio = () => {
  isLoading.value = true;
  uni.request({
    // ... API 配置
    success: (res) => {
      // 生成唯一文件名避免缓存冲突
      const timestamp = Date.now();
      const random = Math.floor(Math.random() * 1000);
      const filePath = `${uni.env.USER_DATA_PATH}/audio_${timestamp}_${random}.mp3`;

      fs.writeFile({
        filePath: filePath,
        data: res.data,
        encoding: "binary",
        success: () => {
          isLoading.value = false;
          isPlaying.value = true;
          innerAudioContext.src = filePath;
          innerAudioContext.play();

          // 播放完成后清理临时文件
          innerAudioContext.onEnded(() => {
            isPlaying.value = false;
            fs.unlink({ filePath });
          });
        },
      });
    },
  });
};

技术特点

  • 流式处理:ArrayBuffer 直接写入文件
  • 临时文件管理:播放后自动清理,节省存储
  • 状态同步:加载和播放状态实时更新 UI
  • 错误处理:网络异常时的优雅降级

3. 语音参数优化

const difficulties = {
  easy: { speed: 0.7, label: "舒缓" },
  normal: { speed: 1.0, label: "正常" },
  hard: { speed: 1.5, label: "急促" },
};

语音配置

  • 语音模型zh-CN-XiaoyiNeural(微软小艺,自然度高)
  • 音频格式:MP3(压缩率好,兼容性强)
  • 速度调节:0.7x - 1.5x 范围,适应不同难度

项目架构设计

1. 项目目录结构

memory-game/
├── .gitignore                    # Git 忽略文件配置
├── .hbuilderx/                   # HBuilderX 编辑器配置
│   └── launch.json              # 启动配置
├── .prettierrc.cjs              # 代码格式化配置
├── App.vue                      # 应用根组件
├── components/                   # 可复用组件目录
│   ├── CustomToast.vue          # 自定义提示组件
│   ├── DifficultySelector.vue   # 难度选择器组件
│   ├── NumberKeypad.vue         # 数字键盘组件
│   └── SoundWave.vue            # 声波可视化组件
├── composables/                  # 组合式函数目录
│   ├── useAudio.js              # 音频处理逻辑
│   └── useGame.js               # 游戏核心逻辑
├── pages/                        # 页面目录
│   └── index/                   # 首页目录
│       └── index.vue            # 游戏主页面
├── index.html                   # HTML 入口文件
├── main.js                      # 应用入口文件
├── manifest.json                # uni-app 应用配置
├── pages.json                   # 页面路由配置
├── uni.promisify.adaptor.js     # uni-app Promise 适配器
└── uni.scss                     # 全局样式文件

目录设计原则

  • 组件化components/ 存放可复用的 UI 组件
  • 逻辑分离composables/ 存放业务逻辑和状态管理
  • 页面隔离pages/ 按功能模块组织页面文件
  • 配置集中:根目录存放各种配置文件

2. Composables 模式

// useGame.js - 游戏逻辑
export function useGame() {
  const level = ref(7);
  const gameStatus = ref("initial");

  const startGame = () => {
    gameStatus.value = "playing";
    return generateNumber();
  };

  return { level, gameStatus, startGame };
}

// useAudio.js - 音频处理
export function useAudio() {
  const initAudio = () => {
    /* ... */
  };
  const playTTS = (text, voice, speed) => {
    /* ... */
  };

  return { initAudio, playTTS };
}

架构优势

  • 逻辑分离:游戏逻辑与音频处理解耦
  • 可复用性:Composables 可在多个组件中使用
  • 测试友好:纯函数易于单元测试

2. 组件化设计

  • CustomToast.vue:统一的消息提示组件
  • SoundWave.vue:声波可视化组件
  • DifficultySelector.vue:难度选择器
  • NumberKeypad.vue:数字键盘组件

性能优化策略

1. 音频优化

  • 音频池:避免频繁创建/销毁音频实例
  • 预加载:关键音效提前加载
  • 格式选择:MP3 平衡质量与体积

2. 内存管理

  • 临时文件清理:播放后立即删除
  • 组件懒加载:按需加载非关键组件
  • 事件监听清理:组件销毁时移除监听器

3. 用户体验

  • 零延迟输入:按钮响应与音效播放异步处理
  • 状态反馈:加载、播放状态的视觉提示
  • 错误处理:网络异常时的友好提示

总结

这个瞬间听力测试微信小程序项目展示了现代前端开发的多个技术亮点:

  1. CSS 工程化:主题系统、Mixin 复用、动画优化
  2. 音效技术:程序化合成、音频池优化
  3. 语音服务:微软 TTS 集成、流式音频处理
  4. 架构设计:Composables 模式、组件化开发
  5. 性能优化:内存管理、用户体验优化

项目不仅实现了完整的游戏功能,更在技术实现上体现了工程化思维和用户体验的深度考虑。通过合理的架构设计和优化策略,确保了应用在各种设备上的流畅运行。

前端,远不止“画页面”:在AI时代重新定义我们的价值

2026年2月3日 10:04

当我们谈论前端时,我们在谈论什么?

在AI工具能够“一键生成”网站的今天,不少前端工程师陷入了存在主义危机:如果产品经理、设计师甚至普通人都能通过AI工具快速创建一个网站,前端岗位的价值何在?

让我们坦诚面对现实——对于一个从无到有的简单web应用初期,确实,AI工具已经足够胜任。几个展示页面,基本表单,甚至一些简单交互,这些曾经占据前端工程师大量时间的“基础工作”,正在被自动化工具快速取代。

但这正是转折点所在。

当简单页面演变为真正应用

一个简单的展示页面与一个真正的web应用之间的差距,就像一间毛坯房与一栋智能大厦的区别。当你的web项目从“几个页面”成长为真正的“应用”时,前端特有的复杂性开始显现。

前端的第一重复杂性:终端的无尽多样性

用户通过什么访问你的应用?手机浏览器?桌面客户端?智能手表?还是最新的Apple Vision Pro?作为前端开发者,我们面对的是一个无法控制的终端生态。

后端的优雅之处在于,作为服务提供者,你可以选择自己的运行环境——统一的服务器规格,标准化的部署流程。而前端的世界则完全不同:你不能要求用户“请使用Chrome 105版本以上访问以获得最佳体验”。

这种多样性带来的挑战是真实而持续的。我们需要要么为每种终端类型做兼容适配,要么在终端之上抽象出一层跨端方案。从React Native到Flutter,从Taro到UniApp,再到新兴的Tauri、Lynx——我们不断探索着平衡开发效率与终端适配的最佳路径,因为完美的解决方案永远在下一个迭代中。

第二重复杂性:状态管理,在不确定中寻找确定

想象一个填写到一半的复杂表单。用户不小心关闭了标签页,再次打开时,是应该清空重来,还是保留进度?手机上看视频时突然进入地铁隧道,是让视频卡住,还是智能切换至低清模式继续播放?

这些场景都涉及到用户侧的状态管理——这是服务端无法处理的领域。用户的操作序列、界面状态、临时数据,所有这些都在用户的设备上实时变化,需要前端作为用户与服务之间的“智能代理”,在不确定性中维持用户体验的连贯性。

状态管理没有银弹,没有统一标准。它是艺术与工程的结合,需要深刻理解用户意图、交互逻辑和数据流。这正是AI工具难以替代的领域——因为它们缺乏对人类行为微妙之处的理解,也无法在缺乏明确规则的情况下做出符合直觉的决策。

第三重复杂性:在有限资源中追求无限流畅

性能,这个看似简单的词汇,背后是前端工程师与用户设备有限资源的持续博弈。

当用户使用时间从几分钟延长到几小时,当数据从几十条增长到成千上万条,如何保持应用的始终流畅?这里需要的是前端特有的“资源管理艺术”:

  • 视觉感知优化:只渲染用户看得见的内容,对看不见的部分进行虚拟化处理
  • 数据分片策略:智能加载,按需索取,而非一次性吞噬所有数据
  • 资源瘦身术:在功能丰富与加载速度之间找到微妙的平衡点

这些优化需要对浏览器工作原理、设备性能特征和用户行为模式的深刻理解——这些知识很难被编码为简单的规则供AI学习。

组件化的悖论:为什么积木没有变得更容易搭建?

前端工程化引入组件化和模块化已经多年,理论上我们应该像搭积木一样轻松构建应用。但现实是,大多数正在运行的前端项目仍然受制于技术债务,变得越来越难以维护和更新。

为什么会这样?

JavaScript作为前端的主要语言,最初并未设想会承担如此复杂的应用逻辑。当我们将后端工程的最佳实践移植到前端时,遇到了根本性的矛盾:用户界面需要快速迭代和灵活变化,而工程化追求的是稳定和可预测性。

这就是前端工程的核心挑战——在变化与稳定之间找到平衡。用户期待界面不断推陈出新,终端设备日新月异,交互模式持续演进。前端工程不能完全套用后端那套“长久不变”的构建规则,也不能完全放弃工程化的约束。

这种平衡需要人类的判断力:知道何时严格遵守规范,何时为创新留出空间;知道如何设计既灵活又可维护的架构;知道在技术债务累积到临界点前进行重构。

前端工程师的独特价值:在人与机器之间搭建桥梁

所以,前端岗位的核心价值是什么?我认为是在确定性与不确定性之间搭建桥梁的能力

后端世界是确定的:已知的服务器环境,可控的运行时状态,标准化的数据处理流程。用户世界是不确定的:各异的设备能力,多变的网络环境,不可预测的操作序列。

前端工程师站在这两个世界的交界处,用技术将后端的数据与服务,转化为用户能够理解、乐于使用的体验。这不仅仅是“翻译”,更是“适应”与“优化”。

在AI时代,前端工程师的角色不是被取代,而是被重新定义。我们从“界面实现者”转变为“体验架构师”,从“代码编写者”升级为“复杂性的管理者”。

AI可以生成初始代码,但无法理解不同终端上微妙的行为差异;可以建议优化方案,但无法在性能与功能之间做出符合特定业务场景的权衡;可以识别模式,但无法创造既符合工程规范又提供创新体验的解决方案。

结语:前端的存在主义答案

面对“前端价值何在”的追问,我的回答是:前端工程师是数字世界与人类体验之间的最后一道人工防线

我们处理的是技术中最贴近人类的部分:视觉、交互、即时反馈。我们面对的挑战不是如何实现某个功能,而是如何在无限多样的环境中,为各种背景的用户提供一致而优质的体验。

当AI能够自动生成代码时,前端工程师的价值不在于编写那些代码,而在于判断哪些生成的代码值得保留,如何将它们组织成可维护的系统,以及如何在不断变化的需求和设备环境中保持系统的适应性

前端没有变得更容易——它只是将简单的部分交给了工具,让我们能够专注于真正的复杂性:在技术的确定性与人类体验的不确定性之间,建造既稳固又灵活的桥梁。

这,就是在AI时代,前端工程师不可替代的价值所在。


本文由前端工程师撰写,给所有在AI时代思考自身价值的技术人。工具会进化,角色会演变,但人类在技术与体验之间所做的权衡与判断,始终是创造卓越数字产品的核心。

[Nuxt 4 实战] 用 Pinia + 持久化,让你的工具站不再“健忘”

作者 sonicsunsky
2026年2月3日 09:48

前言

我们在做工具类网站(比如 JSON 格式化、Base64 转换)时,经常遇到一个痛点:用户如果不小心刷新了页面,刚刚输入的一大段内容或者辛苦调整的配置项(比如“缩进2空格”、“深色模式”)就全丢了。

在开发我的个人项目 SonicToolLab 时,为了提升这种“无感”的用户体验,我使用了 Pinia 配合 持久化插件

今天分享在 Nuxt 4 中如何极其优雅地实现这一功能。

📦 1. 为什么是 Pinia?

在 Vue 3 和 Nuxt 4 时代,Vuex 已经成为历史。Pinia 不仅体积更小,而且对 TypeScript 的支持是原生的,写起来非常有安全感。

在 Nuxt 4 中使用 Pinia 甚至不需要手动 import,官方模块已经帮我们要好了所有配置。

安装

pnpm add @pinia/nuxt pinia

配置 nuxt.config.ts

TypeScript

export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt'
  ],
})

💾 2. 核心魔法:数据持久化 (Persistedstate)

光有 Pinia 只是存在内存里,刷新就没了。我们需要把 State 自动同步到 localStorage 中。这里推荐 pinia-plugin-persistedstate

安装插件

pnpm add @pinia-plugin-persistedstate/nuxt

启用配置

TypeScript

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt',
    '@pinia-plugin-persistedstate/nuxt' // 引入持久化模块
  ],
})

🛠️ 3. 实战代码:记录用户的“操作历史”

SonicToolLab 中,我希望记录用户最近使用的 5 个工具,方便他们下次直接点击。

看看代码有多简洁:

TypeScript

// stores/history.ts
export const useHistoryStore = defineStore('history', {
  state: () => ({
    recentTools: [] as string[]
  }),
  actions: {
    addRecord(toolName: string) {
      // 简单的去重逻辑,保持最近5个
      this.recentTools = this.recentTools.filter(t => t !== toolName)
      this.recentTools.unshift(toolName)
      if (this.recentTools.length > 5) this.recentTools.pop()
    }
  },
  // 🔥 关键一行:开启持久化
  persist: true
})

在页面中使用

Code snippet

<script setup>
const historyStore = useHistoryStore()

// 当用户进入某个工具页时
onMounted(() => {
  historyStore.addRecord('JSON 格式化')
})
</script>

<template>
  <div v-if="historyStore.recentTools.length">
    最近使用:
    <UBadge v-for="tool in historyStore.recentTools" :key="tool">
      {{ tool }}
    </UBadge>
  </div>
</template>

就这么简单!现在,无论用户怎么刷新页面,甚至关闭浏览器再打开,recentTools 里的数据依然存在。

⚠️ 4. 避坑指南:Hydration Mismatch

在 Nuxt (SSR) 中使用 localStorage 持久化,最容易遇到的问题是:服务端渲染时没有 localStorage,而客户端有,导致 HTML 不匹配。

@pinia-plugin-persistedstate/nuxt 默认处理得很好,但如果你发现控制台报 Hydration 错误,可以在组件外层包裹一个 <ClientOnly>,或者确保只在 onMounted 之后再渲染依赖 Store 的 UI。

总结

对于独立开发者来说,Pinia + 持久化插件 是提升工具站 UX(用户体验)性价比最高的组合。它不需要数据库,不需要后端接口,纯前端就能让你的网站显得“很智能”。

这一套逻辑目前稳定运行在我的 SonicToolLab 上,欢迎大家去体验一下“刷新页面数据不丢失”的效果。

下一篇,我们要聊聊工具站中 “如何优雅地处理文件上传与解析(纯前端 vs 服务端中转)” ,敬请期待!

封装了一个会 "Duang" 的果冻标签栏组件

作者 Michele
2026年2月3日 09:46

「好的动画是用户感觉不到存在的,但缺少它时一切都会显得生硬」

最近在工作中需要实现一个底部标签栏切换效果,参考了 掘金上的优秀实践,结合 Vue3 封装了一个具有「果冻弹性」的 Tab 组件。点击切换时,背景块会像果冻一样拉伸变形并丝滑移动,本文将详细拆解实现思路。

一、为什么需要「双层结构」?

这是最核心的设计思路。如果只用一层元素,我们会遇到一个技术矛盾:

  • 位移需要 transform: translateX() 来实现 GPU 加速
  • 形变需要 transform: scale() 来实现果冻拉伸
  • 冲突:CSS 的 transform 属性不能同时应用两个不同类型的变换(后设置的会覆盖前者)

💡 解决方案:分层职责

外层 indicator(只负责位移)
└── 内层 indicator-blob(只负责形变)
  • 外层:使用 transition 监听 transform 变化,实现丝滑平移
  • 内层:使用 animation 定义关键帧动画,实现弹性形变 这种"关注点分离"的模式,让两个动画互不干扰,同时运行。

二、核心代码解析

<template>
    <div class="nav-container">
        <!-- 果冻背景:外层移动,内层形变 -->
        <div class="indicator" ref="indicatorRef">
            <div class="indicator-blob" :style="styleCore"></div>
        </div>

        <!-- 标签项 -->
        <div v-for="(tab, index) in tabs"
            :key="index"
            class="icon-item"
            @click="moveIndicator(index)"
            :ref="el => {if(el)tabRefs[index] = el}">
            <span class="tab-text" :style="styleOuter">
                {{ tab }}
            </span>
        </div>
    </div>
</template>

注意:这里使用了 Vue3 的模板 ref 绑定方式,通过函数式绑定将每个 tab 的 DOM 引用存入数组。

2. 逻辑层:计算位移与触发动画

<script setup>
import { ref, onMounted, nextTick } from 'vue';
let resizeObserver;

const props = defineProps({
    modelValue: { type: Number, default: 0 }, // 支持 v-model
    tabs: { type: Array, default: () => [] }, // 源数组
    styleCore: { type: Object, default: () => ({}) }, // 内层果冻样式
    styleOuter: { type: Object, default: () => ({}) }, // 外层标签项样式
});

const emit = defineEmits(['update:modelValue', 'change']);

const indicatorRef = ref(null);
const tabRefs = ref([]);
const currentIndex = ref(props.modelValue);

// 核心移动逻辑
const moveIndicator = (index, silent = false) => {
    const indicator = indicatorRef.value;
    const targetTab = tabRefs.value[index];

    if (!indicator || !targetTab) return;

    // 计算几何信息
    const containerRect = indicator.parentElement.getBoundingClientRect();
    const tabRect = targetTab.getBoundingClientRect();

    // 计算相对于容器的偏移量
    const translateX = tabRect.left - containerRect.left;
    const width = tabRect.width;
    const height = tabRect.height;

    // 1. 设置外层位移和宽度
    indicator.style.width = `${width}px`;
    indicator.style.height = `${height}px`;
    indicator.style.transform = `translateX(${translateX}px)`;

    // 2. 触发内层果冻动画(关键技巧)
    triggerJellyAnimation(indicator);

    // 3. 更新状态
    if (!silent) {
        emit('change', index); // 只有非静默模式才触发
        emit('update:modelValue', index);
        currentIndex.value = index;
    }
};

// 触发动画的技巧:强制重绘
const triggerJellyAnimation = element => {
    const blob = element.querySelector('.indicator-blob');
    // 移除类 -> 强制回流 -> 添加类,这样才能重复触发动画
    blob.classList.remove('stretching');
    void blob.offsetWidth; // 魔法代码:强制浏览器重绘
    blob.classList.add('stretching');
};

// 防抖
const debounce = (func, delay) => {
    let timeoutId;
    return (...args) => {
        if (timeoutId) {
            clearTimeout(timeoutId);
        }
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
};

// 初始化位置
onMounted(() => {
    nextTick(() => {
        if (props.tabs.length > 0) {
            moveIndicator(props.modelValue, true);
        }
    });
    // 监听容器大小变化,自动重新定位
    resizeObserver = new ResizeObserver(
        debounce(() => {
            moveIndicator(currentIndex.value);
        }, 200)
    );
    resizeObserver.observe(indicatorRef.value.parentElement);
});

onUnmounted(() => {
    resizeObserver?.disconnect();
});
</script>

🔑 关键技术点:

  1. void blob.offsetWidth:这是实现重复触发动画的核心。浏览器优化机制会批量处理样式变更,通过读取元素的 offsetWidth 强制触发重排(Reflow),确保动画类被移除后重新添加能生效。
  2. nextTick:确保 DOM 渲染完成后再计算几何位置,避免获取到错误的 getBoundingClientRect 数据。

3. 样式层:CSS 动画与贝塞尔曲线

<style lang="scss" scoped>
.nav-container {
    position: relative;
    display: flex;
    padding: 8px;
    border-radius: 24px;
    gap: 8px;
}

/* 外层:GPU 加速的平滑位移 */
.indicator {
    position: absolute;
    top: 8px;
    left: 0;
    z-index: 1;
    pointer-events: none;

    /* 关键:使用 cubic-bezier 实现回弹效果(超越原位置再回弹) */
    transition:
        transform 0.5s cubic-bezier(0.68, -0.2, 0.265, 1.2),
        width 0.3s ease;

    will-change: transform, width; // 性能优化:提前告知浏览器
}

/* 内层:果冻形变动画主体 */
.indicator-blob {
    width: 100%;
    height: 100%;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

/* 果冻关键帧动画 */
.stretching {
    animation: jelly-anim 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

@keyframes jelly-anim {
    0% {
        transform: scale(1, 1);
    }
    30% {
        // 开始移动:水平拉伸,垂直压缩(像被扯开的果冻)
        transform: scale(1.25, 0.75);
    }
    50% {
        transform: scale(1.25, 0.75);
    }
    70% {
        // 即将停止:水平压缩,垂直膨胀(回弹挤压)
        transform: scale(0.85, 1.15);
    }
    100% {
        transform: scale(1, 1);
    }
}

/* Tab 项样式 */
.icon-item {
    padding: 12px;
    text-align: center;
    cursor: pointer;
    z-index: 2;
    position: relative;
    // user-select: none;

    .tab-text {
        font-size: 14px;
        font-weight: 600;
        transition: color 0.3s;
    }

    /* 激活状态的文字颜色(如果不使用 mix-blend-mode) */
    &.active .tab-text {
        color: #2c2c2c;
    }
}
</style>

🎨 CSS 技巧解读:

  1. Cubic Bezier 回弹曲线cubic-bezier(0.68, -0.2, 0.265, 1.2) 中的 y 值超出 0-1 范围(-0.2 和 1.2),实现了"过冲"效果——果冻块会稍微超过目标位置再回弹,这就是物理世界中的弹性表现。
  2. Will-Change 属性:提前告知浏览器哪些属性会变化,让浏览器提前创建合成层,避免动画开始时的卡顿。
  3. Mix-Blend-Mode(可选黑科技) :如果你将文字颜色设为白色并开启 mix-blend-mode: difference,当白色背景块移动到文字下方时,文字会自动变成黑色,无需 JS 干预颜色切换。

你真的懂 export default 吗?

2026年2月3日 09:34

我们每天都在写 importexport,这是 ES6 模块的基础。但你有没有想过,下面这两种写法,到底有什么区别?

// 写法一
export default myVariable;

// 写法二
export { myVariable as default };

看上去,它们好像做的是同一件事:都是默认导出一个叫做 myVariable 的变量。在很多情况下,它们确实可以互换。但是,在某些特殊场景下,它们的行为完全不同,甚至可能导致难以发现的 bug。

今天,我们就来聊聊这个 JavaScript 模块中的“冷知识”。

一、值拷贝 vs 实时映射

要理解它们的区别,首先要明白 ES6 模块的一个核心特性:模块导出的是“实时映射”(live binding),而不是“值拷贝”(value copy)。

这是什么意思呢?我们来看一个例子。

假设我们有一个 module.js 文件:

// module.js
export let count = 1;

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

然后,在另一个文件 main.js 中导入它:

// main.js
import { count } from './module.js';

console.log('导入时, count =', count); // 输出 1

setTimeout(() => {
  console.log('1.5秒后, count =', count); // 1.5秒后输出 2
}, 1500);

你会发现,main.js 中导入的 count 变量,在1.5秒后自动变成了 2。它就像一个“传送门”,实时反映了 module.js 内部 count 变量的变化。

这就是“实时映射”。import 进来的变量,并不是把原始值复制了一份,而是创建了一个指向原始变量的只读引用。你可以把它想象成一个快捷方式,它自己没有实体,但总是指向源文件。

二、export default 的“陷阱”

理解了“实时映射”之后,我们再回来看 export default

你可能会觉得,既然模块导出的是实时映射,那么 export default 也应该一样吧?

我们来试一下。把 module.js 改成这样:

// module.js
let count = 1;

export default count;

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

然后 main.js 也相应修改:

// main.js
import count from './module.js';

console.log('导入时, count =', count); // 输出 1

setTimeout(() => {
  console.log('1.5秒后, count =', count); // 1.5秒后输出 1
}, 1500);

奇怪的事情发生了!main.js 里的 count 变量,在1.5秒后依然是 1,没有像我们预期的那样变成 2。

这就是 export default 的第一个关键点:export default 后面跟一个变量时,它导出的是这个变量的“值”,而不是“实时映射”。

你可以这么理解,当 JavaScript 引擎看到 export default count; 这行代码时,它做的事情类似于:

  1. 在模块内部创建一个隐藏的、匿名的变量,比如叫 *default*
  2. count 变量当前的值(也就是 1)赋给这个 *default* 变量。
  3. 将这个 *default* 变量作为默认导出。

所以,之后 module.js 内部的 count 变量就算变成了 2,也跟那个已经导出的 *default* 变量没关系了。main.js 导入的,自始至终都是那个值为 1 的 *default*

三、如何用 export default 实现实时映射?

那么,问题来了。如果我就是想用 export default,又想实现“实时映射”的效果,该怎么办呢?

答案就是我们开头提到的第二种写法:

// module.js
let count = 1;

export { count as default }; // 注意这里的写法

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

当你把 main.js 里的 import 语句改成 import count from './module.js'; 再运行一次,你会发现,1.5秒后,它正确地输出了 2。

为什么会这样?

因为 export { ... } 这种语法,遵循的是标准的“实时映射”规则。export { count as default } 的意思是,将 count 这个变量的“实时映射”作为默认导出。它没有创建中间变量和值拷贝的过程。

四、一个特殊的例外

你以为这就完了吗?JavaScript 的世界里,总有那么一些“但是”。

export default 有一个特殊的例外:当它后面直接跟着 functionclass 声明时,它导出的也是“实时映射”。

// module.js
export default function sayHello() {
  console.log('Hello');
}

// 这等同于
function sayHello() {
  console.log('Hello');
}
export { sayHello as default };

这种情况下,export default function ... 是一种语法糖,它会先在模块内部声明这个函数,然后再将这个函数的“实时映射”作为默认导出。class 也是同理。

这个设计主要是为了方便和保持一致性,因为函数和类声明本身就具有“提升”(hoisting)的特性。

五、结论

现在,我们可以总结一下了。

导出的是实时映射:

export { myVar } —— 这是标准的命名导出,导入的变量会随原始变量的改变而改变。

export { myVar as default } —— 与命名导出行为一致,实现了默认导出的实时映射。

export default function() {}export default class {} —— 这两个是特殊情况,行为等同于先声明再默认导出,也是实时映射。

导出的是值拷贝:

export default myVar —— 导入的变量是导出那一刻的快照,之后不再改变。

那么,我们应该用哪一种呢?

  • 如果你想导出一个不会改变的常量、或者一个纯函数,export default myVar 是最简单直观的选择。 大多数情况下,我们导出的就是这类东西。

  • 如果你需要导出一个可能会在模块内部被重新赋值的变量,并且希望外部能感知到这个变化,那么就必须使用 export { myVar as default } 这种情况比较少见,但一旦遇到,这个知识点就至关重要。

这个小小的区别,体现了 JavaScript 语言设计的复杂性和历史原因。虽然有点绕,但理解了它,可以帮助我们写出更健壮、更可预测的代码,也能在遇到奇怪的 bug 时,多一个排查问题的思路。

颜色与渐变:hex、rgb、hsl、渐变、混合模式

作者 VixenAhri
2026年2月3日 09:34

CSS 颜色与渐变,绝对是我刚学前端时的“颜值瓶颈”——最开始只会抄设计稿上的hex色值,调个主题色hover效果,要反复改几十次色值才能勉强满意;写渐变要么过渡得生硬又突兀,要么角度写错导致整体错位,混合模式更是看都不敢看,总觉得太复杂。直到踩够了坑、慢慢摸透了每种用法才发现,hex、rgb、hsl各有各的方便,渐变随手写就能出高级感,混合模式用对了还能省很多事。今天就把我实打实的实操经验、踩过的坑,还有整理的干货分享给大家,新手跟着学,能少走很多弯路。

一、颜色表示法:按需选择,我常用这3种(避坑指南)

CSS 颜色表示法其实有一大堆,关键字、hex、rgb、hsl、oklch 等等,但我日常开发里,真正用得上的就3种——hex、rgb、hsl。oklch 虽然色域广、颜色更鲜艳,但兼容性还不够好,我目前项目里还没敢用;关键字也就偶尔用个transparent,其他的基本不碰。新手真不用贪多,把这3种核心用法吃透,就能应对99%的开发场景。

/* 关键字:简单但不灵活,仅用在透明、基础色,我很少用 */
color: red;
background: transparent; /* 常用,透明背景 */

/* hex:最常用,简洁好复制,6位、8位(含透明度)都常用 */
color: #333; /* 3位简写,等价于#333333,适合基础灰色系 */
color: #333333; /* 6位标准写法,精准度高,日常最常用 */
color: #3333; /* 4位简写 + 透明度,33是透明度,等价于#33333333 */
color: #33333380; /* 8位写法,最后两位80=50%透明度,我做半透明常用这个 */

/* rgb / rgba:直观控制红绿蓝,带透明度,新手易理解 */
color: rgb(51, 51, 51); /* 无透明,和#333一致 */
color: rgba(51, 51, 51, 0.5); /* 传统透明写法 */
color: rgb(51 51 51 / 0.5);  /* 新语法,空格分隔,更简洁,我现在优先用 */

/* hsl / hsla:色相、饱和度、明度,调色超方便,做主题色首选 */
color: hsl(0, 100%, 50%);    /* 红:色相0,饱和度100%,明度50% */
color: hsl(120, 100%, 50%);  /* 绿:色相120 */
color: hsl(240, 100%, 50%);  /* 蓝:色相240 */
color: hsl(0 0% 20% / 0.5);  /* 新语法 + 透明度,灰色50%透明 */

/* oklch:现代色空间,色域更广,颜色更鲜艳,但兼容性一般,我暂不用 */
color: oklch(0.6 0.2 250);

我的实操心得+踩坑吐槽:这几种表示法,我踩过不少冤枉路,后来总结了一套固定用法,再也没出过错,新手直接照搬就行:

  • 日常固定色(边框、基础文字色),首选hex:最简洁,设计稿给的色值基本都是hex,直接复制粘贴就行,不用额外转换,能省不少时间,也避免转换出错。
  • 需要半透明效果(遮罩、悬浮层、半透明背景),必用rgb新语法:比8位hex直观太多,0.5就是50%透明度,一眼就能看懂。我早期傻愣愣用8位hex,每次都要算透明度对应的十六进制值,经常算错,改来改去浪费时间。
  • 做主题色、hover变色、手动调色,一定要用hsl:这绝对是调色神器!改H(色相)就能切换颜色,比如从红色改成蓝色,不用重新找色值;改S(饱和度)调鲜艳程度,改L(明度)调明暗,比如按钮hover要变亮,只需要把L调高一点,比hex、rgb反复试色高效10倍。
  • 避坑点:4位hex简写(#3333)新手千万别乱⽤,只有最后两位是透明度,很容易和6位色值混淆,我早期就因为简写看错了,把半透明色写成了实色;还有rgb新语法,“/”后面别加空格,不然部分低版本浏览器会不兼容,踩过一次坑就记牢了。

二、线性渐变 linear-gradient:日常最常用,按钮/背景首选

线性渐变是我日常用得最多的渐变效果,没有之一!核心特别简单,就是“沿某个方向,从一种颜色过渡到另一种颜色”,按钮、卡片背景、页面装饰,用它都能轻松做出高级感。新手只要记住“方向+色标”这两个关键点,就能快速上手,我早期踩的坑,基本都集中在方向写错、色标位置没设对这两点上。

/* 基础用法:方向 + 色标(最少两种颜色) */
background: linear-gradient(to right, red, blue); /* 从左到右,红渐变到蓝 */
background: linear-gradient(90deg, red, blue); /* 用角度表示方向,90deg=to right,更精准 */
background: linear-gradient(to bottom right, red, blue); /* 从左上到右下,对角线渐变 */

/* 多个色标:实现多颜色过渡,更丰富 */
background: linear-gradient(to right, red, yellow 50%, blue); /* 红→黄(中间位置)→蓝 */

/* 色标位置:手动控制过渡节点,避免过渡生硬,我做精致渐变必用 */
background: linear-gradient(to right, red 0%, blue 50%, green 100%); /* 0%、50%、100% 是过渡位置 */

/* 实际场景1:按钮渐变(我项目里常用的写法) */
.btn-primary {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 135deg角度,更有立体感 */
  border: none;
  color: white;
  padding: 8px 20px;
  border-radius: 4px;
}

/* 实际场景2:条纹背景(高频需求,我早期写错过background-size) */
background: linear-gradient(
  90deg,
  #333 0%,
  #333 50%,
  #666 50%,
  #666 100%
);
background-size: 20px 100%; /* 关键:控制条纹宽度,20px是条纹总宽,漏写就没有条纹效果 */

高频坑点(全是我踩过的冤枉路) :1. 方向混淆:to right是从左到右,to left是从右到左,我早期曾把to right写成to left,渐变方向完全反了,页面整体错乱,排查了半天才发现是这个小问题;2. 条纹背景漏写background-size:我第一次写条纹,只写了渐变代码,没写background-size,结果页面显示的是单一渐变,没有任何条纹效果,还以为是代码写错了,折腾了好久;3. 色标位置不写:默认是均匀过渡,想要条纹那种生硬分界,必须给相邻色标设相同位置(比如50%、50%),不然过渡会很柔和,达不到想要的效果。

三、径向渐变 radial-gradient:做光晕、圆形背景超合适

径向渐变和线性渐变不一样,它是“从中心点向外扩散”的,自带层次感,特别适合做圆形背景、卡片hover光晕、头像边框这些点缀性的效果,能悄悄提升页面质感。它的核心也简单,就是控制“形状+圆心位置+色标”,不用写复杂代码,简单调一调就能出效果。

/* 基础用法:圆形,从中心向外渐变(默认椭圆,circle设为圆形) */
background: radial-gradient(circle, red, blue); /* 圆形,红→蓝 */
background: radial-gradient(ellipse at center, red, blue); /* 椭圆(默认),中心点 */

/* 指定圆心位置:精准控制渐变扩散起点,我做局部光晕常用 */
background: radial-gradient(circle at 20% 80%, yellow, red); /* 圆心在左侧下方(20%宽度,80%高度) */

/* 色标控制:和线性渐变一致,可手动设位置,实现多颜色扩散 */
background: radial-gradient(circle, red 0%, blue 50%, green 100%); /* 红(中心)→蓝→绿(边缘) */

我的实操小技巧:径向渐变不用搞太复杂,简单的圆形渐变+半透明色,就能做出很好的效果。比如卡片hover时的光晕,我就用radial-gradient(circle, rgba(102, 126, 234, 0.3), transparent),再配合background-size调一下范围,就能做出柔和又自然的光晕,比单纯用box-shadow质感好太多。

四、锥形渐变 conic-gradient:色轮、饼图专属

锥形渐变我早期接触得很少,一直觉得它没用,直到做数据可视化、需要写简单饼图时才发现,它简直是“神器”!它和径向渐变的“向外扩散”完全不同,是“围绕中心点旋转”的环形过渡,最适合做色轮、饼图、加载动画,不用写复杂的定位和嵌套,几行代码就能搞定。

/* 基础用法:色轮效果(多颜色环形过渡) */
background: conic-gradient(red, yellow, green, blue, red); /* 从红开始,环形过渡,最后回到红 */

/* 从指定角度开始渐变:控制渐变的起始位置 */
background: conic-gradient(from 90deg, red, blue); /* 从90度位置开始,红→蓝 */

/* 实际场景:饼图(最常用,精准控制每个扇形角度) */
background: conic-gradient(red 0deg 90deg, blue 90deg 180deg, green 180deg 360deg); /* 红(0-90度)、蓝(90-180度)、绿(180-360度) */

避坑提醒(血的教训) :锥形渐变的角度的是“环形角度”,0deg在右侧,顺时针递增,和线性渐变的角度逻辑完全不一样!我第一次做饼图,就按线性渐变的角度逻辑来写,结果扇形位置全错了,排查了好久才找到问题;另外,做饼图时,相邻色标的结束角度和开始角度一定要衔接好(比如90deg接90deg),不然两个扇形之间会出现缝隙,特别丑。

五、重复渐变:条纹、网格等重复效果,不用写多余标签

重复渐变真的太省代码了!它的核心就是“把渐变效果重复排列”,不用写多个div嵌套,就能做出条纹、网格、点阵这些重复样式,我做表格条纹、页面背景网格时,基本都用它,比手动写多个div方便太多。重点就是控制好“渐变长度+重复间距”,就能做出想要的效果。

/* 重复线性渐变:常用做水平/垂直条纹,我做表格条纹必用 */
background: repeating-linear-gradient(
  90deg, /* 方向:水平条纹 */
  #333,
  #333 10px, /* 第一个条纹:#333,宽度10px */
  #666 10px, /* 衔接位置,和上一个结束位置一致,避免缝隙 */
  #666 20px /* 第二个条纹:#666,宽度10px,总重复宽度20px */
);

/* 重复径向渐变:常用做圆形点阵、环形重复效果 */
background: repeating-radial-gradient(circle, red, red 10px, blue 10px, blue 20px); /* 圆形重复,红→蓝,每个重复单元20px */

我的实操小技巧:重复渐变的关键,就是“相邻色标的位置要衔接好”,比如前一个颜色在10px结束,下一个颜色就从10px开始,这样两个渐变之间才不会出现缝隙;另外,重复单元的总宽度(比如20px),决定了条纹的密集程度,宽度越小,条纹越密,根据设计稿灵活调整就好,不用死记硬背。

六、混合模式 mix-blend-mode:高级叠加效果,提升页面质感

混合模式我早期一直不敢碰,总觉得它太复杂,怕用错了把页面颜色搞乱,直到后来做Banner图、需要文字和背景叠加时才发现,它能做出特别高级的效果,而且没那么难。核心就是“让当前元素,和它下方的内容产生叠加反应”,比如正片叠底、滤色,选对模式,就能轻松做出高级感,不用写多余的样式。

/* 元素混合:让当前元素(叠加层)与下方元素叠加 */
.overlay { /* 叠加层,比如遮罩、文字 */
  mix-blend-mode: multiply;   /* 正片叠底:常用做图片暗化遮罩,我最常用 */
  mix-blend-mode: screen;    /* 滤色:常用做图片提亮,适合浅色叠加 */
  mix-blend-mode: overlay;   /* 叠加:保留底层细节,同时叠加上层颜色,高级感强 */
  mix-blend-mode: difference; /* 差值:颜色对比强烈,适合特殊效果,少用 */
}

/* 背景混合:让元素的背景(比如渐变+图片)内部叠加 */
background-blend-mode: multiply; /* 背景内部叠加 */
background: linear-gradient(red, blue), url(bg.jpg); /* 渐变与背景图叠加,配合上面的混合模式 */

避坑提醒(我踩过的蠢坑) :1. 混合模式必须“下方有内容”才能生效!我第一次用mix-blend-mode,给一个单独的遮罩层加了这个属性,结果一点效果都没有,折腾了半天,才发现遮罩层下方是空的,没有可叠加的内容;2. 别滥用混合模式!它会改变元素原本的颜色呈现,普通页面尽量不用,只在Banner、详情页这种需要高级效果的地方用,不然很容易导致颜色错乱,影响页面可读性。

七、color-mix:颜色混合新语法,主题色变体超方便

color-mix 绝对是CSS新增语法里,我最爱的一个!它能快速混合两种颜色,不用手动计算色值,省了大量试色的时间,尤其适合做主题色变体——比如按钮hover变亮、禁用色、浅色系背景,用它来写,高效又精准,比手动调hex、hsl方便太多,我现在做主题色相关的样式,全靠它。

/* 基础用法:混合两个颜色,指定混合比例 */
color: color-mix(in srgb, red 50%, blue); /* 在srgb色空间,红50% + 蓝50%,混合出紫色 */
background: color-mix(in hsl, #333 80%, white); /* 深灰80% + 白20%,混合出浅灰色 */

/* 实际场景:按钮hover变亮(我项目里的常用写法) */
.btn {
  --primary: #667eea; /* 主题色变量 */
  background: var(--primary);
}
.btn:hover {
  /* 主题色85% + 白15%,实现hover变亮,不用重新找色值 */
  background: color-mix(in srgb, var(--primary) 85%, white);
}

我的实操心得:color-mix 不用记复杂语法,核心就是“指定色空间+混合比例”。建议优先用in srgb,兼容性更好;混合比例也不用写两个,只写一个就行,另一个会自动补全(比如红50%,蓝就默认是50%)。做主题色变体时,混合白色就能变亮,混合黑色就能变暗,比手动调hsl的明度更精准,而且全站主题色能保持统一,不会出现色差。

八、我的实际示例(直接套用,少踩坑)

结合我日常开发的高频场景,整理了4个实操写法,新手可以直接复制套用,全是我项目里常用的,避开了所有我踩过的坑,不用再自己瞎琢磨:

/* 场景1:卡片渐变边框(高级感拉满,不用额外写边框标签) */
.card {
  background: white;
  border-radius: 8px;
  position: relative;
  padding: 24px; /* 给内部留空间,避免内容贴边框 */
}
.card::before {
  content: "";
  position: absolute;
  inset: 0; /* 全屏覆盖父元素 */
  padding: 1px; /* 边框宽度,关键 */
  border-radius: inherit; /* 继承父元素圆角,避免圆角错位 */
  background: linear-gradient(135deg, #667eea, #764ba2); /* 渐变边框颜色 */
  /* 关键:实现只显示边框,不覆盖内部背景 */
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask-composite: exclude;
}

/* 场景2:文字渐变(标题常用,高级不生硬) */
.gradient-text {
  background: linear-gradient(90deg, #667eea, #764ba2); /* 渐变颜色 */
  -webkit-background-clip: text; /* 把背景裁剪到文字上,webkit前缀兼容移动端 */
  -webkit-text-fill-color: transparent; /* 文字颜色透明,显示背景渐变 */
  background-clip: text; /* 标准写法 */
  font-size: 2rem;
  font-weight: 600;
}

/* 场景3:简单饼图(数据可视化常用,不用复杂组件) */
.pie-chart {
  width: 100px;
  height: 100px;
  border-radius: 50%; /* 圆形容器 */
  background: conic-gradient(red 0deg 90deg, blue 90deg 180deg, green 180deg 360deg);
}

/* 场景4:带hover效果的渐变按钮(日常高频) */
.btn-primary {
  --primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  background: var(--primary);
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}
.btn-primary:hover {
  /* 混合主题色和白色,实现hover变亮,同时保留渐变 */
  background: color-mix(in srgb, var(--primary) 90%, white);
  transform: translateY(-2px); /* 轻微上浮,提升交互感 */
}

九、个人总结:颜色与渐变避坑核心(新手必看)

  1. 颜色表示法:hex 日常凑活用、rgb 专门做透明、hsl 负责调色,oklch 暂不考虑,新手聚焦这3种,足够应对所有场景,不用贪多求全;

  2. 线性渐变:方向+色标是核心,按钮、背景首选它,写条纹千万别漏写background-size,不然等于白写;

  3. 径向渐变:做圆形、光晕的神器,控制好圆心位置,就能悄悄提升页面层次感,不用搞复杂样式;

  4. 锥形渐变:色轮、饼图专属,记住它的角度是环形角度,相邻色标衔接好,避免出现缝隙;

  5. 重复渐变:相邻色标位置衔接好,能省很多嵌套代码,做条纹、网格超方便;

  6. 混合模式:按需选用,正片叠底、叠加最常用,记住“下方要有内容”,不然没用,别滥用;

  7. color-mix:主题色变体神器,混合白色/黑色实现亮暗变化,不用手动试色,高效又精准。

其实颜色与渐变真的不难,我最开始也是只会抄hex、写简单线性渐变,踩了无数坑,才慢慢熟练起来。核心就是“多试、多总结”,不用一开始就追求复杂效果,先掌握基础用法,再逐步尝试高级效果,慢慢就能做出有质感的页面。配合前3篇讲的盒模型、单位知识,CSS布局+美化,就能轻松拿捏。

Antdv Next v1.0 发布:一次“为 Vue 重建 Ant Design v6”的工程实验

2026年2月3日 09:33

image.png

经历了多次技术迭代,也经历了无数次“要不要继续”的心理斗争,我们终于完成了基于 Ant Design v6 设计体系 的 Vue 组件库重构 —— Antdv Next

致谢

这个项目从一开始就不是一个人的战斗。

在开发过程中,我们也无数次怀疑过: 这件事到底有没有意义?值不值得继续?能不能真的走到最后?

是大家在工作之余持续投入的时间、精力和信任,让这个“看起来不太可能完成”的想法,一点点变成了现实。

最终我们真的把它做完了,也证明了这条路是走得通的,所以我把致谢放到了最前面,希望大家看到这是我们共同的成果。

感谢你们的坚持、耐心和陪伴,没有你们,就不会有今天的 Antdv Next。

同样我也要感谢在这个过程中给我们提交问题、建议和 PR 的社区朋友们!


为什么要做 Antdv Next?

很早开始接触 Ant Design,就被它完整、严谨、系统化的设计语言深深吸引。 不管是视觉规范、组件一致性,还是 Design Token 的工程化落地,它始终是国内最具代表性的设计系统之一。

因此,在 Vue 项目中,我们也长期优先选择 Ant Design Vue

但现实是残酷的:

  • 维护节奏逐渐放缓
  • PR 合并周期拉长
  • Issue 长期悬而未决

与此同时,Ant Design 官方开始推进 v6 架构,全面引入 Design Token、CSS 变量、运行时样式系统。这让我们第一次清晰地看到:

UI 框架的“下一代形态”,已经和过去完全不同。

于是,一个念头开始发酵:

与其等一个慢慢老去的分支,不如重新站在源头之上。

最终,我们决定: 基于 Ant Design v6 设计体系,从零重建一套真正面向 Vue 的组件库。

这就是 Antdv Next 的起点。


我们做的不是“Antdv 的下一个版本”

这是一个必须讲清楚的事实:

Antdv Next 并不是 Ant Design Vue 的升级版。

我们是直接以 Ant Design v6(React)作为源设计体系,再结合 Vue 的使用范式重新实现。

这意味着:

  • API 不会 100% 对齐 Antdv
  • 设计哲学来自 Ant Design
  • 工程实现为 Vue 原生模式

这也是为什么我们提供了明确的迁移文档,但同时也不建议已经稳定运行的项目盲目迁移


设计调整:让 API 更像 Vue

1. 去 React 味

在 Ant Design 中,大量 API 带有明显的 React 语义,例如:

classNames
rootClassName

在 Antdv Next 中,我们统一调整为:

  • classNamesclasses
  • rootClassNamerootClass

这是一次“语义层”的去 React 化: 不是为了区别,而是为了让 Vue 用户读起来更自然。


2. 插槽优先,而不是 children 模型

React 使用 children 作为内容承载模型。 Vue 有插槽,这是语言级的能力。

因此在 Antdv Next 中:

  • 插槽是第一公民
  • props 渲染仍然支持(兼容 JSX / TSX 用户)

并且,我们统一了一套渲染语义:

语义 插槽名
标签渲染 labelRender
内容渲染 contentRender
扩展区域 extraRender
图标渲染 iconRender
... ...Render

这套命名将成为整个库的长期规范。


3. 单一上下文参数模型

在 React 中常见这样的渲染函数:

(originNode, extra) => ReactNode

在 Antdv Next 中,我们统一为:

(ctx: {
  originNode: VueNode
  extra: {
    OkBtn: VNode
    CancelBtn: VNode
  }
}) => VNode

这样做的好处是:

  • 无参数顺序依赖
  • 语义更清晰
  • 更适配 Vue 插槽上下文模型

样式系统:CSS-in-JS + 零运行时

我们同步了 Ant Design v6 的 cssinjs 架构,并做了两层优化:

  1. 零运行时模式(zeroRuntime)

    • 提供静态 antd.css
    • 运行时只更新 CSS 变量
    • 不再全量替换 <style>
  2. 非零运行时

    • 按需注入样式
    • 切换主题仅更新变量
    • 避免整棵样式树重建

目标只有一个: 性能不再因为主题系统而成为瓶颈。


表格与表单的工程取舍

表格

  • 兼容 bodyCell / headerCell
  • 支持虚拟滚动
  • 默认浅响应式模型

如果你在表格中使用:

v-model="record.xxx"

这类写法需要迁移,因为内部是浅响应式代理。

这是一次为了性能与一致性做出的取舍。


表单

  • 移除 a-form-rest
  • 兼容 Antdv 原有用法
  • API 尽量保持稳定

AI 支持:让文档真正“可被机器理解”

我们不仅支持:

  • llm.txt
  • llm.full.txt

还支持 Agent Skills 模型

pnpx skills add antdv-next/skills

配合 antfu/skills 使用,可以:

  • 按需加载组件知识
  • 避免一次性消耗大量 token
  • 减少 AI 幻觉

这是我们对“AI 友好型文档系统”的一次工程实验。


基础生态

后续计划

后续我们最主要的工作重点:

  1. 继续优化支持一些在vue下更合理的使用方式;
  2. 支持nuxt;
  3. 继续优化项目文档。

这是我们目前的一些计划,如果您有新的想法,也可以在github上给我们提交Issue和建议


写在最后

Antdv Next v1.0 并不意味着完成,而是我们终于敢对外说:它可以被使用了。

如果你在使用过程中遇到问题,请通过 Issue 反馈。 如果你愿意参与共建,请提交 PR。

这是一个长期工程,不属于某个人,它属于社区,如果后续真的发生了我因为某些原因无法继续维护的时候,

我寻找在我们团队成员中寻找下一位接任者,把所有权限都放给他,做到无缝交接,希望这个项目能真正的持续下去。


项目地址

如果你愿意,点一颗 ⭐,感谢您的支持!😊

❌
❌