阅读视图

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

JS 高手必会:手写 new 与 instanceof

手写 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'); // 自动适配

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

在 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 冲击:前端转全栈,是自救还是必然 🤔🤔🤔

我正在开发 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 冲击,更能打开职业发展的新空间。那些"前端已死"的声音,其实是在提醒我们:只有不断进化,才能在这个时代立足。

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


前言

在现代 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 高危漏洞,手把手教你升级避坑

前言

在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 深度集成指南

在 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计算器实现详解

大家好,我是 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) 本地存储全攻略:普通数据与敏感数据该存哪?

在移动应用开发中,数据持久化(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 放入普通的“储物柜”。

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

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

大家好,我是 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个教训价值百万

"把知识库代码公开到 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 内存模型

深入理解 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 升级:踩坑与解决方案

前言

当前项目使用 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)

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

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

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 为什么选择原型链?从第一性原理聊聊这个设计

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

Git提交信息太乱?AI一键美化!一行命令拯救你的项目历史🚀

发现了一个 git-rewrite-commits 的仓库,目前 1.1k 🌟

image.png

可以使用 ai 来重写已经提交的 git commit 信息,修改后需要强制提交

这对于之前 git commit 信息写的乱七八糟的项目,又不想手动改,现在一行命令就能解决这个问题!

写这个文章的原因是 git-rewrite-commits 我觉得她这个使用说明写的不是很好懂,但还是建议阅读下!

准备工作

可以使用 OpenAI GPT 或本地 Ollama 模型进行 AI 驱动的提交信息生成

这里我们使用 Ollama 模型进行 AI 驱动的提交信息生成

首先你需要安装 Ollama 猛击参考官方文档安装

然后 Ollama 现在有一个 Cloud 云模型,大概就是使用 Ollama 调用云端模型达成和本地运行一样的效果。

运行云模型需要一个 ollama.com 上的账户,执行 ollama signin 登录或创建账户,登录完成后点击头像->设置,可以看到限额情况

image.png

云模型后边会有标识

image.png

开始使用

npx git-rewrite-commits --provider ollama --model qwen3-coder:480b-cloud --min-quality-score 10 --language zh --verbose
参数 简写 作用说明
--provider ollama 指定 AI 服务提供商,选择使用本地运行的 Ollama 服务处理请求
--model -m qwen3-coder:480b-cloud 指定使用的 AI 模型名称,此处为 Qwen3 Coder 云端版本
--min-quality-score 10 设置质量评分阈值,提交评分低于此值的将被重写,设置为 10 表示强制重写所有提交
--language -l zh 设置生成提交信息的语言,此处为中文(简体)
--verbose -v 无(标志参数) 启用详细输出模式,显示处理过程中的详细信息、评分结果、AI 响应内容等

其他还有一个参数可以执行 npx git-rewrite-commits --help 查看

以我的 blog 项目为例

  1. 输入 y 确认 image.png

  2. 开始执行 image.png

  3. 执行结束会让你确认是否重写 git commit 记录,输入 y 确认后开始执行 image.png

  4. 重写完成后会提示你需要强制推送 image.png 翻译一下:

    1. 查看更改:git log --oneline
    2. 如果满意,强制推送:git push --force-with-lease
    3. 如果出现问题,恢复:git reset --hard backup-main-1765263878856
    4. 完成后清理备份:git branch -D backup-main-176526387885
  5. 执行 git push --force-with-lease 开始强制推送 image.png 没有权限会有这种提示,修改仓库推送权限后重新推送即可

总结

git-rewrite-commits 是一个可以使用 ai 来重写已经提交的 git commit 信息的工具,对于之前项目中 git 提交信息不规范又不想手动去修改时,可以通过一行命令解决,十分方便!

【AI 编程实战】第 2 篇:让 AI 成为你的前端架构师 - UniApp + Vue3 项目初始化

传统方式搭建前端项目:查文档、试错、调试,耗时 1 天起步。AI 辅助:对话式配置,1 小时搞定。这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第二篇文章,带你用 AI 快速搭建专业级前端项目架构。

一、开篇:为什么项目初始化这么让人头疼?

还记得上一篇文章里提到的小何吗?他在"心动恋聊"项目中遇到的第一个挑战,就是前端项目初始化。

1.1 传统方式的痛点

如果你做过前端项目,一定深有体会:

技术选型迷茫

UniApp vs Taro vs 原生?
Vue3 还是 React?
Vite 还是 Webpack?
UnoCSS 还是 Tailwind CSS?

每个选择背后都是无数的文档、博客、踩坑记录。光是技术选型,就能让人纠结半天。

配置地狱

vite.config.ts
tsconfig.json
uno.config.ts
.eslintrc.js
.prettierrc
pages.json
manifest.json
...

每一个配置文件都有自己的语法、规则、坑点。配置完一个,还有下一个在等着你。

时间黑洞

小何最初的估算:

任务 预计时间
技术调研 2 小时
项目创建 0.5 小时
Vite 配置 1 小时
TypeScript 配置 1 小时
UI 框架集成 2 小时
自动导入配置 1 小时
路由配置 1 小时
环境变量配置 0.5 小时
代码规范配置 2 小时
总计 11 小时

实际上,因为各种问题、版本冲突、配置不兼容,往往需要 1.5-2 天

1.2 AI 辅助的革命性变化

有了 TRAE SOLO,一切都不同了。

小何的实际体验

小何:帮我创建一个 UniApp + Vue3 + TypeScript 项目,
      使用 Vite 构建,支持微信小程序、H5、App 三端,
      集成 UnoCSS 和 UView Pro 组件库

AI:好的,我来帮你搭建完整的项目架构:

    ✓ 分析需求,确定技术选型
    ✓ 创建 Monorepo 项目结构
    ✓ 配置 Vite 构建工具
    ✓ 集成 TypeScript 严格模式
    ✓ 配置 UnoCSS 原子化 CSS
    ✓ 集成 UView Pro 组件库
    ✓ 设置自动导入
    ✓ 配置多环境变量
    ✓ 添加代码规范工具

    项目结构已生成,请查看...

一天的工作,不到一小时完成。 这就是 AI 辅助开发的魔力。

二、技术选型:让 AI 帮你做决策

2.1 框架对比分析

项目开始前,小何需要确定技术选型。传统做法是搜索各种博客文章、技术对比,然后纠结半天。

TRAE SOLO 的做法

小何:帮我对比 UniApp、Taro、原生小程序的优劣,
      项目需求:社交类小程序,需要支持微信小程序、H5、App 三端

AI:(通过 MCP 实时查阅最新文档)

      分析结论:

      1. UniApp 优势:
         - 一套代码,支持微信/H5/App/鸿蒙等多端
         - Vue3 技术栈,学习成本低
         - 生态完善,组件库丰富
         - 文档完善,社区活跃

      2. Taro 优势:
         - React 技术栈友好
         - 适合已有 React 经验的团队

      3. 原生小程序:
         - 性能最优
         - 但只能在单一平台运行

      推荐:UniApp + Vue3

      理由:
      1. 项目需要三端发布,UniApp 跨端能力最强
      2. 社交类应用迭代快,UniApp 开发效率高
      3. 后续还要扩展到鸿蒙、安卓、iOS,UniApp 支持最全

5 分钟,得到了专业级的技术选型分析报告。

2.2 构建工具选择

小何:UniApp 应该用 Vite 还是 Webpack?

AI:强烈推荐 Vite!

      对比分析:
      ┌────────────┬────────────────┬────────────────┐
      │   维度     │     Vite       │    Webpack     │
      ├────────────┼────────────────┼────────────────┤
      │ 冷启动     │ 毫秒级         │ 秒级           │
      │ 热更新     │ 即时           │ 有延迟         │
      │ 配置复杂度 │ 简单           │ 复杂           │
      │ 生态支持   │ 完善           │ 非常完善       │
      │ 构建速度   │ 快             │ 较慢           │
      └────────────┴────────────────┴────────────────┘

      实测数据(心动恋聊项目):
      - Vite 冷启动:1.2 秒
      - Webpack 冷启动:8.5 秒

      结论:Vite 开发体验完胜,选它!

2.3 最终技术栈确定

通过 AI 辅助分析,"心动恋聊"项目确定了以下技术栈:

前端技术栈:
├── 框架:UniApp + Vue 3.4.21
├── 构建工具:Vite 5.2.8
├── 语言:TypeScript 5.7
├── 状态管理:Pinia 2.0.36 + 持久化
├── 原子化 CSS:UnoCSS 66.0
├── UI 组件库:UView Pro
├── HTTP 请求:自定义封装(基于 uni.request)
├── 列表优化:z-paging 2.8.7
└── 数据请求:@tanstack/vue-query

三、项目初始化:AI 一键生成

3.1 Monorepo 项目结构

"心动恋聊"采用 Monorepo 架构,这是现代前端项目的最佳实践。

提示词

帮我创建一个 Monorepo 项目结构,包含:
1. apps/unibest-mp - UniApp 小程序端
2. apps/xindong-server - Next.js 服务端
3. packages/shared-types - 共享类型定义
使用 pnpm 管理依赖,Turborepo 编排任务

AI 生成的项目结构

xingdong/
├── apps/
│   ├── unibest-mp/              # UniApp 多端应用
│   │   ├── src/
│   │   │   ├── api/             # API 接口定义
│   │   │   ├── components/      # 公共组件
│   │   │   ├── composables/     # 组合式函数
│   │   │   ├── config/          # 配置文件
│   │   │   ├── hooks/           # 自定义 Hooks
│   │   │   ├── http/            # HTTP 请求封装
│   │   │   ├── layouts/         # 布局组件
│   │   │   ├── pages/           # 页面文件
│   │   │   ├── store/           # Pinia 状态管理
│   │   │   ├── subPackages/     # 分包页面
│   │   │   │   ├── agreement/   # 协议相关
│   │   │   │   ├── vip/         # 会员相关
│   │   │   │   └── planForm/    # 计划表单
│   │   │   ├── tabbar/          # 底部导航
│   │   │   └── utils/           # 工具函数
│   │   ├── env/                 # 环境变量
│   │   ├── vite.config.ts       # Vite 配置
│   │   └── package.json
│   │
│   └── xingdong-server/         # Next.js 服务端
│       ├── src/
│       │   └── app/             # App Router
│       └── package.json
│
├── packages/
│   └── shared-types/            # 共享类型定义
│       ├── src/
│       │   └── index.ts
│       └── package.json
│
├── pnpm-workspace.yaml          # pnpm 工作区配置
├── turbo.json                   # Turborepo 配置
└── package.json                 # 根配置

pnpm-workspace.yaml

packages:
  - apps/*
  - packages/*

turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["apps/unibest-mp/dist/**", "apps/xingdong-server/.next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

这个结构的好处:

  1. 代码复用:shared-types 包让前后端类型一致
  2. 独立开发:前后端可以独立启动、独立部署
  3. 并行构建:Turborepo 自动并行执行不相关的任务

3.2 配置 Vite

Vite 配置是前端项目的核心。让我们看看 AI 生成的实际配置:

提示词

帮我配置 UniApp 的 Vite 配置文件,需要:
1. 自动导入 Vue API
2. 自动导入 uni-app API
3. 自动导入自定义 hooks
4. 配置路径别名
5. 支持多环境变量
6. 集成 UnoCSS
7. 配置分包优化

生成的 vite.config.ts(实际项目配置)

// vite.config.ts 核心配置片段
export default ({ command, mode }) => {
  // ... 环境变量加载逻辑 ...

  return defineConfig({
    plugins: [
      // 1. 页面路由自动生成
      UniPages({
        exclude: ['**/components/**/**.*'],
        subPackages: [
          'src/subPackages/agreement',
          'src/subPackages/vip',
          'src/subPackages/planForm',
        ],
      }),

      // 2. 布局系统 & 平台适配
      UniLayouts(),
      UniPlatform(),
      UniManifest(),

      // 3. 修复 Vue 编译问题(AI 自动生成的补丁)
      {
        name: 'fix-vite-plugin-vue',
        configResolved(config) {
          const plugin = config.plugins.find((p) => p.name === 'vite:vue');
          if (plugin && plugin.api && plugin.api.options) {
            plugin.api.options.devToolsEnabled = false;
          }
        },
      },

      // 4. UnoCSS 原子化 CSS
      UnoCSS(),

      // 5. 自动导入
      AutoImport({
        imports: ['vue', 'uni-app'],
        dirs: ['src/hooks'],
        vueTemplate: true,
      }),

      // ... 其他插件
      Uni(),
    ],
    // ...
  });
};

这个配置包含了

  1. @uni-helper 插件生态:页面路由、布局、平台、manifest 全自动化
  2. UnoCSS:原子化 CSS,开发效率翻倍
  3. AutoImport:Vue API、uni-app API、自定义 hooks 自动导入
  4. Hack 修复:AI 甚至帮我生成了一个 fix-vite-plugin-vue 插件来解决特定的编译 Bug,这在传统开发中可能要排查半天。

3.3 TypeScript 配置

提示词

帮我配置 TypeScript 严格模式,确保类型安全,支持 Vue3 和 uni-app

生成的配置

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "jsxImportSource": "vue",
    "paths": {
      "@/*": ["./src/*"],
      "@img/*": ["./src/static/images/*"]
    },
    "types": ["@dcloudio/types", "@uni-helper/uni-types", "miniprogram-api-typings"]
  },
  "include": ["src/**/*.ts", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

四、UI 框架集成:UnoCSS + UView Pro

4.1 UnoCSS 配置:AI 也会"翻车"

UnoCSS 是原子化 CSS 的最佳选择,但在小程序环境中使用时,AI 一开始给出的配置并不完美。

第一次尝试

小何:配置 UnoCSS
AI:生成了标准的 Web 端配置(presetUno)。
结果:小程序端样式完全不生效。

修正后的交互

小何:这个配置在微信小程序里不生效,请检查是否需要专用预设?

AI:抱歉,你是对的。在 UniApp 中需要使用 `@uni-helper/unocss-preset-uni`。
    我已更新配置:

最终生成的 uno.config.ts

import { defineConfig, presetIcons } from 'unocss';
import { presetUni } from '@uni-helper/unocss-preset-uni';

export default defineConfig({
  presets: [
    // 使用 UniApp 专用预设,解决小程序兼容性问题
    presetUni({
      attributify: {
        prefixedOnly: true, // 避免属性冲突
      },
    }),
    presetIcons({
      scale: 1.2,
      warn: true,
    }),
  ],
  theme: {
    colors: {
      // 使用 CSS 变量,支持动态换肤
      primary: 'var(--wot-color-theme,#0957DE)',
    },
  },
});

这里的重点是 presetUniprefixedOnly,这是 AI 在被指出错误后迅速修正的关键点。

UnoCSS 的优势

<!-- 传统 CSS 写法 -->
<template>
  <view class="container">
    <text class="title">心动恋聊</text>
  </view>
</template>

<style scoped>
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 16px;
}
.title {
  font-size: 24px;
  font-weight: bold;
  color: #ff6b9d;
}
</style>

<!-- UnoCSS 写法 -->
<template>
  <view class="flex-center p-4">
    <text class="text-24px font-bold text-primary">心动恋聊</text>
  </view>
</template>

代码量减少 70%,开发效率大幅提升!

4.2 UView Pro 集成

UView Pro 是 UniApp 最好用的组件库之一。

提示词

帮我集成 UView Pro 组件库,配置按需导入和主题定制

package.json 依赖

{
  "dependencies": {
    "uview-pro": "^0.0.3"
  }
}

组件使用示例

<template>
  <view class="page">
    <!-- 导航栏 -->
    <u-navbar title="心动恋聊" :placeholder="true" />

    <!-- 表单 -->
    <u-form ref="formRef" :model="formData" :rules="rules">
      <u-form-item label="昵称" prop="nickname">
        <u-input v-model="formData.nickname" placeholder="请输入昵称" />
      </u-form-item>
      <u-form-item label="性别" prop="gender">
        <u-radio-group v-model="formData.gender">
          <u-radio label="男" :name="1" />
          <u-radio label="女" :name="2" />
        </u-radio-group>
      </u-form-item>
    </u-form>

    <!-- 按钮 -->
    <u-button type="primary" @click="handleSubmit">提交</u-button>

    <!-- 弹窗 -->
    <u-popup v-model:show="showPopup" mode="bottom">
      <view class="p-4">弹窗内容</view>
    </u-popup>
  </view>
</template>

<script setup lang="ts">
const formRef = ref();
const showPopup = ref(false);

const formData = reactive({
  nickname: '',
  gender: 1,
});

const rules = {
  nickname: [{ required: true, message: '请输入昵称' }],
  gender: [{ required: true, message: '请选择性别' }],
};

const handleSubmit = async () => {
  const valid = await formRef.value.validate();
  if (valid) {
    // 提交逻辑
  }
};
</script>

五、自动导入配置:提升开发体验

5.1 Vue API 自动导入

传统写法每个文件都要导入:

// 传统写法
import { ref, reactive, computed, watch, onMounted } from 'vue';

const count = ref(0);
const state = reactive({ name: '' });

配置自动导入后:

// 自动导入后,直接使用
const count = ref(0);
const state = reactive({ name: '' });
const double = computed(() => count.value * 2);

onMounted(() => {
  console.log('mounted');
});

AutoImport 配置

AutoImport({
  imports: ['vue', 'uni-app'],
  dts: 'src/types/auto-import.d.ts',
  dirs: ['src/hooks'],
  vueTemplate: true,
});

这会自动生成类型声明文件 src/types/auto-import.d.ts

// Auto generated by unplugin-auto-import
export {};
declare global {
  const computed: (typeof import('vue'))['computed'];
  const onMounted: (typeof import('vue'))['onMounted'];
  const onUnmounted: (typeof import('vue'))['onUnmounted'];
  const reactive: (typeof import('vue'))['reactive'];
  const ref: (typeof import('vue'))['ref'];
  const watch: (typeof import('vue'))['watch'];
  // uni-app APIs
  const onLaunch: (typeof import('uni-app'))['onLaunch'];
  const onShow: (typeof import('uni-app'))['onShow'];
  const onHide: (typeof import('uni-app'))['onHide'];
  // ... 更多
}

5.2 自定义 Hooks 自动导入

src/hooks/ 目录下创建的 hooks 也会自动导入:

src/hooks/useRequest.ts

import type { Ref } from 'vue';

interface UseRequestOptions<T> {
  immediate?: boolean;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

export function useRequest<T>(fn: () => Promise<T>, options: UseRequestOptions<T> = {}) {
  const { immediate = false, onSuccess, onError } = options;

  const data: Ref<T | null> = ref(null);
  const loading = ref(false);
  const error = ref<Error | null>(null);

  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fn();
      onSuccess?.(data.value);
    } catch (e) {
      error.value = e as Error;
      onError?.(error.value);
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    execute();
  }

  return { data, loading, error, execute };
}

使用时无需导入

<script setup lang="ts">
// 直接使用,无需 import
const { data, loading, execute } = useRequest(() => apiGetUserInfo(), { immediate: true });
</script>

5.3 组件自动注册

配置 @uni-helper/vite-plugin-uni-components 后,组件也自动注册:

Components({
  extensions: ['vue'],
  deep: true,
  directoryAsNamespace: false,
  dts: 'src/types/components.d.ts',
});

src/components/UserCard.vue

<template>
  <view class="user-card">
    <image :src="user.avatar" class="avatar" />
    <text class="name">{{ user.name }}</text>
  </view>
</template>

<script setup lang="ts">
defineProps<{
  user: {
    avatar: string;
    name: string;
  };
}>();
</script>

使用时无需注册

<template>
  <!-- 直接使用,无需 import 和 components 注册 -->
  <UserCard :user="userInfo" />
</template>

六、路由配置:pages.json 优化

6.1 页面路由自动生成

使用 @uni-helper/vite-plugin-uni-pages,页面路由可以自动生成。

在页面文件中配置路由

<!-- src/pages/index/index.vue -->
<route lang="json">
{
  "style": {
    "navigationBarTitleText": "首页"
  }
}
</route>

<template>
  <view class="page">
    <!-- 页面内容 -->
  </view>
</template>

自动生成的 pages.json

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/my/my",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "subPackages": [
    {
      "root": "subPackages/agreement",
      "pages": [
        {
          "path": "privacy",
          "style": { "navigationBarTitleText": "隐私协议" }
        },
        {
          "path": "user-agreement",
          "style": { "navigationBarTitleText": "用户协议" }
        }
      ]
    },
    {
      "root": "subPackages/vip",
      "pages": [
        {
          "path": "index",
          "style": { "navigationBarTitleText": "会员中心" }
        }
      ]
    }
  ],
  "tabBar": {
    "color": "#999999",
    "selectedColor": "#FF6B9D",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "static/tabbar/home.png",
        "selectedIconPath": "static/tabbar/home-active.png"
      },
      {
        "pagePath": "pages/my/my",
        "text": "我的",
        "iconPath": "static/tabbar/my.png",
        "selectedIconPath": "static/tabbar/my-active.png"
      }
    ]
  },
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "心动恋聊",
    "navigationBarBackgroundColor": "#ffffff",
    "backgroundColor": "#F5F5F5"
  }
}

6.2 分包配置

小程序有包体积限制(主包 2MB,总包 20MB),分包是必须的。

配置分包

// vite.config.ts
UniPages({
  exclude: ['**/components/**/**.*'],
  dts: 'src/types/uni-pages.d.ts',
  subPackages: [
    'src/subPackages/agreement', // 协议相关页面
    'src/subPackages/vip', // 会员相关页面
    'src/subPackages/planForm', // 计划表单页面
  ],
});

分包优化插件

// @uni-ku/bundle-optimizer 自动优化
Optimization({
  enable: {
    optimization: true, // 开启优化
    'async-import': true, // 异步导入
    'async-component': true, // 异步组件
  },
  dts: {
    base: 'src/types',
  },
});

七、环境变量配置

7.1 多环境配置

"心动恋聊"项目支持多环境、多项目配置:

目录结构

env/
├── .env                           # 基础配置
├── .env.development               # 开发环境
└── .env.production                # 生产环境

.env.development

 # 基础配置
 VITE_APP_PORT=5173
 VITE_APP_PUBLIC_BASE=/

 # API 配置
 VITE_SERVER_BASEURL=http://localhost:3000
 VITE_APP_PROXY=true

 # 业务配置
 VITE_APP_SOURCE_ID=your_source_id
 VITE_APP_CHANNEL_ID=weixin

7.2 环境变量使用

在代码中使用

// 直接使用
const apiBaseUrl = import.meta.env.VITE_SERVER_BASEURL;
const sourceId = import.meta.env.VITE_APP_SOURCE_ID;

// 类型安全
interface ImportMetaEnv {
  VITE_APP_PORT: string;
  VITE_SERVER_BASEURL: string;
  VITE_APP_SOURCE_ID: string;
  VITE_APP_CHANNEL_ID: string;
  VITE_APP_BRAND_KEY: string;
}

启动命令

{
  "scripts": {
    "dev:mp": "uni -p mp-weixin --mode development",
    "build:mp": "uni build -p mp-weixin --mode production"
  }
}

八、代码规范配置

8.1 ESLint 配置

提示词

配置 ESLint,适合 Vue3 + TypeScript + UniApp 项目

使用 @uni-helper/eslint-config

// eslint.config.js
import uniHelper from '@uni-helper/eslint-config';

export default uniHelper({
  typescript: true,
  vue: true,
  unocss: true,
});

8.2 Prettier 配置

// .prettierrc
{
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "all",
  "semi": true,
  "tabWidth": 2,
  "endOfLine": "lf"
}

8.3 Git Hooks

// package.json
{
  "lint-staged": {
    "*": "eslint --fix"
  }
}

九、实战演示:首页核心逻辑开发

"心动恋聊"的首页并不是一个简单的展示页,它包含了一个复杂的交互逻辑:文字/图片输入模式切换

9.1 需求描述

小何:首页需要一个输入区域,支持两种模式:
1. 文字模式:输入对方说的话。
2. 图片模式:上传聊天截图。

切换模式时,输入框和上传区域要互斥显示。
并且需要一个"清除记忆"的功能,但只在文字模式下显示。

9.2 AI 的逐步实现

这是一个涉及状态管理、UI 交互和业务逻辑的复杂需求。AI 是如何一步步实现的呢?

第一步:定义状态

AI 首先定义了核心的响应式状态:

// src/pages/index/index.vue
const showUploadArea = ref(false); // 控制模式切换
const inputText = ref(''); // 文字输入
const selectedImages = ref([]); // 图片列表

// 切换模式逻辑
const toggleInputMode = () => {
  showUploadArea.value = !showUploadArea.value;
  // 切换时清空状态,避免数据混淆
  if (showUploadArea.value) {
    inputText.value = '';
  } else {
    selectedImages.value = [];
  }
};

第二步:构建动态 UI

利用 Vue 的 v-if/v-show 指令处理复杂的显示逻辑:

<template>
  <!-- 模式切换按钮 -->
  <view class="flex justify-between items-center">
    <text>{{ showUploadArea ? '与Ta的聊天截图:' : 'Ta说:' }}</text>
    <view @click="toggleInputMode">
      <text>{{ showUploadArea ? '切换文字' : '切换图片' }}</text>
    </view>
  </view>

  <!-- 上传区域 (图片模式) -->
  <view v-show="showUploadArea" class="upload-area">
    <ImageGrid :images="selectedImages" @add="addImages" />
  </view>

  <!-- 输入框 (文字模式) -->
  <view v-show="!showUploadArea" class="input-wrapper">
    <u-input type="textarea" v-model="inputText" :autoHeight="true" />
  </view>

  <!-- 清除记忆 (仅文字模式) -->
  <view v-if="!showUploadArea" class="clear-memory-btn" @click="handleClearMemory">
    <text>清除记忆</text>
  </view>
</template>

第三步:处理业务逻辑

生成回复时,需要根据当前模式调用不同的接口:

const handleGenerate = async () => {
  // 1. 鉴权检查
  if (!(await checkAuth())) return;

  // 2. 模式判断
  if (showUploadArea.value) {
    // 图片模式逻辑
    if (selectedImages.value.length === 0) return toast.info('请上传截图');
    await generateByImage(selectedImages.value);
  } else {
    // 文字模式逻辑
    if (!inputText.value.trim()) return toast.info('请输入内容');
    // 调用带记忆的接口
    await generateByText({
      text: inputText.value,
      sessionId: chatSessionStore.sessionId,
    });
  }
};

9.3 真实代码片段

最终生成的代码不仅逻辑清晰,还处理了很多细节,比如 iOS 的样式适配:

// 适配 iOS 的输入框样式
const inputCustomStyle = computed(() => {
  const isIOS = systemInfo?.platform === 'ios';
  return {
    padding: isIOS ? '12rpx' : '20rpx',
    minHeight: '240rpx',
    borderRadius: '12px',
  };
});

这就是 AI 辅助开发的威力:它不仅能写出跑通的代码,还能考虑到平台差异和边界情况。

9.3 运行测试

# 微信小程序
pnpm --filter unibest-mp dev:mp

# H5
pnpm --filter unibest-mp dev:h5

# App
pnpm --filter unibest-mp dev:app

十、总结与下一步

10.1 本篇完成的工作

通过 AI 辅助,我们在 不到 1 小时 内完成了:

任务 完成情况
✅ Monorepo 项目结构 标准的 pnpm + Turborepo 架构
✅ Vite 配置 完整的插件链和优化配置
✅ TypeScript 配置 严格模式,完整类型支持
✅ UnoCSS 集成 原子化 CSS,主题定制
✅ UView Pro 集成 组件库完整接入
✅ 自动导入配置 Vue API、uni-app API、Hooks
✅ 路由系统 自动生成,分包优化
✅ 环境变量 多环境、多项目支持
✅ 代码规范 ESLint + Prettier
✅ 首页开发 完整的页面实现

10.2 核心提示词模板

项目初始化

创建 [框架] + [技术栈] 项目,
包含 [目录结构],
配置 [构建工具]

配置文件生成

配置 [工具名称],需要:
1. [功能点 1]
2. [功能点 2]
3. [功能点 3]

页面生成

创建 [页面名称],包括:
- [功能描述]
使用 [UI 框架] 和 [样式方案]

10.4 下一篇预告

《【AI 编程实战】第 3 篇:AI 辅助后端开发 - Next.js 15 API 快速搭建》

我们将学习:

  • Next.js 15 App Router 架构
  • Prisma ORM 数据库操作
  • RESTful API 设计
  • JWT 认证中间件
  • 前后端类型共享

关注我,不错过每一篇实战干货!


如果这篇文章对你有帮助,请点赞、收藏、转发,让更多人了解 AI 编程的强大!

有任何问题,欢迎在评论区留言,我们一起讨论。

鸿蒙开发必备:macOS 上 ohpm 的完整安装与配置指南(从报错到成功)

🚀 引言:为什么需要 ohpm?

在 iOS 开发中,我们用 CocoaPods 管理第三方库;在 Android 中,用 Gradle + Maven
而在 鸿蒙(HarmonyOS)生态中,华为官方提供了 ohpm(OpenHarmony Package Manager) —— 这是管理 HarmonyOS 第三方组件(如 Lottie 动画、网络库、UI 组件等)的标准工具

但许多 macOS 开发者在首次使用 ohpm 时,会遇到各种“命令未找到”、“node not found”等问题。本文将从零开始,带你一步步排查并成功配置 ohpm,确保你的 DevEco Studio 项目能顺利集成三方库。


🔧 第一步:确认 DevEco Studio 已正确安装

ohpm 是 DevEco Studio 自带的命令行工具,因此你必须先安装 DevEco Studio。

💡 安装完成后,应用通常位于 /Applications/DevEco-Studio.app(注意名称可能含连字符 -


🔍 第二步:定位 ohpm 的真实路径(关键!)

很多教程直接写死路径为 /Applications/DevEco Studio.app/...,但实际路径因版本和安装方式而异

✅ 正确做法:用 find 命令查找

find /Applications -name ohpm 2>/dev/null

典型输出:

/Applications/DevEco-Studio.app/Contents/tools/ohpm
/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin/ohpm   ← 可执行文件在此!

⚠️ 注意:

  • 应用名可能是 DevEco-Studio.app(带连字符),不是 DevEco Studio.app
  • ohpm 可执行文件在 .../tools/ohpm/bin/ 目录下,不是 .../tools/bin/

📂 第三步:配置 shell 环境变量(zsh)

macOS Catalina(10.15)及以后默认使用 zsh,配置文件为 ~/.zshrc

1. 编辑 ~/.zshrc

nano ~/.zshrc

2. 添加以下内容(根据你的实际路径调整)

# Android SDK(某些混合项目需要,可选)
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools"

# DevEco Studio 内置 Node.js(方案二备用,推荐用 Homebrew)
# export PATH="/Applications/DevEco-Studio.app/Contents/tools/nodejs/bin:$PATH"

# ohpm 可执行文件路径(关键!)
export PATH="$PATH:/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin"

# Homebrew(确保 brew 命令可用)
eval "$(/opt/homebrew/bin/brew shellenv)"

# JetBrains VM options(DevEco Studio 自动添加,保留即可)
___MY_VMOPTIONS_SHELL_FILE="${HOME}/.jetbrains.vmoptions.sh"
if [ -f "${___MY_VMOPTIONS_SHELL_FILE}" ]; then
  . "${___MY_VMOPTIONS_SHELL_FILE}"
fi

🔥 重点替换:

  • DevEco-Studio.app 替换为你 find 命令查到的实际名称
  • 路径必须是 .../tools/ohpm/bin

3. 保存并重载

# 保存后退出 nano(Ctrl+O → Enter → Ctrl+X)
source ~/.zshrc

🐘 第四步:安装 Node.js(解决 "node: command not found")

当你运行 ohpm -v 时,如果看到:

ERROR: node: command not found
...
Failed to find the executable 'node' command

说明 系统缺少 Node.js。虽然 DevEco Studio 内置了 Node,但不建议依赖它

✅ 推荐方案:用 Homebrew 安装 Node.js

# 安装 Node.js(包含 npm)
brew install node

验证安装

node -v   # 输出如 v20.18.0
npm -v    # 输出如 10.8.2

✅ 优点:

  • 版本新、稳定
  • 所有终端和 GUI 应用都能使用
  • 符合华为官方推荐

❌ 不推荐方案:使用 DevEco 内置 Node

如果你坚持不用 Homebrew,可取消注释 .zshrc 中的这行:

export PATH="/Applications/DevEco-Studio.app/Contents/tools/nodejs/bin:$PATH"

但需注意版本锁定和兼容性风险。


🧪 第五步:验证 ohpm 是否工作

# 1. 检查路径
which ohpm
# 应输出:/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin/ohpm

# 2. 查看版本
ohpm -v
# 成功输出:2.0.0(或其他版本号)

# 3. 测试安装一个库
ohpm install @ohos/lottie --save

如果看到 Install success,恭喜你!ohpm 已配置成功。


🖥 第六步:让 DevEco Studio GUI 也能识别环境变量

即使终端中 ohpmANDROID_HOME 生效了,从 Dock 启动的 DevEco Studio 仍可能报错 “ANDROID_HOME not set”

原因:

GUI 应用(如 DevEco Studio)不会读取 ~/.zshrc,只读取登录时加载的 ~/.zprofile

✅ 解决方案:

.zshrc 中的环境变量同步到 ~/.zprofile

# 复制配置
cp ~/.zshrc ~/.zprofile

# 或手动编辑
nano ~/.zprofile

然后 重启电脑,确保 launchd 加载新变量。

💡 重启后,DevEco Studio 就不会再提示环境变量缺失了!


🛠 常见错误与排查

错误现象 原因 解决方案
command not found: ohpm PATH 路径错误或未生效 find 确认路径,检查 .zshrc 是否 source
node: command not found 未安装 Node.js brew install node
DevEco Studio 报 “ANDROID_HOME not set” GUI 应用未继承变量 配置 ~/.zprofile 并重启电脑
ohpm install 卡住或超时 网络问题 设置国内源: ohpm config set registry https://ohpm.openharmony.cn/ohpm/

📚 官方资源


✅ 总结:成功配置 ohpm 的 Checklist

  • find /Applications -name ohpm 确认真实路径
  • ~/.zshrc 中正确设置 PATH(含 .../tools/ohpm/bin
  • 通过 brew install node 安装 Node.js
  • 运行 source ~/.zshrc 使配置生效
  • 验证 ohpm -v 输出版本号
  • 将环境变量同步到 ~/.zprofile重启电脑

🌟 结语

鸿蒙生态正在快速发展,而 ohpm 是连接你与丰富三方组件的桥梁。虽然初期配置稍显繁琐,但一旦打通,你就能像使用 CocoaPods 一样轻松集成动画、网络、工具类库。

希望这篇博客能帮助你少走弯路,快速进入 HarmonyOS 开发的正轨!

Happy Coding with HarmonyOS! 🍀
如果你成功了,欢迎在评论区分享你的经验;如果仍有问题,也请留言,我会尽力解答。


歼20居然是个框架-基于 Signals 信号的前端框架设计

logo

大家好,我是 anuoua,今天我们来讲讲基于 Signal 如何构建一个前端框架。

以 Vue 为响应式前端框架的代表,以 React 则是非响应式前端框架的代表,算是目前前端框架的稳定格局。

响应式的优点不言而喻,是高性能前端框架的选择。

而响应式也有不同的设计理念,区别于 Vue 的 reactivity,preact 的作者提出了 Signal 这种响应式的理念,和深度劫持的 reactivity 不同,Signal 更简单直观,其理念传播广泛,目前 Signal 作为 js 语言特性被提出成为 proposal。

响应式前端框架的现状

目前一些具有代表性的前端框架,基本都走向了响应式 API + 真实 DOM,例如:svelte、solid、vue,这几个前端框架在性能上有了大幅提升,但是仍然存在一些问题。

Vue 3

Vue 作为响应式框架的开创者,Vue3 仍然是虚拟 DOM,而 Vue 3 vapor 转向真实 DOM。Vue 3 版本中遇到最严重的问题是自动解包、**解构以及类型,**为了解决这些问题作者试验过很多语法,最终在数个的迭代后,还是上了编译手段,在SFC中使用宏用来解决开发体验以及 Typescript 类型问题。

<script setup>
const props = defineProps({
  foo: String
})
</script>

除此之外,Vue 的问题就在于官方没有引导用户到理想的开发模式上去,组件写法太多,导致社区力量分散,发力不在一处。如果统一使用 SFC 开发,统一使用 composition api,那么社区就不会陷入使用 jsx 还是 SFC,使用 options 还是 composition api 的纠结,那么社区的生态会好很多。

Svelte

Svelte 借助编译手段将视图转换成真实DOM实现,在 Svelte 5 中转向了和 Vue 类似的深度劫持的响应式API。它设计了一种叫 runes 的概念,通过编译技术追踪由特殊函数名创建的变量,将其编译成响应式代码,基本解决了类似 Vue 的困扰,无需手动解包,开发体验不错。

let message = $state('hello');

我认为 Svelte 的 runes 已经很接近完美了,开发体验很不错。

但 Svelte 本身仍然有以下几点问题:

第一:它有自己的 DSL .svelte,我认为 JSX 更佳,Typescript 对 JSX 的支持非常好,DSL 支持 TS 总是需要付出更多的代价,而且需要支付更多的学习成本。

第二:它的响应式仍然是和 Vue 一样的默认深度劫持,如果是复杂嵌套对象,劫持内部对象会被包装带来会有隐晦的debug负担和理解成本。我认为 Signal 信号的浅劫持理念更加简单和直观。

第三:runes 还不够完美,若在大型应用中使用其创建的变量,会导致和普通变量混淆,编译器可以追踪变量,但是在多文件代码复杂组合的时候,很难区分是普通变量还是响应式变量,给debug带来困难。

Solid

Solidjs,它则是视图部分采取编译手段,API部分保持原生,让用户裸使用原生 Signal API,Solidjs 的 API 是符合 Signal 理念的,没有深度劫持。但是原生的 Signal API 看起来使用较为繁琐。

import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);

Solid 性能不错,JSX + TS 的开发体验基本拉满,唯一的问题是裸 Signal API 的使用不够优雅,略显繁琐。

例如它也不能直接解构 props,需要借助帮助函数才能维持响应性。

通病

它们在支持 Web Component 这点上,都没有做好无缝的开发体验,有额外的使用成本。

总结

以上三个框架都抛弃了虚拟DOM,配合响应式API,性能表现都非常好,但它们都或多或少都有令人在意的问题,很难找到理想中的前端框架。

框架 真实 DOM Signal JSX Signal API 编译
Vue 支持(Vapor Mode) 兼容(shallowRef) 兼容 混合
Svelte 支持 不支持 不支持 支持
Solid 支持 支持 支持 不支持

理想的前端框架

如果我们需要一个新的前端框架,那么应该怎么设计?

根据上述总结,我认为 真实 DOM + JSX + Signal API 编译策略 + Web Component 一等支持 才是最接近完美的方案。

而 Solid 已经接近我们想要的了,给它加上剩下两个特性基本上就满足我们需要了。

所以怎么实现一个“完美”的框架呢?

从细粒度绑定到组件

signal 如何细粒度绑定 DOM 更新呢?又是怎么从基本的绑定演化为框架组件呢?

我们先从 Signal 的用法说起。

Signal 的基本用方法

// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
// 副作用绑定
effect(() => {
  // 当 name.value = "hello2";
  // console => 1. "hello world" 2. "hello2 world"
  console.log(displayName);
});

绑定DOM元素

// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");

const text = document.createTextNode("");

// 副作用绑定
effect(() => {
  text.nodeValue= displayName.value;
});

演化成组件

一个只有 text 节点的组件:

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return (() => {
    const text = document.createTextNode("");
    effect(() => {
      text.nodeValue= displayName.value;
    });
    return text;
  })();
}

更复杂的组件

在 div 中添加 text 节点:

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return (() => {
    const el1 = (() => {
      const text = document.createTextNode("");
      effect(() => {
        text.nodeValue= displayName.value;
      });
      return text;
    })();
    const div = document.createElement("div");
    div.append(el1);
    return div;
  })();
}

演化成 JSX

Solid 的编译策略和上述是类似的,视图的编译是有规律的,创建 - 绑定 - 挂载,只要是有规律的,那就可以通过 DSL 来描述,JSX 正好可以表达这个过程。

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return <div>{displayName.value}</div>;
}

可以看到复杂的视图创建流程通过 DSL 的使用配合编译手段,开发体验可以大幅提升。

同时需要指出 Solid 的编译方式未必是最好的,编译后的代码量挺大,还有各种闭包嵌套,可以稍微改进一下,编译成:

import { jsx, template } from "some/jsx-runtime"

const temp1 = template("<div>");

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return jsx(temp1(), {
    get children() {
      return displayName.value;
    }
  });
}

Solid 把一部分 DOM 操作过程也编译出来了,事实上创建真实 DOM 的过程很大一部分是通用的,我们把创建元素的方法抽出来 jsx,用于创建和组装元素,这样编译出来的代码也会相对直观。

同时需要注意到 template 方法,它做了一件事,内部使用 cloneNode 去创建静态节点,这样可以提升性能。

总结

这套编译策略,从演化中总结编译策略,然后完成 JSX AST 转换实现,确实是有创新思维和难度的,属于框架的创新点核心。

最先搞视图转真实 DOM 编译的是 Svelte,而 Solid 完成了更高效的实现,又最终促进了 Svelte 5 的诞生,使 Web 框架在性能得到了上大幅升级。

完整的框架要考虑的更多

只靠上面的编译策略显然是不够的,需要考虑很多细节问题。

组件的创建,事实上挺复杂的,组件是有实例的,初始化实例的过程中需要做很多工作。

比如:利用插桩来定位组件组件的边界,假设组件直接返回 <><span>1</span><span>2</span></> ,如果没有插桩框架将无法识别边界,在做列表 diff 的时候,组件内元素集合的移除、添加、移动等操作将错乱。

const App = () => {
  const fragment = document.createDocumentFragment();
  const instance = {
    range: [
      document.createTextNode(""),
      document.createTextNode(""),
    ]
  }
  const span1 = document.createElement("span");
  const span2 = document.createElement("span");
  fragment.append(instance.range[0]);
  fragment.append(span1);
  fragment.append(span2);
  fragment.append(instance.range[1]);
  return fragment;
}

界面突变和 diff 算法

和 React 和 Vue 一样,这类编译型的前端框架仍然有 diff 过程。

界面突变的根本逻辑就是列表渲染,而列表渲染一定会涉及 diff,而 Vue 高效的 diff 算法也是可以使用的,算法和实现分离,不同的框架有不同的实现。

为什么说界面突变的根本逻辑是列表渲染?

条件渲染本质也是列表渲染,我们来看一个三目逻辑 :

// React
const List = () => {
  const [toggle, setToggle] = 0;
  
  useEffect(() => {
    setToggle((toggle[0] + 1) % 2);
  });
  
  return [toggle].map(i => (<Fragment key={i}>{i}</Fragment>))
}

实际上就是列表 [0][1] 之间相互切换。

Switch Case 逻辑也类似:

// React
const List = ({ value }) => {
  const [list, setList] = [1,2,3,4];
  
  const deriveList = list.filter(i => i === value).slice(0, 1);
  
  return [deriveList].map(i => (<Fragment key={i}>{i}</Fragment>));
}

根据 value 的值过滤列表,即可以实现 Switch Case 逻辑。

虚拟 DOM 和 真实 DOM 的 diff 实现差异

虚拟 DOM 的 diff 是从的组件节点(Vue)或者根节点(React)开始,遍历一遍,抽离出 DOM 指令以更新视图。

但是真实 DOM 的框架,列表是细粒度绑定的,当列表变化后,更新视图是在副作用内执行的,所以它需要一个特定的组件或者函数来封装这个副作用的逻辑,在 Solid 中就是 <For> 组件, Vue Vapor 和 Svelte 是在编译的时候编译成了一个特定的函数。

svelte:

$.each(node, 16, () => expression, $.index, ($$anchor, name, index, $$array) => {
    $.next();
    var text_2 = $.text('...');
    $.append($$anchor, text_2);
});

diff 算法可以借鉴,但是虚拟 DOM 和 真实 DOM 框架在 diff 算法中进行的操作并不一样,理论上 Solid 也可以用 Vue 3 的算法。

开发体验升级

上面指出 Solid 体验已经很好的,但是仍有不足,裸 Signal API 的使用不够优雅,getter setter 满屏幕跑,Vue Svelte 为了解决体验问题都通过对应的编译策略来解决这个问题,而 Solid 没有,有点遗憾。

事实上开发体验这块,React 除了需要手动管理依赖这块过于逆天之外,它的开发体验真的不错。

React 的组件状态写法已经很简洁了,不用像 Vue,Solid 那样套 computed。

const App = () => {
  const [name, setName] = useState("");
  
  const displayName = "Info: " + name
  
  return <div onClick={() => setName(name + "world")}>{displayName}</div>
}

也就是说,如果我们能改进 Solid,给它加上一组编译手段,改进 Signal 的使用体验,是不是会提升开发体验呢?

让我们尝试推演一下。

理想的组件形态

我们先提出一个理想中的组件形态,要求足够简洁,开发体验足够好:

const App = () => {
  let name = "hello";
  
  return (
    <div onClick={() => {name = name + "world"}}>{name}</div>
  )
}

我们希望改变 name 的时候,视图就会更新,但是这样是做不到的,改变一个变量没有任何作用。

但是如果是信号就不一样了:

const App = () => {
  const name = signal("");
  
  return (
    <div onClick={() => name.value = name.value + "xxx"}>{name.value}</div>
  )
}

我们根据上文所说的 JSX 编译手段,创建元素可以绑定副作用,name.value是可以被副作用收集到,并在name.value 更新的时候顺便更新视图。

import { jsx, template } from "some/jsx-runtime"

const temp1 = template("<div>");

const App = () => {
  const name = signal("");
  return jsx(temp1(), {
    get onClick() {
      return () => {
        name.value = name.value + "xxx";
      }
    },
    get children() {
      return name.value;
    }
  });
}

这时候就需要编译来完成我们的代码转换,在这里我们把信号变量使用 **$** 标记。然后就代码如下:

const App = () => {
  let $name = "hello";
  
  return (
    <div onClick={() => {$name = $name + "world"}}>{$name}</div>
  )
}

这个代码和我们理想中的组件代码非常接近了,要是真的能这样写代码,那么开发体验就能得到大幅提升。

Signal 信号编译策略

前面提到使用 $ 标记信号,就是一种创新的编译策略,通过特殊命名标记变量,将变量编译成响应式信号代码。

编译策略说明

这里我们按照 preact/signals 库的 api 做示例。

编译策略一:let 搭配 $ 开头的变量,即为声明信号。

let $name = "hello"
// 编译成
import { signal } from "@preact/signal";
let $name = signal("hello");

编译策略二:读取 $ 开头的变量会默认解包

let $name = "hello";
console.log($name);
// 编译成
let $name = signal("hello");
console.log($name.value);

编译策略三:const 搭配 $ 开头的变量,为声明派生信号。

let $name = "hello";
const $display = $name + "world";
// 编译成
import { signal, computed } from "@preact/signal";
let $name = signal("hello");
const $display = computed(() => $name.value + "world");

编译策略四:$use 开头的为自定义 hooks 。

const $useName = () => {
  let $name = "hello";
  
  return {
    name: $name
  }
}

// 编译成
const $useName = () => {
  let $name = signal("hello");
  
  return computed(() => ({
    name: $name.value
  }))
}

编译策略五:解构 + 变量传递。

函数入参,入参的响应传递,解构变量需要设置$前缀

const App = ({ name: $name, ...$rest }) => {
  console.log($rest);
  return <div>{$name}</div>
}

// 编译为
const App = ($__0) => {
  const $name = computed(() => $__0.value.name);
  const $rest = computed(() => {
    const { name, ...rest } = $__0.value;
    return rest;
  });
  console.log($rest.value);
  return <div>{$name.value}</div>
}

自定义 hook 返回,解构的时候为了不丢失响应,同样也要解构变量设置$前缀,这样就能触发编译。

const $useName = () => {
  let $name = "hello";
  
  return {
    name: $name
  }
}

// 解构后的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const { name: $name } = $useName();
// 自定义 hook 返回赋值的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const $nameSignal = $useName();

// 编译成
const $useName= () => {
  let $name = signal("hello");
  
  return computed(() => ({
    name: $name.value
  }))
}

const $__0 = computed(() => $useName().value);
const $name = computed(() => $__0.value.name);
const $nameSignal = $useName();

此编译策略的优点

  1. 无需手动导入 API,像普通变量一样使用 Signal
  2. 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
  3. 不怕解构
  4. 标记变量和常规变量一起用不会有混淆

一个简单的鼠标位置hook

const $usePosition = () => {
  let $x = 0;
  let $y = 0;
  
  const $pos = {
    x: $x,
    y: $y
  };
  
  mounted(() => {
    document.addEventListener('mousemove', (e) => {
      $x = e.pageX;
      $y = e.pageY;
    });
  })
  
  return {
    $pos,
  }
}

const App = () => {
  const { $pos } = $usePosition();
  
  return <div>x: {$pos.x}; y:{$pos.y}</div>
}

是不是清爽很多,简单应用的代码量差距不是很明显,但是如果代码量增加,那么代码量的差距还是非常可观的。

同时这样的设计,甚至不需要手动导入 API ,它在编译期间自动导入,让人无需关心 Signal 本身,真正做到了无感,开发体验得到了提升。

Web Component 支持

Vue Solid Svelte 都支持封装 Web Component,但是在开发体验上并没有多好,需要额外操作才能集成到框架中使用,做不到在框架内无缝使用,这样也限制了 Web Component 的推广和使用。

所以我们希望框架能够做好以下几点来支持 Web Component:

  • 和框架本身可以无缝集成,像普通组件一样方便使用
  • 组件 TS 类型易用且完善
  • 可以按照常规 Web Component 一样可以独立使用
  • 可以供给原生 HTML 或者其他框架使用

有这样的框架吗?

有啊 J20 框架 J20

logo

点个 Star 吧。

说在最后

这大概是我最后一个前端框架了,也算是完成了之前对前端框架的想法(中间隔了很久才想起来还有个东西没完成)。

歼20框架大量代码都是AI写的,我负责设计,它负责实现,同时帮我写测试,速度大幅提升。

AI 时代,也许框架不再重要了吧。哈哈

谢谢大家!

❌