普通视图

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

这个 GitHub 项目很有意思啊,解了死磕30 年的前端难题。

作者 逛逛GitHub
2026年4月8日 14:05

最近前端圈被一个叫 Pretext 的开源项目刷屏了。

它的作者是前 React 核心开发者,之前做过 react-motion 那个 21.9K Star 的项目。

这次他搞了个新东西,发布 3 天 Star 数就超过了 react-motion。

推文 1600 万浏览、6.4 万赞,X 上相关讨论帖子超 6.8 万条。

现在热度开始蔓延到国内了。

我花时间研究了一下,确实挺顶的。

01、开源项目简介

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量与布局库。

说直白一点,它解决的问题是:

在不碰 DOM 的情况下,精确算出一段文字在给定宽度下会有多高。

先来看看效果,下面这个视频是我使用和这个库做的网页效果。

只能说,非常丝滑。

开源地址:https://github.com/chenglou/pretext

02、牛在哪里

多行文本灵活布局库,听起来好像不是什么大事。

但这个问题在 Web 开发里已经存在 30 年了。

之前想测文字高度,只能靠 getBoundingClientRect 或者 offsetHeight。

但这些操作会强制浏览器重新计算整个页面布局,代价非常大。

在虚拟滚动列表、聊天界面、瀑布流这种需要频繁测量的场景里,性能直接拉胯。

Pretext 目前已经斩获 3.4 万+ Star,体积才 15KB。

这个项目能这么火不是没原因的,几个核心设计确实有意思。

两阶段架构,性能炸裂

Pretext 把文本测量拆成了两步:

第一步是 prepare(text, font), 负责分词、处理双向文本、用 Canvas measureText 测量每个片段的宽度并缓存。这一步相对重一些,500 段文本大概 19ms。

第二步是 layout(prepared, maxWidth, lineHeight), 基于缓存的宽度做纯算术运算,算换行后的总高度。这一步极轻,500 段文本才 0.09ms。

重点来了:窗口大小变化的时候,只需要重新跑第二步就行。

prepare 的缓存还在,直接用算术就能算出新高度。

比传统 DOM 测量快了 200 倍以上。

全语言支持,准确率 100%

这个库不是只测英文字母的。中文、日文、韩文啥的全都能处理。

而且在 Chrome、Safari、Firefox 三个浏览器上的准确率都是 7680/7680,也就是 100%。

做全语言文本渲染的人都知道这有多难。

所以一经发布,Star 数量就蹭蹭的涨。

03、两种使用场景

一个是预测文本高度。

不需要碰 DOM 就能知道文字多高,虚拟滚动列表、瀑布流布局、聊天气泡 shrink-wrap、防止布局偏移,这些场景都能用上。

二是手动逐行布局。

可以拿到每行文字的精确坐标和内容,然后渲染到 Canvas、SVG、WebGL 上,甚至可以实现文字绕障碍物流动这种高级效果。

你最近刷到的流体烟雾 ASCII 艺术、摄像头追踪人脸做文字避让、物理球碰撞改变文字排列、Mario 游戏 ASCII 文字版,各种花活都有。

这是一个 AI 辅助开发的标杆案例。

开发者 Cheng Lou 在开发过程中大量使用了 Claude Code 和 Codex,让 AI 在几十种容器宽度下对比浏览器的真实渲染结果,然后自动修正差异。

Hacker News 上很多人专门聊这一点,说这是 AI 编程的完美范例。

Simon Willison 也专门写博文推荐,特别称赞了它的测试方法,用整本了不起的盖茨比做跨浏览器对比。

04、部署与使用

安装很简单:

npm install @chenglou/pretext

或者用 bun:

bun add @chenglou/pretext

基础用法:预测文本高度

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI 春天到了.             🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)
// height 就是精确的像素高度,全程没碰 DOM

高级用法:手动逐行布局

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('一些文本', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

本地跑 Demo

git clone https://github.com/chenglou/pretext.git
cd pretext
bun install
bun start

然后浏览器打开 http://127.0.0.1:3000/demos 就能看到了。

这个不碰 DOM 就能预测文本高度的能力。

虚拟滚动、瀑布流、聊天 UI,这些天天在用的东西,以前要么牺牲性能去量,要么用估算值凑合。

现在有个 15KB、零依赖、100% 准确率的方案摆在这里,拿来就能用。

你的JS代码总在半夜崩溃?TypeScript来“上保险”了

作者 kyriewen
2026年4月8日 13:50

你有没有经历过:凌晨三点,线上报“Cannot read property 'name' of undefined”,你爬起来一看,原来是后端返回的数据少了一层。如果JS有“类型检查”,这种悲剧根本不会发生。今天我们就来认识TypeScript——给JavaScript买了一份“意外险”。

前言

JavaScript就像个自由散漫的天才:你给它一个字符串,它当数字用;你忘记传参数,它给你个undefined;你访问对象不存在的属性,它笑眯眯地说“没事,我给你undefined”。这种灵活在小型项目里很爽,但项目一大,就成了噩梦。

TypeScript(简称TS)就是来解决这个问题的。它给JS加上了类型系统,在代码运行之前就帮你检查类型错误。就像给代码装了安检门,不规范的写法根本过不去。

一、TypeScript是啥?JS的“严格模式”Pro Max

TypeScript是微软开发的开源语言,它是JavaScript的超集。意思是:所有合法的JS代码,在TS里也合法。TS只是给JS加了类型注解和一些新特性,然后编译成干净的JS。

// JS写法
function greet(name) {
  return 'Hello, ' + name;
}

// TS写法(加了类型)
function greet(name: string): string {
  return 'Hello, ' + name;
}

greet(123); // ❌ 报错:参数不能是数字

你看,TS在编译阶段就抓住了错误,不用等到运行时。

二、为什么要用TS?三个字:稳、爽、香

  • :类型错误在写代码时就暴露,而不是在用户手里炸。
  • :编辑器智能提示飞起,不用记方法名、参数顺序。
  • :代码即文档,看函数签名就知道怎么用。

据统计,使用TS的项目,早期Bug能减少15%~25%。对于中大型项目,TS几乎是标配。

三、基础类型:TS的“基本词汇”

TS支持JS的所有类型,还加了一些新的。

1. 原始类型

let name: string = '张三';
let age: number = 18;
let isStudent: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol('id');

2. 数组

let list1: number[] = [1, 2, 3];
let list2: Array<string> = ['a', 'b'];  // 泛型写法

3. 元组(固定长度和类型的数组)

let person: [string, number] = ['张三', 18];
person[0] = '李四';  // OK
person[1] = '20';   // ❌ 报错,第二个元素必须是数字

4. 枚举(给一组数字起名字)

enum Color { Red, Green, Blue }
let c: Color = Color.Red;
console.log(c); // 0(默认从0开始)

// 自定义值
enum Status { Success = 200, NotFound = 404 }

5. Any(万能类型,慎用)

let notSure: any = 4;
notSure = '字符串';  // OK
notSure = true;      // OK

any会关闭类型检查,相当于回到JS。尽量少用,除非你确定这个值无法预知类型。

6. Unknown(安全的Any)

let value: unknown = 'hello';
value = 123;  // OK
// console.log(value.toUpperCase()); // ❌ 报错,unknown不能直接调用方法
if (typeof value === 'string') {
  console.log(value.toUpperCase()); // 类型收窄后可用
}

unknownany安全,因为使用前必须先判断类型。

7. Void(没有返回值)

function warnUser(): void {
  console.log('警告');
}
// 变量声明为void类型只能赋值为null或undefined(strict模式下只能undefined)

8. Never(永远不会发生的类型)

function error(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {}
}

四、类型注解:给变量贴标签

TS的核心就是类型注解:在变量、函数参数、返回值后面加上: 类型

let myName: string = '张三';
function add(a: number, b: number): number {
  return a + b;
}

但TS很智能,很多时候可以类型推断,不用显式写:

let age = 18; // TS自动推断为number
age = '18';   // ❌ 报错

五、接口(Interface):定义对象的形状

接口是TS里最常用的功能,用来描述对象的结构。

interface Person {
  name: string;
  age: number;
  email?: string;  // 可选属性
  readonly id: number; // 只读属性
}

const zhangsan: Person = {
  name: '张三',
  age: 18,
  id: 1
};
zhangsan.id = 2; // ❌ 报错,只读属性不能改

接口还可以描述函数类型:

interface AddFunc {
  (a: number, b: number): number;
}
const add: AddFunc = (x, y) => x + y;

六、类型别名(Type):给类型起外号

类型别名和接口很像,但能表示联合类型、元组等更复杂的类型。

type ID = string | number;  // 联合类型
type Point = [number, number]; // 元组
type Callback = (data: string) => void;

let userId: ID = 123;
userId = 'abc';

接口 vs 类型别名

  • 接口可以扩展(extends),类型别名用交叉(&)。
  • 接口可以重复定义自动合并,类型别名不能重复。
  • 推荐优先用接口描述对象,用类型别名描述联合、元组等。

七、实战:用TS写一个简单的函数

// 需求:格式化用户信息
interface User {
  name: string;
  age: number;
  address?: string;
}

function formatUser(user: User, withAddress: boolean = false): string {
  let base = `${user.name}, ${user.age}岁`;
  if (withAddress && user.address) {
    base += `, 地址:${user.address}`;
  }
  return base;
}

const u: User = { name: '李四', age: 20, address: '北京' };
console.log(formatUser(u, true)); // "李四, 20岁, 地址:北京"

如果你在编辑器里打formatUser(,它会提示参数类型和返回值类型,爽不爽?

八、常见坑点与建议

  1. 不要滥用any:any越多,TS的价值越低。实在不知道类型,先写unknown
  2. 严格模式:开启strict: true(tsconfig.json),让TS更严格地检查。
  3. 第三方库:大多数库都有@types/xxx类型定义,安装后就能获得智能提示。
  4. 编译后的JS:TS只负责编译时检查,运行时还是JS,类型信息会被擦除。

九、总结:TS不是敌人,是保镖

  • 给JS加上类型,提前发现错误。
  • 基础类型、接口、类型别名是核心工具。
  • 用好类型推断,少写冗余注解。
  • 逐步迁移老项目,从.js改成.ts,开启allowJs: true

学TS并不难,你只需要把“写JS时的心理预期”明确写出来。明天我们继续深入TypeScript,聊聊高级类型——泛型、联合类型、交叉类型、类型保护,让你写出更灵活更安全的代码。

如果你觉得今天的“保险课”够实在,点个赞让更多人看到。我们明天见!

还原设计稿生成前端代码

作者 WebGirl
2026年4月8日 13:29

VScode插件【GitHub Copilot】+ Figma MCP还原设计稿生成前端代码

Cursor+Figma MCP的教程已经很多了,由于我用的vscode中的GitHub Copilot ,研究了一下直接在vscode里利用GitHub Copilot接入Figma MCP进行设计稿还原代码,大获成功,记录~

step1.方式1:让AI给你配置MCP

在vscode中打开项目,呼出github copilot 对话框,模式选择Agent,模型我用的是GPT-5.4,输入对话内容:

https://github.com/GLips/Figma-Context-MCP 如何配置能让你在vscode里使用这个mcp

之后跟着提示狂点下一步即可完成配置,如果有什么需要装的vscode插件它会自动帮你装,甚至自动生成了配置说明文档。

Figma-Context-MCP是一个为AI编程工具(如 Cursor, Windsurf)和大型语言模型(LLM)搭建的“桥梁”或“通用适配器”。它能自动读取Figma设计文件里的布局、样式和层级信息,转换成AI容易理解的结构化数据,让AI在写代码时能真正“看懂”设计稿,从而大幅提升从设计到代码的转换效率和还原度

mcp.json

{
"servers": {
"framelinkFigma": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"figma-developer-mcp",
"--stdio"
],
"env": {
"FIGMA_API_KEY": "${input:figma-token}"
}
},
"com.figma.mcp/mcp": {
"type": "http",
"url": "https://mcp.figma.com/mcp",
"gallery": "https://api.mcp.github.com",
"version": "1.0.3"
}
},
"inputs": [
{
"type": "promptString",
"id": "figma-token",
"description": "Figma Personal Access Token",
"password": true
}
]
}

step1.方式2:当然也可以手动安装插件完成配置

  • 在VS Code Copilot中设置对应的MCP配置

首先确保MCP发现的功能是开着的,在VS Code中打开设置(Ctrl+,或者Cmd+,), 输入chat.mcp确认Discovery是Enabled.

image.png

  • 在extentions中输入@mcp figma可以找到官方对于figma的访问支持

image.png

注意其中的Install是安装到VSCode 的整体目录下, Install in Workspace是安装到当前项目下,可以根据您的情况选择,建议选择Install in Workspace

选择Install in Workspace之后可以在当前项目.vscode/mcp.json下看到具体的Figma配置(也可以忽略上述的步骤,自己直接新建mcp.json文件然后输入详细的配置)

// 默认生成的,当前不可用
"com.figma.mcp/mcp": {
    "type": "http",
    "url": "https://mcp.figma.com/mcp",
    "gallery": "https://api.mcp.github.com",
    "version": "1.0.3"
}

注意这个是默认生成的配置,截止笔者发稿时,这个配置不可用,会报错。将协议和url改为sse依然不可用。需要改为如下的stdio的配置

//改为stdio格式,当前可用
"figma": {
    "command": "npx",
    "args": [
        "-y",
        "figma-developer-mcp",
        "--stdio"
    ],
    env": {
        "FIGMA_API_KEY": "您的Figma API Token"
    },
    "type": "stdio"
},

配置之后就可以在工具中具体看到Figma了

step2:替换自己的figma密钥

打开Figma的网页点击左上角自己的头像 -> settings -> Security -> Generate new token 设置路径可能会有变化,自己到处点点找到Generate new token就对了 找到点击之后会出现下面这个弹窗,随便起个名字比如mcp,然后把下面的权限列表一个个打开选择读或写,要不然默认是全部No access的。 注意默认是30天过期,30天后需要建一个新的才能继续用。

image.png

都选完之后点右下角的generate token之后会生成一个密钥,这是你唯一一次复制它的机会,没复制好就关掉窗口了就只能重新建了。把这个密钥复制到mcp.json文件中–figma-api-key=后面。

保存后,VS Code 一般会提示你启动或信任这个 MCP 服务。

如果没有自动启动,用命令面板执行(command + shift + p):

  • MCP: List Servers
  • 选中 framelinkFigma
  • Start 或 Restart
  • 如果提示 Trust,确认信任

第一次启动这个MCP服务,会提示你输入token,将刚保存好的figma的token复制到相应位置即可。

配置成功后,你在 Copilot Chat 里直接发:

  • 实现这个 Figma 链接对应的页面
  • 后面附上 Figma 的 frame 或 node 链接 如果 MCP 正常,我就能调用它读取设计数据,而不是只能看截图。

step3: 如何使用

在Figma设计图上选中你要的部分图层,右键后点击Copy link to selection

image.png

之后就可以把链接贴到对话框了,先来测试一下配置是否成功了,确保模式是Agent,提问:

https://www.figma.com/design/GJZhGih0VsGbpevJGkJQ9Z/E-commerce-UI—Figma-Ecommerce-UI-Kit–Demo-Version—Community-?node-id=2804-7985&m=dev 现在你能读到这个设计图了吗

image.png

出现这样的弹窗说明Agent在尝试链接MCP server了,点继续(也可以点击右边的箭头在当前会话中允许操作就不用每次都手动点了),过一会儿可以看到它的描述,说明设计图被读到了,我们的配置生效了。

读取成功后,可以让他写代码了:

请根据这个设计图在我的微信小程序里生成商品卡片组件的代码,注意微信小程序中2rpx=1px,要完全还原设计图的UI,再建一个测试页面展示这个组件的调用效果,可以参考微信小程序官方文档https://developers.weixin.qq.com/miniprogram/dev/api/

对比设计图,指出哪里还原度不够,让它进一步优化,客客气气的。

看上去有一些UI细节不够还原,比如卡片的内边距,还有按钮的布局,请你再仔细检查一下。
商品图片上的三个icon按钮应该是水平居中的,learn more按钮应该是水平居左的。另外你能不能直接下载设计图里的icon为svg来使用,这样更还原。

面试题里的 Custom Hook 思维:从三道题总结「异步状态管理」通用模式

作者 yuki_uix
2026年4月8日 13:23

最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。


三道题,三个场景

先简单描述一下这三道题在考什么:

  • useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
  • useExpenseFilter:报表筛选 Hook,多维联动筛选,需要 useMemo 优化
  • useEmployeeSearch:员工搜索,防抖 500ms + AbortController 取消请求

三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?


归纳出的通用思维框架

在我看来,一个合格的 Custom Hook 需要从四个维度去思考:

1. 状态层(State)     ── 管什么数据?
2. 副作用层(Effect)  ── 什么时候做什么?
3. 清理层(Cleanup)   ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?

下面逐层展开,结合题目来理解。


第一层:状态层 — 先想清楚「管什么」

拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?

这三道题都有一个共同的「三元组」:

// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

除了这个骨架,每道题还有「额外状态」:

  • useRideTracking:需要 failCount(连续失败次数),但这是内部状态,不对外暴露
  • useExpenseFilter:需要 filters 对象,并对外暴露 setFilter / resetFilters
  • useEmployeeSearch:需要 keyword,并对外暴露 setKeyword

一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。

// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);

第二层:副作用层 — 明确「触发时机」

useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。

模式 A:挂载即执行 + 定时触发(useRideTracking)

// 环境:React 18+
// 场景:行程状态轮询

function useRideTracking(rideId) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const failCountRef = useRef(0);
  const timerRef = useRef(null);
  const stoppedRef = useRef(false);

  const fetchStatus = async () => {
    if (stoppedRef.current) return;

    setLoading(true);
    try {
      const res = await fetch(`/api/rides/${rideId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      setStatus(data.status);
      setError(null);
      // success: reset fail counter
      failCountRef.current = 0;
    } catch (err) {
      failCountRef.current += 1;
      if (failCountRef.current >= 3) {
        stoppedRef.current = true;
        setError(err);
        setLoading(false);
        clearInterval(timerRef.current);
        return;
      }
    } finally {
      if (!stoppedRef.current) setLoading(false);
    }
  };

  useEffect(() => {
    // fetch immediately on mount
    fetchStatus();
    timerRef.current = setInterval(fetchStatus, 5000);

    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        clearInterval(timerRef.current);
      } else {
        fetchStatus(); // refetch immediately on visible
        timerRef.current = setInterval(fetchStatus, 5000);
      }
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      clearInterval(timerRef.current);
      document.removeEventListener('visibilitychange', handleVisibility);
      stoppedRef.current = true;
    };
  }, [rideId]);

  return { status, loading, error };
}

这道题的难点有两个:

  1. visibilitychange 事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法
  2. 连续失败计数用 ref 还是 state——改变它不需要重渲染,用 ref 更合适

模式 B:受控输入 + 派生计算(useExpenseFilter)

// 环境:React
// 场景:多维度联动筛选

const emptyFilter = {
  departments: [],
  dateRange: null,
  statuses: [],
  amountRange: null,
};

function useExpenseFilter(data) {
  const [filters, setFilters] = useState({ ...emptyFilter });

  const setFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  const resetFilters = useCallback(() => {
    setFilters({ ...emptyFilter });
  }, []);

  const filteredData = useMemo(() => {
    return data.filter((trip) => {
      if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
      if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
      if (filters.amountRange) {
        const [min, max] = filters.amountRange;
        if (trip.amount < min || trip.amount > max) return false;
      }
      if (filters.dateRange) {
        const [start, end] = filters.dateRange;
        if (trip.date < start || trip.date > end) return false;
      }
      return true;
    });
  }, [data, filters]);

  return { filters, setFilter, filteredData, resetFilters };
}

这道题相对直接,但有两个容易踩的坑:

  1. resetFilters 里要用 { ...emptyFilter } 而非直接传引用——否则 emptyFilter 对象可能被意外修改
  2. setFilter 要用 useCallback 包裹——否则每次渲染都会生成新函数,可能导致消费方的 memo 失效

模式 C:防抖 + 请求竞态处理(useEmployeeSearch)

// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态

function useEmployeeSearch() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  useEffect(() => {
    const trimmed = keyword.trim();

    // empty keyword: reset state immediately
    if (!trimmed) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    const timer = setTimeout(async () => {
      // abort previous pending request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const controller = new AbortController();
      abortControllerRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `/api/employees/search?q=${encodeURIComponent(trimmed)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // ignore abort errors
        setError(err);
      } finally {
        setLoading(false);
      }
    }, 500);

    return () => {
      clearTimeout(timer);
      // abort on cleanup (keyword changed or unmount)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [keyword]);

  return { keyword, setKeyword, results, loading, error };
}

这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。


第三层:清理层 — 「离开时」的责任感

这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。

一个简单的清理检查清单:

□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)

上面三道题都涉及清理,总结一下各自的清理策略:

Hook 需要清理的东西
useRideTracking clearInterval + removeEventListener + 标记 stoppedRef 防止 setState
useExpenseFilter 无(纯状态计算,无副作用)
useEmployeeSearch clearTimeout + AbortController.abort()

第四层:优化层 — 「不做多余的工作」

优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:

场景一:派生状态用 useMemo

useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 datafilters 没有变化。

// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);

// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);

场景二:回调函数用 useCallback

暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。

场景三:不需要触发重渲染的值用 useRef

failCountReftimerRefabortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。


一个「答题」的思维顺序

整理完这三道题,我发现面试时可以按这个顺序思考:

1. 明确返回值契约
   └── 对外暴露哪些状态和方法?

2. 识别副作用触发时机
   └── 依赖什么变化?立即执行还是延迟?

3. 规划清理策略
   └── 定时器 / 事件 / 请求,哪些要清理?

4. 考虑优化点
   └── 有无派生状态?回调需不需要 useCallback?

这个顺序不是铁律,但至少能保证不遗漏关键点。


延伸思考

整理这几道题时,有几个问题让我觉得值得继续探索:

  • useReducer vs 多个 useStateuseExpenseFilter 里的多个筛选条件,用 useReducer 管理会更清晰吗?什么情况下应该做这个选择?
  • 请求库的抽象层:SWR / React Query 的 revalidateOnFocus 本质上就是 useRideTracking 里的 visibilitychange 逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分
  • TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向

小结

Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。

这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。


参考资料

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

作者 禅思院
2026年4月8日 13:06

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

告别正则匹配的硬编码,用规则引擎优雅管理代码分割

引言

在 Rollup 打包配置中,manualChunks 是最强大也最容易被误用的选项之一。社区常见的做法是写一堆 if (id.includes('node_modules')) 或正则表达式,把第三方库一股脑打入 vendor 块。这种方案在项目初期看似简单,但随着迭代,很容易出现:

  • chunk 体积失控:一个 vendor 文件动辄几 MB。
  • 缓存失效频繁:任何依赖更新都会导致整个 vendor 重新下载。
  • 代码复用不佳:被多个入口共享的公共模块无法独立拆分。

为了解决这些问题,我们开发了 rollup.plugin.robin-build 插件(纯 JS/TS 版本,下文简称“本插件”)。它提供了一套声明式的分块规则配置,支持路径匹配、引用次数阈值、优先级排序等高级特性,让代码分割变得可预测、可维护。

插件概览

本插件导出两个主要部分:

  1. output 对象:标准的 Rollup 输出配置,定义了文件命名与分类规则。
  2. createSplitChunks 函数:接收用户配置,返回一个符合 manualChunks 签名 (moduleId, { getModuleInfo }) => string | void 的函数。

插件本身不依赖任何外部库,仅使用 Node.js 内置模块 path。其核心思路是:用户以对象形式定义多个“规则组”,每个规则组包含匹配条件(路径字符串或正则)、目标 chunk 名称、优先级以及最小引用次数。插件在构建时遍历每个模块,按优先级匹配规则,决定模块归属的 chunk。

第一部分:输出配置(output)

export const output = {
    entryFileNames: 'js/robin-[hash].js',
    hashCharacters: 'hex', // 减少字符集,见下图1
    experimentalMinChunkSize: 20 * 1024,
    chunkFileNames: (chunkInfo) => {
        if(chunkInfo.name && chunkInfo.name.startsWith('vendor-')){
            return 'js/[name]-[hash].js'
        }
        return 'js/chunk-[hash].js'
    },
    assetFileNames: (info) => { ... }
}

在这里插入图片描述

1.1 entryFileNames 与 hash 配置

  • entryFileNames:入口 chunk 的文件名模板。这里使用 app-[hash].js,并放入 js/ 目录。
  • hashCharacters: 'hex':指定 hash 编码方式为十六进制(Rollup 5.0+ 支持)。
  • experimentalMinChunkSize:设置最小 chunk 大小(20KB),Rollup 会尝试合并小于此阈值的 chunk,减少 HTTP 请求数量。

1.2 chunkFileNames 动态命名

chunkFileNames 可以是函数,接收 chunkInfo 对象。插件判断如果 chunk 名称以 vendor- 开头(通常是通过规则生成的 vendor 块),则保留原名称,例如 vendor-react-[hash].js;否则统一命名为 chunk-[hash].js

这样做的好处是:vendor 块名称可读性高,便于调试和 CDN 缓存策略区分。

1.3 assetFileNames 按扩展名分类

assetFileNames 根据文件扩展名将静态资源归类到不同子目录:

扩展名类型 输出目录
.css asset/css/
.wasm asset/wasm/
.json, .map asset/data/
.txt, .xml, .pdf asset/docs/
图片格式 asset/img/
音视频格式 asset/media/
字体格式 asset/fonts/
其他 asset/other/

这种细粒度分类对于大型项目尤其重要:运维可以针对不同资源类型设置不同的 CDN 缓存头(例如图片缓存一年,JSON 缓存五分钟)。

第二部分:核心分块引擎 createSplitChunks

createSplitChunks 是整个插件的灵魂。它接收一个配置对象,返回 manualChunks 函数。我们先看它的完整实现:

export const createSplitChunks = (config = {}) => {
    if(!isObject(config)) return null

    const list = []
    Object.keys(config).forEach((key) => {
        const test = config[`${key}`].test

        if(!(isRegExp(test) || isString(test))) {
            throw new Error('test 必须为正则表达式或字符串')
        }

        if (isString(test) && !path.isAbsolute(test)) {
            throw new Error(`test 路径必须为绝对路径,实际获取到的是: ${test}`)
        }

        if (isRegExp(test) && test.global) {
            throw new Error('正则表达式测试不得使用 /g 标志')
        }

        list.push({
            ...config[key],
            chunk_name: `${key.startsWith('vendor') ? key : `vendor-${key}`}`,
            type: isRegExp(test) ? 'regexp' : 'string'
        })
    })
    list.sort((a, b) => (b.priority || 0) - (a.priority || 0))

    return (disk_path, { getModuleInfo }) => {
        const moduleInfo = getModuleInfo(disk_path)

        const target = list.find(item=> {
            if(item['minChunks'] && moduleInfo){
                const static_count = moduleInfo['importers'] ? moduleInfo['importers'].length : 0
                const dynamic_count = moduleInfo['dynamicImporters'] ? moduleInfo['dynamicImporters'].length : 0
                const total = static_count + dynamic_count
                if (total < item['minChunks']) return false
            }
            if(item.type === 'regexp') return item.test.test(disk_path)
            return disk_path.startsWith(item.test)
        })

        if(target && isNull(target.name)) return null

        if(target) return target.name || target.chunk_name

        return null
    }
}

2.1 配置解析与校验

插件期望 config 是一个对象,其每个 key 代表一个规则组的名称,value 必须包含 test 字段(字符串绝对路径或正则表达式)。此外还可以包含:

  • name:自定义 chunk 名称(如果未提供,会自动生成 vendor-${key})。
  • priority:优先级(数字越大越先匹配)。
  • minChunks:最小引用次数,只有模块被引用的总次数 ≥ 该值时才匹配。

首先进行严格的类型校验:

  • 使用 toString.call 来判断数据类型(因为 typeof null === 'object',需要区分)。
  • 对于字符串类型的 test,要求必须是绝对路径(通过 path.isAbsolute 验证)。这确保了匹配的确定性,避免相对路径在不同工作目录下产生歧义。
  • 对于正则表达式,禁止使用 g 全局标志,因为 test 方法在全局标志下会有状态残留,导致不可预期的行为。

2.2 构建规则列表与优先级排序

解析后的每个规则对象会被扩展两个内部字段:

  • chunk_name:自动生成的备用名称(如 vendor-react)。
  • type:标记匹配方式('regexp''string')。

然后将规则数组按 priority 降序排序。没有指定优先级的规则默认为 0。排序保证了高优先级规则先被匹配,避免低优先级规则“抢走”本应归属高优先级规则的模块。

2.3 manualChunks 回调逻辑

manualChunks 接收两个参数:disk_path(模块在磁盘上的绝对路径)和上下文对象 { getModuleInfo }getModuleInfo 可以获取模块的依赖关系信息。

对于每个模块,插件会:

  1. 获取模块的引用信息moduleInfo.importers(静态导入该模块的模块列表)和 dynamicImporters(动态导入该模块的模块列表)。两者的长度之和就是该模块被其他模块引用的总次数 total
  2. 遍历规则列表:按照优先级顺序查找第一个匹配的规则。
    • 如果规则定义了 minChunks,则检查 total >= minChunks,不满足则跳过该规则。
    • 根据规则类型,用 test 匹配 disk_path
  3. 决定 chunk 名称
    • 如果匹配到的规则中 name 字段为 null,则返回 null(表示不强制放入任何特定 chunk,由 Rollup 默认处理)。
    • 否则返回 name 或自动生成的 chunk_name
  4. 未匹配任何规则则返回 null,让 Rollup 按照默认算法处理(通常是基于模块共享度自动拆分)。

2.4 设计亮点

优先级机制解决规则冲突

当多个规则都能匹配同一个模块时,优先级决定了最终归属。例如:

{
  "vue-vendor": {
    test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
    priority: 10
  },
  "node-vendor": {
    test: /[\\/]node_modules[\\/]/,
    priority: 0
  }
}

vuevue-i18nvue-router 会进入 vue-vendor 块,而其他 npm 包则进入 node-vendor。如果没有优先级,node-vendor 可能会先匹配,导致 Vue 也被打入通用 vendor。

minChunks 避免过度拆分

一个模块如果被很多地方引用(例如工具函数 debounce),独立成 chunk 是有益的;但如果只被一个入口使用,则应该合并到该入口的 chunk 中,减少 HTTP 请求。minChunks 参数允许开发者设置阈值,只有达到引用次数的模块才独立打包。

路径匹配的两种模式
  • 字符串绝对路径:适用于明确知道模块所在目录的场景,例如 '/app/shared/utils'
  • 正则表达式:更灵活,可以匹配 node_modules 中的特定包名,例如 /node_modules\/lodash-es/
自定义 chunk 名称与 null 返回值

允许规则返回 null 可以让某些模块“逃逸”出规则体系,由 Rollup 默认算法处理。这在使用第三方插件或有特殊分块需求时非常有用。

第三部分:类型判断辅助函数

插件开头定义了几个类型判断函数:没有引入loadsh-es,个人感觉没有必要,所以简化写一下

const toString = Object.prototype.toString
const isObject = (data) => toString.call(data) === '[object Object]'
const isNull = (data) => toString.call(data) === '[object Null]'
const isRegExp = (data) => toString.call(data) === '[object RegExp]'
const isString = (data) => toString.call(data) === '[object String]'

为什么不直接用 typeofinstanceof

  • typeof null === 'object',无法区分 null 和普通对象。
  • 在 Rollup 插件环境中,moduleInfo 等对象可能来自不同的上下文,instanceof 可能失效。而 Object.prototype.toString 返回的是内部 [[Class]] 属性,跨框架可靠。

第四部分:使用示例

4.1 基础配置

// rollup.config.js
import { createSplitChunks, output } from 'rollup-plugin-robin-build';

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    ...output,
    manualChunks: createSplitChunks({
      // 规则1:vue 全家桶单独打包
      vue: {
        test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
        priority: 10,
        minChunks: 1
      },
      // 规则2:antd 组件库单独打包
      antd: {
        test: /[\\/]node_modules[\\/]antd[\\/]/,
        priority: 9
      },
      // 规则3:其他 node_modules 打入 vendor
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        priority: 0
      },
      // 规则4:src/utils 下的公共工具,引用次数 >=3 时独立
      utils: {
        test: path.resolve(__dirname, 'src/utils'),
        minChunks: 3,
        name: 'shared-utils'
      }
    })
  }
}

4.2 配合动态导入

Rollup 能识别动态导入(import()),getModuleInfo 中的 dynamicImporters 会记录哪些模块动态引入了当前模块。因此,minChunks 同样适用于动态导入的场景。

4.3 高级:跳过某些模块

如果某个模块我们不想受任何规则影响,可以在规则中设置 name: null

{
  exclude: {
    test: /[\\/]src[\\/]legacy[\\/]/,
    name: null,   // 让 Rollup 默认处理
    priority: 100
  }
}

第五部分:性能考量

5.1 时间复杂度

每个模块都会遍历规则列表(最坏 O(m·n)),其中 m 为模块数,n 为规则数。对于大型项目(几千个模块,几十条规则),遍历开销仍然可控。但如果规则数量膨胀到上百条,可以考虑将正则表达式编译一次并缓存结果,或使用 Trie 树优化字符串前缀匹配。

5.2 使用 getModuleInfo 的开销

getModuleInfo 是 Rollup 内部维护的模块图查询函数,调用开销极小。我们只在需要检查 minChunks 时才调用,且仅访问 importersdynamicImporters 属性,性能影响可忽略。

5.3 避免重复计算

插件没有做缓存,因为 manualChunks 函数在构建过程中会被多次调用(每个模块一次)。如果规则中包含复杂的自定义函数,可以考虑在外层 memoize。不过本插件完全基于配置,没有用户自定义函数,所以不需要缓存。

第六部分:与其他方案的对比

方案 优点 缺点
原生 manualChunks + 硬编码 简单直接 规则硬编码,难以维护;无法基于引用次数动态拆分
SplitChunksPlugin (Webpack) 功能强大,支持 cacheGroups 配置复杂,且 Webpack 与 Rollup 生态不同
本插件 声明式配置,支持优先级、minChunks,轻量无依赖 需要手动编写规则;无法像 Webpack 那样自动提取公共模块

本插件更适合那些希望将分块规则集中管理、且对 Rollup 生态有强依赖的项目。配合 experimentalMinChunkSize 和 Rollup 自带的 Tree Shaking,可以获得接近 Webpack 的代码分割体验。

第七部分:扩展建议

虽然当前插件已经满足大多数场景,但还可以进一步增强:

  • 支持函数形式的 test:允许用户传入 (id) => boolean,实现更灵活的匹配逻辑。
  • 支持异步规则:例如根据模块内容的大小或依赖关系动态决策。
  • 提供内置预设:例如 preset: 'vue' 自动配置 Vue 相关的分块规则。
  • 集成 bundle 分析器:生成分块报告,帮助用户调整规则。

结语

代码分割是前端性能优化的核心环节之一,但往往被忽视或粗暴处理。通过本插件,我们可以用声明式的规则引擎精细控制每个模块的去向,实现:

  • 更合理的缓存策略:稳定依赖单独 chunk,业务代码频繁更新不影响第三方库缓存。
  • 更快的首屏加载:避免一次性加载巨大的 vendor 文件。
  • 更清晰的构建产物:每个 chunk 有明确的命名和用途。

希望这篇文章能帮助你理解 manualChunks 的高级用法,并启发你构建属于自己的分块工具。如果你对插件有任何疑问或改进建议,欢迎在评论区交流。

对比

vue 全家桶单独打包 我在项目中走CDN啦! 默认分包 在这里插入图片描述 只用插件分包 在这里插入图片描述 很明显有很大的差别!

Pretext:无 DOM 的多行文本测量与排版库

作者 MrFan111
2026年4月8日 12:19

一、背景:文本测量的老大难问题

1.1 为什么需要精确的文本高度?

以下场景都依赖对文本高度的精确预测:

  • 虚拟列表:渲染 10 万条动态高度的消息,需要提前知道每条的像素高度
  • 瀑布流布局(Masonry):把内容塞进最短的那列,高度算错就乱
  • 自定义排版引擎:Canvas 渲染、WebGL UI、PDF 生成——没有 DOM 可查
  • AI 生成 UI:服务端生成界面,不可能在浏览器里 "渲染一遍再量"

1.2 传统方案的痛点

方案 A:插入 DOM 再查询

const el = document.createElement('div')
el.style.cssText = `font: 16px Inter; width: 320px; visibility: hidden`
el.textContent = text
document.body.appendChild(el)
const height = el.getBoundingClientRect().height  // 强制触发 Layout Reflow!
document.body.removeChild(el)

问题:

  • 每次调用都触发一次完整的 Layout Reflow(浏览器重新计算所有元素位置)
  • 批量测量 500 条文本 → 500 次 Reflow → 主线程卡死
  • 无法在 Node.js / Worker 中运行
  • 时序问题:字体未加载完成时结果不准

方案 B:Canvas measureText(初步改进)

const ctx = canvas.getContext('2d')
ctx.font = '16px Inter'
const metrics = ctx.measureText(text)
// 返回的是单行宽度,无法直接得到多行高度

问题:

  • 只能测单行宽度
  • 不处理换行、多语言、Bidi
  • 自己实现换行逻辑 = 重新造一个排版引擎

二、认识 Pretext

2.1 是什么

Pretext 是一个纯 TypeScript 的多行文本测量与排版库,完全在 DOM 之外运行。

npm install @chenglou/pretext
  • GitHub:github.com/chenglou/pr… — 31k Stars
  • 作者:Cheng Lou(React Motion 作者、ReScript 核心成员、前 Meta/Midjourney)
  • 首发:2026 年初,Hacker News 380 分,国内 Juejin/Zhihu 广泛讨论

2.2 一句话解释它做了什么

你给它一段文字 + 一个字体 + 一个宽度  →  它告诉你会占多少行、多高
完全不碰 DOM,不触发 Reflow,可在 Node.js / Worker / WebGL 中运行

三、核心原理:两阶段架构

这是 Pretext 最关键的设计,理解这个就理解了它为什么快。

3.1 整体架构

┌────────────────────────────────────────────────────────────────┐
│  阶段 1prepare(text, font)                                    │
│                                                                │
│  文本  →  [Unicode 分词][Canvas.measureText 缓存]         │
│       →  [空白规范化]    →  [Emoji 宽度修正]                   │
│       →  PreparedText(不透明对象,内含缓存数据)               │
│                                  ↑                             │
│                          一次性,约 19ms/500条                  │
└────────────────────────────────────────────────────────────────┘
           ↓  PreparedText 可复用,多次 layout
┌────────────────────────────────────────────────────────────────┐
│  阶段 2layout(prepared, maxWidth, lineHeight)                 │
│                                                                │
│  PreparedText + 宽度  →  纯算术  →  { lineCount, height }      │
│                                                                │
│  零 DOM / 零 Canvas API / 零内存分配,约 0.09ms/次              │
└────────────────────────────────────────────────────────────────┘

3.2 阶段 1 细节:prepare() 做了什么

源码路径:src/layout.ts + src/analysis.ts + src/measurement.ts

第一步:空白规范化(对应 CSS white-space: normal

const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g
const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/
// src/analysis.ts
export function normalizeWhitespaceNormal(text: string): string {
  // 1. 将所有空白字符(\t \n \r 等)替换为空格
  // 2. 合并连续空格为单个空格
  // 3. 去除首尾空格
}

第二步:Unicode 分词Intl.Segmenter + 7 步后处理流水线)

分词分两个阶段:先用浏览器原生 API 做初始切分,再经过一条修正流水线。

2a. Intl.Segmenter 初始切分,兼容各种语言

// 全局单例,setLocale() 会重置它
let sharedWordSegmenter: Intl.Segmenter | null = null

function getSharedWordSegmenter(): Intl.Segmenter {
  if (sharedWordSegmenter === null) {
    sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: 'word' })
  }
  return sharedWordSegmenter
}

granularity: 'word' 模式下,浏览器按语言规则切词,并标记每段是否 isWordLike

"Hello, 世界!" 的初始输出:

"Hello"  isWordLike: true
","      isWordLike: false
" "      isWordLike: false
"世"     isWordLike: true
"界"     isWordLike: true
"!"      isWordLike: false

2b. 打上 SegmentBreakKind 标签

每个片段被分类为 8 种类型,决定断行逻辑如何对待它:

type SegmentBreakKind =
  | 'text'             // 普通词
  | 'space'            // 可折叠空格(行末丢弃)
  | 'preserved-space'  // pre-wrap 保留空格
  | 'tab'              // Tab,触发制表位对齐
  | 'glue'             // 不换行空格 \u00A0,粘住两侧词
  | 'zero-width-break' // 零宽换行机会 \u200B(建议可断但不显示)
  | 'soft-hyphen'      // 软连字符 \u00AD(断行时显示 -)
  | 'hard-break'       // 强制换行 \n(pre-wrap 模式)

2c. 7 步合并/拆分流水线

Intl.Segmenter 的结果不完全符合视觉断行规则,需要修正:

Pass 1 & 2:URL 保持完整

"https://example.com/path?a=1"
Intl 会在 / ? = 处切开 → mergeUrlLikeRuns + mergeUrlQueryRuns 合并回一个单元

Pass 3 & 4:数字处理

"1,234.56"  Intl 切开  → mergeNumericRuns 合并为一个词(不可断)
"1234-5678" Intl 合并  → splitHyphenatedNumericRuns 在 - 处拆开(允许断行)

Pass 5:ASCII 标点吸附到前一个词

"Hello,"Intl 切成 "Hello" + ","
          → mergeAsciiPunctuationChains 合并为 "Hello,"

理由:标点和前一个词视觉上是整体,不应在标点前换行。

Pass 6:CJK 引号后的进位(禁则规则)

kinsokuEnd(不能出现在行尾)和 kinsokuStart(不能出现在行首)字符集:

export const kinsokuStart = new Set([
  '\uFF0C', '\uFF0E', '\uFF01', '\uFF1A', '\uFF1B', '\uFF1F',  // ,。!:;?
  '\u3001', '\u3002', '\u30FB', '\uFF09', '\u3015', ...         // 、。・)〕…
])
export const kinsokuEnd = new Set([
  '"', '(', '[', '{', '"', ''', '«',
  '\uFF08', '\u3014', '\u3008', '\u300A', '\u300C', ...         // (〔《「…
])

carryTrailingForwardStickyAcrossCJKBoundary 处理引号跨越 CJK 边界的进位, 此处 Safari 和 Chromium 行为不同(EngineProfile.carryCJKAfterClosingQuote)。

Pass 7:不换行空格粘连

"foo\u00A0bar"  → glue 类型的 \u00A0 把 foo 和 bar 粘在一起
               → mergeGlueConnectedTextRuns 合并为一个单元,不允许在此换行

2d. 最终产物:MergedSegmentation

7 步流水线结束后,得到四个平行数组(数组的结构体,缓存友好):

type MergedSegmentation = {
  len: number
  texts: string[]           // ["Hello", ",", " ", "世", "界", "!"]
  isWordLike: boolean[]     // [true, false, false, true, true, false]
  kinds: SegmentBreakKind[] // ['text', 'text', 'space', 'text', 'text', 'text']
  starts: number[]          // [0, 5, 6, 7, 8, 9]  ← 在原始字符串中的偏移
}

这四个数组就是 PreparedText 的内部骨架,后续 layout() 只操作这些数据,不再碰原始字符串。

为什么用平行数组而不是对象数组?— CPU 缓存

CPU 计算时数据必须先进寄存器,但寄存器极小(几十个),数据平时住在内存里。直接从内存读到寄存器太慢(~60ns),所以 CPU 和内存之间有三级缓存(L1/L2/L3)作为中转:

内存(慢,~60ns)→ L1/L2/L3 缓存(快,~1–10ns)→ 寄存器(极快,~0.3ns)→ 计算

CPU 读数据时不是按字节搬,而是一次从内存载入 64 字节(一个缓存行)到缓存,再从缓存送入寄存器:

你访问了 kinds[2]
CPU 一次性把 kinds[0]~kinds[63] 载入缓存
后续访问 kinds[3][4][5]... 缓存命中,直接送寄存器,不用再等内存

对象数组的内存布局是每个对象连续,访问 kind 字段时要跳过 textisWordLikestart

[text|isWordLike|kind|start] [text|isWordLike|kind|start] ...
← 每个对象 ~50 字节,一个缓存行只能装 1 个 →

平行数组的 kinds 是连续序列:

kinds: [k0][k1][k2][k3]...[k63]
     ← 一个缓存行装 64 个,循环后续 64 次全部命中 →

layout() 的断行循环要遍历几百个分词,每次只看 kinds[i]widths[i],平行数组让这两个热路径数组的缓存命中率接近 100%——这是 layout() 跑到 0.09ms 的原因之一。

第三步:Canvas 测量 + 缓存src/measurement.ts

3a. 获取 Canvas 上下文

let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null

export function getMeasureContext() {
  if (measureContext !== null) return measureContext

  if (typeof OffscreenCanvas !== 'undefined') {
    // 优先用 OffscreenCanvas:不依赖 DOM,可在 Web Worker 中运行
    measureContext = new OffscreenCanvas(1, 1).getContext('2d')!
    return measureContext
  }

  if (typeof document !== 'undefined') {
    // 浏览器主线程回退
    measureContext = document.createElement('canvas').getContext('2d')!
    return measureContext
  }

  throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
}

Canvas 大小是 1×1——不需要真正绘制,只是借用 measureText() 这个 API。

3b. 两级缓存结构

// 外层:font 字符串 → 内层 Map
// 内层:segment 文字 → SegmentMetrics
const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

缓存 key 是 (font, segment) 组合,例如 ("16px Inter", "Hello")。 同一段文字 + 同一字体只调用一次 measureText(),之后永久命中缓存。

3c. 核心测量函数

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,  // 唯一一次 Canvas API 调用
      containsCJK: isCJK(seg),            // 标记是否含 CJK,影响断行策略
    }
    cache.set(seg, metrics)
  }
  return metrics
}

SegmentMetrics 是按需填充的,其余字段只在需要时才计算:

type SegmentMetrics = {
  width: number                          // measureText() 返回的原始宽度
  containsCJK: boolean                   // 是否含 CJK 字符
  emojiCount?: number                    // Emoji 个数(用于修正宽度)
  graphemeWidths?: number[] | null       // 每个字形的宽度(overflow-wrap 断词用)
  graphemePrefixWidths?: number[] | null // 字形宽度前缀和(二分查找用)
}
字段 何时计算
width / containsCJK 首次 measureText() 时立即填充
emojiCount 需要 Emoji 宽度修正时(含 Emoji 的 segment)
graphemeWidths 词宽超过容器、需要词内强制断行时
graphemePrefixWidths Safari 下二分查找断行点时(preferPrefixWidthsForBreakableRuns

这种懒加载设计保证了大多数普通文本只付出最低开销:每个 segment 仅调用一次 measureText(),字形级宽度仅在真正需要断词时才触发。

3d. graphemeWidths 与 graphemePrefixWidths 的计算

当一个词超出容器宽度、需要在词内强制断行时,才会触发字形级宽度的计算:

export function getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemeWidths !== undefined) return metrics.graphemeWidths  // 缓存命中

  const widths: number[] = []
  const graphemeSegmenter = getSharedGraphemeSegmenter()  // Intl.Segmenter granularity:'grapheme'
  for (const gs of graphemeSegmenter.segment(seg)) {
    const graphemeMetrics = getSegmentMetrics(gs.segment, cache)
    widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection))
  }

  // 单字符词不需要存(没有"词内断行"的意义)
  metrics.graphemeWidths = widths.length > 1 ? widths : null
  return metrics.graphemeWidths
}

前缀和数组用同样的方式构建,区别在于每次测量累积前缀字符串而非单个字形:

export function getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemePrefixWidths !== undefined) return metrics.graphemePrefixWidths

  const prefixWidths: number[] = []
  let prefix = ''
  for (const gs of graphemeSegmenter.segment(seg)) {
    prefix += gs.segment
    const prefixMetrics = getSegmentMetrics(prefix, cache)  // 测量 "H", "He", "Hel"...
    prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection))
  }

  metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null
  return metrics.graphemePrefixWidths
}

为什么测前缀字符串而不是直接累加单字形宽度? 因为字体有字距调整(kerning)"Te" 的实际渲染宽度可能小于 width("T") + width("e"), 测整体前缀可以拿到和浏览器完全一致的累积宽度。

第四步:Emoji 宽度修正

macOS 上 Canvas measureText() 对 Emoji 的宽度虚报(比实际 DOM 渲染更宽), Pretext 在每个字体大小上做一次一次性校正:

function getEmojiCorrection(font: string, fontSize: number): number {
  let correction = emojiCorrectionCache.get(font)
  if (correction !== undefined) return correction  // 已校正过,直接返回

  const ctx = getMeasureContext()
  ctx.font = font
  const canvasW = ctx.measureText('\u{1F600}').width  // Canvas 测量值

  correction = 0
  if (canvasW > fontSize + 0.5 && typeof document !== 'undefined') {
    // 插入不可见 <span>,拿到 DOM 实际渲染宽度
    const span = document.createElement('span')
    span.style.cssText = `font:${font};display:inline-block;visibility:hidden;position:absolute`
    span.textContent = '\u{1F600}'
    document.body.appendChild(span)
    const domW = span.getBoundingClientRect().width
    document.body.removeChild(span)

    if (canvasW - domW > 0.5) correction = canvasW - domW  // 记录差值
  }

  emojiCorrectionCache.set(font, correction)
  return correction
}

校正时从 segment 宽度中减去 emojiCount × correction

export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection): number {
  if (emojiCorrection === 0) return metrics.width
  return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

注意这里故意插了一次 DOM——但只插一次,之后所有 Emoji 宽度计算都用这个缓存的校正值。

Canvas 缓存小结

prepare() 阶段  →  measureText() 结果写入两级 Map  →  永久缓存
layout()  阶段  →  只读缓存,做纯算术,0Canvas/DOM API 调用

(font, segment) 二元组作为缓存 key,保证跨多次 layout() 调用时不重复测量。 字形级宽度和 Emoji 校正值也都只计算一次,后续全部命中缓存——这是 layout() 跑到 0.09ms 的核心原因。

第五步:浏览器差异适配

export function getEngineProfile(): EngineProfile {
  // Node.js 环境没有 navigator,返回保守默认值
  if (typeof navigator === 'undefined') {
    return { lineFitEpsilon: 0.005, carryCJKAfterClosingQuote: false, ... }
  }

  const ua = navigator.userAgent
  const isSafari = navigator.vendor === 'Apple Computer, Inc.' &&
    ua.includes('Safari/') && !ua.includes('Chrome/') && ...
  const isChromium = ua.includes('Chrome/') || ua.includes('Chromium/') || ...

  return {
    lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
    //  ↑ 判断一行文字是否"刚好放得下"时的浮点容差
    //    Safari 字宽计算精度是 1/64px,Chromium 是 0.005px

    carryCJKAfterClosingQuote: isChromium,
    //  ↑ 引号后紧跟 CJK 字符时的禁则进位行为,两个引擎不一致

    preferPrefixWidthsForBreakableRuns: isSafari,
    //  ↑ Safari 的 kerning 使单字形累加不准,需要用前缀宽度做二分

    preferEarlySoftHyphenBreak: isSafari,
    //  ↑ 软连字符的断行时机,Safari 偏好更早断
  }
}

这个 profile 只检测一次,结果缓存在 cachedEngineProfile,后续所有 layout() 调用共用。

3.3 阶段 2 细节:layout() 做了什么

源码路径:src/line-break.ts

准备阶段生成了 PreparedLineBreakData

// src/line-break.ts
type PreparedLineBreakData = {
  widths: number[]                         // 每个分词的宽度
  lineEndFitAdvances: number[]             // 行末 fit 宽度(不含尾部空格)
  lineEndPaintAdvances: number[]           // 行末 paint 宽度(含 overflow)
  kinds: SegmentBreakKind[]                // 每段的类型
  simpleLineWalkFastPath: boolean          // 是否走快速路径
  breakableWidths: (number[] | null)[]     // 可断词的字形宽度
  breakablePrefixWidths: (number[] | null)[]
  discretionaryHyphenWidth: number         // 可选连字符宽度
  tabStopAdvance: number                   // Tab 制表位间距
  chunks: { ... }[]                        // 强制换行分块
}

关键优化:simpleLineWalkFastPath

对于大多数普通文本(无 Tab、无软连字符、无强制换行),走一条更简单的代码路径:

// src/line-break.ts
if (prepared.simpleLineWalkFastPath) {
  return walkPreparedLinesSimple(prepared, maxWidth, onLine)  // 快速路径
} else {
  return walkPreparedLinesFull(prepared, maxWidth, onLine)    // 完整路径
}

layout 阶段不调用任何浏览器 API,只做加减法和数组索引——这是 0.09ms 的秘密。


四、API 全貌

4.1 四个层次,按需取用

import {
  prepare, prepareWithSegments,
  layout, layoutWithLines,
  walkLineRanges,
  layoutNextLine,
  clearCache, setLocale,
} from '@chenglou/pretext'

4.2 第一层:快速高度测量

适用场景: 虚拟列表行高预测、判断文本是否需要截断

const prepared = prepare('AGI 春天到了', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20)
// height: 像素高度(lineCount * lineHeight)
// lineCount: 折行后的行数

prepare() 返回的是不透明类型(Branded Type),内部结构不暴露——你只需要把它传给 layout()

4.3 第二层:获取每行文字(自定义渲染)

适用场景: Canvas 绘制文字、SVG 文字排版、WebGL 渲染

const prepared = prepareWithSegments('Hello world', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)

// 在 Canvas 上逐行绘制
const ctx = canvas.getContext('2d')
ctx.font = '18px "Helvetica Neue"'
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

LayoutLine 包含:

type LayoutLine = {
  text: string    // 这一行的文字内容
  width: number   // 这一行的实际渲染宽度
  start: LayoutCursor
  end: LayoutCursor
}
type LayoutCursor = { segmentIndex: number; graphemeIndex: number }

4.4 第三层:零分配的游标遍历

适用场景: 对性能极度敏感,需要避免所有字符串分配

// 不构造字符串,直接操作游标索引
const lineCount = walkLineRanges(prepared, maxWidth, (startCursor, endCursor, lineWidth) => {
  // startCursor / endCursor 是 { segmentIndex, graphemeIndex }
  // 你可以用它们直接索引原始 segments 数组,而不创建子字符串
})

4.5 第四层:动态列宽(文字绕图)

适用场景: 文字绕图排列,类似 CSS float;或每行宽度动态变化的布局

// 例:文字绕过右侧图片区域
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  // 根据当前 y 坐标决定这行的可用宽度
  const width = (y < imageBottom) ? columnWidth - imageWidth : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break

  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += lineHeight
}

4.6 其他实用 API

// 字体加载完成后清除缓存,确保新字体参与测量
clearCache()

// 设置语言区域,影响断行规则(例如区分简/繁体中文的标点行为)
setLocale('zh-CN')

// 支持 pre-wrap 模式(类似 textarea:保留空格、换行符生效)
const prepared = prepare(text, font, { whiteSpace: 'pre-wrap' })

// 诊断工具:分析各阶段耗时
const profile = profilePrepare(text, font)

五、语言与 Unicode 支持

5.1 Bidi(双向文字)

源码:src/bidi.ts,从 pdf.js 移植,实现完整的 Unicode Bidi Algorithm。

// 对每个字符进行 Bidi 分类
type BidiType = 'L' | 'R' | 'AL' | 'AN' | 'EN' | 'WS' | 'ON' | ...

// 计算字符级别的方向 levels(偶数=LTR,奇数=RTL)
computeBidiLevels(str: string): number[]

// 将字符级 level 映射到 prepare() 的分词边界上
computeSegmentLevels(normalized, segStarts): number[]

对于 prepareWithSegments() 返回的 lines,每个 LayoutLine 会附带 bidi levels, 供自定义渲染器按正确方向绘制混合文字。

5.2 CJK 禁则规则

src/analysis.ts 中维护了完整的 Unicode 禁则字符集:

// 不能出现在行首的字符(如:),。、】)
const kinsokuStart: Set<string>

// 不能出现在行尾的字符(如:(【『「)
const kinsokuEnd: Set<string>

// 左侧粘连标点(不和右侧内容分离)
const leftStickyPunctuation: Set<string>

// 引号字符集(影响 CJK 引号后的换行进位行为)
const closingQuoteChars: Set<string>

5.3 中/日/韩文字符逐字符断行

CJK 每个字符都可以独立换行,不需要词边界。Pretext 在 prepare() 阶段检测 CJK 字符后, 自动将其拆分为单字符粒度参与宽度缓存:

// src/analysis.ts
export function isCJK(s: string): boolean  // 检测 Unicode CJK/Hiragana/Katakana/Hangul 范围

六、Demo 演示

以下 Demo 均来自官方:chenglou.me/pretext

Demo 1:Accordion(折叠面板)

展示点: 精确预测折叠/展开后的高度,驱动 CSS transition——不展开就知道目标高度。

// 在点击"展开"时,用 prepare + layout 提前计算展开后高度
// 直接设置 max-height: ${height}px 配合 transition
const { height } = layout(prepare(content, font), containerWidth, lineHeight)
el.style.maxHeight = `${height}px`

Demo 2:Masonry(瀑布流)

展示点: 渲染前批量测量所有卡片高度,精确分配到最短列,避免渲染后的二次调整。

const prepared = cards.map(text => prepare(text, '14px Inter'))
// layout 极快,可在同步代码中批量计算
const heights = prepared.map(p => layout(p, cardWidth, 20).height)

Demo 3:Editorial Engine(编辑排版引擎)

展示点: 多栏杂志式排版,文字实时 60fps 重排,标题自动绕过装饰元素。 使用 layoutNextLine API,每列列宽可动态变化。

Demo 4:Justification Compared(断行算法对比)

展示点: 并排对比三种断行策略:

策略 说明 效果
CSS 默认(贪心算法) 每行尽量塞满 行尾参差不齐
加连字符(hyphenation) 单词末尾加 - 拆分 略好
Knuth-Plass 最优算法 全局最小化行间距差异 最均匀,LaTeX 同款

Pretext 通过暴露游标级 API,让用户自行实现 Knuth-Plass 算法,而不耦合具体排版策略。


七、实际应用场景

场景 1:虚拟列表精确行高

// 预计算所有行高(一次性 prepare,多次 layout)
const prepared = messages.map(msg => prepare(msg.text, '14px Inter'))
const rowHeights = prepared.map(p => layout(p, listWidth, 20).height + PADDING)

// 虚拟列表滚动时直接查表,不再触碰 DOM
function getItemHeight(index: number) { return rowHeights[index] }

场景 2:AI 生成 UI 的服务端布局验证

// Node.js 环境(需要 Canvas 实现,如 node-canvas)
import { createCanvas } from 'canvas'
// Pretext 可在 Node.js 中使用,以验证 LLM 生成的 UI 不会溢出
const { lineCount } = layout(prepare(aiGeneratedText, font), containerWidth, lineHeight)
if (lineCount > maxLines) trimText(aiGeneratedText)

场景 3:防止 CLS(累积布局偏移)

服务端渲染时提前计算高度,在 HTML 中写入 style="height: Xpx", 浏览器渲染时不发生跳动,Google Core Web Vitals 得分不受影响。

场景 4:Canvas / WebGL 渲染引擎

// Canvas 游戏 UI、数据大屏、PDF 生成
const { lines } = layoutWithLines(prepare(text, font), boxWidth, lineHeight)
lines.forEach((line, i) => {
  ctx.fillText(line.text, x, y + i * lineHeight)
})

八、局限性与注意事项

8.1 已知精度问题

问题 场景 原因
macOS system-ui 精度略低 使用系统默认字体时 macOS OS 级字形渲染与 Canvas 测量存在差异
极窄容器触发字形级断行 容器宽度小于单个词 回退到逐字符(grapheme)断行
字体未加载时结果不准 自定义 Web Font 需确保字体加载完成后再调用 prepare()

8.2 使用注意

// 字体更换后记得清缓存
document.fonts.ready.then(() => {
  clearCache()
  // 重新 prepare
})

// Pretext 专注排版逻辑,不负责字形绘制
// 它告诉你文字在哪儿,具体画出来仍然需要 Canvas / SVG / DOM

8.3 不支持的 CSS 特性(当前版本 0.0.4)

  • word-break: keep-all(中文不断行)
  • writing-mode: vertical-*(竖排文字)
  • letter-spacing / word-spacing
  • 复杂 CSS 嵌套行内元素(需用 prepareWithSegments 手动拼接)

九、与现有方案对比

方案 是否触发 Reflow Node.js 可用 多语言完整支持 多行支持 备注
DOM getBoundingClientRect 最常见,最慢
Canvas measureText(手写换行) 部分 手写 需自行处理 Unicode
opentype.js / fontkit 手写 需加载字体文件
Pretext 完整 内置 需要浏览器 Canvas 或 node-canvas

关键差异:

  • opentype.js:操作字体文件字形,精度最高,但需下载字体文件,适合 PDF 生成
  • Pretext:借助 Canvas 的字体渲染,和浏览器显示高度一致,适合 Web UI 场景

十、总结

Pretext 的核心价值

prepare() 一次  →  canvas 测量结果永久缓存
layout()  N次   →  纯数学,任何环境,极致性能

它解决的问题:

  • 消除批量文本测量引发的 Layout Reflow
  • 让"在渲染前知道文本高度"变得真正可行
  • 把精确的文字排版能力带出浏览器(Node.js、Worker、WebGL)

值得关注的信号:

  • 作者 Cheng Lou 在 React 生态有极强的工程判断力
  • 31k Stars + HN 380 分——社区已经验证了痛点的真实性
  • 发布时间短,API 仍在演进,可保持关注

我们的项目有哪些场景可以用?

开放讨论:我们的消息列表、Feed 流、或 Canvas 报表有哪些地方受益?


十一、Q & A

Q:缓存会不会无限增长?有没有 LRU / TTL?

没有。缓存是一个朴素的 Map,不会自动淘汰。 设计前提是:一个应用中使用的 (font, segment) 组合数量是有限且收敛的——普通文本里词汇高度重复,缓存命中率极高,总条目数通常不大。 极端场景(如代码编辑器、每帧随机文字)可能导致缓存膨胀,需要手动定期调用 clearCache() 来重置。这是当前版本的已知权衡。


Q:和浏览器真实渲染差多少?号称 pixel-perfect 有依据吗?

测量数据来自同一个 Canvas 渲染引擎,理论上和浏览器"看到"的字宽一致。已知精度问题有两处:

  • macOS system-ui:OS 级字形渲染与 Canvas 存在亚像素差异,行数预测可能偏差 1 行
  • 浮点容差:Safari 精度是 1/64px,Chromium 是 0.005px,lineFitEpsilon 按引擎分别配置

Emoji 的偏差通过一次性 DOM 校正消除。对常规正文字体,实测误差可以做到 0 像素差。


Q:为什么不直接用 TextMetrics.actualBoundingBoxAscent/Descent

measureText() 返回的 TextMetrics 给的是单行文字的 bounding box,无法处理换行。 你仍然需要自己实现完整的断行算法(空白规范化、Unicode 分词、CJK 禁则、soft-hyphen……),那正好就是 Pretext 本身。measureText() 是 Pretext 内部的一个工具,而不是替代品。


Q:字体还没加载完就调用 prepare(),结果会不会不准?

会。字体未加载时,Canvas 会回退到系统默认字体测量,结果偏差可能很大。 正确做法:

await document.fonts.ready  // 等所有已声明字体加载完
// 或针对特定字体:
await document.fonts.load('16px Inter')

clearCache()               // 清除旧测量结果
const prepared = prepare(text, '16px Inter')

动态加载新字体后同样需要 clearCache()


Q:版本 0.0.4,能上生产吗?

底层依赖的 Canvas.measureText() 是成熟稳定的浏览器 API,核心算法没有风险。 主要不确定因素是公开 API 可能仍在调整(函数签名、返回值结构),升级时需关注 changelog。 建议:非核心链路、或新开发的模块可以直接用;存量代码做好隔离封装,留出升级空间。


参考资料

vite项目开发环境启动白屏

2026年4月8日 11:29

Vite 启动成功但页面打不开:加上 host: '127.0.0.1' 就恢复了,为什么?

今天开发时遇到一个比较“迷惑”的问题:

  • 终端里 npm run dev 正常启动
  • Vite 提示 ready in xxx ms
  • 控制台没有报错
  • 但浏览器访问 http://localhost:1111/ 时,直接报:

ERR_CONNECTION_REFUSED

最后只是在 vite.config.ts 里加了这段配置,问题就解决了:

server: {
  host: '127.0.0.1', //新加了host
  port: 1111,
},

那为什么加了 host: '127.0.0.1' 就好了?这篇文章把这次问题的现象、原因和解决方案整理一下。


一、问题现象

项目使用 Vite,本地启动命令:npm run dev

终端输出类似这样:

VITE v7.x.x ready in 504 ms

➜ Local: http://localhost:1111/

看起来一切正常,但浏览器打开后却提示:

无法访问此网站 localhost 拒绝了我们的连接请求 ERR_CONNECTION_REFUSED

最关键的是:

  • 页面打不开
  • 控制台没有报错
  • 代码本身看起来也没问题

这种情况特别容易让人误以为是 Vue 组件、入口文件或者业务代码出了问题,但实际上这次并不是前端逻辑问题,而是 开发服务器的监听地址问题


二、这次问题的根因

这次问题的根因可以概括成一句话:

Vite 默认监听的地址,与我当前机器上 localhost 的实际解析/访问方式不一致,导致浏览器访问被拒绝。

更具体一点说,就是:

1. localhost 不一定只等于 127.0.0.1

很多人会默认认为:

localhost = 127.0.0.1

但实际上不是。

在现代操作系统里,localhost 往往可能同时解析到:

  • 127.0.0.1(IPv4)
  • ::1(IPv6)

有些环境会优先走 IPv6,也就是 ::1


2. Vite 启动了,不代表浏览器一定能访问成功

Vite 终端里显示 ready,只能说明:

  • Node 进程起来了
  • Vite 逻辑开始监听某个地址和端口了

但这不等于:

  • 浏览器一定能通过你输入的 URL 成功连上它

如果服务绑定在 IPv6 地址、而浏览器/系统/代理最终没有按同样方式访问,或者本机网络栈对 localhost 的处理存在差异,就可能出现:

  • 终端显示启动成功
  • 浏览器却连不上
  • 并且前端控制台没有任何 JS 报错

因为这时候问题发生在 网络连接阶段,还没走到页面脚本执行。


3. 这次加 host: '127.0.0.1' 后生效,本质上是“强制固定到 IPv4”

当我加上:

server: {
  host: '127.0.0.1',
  port: 1111,
},

实际效果是:

  • Vite 明确绑定到 127.0.0.1
  • 浏览器也直接通过 IPv4 地址访问本地服务
  • 避开了 localhost 在本机环境下的 IPv4 / IPv6 解析歧义

也就是说,这个配置的本质不是“让 Vite 更快”或者“修复了业务代码”,而是:

把本地开发服务器的监听地址,从模糊的 localhost,改成了明确的 127.0.0.1


三、为什么之前会报 ERR_CONNECTION_REFUSED,但控制台没有错误?

这是这类问题最容易误导人的地方。

原因很简单:

浏览器控制台通常显示的是 页面加载后的脚本错误,比如:

  • JS 语法错误
  • Vue 运行时错误
  • 接口报错
  • 资源加载失败

但这次浏览器连页面都没连上,所以根本没有机会执行前端代码。

也就是说:

  • 不是页面渲染报错
  • 不是组件逻辑报错
  • 不是业务代码报错
  • 而是浏览器在“建立连接”这一步就失败了**

所以最终看到的是:

ERR_CONNECTION_REFUSED

而不是前端控制台里的红色报错堆栈。


四、最终解决方案

修改前

server: {
  port: 1111,
},

修改后

server: {
  host: '127.0.0.1',
  port: 1111,
},

修改完成后,重新启动:

npm run dev

然后直接访问:

http://127.0.0.1:1111/

页面恢复正常。


五、为什么 host: '127.0.0.1' 能解决问题?

可以直接理解成下面这张逻辑链:

没加 host 时

  • Vite 使用默认 host
  • localhost 在系统里可能优先解析到 IPv6(::1
  • 浏览器访问 localhost 时,和服务实际监听地址不完全一致
  • 最终连接失败

加了 host: '127.0.0.1' 后

  • Vite 明确监听 IPv4 地址 127.0.0.1
  • 浏览器也直接访问 127.0.0.1
  • 服务监听地址和访问地址完全一致
  • 连接恢复正常

一句话总结:

不是 Vite 没启动,而是“启动后的监听地址”与“浏览器访问地址”之间存在偏差。


六、遇到这类问题怎么排查?

如果你也遇到:

  • Vite 显示启动成功
  • 页面却打不开
  • 控制台没有报错

可以按下面顺序排查。

1. 先判断是不是“代码错误”

如果是代码错误,通常会出现:

  • 浏览器白屏但能打开页面
  • 控制台有 JS 报错
  • 网络面板能看到 index.html / main.ts 已经加载

如果是这类情况,就查业务代码。


2. 如果直接 ERR_CONNECTION_REFUSED

那优先怀疑:

  • 本地端口没真正可访问
  • localhost 解析有问题
  • IPv4 / IPv6 监听不一致
  • 被系统代理、VPN、防火墙影响

这种情况通常不是组件代码问题,而是 开发服务器访问路径问题


3. 直接指定 host

最直接有效的办法:

server: {
  host: '127.0.0.1',
  port: 1111,
},

如果你需要局域网访问,可以改成:

server: {
  host: true,
  port: 1111,
},

或者:

server: {
  host: '0.0.0.0',
  port: 1111,
},

七、我的结论

这次问题本质上不是“项目没跑起来”,而是:

Vite 在本机环境中使用默认监听地址时,和浏览器访问 localhost 的方式不一致,导致连接被拒绝。

最终通过显式指定:

host: '127.0.0.1'

强制 Vite 使用 IPv4 回环地址,解决了本地 localhost / IPv6 解析带来的歧义问题。


八、一段适合直接放文章结尾的总结

如果你遇到 Vite 启动成功但浏览器打不开、控制台也没有报错 的情况,不要先怀疑 Vue 代码本身,优先检查开发服务器的监听地址。

在某些 Windows 或本机网络环境下,localhost 可能会涉及 IPv4 / IPv6 的解析差异,导致:

  • Vite 看起来已经启动
  • 浏览器却无法建立连接

这时候可以直接在 vite.config.ts 中指定:

server: {
  host: '127.0.0.1',
  port: 1111,
},

这样可以绕开 localhost 的解析歧义,让本地开发环境稳定恢复。

NativeScript APP 布局学习

作者 sp42_frank
2026年4月8日 11:24

和前端开发不同,NativeScript 目标运行于 APP 之上故而有其独特的布局系统。今天我们就好好了解一下 NativeScript 的布局机制。

网上有很好的学习资源 www.nslayouts.com/,我们翻译一下转为该教程。

<StackLayout>布局

StackLayout 布局,默认按照垂直方向排列元素。

<StackLayout orientation="vertical">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
</StackLayout>

在这里插入图片描述

修改属性StackLayout orientation="horizontal"为水平方向布局: 在这里插入图片描述

居中显示

设定<StackLayout orientation="horizontal" horizontalAlignment="center">在这里插入图片描述 水平、垂直居中<StackLayout orientation="horizontal" horizontalAlignment="center" verticalAlignment="center">

在这里插入图片描述 子元素自己也可以设置水平对齐:

<StackLayout orientation="vertical">
<Image src="res://angular" stretch="none" horizontalAlignment="left"></Image>
<Image src="res://vue" stretch="none" horizontalAlignment="center"></Image>
<Image src="res://preact" stretch="none" horizontalAlignment="right"></Image>
</StackLayout>

在这里插入图片描述

<WrapLayout>布局

<WrapLayout>布局将组件彼此环绕,填满可用空间(要么水平方向——按行,要么垂直方向——按列)。默认情况下,<WrapLayout>的方向是水平的。

<WrapLayout orientation="horizontal">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
  <Image src="res://preact" stretch="none"></Image>
  <Image src="res://webpack" stretch="none"></Image>
  <Image src="res://redux" stretch="none"></Image>
  <Image src="res://nativescripting" stretch="none"></Image>
</WrapLayout>

在这里插入图片描述 改为垂直方向,也就是按列(Column)排列

<WrapLayout orientation="vertical">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
  <Image src="res://preact" stretch="none"></Image>
  <Image src="res://webpack" stretch="none"></Image>
  <Image src="res://redux" stretch="none"></Image>
  <Image src="res://nativescripting" stretch="none"></Image>
</WrapLayout>

在这里插入图片描述

<AbsoluteLayout>绝对布局

<AbsoluteLayout>类似于 Web 的绝对布局,通过布局容器的 top/left 值来定位组件。如下所示,只需为每个 Image 组件分配 top 和 left 属性,:

<Image src="res://nativescript" stretch="none" top="10" left="10" />
[...] top="10" left="170"
[...] top="170" left="10"
[...] top="170" left="170"

⚠️ 提示:top/left 属性的值是"密度无关像素"(一种测量单位,允许布局设计独立于屏幕密度)。

<AbsoluteLayout>
<Image src="res://nativescript" stretch="none" top="10" left="10"></Image>
<Image src="res://angular" stretch="none" top="10" left="170"></Image>
<Image src="res://vue" stretch="none" top="170" left="10"></Image>
<Image src="res://preact" stretch="none" top="170" left="170"></Image>
</AbsoluteLayout>

在这里插入图片描述 当你需要用其他布局容器无法实现的方式来定位组件时,就能看出<AbsoluteLayout>的真正强大之处。例如假设你想让组件重叠。我们该如何实现呢?显而易见的方法是简单地设置新的 top/left 值就完事了。不过我们要尝试一种不同的方法,那就是给元素添加一个 margin 属性。是的,就是你在网页上使用的相同 CSS margin!

在这个例子中,继续给第二个 Image 元素添加 30 的 margin,看看会发生什么。

<AbsoluteLayout>
  <Image src="res://webpack" stretch="none" top="10" left="10"></Image>
  <Image src="res://redux" stretch="none" top="10" left="10" margin="30"></Image>
</AbsoluteLayout>

在这里插入图片描述

<DockLayout>布局

通常在想要将元素定位在其父容器(通常是页面/视图本身)的侧面时使用 <DockLayout>。这里我们把图标停靠在屏幕的左侧/顶部/右侧/底部。请注意 stretchLastChild 属性,因为这将在下一课中发挥作用。

<DockLayout stretchLastChild="false">
  <Image src="res://nativescript" stretch="none" dock="left"></Image>
  <Image src="res://angular" stretch="none" dock="top"></Image>
  <Image src="res://vue" stretch="none" dock="right"></Image>
  <Image src="res://webpack" stretch="none" dock="bottom"></Image>
</DockLayout>

在这里插入图片描述stretchLastChild 设置为 true 实际上可以让你在父容器的中间"停靠"一个项目。名称中的 LastChild 部分是关键,它告诉你列出的最后一个元素将是被拉伸或居中显示在你的布局中的元素。

这次,继续将 stretchLastChild 设置为 true,看看我们的元素会发生什么变化。

在这里插入图片描述

<GridLayout>布局

<GridLayout> 跟 HTML 表格很类似,一样有行和列,你需要指定行/列的大小(以及每个元素应该放入哪个行/列)。

这个例子将创建一个两列布局,并且两列都使用自动调整大小(稍后会有更多介绍)。你只需要在下面的每个元素中填入适当的基于 0 的索引列即可。

<GridLayout columns="auto, auto" rows="auto">
<Image src="res://nativescripting" stretch="none" row="0" col=""></Image>
<Image src="res://redux" stretch="none" row="0" col=""></Image>
</GridLayout>

在这里插入图片描述

下面例子中对 <GridLayout> 中添加一些行,将六个图像元素组织成一个两列三行的布局。同样,我们会在后续课程中解释 auto 到底是什么意思。

<GridLayout columns="auto, auto" rows="auto, auto, auto">
<Image src="res://nativescript" stretch="none" row="" col="0"></Image>
<Image src="res://angular" stretch="none" row="" col="1"></Image>
<Image src="res://vue" stretch="none" row="" col="0"></Image>
<Image src="res://webpack" stretch="none" row="" col="1"></Image>
<Image src="res://nativescripting" stretch="none" row="" col="0"></Image>
<Image src="res://redux" stretch="none" row="" col="1"></Image>
</GridLayout>

在这里插入图片描述

现在我们可以看看 <GridLayout> 的下一个类似 HTML 的属性,colSpan。就像 HTML 表格一样,我们可以使用这个属性让一个元素跨越多列。

下例子中设置第二行中的图像跨越所有三列。

<GridLayout columns="auto, auto, auto" rows="auto, auto">
<Image src="res://nativescript" stretch="none" row="0" col="0"></Image>
<Image src="res://angular" stretch="none" row="0" col="1"></Image>
<Image src="res://vue" stretch="none" row="0" col="2"></Image>
<Image src="res://preact" stretch="none" row="1" col="0" colSpan="3"></Image>
</GridLayout>

在这里插入图片描述 现在我们把 <GridLayout> 颠倒过来 🙃 并学习一下 rowSpan 属性。同样,像 HTML 表格一样,使用 rowSpan 我们可以让一个元素跨越多行。

现在我们可以让我们的布局更加巧妙,以独特的方式排列我们的图像元素。继续并使用 rowSpan 属性作用于前两列中的元素,使得第一列有一个图像,第二列有两个,第三列有三个。

<GridLayout columns="auto, auto, auto" rows="auto, auto, auto">
<Image src="res://nativescript" row="0" col="0" rowSpan="3"></Image>
<Image src="res://angular" row="0" col="1"></Image>
<Image src="res://vue" row="1" col="1" rowSpan="2"></Image>
  <Image src="res://webpack" row="0" col="2"></Image>
  <Image src="res://nativescripting" row="1" col="2"></Image>
  <Image src="res://redux" row="2" col="2"></Image>
</GridLayout>

在这里插入图片描述 回想一下之前的课程中我们使用的语法,如 columns="auto"rows="auto"?当我们使用 auto 时,我们是在告诉我们的布局根据元素的实际大小自动调整列/行的尺寸。

但是如果我们想要给某一列使用特定的宽度怎么办?幸运的是,我们可以通过简单地代入一个数值来处理这个问题。让我们把图像稍微分散一点,并在我们的第一列上使用 200 的固定宽度,在我们的第一行上也使用 200 的固定宽度。

<GridLayout columns=", auto" rows=", auto">
  <Image src="res://webpack" stretch="none" row="0" col="0"></Image>
  <Image src="res://nativescripting" stretch="none" row="0" col="1"></Image>
  <Image src="res://redux" stretch="none" row="1" col="0"></Image>
  <Image src="res://preact" stretch="none" row="1" col="1"></Image>
</GridLayout>

在这里插入图片描述

我们通过查看调整行或列大小的最后一种方式来结束 <GridLayout> 的学习,那就是星号(*)大小调整。星号大小调整允许行/列在为固定宽度和自动宽度列分配空间后(在所有星号大小的列中按比例分配),占据尽可能多的空间。您是否还感到困惑 😕?可以这样理解:

  • "星号" == 贪婪(占据所有它可以占据的空间)
  • "自动" == 吝啬(只占据它需要的空间)

看看一个类似于上一课中图像放置的示例。但这次将使用星号大小调整并添加乘数,使第一列和第一行的空间加倍(提示:2* 给您两倍于*的空间)。

<GridLayout columns="2*, *" rows="2*, *">
  <Image src="res://nativescript" stretch="none" row="0" col="0"></Image>
  <Image src="res://angular" stretch="none" row="0" col="1"></Image>
  <Image src="res://vue" stretch="none" row="1" col="0"></Image>
  <Image src="res://preact" stretch="none" row="1" col="1"></Image>
</GridLayout>

在这里插入图片描述

嵌套布局

通过将布局相互嵌套,您可以创建几乎任何可能的用户界面。但我们先从简单的开始,只需将一个<StackLayout>嵌套在另一个<StackLayout>内部即可。

 <StackLayout>
  <StackLayout>
    <Image src="res://webpack" stretch="none"></Image>
    <Image src="res://nativescripting" stretch="none"></Image>
    <Image src="res://redux" stretch="none"></Image>
  </StackLayout>
</StackLayout>

在这里插入图片描述 诚然,这不是嵌套布局最现实的用法,因为输出结果与仅使用一个 <StackLayout> 相同!

通过在一个单独的 <StackLayout> 中嵌入两个 <StackLayout> 元素,并将这两个布局水平对齐。⚠️ 在这种情况下,您会发现第一个 <StackLayout> 的 orientation 属性仅适用于其直接子容器(在这种情况下是另外两个布局)。

<StackLayout orientation="horizontal">
  <StackLayout>
    <Image src="res://nativescript" stretch="none"></Image>
    <Image src="res://vue" stretch="none"></Image>
  </StackLayout>
  <StackLayout>
    <Image src="res://angular" stretch="none"></Image>
    <Image src="res://preact" stretch="none"></Image>
  </StackLayout>
<StackLayout>

在这里插入图片描述最后总结一下嵌套布局。这个例子将使用一个单列、两行的 <GridLayout>(具有自动调整大小的行/列),其中包含两个 <StackLayout> 元素,每个元素都有不同的方向来放置它们包含的图像。

<GridLayout columns="auto" rows="auto, auto">
  <StackLayout orientation="vertical" row="0" col="0">
    <Image src="res://nativescript" stretch="none"></Image>
    <Image src="res://angular" stretch="none"></Image>
  </StackLayout>
  <StackLayout orientation="horizontal" row="1" col="0">
    <Image src="res://vue" stretch="none"></Image>
    <Image src="res://nativescripting" stretch="none"></Image>
  </StackLayout>
<GridLayout>

在这里插入图片描述

<RootLayout>布局

<RootLayout> 是用于以编程API动态分层视图的布局容器。

<RootLayout> 是一种布局容器,旨在用作应用程序的主要根布局容器,内置API可轻松控制动态视图层。它扩展了 GridLayout,因此具有 GridLayout 的所有功能,并增强了额外的API。

参见官网教程

<FlexboxLayout>布局

<FlexboxLayout>跟 CSS Flexbox 很类似,可以在水平和垂直方向上排列子组件。子组件按照声明的顺序放置,除非被 order 子属性覆盖。

以下示例创建了一行三个等大的元素,这些元素跨越屏幕的整个高度。

<FlexboxLayout backgroundColor="#3c495e">
  <Label text="first" width="70" backgroundColor="#43B3F4" />
  <Label text="second" width="70" backgroundColor="#075B88" />
  <Label text="third" width="70" backgroundColor="#1089CA" />
</FlexboxLayout>

在这里插入图片描述

一周狂揽40K+ Star⭐ 的 Pretext 到底有多变态?

作者 ErpanOmer
2026年4月8日 11:24

这周的前端圈,可以说是被一个叫 Pretext 的项目彻底刷屏了。

短短几天,GitHub 狂揽 41K+ Stars ⭐⭐⭐

很多刚入行的小伙伴看完官方那个极简的 Readme 可能一头雾水:不就是一个算文本长宽高的 JS 库吗?为啥能火成这样?CSS 的 word-wrapflex 难道不够用吗?

v2_741bc7c1a79445528b75ddc1980d6ccd@46958_img_gif.gif

v2_c43f8b79c3f6400d9a995d5f0adc869d@46958_img_gif.gif

v2_715e9ba3c8aa4ea7b8e1bc5b41f87ead@46958_img_gif.gif

但如果是被各种复杂表格、虚拟列表、Canvas 渲染折磨过的老兵,看到 Pretext 的那一刻,绝对会有一种激动的。

因为这个库,极其优雅地干掉了前端性能优化里最恶心、最顽固的问题——强制同步布局(Forced Synchronous Layout)导致的重排(Reflow)。

今天,咱们不念官方文档,结合我这几年的填坑血泪史,聊聊这个 41K Star 的怪物到底解决了什么世界级痛点,以及我们在真实的业务里该怎么用它。


那些年用过的 getBoundingClientRect

前端开发有个大难题:一串动态文本渲染出来到底有多高?

设想一个极度真实的业务场景: 你在做一个拥有十万条数据的 虚拟滚动列表(Virtual List)。为了让列表丝滑,你只能渲染视口内的那 20 条数据。 但问题来了,每条数据里的用户评论长度是不固定的。有的人发了一句 哈哈😁,有的人发了 800 字的写字楼小作文。 在渲染之前,你必须提前知道每一行的高度,才能计算出整个虚拟列表的滚动条位置和绝对定位的 top 值。

在 Pretext 出现之前,我们是怎么做的? 用的往往是最原始、极其粗暴的 离屏 DOM 测量法(Offscreen Measurement)

// 极其恶心的传统测量法:DOM 测算
function measureTextHeightOldWay(text, width, fontSize) {
  // 1. 创建一个隐藏的 div
  const hiddenDiv = document.createElement('div');
  hiddenDiv.style.visibility = 'hidden';
  hiddenDiv.style.position = 'absolute';
  hiddenDiv.style.width = `${width}px`;
  hiddenDiv.style.fontSize = `${fontSize}px`;
  hiddenDiv.innerText = text;

  // 2. 强行塞入 DOM 树
  document.body.appendChild(hiddenDiv);

  // 3. 读取高度(灾难的开始!!!)
  const height = hiddenDiv.offsetHeight; // 或者 getBoundingClientRect()

  // 4. 销毁 DOM
  document.body.removeChild(hiddenDiv);

  return height;
}

代码看着没毛病? 但如果在初始化时,你在一个循环里把这段代码跑了 1000 次,你的页面会当场卡死白屏!

为什么?因为浏览器底层是一个极度慵懒的系统。你操作 DOM 节点,它通常会先攒着,等这一帧结束再一次性绘制。 但当你调用了 offsetHeight 或者 getBoundingClientRect 时,浏览器为了给你一个最精确的值,会被迫打断所有的优化,立刻在主线程里重新计算整个页面的布局(Reflow)。

你循环调用 1000 次,浏览器就被迫重排 1000 次。这种 布局抖动(Layout Thrashing) 是前端性能的头号杀手。


Pretext 的降维打击 - 不碰 DOM,纯数学演算

而 Pretext 的核心卖点,就写在它的第一句介绍里:纯 JavaScript/TypeScript 库,避免了对 DOM 进行测量。

image.png

它完全抛弃了把元素塞进 DOM 里量一下的蠢办法。 你要算这段文字占据多少像素?好,你告诉我字体、字号、容器宽度,我直接在 JS 内存里,通过底层的文本排版算法,硬生生给你出来!

咱们直接上代码,看看接入 Pretext 之后,世界变得有多清爽:

// 使用 Pretext
import { measureText } from 'pretext';

function measureTextHeightNewWay(text, containerWidth) {
  // 没有任何 DOM 操作!直接传入参数计算
  const metrics = measureText(text, {
    fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
    fontSize: 14,
    lineHeight: 1.5,
    maxWidth: containerWidth,
    // 甚至支持复杂的换行策略
    wordBreak: 'break-word', 
  });

  // 直接拿到精准的宽高和行数!
  return metrics.height; 
}

对比一下,这带来了什么工程级别的质变?

快到离谱: 因为不触碰任何 DOM API,不会引起一丝一毫的浏览器重排。同样的测量 1000 条数据,用传统 DOM 法可能需要 300ms(掉帧卡顿),用 Pretext 只需要 2ms。

解锁 Web Worker 潜能: 以前因为要操作 DOM,测量文本的脏活必须在浏览器的主线程(UI 线程)干,极容易阻塞页面。现在它是纯 JS 计算了,你完全可以把这十万条文本高度的计算逻辑,扔到 Web Worker 里去并行跑!主线程依然丝滑如初。

跨平台降维打击: 因为纯 JS/TS,它不仅能在浏览器 DOM 里跑,它还能在 Canvas 游戏引擎里跑,在 Node.js 服务端渲染(SSR)里跑,甚至未来能在 React Native 里跑。

Pretext vs getBoundingClientRect 性能对比

image.png

更多好玩的demo 👉:https://chenglou.me/pretext/

image.png

看似比较简单,实则硬核的技术深水区

其实纯 JS 测算文本这个想法,很多前端老手都想过。为什么直到 2026 年,才被 Pretext 彻底做成了一个 40K+ Star 的杀手级项目?

因为文本排版(Text Layout)是一个深不见底的黑洞。

你以为算个宽度就是 字符数 × 字体宽度? 太天真了。你需要考虑英文单词的断词(换行不能把单词截断)、需要考虑阿拉伯语的从右到左(RTL)、需要考虑中文的标点符号避头尾规则、更别提那些五花八门的 Emoji(有的 Emoji 占好几个字节,但在屏幕上只是一个字符)。

更变态的是,不同浏览器(Chrome / Safari)底层的字体引擎(HarfBuzz 等)渲染规则都有细微差异。

Pretext 的作者 chenglou(做过 React Core,写过 ReasonML 的真大神👍👍👍)用了一种极其聪明且符合现在 AI 时代的方法:将浏览器自身的字体引擎作为基准进行迭代对齐。

image.png

它没有去傻傻地重写一套从零开始的渲染引擎,而是找到了一套能与主流浏览器高度拟合的纯数学计算逻辑。精度极高,且极其轻量。

这不是在造轮子,这是在用极客思维给现有的前端标准打补丁。


那么,哪些场景该果断接入 Pretext?

虽然我把它吹爆了,但作为一个老油条,我必须负责任地告诉你:不要脑子一热,把项目里所有的普通 CSS 排版都换成它。 CSS 引擎依旧是渲染标准流最稳定、最简单的方案。

Pretext 是属于极端场景。 遇到以下三种情况,直接掏出它:

复杂数据看板 / 大规模动态虚拟列表: 前面提到的,需要提前精确知道变长文本高度,来进行复杂绝对定位 计算的场景。

Canvas / WebGL 富文本渲染: 用过 Canvas 的人都知道,Canvas 里的 fillText 极其原始,根本不支持自动换行。以前我们在 Canvas 里画多行文本简直是噩梦,现在可以直接用 Pretext 算好每一行的位置,然后精确绘制。

基于 Node.js 的海报/PDF 自动生成系统: 服务端没有 DOM 环境,以前为了算一下文本会不会超出海报边界,还得专门在服务端起一个无头浏览器(Puppeteer),贼耗服务器资源。现在直接 Node.js 引入 Pretext 纯端计算,一台 2 核机器能顶过去 8 核的并发量。


这才是前端该有的样子🤔

这两年,前端圈充满了大模型、AI 生成代码的焦虑,似乎一切不加个 AI 前缀就不够前沿。

但看到 Pretext 这种纯粹为了解决计算机图形学底层痛点、一行一行扣性能、追求极致优雅的开源项目,短短几天收获 40K+ Star,我心里其实是挺欣慰的。

它证明了一件事:在花里胡哨的概念之外,这个世界上永远有那些扎根在工程最深处、被真实痛点折磨的开发者。

真正高级的前端工程能力,不是你接了多少个最新的大模型 API,而是当系统出现肉眼可见的卡顿时,精准地指出那句隐藏在万行代码里的 offsetHeight,然后用纯粹的数学与算法,把页面性能拉升两个数量级。

周末了,别只顾着看个热闹,去把 Pretext 拉下来,在本地建个 Canvas 或者虚拟列表的 Demo 跑一跑。

那种看着耗时从 300 毫秒断崖式下跌到 2 毫秒的爽感,才是写代码真正的乐趣😁。

NativeScript 的 Tab 标签页和底部导航组件

作者 sp42_frank
2026年4月8日 11:24

和前端开发不同,NativeScript 目标运行于 APP 之上故而有其独特的布局系统。今天我们就好好了解一下 NativeScript 的布局机制。

网上有很好的学习资源 www.nslayouts.com/,我们翻译一下转为该教程。

<StackLayout>布局

StackLayout 布局,默认按照垂直方向排列元素。

<StackLayout orientation="vertical">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
</StackLayout>

在这里插入图片描述

修改属性StackLayout orientation="horizontal"为水平方向布局: 在这里插入图片描述

居中显示

设定<StackLayout orientation="horizontal" horizontalAlignment="center">在这里插入图片描述 水平、垂直居中<StackLayout orientation="horizontal" horizontalAlignment="center" verticalAlignment="center">

在这里插入图片描述 子元素自己也可以设置水平对齐:

<StackLayout orientation="vertical">
<Image src="res://angular" stretch="none" horizontalAlignment="left"></Image>
<Image src="res://vue" stretch="none" horizontalAlignment="center"></Image>
<Image src="res://preact" stretch="none" horizontalAlignment="right"></Image>
</StackLayout>

在这里插入图片描述

<WrapLayout>布局

<WrapLayout>布局将组件彼此环绕,填满可用空间(要么水平方向——按行,要么垂直方向——按列)。默认情况下,<WrapLayout>的方向是水平的。

<WrapLayout orientation="horizontal">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
  <Image src="res://preact" stretch="none"></Image>
  <Image src="res://webpack" stretch="none"></Image>
  <Image src="res://redux" stretch="none"></Image>
  <Image src="res://nativescripting" stretch="none"></Image>
</WrapLayout>

在这里插入图片描述 改为垂直方向,也就是按列(Column)排列

<WrapLayout orientation="vertical">
  <Image src="res://nativescript" stretch="none"></Image>
  <Image src="res://angular" stretch="none"></Image>
  <Image src="res://vue" stretch="none"></Image>
  <Image src="res://preact" stretch="none"></Image>
  <Image src="res://webpack" stretch="none"></Image>
  <Image src="res://redux" stretch="none"></Image>
  <Image src="res://nativescripting" stretch="none"></Image>
</WrapLayout>

在这里插入图片描述

<AbsoluteLayout>绝对布局

<AbsoluteLayout>类似于 Web 的绝对布局,通过布局容器的 top/left 值来定位组件。如下所示,只需为每个 Image 组件分配 top 和 left 属性,:

<Image src="res://nativescript" stretch="none" top="10" left="10" />
[...] top="10" left="170"
[...] top="170" left="10"
[...] top="170" left="170"

⚠️ 提示:top/left 属性的值是"密度无关像素"(一种测量单位,允许布局设计独立于屏幕密度)。

<AbsoluteLayout>
<Image src="res://nativescript" stretch="none" top="10" left="10"></Image>
<Image src="res://angular" stretch="none" top="10" left="170"></Image>
<Image src="res://vue" stretch="none" top="170" left="10"></Image>
<Image src="res://preact" stretch="none" top="170" left="170"></Image>
</AbsoluteLayout>

在这里插入图片描述 当你需要用其他布局容器无法实现的方式来定位组件时,就能看出<AbsoluteLayout>的真正强大之处。例如假设你想让组件重叠。我们该如何实现呢?显而易见的方法是简单地设置新的 top/left 值就完事了。不过我们要尝试一种不同的方法,那就是给元素添加一个 margin 属性。是的,就是你在网页上使用的相同 CSS margin!

在这个例子中,继续给第二个 Image 元素添加 30 的 margin,看看会发生什么。

<AbsoluteLayout>
  <Image src="res://webpack" stretch="none" top="10" left="10"></Image>
  <Image src="res://redux" stretch="none" top="10" left="10" margin="30"></Image>
</AbsoluteLayout>

在这里插入图片描述

<DockLayout>布局

通常在想要将元素定位在其父容器(通常是页面/视图本身)的侧面时使用 <DockLayout>。这里我们把图标停靠在屏幕的左侧/顶部/右侧/底部。请注意 stretchLastChild 属性,因为这将在下一课中发挥作用。

<DockLayout stretchLastChild="false">
  <Image src="res://nativescript" stretch="none" dock="left"></Image>
  <Image src="res://angular" stretch="none" dock="top"></Image>
  <Image src="res://vue" stretch="none" dock="right"></Image>
  <Image src="res://webpack" stretch="none" dock="bottom"></Image>
</DockLayout>

在这里插入图片描述stretchLastChild 设置为 true 实际上可以让你在父容器的中间"停靠"一个项目。名称中的 LastChild 部分是关键,它告诉你列出的最后一个元素将是被拉伸或居中显示在你的布局中的元素。

这次,继续将 stretchLastChild 设置为 true,看看我们的元素会发生什么变化。

在这里插入图片描述

<GridLayout>布局

<GridLayout> 跟 HTML 表格很类似,一样有行和列,你需要指定行/列的大小(以及每个元素应该放入哪个行/列)。

这个例子将创建一个两列布局,并且两列都使用自动调整大小(稍后会有更多介绍)。你只需要在下面的每个元素中填入适当的基于 0 的索引列即可。

<GridLayout columns="auto, auto" rows="auto">
<Image src="res://nativescripting" stretch="none" row="0" col=""></Image>
<Image src="res://redux" stretch="none" row="0" col=""></Image>
</GridLayout>

在这里插入图片描述

下面例子中对 <GridLayout> 中添加一些行,将六个图像元素组织成一个两列三行的布局。同样,我们会在后续课程中解释 auto 到底是什么意思。

<GridLayout columns="auto, auto" rows="auto, auto, auto">
<Image src="res://nativescript" stretch="none" row="" col="0"></Image>
<Image src="res://angular" stretch="none" row="" col="1"></Image>
<Image src="res://vue" stretch="none" row="" col="0"></Image>
<Image src="res://webpack" stretch="none" row="" col="1"></Image>
<Image src="res://nativescripting" stretch="none" row="" col="0"></Image>
<Image src="res://redux" stretch="none" row="" col="1"></Image>
</GridLayout>

在这里插入图片描述

现在我们可以看看 <GridLayout> 的下一个类似 HTML 的属性,colSpan。就像 HTML 表格一样,我们可以使用这个属性让一个元素跨越多列。

下例子中设置第二行中的图像跨越所有三列。

<GridLayout columns="auto, auto, auto" rows="auto, auto">
<Image src="res://nativescript" stretch="none" row="0" col="0"></Image>
<Image src="res://angular" stretch="none" row="0" col="1"></Image>
<Image src="res://vue" stretch="none" row="0" col="2"></Image>
<Image src="res://preact" stretch="none" row="1" col="0" colSpan="3"></Image>
</GridLayout>

在这里插入图片描述 现在我们把 <GridLayout> 颠倒过来 🙃 并学习一下 rowSpan 属性。同样,像 HTML 表格一样,使用 rowSpan 我们可以让一个元素跨越多行。

现在我们可以让我们的布局更加巧妙,以独特的方式排列我们的图像元素。继续并使用 rowSpan 属性作用于前两列中的元素,使得第一列有一个图像,第二列有两个,第三列有三个。

<GridLayout columns="auto, auto, auto" rows="auto, auto, auto">
<Image src="res://nativescript" row="0" col="0" rowSpan="3"></Image>
<Image src="res://angular" row="0" col="1"></Image>
<Image src="res://vue" row="1" col="1" rowSpan="2"></Image>
  <Image src="res://webpack" row="0" col="2"></Image>
  <Image src="res://nativescripting" row="1" col="2"></Image>
  <Image src="res://redux" row="2" col="2"></Image>
</GridLayout>

在这里插入图片描述 回想一下之前的课程中我们使用的语法,如 columns="auto"rows="auto"?当我们使用 auto 时,我们是在告诉我们的布局根据元素的实际大小自动调整列/行的尺寸。

但是如果我们想要给某一列使用特定的宽度怎么办?幸运的是,我们可以通过简单地代入一个数值来处理这个问题。让我们把图像稍微分散一点,并在我们的第一列上使用 200 的固定宽度,在我们的第一行上也使用 200 的固定宽度。

<GridLayout columns=", auto" rows=", auto">
  <Image src="res://webpack" stretch="none" row="0" col="0"></Image>
  <Image src="res://nativescripting" stretch="none" row="0" col="1"></Image>
  <Image src="res://redux" stretch="none" row="1" col="0"></Image>
  <Image src="res://preact" stretch="none" row="1" col="1"></Image>
</GridLayout>

在这里插入图片描述

我们通过查看调整行或列大小的最后一种方式来结束 <GridLayout> 的学习,那就是星号(*)大小调整。星号大小调整允许行/列在为固定宽度和自动宽度列分配空间后(在所有星号大小的列中按比例分配),占据尽可能多的空间。您是否还感到困惑 😕?可以这样理解:

  • "星号" == 贪婪(占据所有它可以占据的空间)
  • "自动" == 吝啬(只占据它需要的空间)

看看一个类似于上一课中图像放置的示例。但这次将使用星号大小调整并添加乘数,使第一列和第一行的空间加倍(提示:2* 给您两倍于*的空间)。

<GridLayout columns="2*, *" rows="2*, *">
  <Image src="res://nativescript" stretch="none" row="0" col="0"></Image>
  <Image src="res://angular" stretch="none" row="0" col="1"></Image>
  <Image src="res://vue" stretch="none" row="1" col="0"></Image>
  <Image src="res://preact" stretch="none" row="1" col="1"></Image>
</GridLayout>

在这里插入图片描述

嵌套布局

通过将布局相互嵌套,您可以创建几乎任何可能的用户界面。但我们先从简单的开始,只需将一个<StackLayout>嵌套在另一个<StackLayout>内部即可。

 <StackLayout>
  <StackLayout>
    <Image src="res://webpack" stretch="none"></Image>
    <Image src="res://nativescripting" stretch="none"></Image>
    <Image src="res://redux" stretch="none"></Image>
  </StackLayout>
</StackLayout>

在这里插入图片描述 诚然,这不是嵌套布局最现实的用法,因为输出结果与仅使用一个 <StackLayout> 相同!

通过在一个单独的 <StackLayout> 中嵌入两个 <StackLayout> 元素,并将这两个布局水平对齐。⚠️ 在这种情况下,您会发现第一个 <StackLayout> 的 orientation 属性仅适用于其直接子容器(在这种情况下是另外两个布局)。

<StackLayout orientation="horizontal">
  <StackLayout>
    <Image src="res://nativescript" stretch="none"></Image>
    <Image src="res://vue" stretch="none"></Image>
  </StackLayout>
  <StackLayout>
    <Image src="res://angular" stretch="none"></Image>
    <Image src="res://preact" stretch="none"></Image>
  </StackLayout>
<StackLayout>

在这里插入图片描述最后总结一下嵌套布局。这个例子将使用一个单列、两行的 <GridLayout>(具有自动调整大小的行/列),其中包含两个 <StackLayout> 元素,每个元素都有不同的方向来放置它们包含的图像。

<GridLayout columns="auto" rows="auto, auto">
  <StackLayout orientation="vertical" row="0" col="0">
    <Image src="res://nativescript" stretch="none"></Image>
    <Image src="res://angular" stretch="none"></Image>
  </StackLayout>
  <StackLayout orientation="horizontal" row="1" col="0">
    <Image src="res://vue" stretch="none"></Image>
    <Image src="res://nativescripting" stretch="none"></Image>
  </StackLayout>
<GridLayout>

在这里插入图片描述

<RootLayout>布局

<RootLayout> 是用于以编程API动态分层视图的布局容器。

<RootLayout> 是一种布局容器,旨在用作应用程序的主要根布局容器,内置API可轻松控制动态视图层。它扩展了 GridLayout,因此具有 GridLayout 的所有功能,并增强了额外的API。

参见官网教程

<FlexboxLayout>布局

<FlexboxLayout>跟 CSS Flexbox 很类似,可以在水平和垂直方向上排列子组件。子组件按照声明的顺序放置,除非被 order 子属性覆盖。

以下示例创建了一行三个等大的元素,这些元素跨越屏幕的整个高度。

<FlexboxLayout backgroundColor="#3c495e">
  <Label text="first" width="70" backgroundColor="#43B3F4" />
  <Label text="second" width="70" backgroundColor="#075B88" />
  <Label text="third" width="70" backgroundColor="#1089CA" />
</FlexboxLayout>

在这里插入图片描述

GitHub 25k Star!这款开源录屏工具,免费无水印可商用,彻底告别付费

2026年4月8日 11:19

最近在学习视频的录制,但是录制的工具始终是一个头疼的问题。

试过几款免费软件,导出的视频不是糊成一团,就是画面挂着一个大水印。

想用 Screen Studio,功能确实好,但29美刀/月的订阅费对于一个还在学习阶段的人来说,实在下不去手。

直到在 GitHub 上刷到了这个项目 OpenScreen,基于 Electron + React + TypeScript 实现,最新版支持中英文切换。

目前 25k Star,三天前还在更新。

它打的口号正中我的痛点:免费、高清、无水印、可商用,做 Screen Studio 的开源替代品。

Windows 直接上手

01、下载与安装

打开 OpenScreen 的 GitHub 仓库(搜 openscreen),打开后可以看到右边有官网地址可以进入,下载,双击运行,一路下一步,30 秒装完。

02、录制前的设置

启动软件,可以看到一个小悬浮框。你需要做三个选择:选择屏幕、是否打开系统音频、麦克风,然后点击开始录制就可以了。

03、进入编辑界面(核心功能区)

停止录制后,自动跳转到编辑界面。界面分三个区域:

中间上方:实时预览窗口
底部:时间轴,视频被切成一条
右侧:属性编辑面板

04、四大核心编辑功能

① 裁剪片段

想剪掉某一段?先在时间轴上定位,然后用分割工具切开再删。

② 自动/手动缩放(灵魂功能)

缩放功能让观众的视线始终跟着你的操作走。

用法很简单:

1:在时间轴上点一下你想放大的位置。 2:点旁边的 「+」 按钮,选择 Z3:预览窗口出现白色框,拖动它框选要放大的区域 。 4:右侧面板调整缩放的倍数

你可以加多个缩放点,画面会平滑地从一个焦点移动到下一个焦点。看下我的效果,我是直接导出的gif。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

③ 更换背景

录制时如果桌面比较乱,或者不想让观众看到桌面壁纸,可以一键换背景。

选中时间轴上的视频条,在右侧属性面板找到 「Background」

选项 效果
Wallpaper 内置抽象壁纸
Solid 纯色背景
Gradient 渐变色
Custom 上传自己的图片

选好之后背景立刻替换,主体内容保持清晰。

④ 添加标注

需要在视频里加文字说明、指向箭头或 Logo 时,用标注功能。

可添加三种元素:

Text:输入任意文字,调整字号颜色
Arrow:画一个箭头,指向你想强调的按钮或区域
Image:插入图片(比如公司 Logo)

每个标注都可以设置出现时长和位置。

⑤ 其他实用功能

还有其它变速、运动模糊,视频的导出功能16:9(横屏)、1:1,你可以自己亲身体验一下。

技术实现深度解析

01、技术栈一览

根据项目代码和依赖配置,OpenScreen 的技术选型如下:

层级 技术 占比/说明
桌面框架 Electron 跨平台桌面应用
前端框架 React + TypeScript 界面与逻辑
构建工具 Vite 快速开发构建
渲染引擎 PixiJS 高性能 2D 渲染
时间轴组件 dnd-timeline 拖拽式时间轴
样式 Tailwind CSS 原子化 CSS
代码规范 Biome 格式化 + Lint

02、屏幕录制是怎么实现的?

核心录屏功能依赖 Electron 的 desktopCapturer API:

用户点击录制
    ↓
调用 desktopCapturer.getSources()
    ↓
获取可录制的窗口和屏幕列表
    ↓
用户选择后,用 navigator.mediaDevices.getUserMedia() 获取视频流
    ↓
用 MediaRecorder API 把流录下来,存成 WebM 格式

音频部分

  • 系统音频:走 desktopCaptureraudio: 'loopback' 参数
  • 麦克风:走单独的音频流
  • 最后两条流合并

说明:Windows 上系统音频之所以“开箱即用”,是因为 Electron 在 Windows 上直接调用了底层的 Windows Audio Session API,不需要额外配置虚拟声卡。

03、缩放和平滑动画怎么做的?

这是 OpenScreen 最核心的编辑功能。

实现思路

第一步:录制时保存原始视频
第二步:用户在时间轴上添加“缩放关键帧”,记录时间位置、缩放区域坐标、放大倍率、持续时间
第三步:渲染时用 PixiJS 实时计算每一帧的变换矩阵
第四步:两个缩放点之间用缓动函数插值,产生平滑过渡

为什么用 PixiJS?

PixiJS 基于 WebGL,处理实时视频纹理变换时性能更好,缩放动画才能保持 60fps 流畅。Canvas 2D 做不了这个复杂度。

运动模糊也是 PixiJS 的能力,在快速平移时对画面施加方向性模糊滤镜,视觉上更接近专业剪辑软件的效果。

04、时间轴的拖拽编辑

编辑界面底部的时间轴,支持拖拽调整片段位置、拉伸裁剪、添加关键帧,这些交互用的是 dnd-timeline 这个库。

它是一个专门为 React 设计的可拖拽时间轴组件,支持:

  • 时间轴缩放(Zoom In/Out 时间精度)
  • 拖拽移动片段
  • 拖拽边缘裁剪
  • 在时间轴上任意位置添加标记点

OpenScreen 把缩放点、标注点都抽象成了时间轴上的“标记”,统一用 dnd-timeline 管理。

05、导出视频的流程

导出时的渲染不是实时播放,而是离屏渲染

根据时间轴数据
    ↓
逐帧计算画面(应用缩放、背景、标注等)
    ↓
每一帧用 PixiJS 渲染到 Canvas
    ↓
用 Canvas.captureStream() 把 Canvas 内容编码成视频
    ↓
最终输出 MP4

注意:这个过程比较耗 CPU,所以导出进度条走的时间通常比视频时长要长,这是正常的。

优缺点总结

优点

优点 说明
完全免费 无水印、可商用,个人和商业用户都没有门槛
Windows 体验完美 系统音频、安装流程都无需折腾
核心功能扎实 自动缩放、背景替换、标注都流畅可用
技术栈现代 代码可读性好,适合二次开发

注意事项

注意事项 说明
Beta 版本 作者坦言可能有 bug,但日常轻度使用基本稳定
功能深度有限 没有高级光标效果、点击涟漪、3D 倾斜等

获取方式

GitHub 地址:

https://github.com/siddharthvaddem/openscreen

作者在 README 里写了一句话让我印象很深:

"I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue."

一个自称“不知道自己在干嘛”的开源新人,居然做出了一款 25k Star、功能扎实、文档齐全的工具,简直太牛了。

如果觉得文章还不错,请给我点个喜欢吧!

cornerstone3D 通过二进制渲染影像

作者 上山打牛
2026年4月8日 11:13

cornerstone3D 通过二进制渲染影像

实现步骤

1.初始化 Cornerstone3D 核心库
import {
init as csRenderInit,} from '@cornerstonejs/core';
await csRenderInit();
2.自定义图像加载器

注册一个自定义图像加载器,协议为 fakeImageLoader:。当遇到以该协议开头的 imageId 时,会调用 registerImageLoader 函数去加载图像

import {imageLoader} from '@cornerstonejs/core';
imageLoader.registerImageLoader('fakeImageLoader', registerImageLoader);
3.添加元数据

添加一个元数据提供者(优先级 10000),用于为 fakeImageLoader 协议的图像提供 DICOM 元数据

//下面用官方的
 metaData.addProvider(fakeMetaDataProvider, 10000);
4.创建渲染引擎并启用视口
  const renderingEngine = new RenderingEngine(renderingEngineId);
  const viewportInput = {
    viewportId,
    type: ViewportType.STACK,
    element: containerRef.value!,
    defaultOptions: {
      background: [0.2, 0, 0.2] as Types.Point3,
    },
  } as Types.PublicViewportInput;
  renderingEngine.enableElement(viewportInput);
5.获取视口实例
  const viewport = renderingEngine.getViewport(viewportId) as Types.IStackViewport;
6.构建图像
  const imageIds = [
    `fakeImageLoader:${encodeURIComponent(JSON.stringify({
      uri: 'https://www.xx.com/CT.1.2.156.14702.1.1006.128.2.202401270115395626786.dcm'
    }))}`,
    `fakeImageLoader:${encodeURIComponent(JSON.stringify({
      uri: 'https://www.xx.com/CT000000.dcm'
    }))}`
  ];
7.设置堆栈
 await viewport.setStack(imageIds);
8.预加载所有图像

预加载所有影像,如果不使用则是懒加载

  imageIds.forEach(async (imageId) => {
    try {
      // 触发registerImageLoader 去加载图像,并将结果存入缓存
      await imageLoader.loadAndCacheImage(imageId);
    } catch (error) {
      console.error(`预加载图像 ${imageId} 失败:`, error);
    }
  });
9.渲染视口
 viewport.render();
10.registerImageLoader函数

registerImageLoader函数是一个自定义图像加载器,当imageIds中包含了该协议就会触发该函数,返回image对象,该对象必须包装为{promise}

const promise = fetch()
.then(res=>res.arrayBuffer())
.then(res=>{
const byteArray = new Uint8Array(buffer);
const dataSet = dicomParser.parseDicom(byteArray);
const columns = dataSet.uint16('x00280011') as number;
const rows = dataSet.uint16('x00280010') as number;
// 像素间距安全解析
      let rowPixelSpacing = 1,
        columnPixelSpacing = 1;
      const pixelSpacingRaw = dataSet.string('x00280030');
      if (pixelSpacingRaw && typeof pixelSpacingRaw === 'string') {
        const parts = pixelSpacingRaw.split('\\');
        if (parts.length >= 2) {
          rowPixelSpacing = parseFloat(parts[0]);
          columnPixelSpacing = parseFloat(parts[1]);
        } else if (parts.length === 1) {
          rowPixelSpacing = columnPixelSpacing = parseFloat(parts[0]);
        }
      }

      // 窗宽窗位
      let windowCenter = dataSet.floatString('x00281050');
      let windowWidth = dataSet.floatString('x00281051');
      if (isNaN(windowCenter) || isNaN(windowWidth)) {
        windowCenter = 40;
        windowWidth = 400;
      }

      // 像素数据元素
      const pixelElem = dataSet.elements.x7fe00010;
      if (!pixelElem) throw new Error('Missing pixel data');

      // 注意:某些 DICOM 文件在像素数据前有 2 个额外字节(如奇偶对齐),尝试自动检测
      let dataOffset = pixelElem.dataOffset;
      let dataLength = pixelElem.length;

      const bitsAllocated = dataSet.uint16('x00280100') || 8;
      const pixelRepresentation = dataSet.uint16('x00280103') || 0;
      const bytesPerPixel = bitsAllocated / 8;
      const expectedLength = columns * rows * bytesPerPixel;

      // 如果长度不匹配,尝试调整偏移量(常见偏移 2 或 4 字节)
      if (dataLength > expectedLength) {
        const excess = dataLength - expectedLength;
        if (excess === 2 || excess === 4) {
          dataOffset += 2; // 尝试跳过 2 个字节
          dataLength = expectedLength;
          console.log(`调整像素数据偏移 +2 字节,新长度 ${dataLength}`);
        } else if (excess % 2 === 0 && excess <= 8) {
          dataOffset += 2; // 通用尝试
          dataLength = expectedLength;
          console.warn(`长度不匹配,自动偏移 +2,原始长度 ${pixelElem.length}, 期望 ${expectedLength}`);
        } else {
          console.warn(`长度不匹配且无法自动修正: 实际 ${dataLength}, 期望 ${expectedLength}`);
        }
      } else if (dataLength < expectedLength) {
        console.warn(`像素数据不足: 实际 ${dataLength}, 期望 ${expectedLength}`);
      }

      const pixelDataRaw = new Uint8Array(buffer, dataOffset, dataLength);

      let scalarData: Uint8Array | Uint16Array | Int16Array;

      if (bitsAllocated === 16) {
        // 确保长度是偶数字节
        const adjustedLength = Math.floor(pixelDataRaw.length / 2) * 2;
        const uint16Data = new Uint16Array(pixelDataRaw.buffer, pixelDataRaw.byteOffset, adjustedLength / 2);
        if (pixelRepresentation === 1) {
          scalarData = new Int16Array(uint16Data.buffer);
        } else {
          scalarData = uint16Data;
        }
      } else {
        scalarData = pixelDataRaw;
      }

      // 如果最终数据长度仍大于期望值,截断
      if (scalarData.length > expectedLength / bytesPerPixel) {
        const constructor = scalarData.constructor as any;
        const truncated = new constructor(expectedLength / bytesPerPixel);
        truncated.set(scalarData.slice(0, expectedLength / bytesPerPixel));
        scalarData = truncated;
      }

      // 计算实际像素值范围
      let minVal = Infinity,
        maxVal = -Infinity;
      for (let i = 0; i < scalarData.length; i++) {
        const val = scalarData[i];
        if (val < minVal) minVal = val;
        if (val > maxVal) maxVal = val;
      }

      // 创建 VoxelManager
      const imageVoxelManager = utilities.VoxelManager.createImageVoxelManager({
        height: rows,
        width: columns,
        numberOfComponents: 1,
        scalarData,
      });
      const image = {
        rows,
        columns,
        width: columns,
        height: rows,
        imageId,
        intercept: 0,
        slope: 1,
        voxelManager: imageVoxelManager,
        invert: false,
        minPixelValue: minVal,
        maxPixelValue: maxVal,
        windowCenter,
        windowWidth,
        rowPixelSpacing,
        columnPixelSpacing,
        getPixelData: () => scalarData,
        sizeInBytes: rows * columns * 1, // 1 byte for now
        FrameOfReferenceUID: 'Stack_Frame_Of_Reference',
        // imageFrame: {
        //   photometricInterpretation: 'RGB',
        // },
      };
      return image;
    });
})
return {promise}

cornerstone3D基本使用

作者 上山打牛
2026年4月8日 10:47

cornerstone3D基本使用

实现步骤

1.初始化 Cornerstone3D 核心库

设置 WebGL 上下文、注册内部组件,并确保渲染引擎、加载器、元数据系统等基础设施就绪。必须在调用任何其他 Cornerstone API 之前执行

  await initCornerstone()
2.获取DICOM 实例的元数据

根据提供的 StudyInstanceUIDSeriesInstanceUID 和 DICOMweb 服务器地址(wadoRsRoot),向服务器发起查询返回一个包含所有 imageId 的数组

const imageIds = await createImageIdsAndCacheMetaData({
    StudyInstanceUID:'',
    SeriesInstanceUID: '',
    wadoRsRoot: '',
  });
3.创建渲染引擎

实例化一个渲染引擎,它负责管理所有视口(Viewport)并协调 WebGL 渲染

 const renderingEngineId = 'myRenderingEngine';
  const renderingEngine = new RenderingEngine(renderingEngineId);
4.定义视口输入
const viewportId = 'CT_STACK';
  const viewportInput = {
    viewportId,
    type: ViewportType.STACK,
    element: containerRef.value,
    defaultOptions: {
      background: [0.2, 0, 0.2] as Types.Point3,
    },
  };
参数 类型 是否必需 描述
viewportId string 视口的唯一标识符
type ViewportType 视口的类型
element HTMLDivElement 容器
defaultOptions PublicViewportInput 视口的默认配置项
defaultOptions.background RGB 视口背景色
defaultOptions.orientation OrientationAxis|OrientationVectors 设置视口的初始观察方向。可传入AXIALSAGITTALCORONAL等轴向枚举值,或自定义viewPlaneNormalviewUp向量
defaultOptions.displayArea DisplayArea 设置图像的初始缩放和位置(如平移),控制图像在视口中的可见区域
defaultOptions.suppressEvents boolean 是否使用平行投影。仅对VOLUME_3D视口有效,平行投影无透视效果,常用于精确测量
defaultOptions.parallelProjection boolean 设为true可禁止视口创建和配置过程中触发相关事件,用于在批量操作或初始化阶段避免不必要的监听器响应
5.启用视口

将视口绑定到渲染引擎,并创建对应的 WebGL 画布和交互层

  renderingEngine.enableElement(viewportInput);
6.获取视口实例

创建的堆栈视口对象,后续通过它操作图像堆栈和渲染

const viewport = renderingEngine.getViewport(
    viewportId
  ) as Types.IStackViewport;
7.设置图像堆栈

堆栈设置为仅包含序列中的第一张图像(此函数也会触发render)

  await viewport.setStack(imageIds,current = 0);
8.渲染

视口立即重绘

viewport.render();

多端项目太乱?我是这样用 Monorepo 重构的

作者 锦木烁光
2026年4月8日 10:27

🚀 如何用 Monorepo 管理多端项目?一套可落地方案

一、从“架构设计”到“工程落地”

在上一篇中,我们解决了一个核心问题:

多端架构应该如何设计?

我们得出的结论是:

用分层架构统一逻辑(UI / modules / services)

👉 那这一篇,我们解决另一个更现实的问题:

如何把这套架构真正落地?

二、为什么多端项目一定会“失控”?

当你开始同时维护 Web、小程序、App 等多个端时,传统的 Multi-repo(多仓库) 很容易演变成开发灾难:

  • 重复劳动:相同业务逻辑在多个仓库重复实现
  • 同步地狱:接口字段变更,需要手动同步多个项目
  • 维护混乱:修一个 Bug,要改多个仓库

🔥 本质问题:

代码没有统一的“抽象与复用边界”

👉 所以你需要一种机制:

既能共享代码,又能保持边界清晰

👉 这就是:

Monorepo(单仓多包)

三、为什么多端架构必须用 Monorepo?

相比传统 Multi-repo,Monorepo 的优势非常明显:

维度 Multi-repo Monorepo
代码复用 复制 / npm 发布 本地直接引用
类型共享 手动同步 自动同步
依赖管理 各自维护 统一管理
代码变更 多仓提交 原子提交

👉 对多端项目来说,它解决了最核心的问题:

让“可复用逻辑”有了统一载体

四、项目结构设计(核心)

这是 Monorepo 成败的关键。


📦 推荐结构(与架构分层一致):

my-repo/
├── apps/                  # 应用层(各端独立)
│   ├── web/               # Web(Next.js / React)
│   ├── mini/              # 原生小程序
│   └── admin/             # 管理后台
│
├── packages/              # 复用能力层
│   ├── services/          # API 层(OpenAPI)
│   ├── modules/           # 业务逻辑(核心 ⭐)
│   ├── request/           # 请求适配层
│   └── shared/            # 工具函数
│
├── package.json
└── pnpm-workspace.yaml

🔥 核心设计原则:

apps = 面向用户(不可复用)
packages = 面向复用(核心资产)

👉 最关键的一点:

所有“可复用逻辑”,必须进入 packages,而不是 apps

五、从 0 搭建 Monorepo(实操)


1️⃣ 初始化项目

mkdir my-repo && cd my-repo
pnpm init

2️⃣ 配置 workspace

创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"


3️⃣ 创建目录结构

mkdir -p apps/web
mkdir -p apps/mini
mkdir -p packages/services
mkdir -p packages/modules
mkdir -p packages/request
mkdir -p packages/shared


4️⃣ 初始化子包(以 modules 为例)

cd packages/modules
pnpm init

修改 package.json

{
  "name": "@repo/modules",
  "version": "1.0.0",
  "private": true,
  "main": "./index.ts",
  "types": "./index.ts"
}


5️⃣ 在应用中引用

apps/web 中执行:

pnpm add @repo/modules --workspace

然后即可直接使用:

import { useUser } from "@repo/modules"

六、依赖管理(核心原则)


🔥 原则一:依赖就近声明

在哪使用,就在哪声明依赖

❌ 错误做法:

所有依赖都装在根目录

👉 会导致:

依赖污染 + 隐式依赖


🔥 原则二:单向依赖

apps → modules → services → request

👉 严禁:

modules → apps
services → modules


🔥 原则三:公共依赖再提升

例如:

pnpm add -wD typescript eslint

七、TypeScript 与构建优化


1️⃣ 类型闭环(强烈推荐)

services 中定义 API 类型:

后端变更 → TS 报错 → 前端即时修复

👉 好处:

把“线上错误”变成“编译错误”


2️⃣ Turborepo(进阶优化)

安装:

pnpm add turbo -wD

配置 turbo.json

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

👉 带来的能力:

并行构建 + 缓存加速

八、最常见的 3 个坑


❗ 1. 依赖循环(Circular Dependency)

modules → shared
shared → modules ❌

👉 解决:

抽象更底层包,保持单向依赖


❗ 2. 编译入口问题

部分环境(如小程序)不支持直接引用 TS 源码。


👉 解决:

配置 exports / alias / 构建输出


❗ 3. 配置冗余

每个包都写 tsconfig 很麻烦。


👉 解决:

// tsconfig.base.json

子包继承:

{
  "extends": "../../tsconfig.base.json"
}

九、这一步完成后,你得到了什么?


✅ 工程能力提升:

一次修改,多端生效
逻辑复用能力大幅提升
类型安全贯穿全链路


🔥 更重要的是:

你的代码开始“结构化”

十、总结一句话

Monorepo 不是工具,而是工程组织方式

🎯 结语

很多人觉得工程复杂,是因为工具太多。

但本质是:

代码没有边界

👉 Monorepo 的意义是:

让代码有“归属”,让复杂度可控

🚀 下一篇预告

到这里,你已经完成:

架构设计(第2篇)
+ 工程落地(第3篇)

👉 下一篇,我们进入最核心的一步:

多端架构最难的 3 个问题(request / modules / design system)

🔥 这一篇,会是整个系列的“认知分水岭”。

约定式路由的极简主义实践:一个插件搞定 React/Vue × Vite/Rspack

2026年4月8日 10:20

约定式路由,再出发

"如果代码是一场修行,那么好的架构就是通往顿悟的第一步。" —— 某只熬夜重构代码的程序员


前情提要

故事要从一个风和日丽的下午说起。

我盯着屏幕上那个 3000 多行的 context.ts 文件,感觉自己的灵魂也在随着代码一起发霉。明明只是一个约定式路由插件,为什么会复杂到这种程度?

8000+ 行冗余代码。这五个字是我那天最不想看到的东西。

没错,27 天前,unplugin-convention-routes 还不是一个插件,更像是一个代码灾难现场——职责混乱、重复满天飞、测试?那是什么?能吃吗?

但现在,我终于可以挺起胸膛说:这一切,都结束了。


灾难现场回顾

让我们穿越回重构之前,看看那个"黑暗时代"的目录结构:

src/
├── core/
│   ├── constants.ts     
│   ├── context.ts       # 上下文管理(175行,塞了配置+扫描+生成)
│   ├── customBlock.ts   # 自定义块处理(105行,处理个啥?)
│   ├── files.ts         
│   ├── initVirualPackage.ts  # 虚拟包初始化(名字还拼错了 Virual)
│   ├── options.ts       # 配置解析
│   ├── path.ts          # 路径处理
│   ├── stringify.ts     # 代码生成(90行)
│   ├── types.ts         # 类型定义
│   └── utils.ts         # 工具函数
├── resovlers/           # resolvers 居然还拼错了!
│   ├── index.ts
│   ├── react.ts
│   ├── solid.ts         
│   └── vue.ts
├── esbuild.ts           # 等等,esbuild 用得上吗?
├── farm.ts              # Farm?Rspack 之前叫 Farm?
├── rollup.ts            # Rollup?这是给库用的吧?
├── rspack.ts
├── vite.ts
├── webpack.ts           
└── ...其他入口文件

看完之后,我的感受是这样的:∑(っ°Д°;)っ

问题?那可太多了:

  1. 职责边界混乱 - context.ts 简直是代码界的"万金油",什么都往里塞
  2. 重复代码遍地 - React 和 Vue 的解析器像双胞胎,但散落在宇宙的不同角落
  3. 构建工具耦合 - 一堆用不着的入口文件,不知道是给谁准备的遗产
  4. 无法单元测试 - 高度耦合的代码,测试?那等于重新写一遍

作为一个有追求的程序员,我陷入了深深的沉思...

是继续凑合,还是大刀阔斧?


重构,新生

我选择了后者。毕竟,代码写得好不好,直接影响我喝咖啡的心情。

破茧:新的架构

重构后的目录结构,简单到让我自己都感动:

src/
├── core/                 # 核心共享层(只放真正共享的东西)
│   ├── options.ts       # 配置解析(干净利落)
│   ├── path.ts           # 路径处理(只干这一件事)
│   └── types.ts          # 类型定义(一目了然)
├── resolvers/            # 路由生成器层(这里是重点)
│   ├── index.ts          # 统一出口,像个正经的网关
│   ├── react.ts          # React 的归 React
│   ├── vue.ts            # Vue 的归 Vue
│   └── utils.ts          # 公共工具,不多不少
├── index.ts              # 插件工厂(核心中的核心)
├── vite.ts               # Vite 用户要用
└── rspack.ts             # Rspack 用户要用

21 个文件 精简到 9 个文件。这不叫重构,这叫断舍离

立新:三层模型

新的架构遵循一个朴素的哲学:术业有专攻

层级 职责 比喻
插件工厂层 统一入口,拦截虚拟模块 前台接待,根据访客类型分流
核心共享层 配置解析、路径处理、类型定义 技术部门,只负责底层能力
路由生成层 根据框架生成路由代码 生产车间,流水线作业

每一层都各司其职,互不干涉,却又紧密协作。

就像一个完美的餐厅:前台接单,后厨做菜,服务员上菜。没有人会跑到后厨去炒菜,对吧?


核心代码解析

说了这么多,还是要看代码说话。

插件工厂:大道至简

// src/index.ts
export const unpluginFactory: UnpluginFactory<UserOptions> = (userOptions, { framework }) => {
  const options = resolveOptions(userOptions)
  const buildTool: BuildTool = framework === "vite" ? "vite" : "rspack"
  const virtualIds = VIRTUAL_MODULE_IDS[options.resolver]

  return {
    name: "unplugin-convention-routes",

    // 拦截虚拟模块请求
    resolveId(id) {
      if (virtualIds.includes(id)) {
        return virtualIds[0]
      }
      return null
    },

    // 生成路由代码
    load(id) {
      if (id === virtualIds[0]) {
        return generateRoutes(options, buildTool)
      }
      return null
    },
  }
}

整个插件的核心就这么区区 40 行

  • resolveId 像一个门卫,看见虚拟模块就放行
  • load 像一个工厂,根据你要的框架,吐出对应的路由代码

没有奇技淫巧,只有返璞归真。

配置解析:五湖四海皆兄弟

最让我头疼的 Windows 路径问题:

// src/core/options.ts
export function resolveOptions(userOptions: UserOptions): ResolvedOptions {
  // ...

  // Windows 用户注意了:所有路径统一使用正斜杠
  const root = slash(process.cwd())

  const resolvedDirs: PageDir[] = toArray(dirs).map((dir) => {
    if (typeof dir === "string") {
      return { dir: slash(dir), baseRoute: "" }
    }
    return {
      ...dir,
      dir: slash(dir.dir),
    }
  })

  return { root, resolver, dirs: resolvedDirs, ... }
}

slash() 函数是我对抗 Windows 反斜杠的武器。C:\Users\xxx\srcC:/Users/xxx/src。世界从此和平。

路径处理:火眼金睛

路由段识别是整个插件最有趣的环节:

// src/core/path.ts

// 动态路由正则 - Next.js 风格 [id]
const DYNAMIC_ROUTE_RE = /^\[(.+)\]$/
// 动态路由正则 - Remix 风格 $id
const REMIX_DYNAMIC_ROUTE_RE = /^\$(.+)$/
// 捕获所有路由正则 - Next.js 风格 [...all]
const CATCH_ALL_ROUTE_RE = /^\[\.{3}(.*)\]$/
// 捕获所有路由正则 - Remix 风格 $
const REMIX_CATCH_ALL_ROUTE_RE = /^\$$/

export function isDynamicRoute(segment: string): boolean {
  return DYNAMIC_ROUTE_RE.test(segment) || REMIX_DYNAMIC_ROUTE_RE.test(segment)
}

export function isCatchAllRoute(segment: string): boolean {
  return CATCH_ALL_ROUTE_RE.test(segment) || REMIX_CATCH_ALL_ROUTE_RE.test(segment)
}

一个字符串进来,正则一匹配,立马知道它是:

  • 普通路由 → 直接使用
  • 动态路由 [id]$id → 转换成 :id
  • 捕获所有 [...all]$ → 转换成 :all/*

就像孙悟空的七十二变,一个咒语就现原形。

路由生成器:一厂两制

// src/resolvers/index.ts
const generators: Record<Resolver, (options: ResolvedOptions, buildTool: BuildTool) => string> = {
  vue: generateVueRoutes,
  react: generateReactRoutes,
}

export function generateRoutes(options: ResolvedOptions, buildTool: BuildTool): string {
  return generators[options.resolver](options, buildTool)
}

策略模式的应用。你要 Vue?我给你 Vue 的流水线。你要 React?我给你 React 的流水线。

简单、清晰、可扩展。如果哪天要加 Svelte,加就是了。


技术亮点

亮点一:零运行时依赖

插件利用构建工具内置的 glob 扫描能力,不需要任何运行时依赖:

// Vite 使用 import.meta.glob(相对路径)
const __pages__ = import.meta.glob("/src/pages/**/*.vue")
Object.entries(__pages__).forEach(([path, module]) => {
  routes.push({ path, name, component: module }) // 直接用 ES Module
})
// Rspack 使用 import.meta.webpackContext(绝对路径)
const __pages__ = import.meta.webpackContext("/path/to/project/src/pages", {
  recursive: true,
  regExp: /\.(vue|ts|js)$/
})
__pages__.keys().forEach((key) => {
  routes.push({ path, name, component: () => __pages__(key) })
})

纯天然,无添加。 你不需要为这个插件多装任何一个 npm 包。

亮点二:双框架通吃

同一套架构,Vue 和 React 都能用:

// Vue 的 routes(Vite 版本)
routes.push({
  path: routePath,
  name,
  component: module // 直接用,躺平
})

// Vue 的 routes(Rspack 版本)
routes.push({
  path: routePath,
  name,
  component: () => context(key) // 需要返回一个 Promise 函数
})

// React 的 routes
routes.push({
  path: routePath,
  element: React.createElement(React.lazy(loadModule))
  // React.lazy 接收一个返回 Promise 的函数
})

Vue 和 React 的路由格式略有不同,但插件帮你屏蔽了这些细节,一行配置搞定

亮点三:排除机制

有时候我们不希望某些文件被扫到路由里:

exclude = [
  "node_modules", // 肯定不要
  ".git", // 绝对不要
  "**/__*__/**", // __xxx__ 目录不要(Remix 风格)
  "**/components/**", // 组件目录不要
]

底层用正则表达式优化,glob 模式会自动转换成正则:

**/components/**  →  .*/components/.*

性能优化:抠门大赛

作为一个有追求的程序员,我对性能有着近乎偏执的追求。

1. 正则预编译

// 模块顶层预编译,只创建一次
const ESCAPE_REGEXP = /[.*+?^${}()|[\]\\/:]/g
const GLOB_DOUBLE_STAR_RE = /\*\*/g
const GLOB_SINGLE_STAR_RE = /\*/g

以前在循环里 new RegExp(),每次迭代都创建新实例。现在在模块顶层创建一次,整个生命周期复用

2. 排除检查外提

// 循环外创建正则数组
const excludePatternsCode = createExcludePatterns(options.exclude)

// 循环内直接用,不重复创建
if (excludePatterns.some(pattern => pattern.test(path)))
  return

想象一下,如果有 1000 个文件,以前每遍历一个文件就创建 5 个正则。现在只创建 5 个正则,用 1000 次。

3. 链式调用

// 链式调用,减少中间变量创建
let routePath = path
  .replace(/${escapedDir}/, '')
  .replace(/^\\//, '')
  .replace(/\\.(vue|ts|js)$/, '')
  .replace(/\\/index$/, '')
// ...

每一步都在原字符串上操作,不产生临时变量。省内存,省心情。


使用方式

安装

pnpm i unplugin-convention-routes

就是这么快。

Vite + Vue

// vite.config.ts
import Pages from "unplugin-convention-routes/vite"

export default defineConfig({
  plugins: [
    Pages({ resolver: "vue" }),
  ],
})

Vite + React

// vite.config.ts
import Pages from "unplugin-convention-routes/vite"

export default defineConfig({
  plugins: [
    Pages({ resolver: "react" }),
  ],
})

Rspack + Vue

// rsbuild.config.ts
import Pages from "unplugin-convention-routes/rspack"

export default defineConfig({
  tools: {
    rspack: {
      plugins: [
        Pages({ resolver: "vue" }),
      ],
    },
  },
})

Rspack + React

// rsbuild.config.ts
import Pages from "unplugin-convention-routes/rspack"

export default defineConfig({
  tools: {
    rspack: {
      plugins: [
        Pages({ resolver: "react" }),
      ],
    },
  },
})

四行代码,开启约定式路由之旅。


文件约定

这是插件的灵魂规则,也是 Remix 精神的体现:

文件名 路由路径 说明
index.vue / 首页
about.vue /about 关于页
about/index.vue /about 和上面一样
blog/[id].vue /blog/:id Next.js 风格动态路由
blog/[...all].vue /blog/:all(.*)* Next.js 风格捕获所有
$id.vue /:id Remix 风格动态路由 ✨
$.vue /* Remix 风格捕获所有 ✨
__xxx.vue 忽略 Remix 风格忽略文件/目录

带 ✨ 的是 Remix 独有的浪漫,用 $ 代替 [...,更简洁,更优雅。


写在最后

这次重构教会了我一件事:代码的复杂度不是能力的体现,简洁才是。

8000+ 行代码可以在 Git 提交记录里沉睡,因为新的架构只需要 500 行 就能完成同样的事情。

没有 context.ts 的臃肿,没有散落各处的工具函数,没有理不清的职责边界。

只有:

  • 清晰的三层架构
  • 对 Remix 约定式路由的致敬
  • 对代码简洁之美的追求

如果你厌倦了手动配置路由,如果你想体验"文件即路由"的快感,不妨试试 unplugin-convention-routes

也许,它就是你一直在寻找的那个人。


相关链接:


"代码是最好的产品文档。当你的代码会说话,注释就成了配角。" —— 鲁迅(不是我说的)

(好吧,是我说的,但确实是这样喵~)

Vue3 插件开发实战 | 从 0 开发一个全局通知组件(Toast/Message)并发布到 npm

作者 代码煮茶
2026年4月8日 10:19

一、为什么要自己写插件?

在日常 Vue3 开发中,我们经常使用 Element Plus 或 Ant Design Vue 的 Message/Toast 组件。但你有没有想过:

  • 这些组件是怎么实现 this.$message.success('操作成功') 这种调用的?
  • 为什么它们不需要在模板里写 <message /> 就能显示?
  • 如何把自己写的组件发布到 npm 供别人使用?

今天,我们就从 0 到 1,手写一个全局通知插件,并发布到 npm,成为真正的“开源贡献者”!

二、插件基础结构

Vue3 插件本质上是一个对象或函数,它暴露一个 install 方法。当使用 app.use(plugin) 时,install 方法会被调用,并接收 app 实例和可选的 options

// 插件基础结构
const MyPlugin = {
  install(app: App, options?: any) {
    // 在这里添加全局功能
    // 1. 注册全局组件
    // 2. 添加全局属性/方法
    // 3. 提供全局指令
    // 4. 注入依赖
  }
}

三、项目初始化

我们使用 Vite 创建一个专门用于插件开发的项目:

npm create vite@latest vue3-toast-plugin -- --template vue-ts
cd vue3-toast-plugin
npm install

为了打包到 npm,我们需要的目录结构如下:

vue3-toast-plugin/
├── src/
│   ├── components/
│   │   └── Toast.vue          # 通知组件本体
│   ├── types/
│   │   └── index.ts           # 类型定义
│   ├── index.ts               # 插件入口
│   └── style.css              # 样式(可选)
├── dist/                      # 打包输出
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md

四、开发 Toast 组件

4.1 组件功能设计

一个成熟的 Toast/Message 组件需要支持:

  • 四种类型:successerrorwarninginfo
  • 可配置:显示时长、是否可关闭、位置、自定义内容
  • 支持链式调用:Toast.success('成功').then(...)
  • 支持手动关闭
  • 多个 Toast 自动堆叠

4.2 组件实现

<!-- src/components/Toast.vue -->
<template>
  <Transition name="toast-fade" @after-leave="handleAfterLeave">
    <div
      v-if="visible"
      class="toast"
      :class="[`toast--${type}`, positionClass]"
      :style="customStyle"
      @mouseenter="pauseTimer"
      @mouseleave="resumeTimer"
    >
      <div class="toast__icon">
        <span v-html="iconMap[type]"></span>
      </div>
      <div class="toast__content">
        <slot>{{ message }}</slot>
      </div>
      <button v-if="closable" class="toast__close" @click="close">×</button>
    </div>
  </Transition>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

const props = withDefaults(defineProps<{
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}>(), {
  type: 'info',
  duration: 3000,
  closable: false,
  position: 'top'
})

const visible = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null

const iconMap = {
  success: '✓',
  error: '✕',
  warning: '⚠',
  info: 'ℹ'
}

const positionClass = computed(() => `toast--${props.position}`)
const customStyle = computed(() => ({})) // 可扩展自定义样式

const startTimer = () => {
  if (props.duration > 0) {
    timer = setTimeout(() => {
      close()
    }, props.duration)
  }
}

const clearTimer = () => {
  if (timer) {
    clearTimeout(timer)
    timer = null
  }
}

const pauseTimer = () => clearTimer()
const resumeTimer = () => startTimer()

const close = () => {
  visible.value = false
}

const handleAfterLeave = () => {
  props.onClose?.()
}

onMounted(() => {
  startTimer()
})
</script>

<style scoped>
/* 样式在下一节给出 */
</style>

4.3 样式设计

为了让通知美观且不影响页面布局,我们使用固定定位(fixed)。

/* src/style.css */
.toast {
  position: fixed;
  z-index: 9999;
  min-width: 200px;
  max-width: 300px;
  padding: 12px 16px;
  border-radius: 8px;
  background: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 14px;
  transition: all 0.3s ease;
}

/* 位置 */
.toast--top {
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--top-right {
  top: 20px;
  right: 20px;
}
.toast--top-left {
  top: 20px;
  left: 20px;
}
.toast--bottom {
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--bottom-right {
  bottom: 20px;
  right: 20px;
}
.toast--bottom-left {
  bottom: 20px;
  left: 20px;
}

/* 类型颜色 */
.toast--success {
  border-left: 4px solid #67c23a;
}
.toast--success .toast__icon {
  color: #67c23a;
}
.toast--error {
  border-left: 4px solid #f56c6c;
}
.toast--error .toast__icon {
  color: #f56c6c;
}
.toast--warning {
  border-left: 4px solid #e6a23c;
}
.toast--warning .toast__icon {
  color: #e6a23c;
}
.toast--info {
  border-left: 4px solid #409eff;
}
.toast--info .toast__icon {
  color: #409eff;
}

.toast__icon {
  font-size: 18px;
  font-weight: bold;
}
.toast__content {
  flex: 1;
  word-break: break-word;
}
.toast__close {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
  padding: 0 4px;
}
.toast__close:hover {
  color: #333;
}

/* 过渡动画 */
.toast-fade-enter-active,
.toast-fade-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
  opacity: 0;
  transform: translateY(-20px) scale(0.9);
}
.toast-fade-leave-to {
  transform: translateY(-20px) scale(0.9);
}

五、插件核心逻辑:管理多个 Toast 实例

为了实现链式调用和多个 Toast 同时存在,我们需要一个管理器(Manager),负责创建、销毁 Toast 实例。

5.1 创建 Toast 管理器

// src/index.ts
import type { App, ComponentPublicInstance } from 'vue'
import { createVNode, render } from 'vue'
import ToastComponent from './components/Toast.vue'
import './style.css'

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

export interface ToastOptions {
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}

// 存储所有活跃的 Toast 实例
let toastInstances: ComponentPublicInstance[] = []

// 生成唯一 ID(用于区分实例)
let seed = 0

function createToast(options: ToastOptions) {
  const container = document.createElement('div')
  document.body.appendChild(container)
  
  // 创建虚拟节点
  const vnode = createVNode(ToastComponent, {
    ...options,
    onClose: () => {
      // 卸载组件并移除容器
      render(null, container)
      container.remove()
      toastInstances = toastInstances.filter(ins => ins !== vnode.component?.proxy)
      options.onClose?.()
    }
  })
  
  // 渲染组件
  render(vnode, container)
  
  const instance = vnode.component?.proxy
  if (instance) {
    toastInstances.push(instance)
  }
  
  return instance
}

// 核心 API
function show(message: string, options?: Partial<ToastOptions>): Promise<void> {
  return new Promise((resolve) => {
    createToast({
      message,
      type: 'info',
      duration: 3000,
      ...options,
      onClose: () => {
        options?.onClose?.()
        resolve()
      }
    })
  })
}

// 快捷方法
function success(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'success' })
}

function error(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'error' })
}

function warning(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'warning' })
}

function info(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'info' })
}

// 关闭所有 Toast
function closeAll() {
  toastInstances.forEach(instance => {
    if (instance && instance.close) {
      (instance as any).close()
    }
  })
  toastInstances = []
}

// 导出插件对象
export default {
  install(app: App) {
    // 添加全局属性 $toast
    app.config.globalProperties.$toast = {
      show,
      success,
      error,
      warning,
      info,
      closeAll
    }
  }
}

// 单独导出 API(用于按需引入)
export { show, success, error, warning, info, closeAll }

六、Vite 打包配置

为了发布到 npm,我们需要将组件打包成 UMD、ES 模块等多种格式。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Vue3ToastPlugin',
      fileName: (format) => `vue3-toast-plugin.${format}.js`,
      formats: ['es', 'umd']
    },
    rollupOptions: {
      // 确保外部化处理那些你不希望打包进库的依赖
      external: ['vue'],
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue'
        },
        assetFileNames: (assetInfo) => {
          if (assetInfo.name === 'style.css') return 'style.css'
          return assetInfo.name || 'assets/[name]-[hash][extname]'
        }
      }
    },
    cssCodeSplit: false, // 将所有 CSS 打包成一个文件
    sourcemap: true,
    emptyOutDir: true
  }
})
// package.json 关键字段配置
{
  "name": "vue3-toast-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/vue3-toast-plugin.umd.js",
  "module": "./dist/vue3-toast-plugin.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/vue3-toast-plugin.es.js",
      "require": "./dist/vue3-toast-plugin.umd.js",
      "types": "./dist/index.d.ts"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build && npm run build:types",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist"
  },
  "peerDependencies": {
    "vue": "^3.2.0"
  }
}

七、生成类型声明文件

为了让 TypeScript 用户有良好的体验,我们需要生成 .d.ts 文件。

// tsconfig.json 中开启声明
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist",
    "emitDeclarationOnly": true
  }
}

也可以在 src/index.ts 中导出类型:

// src/index.ts
export type { ToastOptions, ToastType, ToastPosition } from './components/Toast.vue'

八、本地测试

在发布之前,本地测试非常重要。我们可以使用 npm link 或者在项目的 example 目录下测试。

8.1 创建测试项目

# 在插件项目根目录执行
npm link

# 进入测试项目(比如一个新建的 Vue3 项目)
cd ../vue3-test-project
npm link vue3-toast-plugin

8.2 在测试项目中使用

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <button @click="$toast.success('操作成功!')">成功提示</button>
    <button @click="$toast.error('出错了!')">错误提示</button>
    <button @click="$toast.warning('警告信息')">警告提示</button>
    <button @click="$toast.info('普通消息')">普通提示</button>
  </div>
</template>

九、发布到 npm

9.1 准备工作

  • 注册 npm 账号:www.npmjs.com/
  • 在终端登录:npm login
  • 确保 package.json 中的 name 未被占用

9.2 打包

npm run build

9.3 发布

npm publish --access public

如果版本更新,需要修改 version 后再次发布:

npm version patch  # 1.0.0 -> 1.0.1
npm publish

十、编写 README 文档

一个好的开源项目必须有清晰的文档。README.md 应该包含:

  • 安装方法
  • 基本使用
  • API 文档
  • 示例代码
  • 贡献指南
# vue3-toast-plugin

一个轻量级、高度可定制的 Vue3 全局通知插件。

安装

npm install vue3-toast-plugin

使用

import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<template>
  <button @click="$toast.success('Hello World!')">Show Toast</button>
</template>

API

$toast.success(message, options)

显示成功提示。

参数 类型 默认值 描述
message string - 提示内容
options object {} 可选配置

Options

属性 类型 默认值 描述
duration number 3000 显示时长(ms),设为0则不自动关闭
closable boolean false 是否显示关闭按钮
position string 'top' 位置,可选值见下方

位置选项toptop-righttop-leftbottombottom-rightbottom-left

License

MIT


## 十一、进阶:支持 Vue3 和 Nuxt3

如果你想让插件同时支持 Vue3 和 Nuxt3,可以增加判断环境自动适配的逻辑。Nuxt3 中插件需要写在 `plugins` 目录下,并提供 `ssr: false` 选项。

```typescript
// nuxt 插件适配示例
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(ToastPlugin)
})

十二、总结

通过本篇文章,我们完成了一个完整的 Vue3 插件从开发、打包、测试到发布的全流程。你不仅掌握了插件的核心机制(installcreateVNoderender),还学会了如何管理多个动态组件实例,以及如何让插件具有良好的 TypeScript 支持。

核心收获

  • Vue3 插件本质:{ install(app) {} }
  • 动态渲染组件:createVNode + render
  • 多个实例管理:维护实例数组,提供关闭/销毁逻辑
  • 打包配置:vite.config.ts 的 build.lib 配置
  • 发布流程:npm login → npm run build → npm publish

现在,你可以骄傲地告诉别人:“我发布过一个 npm 包!” 下次遇到重复的组件需求,不妨考虑封装成插件,提升团队复用效率。🚀


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,让更多人学会 Vue3 插件开发!

停更5年后,我为什么重新开始写技术内容了

2026年4月8日 10:18

5年前,我还在持续更新技术、更新公众号。
那时候写的基本都是 Android 技术、踩坑总结,还有一些零散的学习笔记。

后来,我停更了。

不是因为忙,而是因为——
我开始不知道写什么了。


这5年,一个很明显的变化

如果你这几年还在一线开发,应该会有类似的感受:

技术越来越多,但“确定性”越来越少。

我这5年,大致经历了几个阶段:

  • 从 Android 转向大前端

  • 开始接触 Web、跨端、工程化

  • 再到现在,开始用 AI 写代码

表面上看是“技术栈变多了”,
但本质上,其实是:

端的边界在消失,技术开始融合。


一个让我改变认知的点

以前我一直觉得:

技术越深,越有价值。

但这几年慢慢发现一个问题:

  • 很多工作,本质是在“重复实现”
  • 多端开发,很多逻辑是类似的
  • 团队里其实存在大量“重复人效”

也就是说:

很多时候不是技术难,而是组织方式低效。


为什么我开始接触“大前端”

我这里说的大前端,不是“只写前端”,而是:

  • Android / iOS
  • H5 / Web
  • 小程序
  • Flutter / React Native

本质是:
👉 一套能力,覆盖多端


一个更现实的原因:团队人效

我所在的团队,之前是这样配置的:

  • Android:4人
  • iOS:4人
  • 前端:2人

典型的问题是:

  • 同一个业务,要做3套实现
  • 迭代周期长
  • 维护成本高

后来开始往跨端和大前端能力调整,逐步变成:

  • Android:1人
  • iOS:1人
  • 前端:5~6人(具备跨端能力)

带来的变化很直接:

  • 多端开发统一,重复工作减少
  • 业务迭代速度明显提升
  • 团队整体人效提高

这件事对我冲击挺大的:

技术本身没变,但“使用技术的方式”变了。


另一个更现实的问题:AI

这两年如果你用过 AI 写代码,大概率会有这种感受:

  • 一些重复代码,基本不用自己写了
  • 一些基础逻辑,AI能快速补全
  • 一些简单页面,生成效率很高

我不觉得程序员会被替代,但我越来越确定一件事:

“纯写代码”的价值,在下降。

那问题就变成了:

👉 如果代码越来越不值钱,我们的价值在哪?


为什么我又开始写了

停更这几年,其实我不是没写,而是没发。

原因很简单:

  • 写得不够系统
  • 没有输出的动力
  • 也觉得“没人看”

但这两年一个变化让我重新思考:

会写代码的人很多,但能总结和表达的人很少。

而表达,本身就是能力的一部分。

所以我决定重新开始写。

不是为了做内容,而是:

👉 把这些变化、选择和踩坑,整理出来。


后面会写什么?

主要会集中在几块:

  • Android → 大前端的转型过程(包括踩坑和决策)
  • 实际用 AI 写代码的一些经验(不是概念,是具体怎么用)
  • 一些团队人效、技术选型的真实思考
  • 10年开发的一些职场经验

这些内容会尽量写得更具体一点,而不是泛泛而谈


最后

这篇其实只是一个开始。

后面我会把几个比较完整的主题慢慢写出来,比如:

  • 一个人如何逐步具备跨端能力
  • AI在实际项目里的边界在哪里
  • 技术人如何避免“只会写代码”

这些内容我会优先整理在公众号里(会写得更系统一点)。

如果你对这些话题有兴趣,可以关注一下我的公众号:码农职场

实现记忆开关

2026年4月8日 10:17

本次功能

记忆开关

支持:

  • 每个会话单独开启 / 关闭记忆注入
  • 关闭后仍保留记忆数据,但不会参与回答
  • 方便对比“有记忆”和“无记忆”的回复差异

1)改 web/src/utils/session.js

createSession 里新增字段

  return {
    id: crypto.randomUUID(),
    title,
    mode,
    customPrompt: persona.systemPrompt,
    temperature: 0.7,
    topP: 1,
    maxTokens: 1200,
    memoryEnabled: true,
    pinned: false,

loadSessions 里的 normalize 增加默认值

      return {
        mode,
        customPrompt:
          item.customPrompt ||
          item.messages?.find(m => m.role === 'system')?.content ||
          persona.systemPrompt,
        temperature: 0.7,
        topP: 1,
        maxTokens: 1200,
        memoryEnabled: true,
        pinned: false,
        ...item,
      }

2)改 server/app.py

ChatRequest 新增字段

class ChatRequest(BaseModel):
    messages: List[Message]
    session_id: Optional[str] = None
    temperature: Optional[float] = 0.7
    top_p: Optional[float] = 1
    max_tokens: Optional[int] = 1200
    memory_enabled: Optional[bool] = True

build_final_messages 改成支持记忆开关

def build_final_messages(messages: List[dict], session_id: str = "", memory_enabled: bool = True):
    session_memories = get_session_memories(session_id or "") if memory_enabled else []
    memory_prompt = build_memory_prompt(session_memories) if memory_enabled else ""

    final_messages = []
    for item in messages:
        if item["role"] == "system":
            final_messages.append({
                "role": "system",
                "content": f'{item["content"]}\n\n{memory_prompt}'.strip()
            })
        else:
            final_messages.append(item)

    return final_messages, session_memories

/api/chat 里调用改一下

    final_messages, session_memories = build_final_messages(
        messages,
        req.session_id or "",
        req.memory_enabled if req.memory_enabled is not None else True,
    )

/api/chat/stream 里调用也改一下

    final_messages, session_memories = build_final_messages(
        messages,
        req.session_id or "",
        req.memory_enabled if req.memory_enabled is not None else True,
    )

3)改 web/src/App.vue

新增计算属性

const currentMemoryEnabled = computed(() => {
  return currentSession.value?.memoryEnabled ?? true
})

新增状态

const memoryEnabledDraft = ref(true)

增加监听

watch(
  currentMemoryEnabled,
  newVal => {
    memoryEnabledDraft.value = !!newVal
  },
  { immediate: true }
)

新增保存方法

const handleSaveMemorySetting = () => {
  if (!currentSession.value) return

  sessions.value = sortSessions(
    sessions.value.map(item =>
      item.id === currentSessionId.value
        ? {
            ...item,
            memoryEnabled: !!memoryEnabledDraft.value,
            updatedAt: Date.now(),
          }
        : item
    )
  )
}

流式请求 body 增加字段

    body: JSON.stringify({
      messages,
      session_id: currentSession.value.id,
      temperature: currentSession.value.temperature,
      top_p: currentSession.value.topP,
      max_tokens: currentSession.value.maxTokens,
      memory_enabled: currentSession.value.memoryEnabled,
    }),

4)改模板

在参数面板里追加记忆开关块

<div class="param-item">
  <label class="param-label">memory</label>

  <div class="memory-switch-row">
    <label class="memory-switch-label">
      <input v-model="memoryEnabledDraft" type="checkbox" />
      <span>{{ memoryEnabledDraft ? '开启记忆注入' : '关闭记忆注入' }}</span>
    </label>

    <button class="prompt-btn small" @click="handleSaveMemorySetting">保存记忆设置</button>
  </div>

  <div class="param-tip">关闭后会保留记忆数据,但不会注入到对话上下文</div>
</div>

5)补充样式

.memory-switch-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.memory-switch-label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: #111827;
}

.prompt-btn.small {
  padding: 6px 10px;
  font-size: 12px;
}

6)怎么验证

image.png

image.png

nice !

本次 提交修改 代码

github.com/fhj414/ai-c…

完整代码请看仓库,仓库地址:github.com/huanhunmao/… star 🌟🌟🌟 谢谢~

Linux-从0开始-20260408

作者 walking957
2026年4月8日 10:05

Linux

一、基础命令

1. Linux 基础命令

问题:Linux 基础命令

答案核心回答:ls、cd、pwd、mkdir、rm、cp、mv 是基础命令。

代码示例

# 目录操作
ls -la                       # 详细列表
ls -lh                       # 人类可读大小
cd /path/to/dir              # 切换目录
pwd                          # 显示当前目录
mkdir -p /path/to/dir        # 创建目录
mkdir -m 755 dir             # 指定权限

# 文件操作
rm -rf dir                   # 删除目录
rm file.txt                   # 删除文件
cp -r source dest            # 复制目录
cp file.txt dest/             # 复制文件
mv oldname newname           # 重命名/移动

# 查看文件
cat file.txt                 # 全文
head -n 20 file.txt          # 前20行
tail -n 20 file.txt          # 后20行
tail -f /var/log/syslog      # 实时查看日志

# 搜索
grep "pattern" file.txt      # 搜索文本
find / -name "filename"     # 查找文件

2. 文件权限

问题:文件权限

答案核心回答:Linux 文件权限分为 owner、group、others 三类,各有 rwx 权限。

代码示例

# 查看权限
ls -l file.txt
# -rw-r--r-- 1 user group 4096 Jan 15 10:00 file.txt
# 权限位: -rw-r--r--
# 类型  owner  group  others
# r=4 w=2 x=1

# 修改权限
chmod 755 file               # 数字形式
chmod u+x file               # 所有者添加执行
chmod g-w file               # 组移除写
chmod o+r file               # 其他添加读
chmod a+x file               # 所有添加执行

# 修改所有者
chown user:group file        # 修改所有者和组
chown user file              # 只修改所有者
chgrp group file             # 只修改组

# 特殊权限
chmod +s file               # SUID/SGID
chmod +t file               # Sticky Bit

二、文本处理

3. 文本处理命令

问题:文本处理命令

答案核心回答:awk、sed、grep 是强大的文本处理工具。

代码示例

# awk - 文本分析
awk '{print $1, $3}' file.txt          # 打印第1、3列
awk -F',' '{print $2}' file.csv       # 指定分隔符
awk '/pattern/ {print $0}' file.txt   # 模式匹配
awk 'NR==5' file.txt                  # 第5行

# sed - 文本替换
sed 's/old/new/g' file.txt           # 全局替换
sed -i 's/old/new/g' file.txt        # 直接修改
sed '1,5d' file.txt                  # 删除1-5行
sed -n '2p' file.txt                 # 打印第2行

# grep - 搜索
grep "pattern" file.txt
grep -r "pattern" dir/               # 递归搜索
grep -i "pattern" file.txt           # 忽略大小写
grep -v "pattern" file.txt           # 反向匹配
grep -E "regex" file.txt             # 扩展正则

# 管道组合
cat file.txt | grep "pattern" | awk '{print $2}' | sort

三、进程管理

4. 进程管理

问题:进程管理命令

答案核心回答:ps、top、kill 用于进程管理。

代码示例

# 查看进程
ps aux                          # 所有进程
ps -ef                          # 详细格式
top                              # 实时监控
htop                             # 增强版 top

# 查找进程
ps aux | grep nginx
pgrep -f "nginx"

# 信号与 kill
kill -l                          # 列出信号
kill -9 pid                      # 强制终止
kill -15 pid                     # 优雅终止(SIGTERM)
killall nginx                     # 按名称终止
pkill -f "nginx"                 # 按模式终止

# 后台进程
nohup command &                  # 后台运行
bg                               # 后台任务
fg                               # 前台任务
jobs                             # 任务列表

四、网络管理

5. 网络命令

问题:网络命令

答案核心回答:ping、curl、wget、netstat、ss 是常用网络命令。

代码示例

# 测试连通性
ping -c 4 example.com           # 发4个包
ping -i 0.5 example.com         # 0.5秒间隔

# 下载
curl https://example.com       # 下载页面
curl -O file.txt               # 下载文件
curl -L url                    #跟随重定向
wget url                        # 下载工具
wget -c url                    # 断点续传

# 网络诊断
netstat -tuln                  # 监听端口
netstat -anp | grep 80         # 查找端口占用
ss -tuln                       # 替代 netstat
netstat -i                      # 网卡信息

# curl 高级用法
curl -X POST url -d "data"    # POST 请求
curl -H "Header: value" url   # 自定义头
curl -v url                    # 详细输出

五、磁盘与内存

6. 磁盘与内存

问题:磁盘与内存命令

答案核心回答:df、du、free 是查看磁盘和内存的常用命令。

代码示例

# 磁盘使用
df -h                          # 人类可读格式
df -i                          # 查看 inode
du -sh *                       # 目录大小
du -sh /path/to/dir            # 指定目录

# 内存使用
free -h                        # 人类可读格式
free -m                        # MB 为单位
cat /proc/meminfo              # 详细内存信息

# 磁盘挂载
mount /dev/sdb1 /mnt/usb      # 挂载
umount /mnt/usb                # 卸载
df -T                           # 显示文件系统类型
❌
❌