普通视图

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

JS 高手必会:手写 new 与 instanceof

2025年12月11日 00:02

手写 instanceof

首先我们需要了解 instanceof是啥?

在其他面向对象编程语言中instanceof大多为实例判断运算符,即检查对象是否是某个构造函数的实例。

但是在 JS 中,instanceof原型关系判断运算符,用于检测构造函数的prototype属性是否出现在某个对象的原型链上。

// object instanceof Constructor
A instanceof B // A 的原型链上是否有 B 的原型 

而在大型项目、多人协作的情况下,在搞不懂对象上有哪些属性和方法,通过instanceof来查找继承关系

原型链关系:

  • __proto__: 指向原型对象(上一位的 .prototype),用于属性查找
  • constructor: 指向构造函数本身,用于标识对象的创建者
  • prototype: 函数的属性,指向原型对象(内置构造函数的 prototype 上通常有默认方法)
子.__proto__ === 父.prototype
父.prototype.constructor === 父
子.__proto__.constructor === 父(通过原型链访问)

举个最简单的例子:

const arr = [];
console.log(
    arr.__proto__, // Array.prototype
    arr.__proto__.__proto__, // Object.prototype
    arr.__proto__.__proto__.__proto__ // null
)

那么arr的原型链关系就是:arr -> Array.prototype -> Object.prototype -> null

而我们要手写instanceof,也就是只需要沿着原型链去查找,那么用简单的循环即可。

手写代码如下

// right 是否出现在 left 的原型链上
function isInstanceOf(left, right) {
    let proto = left.__proto__;
    // 循环查找原型链
    while (proto) {
        if (proto === right.prototype) {
            return true;
        }
        proto = proto.__proto__; // null 结束循环
    }
    return false;
}

手写 new

new对我们来说并不陌生,也就是实例运算符

在之前的文章我也提到过new的伪代码,让我们再来复习一下:

// 从空对象开始
let obj = {}
// this -> 创建空对象,运行构造函数
Object.call(obj)
// 将空对象的__proto__ 指向 构造函数的原型对象
obj.__proto__ = Object.prototype
// 返回新对象
return obj

写法一:(es6 新写法)

假如我们要new一个实例对象,但是不知道构造函数上的参数数量,而在es6中有一个新的运算符,也就是...运算符,它的诸多功能就可以满足我们的需求。

...扩展运算符

...有双重身份,在不同情况下的作用也不同

  • 函数调用 / 数组 / 对象字面量中...称为扩展运算符,将可迭代对象“展开”为独立元素

  • 函数参数 / 解构赋值中: ...称为剩余参数/剩余元素,将多个值“收集”为一个数组

// 展开数组
const arr1 = [1, 2, 3]; 
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5] 

// 收集数组
const [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 1
console.log(rest);  // [2, 3, 4]

而依据我们的伪代码即可模拟 new的功能

手写代码如下

function objectFactory(Construstor, ...args) {
    // 创建空对象
    var obj = new Object(); 
    // 绑定 this 并执行构造函数
    Construstor.apply(obj, args); // 不能使用call,因为apply调用数组
    // 设置原型链
    obj.__proto__ = Construstor.prototype; 
    return obj;
}

// 使用:完全不需要知道 Person 需要几个参数
function Person(name, age, city) {
  this.name = name;
  this.age = age;
  this.city = city;
}

const p = objectFactory(Person, 'Alice', 25, 'Beijing'); // 自动适配

写法二:(根据arguments es5)

当然,在es6之前我们并没有...运算符,那么如何手写new呢?这就不得不提到arguments了。

arguments 是什么?

arguments 是 JS 中的一个类数组对象,它在所有非箭头函数内部自动可用,用于访问传递给该函数的所有实参

类数组对象:

  • 拥有 length 属性和若干索引属性,但不具备数组原型方法(如 .push(), .map(), .forEach() 等)的对象,所以其不是真正的数组

  • 普通函数中自动绑定。

  • 箭头函数内部没有自己的 arguments,但是会沿作用域链查找外层函数的 arguments,如果外层有就用外层的。

不妨来看个例子理解一下:

function greet() {
  console.log(arguments); // 类数组对象
  console.log(arguments.length); // 实际传入参数个数
  console.log(arguments[0]);     // 第一个参数
}
greet('Alice', 'Bob'); // 输出: { '0': 'Alice', '1': 'Bob' }, length: 2, 'Alice'

如何将 arguments 转为真数组?

因为 arguments 不是数组,不能直接用数组方法。但是可以将其转换为数组:

方法 1:扩展运算符(ES6+,最简洁)

function fn() {
  const args = [...arguments];
  args.map(x => x * 2); // 可用数组方法
}

方法 2:Array.from()

const args = Array.from(arguments);

方法 3:[].slice.call()

const args = Array.prototype.slice.call(arguments);
// 或简写(更推荐)
const args = [].slice.call(arguments);

slice是数组原型上的一个方法,用于返回一个从原数组或类数组中浅拷贝出来的新数组(实现将类数组转换成数组)

[].slice是因为arguments上没有这个方法,所以需要去空数组中“借用”,并且通过 .call()slicethis指向arguments,变相的让arguments可以使用这个方法。

在了解了arguments后,聪明的你已经想到了如何通过它来实现手写new

手写代码如下

function objectFactory() {
    // 创建空对象
    var obj = new Object(); 
    // 将 arugments 的第一项提出来(也就是 构造函数)
    var Construstor = [].shift.call(arguments);
    // 绑定 this 并执行构造函数
    Construstor.apply(obj, arguments); // 不能使用call,因为 apply调用数组
    // 设置原型链
    obj.__proto__ = Construstor.prototype; 
    return obj;
}

function Person(name, age, city) {
  this.name = name;
  this.age = age;
  this.city = city;
}

const p = objectFactory(Person, 'Alice', 25, 'Beijing'); // 自动适配

每日一题-统计被覆盖的建筑🟡

2025年12月11日 00:00

给你一个正整数 n,表示一个 n x n 的城市,同时给定一个二维数组 buildings,其中 buildings[i] = [x, y] 表示位于坐标 [x, y] 的一个 唯一 建筑。

如果一个建筑在四个方向(左、右、上、下)中每个方向上都至少存在一个建筑,则称该建筑 被覆盖 

返回 被覆盖 的建筑数量。

 

示例 1:

输入: n = 3, buildings = [[1,2],[2,2],[3,2],[2,1],[2,3]]

输出: 1

解释:

  • 只有建筑 [2,2] 被覆盖,因为它在每个方向上都至少存在一个建筑:
    • 上方 ([1,2])
    • 下方 ([3,2])
    • 左方 ([2,1])
    • 右方 ([2,3])
  • 因此,被覆盖的建筑数量是 1。

示例 2:

输入: n = 3, buildings = [[1,1],[1,2],[2,1],[2,2]]

输出: 0

解释:

  • 没有任何一个建筑在每个方向上都有至少一个建筑。

示例 3:

输入: n = 5, buildings = [[1,3],[3,2],[3,3],[3,5],[5,3]]

输出: 1

解释:

  • 只有建筑 [3,3] 被覆盖,因为它在每个方向上至少存在一个建筑:
    • 上方 ([1,3])
    • 下方 ([5,3])
    • 左方 ([3,2])
    • 右方 ([3,5])
  • 因此,被覆盖的建筑数量是 1。

 

提示:

  • 2 <= n <= 105
  • 1 <= buildings.length <= 105
  • buildings[i] = [x, y]
  • 1 <= x, y <= n
  • buildings 中所有坐标均 唯一 

Expo 进阶指南:赋予 TanStack Query “原生感知力” —— 深度解析 AppState 与 NetInfo

2025年12月10日 22:58

在 Web 开发中,我们习惯了浏览器的“智能”:切换标签页时页面会自动刷新,断网重连后请求会自动重试。这是因为浏览器底层自动处理了窗口聚焦(Window Focus)和网络状态(Online Status)的事件。

然而,在 React Native (Expo) 环境中,App 默认是“盲”和“聋”的。TanStack Query 不知道用户什么时候把 App 切到了后台,也不知道手机什么时候断了网。

为了让 App 拥有极致的用户体验(如:切回来自动刷新、断网自动暂停重试),我们需要手动配置 “感官系统” 。本文将深入解读 AppStateStatussetOnline,并提供生产环境的完整配置方案。


一、 深度解读:App 的“感官系统”

1. 视觉神经:AppStateStatus (应用状态)

AppStateStatus 是 React Native 原生提供的一个状态类型,用于描述 App 当前处于什么处境。

三个核心状态:

  • active (前台/活跃) :用户正在盯着屏幕,App 在最上层运行。这是我们唯一希望触发数据刷新的状态。
  • background (后台) :用户按了 Home 键,或者切换到了微信。App 还在内存中运行,但界面不可见。
  • inactive (非活跃) :App 处于“半死不活”的过渡态(如被来电画面覆盖、拉下了通知栏、iOS 多任务切换界面)。

为什么要配置它?

TanStack Query 有一个核心功能叫 Window Focus Refetching。

  • 问题:RN 默认没有 window.onfocus 事件。
  • 解决:我们需要监听 AppState 的变化。当状态变为 active 时,手动调用 focusManager.setFocused(true)
  • 效果:TanStack Query 收到信号后,会立即检查页面上的数据是否过期(Stale)。如果过期,它会在后台悄悄发起请求,用户切回来的一瞬间看到的就是最新数据。

2. 听觉神经:setOnline (联网回调)

setOnline 不是一个你可以直接导入的函数,它是 TanStack Query 的 onlineManager.setEventListener 方法传递给你的一个回调函数(参数)。你可以把它理解为 TanStack Query 递给你的一把“开关”。

逻辑流程图:从断网到重连

+---------------------------+
| 📡 物理层                  |
| 手机 4G/WiFi 信号变化       |
+-------------+-------------+
              |
              v (触发系统事件)
+-------------+-------------+
| 🎧 B: NetInfo 监听器       |
+-------------+-------------+
              |
              v (获取状态: isConnected)
+-------------+-------------+
| 💻 C: 开发者代码逻辑        |
| (app/_layout.tsx)         |
+-------------+-------------+
              |
              v (调用 setOnline)
+-------------+-------------+
| ⚙️ D: TanStack Query      |
|     (onlineManager)       |
+-------------+-------------+
       /             \
      / (false)       \ (true)
     v                 v
+---------+       +---------+
| E: 🛑   |       | F: 🚀   |
| 暂停     |       | 恢复    |
| (Pause) |       | (Resume)|
| 停止重试 |       | 立即重试 |
+---------+       +---------+

为什么要配置它?

  • 省电保护:如果没配置,断网时 TanStack Query 会傻傻地一直重试请求,消耗用户电量。配置后,断网即休眠。
  • 体验优化:如果没配置,网络恢复后,App 不会有反应,用户必须手动下拉刷新。配置后,信号恢复的瞬间,数据自动加载成功(“电梯效应”)。

二、 生产环境完整配置 (app/_layout.tsx)

这是融合了上述原理的完整配置代码。请确保你已安装了 @react-native-community/netinfo

import { useEffect } from 'react';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { Slot } from 'expo-router';
import NetInfo from '@react-native-community/netinfo';
import { 
  QueryClient, 
  QueryClientProvider, 
  focusManager, 
  onlineManager 
} from '@tanstack/react-query';

// =================================================================
// 1. 配置网络监听 (给 App 装上“耳朵”)
// =================================================================
onlineManager.setEventListener((setOnline) => {
  // setOnline 是 Query 传进来的一个函数,用来接收网络状态
  // 我们使用 NetInfo 来监听真实的网络变化
  return NetInfo.addEventListener((state) => {
    // state.isConnected 可能为 null,用 !! 强转为 boolean
    // 当这里调用 setOnline(true) 时,Query 会立即重试刚才失败的请求
    setOnline(!!state.isConnected);
  });
});

// =================================================================
// 2. 配置 App 状态监听 (给 App 装上“眼睛”)
// =================================================================
function onAppStateChange(status: AppStateStatus) {
  // Web 浏览器会自动处理窗口聚焦,只有原生 App 需要手动处理
  if (Platform.OS !== 'web') {
    // 核心逻辑:
    // 当 AppState 变为 'active' (前台) 时
    // 我们手动告诉 Query 的 focusManager:“用户现在聚焦在 App 上了”
    // Query 收到信号后,会检查页面数据是否过期 (stale),如果过期则自动 Refetch
    focusManager.setFocused(status === 'active');
  }
}

// =================================================================
// 3. 初始化 QueryClient (配置全局策略)
// =================================================================
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,             // 失败后自动重试 2 次
      staleTime: 1000 * 60, // 数据 1 分钟内算“新鲜”,切回来也不刷新
                            // 超过 1 分钟后切回来,会触发自动刷新
    },
  },
});

export default function RootLayout() {
  // 注册 AppState 监听器
  useEffect(() => {
    // 开始监听系统状态变化
    const subscription = AppState.addEventListener('change', onAppStateChange);
    
    // 组件卸载时取消监听,这是防止内存泄漏的标准做法
    return () => subscription.remove();
  }, []);

  return (
    
      {/* Slot 是 Expo Router 的页面入口 */}
      
    
  );
}

三、 总结

在 React Native 中使用 TanStack Query,90% 的人只做了“安装”,而忽略了“配置”

如果不配置这两个管理器,你的 App 就失去了灵魂:

  1. AppStateStatus + focusManager:让 App 知道“我醒了”,从而实现微信切回来的自动刷新。
  2. setOnline + onlineManager:让 App 知道“我有网了”,从而实现走出电梯后的自动重连。

只要在 _layout.tsx 中写好这 50 行代码,你应用里所有的 useQuery 就都自动拥有了这些原生级别的感知能力。这就是从“能用”到“好用”的关键跨越。

从美团全栈化看 AI 冲击:前端转全栈,是自救还是必然 🤔🤔🤔

作者 Moment
2025年12月10日 22:30

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

大厂日报 称,美团履约团队近期正在推行"全栈化"转型。据悉,终端组的部分前端同学在 11 月末左右转到了后端组做全栈(前后端代码一起写),主要是 agent 相关项目。内部打听了一下,团子目前全栈开发还相对靠谱,上线把控比较严格。

这一消息在技术圈引起了广泛关注,也反映了 AI 时代下前端工程师向全栈转型的必然趋势。但更重要的是,我们需要深入思考:AI 到底给前端带来了什么冲击?为什么前端转全栈成为了必然选择?

最近,前端圈里不断有"前端已死"的话语流出。有人说 AI 工具会替代前端开发,有人说低代码平台会让前端失业,还有人说前端工程师的价值正在快速下降。这些声音虽然有些极端,但确实反映了 AI 时代前端面临的真实挑战。

一、AI 对前端的冲击:挑战与机遇并存

1. 代码生成能力的冲击

冲击点:

  • 低复杂度页面生成:AI 工具(如 Claude Code、Cursor)已经能够快速生成常见的 UI 组件、页面布局
  • 重复性工作被替代:表单、列表、详情页等标准化页面,AI 生成效率远超人工
  • 学习门槛降低:新手借助 AI 也能快速产出基础代码,前端"入门红利"消失

影响: 传统前端开发中,大量时间花在"写页面"上。AI 的出现,让这部分工作变得极其高效,甚至可以说,只会写页面的前端工程师,价值正在快速下降。这也正是"前端已死"论调的主要依据之一。

2. 业务逻辑前移的冲击

冲击点:

  • AI Agent 项目激增:如美团案例中的 agent 相关项目,需要前后端一体化开发
  • 实时交互需求:AI 应用的流式响应、实时对话,要求前后端紧密配合
  • 数据流转复杂化:AI 模型调用、数据处理、状态管理,都需要全栈视角

影响: 纯前端工程师在 AI 项目中往往只能负责 UI 层,无法深入业务逻辑。而 AI 项目的核心价值在于业务逻辑和数据处理,这恰恰是后端能力。

3. 技术栈边界的模糊

冲击点:

  • 前后端一体化趋势:Next.js、Remix 等全栈框架兴起,前后端代码同仓库
  • Serverless 架构:边缘函数、API 路由,前端开发者需要理解后端逻辑
  • AI 服务集成:调用 AI API、处理流式数据、管理状态,都需要后端知识

影响: 前端和后端的边界正在消失。只会前端的前端工程师,在 AI 时代会发现自己"够不着"核心业务。

4. 职业发展的天花板

冲击点:

  • 技术深度要求:AI 项目需要理解数据流、算法逻辑、系统架构
  • 业务理解能力:全栈开发者能更好地理解业务全貌,做出技术决策
  • 团队协作效率:全栈开发者减少前后端沟通成本,提升交付效率

影响: 在 AI 时代,只会前端的前端工程师,职业天花板明显。而全栈开发者能够:

  • 独立负责完整功能模块
  • 深入理解业务逻辑
  • 在技术决策中发挥更大作用

二、为什么前端转全栈是必然选择?

1. AI 项目的本质需求

正如美团案例所示,AI 项目(特别是 Agent 项目)的特点:

  • 前后端代码一起写:业务逻辑复杂,需要前后端协同
  • 数据流处理:AI 模型的输入输出、流式响应处理
  • 状态管理复杂:对话状态、上下文管理、错误处理

这些需求,纯前端工程师无法独立完成,必须掌握后端能力。

2. 技术发展的趋势

  • 全栈框架普及:Next.js、Remix、SvelteKit 等,都在推动全栈开发
  • 边缘计算兴起:Cloudflare Workers、Vercel Edge Functions,前端需要写后端逻辑
  • 微前端 + 微服务:前后端一体化部署,降低系统复杂度

3. 市场需求的转变

  • 招聘要求变化:越来越多的岗位要求"全栈能力"
  • 项目交付效率:全栈开发者能独立交付功能,减少沟通成本
  • 技术决策能力:全栈开发者能更好地评估技术方案

三、后端技术栈的选择:Node.js、Python、Go

对于前端转全栈,后端技术栈的选择至关重要。不同技术栈有不同优势,需要根据项目需求选择。

1. Node.js + Nest.js:前端转全栈的最佳起点

优势:

  • 零语言切换:JavaScript/TypeScript 前后端通用
  • 生态统一:npm 包前后端共享,工具链一致
  • 学习成本低:利用现有技能,快速上手
  • AI 集成友好:LangChain.js、OpenAI SDK 等完善支持

适用场景:

  • Web 应用后端
  • 实时应用(WebSocket、SSE)
  • 微服务架构
  • AI Agent 项目(如美团案例)

学习路径:

  1. Node.js 基础(事件循环、模块系统)
  2. Nest.js 框架(模块化、依赖注入)
  3. 数据库集成(TypeORM、Prisma)
  4. AI 服务集成(OpenAI、流式处理)

2. Python + FastAPI:AI 项目的首选

优势:

  • AI 生态最完善:OpenAI、LangChain、LlamaIndex 等原生支持
  • 数据科学能力:NumPy、Pandas 等数据处理库
  • 快速开发:语法简洁,开发效率高
  • 模型部署:TensorFlow、PyTorch 等模型框架

适用场景:

  • AI/ML 项目
  • 数据分析后端
  • 科学计算服务
  • Agent 项目(需要复杂 AI 逻辑)

学习路径:

  1. Python 基础(语法、数据结构)
  2. FastAPI 框架(异步、类型提示)
  3. AI 库集成(OpenAI、LangChain)
  4. 数据处理(Pandas、NumPy)

3. Go:高性能场景的选择

优势:

  • 性能优秀:编译型语言,执行效率高
  • 并发能力强:Goroutine 并发模型
  • 部署简单:单文件部署,资源占用少
  • 云原生友好:Docker、Kubernetes 生态完善

适用场景:

  • 高并发服务
  • 微服务架构
  • 云原生应用
  • 性能敏感场景

学习路径:

  1. Go 基础(语法、并发模型)
  2. Web 框架(Gin、Echo)
  3. 数据库操作(GORM)
  4. 微服务开发

4. 技术栈选择建议

对于前端转全栈的开发者:

  1. 首选 Node.js:如果目标是快速转全栈,Node.js 是最佳选择

    • 学习成本最低
    • 前后端代码复用
    • 适合大多数 Web 应用
  2. 考虑 Python:如果专注 AI 项目

    • AI 生态最完善
    • 适合复杂 AI 逻辑
    • 数据科学能力
  3. 学习 Go:如果追求性能

    • 高并发场景
    • 微服务架构
    • 云原生应用

建议:

  • 第一阶段:选择 Node.js,快速转全栈
  • 第二阶段:根据项目需求,学习 Python 或 Go
  • 长期目标:掌握多种技术栈,根据场景选择

四、总结

AI 时代的到来,给前端带来了深刻冲击:

  1. 代码生成能力:低复杂度页面生成被 AI 替代
  2. 业务逻辑前移:AI 项目需要前后端一体化
  3. 技术边界模糊:前后端边界正在消失
  4. 职业天花板:只会前端的前端工程师,发展受限

前端转全栈,是 AI 时代的必然选择。

对于技术栈选择:

  • Node.js:前端转全栈的最佳起点,学习成本低
  • Python:AI 项目的首选,生态完善
  • Go:高性能场景的选择,云原生友好

正如美团的全栈化实践所示,全栈开发还相对靠谱,关键在于:

  • 选择合适的技术栈
  • 建立严格的开发流程
  • 持续学习和实践

对于前端开发者来说,AI 时代既是挑战,也是机遇。转全栈,不仅能应对 AI 冲击,更能打开职业发展的新空间。那些"前端已死"的声音,其实是在提醒我们:只有不断进化,才能在这个时代立足。

3531. 统计被覆盖的建筑

作者 stormsunshine
2025年4月29日 20:13

解法一

思路和算法

根据建筑被覆盖的定义,当一个建筑在四个方向都至少存在一个其他建筑时,该建筑被覆盖。为了计算被覆盖的建筑数量,需要分别判断每个建筑是否被覆盖,因此需要分别统计每个 $x$ 坐标和每个 $y$ 坐标的所有建筑的列表。

使用两个哈希表分别记录每个 $x$ 坐标的所有建筑列表和每个 $y$ 坐标的所有建筑列表,两个哈希表分别为 $x$ 分组哈希表和 $y$ 分组哈希表。遍历数组 $\textit{buildings}$ 将所有建筑加入两个哈希表,然后将两个哈希表中的每个 $x$ 坐标和 $y$ 坐标对应的建筑列表排序,排序方法如下:在 $x$ 分组哈希表中,将每个 $x$ 坐标的所有建筑列表按 $y$ 坐标升序排序;在 $y$ 分组哈希表中,将每个 $y$ 坐标的所有建筑列表按 $x$ 坐标升序排序。

排序结束之后,即可判断每个建筑在 $x$ 坐标方向和 $y$ 坐标方向是否被覆盖。遍历所有建筑,对于位于坐标 $(x, y)$ 的建筑,判断方法如下。

  • 从 $y$ 分组哈希表中得到该建筑的相同 $y$ 坐标的所有建筑的列表,列表已经按 $x$ 坐标升序排序,判断当前 $x$ 坐标是否为列表中的最小值或最大值,如果既不是最小值也不是最大值,则该建筑在 $x$ 坐标方向被覆盖。

  • 从 $x$ 分组哈希表中得到该建筑的相同 $x$ 坐标的所有建筑的列表,列表已经按 $y$ 坐标升序排序,判断当前 $y$ 坐标是否为列表中的最小值或最大值,如果既不是最小值也不是最大值,则该建筑在 $y$ 坐标方向被覆盖。

当一个建筑同时在 $x$ 坐标方向和 $y$ 坐标方向被覆盖时,该建筑被覆盖。

遍历结束之后,即可得到被覆盖的建筑数量。

代码

###Java

class Solution {
    public int countCoveredBuildings(int n, int[][] buildings) {
        Map<Integer, List<Integer>> xToBuildings = new HashMap<Integer, List<Integer>>();
        Map<Integer, List<Integer>> yToBuildings = new HashMap<Integer, List<Integer>>();
        for (int[] building : buildings) {
            int x = building[0], y = building[1];
            xToBuildings.putIfAbsent(x, new ArrayList<Integer>());
            xToBuildings.get(x).add(y);
            yToBuildings.putIfAbsent(y, new ArrayList<Integer>());
            yToBuildings.get(y).add(x);
        }
        Set<Map.Entry<Integer, List<Integer>>> xEntries = xToBuildings.entrySet();
        Set<Map.Entry<Integer, List<Integer>>> yEntries = yToBuildings.entrySet();
        for (Map.Entry<Integer, List<Integer>> entry : xEntries) {
            Collections.sort(entry.getValue());
        }
        for (Map.Entry<Integer, List<Integer>> entry : yEntries) {
            Collections.sort(entry.getValue());
        }
        int count = 0;
        for (int[] building : buildings) {
            int x = building[0], y = building[1];
            List<Integer> xList = yToBuildings.get(y);
            List<Integer> yList = xToBuildings.get(x);
            int xMin = xList.get(0), xMax = xList.get(xList.size() - 1);
            int yMin = yList.get(0), yMax = yList.get(yList.size() - 1);
            if (x > xMin && x < xMax && y > yMin && y < yMax) {
                count++;
            }
        }
        return count;
    }
}

###C#

public class Solution {
    public int CountCoveredBuildings(int n, int[][] buildings) {
        IDictionary<int, IList<int>> xToBuildings = new Dictionary<int, IList<int>>();
        IDictionary<int, IList<int>> yToBuildings = new Dictionary<int, IList<int>>();
        foreach (int[] building in buildings) {
            int x = building[0], y = building[1];
            xToBuildings.TryAdd(x, new List<int>());
            xToBuildings[x].Add(y);
            yToBuildings.TryAdd(y, new List<int>());
            yToBuildings[y].Add(x);
        }
        foreach (KeyValuePair<int, IList<int>> pair in xToBuildings) {
            ((List<int>) pair.Value).Sort();
        }
        foreach (KeyValuePair<int, IList<int>> pair in yToBuildings) {
            ((List<int>) pair.Value).Sort();
        }
        int count = 0;
        foreach (int[] building in buildings) {
            int x = building[0], y = building[1];
            IList<int> xIList = yToBuildings[y];
            IList<int> yIList = xToBuildings[x];
            int xMin = xIList[0], xMax = xIList[xIList.Count - 1];
            int yMin = yIList[0], yMax = yIList[yIList.Count - 1];
            if (x > xMin && x < xMax && y > yMin && y < yMax) {
                count++;
            }
        }
        return count;
    }
}

复杂度分析

  • 时间复杂度:$O(m \log m)$,其中 $m$ 是数组 $\textit{buildings}$ 的长度。将所有建筑存入两个哈希表的时间是 $O(m)$,排序的时间是 $O(m \log m)$,排序之后计算被覆盖的建筑数量的时间是 $O(m)$,因此时间复杂度是 $O(m \log m)$。

  • 空间复杂度:$O(m)$,其中 $m$ 是数组 $\textit{buildings}$ 的长度。哈希表的空间是 $O(m)$。

解法二

思路和算法

判断一个建筑是否被覆盖时,需要知道如下信息。

  • 在相同 $y$ 坐标的所有建筑的列表中,该建筑的 $x$ 坐标是否为列表中的最小值或最大值。

  • 在相同 $x$ 坐标的所有建筑的列表中,该建筑的 $y$ 坐标是否为列表中的最小值或最大值。

因此,不需要维护每个 $x$ 坐标和每个 $y$ 坐标的所有建筑,只需要维护每个 $x$ 坐标的最小 $y$ 坐标和最大 $y$ 坐标,以及每个 $y$ 坐标的最小 $x$ 坐标和最大 $x$ 坐标。遍历数组 $\textit{buildings}$ 之后,将最小坐标与最大坐标的信息存入哈希表。再次遍历数组,即可根据哈希表中的最小坐标与最大坐标的信息判断每个建筑是否被覆盖,计算被覆盖的建筑数量。

代码

###Java

class Solution {
    public int countCoveredBuildings(int n, int[][] buildings) {
        Map<Integer, int[]> xToMinMax = new HashMap<Integer, int[]>();
        Map<Integer, int[]> yToMinMax = new HashMap<Integer, int[]>();
        for (int[] building : buildings) {
            int x = building[0], y = building[1];
            xToMinMax.putIfAbsent(x, new int[]{Integer.MAX_VALUE, Integer.MIN_VALUE});
            int[] yMinMax = xToMinMax.get(x);
            yMinMax[0] = Math.min(yMinMax[0], y);
            yMinMax[1] = Math.max(yMinMax[1], y);
            yToMinMax.putIfAbsent(y, new int[]{Integer.MAX_VALUE, Integer.MIN_VALUE});
            int[] xMinMax = yToMinMax.get(y);
            xMinMax[0] = Math.min(xMinMax[0], x);
            xMinMax[1] = Math.max(xMinMax[1], x);
        }
        int count = 0;
        for (int[] building : buildings) {
            int x = building[0], y = building[1];
            int[] xMinMax = yToMinMax.get(y);
            int[] yMinMax = xToMinMax.get(x);
            int xMin = xMinMax[0], xMax = xMinMax[1];
            int yMin = yMinMax[0], yMax = yMinMax[1];
            if (x > xMin && x < xMax && y > yMin && y < yMax) {
                count++;
            }
        }
        return count;
    }
}

###C#

public class Solution {
    public int CountCoveredBuildings(int n, int[][] buildings) {
        IDictionary<int, int[]> xToMinMax = new Dictionary<int, int[]>();
        IDictionary<int, int[]> yToMinMax = new Dictionary<int, int[]>();
        foreach (int[] building in buildings) {
            int x = building[0], y = building[1];
            xToMinMax.TryAdd(x, new int[]{int.MaxValue, int.MinValue});
            int[] yMinMax = xToMinMax[x];
            yMinMax[0] = Math.Min(yMinMax[0], y);
            yMinMax[1] = Math.Max(yMinMax[1], y);
            yToMinMax.TryAdd(y, new int[]{int.MaxValue, int.MinValue});
            int[] xMinMax = yToMinMax[y];
            xMinMax[0] = Math.Min(xMinMax[0], x);
            xMinMax[1] = Math.Max(xMinMax[1], x);
        }
        int count = 0;
        foreach (int[] building in buildings) {
            int x = building[0], y = building[1];
            int[] xMinMax = yToMinMax[y];
            int[] yMinMax = xToMinMax[x];
            int xMin = xMinMax[0], xMax = xMinMax[1];
            int yMin = yMinMax[0], yMax = yMinMax[1];
            if (x > xMin && x < xMax && y > yMin && y < yMax) {
                count++;
            }
        }
        return count;
    }
}

复杂度分析

  • 时间复杂度:$O(m)$,其中 $m$ 是数组 $\textit{buildings}$ 的长度。遍历所有建筑维护两个哈希表的时间是 $O(m)$,计算被覆盖的建筑数量的时间是 $O(m)$,因此时间复杂度是 $O(m)$。

  • 空间复杂度:$O(m)$,其中 $m$ 是数组 $\textit{buildings}$ 的长度。哈希表的空间是 $O(m)$。

统计行列的最小值和最大值(Python/Java/C++/Go)

作者 endlesscheng
2025年4月27日 12:46

分析

如果一个点在同一行的最左边,那么它左边没有点;如果一个点在同一行的最右边,那么它右边没有点。

如果一个点在同一列的最上边,那么它上边没有点;如果一个点在同一列的最下边,那么它下边没有点。

反之,如果一个点不在同一行的最左边也不在最右边,那么这个点左右都有点;如果一个点不在同一列的最上边也不在最下边,那么这个点上下都有点。

算法

记录同一行的最小横坐标和最大横坐标,同一列的最小纵坐标和最大纵坐标。

对于每个建筑 $(x,y)$,如果 $x$ 在这一行的最小值和最大值之间(不能相等),$y$ 在这一列的最小值和最大值之间(不能相等),那么答案加一。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def countCoveredBuildings(self, n: int, buildings: List[List[int]]) -> int:
        row_min = [n + 1] * (n + 1)
        row_max = [0] * (n + 1)
        col_min = [n + 1] * (n + 1)
        col_max = [0] * (n + 1)

        for x, y in buildings:
            # 手写 min max 更快
            if x < row_min[y]: row_min[y] = x
            if x > row_max[y]: row_max[y] = x
            if y < col_min[x]: col_min[x] = y
            if y > col_max[x]: col_max[x] = y

        ans = 0
        for x, y in buildings:
            if row_min[y] < x < row_max[y] and col_min[x] < y < col_max[x]:
                ans += 1
        return ans

###java

class Solution {
    public int countCoveredBuildings(int n, int[][] buildings) {
        int[] rowMin = new int[n + 1];
        int[] rowMax = new int[n + 1];
        int[] colMin = new int[n + 1];
        int[] colMax = new int[n + 1];
        Arrays.fill(rowMin, n + 1);
        Arrays.fill(colMin, n + 1);

        for (int[] p : buildings) {
            int x = p[0], y = p[1];
            rowMin[y] = Math.min(rowMin[y], x);
            rowMax[y] = Math.max(rowMax[y], x);
            colMin[x] = Math.min(colMin[x], y);
            colMax[x] = Math.max(colMax[x], y);
        }

        int ans = 0;
        for (int[] p : buildings) {
            int x = p[0], y = p[1];
            if (rowMin[y] < x && x < rowMax[y] && colMin[x] < y && y < colMax[x]) {
                ans++;
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int countCoveredBuildings(int n, vector<vector<int>>& buildings) {
        vector<int> row_min(n + 1, INT_MAX), row_max(n + 1);
        vector<int> col_min(n + 1, INT_MAX), col_max(n + 1);
        for (auto& p : buildings) {
            int x = p[0], y = p[1];
            row_min[y] = min(row_min[y], x);
            row_max[y] = max(row_max[y], x);
            col_min[x] = min(col_min[x], y);
            col_max[x] = max(col_max[x], y);
        }

        int ans = 0;
        for (auto& p : buildings) {
            int x = p[0], y = p[1];
            if (row_min[y] < x && x < row_max[y] && col_min[x] < y && y < col_max[x]) {
                ans++;
            }
        }
        return ans;
    }
};

###go

func countCoveredBuildings(n int, buildings [][]int) (ans int) {
type pair struct{ min, max int }
row := make([]pair, n+1)
col := make([]pair, n+1)
for i := 1; i <= n; i++ {
row[i].min = math.MaxInt
col[i].min = math.MaxInt
}

add := func(m []pair, x, y int) {
m[y].min = min(m[y].min, x)
m[y].max = max(m[y].max, x)
}
isInner := func(m []pair, x, y int) bool {
return m[y].min < x && x < m[y].max
}

for _, p := range buildings {
x, y := p[0], p[1]
add(row, x, y) // x 加到 row[y] 中
add(col, y, x) // y 加到 col[x] 中
}

for _, p := range buildings {
x, y := p[0], p[1]
if isInner(row, x, y) && isInner(col, y, x) {
ans++
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n+m)$,其中 $m$ 是 $\textit{buildings}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

模拟

作者 tsreaper
2025年4月27日 12:15

解法:模拟

把 $x$ 值相同的建筑放在一个 vector 里,对它们的 $y$ 值排序,就能知道每个建筑上下有没有其它建筑。左右的处理类似。

复杂度 $\mathcal{O}(n\log n)$。

参考代码(c++)

class Solution {
public:
    int countCoveredBuildings(int n, vector<vector<int>>& buildings) {
        int m = buildings.size();
        int cnt[m];
        memset(cnt, 0, sizeof(cnt));

        typedef pair<int, int> pii;
        // 把 x 值相同的建筑放在同一个 vector 里,对它们的 y 值排序
        unordered_map<int, vector<pii>> X;
        // 把 y 值相同的建筑放在同一个 vector 里,对它们的 x 值排序
        unordered_map<int, vector<pii>> Y;
        for (int i = 0; i < m; i++) {
            X[buildings[i][0]].push_back({buildings[i][1], i});
            Y[buildings[i][1]].push_back({buildings[i][0], i});
        }

        for (auto &p : X) {
            auto &vec = p.second;
            sort(vec.begin(), vec.end());
            // 标记下面有其它建筑的房子
            for (int i = 1; i < vec.size(); i++) cnt[vec[i].second]++;
            // 标记上面有其它建筑的房子
            for (int i = 0; i + 1 < vec.size(); i++) cnt[vec[i].second]++;
        }
        for (auto &p : Y) {
            auto &vec = p.second;
            sort(vec.begin(), vec.end());
            // 标记左面有其它建筑的房子
            for (int i = 1; i < vec.size(); i++) cnt[vec[i].second]++;
            // 标记右面有其它建筑的房子
            for (int i = 0; i + 1 < vec.size(); i++) cnt[vec[i].second]++;
        }

        int ans = 0;
        // 需要有 4 个标记
        for (int i = 0; i < m; i++) if (cnt[i] == 4) ans++;
        return ans;
    }
};
昨天 — 2025年12月10日技术

css的臂膀,前端动效的利器,还是布局的“隐形陷阱”?

作者 胡gh
2025年12月10日 21:07

前言

在现代 Web 开发中,transform 几乎成了动效与居中的代名词。它高效、灵活、支持硬件加速,一句 transform: translate(-50%, -50%) 就能优雅实现未知尺寸元素的绝对居中——这曾是多少前端开发者梦寐以求的解决方案。然而,在赞美其强大之余,我们是否忽略了它那“温柔面具”下的另一面?transform 不仅改变视觉,更悄然重塑了 CSS 的底层规则——尤其是与 position 的交互,常常成为布局崩坏的隐形元凶。


一、transform 的“理想面”:高效、无侵入、表现力强

核心优势:不扰动文档流

这是 transform 最被称道的特性:它只作用于合成层(compositing layer),不影响文档流中的占位
对比传统方案:

/* 低效:触发重排(reflow) */
.box { top: 10px; left: 20px; }

/* 高效:仅触发合成(compositing) */
.box { transform: translate(20px, 10px); }

前者会迫使浏览器重新计算整个布局树,而后者直接由 GPU 处理,帧率更高、能耗更低。

经典用例:绝对居中

.centered {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
  • 无需知道宽高-50% 基于元素自身尺寸,完美适配动态内容。
  • 语义清晰:意图明确,代码简洁。
  • 性能优异:远胜 margin: autocalc() 方案。

交互增强:微动效提升体验

.card:hover {
  transform: translateY(-4px) scale(1.02);
  transition: transform 0.2s ease;
}

这种“浮起”效果已成为现代 UI 的标配,而 transform 是实现它的最佳载体。


二、transform 的“暗面”:当它遇上 position

问题来了:如果 transform 如此美好,为何无数开发者在使用 Modal、Tooltip 时遭遇“fixed 元素跟着滚动”的诡异现象?

答案就藏在 CSS 规范的一个角落里:

“任何非 nonetransform 值,都会使该元素成为其 position: fixed 后代的包含块(containing block)。”

这意味着:fixed 元素的定位参考系,不再是视口,而是最近的带 transform 的祖先!

实例:一个“失效”的固定导航栏

<div class="app">
  <div class="animated-section"> 
    <nav class="navbar">我是导航栏</nav>
  </div>
</div>
.animated-section {
  transform: translateX(0); /* 哪怕是“无操作”变换! */
  height: 200vh; /* 足够滚动 */
}

.navbar {
  position: fixed;
  top: 0;
  width: 100%;
  background: black;
  color: white;
  z-index: 1000;
}

预期行为:导航栏固定在顶部,页面滚动时不动。
实际行为:导航栏随 .animated-section 一起滚动!

原因.animated-sectiontransform 成为新的包含块,fixedtop: 0 变成了“相对于该容器顶部”,而非视口。

这并非浏览器 bug,而是 CSS 规范的明确设计。但对开发者而言,这却是一个典型的“符合规范但违背直觉”的陷阱。


补充


关键补充属性
1. transform-origin:修改变换原点

默认变换原点是元素中心(50% 50% 或 center center),可通过该属性自定义:

  • 语法:transform-origin: x y;(x/y 支持 px、%、关键字(left/right/top/bottom/center));

  • 示例:

    css

    .box {
      transform: rotate(45deg);
      transform-origin: left top; /* 绕左上角旋转 */
    }
    
2. transform-style:3D 变换层级

当使用 3D 变换(如 rotateX/rotateY)时,需设置该属性让子元素继承 3D 空间:

  • transform-style: preserve-3d;:保留 3D 变换效果(常用);
  • transform-style: flat;:默认值,子元素扁平化到 2D 平面。

三、不止 transformfilterperspective 的共谋

更令人头疼的是,transform 并非孤例。以下属性同样会创建新的包含块:

  • filter: blur(0)(哪怕无视觉变化)
  • perspective: 1000px
  • will-change: transform

它们常被用于:

  • 动画库(Framer Motion、GSAP)自动注入 transform
  • 组件库内部使用 filter 实现毛玻璃效果
  • 3D 卡片组件启用 perspective

结果就是:你的 fixed Modal 突然无法覆盖全屏,Tooltip 错位,悬浮按钮失效……


四、反思:我们是否过度依赖 transform

transform 的流行,某种程度上掩盖了我们对 CSS 布局模型理解的不足。我们习惯性地用它解决一切位置问题,却忘了问:

“这个元素真的需要脱离文档流吗?它的祖先是否干净?”

在 React/Vue 等组件化框架中,问题被进一步放大:

  • 组件嵌套深,难以追踪哪个祖先加了 transform
  • 第三方库内部实现不可控
  • 动画与布局逻辑耦合,调试困难

于是,transform 从“解决方案”变成了“问题源头”。


五、破局之道:理性使用 + 架构规避

1. 浮层组件必须脱离当前上下文

在 React 中,永远使用 ReactDOM.createPortal 渲染 Modal/Toast/Dropdown

const Modal = ({ children }) => 
  createPortal(
    <div>
      {children}
    </div>,
    document.body
  );

这样可确保其祖先无 transformfixed 行为符合预期。

2. 避免在可能包含浮层的区域使用 transform

  • 导航区域、主内容区慎用动画
  • 若必须用,确保浮层不在其子树中

3. 调试时牢记“包含块”概念

fixed 行为异常,立即检查:

  • 所有祖先是否有 transform / filter / perspective
  • 是否意外创建了层叠上下文

结语:工具无善恶,认知定成败

transform 本身并无过错。它是 CSS 进化的重要里程碑,极大提升了 Web 的表现力与性能。
真正的风险,来自于我们对其副作用的无知,以及对“简单一行代码就能解决问题”的盲目信任。

前端开发不仅是写代码,更是理解系统。当我们熟练运用 transform: translate(-50%, -50%) 的同时,也应深知:

每一个看似优雅的解决方案背后,都有一套严密的规则在运行。忽视规则,终将被规则反噬。

因此,请继续热爱 transform,但请带着敬畏之心使用它——尤其是在与 position 共舞之时。

紧急修复!Dify CVE-2025-55182 高危漏洞,手把手教你升级避坑

作者 wwwzhouhui
2025年12月10日 20:40

前言

在AI应用快速发展的今天,如何保障生产环境的安全成为了企业关注的焦点。传统的开发方式往往只关注功能实现,却忽视了底层框架的供应链安全风险,特别是当你使用的技术栈升级到最新架构时,一些隐藏的安全漏洞可能在不经意间就让你的服务器"裸奔"在互联网上。

这2天Dify用户炸锅了!号称"史上最严重"的React漏洞CVE-2025-55182被曝光,CVSS评分高达10.0满分。这个漏洞源于React Server Components(RSC)和Next.js App Router架构的反序列化缺陷,攻击者无需任何身份验证,只需发送一个精心构造的HTTP请求,就能在服务器上执行任意代码,直接拿下服务器权限。更可怕的是,全球已有大量Dify自托管实例被植入挖矿程序、窃取API密钥、甚至建立持久化后门。好家伙,这不就是现代版的"一键夺舍"吗?

1765352612636

今天我们就带小伙伴们深入了解CVE-2025-55182漏洞的来龙去脉,并手把手教大家在Dify平台上进行安全修复升级,避开升级过程中的各种大坑。呵呵,保证让你的Dify实例固若金汤!

✨ 漏洞核心特征

  • 🔥 CVSS 10.0满分: 史上最严重的React安全漏洞,影响范围极广
  • 💀 无需身份验证: 攻击者不需要登录,只要服务暴露在公网即可攻击
  • ⚡ 远程代码执行: 一个HTTP请求直接获取服务器Shell权限
  • 🎯 精准打击Dify: 使用Next.js App Router的Dify v1.9.x-v1.10.1全线受影响
  • 💰 高价值目标: 可窃取OpenAI/Claude API密钥、数据库凭证等核心资产
  • 🔗 供应链攻击: 源头漏洞在React,Next.js连带受影响,波及整个生态
  • ⚠️ 升级有坑: 官方镜像标签存在错误,盲目升级可能无效
  • 🛡️ WAF可防御: 通过雷池等WAF可临时拦截攻击,争取升级时间

1765359233004

🛠️ 技术背景:从CSR到RSC的架构演进

要理解这个漏洞为什么这么严重,我们得先了解React和Next.js的技术演进史。呵呵,别怕,我用大白话给大家讲清楚。

React架构的三次进化

第一代:客户端渲染(CSR)

  • 时代: 2013-2016年
  • 特点: 服务器只发送空HTML,浏览器下载JS后动态生成页面
  • 问题: 首屏慢、SEO差、JS体积大

第二代:服务端渲染(SSR)

  • 时代: 2016-2022年
  • 代表: Next.js Pages Router
  • 特点: 服务器生成完整HTML,浏览器"注水"(Hydration)实现交互
  • 问题: 仍需下载全量JS,Hydration延迟高

第三代:React Server Components(RSC)

  • 时代: 2022年至今
  • 代表: Next.js 13+ App Router
  • 特点: 组件分为服务端组件和客户端组件,服务端组件不发送到客户端
  • 优势: JS体积减少90%,性能大幅提升
  • 风险: 引入了新的通信协议——Flight Protocol

1765359490644

Flight协议:漏洞的"元凶"

为了让服务端组件和客户端组件能够无缝通信,React团队设计了Flight Protocol。这个协议不仅要传输数据,还要传输组件树结构、Promise对象、甚至服务端函数引用。传统的JSON格式无法满足这些复杂需求,于是React实现了一套自定义的序列化/反序列化机制。

问题就出在这里! 当服务器反序列化客户端发来的Flight协议数据时,没有充分验证数据的安全性,导致攻击者可以构造恶意的序列化数据,触发服务器执行任意代码。

架构特征 CSR SSR RSC
执行位置 浏览器 服务器+浏览器 服务器(Server Components)
数据传输 JSON API HTML字符串 Flight协议序列化流
主要风险 XSS, CSRF XSS, 注入 反序列化RCE
受此漏洞影响 ❌ 否 ❌ 否

unnamed

🎯 漏洞影响范围

受影响的Dify版本

  • Dify v1.9.0 - v1.10.1: 全线受影响
  • 核心受害者: dify-web容器(前端服务)
  • 技术栈: Next.js 15.x + React 19.x (App Router架构)

受影响的Next.js版本

  • Next.js 13.4+: 使用App Router的所有版本
  • Next.js 14.x, 15.x, 16.x: 全部受影响
  • Pages Router用户: 完全不受影响(使用pages/目录)

1765359829068

攻击后果

根据真实案例,攻击者成功入侵后会进行以下操作:

  1. 资源劫持: 部署XMRig挖矿程序,CPU占用飙升至100%
  2. 凭证窃取: 读取环境变量中的API密钥(OpenAI/Claude/AWS等)
  3. 数据泄露: 窃取数据库密码,访问PostgreSQL中的敏感数据
  4. 持久化后门: 在.next目录写入Webshell,修改cron任务
  5. 内网渗透: 以Dify为跳板,攻击内网其他服务

安全漏洞复现

dify部署

我这里使用最新的dify1.10.1版本给大家演示。

image-20251210142140149

我们在本地电脑上使用docker compose 安装

image-20251210142250397

下图我们按照官方的部署方式部署了一个1.10.1版本(算是比较新的版本)

漏洞检查

我们这里在github搜索CVE-2025-55182 搜到这个开源项目 把这个代码下载到本地

image-20251210142527875

代码下载本地

image-20251210142556609

这个poc.py 代码非常简单,我们在命令行窗口直接下面地址

python poc.py http://127.0.0.1

image-20251210142741505

出现下面的画面我们的浮现了上手漏洞。脚本已经成功拿到数据库返回uid=1001 用户root。如果这个暴露在互联网,如果没有安全防护WAF等安全设备硬件或者软件基本上我通过这一行命令就可以成功拿到服务器权限了。

扫描生成环境

我们公司也少量的使用的dify做一些信息查询和统计。我们查看一下生产环境的版本。

image-20251210143144806

我们检查下面当前生产环境使用的是1.9.0 版本。刚才我们上面给大家演示的是1.10.1 版本也是成功拿下,我们也在想这个脚本是不是也可以拿下生产环境?答案我也不知道,我们来试一试。

python poc.py https://xxx.xxx.com

生产环境我们用域名来实现访问的所以地址是上面的信息(我这里影藏了。)

image-20251210143455362

返回了一串信息还有一个403。返回信息是一个HTML,我们抓出来保存HTML

image-20251210143608687

我们看到这里的POC请求被我们的雷池WAF成功拦截了。因为这个是一个反序列化漏洞,WAF通过判断成功拦截了一些非法请求。成功保护了我们后端程序不给其他非法工具攻击成功。

所以生产环境中我们在前端增加一个WAF防护拦截还是有必要的。这样我们可以给后端程序争取升级的时间,有效的包含我们的信息资产不会被攻击到。

升级漏洞修复

升级包介绍

知道了上面的安全漏洞我们按照官方提供的安全漏洞镜像包升级就可以了。我们在dify官方镜像包

image-20251210144155082

我们把代码下载到本地

我们打开代码进入docker文件夹中找到docker-compose.yaml 并打开它。这个地方其实有一个坑,上周官方紧急修改了BUG,但是这个docker-compose.yaml里面的镜像文件并没有修复还是老的langgenius/dify-api:1.10.1、langgenius/dify-web:1.10.1

image-20251210145019818

我们需要注意这个地方应该是langgenius/dify-web:1.10.1-fix.1、langgenius/dify-api:1.10.1-fix.1

image-20251210145142608

很多小伙伴被坑了一把,服务器我是周末手工升级的,公司的环境我周一(2025年12月8日)安排其他人升级的,结果升级后没有修复这个漏洞复现了。我今天仔细检查了升级的记录发现上述问题。

新的升级包已经修复了这个问题,我看升级时间大概是昨天

image-20251210145510508

大家可以使用比较工具比较(我平时升级都不是安装官方方式升级,官方升级坑多)

image-20251210145742384

确保以上版本是langgenius/dify-web:1.10.1-fix.1、langgenius/dify-api:1.10.1-fix.1 升级后才不会有问题。

顺便提一嘴,我使用的比较工具名字叫做BCompare,写代码、分析代码比较文档神器。我这个工具我用了很多年了推荐给大家。(工具下载大家可以网上搜,这里就不给提供下载包了,包我也没有。)

image-20251210153213972

image-20251210153151439

升级复测

我们确保升级版本是langgenius/dify-web:1.10.1-fix.1、langgenius/dify-api:1.10.1-fix.1 正确的修复镜像后启动dify, 接下来我们还是使用前面的验证脚本测试

python poc.py  http://192.168.210.10

image-20251210150011032

出现404了。这个测试方法我是绕过公司网络安全防护WAF验证的。这POC 没有返回信息,说明安全漏洞已经修复没有出现反弹的序列化漏洞了。

总结

今天主要带大家了解并实现了Dify平台CVE-2025-55182高危安全漏洞的完整检测与修复流程,该漏洞以"React Server Components反序列化缺陷 + CVSS 10.0满分评级"为核心特征,结合Next.js App Router架构和Flight协议的不安全反序列化机制,通过无需身份验证的恶意HTTP请求直接触发远程代码执行,形成了一套从漏洞检测到安全加固的全链路防御方案。

在实际应用中,该修复方案不仅解决了React生态史上最严重的安全漏洞威胁,还通过对比CSR/SSR/RSC三代架构演进揭示了Flight协议设计缺陷的根本原因,技术深度远超简单的"打补丁"操作;特别是通过真实的生产环境WAF拦截案例、官方镜像标签错误踩坑经历、周末紧急升级与周一复测失败的血泪教训等关键信息点,有效帮助小伙伴们避开升级过程中12月6-8日期间docker-compose.yaml配置文件未更新-fix.1后缀的致命陷阱。

感兴趣的小伙伴可以按照文中提供的步骤进行实践,根据实际部署架构调整镜像版本检查和POC测试方法。今天的分享就到这里结束了,我们下一篇文章见。

Expo (React Native) 最佳实践:TanStack Query 深度集成指南

2025年12月10日 19:53

在 Expo 开发中,引入 TanStack Query (React Query) 是移动端开发的强烈推荐方案

它不仅仅是一个请求库,而是一套完整的异步状态管理方案,专门解决了 React Native 中最棘手的网络与缓存问题。

1. 为什么它是必选项?

在移动端场景下,TanStack Query 解决了传统 useEffect + useState 难以处理的四大痛点:

  • 智能缓存与离线支持:在网络不稳定或离线时展示缓存数据,避免白屏;网络恢复后自动在后台静默更新。
  • 应用激活自动刷新 (Focus Refetch) :当用户从后台(如回复微信后)切回 App 时,自动检测并刷新过期数据,确保用户看到的信息永远是最新的。
  • 状态管理自动化:彻底告别手写 isLoadingisErrordata 等繁琐的样板代码。
  • 列表交互集成:内置了对 下拉刷新无限滚动 (Infinite Scroll) 的完美支持,与 FlatList 配合天衣无缝。

2. 推荐的技术栈架构

在 React Native 应用中,我们推荐以下的“黄金三角”组合:

  • 状态管理 (Server State)TanStack Query (负责请求去重、缓存、过期策略)
  • 网络请求 (Fetcher)AxiosFetch (负责发送 HTTP 请求)
  • 安全存储 (Auth)Expo Secure Store (负责存储 Token)

3. 生产环境集成指南 (关键)

在 React Native 中使用 TanStack Query,必须手动配置 App 状态监听网络状态监听,否则“切后台自动刷新”等核心功能在真机上将失效。

第一步:安装依赖

# 核心库
npm install @tanstack/react-query

# 用于监听网络状态(必须)
npx expo install @react-native-community/netinfo

第二步:配置全局 Provider (app/_layout.tsx)

这是最关键的一步。我们需要告诉 TanStack Query 何时 App 处于“前台”,以及何时“联网”。

import { useEffect } from 'react';
import { AppStateStatus, Platform } from 'react-native';
import { Slot } from 'expo-router';
import { 
  QueryClient, 
  QueryClientProvider, 
  focusManager, 
  onlineManager 
} from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
import { useAppState } from '@/hooks/useAppState'; // 假设你有一个 AppState Hook,或者直接写在下面

// 1. 配置网络状态监听 (解决断网重连自动刷新)
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

// 2. 配置 App 焦点监听 (解决切回前台自动刷新)
function onAppStateChange(status: AppStateStatus) {
  // Web 端自带监听,RN 端需要手动触发
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active');
  }
}

// 3. 初始化 QueryClient (配置全局默认策略)
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2, // 请求失败自动重试 2 次
      staleTime: 1000 * 60, // 数据 1 分钟内认为是“新鲜”的,不触发自动请求
      gcTime: 1000 * 60 * 5, // 缓存垃圾回收时间 (5分钟)
    },
  },
});

export default function RootLayout() {
  // 监听 App 状态变化
  useEffect(() => {
    const subscription = AppState.addEventListener('change', onAppStateChange);
    return () => subscription.remove();
  }, []);

  return (
    
       {/* 你的页面入口 */}
    
  );
}

第三步:在页面中使用

配合 RefreshControl 实现下拉刷新变得非常简单:

import { useQuery } from '@tanstack/react-query';
import { View, Text, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
import axios from 'axios';

// 定义请求函数
const fetchTodos = async () => {
  const { data } = await axios.get('https://api.example.com/todos');
  return data;
};

export default function TodoListScreen() {
  const { data, isLoading, isError, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  if (isLoading) return ;
  if (isError) return 加载失败,请重试;

  return (
     item.id.toString()}
      renderItem={({ item }) => {item.title}}
      // 集成下拉刷新,只需一行代码
      refreshControl={
        
      }
    />
  );
}

4. 总结

引入 TanStack Query 是 Expo 应用迈向高性能高可用性的关键一步。

  • 对于开发者:它消除了 90% 的冗余代码和手动状态管理。
  • 对于用户:它提供了丝滑的“切后台刷新”和“断网重连”体验。

【鸿蒙开发案例篇】鸿蒙6.0计算器实现详解

2025年12月10日 19:45

大家好,我是 V 哥。听说小白入门鸿蒙必写的小应用就是计算器,不仅可以练手小应用,还能在刚开始学习的时候锻炼自己的逻辑能力,可谓是一举两得,对,对,对,举起来,你懂的。

下面是基于基础组件和容器组件来练练手,V哥将详细实现一个支持加减乘除混合运算的计算器。

联系V哥获取 鸿蒙学习资料


一、项目结构与核心设计

技术栈

  • API版本:HarmonyOS 6.0.0 Release (API 21)
  • 开发工具:DevEco Studio 6
  • 核心组件:TextInput、Button、ForEach、Grid、Stack

目录结构

Calculator/
├── entry/
│   └── src/
│       ├── main/
│       │   ├── ets/
│       │   │   ├── pages/
│       │   │   │   └── Index.ets        # 主页面
│       │   │   ├── utils/
│       │   │   │   └── Calculator.ts    # 计算逻辑
│       │   │   └── CommonConstants.ts   # 常量定义
│       │   └── resources/               # 资源文件

二、核心代码实现

1. 常量定义(CommonConstants.ts)

export class CommonConstants {
  // 运算符常量
  static readonly ADD: string = '+';
  static readonly SUB: string = '-';
  static readonly MUL: string = '×';
  static readonly DIV: string = '÷';
  static readonly EQUAL: string = '=';
  static readonly CLEAR: string = 'C';
  static readonly BACKSPACE: string = '⌫';
  static readonly DOT: string = '.';
  
  // 按钮布局
  static readonly BUTTONS: string[][] = [
    ['C', '⌫', '÷', '×'],
    ['7', '8', '9', '-'],
    ['4', '5', '6', '+'],
    ['1', '2', '3', '='],
    ['0', '.', '=']
  ];
}

2. 计算逻辑核心(Calculator.ts)

export class Calculator {
  private currentInput: string = '0';
  private previousInput: string = '';
  private operator: string = '';
  private shouldResetInput: boolean = false;

  // 处理按钮点击
  handleButtonClick(button: string): string {
    if (this.isNumber(button)) {
      return this.handleNumber(button);
    } else if (this.isOperator(button)) {
      return this.handleOperator(button);
    } else if (button === CommonConstants.DOT) {
      return this.handleDot();
    } else if (button === CommonConstants.CLEAR) {
      return this.handleClear();
    } else if (button === CommonConstants.BACKSPACE) {
      return this.handleBackspace();
    } else if (button === CommonConstants.EQUAL) {
      return this.handleEqual();
    }
    return this.currentInput;
  }

  // 数字处理(解决精度问题)
  private handleNumber(num: string): string {
    if (this.shouldResetInput || this.currentInput === '0') {
      this.currentInput = num;
      this.shouldResetInput = false;
    } else {
      this.currentInput += num;
    }
    return this.currentInput;
  }

  // 运算符处理
  private handleOperator(op: string): string {
    if (this.operator && !this.shouldResetInput) {
      this.calculate();
    }
    this.previousInput = this.currentInput;
    this.operator = op;
    this.shouldResetInput = true;
    return this.currentInput;
  }

  // 等于号处理
  private handleEqual(): string {
    if (this.operator && this.previousInput) {
      this.calculate();
      this.operator = '';
      this.shouldResetInput = true;
    }
    return this.currentInput;
  }

  // 核心计算逻辑(解决浮点数精度问题)
  private calculate(): void {
    const prev = parseFloat(this.previousInput);
    const current = parseFloat(this.currentInput);
    
    if (isNaN(prev) || isNaN(current)) return;

    let result: number;
    switch (this.operator) {
      case CommonConstants.ADD:
        // 小数精度处理:转换为整数计算
        result = this.add(prev, current);
        break;
      case CommonConstants.SUB:
        result = this.subtract(prev, current);
        break;
      case CommonConstants.MUL:
        result = this.multiply(prev, current);
        break;
      case CommonConstants.DIV:
        result = this.divide(prev, current);
        break;
      default:
        return;
    }
    
    // 处理大数显示(科学计数法)
    this.currentInput = this.formatResult(result);
  }

  // 精确加法(解决0.1+0.2≠0.3问题)
  private add(a: number, b: number): number {
    const multiplier = Math.pow(10, Math.max(this.getDecimalLength(a), this.getDecimalLength(b)));
    return (a * multiplier + b * multiplier) / multiplier;
  }

  // 精确乘法
  private multiply(a: number, b: number): number {
    const decimalLength = this.getDecimalLength(a) + this.getDecimalLength(b);
    const multiplier = Math.pow(10, decimalLength);
    return (a * Math.pow(10, this.getDecimalLength(a))) * 
           (b * Math.pow(10, this.getDecimalLength(b))) / multiplier;
  }

  // 获取小数位数
  private getDecimalLength(num: number): number {
    const str = num.toString();
    return str.includes('.') ? str.split('.').length : 0;
  }

  // 结果格式化(大数转科学计数法)
  private formatResult(result: number): string {
    if (Math.abs(result) > 1e15 || (Math.abs(result) < 1e-6 && result !== 0)) {
      return result.toExponential(10).replace(/(\.\d*?[1-9])0+e/, '$1e');
    }
    return result.toString();
  }

  // 其他辅助方法
  private isNumber(str: string): boolean { return !isNaN(Number(str)); }
  private isOperator(str: string): boolean { 
    return [CommonConstants.ADD, CommonConstants.SUB, CommonConstants.MUL, CommonConstants.DIV].includes(str); 
  }
  private handleDot(): string { /* 实现小数点逻辑 */ }
  private handleClear(): string { /* 实现清空逻辑 */ }
  private handleBackspace(): string { /* 实现退格逻辑 */ }
  private subtract(a: number, b: number): number { /* 精确减法 */ }
  private divide(a: number, b: number): number { /* 精确除法 */ }
}

3. 主页面UI实现(Index.ets)

@Entry
@Component
struct CalculatorPage {
  @State displayText: string = '0'
  private calculator: Calculator = new Calculator()

  build() {
    Column({ space: 20 }) {
      // 显示区域
      TextInput({ text: this.displayText })
        .width('90%')
        .height(80)
        .fontSize(32)
        .textAlign(TextAlign.End)
        .backgroundColor(Color.White)
        .border({ width: 2, color: Color.Blue })

      // 按钮区域 - 使用Grid布局
      Grid() {
        ForEach(CommonConstants.BUTTONS, (row: string[], rowIndex: number) => {
          GridItem() {
            Row({ space: 10 }) {
              ForEach(row, (button: string) => {
                Button(button)
                  .width(70)
                  .height(70)
                  .fontSize(24)
                  .fontColor(this.getButtonColor(button))
                  .backgroundColor(this.getButtonBgColor(button))
                  .borderRadius(10)
                  .onClick(() => {
                    this.onButtonClick(button)
                  })
              })
            }
          }
        })
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr 1fr 1fr')
      .width('90%')
      .height(400)
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .backgroundColor('#F5F5F5')
  }

  // 按钮点击处理
  private onButtonClick(button: string): void {
    this.displayText = this.calculator.handleButtonClick(button)
  }

  // 按钮颜色配置
  private getButtonColor(button: string): Color {
    if (this.isOperator(button) || button === CommonConstants.EQUAL) {
      return Color.White
    }
    return Color.Black
  }

  private getButtonBgColor(button: string): Color {
    if (button === CommonConstants.CLEAR) {
      return Color.Red
    } else if (this.isOperator(button) || button === CommonConstants.EQUAL) {
      return Color.Blue
    }
    return Color.White
  }

  private isOperator(button: string): boolean {
    return [CommonConstants.ADD, CommonConstants.SUB, CommonConstants.MUL, CommonConstants.DIV].includes(button)
  }
}

三、关键技术特性

1. 精度处理机制

// 示例:0.2 + 2.22 的正确计算
private add(a: number, b: number): number {
  const decimalA = this.getDecimalLength(a)  // 1位小数
  const decimalB = this.getDecimalLength(b)  // 2位小数
  const multiplier = Math.pow(10, Math.max(decimalA, decimalB))  // 100
  
  return (a * multiplier + b * multiplier) / multiplier
  // 计算过程:(0.2*100 + 2.22*100)/100 = (20 + 222)/100 = 242/100 = 2.42
}

2. 大数显示优化

// 9007199254740992 + 1 显示为科学计数法
private formatResult(result: number): string {
  if (result > 1e15) {
    return result.toExponential(10)  // &#34;9.007199254740993e15&#34;
  }
  return result.toString()
}

3. 连续运算支持

  • 支持表达式:3 + 5 × 2 - 1 = 12
  • 状态管理:通过previousInputcurrentInputoperator跟踪运算状态

四、运行效果与测试用例

测试场景

  1. 基础运算5 + 3 = 8
  2. 小数精度0.1 + 0.2 = 0.3
  3. 混合运算3 + 5 × 2 = 13
  4. 大数处理999999999999999 + 1 = 1e+15
  5. 错误处理5 ÷ 0 = Infinity

五、扩展功能建议

  1. 历史记录:添加Stack组件保存计算历史
  2. 主题切换:使用@StorageLink实现深色模式
  3. 语音播报:集成TTS功能朗读计算结果
  4. 单位转换:扩展科学计算器功能

这个实现完整展示了鸿蒙6.0下基于基础组件的计算器开发,重点解决了浮点数精度和大数显示等核心问题。兄弟们,可以去试试哦。

卷二_副本2.jpg

Expo (React Native) 本地存储全攻略:普通数据与敏感数据该存哪?

2025年12月10日 19:29

在移动应用开发中,数据持久化(Data Persistence)是绕不开的话题。无论是为了让用户免于重复登录,还是保存用户的偏好设置(如深色模式),我们都需要将数据存储在用户的设备上。

对于使用 Expo 开发的 React Native 项目,官方推荐了两种截然不同的存储方案:AsyncStorageexpo-secure-store

本文将深入解析这两者的区别、适用场景,并教你如何在项目中优雅地封装它们。


方案一:AsyncStorage(通用存储)

AsyncStorage 是 React Native 社区维护的一个异步、非加密的键值对(Key-Value)存储系统。你可以把它想象成浏览器端的 localStorage

1. 适用场景

由于它是未加密的,任何拥有设备文件系统访问权限的人(或恶意软件)都能读取这些数据。因此,它只适合存储:

  • ✅ 用户偏好设置(如:是否开启推送、语言设置、主题色)
  • ✅ 应用缓存数据(如:首页的临时数据列表)
  • ✅ Redux/Zustand 状态的持久化
  • 绝对不要存:用户密码、身份认证 Token、信用卡信息

2. 安装

npx expo install @react-native-async-storage/async-storage

3. 使用示例

AsyncStorage 只能存储字符串。如果你要存储对象,需要使用 JSON.stringifyJSON.parse 进行转换。

import AsyncStorage from '@react-native-async-storage/async-storage';

// 1. 存储数据 (Key 必须是字符串,Value 也必须是字符串)
const saveSettings = async () => {
  try {
    await AsyncStorage.setItem('app_theme', 'dark');
    // 存储对象需序列化
    await AsyncStorage.setItem('user_profile', JSON.stringify({ name: 'John', age: 30 }));
  } catch (e) {
    console.error('保存失败', e);
  }
};

// 2. 读取数据
const loadSettings = async () => {
  try {
    const theme = await AsyncStorage.getItem('app_theme');
    
    const jsonValue = await AsyncStorage.getItem('user_profile');
    const userProfile = jsonValue != null ? JSON.parse(jsonValue) : null;
    
    console.log(theme, userProfile);
  } catch (e) {
    console.error('读取失败', e);
  }
};

// 3. 删除/清空
const clearData = async () => {
    await AsyncStorage.removeItem('app_theme'); // 删除单个
    await AsyncStorage.clear(); // 清空所有(慎用)
}

方案二:expo-secure-store(安全存储)

SecureStore 是 Expo 提供的一个库,它利用了操作系统底层的安全机制来加密存储数据。

  • iOS: 使用 Keychain Services(钥匙串)。
  • Android: 使用 SharedPreferences(配合 Keystore 加密)。

1. 适用场景

这是存储敏感数据的唯一正确选择:

  • ✅ 身份认证 Token (Auth Token / Refresh Token)
  • ✅ 用户密码 (Password)
  • ✅ API 密钥 (API Keys)

2. 安装

npx expo install expo-secure-store

3. 使用示例

API 与 AsyncStorage 非常相似,但它保证了数据是加密落盘的。

import * as SecureStore from 'expo-secure-store';

// 1. 存储敏感数据
const saveToken = async (token: string) => {
  // 注意:Key 和 Value 都必须是字符串
  await SecureStore.setItemAsync('user_token', token);
};

// 2. 读取数据
const getToken = async () => {
  const token = await SecureStore.getItemAsync('user_token');
  if (token) {
    console.log('获取到 Token:', token);
  } else {
    console.log('未找到 Token');
  }
};

// 3. 删除数据 (如用户登出时)
const removeToken = async () => {
  await SecureStore.deleteItemAsync('user_token');
};

深度对比:该选哪一个?

特性 AsyncStorage SecureStore
安全性 🚨 (明文存储,类似 TXT 文件) 🔒 (OS 级硬件加密)
存储容量 较大 (几 MB 到几十 MB 没问题) 极小 (建议只存 < 2KB 的短字符串)
读写速度 稍慢 (涉及解密过程)
数据类型 仅字符串 (需手动 JSON 序列化) 仅字符串
核心用途 缓存、设置、非敏感状态 Token、密码、证书

最佳实践:封装统一的存储工具

在真实的项目开发中,我们不应该在 UI 组件里直接调用 AsyncStorageSecureStore,而是应该封装一个工具模块。这样既能统一管理 Key,又能屏蔽底层实现的差异。

以下是一个推荐的 utils/storage.ts 封装示例:

// utils/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';

// 定义所有的 Key,防止手误写错
const KEYS = {
  TOKEN: 'auth_token',
  USER_INFO: 'user_info',
  THEME: 'app_theme',
};

/**
 * 敏感数据存储工具 (使用 SecureStore)
 */
export const AuthStorage = {
  async setToken(token: string) {
    await SecureStore.setItemAsync(KEYS.TOKEN, token);
  },

  async getToken() {
    return await SecureStore.getItemAsync(KEYS.TOKEN);
  },

  async clearToken() {
    await SecureStore.deleteItemAsync(KEYS.TOKEN);
  },
};

/**
 * 普通数据存储工具 (使用 AsyncStorage)
 */
export const AppStorage = {
  // 封装对象存储,自动处理 JSON 转换
  async setUser(user: any) {
    try {
      const jsonValue = JSON.stringify(user);
      await AsyncStorage.setItem(KEYS.USER_INFO, jsonValue);
    } catch (e) {
      console.error('Saving user failed', e);
    }
  },

  async getUser() {
    try {
      const jsonValue = await AsyncStorage.getItem(KEYS.USER_INFO);
      return jsonValue != null ? JSON.parse(jsonValue) : null;
    } catch (e) {
      console.error('Loading user failed', e);
      return null;
    }
  },
  
  async clearAll() {
      await AsyncStorage.clear();
  }
};

如何在业务中使用?

import { AuthStorage, AppStorage } from './utils/storage';

// 登录成功后
const handleLoginSuccess = async (apiResponse) => {
    const { token, user } = apiResponse;
    
    // 1. Token 进保险箱
    await AuthStorage.setToken(token);
    
    // 2. 用户信息进普通仓库
    await AppStorage.setUser(user);
    
    console.log('登录数据已持久化');
};

// 退出登录时
const handleLogout = async () => {
    await AuthStorage.clearToken();
    await AppStorage.setUser(null); 
};

总结

在 React Native (Expo) 开发中,请务必遵守 “数据分级” 原则:

  1. Token 和密码:必须通过 expo-secure-store 放入系统的“保险箱”。
  2. 设置和缓存:可以通过 @react-native-async-storage/async-storage 放入普通的“储物柜”。

通过合理的封装,你可以让代码更安全、更整洁,也更易于维护。

【鸿蒙开发案例篇】鸿蒙跨设备实时滤镜同步的完整方案

2025年12月10日 19:14

大家好,我是 V 哥。鸿蒙在跨设备数据传输的能力,即丝滑又酷炫,今天的内容,V 哥想分享一下基于鸿蒙 6.0(API21)实现跨设备实时滤镜同步的完整方案,结合分布式软总线与 PixelMap 的协同处理流程:

联系V哥获取 鸿蒙学习资料


一、技术架构设计

层级 组件 功能说明
应用层 手机(客户端) 调用相机生成 PixelMap,应用滤镜算法,通过 RPC 发送数据
传输层 分布式软总线(SoftBus) 设备自动发现、低延迟传输渲染指令流
渲染层 平板(服务端) 接收 PixelMap 数据流,通过 CanvasRenderer 实时渲染

二、核心实现步骤

1. 环境配置与权限声明

module.json5 中添加分布式能力与相机权限:

{
  &#34;module&#34;: {
    &#34;requestPermissions&#34;: [
      {
        &#34;name&#34;: &#34;ohos.permission.DISTRIBUTED_DATASYNC&#34;,  // 分布式数据同步
        &#34;reason&#34;: &#34;跨设备传输PixelMap数据&#34;
      },
      {
        &#34;name&#34;: &#34;ohos.permission.CAMERA&#34;,
        &#34;usedScene&#34;: { &#34;abilities&#34;: [&#34;CameraAbility&#34;] }
      }
    ],
    &#34;abilities&#34;: [
      {
        &#34;name&#34;: &#34;CameraAbility&#34;,
        &#34;distributedEnabled&#34;: true  // 启用分布式能力
      }
    ]
  }
}

2. 设备发现与连接管理

使用 DistributedDeviceManager 获取目标设备 NetworkId:

import distributedDeviceManager from '@ohos.distributedDeviceManager';

// 发现可用设备
let deviceManager = distributedDeviceManager.createDeviceManager(context);
let deviceList = deviceManager.getTrustedDeviceListSync();
let targetDevice = deviceList.find(device => device.deviceName === &#34;平板设备&#34;);
let networkId = targetDevice.networkId;

3. 手机端:相机捕获与滤镜处理

通过 @ohos.multimedia.camera 获取相机帧数据并转换为 PixelMap

import camera from '@ohos.multimedia.camera';

// 创建相机预览流
camera.createPreviewOutput(cameraManager, surfaceId).then((previewOutput) => {
  previewOutput.on('frameStart', () => {
    // 获取图像数据并应用滤镜
    let pixelMap = await image.createPixelMapFromSurface(surfaceId);
    let filteredPixelMap = applySepiaFilter(pixelMap);  // 示例:棕褐色滤镜
    sendToTablet(filteredPixelMap, networkId);
  });
});

// 滤镜算法示例(棕褐色调)
function applySepiaFilter(pixelMap: image.PixelMap): image.PixelMap {
  let arrayBuffer = await pixelMap.getImageData();
  for (let i = 0; i < arrayBuffer.length; i += 4) {
    let r = arrayBuffer[i], g = arrayBuffer[i+1], b = arrayBuffer[i+2];
    arrayBuffer[i] = Math.min(255, (r * 0.393) + (g * 0.769) + (b * 0.189));
    arrayBuffer[i+1] = Math.min(255, (r * 0.349) + (g * 0.686) + (b * 0.168));
    arrayBuffer[i+2] = Math.min(255, (r * 0.272) + (g * 0.534) + (b * 0.131));
  }
  return await image.createPixelMapFromData(arrayBuffer, pixelMap.getImageInfo());
}

4. 跨设备数据传输(RPC 调用)

服务端(平板)定义远程接口

import rpc from '@ohos.rpc';

class FilterService extends rpc.RemoteObject {
  // 接收手机端发送的PixelMap数据
  async onRemoteMessageRequest(code: number, data: rpc.MessageSequence, reply: rpc.MessageSequence) {
    if (code === 1) {
      let pixelMapData = data.readObject() as image.PixelMap;  // 反序列化数据
      renderOnCanvas(pixelMapData);  // 在平板端渲染
    }
    return true;
  }
}

客户端(手机)绑定服务并发送数据

// 构造Want对象绑定平板服务
let want = {
  bundleName: &#34;com.example.tabletapp&#34;,
  abilityName: &#34;FilterServiceAbility&#34;,
  deviceId: networkId  // 目标设备标识
};

// 发送滤镜处理后的PixelMap
let proxy = await Context.connectService(want);
let data = rpc.MessageSequence.create();
data.writeObject(pixelMap);
proxy.sendMessageRequest(1, data);

5. 平板端:实时渲染与显示

使用 CanvasRenderer 渲染接收到的 PixelMap

import ui from '@ohos.arkui.ui';

// 创建画布组件
struct FilterCanvas {
  @State pixelMap: image.PixelMap | null = null;

  build() {
    Canvas(this.onCanvasReady)
      .width('100%')
      .height('100%')
  }

  onCanvasReady(ctx: CanvasRenderingContext2D) {
    if (this.pixelMap) {
      ctx.drawImage(this.pixelMap, 0, 0, 360, 640);  // 渲染图像
    }
  }

  // 接收手机端数据更新UI
  updatePixelMap(newPixelMap: image.PixelMap) {
    this.pixelMap = newPixelMap;
  }
}

三、性能优化关键点

  1. 数据压缩传输

    • PixelMap 转换为 ArrayBuffer 后使用 LZ4 压缩,减少软总线带宽占用。
    • 代码示例:
      let compressedData = zlib.compress(pixelMap.getImageData(), { level: 3 });
      
  2. 渲染帧率控制

    • 通过 requestAnimationFrame 限制刷新率(如 30fps),避免平板端过载。
  3. 错误处理与重连

    • 监听设备断开事件,自动切换至本地渲染:
      deviceManager.on('deviceOffline', (device) => {
        showDialog('设备断开,已切换本地模式');
        switchToLocalRender();
      });
      

四、完整流程示意图

手机端采集 → 滤镜处理 → 软总线传输 → 平板渲染 → 显示反馈
    ↓          ↓           ↓           ↓         ↓
  Camera → PixelMap → RPC调用 → Canvas → UI更新

注意事项

  1. 设备兼容性:需确保双端设备为 HarmonyOS 6.0 及以上版本,且登录同一华为账号。
  2. 延迟优化:若传输延迟 >100ms,可降低图像分辨率(如 720p→480p)。
  3. 隐私安全:传输的 PixelMap 数据需在本地完成脱敏处理,避免敏感信息泄露。

通过以上方案,可实现手机拍摄后滤镜效果实时同步至平板渲染,充分发挥鸿蒙分布式能力与图像处理性能。

知识库创业复盘:从闭源到开源,这3个教训价值百万

作者 徐小夕
2025年12月10日 18:33

"把知识库代码公开到 github 上的那天,我失眠到凌晨四点。

不是因为激动,是害怕——害怕一年心血被一键复制,害怕客户部署了就没有采购的欲望了。

120 天后再回头看,正是那次'裸奔',才救活了公司,也重塑了我对商业护城河的所有认知。

今天把决策前后最痛的 3 个教训掏出来,能帮你省下至少 100 万试错成本。"

关注我的朋友也许了解,我们24年年初,上线了一款知识库产品——橙子轻文档。

图片

github地址:

我一直觉得「在线办公工具」是个矛盾体:要么功能全但收费贵(比如某钉、某飞),要么免费但功能零散(比如单独的在线文档、独立的思维导图工具)。所以我们决定花半年时间打造 OfficeHub 这个项目,把「文档 + 表格 + 思维导图 + AI + 知识库」完美的融合成一个办公智能体。

图片

核心定位:基于 Web 的开源在线办公协作平台,集成文档编辑、思维导图、电子表格、AI 创作、模板管理和知识库功能。 简单说,OfficeHub 想做的是「办公工具界的瑞士军刀」:不用切换多个平台,一个系统搞定从内容创作到知识沉淀的全流程。

图片

但是上线运营了3个月之后,虽然积累了上千的用户使用,但是也发现了很多商业上的问题。接下来我会系统复盘一下,橙子轻文档创业的一些经验和教训。

01 教训一:把"闭源"当护城河,其实是给自己挖坟

1. 自嗨式壁垒

  • 我们曾花 2 个月自研「文档搭建引擎」,体验类比 Notion,PPT 里写满"技术壁垒"。
  • 闭源 6 个月,GitHub 只有 12 个 star,其中 3 个还是团队成员小号。

2. 客户用脚投票

  • 企业版客单价 3 万,成交周期 60 天,我天天被客户问:"有没有 API?能不能二开?"
  • 一次 POC,客户 IT 总监直接甩出飞书知识库组件:"你们不开放,我就不买。"

3. 致命瞬间

去年 9 月,账上现金只剩 2 个月,一位也在创业的朋友问我:

"如果 Notion 明天开源,你们还剩什么?" 我答不上来,那一刻我知道:闭源不是墙,是牢笼。


02 教训二:开源不是"做慈善",是"放鱼饵"

图片

上面是我们开源了编译版之后的 github 截图。

1. 120 天数据对比

指标 闭源末期 开源 +120 天
新增线索 68 条 300 条
企业版演示 1-3 次/月 10 次/月

2. 鱼饵配方

我们把知识库编译版代码开源了,让用户可以直接本地安装和部署,保证数据的绝对安全,同时还能在本地环境体验,测试。

  • Apache 模块 → 审计日志、合规,企业刚需;
  • 云原生插件 → 只给二进制,订阅解锁。

开发者爽了,企业慌了,我们笑了。开源第 7 天,一家头部券商主动约演示:"我们发现本地体验不错,想二次开发,买商业源码授权多少钱?"

这个时候我才发现,让客户能用起来,比说产品亮点说1万遍都有用。

虽然开源后,很多没有二次开发需求的企业,直接用我们的源码部署到自己服务器上作为私有知识库使用,但是这间接的提高了我们产品的影响力,进而带来了真正有二次开发需求的潜在客户。

03 教训三:开源节奏错了,同样会死

1. 一步错,全盘输

同期友商 B 直接把 100% 代码 GPL 开源,结果:

  • 企业客户担心"被传染",不敢用;
  • 社区开发者发现没"高级功能",star 数暴涨却无人续费;
  • 3 个月后资金链断裂,核心团队被大厂打包挖走。

2. 三把尺子,决定"先开哪块"

尺子 先开源 后闭源
技术壁垒低 ✓ 编辑器 UI ✗ 协同算法
获客需求强 ✓ 导入工具 ✗ 审计日志
合规风险高 ✗ 国密加密 ✓ 私有部署脚本

一句话:让开发者爽在先,让企业怕在后。

3. 节奏表

  • 0-3 个月:放编辑器 + 基础 API,拉 star、拉社群;
  • 3-6 个月:放权限框架 50%,留 GPL 暗钩,销售跟进;
  • 6 个月后:逐步闭源高阶合规模块,推出订阅制。

04 彩蛋:开源那晚,我们在代码里藏了一句 joke

// If you find this line, call cxzk_168,// we will send you a crate of oranges.

结果真有 3 个开发者联系我,我们按约寄出橙子,其中一人后来成了橙子轻文档的客户。

你看,诚意永远是最便宜的 PR。

05 如果你只能记住三句话

  1. 闭源不是护城河,是拦路墙,越早推倒越省钱。
  2. 开源不是全裸,是"比基尼"——露得恰到好处,才让人想花钱看里面。
  3. 节奏 > 决心,先让社区爽,再让客户怕,最后让公司活下来。

把代码公开那天,我以为会失去一切;
120 天后才发现,真正的护城河,是与客户一起进化和成长。

愿这 3 条价值百万的教训,帮你少失眠 180 个夜晚。

橙子已熟,等你来摘。

github地址:

目 橙子轻文档 由于用户量过多,目前只做为演示使用,切勿传个人敏感数据。

如果大家想线上管理知识笔记,大家可以移步我们的新产品——FlowmixAI

函数执行完就销毁?那闭包里的变量凭什么活下来!—— 深入 JS 内存模型

作者 xhxxx
2025年12月10日 18:01

深入理解 JavaScript 的内存机制与执行流程

在前端开发中,我们每天都在写函数、声明变量、使用闭包。但你是否曾思考过:JavaScript 引擎究竟是如何运行你的代码的?
为什么一个函数执行完毕后,它内部的变量还能被外部访问?这些“自由变量”到底存在哪里?
要回答这些问题,我们必须深入 JS 的执行机制内存模型


一、JavaScript 是一门怎样的语言?

JavaScript 是一门 动态弱类型语言

  • 动态:变量的数据类型在运行时确定,无需提前声明。
  • 弱类型:允许隐式类型转换,不同类型之间可以互相赋值。

var bar;
console.log(typeof bar); // &#34;undefined&#34;

bar = 12;
console.log(typeof bar); // &#34;number&#34;

bar = &#34;极客时间&#34;;
console.log(typeof bar); // &#34;string&#34;

bar = true;
console.log(typeof bar); // &#34;boolean&#34;

bar = null;
console.log(typeof bar); // &#34;object&#34; ← 这是 JS 的一个历史 bug!

bar = { name: &#34;极客时间&#34; };
console.log(typeof bar); // &#34;object&#34;

这种灵活性带来了开发效率,但也要求我们更清楚 JS 在背后如何管理数据与内存。
相对于其他的静态类型语言,为什么JS不需要自己手动的free()?
这正是JS的内存空间划分决定的


二、JS 的内存模型:栈 vs 堆

JavaScript 的内存主要分为三类:

1. 代码空间

  • 存放可执行代码。将代码从硬盘加载到内存。体积不大,但至关重要。

1. 栈内存(Stack)

  • 存放 原始数据类型(Primitive Types)numberstringbooleanundefinednullsymbolbigint
  • 特点:体积小、连续、分配/回收快
  • 用于维护 调用栈(Call Stack)执行上下文(Execution Context)
function foo() {
  var a = 1;     // 存于栈中
  var b = a;     // 值拷贝(b 是 a 的副本)
  a = 2;
  console.log(a); // 2
  console.log(b); // 1
}
foo();

这里 ab 是两个独立的栈变量,修改 a 不会影响 b

2. 堆内存(Heap)

  • 存放 引用类型(Object、Array、Function 等)
  • 特点:空间大、不连续、分配/回收较慢
  • 变量在栈中保存的是 指向堆中对象的引用(指针)
function foo() {
  var a = { name: &#34;极客时间&#34; }; // 对象存于堆,a 是引用
  var b = a;                    // b 也指向同一个堆对象
  a.name = &#34;极客邦&#34;;
  console.log(a); // { name: &#34;极客邦&#34; }
  console.log(b); // { name: &#34;极客邦&#34; } ← 被同步修改!
}
foo();

这说明:对象赋值是“引用式赋值” ,而非值拷贝。
为什么同样是赋值操作却要遭受不一样的待遇?
原因如下:

  1. 在栈内存中的数据发生复制行为时,系统会自动为新的变量分配一个新值var b = a执行之后,ab虽然值都等于2,但是他们其实已经是相互独立互不影响的值了
  2. 引用类型的复制同样也会为新的变量自动分配一个新的值保存在栈内存中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在堆内存中访问到的具体对象实际上是同一个。
    给你一行代码快速理解栈内存和堆内存
var b = { m: 20 };

变量b存在于栈内存中,而真正的对象{ m: 20 }实际上存在于堆内存之中,栈内存中的b只保存着该对象的地址。
而对于我们上面的那段代码给你一张图你马上就能理解了栈和堆之间存储变量的差异与联系

8d16d83bb7e07b4453932e4e3cac71af.png


三、执行上下文与调用栈

JS 引擎通过 调用栈(Call Stack) 来管理函数的执行顺序。

JS 引擎通过 调用栈(Call Stack) 来管理函数的执行顺序。

  • 每次函数调用,都会创建一个 执行上下文(Execution Context)

  • 执行上下文包含:

    • 变量环境(Variable Environment) :存放 var、函数声明等
    • 词法环境(Lexical Environment) :存放 let/const 等声明的变量和块级作用域
    • outer:指向外层作用域的指针
    • this:当前上下文的 this 绑定

执行完毕后,上下文从栈顶弹出,其占用的栈内存通过 栈顶指针的偏移 快速释放——不需要逐个清理变量,只需将指针回退到上一个上下文的位置即可。

为什么执行上下文选择使用调用栈这种栈结构来实现?

  • 结构简单、内存连续,适合频繁的压栈(push)和出栈(pop)操作;
  • 大小固定、切换高效:上下文切换本质上只是移动栈顶指针,开销极小;
  • 如果把复杂对象也放在栈中,会导致栈空间膨胀、内存碎片化,严重影响上下文切换效率。

dff697438316fd86891d1e3c705ad659.png

四、怎么确定切换上下文时的指针偏移量

一、什么是“指针偏移”?

在 JS 引擎中,调用栈(Call Stack)是一个连续的内存区域,每个函数调用都会在栈顶“压入”一个执行上下文帧(frame) 。当函数执行完毕,这个帧就被“弹出”,栈顶指针(通常叫 stack pointerSP)就向低地址方向移动一段固定距离——这就是所谓的“指针偏移”。

✅ 指针偏移的本质:不是逐个释放变量,而是整体“回退”到上一个函数的栈帧起始位置


二、偏移量是如何确定的?

在函数编译阶段就已静态确定

JavaScript 虽然是解释型语言,但现代引擎(如 V8)会先将代码 解析 + 编译为字节码或机器码,在这个过程中完成栈帧大小的计算

具体步骤如下:

1. 词法分析与作用域分析

引擎扫描函数体,识别所有:

  • var / let / const 声明的变量
  • 函数参数
  • 内部函数声明
  • thisarguments 等特殊对象

例如:

function foo(a, b) {
  var x = 10;
  let y = 20;
  const z = { name: 'test' };
}

2. 计算栈帧所需空间

引擎会为该函数的执行上下文分配一个固定大小的栈帧(stack frame) ,包括:

  • 参数区(parameters)
  • 局部变量区(local variables)
  • 返回地址(return address)
  • 词法环境/变量环境的元数据指针(可能指向堆中的结构)

⚠️ 注意:只有原始类型会直接存放在栈帧中;像 { name: 'test' } 这样的对象仍存于堆,栈中只存其引用(一个指针,通常 8 字节)。

因此,整个栈帧的大小 = 所有局部变量 + 参数 + 元信息 的总字节数

这个大小在函数首次编译时就确定了,是静态的、固定的

3. 生成“出栈”指令

当函数执行结束,引擎执行类似这样的底层操作(伪代码):

// 假设当前栈顶指针为 SP,当前执行上下文的栈帧大小为foo_size;
SP=SP-foo_size;
//那么此时栈顶指针就回退到了上一个执行上下文中

所以偏移量实际上就等于每一个函数上下文的栈帧大小

🔍 这就是为什么栈内存回收极快:不需要遍历、不需要标记,只需一次指针加减!


4、对比:为什么堆不能这样干?

因为堆内存具有以下特点:

  • 分配大小动态(比如 new Array(n) 的 n 是运行时才知道)
  • 生命周期不确定(可能被闭包、全局变量等长期持有)
  • 内存不连续,无法通过简单偏移回收

随着JS的更新,关于栈帧的计算已经被简化,它与变量的数量无关,它几乎是一个固定大小的值。他将变量,无论什么类型,全部保存在堆内存中,此时栈帧仅包含返回地址、函数指针、Context 指针等少量固定控制信息。但无论是哪种方式的计算,栈帧的大小始终在编译阶段就确定,这也是为什么能够精准又快速的切换执行上下文的精髓所在


五、作用域与闭包:自由变量的归宿

什么是闭包?

闭包是指 内部函数访问其外部函数的变量(自由变量) ,即使外部函数已执行完毕。

      function foo() {
    var myName = &#34;极客时间&#34;
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(&#34;极客邦&#34;)
bar.getName()
console.log(bar.getName())

闭包是如何实现的?

  • 编译阶段的快速词法扫描,v8引擎在编译阶段会判断出内部函数是否被返回且引用了外部作用域的变量,如果存在,那么就会提前做好一个准备,标记被引用的变量
  • 函数入栈时,真正的创建闭包,那么它被保存到哪里呢?实际上闭包被存在堆内存之中,而栈内存中只存它的引用地址

35f91851c681a015058a6a171a100ccf.png

✅ 闭包的本质:通过堆内存延长外部变量的生命周期


六、对比 C 语言:JS 为何不用手动管理内存?

C/C++ 需要开发者手动调用 malloc / free 来申请和释放堆内存,而 JavaScript 由引擎自动管理内存,主要依赖两大机制:

1. 栈内存:自动释放

  • 函数执行结束 → 执行上下文出栈 → 栈顶指针回退 → 所有局部原始类型变量立即“消失”。

2. 堆内存:垃圾回收(Garbage Collection, GC)

JS 引擎采用 标记-清除(Mark-and-Sweep) 为主的垃圾回收策略:

  • 标记阶段:从根对象(如全局对象、当前调用栈中的变量)出发,遍历所有可达对象,标记为“存活”。
  • 清除阶段:回收未被标记的对象所占的堆内存。

早期也曾使用 引用计数(记录对象被引用的次数),但因无法处理循环引用问题(如 objA.ref = objB; objB.ref = objA)而被弃用。

💡 开发者无需关心内存分配与释放,但也需注意

  • 避免内存泄漏:如意外保留对大对象或 DOM 节点的引用;
  • 理解闭包可能导致的内存占用:只要闭包存在,其捕获的变量就不会被 GC 回收。

七、总结

  • JavaScript 是动态弱类型语言,类型和内存管理由引擎自动处理。
  • 内存分为栈(存放原始类型和执行上下文)和堆(存放对象等引用类型)。
  • 函数调用时创建执行上下文,其栈帧大小在编译阶段静态确定,通过指针偏移高效切换。
  • 作用域链通过词法环境的 outer 引用实现,闭包本质是将外部变量保存在堆中的 Context 对象里。
  • 堆内存由垃圾回收器自动管理(主要采用标记-清除算法),无需手动释放。 理解这些机制,不仅能写出更高效的代码,还能在调试内存问题、性能瓶颈时游刃有余。

umi3 → umi4 升级:踩坑与解决方案

作者 WindStormrage
2025年12月10日 17:49

前言

当前项目使用 umi3,为了提升开发效率和体验,决定将项目从 umi3 升级到 umi4。本文记录升级过程中遇到的问题和解决方案。

一、umi4 的核心变化

在开始踩坑之前,先给大家介绍一下升级 umi4 后两个变化最大的部分:

1.1 路由系统:React Router v5 → v6

这是影响最大的变化,导致了大量 API 需要迁移,具体改动点在 umi 的升级文档里面有列出,这里就不赘述了。

// umi3 (React Router v5)
import { useHistory } from 'umi';
const history = useHistory();
history.push('/path');

// umi4 (React Router v6)
import { useNavigate } from 'umi';
const navigate = useNavigate();
navigate('/path');

1.2 Layout 插件机制

umi4 改成了自动查找 layout,子路由逻辑也做了修改,直接替换就行。

维度 umi3 umi4
布局发现 需要在路由中指定component 自动查找src/layouts/index.tsx
子路由渲染 props.children Outlet 标签

二、踩坑实录

接下来我会逐个介绍这次升级遇到的问题和解决方案,可能各个项目的情况有所不同,不一定遇到相同的问题,可以直接在目录中找到遇到的问题。

2.1 SSO 401 跳转问题

本地启动后跳转 sso 登录页面,点击登录后还是会调到sso登录页面,后来去排查接口请求,发现是页面上的请求都返回401,在确认接口没问题后,对比了一下请求头,发现请求头缺少 accessToken,所以问题是请求没带上 accessToken。

原因:umi3 会自动在请求中添加 accessToken,umi4 不再自动注入。

解决方案:在请求拦截器中手动添加 accessToken。

image.png

2.2 导航嵌套问题

页面出现两层导航栏互相嵌套。

原因

  • umi4 会自动查找并应用 src/layouts/index.tsx
  • umi4 会通过 渲染子路由,如果路由配置中又指定了 component: '@/layouts',会导致布局渲染两次。

解决方案

去掉之前在 routes 文件里面定义的 layout。

{
  path: '/',
  // component: '@/layouts',  // 注释掉这行
  routes: [...]
}

2.3 SVG 图标不显示

部分图标没有显示出来,排查发现没显示出来的都是 iconfont 的 SVG 图标。接着检查资源里面没有 iconfont.js。

原因: 在代码中查看 iconfont 引用的地方 document.ejs,发现使用了 <%= context.config.publicPath %>iconfont.js引入文件,在 umi4 中,context.config.publicPath 这个模板变量不再可用。

解决方案:在 app.tsx 中运行时动态加载:

if (typeof window !== 'undefined') {
  const script = document.createElement('script');
  script.src = '/iconfont.js';
  script.async = true;
  document.head.appendChild(script);
}

2.4 KeepAlive + useModel 白屏

当子路由替换成 Outlet 标签后,页面出现白屏,控制台出现报错:

image.png

排查过程

  1. 根据报错信息,定位到报错发生在 useModel 调用处
  2. 通过搜索 GitHub Issue 找到相关讨论:umi#10334
  3. 确认是 KeepAlive 的缓存机制导致 React Context 传递链断裂

原因: 当 useModel 在 KeepAlive 包裹的组件内部调用时,可能无法正确访问到 umi 提供的全局 Context,从而导致 dispatcher 为 null。

解决方案:将 useModel 调用移到 KeepAlive 外部,通过 props 向子组件传递数据:

image.png

2.5 React Router v6 带来的 API 变更

umi4 升级了 React Router 到 v6,导致一系列 API 变化,具体可以参考umi的官方升级文档。

useHistory → useNavigate

// umi3
import { useHistory } from 'umi';
const history = useHistory();
history.push('/path');
history.replace('/path');

// umi4
import { useNavigate } from 'umi';
const navigate = useNavigate();
navigate('/path');
navigate('/path', { replace: true });

location.query 不再支持

// umi3
const { id } = location.query;

// umi4
import { parse } from 'query-string';
const query = parse(location.search);

props 中的参数不能直接获取

umi4 中 props 默认为空对象,locationhistorymatch 等都不能直接从 props 获取,需要使用对应的 hooks。

// umi3
export default function Page(props) {
  const { location, history, match } = props;
  props.location;
  props.history.push('list');
  props.match
}

// umi4
import { useLocation, history, useMatch } from 'umi';
export default function Page() {
  const location = useLocation();
  history.push('list');
  const match = useMatch({ path: 'list/:id' });
}

2.6 ProLayout headerRender 不生效

现象:ProLayout 的 headerRender 属性设置后,自定义 header 不显示。

解决方案:不使用 headerRender,改为将 header 组件放在 ProLayout 的 children 中:

image.png

2.7 Cursor 编辑器卡顿

现象:升级后使用 Cursor 打开项目非常卡。

原因:umi4 本地启动的时候会生成 .umi 的临时文件夹,内容比较多,Cursor 索引时会消耗大量资源。同时 VSCode 也会监听 .umi 的文件导致卡顿。

解决方案:在项目根目录创建 .cursorignore 文件让 Cursor 忽略:

.umi
.umi-production

同时在 .vscode 的 settings.json 中也要添加对.umi的忽略:

image.png

2.8 无法从 'umi' 导入的问题

现象:之前从 'umi' 导入的某些函数报错找不到。

原因:项目中自定义的 model 初始化函数(如 getIndicatorInitStategetDatasetInitState)在 umi3 中可能通过插件挂载到 umi 命名空间,umi4 不再支持。

解决方案

// Before: umi3
import { getIndicatorInitState, getDatasetInitState } from 'umi';

// After: umi4
import { getIndicatorInitState } from '@/models/indicator';
import { getDatasetInitState } from '@/models/dataset';

三、升级 Checklist

最后贴一下我本次 umi 升级的主要流程

  1. 准备工作
  • 项目备份
  • 清理缓存
  1. 依赖升级
  • 更新 package.json 依赖
  • 更新脚本命令
  1. 配置文件调整
  • 更新 config.ts 配置,创建 .env 文件
  • 更新 routers 和 layouts 文件
  1. 报错处理
  • 本地启动,检查命令行报错
  • 检查控制台报错
  • 启动后,检查样式问题
  1. 构建和测试
  • node 版本升级
  • 构建测试环境

四、总结

本次升级除了上述问题还遇到了其他很多问题,篇幅有限简单问题就没提了,升级中很多的步骤都是依靠 Cursor 来协助完成的,比如依赖的变更,配置文件的迁移,Cursor 会比我更加的了解 umi4 的文档一些,出现的很多问题也会很好的解决,但是也会有一些局限性,比如 KeepAlive 导致白屏的问题,反复提问都无法正确解决,最终是搜到了相同的报错,提供了 Issue 给 Cursor 才能正确解决问题。总的来说要善用 Cursor 提升效率还是比较明显的。

参考资料

前端接入sse(EventSource)(@fortaine/fetch-event-source)

作者 红彤彤
2025年12月10日 17:47

1、使用原生的原生 EventSource

特性:1、自动重连(固定3秒重试) 2、无法手动设置header头信息,只能使用get请求

    // SSE 连接
    const sse: InfoStore['sse'] = (
        onMessage?: (data: any) => void,
        onError?: (error: any) => void,
        onOpen?: () => void,
        onClose?: () => void
    ) => {
        // 从 LocalStorage 获取认证令牌
        const token = 你的token
        // 如果没有令牌,提示错误并返回
        if (!token) {
            message.error('未获取到认证令牌,无法建立 SSE 连接');
            if (onError) {
                onError(new Error('未获取到认证令牌'));
            }
            return;
        }

        // 客户端 ID
        // 构建完整的请求 URL,包含查询参数
        const url = new URL('/api/resource/sse', window.location.origin);
        url.searchParams.append('tokenValue', `${token}`);
        const en = new EventSource(url)
        en.onmessage = event => {
            console.log('e.data', event.data)
            let data: any = event.data;
            try {
                data = JSON.parse(event.data);
                console.log('event--消息接收成功', data)
            } catch {
                // 如果不是 JSON 格式,直接使用原始数据
            }
        }
        en.onerror = e => {
            console.log('err', e)
        }
        en.onopen = e => {
            console.log('onopen', e)
        }
        return
    }

fetch + ReadableStream 手动实现

特点:可操作性更加高一点

const sse: DocumentLibInfoStore['sse'] = (
  onMessage?: (data: any) => void,
  onError?: (error: any) => void,
  onOpen?: () => void,
  onClose?: () => void
) => {
const token = LocalStorage.getToken();
  if (!token) {
    message.error('未获取到认证令牌,无法建立 SSE 连接');
    onError?.(new Error('未获取到认证令牌'));
    return () => {};
  }
const clientid = 'e5cd7e4891bf95d1d19206ce24a7b32e';
  // 重连配置(可根据业务调整)
  let reconnectCount = 0; // 当前重连次数
  const maxReconnectTimes = 10; // 最大重连次数(0 表示无限重连)
  let baseReconnectInterval = 3000; // 初始重连间隔(3秒,和原生EventSource一致)
  let abortController: AbortController | null = null;
  let isManualClose = false; // 主动关闭标识(避免主动关了还重连)

  // 核心:封装 SSE 连接函数(包含重连逻辑)
  const connectSSE = async () => {
    // 重置控制器
    abortController = new AbortController();
    try {
      // 1. 前置处理:若重连时 token 过期,可先刷新 token(可选)
      const currentToken = LocalStorage.getToken();
      if (!currentToken) {
        throw new Error('token 已过期,停止重连');
      }

      // 2. 发起 fetch 请求(支持自定义 Header)
      const response = await fetch('/api/resource/sse', {
        method: 'GET',
        headers: {
          'Authorization': `Bearer ${currentToken}`,
          'Clientid': clientid,
          'Accept': 'text/event-stream',
        },
        signal: abortController.signal,
        credentials: 'include',
      });

      // 3. 处理 HTTP 错误(区分是否重连)
      if (!response.ok) {
        const errorMsg = `SSE 连接失败:${response.status} ${response.statusText}`;
        // 规则:401/403(鉴权失败)不重连,5xx(服务端临时错误)重连,4xx 其他不重连
        if (response.status >= 500 && response.status < 600) {
          throw new Error(`服务器临时错误:${errorMsg},将尝试重连`);
        } else if (response.status === 401) {
          LocalStorage.removeToken(); // 清除过期 token
          throw new Error('token 失效,停止重连');
        } else {
          throw new Error(errorMsg + ',不重连');
        }
      }

      // 4. 连接成功:重置重连计数和间隔
      reconnectCount = 0;
      baseReconnectInterval = 3000;
      onOpen?.();
      message.success('SSE 连接成功');

      // 5. 读取流式响应(和之前逻辑一致)
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          // 流正常关闭:若不是主动关闭,触发重连
          if (!isManualClose) {
            throw new Error('SSE 流正常关闭,尝试重连');
          }
          break;
        }

        // 解析 SSE 消息(原有业务逻辑)
        buffer += decoder.decode(value, { stream: true });
        const messages = buffer.split('\n\n');
        buffer = messages.pop() || '';
        for (const msg of messages) {
          if (msg.startsWith('data: ')) {
            const dataStr = msg.slice(6);
            try {
              const data = JSON.parse(dataStr);
              onMessage?.(data);
              // 你的业务逻辑(更新进度、刷新列表等)
              const flagProcessingList = JSON.parse(JSON.stringify(state.processingList));
              flagProcessingList.forEach((item: any) => {
                if (item.documentId === data.documentId) {
                  item.progress = data.progress;
                  item.status = data.status;
                  if (data.progress === 100 || data.message === 'error') {
                    api.fetchDocumentLibList(0);
                    api.fetchDocumentLibList(1);
                  }
                }
              });
              actions.setProcessingList(flagProcessingList);
            } catch (e) {
              onMessage?.(dataStr);
            }
          }
        }
      }
    } catch (error) {
      const err = error as Error;
      // 排除主动关闭的错误(AbortError)
      if (err.name === 'AbortError' && isManualClose) {
        console.log('SSE 主动关闭,不重连');
        onClose?.();
        return;
      }

      // 触发错误回调
      onError?.(err);
      message.error(`SSE 连接异常:${err.message}`);

      // 重连逻辑:未达最大次数 + 不是主动关闭 + 不是鉴权失败
      if (!isManualClose && reconnectCount < maxReconnectTimes) {
        reconnectCount++;
        // 指数退避:重连间隔翻倍(3s→6s→12s...),上限 30s
        const currentInterval = Math.min(baseReconnectInterval * Math.pow(2, reconnectCount - 1), 30000);
        console.log(`SSE 将在 ${currentInterval/1000} 秒后重连(第 ${reconnectCount} 次)`);
        
        // 延迟重连
        setTimeout(() => {
          connectSSE();
        }, currentInterval);
      } else if (reconnectCount >= maxReconnectTimes) {
        message.error('SSE 重连次数已达上限,停止重连');
        onClose?.();
      }
    }
  };

  // 启动首次连接
  connectSSE();

  // 返回手动关闭函数(标记主动关闭,终止重连)
  return () => {
    isManualClose = true; // 标记为主动关闭
    if (abortController) {
      abortController.abort(); // 取消 fetch 请求
    }
    onClose?.();
    console.log('SSE 已手动关闭,终止重连');
  };
};

使用 @fortaine/fetch-event-source

这个插件的使用就比较简单也比较方便可以参考官方API地址

@fortaine/fetch-event-source和使用原生的原生 EventSource对比如下图

image.png

  • 当我实际使用的时候遇到了一个问题: @fortaine/fetch-event-source 调用的这个库sse方法的时候,第二次建立的时候会把直接走onclose方法关闭掉的同时我就接收不到消息了,而使用 const useEventSource: typeof import('@vueuse/core')['useEventSource'] vue3核心库里面的这个方法时候是可以的,下面是实际原因和解决方案

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

实现一个内网服务监测告警系统

2025年12月9日 15:54

ChatGPT Image 2025年12月9日 14_27_46

前言

昨天我的pve系统整个挂掉了,之前搭建的告警服务自然也死掉了,这就导致了我不能及时发现网站崩掉了,重启机器。

于是,我就把目光锁定到了家里的软路由上面,它是x86架构的,也安装了docker,我只需要用python写个脚本,做个docker服务即可。

功能设计

有了想法后,接下来需要先确定下要实现什么功能。

  • 定时检查:每 N 秒检查一次指定主机的指定端口
  • 自动告警:如果连续失败 N 次,就自动通过 QQ 邮箱发邮件通知
  • Docker / docker-compose 支持:一个 docker-compose up -d 就搞定,不需要在宿主机安装什么复杂依赖
  • 日志 + 时区:日志里记录访问时间 / 成功失败 / 告警状态,就算重启也能看到历史

实现过程

接下来就跟大家分享下我的具体实现过程。

  • 用 Python + smtp + socket,做一个循环脚本:
    • 尝试 TCP connect(检测端口)
    • 连不上就计数,超过阈值就发邮件
  • 用 Dockerfile 构建一个镜像,在里面安装 pingca-certificates ,配置时区,使得:
    • 容器里的时间符合预期
    • 脚本日志能实时输出,中断重启也方便查看
  • docker-compose 管理:使用的时候只需要填写环境变量(目标主机 + 端口 + 邮箱 + 授权码…),然后 docker-compose up -d 就能全自动运行。
import os
import smtplib
import time
import socket
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr

# 监控配置
TARGET_HOST = os.getenv("TARGET_HOST", "127.0.0.1")
TARGET_PORT = int(os.getenv("TARGET_PORT", "80"))
INTERVAL_SEC = int(os.getenv("INTERVAL_SEC", "60"))
FAIL_THRESHOLD = int(os.getenv("FAIL_THRESHOLD", "3"))

# 邮件配置(QQ 邮箱)
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.qq.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER)
MAIL_TO = os.getenv("MAIL_TO", "")


def check_port(host: str, port: int, timeout=2) -> bool:
    """
    返回 True 表示端口可连接,False 表示失败
    """
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except Exception:
        return False


def send_mail(subject: str, content: str):
    if not (SMTP_HOST and SMTP_USER and SMTP_PASS and MAIL_TO):
        print("SMTP 配置不完整,无法发送邮件")
        return

    from_addr = MAIL_FROM or SMTP_USER

    msg = MIMEText(content, "plain", "utf-8")
    msg["From"] = formataddr(("Ping告警系统", from_addr))
    msg["To"] = formataddr(("告警接收人", MAIL_TO))
    msg["Subject"] = Header(subject, "utf-8")

    print(f"【邮件】准备连接 SMTP: host={SMTP_HOST}, port={SMTP_PORT}, user={SMTP_USER}")

    server = None
    try:
        if SMTP_PORT == 465:
            print("【邮件】使用 SMTP_SSL 连接(465 端口)")
            server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10)
        else:
            print("【邮件】使用 SMTP + STARTTLS 连接")
            server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10)
            server.ehlo()
            server.starttls()
            server.ehlo()

        server.login(SMTP_USER, SMTP_PASS)
        # sendmail 如果不抛异常,就认为成功
        failed = server.sendmail(from_addr, [MAIL_TO], msg.as_string())
        if failed:
            print("【邮件】部分收件人发送失败:", failed)
        else:
            print("【邮件】告警邮件已发送(sendmail 返回正常)")

    except smtplib.SMTPResponseException as e:
        if e.smtp_code == -1 and e.smtp_error == b'\x00\x00\x00':
            print("【邮件】QQ 在 QUIT 阶段返回 (-1, b'\\x00\\x00\\x00'),可忽略,邮件已经入队。")
        else:
            print(f"【邮件】SMTPResponseException:code={e.smtp_code}, error={e.smtp_error}")
    except Exception as e:
        print(f"【邮件】发送失败:{repr(e)},类型:{type(e)}")
    finally:
        if server is not None:
            try:
                server.quit()
            except Exception as e:
                # 这里的异常直接吞掉即可
                print(f"【邮件】关闭连接时异常(可忽略):{repr(e)}")





def main():
    fail_count = 0
    print(
        f"开始监控 {TARGET_HOST}:{TARGET_PORT},每 {INTERVAL_SEC}s 检测一次,"
        f"连续失败 {FAIL_THRESHOLD} 次触发一次告警"
    )

    while True:
        now = time.strftime("%F %T")
        ok = check_port(TARGET_HOST, TARGET_PORT)

        if ok:
            print(f"{now} [OK]  {TARGET_HOST}:{TARGET_PORT} 端口可访问")
            if fail_count > 0:
                print(f"{now} 恢复正常,之前连续失败 {fail_count} 次,计数清零")
            fail_count = 0
        else:
            fail_count += 1
            print(f"{now} [FAIL] {TARGET_HOST}:{TARGET_PORT} 无法连接,连续失败次数:{fail_count}")

            if fail_count == FAIL_THRESHOLD:
                subject = f"[告警] {TARGET_HOST}:{TARGET_PORT} 无法访问"
                content = (
                    f"目标 {TARGET_HOST}:{TARGET_PORT} 已连续 {FAIL_THRESHOLD} 次连接失败。\n"
                    f"时间:{now}"
                )
                send_mail(subject, content)

        time.sleep(INTERVAL_SEC)


if __name__ == "__main__":
    main()

构建与上传镜像

编写DockerFile镜像文件

FROM python:3.11-slim

ENV TZ=Asia/Shanghai

WORKDIR /app

RUN apt-get update && \
    apt-get install -y iputils-ping ca-certificates tzdata && \
    ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
    echo $TZ > /etc/timezone && \
    update-ca-certificates && \
    rm -rf /var/lib/apt/lists/*

COPY ping_alert.py .

CMD ["python", "-u", "ping_alert.py"]

编写构建脚本

#!/usr/bin/env sh
set -e

# === 配置区:按需修改 ===
IMAGE_NAME="magiccoders/ping-alert" # magiccoders需要改成你的docker-hub的用户名
TAG="latest"
BUILD_CONTEXT="./app"
# =======================

echo "==> 构建镜像: ${IMAGE_NAME}:${TAG}"
docker build -t "${IMAGE_NAME}:${TAG}" "${BUILD_CONTEXT}"

echo "==> 推送镜像到仓库: ${IMAGE_NAME}:${TAG}"
docker push "${IMAGE_NAME}:${TAG}"

echo "==> 完成:${IMAGE_NAME}:${TAG} 已发布"

执行此脚本前,需要先在终端执行docker login 命令登录到你的docker-hub账户。

编写docker-compose配置

构建好镜像后,需要创建docker-compose.yml文件来编排这个镜像运行所需的环境变量。

version: '3.8'

services:
  ping-alert:
    image: magiccoders/ping-alert:latest # 此处就是存储在docker-hub上的镜像
    container_name: ping-alert
    restart: always
    environment:
      # ===== 监控目标配置 =====
      TARGET_HOST: "192.168.9.131" #监控目标机器ip
      TARGET_PORT: "80" # 目标机器端口号
      INTERVAL_SEC: "30"              # 每 30 秒检查一次
      FAIL_THRESHOLD: "3"             # 连续 3 次失败发一封告警邮件

      # ===== QQ 邮箱 SMTP 配置 =====
      SMTP_HOST: "smtp.qq.com"
      SMTP_PORT: "465"
      SMTP_USER: ""    # 你的 QQ 邮箱
      SMTP_PASS: ""  # 开通 SMTP 服务时得到的授权码
      MAIL_FROM: ""    # 和 SMTP_USER 保持一致
      MAIL_TO: ""  # 接受告警的邮箱

    # 直接复用宿主机网络,方便访问内网 IP
    network_mode: "host"

实现效果

我的软路由使用DPanel来管理docker,此处我就以它为例来讲解如何使用这个镜像。

如图所示,切换到compose选项卡,点击创建任务。

image-20251209144128674

在打开的面板中,填写标识、名称,以及刚才的docker-compose配置代码,按需更改里面的变量即可

image-20251209144606047

做完这些操作后,启动容器,查看日志,如果你的服务正常运行你就能看到如下所示的输出:

image-20251209144814577

我把端口关闭,再来验证下失败的情况。

image-20251209144954699

image-20251209145134325

邮箱也收到了邮件。

image-20251209152415652

最后,我启动服务,再来验证下他是否会清零计数。

image-20251209145400497

image-20251209145438229

项目地址

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

JavaScript 为什么选择原型链?从第一性原理聊聊这个设计

2025年12月9日 15:37

JavaScript 为什么选择原型链?从第一性原理聊聊这个设计

学 JavaScript 的时候,原型链是个绑不过去的坎。很多人(包括我)的第一反应是:这玩意儿怎么这么别扭?为什么不像 Java、C++ 那样用类继承?

后来了解了 JavaScript 的诞生背景,才发现原型链不是"别扭的设计",而是在特定约束下的合理选择。今天从第一性原理的角度,聊聊 JavaScript 为什么选择了原型链。


先回到 1995 年

1995 年 5 月,Brendan Eich 在 Netscape 公司用 10 天时间写出了 JavaScript 的第一个版本(当时叫 Mocha)。

10 天,这个时间约束很关键。

当时 Netscape 急着在浏览器里加一门脚本语言,用来做简单的表单验证、页面交互。管理层给 Eich 的要求是:

  1. 语法要像 Java(因为 Netscape 和 Sun 有合作,Java 正火)
  2. 要简单(目标用户是业余开发者,不是专业程序员)
  3. 要快(10 天内搞定原型)

Eich 本来想把 Scheme(一门函数式语言)塞进浏览器,但管理层否了——语法太怪,不像 Java。于是他搞了个混合体:

  • 语法:像 Java
  • 函数:像 Scheme(一等公民、闭包)
  • 对象系统:像 Self(原型继承)

为什么对象系统选了 Self 而不是 Java?这就要从第一性原理说起。


第一性原理:对象系统的本质需求是什么?

不管用什么方式实现,对象系统要解决的核心问题就两个:

  1. 代码复用:多个对象共享相同的行为
  2. 创建对象:能方便地造出新对象

类继承和原型继承都能解决这两个问题,但方式不同。

类继承的思路

类继承把世界分成两层:实例

类(Class)= 模板、蓝图
    ↓ 实例化
实例(Instance)= 具体对象

你先定义一个类,描述"这类对象长什么样、有什么方法",然后用 new 从类创建实例。

这套东西在静态语言里运作良好,但有个问题:概念多

类继承需要你理解:类、实例、构造函数、接口、抽象类、虚函数、多重继承、菱形继承问题……一套学下来,不轻松。

原型继承的思路

原型继承只有一层:对象

对象 → 对象 → 对象 → ... → null

没有"类"这个概念,只有对象。要创建新对象?从现有对象复制一份,改改就行。要共享行为?让多个对象指向同一个原型对象。

Self 语言的设计者 David Ungar 和 Randall Smith 在 1987 年的论文里说:

"Prototypes are more concrete than classes because they are examples of objects rather than descriptions of format and initialization."

(原型比类更具体,因为原型是对象的实例,而类只是格式和初始化的描述。)

说白了:原型是活的对象,类是抽象的描述


为什么 JavaScript 选择了原型?

回到 1995 年的约束条件,原型继承的优势就很明显了:

1. 实现更简单

类继承需要一套复杂的类型系统:类的定义、继承关系的解析、方法查找表的构建……

原型继承只需要:

  • 每个对象有个 __proto__ 指针,指向它的原型
  • 访问属性时,顺着指针往上找

10 天时间,选哪个?

Eich 后来回忆说:"选择原型继承意味着解释器可以非常简单,同时保留面向对象的特性。"

2. 动态性更强

JavaScript 是动态语言,对象可以随时增删属性。原型继承天然支持这种动态性:

// 随时给原型加方法,所有实例立刻能用
Array.prototype.first = function() {
  return this[0];
};

[1, 2, 3].first(); // 1

类继承在静态语言里很自然,但在动态语言里反而别扭——类定义完了还能改吗?方法能动态添加吗?处理起来麻烦。

3. 概念更少

类继承需要区分"类"和"实例",原型继承只有"对象"。

对于 1995 年的目标用户(网页设计师、业余开发者)来说,概念越少越好。


原型继承的核心:委托(Delegation)

原型继承有时候也叫委托继承,这个词更能说明它的工作方式。

当你访问一个对象的属性时:

  1. 先在对象自身找
  2. 找不到,委托给原型对象
  3. 还找不到,继续委托给原型的原型
  4. 直到 null
flowchart LR
    A["obj.foo"]:::start --> B{"obj 有 foo?"}
    B -->|有| C["返回 obj.foo"]:::success
    B -->|没有| D{"obj.__proto__ 有 foo?"}
    D -->|有| E["返回原型的 foo"]:::success
    D -->|没有| F["继续往上找..."]
    F --> G["直到 null,返回 undefined"]:::error

    classDef start fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef success fill:#d4edda,stroke:#28a745,color:#155724
    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24

这跟类继承的"复制"模型不同。类继承是在创建实例时把行为"复制"到实例上(或者通过虚函数表间接访问)。原型继承是运行时动态查找,真正的"按需委托"。

委托的好处

内存效率高:方法只在原型上存一份,所有实例共享。

function Dog(name) {
  this.name = name;
}
Dog.prototype.bark = function() {
  console.log('Woof!');
};

const dog1 = new Dog('A');
const dog2 = new Dog('B');

dog1.bark === dog2.bark; // true,同一个函数

运行时可修改:原型改了,所有实例立刻生效。

Dog.prototype.bark = function() {
  console.log('汪汪!');
};

dog1.bark(); // 汪汪!(立刻变了)

这种动态性在静态类继承里很难实现。


原型链的设计权衡

原型继承不是完美的,它做了一些权衡。

放弃了什么

静态类型检查:没有类,就没法在编译时检查类型。JavaScript 是动态类型语言,这是设计选择的一部分。

封装性较弱:原型上的东西都是公开的,没有 private/protected 的原生支持(ES2022 才加了私有字段 #)。

继承关系不明显:类继承的 extends 一眼就能看出继承关系,原型链要顺着 __proto__ 找。

得到了什么

极致的灵活性:对象可以随时改,原型可以动态换。

简单的心智模型:只有对象,没有类/实例的二元论。

运行时效率:对于 1995 年的浏览器来说,原型链的实现比类系统轻量得多。


后来的故事:class 语法糖

ES6(2015 年)加了 class 关键字,看起来像类继承:

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} makes a sound`);
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof!');
  }
}

但这只是语法糖,底层还是原型链:

console.log(typeof Animal); // "function"
console.log(Dog.prototype.__proto__ === Animal.prototype); // true

class 让代码更清晰,但没有改变 JavaScript 的对象模型。理解原型链,才能理解 class 背后发生了什么。


总结

JavaScript 选择原型链,不是随意的决定,而是在特定约束下的合理选择:

约束 原型继承的优势
10 天开发时间 实现简单,解释器轻量
目标用户是业余开发者 概念少,只有"对象"
动态语言特性 天然支持运行时修改
浏览器性能有限 内存效率高,方法共享

从第一性原理看,对象系统的本质是"代码复用 + 对象创建"。原型继承用最简单的方式解决了这两个问题:

  • 代码复用:对象委托给原型,原型上的方法共享
  • 对象创建:复制现有对象,改改就行

类继承更严谨、更适合大型静态系统。原型继承更灵活、更适合动态脚本语言。JavaScript 选对了。


参考资料


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
❌
❌