阅读视图

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

Claude CLI 从安装到使用完整教程(Windows 版)

Claude Code 是 Anthropic 官方推出的命令行 AI 编程助手,可以直接在终端中与 Claude 对话,帮助你完成代码编写、调试、重构等任务。本教程带你在 Windows 系统上从零开始,快速上手 Claude CLI。


一、环境准备

在安装 Claude CLI 之前,需要确保你的系统满足以下条件:

  • Node.js:版本 18 及以上
  • npm:包管理工具(随 Node.js 一同安装)
  • 操作系统:Windows 10 / Windows 11
  • 终端:推荐使用 PowerShell 5.1+Windows Terminal
  • 网络:能正常访问 Anthropic API,如果有自备的公益API也行,例如anyrouter

安装 Node.js

前往 nodejs.org 下载最新 LTS 版本的 Windows 安装包(.msi),按向导完成安装,安装时勾选"自动安装必要工具"选项。

安装完成后,打开 PowerShell 验证版本:

node --version
# 输出示例:v24.14.0

npm --version
# 输出示例:11.9.0

01-flowchart-install-flow.png

二、安装 Claude CLI

打开 PowerShell(以管理员身份运行),执行全局安装命令:

npm install -g @anthropic-ai/claude-code

提示:如果安装时报权限错误,请右键点击 PowerShell 选择"以管理员身份运行"后重试。

安装完成后,验证安装是否成功:

claude --version

更新到最新版本

npm update -g @anthropic-ai/claude-code

解决 PATH 问题

如果提示 claude 不是可识别的命令,需要将 npm 全局包路径加入系统 PATH:

# 查看 npm 全局包安装路径
npm config get prefix

将输出的路径(如 C:\Users\你的用户名\AppData\Roaming\npm)添加到系统环境变量 PATH 中:

  1. 右键"此电脑" → "属性" → "高级系统设置" → "环境变量"
  2. 在"用户变量"中找到 Path,点击"编辑"
  3. 点击"新建",粘贴上述路径
  4. 确定保存后,重新打开 PowerShell 使其生效

三、配置 API Key

Claude CLI 需要 Anthropic API Key 才能正常工作。

获取 API Key

  1. 访问 console.anthropic.com
  2. 注册或登录账号
  3. 进入 API Keys 页面
  4. 点击 Create Key 生成新的 API Key

设置环境变量(PowerShell)

临时设置(仅当前会话有效):

$env:ANTHROPIC_API_KEY = "sk-ant-xxxxxxxxxxxxxxxx"

永久设置(推荐):

方法一:通过 PowerShell 命令写入用户环境变量:

[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", "sk-ant-xxxxxxxxxxxxxxxx", "User")

方法二:通过系统界面设置:

  1. 右键"此电脑" → "属性" → "高级系统设置" → "环境变量"
  2. 在"用户变量"区域点击"新建"
  3. 变量名填写 ANTHROPIC_API_KEY,变量值填写你的 API Key
  4. 确定保存,重新打开 PowerShell 使其生效

验证环境变量已生效:

echo $env:ANTHROPIC_API_KEY

四、基础使用

启动交互模式

在项目目录下打开 PowerShell,运行 claude,进入交互式对话:

cd C:\Users\你的用户名\projects\your-project
claude

进入后,你会看到提示符,可以直接输入问题或指令。

单次查询模式

使用 -p 参数执行一次性查询:

claude -p "解释这个项目的目录结构"

处理文件

让 Claude 分析或修改特定文件:

claude -p "审查 src/index.js 中的代码,找出潜在的 bug"

五、核心功能演示

代码生成

> 用 Python 写一个读取 CSV 文件并计算每列平均值的脚本

代码解释

> 解释 @src/utils/parser.ts 这个文件的作用

代码重构

> 重构 @components/Button.jsx,将 class 组件改为函数组件,并添加 TypeScript 类型

调试协助

> 我的应用报错:TypeError: Cannot read property 'map' of undefined,帮我分析原因

生成测试

02-infographic-features-overview.png

> 为 @src/api/user.js 中的所有函数生成单元测试

六、常用命令与快捷键

操作 命令 / 快捷键
启动 Claude claude
单次查询 claude -p "问题"
查看帮助 claude --help
清空对话 /clear
退出 /exitCtrl+C
查看历史 /history

常用 Slash 命令

  • /help — 查看所有可用命令
  • /clear — 清除当前对话上下文
  • /compact — 压缩对话历史以节省 Token
  • /model — 切换使用的模型
  • /cost — 查看本次会话的 Token 消耗

七、项目上下文管理

CLAUDE.md 文件

在项目根目录创建 CLAUDE.md 文件,Claude 会自动读取其中的内容作为项目背景:

# 项目说明

这是一个 React + TypeScript 的电商平台前端项目。

## 技术栈
- React 18
- TypeScript 5
- Tailwind CSS
- Zustand 状态管理

## 编码规范
- 使用函数组件和 Hooks
- 组件文件使用 PascalCase 命名
- 工具函数使用 camelCase 命名

在 PowerShell 中快速创建该文件:

New-Item -Path "CLAUDE.md" -ItemType File
notepad CLAUDE.md

引用文件

在对话中使用 @文件路径 引用具体文件:

> 参考 @src/components/Card.tsx 的风格,创建一个新的 Modal 组件

八、高级配置

配置文件

Claude CLI 支持通过配置文件自定义行为,配置文件位于:

  • 全局C:\Users\你的用户名\.claude\settings.json
  • 项目.claude\settings.json

在 PowerShell 中查看全局配置目录:

explorer "$env:USERPROFILE\.claude"

示例配置:

{
  "model": "claude-opus-4-6",
  "theme": "dark",
  "autoApprove": false
}

权限模式

Claude CLI 有三种权限模式:

  • 默认模式:每次文件修改都需要用户确认
  • 自动批准--dangerously-skip-permissions 自动执行所有操作(谨慎使用)
  • 只读模式:仅读取文件,不做修改

九、最佳实践

  1. 使用 Windows Terminal:比默认 PowerShell 窗口体验更好,支持多标签和彩色输出
  2. 始终在项目根目录启动:这样 Claude 能更好地理解项目结构
  3. 编写详细的 CLAUDE.md:提供技术栈、编码规范等背景信息
  4. 明确描述需求:越具体的指令,生成的结果越准确
  5. 分步执行复杂任务:将大任务拆分为小步骤,逐一确认
  6. 定期使用 /compact:长对话时压缩上下文,避免 Token 超限

十、常见问题

Q: 提示 claude 不是内部或外部命令

检查 npm 全局包路径是否在 PATH 中,参考"安装 Claude CLI"章节中的 PATH 配置步骤。

# 查看 npm 全局包路径
npm config get prefix

Q: PowerShell 提示脚本执行被禁止?

以管理员身份运行 PowerShell,执行以下命令允许本地脚本运行:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Q: API 请求失败,返回 401 错误?

检查 ANTHROPIC_API_KEY 环境变量是否正确设置,或 Key 是否已过期:

echo $env:ANTHROPIC_API_KEY

Q: 响应速度慢?

可以切换到更快的模型,如 claude-haiku-4-5

/model claude-haiku-4-5-20251001

总结

Claude CLI 是一个强大的 AI 编程助手,将 Claude 的能力无缝集成到你的开发工作流中。通过本教程,你已经掌握了在 Windows 上从安装配置到日常使用的完整流程。建议在实际项目中多加练习,充分利用 Claude 的能力提升开发效率。

更多文档请参考官方文档:docs.anthropic.com/claude-code

如果你喜欢本教程,记得点赞+收藏!关注我获取更多AI相关干货

速通Canvas指北🦮——基础入门篇

引言

本文缘起自笔者开发一个基于 PIXI.js 的在线动画编辑器时,想系统学习 Canvas 相关知识,却发现缺少合适的中文入门资料,于是萌生了撰写这份“速通指北”的想法,欢迎感兴趣的朋友订阅我的 《Canvas 指北》专栏。

参考内容:

第1章:Canvas 简介

在这一章中,我们将一起探索 Canvas 是什么,为什么我们需要它,以及如何在 HTML 中使用 <canvas> 元素。如果你是 Web 图形开发的新手,不用担心——我们会从最基础的概念开始,逐步带你进入 Canvas 的精彩世界。

1.1 为什么需要 canvas

在 HTML5 出现之前,网页上的图形大多是静态的——图片、CSS 绘制的简单形状,或者是通过 Flash、SVG 等技术实现的动态效果。这些方法要么功能有限,要么需要额外的依赖,难以与现代 Web 应用的动态需求匹配。

Canvas(画布)是 HTML5 引入的一个革命性特性。它是一个可以通过 JavaScript 脚本在网页上绘制图形的“画板”。与传统的 DOM 元素不同,Canvas 不保存绘制的图形对象,主要通过绘图指令即时渲染,必要时也可进行像素级处理,这使得它非常适合以下场景:

  • 动态数据可视化:比如绘制实时更新的图表、股票走势图、数据仪表盘。
  • 游戏开发:Canvas 的高性能使其成为 2D 小游戏(如贪吃蛇、飞机大战)的常用渲染方案。
  • 图像处理:可以直接在浏览器中实现滤镜、颜色调整、图像合成等功能。
  • 动画与特效:可以创建粒子系统、炫酷的背景动画、交互式艺术创作。
  • 绘图应用:比如在线白板、签名板、简单的画图工具。

简单来说,Canvas 让你能用 JavaScript 在网页上“画画”,而且画出来的内容是动态的、可交互的,并且性能非常出色。它让网页从“展示信息”进化到了“创造视觉体验”的新阶段。

1.2 <canvas> 元素

Canvas 的使用分为两步:首先在 HTML 中放置一个 <canvas> 元素,然后通过 JavaScript 获取它的绘图上下文(context)并进行绘制。

1.2.1 HTML 中的 <canvas>标签

<canvas> 是一个行内元素(inline),但通常我们把它当作块级元素来设置宽高,实践中常设置 display: block;,可避免与文本基线对齐导致的底部空隙问题。它的基本语法如下:

<canvas id="myCanvas" width="400" height="300">
  您的浏览器不支持 Canvas,请升级或更换浏览器。
</canvas>
  • id:用于在 JavaScript 中定位该元素。
  • width 和 height:指定画布的实际像素尺寸(不是 CSS 宽高)。默认值为 300×150 像素。
  • 标签内的文本:当浏览器不支持 Canvas 时,会显示这段后备内容(fallback content)。

注意:尽量不要用 CSS 的 width 和 height 来修改画布的像素尺寸。如果通过 CSS 设置宽高,画布会按原始像素被拉伸,导致图形模糊。正确的做法是在 <canvas> 标签中直接指定 width 和 height 属性,或者通过 JavaScript 动态设置。

如果为了适配高 DPI 屏幕,通常会用 JavaScript 将 canvas 的像素尺寸设为 CSS 尺寸乘以 devicePixelRatio

1.2.2 获取绘图上下文

在 JavaScript 中,我们通过 <canvas> 元素的 getContext() 方法获取绘图上下文。上下文定义了所有绘图方法和属性。目前最常用的是 2D 上下文:

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

除了 '2d',还有 'webgl'(用于 3D 绘图)和 'webgl2' 等上下文类型。在本教程中,我们将专注于 2D 上下文。

1.2.3 第一个绘制示例

获得了 ctx 对象后,我们就可以调用各种绘图方法了。下面是一个简单的例子:在画布上绘制一个红色的矩形。

<!doctype html>
<html>
  <canvas
    id="myCanvas"
    width="200"
    height="100"
    style="border: 1px solid black"
  >
  </canvas>

  <script>
    const canvas = document.getElementById('myCanvas');
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = 'red';
    ctx.fillRect(0, 0, 150, 75);
  </script>
</html>

这段代码会在画布的 (0,0) 位置开始,绘制一个宽 150 像素、高 75 像素的红色矩形:

1.3 Canvas 坐标系

Canvas 的坐标系原点 (0,0) 位于画布左上角,Y 轴正方向是向下的(与常见的数学坐标系 Y 轴向上相反)。也就是说,y 值越大,图形的位置越靠下。

第2章:线条

本章带你快速掌握 Canvas 中最基础的图形——线条。我们将学习如何绘制直线,以及如何控制线条的粗细、端点样式、连接样式和虚线效果。

2.1 绘制线条

绘制线条需要掌握三个核心方法:

  • moveTo(x, y):将画笔移动到指定坐标(不绘制任何东西)
  • lineTo(x, y):从当前位置绘制一条直线到指定坐标(仅定义路径,不实际描边)
  • stroke():对当前定义的路径进行描边

此外,还需要通过 strokeStyle 设置线条颜色(默认黑色)。

画一条从 (50,30) 到 (250,120) 的红色直线:

<canvas id="lineCanvas" width="300" height="150"></canvas>
<script>
  const canvas = document.getElementById('lineCanvas');
  const ctx = canvas.getContext('2d');

  ctx.strokeStyle = 'red';   // 设置线条颜色
  ctx.moveTo(50, 30);        // 起点 (50,30)
  ctx.lineTo(250, 120);      // 终点 (250,120)
  ctx.stroke();              // 描边绘制
</script>

注意: 以上代码未使用 beginPath(),仅适用于简单场景。如果后续需要绘制多条独立的线,必须调用 ctx.beginPath() 重置路径,否则之前绘制的线会被重复描边。我们将在第4章“路径”中详细介绍。

2.2 线条样式

2.2.1 线宽:lineWidth

设置线条的粗细(像素)。默认值为 1.0。

线宽越大,线条越粗。效果对比:

<canvas id="widthCanvas" width="300" height="120"></canvas>
<script>
  const canvas = document.getElementById('widthCanvas');
  const ctx = canvas.getContext('2d');

  // 线宽 1
  ctx.beginPath();
  ctx.lineWidth = 1;
  ctx.moveTo(20, 20);
  ctx.lineTo(280, 20);
  ctx.stroke();

  // 线宽 5
  ctx.beginPath();
  ctx.lineWidth = 5;
  ctx.moveTo(20, 50);
  ctx.lineTo(280, 50);
  ctx.stroke();

  // 线宽 10
  ctx.beginPath();
  ctx.lineWidth = 10;
  ctx.moveTo(20, 80);
  ctx.lineTo(280, 80);
  ctx.stroke();
</script>

2.2.2 端点样式:lineCap

  • "butt"(默认):平直端点,不超出线段端点
  • "round":圆形端点,半圆直径等于线宽
  • "square":方形端点,超出线段端点一半线宽

画三条相同起止点的线段,分别设置不同 lineCap。效果对比:

<canvas id="capCanvas" width="320" height="120"></canvas>
<script>
  const canvas = document.getElementById('capCanvas');
  const ctx = canvas.getContext('2d');
  ctx.lineWidth = 10;
  ctx.strokeStyle = 'blue';

  // butt (默认)
  ctx.beginPath();
  ctx.lineCap = 'butt';
  ctx.moveTo(40, 30);
  ctx.lineTo(140, 30);
  ctx.stroke();

  // round
  ctx.beginPath();
  ctx.lineCap = 'round';
  ctx.moveTo(40, 60);
  ctx.lineTo(140, 60);
  ctx.stroke();

  // square
  ctx.beginPath();
  ctx.lineCap = 'square';
  ctx.moveTo(40, 90);
  ctx.lineTo(140, 90);
  ctx.stroke();
</script>

image.png

2.2.3 连接样式:lineJoin

定义两条线相交处的拐角形状。可选值:

  • "miter"(默认):尖角(通过 miterLimit 限制尖锐程度)
  • "round":圆角
  • "bevel":平角(切去尖角)

绘制两条折线,分别应用不同连接样式。效果对比

<canvas id="joinCanvas" width="320" height="200"></canvas>
<script>
  const canvas = document.getElementById('joinCanvas');
  const ctx = canvas.getContext('2d');
  ctx.lineWidth = 15;

  // miter (默认)
  ctx.beginPath();
  ctx.lineJoin = 'miter';
  ctx.moveTo(30, 30);
  ctx.lineTo(80, 80);
  ctx.lineTo(130, 30);
  ctx.stroke();

  // round
  ctx.beginPath();
  ctx.lineJoin = 'round';
  ctx.moveTo(160, 30);
  ctx.lineTo(210, 80);
  ctx.lineTo(260, 30);
  ctx.stroke();

  // bevel
  ctx.beginPath();
  ctx.lineJoin = 'bevel';
  ctx.moveTo(30, 100);
  ctx.lineTo(80, 150);
  ctx.lineTo(130, 100);
  ctx.stroke();
</script>

2.2.4 尖角限制:miterLimit

lineJoin = "miter" 时,该属性限制尖角的长度。尖角长度是指内角顶点到外角顶点的距离。如果尖角长度超过 miterLimit 与线宽的乘积,则连接样式会回退为 bevel。默认值为 10.0

保持同一折线,用不同 miterLimit 值观察尖角变化。效果对比

<canvas id="miterCanvas" width="320" height="200"></canvas>
<script>
  const canvas = document.getElementById('miterCanvas');
  const ctx = canvas.getContext('2d');
  ctx.lineWidth = 10;
  ctx.strokeStyle = 'green';
  ctx.lineJoin = 'miter'; // 必须为 miter 才生效

  // miterLimit = 1(较小,尖角被截断为平角)
  ctx.beginPath();
  ctx.miterLimit = 1;
  ctx.moveTo(30, 30);
  ctx.lineTo(80, 90);
  ctx.lineTo(130, 30);
  ctx.stroke();

  // miterLimit = 5(中等,仍为尖角)
  ctx.beginPath();
  ctx.miterLimit = 5;
  ctx.moveTo(160, 30);
  ctx.lineTo(210, 90);
  ctx.lineTo(260, 30);
  ctx.stroke();

  // miterLimit = 10(默认,通常保持尖角)
  ctx.beginPath();
  ctx.miterLimit = 10;
  ctx.moveTo(30, 100);
  ctx.lineTo(80, 160);
  ctx.lineTo(130, 100);
  ctx.stroke();
</script>

image.png

2.2.5 虚线模式:setLineDash()

通过数组指定虚线的线段与间隙长度(像素)。数组元素依次为线段长、间隙长、线段长、间隙长……重复循环,若要恢复实线,可传入空数组 ctx.setLineDash([])

绘制多条虚线,展示不同数组模式。效果对比

<canvas id="dashCanvas" width="320" height="140"></canvas>
<script>
  const canvas = document.getElementById('dashCanvas');
  const ctx = canvas.getContext('2d');
  ctx.lineWidth = 3;

  // 实线
  ctx.beginPath();
  ctx.setLineDash([]);
  ctx.moveTo(20, 20);
  ctx.lineTo(300, 20);
  ctx.stroke();

  // 虚线 [10, 5] (10画,5空)
  ctx.beginPath();
  ctx.setLineDash([10, 5]);
  ctx.moveTo(20, 50);
  ctx.lineTo(300, 50);
  ctx.stroke();

  // 点线 [2, 4] (2画,4空)
  ctx.beginPath();
  ctx.setLineDash([2, 4]);
  ctx.moveTo(20, 80);
  ctx.lineTo(300, 80);
  ctx.stroke();

  // 点划线 [15, 5, 2, 5] (长画,短空,点,短空)
  ctx.beginPath();
  ctx.setLineDash([15, 5, 2, 5]);
  ctx.moveTo(20, 110);
  ctx.lineTo(300, 110);
  ctx.stroke();
</script>

2.2.6 虚线偏移:lineDashOffset

lineDashOffset 属性用于设置虚线模式的起始偏移量(像素),会改变虚线图案在路径上的起点位置。连续更新该值(递增或递减)可以实现“流动虚线”动画效果。

效果对比:绘制三条相同的虚线,分别设置不同偏移量。

<canvas id="dashOffsetCanvas" width="320" height="120"></canvas>
<script>
  const canvas = document.getElementById('dashOffsetCanvas');
  const ctx = canvas.getContext('2d');
  ctx.lineWidth = 4;
  ctx.strokeStyle = 'purple';
  ctx.setLineDash([10, 5]); // 统一虚线模式

  // 偏移 0 (默认)
  ctx.beginPath();
  ctx.lineDashOffset = 0;
  ctx.moveTo(20, 20);
  ctx.lineTo(300, 20);
  ctx.stroke();

  // 偏移 5
  ctx.beginPath();
  ctx.lineDashOffset = 5;
  ctx.moveTo(20, 50);
  ctx.lineTo(300, 50);
  ctx.stroke();

  // 偏移 -8
  ctx.beginPath();
  ctx.lineDashOffset = -8;
  ctx.moveTo(20, 80);
  ctx.lineTo(300, 80);
  ctx.stroke();
</script>

🚀 下篇预告:形状与路径篇

在下一篇中,你将学到:

  • 如何绘制矩形、多边形、圆形、弧线等基本形状;
  • 路径的深入用法,包括 beginPathclosePath 的正确姿势;
  • 填充与描边的区别,以及 nonzeroevenodd 两种填充规则的原理与效果;
  • arcarcTo 的详细对比与适用场景。

掌握了这些,你就能组合路径创造出复杂图形,为后续的样式与动画打下坚实基础。

又被 Safari 差异坑了:textContent 拿到的值居然没换行?

作为前端开发,我们习惯了用 textContent 来获取纯文本。毕竟它性能好、不触发重排(Reflow),是 W3C 标准的亲儿子。但今天,我在处理一个自动换行的多行文本时,被 Safari 给上了一课。

起因:消失的换行符

需求很简单:用户在一个高度自适应的 <div> 中输入或展示一段文字,我需要抓取这段文字存入数据库。

为了追求性能,我习惯性地写下了:

const content = document.querySelector('.text-box').textContent;

ChromeFirefox 下,一切完美。由于 CSS 设置了 white-space: pre-wrap;,我拿到的字符串里自带优雅的 \n

然而,测试同学拿着 Safari 跑过来:“为什么存进去的数据全挤成一团了?”


破案:textContent 并不“所见即所得”

经过一番排查,我发现了这个隐藏极深的坑点:

1. textContent 的“冷酷”

textContent 获取的是 DOM 树中所有文本节点的原始数据。它根本不在乎你的 CSS 长什么样。

  • 如果你的 HTML 源码里是一行,即便 CSS 用 word-breakwhite-space 让它在视觉上换了行,textContent 拿到的依然是硬邦邦的一行

2. Safari 的“严格”

在某些特定版本的 Safari 渲染引擎中,它对 textContent 的实现非常遵循“源码原始性”。如果文本是因为容器宽度挤压产生的软换行(Soft Wrap),Safari 的 textContent 绝对不会帮你补上那个 \n


解决方案:innerText 才是救星

这时候,那个曾经被嫌弃“性能略差”的 innerText 站了出来。

为什么这次要用 innerText?

  • 感知 CSS 样式innerText 是受 CSS 影响的。它会触发一次布局计算,获取用户肉眼看到的文本形态。
  • 自动转换换行:如果你的元素里有 <br>,或者因为 white-space: pre-wrap 产生了视觉换行,innerText 会非常贴心地在对应位置插入 \n

代码修正:

// ❌ 坑点代码(Safari 下可能丢失换行)
// const text = el.textContent;

// ✅ 避坑代码(所见即所得,保留视觉换行)
const text = el.innerText;


总结:避坑指南

这次折腾让我记住了两点:

  1. 如果你要的是“源码里的字”:选 textContent,它快且稳,不理会 CSS。
  2. 如果你要的是“屏幕上的字”:特别是涉及**换行、空格、大小写转换(text-transform)**时,请务必果断使用 innerText

前端无小事,永远不要低估 Safari 的“独特性”。 以后看到文本抓取需求,还是先老老实实测一下兼容性吧!


📝 避坑速查表

场景 推荐属性 原因
高性能纯文本提取 textContent 不触发重排
保留 <br> 换行 innerText 会将标签转为 \n
处理隐藏元素 textContent innerText 拿不到隐藏文本
获取视觉换行文本 innerText 解决 Safari 差异的关键

React 19 对比 React 16 新特性解析

AICoding快速做了一个React19和React16的特性对比网站

react19.fufanghao.space

🎉 React 19 正式发布了!这次更新不是换了个壳,而是真的在帮你少写代码。本文通过和 React 16 的对比,用简单的比喻带你搞懂每个新特性,看完保证你立刻想升级。


前言:React 19 升级了什么?

如果把 React 16 比作一辆手动挡汽车,那 React 19 就是自动挡——你还是在开同一辆车,但省掉了很多繁琐操作,开起来更顺畅了。

本文覆盖 React 19 的 9 个核心特性,每个都配有新旧代码对比,一看就懂。


一、use() Hook:读数据再也不用"三件套"了

🤔 以前怎么做

以前从接口拿数据,必须写 useState + useEffect + 手动判断 loading/error 的三件套,就像每次去超市买东西,都要先写购物清单、排队领号、再等叫号——步骤缺一不可。

// React 16:必须手动管理所有状态
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <div>{user.name}</div>;
}

光是"显示一个用户名",就要写将近 20 行。

✅ React 19 怎么做

React 19 的 use() Hook 就像给了你一个**"魔法读取器"**——把 Promise 塞进去,直接拿到结果,加载中的状态交给 Suspense 来管。

// React 19:直接读取,爽!
import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  const user = use(userPromise); // 就这一行!
  return <div>{user.name}</div>;
}

function App() {
  const userPromise = fetchUser(1);
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

省了多少? 20 行 → 2 行核心逻辑,loading 和 error 状态完全不用自己管。

💡 一句话记住use() 就像奶茶店的自助取餐柜,你下单(传入 Promise),等好了直接取(读到数据),等待过程(Suspense)自动帮你排队。


二、Server Actions:表单提交不用再写 API 了

🤔 以前怎么做

以前写一个表单提交,需要:

  1. 前端写 handleSubmit 函数
  2. 手写 fetch 请求
  3. 后端单独写一个 /api/submit 接口
  4. 还要自己管 loading 状态

就像你给朋友发快递,要先打电话、再填单子、再去前台、再等确认——中间每步都要自己操作。

// React 16:前后端需要分别维护
function ContactForm() {
  const [status, setStatus] = useState('idle');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('submitting');
    const formData = new FormData(e.target);
    
    try {
      await fetch('/api/submit', {  // 还得单独建 API 路由
        method: 'POST',
        body: JSON.stringify({ name: formData.get('name') }),
        headers: { 'Content-Type': 'application/json' }
      });
      setStatus('success');
    } catch {
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button disabled={status === 'submitting'}>提交</button>
    </form>
  );
}

✅ React 19 怎么做

React 19 的 Server Actions 让你直接在组件里定义服务端函数,form 的 action 属性直接传进去就行了。

// React 19:函数加个标记,直接用
async function submitForm(formData) {
  'use server'; // 一行声明,这个函数在服务器跑
  const name = formData.get('name');
  await db.users.create({ name });
}

function ContactForm() {
  return (
    <form action={submitForm}> {/* 直接传函数! */}
      <input name="name" />
      <button type="submit">提交</button>
    </form>
  );
}

省了多少? 不用写 /api/submit 接口,不用手写 fetch,不用管状态。

💡 一句话记住:Server Actions 就像微信支付——你只管扫码确认(调用函数),钱从哪儿走、怎么到账,后台自动帮你搞定。


三、useFormStatus:按钮终于知道表单在忙了

🤔 以前怎么做

你有一个「提交按钮」组件,它想知道父级表单是否正在提交中,以便显示 loading 状态。在 React 16 里,你必须把状态从父组件一层层传下去(prop drilling),或者创建一个 Context——就像老板开会要知道项目进度,得一个部门一个部门打电话问。

// React 16:状态传递好麻烦
const FormContext = createContext({ pending: false });

function SubmitButton() {
  const { pending } = useContext(FormContext); // 要专门建一个 Context
  return (
    <button disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function MyForm() {
  const [pending, setPending] = useState(false);
  // 还得自己管状态...
  return (
    <FormContext.Provider value={{ pending }}>
      <form onSubmit={...}>
        <SubmitButton />
      </form>
    </FormContext.Provider>
  );
}

✅ React 19 怎么做

useFormStatus 让子组件天生就能感知父表单状态,不需要任何传递。

// React 19:自动感知,什么都不用传
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus(); // 自动知道父表单状态!
  return (
    <button disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function MyForm() {
  return (
    <form action={serverAction}>
      <input name="email" />
      <SubmitButton /> {/* 直接用,不传任何 props */}
    </form>
  );
}

💡 一句话记住useFormStatus 就像广播电台——表单"开播"了(提交中),收音机(按钮)自动就知道,不需要有人专门去通知。


四、useOptimistic:点了就立刻显示,不用等服务器

🤔 以前怎么做

做一个点赞功能,点击后要等服务器响应才更新 UI,用户会感觉"卡"。想做成"立刻显示"的效果,就得自己写一套乐观更新逻辑:

  • 先临时更新 UI
  • 等接口成功后替换真实数据
  • 接口失败了还得手动回滚

就像你发了一条朋友圈,要等服务器确认才能看到——而现实中微信是先让你看到,有问题再悄悄处理。

// React 16:手动管理乐观更新,容易出 bug
function TodoList({ todos: initialTodos, addTodo }) {
  const [todos, setTodos] = useState(initialTodos);

  const handleAdd = async (text) => {
    const tempId = Date.now();
    // 先临时显示
    setTodos(prev => [...prev, { id: tempId, text, pending: true }]);
    
    try {
      const newTodo = await addTodo(text);
      // 成功:替换临时项
      setTodos(prev => prev.map(t => t.id === tempId ? newTodo : t));
    } catch {
      // 失败:手动回滚
      setTodos(prev => prev.filter(t => t.id !== tempId));
    }
  };
  // ...
}

✅ React 19 怎么做

useOptimistic 把这一套逻辑内置了,你只需要描述"乐观的样子",其他的 React 帮你处理。

// React 19:两行搞定乐观更新
import { useOptimistic } from 'react';

function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, pending: true }]
  );

  const handleAdd = async (text) => {
    addOptimisticTodo({ id: Date.now(), text }); // 立即显示
    await addTodo(text); // 实际提交(失败会自动回滚)
  };

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

💡 一句话记住useOptimistic 就像外卖 App 的"预计送达"——你一下单就显示送货中,真正到了再更新状态,失败了自动消失,你完全不用管中间过程。


五、ref 直接当 prop 传:告别 forwardRef

🤔 以前怎么做

你有一个自定义 <Input> 组件,想从外部拿到它的 DOM 节点(比如让它聚焦)。在 React 16 里,直接传 ref 是不行的,必须用 forwardRef 包一层——就像你想给朋友一个礼物,但必须先装进一个特定的礼品盒才能交出去。

// React 16:必须加 forwardRef 包装
import { forwardRef } from 'react';

const Input = forwardRef(function Input(props, ref) {
  return <input ref={ref} {...props} />;
});

// 使用者看不出来,但写组件的人知道有多麻烦

✅ React 19 怎么做

React 19 里,ref 就是个普通 prop,直接收、直接用。

// React 19:ref 就是普通 prop
function Input({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

// 调用方直接传,简洁!
function App() {
  const inputRef = useRef(null);
  return (
    <div>
      <Input ref={inputRef} placeholder="点我聚焦" />
      <button onClick={() => inputRef.current.focus()}>聚焦</button>
    </div>
  );
}

💡 一句话记住:以前 ref 是"特殊乘客",必须走专用通道(forwardRef)。React 19 让它跟普通乘客一样,随便哪个门进都行。


六、Document Metadata:管 SEO 再也不用装插件了

🤔 以前怎么做

以前想在组件里动态修改页面标题、meta 标签,必须装 react-helmet 或者用框架提供的 <Head> 组件——就像想挂一幅画,必须先找物业批准、填申请表才行。

// React 16:需要第三方库
import { Helmet } from 'react-helmet';

function BlogPost({ post }) {
  return (
    <article>
      <Helmet>
        <title>{post.title} | My Blog</title>
        <meta name="description" content={post.excerpt} />
      </Helmet>
      <h1>{post.title}</h1>
    </article>
  );
}

✅ React 19 怎么做

直接在组件里写 <title><meta>,React 会自动把它们放到 <head> 里。

// React 19:原生支持,直接写!
function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title} | My Blog</title>  {/* 直接写! */}
      <meta name="description" content={post.excerpt} />
      <h1>{post.title}</h1>
    </article>
  );
}

💡 一句话记住:以前写 meta 标签像去银行开户需要各种证明,现在 React 19 给你开了绿色通道,直接走,不用审批。


七、Asset 预加载 API:用 JS 控制资源加载

🤔 以前怎么做

想预加载字体、脚本,要么在 HTML 里手动加 <link rel="preload">,要么通过 react-helmet 动态插入——资源管理分散在 HTML 和 JS 两处,难以维护。

// React 16:在 HTML 或 Helmet 里手动加
<Helmet>
  <link rel="dns-prefetch" href="https://cdn.example.com" />
  <link rel="preload" href="/fonts/inter.woff2" as="font" />
</Helmet>

✅ React 19 怎么做

React 19 提供了 preloadprefetchDNSpreinit 等 API,直接在 JS 里调用:

// React 19:在代码里统一管理
import { prefetchDNS, preload, preinit } from 'react-dom';

function App() {
  prefetchDNS('https://cdn.example.com');
  preload('/fonts/inter.woff2', { as: 'font' });
  preinit('/scripts/analytics.js', { as: 'script' });
  
  return <main>...</main>;
}

💡 一句话记住:以前资源预加载写在 HTML 里,就像把工作任务写在便利贴上贴墙上——不好管。现在全部写进代码,统一放在"任务清单"里。


八、增强错误处理:报错信息更清晰,错误边界更好用

🤔 以前怎么做

React 16 里,错误边界只能用类组件实现,写起来又臭又长,而且不同来源的错误(被捕获 vs 未被捕获)很难区分。

// React 16:必须写类组件
class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    logErrorToService(error, errorInfo.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>出错了</h1>;
    }
    return this.props.children;
  }
}

就算在 2024 年,你还是得为了错误边界专门写一个类组件。

✅ React 19 怎么做

React 19 在 createRoot 上提供了三种错误回调,可以精细区分错误类型:

// React 19:在根节点统一配置错误处理
const root = createRoot(document.getElementById('root'), {
  onCaughtError(error, errorInfo) {
    // 被错误边界捕获的错误
    logError(error, errorInfo.componentStack);
  },
  onUncaughtError(error, errorInfo) {
    // 未被捕获的错误
    showErrorDialog(error);
  },
  onRecoverableError(error) {
    // 可自动恢复的错误(如 hydration 不一致)
    console.warn(error);
  }
});

💡 一句话记住:React 16 的错误处理像保安——只有一个岗亭,什么人都拦在一起。React 19 装了分类闸机:普通人走这边、VIP 走那边、问题人员走那边,各司其职。


九、Context 简化:.Provider 消失了

🤔 以前怎么做

用 Context 传数据,必须写 <ThemeContext.Provider> 这种带 .Provider 的写法,感觉像买了个手机还必须套个指定品牌的壳才能用。

// React 16:必须写 .Provider
<ThemeContext.Provider value="dark">
  <App />
</ThemeContext.Provider>

✅ React 19 怎么做

直接用 Context 本身作为组件:

// React 19:直接用,省掉 .Provider
<ThemeContext value="dark">
  <App />
</ThemeContext>

而且读取时还可以用更灵活的 use() 替代 useContext(),最大的区别是 use() 可以在 if 语句里调用useContext 不行):

// React 19:use() 可以在条件中使用
function Page({ isLoggedIn }) {
  if (!isLoggedIn) return <Login />;
  const theme = use(ThemeContext); // ✅ 在 if 之后调用没问题
  return <div className={theme}>...</div>;
}

💡 一句话记住:以前用 Context 像开车必须带驾照(.Provider),React 19 直接把驾照内置到车里了——上去就能开。


总结对比表

特性 React 16 React 19 节省了什么
异步数据获取 useState + useEffect 三件套 use() + Suspense 减少 ~70% 样板代码
表单提交 手写 fetch + API 路由 Server Actions 消除客户端/服务端分离
表单状态共享 Context 或 prop drilling useFormStatus 零传参自动感知
乐观更新 手写临时状态 + 回滚逻辑 useOptimistic 自动管理,消除 bug
ref 转发 必须 forwardRef 包装 直接当 prop 传 告别包装地狱
SEO 元数据 react-helmet 等第三方库 原生 <title>/<meta> 零依赖
资源预加载 HTML 手写 / Helmet preload/preinit API JS 统一管理
错误处理 类组件错误边界 createRoot 回调 + 细分类型 告别类组件
Context 使用 <Context.Provider> <Context> 直接用 语法更简洁

该不该升级?

✅ 推荐升级的场景:

  • 新项目:直接用 React 19,享受所有新特性
  • 用了 Next.js 14+:Server Actions 已经是主推方案
  • 有大量表单交互的项目:useFormStatus + useOptimistic 省代码很明显

⚠️ 谨慎升级的场景:

  • 大型存量项目:Server Actions 依赖特定框架(Next.js/Remix),不是纯前端能用的
  • 严格依赖类组件的老项目:需要逐步迁移
  • 团队还没准备好:use() 的心智模型和以前不一样

结语

React 19 最大的变化不是增加了多少功能,而是帮你删掉了多少样板代码。每个新 API 背后都是 React 团队在说:"这个事儿你不用管了,交给我们。"

从 16 到 19,变化的不只是版本号,而是写 React 的姿势。


如果这篇文章对你有帮助,欢迎点赞收藏 👍 有问题欢迎评论区交流~

如何为 AI 编码代理配置 Next.js 项目

原文:How to set up your Next.js project for AI coding agents

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

最后更新:2026 年 2 月 27 日

Next.js 在 next 包内置了与版本精确匹配的文档,使 AI 编码代理可以引用准确、最新的 API 和实践模式。你只需在项目根目录放置一个 AGENTS.md,就能把代理从“训练语料记忆”引导到这份本地文档。

工作原理

安装 next 后,Next.js 文档会被打包到 node_modules/next/dist/docs/。其目录结构与 Next.js 官方文档站 保持一致:

node_modules/next/dist/docs/
├── 01-app/
│   ├── 01-getting-started/
│   ├── 02-guides/
│   └── 03-api-reference/
├── 02-pages/
├── 03-architecture/
└── index.mdx

这意味着:代理始终能读取与你本地安装版本一致的文档,不需要网络请求,也不依赖外部检索。

项目根目录下的 AGENTS.md 会明确要求代理在写代码前先阅读这些文档。包括 Claude Code、Cursor、GitHub Copilot 在内的多数编码代理,会在会话启动时自动读取 AGENTS.md

快速开始

新项目

create-next-app 会自动生成 AGENTS.mdCLAUDE.md,无需额外配置:

pnpm create next-app@canary

如果你不希望生成代理配置文件,可传入 --no-agents-md

npx create-next-app@canary --no-agents-md

既有项目

确保 Next.js 版本为 v16.2.0-canary.37 或更高,然后在项目根目录新增下列文件。

AGENTS.md(代理会读取的规则):

<!-- BEGIN:nextjs-agent-rules -->

# Next.js: ALWAYS read docs before coding

Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.

<!-- END:nextjs-agent-rules -->

CLAUDE.md(通过 @ 引入 AGENTS.md,避免重复维护):

@AGENTS.md

理解 AGENTS.md

默认的 AGENTS.md 只有一条核心规则:写代码前先读内置文档。这个设计刻意保持最小化,目标是把代理从过时训练数据重定向到 node_modules/next/dist/docs/ 中版本匹配的官方文档。

<!-- BEGIN:nextjs-agent-rules --><!-- END:nextjs-agent-rules --> 定义了 Next.js 托管区块。你可以在这个区块外添加项目私有规则,后续升级时不会被覆盖。

内置文档包含 App Router 与 Pages Router 的指南、API 参考与文件约定。当代理遇到路由、数据获取或其他 Next.js 任务时,应优先查阅本地文档,而不是依赖可能过时的训练记忆。

补充:**想看这套机制在真实任务中的效果,可参考 benchmark results

下一步

Next.js MCP Server

继续阅读 Next.js MCP 支持文档,让编码代理可以访问你的应用运行时状态与上下文。

你的项目真的需要SSR吗?还是只是你的简历需要?

技术选型不是为了简历好看,是为了解决问题

上周和一个前同事约饭,他现在在一家创业公司带前端团队。

聊到一半他突然问我:“我们准备把项目重构成 Next.js,上服务端渲染,你觉得怎么样?”

我放下筷子:“你们现在遇到什么坑了要重构?”

他挠挠头:“坑倒是没有……就是现在出去面试,人家都问有没有 Next.js 经验,我们不用会不会显得技术栈太旧了。”

“那你们产品要 SEO 吗?用户网络环境咋样?”

“用户都在一二线城市,网速挺好的。SEO 的话……产品得登录才能用,搜索引擎也爬不到。”

我看着他,不知道该说什么。

又一个被技术流行绑架的老同事。


一、SSR不是银弹,它是一把双刃剑

这两年,Next.js、Nuxt.js确实火得不行。

打开技术社区,满屏都是“从CSR迁移到SSR后,FCP提升50%”、“SSR才是前端正确的打开方式”。

但你有没有发现,很少有人告诉你:为了这点性能提升,你和你团队接下来半年要填多少坑。

1. 服务器账单:以前不要钱,现在要钱了

纯静态页面放OSS上,流量不大时一年可能就几百块。

上了SSR呢?

  • 你需要一台服务器,或者云函数实例
  • 你需要考虑并发,一台扛不住要上负载均衡
  • 你还要担心服务器宕机,得配监控、告警、容灾

我一个朋友的项目,上了SSR后第一个月,云账单从300涨到了3000。老板拿着账单问他:“用户感受到变快了吗?多出来的钱能从收入里赚回来吗?”

他答不上来。

更扎心的是,后来发现大部分用户都是从首页跳详情页,首屏优化的收益根本覆盖不了成本

2. 复杂度转移:以前是纯前端问题,现在是全栈问题

CSR项目,前端只管写页面,接口调不通那是后端的事。

SSR项目呢?

// 以前写页面,岁月静好
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(setUser)
  }, [userId])
  
  return <div>{user?.name}</div>
}
// 上了SSR之后,噩梦开始
export async function getServerSideProps({ params }) {
  try {
    // 要处理接口超时
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 3000)
    
    const res = await fetch(`http://internal-api/user/${params.id}`, {
      signal: controller.signal
    }).catch(() => null)
    
    clearTimeout(timeoutId)
    
    if (!res || !res.ok) {
      // 降级策略怎么写?
      return { props: { user: null, fallback: true } }
    }
    
    const user = await res.json()
    return { props: { user } }
  } catch (error) {
    // 服务端报错,用户看到什么?
    return { notFound: true }
  }
}

function UserProfile({ user, fallback }) {
  // 客户端还要再校验一遍状态
  const [mounted, setMounted] = useState(false)
  
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return <div>加载中...</div> // 防止hydrate报错
  }
  
  return <div>{user?.name}</div>
}

数据在服务端取,取不到怎么办?页面直接500还是降级成CSR? 服务端取的接口超时了,是要等待还是超时返回? 服务端和客户端的状态怎么对齐?一不小心就 hydrate 报错

我一个同事转做Next.js项目后发朋友圈:“自从上了SSR,我不仅要写React,还要会配Nginx、懂PM2、会分析内存泄漏。工资没涨,责任翻倍。”

底下点赞的全是前端。

3. 开发体验的割裂感

以前写CSR,window、document随便用,反正都在浏览器里。

写了SSR之后:

// 以前一行代码
const width = window.innerWidth
const token = localStorage.getItem('token')
const height = document.getElementById('app').offsetHeight
// 现在
const width = typeof window !== 'undefined' ? window.innerWidth : 1024
const token = typeof window !== 'undefined' 
  ? localStorage.getItem('token') 
  : null
const [height, setHeight] = useState(0)

useEffect(() => {
  setHeight(document.getElementById('app')?.offsetHeight || 0)
}, [])
  • 第三方库如果不兼容服务端渲染,要动态导入
  • localStorage、sessionStorage都不能直接用
  • 路由跳转要小心,服务端没有history API

原本简单的逻辑,现在要写一堆防御代码。你在给代码做安检,但业务逻辑一点没变复杂。


二、你真的需要SSR吗?问自己三个扎心的问题

每次有人问我该不该上SSR,我都让他先回答三个问题。回答完,80%的人自己就放弃了。

问题一:你的产品靠搜索引擎吃饭吗?

这是最硬性的指标,也是最容易被拿来当借口的。

如果你的产品是:

  • 内容型网站(博客、新闻、官网)
  • 电商网站(需要被搜索引擎收录商品页)

那SSR确实有必要。因为爬虫可能不执行JS,或者执行不完整。

但如果你的产品是:

  • 需要登录的后台管理系统
  • 工具类、游戏类H5
  • B端SaaS应用
  • 社区类App的H5版(用户得先登录)

搜索引擎根本爬不到,SEO就是伪需求。

别拿SEO当借口,你只是想让简历里多一行Next.js。

问题二:你的首屏速度真的慢到不能忍了吗?

很多时候,我们觉得首屏慢,其实不是因为CSR不行,是代码写得太糙。

我见过一个“慢”的项目,分析下来:

// 问题代码示例
function App() {
  const [data, setData] = useState(null)
  const [user, setUser] = useState(null)
  const [config, setConfig] = useState(null)
  
  useEffect(() => {
    // 串行调用,一个等一个
    fetch('/api/data').then(res => res.json()).then(data => {
      setData(data)
      return fetch('/api/user')
    }).then(res => res.json()).then(user => {
      setUser(user)
      return fetch('/api/config')
    }).then(res => res.json()).then(setConfig)
    
    // 图片没处理
    new Image().src = 'https://example.com/big-banner.png'
    
    // 第三方脚本同步加载
    const script = document.createElement('script')
    script.src = 'https://analytics.com/sdk.js'
    document.head.appendChild(script)
  }, [])
  
  return <div>...</div>
}

这些问题,优化代码比换架构性价比高得多

我去年优化过一个Vue2项目,纯CSR,首屏从3.2秒优化到1.1秒,只做了四件事:

// 优化后
useEffect(() => {
  // 1. 并行调用
  Promise.all([
    fetch('/api/data'),
    fetch('/api/user'),
    fetch('/api/config')
  ]).then(...)
  
  // 2. 图片转WebP + 懒加载
  // 3. 第三方脚本异步
  const script = document.createElement('script')
  script.async = true
  script.src = 'https://analytics.com/sdk.js'
  
  // 4. 路由懒加载
  const List = lazy(() => import('./pages/List'))
}, [])

没动架构,没重构,没加班。

问题三:你的团队准备好了吗?

这是最容易被忽略的,也是上线后最痛苦的。

上SSR意味着你的前端团队要开始写服务端代码:

  • 有人写过Node.js吗?
  • 有人配过Nginx吗?
  • 有人处理过内存泄漏吗?
# 线上出问题了,你能处理吗?
curl -X POST https://your-site.com/api/user -H "Content-Type: application/json" -d '{"id":123}'

# 如果返回 502
# 是Node进程挂了?Nginx配置错了?接口超时了?

# 登录服务器
ssh user@your-server
pm2 logs
df -h # 磁盘满了?
free -m # 内存泄漏?
top # CPU爆了?
  • 线上出问题了,有人能在凌晨两点爬起来回滚吗?

如果答案都是“没有”,那SSR上线的那天,就是团队噩梦的开始。

技术选型不仅要看技术好不好,还要看团队接不接得住。


三、那些比SSR更香的选择

如果你确实有性能痛点,但又不是非要SSR,其实有很多折中方案。

方案一:静态站点生成(SSG)

如果你的内容是静态的,或者更新不频繁,SSG是完美的选择。

// next.config.js
module.exports = {
  // 构建时生成HTML
  exportPathMap: async function() {
    return {
      '/': { page: '/' },
      '/about': { page: '/about' },
      '/blog/1': { page: '/blog/[id]', query: { id: '1' } },
    }
  }
}
  • 构建时生成HTML,部署在CDN上
  • 首屏速度极快,SEO友好
  • 没有服务器成本,没有运维负担

Next.js、Nuxt.js、VitePress都支持。既有了SSR的首屏优势,又保留了CSR的简单部署。

方案二:静态部署 + 客户端渲染

大部分场景,这才是最优解。

// index.html
<!DOCTYPE html>
<html>
<head>
  <!-- 骨架屏,让用户看到东西 -->
  <style>
    .skeleton { background: #f0f0f0; height: 20px; margin: 10px; }
  </style>
</head>
<body>
  <div id="root">
    <div class="skeleton"></div>
    <div class="skeleton"></div>
    <div class="skeleton"></div>
  </div>
  
  <!-- 资源放CDN,并行加载 -->
  <link rel="preconnect" href="https://api.example.com">
  <script src="https://cdn.example.com/react.js" async></script>
  <script src="https://cdn.example.com/app.js" async></script>
</body>
</html>
  • HTML放CDN,全球加速
  • 接口走API网关,BFF层做聚合
  • 配合预加载、懒加载、骨架屏,体验一点都不差

我现在的项目就是这种方案:React + Vite,打包后放OSS,CDN加速。首屏1.2秒,月PV百万,服务器成本主要是BFF层的几个云函数,加起来不到500块。

够用就好,别为了炫技给自己挖坑。

方案三:部分页面SSR,大部分页面CSR

如果你确实有少数页面需要SEO(比如官网、 landing page),其他页面需要登录。

// next.config.js
module.exports = {
  // 只有这些页面走SSR
  pageExtensions: ['ssr.js', 'page.js'],
  
  async rewrites() {
    return [
      // landing page走SSR
      {
        source: '/',
        destination: '/landing.ssr',
      },
      // 其他页面走静态CSR
      {
        source: '/app/:path*',
        destination: '/app.html',
      }
    ]
  }
}

那可以只把这几个页面抽出来做SSR,剩下的保持CSR。

Next.js支持多页应用模式,Vue也有混合渲染方案。没必要为了10%的页面,让90%的页面承担复杂度。


四、写在最后:技术选型不是为了简历好看

写这篇文章,不是为了否定SSR。

SSR是好技术,Next.js是好框架。我在合适的项目里也用它,确实能解决问题。

但我不喜欢一种风气:明明是个简单的H5活动页,非要上Next.js;明明是个后台管理系统,非要搞服务端渲染;明明首屏已经1.5秒了,非要重构到1.2秒。

为了那0.5秒的优化,搭进去团队半年的维护成本,值吗?

技术选型的标准,不是“别人都用”,也不是“大厂在用”,而是:

  • 能解决我们现在的痛点吗?
  • 团队能驾驭吗?
  • 维护成本扛得住吗?
  • 换来的收益对得起付出的代价吗?

别让技术选型,变成一场给简历镀金的表演。


最后想问问大家: 你们见过哪些“没必要上SSR但硬上”的项目?踩过哪些坑?欢迎在评论区互相伤害。

如果你也看不惯那些为了炫技而复杂化的技术选型,欢迎关注我,一起聊聊真实的前端。

Cesium-气象要素PNG色斑图叠加

1、PNG色斑图渲染原理

  • PNG的R通道存储要素的像素值0-255
  • 构建256色查找表,将颜色查找表转换为cesium可用纹理
  • ceaium渲染中纹理采样选择最邻近采样,边界清晰

2、实现步骤

(1)生成256色查找表,使用反归一化,将像素值转为实际值

反归一化公式: 实际值 = 归一化值 * (最大值 - 最小值)+ 最小值

映射过程 (i / 255)将像素索引归一化到[0, 1]区间 如i= 128 -> 128/255 ≈ 0.5

代码的实现

/**
 * 生成256色查找表
 * @param {Object} params - 参数
 * @param {string} params.colorInfo - 色卡信息,如 "[[255,0,0],[0,255,0],[0,0,255]]"
 * @param {string} params.colorLevel - 色阶,如 "-10,0,10,20,30,40"
 * @param {number} params.minValue - 数据最小值
 * @param {number} params.maxValue - 数据最大值
 * @returns {Uint8Array} 颜色查找表 (256x4 RGBA)
 */
generateColorLUT({colorInfo, colorLevel, minValue, maxValue}) {

    // 解析色卡
    let colors = colorInfo.split('],').map(ele => ele.trim()).map(ele => ele.replace('[', '').replace(']', ''));
    colors = colors.map(ele => ele.split(',').map(Number));

    // 解析色阶
    let levels = colorLevel.split(',').map(Number);

    // 构建颜色区间
    const colorRange = []
    for (let i = 0; i < levels.length - 1; i++) {
        colorRange.push({
            min: levels[i],
            max: levels[i + 1],
            color: colors[i] || colors[i - 1],
        });
    }

    // 生成256色查询表(RGBA格式)
    let lut = new Uint8Array(256 * 4); // 256个颜色,每个RGBA 4个字节

    for (let i = 0; i < 256; i++) {
        const idx = i * 4

        // 索引0为透明背景
        if (i === 0) {
            lut[idx] = 0;      // R
            lut[idx + 1] = 0;  // G
            lut[idx + 2] = 0;  // B
            lut[idx + 3] = 0;  // A (透明)
            continue;
        }

        // 将像素索引(0-255)反归一化为实际值
        const tempValue = (i / 255) * (maxValue - minValue) + minValue;

        // 查找对应的颜色区间
        let selectedColor = colors[colors.length -1]; // 默认最大值颜色

        for (let j = 0; j < colorRange.length; j++) {
            const range = colorRange[j];

            if (tempValue >= range.min && tempValue < range.max) {
                selectedColor = range.color
                break;
            }

        }

        // 设置颜色 (完全不透明)
        lut[idx] = selectedColor[0] // R
        lut[idx + 1] = selectedColor[1] // G
        lut[idx + 2] = selectedColor[2] // B
        lut[idx + 3] = 255 // A (不透明)

    }

    return lut;

}
(2)将颜色查找表转换为cesium可用纹理 Uint8Array -> HTMLCanvasElement
/**
 * 从数组创建纹理
 * @param {Uint8Array} data - RGBA数据
 * @param {number} width - 宽度
 * @param {number} height - 高度
 * @returns {HTMLCanvasElement} Canvas纹理
 */
createTextureFromArray(data, width, height) {
    // 创建canvas
    const canvas = document.createElement('canvas');
    canvas.width = width; // 通常是256
    canvas.height = height; // 通常是1

    // 获取2D绘图上下文
    const ctx = canvas.getContext('2d');

    // 创建空的ImageData对象--ImageData是canvas原生支持的像素格式数据
    const imageData = ctx.createImageData(width, height);
    // imageData.data 是一个Unit8ClampedArray,长度为width * height * 4

    // 复制数据--将Uint8Array数据复制到ImageData中
    for (let i = 0; i < data.length; i++) {
        imageData.data[i] = data[i];
    }

    // 将ImageData绘制到Canvas上
    ctx.putImageData(imageData, 0, 0);

    return canvas;
}

输出为

image.png

(3)加载图片数据
/**
 * 加载图片
 * @param {string} url - 图片URL或者
 * @returns {Promise<HTMLImageElement>}
 */
loadImage(url) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = 'anonymous'; // 设置跨域属性
        img.onload = () => resolve(img);
        img.onerror = reject;
        img.src = url;
    })
}
(4)创建材质

最近邻采样 (Nearest Neighbor):边界锐利,使用四舍五入

// GLSL实现
float index = pixel.r * 255.0;
float roundedIndex = floor(index + 0.5);  // 四舍五入到最近整数
float texCoord = (roundedIndex + 0.5) / 256.0;
vec4 color = texture2D(colorLUT, vec2(texCoord, 0.5));

image.png

在着色器中使用最邻近采样(另一篇文章有解释最邻近采样和线性插值,有兴趣的可以看一下)

/**
 * 获取着色器源码
 * @returns {string} GLSL着色器代码
 */
getShaderSource() {
    return `
  uniform sampler2D dataTexture;    // 数据纹理 (PNG, R通道存储温度值)
  uniform sampler2D colorLUT;        // 颜色查找表 (256x1)
  uniform float minTemp;             // 温度最小值
  uniform float maxTemp;             // 温度最大值
  uniform float opacity;             // 透明度
  uniform float visible;              // 可见性
  
  czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    
    // 如果不可见,返回透明
    if (visible < 0.5) {
      material.alpha = 0.0;
      return material;
    }
    
    // 采样数据纹理
    vec4 pixel = texture(dataTexture, materialInput.st);
    
    // 透明像素直接返回
    if (pixel.a < 0.01) {
      material.alpha = 0.0;
      return material;
    }
    
    // 【关键】最近邻采样 - 避免颜色混合
    // pixel.r 范围 0.0-1.0,乘以255得到0-255的索引
    float index = pixel.r * 255.0;
    // 四舍五入到最近的整数索引
    float roundedIndex = floor(index + 0.5);
    // 转换为纹理坐标 (0-1),偏移0.5到像素中心
    float texCoord = (roundedIndex + 0.5) / 256.0;
    
    // 从查找表获取颜色
    vec4 color = texture(colorLUT, vec2(texCoord, 0.5));
    
    // 计算实际温度值 (用于调试,但不影响渲染)
    // float temperature = roundedIndex / 255.0 * (maxTemp - minTemp) + minTemp;
    
    material.diffuse = czm_gammaCorrect(color.rgb);
    material.alpha = color.a * opacity;
    
    return material;
  }
`;
}

创建材质

//创建材质
this.material = new Cesium.Material({
    fabric: {
        type: 'material',
        uniforms: {
            dataTexture: dataImage,
            colorLUT: lutTexture,
            minValue: minValue,
            maxValue: maxValue,
            opacity: opacity / 100,
            visible: 1.0
        },
        source: this.getShaderSource()
    }
});
(5)Cesium中创建一个矩形几何体并将其添加到3D场景中
// 创建矩形范围-   extend数组包含4个值:[西经, 南纬, 东经, 北纬]
const rectangle = Cesium.Rectangle.fromDegrees(
    extend[0], extend[1], extend[2], extend[3]
);

// 创建几何体
const geometry = new Cesium.RectangleGeometry({
    rectangle: rectangle,
    vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
    height: 0
});

// 创建几何体实例
const instance = new Cesium.GeometryInstance({
    id: id,
    geometry: geometry
});

// 创建外观
const appearance = new Cesium.EllipsoidSurfaceAppearance({
    material: this.material,
    translucent: true,
    flat: true,
    aboveGround: true
});

// 创建Primitive(使用普通Primitive替代GroundPrimitive,避免地形细分产生的接缝,我页面上用了地形,会出现接缝)
this.layer = new Cesium.Primitive({
    geometryInstances: instance,
    appearance: appearance,
    asynchronous: false
});

// 添加到场景
this.viewer.scene.primitives.add(this.layer);

Primitive与GroundPrimitive的区别 image.png

3、最终实现效果:

image.png

4、所有代码

StaticLayer.js

import * as Cesium from "cesium";

class StaticLayers {

    constructor(viewer) {
        this.viewer = viewer;
        this.imageLayers = [];
        this.layer = null;
        this.isVisible = false;

    }

    /**
     * 生成256色查找表
     * @param {Object} params - 参数
     * @param {string} params.colorInfo - 色卡信息,如 "[[255,0,0],[0,255,0],[0,0,255]]"
     * @param {string} params.colorLevel - 色阶,如 "-10,0,10,20,30,40"
     * @param {number} params.minValue - 数据最小值
     * @param {number} params.maxValue - 数据最大值
     * @returns {Uint8Array} 颜色查找表 (256x4 RGBA)
     */
    generateColorLUT({colorInfo, colorLevel, minValue, maxValue}) {

        // 解析色卡
        let colors = colorInfo.split('],').map(ele => ele.trim()).map(ele => ele.replace('[', '').replace(']', ''));
        colors = colors.map(ele => ele.split(',').map(Number));

        // 解析色阶
        let levels = colorLevel.split(',').map(Number);

        // 构建颜色区间
        const colorRange = []
        for (let i = 0; i < levels.length - 1; i++) {
            colorRange.push({
                min: levels[i],
                max: levels[i + 1],
                color: colors[i] || colors[i - 1],
            });
        }

        console.log('colorRange--', colorRange)

        // 生成256色查询表(RGBA格式)
        let lut = new Uint8Array(256 * 4); // 256个颜色,每个RGBA 4个字节

        for (let i = 0; i < 256; i++) {
            const idx = i * 4

            // 索引0为透明背景
            if (i === 0) {
                lut[idx] = 0;      // R
                lut[idx + 1] = 0;  // G
                lut[idx + 2] = 0;  // B
                lut[idx + 3] = 0;  // A (透明)
                continue;
            }

            // 将像素索引(0-255)反归一化为实际值
            const tempValue = (i / 255) * (maxValue - minValue) + minValue;

            // 查找对应的颜色区间
            let selectedColor = colors[colors.length -1]; // 默认最大值颜色

            for (let j = 0; j < colorRange.length; j++) {
                const range = colorRange[j];

                if (tempValue >= range.min && tempValue < range.max) {
                    selectedColor = range.color
                    break;
                }

            }

            // 设置颜色 (完全不透明)
            lut[idx] = selectedColor[0] // R
            lut[idx + 1] = selectedColor[1] // G
            lut[idx + 2] = selectedColor[2] // B
            lut[idx + 3] = 255 // A (不透明)

        }

        return lut;

    }

    /**
     * 从数组创建纹理
     * @param {Uint8Array} data - RGBA数据
     * @param {number} width - 宽度
     * @param {number} height - 高度
     * @returns {HTMLCanvasElement} Canvas纹理
     */
    createTextureFromArray(data, width, height) {
        // 创建canvas
        const canvas = document.createElement('canvas');
        canvas.width = width; // 通常是256
        canvas.height = height; // 通常是1

        // 获取2D绘图上下文
        const ctx = canvas.getContext('2d');

        // 创建空的ImageData对象--ImageData是canvas原生支持的像素格式数据
        const imageData = ctx.createImageData(width, height);
        // imageData.data 是一个Unit8ClampedArray,长度为width * height * 4

        // 复制数据--将Uint8Array数据复制到ImageData中
        for (let i = 0; i < data.length; i++) {
            imageData.data[i] = data[i];
        }

        // 将ImageData绘制到Canvas上
        ctx.putImageData(imageData, 0, 0);

        return canvas;
    }

    /**
     * 加载图片
     * @param {string} url - 图片URL或者
     * @returns {Promise<HTMLImageElement>}
     */
    loadImage(url) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = 'anonymous'; // 设置跨域属性
            img.onload = () => resolve(img);
            img.onerror = reject;
            img.src = url;
        })
    }

    /**
     * 获取着色器源码
     * @returns {string} GLSL着色器代码
     */
    getShaderSource() {
        return `
      uniform sampler2D dataTexture;    // 数据纹理 (PNG, R通道存储温度值)
      uniform sampler2D colorLUT;        // 颜色查找表 (256x1)
      uniform float minTemp;             // 温度最小值
      uniform float maxTemp;             // 温度最大值
      uniform float opacity;             // 透明度
      uniform float visible;              // 可见性
      
      czm_material czm_getMaterial(czm_materialInput materialInput) {
        czm_material material = czm_getDefaultMaterial(materialInput);
        
        // 如果不可见,返回透明
        if (visible < 0.5) {
          material.alpha = 0.0;
          return material;
        }
        
        // 采样数据纹理
        vec4 pixel = texture(dataTexture, materialInput.st);
        
        // 透明像素直接返回
        if (pixel.a < 0.01) {
          material.alpha = 0.0;
          return material;
        }
        
        // 【关键】最近邻采样 - 避免颜色混合
        // pixel.r 范围 0.0-1.0,乘以255得到0-255的索引
        float index = pixel.r * 255.0;
        // 四舍五入到最近的整数索引
        float roundedIndex = floor(index + 0.5);
        // 转换为纹理坐标 (0-1),偏移0.5到像素中心
        float texCoord = (roundedIndex + 0.5) / 256.0;
        
        // 从查找表获取颜色
        vec4 color = texture(colorLUT, vec2(texCoord, 0.5));
        
        // 计算实际温度值 (用于调试,但不影响渲染)
        // float temperature = roundedIndex / 255.0 * (maxTemp - minTemp) + minTemp;
        
        material.diffuse = czm_gammaCorrect(color.rgb);
        material.alpha = color.a * opacity;
        
        return material;
      }
    `;
    }


    /**
     * 添加色斑图
     * @param {Object} options - 配置选项
     * @param {string} options.data - base64 (R通道存储温度值)
     * @param {Array<number>} options.extend - 地理范围 [西经, 南纬, 东经, 北纬]
     * @param {number} options.min_value - 最小值
     * @param {number} options.max_value - 最大值
     * @param {string} options.colorParams - 色卡信息
     * @param {number} options.opacity - 透明度 0-100,默认100
     * @param {string} options.id - 图层ID(可选)
     * @returns {Promise<Cesium.GroundPrimitive>} 返回创建的Primitive
     */
    async addLayer(options) {
        const {
            data,
            extend,
            min_value,
            max_value,
            colorParams,
            opacity = 100,
            id = 'temperature-layer'
        } = options;

        const minValue =  Number(min_value);
        const maxValue =  Number(max_value);

        const {
            colorInfo,
            colorLevel,
            dataType,
            code,
            elementName
        } = colorParams

        // 生成颜色查找表
        const lutData = this.generateColorLUT({colorInfo, colorLevel, minValue, maxValue});

        // 创建查找表纹理(256 * 1)--目的将颜色查找表数据转换为cesium可用纹理 unit8Array --> HTMLCanvasElement
        const lutTexture = this.createTextureFromArray(lutData, 256, 1)

        // 加载图片数据
        const dataImage = await this.loadImage(data);

        // 4. 创建材质
        this.material = new Cesium.Material({
            fabric: {
                type: 'material',
                uniforms: {
                    dataTexture: dataImage,
                    colorLUT: lutTexture,
                    minValue: minValue,
                    maxValue: maxValue,
                    opacity: opacity / 100,
                    visible: 1.0
                },
                source: this.getShaderSource()
            }
        });

        // 5. 创建几何体
        const rectangle = Cesium.Rectangle.fromDegrees(
            extend[0], extend[1], extend[2], extend[3]
        );

        const geometry = new Cesium.RectangleGeometry({
            rectangle: rectangle,
            vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
            height: 0
        });

        const instance = new Cesium.GeometryInstance({
            id: id,
            geometry: geometry
        });

        const appearance = new Cesium.EllipsoidSurfaceAppearance({
            material: this.material,
            translucent: true,
            flat: true,
            aboveGround: true
        });

        // 6. 创建Primitive(使用普通Primitive替代GroundPrimitive,避免地形细分产生的接缝)
        this.layer = new Cesium.Primitive({
            geometryInstances: instance,
            appearance: appearance,
            asynchronous: false
        });

        // 7. 添加到场景
        this.viewer.scene.primitives.add(this.layer);
        this.isVisible = true;

        return this.layer;



    }

    /**
     * 显示图层
     */
    showLayer() {
        if (this.layer) {
            this.layer.show = true
            if (this.material?.uniforms) {
                this.material.uniforms.visible = 1.0;
            }
            this.isVisible = true;
        }
    }

    /**
     * 隐藏图层
     */
    hideLayer() {
        if (this.layer) {
            this.layer.show = false;
            if (this.material?.uniforms) {
                this.material.uniforms.visible = 0.0;
            }
            this.isVisible = false;
        }
    }




    
}

export default StaticLayers;

使用方法:

let baseMap;
let baseViewer;
let staticLayers;
onMounted(() => {
  baseMap= new BaseMap('cesiumContainer');
  baseViewer = baseMap.getViewer()
  staticLayers = new StaticLayers(baseViewer);

  // 隐藏logo
  baseViewer.cesiumWidget.creditContainer.style.display = 'none';

});

const createLayer = () => {
  const extend = [spotTempData.min_lon, spotTempData.min_lat, spotTempData.max_lon, spotTempData.max_lat];

  const staticOptions = {
    ...spotTempData,
    extend,
    colorParams: {...colorList}
  }

  staticLayers.addLayer(staticOptions)
}

NativePHP 移动版现已免费:用 Laravel 打造原生 iOS 和 Android 应用

“等等,你能用 Laravel 编原生移动 App?从什么时候开始的?”

image.png 当你第一次听说 NativePHP 时,脑子里大概率会冒出这个问题。但请相信我,这绝不是在做梦。而且最棒的部分来了:现在你可以不用花一分钱,就直接用 Laravel 打造原生的 iOS 和 Android 应用!

为什么这事儿意义重大?

试想一下这个场景:你是一名 Laravel 开发者,玩 PHP 玩得炉火纯青。结果有一天,你的老板或客户突然要求你开发一个移动 App。通常情况下,你不得不:

  • 从零开始学习 Swift 或 Kotlin
  • 雇个移动端开发(那成本……你懂的)。
  • 使用 React Native 或 Flutter,并投入大量时间去学习全新的生态系统。

NativePHP 的出现彻底改变了游戏规则——它让你能直接利用现有的 Laravel 技能来构建移动应用。而且从第 3 版(代号 NativePHP Air)开始,所有的功能在 MIT 协议下完全免费且开源


第 3 版(Version 3)有哪些新特性?

1. 模块化插件系统

这是最核心的改变。在此之前,所有的原生功能都被打包在一个巨大的包里,导致生成的 App 非常臃肿。现在呢?你只需要安装你真正需要的插件。

例如,如果你只需要访问摄像头和文件系统:

composer require nativephp/mobile-camera  
composer require nativephp/mobile-file

简单,对吧?你的 App 能保持轻量、构建速度飞快,而且因为没有那些乱七八糟的冗余功能,提交 App Store 审核时也会更顺畅。

2. 免费 vs. 付费插件

NativePHP 团队制定了一套非常合理的平衡策略。他们将核心的基础插件完全免费提供(基于 MIT 协议):

🆓 免费插件:

  • Browser —— 在 App 内打开网页链接
  • Camera —— 访问设备摄像头
  • Device —— 获取设备信息
  • Dialog —— 弹窗告警与提示
  • File —— 文件管理
  • Microphone —— 音频录制
  • Network —— 检查网络连接状态
  • Share —— 分享内容到其他应用
  • System —— 获取系统信息

💎 付费插件(一次性付费,终身使用):

  • Biometrics —— 指纹与面部识别
  • Geolocation —— GPS 与定位服务
  • Firebase Push Notifications —— 推送通知
  • Scanner —— 二维码与条形码扫描
  • Secure Storage —— 加密存储敏感数据

付费插件的价格大约在每个 99 美元左右。关键在于,这是一次性付费且不限项目数量。哪怕你写了 10 个 App,也只需要付一次钱。


动手实操!

基础安装

首先,创建一个新的 Laravel 项目:

laravel new awesome-app  
cd awesome-app  
composer require nativephp/mobile

搞定!现在你已经打好了移动 App 的地基。

安装所需插件

假设你想开发一个具备摄像头访问照片分享功能的 App:

composer require nativephp/mobile-camera  
composer require nativephp/mobile-share

注册对应插件:

php artisan native:plugin:register nativephp/mobile-camera  
php artisan native:plugin:register nativephp/mobile-share

示例:构建一个拍照并分享的功能

让我们来实现一个简单的功能:调用摄像头拍一张照片,然后将其分享到其他应用。

Livewire 组件中,代码如下:

<?php

namespace App\Livewire;

use Livewire\Component;
use Native\Mobile\Facades\Camera;
use Native\Mobile\Facades\Share;
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Camera\PhotoTaken;

class PhotoSharer extends Component
{
    public $photoPath = null;
    
    public function takePhoto()
    {
        // 以高质量打开摄像头
        Camera::takePicture()
            ->quality(0.9)
            ->dispatch();
    }
    
    #[OnNative(PhotoTaken::class)]
    public function handlePhoto(string $path)
    {
        // 保存捕获的照片路径
        $this->photoPath = $path;
        
        // 给用户反馈
        $this->dispatch('photo-saved', path: $path);
    }
    
    public function sharePhoto()
    {
        if (!$this->photoPath) {
            return;
        }
        
        // 分享照片到其他应用
        Share::files([$this->photoPath])
            ->message('Check out this awesome photo!')
            ->dispatch();
    }
    
    public function render()
    {
        return view('livewire.photo-sharer');
    }
}

Blade View:


<div class="container mx-auto p-4">
    <h1 class="text-2xl font-bold mb-4">Photo Sharer</h1>
    
    <div class="space-y-4">
        <!-- Take Photo Button -->
        <button 
            wire:click="takePhoto"
            class="w-full bg-blue-500 text-white py-3 px-6 rounded-lg hover:bg-blue-600">
            📸 Take Photo
        </button>
        
        <!-- Photo Preview -->
        @if($photoPath)
            <div class="bg-gray-100 p-4 rounded-lg">
                <img src="{{ $photoPath }}" alt="Photo" class="w-full rounded">
                
                <button 
                    wire:click="sharePhoto"
                    class="mt-4 w-full bg-green-500 text-white py-3 px-6 rounded-lg hover:bg-green-600">
                    📤 Share Photo
                </button>
            </div>
        @endif
    </div>
</div>

简单,对吧?你依然是在用熟悉的 Livewire 模式开发,只是顺手加了点 NativePHP 的门面(Facades)而已。

示例:使用付费插件(Firebase 推送通知)

现在我们来看看付费插件的例子。假设你已经购买了用于推送通知的 Firebase 插件。

安装插件:

composer require nativephp/mobile-firebase  
php artisan native:plugin:register nativephp/mobile-firebase

Firebase 配置步骤:

  1. Firebase 控制台 创建一个新项目。
  2. 下载 Android 版的 google-services.json 和 iOS 版的 GoogleService-Info.plist
  3. 将这两个文件直接放入你的项目根目录
  4. NativePHP 会在构建应用时自动编译并配置它们。

注册推送通知的代码如下:

<?php

namespace App\Livewire;

use Livewire\Component;
use Native\Mobile\Facades\PushNotifications;
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\PushNotification\TokenGenerated;

class NotificationSettings extends Component
{
    public $notificationToken = null;
    public $isEnrolled = false;
    
    public function enableNotifications()
    {
        // 请求权限并注册推送通知
        PushNotifications::enroll();
    }
    
    #[OnNative(TokenGenerated::class)]
    public function handleTokenGenerated(string $token)
    {
        // Token 生成成功!
        $this->notificationToken = $token;
        $this->isEnrolled = true;
        
        // 将 token 发送到后端进行存储
        $this->sendTokenToServer($token);
    }
    
    private function sendTokenToServer(string $token)
    {
        // 发送到你的后端 API
        auth()->user()->update([
            'fcm_token' => $token,
            'platform' => $this->detectPlatform()
        ]);
    }
    
    private function detectPlatform()
    {
        // 检测平台(iOS 或 Android)
        return \Native\Mobile\Facades\System::platform();
    }
    
    public function render()
    {
        return view('livewire.notification-settings');
    }
}

页面:

<div class="p-4">
    <div class="bg-white rounded-lg shadow p-6">
        <h2 class="text-xl font-bold mb-4">Notification Settings</h2>
        
        @if(!$isEnrolled)
            <div class="mb-4">
                <p class="text-gray-600 mb-4">
                    Enable notifications to receive updates directly on your device!
                </p>
                
                <button 
                    wire:click="enableNotifications"
                    class="w-full bg-blue-500 text-white py-3 px-6 rounded-lg hover:bg-blue-600">
                    🔔 Enable Notifications
                </button>
            </div>
        @else
            <div class="bg-green-50 p-4 rounded-lg">
                <p class="text-green-800 font-semibold mb-2">
                    ✅ Notifications Enabled
                </p>
                <p class="text-sm text-gray-600">
                    You'll receive notifications for important updates.
                </p>
            </div>
        @endif
    </div>
</div>

从后台发送通知:

<?php

namespace App\Services;

use Kreait\Firebase\Factory;
use Kreait\Firebase\Messaging\CloudMessage;

class PushNotificationService
{
    private $messaging;
    
    public function __construct()
    {
        $factory = (new Factory)->withServiceAccount(config('firebase.credentials'));
        $this->messaging = $factory->createMessaging();
    }
    
    public function sendToUser($user, $title, $body, $data = [])
    {
        if (!$user->fcm_token) {
            return false;
        }
        
        $message = CloudMessage::withTarget('token', $user->fcm_token)
            ->withNotification([
                'title' => $title,
                'body' => $body,
            ])
            ->withData($data);
        
        try {
            $this->messaging->send($message);
            return true;
        } catch (\Exception $e) {
            \Log::error('Push notification failed: ' . $e->getMessage());
            return false;
        }
    }
}

在Controller中使用:

use App\Services\PushNotificationService;

class OrderController extends Controller
{
    public function __construct(
        private PushNotificationService $pushService
    ) {}
    
    public function confirmOrder(Order $order)
    {
        $order->update(['status' => 'confirmed']);
        
        // 向客户发送通知
        $this->pushService->sendToUser(
            $order->user,
            'Order Confirmed',
            "Order #{$order->id} has been confirmed and is being processed!",
            ['order_id' => $order->id]
        );
        
        return response()->json(['success' => true]);
    }
}

飞跃式体验:让测试回归简单

NativePHP 最酷的功能之一莫过于 Jump。你只需要在手机上(iOS 或 Android)安装这个专门的 App,就能直接测试你的 Laravel 应用,完全不需要经历漫长的打包过程,也不用安装那些沉重的开发者工具(比如 Xcode 或 Android Studio)。

使用方法极其简单:

php artisan native:jump  
# 或者  
./native jump

然后,只需用手机上的 Jump App 扫描出现的二维码。Boom! 你的 Laravel 应用就会直接在你的设备上跑起来。

为什么 Jump 功能如此惊艳:

  • 无需 Mac 即可测试 iOS 应用:即使你用的是 Windows 或 Linux,也能在 iPhone 上看到运行效果。
  • 无需安装 Xcode 或 Android Studio:跳过那些动辄几十 GB 的开发环境配置。
  • 极速反馈:本地代码的修改会立即同步到手机端。
  • 完全免费:你可以免费测试所有插件(包括那些高级付费插件)。
  • 演示神器:开发完一个功能,立刻就能在真机上演示给客户看。

开发属于你自己的插件

这才是 NativePHP 真正强大的地方。如果官方提供的功能无法满足你的需求,你完全可以利用原生能力开发自己的插件。

自动化插件脚手架

最简单的方法是使用 NativePHP 内置的命令来生成模板:

php artisan native:plugin:create

这个命令会询问你的插件名称、命名空间以及你想要实现的功能,随后它会自动生成一套完整的插件结构。

插件结构解析

my-plugin/
├── composer.json              # 包元数据
├── nativephp.json             # 插件清单
├── src/
│   ├── MyPluginServiceProvider.php
│   ├── MyPlugin.php           # 主类
│   ├── Facades/
│   │   └── MyPlugin.php
│   ├── Events/
│   │   └── SomethingHappened.php
│   └── Commands/              # 生命周期钩子命令
├── resources/
│   ├── android/src/           # Kotlin 代码
│   ├── ios/Sources/           # Swift 代码
│   └── js/                    # JavaScript 库

composer.json 文件

这一点至关重要:你必须在插件的 composer.json 中将 type(类型)字段设置为 nativephp-plugin

{
    "name": "vendor/my-plugin",
    "type": "nativephp-plugin",
    "extra": {
        "laravel": {
            "providers": ["Vendor\\MyPlugin\\MyPluginServiceProvider"]
        },
        "nativephp": {
            "manifest": "nativephp.json"
        }
    }
}

nativephp.json 文件

这是插件的清单文件(Manifest) ,用于声明原生的相关配置:

{
    "namespace": "MyPlugin",
    "bridge_functions": [
        {
            "name": "MyPlugin.DoSomething",
            "ios": "MyPluginFunctions.DoSomething",
            "android": "com.myvendor.plugins.myplugin.MyPluginFunctions.DoSomething"
        }
    ],
    "events": ["Vendor\\MyPlugin\\Events\\SomethingHappened"],
    "android": {
        "permissions": ["android.permission.CAMERA"],
        "dependencies": {
            "implementation": ["com.google.mlkit:barcode-scanning:17.2.0"]
        }
    },
    "ios": {
        "info_plist": {
            "NSCameraUsageDescription": "摄像头将用于扫描二维码"
        },
        "dependencies": {
            "pods": [{"name": "GoogleMLKit/BarcodeScanning", "version": "~> 4.0"}]
        }
    }
}

Android 包名命名(Package Naming)

至关重要: 在 Android/Kotlin 开发中,你必须在每个文件的顶部声明包名:

// 资源文件路径:resources/android/src/MyPluginFunctions.kt
package com.myvendor.plugins.myplugin

import com.nativephp.mobile.bridge.BridgeFunction
import com.nativephp.mobile.bridge.BridgeResponse

object MyPluginFunctions {
    class DoSomething : BridgeFunction {
        override fun execute(parameters: Map<String, Any>): Map<String, Any> {
            return BridgeResponse.success(mapOf("status" to "done"))
        }
    }
}

包路径必须与清单文件(Manifest)中定义的路径完全匹配。

简单插件示例 —— 震动功能(Vibration)

让我们来创建一个简单的插件,实现设备的震动功能。

Swift (iOS) —— resources/ios/Sources/VibrationFunctions.swift

import Foundation
import UIKit

@objc public class VibrationFunctions: NSObject {
    @objc public static func vibrate(_ type: String) -> [String: Any] {
        switch type {
        case "light":
            let generator = UIImpactFeedbackGenerator(style: .light)
            generator.impactOccurred()
        case "medium":
            let generator = UIImpactFeedbackGenerator(style: .medium)
            generator.impactOccurred()
        case "heavy":
            let generator = UIImpactFeedbackGenerator(style: .heavy)
            generator.impactOccurred()
        default:
            UINotificationFeedbackGenerator().notificationOccurred(.success)
        }
        
        return ["success": true, "type": type]
    }
}

Kotlin (Android) — resources/android/src/VibrationFunctions.kt :

package com.myvendor.vibration

import android.content.Context
import android.os.VibrationEffect
import android.os.Vibrator
import com.nativephp.mobile.bridge.BridgeFunction
import com.nativephp.mobile.bridge.BridgeResponse

object VibrationFunctions {
    class Vibrate : BridgeFunction {
        override fun execute(parameters: Map<String, Any>): Map<String, Any> {
            val context = parameters["context"] as? Context 
                ?: return BridgeResponse.error("Context not available")
            
            val type = parameters["type"] as? String ?: "medium"
            val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
            
            val duration = when (type) {
                "light" -> 50L
                "medium" -> 100L
                "heavy" -> 150L
                else -> 100L
            }
            
            val amplitude = when (type) {
                "light" -> 50
                "medium" -> 100
                "heavy" -> 150
                else -> VibrationEffect.DEFAULT_AMPLITUDE
            }
            
            vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
            
            return BridgeResponse.success(mapOf("type" to type))
        }
    }
}

清单文件 nativephp.json


{
    "namespace": "Vibration",
    "bridge_functions": [
        {
            "name": "Vibration.vibrate",
            "ios": "VibrationFunctions.vibrate",
            "android": "com.myvendor.vibration.VibrationFunctions.Vibrate"
        }
    ],
    "android": {
        "permissions": ["android.permission.VIBRATE"]
    }
}

PHP 类 — src/Vibration.php :

<?php

namespace MyVendor\VibrationPlugin;

use Native\Mobile\Bridge;

class Vibration
{
    public function vibrate(string $type = 'medium'): array
    {
        return Bridge::call('Vibration.vibrate', [
            'type' => $type
        ]);
    }
}

Facade —— src/Facades/Vibration.php

<?php

namespace MyVendor\VibrationPlugin\Facades;

use Illuminate\Support\Facades\Facade;

class Vibration extends Facade
{
    protected static function getFacadeAccessor()
    {
        return \MyVendor\VibrationPlugin\Vibration::class;
    }
}

Service Provider — src/VibrationServiceProvider.php :

<?php

namespace MyVendor\VibrationPlugin;

use Illuminate\Support\ServiceProvider;

class VibrationServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(Vibration::class, function () {
            return new Vibration();
        });
    }
    public function boot()
    {
        // 如果需要,可以在这里添加引导逻辑
    }
}

使用:

use MyVendor\VibrationPlugin\Facades\Vibration;

// 在 Livewire 组件或控制器中
public function onButtonClick()
{
    Vibration::vibrate('heavy');
    
    // 执行其他操作...
}

本地开发调试

在开发过程中,为了能实时测试插件的改动,你需要将插件以**路径仓库(Path Repository)**的形式添加到应用的 composer.json 中:

{
    "repositories": [
        {"type": "path", "url": "../packages/my-plugin"}
    ]
}

然后require一下:

composer require vendor/my-plugin

PHP 代码的修改会立即生效(实时重载)。但如果你修改了原生代码(Swift 或 Kotlin) ,则需要重新构建:

php artisan native:run

对于清单文件(Manifest)或原生代码的重大改动,建议强制重新安装

php artisan native:install --force

注册插件

通过 Composer 安装插件后,你必须对其进行注册,这样它才能被正式编译到原生构建(Native Builds)中。

首次设置:

php artisan vendor:publish --tag=nativephp-plugins-provider

这将会创建app/Providers/NativeServiceProvider.php.

注册一个插件

php artisan native:plugin:register vendor/my-plugin

这会自动将插件的服务提供者(Service Provider)添加到你的 plugins() 数组中:

public function plugins(): array
{
    return [
        \Vendor\MyPlugin\MyPluginServiceProvider::class,
    ];
}

列出所有插件

# 显示已注册的插件
php artisan native:plugin:list  
  
# 显示所有已安装的插件(包括未注册的)
php artisan native:plugin:list --all

移除一个插件

php artisan native:plugin:register vendor/my-plugin --remove

验证你的插件

在构建之前,验证一下你的插件:

php artisan native:plugin:validate

这能捕捉到清单文件(Manifest)中的错误、缺失的原生代码,或是函数声明不匹配的问题。

为单页应用(SPA)准备的 JavaScript 库

如果你的 Laravel 项目前端使用的是 Vue 或 React,插件也可以提供专门的 JavaScript 库进行适配。你只需要在 resources/js/ 目录下创建一个文件:

// 资源文件路径:resources/js/vibration.js
const baseUrl = '/_native/api/call';

async function bridgeCall(method, params = {}) {
    const response = await fetch(baseUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ method, params })
    });
    return response.json();
}

export async function vibrate(type = 'medium') {
    return bridgeCall('Vibration.vibrate', { type });
}

使用者可以直接导入:

import { vibrate } from '@myvendor/vibration-plugin';  
  
await vibrate('heavy');

使用 JavaScript 框架(Vue/React/Inertia)

NativePHP 不仅仅局限于传统的服务端渲染,它也完美支持现代 JavaScript 框架。如果你习惯于构建单页应用(SPA),可以通过 NativePHP 提供的 JS 桥接层直接与原生系统交互。 Vue 示例:

<script setup>
  import { ref, onMounted, onUnmounted } from 'vue'
  import { camera, on, off, Events } from '#nativephp'
  
  const photoPath = ref(null)
  const isLoading = ref(false)
  
  const takePhoto = async () => {
    isLoading.value = true
    
    await camera.takePicture()
      .quality(0.9)
      .remember()
  }
  
  const handlePhotoTaken = (event) => {
    photoPath.value = event.path
    isLoading.value = false
  }
  
  onMounted(() => {
    on(Events.Camera.PhotoTaken, handlePhotoTaken)
  })
  
  onUnmounted(() => {
    off(Events.Camera.PhotoTaken, handlePhotoTaken)
  })
</script>

<template>
  <div class="photo-capture">
    <button 
      @click="takePhoto"
      :disabled="isLoading"
      class="btn-primary">
      {{ isLoading ? 'Loading...' : '📸 Take Photo' }}
    </button>
    
    <img 
      v-if="photoPath"
      :src="photoPath"
      alt="Captured photo"
      class="preview-image" />
  </div>
</template>

提示与最佳实践

1. 在合适的时机申请权限

千万不要在 App 刚启动时就申请所有权限。 这种做法会让用户感到突兀,极大地增加被拒绝的可能性。

// ❌ 不要这样做
public function mount()
{
    Camera::requestPermission();
    Microphone::requestPermission();
    Geolocation::requestPermission();
}

// ✅ 在用户需要该功能时再请求
public function startRecording()
{
    // 先检查权限
    if (!Microphone::hasPermission()) {
        // 解释为什么需要该权限
        $this->showPermissionExplanation();
        return;
    }
    
    // 然后再请求
    Microphone::record();
}

2. 正确地处理错误

use Native\Mobile\Facades\Camera;

public function capturePhoto()
{
    try {
        Camera::takePicture()
            ->quality(0.9)
            ->dispatch();
            
    } catch (\Exception $e) {
        // 记录错误日志
        \Log::error('Camera error: ' . $e->getMessage());
        
        // 给用户反馈
        session()->flash('error', 'Failed to open camera. Please try again.');
    }
}

3. 性能优化

虽然 NativePHP 插件运行在原生层,性能表现已经非常出色,但你仍然需要注意以下几点,以确保应用在大规模使用时依然丝滑:

// ✅ 批量操作
Share::files([
    'photo1.jpg',
    'photo2.jpg',
    'photo3.jpg'
])->dispatch();

// ❌ 避免在循环中进行多次调用
foreach ($photos as $photo) {
    Share::files([$photo])->dispatch(); // 太浪费资源了!
}

4. 测试

虽然 Jump 极其方便,能让你快速验证逻辑,但千万不要忽略**实际打包(Actual Builds)**的测试:

# 使用 Jump 进行测试(快速,适用于开发)
php artisan native:jump  
  
# 构建用于彻底测试
# (需要 Bifrost 或自行设置)

变现你的 App

如果你正打算用 NativePHP 构建移动应用并赚取收益,这里有几种变现方案可供选择:

1. 应用内购买 (In-App Purchases)

你可以利用第三方插件,或者直接通过原生层集成 iOS 的 StoreKit 和 Android 的 Google Play Billing

2. 订阅模式 (Subscription Model)

这是 SaaS(软件即服务)类应用的绝佳选择。你可以将移动端与 Laravel Cashier 结合使用:

use Laravel\Cashier\Cashier;

public function subscribe()
{
    $user = auth()->user();
    
    $user->newSubscription('premium', 'price_monthly')
         ->create($paymentMethod);
         
    // 更新 App 中的状态
    $this->isPremium = true;
}

3. 广告集成 (Ads Integration)

你可以使用社区提供的 AdMob 插件,或者通过自定义插件自行接入。


竞品对比:选择最适合你的武器

NativePHP vs. React Native

NativePHP:

  • 驾轻就熟:直接使用你已经掌握的 PHP 技能。
  • 全家桶支持:完美调用 Laravel 生态(Eloquent、Queue 队列等)。
  • 极简配置:安装与环境搭建更简单。
  • 社区较小:目前开发者群体还在成长中。
  • 生态差异:现成插件的数量暂时少于 RN。

React Native:

  • 生态繁荣:庞大的社区支持。
  • 库文件丰富:几乎能找到任何你想要的第三方库。
  • 学习门槛:必须掌握 JavaScript 和 React。
  • 配置繁琐:原生环境的配置和调试相对复杂。

NativePHP vs. Flutter

NativePHP:

  • 专注 PHP:无需离开 Laravel/PHP 舒适区。
  • 逻辑共享:移动端与 Web 端可以共用大量后端代码。
  • 性能上限:在高频高性能需求下,略逊于 Flutter。

Flutter:

  • 顶级性能:渲染速度极快,体验丝滑。
  • UI 高度一致:跨端视觉效果完全同步。
  • 全新语言:必须从头学习 Dart 语言。
  • 前后端分离:移动端是一个完全独立的体系,无法直接复用 Laravel 逻辑。

🗺️ 发展路线图:NativePHP 的未来

NativePHP 团队雄心勃勃,已经规划了清晰的未来:

  • 开放插件市场 (Plugin Marketplace) :开发者可以上架并出售自己编写的插件。
  • Mimi AI:专属 AI 助手,通过对话就能帮你生成 NativePHP 应用。
  • 持续扩充插件库:官方将不断增加新的原生功能支持。
  • 桌面端持续发力:在移动端突破的同时,已有的桌面端支持也将得到进一步强化。

🏁 结语

NativePHP 移动版宣布免费,对于想要进军移动端却不想从零学习新技术的 Laravel 开发者来说,这绝对是改变游戏规则的时刻。

为什么你现在就该尝试?

  • 完全免费:核心框架和 9 款核心插件全部免费开放。
  • 死磕 PHP:无需为了做个 App 去学 Swift 或 Kotlin。
  • 插件化架构:模块化设计,扩展性极强。
  • Jump 极速预览:无需漫长编译,手机扫码即可看到改动。
  • Laravel 力量:整个 Laravel 生态都是你坚实的后盾。
  • 生产环境就绪:已经有大量应用在正式环境平稳运行。

🔥 还在等什么?现在就开始你的第一个 NativePHP 项目吧!:

# 创建新项目
laravel new my-app  
cd my-app  
  
# 安装 NativePHP  
composer require nativephp/mobile  
  
# 在手机上安装 Jump  
# 从 bifrost.nativephp.com/jump 下载
# 开始开发  
php artisan native:jump

这不仅仅是一个技术的终点,更是一个属于 Laravel 开发者的大航海时代的起点

2026 年,NativePHP 3 (Air) 的发布彻底推倒了 Web 与原生应用之间的高墙。你现在掌握的,是这个时代最高效的“跨界超能力”。

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案

在电商系统中,用户的操作路径往往是这样的:

进入商品列表页 → 进行筛选、排序、分页 → 点击进入商品详情页 → 查看后返回列表页。

这时,用户通常有一个非常明确的预期:

  • 筛选条件还在
  • 分页状态保持
  • 滚动位置不丢失

但同时,我们也会遇到另一种场景:

  • 从首页、搜索页或其他模块进入商品列表页时
  • 希望列表页是“全新状态”
  • 需要重新请求数据

也就是说:

同一个页面,在不同来源路径下,对“是否缓存”的期望是不同的。

在 Vue 项目开发中,KeepAlive 是提升用户体验的重要工具。但在复杂的嵌套路由场景(例如:Layout → SubLayout → Page)下,我们常常会遇到两个典型痛点:

1️⃣ 缓存失效

明明在路由里配置了 keepAlive: true
但页面返回时依然重新挂载,onMounted 再次触发,状态丢失。

2️⃣ 控制逻辑越来越混乱

当我们尝试实现:

  • 从 A 页面跳到 B 页面时缓存
  • 从 C 页面跳到 B 页面时不缓存

路由守卫里开始出现大量 if-else 判断,
逻辑逐渐变得难以维护,甚至演变成“屎山”。

如何实现:

  • 精准控制缓存来源
  • 支持嵌套路由结构
  • 避免父级 Layout 被误销毁
  • 同时保持代码清晰、可维护

本文将分享一种“反向配置”的缓存设计方案,并深入解析嵌套路由场景下的一个核心原则:

父随子存。

通过这套设计,我们可以在复杂电商场景中,实现精细化、可控且可扩展的页面缓存策略。

一、核心痛点:为什么嵌套路由下的缓存容易失效?

在 Vue 中,KeepAlive 的本质其实是“链路存活”。

换句话说,只要组件所在的这条渲染链路还存在,它的缓存才能继续保留。一旦链路中某一层被销毁,缓存就会随之消失。

假设我们的路由结构是这样的:

  • 一级容器:WebsiteLayout(顶层布局)
  • 二级容器:UserLayout(用户中心布局)
  • 三级页面:UserProfile、UserFavorites 等子页面
WebsiteLayout
  └── UserLayout
        └── UserProfile / UserFavorites

这里有一个很多人忽略的“真相”:

如果父容器(例如 UserLayout)被销毁,那么它内部缓存的所有子页面,也会被瞬间“物理清空”。

即使你在 UserProfile 上配置了keepAlive: true, 只要 UserLayout 这一层被重新挂载, 内部所有缓存都会失效,onMounted 会再次触发。

这就是嵌套路由场景下缓存“看起来配置了却没生效”的根本原因。

很多开发者只给子页面设置缓存,却忽略了一个关键原则:

子页面想存活,父容器必须先存活。

这也是后文要讲的核心设计理念——“父随子存”原则。

二、 解决方案:反向配置 + 递归缓存

1. 路由配置:由“去向页”决定“来源页”

我们不再在每个页面写复杂的判断,而是在详情页声明:“从我这里回退时,请保持谁的缓存”

// router/index.ts
export const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/UserLayout.vue'),
    meta: { keepAlive: true }, // 父容器必须支持缓存
    children: [
      {
        path: 'favorites',
        name: 'UserFavorites',
        component: () => import('@/views/user/Favorites.vue'),
        meta: { keepAlive: true }
      }
    ]
  },
  {
    path: '/product/:id',
    name: 'Product',
    component: () => import('@/views/product/index.vue'),
    meta: { 
      // 反向配置:从这里回退时,保护以下页面的缓存
      keepAliveSources: ['UserFavorites', 'Home'] 
    }
  }
];

2. 全局守卫:实现“父随子存”

这是整套方案的灵魂。我们需要在路由守卫中做两件事:

  1. 补全链路:进入子页面时,强制将其所有父 Layout 加入缓存名单。
  2. 精准清理:非合法来源进入时,即时销毁旧缓存。
export const cacheStack = ref<string[]>([]);

router.beforeEach(async (to, from, next) => {
    const fromName = from.name as string;
    const keepAliveSources = to.meta.keepAliveSources as string[] || [];

    // 1️⃣ 核心:补全父路由缓存名单
    // 遍历 to.matched,确保当前路由的所有父 Layout 都在缓存数组中
    to.matched.forEach((record) => {
      if (record.meta.keepAlive && record.name) {
        const name = record.name as string;
        if (!cacheStack.value.includes(name)) {
          cacheStack.value.push(name);
        }
      }
    });

    // 2️⃣ 处理来源页逻辑
    if (from.meta.keepAlive && fromName) {
      // 如果来源页本身声明了需要缓存,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (keepAliveSources.includes(fromName)) {
      // 如果去向页声明了它是合法的回退来源,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (fromName) {
      // 3️⃣ 清理:如果不是合法来源,从名单中移除,触发组件销毁
      const index = cacheStack.value.indexOf(fromName);
      if (index > -1) cacheStack.value.splice(index, 1);
    }

    next();
});

3. 布局组件:视图层的配合

在所有包含 router-viewLayout 组件中,必须使用 include 绑定这个全局名单。

  1. WebsiteLayout.vue
// WebsiteLayout.vue
<template>
    <router-view v-slot="{ Component, route }">
      <keep-alive :include="cacheStackList">
        <component 
          :is="Component" 
          :key="route.fullPath" 
        />
      </keep-alive>
    </router-view>
</template>

<script lang="ts" setup name="WebsiteLayout">
  import { defineComponent, computed } from 'vue';
  import { cacheStack } from '/@/router/guard/index';

  const cacheStackList = computed(() => {
    return cacheStack.value
  })
</script>
  1. UserLayout.vue
// UserLayout.vue
<template>
  <div class="flex flex-col">
    <div class="flex gap-6 py-4">
      <!-- 左侧菜单 -->
      <aside class="w-96 shrink-0">
        <UserMenu />
      </aside>

      <!-- 右侧内容 -->
      <main class="flex-1">
        <router-view v-slot="{ Component, route }">
          <keep-alive :include="cacheStackList">
            <component 
              :is="Component" 
              :key="route.fullPath" 
            />
          </keep-alive>
        </router-view>
      </main>
    </div>
  </div>
</template>
<script setup lang="ts" name="User">
import UserMenu from './userMenu/index.vue'
import { cacheStack } from '/@/router/guard/index';
import { computed } from 'vue';
const cacheStackList = computed(() => {
  return cacheStack.value
})
</script>

三、 方案优势

  1. 物理隔离确保生效:通过 to.matched 递归确保父容器存活,彻底解决了嵌套路由“名单对了但不缓存”的问题。

  2. 配置解耦:新加一个详情页时,只需在详情页 meta 里增加一行回退目标,无需改动任何业务组件。

  3. 内存友好:不满足回退条件时,缓存会被立即 splice 清理,避免内存堆积。

Kotlin抽象类与接口:相爱相杀的编程“CP”

一、开篇引入

在 Kotlin 的编程世界里,你是否常常在定义一些通用行为或属性时,纠结于到底该使用抽象类还是接口呢?就像在建造一座大厦时,选择合适的建筑材料至关重要,在 Kotlin 编程中,正确选用抽象类和接口,对于构建健壮、可维护的代码结构同样意义非凡 。今天,我们就一起来深入探讨 Kotlin 中抽象类以及它与接口的区别。

二、Kotlin 抽象类详解

(一)抽象类定义

在 Kotlin 中,抽象类是一种不能被直接实例化的类,就像是一个还未完成的 “蓝图”,它主要的作用是作为其他类的基类(父类) ,为子类提供通用的属性和方法定义,而将具体的实现细节留给子类去完成。我们使用abstract关键字来声明一个抽象类。例如:


abstract class AbstractClass {
    // 这里可以定义抽象属性和抽象方法
    // 也可以定义非抽象属性和非抽象方法
}

抽象类不能被直接实例化,比如不能写成val abstractObj = AbstractClass(),这就如同你不能直接使用一个未完成的蓝图来建造实际的建筑一样。它存在的意义更多是为了提供一种通用的结构和规范,让子类基于它进行扩展和实现 。

(二)抽象类示例

以一个图形相关的程序为例,我们定义一个Shape抽象类:


abstract class Shape {
    // 抽象属性,没有初始化值,必须在子类中重写
    abstract val name: String

    // 抽象方法,没有方法体,必须在子类中重写
    abstract fun calculateArea(): Double

    // 非抽象方法,有具体实现,可以被子类继承或重写
    fun printName() {
        println("形状名称: $name")
    }
}

在这个Shape抽象类中,name是一个抽象属性,它代表图形的名称,每个具体的图形(如圆形、矩形)名称都不同,所以需要在子类中具体实现;calculateArea()是一个抽象方法,用于计算图形的面积,不同图形的面积计算方式不同,也需要子类去实现;而printName()是一个非抽象方法,它会打印出图形的名称,这个方法有具体的实现,子类可以直接继承使用,如果有特殊需求也可以重写。

(三)继承抽象类

当子类继承抽象类时,必须使用override关键字重写所有抽象属性和方法。例如,我们定义Circle(圆形)和Rectangle(矩形)类来继承Shape类:


// 子类Circle继承自抽象类Shape
class Circle(val radius: Double) : Shape() {
    // 重写抽象属性
    override val name: String = "圆形"

    // 重写抽象方法
    override fun calculateArea(): Double {
        return Math.PI * radius * radius
    }
}

// 子类Rectangle继承自抽象类Shape
class Rectangle(val width: Double, val height: Double) : Shape() {
    override val name: String = "矩形"
    override fun calculateArea(): Double {
        return width * height
    }
}

Circle类中,我们重写了name属性为 “圆形”,并实现了calculateArea()方法来计算圆形的面积;在Rectangle类中,同样重写了name属性为 “矩形”,并实现calculateArea()方法来计算矩形面积。通过这种方式,抽象类的抽象成员在子类中得到了具体的实现 。

(四)抽象类特点总结

  1. 不能实例化:抽象类不能直接创建对象,它主要为子类提供一个通用的框架。就像我们不能直接使用一个抽象的 “交通工具” 类来创建一个具体的交通工具,而是需要基于它创建 “汽车”“飞机” 等具体子类的对象。

  2. 可包含抽象和非抽象成员:抽象类中既可以有抽象属性和抽象方法,这些需要子类去实现;也可以有非抽象属性和非抽象方法,子类可以直接继承使用,也可以根据需求重写。

  3. 子类必须实现所有抽象成员:如果一个子类继承了抽象类,那么它必须重写抽象类中的所有抽象属性和方法,否则这个子类也必须声明为抽象类。例如,下面这个只重写了部分抽象成员的类,就必须声明为抽象类:


abstract class Square(val sideLength: Double) : Shape() {
    // 只重写了抽象属性,没有重写抽象方法calculateArea()
    override val name: String = "正方形"
    // 因此Square类也必须是抽象的
}
  1. 可以继承其他类:抽象类可以继承自另一个非抽象类或抽象类,进一步扩展和定制自己的行为和属性。例如:

open class Animal {
    open fun makeSound() {
        println("动物发出声音")
    }
}

abstract class Dog : Animal() {
    override abstract fun makeSound() // 重写并声明为抽象方法,子类必须实现
}

class Puppy : Dog() {
    override fun makeSound() {
        println("小狗汪汪叫")
    }
}

在这个例子中,Dog抽象类继承自Animal类,并重写了makeSound()方法并声明为抽象方法,Puppy类再继承Dog类并实现了makeSound()方法 。

三、Kotlin 接口详解

(一)接口定义

在 Kotlin 中,接口是一种强大的抽象机制,它使用interface关键字来定义。与抽象类不同,接口主要用于定义一组方法的签名,这些方法可以是抽象的,也可以有默认实现 。接口无法存储状态,它就像是一份 “行为契约”,规定了实现它的类应该具备哪些行为 。接口可以包含抽象方法声明和方法实现 ,接口中的属性默认是抽象的,或必须提供getter实现 。例如:


interface MyInterface {
    // 抽象方法,没有方法体,实现接口的类必须实现这个方法
    fun abstractMethod()

    // 带默认实现的方法,实现接口的类可以选择重写这个方法,也可以使用默认实现
    fun methodWithDefaultImplementation() {
        println("这是一个带有默认实现的方法")
    }
}

(二)接口示例

Vehicle接口为例,展示接口中抽象方法和带默认实现方法的定义:


interface Vehicle {
    // 抽象方法,启动车辆,必须在实现接口的类中实现
    fun start()

    // 抽象方法,停止车辆,必须在实现接口的类中实现
    fun stop()

    // 带默认实现的方法,车辆鸣笛,实现接口的类可以选择重写,也可以使用默认实现
    fun honk() {
        println("嘟嘟!")
    }
}

在这个Vehicle接口中,start()stop()是抽象方法,因为不同类型的车辆启动和停止的方式可能不同,需要具体的实现类去实现;而honk()是一个带默认实现的方法,默认情况下车辆鸣笛输出 “嘟嘟!”,如果有特殊的鸣笛需求,实现类也可以重写这个方法 。

(三)实现接口

一个类或对象可以实现一个或多个接口。当一个类实现接口时,它必须实现接口中所有的抽象方法(除非这个类本身也是抽象类) 。以Car类实现Vehicle接口为例:


class Car : Vehicle {
    override fun start() {
        println("汽车启动")
    }

    override fun stop() {
        println("汽车停止")
    }

    // 这里没有重写honk()方法,所以会使用接口中honk()的默认实现
}

Car类中,通过override关键字重写了Vehicle接口中的start()stop()抽象方法,来实现汽车的启动和停止逻辑 。而对于honk()方法,由于没有重写,所以当调用Car对象的honk()方法时,会执行接口中honk()的默认实现,输出 “嘟嘟!” 。

(四)接口继承与解决覆盖冲突

接口可以继承其他接口,通过继承,接口可以扩展和增强自身的功能 。例如:


interface Moveable {
    fun move()
}

interface Flyable : Moveable {
    fun fly()
}

在这个例子中,Flyable接口继承了Moveable接口,这意味着实现Flyable接口的类不仅要实现fly()方法,还要实现Moveable接口中的move()方法 。

当一个类实现多个接口时,如果这些接口中定义了相同签名的方法,就会出现方法覆盖冲突 。例如:


interface A {
    fun foo() {
        println("A中的foo方法")
    }
}

interface B {
    fun foo() {
        println("B中的foo方法")
    }
}

class C : A, B {
    // 必须重写foo()方法来解决冲突
    override fun foo() {
        // 调用A接口中的foo()方法
        super<A>.foo()
        // 调用B接口中的foo()方法
        super<B>.foo()
        println("C中重写的foo方法")
    }
}

在上述代码中,C类实现了AB两个接口,而这两个接口都定义了foo()方法,所以在C类中必须重写foo()方法 。在重写的foo()方法中,通过super<A>.foo()super<B>.foo()分别调用了AB接口中的foo()方法,并添加了自己的逻辑 。这样就解决了方法覆盖冲突的问题 。

四、抽象类与接口的区别

通过前面的介绍,我们对 Kotlin 中的抽象类和接口都有了一定的了解 。接下来,我们来详细对比一下它们之间的区别,以便在实际开发中能够更准确地选择使用。

(一)构造函数

抽象类可以有构造函数,包括主构造函数和次构造函数,用于初始化抽象类中的属性和状态 。例如,我们在Shape抽象类中添加一个主构造函数:


abstract class Shape(val color: String) {
    abstract val name: String
    abstract fun calculateArea(): Double

    fun printName() {
        println("形状名称: $name")
    }
}

在这个例子中,Shape抽象类有一个主构造函数,接受一个color参数,用于表示图形的颜色 。

而接口不能有构造函数 。这是因为接口主要用于定义行为,不存储状态,所以不需要构造函数来初始化 。不过,从 Kotlin 1.9 + 开始,虽然支持接口中定义带默认实现的属性,但仍然不能有构造函数 。

(二)多重继承

在 Kotlin 中,一个类只能继承一个抽象类,即抽象类是单继承的 。这是为了避免多重继承带来的复杂性和冲突 。例如:


abstract class Animal {
    open fun makeSound() {
        println("动物发出声音")
    }
}

abstract class Dog : Animal() {
    override abstract fun makeSound()
}

这里Dog抽象类继承自Animal抽象类,一个类不能同时继承多个抽象类 。

而接口则不同,一个类可以实现多个接口,通过实现多个接口,一个类可以拥有多个不同的行为集合 。例如:


interface Flyable {
    fun fly()
}

interface Runable {
    fun run()
}

class Bird : Flyable, Runable {
    override fun fly() {
        println("鸟儿飞翔")
    }

    override fun run() {
        println("鸟儿奔跑")
    }
}

在这个例子中,Bird类实现了FlyableRunable两个接口,从而具备了飞翔和奔跑的行为 。

(三)属性

抽象类可以包含非抽象属性,这些属性可以有初始值,也可以在构造函数中初始化 。例如,我们在Shape抽象类中添加一个非抽象属性borderWidth


abstract class Shape(val color: String) {
    abstract val name: String
    abstract fun calculateArea(): Double

    val borderWidth: Int = 1

    fun printName() {
        println("形状名称: $name")
    }
}

在这个例子中,borderWidth是一个非抽象属性,它有初始值1

接口中的属性默认是抽象的,必须在实现接口的类中重写并提供具体实现,除非该属性提供了getter的默认实现 。例如:


interface ShapeInterface {
    val name: String
    val borderWidth: Int
        get() = 1
}

class Rectangle : ShapeInterface {
    override val name: String = "矩形"
    override val borderWidth: Int
        get() = super.borderWidth
}

在这个例子中,ShapeInterface接口中的name属性是抽象的,没有默认实现,必须在实现类Rectangle中重写;而borderWidth属性提供了getter的默认实现,在Rectangle类中如果不需要修改其行为,可以直接使用默认实现 。

(四)方法实现

抽象类可以包含非抽象方法的实现,子类可以继承这些方法,也可以根据需要重写它们 。例如,我们在Shape抽象类中的printName()方法就是一个非抽象方法,有具体的实现 。

接口中的方法默认是抽象的,没有方法体,必须在实现接口的类中实现 。不过,从 Kotlin 1.4 + 开始,接口支持方法的默认实现 。例如,我们在Vehicle接口中添加一个带默认实现的方法startEngine()


interface Vehicle {
    fun start()
    fun stop()

    fun honk() {
        println("嘟嘟!")
    }

    fun startEngine() {
        println("发动机启动")
    }
}

在这个例子中,start()stop()方法是抽象的,必须在实现类中实现;而honk()startEngine()方法是带默认实现的方法,实现类可以选择重写这些方法,也可以使用默认实现 。

(五)访问修饰符

抽象类可以使用privateprotectedpublic等访问修饰符来控制成员的访问权限 。private修饰的成员只能在抽象类内部访问,protected修饰的成员可以在抽象类及其子类中访问,public修饰的成员可以在任何地方访问 。例如:


abstract class Shape {
    private val privateProperty: String = "私有属性"
    protected val protectedProperty: String = "受保护属性"
    val publicProperty: String = "公共属性"

    private fun privateMethod() {
        println("这是一个私有方法")
    }

    protected fun protectedMethod() {
        println("这是一个受保护方法")
    }

    fun publicMethod() {
        println("这是一个公共方法")
    }
}

在这个例子中,privatePropertyprivateMethod()是私有的,只能在Shape抽象类内部访问;protectedPropertyprotectedMethod()是受保护的,可以在Shape抽象类及其子类中访问;publicPropertypublicMethod()是公共的,可以在任何地方访问 。

接口成员默认是public的,不能有private修饰符 。这是因为接口的主要目的是定义一组可供其他类实现的行为,这些行为通常是对外公开的 。例如:


interface MyInterface {
    fun method1()
    fun method2()
}

在这个MyInterface接口中,method1()method2()方法默认都是public的,不能声明为private

五、使用建议与场景

(一)抽象类使用场景

当你需要定义一个通用的基类,并且这个基类包含一些通用的属性、方法以及构造函数时,抽象类是一个很好的选择 。例如,在 Android 开发中,我们经常会创建一个BaseActivity抽象类,它包含了一些所有 Activity 都通用的逻辑,如设置布局、初始化视图、加载数据等:


import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

abstract class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(getLayoutId())
        initViews()
        initData()
    }

    // 抽象方法,由子类实现,返回布局ID
    abstract fun getLayoutId(): Int

    // 抽象方法,由子类实现,初始化视图
    abstract fun initViews()

    // 抽象方法,由子类实现,加载数据
    abstract fun initData()
}

然后,具体的 Activity 类可以继承自BaseActivity,并实现其中的抽象方法 。例如:


class MainActivity : BaseActivity() {
    override fun getLayoutId(): Int {
        return R.layout.activity_main
    }

    override fun initViews() {
        // 初始化视图的具体逻辑
    }

    override fun initData() {
        // 加载数据的具体逻辑
    }
}

通过这种方式,我们可以将通用的逻辑提取到BaseActivity抽象类中,减少代码重复,提高代码的可维护性和可扩展性 。

(二)接口使用场景

当你只需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为时,接口是更合适的选择 。例如,在一个图形绘制库中,我们可以定义多个接口来表示不同的功能:


// 定义一个可绘制的接口
interface Drawable {
    fun draw()
}

// 定义一个可点击的接口
interface Clickable {
    fun onClick()
}

// 定义一个可拖动的接口
interface Draggable {
    fun drag()
    fun drop()
}

然后,一个类可以实现多个接口,以具备多种行为 。比如一个Button类可以同时实现DrawableClickableDraggable接口:


class Button : Drawable, Clickable, Draggable {
    override fun draw() {
        println("绘制按钮")
    }

    override fun onClick() {
        println("按钮被点击")
    }

    override fun drag() {
        println("按钮被拖动")
    }

    override fun drop() {
        println("按钮被放下")
    }
}

通过接口,我们可以让不同的类灵活地组合不同的行为,而不受单继承的限制,使代码更加灵活和可复用 。

六、总结回顾

通过今天的学习,我们深入了解了 Kotlin 中抽象类和接口这两个重要的概念 。抽象类像是一个未完成的蓝图,不能被直接实例化,它为子类提供通用的属性和方法定义,子类继承抽象类时必须重写所有抽象成员 。而接口则是一份行为契约,定义了一组方法签名,一个类可以实现多个接口,以获得多种行为 。

它们在构造函数、多重继承、属性、方法实现以及访问修饰符等方面都存在明显的区别 。在实际的 Kotlin 开发中,我们要根据具体的需求来选择使用抽象类还是接口 。如果需要定义一个通用的基类,并且这个基类包含构造函数、非抽象属性和方法,那么抽象类是合适的选择 ;如果只是需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为,接口则更为合适 。 希望大家在今后的 Kotlin 编程中,能够熟练运用抽象类和接口,构建出更加健壮、灵活和可维护的代码 。如果对今天的内容还有任何疑问,欢迎在评论区留言交流 。

Angular学习笔记24:Angular 响应式表单 FormArray 与 FormGroup 相互嵌套

Angular 响应式表单 FormArray 与 FormGroup 相互嵌套

在类文件中(组件的TS文件):
声明一个Form表单:
  

 public validateForm: FormGroup;


在构造方法中:
  

  private fb: FormBuild;


    声明一个FormBuild的对象

在构造方法中:
  

constructor(private fb: FormBuilder) {
     this.validateForm = this.fb.group({
        name: [null],
        sex : [null],
        age : [null],
        address: this.fb.array([
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            }),
        ]),
    });
  }

这样在组件中就构造出来了一个嵌套了FormArray的FormGroup,
这个时候,需要将validateForm这个表单中address的属性实例成一个FormArray

使用Angular中的get 方法

  

 get addressFormArray(){
        return this.validateForm.controls['address'] as FormArray;
    }

这个时候,在组件中就会生成一个变量:addressFormArray;
    当想对表单中的address中的控件进行操作,可以直接对变量:addressFormArray进行操作;

1.对validateForm中的address增加一对新的 street 和 country 有两种方法:
    a.使用变量addressFormArray,具体如下:
      

  this.addressFormArray.push(
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            }),
        )


    b.直接对validateForm进行操作
      

 (this.validateForm.controls['address'] as FormArray).push(
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            })
        )

对validateForm的增加,可以放在一个事件的方法里

2.去掉validateForm中的address对某一对属性的控制,
    正常情况下,是可以知道在当前删除的是 street 和 country在address这个数组中的下标,从而可以快速准确的删除,同样,删除也是可以有两种方式:
        a.使用变量addressFormArray,具体如下:
          

  this.addressFormArray.removAt(需要删除元素的下标)


        b.直接对validateForm进行操作,具体如下:
          

 (this.validateForm.controls['address'] as FormArray).removeAt(需要删除的数组的下标)


3.在模版文件中如何显示
    

<form [FormGroup]="validateForm">
        <div> 
            ... 
            <!-- 关于直接在FormGroup的部分省略 -->
        </div>
    <div FormArrayName="adderss">
        <div *ngFor="let address of validateForm.controls['address'].controls;let i = index">
            <div [formGroupName]="i">
                <div>
                    在这里就可以自己定义address 中FormGroup的内容了,增加关于FormGroup的控件。
                </div>
            </div>
            
        </div>
    </div>

 

Angular6学习笔记13:HTTP(3)

HTTP

继学习笔记12以后,可以模拟向后端发送get/post/put/delete请求了。在项目中,有一个table,这个table的数据非常多,就好比现在的heroList,需要根据用户输入的信息发送给远端服务器,让远端服务器通过这个信息,返回搜索结果。现在要检索heroList中的信息,就需要一个输入框,让用户输入检索的值,然后将这个值发送给远端服务器(模拟),然后让远端服务器(模拟)返回检索的结果。

1.在heroService中创建一个关于搜索的方法:searchHeroes()

searchHeroes(term: string): Observable<Hero[]> {
  if (!term.trim()) {
    return of([]);
  }
  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
    tap(_ => this.log(`found heroes matching "${term}"`)),
    catchError(this.handleError<Hero[]>('searchHeroes', []))
  );
}

在这个方法中,当没有搜素词,则返回一个空的数组,当有搜索词的时候,在url中拼接上name

2.在仪表盘的组件中添加搜索功能

<h3>Top Heroes</h3>
<div class="grid grid-pad">
  <a *ngFor="let hero of heroes" class="col-1-4"
      routerLink="/detail/{{hero.id}}">
    <div class="module hero">
      <h4>{{hero.name}}</h4>
    </div>
  </a>
</div>

<app-hero-search></app-hero-search>

这里会让这个应用挂了,因为找不到<app-hero-search></app-hero-search>(接下来创建)

3.创建HeroSearchComponent

利用angular CLI 创建组件

ng generate component hero-search

CLI 生成了 HeroSearchComponent 的三个文件,并把该组件添加到了 AppModule 的声明中。

修改HeroSearch组件的模版文件:

<div id="search-component">
  <h4>Hero Search</h4>
  <input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
  <ul class="search-result">
    <li *ngFor="let hero of heroes$ | async" >
      <a routerLink="/detail/{{hero.id}}">
        {{hero.name}}
      </a>
    </li>
  </ul>
</div>

在模版文件中,创建了keyup 事件,这个keyup事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。

*ngFor 是在一个名叫 heroes$ 的列表上迭代,在这里$ 是一个命名惯例,用来表明 heroes$ 是一个 Observable,而不是数组。

正常情况下,*ngFor是不能直接使用Observable,此时,就要用到管道,利用管道字符(|),后面紧跟着一个 async,它表示 Angular 的 AsyncPipeAsyncPipe 会自动订阅到 Observable,这样你就不用再在组件类中订阅了。

美化这个HeroSearch组件,修改heroSearch的CSS文件

.search-result li {
  border-bottom: 1px solid gray;
  border-left: 1px solid gray;
  border-right: 1px solid gray;
  width:195px;
  height: 16px;
  padding: 5px;
  background-color: white;
  cursor: pointer;
  list-style-type: none;
}

.search-result li:hover {
  background-color: #607D8B;
}

.search-result li a {
  color: #888;
  display: block;
  text-decoration: none;
}

.search-result li a:hover {
  color: white;
}
.search-result li a:active {
  color: white;
}
#search-box {
  width: 200px;
  height: 20px;
}


ul.search-result {
  margin-top: 0;
  padding-left: 0;
}

修改HeroSearch 的类文件

import { Component, OnInit } from '@angular/core';

import { Observable, Subject } from 'rxjs';

import {
   debounceTime, distinctUntilChanged, switchMap
 } from 'rxjs/operators';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-search',
  templateUrl: './hero-search.component.html',
  styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
  heroes$: Observable<Hero[]>;
  private searchTerms = new Subject<string>();

  constructor(private heroService: HeroService) {}

  // Push a search term into the observable stream.
  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.heroes$ = this.searchTerms.pipe(
      // wait 300ms after each keystroke before considering the term
      debounceTime(300),

      // ignore new term if same as previous term
      distinctUntilChanged(),

      // switch to new search observable each time the term changes
      switchMap((term: string) => this.heroService.searchHeroes(term)),
    );
  }
}

注意,heroes$ 声明为一个 Observable

a.RxJS Subject 类型的 searchTerms

Subject 既是可观察对象的数据源,本身也是 Observable。可以像订阅任何 Observable 一样订阅 Subject。还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。search() 是通过对文本框的 keystroke 事件的事件绑定来调用的。

private searchTerms = new Subject<string>();

search(term: string): void {
  this.searchTerms.next(term);
}

每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。

b.串联 RxJS 操作符

每当用户击键后就直接调用 searchHeroes() 将导致创建海量的 HTTP 请求,浪费服务器资源并消耗大量网络流量。

应该怎么做呢?ngOnInit() 往 searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes() 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[] )。

this.heroes$ = this.searchTerms.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((term: string) => this.heroService.searchHeroes(term)),
);
  • 在传出最终字符串之前,debounceTime(300) 将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。

  • distinctUntilChanged() 会确保只在过滤条件变化时才发送请求。

  • switchMap() 会为每个从 debounce 和 distinctUntilChanged 中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。

借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。

switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。

注意,取消前一个 searchHeroes() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。

 

 

 

 

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

一个让人头秃的 bug

上周组里一个同事来问我:"为什么我给 reactive 对象加了个新属性,页面不更新?"

我看了一眼代码——Vue2 的写法,用的 Vue3 的 API。

const state = reactive({ name: '张三' })

// 他的操作:
state.age = 25 // 页面更新了 ✅(Vue3 没问题)

// 但他之前在 Vue2 项目里被坑过,条件反射写了:
Vue.set(state, 'age', 25) // Vue3 里根本没有 Vue.set 了

这件事让我意识到:很多人用了两三年 Vue3,但对响应式到底怎么工作的,还是停留在"Proxy 比 defineProperty 好"这句话上。

好在哪?为什么好?依赖怎么收集的?什么时候触发更新?

今天咱们把这事彻底说清楚,顺便手写一个能跑的迷你 reactivity。


Vue2 的 defineProperty 到底差在哪

先别急着夸 Proxy,得知道 Vue2 为啥被淘汰。

// Vue2 的响应式核心:逐个属性拦截
Object.defineProperty(obj, 'name', {
  get() {
    // 收集依赖
    return value
  },
  set(newVal) {
    value = newVal
    // 通知更新
  }
})

// ❌ 问题1:新增属性拦截不到,必须用 Vue.set
obj.age = 25 // set 根本不会触发,页面不更新

// ❌ 问题2:数组下标修改拦截不到
arr[0] = 'new' // 没反应

// ❌ 问题3:初始化时要递归遍历整个对象,性能差
// 1000 个属性 → 1000 次 defineProperty

本质问题就一句话:defineProperty 是"属性级别"的拦截,你得提前知道有哪些属性。

这就像安检——defineProperty 是给每个人单独装一个安检门,来一个新人得现装;Proxy 是在大楼入口装一个,谁进来都得过。


Proxy:对象级别的拦截

const raw = { name: '张三', age: 25 }

const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取了 ${key}`)  // 任何属性的读取都能拦截
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置了 ${key} = ${value}`)
    target[key] = value
    return true  // set 必须返回 true,不然严格模式报错
  }
})

proxy.name           // → "读取了 name"
proxy.hobby = '摸鱼'  // → "设置了 hobby = 摸鱼"  ✅ 新增属性也能拦截!
delete proxy.age     // 配合 deleteProperty trap,删除也能拦截

不用提前遍历,不用 Vue.set,天然支持新增/删除属性。这不是"好一点",这是降维打击。


光有 Proxy 还不够

拦截到读写只是第一步。关键问题是:谁在读?读了之后要通知谁?

这就是依赖收集。

想象一个场景:你在公司群里发了条消息"今晚团建",但不是所有人都需要知道——只有你组里的人需要收到通知。响应式系统干的就是这事:精准投递,别群发。

核心流程就三步:

  1. 读取(get) → 谁在读我?记住他(track)
  2. 修改(set) → 值变了,通知所有记住的人(trigger)
  3. effect → "那个在读的人"到底是谁?就是当前正在执行的副作用函数

手撸一个迷你 reactivity

别怕,核心代码不到 80 行。

第一步:全局变量——当前正在执行的 effect

// 全局指针:当前谁在执行?
// 这是整个系统的"指挥棒"
let activeEffect: Function | null = null

function effect(fn: Function) {
  activeEffect = fn  // 先把"当前执行者"挂上
  fn()               // 执行函数 → 函数内部会读取响应式数据 → 触发 get
  activeEffect = null // 执行完了,摘掉
}

这里有个精妙的设计:执行 fn() 的时候,fn 内部读取了什么属性,Proxy 的 get 就知道"当前是谁在读"。

时序上是这样的:

effect(() => console.log(state.name))
│
├─ activeEffect = fn        ← 挂上
├─ fn()                     ← 开始执行
│   └─ 读取 state.name      ← 触发 Proxy get
│       └─ get 里发现 activeEffect 不为 null
│           └─ 记住:name 这个属性被 fn 依赖了!(track)
└─ activeEffect = null       ← 摘掉

第二步:依赖存储结构

// 依赖关系的存储:target → key → Set<effect>
// 用 WeakMap 是为了不阻止对象被垃圾回收
const targetMap = new WeakMap<object, Map<string | symbol, Set<Function>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return // 没人在执行 effect,不用收集

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }

  deps.add(activeEffect) // 把当前 effect 加进去
}

数据结构长这样:

targetMap (WeakMap)
  └─ { name: '张三', age: 25 }  (Map)
       ├─ 'name'Set [ effect1, effect2 ]
       └─ 'age'Set [ effect3 ]

为什么用三层结构? 因为一个应用里有多个响应式对象(target),每个对象有多个属性(key),每个属性可能被多个 effect 依赖。三层刚好,多了浪费,少了不够。

第三步:触发更新

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (!deps) return

  // 遍历所有依赖了这个 key 的 effect,逐个执行
  deps.forEach(effect => effect())
}

简单粗暴:找到谁依赖了这个 key,挨个重新执行。

第四步:组装 reactive

function reactive<T extends object>(raw: T): T {
  return new Proxy(raw, {
    get(target, key, receiver) {
      track(target, key)                  // 读取时收集依赖
      const result = Reflect.get(target, key, receiver)

      // 如果值是对象,递归代理(懒代理,用到才包)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key as keyof T]
      const result = Reflect.set(target, key, value, receiver)

      if (oldValue !== value) {
        trigger(target, key)              // 值变了才触发,没变不浪费
      }
      return result
    }
  })
}

注意两个细节:

  • 懒代理:Vue3 不会在初始化时递归代理整个对象,只有 get 到嵌套对象时才代理。对比 Vue2 初始化就递归遍历,这就是性能差距。
  • Reflect.get/set:不直接用 target[key],因为 Reflect 能正确处理 this 指向和继承问题。你可能觉得"直接读不也行吗"——行,但在有 getter/继承的场景会出 bug。

跑一下看看

const state = reactive({ count: 0, msg: 'hello' })

// effect 1:只依赖 count
effect(() => {
  console.log('count changed:', state.count)
})
// 立即输出:count changed: 0

// effect 2:只依赖 msg
effect(() => {
  console.log('msg changed:', state.msg)
})
// 立即输出:msg changed: hello

state.count++
// → "count changed: 1"   ✅ effect1 触发
// → (effect2 没触发)      ✅ 精准更新,不是无脑全刷

state.msg = 'world'
// → "msg changed: world"  ✅ effect2 触发

70 多行代码,一个能跑的响应式系统就出来了。


设计权衡:Vue3 做了哪些取舍

为什么用 WeakMap 而不是 Map?

// WeakMap 的 key 是弱引用,对象没有其他引用时会被 GC 回收
// 如果用 Map → 响应式对象永远被 targetMap 引用 → 内存泄漏
const targetMap = new WeakMap() // ✅
const targetMap = new Map()     // ❌ 内存泄漏风险

为什么 effect 要立即执行一次?

因为不执行就收集不到依赖。依赖收集发生在 get 里,不读一遍属性,系统不知道你依赖了谁。

这也是 watchEffectwatch 的核心区别:

// watchEffect → 立即执行,自动收集依赖
watchEffect(() => {
  console.log(state.count) // 读了 count → 自动依赖 count
})

// watch → 你手动告诉它监听谁
watch(() => state.count, (newVal) => {
  console.log(newVal)
})

懒代理 vs 初始化全量代理

策略 初始化耗时 运行时耗时 适合场景
Vue2 全量递归 高(大对象很慢) 小型对象
Vue3 懒代理 几乎为零 首次访问有微小开销 大型 / 深层嵌套对象

Vue3 选了懒代理,因为实际项目中大部分属性不会在首帧全部读取——你一个 1000 行的 config 对象,首屏可能只用了 5 个字段,全量代理纯属浪费。


我们的迷你版漏了什么

写到这里你可能觉得"就这?挺简单啊"。别急,真实的 Vue3 reactivity 还处理了一堆你想不到的边界情况:

1. effect 嵌套

effect(() => {
  console.log('outer', state.a)
  effect(() => {
    console.log('inner', state.b) // 内层 effect 执行完,activeEffect 被置为 null
  })
  console.log(state.c) // ❌ 这里 activeEffect 已经是 null 了,c 的依赖收集不到!
})

Vue3 用 effectStack(栈结构)解决这个问题——进入 effect 时 push,退出时 pop,恢复上一层的 activeEffect。

2. 无限循环

effect(() => {
  state.count = state.count + 1 // 读 count → 触发 get → 收集依赖
                                 // 写 count → 触发 set → 执行 effect
                                 // effect 又读 count → 又触发 set → 💥 死循环
})

Vue3 的解法:如果当前正在执行的 effect 和要触发的 effect 是同一个,跳过。

3. ref 的存在意义

// reactive 只能代理对象
const count = reactive(0)  // ❌ Proxy 不能代理基本类型

// ref 用对象包一层,把基本类型变成对象
const count = ref(0)       // 内部:{ value: 0 } → 再用 reactive 代理
count.value++               // 通过 .value 触发 get/set

所以 ref.value 不是脱裤子放屁——是基本类型没法直接 Proxy 的无奈之举。

4. 集合类型的处理

const map = reactive(new Map())
map.set('key', 'value') // set 方法不是赋值操作,Proxy 的 set trap 拦不到

// Vue3 对 Map/Set/WeakMap/WeakSet 做了专门的 handler
// 拦截的是 get → 拿到 set 方法 → 返回一个包装后的 set 方法

这部分代码在 Vue3 源码里占了不少篇幅,也是最容易被忽略的。


可扩展性:这套模型能做什么

这套 track → trigger → effect 模型不只是给 Vue 用的,它本质上是一个自动依赖追踪的发布-订阅系统

你完全可以用它来做:

  • 状态管理:Pinia 的底层就是 reactive + 一些封装
  • 计算缓存:computed 就是一个带 dirty 标记的 effect
  • 跨组件通信:provide/inject + reactive = 自动响应式的上下文注入
  • 表单引擎:字段之间的联动关系,天然适合响应式依赖图

如果你的项目需要一套"数据变了自动通知"的机制,不一定要上 Vue,把这 70 行代码抄走改改就能用。


总结:一个通用模型

Vue3 响应式的本质,是解决一个古老的编程问题:状态同步。

A 变了,B 要跟着变。手动同步容易漏、容易错、容易忘。

Vue3 的解法是:

  1. Proxy 拦截读写——知道谁被读了、谁被改了
  2. effect + activeEffect——知道"谁在关心这个数据"
  3. track / trigger——自动建立和触发依赖关系
  4. WeakMap 三层结构——高效存储依赖映射

以后遇到类似的问题,不管是前端状态管理、后端事件驱动、还是 Excel 的单元格公式联动,底层模型都是一样的:观察者模式 + 自动依赖追踪。

区别只在于:谁来当观察者,怎么收集依赖,粒度做到多细。

想明白这一层,你看任何响应式框架(Solid、Svelte 5 runes、Preact signals)都会觉得——嗯,换了个壳,内核没跑出这个圈。

Tapable学习

2020.2.28

前言

看webpack源码就绕不过Tapable,Tapable介绍里都会提到这么一句:Tapable是个类似于Node.js EventEmitter的库。于是不禁发问:EventEmitter有什么场景无法满足事件管理,导致开发了个Tapable呢?

本文带着这个问题,对Tapable是什么、如何使用、底层如何实现等知识进行介绍。

NodeJS EventEmitter

先来看一个使用EventEmitter注册自定义事件并触发的例子:

const EventEmitter = require('events').EventEmitter; 
const events = new EventEmitter()
// 注册自定义事件
events.on('login', (param) => {
  console.log('triggered', param)
})
events.once('logout', function() {
  console.log('logout triggered')
})
// 触发
events.emit('login', 'success') // triggered sucess
events.emit('loginout') // logout triggered

EventEmitter:

1.提供了on emit once三个方法

2.如果给一个事件订阅了多个监听器,那么会按注册顺序执行

那么,如果一个事件名称注册了多个监听器,如何控制这些监听器的执行顺序?如何在监听器之间传递值?如何中止某个监听器的执行?监听器有多个异步操作,想等这些异步操作都执行完了如何处理?

似乎做不到,猜想也许正是由于这些问题,产生了Tapable.

Tapable是什么

一个用于自定义事件的触发和处理管理的库,比EventEmitter强大。它定义了多种事件类型,能以更多方式控制监听器的执行,具体来说,

Tapable提供了9个钩子类型,使用不同类型的钩子自定义事件,能覆盖以下场景:

  • 连续地执行监听器
  • 并行地执行监听器
  • 一个接一个地执行监听器,从前面的监听器获取输入
  • 异步地执行监听器
  • 在允许时停止执行监听器

Tapable是webpack的一个核心组件,但它也可以用于其他类似提供插件接口场景的应用。

webpack中很多对象是继承自Tapable的.

Tapable的9个钩子类型

const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
 } = require("tapable");
序号 钩子名称 执行方式 特点
1 SyncHook 同步串行 不关心监听函数返回值
2 SyncBailHook 同步串行 监听函数中只要有一个return 有值,后面的就不执行
3 SyncWaterfallHook 同步串行 上一个监听函数返回值可以传递给下一个监听函数
4 SyncLoopHook 同步循环 监听函数返回true则一直循环执行,返回undefined则停止
5 AsyncParallelHook 异步并行 不关心监听函数返回值
6 AsyncParallelBailHook 异步并行 监听函数返回值不为null则后面的不执行,然后执行callAsync的回调函数(如果有的话)
7 AsyncSeriesHook 异步串行 不关心监听函数的参数
8 AsyncSeriesWaterfallHook 异步串行 监听函数的参数不为null,则直接执行callAsync的回调函数(如果有的话)
9 AsyncSeriesBailHook 异步串行 上一个监听函数callback(err, data)的第二个参数data作为值传递给下一个监听函数的参数

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

Tapable的使用

基本用法

注册

注册:tap/tapAsync/tapPromise

其中同步钩子使用tap注册,异步钩子使用tapAsync/tapPromise注册(效果不同)

调用

call/callAsync

Tapable SyncHook Demo:

const { SyncHook } = require('tapable')
const hook = new SyncHook(['arg1', 'arg2'])  // 自定义callback的参数

hook.tap('eventname', (arg1, arg2, arg3) => {
  console.log(arg1, arg2, arg3) // 1, undefined, undefined
})

hook.call(1)
const { SyncHook } = require('tapable')
class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}
const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50)

拦截器interception

tapable还提供了拦截器。所有的钩子都提供了拦截API, 共有register, call, tap, loop 四个API,这里不展开讲

原理实现

从上面的demo来看,Tapable的使用方式与EventEmitter还是不太一样的。(不同于on emit)那么它是如何实现事件的监听与触发的呢(call方法执行时如何找到监听器函数并按规则执行)?

SyncHook源码阅读

我们以SyncHook为例,了解下内部机制:

Tapable声明了四个类

class Hook {} // 基础的Hook类

class SyncHook extends Hook {} // 同步钩子类

class HookCodeFactory {}  // 用于生成hook代码的工厂类,类里的create方法使用new Function 为sync, async, promise三类钩子生成call时的fn

class SyncHookCodeFactory extends HookCodeFactory {}

其中,HookCodeFactory是编译生成可执行 fn 的工厂类,这意味着Hook的函数体代码是拼接生成的, HookCodeFactory类里的create方法使用new Function 为sync, async, promise三类钩子生成函数代码

初始化

class Hook {
  // ...
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
} // constructor里: this.call = this._call;

其中createCompileDelegate:

function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type); // name: 'call'
return this[name](...args);
};
}

这里lazyCompileHook是个惰性函数 ,创建this.call函数,并在下一次使用时直接返回(提升性能)

其中_createCall是Hook类的成员函数:

class Hook {
// ...
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
// ...
}

其中compile:

class Hook {
// ...
  compile(options) {
factory.setup(this, options); // 取出taps里的fn
return factory.create(options);  // factory即是上述四个类中的SyncHookCodeFactory,create用来生成代码
}
// ...
}

factory.create根据"call", "sync"两个参数,生成了方法的函数体代码,这样就定义好了call函数:

class HookCodeFactory {
  create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
fn = new Function( // 生成动态的function
this.args(),
'"use strict";\n' +
this.header() +
this.content({ // this.content 每种类型的钩子不同的实现 会去取this.taps
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.content({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
let code = "";
code += '"use strict";\n';
code += "return new Promise((_resolve, _reject) => {\n";
if (errorHelperUsed) {
code += "var _sync = true;\n";
code += "function _error(_err) {\n";
code += "if(_sync)\n";
code += "_resolve(Promise.resolve().then(() => { throw _err; }));\n";
code += "else\n";
code += "_reject(_err);\n";
code += "};\n";
}
code += this.header();
code += content;
if (errorHelperUsed) {
code += "_sync = false;\n";
}
code += "});\n";
fn = new Function(this.args(), code);
break;
}
this.deinit();
return fn;
}
}

这里的new Function解释下:我们知道,js中定义函数常用的有:

// 定义1. 函数声明
function add(a, b){
    return a + b
}

// 定义2. 函数表达式
const add = function(a, b){
    return a + b
}
```js
还有第三种,我们平常用的相对较少:
```js
// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')

他们的区别就是前两种是静态的,函数的功能是做什么是在定义时就定下来了;而第三种是动态的,也就是函数的功能可能会随程序运行发生变化。

tap收集监听器

class Hook {
  constructor(args) {
this.taps = [];
this.call = this._call;
}
  tap(options, fn) {
      this._insert(options);
  }
  _insert(item) {
    // ...
    // 检查name 
    if (before.has(x.name)) {
before.delete(x.name);
continue;
}
      this.taps[i] = item; // this.taps里保存了所有的监听器
  // ...
  }
}

示例

假设有下列demo使用代码:

const { SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50) // createCall时,已经根据之前tap,生成了call时的代码。 call执行时,会依次执行callback代码

这里我们执行到call时,进入函数,断点看一下call方法的函数体代码:

function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];// 注册的第一个监听器
  _fn0(newSpeed); // 执行
  var _fn1 = _x[1]; // 注册的第二个监听器
  _fn1(newSpeed);// 执行
}

总结一下,比较让我印象深刻的是:generate code这个地方

官网上 有段介绍很棒:

The Hook will compile a method with the most efficient way of running your plugins. It generates code depending on:

  • The number of registered plugins (none, one, many)
  • The kind of registered plugins (sync, async, promise)
  • The used call method (sync, async, promise)
  • The number of arguments
  • Whether interception is used

This ensures fastest possible execution.

也就是,Tapable会根据注册的监听器的数量、种类、call方法,参数个数,是否有拦截器等生成钩子的本体代码。

其他类型Hook如何管理顺序的?

SyncBailHook this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
    return _result0;;
  } else {
    var _fn1 = _x[1];
    var _result1 = _fn1(newSpeed);
    if(_result1 !== undefined) {
      return _result1;
    } else {
    }
  }
}"

SyncWaterfallHook  this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
  newSpeed = _result0;
  }
  var _fn1 = _x[1];
  var _result1 = _fn1(newSpeed);
  if(_result1 !== undefined) {
  newSpeed = _result1;
  }
  return newSpeed;
}"

AsyncParallelHook Demo:

const { AsyncParallelHook, SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new AsyncParallelHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

// 1. tap
// myCar.hooks.accelarate.tap('eventname1', (speed) => {
//   console.log('speed cb 1:', speed)
// })

// myCar.hooks.accelarate.tap('eventname2', (speed) => {
//   console.log('speed cb 2:', speed)
// })

// myCar.hooks.accelarate.callAsync(50, (err) => {
//   console.log('end') // 
// })

// 2. tapAsync 注意这种用法里 参数多了个cb 回调
myCar.hooks.accelarate.tapAsync('eventname1', (speed, cb) => {
  setTimeout(() => {
    console.log(1, speed);
    cb();
}, 1000);
})

myCar.hooks.accelarate.tapAsync('eventname2', (speed, cb) => {
  setTimeout(() => {
    console.log(2, speed);
    cb();
}, 2000);
})

myCar.hooks.accelarate.callAsync(50, () => {
  console.log('end') // 
})

// 3.tapPromise
// myCar.hooks.accelarate.tapPromise('eventname1', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(1, speed);
//         resolve();
//     }, 1000);
//   });
// })

// myCar.hooks.accelarate.tapPromise('eventname2', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(2, speed);
//         resolve();
//     }, 2000);
//   });
// })

// myCar.hooks.accelarate.promise(50).then(() => {
//   console.log('end')
// })

AsyncParallelHook  this.callAsync.toString():

function anonymous(
  newSpeed,
  _callback,
  /*``*/
) {
  'use strict';
  var _context;
  var _x = this._x;
  do {
    var _counter = 2;
    var _done = () => {
      _callback();
    };
    if (_counter <= 0) break;
    var _fn0 = _x[0];
    _fn0(newSpeed, _err0 => {
      if (_err0) {
        if (_counter > 0) {
          _callback(_err0);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
    if (_counter <= 0) break;
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        if (_counter > 0) {
          _callback(_err1);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
  } while (false);
}

AsyncParallel counter计数器,每执行完一个计数器减一,减到0则执行回调

function anonymous(
  newSpeed,
  _callback
  /*``*/
) {
  "use strict";
  var _context;
  var _x = this._x;
  function _next0() {
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        _callback(_err1);
      } else {
        _callback();
      }
    });
  }
  var _fn0 = _x[0];
  _fn0(newSpeed, _err0 => {
    if (_err0) {
      _callback(_err0);
    } else {
      _next0();
    }
  });
}

AsyncSeriesHook 按顺序执行,回调函数执行法

Tips

Tapable 2.0 处于beta阶段,据说针对async的执行进行了优化(减小内存消耗问题,消除递归)

webpack + Tapable

webpack 就像一条生产线,具有多个处理流程,每个流程职责单一,流程之间有依赖关系,处理完交个下个流程。

插件就像插入到生产线中的一个功能,能在特定的时机对生产线上的资源做处理。

加入生产线的方式就是监听webpack广播出来的事件。

webpack通过Tapable来组织这条生产线。

好处:有序性好、扩展性好

webpack中都使用了什么样的钩子呢?

Compiler钩子 类型 描述
done AsyncSeriesHook 如果有两个插件监听了done钩子,意味着两个顺序执行完了才会执行回调
entryOption AsyncBailHook 如果有两个插件监听了entryOption钩子,意味着如果两个插件都开启了时,有一个plugin触发了如果有返回值,则另一个不会触发
make AsyncParallelHook 如果有四个插件监听了make钩子,意味着call触发时,回调函数会等待这四个插件的监听器并行执行完才会执行

调试技巧

1.断点查看function body code: fn.toString()

Reference

  1. 编写自定义webpack插件从理解Tapable开始: juejin.im/post/5dcba2…
  2. webpack4.0源码分析之Tapable: juejin.im/post/5abf33… (demo较多)
  3. Tapable Github:github.com/webpack/tap…
  4. js之惰性函数:juejin.im/entry/5a629…
  5. 可能是全网最全最新最细的 webpack-tapable-2.0 的源码分析

打包原理

现在我们站在打包工具的角度来理解下打包原理,假设我们自己要去做一个打包工具,想象一下都应该做什么,怎么做。

打包工具都应该做什么

打包工具负责把源码打包成目标代码。

这个打包过程就涉及很多问题了,先看下源码都长什么样

  • 源码中JS可能是由各种语法来写的,ES6, ES7, TS等;
  • 源码不止包含JS, 还有静态资源,例如CSS, 图片等;
  • 源码的代码组织方式上,出于模块化的编程思想,项目代码会被自己拆解为多个文件,文件之间通过一定的依赖关系组织在一起;
  • 每个文件(模块)可能遵循不同的模块规范(CommonJS, ES6 module, AMD etc);

而目标代码是可能运行在不同环境上的,例如浏览器,NodeJS等。

因此,打包工具要做的就是把这样的源码打包成能运行在目标环境中的代码。

由此,一款打包工具应该做哪些工作从上述分析源码特点这里就可以知道了,它需要解决的问题至少有下面几个:

  • 转换

    • 例如语法转换(使用了ES6,TS等语法要转换为目标环境可以运行的语法)
  • 非JS的静态资源的处理

    • 例如CSS图片资源的处理
  • 识别各类模块规范并处理,并解析模块依赖关系

    • 例如CommonJS 中的require,浏览器并不认识,如何处理成为它可以运行的代码
    • 捋清楚模块的关系,打包到一起
  • 创建bundle代码

    • 针对一个入口,最终代码还是要化多模块为一份代码文件
  • 生成bundle文件

  • 输出代码文件,写入磁盘

三个打包工具如何实现的

那么,Webpack, Rollup, Parcel是现有的较为流行的打包工具,他们都是如何处理这些问题的呢?

(以下内容都假设目标环境是web)版本分别为:rollup: 1.28.0 webpack: 4.41.5 parcel-bundler:1.12.4

功能 Webpack Rollup Parcel
转换 js语法:需要配置babel-loader等完成转换 js语法:借助插件rollup-plugin-babel转换 js语法:无需配置,默认使用@babel/preset-env转换 开箱即用
非JS静态资源的处理 利用loader处理为模块(webpack主打“一切皆模块”的思想) 借助插件: - rollup-plugin-postcss - rollup-plugin-copy-assets 非js资源实际上支持的有限,与rollup定位有关系,适合库打包 无需配置,CSS/Less默认支持(会抽CSS为单独文件) Postcss需要.postcssrc文件 图片自动打包
解析模块依赖关系 使用acorn解析AST收集依赖关系 使用acorn解析AST收集依赖关系 使用babel解析AST收集依赖关系
创建bundle代码 使用template, __webpack_require__等辅助函数替换等,拼接成bundle代码,bundle形式:自执行函数,详见下述分析 利用magicString,代码拼接。bundle形式:自执行函数,代码粘贴,无额外辅助函数,详见下述分析 构建资源树,根据资源树构建Bundle树
生成bundle文件 使用文件系统如fs.writeFile写入磁盘 fs.writeFile写入磁盘 bundle形式:自执行函数,详见下述分析

这里我们来比较下在创建bundle代码上三者的不同:

Bundle形式/模块机制

webpack

webpack的模块我们看下打包后的代码形式:

// src/index.js:
const { b } = require('./b.js')
console.log(b)
// src/b.js
module.exports = {
  b: 'b'
}

// webpack.config.js mode:development

const path = require('path');
module.exports = {
    entry: {
      'index': './src/index.js',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js'
    },
    devtool: 'source-map',
    mode: 'development',
};

Webpack打包结果, mode:development, src代码使用commonjs模块规范

(function(modules) {
  function __webpack_require__(moduleId) {...}
  // ...
  return __webpack_require__(入口文件) // 假设入口文件是src/index.js
})(obj);

其中,参数modules传入的obj的 key, value是下列形式:key是模块文件路径,value是个function, function内部是:转换后的模块代码,也就是,把模块用function包裹了一层:

(mode:production 使用es6模块规范时,key 是id, mode:development 使用es6模块规范时,key是文件名称)

obj:

{
"./src/index.js": function() { 转换后当前模块的代码 },  // function 的三个参数:module, __webpack_exports__, __webpack_require__
"./src/b.js": function () { 转换后当前模块的代码 }
}

整个obj作为参数传递给自执行函数,自执行的结果应该是__webpack_require__(入口文件)的执行结果。

(可谓是运行时获取,即层层依赖的模块是在运行__webpack_require__时才拿到模块代码执行的。)

Rollup

再来看rollup rollup.config.js:

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'iife', // iife, cjs, amd, esm, umd
  }
}

src/index.js rollup默认使用ES6模块作为标准:

import { b } from './b'
console.log(b)

src/b.js:

export const b = 'b'

rollup打包结果:

(function () {
'use strict';
const b = 'b';
console.log(b);
}());

可以看到,rollup所采取的方式更直接一些,output format为iife时,外层是自执行函数,内部代码在导入模块时的处理像是将代码“粘贴”过去了一样,没有额外的require的实现。模块代码搬运到一起实现最终的输出。

可能会有疑问,变量名冲突了怎么办?rollup会自动为冲突的变量重新起名字。例如变量b出现了两次,那么就是var b, var b$1,...

Parcel

再看下parcel

parcel主打0配置和极速编译,所以没有配置文件

src/index.js:

const { b } = require('./b.js')
console.log(b)

src/b.js:

module.exports = {
  b: 'b'
}

parcel区分dev product是靠运行指令 parcel xxx.html/js 还是 parcel build xxx.html/js 来区分的。

这里,我们执行parcel index.html:

parcelRequire = (function(modules, cache, entry, globalName) { // entry数组
    var nodeRequire = typeof require === 'function' && require; 
  function newRequire(name, jumped) {
          if (!cache[name]) { 
            // ...
            // 没有模块缓存则取出模块执行
             modules[name][0].call(module.exports, localRequire, module, module.exports, this);
          }
      // 有模块缓存则返回模块
          return cache[name].exports;
  }
    newRequire.isParcelRequire = true;
    newRequire.isParcelRequire = true;
    newRequire.Module = Module;
    newRequire.modules = modules;
    newRequire.cache = cache;
    newRequire.parent = previousRequire;
    newRequire.register = function (id, exports) {
      modules[id] = [function (require, module) {
        module.exports = exports;
      }, {}];
    };
  for (var i = 0; i < entry.length; i++) {
      try {
        newRequire(entry[i]); // 遍历entry,开始执行 
      } catch (e) {
      }
    }
    if (entry.length) {
      // Expose entry point to Node, AMD or browser globals
      // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js
      var mainExports = newRequire(entry[entry.length - 1]);
      // CommonJS
      if (typeof exports === "object" && typeof module !== "undefined") {
        module.exports = mainExports;
      // RequireJS
      } else if (typeof define === "function" && define.amd) {
       define(function () {
         return mainExports;
       });
      // <script>
      } else if (globalName) {
        this[globalName] = mainExports;
      }
    }
    // Override the current require with this new one
    parcelRequire = newRequire;
    return newRequire;
})(obj, {}, ["../node_modules/parcel-bundler/src/builtins/hmr-runtime.js","index.js"], null)

其中参数modules传入的obj为:

{
  "b.js":[ function(){ 
    // ...b.js 的代码
  }, {}],// 空对象,说明b没有依赖其他模块
  "index.js": [ function(){
        // ...index.js的代码
        }, {
          "./b.js":"b.js" // index.js依赖b.js
        }],
  "../node_modules/parcel-bundler/src/builtins/hmr-runtime.js": [ // parcel dev模式需要的runtime代码
    function() {}, 
  ],
}

key value, key为模块文件名称(有时也会带路径),value是个数组,数组第一项为模块代码,第二项为该模块依赖其他模块的关系, 最后一项固定为parcel hmr 的runtime 文件(dev模式下)

parcel打包结果是一个自执行函数的同时,还将parcelRequire挂在了window上,不同于webpack的使用webpack_require来替换require或import,parcel将模块平铺展开,并将模块的依赖通过模块名指定出来。

备注

  1. magicstring: 快速轻量的工具,用来操作字符串,生成sourcemap等(例如replace, wrap some code, 生成sourcemap等)
  2. webpack target除了web还有,web-worker, electron等

小结

从打包结果来看,rollup是最直接和容易理解的,webpack 和 parcel 都各自用了自己的方法支持模块关系的解析。

代码比rollup打包结果大,由此,也容易看出,rollup适合类库类打包,webpack parcel适用于应用类项目的打包

Reference

  1. parcel官网:parceljs.org/
  2. Parcel 源码解读
  3. magicstring: github.com/Rich-Harris…

20个例子掌握RxJS——第十一章实现 WebSocket 消息节流

RxJS 实战:WebSocket 连接管理与消息节流

概述

WebSocket 是一种全双工通信协议,允许服务器和客户端之间进行实时双向通信。在实际应用中,我们需要:

  1. 管理连接状态:处理连接、断开、重连等
  2. 控制消息频率:避免发送过于频繁的消息
  3. 错误处理:处理连接错误和消息错误
  4. 自动重连:连接断开后自动重连

本章将介绍如何使用 RxJS 管理 WebSocket 连接,并使用 throttleTime 实现消息节流。

WebSocket 基础

WebSocket 的特点

  1. 全双工通信:客户端和服务器可以同时发送和接收消息
  2. 低延迟:比 HTTP 轮询更高效
  3. 持久连接:建立连接后保持打开状态
  4. 实时性:适合实时通信场景

WebSocket 连接状态

  • CONNECTING (0):正在连接
  • OPEN (1):连接已打开
  • CLOSING (2):正在关闭
  • CLOSED (3):连接已关闭

实现思路

1. WebSocket 连接管理

// WebSocket 服务器地址
private readonly wsUrl = 'ws://localhost:8080/ws';

// WebSocket 连接
private ws: WebSocket | null = null;

// 连接状态
connectionStatus: 'disconnected' | 'connecting' | 'connected' = 'disconnected';

// 连接 WebSocket
connect(): void {
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
    return;
  }
  
  this.connectionStatus = 'connecting';
  this.cdr.detectChanges();
  
  try {
    this.ws = new WebSocket(this.wsUrl);
    
    // 连接打开
    this.ws.onopen = () => {
      console.log('WebSocket 连接已建立');
      this.connectionStatus = 'connected';
      this.cdr.detectChanges();
    };
    
    // 接收消息
    this.ws.onmessage = (event) => {
      try {
        const message: WebSocketMessage = JSON.parse(event.data);
        console.log('收到 WebSocket 消息:', message);
        this.handleReceivedMessage(message);
      } catch (error) {
        console.error('解析 WebSocket 消息错误:', error);
      }
    };
    
    // 连接关闭
    this.ws.onclose = (event) => {
      console.log('WebSocket 连接已关闭', event);
      this.connectionStatus = 'disconnected';
      this.cdr.detectChanges();
      
      // 如果非正常关闭,尝试重连(可选)
      if (event.code !== 1000) {
        console.log('连接异常关闭,5秒后尝试重连...');
        setTimeout(() => {
          if (this.connectionStatus === 'disconnected') {
            this.connect();
          }
        }, 5000);
      }
    };
    
    // 连接错误
    this.ws.onerror = (error) => {
      console.error('WebSocket 错误:', error);
      this.connectionStatus = 'disconnected';
      this.cdr.detectChanges();
    };
  } catch (error) {
    console.error('创建 WebSocket 连接失败:', error);
    this.connectionStatus = 'disconnected';
    this.cdr.detectChanges();
  }
}

// 断开 WebSocket 连接
disconnect(): void {
  if (this.ws) {
    this.ws.close(1000, '正常关闭');
    this.ws = null;
  }
  this.connectionStatus = 'disconnected';
  this.cdr.detectChanges();
}

2. 消息发送节流

使用 throttleTime 限制消息发送频率:

// 消息发送 Subject(用于节流)
private messageSendSubject$ = new Subject<string>();

// 是否启用节流
throttleEnabled = true;

ngOnInit(): void {
  // 设置消息发送节流(500ms 内最多发送一次)
  this.messageSendSubject$
    .pipe(
      throttleTime(500), // 节流:每 500ms 最多发送一次
      takeUntil(this.destroySubject$)
    )
    .subscribe((message) => {
      // 只有在连接状态下才发送
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.sendMessageToServer(message);
      }
    });
}

// 发送消息(点击按钮)
sendMessage(): void {
  const message = this.messageInput.value?.trim() || '';
  if (!message) {
    return;
  }
  
  if (this.throttleEnabled) {
    // 使用节流发送
    this.messageSendSubject$.next(message);
  } else {
    // 直接发送
    this.sendMessageToServer(message);
  }
  
  // 清空输入框
  this.messageInput.setValue('');
}

// 发送消息到服务器
private sendMessageToServer(content: string): void {
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
    console.warn('WebSocket 未连接,无法发送消息');
    return;
  }
  
  if (!content || content.trim() === '') {
    return;
  }
  
  // 发送 echo 类型的消息(服务器会回显)
  const message = {
    type: 'echo',
    content: content.trim()
  };
  
  try {
    this.ws.send(JSON.stringify(message));
    
    // 添加发送记录
    const record: MessageRecord = {
      id: ++this.messageCounter,
      type: 'sent',
      content: content.trim(),
      timestamp: Date.now(),
      messageType: 'echo'
    };
    
    this.messages.unshift(record);
    this.cdr.detectChanges();
  } catch (error) {
    console.error('发送消息失败:', error);
  }
}

3. 消息接收处理

// 处理接收到的消息
handleReceivedMessage(message: WebSocketMessage): void {
  let displayContent = '';
  let messageType = message.type;
  
  switch (message.type) {
    case 'welcome':
      displayContent = message.message || '连接成功';
      if (message.clientId) {
        this.clientId = message.clientId;
      }
      break;
    case 'echo':
      displayContent = message.original || '';
      break;
    case 'pong':
      displayContent = '收到心跳响应';
      break;
    case 'broadcast':
      displayContent = `${message.from ? `来自 ${message.from}: ` : ''}${message.content || ''}`;
      break;
    case 'message':
      displayContent = `${message.from ? `来自 ${message.from}: ` : ''}${JSON.stringify(message.content)}`;
      break;
    default:
      displayContent = JSON.stringify(message);
  }
  
  // 添加消息记录
  const record: MessageRecord = {
    id: ++this.messageCounter,
    type: 'received',
    content: displayContent,
    timestamp: Date.now(),
    messageType: messageType
  };
  
  this.messages.unshift(record);
  this.cdr.detectChanges();
}

关键点解析

1. 连接状态管理

通过维护 connectionStatus 状态,可以:

  • 在 UI 中显示连接状态
  • 根据状态决定是否允许发送消息
  • 处理重连逻辑

2. 消息节流

使用 throttleTime 可以:

  • 限制消息发送频率,避免服务器压力过大
  • 提升用户体验,避免消息过于频繁
  • 可以通过开关控制是否启用节流

3. 自动重连

onclose 事件中,如果非正常关闭,可以自动重连:

this.ws.onclose = (event) => {
  if (event.code !== 1000) { // 1000 表示正常关闭
    setTimeout(() => {
      if (this.connectionStatus === 'disconnected') {
        this.connect(); // 自动重连
      }
    }, 5000);
  }
};

4. 错误处理

确保所有可能的错误都有适当的处理:

  • 连接错误
  • 消息解析错误
  • 发送消息错误

实际应用场景

1. 实时聊天

// 聊天消息发送
sendChatMessage(message: string): void {
  this.messageSendSubject$.next(message);
}

// 接收聊天消息
handleChatMessage(message: WebSocketMessage): void {
  this.chatMessages.push(message);
  this.scrollToBottom();
}

2. 实时通知

// 接收服务器推送的通知
handleNotification(message: WebSocketMessage): void {
  if (message.type === 'notification') {
    this.showNotification(message.content);
  }
}

3. 实时数据更新

// 接收实时数据更新
handleDataUpdate(message: WebSocketMessage): void {
  if (message.type === 'data-update') {
    this.updateData(message.data);
  }
}

性能优化建议

1. 心跳机制

定期发送心跳消息,保持连接活跃:

// 心跳间隔
private readonly HEARTBEAT_INTERVAL = 30000; // 30 秒

// 启动心跳
startHeartbeat(): void {
  setInterval(() => {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, this.HEARTBEAT_INTERVAL);
}

2. 消息队列

对于重要消息,可以实现消息队列,确保消息不丢失:

private messageQueue: string[] = [];

// 发送消息(带队列)
sendMessageWithQueue(message: string): void {
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
    // 发送队列中的消息
    while (this.messageQueue.length > 0) {
      this.ws.send(this.messageQueue.shift()!);
    }
    // 发送当前消息
    this.ws.send(message);
  } else {
    // 连接未建立,加入队列
    this.messageQueue.push(message);
  }
}

3. 限制消息历史

限制保存的消息数量,避免内存占用过大:

// 限制消息数量
if (this.messages.length > 100) {
  this.messages = this.messages.slice(0, 100);
}

注意事项

  1. 内存泄漏:确保在组件销毁时关闭连接和取消订阅
  2. 重连策略:合理设置重连间隔,避免频繁重连
  3. 消息格式:统一消息格式,便于解析和处理
  4. 安全性:使用 WSS(WebSocket Secure)保护数据传输

总结

使用 RxJS 管理 WebSocket 连接是一个完整的解决方案,它提供了:

  • 连接管理:处理连接、断开、重连等状态
  • 消息节流:使用 throttleTime 限制消息发送频率
  • 错误处理:处理各种错误情况
  • 自动重连:连接断开后自动重连
  • 消息处理:统一处理不同类型的消息

通过合理使用 RxJS 操作符(throttleTimetakeUntil 等),我们可以构建一个稳定、高效的 WebSocket 通信系统。

记住:WebSocket 适合实时通信场景,但对于不需要实时性的场景,HTTP 轮询可能更简单

码云地址:gitee.com/leeyamaster…

15个例子熟练异步框架 Zone.js

15个例子熟练异步框架 Zone.js

一、理解 Zone.js

可以把 Zone.js 理解成异步的监听器,通过钩子感知异步的各个阶段:

钩子 对应阶段
onScheduleTask 订阅/注册(调用 setTimeout、.then 等)
onInvokeTask 执行(回调真正运行)
onCancelTask 取消(clearTimeout 等)
onHasTask 是否有未完成任务(全部完成时 hasTask 变为 false)

核心价值:

  1. 提取冗余代码:错误处理、耗时统计、日志等可集中到 Zone 钩子,业务回调只保留核心逻辑
  2. 共享变量:用 properties 在 Zone 上挂数据,Zone.current.get('key') 即可访问,无需闭包或层层传参
  3. 统一错误捕获onHandleError 可捕获 Zone 内同步和异步抛出的错误
  4. 等所有异步完成onHasTask 在 hasTask 变为 false 时,表示全部完成,可触发回调(类似自动版 Promise.all)

官方总结

  • Zone.js 通过包装异步 API,在订阅、执行、取消、完成等阶段提供钩子,让你可以集中处理错误、上下文、监控和“全部完成”等逻辑,从而减少重复代码并提高可读性。

白话文总结:

  • Zone.js 就是一个异步“监听器”,可以追踪异步任务的执行取消注册/订阅等各个阶段,把原本分散在异步代码里的冗余处理(比如日志、错误捕获、耗时统计)提取到统一的位置,让业务代码更清晰。
  • 当“异步套异步”时,各层 Zone 可以共享变量,无需再用闭包或层层传参。
  • 支持在异步任务中统一捕获错误,不用再每处手动 try/catch。
  • 可以检测多个异步任务何时全部完成,比如多个 loading 结束后再统一触发某些操作。

二、示例精华(01-15)

01 最基本用法

// Zone.current 获取当前 Zone
console.log('当前 Zone:', Zone.current.name);

// zone.run() 在 Zone 内执行代码
Zone.current.run(function() {
  console.log('在 Zone 内执行,当前 Zone:', Zone.current.name);
});

讲解:Zone.js 加载后自动创建 root Zone。zone.run(fn) 在指定 Zone 内执行函数。


02 Zone 嵌套

var childZone = Zone.current.fork({ name: 'child-zone' });
var grandchildZone = childZone.fork({ name: 'grandchild-zone' });

childZone.run(function() {
  console.log('在 child-zone 内:', Zone.current.name);
  grandchildZone.run(function() {
    console.log('在 grandchild-zone 内:', Zone.current.name);
  });
});

讲解zone.fork(config) 基于当前 Zone 创建子 Zone,形成父子层级关系。


03 Zone 存储数据

var myZone = Zone.current.fork({
  name: 'my-zone',
  properties: {
    userId: 'user-123',
    requestId: 'req-456'
  }
});

myZone.run(function() {
  console.log('同步:', Zone.current.get('userId'));
  setTimeout(function() {
    // 异步回调里也能拿到!
    console.log('异步:', Zone.current.get('requestId'));
  }, 500);
});

讲解properties 让 Zone 携带数据,同步和异步代码都能用 Zone.current.get('key') 访问。


04 对比:上下文数据

无 Zone:多层 setTimeout 需闭包或层层传参才能拿到 requestId。

有 Zone:在 Zone 上设置一次,所有异步回调都能直接拿到。

var myZone = Zone.current.fork({
  name: 'request-zone',
  properties: { requestId: 'req-002' }
});

myZone.run(function() {
  setTimeout(function() {
    setTimeout(function() {
      // 照样能拿到,不用传参!
      console.log(Zone.current.get('requestId'));
    }, 200);
  }, 200);
});

05 对比:任务追踪

无 Zone:需手动 pendingCount++/--,每次 setTimeout 前后自己维护。

有 ZoneonHasTask 自动感知「有任务」或「全部完成」。

var trackingZone = Zone.current.fork({
  name: 'tracking-zone',
  onHasTask: function(delegate, current, target, hasTaskState) {
    var hasTask = hasTaskState.macroTask || hasTaskState.microTask || hasTaskState.eventTask;
    // hasTask 为 true:有异步任务
    // hasTask 为 false:全部完成
    console.log(hasTask ? '有任务执行中' : '空闲');
  }
});

trackingZone.run(function() {
  setTimeout(function() {
    setTimeout(function() { /* 什么都不用做 */ }, 300);
  }, 500);
});

06 对比:错误捕获

无 Zone:try-catch 抓不到 setTimeout 里的错误。

有 ZoneonHandleError 统一捕获 Zone 内所有异步错误。

var errorZone = Zone.current.fork({
  name: 'error-zone',
  onHandleError: function(delegate, current, target, error) {
    console.log('捕获到:', error.message);
    return false; // 不继续向外抛
  }
});

errorZone.run(function() {
  setTimeout(function() {
    throw new Error('setTimeout 里的错误!');
  }, 300);
});

07 对比:任务拦截

无 Zone:无法知道 setTimeout、Promise.then 何时执行。

有 ZoneonInvokeTask 在每次异步回调执行前都会触发。

var interceptZone = Zone.current.fork({
  name: 'intercept-zone',
  onInvokeTask: function(delegate, current, target, task, applyThis, applyArgs) {
    console.log('▶ 执行任务:', task.source);
    return delegate.invokeTask(target, task, applyThis, applyArgs);
  }
});

interceptZone.run(function() {
  setTimeout(function() { /* ... */ }, 200);
  Promise.resolve().then(function() { /* ... */ });
});

注意:onInvokeTask 在回调执行前触发,不是执行后。delegate.invokeTask() 会同步执行回调,执行完才返回。


08 onScheduleTask vs onInvokeTask

var z = Zone.current.fork({
  name: 'demo',
  onScheduleTask: function(delegate, curr, target, task) {
    console.log('📋 任务被注册:', task.source);  // 调用 setTimeout 的瞬间
    return delegate.scheduleTask(target, task);
  },
  onInvokeTask: function(delegate, curr, target, task, applyThis, applyArgs) {
    console.log('▶ 任务即将执行:', task.source);  // 回调真正运行的瞬间
    return delegate.invokeTask(target, task, applyThis, applyArgs);
  }
});

z.run(function() {
  setTimeout(function() { console.log('回调执行了'); }, 500);
});
// 顺序:onScheduleTask → (500ms) → onInvokeTask → 回调

讲解:onScheduleTask = 注册时;onInvokeTask = 执行时。


09 zone.wrap

var myZone = Zone.current.fork({
  name: 'my-zone',
  properties: { requestId: 'req-999' }
});

// 包装后,无论何时何处被调用,都会在 my-zone 内执行
var wrappedCallback = myZone.wrap(function() {
  console.log(Zone.current.get('requestId'));
}, 'button-callback');

document.getElementById('btn').addEventListener('click', wrappedCallback);

讲解:适合 addEventListener、第三方库回调等,你无法控制调用时机,但希望它在你的 Zone 内执行。


10 异步耗时统计

var timingZone = Zone.current.fork({
  name: 'timing-zone',
  onInvokeTask: function(delegate, curr, target, task, applyThis, applyArgs) {
    var start = performance.now();
    var result = delegate.invokeTask(target, task, applyThis, applyArgs);
    var cost = (performance.now() - start).toFixed(2);
    console.log(task.source + ' 耗时: ' + cost + ' ms');
    return result;
  }
});

讲解delegate.invokeTask() 是同步的,执行完才返回,所以前后 performance.now() 的差值就是回调耗时。


11 onInvoke 同步钩子

var z = Zone.current.fork({
  name: 'invoke-zone',
  onInvoke: function(delegate, curr, target, callback, applyThis, applyArgs, source) {
    console.log('onInvoke: 即将执行', source);
    return delegate.invoke(target, callback, applyThis, applyArgs, source);
  }
});

z.run(function() {
  console.log('zone.run 里的回调体执行了');
}, null, null, 'main');

讲解onInvoke 针对 zone.run(fn)同步执行;onInvokeTask 针对异步任务。


12 onCancelTask

var z = Zone.current.fork({
  onCancelTask: function(delegate, curr, target, task) {
    console.log('❌ 任务被取消:', task.source);
    return delegate.cancelTask(target, task);
  }
});

z.run(function() {
  var id = setTimeout(function() {}, 3000);
  // 点击按钮时 clearTimeout(id) → onCancelTask 触发
});

讲解clearTimeoutclearInterval 取消任务时,onCancelTask 会触发。


13 模拟 Angular 变更检测

var ngZone = Zone.current.fork({
  name: 'ng-zone',
  onHasTask: function(delegate, curr, target, hasTaskState) {
    delegate.hasTask(target, hasTaskState);
    var hasTask = hasTaskState.macroTask || hasTaskState.microTask || hasTaskState.eventTask;
    if (!hasTask) {
      console.log('🔔 所有异步完成 → 执行变更检测');
    }
  }
});

ngZone.run(function() {
  setTimeout(function() {
    // 更新数据...
    setTimeout(function() { /* 后续处理 */ }, 200);
  }, 500);
});

讲解:Angular 的 NgZone 就是利用 onHasTask,在「全部完成」时触发变更检测。


14 Zone 边界

// Zone 内发起 → 会被追踪
trackingZone.run(function() {
  setTimeout(function() { /* 会被 onInvokeTask 捕获 */ }, 200);
});

// Zone 外发起 → 不会被追踪
setTimeout(function() { /* 不会被 Zone 追踪! */ }, 400);

讲解:只有在 Zone 内发起的异步才会被追踪。Zone 外调用的 setTimeout 不会被感知。


15 zone.runGuarded

var safeZone = Zone.current.fork({
  onHandleError: function(delegate, curr, target, error) {
    console.log('捕获:', error.message);
    return false; // 不继续向外抛
  }
});

safeZone.runGuarded(function() {
  throw new Error('故意的错误!');
});
console.log('程序继续运行');

讲解zone.run(fn) 抛错会向外冒泡;zone.runGuarded(fn) 会捕获错误交给 onHandleError,不向外抛。


三、核心概念速查

概念 说明
Zone.current 当前所在的 Zone
zone.run(fn) 在指定 Zone 内执行函数
zone.runGuarded(fn) 安全执行,错误交给 onHandleError
zone.fork(config) 基于当前 Zone 创建子 Zone
zone.wrap(callback) 包装回调,使其在 Zone 内执行
Zone.current.get('key') 获取 Zone 的 properties
properties Zone 携带的数据

四、码云地址

码云地址gitee.com/leeyamaster…

JavaScript数据类型整理1

有哪些数据类型?

  1. 基本类型:Number String Boolean Null Undefined Symbol BigInt
  2. 引用类型:Object Function Array Map Set
  3. 区别:是数据存储不同

a. 基本类型的值存储在栈中,在栈中存储的是值,它赋值后值相同,但是两个值对应的地址不同,所以a = 1;b=a;a=2;b还是等于1;
b. 引用类型的值存储在堆中,在栈存放的是指向堆内存的指针地址;它赋值时候是将对象的内存地址赋值給另一个对象,也就是说两个对象指向的是同一个堆内存,所以a={name:11};b=a;b.name=22; a也会改变;

null与undefined区别是什么?

  1. ①undefined代表定义未赋值;②nulll定义并赋值了, 只是值为null
  2. typeof null是object
  3. 什么时候给变量赋值为null呢?①初始赋值, 表明将要赋值为对象;②结束前, 让对象成为垃圾对象(被垃圾回收器回收)
  4. null==undefined; null!==undefined

Symbol创建唯一值

  1. 是什么?给对象设置“唯一值"的属性名,对象的属性名可以是数字、字符串、Symbol类型;
  2. 用法?
let a1= symbol('AA'); 
let a2= symbol('AA'); 
let a3 = a1; 
a1 === a2; //false 
a1 === a3 //true
  1. 作用? Symbol.asyncIterator/iterator/hasInstance/toPrimitive/tostringTag 是某些js底层原理的实现机制。基于symbol类型的值,保证行为标识的唯一性

BigInt大数类型

  1. 超过安全数后,进行运算或者访问,结果会不准确
 a. Number.MAX_SAFE_INTEGER: 9007199254740991 //jS中的最大安全数
 b. Number.MIN_SAFE_INTEGER: -9007199254740991 //jS中的最小安全数
  1. 解决方案 a. 服务器返回给客户端的大数,按照"字符串"格式返回;然后客户端把其变为 BigInt,然后按照BigInt进行运算,最后把运算后的BigInt转换为字符串,在传递给服务器即可

数据类型的检测方式有哪些?

  1. typeof:其中数组、对象、null 都会被判断为 object
console.log(typeof 2); // number
console.log(typeof true); // boolean
console.log(typeof "str"); // string
console.log(typeof undefined); // undefined
console.log(typeof function () {}); // function
console.log(typeof null); // object
console.log(typeof {}); // object
console.log(typeof []); // object
  • 数据类型的值在计算机底层是按照64位的二进制值进行存储的,typeof也是按照二进制进行类型检测。
  • 前三位是0认为是对象,然后再去看有没有实现call方法。如果实现了则返回'function',没有实现,则返回'object'。null是64个零,所以typeof null->'object'
  1. instanceof:可以判断对象的类型,不能判断基本类型的;原理是构造函数的prototype属性是否出现在对象原型链的任何位置。
console.log(2 instanceof Number); // false
console.log(true instanceof Boolean); // false
console.log("str" instanceof String); // false

console.log([] instanceof Array); // true
console.log(function () {} instanceof Function); // true
console.log({} instanceof Object); // true
  • 先检测构造函数是否拥有 Symbol.hasInstance 方法
  • 如果有这个方法:构造函数[Symbol.hasInstance](实例)返回的值就是结果;
  • 如果没有这个方法,则按照原型链进行查找:按照实例的_proto-一直向上找,直到找到0bject.prototype为止,只要在原型链上出现了“构造函数.prototype”,说明当前实例率属于它,结果返回true;如果没找到,结果就是false;
  1. constructor:判断两种数据的类型,对象实例通过constructor来访问它的构造函数,缺点是如果对象改变过它的原型,那么constructor判断会有问题
console.log((2).constructor === Number); // true
console.log((true).constructor === Boolean); // true
console.log(('').constructor === String); // true
console.log(([]).constructor === Array); // true
console.log((function() {}).constructor === Function); // true
console.log(({}).constructor === Object); // true
  1. Object.prototype.toString.call([value]):使用Object对象的原型方法toString来判断数据类型,属于检测最准确、最全面的方式了,能够区分null、能够检测原始值类型、能够细分对象、即便重构原型对象检测也是准确的
console.log(Object.prototype.toString.call(2));        //'[object Number]'
console.log(Object.prototype.toString.call(true));     //'[object Boolean]'
console.log(Object.prototype.toString.call("str"));    //'[object String]'
console.log(Object.prototype.toString.call([]));       //'[object Array]'
console.log(Object.prototype.toString.call(function () {}));//'[object Function]'
console.log(Object.prototype.toString.call({}));       //'[object Object]'
console.log(Object.prototype.toString.call(undefined));//'[object Undefined]'
console.log(Object.prototype.toString.call(null));     //'[object Null]'
  • 返回结果“[object ?] ?:一般是自己所属的构造函数
  • 首先会看[value]值是否有 Symbol.tostringTag 属性,有这个属性,属性值是啥,检测出来是啥就是啥; Math[Symbol.tostringTag]:'Math' map.prototype[Symbol.tostringTag]:'Map' Promise.prototype[Symbol.tostringTag]:'Promise' Set.prototype[Symbol.tostringTag]:'set'
  • 如果没有这个属性,才一般是按照自己所属的构造函数返回

数组的判断有几种?

  1. Object.prototype.toString.call(): Object.prototype.toString.call([]).slice(8,-1) === "Array"
  2. Array.isArray(): Array.isArray([])
  3. instanceof: [] instanceof Array
  4. Array.prototype.isPrototypeOf:Array.prototype.isPrototypeOf([])
  5. __proto__: [].__proto__ === Array.prototype

typeof NaN是多少?

  1. typeof NaN是number
  2. NaN:指不是一个数字,意思是执行数字运行失败,是失败后返回的结果
  3. NaN === NaN 为false

instanceof操作符的实现原理?

  1. 作用:检测某个对象是否属于某个类型 [] instanceof Array
  2. 原理是判断构造函数的prototype属性是否出现在对象原型链的任何位置;所以重点就是找到递归对象的原型链以及构造函数的prototype
  3. 步骤

a. 获取对象的原型 proto = Object.getPrototypeOf(obj)
b. 获取构造函数的原型对象 prototype = ctor.prototype;
c. 判断构造函数的原型对象是否在对象的原型链上 proto === prototype
d. 如果没有找到就继续在其原型上找 proto = Object.getPrototypeOf(proto);

function myInstanceof(obj, ctor) {
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  // 获取构造函数的 prototype 对象
  let prototype = ctor.prototype;
  // 判断构造函数的 prototype 对象是否在对象的原型链上
  while (true) {
    if (!proto) return false;
    if (proto === prototype) return true;
    // 如果没有找到,就继续从其原型上找,Object.getPrototypeOf方法用来获取指定对象的原型
    proto = Object.getPrototypeOf(proto);
  }
}

类型转换机制?

  1. 常见的类型转换有:强制转换(显式转换)、自动转换(隐式转换)
  2. 显式转换转化规则常见的方法有:Number() parseInt() parseFloat() String() Boolean()
  3. 隐式转换转化规则?比较运算(==、!=、>、<)if、while;算术运算(+、-、*、/、%)
  4. 其他类型转数字: ①Number([val]):比较严格,只要有一个字符无法转成数值,整个字符串就会被转为NaN; ②parseInt([val], [radix]):没那么严格,遇到不能转换的字符就停下来。
  1. 空字符串为0,有出现非数字结果为NaN
  2. 布尔值转数字,true:1,false:0
  3. Symbol会报错;
  4. null:0; undefined:NaN;
  5. BigInt去除“n”
  6. 对象转数字:①先调用对象原型上的一个函数Symbol.toPrimitive()②如果不存在则调用对象的valueOf获取原始值;③如果获取的不是原始值,再调用对象的toString转为字符串;④再把字符串基于Number方法转换为数字;
Number([10]) //10
// 1 首先检测Symbol.toPrimitive是否存在,如果存在就调用 ƒ [Symbol.toPrimitive]() { [native code] }
// 2 如果是undefined,那么arr.valueOf()获取原始值 
// 3 结果为[10],不是原始值,那么就是arr.toString()转为字符串‘10’;
// 4 最后再把字符串转为数字 Number('10')->10
// Number([10, 20])结果为NaN,分析如下:
//  [10,20].valueOf():[10, 20]-> [10,20].toString():'10,20' -> Number('10,20')->NaN
  1. 其他类型转字符串:①拿字符串包起来String({}) //'[object Object]'; ②“+”出现在两边,其中一边是字符串或者某些对象,会以字符串拼接规则处理
console.log(10+'10') //'1010'
console.log(10+new Number(10)) //20
//①new Number(10)[Symbol.toPrimitive]:undefined;-> ②new Number(10).value0f():10 -> 10+10 = 20
console.log(10+[10]) //'1010' 
//①[10][Symbol.toPrimitive]:undefined;-> ②[10].valueOf().toString():'10',为字符串,所以是拼接 
  1. 其他类型转布尔值: Boolean([val])或者!/!! 除了null undefined '' NaN,其他结果都是true。

ToPrimitive: 用来将值转换为基本类型值

  1. 如果值为对象,ToPrimitive(obj, type);对象默认type为number。
  2. 当type为number时规则:var objToNumber = (value) => Number(value.valueOf().toString());
  3. 当type为string时规则:var objToNumber = (value) => Number(value.toString().valueOf());
  4. 对于 Date 以外的对象,转换为基本类型的大概规则可以概括为一个函数:var objToNumber = (value) => Number(value.valueOf().toString());
var a = { name: "Tom" };
var b = { age: 18 };
a + b; // "[object Object][object Object]"
a.valueOf().toString(); // "[object Object]"
b.valueOf().toString(); // "[object Object]"
a + b; // "[object Object][object Object]"

==操作符的强制类型转换规则?

  1. 首先会判断两者的类型是否一样,一样则比较大小;不一样则进行类型装换;
  2. 两个都为简单类型则有一方为字符串和布尔值都会转换成数值再进行判断;null==undefined是true;
  3. 两个都为引用类型则比较他们是否指向同一对象,比较的是堆内存地址,地址相同则相等
  4. 一个为引用类型,一个为复杂类型则将复杂类型用ToPrimitive转为原始类型再进行判断
  5. 除了以上情况,只要两边类型不一致,剩下的都是转换为数字,然后再进行比较的。
// 一个为引用类型,一个为复杂类型
//对象==字符串 需要将对象转字符串再比较「symbol.toPrimitive->value.toString().valueOf()]
//对象==数字 需要将对象转数字再比较「symbol.toPrimitive->value.valueOf().toString()]
[] == false //true
//只要两边类型不一致,剩下的都是转换为数字 Number([])为0,所以0 == 0为true

为什么 0.1+0.2 ! == 0.3,如何让其相等?

  1. 为什么小数(浮点数)的计算会出现精准度丢失问题?0.1+0.2 // 0.30000000000000004 0.1+0.7 //0.7999999999999999
  • 计算机存储值是以二进制在计算机底层来存的,所以需要先把十进制转为二进制的科学计数法n.toString(2)
  • 整数:(10).toString(2) //'1010'->10一直去除以2,余数组合就是结果;
  • 浮点数:(0.1).toString(2) //'0.0001100110011001100110011001100110011001100110011001101'
  • 某些十进制的浮点数在转二进制可能会无限循环下去,在底层存储最多存64位,舍弃了一些值后值本身就失去了精准值,再转成十进制就有了误差;
  1. 怎么解决精准度问题?使用 toPrecision 凑整;扩大系数法,把小数转成整数后再运算;使用第三方库,如Math.js、BigDecimal.js
//扩大系数法,把小数转成整数后再运算
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}
❌