普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月27日首页

How to Install Java on Ubuntu 26.04

Java is a popular programming language used for building applications and software solutions. It runs on all major operating systems and devices.

This guide covers installing OpenJDK and Oracle JDK on Ubuntu 26.04.

Quick Reference

Task Command
Install default JDK sudo apt install default-jdk
Install OpenJDK 21 sudo apt install openjdk-21-jdk
Install OpenJDK 25 sudo apt install openjdk-25-jdk
Check Java version java -version
Change default Java sudo update-alternatives --config java
Set JAVA_HOME Add to /etc/environment
Uninstall Java sudo apt remove openjdk-25-jdk

Before You Begin

There are several Java implementations available. OpenJDK and Oracle JDK are the two most common choices. OpenJDK is the default option in Ubuntu and the best fit for most systems.

Ubuntu 26.04 includes OpenJDK packages for both the Java Runtime Environment (JRE) and the Java Development Kit (JDK). The JRE includes the Java virtual machine (JVM) and the libraries needed to run Java programs. The JDK includes the JRE plus the tools needed to build Java applications.

If you are not sure which package to install, start with the default OpenJDK version. Some applications require a specific Java release, so check the application documentation before you install it.

Installing OpenJDK in Ubuntu

Check if Java is already installed:

Terminal
java -version

If Java is not installed, the output will tell you the command is not found. Otherwise, it shows the installed version.

Update the package index:

Terminal
sudo apt update

The current long-term supported (LTS) versions of Java are: 11, 17, 21, and 25. The default Java in Ubuntu 26.04 is Java 25, which you get by installing the default-jdk package.

Install latest LTS Java 25:

Terminal
sudo apt install openjdk-25-jdk
Info
If you want to install another version, replace the version number. For example, if your application requires Java 21, install the openjdk-21-jdk package.

Verify the installation:

Terminal
java -version
output
openjdk version "25.0.3-ea" 2026-04-21
OpenJDK Runtime Environment (build 25.0.3-ea+7-Ubuntu-2)
OpenJDK 64-Bit Server VM (build 25.0.3-ea+7-Ubuntu-2, mixed mode, sharing)

JRE is included in the JDK package. If you need only JRE, install the openjdk-25-jre package. For minimal Java runtime, install the openjdk-**25**-jre-headless package.

Installing Oracle Java in Ubuntu

Oracle JDK is not available in the default Ubuntu repositories. You can install it by downloading the .deb package from Oracle.

At the time of writing, Oracle’s downloads page offers both JDK 26, the latest feature release, and JDK 25, the latest LTS release. Oracle JDK 25 is available under Oracle No-Fee Terms and Conditions (NFTC), which allows free production use and redistribution for that release. If you plan to standardize on Oracle JDK, review Oracle’s current licensing terms before deployment.

Visit the Oracle Java Downloads page and select the version you need.

In this example, we will download and install Java 25 because it is the current LTS release. If you want the newest feature release instead, download JDK 26 from the same page. Choose the Linux x64 Debian Package for the version you want and download the .deb file.

java 25

If you are installing on a server, use wget to download the file:

Terminal
wget https://download.oracle.com/java/25/latest/jdk-25_linux-x64_bin.deb

Install the package:

Terminal
sudo apt install ./jdk-25_linux-x64_bin.deb

Replace the filename if you downloaded a different version.

Setting the Default Java Version

If you have multiple Java versions installed, check the current default:

Terminal
java -version

Change the default version with update-alternatives:

Terminal
sudo update-alternatives --config java

You will see a list of installed Java versions:

output
There are 2 choices for the alternative java (providing /usr/bin/java).
Selection Path Priority Status
------------------------------------------------------------
* 0 /usr/lib/jvm/jdk-25.0.3-oracle-x64/bin/java 419454976 auto mode
1 /usr/lib/jvm/java-25-openjdk-amd64/bin/java 2511 manual mode
2 /usr/lib/jvm/jdk-25.0.3-oracle-x64/bin/java 419454976 manual mode
Press <enter> to keep the current choice[*], or type selection number: 

Enter the number of the version you want as default and press Enter.

Verify the change:

Terminal
java -version

Setting the JAVA_HOME Environment Variable

Some Java applications use the JAVA_HOME environment variable to determine the JDK location.

First, find the Java installation path:

Terminal
sudo update-alternatives --config java

The paths are:

  • Oracle JDK 25 is located at /usr/lib/jvm/jdk-25-oracle-x64/bin/java
  • OpenJDK 25 is located at /usr/lib/jvm/java-25-openjdk-amd64/bin/java
Info
The java binary is located at JAVA_HOME/bin/java. Set JAVA_HOME to the path above, excluding the bin/java part.

Open the /etc/environment file:

Terminal
sudo nano /etc/environment

Add the following line (adjust the path for your preferred version):

/etc/environmentsh
JAVA_HOME="/usr/lib/jvm/java-25-openjdk-amd64"

Apply the changes:

Terminal
source /etc/environment

Verify the variable is set:

Terminal
echo $JAVA_HOME
output
/usr/lib/jvm/java-25-openjdk-amd64
Info
/etc/environment is system-wide. To set JAVA_HOME per user, add the line to .bashrc or another shell configuration file.

Uninstalling Java

Uninstall Java like any other package:

Terminal
sudo apt remove openjdk-25-jdk

Replace the package name with the version you want to remove.

Conclusion

We covered installing OpenJDK from the Ubuntu 26.04 repositories and downloading Oracle JDK manually. The default OpenJDK 25 works for most applications, but Java 26 is also available in the Oracle repositories for the latest features.

For more information, see the official OpenJDK documentation .

昨天 — 2026年4月26日首页

Polyline 组件如何绘制渐变区域?

作者 光影少年
2026年4月26日 17:07

✅ 方案一:用 Polygon 替代 Polyline(最推荐)

如果你是想做“线下方渐变”(类似折线图面积图),可以:

  1. 把 Polyline 的点复制一份
  2. 补齐底部闭合路径
  3. Polygon 填充渐变

示例(以高德地图 JS API 为例)

const path = [
  [116.3, 39.9],
  [116.4, 39.8],
  [116.5, 39.85],
];

// 构造闭合区域(补到底部)
const polygonPath = [
  ...path,
  [116.5, 39.7],
  [116.3, 39.7],
];

const polygon = new AMap.Polygon({
  path: polygonPath,
  fillColor: 'rgba(0, 0, 255, 0.5)', // 基础色
  fillOpacity: 0.5,
});

👉 渐变实现:
高德原生不支持渐变填充,但你可以:

  • CanvasLayer 自绘渐变
  • 或使用 自定义覆盖物

✅ 方案二:CanvasLayer + 渐变(高级玩法)

如果你需要真正的渐变(linear-gradient),可以:

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

// 创建渐变
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, 'rgba(0,0,255,0.8)');
gradient.addColorStop(1, 'rgba(0,0,255,0)');

ctx.fillStyle = gradient;

// 画路径(类似 polygon)
ctx.beginPath();
ctx.moveTo(...);
ctx.lineTo(...);
ctx.fill();

然后通过:

new AMap.CanvasLayer({
  canvas: canvas,
  bounds: ...
});

👉 优点:

  • 完全自定义渐变方向/颜色
  • 可做动态效果

👉 缺点:

  • 需要自己处理坐标转换(经纬度 → 像素)

✅ 方案三:ECharts(如果你是数据可视化场景)

你之前用过 ECharts,这个其实最简单:

series: [{
  type: 'line',
  data: [...],
  areaStyle: {
    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
      { offset: 0, color: 'blue' },
      { offset: 1, color: 'transparent' }
    ])
  }
}]

👉 直接支持渐变区域,不用自己画


✅ 方案四:Polyline + 多条叠加(伪渐变)

如果不想用 Canvas,可以:

  • 画多条宽度不同、透明度不同的 Polyline
  • 模拟渐变效果
// 多层叠加
strokeOpacity: 0.3 / 0.2 / 0.1
strokeWeight: 10 / 20 / 30

👉 效果有限,但实现简单


💡 总结

你要的是“渐变区域”,关键不是 Polyline,而是:

需求 推荐方案
简单渐变区域 Polygon + 伪渐变
高质量渐变 CanvasLayer
数据图表 ECharts
快速hack 多Polyline叠加

收藏即复用!50个极致实用JavaScript单行代码,前端开发效率直接拉满

作者 悟空瞎说
2026年4月26日 14:58

50个原生JS/TS高频单行工具函数!零依赖、生产可用,告别重复造轮子

前言

作为前端开发者,日常业务开发中,字符串处理、数组运算、日期格式化、浏览器API、对象数据清洗等基础逻辑几乎无处不在。

很多小伙伴为了省事,项目里习惯性引入 Lodash、Dayjs 等第三方工具库。但绝大多数场景下,完全不需要引入庞大依赖。

几行原生 JS/TS 代码,就能优雅实现需求,不仅可以减少项目打包体积、降低项目依赖,还能提升代码熟练度,写出更简洁优雅的业务代码。

今天给大家整理了 50个生产可用的原生单行代码片段,覆盖前端9大高频开发场景。

告别玩具代码,全部适配浏览器/Node.js/Vue/React 所有前端项目,开箱即用,建议收藏!

一、字符串操作(最高频)

所有方法默认空值兜底,防止传参 undefined 导致代码报错

1. 字符串首字母大写

const capitalize = (str = '') => str.charAt(0).toUpperCase() + str.slice(1);

2. 反转字符串

const reverseString = (str = '') => str.split('').reverse().join('');

3. 判断字符串是否为回文

const isPalindrome = (str = '') => str === str.split('').reverse().join('');

二、数组操作

1. 数组扁平化一层

const flatArr = arr => arr.flat(1);

2. 移除数组所有假值

自动过滤:false、0、空字符串、null、undefined、NaN

const removeFalsy = arr => arr.filter(Boolean);

3. 快速生成 0-99 连续数组

const createArr = () => Array.from({length: 100}, (_, i) => i);

4. 随机打乱数组(标准洗牌算法)

Fisher–Yates 算法

const shuffleArr = arr => {
  const list = [...arr];
  for (let i = list.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [list[i], list[j]] = [list[j], list[i]];
  }
  return list;
};

5. 基础数组去重

const uniqueArr = arr => [...new Set(arr)];

6. 对象数组根据指定字段去重

const uniqueByKey = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];

7. 获取多个数组交集

const getIntersection = (a = [], ...arr) => [...new Set(a)].filter(v => arr.every(b => b.includes(v)));

8. 查找数组最大值索引

const maxIndex = (arr = []) => arr.length ? arr.indexOf(Math.max(...arr)) : -1;

9. 查找数组最小值索引

const minIndex = (arr = []) => arr.length ? arr.indexOf(Math.min(...arr)) : -1;

10. 找到数组中最接近指定数字的值

const closestNum = (arr = [], n = 0) => arr.reduce((a, b) => Math.abs(b - n) < Math.abs(a - n) ? b : a);

11. 多个数组合并为二维数组

const merge2D = (...arrList) => [...arrList];

12. 矩阵行列转置

const transpose = (matrix = []) => matrix[0]?.map((_, i) => matrix.map(row => row[i])) ?? [];

三、数制转换

原生 API 一行搞定,无需手写复杂计算公式

1. 十进制转换为任意 n 进制

const decToBase = (num = 0, base = 10) => num.toString(base);

2. 任意 n 进制转换为十进制

const baseToDec = (str = '', base = 10) => parseInt(str, base);

四、正则与文本处理

全部增加异常捕获,适配不规则入参

1. 从URL中提取域名

const getDomain = (url = '') => {
  try { return new URL(url).hostname; } catch { return ''; }
};

2. 验证电子邮箱格式

const isEmail = (mail = '') => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(mail);

3. 移除文本所有多余空格

const trimAll = (str = '') => str.replace(/\s+/g, ' ').trim();

五、浏览器原生 Web 操作

零框架依赖,兼容所有现代浏览器

1. 重新加载当前页面

const reloadPage = () => location.reload();

2. 平滑滚动到页面顶部

const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });

3. 平滑滚动到指定元素

const scrollToEl = (el) => el?.scrollIntoView({ behavior: 'smooth' });

4. 检测当前浏览器是否为IE

const isIE = () => !!window.ActiveXObject || /msie|trident/i.test(navigator.userAgent);

5. 移除文本中所有 HTML 标签

const stripHtml = (html = '') => html.replace(/<[^>]*>/g, '');

6. 页面重定向跳转

const redirect = (url = '') => location.href = url;

7. 一键复制文本到剪贴板

const copyText = async (text = '') => {
  try { await navigator.clipboard.writeText(text); return true; } 
  catch { return false; }
};

六、日期时间处理(重点修复时区BUG)

1. 判断日期是否为今天

const isToday = (date) => {
  const d1 = new Date(date);
  const d2 = new Date();
  return d1.getFullYear() === d2.getFullYear() &&
         d1.getMonth() === d2.getMonth() &&
         d1.getDate() === d2.getDate();
};

2. 日期转为标准 YYYY-MM-DD

const formatDate = (date = new Date()) => {
  const d = new Date(date);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
};

3. 秒数转为 hh:mm:ss 时长格式

const secToTime = (s = 0) => {
  const t = Math.floor(s);
  const h = String(Math.floor(t / 3600)).padStart(2, '0');
  const m = String(Math.floor((t % 3600) / 60)).padStart(2, '0');
  const ss = String(t % 60).padStart(2, '0');
  return `${h}:${m}:${ss}`;
};

4. 获取指定年月的第一天

const firstDay = (y, m) => new Date(y, m - 1, 1);

5. 获取指定年月的最后一天

const lastDay = (y, m) => new Date(y, m, 0);

七、函数相关操作

1. 判断是否为异步 async 函数

const isAsyncFn = (fn) => fn?.constructor.name === 'AsyncFunction';

八、数字精度处理(金额展示必备)

专门用于前端金额、小数展示,精准可控

1. 截断小数(不四舍五入)

const toFixedFloor = (num = 0, len = 2) => Math.trunc(num * Math.pow(10, len)) / Math.pow(10, len);

2. 截断小数(自动四舍五入)

const toFixedRound = (num = 0, len = 2) => Number(num.toFixed(len));

3. 数字前置补零

const padNum = (num = 0, len = 2) => num.toString().padStart(len, '0');

九、对象常用操作(接口数据清洗神器)

1. 清除对象 null、undefined 空属性

const cleanObj = (obj = {}) => Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null));

2. 交换对象键值

const invertObj = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));

3. JSON 字符串转对象

增加异常捕获,非法字符串不报错

const strToObj = (str = '') => {
  try { return JSON.parse(str); } catch { return null; }
};

4. 生产级对象深度对比(重点推荐)

避坑说明: 网上主流的 JSON.stringify 对比方式存在大量BUG,键顺序、undefined、NaN、日期都会对比失效。以下是轻量递归深对比方案,生产稳定可用

const deepEqual = (a, b) => {
  if (a === b) return true;
  if (!(a && b) || typeof a !== typeof b) return false;
  if (typeof a !== 'object') return false;
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every(k => deepEqual(a[k], b[k]));
};

十、通用万能工具函数

1. 生成随机十六进制颜色

const randomColor = () => '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');

2. RGB 转 HEX

const rgbToHex = (r = 0, g = 0, b = 0) => '#' + [r, g, b].map(x => String(x.toString(16)).padStart(2, '0')).join('');

3. HEX 转 RGB

const hexToRgb = (hex = '') => {
  const h = hex.replace('#', '');
  return {
    r: parseInt(h.slice(0, 2), 16),
    g: parseInt(h.slice(2, 4), 16),
    b: parseInt(h.slice(4, 6), 16)
  };
};

4. 生成全局唯一 UUID

const getUUID = () => crypto.randomUUID();

5. 获取当前页面 Cookie

const getCookie = () => document.cookie;

6. 延迟等待函数

const wait = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));

写在最后

本文所有代码全部修复网络通用BUG,解决了市面上大部分前端工具合集存在的:时区错误、算法不均、空值报错、对象对比失效、浏览器报错等问题。

所有方法零第三方依赖、轻量简洁,兼容浏览器、Node.js、Vue、React、uniapp 等绝大部分前端项目。

日常开发中,大家可以将这些工具函数统一封装到项目的 utils.ts / utils.js 工具文件中,全局复用,彻底告别重复造轮子,大幅提升开发效率,写出更优雅、更健壮的业务代码。

文章干货满满,建议收藏+点赞,开发随时查阅!也欢迎各位大佬在评论区补充更多优质工具函数,一起交流精进✨

React 常用 Hooks 函数及使用方法完全指南(useState / useEffect / useRef / useContext / useCallback / useMemo / useReducer)

作者 玖玖passion
2026年4月26日 12:54

前言

React Hooks 自 React 16.8 引入以来,已经彻底改变了我们编写 React 组件的方式。Hooks 让我们在函数组件中使用状态和生命周期能力,告别了类组件的繁琐写法。本文将从最常用的几个 Hook 入手,详细介绍它们的使用方法、最佳实践和常见陷阱。

如果你刚开始接触 React Hooks,或者想系统地梳理一遍常用 Hook 的用法,这篇文章应该能帮到你。


一、useState — 组件状态管理

useState 是最基础、最常用的 Hook,用于在函数组件中声明和管理状态。

基本用法

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

useState 接收一个初始值,返回一个长度为 2 的数组:当前状态更新状态的函数

更新状态的两种方式

方式一:直接传入新值

setCount(count + 1);

方式二:传入更新函数(推荐,当新值依赖旧值时)

setCount(prev => prev + 1);

第二种方式可以避免闭包陷阱。如果你的 setCount 在异步回调中调用,使用函数式更新能确保拿到最新的状态值。

状态更新的异步性

很多人刚接触时会被这个问题困扰:setState 之后立刻读取状态,发现值没变。

const [count, setCount] = useState(0);

setCount(1);
console.log(count); // 还是 0,不是 1

这是因为 React 的状态更新是异步且批量的。实际的重新渲染会在当前事件循环结束后进行。

复杂状态:多个字段

如果状态是一个对象,更新时要手动合并:

const [form, setForm] = useState({ name: '', age: 0 });

// 错误:会丢失 name 字段
setForm({ age: 18 });

// 正确:需要手动合并
setForm(prev => ({ ...prev, age: 18 }));

💡 如果你的状态逻辑比较复杂(多个子字段、相互依赖),考虑用 useReducer 替代。


二、useEffect — 副作用处理

useEffect 用于处理组件中的副作用:数据请求、DOM 操作、订阅、计时器等。

基本用法

import { useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // 依赖项

  return <div>{user?.name}</div>;
}

理解依赖数组

依赖数组是 useEffect 的灵魂,它决定了 effect 何时执行:

依赖数组 执行时机
undefined (不传) 每次渲染后执行
[] (空数组) 只在挂载后执行一次
[a, b] 当 a 或 b 发生变化时执行

清理函数

如果 effect 产生了订阅、计时器等需要清理的资源,返回一个清理函数:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);

  return () => {
    clearInterval(timer); // 组件卸载时清理
  };
}, []);

这里的 return () => clearInterval(timer) 就是清理函数,会在组件卸载时执行,防止内存泄漏。

常见陷阱

陷阱一:忘记添加依赖

useEffect(() => {
  fetch(`/api/users/${userId}`).then(...)
}, []); // ❌ userId 变了也不会重新请求

陷阱二:不必要的依赖导致死循环

useEffect(() => {
  setCount(count + 1); // ❌ count 变化 → 重新渲染 → effect 触发 → count 变化 → 死循环
}, [count]);

陷阱三:在 effect 中使用旧值(闭包问题)

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ count 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 没有依赖 count
}

解决方案:使用函数式更新 setCount(c => c + 1),或者把 count 加入依赖数组并清理/重建定时器。

useEffect 与 useLayoutEffect 的区别

  • useEffect异步执行,在浏览器绘制之后触发。适合数据请求、事件绑定等不需要阻塞视觉更新的操作。
  • useLayoutEffect同步执行,在 DOM 更新后、浏览器绘制前触发。适合需要读取 DOM 布局的场景(如测量元素尺寸)。

绝大多数情况下用 useEffect 就够了,只有当你遇到闪烁(flicker)问题时才考虑使用 useLayoutEffect


三、useRef — 引用 DOM 和可变值

useRef 有两个主要用途:引用 DOM 元素存储可变值(不触发重新渲染)。

引用 DOM 元素

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus(); // 组件挂载后自动聚焦
  }, []);

  return <input ref={inputRef} />;
}

存储可变值(改变不触发重渲染)

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 始终保持 ref 与 state 同步
  countRef.current = count;

  useEffect(() => {
    const id = setInterval(() => {
      console.log('当前 count:', countRef.current); // 能拿到最新值
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖,定时器只创建一次
}

这个模式常用于解决闭包陷阱——ref.current 永远指向最新的值,因为它是一个可变对象。

useRef vs useState 的关键区别

特性 useState useRef
修改触发重渲染 ✅ 是 ❌ 否
跨渲染周期保存数据 ✅ 是 ✅ 是
在异步回调中获取最新值 ❌ 闭包问题 ✅ 始终最新
修改方式 setState(newVal) ref.current = newVal

四、useContext — 跨组件数据共享

useContext 让你在不使用 props 层层传递的情况下,在组件树中共享数据。

三步使用法

第一步:创建 Context

import { createContext } from 'react';

const ThemeContext = createContext('light');

第二步:使用 Provider 提供数据

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <ThemedComponent />
    </ThemeContext.Provider>
  );
}

第三步:子组件消费数据

import { useContext } from 'react';

function ThemedComponent() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <div className={theme}>
      当前主题:{theme}
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </div>
  );
}

性能注意点

当 Provider 的 value 发生变化时,所有使用 useContext 的子组件都会重新渲染。如果 value 是一个对象,每次父组件渲染都会创建新引用,导致所有消费者重渲染。

解决方案:用 useMemo 包裹 value,或者将 Context 拆分为多个(读写分离)。

// 分离读和写,避免不必要的重渲染
const ThemeContext = createContext('light');
const ThemeUpdateContext = createContext(() => {});

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = useCallback(
    () => setTheme(t => (t === 'light' ? 'dark' : 'light')),
    []
  );

  return (
    <ThemeContext.Provider value={theme}>
      <ThemeUpdateContext.Provider value={toggleTheme}>
        {children}
      </ThemeUpdateContext.Provider>
    </ThemeContext.Provider>
  );
}

五、useCallback — 缓存函数引用

useCallback 用于缓存函数的引用,避免因函数重新创建导致子组件不必要的重新渲染。

import { useCallback } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  // 每次 Parent 渲染都会创建新的函数引用
  const handleClick = () => setCount(c => c + 1);

  // useCallback 缓存函数,只有依赖变化时才重建
  const handleClickCached = useCallback(
    () => setCount(c => c + 1),
    []
  );

  return <ExpensiveChild onClick={handleClickCached} />;
}

什么时候用 useCallback

不是所有函数都需要包裹 useCallback。过度使用反而会降低可读性和性能(因为 useCallback 本身也有开销)。

适合的场景:

  • 函数作为 props 传给使用了 React.memo 的子组件
  • 函数作为其他 Hook 的依赖项(比如 useEffect 的依赖)
  • 函数在自定义 Hook 中返回给外部使用

💡 一句话法则:当你确定不缓存会导致不必要的性能问题时再用。初期先正常写函数,遇到性能瓶颈再优化。


六、useMemo — 缓存计算结果

useMemo 用于缓存复杂计算的结果,避免每次渲染都重复执行。

import { useMemo } from 'react';

function Dashboard({ transactions }) {
  // 复杂计算:过滤 + 聚合
  const summary = useMemo(() => {
    return transactions
      .filter(t => t.amount > 0)
      .reduce((acc, t) => ({
        total: acc.total + t.amount,
        count: acc.count + 1,
        avg: (acc.total + t.amount) / (acc.count + 1)
      }), { total: 0, count: 0, avg: 0 });
  }, [transactions]);

  return <div>总金额:{summary.total},平均:{summary.avg}</div>;
}

useMemo vs useCallback

  • useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • useCallback 缓存的是函数本身
  • useMemo 缓存的是计算的结果

不要滥用 useMemo

useCallback 一样,useMemo 也不是免费的。简单的计算(如数组 map、filter)其开销可能还不如 useMemo 的对比开销大。

适合 useMemo 的场景:

  • 计算复杂度较高(O(n²) 及以上)
  • 计算结果作为 props 传给 React.memo 子组件
  • 计算结果作为其他 Hook 的依赖项

七、useReducer — 复杂状态管理

当状态逻辑变得复杂(多个子值、相互依赖、多层次更新),useState 就不太够用了。这时 useReducer 是更好的选择。

import { useReducer } from 'react';

// 1. 定义 reducer 函数
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now(), text: action.payload, done: false }];
    case 'TOGGLE':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      );
    case 'DELETE':
      return state.filter(t => t.id !== action.payload);
    default:
      return state;
  }
}

function TodoApp() {
  // 2. 使用 useReducer
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD', payload: '新任务' })}>
        添加
      </button>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: 'TOGGLE', payload: todo.id })}>
            完成
          </button>
        </div>
      ))}
    </div>
  );
}

useReducer vs useState 的选择

场景 推荐
独立、简单的状态 useState
包含多个子值的复杂状态 useReducer
下一个状态依赖前一个 useReducer(或函数式 setState)
更新逻辑在组件外可独立测试 useReducer
只需浅层更新表单字段 useState

八、自定义 Hooks — 逻辑复用

自定义 Hook 是 React Hooks 的精髓之一。当你发现多个组件中有相似的逻辑时,可以提取成一个自定义 Hook。

import { useState, useEffect } from 'react';

// 自定义 Hook:获取数据
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    setLoading(true);
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('请求失败');
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => { cancelled = true; }; // 清理:防止竞态
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserList() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了:{error.message}</div>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

自定义 Hook 的命名规则

  • 必须以 use 开头(React 官方约定,linter 也会检查)
  • 内部可以调用其他 Hook
  • 普通函数不能调用 Hook,但自定义 Hook 可以

九、Hooks 使用规则

最后,牢记 React Hooks 的两条铁律:

规则一:只在顶层调用 Hooks

不要在循环、条件语句或嵌套函数中调用 Hook。

// ❌ 错误:在条件中调用
if (isLoading) {
  useEffect(() => { ... }, []);
}

// ✅ 正确:始终在顶层
useEffect(() => {
  if (!isLoading) { ... }
}, [isLoading]);

规则二:只在 React 函数中调用 Hooks

  • 在函数组件中调用 ✅
  • 在自定义 Hook 中调用 ✅
  • 在普通函数中调用 ❌
  • 在类组件中调用 ❌
  • 在回调中调用 ❌

总结

本文介绍了 React 中最常用的 7 个核心 Hook:

Hook 用途
useState 声明和管理组件状态
useEffect 处理副作用(请求、订阅、DOM 操作)
useRef 引用 DOM 元素、存储可变值
useContext 跨组件层级共享数据
useCallback 缓存函数引用
useMemo 缓存计算结果
useReducer 管理复杂状态逻辑

记住:Hooks 是工具,不是目的。不要在不需要的地方强行使用性能优化 Hook(useCallback、useMemo),先写出清晰的代码,遇到性能问题再针对性地优化。

希望这篇文章能帮你更好地理解和运用 React Hooks。有什么问题欢迎在评论区交流讨论~

Openclaw 快速接入 DeepSeek V4 Pro 指南

作者 VagueVibes
2026年4月26日 05:19

DeepSeek v4 重磅发布,博查 Model API 在首发当日便已支持v4 全系的调用,那么如何在 OpenClaw 平台中通过修改配置文件接入博查 Model API 以使用 DeepSeek V4 系列模型?

主要步骤包括定位 openclaw.json 文件、添加自定义 Provider 配置、设置默认调用模型以及重启网关验证。配置过程中需注意 contextWindow 参数调整及 api 协议凭证的正确填写,同时确保 API Key 安全。

0 前置准备

  1. 已安装 OpenClaw(版本 ≥ 1.8.0,低于该版本请先执行 brew upgrade openclaw 升级);如果尚未安装,可以查看新手快速安装入门步骤。
  2. 已获取博查 API Key;(获取地址 open.bocha.cn)
  3. 熟悉基础的 JSON 语法。

1 新手快速安装入门

  1. 安装 OpenClaw
# macOS / Linux
curl -fsSL https://openclaw.ai/install.sh | bash

# Windows(PowerShell)
iwr -useb https://openclaw.ai/install.ps1 | iex

001.PNG

  1. 运行配置引导
openclaw onboard --install-daemon

选择自定义模型供应商 Custom Provider

002.png

配置信息如下:

# Model/auth provider
Custom Provider

# API Base URL
https://api.bocha.cn/v1

# How do you want to provide this API key?
Paste API key now

# API Key (leave blank if not required)
sk-******

# Endpoint compatibility
Anthropic-compatible

# Model ID
deepseek-v4-pro

# Verification successful.

# Endpoint ID
custom-api-bocha-cn

# Model alias (optional)
deepseek-v4-pro

003.png

2 更新模型配置

配置文件定位

安装完 OpenClaw 后,其所有模型、渠道配置都集中在 openclaw.json 文件中,不同操作系统的默认路径如下:

操作系统 配置文件默认路径 快速打开方式
Windows C:\Users<你的用户名>.openclaw\openclaw.json 按 Win+R,输入路径直接跳转
macOS ~/.openclaw/openclaw.json 终端执行 open ~/.openclaw/openclaw.json
Linux ~/.openclaw/openclaw.json 终端执行 vim ~/.openclaw/openclaw.json

提示:如果找不到文件,先执行 openclaw init 初始化配置(执行后会自动生成配置文件)。

找到对应的 openclaw.json 配置文件,变更配置如下:

{
  "agents": {
    "defaults": {
      "models": {
        "custom-api-bocha-cn/deepseek-v4-pro": {
          "alias": "deepseek-v4-pro"
        }
      },
      "model": {
        "primary": "custom-api-bocha-cn/deepseek-v4-pro"
      }
    }
  },
  "tools": {
    "profile": "coding"
  },
  "models": {
    "mode": "merge",
    "providers": {
      "custom-api-bocha-cn": {
        "baseUrl": "https://api.bocha.cn/v1",
        "api": "anthropic-messages",
        "apiKey": "sk-******",
        "models": [
          {
            "id": "deepseek-v4-pro",
            "name": "deepseek-v4-pro (Custom Provider)",
            "api": "anthropic-messages",
            "baseUrl": "https://api.bocha.cn/v1",
            "reasoning": false,
            "input": [
              "text"
            ],
            "cost": {
              "input": 0,
              "output": 0,
              "cacheRead": 0,
              "cacheWrite": 0
            },
            "contextWindow": 1000000,
            "maxTokens": 384000
          }
        ]
      }
    }
  }
}

重要参数:

contextWindow 1000000 上下文窗口:1M tokens(V4 全系)

maxTokens 384000 最大输出:384K tokens(官方上限)

保存并验证

完成以上修改后,保存文件。建议先重启 OpenClaw 网关让配置生效。

  1. 重启 OpenClaw 网关:
openclaw gateway restart
  1. 验证模型挂载状态:
openclaw models status

在 Web UI 聊天中,输入一条消息测试模型:

004.png

或者在命令行输入 openclaw chat,再与 agent 对话:

005.png

006.png

记录一下自动化构建中 SSE 与子进程管理的三个坑

作者 heyCHEEMS
2026年4月25日 23:57

最近在写一个博客后台管理系统的轻量自动化部署接口,用 SSE 来流传输给前端打印实时构建日志,简单记录一下遇到的最主要的三个坑。

坑一:子进程杀不死

在 Node.js 中,我们习惯用 childProcess.kill()。但在运行 pnpm build 这种命令时,它会衍生出一大堆子进程(如 Vite 或 Webpack)。如果你只杀掉父进程,那些构建进程就会变成“孤儿进程”继续运行。

解决办法是 在 Windows 下要用 taskkill 配合 /T 参数杀掉整棵进程树,在 Linux 下则要开启 detached 模式并用负数 PID 来杀掉整个进程组。

这里涉及到了进程树与进程组,操作系统中,程序启动另一个程序即为父子关系。Linux 的 detached 模式相当于让子进程自立门户当“组长”,通过组 ID 即可实现“一锅端”。

// 后端执行终止子进程
stopProcess() {
    if (this.childProcess && this.childProcess.pid) {
        const pid = this.childProcess.pid;
        if (this.isWindows) {
            // Windows 通过 /T 杀掉子进程树,/F 强制终止
            exec(`taskkill /PID ${pid} /T /F`);
        } else {
            // Linux 或 Mac 通过负数 PID 杀死整个进程组
            process.kill(-pid, 'SIGKILL');
        }
        this.childProcess.removeAllListeners();
        this.childProcess = null;
    }
}

坑二:SSE 切换页面自动重连

当你开启构建后切换到其他标签页,浏览器为了节能会挂起网络请求。等你切回来时,浏览器发现连接断开并自动尝试重连。由于后端为了防止并发给任务加了锁,重连请求撞上正在运行的任务,后端就返回了错误。

fetch-event-source 库提供了一个参数叫 openWhenHidden,把它设为 true,就能绕过浏览器的节能限制,当浏览器最小化或切换到其它标签页时也保持连接。

// 前端请求配置
await fetchEventSource('/sse-api/deploy', {
    method: 'POST',
    openWhenHidden: true, // 切换标签页时不中断连接
    onmessage(ev) {
        const data = JSON.parse(ev.data);
        setLog(prev => prev + data.log);
    },
    onerror(err) {
        if (err.code === 409) {
            message.warning('后台已有任务在运行');
        }
    }
});

坑三:原生 API 的局限性

很多人觉得原生 EventSource 更轻量,但它其实是个黑盒:原生的 EventSource 默认不支持在请求中添加自定义请求头(如 Authorization)。如果你的博客后台接口需要 Token 验证,原生 API 只能被迫将 Token 挂在 URL 参数里。这会让 Token 暴露在服务器日志中,还显得非常不专业。

原生 SSE 强制要求必须是 GET 请求,但如果我们需要向后端发送一些复杂数据时,把这些东西塞进 URL 参数里既臃肿又不安全。用 Fetch 模拟 SSE,就可以轻松发起 POST 请求,把参数优雅地放在 Request Body 里。

原生 API 一旦断开,会按照浏览器内置的逻辑盲目重连。而 Fetch 模式配合 AbortController,可以让我们精准控制,什么时候该彻底断开,什么时候该带着上一次的 Last-Event-ID 重新寻找断点。

在 Node.js 文件上传中集成 ClamAV 扫描

作者 SonoTommy
2026年4月25日 23:09

文件上传是常见的攻击面。用户上传的文件可能包含恶意软件、ZIP 炸弹或 伪造的 MIME 类型。大多数 Node.js 项目只做扩展名检查,这远远不够。

pompelmi 是一个 Node.js 库,在文件落盘之前完成扫描,返回类型化的 verdict symbol,不依赖任何第三方运行时。

GitHub: github.com/pompelmi/po…


工作原理

  1. 验证参数是否为字符串,文件是否存在
  2. 通过 child_process 调用 clamscan,读取退出码
  3. 将退出码映射为 Symbol

没有 stdout 解析,没有正则,没有隐式状态。


安装

需要 Node.js 和 ClamAV。

npm install pompelmi

安装 ClamAV:

# macOS
brew install clamav && freshclam

# Debian / Ubuntu
sudo apt-get install -y clamav clamav-daemon && sudo freshclam

# Windows
choco install clamav -y

基本用法

const { scan, Verdict } = require('pompelmi');

const result = await scan('/path/to/file.zip');

switch (result) {
  case Verdict.Clean:
    // 文件安全,继续处理
    break;
  case Verdict.Malicious:
    throw new Error('检测到恶意软件,文件已拒绝');
  case Verdict.ScanError:
    // 扫描未完成,按不可信文件处理
    console.warn('扫描失败,拒绝文件');
    break;
}

返回值:

结果 ClamAV 退出码 含义
Verdict.Clean 0 未发现威胁
Verdict.Malicious 1 匹配到已知病毒签名
Verdict.ScanError 2 扫描本身失败,文件状态未知

在 Express 中集成

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs   = require('fs');

const app    = express();
const upload = multer({ dest: 'tmp/' });

app.post('/upload', upload.single('file'), async (req, res) => {
  const filePath = path.resolve(req.file.path);

  try {
    const result = await scan(filePath);

    if (result === Verdict.Malicious) {
      fs.unlinkSync(filePath);
      return res.status(422).json({ error: '文件包含恶意软件' });
    }

    if (result === Verdict.ScanError) {
      fs.unlinkSync(filePath);
      return res.status(422).json({ error: '扫描失败,文件已拒绝' });
    }

    // Verdict.Clean — 继续保存文件
    return res.status(200).json({ verdict: 'clean' });

  } catch (err) {
    fs.unlinkSync(filePath);
    return res.status(500).json({ error: err.message });
  }
});

远程扫描(Docker)

如果 ClamAV 运行在容器中,通过 TCP socket 连接:

const result = await scan('/path/to/file.zip', {
  host: '127.0.0.1',
  port: 3310,
});

API 保持不变,verdict 类型不变。


错误处理

try {
  const result = await scan(path.resolve(filePath));
  return result;
} catch (err) {
  // filePath 不是字符串      → 'filePath must be a string'
  // 文件不存在               → 'File not found: <path>'
  // clamscan 不在 PATH 中    → ENOENT
  // 未知退出码               → 'Unexpected exit code: N'
  console.error('扫描异常:', err.message);
  return null;
}

特性

  • 零运行时依赖,仅使用 Node.js 内置 child_process
  • 不解析 stdout,直接读取退出码
  • 支持 TypeScript,verdict 为 Symbol 类型,防止拼写错误
  • 支持本地 clamscan 和远程 clamd TCP socket
  • 跨平台:macOS、Linux、Windows

相关链接

事件循环(Event Loop)

作者 薯老板
2026年4月25日 10:25

一、什么是Event Loop🤔

JavaScript是一门单线程语言,它一个时间只能做一件事;

在js代码运行的时候,会形成一个执行栈,每调用一个函数,就会把该函数的执行上下文放入执行栈;

另外还有任务队列存放待完成的任务,在js中,有同步任务异步任务,异步任务又分为宏任务微任务

因为script代码整体就是一个宏任务,因此事件循环先从一个宏任务开始,当执行完宏任务后,就从微任务队列拿微任务到执行栈中执行,当前微任务队列所有微任务执行完后,就执行宏任务队列的下一个宏任务;

然后重复这个操作,直到任务队列里的任务执行完,这个过程就叫事件循环。

二、宏任务和微任务📜

JavaScript 的事件分两种,宏任务(macro-task)和微任务(micro-task)。

1. 宏任务

  • script
  • setTimeout
  • setInterval
  • setImmediate

2. 微任务

  • promise.then() promise.catch() 的回调cb
  • async await
  • mutationObserver ==> 监听DOM的改变
  • process.nextTick() ==> Node

关系: 微任务是包含在宏任务里面的,一个宏任务中,可以有多个微任务。

3. 执行顺序

  • 首先script代码块可以看做第一个宏任务,开始第一个Tick事件循环
  • 会先执行script代码块中的同步代码
  • 如果遇到宏任务,就放到宏任务队列中等待执行, 如果遇到微任务,放到微任务队列中
  • 当主线程执行完同步代码的时候,首先去微任务队列中清空当前事件循环的所有微任务(本轮事件循环Tick结束)
  • 如果还有异步的宏任务,那么就会进行循环执行上述的操作

三、来点例子🌰

eg1:

const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve(5);
    console.log(2);
}).then(val => {
    console.log(val);
});

promise.then(() => {
    console.log(3);
});

console.log(4);

setTimeout(function() {
    console.log(6);
});

// 1  2  4  5  3  6

执行结果为: 1 2 4 5 3 6

event1.png

eg2:

async function async1() {
    console.log('async1 start')  
    await async2()      // await这一行,这个async2 同步执行的; await的下面,放到微任务队列
    console.log('async1 end') // 这一行,相当于放到.then()中  微1 
    console.log(666)
}
async function async2() {
    console.log('async2')
}

console.log('script start')  

setTimeout(function() {
    console.log('setTimeout') // 放到宏任务队列中 宏1 
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1')
    resolve();
}).then(function() {
    console.log('promise2')  // 微任务  微2 
});
console.log('script end')

// script start 
// async1 start
// async2
// promise1
// script end
// async1 end
// 666
// promise2  (本轮tick结束)
// setTimeout

执行结果为:

event2.png

event3.png

✨✨✨✨✨✨✨✨✨✨

在本轮事件循环中的微任务比下一次的事件循环中的宏任务优先级高,也就是我们要将这轮的微任务都执行完毕,才能去执行下一个宏任务。

Node.js技术周刊 2026年第17周

2026年4月25日 16:28

TypeScript 7.0 Beta 发布(Go驱动,性能提升10倍),Node.js LTS和Current双版本更新,Axios供应链攻击事件引发安全关注。

🔥 头条

aube:新的 Node.js 包管理器

又一个包管理器!值得关注的是它来自 mise 的开发者,mise 是一款让多语言管理变得轻松的工具。aube 的卖点……

📖 文章

编写更好的提示词

跟随 GitHub 的 Sabrina Goldfarb 学习这门详细的视频课程,掌握用 AI 生成更高质量代码的实用提示技巧。

使用 .NET Native AOT 编写 Node.js 插件

现在可以用 C# 等 .NET 语言编写原生 Node 插件了。Native AOT 将程序编译为共享库(或可执行文件),可暴露 N-API……

TypeScript 7.0 Beta:快10倍的 TypeScript 编译器

Go 驱动的原生移植版首次发布 beta,号称"约10倍性能提升"。TypeScript 6.0 作为过渡版本仍然重要……

构建生产环境稳定的 AI Agent(网络研讨会)

学习开源4层架构,使用 Agentspan 跨 LangGraph、OpenAI 和 Google SDK 运行可靠的 AI Agent。

Optique 1.0:类型安全的组合式 CLI 解析器

构建可组合的 CLI 解析器,具备类型安全、类型推断、内置 shell 补全支持,以及配置文件集成和 man 页面生成……

DocMD:从 Markdown 构建生产级文档站点

基于 Node 的零配置文档站点生成器,专注于生成精简快速的输出。支持 i18n 和……

rocksdb-js:Node 的新 RocksDB 绑定

近年来从 Node 使用 Facebook 的 RocksDB 键值存储一直比较混乱,现在终于有了一个现代的原生插件。GitHub……

别再为监控一个 Node 应用折腾5个工具

错误、性能、日志、可用性、主机指标——AppSignal 为你的 Node.js 技术栈一站式搞定。自动插桩 Express、Koa、Prisma 和 BullM……

OWASP NPM 安全最佳实践速查表

持续更新的长期资源,仍然是实用的检查清单。近期更新涉及禁用生命周期脚本、防 typosquatting……

嵌套 Promise 的用途

James 重新审视了2013年 Promises/A+ 单子辩论,因遇到一个实际并发问题而改变了看法。有深度但值得读。

你已经有了 Postgres,何不让它处理分析?

TimescaleDB 添加了超表、95% 压缩率和持续聚合。无需第二个数据库,无需数据管道。免费试用。

你不必参加全部44场 Postgres 演讲

POSETTE: An Event for Postgres 2026 是一场免费虚拟开发者活动,6月16-18日举行。44场演讲将直播并在之后提供回放。

你无法取消 Promise(除非某些时候可以)

你无法取消 Promise,但可以通过让 async 函数 await 一个永远不 resolve 的 Promise 来中止它。函数会静默停止,GC 可以……

Node 安全漏洞赏金计划因失去资金而暂停

自2016年以来,Node.js 项目一直为符合条件的安全漏洞报告提供赏金。该计划由 Internet Bug Bounty 项目资助……

tsdown 现在可以为 Node 应用生成可执行文件

tsdown 是 VoidZero(Evan You 的公司)的库打包工具,现已支持使用 Node 的 Single Executable Application 功能构建独立可执行文件……

分析不需要独立的基础设施

TimescaleDB 扩展 Postgres 使分析可在实时数据上运行。同一连接,无需数据管道,无需第二个数据库。免费开始。

Marked.js 18.0:快速 Markdown 解析库

为速度而构建的底层 Markdown 编译器,可在客户端和服务端使用。v18 是一个 bug 修复版本……

Memetria K/V:高效的 Redis 和 Valkey 托管

Memetria K/V 为 Node.js 应用托管 Redis OSS 和 Valkey,具备大键追踪和详细分析功能。

Axios 被入侵事件的隐藏影响范围

你可能已经听说了本周通过 Axios 进行的供应链攻击(如果没有,请检查你是否受影响)。Ahmad 反思了攻击机制……

npm Workspaces 温和入门

使用 workspaces,你可以在一个仓库中管理多个包,并链接本地包使其可以按名称互相导入。npm 随后可以提升和去重……

在生产级保真沙箱中运行 Agent

Ox 为每个 Agent 任务启动沙箱。隔离的代码、计算和数据。以零影响范围对生产环境进行测试。

🛠 工具

Bun v1.3.13:更智能的测试与更低内存占用

该替代运行时增强了 bun test,新增测试环境隔离、并行化选项,以及仅运行受近期改动影响的测试……

Node 推进默认启用 Temporal API

Temporal API 旨在现代化 JavaScript 的日期时间处理,上月已达 Stage 4。Node 此前在等待 V8 默认启用它……

Node.js 24.15.0 (LTS) 发布

Node 的 LTS 版本从 v25 获得了一些新特性,包括 require(esm) 和模块编译缓存标记为稳定,以及 --max-heap-……

x-win:从 Node 检查打开和活动的窗口

获取 macOS、Linux 和 Windows 上打开窗口的位置、大小、应用图标和标题,以及其底层进程信息和内存使用……

Axios 被入侵事件的事后分析

Axios 团队分享了近期供应链被入侵的详细事后分析,攻击者通过恶意依赖注入了木马。该攻……

web-audio-api:在 Node 中使用 Web Audio API

在 Node 中获得完整的 Web Audio API 支持,可在本机播放音频或渲染到文件(Tone.js 也可用)。提供了大量示例……

Node.js 25.9.0 (Current) 发布

包括 --max-heap-size 选项用于设置进程最大堆大小,James Snell 的实验性"更好的流 API"实现作为……

node-re2:Google RE2 正则库的绑定

RE2 是一个线性时间匹配的正则表达式库,可免疫由回溯导致的 ReDoS 攻击。node-re2 将其作为近……

Defuddle:从网页提取主要内容

去除 HTML 中的杂乱内容,只保留主要内容供你使用。有在线演示可以试用。

关注微信公众号「右耳朵猫AI」获取更多资讯

Web3表单签名验证:我如何用 wagmi 和 siwe 让用户“无密码”登录

作者 竹林818
2026年4月25日 10:00

背景:用户提交地址,后端凭什么相信?

几个月前,我在做一个 DeFi 策略管理平台的前端。用户可以在上面创建“自动复投”策略,然后通过我们的合约执行。流程很简单:前端收集用户输入的策略参数(比如目标池地址、复投频率),然后调用合约。

但问题出在“用户身份”上。后端需要记录每个用户创建了哪些策略,但用户并没有注册流程,也没有密码。他们只是连接了钱包(MetaMask 或 WalletConnect),然后直接操作。后端收到的请求里,用户传一个 userAddress 字段,比如 0x1234...

我当时就想:这太不安全了。如果某个恶意用户伪造一个请求,把 userAddress 改成别人的地址,后端怎么知道这个地址真的是当前操作者?更糟的是,我们的后端还依赖这个地址来查询用户的历史数据,如果地址被篡改,数据就全乱了。

我需要一种方法:让后端能够验证“当前请求确实来自某个地址的持有者”,而且这个过程不能依赖密码,必须完全基于区块链钱包的签名机制。

问题分析:为什么简单的签名不行?

我最初的想法很简单:让前端用 ethers.js 对一段固定字符串签名,然后把签名和地址一起发给后端,后端用 ethers.utils.verifyMessage 验证。

// 最初的错误思路
const message = "I am the owner of this address";
const signature = await signer.signMessage(message);
// 然后发 { address, signature } 给后端

这看起来没问题,但实际跑起来就发现一堆坑:

  1. 重放攻击:如果签名被截获,攻击者可以重复使用这个签名来冒充用户。因为消息是固定的,签名永远有效。
  2. 过期问题:没有时间戳,后端不知道这个签名是什么时候签的。如果用户忘记断开连接,别人拿到这个签名可以一直用。
  3. 跨域问题:如果用户在不同 dApp 上签名了同样的消息,攻击者可以拿到签名后在我们的后端使用。

我当时就踩了这个坑:上线第一天,团队安全审计就说“这个方案不能上线,太脆弱了”。后来我才知道,社区早就有一个标准解决方案——EIP-4361,也就是“Sign-In with Ethereum”(SIWE)。

核心实现:用 siwe 构造防重放签名

SIWE 的核心思想是:把签名消息变成一个结构化的对象,包含 domain(域名)、uri(当前页面)、nonce(随机数)、issuedAt(签发时间)等字段。这样每个签名都是唯一的、有时效的、绑定到特定网站的。

我选择了 siwe 这个 npm 包,配合 wagmi v2 的 useSignMessage hook 来实现。

第一步:前端生成 nonce 并让用户签名

这里有个关键点:nonce 必须由后端生成,否则前端自己生成的 nonce 没有意义。所以我先向后端请求一个 nonce。

// 1. 从后端获取 nonce
const getNonce = async (): Promise<string> => {
  const res = await fetch('/api/auth/nonce');
  const data = await res.json();
  return data.nonce;
};

// 2. 构造 SIWE 消息
import { SiweMessage } from 'siwe';
import { useSignMessage, useAccount } from 'wagmi';

function LoginButton() {
  const { address, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();

  const handleLogin = async () => {
    if (!address || !chainId) return;

    // 注意:domain 必须和你的前端域名一致,否则验证会失败
    const domain = window.location.host;
    const origin = window.location.origin;

    const nonce = await getNonce();

    const siweMessage = new SiweMessage({
      domain,
      address,
      statement: 'Sign in to DeFi Dashboard to manage your strategies.',
      uri: origin,
      version: '1',
      chainId,
      nonce,
      issuedAt: new Date().toISOString(),
    });

    const message = siweMessage.prepareMessage();
    const signature = await signMessageAsync({ message });

    // 发送给后端验证
    const verifyRes = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });

    if (verifyRes.ok) {
      // 登录成功,后端返回一个 session token
      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
    }
  };

  return <button onClick={handleLogin}>Sign in with Ethereum</button>;
}

这里有个坑domain 字段必须精确匹配。我当时在本地开发时用的是 localhost:3000,但部署后域名变成了 app.example.com,结果生产环境一直报 Domain mismatch。后来我把 domain 从 window.location.host 获取,问题解决。

第二步:后端验证签名并创建 session

后端我用 Node.js + Express 实现,使用 siwe 包进行验证。验证通过后,我生成一个 JWT token 返回给前端,后续的 API 请求都带上这个 token。

// 后端:验证签名
import { SiweMessage } from 'siwe';
import express from 'express';

const app = express();
app.use(express.json());

// 存储 nonce(生产环境应该用 Redis)
const nonceStore: Set<string> = new Set();

// 生成 nonce 接口
app.get('/api/auth/nonce', (req, res) => {
  const nonce = generateRandomNonce(); // 使用 crypto.randomBytes 生成
  nonceStore.add(nonce);
  // 设置过期时间,比如 5 分钟
  setTimeout(() => nonceStore.delete(nonce), 5 * 60 * 1000);
  res.json({ nonce });
});

// 验证签名接口
app.post('/api/auth/verify', async (req, res) => {
  const { message, signature } = req.body;

  try {
    const siweMessage = new SiweMessage(message);
    const fields = await siweMessage.verify({
      signature,
      // 这里传入 nonce 是为了验证 nonce 是否有效
      nonce: siweMessage.nonce,
      // 这里传入 domain 是为了验证域名
      domain: siweMessage.domain,
    });

    // 验证成功后,从存储中删除 nonce,防止重放
    nonceStore.delete(siweMessage.nonce);

    // 生成 JWT token
    const token = jwt.sign(
      { address: fields.data.address, chainId: fields.data.chainId },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );

    res.json({ token });
  } catch (error) {
    console.error('Verification failed:', error);
    res.status(401).json({ error: 'Invalid signature' });
  }
});

注意这个细节siweMessage.verify 方法内部会自动检查 nonce、domain、过期时间等。如果 nonce 已经被使用过(比如重放攻击),就会抛出异常。我一开始没理解这个机制,以为需要手动检查,后来发现包已经帮我做了。

第三步:session 持久化与自动登录

用户每次刷新页面都要重新签名,体验很差。所以我用 JWT token 做 session 持久化。前端在初始化时检查 localStorage 中是否有 token,如果有就自动恢复登录状态。

// 封装一个 hook 来管理认证状态
import { useAccount, useDisconnect } from 'wagmi';
import { useState, useEffect } from 'react';

export function useAuth() {
  const { address, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  // 检查是否有有效的 token
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (token && address) {
      // 可以验证 token 是否过期(简单做法:解码 payload 检查 exp)
      setIsAuthenticated(true);
    }
  }, [address]);

  // 登出
  const logout = () => {
    localStorage.removeItem('auth_token');
    setIsAuthenticated(false);
    disconnect();
  };

  return { isAuthenticated, logout };
}

这里有个坑:JWT token 过期后,用户需要重新签名。我最初没有处理 token 过期的情况,结果用户操作到一半突然报 401 错误,体验非常糟糕。后来我加了一个“静默刷新”机制:在 API 请求拦截器中检查 token 是否即将过期,如果是,就弹出一个轻提示让用户重新签名。

完整代码:一个可运行的 React 组件

下面是一个完整的登录组件,包含签名验证和 session 管理。假设你已经配置好了 wagmi 的 provider。

// LoginWithSiwe.tsx
import { useState } from 'react';
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
import { SiweMessage } from 'siwe';
import { useAuth } from './useAuth';

export default function LoginWithSiwe() {
  const { address, isConnected, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { disconnect } = useDisconnect();
  const { isAuthenticated, logout } = useAuth();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleLogin = async () => {
    if (!address || !chainId) {
      setError('Please connect your wallet first');
      return;
    }

    setLoading(true);
    setError('');

    try {
      // 1. 获取 nonce
      const nonceRes = await fetch('/api/auth/nonce');
      const { nonce } = await nonceRes.json();

      // 2. 构造 SIWE 消息
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in to access your dashboard.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce,
        issuedAt: new Date().toISOString(),
      });

      // 3. 签名
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      // 4. 发送给后端验证
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: message.prepareMessage(),
          signature,
        }),
      });

      if (!verifyRes.ok) {
        throw new Error('Verification failed');
      }

      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
      // 触发状态更新
      window.location.reload();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div>
        <p>Logged in as: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }

  return (
    <div>
      {!isConnected ? (
        <p>Please connect your wallet first</p>
      ) : (
        <button onClick={handleLogin} disabled={loading}>
          {loading ? 'Signing...' : 'Sign in with Ethereum'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录

  1. Domain mismatch 错误:在本地开发时,domain 是 localhost:3000,但部署到生产环境后,domain 变成了 app.example.com。SIWE 验证要求 domain 必须精确匹配前端域名。解决方案:使用 window.location.host 动态获取 domain。

  2. Nonce already used 错误:第一次测试时,我在短时间内连续点击登录按钮,结果第二次签名时后端报错。原因是 nonce 只能使用一次,我忘记在验证成功后删除 nonce。解决方案:在验证成功后立即从存储中删除 nonce。

  3. 签名弹窗不显示:使用 wagmi 的 useSignMessage 时,如果用户已经连接了钱包,但 MetaMask 不弹出签名窗口。后来发现是因为我传入了 { message } 而不是 { message: siweMessage.prepareMessage() }prepareMessage() 方法会把结构化消息转成符合 EIP-4361 格式的字符串,MetaMask 才能正确识别。

  4. JWT token 过期后用户无感知:用户登录后,如果 token 过期了,API 请求会返回 401,但前端没有提示。解决方案:在 API 请求拦截器中检查 token 的 exp 字段,如果即将过期,提前弹出提示让用户重新签名。

小结

通过 EIP-4361 + SIWE,我成功实现了一套无需密码、基于钱包签名的身份认证方案。核心收获是:不要自己造轮子,社区标准方案(SIWE)已经解决了重放攻击、过期、跨域等问题。如果想继续深挖,可以研究 EIP-4361 的扩展(如 EIP-5573),或者结合 SIWE 实现更细粒度的权限控制。

昨天以前首页

Ant Design Table 横向滚动条神秘消失?我是如何一步步找到真凶的

2026年4月24日 17:17

起因

项目中有一个设备管理页面,使用了 Ant Design 的 Table 组件,配置了横向和纵向滚动:

<Table
  scroll={{
    x: "100%",
    y: "calc(100vh - 300px)",
  }}
  // ... 其他属性
/>

某天测试同学反馈了一个诡异的问题:表格的滚动条会莫名其妙地消失

更离谱的是,滚动条虽然看不见了,但鼠标放在原来滚动条的位置仍然可以拖动一个"隐形"的滚动条!


第一步:确认复现路径

首先,我需要搞清楚滚动条在什么情况下会消失。经过反复测试,终于找到了稳定的复现路径:

  1. 在标签页 A 中打开设备管理页面,Table 正常显示横向和纵向滚动条 ✅
  2. 点击某个设备进入详情页,右键点击二维码,在新标签页 B 中打开手机端页面
  3. 在标签页 B 中按 F12 打开开发者工具,切换视图之后
  4. 切回标签页 A → 滚动条消失了!

关键发现:问题只在"标签页 B 切换设备仿真"后才会出现。如果不切换设备仿真,滚动条一直正常。

这说明问题跟 Chrome DevTools 的设备仿真有关。但为什么呢?设备仿真只影响当前标签页 B,为什么会影响到标签页 A?


第二步:排除 CSS 原因

我的第一反应是:是不是 CSS 样式污染了?

项目里有一个 device-details-mgmt.css,里面用全局的 ::-webkit-scrollbar 把所有滚动条设成了 5px 宽、浅灰色:

::-webkit-scrollbar {
    width: 5px;
    height: 5px;
}
::-webkit-scrollbar-thumb {
    background: #c1c1c1;  /* 浅灰滑块 */
}
::-webkit-scrollbar-track {
    background: #f1f1f1;  /* 浅灰轨道 */
}

5px 宽 + 浅灰色,在浅色背景下确实不太看得清。我试着把这些样式限定到设备详情容器内,避免影响 Table。

结果:滚动条照样消失。

这说明 CSS 不是根因。但我还是不死心,又试了几种 CSS 方案:

尝试的方案 结果
overflow: scroll !important 强制显示滚动条 ❌ 无效
scrollbar-gutter: stable 保留滚动条空间 ❌ 无效
scrollbar-color + scrollbar-width 标准属性 ❌ 无效

所有 CSS 方案全部无效!

这让我意识到,问题不在 CSS 层面,而是更底层的原因。


第三步:排除 JS 原因

既然 CSS 搞不定,那是不是 JS 的问题?

我怀疑的方向有:

怀疑 1react-full-screen 组件的跨标签页事件干扰

设备详情页用了 react-full-screen,设备仿真可能触发了全屏变化事件。我在 onChange 中加了 document.fullscreenElement 检查,只允许当前标签页的全屏事件生效。

结果:无效。全屏事件根本没有被触发。

怀疑 2vh 单位被设备仿真重新计算

Table 的 scroll.y 用了 calc(100vh - 300px),设备仿真可能改变了 vh 的值。我改用 useRef + getBoundingClientRect() 动态计算高度。

结果:无效。高度计算完全正确,滚动条消失不是因为高度问题。

怀疑 3:标签页切回时需要强制重渲染

我监听了 visibilitychange 事件,当标签页重新可见时,通过临时切换 overflow 属性强制浏览器重新渲染滚动条。

结果:无效。重新渲染后滚动条仍然是透明的。

JS 方案也全部无效!


第四步:换个思路——为什么 B 标签页能影响 A 标签页?

CSS 和 JS 都试过了,问题依然存在。我不得不重新审视一个最基本的问题:

为什么标签页 B 的操作,能影响到标签页 A?

在正常的认知中,浏览器的每个标签页是相互隔离的。一个标签页的 JS、CSS、DOM 不应该影响另一个标签页。

但事实摆在眼前:B 的设备仿真确实影响了 A 的滚动条。

这说明 A 和 B 之间存在某种共享。那共享的是什么?


第五步:认识 Chrome 渲染进程

我开始研究 Chrome 的多进程架构,发现了一个关键知识点:

Chrome 会将具有 opener 关系的标签页分配到同一个渲染进程(Renderer Process)中。

什么是 opener 关系?当你用 window.open(url, '_blank') 打开新标签页时,新标签页可以通过 window.opener 访问原标签页。Chrome 为了性能优化,会将这样的两个标签页放在同一个渲染进程中。

而我们的代码正是这样写的:

// DownloadSvgQRCode.js
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode?device_id=${device_id}`,
  '_blank'
  // 没有第三个参数!
);

没有 noopener,所以 A 和 B 共享同一个渲染进程!


第六步:理解设备仿真对渲染进程的影响

那设备仿真又是怎么影响渲染进程的呢?

当你在 DevTools 中切换设备仿真时,Chrome 通过 CDP(Chrome DevTools Protocol) 发送命令:

Emulation.setScrollbarsHidden({ hidden: true })
Emulation.setDeviceMetricsOverride({ mobile: true, ... })

关键在于 setScrollbarsHidden——它的效果是修改渲染进程级别的滚动条模式,将经典滚动条(Classic Scrollbar)切换为覆盖式滚动条(Overlay Scrollbar)。

而 Overlay 滚动条的特点是:半透明、自动隐藏。这就是为什么滚动条看起来"消失"了,但拖动区域还在——滚动条其实还在,只是变成了透明的 overlay 模式!

因为 A 和 B 共享同一个渲染进程,所以 B 的设备仿真修改了进程级滚动条模式,A 也被影响了!


第七步:验证——noopener 分离渲染进程

既然根因是共享渲染进程,那解决方案就是让 A 和 B 使用独立的渲染进程

方法很简单:给 window.open 添加 noopener 参数:

// 修改前
window.open(url, '_blank');

// 修改后
window.open(url, '_blank', 'noopener');

noopener 做了两件事:

  1. 断开 opener 关系:新标签页的 window.opener 变为 null
  2. 强制分离渲染进程:Chrome 不再需要维护 opener 通信通道,新标签页被分配到独立渲染进程

修改后测试:✅ 问题完美解决! B 标签页的设备仿真不再影响 A 标签页的滚动条。


原因总结

用一张图说清楚整个因果链:

window.open('_blank') 没有加 noopener
        │
        ▼
AB 标签页建立 opener 关系
        │
        ▼
Chrome 将 AB 分配到同一个渲染进程
        │
        ▼
B 标签页切换设备仿真
        │
        ▼
CDP 发送 Emulation.setScrollbarsHidden({ hidden: true })
        │
        ▼
渲染进程级别的滚动条模式从 Classic 切换为 Overlay
        │
        ▼
A 标签页的滚动条也变成 Overlay 模式(半透明、自动隐藏)
        │
        ▼
A 标签页的滚动条"消失"了!

修复:添加 noopener,让 B 使用独立渲染进程,B 的设备仿真不再影响 A。


延伸知识

Chrome 渲染进程与标签页的关系

打开方式 是否共享渲染进程
window.open(url, '_blank') ✅ 共享(同一站点)
window.open(url, '_blank', 'noopener') ❌ 独立
用户手动 Ctrl+T 打开新标签页 ❌ 独立
从书签栏打开 ❌ 独立

两种滚动条模式的区别

Classic(经典) Overlay(覆盖式)
外观 始终可见 半透明,自动隐藏
布局 占据空间 浮在内容上方
CSS ::-webkit-scrollbar ✅ 有效 无效
scrollbar-gutter: stable ✅ 有效 无效
触发条件 桌面模式(默认) 移动端 / DevTools 设备仿真

CDP 命令的影响范围

CDP 命令 影响范围
Emulation.setDeviceMetricsOverride 仅当前标签页
Emulation.setScrollbarsHidden ⚠️ 整个渲染进程
Emulation.setTouchEmulationEnabled 仅当前标签页

如何确认标签页是否共享渲染进程

  • 方法 1:按 Shift+Esc 打开 Chrome 任务管理器,查看是否有多个标签页共用同一个进程 ID
  • 方法 2:地址栏输入 chrome://process-internals,查看每个标签页的进程信息
  • 方法 3:在 Console 中执行 console.log(window.opener),如果不为 null,说明可能共享渲染进程

最终修复

// DownloadSvgQRCode.js

// 修改前
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
  '_blank'
);

// 修改后 —— 只加了第三个参数 'noopener'
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
  '_blank',
  'noopener'
);

一行代码,问题解决。noopener 不仅是安全最佳实践(防止 tabnapping 攻击),还能避免渲染进程级别的副作用。

前端JavaScript:Object和Map及其区别是什么?

作者 淸湫
2026年4月24日 17:05

在 JavaScript 中,ObjectMap 都是用于存储键值对的数据结构。长期以来,开发者们习惯使用普通对象来处理映射关系,但随着 ES6 的到来,Map 的出现彻底改变了这一局面。你是否曾疑惑过,为什么明明对象也能存键值对,还要引入 Map?它们之间到底有什么区别?什么时候该用 Map,什么时候该用 Object?本文将从底层原理到实战应用,带你彻底搞懂这两个数据结构。

一、基础认知:从设计初衷说起

1.1 传统的 Object:为结构化数据而生

普通对象(Plain Object)是 JavaScript 中最基础的数据结构之一,它的设计初衷是用来表示一个 “实体” 或 “结构化数据”。比如:

const user = {
  name: '张三',
  age: 25,
  city: '北京'
};

在这个例子中,user 代表了一个用户实体,它的键是固定的字符串,值是对应的属性。这种场景下,Object 非常直观,我们可以通过 . 操作符快速访问属性。

1.2 现代的 Map:为通用映射而生

Map 是 ES6 引入的新数据结构,它的设计目标是成为一个通用的键值对映射容器。它不再局限于 “实体” 的概念,而是更像一个字典,允许你将任意类型的值映射到另一个值,无论键是什么类型。

const userMap = new Map();
userMap.set('name', '张三');
userMap.set({ id: 1 }, '用户详情'); // 直接用对象作为键

二、核心差异:8 个维度的全面对比

为了让你直观地看到两者的区别,我们先来看一张完整的特性对比表:

图 1:Map 与 Object 核心特性对比

接下来,我们深入解析这些差异。

2.1 键的类型:突破限制的灵活性

这是 Map 最核心的优势。

  • Object:键只能是 字符串Symbol 类型。如果你尝试使用其他类型,JavaScript 会自动调用 toString() 方法将其转换为字符串。
  • Map:键可以是 任意类型,包括对象、函数、数组、数字、布尔值,甚至 NaN
// Object 的隐式类型转换
const obj = {};
const key1 = { id: 1 };
const key2 = { name: 'test' };

obj[key1] = '这是第一个对象';
obj[key2] = '这是第二个对象';

console.log(obj[key1]); 
// 输出:"这是第二个对象"!因为 key1.toString() 和 key2.toString() 都是 "[object Object]"

而在 Map 中,这完全不是问题:

const map = new Map();
const key1 = { id: 1 };
const key2 = { name: 'test' };

map.set(key1, '这是第一个对象');
map.set(key2, '这是第二个对象');

console.log(map.get(key1)); // 输出:"这是第一个对象"
console.log(map.get(key2)); // 输出:"这是第二个对象"

这意味着,你可以直接将 DOM 元素、函数实例作为键,来存储它们的关联数据,而无需手动生成唯一 ID。

2.2 键的顺序:严格的插入顺序

很多人以为 Object 的键是无序的,其实在 ES6 之后,Object 也开始保留插入顺序了,但它有一个致命的例外:数字键会被优先排序

const obj = {};
obj['b'] = 2;
obj['1'] = 1;
obj['a'] = 3;
obj['2'] = 4;

console.log(Object.keys(obj)); 
// 输出:["1", "2", "b", "a"]
// 数字键被自动排到了前面,完全打乱了插入顺序!

而 Map 则严格保证了插入顺序,没有任何例外:

const map = new Map();
map.set('b', 2);
map.set('1', 1);
map.set('a', 3);
map.set('2', 4);

console.log(Array.from(map.keys())); 
// 输出:["b", "1", "a", "2"]
// 完美遵循了我们的插入顺序

这对于日志记录、有序缓存等时序敏感的场景至关重要。

2.3 大小获取:O (1) vs O (n)

获取键值对的数量,两者的效率天差地别。

  • Object:你必须手动遍历所有键来计算长度,这是一个 O (n) 的操作。

    •     const size = Object.keys(obj).length;
      
  • Map:内置了 size 属性,直接返回大小,这是一个 O (1) 的操作,无需遍历。

    •     const size = map.size;
      

2.4 迭代能力:原生的遍历支持

  • Object:它本身不是可迭代对象(Iterable),你无法直接使用 for...of 遍历它。必须先通过 Object.keys()Object.entries() 等方法转换为数组。
  • Map:它原生实现了迭代器协议,你可以直接遍历它,而且默认就是遍历键值对。
// Map 直接遍历
for (const [key, value] of map) {
  console.log(key, value);
}

// Object 必须转换
for (const [key, value] of Object.entries(obj)) {
  console.log(key, value);
}

2.5 原型链污染:安全的隔离

普通对象默认继承了 Object.prototype,这意味着它自带了 toStringhasOwnProperty 等默认属性。如果你不小心用这些名字作为键,就会发生冲突,甚至引发原型链污染攻击。

const obj = {};
console.log(obj.toString); // 输出:[Function: toString],这是原型上的方法

而 Map 从一开始就是一张白纸,它没有原型,完全不存在这个问题:

const map = new Map();
console.log(map.has('toString')); // 输出:false

三、性能深度剖析:谁更快?

很多人都听说过 Map 性能更好,但具体好在哪里?我们来看一下基于 V8 引擎的实测数据。

图 2:10 万次操作下的性能对比(单位:毫秒)

3.1 底层实现的差异

  • Object:V8 引擎为了优化属性访问,引入了 “隐藏类(Hidden Class)” 的机制。当你创建一个对象并添加固定的属性时,V8 会为它生成一个隐藏类,属性访问会被优化为直接的内存偏移,速度极快。但是,一旦你频繁地添加和删除属性,隐藏类就会不断地被重建和重排,这会带来巨大的性能开销,甚至会降级到 “字典模式”。
  • Map:它的底层是基于哈希表(Hash Table)实现的。哈希表天生就为频繁的增删查改做了优化,插入、删除、查找的平均时间复杂度都是 O (1)。无论你怎么操作,它的性能都非常稳定。

3.2 关键发现

从测试数据中我们可以看到:

  1. 删除操作:Map 比 Object 快了近 3 倍!这是因为 Object 删除属性会触发隐藏类的重排,而 Map 的哈希表删除只是调整指针。
  2. 插入操作:Map 也有明显优势,特别是在动态数据场景下。
  3. 查找操作:两者差距不大,Object 因为隐藏类的优化,在小数据量下甚至略快。
  4. 内存占用:存储 10 万条数据时,Map 比 Object 节省了约 38% 的内存。

四、实战应用:什么时候用哪个?

了解了原理,我们来看看实际开发中该如何选择。

4.1 优先使用 Map 的场景

当你遇到以下情况时,Map 绝对是更好的选择:

1. 键不是简单的字符串

比如你需要用对象、DOM 元素作为键。

// 存储 DOM 元素的关联数据
const elementData = new Map();
const button = document.querySelector('#btn');

elementData.set(button, { clickCount: 0 });

button.addEventListener('click', () => {
  const data = elementData.get(button);
  data.clickCount++;
});

2. 需要频繁增删键值对

比如缓存系统、高频更新的状态。

// 防止重复请求
const pendingRequests = new Map();

function requestInterceptor(config) {
  const key = generateRequestKey(config);
  if (pendingRequests.has(key)) {
    // 取消之前的请求
    pendingRequests.get(key).cancel();
  }
  // 存储新的请求
  pendingRequests.set(key, cancelToken);
}

3. 需要有序的键值对

比如日志记录、有序的配置列表。

4. 需要频繁查询大小

比如你需要经常知道当前缓存里有多少条数据。

4.2 优先使用 Object 的场景

当然,Object 并没有被淘汰,在这些场景下,它依然是首选:

1. 存储静态的结构化数据

比如用户信息、配置项,这些数据的键是已知的、固定的字符串。

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  debug: true
};

这种场景下,Object 的 . 语法访问属性比 map.get() 更直观,而且 V8 的隐藏类优化能发挥到极致。

2. 需要 JSON 序列化

Object 天生支持 JSON.stringify(),而 Map 不支持,需要手动转换。

JSON.stringify(user); // 正常工作
JSON.stringify(userMap); // 输出:{},无法直接序列化

3. 简单的一次性数据处理

如果只是临时存几个简单的键值对,用完就扔,用字面量 {} 创建对象比 new Map() 更快捷。

五、面试高频考点

这部分是面试中的常客,你需要掌握:

  1. 问:Map 和 Object 的区别是什么? 答:从键的类型、顺序、大小获取、迭代、原型链、性能这几个维度回答即可。
  2. 问:Object 的键顺序是怎样的? 答:ES6 之后,Object 会先把整数键按升序排,然后字符串和 Symbol 键按插入顺序排。而 Map 是严格的插入顺序。
  3. 问:为什么 Map 在频繁增删时性能更好? 答:因为 Object 底层是隐藏类,频繁增删会导致隐藏类重排;而 Map 是哈希表,增删查改都是 O (1) 的稳定操作。

六、总结

Map 和 Object 并不是谁取代谁的关系,它们是互补的。

  • Object 更像一个 “数据模型”,适合存储结构固定、键为字符串的静态数据,它支持 JSON 序列化,语法直观。
  • Map 更像一个 “数据容器”,适合处理动态的、复杂的映射关系,它支持任意键、有序性、高效的增删操作。

在现代前端开发中,随着应用复杂度的提升,Map 的使用场景越来越多。学会根据业务场景灵活选择,才能写出更高效、更健壮的代码。

参考资料

[1] MDN Web Docs. 带键的集合 [EB/OL]. developer.mozilla.org/zh-CN/docs/…, 2025. [2] zqmgx13291. JavaScript Map 数据结构:原理、实践与性能优化 [EB/OL]. CSDN 博客,2025. [3] 前端小木屋. Object 与 Map 的区别有哪些?[EB/OL]. 稀土掘金,2025. [4] Pu_Nine_9. 深入理解 ES6 Map 数据结构:从理论到实战应用 [EB/OL]. CSDN 博客,2026. [5] 软件求生。你以为你会用 Map? 这些细节 90% 的人都忽略了 [EB/OL]. 今日头条,2026.

js 实现 Blob、File、ArrayBuffer、base64、URL 之间互转

2026年4月24日 17:00

在处理文件数据时常常需要将其转换为其他的类型数据以方便后续操作。例如在引入第三方库时,支持的类型可能在项目不能直接获取到,那么就需要进行类型转换。其中主要的类型包括 Blob、File、ArrayBuffer、base64、URL 。

类型解释

Blob

Blob(Binary Large Object)是一种二进制大对象,是一种存储大量二进制数据的容器。

File

File 通常为用户在 input 上选择文件的结果。 继承于 Blob,一些处理 Blob 的函数也可以直接处理 FIle(如:URL.createObjectURL)。

ArrayBuffer

ArrayBuffer 是一种用于表示通用的、固定长度的原始二进制数据缓冲区的对象。它提供了一种在内存中分配固定大小的缓冲区,可以存储各种类型的二进制数据。ArrayBuffer 本身并不能直接操作数据,而是需要使用 TypedArray 视图或 DataView 对象来读取和写入数据。

base64

Base64 是一种用于表示二进制数据的编码方式,通过将二进制数据转换为文本字符串,以便在文本环境中传递。

URL

URL 可以分为两种,一种为 base64 拼接上类型的 DataURL 地址,另一种为 createObjectURL 方法创建的当前页面生命周期下的 ObjectURL 地址。

DataURL: data:image/png;base64,iVBORw0KGgoAAAANS...

ObjectURL: blob:https://f1eb432b-1ef7-42...

Blob 类型转换

对于 Blob 的 b 部分类型转换可以利用 FileReader 类的读取函数完成。其中包括 readAsArrayBuffer,readAsDataURL,readAsText(得到字符串形式内容)。

Blob 转 ArrayBuffer

function blobToArrayBuffer(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsArrayBuffer(blob);
  });
}

Blob 转 File

直接使用 File 构造方法即可,可以指定文件名称,文件类型(如:image/jpeg),修改时间

function blobToFile(blob, fileName, type = '', lastModified = Date.now()) {
  return new File(blob, fileName, { type, lastModified });
}

Blob 转 DataURL

function blobToDataURL(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(blob);
  });
}

Blob 转 base64

先使用 FileReader 将 Blob 转为 DataURL,再对将 DataURL 的类型去掉既可以。

function blobToBase64(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result.split(',')[1]);
    reader.readAsDataURL(blob);
  });
}
// 使用 blobToDataURL
function blobToBase64(blob) {
  return new Promise((resolve) => {
    blobToDataURL(blob).then((dataURL) => resolve(dataURL.split(',')[1]));
  });
}

Blob 转 ObjectURL

function blobToObjectURL(blob) {
  return URL.createObjectURL(blob);
}

File 类型转换

File 转 Blob、ArrayBuffer、base64、DataURL

在大多数情况下是不需要转换的,因为 File 本来就继承与 Blob。在必须转换的情况下可以利用 FileReader.readAsArrayBuffer 获取到 arrayBuffer,再将 arrayBuffer 转为 Blob

ArrayBuffer、DataURL 也可以通过 FileReader 转换

base64 只需要把 DataURL 的类型去掉即可

function fileToBlob(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(new Blob([reader.result], { type: file.type }));
    reader.readAsArrayBuffer(file);
  });
}
function fileToArrayBuffer(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsArrayBuffer(file);
  });
}
function fileToDataURL(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsDataURL(file);
  });
}
function fileToBase64(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result.split(',')[1]);
    reader.readAsDataURL(file);
  });
}

File 转 ObjectURL

function blobToObjectURL(blob) {
  return URL.createObjectURL(blob);
}

ArrayBuffer 类型转换

ArrayBuffer 是没有指定类型的二进制缓存,所以在一些转换时需要提供具体的类型。

ArrayBuffer 转 Blob、File

直接使用 Blob、File 构造函数即可,可以指定数据类型。文件可以指定文件名和修改时间。

function arrayBufferToBlob(arrayBuffer, type) {
  return new Blob(arrayBuffer, { type });
}
function arrayBufferToFile(arrayBuffer, fileName, type = '', lastModified = Date.now()) {
  return new File(arrayBuffer, fileName, { type, lastModified });
}

ArrayBuffer 转 Base64

需要先将 ArrayBuffer 转为二进制字符串,再将二进制字符串转为 Base64

function arrayBufferToBase64(arrayBuffer) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
}

ArrayBuffer 转 DataURL

  1. 先将 ArrayBuffer 转为 base64,再加上类型即可。(推荐)
  2. 先将 ArrayBuffer 转为 Blob,再使用 FileReader.readAsDataURL 获取。
function arrayBufferToDataURL(arrayBuffer, type) {
  return `data:${type};base64,${btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))}`;
}
function arrayBufferToDataURL(arrayBuffer, type) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(new Blob(arrayBuffer, { type }));
  });
}

ArrayBuffer 转 ObjectURL

需要先将 ArrayBuffer 转为 Blob 或者 File,再使用 createObjectURL 转为 ObjectURL

function arrayBufferToObjectURL(arrayBuffer, type) {
  return URL.createObjectURL(new Blob(arrayBuffer, { type }));
}

String 转 ArrayBuffer

有时需要将其他类型转换为 ArrayBuffer,比如将字符串转为 ArrayBuffer:

function stringToArrayBuffer(text) {
  return new TextEncoder().encode(text).buffer;
}

DataURL 类型转换

DataURL 转 base64

直接去掉类型即可

function stringToArrayBuffer(dataURL) {
  return dataURL.split(',')[1];
}

DataURL 转 ArrayBuffer

在转为 ArrayBuffer 时需要先提取 base64 并解码,然后定义二进制字符串长度的 ArrayBuffer 并关联 Unit8Array,最后将字符串转为 UTF-16 码元并写入关联的 Unit8Array 中

function dataURLToArrayBuffer(dataURL) {
  const base64 = dataURL.split(',')[1];
  const binaryString = atob(base64);
  const arrayBuffer = new ArrayBuffer(binaryString.length);
  const uint8Array = new Uint8Array(arrayBuffer);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return arrayBuffer;
}

DataURL 转 Blob、File

转为 Blob 或 File 时其实是几种数据切换:DataURL => base64 => binaryArray => typedArray => Blob\File

其中使用 atob 将 base64 解码为字符串,定义 Unit8Array 的 typedArray 用于缓存 UTF-16 码元,通过 String.chartCodeAt 获取字符的 UTF-16,最后使用 Blob\File 的构造函数完成类型转换。由于 Blob 和 File 构造函数可以接受 typedArray,那么就没必要转 ArrayBuffer 了。另外转 File 时可以指定文件名

function base64ToUnit8Array(base64) {
  const binaryString = atob(base64);
  const uint8Array = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return uint8Array;
}
function dataURLToBlob(dataURL) {
  const [type, base64] = dataURL.split(',');
  return new Blob([base64ToUnit8Array(base64)], { type });
}
function dataURLToFile(dataURL, fileName) {
  const [type, base64] = dataURL.split(',');
  return new File([base64ToUnit8Array(base64)], fileName, { type });
}

DataURL 转 ObjectURL

由于 createObjectURL 接受 Blob 或 File,所以需要先转为 Blob 或 File。这里转为 Blob。

function dataURLToObjectURL(dataURL) {
  const [type, base64] = dataURL.split(',');
  const binaryString = atob(base64);
  const uint8Array = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return URL.createObjectURL(new Blob([uint8Array], { type }));
}
// 使用 dataURLToBlob
function dataURLToObjectURL(dataURL) {
  const type = dataURL.split(',')[0];
  return URL.createObjectURL(dataURLToBlob(dataURL), { type });
}

ObjectURL 类型转换

一般情况下是不会有 ObjectURL 转为其他类型的需求的,因为 ObjetcURL 的生命周期只在当前页面,只会在当前页面由其他资源生成,既然已经存在其原资源,也就没有必要再转换,如果需要其他类型的也完全可以使用原资源来转换。如果需要转换,那么第一步就是通过请求拉到定义的数据。这些转换也是适用远程请求的。

ObjectURL 转 Blob、File

// function objectURLToBlob(objectURL, fileType) {
function objectURLToFile(objectURL, fileName, fileType) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', objectURL, true);
  xhr.responseType = 'blob';
  return new Promise((resolve, reject) => {
    xhr.onload = () => {
      if (xhr.status === 200) {
        const blob = xhr.response;
        // resolve(blob);
        const file = new File([blob], fileName, { type: fileType });
        resolve(file);
      } else {
        reject(new Error('Failed to load the resource'));
      }
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send();
  });
}

ObjectURL 转 ArrayBuffer、base64、DataURL

先获取到文件数据,之后再使用 Blob 或者 File 类型转为 DataURL 或 ArrayBuffer 或其他类型即可。

总结

  1. 对于 File 和 Blob 的转为其他类型大多依赖 FileReader。

  2. 其他类型转为 File 或者 Blob 时最终都是通过构造函数完成的。

  3. base64 和 DataURL 的转换只是类型的截取和拼接。

  4. 转为 ObjectURL 时需要先转为 Blob 或者 File 再通过 createObjectURL 生成。

前端JavaScript:数据类型及类型判断

作者 淸湫
2026年4月24日 16:58

在 JavaScript 这门动态弱类型语言中,变量的类型在运行时才能确定,这既赋予了语言极大的灵活性,也给开发者带来了类型判断的挑战。你是否曾被 typeof null === 'object' 这一诡异的结果所困惑?是否在跨 iframe 环境中遇到过 instanceof 判断失效的问题?本文将从底层原理出发,带你彻底搞懂 JavaScript 的数据类型体系以及各种类型判断方法的适用场景。

一、JavaScript 的数据类型体系

在 ES2020 之后,JavaScript 总共定义了 8 种数据类型,它们被划分为两大类:原始类型(Primitive Types)引用类型(Reference Types)

1.1 原始类型:不可变的基础值

原始类型是直接存储在栈(Stack)内存中的简单数据段,它们的值是不可变的,且占据固定大小的空间。当你复制一个原始类型变量时,实际上是在栈中创建了一个全新的值。

目前 JavaScript 包含 7 种原始类型:

  • Undefined:只有一个值 undefined,表示变量未初始化。
  • Null:只有一个值 null,表示空对象指针。
  • Boolean:包含 truefalse 两个值。
  • Number:基于 IEEE 754 标准的双精度浮点数,包含整数和小数,以及特殊的 NaNInfinity
  • String:字符串类型,JavaScript 中的字符串是不可变的。
  • Symbol:ES6 引入,表示独一无二的值,常用于对象的属性键。
  • BigInt:ES2020 引入,用于表示任意精度的整数,解决了 Number 类型无法精确表示大整数的问题。

1.2 引用类型:可变的对象

引用类型的值是对象,它们存储在堆(Heap)内存中。栈内存中仅存储了指向堆内存地址的指针。当你复制一个引用类型变量时,实际上复制的只是这个指针,两个变量最终指向的是堆中的同一个对象。

引用类型包含了所有的对象类型,例如:

  • 普通对象(Object)
  • 数组(Array)
  • 函数(Function)
  • 日期(Date)
  • 正则(RegExp)
  • Map、Set 等

图 1:原始类型与引用类型在内存中的存储差异

二、类型判断的四大金刚

了解了数据类型之后,我们来看看如何准确地判断它们。JavaScript 提供了多种判断手段,但它们各有千秋。

2.1 typeof:快速但有缺陷的检测

typeof 是最基础也是最常用的类型判断运算符,它返回一个字符串,表示未经计算的操作数的类型。

console.log(typeof 42);          // "number"
console.log(typeof 'hello');     // "string"
console.log(typeof true);        // "boolean"
console.log(typeof undefined);   // "undefined"
console.log(typeof Symbol());    // "symbol"
console.log(typeof BigInt(123)); // "bigint"
console.log(typeof function(){});// "function"

然而,typeof 存在两个著名的缺陷:

  1. 无法区分具体的引用类型:除了 Function 之外,所有的对象(包括 Array、Date、RegExp 等)都会返回 "object"

    1.   console.log(typeof []);        // "object"
        console.log(typeof {});        // "object"
        console.log(typeof new Date());// "object"
      
  2. typeof null 返回 "object" :这是 JavaScript 历史上最著名的 Bug。在 JavaScript 最初的实现中,为了性能,值的类型是通过二进制的前三位来标记的,其中 000 代表对象。而 null 表示空指针,在大多数平台下被表示为全 0,因此它的前三位也是 000,导致被误判为对象。虽然这个 Bug 广为人知,但由于兼容性原因,至今未能修复。

2.2 instanceof:基于原型链的侦探

为了解决引用类型的判断问题,JavaScript 提供了 instanceof 运算符。它的原理是检查构造函数的 prototype 属性是否出现在目标对象的原型链上。

let arr = [];
console.log(arr instanceof Array);  // true
console.log(arr instanceof Object); // true,因为 Array 的原型最终也指向 Object

let date = new Date();
console.log(date instanceof Date);  // true

手写实现 instanceof

理解了原理,我们就可以手动实现一个 instanceof

function myInstanceof(left, right) {
    // 基本类型直接返回 false
    if (typeof left !== 'object' || left === null) return false;
    
    // 获取原型链
    let proto = Object.getPrototypeOf(left);
    while (true) {
        if (proto === null) return false; // 找到原型链顶端
        if (proto === right.prototype) return true;
        proto = Object.getPrototypeOf(proto);
    }
}

instanceof 的局限性:

  • 无法判断基本类型:基本类型没有原型链,所以 123 instanceof Number 永远是 false
  • 跨执行上下文失效:在不同的 iframe 中,各自有独立的执行环境和全局对象。如果父窗口把一个数组传给子窗口,在子窗口中用 instanceof Array 判断会失败,因为它们的 Array 构造函数不是同一个。

2.3 Object.prototype.toString:万能的检测器

如果你需要一个能准确判断所有类型的终极方案,那么 Object.prototype.toString 绝对是你的首选。

根据 ECMAScript 规范,这个方法会返回一个格式为 [object Type] 的字符串,其中 Type 就是该值的内部 [[Class]] 属性。这个属性是引擎内部用来标记类型的,几乎无法被篡改。

const toString = Object.prototype.toString;

console.log(toString.call(123));        // "[object Number]"
console.log(toString.call('hello'));    // "[object String]"
console.log(toString.call(null));        // "[object Null]"
console.log(toString.call(undefined));   // "[object Undefined]"
console.log(toString.call([]));          // "[object Array]"
console.log(toString.call(new Date()));  // "[object Date]"
console.log(toString.call(new Map()));   // "[object Map]"

通过这个方法,我们可以封装一个通用的类型检测函数:

function getType(value) {
    return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
}

console.log(getType([]));      // "array"
console.log(getType(null));    // "null"
console.log(getType(new Map())); // "map"

这个方法完美解决了 typeofinstanceof 的所有痛点,无论是基本类型、引用类型,还是跨环境判断,它都能准确无误。

2.4 专用检测方法

除了上述通用方法,JavaScript 还提供了一些专用的检测函数,例如:

  • Array.isArray():专门用于判断是否为数组,它的本质上也是基于内部的 [[Class]] 实现的,因此比 instanceof 更可靠。
  • Number.isNaN():用于判断是否为 NaN,比全局的 isNaN 更严格,因为它不会进行隐式类型转换。

三、各方法对比与实战

为了让你更直观地看到各种方法的差异,我们整理了如下对比表:

图 2:不同类型判断方法的表现对比

3.1 实战场景:深拷贝中的类型判断

在实现深拷贝函数时,我们需要准确判断数据的类型,以便进行不同的处理:

function deepClone(obj) {
    const type = getType(obj);
    
    switch (type) {
        case 'object':
            const clonedObj = {};
            for (let key in obj) {
                clonedObj[key] = deepClone(obj[key]);
            }
            return clonedObj;
        case 'array':
            return obj.map(item => deepClone(item));
        case 'date':
            return new Date(obj.getTime());
        case 'regexp':
            return new RegExp(obj);
        default:
            // 基本类型直接返回
            return obj;
    }
}

3.2 通用工具函数

在实际项目中,我们通常会封装一个类型检查工具类:

const TypeChecker = {
    isString: (val) => typeof val === 'string',
    isNumber: (val) => typeof val === 'number' && !isNaN(val),
    isBoolean: (val) => typeof val === 'boolean',
    isFunction: (val) => typeof val === 'function',
    isArray: (val) => Array.isArray(val),
    isObject: (val) => getType(val) === 'object',
    isNull: (val) => val === null,
    isUndefined: (val) => val === undefined,
    isEmpty: (val) => {
        if (val === null || val === undefined) return true;
        if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
        if (typeof val === 'object') return Object.keys(val).length === 0;
        return false;
    }
};

四、面试高频考点

在前端面试中,类型判断是一个高频考点,以下是几个必问的问题:

  1. 问:为什么 typeof null 等于 'object'? 答:这是 JavaScript 早期实现的历史遗留问题。由于 null 的二进制表示全为 0,与对象的类型标签(前三位 000)冲突,导致被误判。
  2. 问:如何准确判断一个变量是数组? 答:推荐使用 Array.isArray(),它是 ES5 引入的标准方法,能处理跨环境问题。其次可以使用 Object.prototype.toString.call(arr) === '[object Array]'
  3. 问: instanceof 的原理是什么? 答:它通过遍历左边变量的原型链,检查右边构造函数的 prototype 是否存在于该原型链上。

五、最佳实践建议

经过以上分析,我们可以总结出如下最佳实践:

  • 判断基本类型:优先使用 typeof,注意对 null 要额外判断 val === null
  • 判断数组:直接使用 Array.isArray(),简单高效。
  • 判断特定引用类型:在同环境下可以用 instanceof,但如果涉及到跨窗口通信,优先使用 toString
  • 通用、准确的类型检测:使用 Object.prototype.toString.call(),它是最可靠的万能方法。
  • 性能敏感场景:如果是在性能要求极高的循环中,优先使用 typeofinstanceof,因为它们的性能比调用 toString 要快。

总结

JavaScript 的类型系统虽然看似简单,但其背后隐藏着许多设计细节和历史遗留问题。理解原始类型与引用类型的区别,掌握 typeofinstanceofObject.prototype.toString 这三种核心判断方法的原理与局限,是你写出健壮、可靠代码的基础。

记住,没有最好的方法,只有最合适的方法。根据不同的业务场景,灵活选择判断手段,才能真正驾驭好这门动态语言。

参考资料

[1] MDN Web Docs. JavaScript 数据类型和数据结构 [EB/OL]. developer.mozilla.org/zh-CN/docs/…, 2025. [2] 前端侦探。三种类型判断的区别和原理解析 [EB/OL]. 稀土掘金,2023. [3] BUG 收容所所长. JavaScript 类型判断终极指南 [EB/OL]. 稀土掘金,2025. [4] 发现一只大呆瓜. JS 类型判断之 typeof、instanceof 与 toString 示例详解 [EB/OL]. 脚本之家,2026. [5] Thiemann P. Towards a Type System for Analyzing JavaScript Programs [C]//Static Analysis: 12th International Symposium. Springer, 2005.

前端技巧:用 Bookmarklet 给网页临时挂载一个图片调试面板

2026年4月24日 16:51

前端技巧:用 Bookmarklet 给网页临时挂载一个图片调试面板

在前端开发中,我们经常需要分析页面中的资源情况,比如:

  • 页面实际加载了哪些图片
  • 不同分辨率资源的分布
  • 是否存在重复图片请求
  • UI 还原时如何快速定位原始素材

这些事情当然可以通过 DevTools 完成,但在某些场景下效率并不高。例如批量浏览图片、筛选大图、快速预览等操作,都需要在多个面板之间来回切换。

于是可以换一个思路:

不扩展 DevTools,而是用一段 Bookmarklet,在任意网页上临时挂载一个“图片调试面板”。

这篇文章的重点不在工具本身,而在于这种实现方式背后的前端技术思路。

image.png


一、为什么是 Bookmarklet

Bookmarklet 的本质,是一段运行在当前页面上下文中的 JavaScript。

javascript:(()=>{ /* your code */ })()

它有几个非常关键的特性:

  • 直接运行在页面环境中,可以访问 DOM
  • 不需要构建或发布浏览器插件
  • 无需侵入页面代码
  • 可以在任意网站使用

这使它非常适合做“临时调试能力注入”。

可以把它理解为:

一种轻量级的“运行时工具扩展机制”


二、核心问题:如何获取真实图片资源

最直接的方式是:

[...document.images]

但这只是第一步,真正需要解决的是两个问题:

1. 图片是否已经加载完成

i.naturalWidth

只有当图片完成加载后,naturalWidthnaturalHeight 才是有效的。


2. 如何获取真实资源地址

i.currentSrc || i.src

这里的关键是 currentSrc

在响应式图片场景中:

<img src="small.jpg" srcset="large.jpg 2x">

浏览器实际使用的资源并不一定是 src,而是 currentSrc

如果忽略这一点,拿到的数据很可能是不准确的。


三、数据建模:不仅仅是收集

收集图片之后,需要对数据做一层结构化处理:

{
  s: src,
  w: width,
  h: height,
  m: max(width, height)
}

这里的关键字段是:

  • w / h:用于展示尺寸信息
  • m:用于排序和筛选

为什么使用最大边?

因为在实际使用中:

  • 横图和竖图不好直接比较
  • 最大边可以作为统一尺度
  • 更适合做“是否为大图”的判断

四、去重策略:Map 比 Set 更合适

const map = new Map()
imgs.forEach(i => !map.has(i.s) && map.set(i.s, i))

这里选择 Map 而不是 Set 的原因是:

  • 去重依据是 URL
  • 但我们需要保留完整对象
  • Map 可以同时解决“唯一性 + 数据存储”

这是一个很典型的前端数据处理模式。


五、筛选与排序:面向使用场景设计

base.filter(i => !v || i.m >= v)

筛选逻辑围绕一个实际需求:

快速找到大图资源

配合排序:

first ? b.m - a.m : a.m - b.m

首屏优先展示大图,可以显著提升信息获取效率。

这其实是一个“数据展示策略”的问题,而不仅仅是代码实现。


六、为什么在新窗口中渲染 UI

const w = open()
w.document.write(...)

这是整个实现中一个很关键的设计点。

如果直接在当前页面插入 UI,会遇到几个问题:

  • 样式冲突(CSS 污染)
  • z-index 竞争
  • 可能被页面脚本影响

而新窗口的优势是:

  • 完全隔离运行环境
  • 样式可控
  • 生命周期独立

可以理解为:

用浏览器原生能力实现了一种“轻量沙箱”

七、实现代码

完整实现如下(Bookmarklet 版本):

javascript:(()=>{const imgs=[...document.images].filter(i=>i.naturalWidth).map(i=>({s:i.currentSrc||i.src,w:i.naturalWidth,h:i.naturalHeight,m:Math.max(i.naturalWidth,i.naturalHeight)}));const map=new Map();imgs.forEach(i=>!map.has(i.s)&&map.set(i.s,i));const base=[...map.values()];const sizes=[...new Set(base.map(i=>i.m))].sort((a,b)=>b-a);const w=open();w.document.write(`<!doctype html><meta charset=utf-8><title>页面图片资源(${base.length})</title><style>*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto;background:#0f1115;color:#e6e6e6}header{position:sticky;top:0;z-index:10;background:#161a20;padding:12px 16px;font-size:14px;display:flex;gap:12px;align-items:center;box-shadow:0 6px 20px rgba(0,0,0,.4)}select{margin-left:auto;background:#0f1115;color:#e6e6e6;border:1px solid #333;border-radius:6px;padding:4px 8px;font-size:12px}.grid{padding:16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.card{background:#161a20;border-radius:12px;overflow:hidden;position:relative;transition:transform .18s ease-out,opacity .18s ease-out}.card.enter{transform:scale(.96);opacity:0}.card img{width:100%;height:180px;object-fit:contain;background:#0b0d11;cursor:zoom-in}.badge{position:absolute;top:8px;right:8px;font-size:11px;padding:2px 6px;border-radius:6px;background:rgba(0,0,0,.65)}.tools{position:absolute;bottom:8px;right:8px;display:flex;gap:6px}.tools button{background:rgba(0,0,0,.7);color:#fff;border:none;padding:5px 7px;font-size:12px;border-radius:6px;cursor:pointer}.toast{position:fixed;top:14px;left:50%;transform:translateX(-50%);background:#222;padding:8px 14px;border-radius:20px;font-size:12px;opacity:0;transition:.2s;z-index:20}.toast.show{opacity:1}.preview{position:fixed;inset:0;background:rgba(0,0,0,.9);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:.2s;z-index:30}.preview.show{opacity:1;pointer-events:auto}.preview img{max-width:90%;max-height:90%}</style><header>页面图片资源(${base.length})<select id=f><option value=0>全部</option></select></header><div class=toast id=t></div><div class=grid id=g></div><div class=preview id=p><img></div>%60);const d=w.document,G=d.getElementById("g"),F=d.getElementById("f"),T=d.getElementById("t");sizes.forEach(s=>{const o=d.createElement("option");o.value=s;o.textContent="≥ "+s+"px";F.appendChild(o)});const toast=m=>{T.textContent=m;T.className="toast show";setTimeout(()=>T.className="toast",1200)};const draw=(v,first)=>{const arr=base.filter(i=>!v||i.m>=v).sort((a,b)=>first?b.m-a.m:a.m-b.m);G.innerHTML="";arr.forEach(i=>{const c=d.createElement("div");c.className="card enter";c.innerHTML='<span class="badge">'+i.w+' × '+i.h+'</span><img src="'+i.s+'"><div class="tools"><button data-copy="'+i.s+'">复制</button><button data-open="'+i.s+'">打开</button></div>';G.appendChild(c);setTimeout(()=>c.classList.remove("enter"),0)})};draw(0,true);F.onchange=e=>draw(+e.target.value,false);d.onclick=e=>{if(e.target.dataset.copy){const a=d.createElement("textarea");a.value=e.target.dataset.copy;d.body.appendChild(a);a.select();d.execCommand("copy");a.remove();toast("已复制")}if(e.target.dataset.open)w.open(e.target.dataset.open,"_blank","noopener");if(e.target.tagName==="IMG"){d.getElementById("p").classList.add("show");d.querySelector("#p img").src=e.target.src}if(e.target.id==="p")e.target.classList.remove("show")};d.onkeydown=e=>e.key==="Escape"&&d.getElementById("p").classList.remove("show");d.close()})();

八、可以延展的方向

这个思路可以进一步扩展为一类能力:

  • CSS 调试面板(查看覆盖关系)
  • 字体分析工具
  • 网络请求监控(结合 fetch hook)
  • DOM 结构可视化

也就是说:

Bookmarklet 不只是“小工具”,而是一种可以快速构建调试能力的前端模式。


九、总结

这段代码的价值不在于“提取图片”,而在于它体现了几个重要的前端思路:

  • 如何在运行时扩展页面能力
  • 如何做轻量级的数据建模与处理
  • 如何在无框架环境下构建完整交互
  • 如何利用浏览器原生能力实现隔离

如果换一个角度看,它更像是一个无需安装的临时 DevTools 扩展。

理解这一点,比代码本身更重要。

本文仅用于前端开发调试与技术研究,请勿用于侵犯他人版权或违反网站使用协议的行为。

面试官:LangChain中 TS 和 Python 版本有什么差别,什么时候选TS ❓❓❓

作者 Moment
2026年4月23日 09:23

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

很多人一上来就问 LangChain.js 到底能不能和 Python 版打,其实这个问题放在 2025 年已经不太成立了。现在的 LangChain.js 和 Python 版早就不是一个能做、一个不能做的关系,而是生态重心、运行时环境、团队技能结构上的差别。官方两边都在围绕 createAgentcreate_agentmiddlewareLangGraph 这套 agent runtime 推进,核心能力的差距相比早期缩小了很多。真正值得讨论的是,在你自己的项目里到底该选哪一边。

核心能力已经很接近

先从能力层面看。两边都提供了生产可用的 agent 入口,Python 侧是 create_agent,JS/TS 侧是 createAgent。两边都把 middleware 作为核心定制机制,用来做上下文工程、摘要、PII 处理、人类审批、工具控制、状态管理这些事。JS 侧的 createAgent 底层基于 LangGraph 的 graph-based runtime,和 Python 侧走的是同一条架构路线。换句话说,做工具调用 agent、工作流 agent、带状态和中间控制的 agent,两边都能胜任,你不会因为选了 TS 就被卡在某个能力边界上。

生态厚度的差别在集成数量上

能力差不多,但生态厚度差得比较明显。官方集成页上 Python 侧的可用集成是 1000+,JS/TS 侧是 100 多个,差了将近一个数量级。

20260422172323

20260422172355

这个差距短期内不会被抹平,因为 Python 天然连着更大的 AI 和数据生态。数据清洗、文本处理、embedding 前处理、实验脚本、评测、离线任务,这些事情在 Python 里通常都有现成的库和现成的示例可以直接抄。你遇到一个冷门的向量库、冷门的模型提供商、冷门的文档解析器,在 Python 侧大概率能找到适配,在 JS 侧可能就要自己包一层。

反过来看,JS/TS 的优势不在集成数量上,而在离 Web 产品更近。如果你本来就在 Node 里做 API、SSE、WebSocket、前后端共享 schema、全栈 monorepo,那用 TS 会让系统边界更简单,省掉大量跨语言胶水代码。

TS 的真正价值是全栈一致

沿着上面这点继续往下说。选 TS 的最大好处其实不是 AI 能力更强,而是能把整个系统打通成一套类型。前端表单类型、后端 DTO、工具入参 schema、Zod 校验、agent 输出结构、SSE 返回类型,甚至日志事件类型,都可以放在同一套类型系统里管。这种一致性在 Python 和 TS 混用的架构里很难做到,常常要靠文档约定或者手写 schema 对齐,维护起来很累。

Node 项目里,middleware 这类运行时控制也更容易直接融进现有的 Web 服务,不用额外起一个 Python 子服务再做接口拼接。对做产品的团队来说,这种工程上的顺滑感,往往比多几百个集成更重要。

两边都更规范但仍在快速演进

说完优势再说一下稳定性。LangChain 目前按 semver 管理版本,minor 加新特性,patch 高频修 bug。和 0.x 时期相比稳定了很多,不再是动不动就改 API 的状态。但这个领域整体仍在快速变化,不管你选 Python 还是 TS,做生产项目都要锁版本,不要无脑追最新。这一点两边是一样的,不构成选型差异。

什么时候优先选 TS

把前面几点串起来看,就能得到一个比较清楚的选型判断。下面这几类场景选 TS 会更顺。

  • 在做 AI 产品,不是在做 AI 研究。比如聊天、文档编辑 agent、知识库问答、工作流编排、客服后台、内容生成后台这类偏产品交付的系统。
  • 主栈已经是 Next.jsNestJS 或者纯 Node,用 TS 能减少语言切换、减少服务拆分、减少跨语言 schema 漂移。
  • 特别在意类型安全和契约一致性,工具参数、结构化输出、前后端共享类型、Zod 校验这些需求都希望一套语言搞定。
  • 要把 AI 能力直接嵌进现有 Web 服务,比如 SSE 流式输出、实时 UI、在线编辑器、业务鉴权、BFF 层整合。

什么时候反而该选 Python

反过来,下面这几类场景选 Python 更省事。

  • 大量文档 ETL、离线索引、数据实验、批处理。
  • 高度依赖更广的第三方 AI、检索、数据生态,需要用到很多冷门集成。
  • 团队里 AI 工程师以 Python 为主,notebook 和实验迭代是主工作流。
  • 经常要找社区现成示例,希望命中率更高。

这两组判断背后的事实基础其实是同一个,就是 1000+ 和 100+ 这个集成数量差,决定了两边在不同场景下的顺手程度。

混合架构通常是更稳的落地方式

在真实项目里,很多团队不是非此即彼,而是两边都用。尤其是做 Next.jsNestJS 加编辑器 Agent 产品的团队,第一选择可以是 TS,但不代表全链路都得 TS。因为你真正要解决的问题不是做最前沿的算法实验,而是下面这几件事。

  • 怎么把 agent 接到产品里
  • 怎么和编辑器、接口、鉴权、队列、流式返回结合
  • 怎么把 schema、状态、工具调用、前后端契约统一起来

这些问题上 TS 比 Python 省很多系统复杂度。但一旦涉及重 ETL、重索引、重离线处理,用 TS 去硬啃生态空缺反而不划算。这时候比较实用的做法是把链路拆成两层。

20260422173256

前台产品层和在线 agent 层用 TS,负责直接面向用户的实时请求。重 ETL、重索引、重离线处理的 worker 单独上 Python,吃 Python 那边的生态红利。两条链路通过消息队列或者存储层解耦,互相不干扰。这种架构通常比一开始全 Python 或者强行全 TS 都更稳。

总结

回到最初那个问题。LangChain.js 和 Python 版今天已经站在同一条架构路线上,核心 agent 能力都够用,真正的差别在生态厚度和运行时环境。Python 胜在 1000+ 集成和更厚的 AI 数据生态,JS/TS 胜在和 Web 产品栈的天然贴合以及一套类型贯穿全栈的工程体验。

所以最后给你的结论是这样。做 Web 产品、编辑器、SaaS、Agent 平台这类偏产品交付的系统,优先 TS。做数据实验、检索管线、研究型系统这类偏数据和研究的工作,优先 Python。如果两头都要做,就按用户实时链路和离线数据链路拆开,让 TS 和 Python 各司其职。这样既能吃到 TS 在产品工程上的顺滑,也能吃到 Python 在 AI 生态上的厚度,不用二选一。

前端仔速通 Python

作者 醒来明月
2026年4月23日 17:55

前言

对于很多已经熟悉 JavaScript / Node.js 的开发者来说,学习 Python 往往不是“从零开始”,而是一次语言迁移。你已经掌握了编程的核心能力:如何拆解问题、如何设计数据结构、如何写函数和模块、如何处理异步逻辑、如何组织项目结构。所以学习 Python,真正需要适应的,其实只有两件事:

第一,语法表达方式不同。
第二,Python 社区有自己的一套开发习惯。

这篇文章是站在 JavaScript 开发者的视角,帮助你快速理解 Python 到底该怎么学、怎么写、怎么用。

一、先理解本质

很多人第一次看到 Python,会觉得它和 JavaScript 差别巨大,比如:

// JavaScript
for (let i = 0; i < 5; i++) {
  console.log(i)
}
# Python
for i in range(5):
    print(i)

看起来像两种语言,但本质上它们表达的是同一件事:创建变量 i,循环执行 5 次并输出结果。区别只是:Python 不写花括号、用缩进表示代码块、更强调可读性。如果用一句话总结:JavaScript 更灵活而 Python 更克制。

二、变量与数据类型:概念相同,命名不同

// JavaScript 写法
let name = "Tom"
let age = 18
let price = 9.9
let isAdmin = true
let tags = ["js", "python"]
let user = { name: "Tom", age: 18 }
# Python 写法
name = "Tom"
age = 18
price = 9.9
is_admin = True
tags = ["js", "python"]
user = {"name": "Tom", "age": 18}
JavaScript Python
string str
number int / float
boolean bool
array list
object dict
null None

你需要适应的地方

1. 布尔值大小写不同

True False None
true false null

2. 命名风格不同

# JavaScript 常用:
userName
getUserInfo

# Python 主流写法:
user_name
get_user_info

这叫 snake_case(蛇形命名) ,是 Python 社区默认规范。

三、条件判断:去掉括号和花括号

# JavaScript
if (age >= 18) {
  console.log("成年")
} else if (age >= 12) {
  console.log("青少年")
} else {
  console.log("儿童")
}
# Python
if age >= 18:
    print("成年")
elif age >= 12:
    print("青少年")
else:
    print("儿童")

最大变化有三个:1. 不需要括号、2. 使用冒号开始代码块、3. 使用缩进表示层级。这也是 Python 最核心的语法特征之一。

四、循环:Python 更像自然语言

自然计数循环

// JavaScript
for (let i = 0; i < 5; i++) {
  console.log(i)
}
# Python
for i in range(5):
    print(i)

遍历数组

// JavaScript
for (const item of arr) {
  console.log(item)
}
# Python
for item in arr:
    print(item)

while 循环

// JavaScript
let i = 0
while (i < 5) {
  i++
}
# Python
i = 0
while i < 5:
    i += 1

注意:Python 没有 ++

# 错误写法:
i++

# 正确写法:
i += 1

五、函数:写法更干净

// JavaScript
function add(a, b) {
  return a + b
}
// 或者
const add = (a, b) => a + b
# Python
def add(a, b):
    return a + b
# 或者
add = lambda a, b: a + b

Python 也有简写函数(lambda),但实际开发里,大多数场景仍然推荐正常使用 def。因为更清晰,也更符合 Python 风格。


六、字符串处理

// JavaScript
const name = "Tom"
console.log(`Hello ${name}`)
# Python
name = "Tom"
print(f"Hello {name}")

Python 的叫:f-string,非常常用,也是 Python 字符串格式化的首选方式。

七、数组 map / filter 在 Python 怎么写

// JavaScript map
arr.map(x => x * 2)

// JavaScript filter
arr.filter(x => x > 2)
# Python 推荐写法
arr = [1, 2, 3]
result = [x * 2 for x in arr]
# 筛选
result = [x for x in arr if x > 2]

为什么 Python 喜欢这样写?因为这叫:列表推导式(List Comprehension),它兼顾:简洁、可读性、性能,在 Python 中非常常见。

八、对象在 Python 里叫 dict

// JavaScript
const user = {
  name: "Tom",
  age: 18
}
// 读取
user.name
# Python
user = {
    "name": "Tom",
    "age": 18
}
# 读取:
user["name"]
# 修改:
user["age"] = 20

注意区别,JavaScript 对象很多时候既是数据结构,也是实例对象。

Python 更明确区分:字典(dict)是键值数据结构,类实例(class object)是对象。

九、类与 this:Python 用 self

// JavaScript
class User {
  constructor(name) {
    this.name = name
  }

  say() {
    console.log(this.name)
  }
}
# Python
class User:
    def __init__(self, name):
        self.name = name

    def say(self):
        print(self.name)
        
u = User("Tom")
u.say()

理解 self

你可以把它理解成:Python 把 JavaScript 隐式的 this,变成了显式参数 self。这样做的好处是更直观,也更明确。

十、模块系统:import / export 与 Python 的区别

JavaScript 模块(ES Module)

// 导出
export const name = "Tom"

export function add(a, b) {
  return a + b
}

// 导入
import { name, add } from "./utils.js"

Python 模块系统

Python 没有 export 关键字,一个 .py 文件天然就是一个模块。

# 比如你有一个 utils.py 文件,内容:
name = "Tom"
def add(a, b):
    return a + b
    
    
# 导入模块
import utils
print(utils.name)
print(utils.add(1, 2))

# 解构导入(类似 JS)
from utils import name, add
print(name)
print(add(1, 2))

起别名

import utils as u

from utils import add as plus

对比总结

JavaScript Python
export 默认公开
import xxx from import xxx
import { a } from from xxx import a
import * from xxx import *(不推荐)

Python 为什么没有 export?

因为 Python 默认认为模块中的变量和函数都可以被导入。如果你不想被导入,通常约定写成_internal_var的形式,下划线表示“内部使用”。

十一、文件操作

//  JavaScript
const fs = require("fs")
const text = fs.readFileSync("a.txt", "utf8")
# Python
with open("a.txt", "r", encoding="utf-8") as f:
    text = f.read()

print(text)

with open()会自动关闭文件资源。这是 Python 非常经典的写法。


十二、异常处理

// JavaScript
try {
  run()
} catch (e) {
  console.log(e)
}
# Python
try:
    run()
except Exception as e:
    print(e)

# 多种异常捕获
try:
    pass
except ValueError:
    pass
except TypeError:
    pass

十三、包管理:npm 对应 pip

# JavaScript
npm install axios

# Python
pip install requests

项目依赖文件

JavaScript:package.json

Python 常见:requirements.txtpyproject.toml

十四、如果你是 Node.js 开发者,Python 后端怎么选?

Node.js Python
Express Flask
NestJS FastAPI
Fullstack Framework Django

如果你追求现代开发体验,建议优先学:FastAPI,因为它对 JS 开发者非常友好。

十五、总结

如果你会 JavaScript,学习 Python 并不是重新学编程。你只是在学习另一种更简洁、更稳定、更适合工程和工具开发的表达方式。JavaScript 像一个灵活的年轻人,Python 像一个经验丰富的老工程师,它们并不冲突。

Rsbuild 2.0 发布:即将支持 TanStack Start

作者 WebInfra
2026年4月23日 17:23

我们很高兴地宣布 Rsbuild 2.0 已经正式发布!

Rsbuild 是一个由 Rspack 驱动的现代 Web 应用构建工具,也是 Rstack 生态的重要基础设施。围绕 Rsbuild,我们陆续打造了一系列上层工具,包括 RspressRslibRstestStorybook Rsbuild 等。这些工具通过 Rsbuild 共享统一的构建能力与插件体系,在应用开发、库构建、文档站点以及测试等场景中提供一致的开发体验。

自 1.0 发布以来,Rsbuild 的 npm 周下载量已增长超过 15 倍,并成为 Rspack 新项目的首选构建工具。与此同时,越来越多团队从 webpack、Create React App 等工具迁移至 Rsbuild,并在构建效率和开发体验上获得了提升。

为了帮助生态平稳升级到 2.0,我们投入了三个月进行验证与打磨,期间发布了 20 多个预览版本。目前,Rslib、Rstest、Rspress、Storybook Rsbuild 和 Modern.js 均已完成升级,并在生产环境中稳定运行。

2.0 版本的主要改进包括:

  • 新特性:
    • 升级 Rspack 2.0
    • React Server Components 支持
    • 开发服务器与客户端通信
    • 支持扩展内置 Server
    • 支持自定义 logger
    • 更易用的拆包配置
    • create-rsbuild 模板更新
  • 更轻量:
    • 默认依赖从 13 个减少到 4 个
  • 更安全:
    • 默认仅监听 'localhost'
    • Proxy 中间件升级,支持 HTTP/2 代理
  • 更现代:
    • Pure ESM 包
    • 不再支持 Node.js 18
    • 默认目标环境更新
    • 默认输出 ESM Node.js 产物
    • 默认使用 '2023-11' 装饰器版本

升级 Rspack 2.0

Rsbuild 2.0 基于 Rspack 2.0 实现,因此也继承了 Rspack 2.0 在构建性能、产物优化和底层能力上的一系列改进。

参考 Rspack 2.0 博客 了解这部分变更。

React Server Components 支持

React Server Components (RSC) 是一种预先渲染的 React 组件类型,它将数据获取与组件逻辑结合起来,并减少发送到客户端的 JavaScript。

为了帮助基于 Rsbuild 的 Web 应用或框架更便捷地使用 RSC,我们提供了 rsbuild-plugin-rsc 插件。该插件基于 Rspack 内置的 RSC 能力实现,并借助 Rsbuild 的 Environments API 对 client 与 server 等多环境进行统一组织,降低了接入与配置成本。

import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginRSC } from 'rsbuild-plugin-rsc';

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginRSC({
      // Plugin options
    }),
  ],
  environments: {
    server: {
      // Server config...
    },
    client: {
      // Client config...
    },
  },
});

目前该插件仍处于实验阶段。它已经能够运行 React Router 的 RSC 示例,也已经在 Modern.js 框架 中落地使用。

另外,我们也在与 TanStack 团队展开合作,计划在后续版本中提供对 TanStack StartTanStack 的 RSC 的支持。TanStack Start 是一个基于 TanStack Router 构建的全栈框架,我们非常期待结合双方的能力,共同探索 RSC 在不同场景下的更多可能性。

开发服务器与客户端通信

在支持 React Server Components 的过程中,我们发现一些场景需要在开发服务器与浏览器之间进行通信。例如,服务端完成某些操作后,需要主动通知客户端执行对应逻辑。

为此,Rsbuild 2.0 提供了一组通信 API:

  • 服务端可通过 hot.send 向当前 environment 对应的客户端发送消息
  • 客户端可通过 import.meta.webpackHot.on 监听这些自定义事件

这些 API 复用了现有的 HMR 通道,无需额外创建 WebSocket 连接。同时,消息仅会发送到匹配的 environment,避免不必要的广播。

例如,当服务端状态发生变化时,通知客户端更新,而不是触发整页刷新:

  • 在服务端触发消息:
// rsbuild.config.ts
server.environments.web.hot.send('data-change', {
  count: 1,
});
  • 在客户端监听消息:
if (import.meta.webpackHot) {
  import.meta.webpackHot.on('data-change', ({ count }) => {
    console.log('data updated:', count);
  });
}

扩展内置 Server

Rsbuild 2.0 新增了 server.setup 选项,用于在开发服务器或预览服务器启动时执行初始化逻辑。

该选项相较于原有的 server.setupMiddlewares 更为强大,用于对 Rsbuild 内置服务器进行定制,例如注册中间件、执行启动前任务,或根据 dev / preview 模式注入不同逻辑。通过 server.setup,这些能力可以直接在 Rsbuild 配置中完成。

例如,为本地开发和预览环境添加一个简单的接口:

// rsbuild.config.ts
export default {
  server: {
    setup: ({ server }) => {
      server.middlewares.use((req, res, next) => {
        if (req.url === '/api/health') {
          res.end('ok');
          return;
        }
        next();
      });
    },
  },
};

支持自定义 logger

通过新增的 customLogger 选项,你可以为多个 Rsbuild 实例自定义不同的 logger。

这允许你为不同 Rsbuild 实例设置不同的日志级别、输出前缀,或者接入自定义的日志系统,而无需修改 全局 logger 实例

// rsbuild.config.ts
import { createLogger, defineConfig } from '@rsbuild/core';

const customLogger = createLogger({
  level: 'warn',
  prefix: '[web]',
});

export default defineConfig({
  customLogger,
});

查看 日志指南 了解更多。

更易用的拆包配置

在 1.x 中,Rsbuild 通过 performance.chunkSplit 封装了常见的拆包策略,但它的设计与 Rspack 的 splitChunks 差异较大,开发者需要额外理解 strategyforceSplitting 等概念。对于 coding agent 来说,也难以直接生成符合社区习惯的 splitChunks 配置,通常还需要进行额外转换。

因此,Rsbuild 2.0 提供了新的 splitChunks 选项。它的行为与 Rspack 的 splitChunks 完全对齐,并通过额外的 preset 选项来提供预设配置。

例如,使用 per-package 预设将每个 package 拆分成一个独立的 chunk:

// rsbuild.config.ts
export default {
  splitChunks: {
    preset: 'per-package',
    chunks: 'all',
  },
};

performance.chunkSplit 已在 2.0 中废弃,但现有配置仍可继续使用。建议参考 迁移 performance.chunkSplit 进行迁移。

create-rsbuild 模板更新

在核心能力升级的同时,我们也更新了 create-rsbuild 中的模板,使新项目的初始化流程更加贴近当前的开发实践:

  • 默认生成 AGENTS.md 文件,并支持在初始化时安装 rsbuild-best-practices 等 Agent Skills。
  • 创建 React 项目时,可以选择 React Compiler 作为可选工具。
  • 新增对 Rslint 的实验性支持,Rslint 是基于 typescript-go 的高性能代码检查工具。
  • 移除过时的 React 18 和 Vue 2 模板。

精简依赖

Rsbuild 2.0 对默认依赖进行了精简,将仅在特定场景下使用的包移出默认依赖,使默认依赖的数量从 13 个减少到 4 个,安装体积约减少 2 MB。

本次调整主要涉及:

默认 host 变化

server.host 的默认值从 '0.0.0.0' 调整为 'localhost'。开发和预览服务器默认仅监听本机,不再对局域网内的其他设备开放。

这一调整遵循「默认安全」的原则。在大多数本地开发场景中,开发服务器无需对外暴露。仅监听本机地址可以减少意外暴露,降低在共享网络环境中被扫描或攻击的风险。

如果你需要在局域网设备上访问页面,可以显式开启网络访问:

// rsbuild.config.ts
export default {
  server: {
    host: '0.0.0.0',
  },
};

也可以通过 CLI 的 --host 参数快速开启:

rsbuild --host

Proxy 中间件升级

开发服务器使用的 http-proxy-middleware 已经从 v2 升级至最新的 v4 版本,同时其底层依赖从已经停止维护的 http-proxy 切换为由 unjs 社区 积极维护的 httpxy

这主要带来几点改进:

  • 支持 HTTP/2 代理
  • 解决已知的安全问题
  • 不再依赖 Node.js 已废弃的 url.parse() API

server.proxy 的部分字段已发生变更,升级时请参考 从 v1 升级到 v2

Pure ESM 包

@rsbuild/core 现在以 pure ESM 包的形式发布,并移除了自身的 CommonJS 构建产物。这一调整仅影响 Rsbuild 本身的发布形式,使安装体积减少了约 500KB。

在 Node.js 20 及以上版本中,运行时已原生支持通过 require(esm) 加载 ESM 模块。因此,对大多数仍通过 JavaScript API 使用 Rsbuild 的项目来说,这一变更通常不会带来实际影响,也无需额外修改现有代码。

Node.js 支持

从 2.0 开始,Rsbuild 最低支持的 Node.js 版本为 20.19+22.12+。由于 Node.js 18 已于 2025 年 4 月底结束维护,2.0 也不再继续支持该版本。

我们通常会在某个 Node.js 版本进入 EOL 约一年后再移除支持,以为社区和用户预留更充足的升级时间。

默认目标环境更新

Rsbuild 2.0 调整了默认的目标环境,使产物面向更现代的浏览器和 Node.js 版本。

对于 Web 产物,默认的 browserslist 现在对齐到 Baseline 的广泛可用范围。这里选取的是 2025-05-01 对应的目标集,表示截至该时间点已被主流浏览器广泛支持的 Web 平台能力。

默认值变化如下:

  • Chrome 87 → 107
  • Edge 88 → 107
  • Firefox 78 → 104
  • Safari 14 → 16

这意味着在未显式配置 browserslist 的情况下,Rsbuild 会默认输出更现代的 JavaScript 和 CSS,同时减少语法降级和 polyfill 的引入。

对于 Node.js 产物,默认目标版本也从 Node.js 16 提升至 Node.js 20。

如果你已经通过 .browserslistrcpackage.json#browserslistoutput.overrideBrowserslist 显式配置了目标环境,则不会受到上述调整的影响。

ESM Node.js 产物

在构建 Node.js 产物时,相比 Rsbuild v1 默认输出压缩后的 CommonJS 代码,Rsbuild 2.0 现在会默认输出未压缩的 ES modules 代码。

这一调整更符合现代 Node 应用的主流实践。同时,服务端代码默认不压缩,有助于保留清晰的调试堆栈,提升问题排查效率。

需要注意的是,运行时需要具备加载 ESM 的能力。例如在 package.json 中设置 "type": "module",或者使用 .mjs 作为输出文件扩展名。如果你的项目仍依赖 CommonJS,可以显式切回原有行为:

// rsbuild.config.ts
export default {
  output: {
    target: 'node',
    module: false,
    minify: true,
  },
};

装饰器版本更新

随着底层 SWC 支持 2023-11 装饰器版本,Rsbuild 将 decorators.version 默认值从 2022-03 调整为 2023-11

2023-11 是当前最新的提案版本,对应 2023 年 11 月 TC39 会议后的规范,同时也是 Babel 8 的默认行为。如果你需要保留旧行为,可以显式指定版本:

// rsbuild.config.ts
export default {
  source: {
    decorators: {
      version: '2022-03',
    },
  },
};

升级至 Rsbuild 2.0

对于大多数项目来说,升级到 Rsbuild 2.0 是一个相对平滑的过程。尽管 2.0 引入了一些默认行为调整和不兼容变更,但大多数变更都提供了清晰的迁移路径,通常无需修改业务代码。

如果你正在使用支持 skills 的 coding agent,可以安装 rsbuild-v2-upgrade skill,由 agent 自动协助完成依赖升级、配置调整和迁移检查,减少手动操作成本。

npx skills add rstackjs/agent-skills --skill rsbuild-v2-upgrade

完整的升级指南及所有不兼容变更,请参考 从 v1 升级到 v2

致谢

Rsbuild 由 Rstack 团队主导开发,同时也离不开社区贡献者与所有用户的共同参与。自 1.0 发布以来,许多开发者通过贡献推动了 Rsbuild 的演进,在此感谢所有参与其中的朋友:

@9aoy@adammark@ahabhgk@alexUXUI@bodia-uz@Brennvo@caohuilin@Cheese-Yu@chenjiahan@Chevindu@Colin3191@colinaaa@CPunisher@davide97g@Deku-nattsu@DeveshSapkale@dovigod@Draculabo@easy1090@escaton@fansenze@fi3ework@gaoachao@GiveMe-A-Name@GRAMMAC1@hai-x@hangCode2001@hardfist@hasnum-stack@htoooth@Huxpro@ianzone@iceprosurface@inottn@jerrykingxyz@jkzing@JounQin@JSerFeng@JSH-data@junhea@junxiongchu@lguzzon@LingyuCoder@lluisemper@lxKylin@mhutter@miownag@mycoin@nikhilsnayak@notzheng@Nsttt@nyqykk@puxiao@qmakani@quininer@RobHannay@roli-lpci@s-chance@s-r-x@sagar-dwivedi@Sang-Sang33@schu34@ScriptedAlchemy@Shucei@shulaoda@Simon-He95@slobo@snatvb@SoonIter@stormslowly@SyMind@T9-Forever@thinkasany@Timeless0911@TinsFox@valorkin@vegerot@VenDream@wangi4myself@wChenonly@wjw99830@wralith@wxiaoyun@xbzhang2020@xc2@xettri@xiaohp@xuexb@xun082@yifancong@ymq001@zackarychapple@zalishchuk@zoolsher

OpenMUSE 全面详解:非扩散Transformer文生图开源基座(对标GPT Image 2)

2026年4月23日 17:11

大家好,我是安东尼(tuaran.me),一名专注于前端与 AI 工程化的独立开发者。

我在建设 「博主联盟」——连接AI产品方与技术博主的品牌增长平台,帮AI产品精准触达开发者,也帮博主拿到推广资源与成长机会。

同时也在做 「前端下一步」——一个聚焦前端、AI Agent 与大模型的技术情报站,帮你从技术革新焦虑中解脱,得到技术转向判断。

这篇文章,希望对你有所启发。

26d4ed63-b351-4c43-a7e8-aa8fd7e0f7a4.png

一、前言

当前主流文生图模型(Stable Diffusion、DALL·E系列)均基于Diffusion扩散架构,普遍存在文字渲染崩坏、构图逻辑差、推理步骤多、上下文语义丢失等痛点。而OpenAI最新闭源生图模型GPT Image 2彻底抛弃扩散路线,采用Transformer自回归Token生成范式,在密集文字、复杂构图、现实世界还原上实现断层领先,但全程闭源无法本地部署与二次改造。

Hugging Face开源的OpenMUSE,是目前开源社区最贴近GPT Image 2技术路线的原生Transformer文生图基座,基于Google原始MUSE掩码生成范式重构,全代码、权重开源,支持本地私有化部署、企业二次微调,是自研数字员工智绘模块、通用AI绘图能力建设的优选底层底座。

二、OpenMUSE 基础简介

2.1 模型溯源

OpenMUSE 为 Hugging Face 官方开源复现项目,完整复刻 Google MUSE 论文 MaskGit 掩码Transformer文生图方案。

  • 项目仓库:github.com/huggingface…
  • 开源协议:Apache 2.0,允许本地部署、商用、闭源二次改造、领域微调,无版权风险
  • 训练数据集:基于 LAION-2B、COYO-700M 大规模图文数据预训练
  • 社区轻量衍生版:aMUSEd,大幅降参降显存门槛,工业落地首选

2.2 核心定位

非扩散、纯Transformer序列生成文生图模型,完全摒弃Diffusion去噪管线,以离散视觉Token为媒介完成图像生成,天生解决扩散模型文字差、构图乱、语义脱节的原生缺陷,是对标闭源GPT Image 2架构路线的最优开源备选。

三、模型架构与生成原理

OpenMUSE 整体流水线无Unet、无多步扩散去噪,全程分为三大模块,链路简洁可控:

文本Prompt → CLIP文本编码器 → MaskGit Transformer主干 → VQGAN编解码 → 输出图像

3.1 模块拆解

  1. 文本编码层
    采用CLIP-L/14文本编码器,完成自然语言提示词语义向量化,完成基础图文对齐。
  2. 主干网络:MaskGit Transformer
    模型核心模块,掩码Token预测机制:先初始化掩码图像Token序列,多轮迭代逐步还原有效视觉Token,属于离散序列生成范式。
    对比扩散模型多步噪声迭代,OpenMUSE推理步数更少、画面布局一致性更强、空间结构逻辑更严谨。
  3. VQGAN 视觉编解码
    实现离散图像Token与像素图像的双向转换,将Transformer生成的Token序列还原为可视化图片,同时支持图像压缩与分辨率适配。

3.2 核心生成差异(vs 扩散模型SD/DALL·E)

对比维度 OpenMUSE(MaskGit Transformer) Stable Diffusion 扩散模型
底层架构 纯Transformer掩码序列生成 隐空间扩散+多步去噪迭代
推理步数 少步快速生成,无冗余迭代 20~50步采样,推理速度慢
文字渲染能力 原生Token级排版,文字不易崩坏 像素拟合,密集文字极易模糊错乱
构图可控性 全局布局规划,实体一致性高 局部像素生成,空间逻辑易混乱
可解释性 高,Token生成过程可追溯 低,去噪黑盒难以溯源
微调成本 轻量化易微调,小样本适配快 训练成本高,领域适配繁琐

四、参数量与硬件部署要求

4.1 官方权重参数量

  • OpenMUSE Base(256×256):1.2B 参数
  • OpenMUSE Large(512×512):1.5B 参数
  • 社区轻量版 aMUSEd:800M 参数,消费级显卡友好

4.2 本地部署硬件门槛(实测)

原版 OpenMUSE

  • 最低显卡:RTX 3090 / A10 24G 显存
  • 推荐显卡:RTX 4090、A100 40G
  • 显存占用:18~22GB
  • 推理速度:512×512 图像 8~15s/张

轻量版 aMUSEd(工业落地首选)

  • 最低显卡:RTX 3060 12G 即可本地离线运行
  • 显存占用:8~11GB,支持4/8bit量化压缩
  • 推理速度:512×512 图像 4~7s/张
  • 部署环境:Python 3.9+、PyTorch 1.13.1、CUDA 11.7,支持Linux、Windows、Docker容器化部署

五、OpenMUSE 优缺点全解析

5.1 优势亮点

  1. 架构路线对标GPT Image 2
    同属非扩散Transformer生成范式,从根源解决扩散模型文字崩坏、构图混乱痛点,契合自研智绘官通用出图、海报UI、图文排版场景需求。
  2. 全开源私有化可控
    代码、预训练权重、训练脚本完整开源,数据不出内网,支持深度二次改造、模块插拔、中文增强训练。
  3. 生成可控性强
    掩码序列生成机制带来稳定的画面布局、实体比例、空间结构,适合标准化业务素材生成。
  4. 轻量化易微调
    1.5B以内小参数量,普通算力集群即可完成领域微调、中文数据集增强、业务风格定制。
  5. 社区生态完善
    拥有量化方案、中文微调分支、VQGAN替换优化、推理加速工具,工业改造资料齐全。

5.2 现存短板

  1. 无MoE稀疏架构:稠密Transformer主干,无多专家任务分流,复杂多任务上限低于GPT Image 2。
  2. 无原生多模态思维链:仅文生图能力,缺少前置构图推理、联网校验、多图连贯生成模块。
  3. 原生中文能力薄弱:预训练以英文图文数据为主,密集中文、小字排版仍需额外微调优化。
  4. 分辨率上限较低:原生最高仅支持512×512,无原生4K超清输出能力。
  5. 现实常识知识匮乏:无真实商品、品牌、物理世界知识绑定,写实物体还原精度有限。

六、快速本地部署命令

# 1. 克隆官方开源仓库
git clone https://github.com/huggingface/open-muse.git
cd open-muse

# 2. 安装依赖环境
pip install -e ".[extra]"

# 3. 自动下载Hugging Face预训练权重,本地Pipeline推理
# 无需云端API,完全离线本地运行

七、自研落地应用总结(结合数字员工智绘模块)

GPT Image 2 全程闭源、仅API调用、无法私有化部署,OpenMUSE 是当前开源领域最优对标基座
结合企业数字员工应用中心建设,自研改造路线清晰:

  1. 选用aMUSEd轻量版完成本地私有化底座部署;
  2. 接入中文编码器与文字排版增强模块,补齐原生中文渲染短板;
  3. 外挂开源视觉思维链模块,增加前置构图规划能力,对标GPT Image 2思考生成机制;
  4. 基于内部业务素材做领域微调,适配通识海报、UI素材、常规图文出图需求。

八、总结

OpenMUSE 打破了扩散模型垄断,以Transformer掩码生成开辟开源文生图新路线,凭借全开源、本地可部署、可控可微调、构图文字原生优势,成为企业自研AI绘图、数字员工智绘能力建设的优质底层基座。虽在大模型融合、超高分辨率、深层世界知识上仍有短板,但通过模块外挂、领域微调即可补齐业务缺口,完美适配中小团队低成本自研对标闭源顶尖生图模型的技术需求。

❌
❌