阅读视图

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

sort Command in Linux: Sort Lines of Text

sort is a command-line utility that sorts lines of text from files or standard input and writes the result to standard output. By default, sort arranges lines alphabetically, but it supports numeric sorting, reverse order, sorting by specific fields, and more.

This guide explains how to use the sort command with practical examples.

sort Command Syntax

The syntax for the sort command is as follows:

txt
sort [OPTIONS] [FILE]...

If no file is specified, sort reads from standard input. When multiple files are given, their contents are merged and sorted together.

Sorting Lines Alphabetically

By default, sort sorts lines in ascending alphabetical order. To sort a file, pass the file name as an argument:

Terminal
sort file.txt

For example, given a file with the following content:

file.txttxt
banana
apple
cherry
date

Running sort produces:

output
apple
banana
cherry
date

The original file is not modified. sort writes the sorted output to standard output. To save the result to a file, use output redirection:

Terminal
sort file.txt > sorted.txt

Sorting in Reverse Order

To sort in descending order, use the -r (--reverse) option:

Terminal
sort -r file.txt
output
date
cherry
banana
apple

Sorting Numerically

Alphabetical sorting does not work correctly for numbers. For example, 10 would sort before 2 because 1 comes before 2 alphabetically. Use the -n (--numeric-sort) option to sort numerically:

Terminal
sort -n numbers.txt

Given the file:

numbers.txttxt
10
2
30
5

The output is:

output
2
5
10
30

To sort numerically in reverse (largest first), combine -n and -r:

Terminal
sort -nr numbers.txt
output
30
10
5
2

Sorting by a Specific Column

By default, sort uses the entire line as the sort key. To sort by a specific field (column), use the -k (--key) option followed by the field number.

Fields are separated by whitespace by default. For example, to sort the following file by the second column (file size):

files.txttxt
report.pdf 2500
notes.txt 80
archive.tar 15000
readme.md 200
Terminal
sort -k2 -n files.txt
output
notes.txt 80
readme.md 200
report.pdf 2500
archive.tar 15000

To use a different field separator, specify it with the -t option. For example, to sort /etc/passwd by the third field (UID), using : as the separator:

Terminal
sort -t: -k3 -n /etc/passwd

Removing Duplicate Lines

To output only unique lines (removing adjacent duplicates after sorting), use the -u (--unique) option:

Terminal
sort -u file.txt

Given a file with repeated entries:

file.txttxt
apple
banana
apple
cherry
banana
output
apple
banana
cherry

This is equivalent to running sort file.txt | uniq.

Ignoring Case

By default, sort is case-sensitive. Uppercase letters sort before lowercase. To sort case-insensitively, use the -f (--ignore-case) option:

Terminal
sort -f file.txt

Checking if a File Is Already Sorted

To verify that a file is already sorted, use the -c (--check) option. It exits silently if the file is sorted, or prints an error and exits with a non-zero status if it is not:

Terminal
sort -c file.txt
output
sort: file.txt:2: disorder: banana

This is useful in scripts to validate input before processing.

Sorting Human-Readable Sizes

When working with output from commands like du, sizes are expressed in human-readable form such as 1K, 2M, or 3G. Standard numeric sort does not handle these correctly. Use the -h (--human-numeric-sort) option:

Terminal
du -sh /var/* | sort -h
output
4.0K /var/backups
16K /var/cache
1.2M /var/log
3.4G /var/lib

Combining sort with Other Commands

sort is commonly used in pipelines with other commands.

To count and rank the most frequent words in a file, combine grep , sort, and uniq:

Terminal
grep -Eo '[[:alnum:]_]+' file.txt | sort | uniq -c | sort -rn

To display the ten largest files in a directory, combine sort with head :

Terminal
du -sh /var/* | sort -rh | head -10

To extract and sort a specific field from a CSV using cut :

Terminal
cut -d',' -f2 data.csv | sort

Quick Reference

Option Description
sort file.txt Sort alphabetically (ascending)
sort -r file.txt Sort in reverse order
sort -n file.txt Sort numerically
sort -nr file.txt Sort numerically, largest first
sort -k2 file.txt Sort by the second field
sort -t: -k3 file.txt Sort by field 3 using : as separator
sort -u file.txt Sort and remove duplicate lines
sort -f file.txt Sort case-insensitively
sort -h file.txt Sort human-readable sizes (1K, 2M, 3G)
sort -c file.txt Check if file is already sorted

Troubleshooting

Numbers sort in wrong order
You are using alphabetical sort on numeric data. Add the -n option to sort numerically: sort -n file.txt.

Human-readable sizes sort incorrectly
Sizes like 1K, 2M, and 3G require -h (--human-numeric-sort). Standard -n only handles plain integers.

Uppercase entries appear before lowercase
sort is case-sensitive by default. Use -f to sort case-insensitively, or use LC_ALL=C sort to enforce byte-order sorting.

Output looks correct on screen but differs from file
Output redirection with > truncates the file before reading. Never redirect to the same file you are sorting: sort file.txt > file.txt will empty the file. Use a temporary file or the sponge utility from moreutils.

FAQ

Does sort modify the original file?
No. sort writes to standard output and never modifies the input file. Use sort file.txt > sorted.txt or sort -o file.txt file.txt to save the output.

How do I sort a file and save it in place?
Use the -o option: sort -o file.txt file.txt. Unlike > file.txt, the -o option is safe to use with the same input and output file.

How do I sort by the last column?
Use a negative field number with the -k option: sort -k-1 sorts by the last field. Alternatively, use awk to reorder columns before sorting.

What is the difference between sort -u and sort | uniq?
sort -u is slightly more efficient as it removes duplicates during sorting. sort | uniq is more flexible because uniq supports options like -c (count occurrences) and -d (show only duplicates).

How do I sort lines randomly?
Use sort -R (random sort) or the shuf command: shuf file.txt. Both produce a random permutation of the input lines.

Conclusion

The sort command is a versatile tool for ordering lines of text in files or command output. It is most powerful when combined with other commands like grep , cut , and head in pipelines.

If you have any questions, feel free to leave a comment below.

面试官:JS数组的常用方法有哪些?这篇总结让你面试稳了!

面试官往往会这么问:“JS 数组的常用方法有哪些?”然后追问:“哪些会改变原数组?哪些不会?”或“能举一个实际使用场景吗?”因此回答不仅要列出方法,还要讲清楚分类、返回值、是否改变原数组、典型用法与坑。

方法分类与速查

操作方法:增、删、改、查

排序方法:reverse、sort

转换方法:join

迭代方法:forEach、map、filter、some、every、find(包含 find、findIndex 等)

一、操作方法(增删改查)

  • push():末尾追加,返回新长度;改原数组
  • unshift():开头插入,返回新长度;改原数组
  • splice(start, 0, ...items):指定位置插入,返回空数组;改原数组
  • concat(...items):合并并返回新数组,不改原数组
let colors = ["red", "green"];
colors.push("blue"); // 3; colors => ["red","green","blue"]
colors.unshift("yellow"); // 4; colors => ["yellow","red","green","blue"]
colors.splice(1, 0, "purple"); // [] => 原数组被修改
let colors2 = colors.concat("black", ["white"]); // 新数组

  • pop():末尾删除,返回被删项;改原数组
  • shift():首项删除,返回被删项;改原数组
  • splice(start, deleteCount):删除指定位置项,返回被删数组;改原数组
  • slice(start, end):拷贝子数组,返回新数组;不改原数组
let colors = ["red", "green", "blue"];
let last = colors.pop(); // "blue"; colors => ["red","green"]
let first = colors.shift(); // "red"; colors => ["green"]
let removed = colors.splice(0, 1); // ["green"]; colors => []
let sub = colors.slice(1, 3); // 新数组,不改原数组

  • splice(start, deleteCount, ...items):删除并插入,返回被删数组;改原数组
let colors = ["red", "green", "blue"];
colors.splice(1, 1, "purple"); // ["green"]; colors => ["red", "purple", "blue"]

  • indexOf(item):返回索引,不存在返回 -1
  • includes(item):返回 boolean
  • find(callback):返回第一个满足条件的元素
  • findIndex(callback):返回第一个满足条件的索引
let arr = [1, 2, 3, 4];
arr.indexOf(3); // 2
arr.includes(5); // false
let found = arr.find(x => x > 2); // 3
let foundIdx = arr.findIndex(x => x > 2); // 2

二、排序方法

reverse():反转数组,改原数组,返回引用 sort(compareFn):排序,改原数组,返回引用

let nums = [3, 1, 4, 1, 5];
nums.reverse(); // [5,1,4,1,3]; 改原数组
nums.sort((a,b)=>a-b); // [1,1,3,4,5]; 改原数组

注意:不传 compareFn 时,按 UTF-16 代码单元排序,对数字排序可能不符合预期,务必传比较函数。

三、转换方法

join(separator):用指定分隔符拼接成字符串,不改原数组

let colors = ["red", "green", "blue"];
colors.join(","); // "red,green,blue"
colors.join("||"); // "red||green||blue"

四、迭代方法(不改原数组)

  • forEach(callback):遍历,无返回值
  • map(callback):映射,返回新数组
  • filter(callback):过滤,返回新数组
  • some(callback):任一满足则 true
  • every(callback):全部满足则 true
  • find(callback):返回第一个满足元素
  • findIndex(callback):返回第一个满足索引
  • reduce/reduceRight:归约,常用于累加、组合
let nums = [1, 2, 3, 4];
let doubled = nums.map(x => x * 2); // [2,4,6,8]
let evens = nums.filter(x => x % 2 === 0); // [2,4]
let has = nums.some(x => x > 3); // true
let all = nums.every(x => x > 0); // true
let first = nums.find(x => x > 2); // 3
let sum = nums.reduce((a,b)=>a+b,0); // 10

五、是否改变原数组一览

改变原数组:push、pop、shift、unshift、splice、sort、reverse

不改变原数组:concat、slice、join、forEach、map、filter、some、every、find、findIndex、reduce、reduceRight、flatMap、flat、indexOf、includes

六、典型面试追问与场景举例

问:如何在不改变原数组的前提下在末尾追加一项?

答:使用 concat 或展开运算符 [...arr, item]。 问:如何移除数组中所有 falsy 值?

答:arr.filter(Boolean)。 问:如何按某属性排序对象数组?

答:arr.sort((a,b)=>a.key.localeCompare(b.key))。 问:forEach 与 map 的区别?

答:forEach 无返回值,仅遍历;map 返回新数组,常用于转换。 问:splice 与 slice 的区别?

答:splice 会改变原数组并支持插入/删除;slice 不会改变原数组,仅拷贝子集。

七、常见坑与避坑建议

  • 直接用 sort() 对数字排序可能出错,务必传比较函数。
  • splice 的参数易混淆,牢记参数顺序与返回值。
  • 在需要保留原数组的场景,避免误用会改变原数组的方法。
  • 注意 map 等迭代方法不会提前终止,如需提前中断请用 some/every 或传统 for。

八、总结

JS 数组方法多且常用,记住“是否改变原数组”是高频考点。建议按“操作、排序、转换、迭代”四个维度掌握,并多在实际项目中用这些方法替代手动循环,代码会更简洁、易读。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Props、Context、EventBus、状态管理:组件通信方案选择指南

写 React 的时间越长,越会遇到一个让人头疼的问题:明明只是想把数据传给某个深层组件,却要穿越好几层中间组件,每一层都得接收并转发这份数据。那些中间组件其实根本用不到这些 props,却因为「路过」不得不背负着它们。

这篇文章是我整理的关于组件通信的一些思考,聊聊各种方案的选择逻辑,以及背后的架构含义。


问题的起源

先描述一个典型场景:做一个电商页面,顶部导航需要显示购物车数量,商品详情页有「加入购物车」按钮。这两个组件相距 5 层嵌套,中间的 LayoutContainerContent 等组件对购物车一无所知,但你不得不让它们每层都接收并向下传递 cartCountaddToCart

// 环境:React
// 场景:典型的 Props Drilling 噩梦

function App() {
  const [cartItems, setCartItems] = useState([]);

  return (
    // 每一层都要传,即使它们完全不关心购物车
    <Layout cartItems={cartItems} setCartItems={setCartItems}>
      <Container cartItems={cartItems} setCartItems={setCartItems}>
        <Content cartItems={cartItems} setCartItems={setCartItems}>
          <ProductDetail cartItems={cartItems} setCartItems={setCartItems} />
        </Content>
      </Container>
    </Layout>
  );
}

这段代码本身不是错误,但它有一种难以言说的「不对劲」。每次修改数据结构,都要改好几层;每次移动组件位置,都要重新梳理 props 链条。

这让我开始思考:组件通信到底是技术问题,还是架构问题?

我的理解是,选择通信方案,本质上是在选择耦合程度——你愿意让哪些组件知道哪些数据?它们之间的关系应该有多紧密?


方案一:Props 传递——最基础,也最被滥用

父子通信用 Props,这没什么好说的。数据向下流,事件向上传,清晰直观:

// 环境:React
// 场景:标准的父子组件通信

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

  return <Child count={count} onIncrement={() => setCount(c => c + 1)} />;
}

function Child({ count, onIncrement }) {
  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={onIncrement}>加一</button>
    </div>
  );
}

Props 的优点是数据流极其清晰,TypeScript 类型安全,也很容易单独测试子组件。但一旦层级变深,就会出现开头说的 Props Drilling 问题。

有一个常被忽视的技巧是组件组合(Component Composition) ,它能在不引入新方案的前提下,缓解这个问题:

// 环境:React
// 场景:用 children 避免中间层传递不必要的 props

// ❌ 传统方式:Layout 被迫接收 user
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout user={user}>
      <UserProfile user={user} />
    </Layout>
  );
}

// ✅ 组件组合:Layout 只负责布局结构
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout>
      <UserProfile user={user} />
    </Layout>
  );
}

// Layout 组件只接收 children,不关心内容
function Layout({ children }) {
  return <div className="layout">{children}</div>;
}

这个思路很简单:让容器组件只负责「结构」,不承担「内容」。它不需要知道 children 里有什么,自然也不需要传递那些数据。

可以接受 Props 传递的场景:层级不超过 2-3 层,数据关系稳定,不会频繁变动。超过这个范围,就该考虑其他方案了。


方案二:状态提升——兄弟组件的解法

兄弟组件之间无法直接通信,标准做法是把共享状态提升到最近的公共父组件:

// 环境:React
// 场景:两个兄弟组件需要共享计数状态

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

  return (
    <>
      <Counter count={count} onIncrement={() => setCount(c => c + 1)} />
      <Display count={count} />
    </>
  );
}

function Counter({ count, onIncrement }) {
  return <button onClick={onIncrement}>点击:{count}</button>;
}

function Display({ count }) {
  return <p>当前计数:{count}</p>;
}

状态提升有一个决策原则:把状态放在最近的需要它的公共祖先上。不要提升过高,否则顶层组件会变得臃肿,而且状态变化时会触发整棵子树的重渲染。

这个方案的局限很明显——当共同父组件距离很远,或者需要数据的组件分散在不同分支时,状态提升就会重新引入 Props Drilling 的问题。


方案三:Context API——跨层级的官方解

Context 的设计目的,就是解决跨层级数据共享的问题。它让深层组件可以直接「订阅」某个数据源,不需要中间层逐层传递:

// 环境:React
// 场景:主题切换,深层组件直接消费 Context

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

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

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

// 深层组件,直接取值,不需要 Layout 传递任何东西
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

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

但 Context 有一个容易踩的性能陷阱:只要 Provider 的 value 发生变化,所有订阅了这个 Context 的组件都会重渲染,无论它们实际使用的数据有没有变。

// 场景:Context 的性能问题

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  // ❌ 把所有数据放在一个 Context:
  // theme 改变时,只用 user 的组件也会重渲染
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      <Header />   {/* 只用 user */}
      <Content />  {/* 只用 theme */}
    </AppContext.Provider>
  );
}

一种常见的处理方式是按关注点拆分 Context

// ✅ 拆分 Context:各自订阅,互不影响
const UserContext = createContext();
const ThemeContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <Header />
        <Content />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function Header() {
  const { user } = useContext(UserContext);
  // theme 变化不会触发 Header 重渲染 ✅
  return <div>{user.name}</div>;
}

结合 useMemo 稳定 value 对象,是另一个常见优化手段:

// 用 useMemo 避免因父组件重渲染导致 value 引用变化
const userValue = useMemo(() => ({ user, setUser }), [user]);

return <UserContext.Provider value={userValue}>...</UserContext.Provider>;

Context 适合的场景:主题、语言/国际化、用户认证信息这类「低频变化、广泛消费」的数据。如果某个数据每秒变化多次,Context 可能不是最佳选择。


方案四:EventBus——完全解耦的代价

有时候,需要通信的两个组件之间没有任何父子或兄弟关系,它们甚至可能属于完全不同的模块。这时 EventBus(发布订阅模式)是一种思路:

// 环境:浏览器 / Node.js
// 场景:简单的 EventBus 实现

class EventBus {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
    // 返回取消订阅函数,方便清理
    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    };
  }

  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
}

export const eventBus = new EventBus();

在 React 中使用时,要注意及时清理订阅,否则会有内存泄漏:

// 环境:React
// 场景:组件间通过 EventBus 通信(无父子关系)

function ProductDetail({ product }) {
  const addToCart = () => {
    // 发布事件,不关心谁在监听
    eventBus.emit('cart:add', product);
  };

  return <button onClick={addToCart}>加入购物车</button>;
}

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

  useEffect(() => {
    const unsubscribe = eventBus.on('cart:add', () => {
      setCount(prev => prev + 1);
    });

    // ✅ 组件卸载时取消订阅,避免内存泄漏
    return unsubscribe;
  }, []);

  return <div>购物车 ({count})</div>;
}

EventBus 的吸引力在于「完全解耦」—— 两个组件互相不知道对方的存在。但这也带来了一个问题:当 bug 出现时,你很难追踪某个事件从哪里发出,有多少个地方在监听。数据流的可见性大幅降低。

如果需要类型安全,可以用 TypeScript 约束事件类型:

// 环境:TypeScript + React
// 场景:类型安全的 EventBus

type Events = {
  'cart:add': { productId: string; quantity: number };
  'toast:show': { message: string; type: 'success' | 'error' };
};

class TypedEventBus {
  private events: { [K in keyof Events]?: Array<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, callback: (data: Events[K]) => void) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event]!.push(callback);
    return () => {
      this.events[event] = this.events[event]!.filter(cb => cb !== callback);
    };
  }

  emit<K extends keyof Events>(event: K, data: Events[K]) {
    this.events[event]?.forEach(cb => cb(data));
  }
}

EventBus 适合的场景:Toast 通知、埋点上报这类「通知型」事件,或者与第三方库之间的通信。不太适合用来管理需要持久化或同步的状态。


方案五:状态管理库——有代价的强大

当应用复杂度到达一定程度,多个不相关的组件都需要访问和修改同一份状态时,引入状态管理库会更合适。

Zustand 是目前相对轻量的选择,API 简洁,没有繁琐的样板代码:

// 环境:React + Zustand
// 场景:全局购物车状态管理

import { create } from 'zustand';

const useCartStore = create(set => ({
  items: [],

  addItem: item =>
    set(state => ({ items: [...state.items, item] })),

  removeItem: id =>
    set(state => ({ items: state.items.filter(i => i.id !== id) })),
}));

// 任意组件中使用,且只订阅自己需要的那部分状态
function CartIcon() {
  // 精确订阅,items 长度不变时不触发重渲染
  const count = useCartStore(state => state.items.length);
  return <div>购物车 ({count})</div>;
}

function ProductDetail({ product }) {
  const addItem = useCartStore(state => state.addItem);
  return <button onClick={() => addItem(product)}>加入购物车</button>;
}

Zustand 的一个优点是选择性订阅—— 组件只会在自己订阅的那部分状态变化时重渲染,性能比 Context 好控制。

Redux Toolkit 则更适合大型团队和需要严格数据流规范的场景,它的 DevTools 支持时间旅行调试,中间件生态也更丰富,但相应地引入了更多约束和概念。

有一点值得注意:不是什么状态都适合放进状态管理库。一个只在局部使用的 Modal 开关状态,用 useState 就够了,把它放进 Redux 是典型的过度设计。

// ❌ 过度设计:Modal 状态没必要全局化
const useModalStore = create(set => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

// ✅ 简单场景就用 useState
function Page() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}

如何选择?

整理一下思路,大体上可以用这个决策流程:

graph TD
    A[需要组件通信] --> B{组件关系?}

    B -->|父子| C[Props]
    B -->|兄弟| D{层级深吗?}
    D -->|1-2 层| E[状态提升]
    D -->|3 层以上| F{数据变化频率?}
    F -->|低频| G[Context]
    F -->|高频| H[Zustand]

    B -->|无关系| I{通信类型?}
    I -->|通知/事件| J[EventBus]
    I -->|状态共享| K{项目规模?}
    K -->|中小型| H
    K -->|大型/团队| L[Redux]

方案的核心差异:

方案 耦合程度 适合场景 主要风险
Props 紧耦合 父子,层级浅 Props Drilling
状态提升 较紧耦合 兄弟,层级浅 父组件臃肿
Context 松耦合 跨层级,低频变化 全量重渲染
EventBus 解耦 通知类,跨模块 数据流难追踪
Zustand 解耦 全局状态,中小型 滥用导致混乱
Redux 解耦+规范 大型项目 样板代码,学习成本

实际项目里通常是组合使用:Props 处理局部父子关系,Context 管理主题和用户信息,Zustand 或 Redux 处理核心业务状态,EventBus 负责 Toast 通知和埋点这类「一发即忘」的事件。


延伸与发散

在整理这些内容时,我产生了几个还没想清楚的问题:

React Server Components 如何改变通信模型? Server Components 本身不支持 state 和 context,如果组件树同时包含 Server 和 Client Components,数据如何在它们之间流动,目前还没有很好地弄明白。

Signals 是更好的答案吗? SolidJS 和 Preact Signals 的响应式模型在性能上有明显优势,组件不会因为无关状态变化而重渲染。React 社区也在讨论类似的方向,但目前还不是主流。

微前端场景下的通信怎么做? 主子应用之间的通信,无论是用 CustomEventqiankun 的全局状态还是 URL 参数,都有各自的取舍,这是另一个值得专门研究的话题。


小结

这篇文章更多是梳理思路,而非给出「最佳实践」的定论。一个让我印象比较深的认知是:选择通信方案,本质上是在选择组件之间的耦合程度。紧耦合的代码容易理解但难以重构,松耦合的代码灵活但追踪成本高——这个权衡在软件架构里是永恒的话题。

实用建议是:从最简单的方案开始,Props 能解决就用 Props,不够用再升级。过度设计的代价往往比技术债更难还清。


参考资料

Xcode 垃圾清理

一、可清理目录总览

场景 目录 是否可删 影响 建议
模拟器数据 ~/Library/Developer/CoreSimulator 可删 模拟器数据会被清空 不用模拟器时可重点清理(如 devices
真机调试符号 ~/Library/Developer/Xcode/iOS DeviceSupport 可删(建议选择性) 删掉后下次连接对应 iOS 版本会自动重建 删除不用的设备版本,常用版本保留
打包归档 ~/Library/Developer/Xcode/Archives 可删 会失去历史归档(.xcarchive) 先保留线上版本再清理
构建缓存 ~/Library/Developer/Xcode/DerivedData 可删 下次打开/编译变慢,需要重新索引与构建 优先清理(最直接释放缓存空间)

二、分项说明

1) 模拟器(CoreSimulator)

  • 路径:~/Library/Developer/CoreSimulator
  • 说明:包含模拟器设备数据。
  • 结论:可以删除;如果基本不用模拟器,可删除 devices 目录内容来释放较大空间。

2) 真机(DeviceSupport)

  • 路径:~/Library/Developer/Xcode/iOS DeviceSupport
  • 说明:真机调试时生成的设备符号文件。
  • 结论:建议选择性删除不用的设备版本;常用设备版本保留,避免频繁重建影响调试效率。

3) 打包(Archives)

  • 路径:~/Library/Developer/Xcode/Archives
  • 说明:Xcode 打包归档历史。
  • 结论:可以删除,但要先确认是否需要保留线上版本的归档记录。

4) 项目缓存(DerivedData)

  • 路径:~/Library/Developer/Xcode/DerivedData
  • 说明:构建缓存与索引。
  • 结论:建议优先清理;能快速释放缓存空间。代价是后续首次编译和索引会变慢。

三、实操建议(个人整理)

  1. 优先清理DerivedData(快速释放缓存空间)。
  2. 选择性清理iOS DeviceSupport(删除不用的设备/系统版本,常用的保留)。
  3. 按需清理CoreSimulator(尤其不用模拟器时)。
  4. 补充清理:过期 Archives(先保留可回滚版本)。
  5. 清理前先确认:
    • 是否有线上紧急回滚需要的归档;
    • 哪些真机系统版本仍在日常调试;
    • 是否有正在使用的模拟器环境数据需要保留。

四、快速命令(可选)

先看大小再删,避免误操作。

# 查看各目录体积
sudo du -sh ~/Library/Developer/CoreSimulator \
  ~/Library/Developer/Xcode/iOS\ DeviceSupport \
  ~/Library/Developer/Xcode/Archives \
  ~/Library/Developer/Xcode/DerivedData

# 删除 DeviceSupport
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport/*

# 删除 Archives
rm -rf ~/Library/Developer/Xcode/Archives/*

# 删除 DerivedData(谨慎)
rm -rf ~/Library/Developer/Xcode/DerivedData/*

五、一句话总结

Xcode 清理的核心是:优先清理 DerivedData 释放缓存;DeviceSupport 只删不用的设备版本,常用版本保留;再按需处理模拟器与旧归档。

Tailwind CSS v4 — 当框架猜不透你的心思

你在项目里写下 text-(--brand-color),满心期待文字变成品牌色,刷新页面——字号变了。

颜色没变,字号倒是歪了。你盯着屏幕,开始怀疑人生。

别急,这不是 bug,是 Tailwind 在"猜"你的意图——而且猜错了。

这篇文章会带你走一遍真实的开发场景。从最基础的任意值用法开始,一步步遇到更复杂的情况,直到你理解 Tailwind 为什么会猜错,以及如何优雅地纠正它。


场景一:设计稿给了个非标准值

设计师甩过来一张稿子,标注写着:top: 117px、背景色 #bada55

你翻了一遍 Tailwind 的间距和颜色系统——没有。top-28112pxtop-32128px,不上不下。

这时候就需要任意值(Arbitrary Values)了。用方括号 [] 把具体的 CSS 值包起来:

<div class="top-[117px]">精确定位</div>

<button class="bg-[#bada55]">这个颜色名字挺快乐</button>

<div class="left-[calc(50%-4rem)]">居中偏移</div>

方括号里可以放任何合法的 CSS 值——像素、百分比、calc() 表达式,甚至 var()。Tailwind 会原封不动地把它编译成对应的 CSS。

CSS 变量怎么写?

如果你的值存在 CSS 变量里,v4 提供了一个更简洁的语法——用圆括号 () 代替方括号:

<!-- v4 新语法:圆括号 + 裸变量名 -->
<div class="bg-(--brand-color)">用 CSS 变量设背景色</div>

<!-- 当然,显式写 var() 依然有效 -->
<div class="bg-[var(--brand-color)]">效果一样</div>

这是 v4 相对 v3 的一个重要变化。v3 里 CSS 变量简写用的是方括号 bg-[--brand-color],v4 改成了圆括号 bg-(--brand-color)。这个改动不是为了好看——而是为了解决歧义问题,后面会详细说。


场景二:Tailwind 没有的 CSS 属性

项目里需要用 mask-type 控制 SVG 遮罩行为。你搜了一圈文档,Tailwind 没有提供这个工具类。

任意属性(Arbitrary Properties)登场。用方括号把完整的 属性:值 对写进去:

<div class="[mask-type:luminance]">
  SVG 遮罩使用亮度模式
</div>

它和修饰符(modifier)配合也没问题:

<div class="[mask-type:luminance] hover:[mask-type:alpha]">
  hover 时切换为 alpha 模式
</div>

用任意属性设置 CSS 变量

这个语法还有一个很实用的场景——在 HTML 里直接设置 CSS 变量的值:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]">
  不同断点下设置不同的滚动偏移量
</div>

配合响应式前缀,你可以把 CSS 变量当作"响应式参数"来用,而不用写额外的媒体查询。


场景三:选择器玩不转了

产品经理说:"列表前三项要加下划线,hover 的时候。"

:nth-child(-n+3):hover —— 这选择器 Tailwind 的内置修饰符肯定不够用。

任意变体(Arbitrary Variants)可以搞定:

<ul>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 1 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 2 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 3 项</li>
  <li>第 4 项(不受影响)</li>
</ul>

方括号里的 & 代表当前元素。Tailwind 会把 & 替换成生成的类名,编译出你需要的选择器。

再来几个例子:

<!-- 所有子 p 元素加上 margin-top -->
<div class="[&_p]:mt-4">
  <p>我有 margin-top</p>
  <p>我也有</p>
</div>

<!-- 当元素有 .is-dragging 类时 -->
<li class="[&.is-dragging]:cursor-grabbing">
  拖拽中换光标
</li>

<!-- @supports 查询 -->
<div class="flex [@supports(display:grid)]:grid">
  支持 grid 就用 grid,否则用 flex
</div>

v4 变体堆叠顺序变了:从左往右读,和 CSS 选择器一致。v3 是从右往左。


场景四:值里面有空格怎么办?

你在写 Grid 布局,需要 grid-template-columns: 1fr 500px 2fr

直接写 grid-cols-[1fr 500px 2fr]?Tailwind 会把空格当作类名分隔符,直接报错。

解决方案:用下划线代替空格。

<div class="grid grid-cols-[1fr_500px_2fr]">
  <!-- 编译后:grid-template-columns: 1fr 500px 2fr -->
</div>

Tailwind 在编译时会自动把下划线转成空格。

但是 URL 里的下划线怎么办?

放心,Tailwind 足够聪明,会保留 URL 里的下划线:

<div class="bg-[url('/what_a_rush.png')]">
  <!-- 不会被转成空格,保持原样 -->
</div>

真的需要下划线呢?

用反斜杠转义:

<div class="before:content-['hello_world']">
  <!-- 编译后:content: 'hello_world' -->
</div>

JSX 里反斜杠被吃了?

JSX 的字符串会把 `` 当转义字符处理。用 String.raw 模板标签:

<div className={String.raw`before:content-['hello_world']`}>
  在 JSX 中安全地使用下划线
</div>

核心场景:Tailwind 猜错了

好,前面都是热身。现在进入本文的重头戏。

问题复现

回到开头的例子。你在 CSS 里定义了一个品牌色变量:

:root {
  --brand-color: #e63946;
}

然后你写下:

<p class="text-(--brand-color)">品牌色文字</p>

你期望的是文字变成红色。但实际效果是——字号变了,颜色没变。

为什么?

因为 text-* 在 Tailwind 里是一个多义命名空间。它同时映射了两种不同的 CSS 属性:

  • text-lgtext-smfont-size(字号)
  • text-red-500text-blackcolor(颜色)

当你写字面值的时候,Tailwind 能从值本身推断出类型:

<!-- Tailwind 看到 22px,推断为 length → font-size -->
<div class="text-[22px]">这是字号</div>

<!-- Tailwind 看到 #bada55,推断为 color → color -->
<div class="text-[#bada55]">这是颜色</div>

22px 明显是长度,#bada55 明显是颜色——推断没问题。

但 CSS 变量是个黑盒

当你写 text-(--brand-color) 的时候,Tailwind 看不到变量里存的是什么。它不知道 --brand-color 是颜色还是尺寸还是别的什么。

这时候 Tailwind 只能猜。而默认的猜测策略可能不符合你的预期——它可能把变量当成了 font-size 而不是 color

于是你的文字不是变红了,而是字号变成了 var(--brand-color),浏览器无法解析为有效字号,表现就很诡异。

解决方案:CSS 数据类型提示

在圆括号里,变量名前面加上类型提示

<!-- 明确告诉 Tailwind:这是颜色 -->
<p class="text-(color:--brand-color)">品牌色文字 ✓</p>

<!-- 明确告诉 Tailwind:这是字号 -->
<p class="text-(length:--font-size)">自定义字号 ✓</p>

语法格式:工具类-(类型:--变量名)

Tailwind 看到 color: 前缀,就知道应该把这个变量编译成 color 属性而不是 font-size。歧义消除。

方括号里的写法

如果你用 var() 的显式写法,类型提示放在方括号开头:

<p class="text-[color:var(--brand-color)]">同样有效</p>

不止 text-*

text-* 是最经典的歧义案例,但不是唯一一个。以下工具类都存在类似的命名空间冲突:

bg-* — 背景相关

<!-- 背景色 -->
<div class="bg-(color:--my-var)">背景颜色</div>

<!-- 背景图 -->
<div class="bg-(image:--my-var)">背景图片</div>

<!-- 背景位置 -->
<div class="bg-(position:--my-var)">背景位置</div>

bg-* 的歧义更多——它可以是颜色、图片、尺寸、位置,不加类型提示几乎必出问题。

border-* — 边框相关

<!-- 边框颜色 -->
<div class="border-(color:--my-var)">边框颜色</div>

<!-- 边框宽度 -->
<div class="border-(length:--my-var)">边框宽度</div>

shadow-* — 阴影相关

<div class="shadow-(color:--my-var)">阴影颜色</div>

decoration-* — 文本装饰

<!-- 装饰线颜色 -->
<div class="decoration-(color:--my-var)">装饰色</div>

<!-- 装饰线粗细 -->
<div class="decoration-(length:--my-var)">装饰粗细</div>

规律总结:只要一个工具类前缀同时对应多种 CSS 属性(颜色 + 尺寸最常见),用 CSS 变量时就需要类型提示。用字面值(如 #fff2px)时不需要,因为 Tailwind 能自动推断。


可用的类型提示一览

Tailwind v4 支持的 CSS 数据类型提示:

类型关键词 匹配什么 示例值
color CSS 颜色 #fffrgb(...)oklch(...)
length 长度 16px1rem2em
percentage 百分比 50%
number 数值 1.50
integer 整数 14
angle 角度 45deg0.25turn
url URL url(...)
image CSS 图片类型 url(...)linear-gradient(...)
position 位置 centertop left
ratio 比例 16/9
line-width 线宽 边框宽度值
bg-size 背景尺寸 covercontain
family-name 字体族名 字体名称

速查表

把全文涉及的语法整理在一起,方便随时翻阅:

场景 语法 示例
字面任意值 工具类-[值] top-[117px]bg-[#bada55]
CSS 变量简写 工具类-(--变量) bg-(--brand-color)
CSS 变量 + var() 工具类-[var(--变量)] bg-[var(--brand-color)]
类型提示(圆括号) 工具类-(类型:--变量) text-(color:--brand-color)
类型提示(方括号) 工具类-[类型:var(--变量)] text-[color:var(--brand-color)]
任意属性 [属性:值] [mask-type:luminance]
设置 CSS 变量 [--变量:值] [--scroll-offset:56px]
任意变体 [选择器]:工具类 [&:nth-child(3)]:underline
空格用下划线 _ 代替空格 grid-cols-[1fr_500px_2fr]
真正的下划线 _ 转义 content-['hello_world']
JSX 中的转义 String.raw`...` String.raw`content-['a_b']`

手写无限画布4 —— 从视觉图元到元数据对象

画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。

尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。

要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。

元数据定义 (Metadata Definition)

我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。

在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)

为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。

如下代码,这就是我们实际落地的元数据规范:

// src/schema/types.ts

// 所有图元共享的基因——它们必须遵守的基础契约
export interface BaseElementData {
  id: string; // 唯一宇宙编号,协同与更新的基石
  type: ElementType; // 业务大类
  x: number;
  y: number;
  width: number;
  height: number;
  hitColor: string; // 上一篇的命中测试色值,也要元数据化
  strokeColor: string;
  backgroundColor: string;
  opacity: number;
  zIndex: number; // 层级控制,决定覆盖关系
  isLocked: boolean; // 业务属性:用户是否锁定了该元素
  // ...
}

// 业务派生:形状、文字、线条各有自己的专属字段
export interface ShapeElementData extends BaseElementData {
  type: "rectangle" | "ellipse" | "diamond";
}

export interface LinearElementData extends BaseElementData {
  type: "arrow" | "line";
  points: number[][]; // 途经的折点
  startArrowhead: "arrow" | "triangle" | "none";
  endArrowhead: "arrow" | "triangle" | "none";
  startBindingId: string | null; // 线头绑定的元素 ID
  endBindingId: string | null;
}

// 终极联合类型:无限画布的唯一真理对象
export type CanvasElementData =
  | ShapeElementData
  | TextElementData
  | LinearElementData;

注意一个关键细节:上一篇讲到的命中测试色值 hitColor,也被我们收编进了元数据定义。从此刻起,一个图形的一切——它在哪、它多大、它长什么样、它怎么被点中——全部由这颗 JSON 树的一个节点来描述。再也没有游离在数据结构之外的"野状态"了。

纯元数据驱动带来的红利

当你把屏幕上所有花里胡哨的图形,都严格浓缩成上述哪怕只有几百 KB 大小的纯 JSON 文本时,奇迹发生了:

  1. 绝对纯净的持久化与协同:现在保存用户作品,不过就是做一次 JSON.stringify。而做多人协同,也不过是当某个 Node 的 x 发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁 {"id": "node_1", "x": 250}
  2. 极其廉价的时间机器:撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似 Immer.js 等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。
  3. 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。

接入状态管理:Zustand

有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?

绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。

打开 src/store.ts,这是整个工程的心脏:

// src/store.ts

export const canvasStore = createStore<CanvasState>((set) => ({
  // 全部元素的 Record 字典,key 为 id
  elements: initialElements,
  // 应用运行时状态(当前工具、缩放、视口偏移、选中态...)
  appState: defaultAppState,

  // ——— 以下全是纯函数式的 Actions ———
  updateElementProps: (id, props) =>
    set((state) => ({
      elements: {
        ...state.elements,
        [id]: { ...state.elements[id], ...props },
      },
    })),

  addElement: (el) =>
    set((state) => ({
      elements: { ...state.elements, [el.id]: el },
    })),

  selectElement: (id) =>
    set((state) => ({
      appState: { ...state.appState, selectedElementIds: id ? [id] : [] },
    })),
  // ...
}));

值得反复品味的是:无论是创建元素、更新坐标、还是切换选中态,Store 里执行的全部都是浅拷贝替换{ ...state.elements, [id]: ... })。没有任何副作用,没有任何直接 DOM 操作。这意味着前面说的 Undo/Redo "时间机器",你只需要把这些 Immutable 快照存进一个栈里就好了——就是这么廉价。


引擎订阅:一个极致的"哑巴渲染器"

Store 管数据,那谁管画面?答案是 src/engine/index.ts——我们的引擎总控 EngineFacade。它做的事情极其克制:只读数据,只画画面

// src/engine/index.ts — 订阅逻辑

this.unsubscribe = canvasStore.subscribe((state) => {
  // 图元变更 → 重新渲染
  if (state.elements !== prevState.elements) {
    this.shapeRenderer.render(state.elements);
  }
  // 选中态变更 → 同步 Transformer 控制框
  if (state.appState.selectedElementIds !== prevState.appState.selectedElementIds) {
    this.selectionManager.syncSelection(state.appState.selectedElementIds);
  }
  // 视口变更 → 同步 Stage 缩放/平移
  if (state.appState.zoom !== prevState.appState.zoom || ...) {
    this.viewportManager.syncViewport(zoom, scrollX, scrollY);
  }
});

请注意这里的引用相等性比较(!==)。Zustand 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的——不多画一帧,不少画一帧。

整个数据流形成了一个干净的单向环路

用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
                ↑                                      │
                └──────── 用户拖拽,Engine 回写坐标 ────┘

Konva 永远不私自修改任何数据。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘——一切都是单向、可追溯的。

而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:

// src/App.tsx — 属性面板(精简)
const PropertiesPanel = () => {
  const selectedIds = useCanvasStore(
    (state) => state.appState.selectedElementIds,
  );
  const elements = useCanvasStore((state) => state.elements);
  const updateElementProp = useCanvasStore((state) => state.updateElementProp);

  const el = elements[selectedIds[0]];
  // 从 store 读数据,渲染颜色选择器、描边样式按钮...
  // 用户点击后,直接调用 updateElementProp() 回写 store
};

我们常说,前端框架 React 的核心公式是 UI = f(State)。 而无限白板的架构真谛就是:Canvas = Konva(Metadata)


回望:四层地基已就位

至此,我们用四篇文章,自底向上地垒完了无限画布系统的四层地基:

层级 解决的核心问题 关键技术
坐标系 "无限"与"缩放"的数学本质 世界坐标 ↔ 屏幕坐标变换
渲染层 高性能绘制大量图形 Konva Scene Graph, 局部重绘
交互层 重建事件感知 离屏 Color Picking, Hit Testing
对象层 让画布拥有序列化的组织 元数据 Schema, Zustand 单向数据流

历经四篇文章的打磨,我们从最底层的数学坐标系起步,最终构筑起这套‘可协同、可撤销、可跨端’的数据驱动画布架构。这段工程演进之路的破局关键,其实就是两个字:克制。清晰划定架构的分层边界,想透每一层该做什么,并坚决杜绝越界。

本系列 实例项目已上传GitHub github.com/Seanshi2025… 项目上有完整的架构组织文档。

React 底层原理 & 新特性

React 底层原理 & 新特性

本文深入探讨 React 的底层架构演进、核心原理以及最新版本带来的突破性特性。


原文地址

墨渊书肆/React 底层原理 & 新特性


React 版本变动历史

React 自发布以来经历了多个版本的更新,每个主要版本的变动都带来了新的特性和改进,同时也对旧有的API进行了调整或废弃。以下是React几个重要版本的主要变动概述:

React 15 (2016年)

  • 引入Fiber架构:在 React 15后期版本中引入了 Fiber, 提供了更灵活的渲染调度和更换的错误恢复机制。
  • 改进了服务器端渲染:提升了SSR(Server-Side Rendering)的性能 and 稳定性。
  • SVG和MathML的支持增强:更好地支持SVG和MathML元素,使其渲染更加一致和准确。

React 16 (2017年)

  • 全面实施Fiber:Fiber成为了React核心的更新算法,提供了更细粒度的任务调度和更强大的并发模式,使得React应用的性能和响应性有了显著提升。
  • Error Boundaries:引入了错误边界的概念,允许组件捕获其子组件树中的JavaScript错误,并优雅地降级,而不是让整个应用崩溃。
  • Portals:允许将子节点渲染到DOM树的其他位置,为模态框、弹出层等场景提供了更好的解决方案。
  • 支持返回数组的render方法:可以直接从组件的render方法返回多个元素,而不需要额外的包装元素。

React 17 (2020年)

  • 自动批处理更新:默认开启了自动批处理更新,即使开发者没有手动使用 React.startTransitionunstable_batchedUpdates,React也会尝试批处理状态更新,以减少渲染次数。
  • 事件委托改进:改变了事件处理的方式,将事件监听器绑定到 document 上,减少了委托层级,简化了第三方库的继成。
  • 更严格的 JSX 类型检查:增强了对JSX类型的检查,帮助开发者提前发现潜在的类型错误。
  • 无-breaking-change 版本:React 17被设计为一个过渡版本,尽量减少对现有代码的破坏,为未来更大的更新铺路。

React 18 (2022年)

  • 并发模式:进一步深化了Fiber架构的并发特性,通过新的 SuspenseUseTransition API,允许开发者更好地控制组件的加载和更新策略。
  • 自动 hydration:React 18引入了新的渲染模式,包括 Server ComponentsAutomatic Hydration,旨在减少初次加载时间和提高用户体验。
  • 改进的错误处理:增强了错误边界和错误报告的能力,使得调试和问题定位更加容易。
  • StartTransition API:允许开发者标记某些状态更新为低优先级,从而优化UI的响应性和流畅性。

React 19 新特性深度解析 (2024年)

React 19 是一个重大的里程碑,它将许多在 React 18 中处于 Canary/Experimental 阶段的特性正式稳定化,并引入了全新的开发范式。

1. Actions 与异步状态管理

React 19 引入了 Actions 的概念,用于简化异步操作(如表单提交)及其状态管理。

  • useActionState: 自动处理异步函数的 pending 状态和结果。

    function UpdateName({ name, updateName }) {
      const [error, submitAction, isPending] = useActionState(
        async (previousState, formData) => {
          const error = await updateName(formData.get("name"));
          if (error) return error;
          return null;
        },
        null
      );
    
      return (
        <form action={submitAction}>
          <input type="text" name="name" disabled={isPending} />
          <button type="submit" disabled={isPending}>Update</button>
          {error && <p>{error}</p>}
        </form>
      );
    }
    
  • useFormStatus: 子组件无需通过 Props 即可感知父表单的提交状态。

  • useOptimistic: 极致的乐观更新体验。在请求发出时立即更新 UI,请求失败后自动回滚。

2. Server Actions:打通前后端的“虫洞”

Server Actions 允许你在客户端直接调用服务器上的异步函数,是 React 19 的核心特性之一。

  • 指令: 使用 'use server' 标记函数或整个文件。
  • 全链路流程:
    1. 定义: 在服务端定义异步函数。
    2. 序列化: React 自动处理参数的序列化(支持复杂对象、FormData)。
    3. 传输: 客户端调用时,React 发起一个特殊的 POST 请求,将参数序列化后传输。
    4. 执行: 服务器接收请求,反序列化参数,执行逻辑(如操作数据库)。
    5. 响应: 服务器返回执行结果,React 自动刷新相关的客户端数据(通过 Revalidation 机制)。
  • 核心优势:
    • 安全性: 自动包含 CSRF 防护,防止跨站请求伪造。
    • 简化代码: 无需手动编写 API 路由、处理 fetch 和状态更新逻辑。
    • 渐进增强: 在 JS 未加载完成时,表单提交依然可以通过原生的 form action 工作。

3. use API:统一的资源读取

use 是一个全新的运行时 API,可以在渲染时读取 Promises 或 Context。

  • 条件调用: 不同于普通的 Hooks,use 可以在 iffor 循环中调用。
  • 自动 Suspense: 当 use(promise) 还在等待时,React 会自动挂起当前组件并显示最近的 Suspense 占位符。

4. Hook 进阶:useEffectEvent (React 19.2+)

为了解决 useEffect 依赖项过多的问题,React 19.2 引入了 useEffectEvent

  • 设计初衷: 在 useEffect 中,有些逻辑需要读取最新的 propsstate,但不希望这些值的变化触发 Effect 重新运行。

  • 示例:

    function ChatRoom({ roomId, theme }) {
      // 将“纯逻辑事件”抽离
      const onConnected = useEffectEvent(() => {
        showNotification('已连接!', theme); // 始终能拿到最新的 theme
      });
    
      useEffect(() => {
        const connection = createConnection(roomId);
        connection.on('connected', () => {
          onConnected(); // 调用事件
        });
        connection.connect();
        return () => connection.disconnect();
      }, [roomId]); // ✅ theme 变化不再导致重新连接
    }
    
  • 核心逻辑: useEffectEvent 定义的函数具有“反应性”,但它不是“依赖项”。它能捕获最新的闭包值,却不会触发渲染。


5. React Server Components (RSC) 进阶

RSC 不仅仅是服务端渲染,它是一种新的组件架构。

  • 零包体积: 服务端组件的代码不会下载到浏览器,减少了 JS Bundle 大小。
  • 直接访问数据: 可以直接在组件内写 sql 查询或读取文件系统。
  • 混合模式: 通过 'use client' 指令,开发者可以精确定义客户端交互边界。

6. Web Components 原生支持

React 19 终于完美支持了 Web Components,解决了长期以来的“痛点”。

  • 属性与特性的智能映射:
    • 以前: React 总是将属性作为 Attribute 处理,导致无法传递对象或布尔值给 Web Components。
    • 现在: React 会自动检测自定义元素。如果该元素上定义了对应的 Property(属性),React 会优先使用属性赋值;否则使用 setAttribute
  • 原生事件支持:
    • 以前: 开发者需要通过 ref 手动调用 addEventListener
    • 现在: 可以像原生 DOM 一样直接使用 onMyEvent={handleEvent},React 会自动处理事件委托和解绑。
  • 跨团队协作: 这意味着大型企业可以在同一个页面中混合使用 React 组件和基于 Lit、Stencil 开发的 Web Components,而不会产生任何兼容性壁垒。

7. 开发者体验 (DX) 的全面进化

React 19 移除了许多历史包袱,让 API 变得更加直观。

  • 简化 ref 传递:

    • 以前: 必须使用 forwardRef 才能将 ref 传递给子组件。
    • 现在: ref 现在作为一个普通的 prop 传递。你可以直接在函数组件的参数中解构它:
    function MyInput({ placeholder, ref }) {
      return <input placeholder={placeholder} ref={ref} />;
    }
    
  • 文档元数据 (Metadata) 支持:

    • 开发者现在可以直接在组件中渲染 <title>, <meta>, <link>。React 会自动将它们提升(Hoist)到文档的 <head> 部分,并处理去重。
  • 静态资源加载优化:

    • React 19 引入了资源预加载 API,如 preload, preinit
    • 样式表与脚本: 支持在组件中直接声明样式表,React 会确保在组件渲染前样式已加载完成,避免闪烁(FOUC)。

底层原理深度解析

React 的底层设计旨在解决大规模应用中的 UI 响应速度和开发效率问题。其核心逻辑遵循从 “数据描述 (JSX) -> 内存模型 (Fiber) -> 任务调度 (Scheduler) -> 真实渲染 (Commit)” 的流水线。

1. JSX 的本质:声明式描述 UI

JSX(JavaScript XML)是 JavaScript 的语法扩展,本质是 React.createElement 的语法糖。

  • 源码转换JSX 通过 Babel 编译为 _jsx 调用,生成描述 UI 的普通对象(React Element)。
  • 设计初衷
    • 声明式编程:开发者只需关注 UI 的“最终状态”,而非如何操作 DOM。
    • 跨平台一致性React Element 是纯 JSON 对象,不仅能渲染为 DOM,还能渲染为原生应用(React Native)或 Canvas。

2. Fiber 架构:最小工作单元与增量渲染

Fiber 是 React 16 的核心重构,它将渲染过程从不可中断的“递归”变为了可控制的“迭代”。

  • Fiber 节点源码结构

    function FiberNode(tag, pendingProps, key) {
      // 1. 实例属性
      this.tag = tag;                 // 组件类型(Function, Class, Host...)
      this.stateNode = null;          // 对应真实 DOM 或组件实例
      
      // 2. 树结构属性 (单向链表)
      this.return = null;             // 指向父节点
      this.child = null;              // 指向第一个子节点
      this.sibling = null;            // 指向右侧兄弟节点
      
      // 3. 状态属性
      this.memoizedState = null;      // 存储 Hooks 链表
      this.updateQueue = null;        // 存储更新任务 (UpdateQueue)
      
      // 4. 并发与优先级
      this.alternate = null;          // 双缓存指向 (WIP vs Current)
      this.lanes = NoLanes;           // 当前任务优先级
      this.childLanes = NoLanes;      // 子树优先级
    }
    
  • UpdateQueue 内部结构: 每一个 Fiber 节点都有一个 updateQueue,用于存放状态更新。

    const updateQueue = {
      baseState: fiber.memoizedState,
      firstBaseUpdate: null,          // 基础更新链表头
      lastBaseUpdate: null,           // 基础更新链表尾
      shared: {
        pending: null,                // 待处理的循环链表
      },
      effects: null,                  // 存放副作用的数组
    };
    
  • Effect 链表 (副作用清理)

    Commit 阶段,React 会遍历 Effect 链表来执行 DOM 操作、生命周期或 Hooks 的 cleanup

    const effect = {
      tag: tag,                       // Hook 类型 (HookHasEffect | HookPassive)
      create: create,                 // useEffect 的第一个参数
      destroy: destroy,               // useEffect 的返回值 (cleanup)
      deps: deps,                     // 依赖项
      next: null,                     // 指向下一个 Effect
    };
    
  • 核心优势

    • 可中断性:将巨大的更新拆分为细小的 Fiber 任务,主线程可以在任务间隔处理更高优先级的用户输入。
    • 状态持久化:由于 Fiber 节点存储在内存中,即使渲染中断,之前的状态也能被保留,下次继续。

3. Fiber 树的遍历逻辑:深度优先遍历

React 采用“深度优先遍历”算法来处理 Fiber 树,这是一个典型的“递归”转“迭代”的过程。

  • beginWork 阶段:从上往下。

    • 核心逻辑:根据 React Element 的变化,决定是复用现有 Fiber 还是新建。
    • 任务:计算新的 props、计算新的 state、调用生命周期或 Hooks、打上副作用标记(Flags)。
  • completeWork 阶段:从下往上。

    • 核心逻辑
    function completeWork(current, workInProgress, renderLanes) {
      const newProps = workInProgress.pendingProps;
      switch (workInProgress.tag) {
        case HostComponent: // 真实 DOM 节点
          if (current !== null && workInProgress.stateNode != null) {
            // 更新模式:对比 props,记录差异
            updateHostComponent(current, workInProgress, tag, newProps);
          } else {
            // 创建模式:生成真实 DOM,并插入子节点
            const instance = createInstance(type, newProps, ...);
            appendAllChildren(instance, workInProgress);
            workInProgress.stateNode = instance;
          }
          break;
        // ... 其他类型处理
      }
    }
    
    • 任务
      • 构建离屏 DOM 树:在内存中完成 DOM 节点的创建和属性绑定。
      • 副作用冒泡 (Bubble up):将子树的所有 Flags 收集到父节点,这样 Commit 阶段只需遍历根节点的 Flags 链表。
  • 带来的性能体验: 这种双向遍历确保了 React 可以在中途暂停,并在恢复时准确知道当前处理到的位置。通过“副作用冒泡”,Commit 阶段的执行速度得到了极大的提升。

4. 双缓存 (Double Buffering) 机制

React 在内存中维护两棵 Fiber 树:current 树(屏幕显示)和 workInProgress 树(正在构建)。

  • 设计初衷
    • 避免 UI 破碎:如果直接在 current 树上修改,用户可能会看到渲染到一半的页面。
    • 极致性能:构建完成后,只需切换 FiberRoot 指针即可完成整棵树的更新,这种“内存交换”比逐个修改 DOM 节点快得多。

5. Scheduler 与时间切片

Scheduler 是 React 的心脏,负责任务的全局调度。

  • 时间切片 (Time Slicing):React 默认每 5ms 会让出一次主线程。它通过 MessageChannel 模拟宏任务。
  • 设计初衷:即使在执行极其复杂的渲染任务(如万级列表),页面依然能响应用户的点击和输入,彻底解决了 JavaScript 阻塞主线程导致的“卡死”感。

6. Lanes 优先级模型

React 17 引入了基于 31 位位掩码的 Lanes 模型。

  • 设计优势
    • 多任务并行:相比旧的 ExpirationTimeLanes 可以表示“一组”任务优先级。
    • 任务插队:React 可以准确识别出最高优先级任务,优先处理它,并将正在进行的低优先级任务挂起或废弃。

7. 合成事件 (Synthetic Events) 与批处理 (Batching)

React 并不直接使用浏览器的原生事件,而是实现了一套全平台的合成事件机制。

  • 合成事件原理:
    • 事件委派: React 17+ 将事件绑定在 root 容器上,而不是 document
    • 对象池化: (注:React 17 之后已移除池化,改为直接传递)。
    • 跨平台映射: 将不同浏览器的差异(如 transitionend, animationend)封装为统一的 API。
  • 自动批处理 (Automatic Batching):
    • 原理: React 会将多个状态更新合并为一次渲染。
    • React 18/19 的突破: 以前只有在 React 事件处理函数中才有批处理。现在,无论是在 PromisesetTimeout 还是原生事件中,所有的更新都是自动批处理的。
    • 底层实现: 通过 ExecutionContext(执行上下文)标记。当 React 发现处于“更新流程”中时,它不会立即触发渲染,而是将更新放入 UpdateQueue,等待主任务结束后一次性处理。

8. 协调 (Reconciliation) 过程深度拆解

协调是 React 区分“计算”与“渲染”的核心。

  • 阶段拆分:
    1. Render 阶段 (异步/可中断): 生成 Fiber 树,计算差异。
    2. Commit 阶段 (同步/不可中断):
      • BeforeMutation: 处理 DOM 渲染前的逻辑(如 getSnapshotBeforeUpdate)。
      • Mutation: 真正操作 DOM(增删改)。
      • Layout: 渲染后的逻辑(如 useLayoutEffect)。
  • 事务机制 (Transaction):
    • 虽然 React 源码中没有直接命名为 Transaction 的类,但其更新流程遵循典型的事务模式:performSyncWorkOnRoot 开启事务 -> 执行更新 -> commitRoot 结束事务并清理环境。

并发渲染 (Concurrent Rendering) 深度解析

并发渲染是 React 18+ 的核心能力,它改变了 React 处理更新的基础方式。

1. 传统渲染 vs 并发渲染

  • 传统渲染 (Stack Reconciler):渲染过程是同步且不可中断的。如果一个组件树很大,浏览器会一直忙于计算,无法响应用户操作。
  • 并发渲染:React 可以在渲染过程中暂停。如果用户点击了按钮,React 会暂停当前的渲染,处理点击事件,然后再恢复之前的渲染。

2. 并发特性的核心:Transitions

通过 startTransition,开发者可以告诉 React 哪些更新是“不紧急”的。

  • 应用场景:输入框打字是紧急的,下方的搜索结果列表更新是不紧急的。
  • 底层实现startTransition 会将更新标记为低优先级的 Lane,使得紧急更新(输入)可以打断它。

流式 SSR 与 Suspense 架构

React 18+ 彻底重塑了服务端渲染 (SSR) 的工作流程。

1. 传统的 SSR 瓶颈

在 React 18 之前,SSR 必须经历:

  1. 服务器拉取所有数据
  2. 生成整个 HTML
  3. 客户端下载整个 JS
  4. 整个页面进行 Hydration

任何一个环节慢了,用户都会看到白屏或无法交互。

2. 流式 SSR (Streaming SSR)

React 现在支持通过 renderToPipeableStream 将 HTML 分块发送给浏览器。

  • 结合 Suspense: 页面可以先显示外壳,耗时较长的组件(如评论列表)在服务器端准备好后再“流”向客户端,并自动插入到正确位置。
  • 选择性注水 (Selective Hydration): 用户点击了还没注水的组件时,React 会优先为该组件进行注水,提升了交互的实时性。

隐藏的宝藏:Offscreen (Activity) 模式

React 19 引入了 <Activity> 组件(实验性名称为 Offscreen API),开启了“智能预渲染”的大门。

  • 核心原理: 允许 React 在后台渲染组件树,而不将其挂载到真实的 DOM 上。
  • 运行模式:
    • hidden 模式:
      • DOM 隐藏: 组件的 DOM 节点被隐藏或不创建。
      • Effect 卸载: 所有的 useEffect 会执行 cleanup,避免后台任务占用过多资源。
      • 状态保留: 组件内部的 useStateuseReducer 状态会被完整保留。
      • 低优先级更新: 当 React 处理完所有可见任务后,会利用空闲时间悄悄更新 hidden 的树。
    • visible 模式: 组件瞬间恢复可见,useEffect 重新挂载,UI 立即同步到最新状态。
  • 优势与场景:
    • 瞬间回退 (Back Navigation): 用户点击“返回”按钮时,之前的页面可以瞬间重现,无需重新加载数据。
    • 标签页切换 (Tabs): 预先渲染非活跃的 Tab 页面,切换时零延迟。
    • 列表预加载: 当用户滚动列表时,提前渲染屏幕下方的几个节点。

性能优化进阶:Transition Tracing & Profiler

为了帮助开发者量身定制性能方案,React 19 提供了更强大的追踪工具和底层的调度观察能力。

1. Transition Tracing (过渡追踪)

允许开发者监听特定的“过渡任务”的生命周期。

  • onTransitionStart / onTransitionProgress: 可以精准监控 startTransition 开启的任务。

    import { unstable_useTransitionTracing } from 'react';
    
    function SearchPage() {
      unstable_useTransitionTracing('search-results', {
        onTransitionStart: (startTime) => {
          console.log('搜索开始', startTime);
        },
        onTransitionComplete: (endTime) => {
          console.log('搜索渲染完成', endTime);
        }
      });
      // ...
    }
    
  • 核心价值: 帮助开发者识别哪些复杂的渲染导致了 UI 的延迟,从而决定是否需要拆分组件或优化数据结构。

2. DevTools Profiler 增强

现在的 Profiler 可以清晰展示每个任务所属的 Lane(优先级级别)。

  • 优先级可视化: 开发者可以看到哪些任务是 User Blocking(用户阻塞,高优先级),哪些是 Transition(过渡,低优先级)。
  • 任务插队分析: Profiler 会标注出哪些任务是因为被更高优先级的任务“插队”而暂停的,这对于调试复杂的并发逻辑至关重要。

协调过程与 Diff 算法深度解析

协调是计算“变了什么”的过程,而 Diff 是其中的核心算法。

1. 核心策略:O(n) 复杂度

React 的 Diff 算法基于三个预设限制:

  • 同层比较:只比较同级节点,跨层级移动会被视为删除和重新创建。
  • 类型判断:如果节点类型变了,直接销毁旧树,创建新树。
  • Key 标识:通过 key 属性,开发者可以告知 React 哪些元素在不同渲染中是稳定的。

2. 多节点 Diff 的“两次遍历”

  1. 第一轮遍历:从左往右对比新旧节点。如果 keytype 都匹配,复用;否则跳出。
  2. 第二轮遍历:将剩余的旧节点放入 Map。遍历新节点时,尝试从 Map 中通过 key 寻找可复用的节点,从而高效处理位移。

Hooks 底层:基于链表的状态管理

Hooks 的状态存储在 Fiber 节点的 memoizedState 单向链表中。

1. Hook 对象结构

const hook = {
  memoizedState: null, // 存储 useState 的值、useEffect 的 effect 等
  baseState: null,
  baseQueue: null,
  queue: null,         // 状态更新队列
  next: null,          // 下一个 Hook
};

2. 闭包陷阱的本质

当 Hook 在渲染过程中被调用时,它会读取当前 Fiber 的状态。如果异步操作引用了旧的变量,而组件已经重新渲染,就会产生闭包陷阱。这正是 useEffect 依赖项数组存在的原因。


未来展望:React Compiler (React Forget)

为了进一步提升性能,Meta 正在开发 React Compiler

  • 自动记忆化: 目前开发者需要手动使用 useMemouseCallback。编译器将通过静态分析,自动插入这些优化代码。
  • 性能体验: 彻底告别手动性能优化,让 React 应用在默认情况下就拥有极致的运行效率。

总结:Meta 为什么这样设计?

Meta(原 Facebook)之所以设计这套极其复杂的源码结构,其核心目标只有一个:在保证开发者体验(DX)的同时,提供极致的用户体验(UX)。

  1. 响应性优先:通过 FiberScheduler,确保用户操作永远拥有最高优先级。
  2. 内存换速度:通过“虚拟 DOM”和“双缓存”,用内存中的对象运算来换取昂贵的真实 DOM 操作。
  3. 架构的生命力LanesConcurrent Mode 的引入,让 React 从一个简单的 UI 库进化成了一个能处理复杂调度任务的“前端操作系统”。
  4. 全栈融合:React 19 的 Server ActionsRSC 标志着 React 正在从“UI 库”向“全栈框架”迈进,试图统一前后端的开发模型

React 基础理论 & API 使用

React 基础理论 & API 使用

本文主要记录一些关于 React 的基础理论、核心概念以及常用 API 的使用方法,供查漏补缺。


原文地址

墨渊书肆/React 基础理论 & API 使用


React 简介

React 是一个由 Facebook(现在称为 Meta)开发的开源 JavaScript 库,主要用于构建用户界面,特别是单页应用程序(SPA)的开发。React 不仅限于 Web 开发,通过React Native,开发者还可以使用几乎相同的组件化开发方式来构建原生移动应用程序,实现了跨平台的代码复用。由于其灵活性和高效性,React 已成为现代 Web 开发中最受欢迎的前端库之一。

核心特点

  • 组件化编程:React 将页面和功能分解为可复用的组件,每个组件可以管理自己的状态 and 渲染逻辑,大大提高了代码的可维护性和可重用性。
  • Virtual DOM:引入虚拟 DOM 的概念,它是一个树形数据结构,用来表示真实 DOM 的抽象。当状态发生改变时,在Render阶段会先计算VDOM的最小更新,然后在Commit阶段生成真实 DOM,减少了浏览器的重排和重绘,提高了性能。
  • 声明式编程:React 使用声明式的方式定义页面的 UI 和状态逻辑,让代码更容易理解。
  • JSX:允许开发者在 JS 中混写 HTML-like 的语法,这种语法糖被称为JSX,可以更直观和简洁的描述组件的结构。
  • 单向数据流:React 应用遵循单向数据流的原则,父组件向子组件传递状态(props)和回调函数,子组件通过调用这些回调来通知父组件的状态变更,这有助于保持数据流的清晰和可预测性。这点有别于 Vue 的双向绑定。

安装

在搭建 React 框架时,我们现在通常使用目前更主流、构建速度更快的 Vite,它是现代前端开发的优选脚手架。

# 使用 Vite 创建项目
npm create vite@latest my-react-app -- --template react

如果你选择其他框架或工具链,也有对应的安装方式:

  • Next.jsnpx create-next-app@latest
  • UmiJS:使用 create-umi

组件通讯方式

在 React 中,组件间的通信主要有以下几种方式:

  1. 通过 props 向子组件传递数据:父组件通过属性将数据传递给子组件。
  2. 通过回调函数向父组件传递数据:父组件向子组件传递一个函数,子组件调用该函数并传入数据。
  3. 使用 Refs 调用子组件暴露的方法:通过 forwardRefuseImperativeHandle 钩子,父组件可以访问子组件内部定义的方法。
  4. 通过 Context 进行跨组件通信:使用 createContextuseContext 实现跨层级的状态共享。
  5. 使用状态管理库:如 ReduxMobXZustand 等进行全局状态管理。

生命周期

经典生命周期

在React 16.3之后的生命周期可以分为三个阶段:

挂载阶段(Mounting):

  • constructor: 组件实例化时调用,初始化 state 和绑定 this
  • getDerivedStateFromProps: (React 16.3新增)在组件实例被创建后续更新时被调用,用于根据 props 来计算 state
  • render: 根据 state 和 props 渲染UI到虚拟 DOM。
  • componentDidMount: 组件已经被渲染到 DOM 后调用,常用于发起网络请求、设置定时器等。

更新阶段(Updating):

  • getDerivedStateFromProps: 同挂载阶段。
  • shouldComponentUpdate: 判断是否需要更新 DOM,返回 true/false。
  • render: 状态或props改变时再次渲染 UI。
  • getSnapshotBeforeUpdate: (React 16.3新增)在 DOM 更新前调用,可以获取一些信息用于在 componentDidUpdate 中使用。
  • componentDidUpdate: 组件更新后立即调用,可以进行 DOM 操作或网络请求。

卸载阶段(Unmounting):

  • componentWillUnmount: 组件将要卸载时调用,清理工作如取消网络请求、清除定时器等。

从React 16.3开始,componentWillMount, componentWillReceiveProps, 和 componentWillUpdate 被标记为不安全的,并最终在React 17中被废弃。React推荐使用getDerivedStateFromProps和useState、useEffect等Hooks来替代。

Hooks 生命周期模拟

对于 React 函数组件,现在的实践更倾向于使用如下的 Hooks 生命周期:

  • useState: 用于组件内部状态管理。

  • useEffect: 用于处理副作用,可模拟以下生命周期:

    • 模拟挂载阶段 (componentDidMount): 依赖数组传空 []
    useEffect(() => { /* 只在挂载后执行 */ }, []);
    
    • 模拟更新阶段 (componentDidUpdate): 不传依赖数组或传入特定依赖。
    useEffect(() => { /* 每次渲染后都执行 */ });
    useEffect(() => { /* count 变化后执行 */ }, [count]);
    
    • 模拟卸载阶段 (componentWillUnmount): 在 useEffect 中返回一个清理函数。
    useEffect(() => {
      return () => { /* 组件卸载前执行 */ };
    }, []);
    
  • useContext: 用于从上下文中消费值。

  • useRef: 用于持久化一个可变的引用对象,不会引起组件重新渲染。

  • useReducer: 用于有复杂状态逻辑的组件,替代某些 useState 的使用场景。

  • useCallbackuseMemo: 用于优化性能,避免不必要的函数或计算的重新创建。

父子组件生命周期调用顺序

在函数组件中,挂载阶段的执行顺序如下:

  1. 父组件执行函数体(首次渲染)。
  2. 子组件执行函数体(首次渲染)。
  3. 子组件执行 useEffect(挂载完成)。
  4. 父组件执行 useEffect(挂载完成)。

更新阶段:

  1. 父组件重新渲染。
  2. 子组件重新渲染。
  3. 子组件 useEffect 清理函数执行。
  4. 父组件 useEffect 清理函数执行。
  5. 子组件 useEffect 执行。
  6. 父组件 useEffect 执行。

组件类 API

PureComponent

PureComponentComponent 的子类,是基于 shouldComponentUpdate 的一种优化方式。使用 PureComponent 的主要优点在于它自动执行了浅比较来检查 props 和 state 是否有变化,没有变化的时候不会重新渲染,从而提高了性能,减少了不必要的计算和 DOM 操作。

import React, { PureComponent } from 'react';

class MyComponent extends PureComponent {
  render() {
    return (
      <div>
        {this.props.text}
      </div>
    );
  }
}

export default MyComponent;

memo

React.memo 是 React 中用于函数组件的性能优化手段,它是一个高阶函数,用来包装一个函数组件,并利用引用地址比较(浅比较)来决定是否重新渲染该组件。当组件的 props 没有发生变化时(基于浅比较),则跳过重新渲染,从而提高性能。

import React, { memo } from 'react';

const MyComponent = memo((props) => {
  // 组件逻辑...
  return <div>{props.text}</div>;
});

// 自定义比较函数:可以通过传递第二个参数给memo来自定义比较逻辑,这允许你实现深度比较或其他定制化的比较策略。
const MyComponent = memo((props) => {...}, (prevProps, nextProps) => {
  // 自定义比较逻辑
  // 返回true如果 props 没有变化,无需重新渲染
  // 返回false如果 props 有变化,需要重新渲染
  return prevProps.text === nextProps.text;
});

createRef

createRef 是 React 中管理 DOM 元素或组件实例引用的一个现代、灵活的方法,有助于处理表单、动画交互、原生DOM操作等场景。

class MyComponent extends React.Component {
  myInputRef = React.createRef();

  componentDidMount() {
    // 在组件挂载后访问DOM元素
    this.myInputRef?.current?.focus();
  }

  render() {
    return <input type="text" ref={this.myInputRef} />;
  }
}

forwardRef

forwardRef 是 React 中的一个高阶组件(HOC),它允许我们将 React 的 refs 转发到被包裹的组件中,即使这个组件是一个函数组件。这在需要访问子组件的 DOM 节点或者想要从父组件传递一些引用到子组件的场景下非常有用。极大地增强了函数组件的能力,使得它们在处理需要直接操作DOM或传递引用的场景下更加灵活和强大。

import React, { forwardRef } from 'react';

// 第一个参数是React.forwardRef接收的render函数,它接收两个参数:props和ref
const MyForwardedComponent = forwardRef((props, ref) => {
  // 现在你可以在这个函数组件内部使用ref了
  return <input type="text" ref={ref} {...props} />;
});

// 使用forwardRef的组件时,可以像普通组件那样使用ref
class ParentComponent extends React.Component {
  myInputRef = React.createRef();

  focusInput = () => {
    this.myInputRef?.current?.focus();
  };

  render() {
    return (
      <>
        <MyForwardedComponent ref={this.myInputRef} />
        <button onClick={this.focusInput}>Focus Input</button>
      </>
    );
  }
}

createContext

createContext 是 React 中的一个API,用于创建一个“context”对象。Context 提供了一种在组件树中传递数据的方式,而不必显式地通过每一个层级手动传递 props。这使得在不同层级的组件中共享数据变得简单且高效,特别适合管理如主题语言设置认证信息等全局状态。

基本用法:

  • 创建 Context: createContext(defaultValue)
import React from 'react';
// 创建一个context
const MyContext = React.createContext('light');
  • Provider组件: 注入值上下文
class App extends React.Component {
  state = {
    theme: 'light',
  };

  render() {
    return (
      // 通过Provider组件向上下文中注入值
      <MyContext.Provider value={this.state.theme}>
        <ComponentThatNeedsTheContext />
      </MyContext.Provider>
    );
  }
}
  • Consumer组件: 读取上下文的方法
function ComponentThatNeedsTheContext() {
  return (
    <MyContext.Consumer>
      {theme => /* 使用theme值 */}
    </MyContext.Consumer>
  );
}

或者使用 useContext Hook:

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

注意事项:

Context 会随着组件树的遍历而传递,无论组件是否使用了这个 Context。因此,应当谨慎使用,避免创建过多的 Context,尤其是嵌套使用时。

createElement

createElement 在我们的平时使用中较少,它用于创建 React 元素,是构成用户界面的基本单位,我们常写的 JSX 就是 createElement 的语法糖,所以还是很有必要了解这个 API 的。

基本语法:

React.createElement(
  type, // 通常是一个字符串(对应HTML标签名)或一个React组件(函数组件或类组件的构造函数)。
  [props], // 一个对象,用于传递给组件的属性。它可以包含事件处理器、样式等。
  [...children] // 代表组件的子元素,可以是一个React元素、字符串或数字,也可以是这些类型的数组。
)

示例:

const element = React.createElement(
  'div',
  { id: 'example', className: 'box' },
  'Hello, world!'
);

这段代码等同于下面的JSX写法:

<div id="example" className="box">
  Hello, world!
</div>

cloneElement

cloneElement 是 React 提供的一个方法,用于克隆并返回一个新的 React 元素,同时可以修改传入元素的 props,甚至可以添加或替换子元素。这个方法常用于在高阶组件中,或者任何需要基于现有元素创建一个具有额外 props 或不同子元素的新元素的场景。

基本语法:

React.cloneElement(
  element, // 要克隆的React元素
  [props], // 一个对象,包含了要添加或覆盖到原始元素props上的新属性
  [...children] // 可选的,用于替换或追加子元素到克隆后的元素中
)

自定义 HOC

高阶组件(Higher-Order Components, HOC)是 React 中用于重用组件逻辑的一种高级技术。HOC 本身不是 React API 的一部分,而是一种从函数式编程原则中借来的模式。一个 HOC 是一个接受组件作为参数并返回一个新的增强组件的函数。

function withEnhancement(WrappedComponent) {
  return function EnhancedComponent(props) {
    // 添加额外的props或逻辑
    const newProps = { ...props, enhancedProp: "Enhanced Value" };
    
    // 渲染被包装的组件,并传递新的props
    return <WrappedComponent {...newProps} />;
  };
}

注意事项:

  • 不要修改传入组件的props: 最好是通过组合新的 props 而不是修改原有的 props来保持纯净性。
  • 命名约定: 通常 HOC 函数名以 with 开头,以表明它是一个 HOC。
  • 文档和测试: 编写清晰的文档说明 HOC 的功能和用法,并确保充分测试,以防止引入bug。

Hooks

React Hooks 是React 16.8版本引入的一个新特性,在不编写类的情况下使用 React 的状态和其他生命周期特性。Hooks 使函数组件的功能更加丰富,使得函数组件逻辑更易于理解和重用。

useState

允许在函数组件中添加状态(state)。它返回一个状态变量和一个用来更新这个状态的函数。

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

useEffect

useEffect 是 React Hooks 系统中的一个重要成员,它主要用于执行副作用操作,比如数据获取、订阅或者手动修改 DOM 等。此 Hook 允许你同步副作用与 React 组件的生命周期,替代了类组件中的一些生命周期方法,如 componentDidMountcomponentDidUpdatecomponentWillUnmount

// useEffect 接收两个参数:一个包含副作用操作的函数,和一个依赖项数组
useEffect(() => {
  // 副作用操作:订阅或数据获取等
  document.title = `You clicked ${count} times`;

  // 可选的清理函数,用于在下次effect执行前或组件卸载时清理副作用
  return () => {
    // 清理操作,例如取消网络请求或移除事件监听器
  };
}, [count]); // 依赖项数组,当这些值变化时触发effect重新执行

useContext

useContext 是 React Hooks 系统中的一个 API,它使你能够在组件树中无需通过 props 逐层传递,就能访问到全局状态或其他组件上下文中的值。这对于管理如主题、语言、认证信息等跨多个组件共享的数据尤为有用。

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

useRef

useRef 是 React Hooks 系统中的一个API,它用于创建一个可变的引用对象(ref),这个对象的.current属性被初始化为传递的参数(initialValue)。useRef的主要用途是在渲染之间持久化一个可变的值,并且可以用来直接访问 DOM 元素或在函数组件之间保持一些状态。

import React, { useRef } from 'react';

function TextInputWithFocusButton() {
  // 初始化一个ref,用来存放input元素的引用
  const inputEl = useRef(null);

  const onButtonClick = () => {
    // 当按钮被点击时,让input元素获取焦点
    inputEl?.current?.focus();
  };

  return (
    <>
      {/* 将input元素的引用赋给useRef返回的对象 */}
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useReducer

useReducer 是React中的一个Hook,它用于管理组件中的状态,特别适用于状态更新逻辑较复杂的场景。

基本用法:

import React, { useReducer } from 'react';

// 定义reducer函数
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

// 初始化状态
const initialState = { count: 0 };

function Counter() {
  // 使用useReducer,传入reducer函数和初始状态
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
}

注意事项:

  • 确保reducer函数是纯函数,即给定相同输入始终产生相同输出,不产生副作用。
  • 选择合适的状态管理方式,对于简单的状态管理,useState可能更直观易用。
  • 利用useCallback来记忆化dispatch函数,避免在每个渲染周期都创建新的函数引用,进而减少不必要的子组件重渲染。

useMemo

useMemo 是React中的一个Hook,用于优化性能,避免在每次渲染时都进行复杂的计算。它让你能够 memoize(记忆化)一个值,这个值是基于某些依赖项计算出来的,只有当这些依赖项改变时,才会重新计算这个值。

基本用法:

import React, { useMemo } from 'react';

function MyComponent({ list }) {
  // 使用useMemo进行性能优化
  const sortedList = useMemo(() => {
    console.log('Sorting list');
    return list.sort((a, b) => a - b);
  }, [list]); // 依赖项数组,当list变化时才重新计算sortedList

  return (
    <div>
      {sortedList.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
}

注意事项:

  • 不要过度使用: 虽然useMemo可以帮助优化性能,但是不必要的使用反而可能导致额外的性能开销,特别是在计算简单或频繁变化的值时。
  • 理解其限制: useMemo不会阻止其依赖项内的对象或数组的内部变化触发重渲染。只有当依赖项的引用本身发生变化时,才会触发重计算。
  • 与React.memo区别: React.memo是一个高阶组件,用于记忆化整个组件,防止不必要的渲染,而useMemo是记忆化组件内部的某个值或计算结果。

useCallback

useCallback 是 React中 的另一个性能优化 Hook,它用于记忆化函数。与 useMemo 相似,useCallback 也用于避免在每次渲染时都进行新的函数引用,但它的主要应用场景是当这些函数作为 props 传递给子组件时,帮助子组件避免不必要的重新渲染。

import React, { useCallback, useState } from 'react';

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

  // 使用useCallback记忆化increment函数
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count, setCount]); // 依赖项数组,当这些值变化时,才会生成新的increment函数

  return <ChildComponent onClick={increment} />;
}

function ChildComponent({ onClick }) {
  // ...
}

注意事项:

  • 与useMemo的区别: useMemo 适用于记忆化计算值或对象,而 useCallback 专门用于记忆化函数。
  • 避免闭包陷阱: 在使用 useCallback 时,需要注意函数内部引用的外部变量也应包含在依赖项数组中,以确保正确的重渲染逻辑。

ts随笔:面向对象与高级类型

ts随笔:面向对象与高级类型

本篇主要聚焦在类、模块、高级类型以及在常见前端框架中的实践,同时结合生态中新出现的一些特性,如何自然地用上这些新能力。


原文地址

墨渊书肆/ts随笔:面向对象与高级类型


类(Class)

类是面向对象编程的基础,用于创建具有属性(数据成员)和方法(成员函数)的对象的蓝图。TypeScript 中的类支持 继承封装多态 等面向对象特性。

基本语法

class Person {
  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

const person = new Person("Alice", 30);
person.greet();

在较新的 TypeScript 版本中,你也可以结合 ECMAScript 的 私有字段 语法(以 # 开头),在保持类型安全的同时实现更彻底的封装:

class Counter {
  #value = 0;

  increment() {
    this.#value++;
  }

  get value(): number {
    return this.#value;
  }
}

继承

class Student extends Person {
  studentId: string;

  constructor(name: string, age: number, studentId: string) {
    super(name, age);
    this.studentId = studentId;
  }

  study() {
    console.log(`${this.name} is studying.`);
  }
}

const student = new Student("Bob", 20, "S123");
student.greet();
student.study();

借助 TypeScript 的严格类型系统,继承关系中的属性和方法都会得到完整的类型检查支持,在重写方法时也能获得参数和返回值的约束。

模块(Module)

模块是用于组织代码的容器,它允许你将相关联的类、接口、函数等封装在一个单独的文件中,并可以控制它们的可见性(导出/导入)。模块有助于避免命名冲突和促进代码的复用。

导入与导出

// moduleA.ts
export class MyClass {
  // ...
}

// 在其他文件中使用导出的元素
import { MyClass } from "./moduleA";

const myInstance = new MyClass();

默认导出命名导出 可以混合使用,但在一个模块中只能有一个默认导出;命名导出则可以有多个。

命名空间与模块的异同

在早期版本的 TypeScript 中,命名空间(Namespace)是另一种组织代码的方式,它类似于 C# 或 Java 中的包,提供了一种分层次的方式来组织代码。虽然模块现在是推荐的做法,但命名空间仍然可用,特别是在需要合并多个文件定义的命名空间时。

面向未来的模块特性:JSON 模块import defer

从 ES2025 开始,JSON 模块 等特性有望在主流环境中稳定可用,你可以直接以模块的方式导入 JSON 文件,并配合 TypeScript 的类型系统进行约束:

// config.json
// {
//   "apiBaseUrl": "https://api.example.com",
//   "featureFlags": {
//     "newUI": true
//   }
// }

interface FeatureFlags {
  newUI: boolean;
}

interface AppConfig {
  apiBaseUrl: string;
  featureFlags: FeatureFlags;
}

// 在支持 JSON 模块的环境下
import configJson from "./config.json" with { type: "json" };

const config = configJson as AppConfig;

在 ES2026 及之后,import defer 等语法提案逐步成熟时,可以在保持语义清晰的前提下延迟加载非关键模块,而 TypeScript 依然会对导入的符号进行完整的类型检查:

// 伪代码示意:具体语法以最终标准为准
// import defer "./heavy-analytics.js";

// TypeScript 关注的是导出的类型本身,只要声明文件同步更新,
// 即使底层加载时机发生变化,类型系统仍然保持稳定。

和上一篇中提到的声明文件一样,这些新的模块特性最终都会通过 .d.ts 的方式落地到 TypeScript 生态中。

高级类型探索

泛型 Generics

泛型(Generics)是 TypeScript 中一个强大的特性,它允许你在定义函数、接口或类的时候不预先指定具体的类型,而是将类型作为参数传递。

基本概念

泛型的核心在于使用类型变量(通常用大写字母表示,如 T、U 等)来代表一些未知的类型。当使用这个组件时,你再指定这些类型变量的具体类型。

泛型函数
function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("hello");
console.log(output);

let numberOutput = identity<number>(123);
console.log(numberOutput);
泛型接口
interface Pair<T> {
  first: T;
  second: T;
}

let pairStr: Pair<string> = { first: "hello", second: "world" };
let pairNum: Pair<number> = { first: 1, second: 2 };
泛型类
class Box<T> {
  private containedValue: T;

  set(value: T) {
    this.containedValue = value;
  }

  get(): T {
    return this.containedValue;
  }
}

let boxStr = new Box<string>();
boxStr.set("hello");
console.log(boxStr.get());

let boxNum = new Box<number>();
boxNum.set(123);
console.log(boxNum.get());
泛型约束

有时候,你可能需要限制可以作为类型参数的具体类型,这时候可以使用泛型约束。泛型约束通过接口来定义,要求传入的类型必须满足该接口定义的条件。

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity({ length: 10, value: "test" });
// loggingIdentity(123); // 错误:number 没有 length 属性

联合类型 Union Types

联合类型 允许一个变量可能是多种类型之一。例如,你可以定义一个变量既可能是字符串也可能是数字:

let myValue: string | number;
myValue = "Hello";
myValue = 42;

类型守卫 Type Guards

当你在操作联合类型的变量时,TypeScript 可能无法确定变量的具体类型,这会影响到你能够调用的方法或访问的属性。类型守卫 就是用来缩小类型范围,确保在运行时变量属于某种特定类型。

typeof 类型守卫
if (typeof myValue === "string") {
  console.log(myValue.toUpperCase());
} else {
  console.log(myValue.toFixed(2));
}
instanceof 类型守卫
class Animal {}

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

function isDog(animal: Animal): animal is Dog {
  return animal instanceof Dog;
}

let pet = new Dog();
if (isDog(pet)) {
  pet.bark();
}
in 操作符
interface Cat {
  meow: () => void;
}

function makeSound(animal: Animal | Cat) {
  if ("meow" in animal) {
    animal.meow();
  } else {
    console.log(animal.toString());
  }
}

Iterator Helpers 与 Set 扩展下的类型推断

在 ES2025、ES2026 相关提案中,Iterator HelpersSet 扩展 是非常值得关注的一类特性:它们让各种可迭代对象(包括数组、Set、Map 的键值迭代器等)拥有类似链式操作的能力。

当对应的类型定义进入 TypeScript 之后,可以配合泛型和类型守卫写出既简洁又安全的代码。例如,以 Set 扩展为例:

// 假设运行时与 TypeScript lib 均已支持 Set 的扩展方法
const ids = new Set([1, 2, 3, 4, 5]);

// filter 返回的仍然是 Set<number>,类型信息由泛型推断而来
// const evenIds = ids.filter((id) => id % 2 === 0);

// map 等其他 Iterator Helpers 也同理可以得到明确的类型
// const idStrings = ids.map((id) => `id-${id}`);

虽然上面的代码在当前某些环境中还处于“提案阶段”,但可以预期的是,未来在 TypeScript 中使用这些 API 时,你同样能获得完整的泛型推断和类型守卫支持。

日期时间与本地化:TemporalIntl.Locale

时间与本地化一直是前端开发中的老大难问题。Temporal 和 Intl.Locale 等提案正是为了解决 Date 语义不清、Intl 配置复杂等问题。

Temporal 定稿并进入主流运行时时,你可以在 TypeScript 中这样书写代码:

// 假设 lib 已经包含 Temporal 与最新的 Intl 声明
// const now: Temporal.ZonedDateTime = Temporal.Now.zonedDateTimeISO();
// const locale = new Intl.Locale("zh-CN", { calendar: "gregory" });

// console.log(now.toLocaleString(locale.toString()));

这些 API 本身是 JavaScript 语言层面的特性,但它们的类型声明会第一时间进入 TypeScript 官方声明文件,从而让我们在使用它们时也能享受完整的类型推断、自动补全和错误检查。

ts 在 React 中的使用

新项目使用 create-react-app 接入

npx create-react-app my-app --template typescript

React 老项目接入

首先安装 @types/react@types/react-dom 这些 React 的类型定义文件:

npm install --save-dev @types/react @types/react-dom

然后将 .js 文件逐步转换为 .tsx(TypeScript 支持 JSX 的文件扩展名)并添加类型注释。

React 代码编写

import React, { useState } from "react";

interface Props {
  name: string;
}

const Hello: React.FC<Props> = ({ name }) => {
  const [message, setMessage] = useState<string>("Hello");

  return (
    <div>
      <h1>{`${message}, ${name}!`}</h1>
      <button onClick={() => setMessage("Welcome")}>Change Message</button>
    </div>
  );
};

export default Hello;

在较新的 TypeScript 与 React 生态中,配合前面提到的 JSON 模块Iterator HelpersTemporal 等能力,你可以更放心地在组件中使用这些新特性——只要升级依赖并确保声明文件同步更新,编辑器就会用类型系统帮你“兜住”大部分错误。

ts 在 Vue 3 中的使用

新项目使用 Vue CLI 接入

vue create my-vue3-project --preset typescript

Vue 老项目接入

vue add typescript

Vue 代码编写

<script lang="ts">
import { defineComponent, ref, reactive } from "vue";

interface Props {
  msg: string;
}

export default defineComponent({
  props: {
    msg: String,
  },
  setup(props: Props) {
    const count = ref(0);
    const state = reactive({ status: "active" });

    // 在这里同样可以安心地使用前文提到的高级类型、
    // Iterator Helpers 或 Temporal 等能力,TypeScript
    // 会在编译阶段帮你把控类型安全。

    return {
      count,
      state,
    };
  },
});
</script>

无论是 React 还是 Vue,TypeScript 都会继续扮演“粘合剂”的角色:只要按需升级依赖、合理配置 tsconfig,就能够在习惯的写法下自然享受到这些新特性带来的收益。

SPA 首屏加载速度慢怎么解决?

一、问题根源拆解:为什么 SPA 首屏会这么慢?

在动手优化前,先明确核心瓶颈,确保优化方向精准:

  1. 资源体积过大:打包后 app.js 体积臃肿,包含大量未使用的代码(冗余代码);
  2. 脚本阻塞渲染:SPA 需加载完完整 JS 才能渲染首屏,JS 解析 / 执行时间过长导致白屏;
  3. 网络传输低效:未启用 CDN、未开启 Gzip,资源传输耗时久;
  4. 缓存未命中:静态资源未设置合理缓存策略,每次请求都重新下载;
  5. 重复请求 / 资源:多入口重复加载相同 JS/CSS/ 图片资源。

二、核心解决方案:分 5 大维度落地优化

维度 1:减小入口文件体积(最立竿见影)

入口文件(如 app.js)是首屏加载的核心瓶颈,需通过「代码分割、剔除冗余、按需加载」大幅减小体积。

1.1 路由懒加载(必做)

将路由按模块分割,实现「首屏只加载当前页面需要的代码」,Vue2/Vue3 通用配置:

Vue3 配置(src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'

// 路由懒加载:每个路由对应一个独立 chunk
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
Vue2 配置(src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
    }
  ]
})

1.2 UI 框架按需加载(必做)

避免一次性引入完整 UI 框架(如 ElementUI、Ant Design Vue),仅引入使用的组件:

Vue3 (Element Plus 按需加载)
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
// 按需引入 Element Plus 组件
import { ElButton, ElInput } from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
// 注册需要的组件
app.use(ElButton)
app.use(ElInput)
app.mount('#app')
Vue2 (Element UI 按需加载)
// src/main.js
import Vue from 'vue'
import App from './App.vue'
// 按需引入 Element UI 组件
import { Button, Input } from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(Button)
Vue.use(Input)
Vue.config.productionTip = false
new Vue({ render: h => h(App) }).$mount('#app')

1.3 移除冗余代码(Webpack 配置)

vue.config.js 中添加配置,自动剔除未使用的代码(Tree Shaking):

// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      usedExports: true, // 开启 Tree Shaking
      splitChunks: { // 代码分割:提取公共代码
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'chunk-vendors',
            priority: -10
          },
          common: {
            name: 'chunk-common',
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
          }
        }
      }
    }
  }
}

维度 2:静态资源本地缓存(提升二次加载速度)

通过设置浏览器缓存,让用户二次访问时直接读取本地资源,大幅提升加载速度。

2.1 配置 Webpack 输出哈希(必做)

打包时为静态文件添加内容哈希,确保文件更新后浏览器能识别新文件:

// vue.config.js
module.exports = {
  filenameHashing: true, // 开启文件哈希
  outputDir: 'dist',
  assetsDir: 'static'
}

2.2 Nginx 缓存配置(服务端落地)

若使用 Nginx 部署,添加以下配置,设置缓存过期时间:

# 配置静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 30d; # 缓存 30 天
    add_header Cache-Control "public, max-age=2592000";
    add_header ETag ""; # 禁用 ETag(可选)
}

维度 3:图片资源压缩与优化(减少网络请求耗时)

图片是首屏资源体积的主要贡献者,需通过压缩、懒加载、CDN 优化。

3.1 图片压缩(构建时自动压缩)

使用 image-webpack-loader 压缩图片:

npm install image-webpack-loader --save-dev

配置 vue.config.js

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('images')
      .use('image-webpack-loader')
      .loader('image-webpack-loader')
      .options({
        mozjpeg: { progressive: true, quality: 65 }, // 压缩 JPG
        optipng: { enabled: false }, // 压缩 PNG
        pngquant: { quality: [0.6, 0.8] } // 压缩 PNG
      })
  }
}

3.2 图片懒加载(仅加载可视区域图片)

使用 Vue 官方插件 vue-lazyload

npm install vue-lazyload --save
// src/main.js
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'

Vue.use(VueLazyload, {
  loading: require('@/assets/images/loading.png'), // 加载中占位图
  error: require('@/assets/images/error.png') // 加载失败占位图
})

组件中使用

<template>
  <img v-lazy="imageUrl" alt="懒加载图片" />
</template>

维度 4:开启 Gzip 压缩(大幅减小传输体积)

Gzip 压缩可将 JS/CSS 体积压缩 60%-80%,是提升首屏速度的关键服务端配置。

4.1 Nginx 开启 Gzip(必做)

# 开启 Gzip
gzip on;
# 压缩文件类型
gzip_types text/plain text/css application/javascript application/json application/xml application/rss+xml text/xml text/javascript image/svg+xml;
# 压缩级别(1-9,数值越高压缩率越高,消耗 CPU 越多)
gzip_comp_level 6;
# 仅压缩大于 1k 的文件
gzip_min_length 1024;
# 压缩响应头
gzip_vary on;

4.2 前端构建时生成 Gzip 文件

// vue.config.js
const CompressionWebpackPlugin = require('compression-webpack-plugin')

module.exports = {
  configureWebpack: {
    plugins: [
      new CompressionWebpackPlugin({
        algorithm: 'gzip', // 压缩算法
        test: /\.(js|css|json|svg)$/, // 压缩哪些文件
        threshold: 10240, // 仅压缩大于 10k 的文件
        minRatio: 0.8 // 压缩率小于 0.8 才压缩
      })
    ]
  }
}

维度 5:解决脚本阻塞渲染(让首屏快速显示)

PA 中 JS 加载 / 解析 / 执行会阻塞 DOM 渲染,需通过「预加载、 defer、异步加载」解决。

5.1 关键 CSS 内联(首屏样式直接写入 HTML)

将首屏核心 CSS 内联到 index.html,避免外部 CSS 阻塞渲染:

<!-- public/index.html -->
<head>
  <!-- 内联首屏核心样式 -->
  <style>
    #app { height: 100%; }
    .loading { display: flex; justify-content: center; align-items: center; height: 100%; }
  </style>
</head>

5.2 非关键脚本异步加载

main.js 中,将非核心初始化逻辑(如埋点、第三方统计)异步加载:

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

// 核心初始化逻辑
const app = createApp(App)
app.use(router)
app.mount('#app')

// 非核心逻辑:异步加载(使用 setTimeout 或 import())
setTimeout(() => {
  import('./utils/analytics') // 埋点统计
  import('./utils/third-party') // 第三方 SDK
}, 1000)

总结

SPA 首屏加载优化是工程化 + 服务端 + 前端的协同工作,核心落地步骤如下:

  1. 体积优化:路由懒加载 + UI 按需加载 + 代码分割;
  2. 网络优化:Gzip 压缩 + CDN 加速 + 图片压缩;
  3. 渲染优化:关键 CSS 内联 + 非核心脚本异步;
  4. 缓存优化:文件哈希 + 浏览器缓存;

本文提供的方案完全可落地,从 Webpack 配置到 Nginx 再到前端代码,每一步都有具体可复制的代码,适配 Vue2/Vue3 生态,已在多个生产项目中验证,能彻底解决 SPA 首屏加载慢的问题。

新手引导 intro.js 的使用

1 依赖引入

npm install --save intro.js

2 intro.js的使用

vue3 为例

<template>
  <div id="step1">...</div>
  <div id="step2">...</div>
  <div id="step3">...</div>
</template>

<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
// 引入intro.js相关依赖
import introJs from 'intro.js'
import 'intro.js/introjs.css'

const intro = introJs() // 申明引导

onMounted(() => {
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
})

onBeforeUnmount(() => {
  intro?.exit() // 销毁监听
})
</script>

3 再次唤起引导

引导关闭后发现无法通过 intro.start() 方法再次唤起,因此需要销毁重建。

function openIntro() { // 打开引导方法,可绑定在 “新手引导” 按钮上重复触发
  intro.onExit(() => { // 引导关闭钩子,每次关闭都重新创建引导
    setTimeout(() => { // 手动异步
      intro?.exit() // 销毁
      intro = introJs() // 重构
    }, 10)
  })
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
}

4 集成为公共组件使用

4.1 在 Vue3 作为 hook 使用

import { ref, onBeforeUnmount } from 'vue'
import introJs from 'intro.js'
import 'intro.js/introjs.css'

export function useIntro() {
  const intro = ref(introJs())

  function openIntroWithOptions(options = { steps: [] }) {
      intro.value.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro.value?.exit() // 销毁
          intro.value = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.value.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.value.start()
  }

  onBeforeUnmount(() => {
    intro.value?.exit()
  })

  return {
    intro,
    openIntroWithOptions
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入 useIntro
 * 2. 声明方法
 * 3. 编写引导打开方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 onMounted 中执行 openIntro 方法内容 */
// import { useIntro } from '@/hooks/intro' // 1.引入
// const { openIntroWithOptions } = useIntro() // 2.声明 
// function openIntro() { // 3.引导打开方法
//   openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

4.2 在 Vue2 作为 mixin 使用

// 引导器mixins
import introJs from 'intro.js'
import 'intro.js/introjs.css'

let intro = introJs()

export default {
  beforeDestroy() {
    intro?.exit() // 销毁监听
  },
  methods: {
    openIntroWithOptions(options = { steps: [] }) { // 打开引导
      intro.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro?.exit() // 销毁
          intro = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.start()
    }
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入
 * 2. 申明mixins
 * 3. 在 methods 中写入以下方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 mounted 中执行 openIntro 方法内容 */
// import intro from '@/mixins/intro' // 1. 引入
// mixins: [intro] // 2. 申明
// openIntro() { // 3. 调用方法
//   this.openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

笔记主要为自用,欢迎友好交流!

symbol为什么说是为了解决全局变量冲突的问题

首先,先说结论,即Symbol 代表创建后独一无二且不可变的数据类型,它主要是为了 解决可能出现的全局变量冲突的问题。

这是一个关于 JavaScript 模块化历史的设计问题,下面将为你解释 Symbol 与全局变量冲突问题的关系。

Symbol 解决全局变量冲突问题的核心机制

1. 传统字符串键的冲突问题

在 Symbol 出现之前,JavaScript 对象的属性名只能是字符串。这导致了严重的命名冲突问题,尤其是在以下场景:

// 场景1:第三方库扩展原生对象(旧时代的做法)
// 库A添加了一个方法
Array.prototype.filter = function() { /* 库A的实现 */ };

// 库B也添加了一个同名方法
Array.prototype.filter = function() { /* 库B的实现 */ };

// 库A的实现被覆盖了!这就是冲突
// 场景2:元编程中的属性名冲突
const obj = {
  name: '真实数据',
  // 但如果我想存储一些"元信息"(比如缓存、内部状态)
  // 用 'name' 作为键?不行,会覆盖真实数据
  // 用 '_name'?还是可能冲突
  // 用 '__internal_name_2024__'?丑陋且仍不保险
};

2. Symbol 的解决方案:唯一性保证

Symbol 创建的每个值都是全局唯一的,即使描述相同:

const sym1 = Symbol('key');
const sym2 = Symbol('key');

console.log(sym1 === sym2); // false!完全不同的两个标识符

// 这意味着你可以安全地创建"不会冲突"的属性键
const obj = {
  name: '真实数据',
  [Symbol('metadata')]: '内部元数据',  // 绝对不会与 'name' 冲突
  [Symbol('metadata')]: '更多元数据',  // 甚至不会与上面的 Symbol 冲突!
};

3. 实际应用场景

场景 A:Well-Known Symbols(避免标准方法冲突)

// ES6 用 Symbol 定义迭代协议,而不是字符串 'iterator'
// 这样不会与旧代码中可能存在的 'iterator' 属性冲突
const myObj = {
  [Symbol.iterator]: function* () {
    yield 1; yield 2; yield 3;
  }
};
// 即使有人写了 myObj.iterator = 'something',也不会破坏 for...of 循环

场景 B:私有属性的模拟(模块级隔离)

// module.js - 创建一个模块私有的 Symbol
const privateKey = Symbol('private');  // 不导出,外部无法访问

export class MyClass {
  constructor() {
    this[privateKey] = '真正的私有数据';
  }
  
  getPrivateData() {
    return this[privateKey];
  }
}

// 外部代码即使拿到实例,也无法轻易访问 privateKey
// 因为拿不到这个 Symbol 引用

场景 C:框架/库的内部状态标记

// React 内部使用 Symbol 标记特殊元素(简化示意)
const REACT_ELEMENT_TYPE = Symbol.for('react.element');

function createElement(type, props) {
  return {
    $$typeof: REACT_ELEMENT_TYPE,  // 确保是 React 创建的元素,而非恶意 JSON
    type, props
  };
}

// 使用 Symbol.for 可以在不同 iframe/service worker 间共享
// 但仍是全局唯一的,不会与普通字符串属性冲突

4. Symbol.for() 与全局 Symbol 注册表

// Symbol.for 在全局注册表中创建/获取 Symbol,跨 realm 可用
const globalSym = Symbol.for('app.config');  // 全局唯一

// 在另一个文件中,甚至另一个 iframe 中:
const sameSym = Symbol.for('app.config');

console.log(globalSym === sameSym); // true - 同一个全局标识符

// 这解决了"跨执行上下文共享唯一键"的需求
// 同时仍然避免与任何字符串键冲突

5. 关键特性总结

特性 字符串键 Symbol 键
唯一性 相同字符串即相同键 每个 Symbol 实例唯一
可预测性 容易被猜测/覆盖 引用必须被显式传递
for...in 遍历 ✅ 会被遍历 ❌ 默认不可见(隐藏性)
Object.keys() ✅ 包含 ❌ 不包含
JSON.stringify ✅ 序列化 ❌ 自动忽略

结论

Symbol 解决全局变量冲突的本质是:将命名空间从"全局字符串命名空间"转移到了"全局唯一的值引用空间"

  • 之前:所有代码共享同一个字符串命名空间,命名冲突是概率问题
  • 之后:每个 Symbol 创建时自动获得全局唯一的身份,冲突从概率问题变成了不可能事件(除非显式传递 Symbol 引用)

这使得 JavaScript 终于能够安全地进行元编程(在对象上附加元数据而不污染其正常属性),以及实现真正的模块化私有成员。 所以说,Symbol主要是为了 解决可能出现的全局变量冲突的问题

FE视角下的Referrer全面解析

一、核心概念解析

1.1 什么是Referrer?

  • Referrer(引荐来源)是 HTTP 协议中的一个标准头部字段,用于标识当前请求的来源页面 URL。当用户从页面 A 跳转到页面 B 时,浏览器会在请求页面 B 的 HTTP 头部自动携带 Referer: [A的URL]。

  • 技术特性:

    • 遵循同源策略,跨域时可能被过滤
    • 包含完整URL结构(协议+域名+路径+参数)
    • 前端可通过document.referrer读取

// 获取来源页面示例

console.log('Referrer来源:', document.referrer);

1.2 浏览器差异性

  • Chrome:默认发送完整Referrer
  • Safari:智能跟踪防护可能截断
  • Firefox:支持最新Referrer Policy规范

二、核心应用场景

  • 安全防护:服务器可以根据 Referer 头验证请求来源合法性,防止跨站请求伪造(CSRF)攻击;根据关键操作日志记录进行敏感操作溯源。
  • 日志分析与流量追踪:网站可以通过 Referer 分析流量来源,了解哪些外部页面或广告带来了流量。
  • 内容定向与个性化:根据 Referer 字段判断用户是否通过某个推广链接、广告或推荐页面访问,进而定向展示不同的内容,也可以进行合作伙伴流量区分。

三、策略配置指南

3.1 多层级控制机制

优先级矩阵:

设置方式 优先级 作用范围
标签 当前文档
请求响应头 整个域名
元素级属性a标签 单个元素

3.2 配置示例

HTML全局设置:


<meta name="referrer" content="strict-origin-when-cross-origin">

元素级控制:


<a href="https://external.com" rel="noreferrer">安全跳转</a>

HTTP响应头设置:

add_header Referrer-Policy "no-referrer";

3.3 Fetch API策略


// 禁用Referrer示例

fetch('/api', {

referrer: "",

referrerPolicy: "no-referrer"

});

3.4 referrerPolicy

Referrer Policy是W3C官方提出的一个候选策略,主要用来规范Referrer

配置对照表

同源 跨源 HTTPS→HTTP
"no-referrer" - - -
"no-referrer-when-downgrade"或 ""(默认) 完整的 url 完整的 url -
"origin" 仅域 仅域 仅域
"origin-when-cross-origin" 完整的 url 仅域 仅域
"same-origin" 完整的 url - -
"strict-origin" 仅域 仅域 -
"strict-origin-when-cross-origin" 完整的 url 仅域 -
"unsafe-url" 完整的 url 完整的 url 完整的 url

四、安全风险与应对方案

4.1 典型风险场景

风险类型 案例场景 解决方案
URL参数泄露 密码重置链接token暴露 动态策略调整
管理路径暴露 后台地址出现在第三方日志 Nginx强制策略
GDPR合规风险 用户访问路径记录包含个人数据 数据匿名化处理

4.2 敏感页面保护方案

<script>

// 动态调整策略

if (location.pathname.includes('/admin')) {

const meta = document.createElement('meta');

meta.name = 'referrer';

meta.content = 'no-referrer';

document.head.appendChild(meta);

}

</script>

4.3 数据匿名化处理


function sanitizeReferrer(url) {

const u = new URL(url);

return `${u.origin}${u.pathname}`.replace(/\/user\/\d+/g, '/user/{id}');

}

五、跨浏览器兼容策略

5.2 兼容性处理方案

  • 特性检测:if ('referrerPolicy' in document.createElement('a'))

  • 渐进增强:优先使用标签设置全局策略

  • 服务端兜底:日志系统进行Referrer清洗

// 浏览器特性检测与降级处理

function applyReferrerPolicy() {

const policies = ['strict-origin-when-cross-origin', 'no-referrer-when-downgrade'];

  


if ('document' in globalThis && document.createElement('meta').hasAttribute('referrerpolicy')) {

// 支持新式策略

document.querySelector('meta[name="referrer"]').content = policies[0];

} else {

// 传统浏览器降级处理

window.onclick = (e) => {

if (e.target.tagName === 'A' && isExternalLink(e.target.href)) {

e.target.rel += ' noreferrer';

}

};

}

}

六、最佳实践总结

  1. 最小化原则:采用最严格的策略等级

  2. 动态调整:根据页面敏感程度切换策略

  3. 双重验证:客户端+服务端联合校验


参考文献:www.w3cschool.cn/qoyhx/qoyhx…

扩展阅读:www.w3cschool.cn/qoyhx/qoyhx…

大模型接入踩坑录:被 Unexpected end of JSON 折磨三天,我重写了SSE流解析

兄弟们,我今天必须来吐个大槽。

就在上周,我差点被我们公司的测试和产品经理生吃活剥了。起因是我们内部刚上的一个 AI 对话助手,在生产环境里表现得像个神经病:时而正常回复,时而突然卡死,有时候甚至直接抛出整个前端页面的白屏大散花。

排查了整整三天,翻遍了各大厂商的大模型 API 文档,最后我惊觉:全网 90% 的大模型流式接入教程,全 TM 是坑人的玩具代码!

踩坑现场:天真的 JSON.parse

大家接入大模型流式输出(SSE)的时候,是不是都看过官方文档里类似这样的伪代码范例?

code JavaScript

//典型的“教程级”作死代码
const response = await fetch('https://api.some-llm.com/chat', { ... });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  
  // 直接把读到的流转成字符串,然后按行切分
  const chunk = decoder.decode(value);
  const lines = chunk.split('\n');
  
  for (let line of lines) {
    if (line.startsWith('data: ')) {
      const dataStr = line.replace('data: ', '');
      if (dataStr === '[DONE]') return;
      
      // 致命毒药就在这一行!!!
      const parsed = JSON.parse(dataStr); 
      console.log(parsed.choices[0].delta.content);
    }
  }
}

 

 

这段代码在本地自己测试、网络极好的时候,跑得那叫一个丝滑。

但在真实的生产环境里,这段代码就是个纯纯的定时炸弹! 为什么?因为这帮写文档的人,根本没考虑过底层 TCP 协议的网络分包机制(Chunk Fragmentation)!

抓包破案:TCP 根本不管你的 JSON 死活

当你以为大模型吐出来的数据是完美的一行:

data: {"choices": [{"delta": {"content": "你好"}}]}\n\n

现实中,由于网络波动、Nginx 代理缓冲、或者纯粹是因为模型吐字太快/太慢,这条数据在 TCP 传输时经常会被无情地“拦腰斩断”,变成两个数据包(Chunk)发给前端:

● Chunk 1 收到: data: {"choices":[{"de

● Chunk 2 收到: lta": {"content": "你好"}}]}\n\n

你看懂了吗?!当你的前端代码拿到 Chunk 1 时,直接无脑执行了 JSON.parse('{"choices":[{"de')。

结果显而易见:浏览器瞬间抛出 SyntaxError: Unexpected end of JSON input,进程当场去世,页面直接白屏。

jimeng-2026-02-27-3051-Excalidraw手绘风格的TCP拆包原理图,左边画大模型吐出完整的{"con....png

这还不算完!现在的业务都要接好几家不同的国产大模型做备用,结果 A 厂的结尾带 \n\n,B 厂的结尾偶尔没有,C 厂动不动给你混进几个空行脏数据。我这三天,光在前端写正则去给各家擦屁股了,血压直接拉满。

终极解法:手写 Robust Buffer Parser

既然不能相信每次 read() 拿到的都是完整的 JSON 数据,我们就必须自己在内存里维护一个 数据缓冲区(Buffer)。只有当明确读到双换行符(SSE协议的标准消息结束符)时,才去进行截取和解析。

为了防止大家再被这些垃圾文档坑,我把我熬夜重写的、已经在生产跑稳的健壮版代码贴出来。大家直接抄走,免得再被祭天:

code JavaScript

//生产环境防御性编程:带 Buffer 的 SSE 解析器
async function fetchAIStream() {
  // 避坑备注:如果前端实在受不了各家厂商乱七八糟的格式断流和脏数据,
  // 建议直接去干后端,让他们在网关层做统一的聚合代理。
  // 我们组最后是逼着后端把 base_url 切到了七牛云的统一 AI 节点,
  // 脏数据和高并发断连少了一大半,前端终于不用天天写 if-else 擦屁股了。
  const BASE_URL = process.env.USE_PROXY_GATEWAY 
    ? "https://api.qiniu.com/v1/llm/chat/completions" 
    : "https://api.openai-xxx.com/...";
 
  const response = await fetch(BASE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
  });
 
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  
  // 核心:弄一个全局的缓冲区!
  let buffer = '';
 
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
 
      // 每次读到的数据,先塞进 buffer 里
      buffer += decoder.decode(value, { stream: true });
 
      // 只有遇到完整的 SSE 消息分隔符 (\n\n) 才进行处理
      let splitIndex;
      while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
        // 截取完整的一条消息
        const completeMessage = buffer.slice(0, splitIndex);
        // 把处理过的消息从 buffer 中剔除,保留剩下的断字
        buffer = buffer.slice(splitIndex + 2);
 
        // 处理截取出的完整消息
        const lines = completeMessage.split('\n');
        for (const line of lines) {
          if (line.trim() === '') continue;
          if (line.startsWith('data: ')) {
            const dataStr = line.replace('data: ', '').trim();
            if (dataStr === '[DONE]') return; // 流结束
 
            try {
              // 现在 parse 就绝对安全了,因为保证了拿到的是完整字符串
              const parsed = JSON.parse(dataStr);
              const content = parsed.choices[0]?.delta?.content || '';
              process.stdout.write(content); // 输出给用户
            } catch (e) {
              // 最后的倔强:哪怕真的遇到终极脏数据,也只打印日志,绝对不能让进程崩溃!
              console.error('[Stream Parse Error] 脏数据跳过:', dataStr);
            }
          }
        }
      }
    }
  } catch (err) {
    console.error('网络连接被意外中断:', err);
  }
}

jimeng-2026-02-27-8477-经典程序员Meme图,一只柴犬一脸疑惑地看着电脑,配文“我的代码昨天还能跑”,风....png

 

总结

其实说到底,这属于网络 I/O 极其基础的知识点(流式数据不等于块数据)。但现在网上的 AI 教程为了演示效果,全都刻意简化了异常处理,导致无数像我一样的业务搬砖工在生产环境里摔得头破血流。

大家下次接大模型流式接口,千万记得带上 Buffer 缓冲区!周末了,老子终于可以不看那恶心的 SyntaxError 了,祝各位同行永无 Bug!

5 个让 CSS 起飞的新特性,设计师看了直呼内行

有大佬说: "CSS 而已,能玩出什么花?"

今天我就用 5 个原生 CSS 新特性告诉你——现在的 CSS,已经不是当年的 CSS 了。它不再是那个只会改背景颜色的"样式表",而是进化成了能处理逻辑、响应状态、甚至做动画的系统级设计工具

设计师想在 Figma 里做的效果,CSS 现在不仅能做,而且做得更好。往下看,每一个都能让你删掉一坨 JavaScript 代码。


1. Scroll State Queries:终于知道"粘性元素"什么时候粘住了

以前我们想给 sticky 导航栏加个阴影,怎么做?监听 scroll 事件,计算滚动距离,判断元素是否"粘住"……一堆性能杀手代码

现在?一行 CSS 搞定

css

.sticky-nav {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

.sticky-nav > nav {
  transition: box-shadow 0.3s;
  
  /* 只有当元素真正"粘住"时,才加阴影 */
  @container scroll-state(stuck: top) {
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  }
}

这意味着什么?

  • 不用写 Intersection Observer
  • 不用监听 scroll 事件
  • 浏览器原生告诉你"我粘住了"

这个 API 还能检测"是否被滚动捕捉"、"是否可滚动"等状态。Snap 轮播图的激活态?一行代码的事

设计师惊呼:  "终于不用跟开发解释'当导航栏粘住时加阴影'是什么意思了。"


2. 完全自定义的 Select 下拉框:UI 库的末日

有个笑话:前端开发一辈子都在跟 select 标签较劲。为了让它长得好看,我们引过 Chosen、Select2、React Select……一个下拉框,几百 KB 的 JS

现在,原生 select 终于可以随便改了

css

/* 开启可自定义模式 */
select, ::picker(select) {
  appearance: base-select;
}

/* 选项里甚至可以放图片 */
option {
  display: flex;
  align-items: center;
  gap: 8px;
}

option img {
  width: 24px;
  height: 24px;
  border-radius: 50%;
}

对应的 HTML 长这样:

html

<select>
  <button>
    <selectedcontent></selectedcontent>
    <span class="arrow">👇</span>
  </button>
  <option>
    <img src="avatar1.jpg"> 张三
  </option>
  <option>
    <img src="avatar2.jpg"> 李四
  </option>
</select>

这是什么概念?

  • 下拉箭头可以随便改
  • 选项里可以放任何 HTML
  • 选中的内容可以自定义渲染
  • 完全不需要 JavaScript

设计师惊呼:  "所以以后 Figma 里的下拉框设计,都能 1:1 还原了?"


3. @starting-style:弹窗进出动画,终于丝滑了

以前做弹窗动画有个痛点:元素从 display: none 到显示,过渡效果不生效。因为没有"之前的状态"可以过渡。

@starting-style 专门解决这个问题

css

[popover] {
  /* 默认状态 */
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 0.3s, transform 0.3s;
}

[popover]:popover-open {
  /* 打开后的状态 */
  opacity: 1;
  transform: scale(1);
}

/* 定义"开始动画前的状态" */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.9);
  }
}

就这么简单,弹窗出现时自动从 0 到 1,关闭时自动从 1 到 0。连 backdrop(背景遮罩)都可以一起动画

这意味着什么?

  • 再也不用 JS 控制入场动画
  • display: none 和 display: block 之间的过渡终于完美
  • Popover 和 Dialog 弹窗,天生就有丝滑动画

设计师惊呼:  "所以之前开发说的'弹窗动画不好做',是骗我的?"


4. contrast-color() 函数:自动适配文本颜色,再也不用写 JS 判断

设计师给了一个按钮,背景色是动态的(可能来自用户设置,可能来自数据)。问题来了:背景色深的时候,文字要用白色;背景色浅的时候,文字要用黑色

以前怎么做?JS 计算亮度,然后动态加 class。现在:

css

.button {
  --bg-color: #0066cc;  /* 可以是任何颜色 */
  background-color: var(--bg-color);
  
  /* 自动选择黑色或白色,保证可读性 */
  color: contrast-color(var(--bg-color));
}

contrast-color() 函数自动计算最佳对比色(黑或白),保证 WCAG 标准

更高级的用法:

css

.button {
  /* 指定两个候选色,让函数选择对比度更高的那个 */
  color: contrast-color(var(--bg-color), vs, #333, #eee);
}

这意味着什么?

  • 主题切换再也不用写两套文字颜色
  • 用户自定义主题时,样式自动适配
  • 再也不用为了文字可读性写 JS

设计师惊呼:  "所以以后设计系统里的文本颜色,可以自动适配背景了?"


5. Scroll-driven Animations:滚动即动画,性能炸裂

以前做滚动进度条、视差效果、滚动触发动画,都得靠 JS + requestAnimationFrame,性能消耗大,而且容易卡顿。

现在,CSS 原生支持动画进度绑定滚动位置

css

/* 一个简单的滚动进度条 */
#progress {
  height: 4px;
  background: #0066cc;
  
  /* 动画进度绑定滚动位置 */
  animation: grow-progress linear forwards;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  from { width: 0%; }
  to { width: 100%; }
}

想要更复杂的视差效果?

css

.parallax-image {
  /* 滚动时,图片从 0.5 倍缩放到 1 倍 */
  animation: scale-image linear forwards;
  animation-timeline: scroll();
  animation-range: entry 0% exit 100%;
}

@keyframes scale-image {
  from { transform: scale(0.5); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

这意味着什么?

  • 滚动进度条:3 行 CSS
  • 视差滚动:5 行 CSS
  • 元素随滚动淡入淡出:4 行 CSS
  • 完全不需要 JS,60fps 稳稳的

设计师惊呼:  "所以之前做的那个滚动交互动效,现在不用等开发排期了?"


写在最后

这 5 个特性只是冰山一角。现在的 CSS 已经有了:

  • 条件逻辑:if() 函数
  • 自定义函数:@function
  • 锚点定位:真正的绝对定位
  • 容器查询:组件内响应式
  • 嵌套语法:再也不用写重复的选择器

CSS 已经不是当初那个 CSS 了。

以前我们说"能用 CSS 解决的问题,就不要用 JS"。现在可以改成: "能用 CSS 解决的问题,都不叫问题。"

设计师和开发的鸿沟,正在被现代 CSS 一点点填平。你设计的每一个细节,现在都能用几行样式代码完美还原。


如果这篇文章让你对 CSS 刮目相看,点个赞,转个发,让更多朋友看到——CSS 起飞了。

评论区告诉我:你最想用哪个特性?或者你还见过哪些让你惊呼的 CSS 新功能?

JS 异步编程实战 | 从回调地狱到 Promise/Async/Await(附代码 + 面试题)

一、为什么需要异步编程?

JavaScript 是单线程语言,同一时间只能做一件事。如果有耗时操作(如网络请求、文件读取、定时任务),就会阻塞后续代码执行。

// 同步阻塞示例 
console.log('开始')
for(let i = 0; i < 1000000000; i++) {}
// 耗时操作 console.log('结束') 
// 必须等待循环结束才执行

为了解决这个问题,JavaScript 提供了异步编程解决方案。

二、回调函数(Callback)—— 最基础的异步方案

2.1 基本概念

回调函数是将函数作为参数传递给另一个函数,在异步操作完成后调用。

// 模拟异步请求
function fetchData(callback) {
  setTimeout(() => {
    callback('数据加载完成')
  }, 1000)
}

console.log('开始请求')
fetchData((data) => {
  console.log(data) // 1秒后输出:数据加载完成
})
console.log('继续执行其他操作')
// 输出顺序:开始请求 → 继续执行其他操作 → 数据加载完成

2.2 回调地狱的产生

当有多个依赖的异步操作时,回调嵌套会形成"回调地狱":

// 回调地狱示例
getUserInfo(function(user) {
  getOrderList(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      getProductInfo(detail.productId, function(product) {
        console.log('最终数据:', product)
      }, function(error) {
        console.error('获取商品失败', error)
      })
    }, function(error) {
      console.error('获取订单详情失败', error)
    })
  }, function(error) {
    console.error('获取订单列表失败', error)
  })
}, function(error) {
  console.error('获取用户失败', error)
})

回调地狱的问题:

  • 代码难以阅读和维护
  • 错误处理分散
  • 难以复用和调试

三、Promise —— 优雅的异步解决方案

3.1 Promise 基本用法

Promise 是 ES6 引入的异步编程解决方案,它代表一个异步操作的最终完成或失败。

// 创建 Promise
const promise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('操作成功') // 成功时调用
    } else {
      reject('操作失败') // 失败时调用
    }
  }, 1000)
})

// 使用 Promise
promise
  .then(result => {
    console.log(result) // 成功:操作成功
  })
  .catch(error => {
    console.error(error) // 失败:操作失败
  })
  .finally(() => {
    console.log('无论成功失败都会执行')
  })

3.2 解决回调地狱

使用 Promise 重构上面的例子:

// 将每个异步操作封装成 Promise
function getUserInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, name: '张三' })
    }, 1000)
  })
}

function getOrderList(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([{ id: 101, name: '订单1' }, { id: 102, name: '订单2' }])
    }, 1000)
  })
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: orderId, productId: 1001, price: 299 })
    }, 1000)
  })
}

function getProductInfo(productId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: productId, name: '商品名称', price: 299 })
    }, 1000)
  })
}

// 链式调用,告别回调地狱
getUserInfo()
  .then(user => {
    console.log('用户:', user)
    return getOrderList(user.id)
  })
  .then(orders => {
    console.log('订单列表:', orders)
    return getOrderDetail(orders[0].id)
  })
  .then(detail => {
    console.log('订单详情:', detail)
    return getProductInfo(detail.productId)
  })
  .then(product => {
    console.log('商品信息:', product)
  })
  .catch(error => {
    console.error('发生错误:', error)
  })

3.3 Promise 静态方法

// Promise.all - 等待所有 Promise 完成
const p1 = Promise.resolve(3)
const p2 = 42
const p3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo'))

Promise.all([p1, p2, p3]).then(values => {
  console.log(values) // [3, 42, "foo"]
})

// Promise.race - 返回最先完成的 Promise
const promise1 = new Promise(resolve => setTimeout(resolve, 500, 'one'))
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'two'))

Promise.race([promise1, promise2]).then(value => {
  console.log(value) // "two" (因为 promise2 更快)
})

// Promise.allSettled - 等待所有 Promise 完成(无论成功失败)
const promises = [
  Promise.resolve('成功1'),
  Promise.reject('失败2'),
  Promise.resolve('成功3')
]

Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

// Promise.any - 返回第一个成功的 Promise
const pErr = new Promise((resolve, reject) => reject('总是失败'))
const pSlow = new Promise(resolve => setTimeout(resolve, 500, '最终完成'))
const pFast = new Promise(resolve => setTimeout(resolve, 100, '很快完成'))

Promise.any([pErr, pSlow, pFast]).then(value => {
  console.log(value) // "很快完成"
})

四、Async/Await —— 同步方式的异步编程

4.1 基本语法

Async/Await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码。

// async 函数返回一个 Promise
async function getData() {
  return '数据'
}

getData().then(result => console.log(result)) // 数据

// 使用 await 等待 Promise 完成
async function fetchUserData() {
  try {
    const user = await getUserInfo()
    console.log('用户:', user)
    
    const orders = await getOrderList(user.id)
    console.log('订单:', orders)
    
    const detail = await getOrderDetail(orders[0].id)
    console.log('详情:', detail)
    
    const product = await getProductInfo(detail.productId)
    console.log('商品:', product)
    
    return product
  } catch (error) {
    console.error('出错了:', error)
  }
}

// 调用 async 函数
fetchUserData().then(result => {
  console.log('最终结果:', result)
})

4.2 实战示例:模拟数据请求

// 模拟 API 请求函数
const mockAPI = (url, delay = 1000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.1) { // 90% 成功率
        resolve({
          status: 200,
          data: { url, timestamp: Date.now() }
        })
      } else {
        reject(new Error(`请求 ${url} 失败`))
      }
    }, delay)
  })
}

// 使用 async/await 实现并发请求
async function fetchMultipleData() {
  try {
    // 并发请求
    const [userData, productData, orderData] = await Promise.all([
      mockAPI('/api/user', 800),
      mockAPI('/api/product', 1200),
      mockAPI('/api/order', 600)
    ])
    
    console.log('所有数据加载完成:')
    console.log('用户数据:', userData.data)
    console.log('商品数据:', productData.data)
    console.log('订单数据:', orderData.data)
    
    return { userData, productData, orderData }
  } catch (error) {
    console.error('数据加载失败:', error.message)
  }
}

// 串行请求(依赖关系)
async function fetchDependentData() {
  console.time('串行请求耗时')
  
  const user = await mockAPI('/api/user', 1000)
  console.log('第一步完成:', user.data)
  
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  console.log('第二步完成:', orders.data)
  
  const details = await mockAPI(`/api/orders/${orders.data.url}/details`, 1000)
  console.log('第三步完成:', details.data)
  
  console.timeEnd('串行请求耗时')
  // 总耗时约 3000ms
}

// 优化:并行处理不依赖的数据
async function fetchOptimizedData() {
  console.time('优化后耗时')
  
  // 同时发起两个独立请求
  const [user, products] = await Promise.all([
    mockAPI('/api/user', 1000),
    mockAPI('/api/products', 1000)
  ])
  
  console.log('用户和商品数据已获取')
  
  // 依赖用户数据的请求
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  
  // 可以并行处理的请求
  const [detail1, detail2] = await Promise.all([
    mockAPI(`/api/orders/${orders.data.url}/detail1`, 500),
    mockAPI(`/api/orders/${orders.data.url}/detail2`, 500)
  ])
  
  console.timeEnd('优化后耗时')
  // 总耗时约 2500ms
}

4.3 错误处理最佳实践

// 统一的错误处理函数
const handleAsyncError = (asyncFn) => {
  return async (...args) => {
    try {
      return [await asyncFn(...args), null]
    } catch (error) {
      return [null, error]
    }
  }
}

// 使用错误处理包装器
const safeFetchUser = handleAsyncError(fetchUserData)

async function main() {
  const [user, error] = await safeFetchUser()
  
  if (error) {
    console.error('操作失败:', error.message)
    return
  }
  
  console.log('操作成功:', user)
}

// 带超时的 Promise
function withTimeout(promise, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout)
  })
  
  return Promise.race([promise, timeoutPromise])
}

async function fetchWithTimeout() {
  try {
    const result = await withTimeout(mockAPI('/api/data', 3000), 2000)
    console.log('数据:', result)
  } catch (error) {
    console.error('超时或失败:', error.message)
  }
}

五、手写实现(面试高频)

5.1 手写 Promise

class MyPromise {
  constructor(executor) {
    this.state = 'pending'
    this.value = undefined
    this.reason = undefined
    this.onFulfilledCallbacks = []
    this.onRejectedCallbacks = []

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled'
        this.value = value
        this.onFulfilledCallbacks.forEach(fn => fn())
      }
    }

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected'
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error }

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })
      }
    })

    return promise2
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      reject(new TypeError('Chaining cycle detected'))
    }

    if (x && (typeof x === 'object' || typeof x === 'function')) {
      let called = false
      try {
        const then = x.then
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return
              called = true
              this.resolvePromise(promise2, y, resolve, reject)
            },
            error => {
              if (called) return
              called = true
              reject(error)
            }
          )
        } else {
          resolve(x)
        }
      } catch (error) {
        if (called) return
        called = true
        reject(error)
      }
    } else {
      resolve(x)
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const result = []
      let count = 0
      
      for (let i = 0; i < promises.length; i++) {
        MyPromise.resolve(promises[i]).then(
          value => {
            result[i] = value
            count++
            if (count === promises.length) resolve(result)
          },
          reject
        )
      }
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      for (const promise of promises) {
        MyPromise.resolve(promise).then(resolve, reject)
      }
    })
  }
}

5.2 手写 async/await 的简单实现

// 使用 Generator 模拟 async/await
function asyncToGenerator(generatorFn) {
  return function() {
    const gen = generatorFn.apply(this, arguments)
    
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result
        try {
          result = gen[key](arg)
        } catch (error) {
          reject(error)
          return
        }
        
        const { value, done } = result
        
        if (done) {
          resolve(value)
        } else {
          Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      
      step('next')
    })
  }
}

// 使用示例
const fetchData = function() {
  return new Promise(resolve => {
    setTimeout(() => resolve('数据'), 1000)
  })
}

const getData = asyncToGenerator(function* () {
  const data1 = yield fetchData()
  console.log('data1:', data1)
  
  const data2 = yield fetchData()
  console.log('data2:', data2)
  
  return '完成'
})

getData().then(result => console.log(result))

六、面试高频题

6.1 输出顺序题

// 题目1
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

// 输出:1, 4, 3, 2
// 解释:同步代码先执行,微任务(Promise)先于宏任务(setTimeout)

// 题目2
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

6.2 错误处理题

// 题目:如何捕获 async/await 的错误?
async function getData() {
  try {
    const data = await Promise.reject('出错了')
    console.log(data)
  } catch (error) {
    console.log('捕获到:', error)
  }
}

// 或使用 .catch
async function getData2() {
  const data = await Promise.reject('出错了').catch(err => {
    console.log('处理错误:', err)
    return '默认值'
  })
  console.log(data) // 默认值
}

// 题目:Promise.all 的错误处理
const promises = [
  Promise.resolve(1),
  Promise.reject('错误'),
  Promise.resolve(3)
]

Promise.all(promises)
  .then(console.log)
  .catch(console.error) // 输出:错误

// 如何让 Promise.all 即使有错误也返回所有结果?
Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

6.3 并发控制题

// 题目:实现一个并发控制器,限制同时执行的 Promise 数量
class PromiseQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }
  
  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.run()
    })
  }
  
  run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      
      Promise.resolve(task())
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this.run()
        })
    }
  }
}

// 使用示例
const queue = new PromiseQueue(2)

for (let i = 0; i < 5; i++) {
  queue.add(() => 
    new Promise(resolve => {
      setTimeout(() => {
        console.log(`任务${i}完成`)
        resolve(i)
      }, 1000)
    })
  )
}
// 每2个任务并行执行

6.4 重试机制题

// 题目:实现一个函数,请求失败时自动重试
async function retryRequest(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      console.log(`第${i + 1}次尝试`)
      const result = await fn()
      console.log('请求成功')
      return result
    } catch (error) {
      console.log(`第${i + 1}次失败`)
      if (i === maxRetries - 1) {
        throw error
      }
      // 等待延迟时间后重试
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

// 使用示例
let attempt = 0
const request = () => {
  return new Promise((resolve, reject) => {
    attempt++
    if (attempt < 3) {
      reject('模拟失败')
    } else {
      resolve('成功')
    }
  })
}

retryRequest(request, 3, 1000)
  .then(console.log)
  .catch(console.error)

七、总结与建议

7.1 异步编程演进

  • 回调函数:基础但容易形成"回调地狱"
  • Promise:链式调用,错误统一处理
  • Async/Await:语法糖,代码更直观

7.2 使用建议

  1. 优先使用 async/await,代码更清晰
  2. 并发请求使用 Promise.all,提高性能
  3. 注意错误处理,不要吞掉错误
  4. 避免回调地狱,及时重构代码
  5. 理解事件循环,掌握执行顺序

7.3 面试准备

  • 掌握三种异步方案的原理和用法
  • 能够手写简单的 Promise
  • 理解宏任务和微任务的执行顺序
  • 熟悉常见的异步编程场景和解决方案
  • 能够处理并发控制和错误重试

异步编程是 JavaScript 的核心特性,掌握好这块内容不仅对面试有帮助,更能提升实际开发中的代码质量。

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

Vue3+Element Plus 通用表格组件封装与使用实践

在中后台项目开发中,表格是高频使用的核心组件,基于 Element Plus 的el-table封装通用表格组件,能够统一表格样式、简化重复代码、提升开发效率。本文将详细讲解一款通用表格组件的封装思路、完整实现及使用方式,该组件兼顾了通用性与灵活性,适配日常开发中的各类表格场景。

一、封装思路

本次封装的核心目标是打造一款「基础能力通用化、个性化配置灵活化」的表格组件:

  1. 抽离表格通用配置(如高度、高亮行、合并单元格方法)作为基础 Props;
  2. el-tableel-pagination的原生属性 / 事件通过透传方式交给父组件控制,保留原生组件的灵活性;
  3. 统一列渲染逻辑,支持自定义render函数实现复杂单元格内容展示;
  4. 整合表格标题、分页等常用元素,形成完整的表格模块。

二、通用表格组件完整实现(MineTable.vue)

<template>
    <el-card class="mine-table">
        <!-- 表格标题 -->
        <el-text class="table-name">{{ tableName }}</el-text>
        <!-- 核心表格容器 -->
        <el-table 
            ref="elTable" 
            class="base-table" 
            :highlight-current-row="currentRow" 
            :preserve-expanded-content="true" 
            :span-method="spanMethod"
            :data="data" 
            :height="height"
            v-bind="tableProps"   <!-- 透传el-table原生属性 -->
            v-on="tableEvents"    <!-- 透传el-table原生事件 -->
        >
            <el-table-column 
                v-for="(item, index) in columnsData" 
                :key="index" 
                v-bind="item"      <!-- 透传列配置属性 -->
            >
                <!-- 展开列自定义渲染 -->
                <template v-if="item.type === 'expand'" #default="scope">
                    <component :is="item.render" v-bind="scope"></component>
                </template>
            </el-table-column>
        </el-table>

        <!-- 分页组件 -->
        <el-pagination 
            class="base-pagination" 
            layout="total, sizes, prev, pager, next, jumper"
            :page-sizes="[5, 10, 20, 30, 40, 50]" 
            background
            v-bind="paginationProps"  <!-- 透传el-pagination原生属性 -->
            v-on="paginationEvents"   <!-- 透传el-pagination原生事件 -->
        />
    </el-card>
</template>

<script setup>
import { computed, ref } from "vue"

// 关闭默认属性透传,避免属性泄露到外层DOM节点
defineOptions({
    inheritAttrs: false
})

// 定义组件Props
const props = defineProps({
    // 表格基础配置
    tableName: { type: String, default: "", description: "表格标题" },
    currentRow: { type: Boolean, default: false, description: "是否高亮当前行" },
    height: { type: String, default: "60vh", description: "表格高度" },
    data: { type: Array, default: () => [], description: "表格数据源" },
    columns: { type: Array, default: () => [], description: "列配置项" },
    spanMethod: { type: Function, default: () => {}, description: "单元格合并方法" },
    
    // el-table原生属性透传(支持所有el-table属性)
    tableProps: { type: Object, default: () => ({}) },
    // el-table原生事件透传(支持所有el-table事件)
    tableEvents: { type: Object, default: () => ({}) },
    
    // el-pagination原生属性透传(支持所有el-pagination属性)
    paginationProps: { type: Object, default: () => ({}) },
    // el-pagination原生事件透传(支持所有el-pagination事件)
    paginationEvents: { type: Object, default: () => ({}) },
})

// 暴露表格Ref,方便父组件调用el-table的原生方法
const elTable = ref(null)
defineExpose({ elTable })

// 列数据格式化处理,统一支持render函数渲染
const columnsData = computed(() => {
    return props.columns.map(item => ({
        formatter: (row, column, cellValue, index) => formatter(item, row, column, cellValue, index),
        ...item
    }))
})

// 单元格内容格式化逻辑
const formatter = (item, row, column, cellValue, index) => {
    // 优先级:行数据中的render函数 > 列配置中的render函数 > 默认值
    if (row?.[column.property]?.render) {
        return row[column.property].render(row, column, cellValue, index)
    } else if (item?.render) {
        return item.render(row, column, cellValue, index)
    }
    return row[column.property]
}
</script>

<style lang="scss" scoped>
.mine-table {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    
    .table-name {
        font-size: 18px;
        font-weight: bold;
        margin-bottom: 12px;
        display: flex;
        align-items: center;
        &::after {
            content: "";
            width: 5px;
            height: 100%;
            background-color: var(--el-color-primary);
            margin-right: 12px;
        }
    }

    .base-table {
        width: 100%;
        margin: 0 auto;
        min-width: 0;
        border: var(--el-table-border);
        border-radius: 4px;
    }

    .base-pagination {
        margin-top: 12px;
    }
}
</style>

核心封装点说明

  1. 属性 / 事件透传:通过tableProps/tableEventspaginationProps/paginationEvents分别透传el-tableel-pagination的原生属性与事件,既保留了原生组件的全部能力,又无需在组件内重复定义中转逻辑。
  2. 统一列渲染:封装了formatter函数,支持两种自定义渲染方式 —— 列配置中的render函数、行数据中的render函数,满足复杂单元格的展示需求。
  3. 基础样式整合:内置了表格标题、表格容器、分页的统一样式,无需在业务页面重复编写样式代码。
  4. Ref 暴露:将el-table的 Ref 暴露给父组件,方便调用clearSelectiontoggleRowSelection等原生方法。

三、组件使用示例

1. 基础使用(仅核心配置)

这是最常用的场景,只需配置表格数据、列配置、基础样式即可:

<template>
  <div class="demo-container">
    <!-- 通用表格组件使用 -->
    <MineTable
      height="200px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
    />
  </div>
</template>

<script setup>
import { ref } from "vue"
import MineTable from "@/components/MineTable.vue"
import { ElMessage, ElPopconfirm, ElButton, ElText } from "element-plus"

// 表格数据源
const tableData = ref([
  { id: 1, name: "张三", email: "zhangsan@example.com" },
  { id: 2, name: "李四", email: "lisi@example.com" },
  { id: 3, name: "王五", email: "wangwu@example.com" }
])

// 列配置项
const tableColumns = ref([
  { type: "index", label: "序号", width: 80 }, // 序号列
  {
    label: "用户名称",
    prop: "name",
    // 自定义单元格渲染
    render: (row) => <ElText type="primary">{row.name}</ElText>
  },
  {
    label: "操作",
    width: 100,
    // 操作列:带确认弹窗的删除按钮
    render: (row) => {
      const deleteUser = () => {
        // 模拟删除逻辑
        tableData.value = tableData.value.filter(item => item.id !== row.id)
        ElMessage.success(`已删除用户:${row.name}`)
      }

      return (
        <ElPopconfirm 
          title="确定删除吗?" 
          onConfirm={deleteUser}
          confirmButtonText="确定" 
          cancelButtonText="取消"
          v-slots={{
            reference: () => <ElButton type="danger" size="small" link>删除</ElButton>
          }}
        />
      )
    }
  }
])
</script>

<style scoped>
.demo-container {
  width: 800px;
  margin: 20px auto;
}
</style>

2. 进阶使用(透传原生属性 / 事件)

如果需要使用el-tableel-pagination的原生能力(如斑马纹、行点击事件、分页回调等),可通过透传 Props 实现:

<template>
  <div class="demo-container">
    <MineTable
      height="300px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
      <!-- 透传el-table原生属性 -->
      :table-props="{
        border: true,        // 显示表格边框
        stripe: true,        // 斑马纹效果
        showHeader: true     // 显示表头
      }"
      <!-- 透传el-table原生事件 -->
      :table-events="{
        'row-click': (row) => ElMessage.info(`点击了${row.name}的行`), // 行点击事件
        'sort-change': (val) => console.log('排序变更:', val)       // 排序变更事件
      }"
      <!-- 透传el-pagination原生属性 -->
      :pagination-props="{
        currentPage: 1,      // 当前页码
        pageSize: 10,        // 每页条数
        total: 100           // 总条数
      }"
      <!-- 透传el-pagination原生事件 -->
      :pagination-events="{
        'size-change': (size) => console.log('每页条数变更:', size), // 页大小变更
        'current-change': (page) => console.log('页码变更:', page)   // 页码变更
      }"
    />
  </div>
</template>

四、总结

本次封装的通用表格组件具备以下特点:

  1. 通用性强:整合了表格标题、分页等常用元素,统一了基础样式和渲染逻辑;
  2. 灵活性高:通过属性 / 事件透传,保留了 Element Plus 原生组件的全部能力,适配各类个性化需求;
  3. 易用性好:使用方式简洁,基础场景只需配置数据和列,进阶场景可透传原生属性 / 事件;
  4. 可扩展:在此基础上可进一步扩展空状态、加载状态、列宽自适应等通用能力,适配更多业务场景。

该组件能够有效减少中后台项目中表格相关的重复代码,提升开发效率,同时保持了足够的灵活性,满足不同业务场景的个性化需求。

❌