跳到主要内容

21 篇博文 含有标签「前端」

查看所有标签

前端面试高频算法与数据结构:从复杂度到手写题一网打尽

· 阅读需 18 分钟

前端面试考算法,问的不是"你数学好不好",而是两件事:你有没有数据结构意识(看到问题能想到该用栈、哈希表还是树),你有没有复杂度意识(知道这段代码是 O(n) 还是 O(n²))。因为前端日常处理的 DOM、事件、状态、缓存,底层全是数据结构——只是平时被框架藏起来了。

这篇文章按"数据结构 → 算法 → 手写题"三层把最常考的内容串一遍:每个结构讲清特性、给一两个高频题、再落到前端场景。不贪多,但每一块都能直接拿去应对面试。


1. 复杂度:一切讨论的前提

面试里"这个解法复杂度多少"是必问的。复杂度用大 O 表示法描述数据量变大时,运行时间/空间增长的量级

复杂度含义典型场景
O(1)恒定数组按下标访问、哈希表查找
O(log n)每次砍一半二分查找、平衡树查找
O(n)线性数组遍历、单层 for 循环
O(n log n)分治快排、归并
O(n²)双重循环冒泡排序、两两比较

前端的复杂度直觉:一个 10 万条的列表,O(n) 是 10 万次操作,O(n²) 是 100 亿次——后者必然卡死页面。所以 React 的 diff、虚拟列表、防抖节流,本质上都是在"把复杂度压下来"。这也是为什么"写代码前先估一下复杂度"是面试官最看重的基本功。


2. 数组:随机访问之王

特性:按下标访问 O(1);但在中间插入/删除要挪动后面的元素,O(n)

高频操作三个:

// ① 去重 —— O(n)
const unique = [...new Set([1, 1, 2, 3, 3])]; // [1, 2, 3]

// ② 扁平化 —— O(n)(含所有元素)
const flat = [1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]
// 手写版(递归,面试爱考)
function flatten(arr) {
return arr.reduce(
(acc, cur) => Array.isArray(cur) ? acc.concat(flatten(cur)) : acc.concat(cur),
[],
);
}

// ③ 翻转 —— O(n)
const rev = [1, 2, 3].reverse(); // [3, 2, 1]

前端场景:列表渲染 map、去重后的筛选条件、flatMap 拍平树形数据再渲染——都是数组基本功。


3. 栈 Stack:后进先出(LIFO)

栈只有两个动作:压栈 push(放栈顶)、弹栈 pop(取栈顶),看栈顶 peek。特性一句话:最后放进去的,最先被拿出来

经典题:有效的括号

function isValid(s) {
const stack = [];
const map = { ')': '(', ']': '[', '}': '{' };
for (const ch of s) {
if (ch in map) {
if (stack.pop() !== map[ch]) return false; // 遇到右括号,必须匹配栈顶
} else {
stack.push(ch); // 左括号入栈
}
}
return stack.length === 0;
}

前端场景:栈无处不在——

  • 函数调用栈:每次函数调用压栈,返回时弹栈——递归爆栈就是栈被压满了;
  • 错误堆栈console.trace() / 报错时的调用链,就是栈的直观展示;
  • Undo / Redo:撤销栈 + 重做栈;
  • 括号/标签匹配:HTML 解析、模板引擎都在用。

4. 队列 Queue:先进先出(FIFO)

队列只有两个动作:入队(放队尾)、出队(取队头)。特性:先来的先处理

JS 里的队列shift() 出队是 O(n)(要挪动),追求性能可用两个栈模拟或用环形数组。但前端高频考的不是实现,而是事件循环

规则就一句:每次事件循环取一个宏任务执行 → 然后把微任务队列清空 → 再取下一个宏任务。所以:

console.log(1); // 同步
Promise.resolve().then(() => console.log(2)); // 微任务
setTimeout(() => console.log(3)); // 宏任务
// 输出:1 → 2 → 3

前端场景:BFS 也是用队列——见第 6 节树的层序遍历。React 的并发渲染、消息队列、任务调度全是队列思想。


5. 链表 Linked List:插入删除快,随机访问慢

链表和数组正好互补:已知节点时插入/删除 O(1)(改指针即可),但按下标访问要逐个走,O(n)

经典题 ①:反转链表(三指针)

function reverseList(head) {
let prev = null, cur = head;
while (cur) {
const next = cur.next; // 先记住下一个
cur.next = prev; // 反转指针
prev = cur;
cur = next;
}
return prev;
}

经典题 ②:环形链表(快慢指针——最常用的技巧之一)

function hasCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; // 走一步
fast = fast.next.next; // 走两步
if (slow === fast) return true; // 有环必相遇
}
return false;
}

前端场景:React Fiber 的 workInProgress 树底层就是链表结构;浏览器的 DOMnextSibling / parentNode 也是指针式遍历;LRU 缓存(见第 7 节)更是链表 + 哈希表的经典组合。


6. 哈希表(Map / Set / Object):O(1) 查找

哈希表的核心思想:用"散列函数"把键映射成数组下标,直接落到对应的"桶"里。所以查找 / 插入 / 删除平均都是 O(1)——不靠遍历,靠"算一下就定位"。

6.1 先分清 JS 里的三个容器:Map / Set / Object

面试最爱问"MapObject 选谁"。先说结论:需要"键值对 + 保持插入顺序 + 任意类型的键"时用 Map;只是普通的"名值结构 / 要 JSON 序列化"时用 Object

特性MapObject
键的类型任意(对象、函数都能当键)只能是字符串 / Symbol(数字键会被转成字符串)
顺序保持插入顺序for...of / forEach 遍历稳定)整数键会自动按升序重排,不保证插入序
元素个数map.size 直接拿Object.keys(o).length 额外数一遍
遍历原生可迭代,for...of 直接用要借 Object.keys / Object.entries
频繁增删性能稳定删属性可能触发枚举重排,历史上更慢
JSON不能直接 JSON.stringify✅ 原生序列化
原型链纯"数据容器",无原型干扰继承 Object.prototype,键可能和原型方法撞名

Set 是"只有键、不重复"的集合:new Set([1, 1, 2]){1, 2},同样保持插入顺序,常用于去重判存在

6.2 经典题:两数之和

用哈希表把"已经见过的值"记下来,一次遍历就能找到另一半:

function twoSum(nums, target) {
const map = new Map();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (map.has(need)) return [map.get(need), i]; // 找到了之前存的另一半
map.set(nums[i], i); // 记下"当前值 → 下标"
}
return [];
}

不用哈希表的话,双循环要 O(n²);用哈希表把"查找另一半"从 O(n) 降到 O(1),整体 O(n)。这就是"用空间换时间"的典型。

6.3 进阶:LRU 缓存(最常考的设计题)

先弄懂它是干嘛的。缓存 = 把"算得贵 / 读得慢"的结果暂时存起来,下次直接取,省得再算。但缓存容量有限(内存就那么大),满了就得淘汰旧数据。淘汰谁?——最久没被用过的那个。这就是 LRU(Least Recently Used,最近最少使用)

为什么偏偏淘汰"最久未使用"? 因为真实数据有时间局部性(temporal locality):刚被访问过的数据,短时间内大概率还会再被访问;反之,很久没碰的最可能以后也用不上了。所以"先丢最久没用的"。浏览器 HTTP 缓存、Redis 内存淘汰、操作系统的页置换,全是这个思路。

Map 版本(能写出来就是加分项)——秘密全在 §6.1 说的"Map 保持插入顺序":

class LRUCache {
constructor(capacity) { this.capacity = capacity; this.cache = new Map(); }
get(key) {
if (!this.cache.has(key)) return -1;
const value = this.cache.get(key);
this.cache.delete(key); // ① 先删掉旧的
this.cache.set(key, value); // ② 再插回去 → 排到"最新"位置
return value;
}
put(key, value) {
if (this.cache.has(key)) this.cache.delete(key); // 更新也先删旧的
this.cache.set(key, value);
if (this.cache.size > this.capacity) {
this.cache.delete(this.cache.keys().next().value); // 淘汰队头
}
}
}

每步在干什么,拆开看:

  • get 命中deleteset,等于把这条记录从旧位置挪到队尾——队尾永远是最新用过的;
  • put 更新:同理,先删后插;
  • 淘汰keys().next().value 是迭代器的第一个元素 = 插入最早 = 最久未使用,超容量就删它。

(整套思路一句话:访问一次就把记录"顶"到最新,淘汰时永远踢队头。

教科书版:双向链表 + 哈希表——为什么还要会这个版本?因为 Map 版本虽然能写,但面试官想确认你理解 O(1) 是怎么保证的。标准答案是双向链表管"顺序",哈希表管"O(1) 定位"

class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map(); // key → 链表节点
this.head = this.tail = null; // 双向链表头尾
}
_detach(node) { // O(1) 从链表摘掉 node
if (node.prev) node.prev.next = node.next; else this.head = node.next;
if (node.next) node.next.prev = node.prev; else this.tail = node.prev;
}
_pushToTail(node) { // O(1) 把 node 放到队尾
node.prev = this.tail; node.next = null;
if (this.tail) this.tail.next = node; else this.head = node;
this.tail = node;
}
get(key) {
const node = this.map.get(key);
if (!node) return -1;
this._detach(node);
this._pushToTail(node); // 顶到最新
return node.value;
}
put(key, value) {
if (this.map.has(key)) this._detach(this.map.get(key));
const node = { key, value, prev: null, next: null };
this.map.set(key, node);
this._pushToTail(node);
if (this.map.size > this.capacity) {
this.map.delete(this.head.key); // 淘汰队头
this._detach(this.head);
}
}
}

为什么是"哈希表 + 双向链表"这套组合:

  • 哈希表负责"给个 key 直接 O(1) 找到节点"——没有它,找节点要遍历链表 O(n);
  • 双向链表负责"O(1) 移动 / 删除节点"——单向链表删除时不知道前驱,还得遍历找;双向链表有 prev 指针,摘除即 O(1)。

两个数据结构各管一件事,合起来才是完整的 O(1) LRU。这也是面试设计题的标准答题骨架:先想清楚"哪个结构负责哪种能力",再动手写。

真实场景:浏览器 HTTP 缓存、Redis 的 maxmemory-policy=allkeys-lru、Vue 的 <keep-alive> 组件缓存、React Query / SWR 的请求缓存、小程序本地缓存——凡是"内存有限 + 读多 + 想把热的留着"的地方,都是 LRU。

前端场景:对象属性查找 O(1)、Set 去重、函数 memo 缓存(useMemo 依赖比对底层)、依赖收集(Vue 的响应式)、虚拟 DOM 的属性 diff,全是哈希表。


7. 树:二叉树遍历与 DFS / BFS

树是最"前端"的数据结构——DOM 树、组件树、AST 都是树。核心操作是遍历,两种方式:

DFS(深度优先):用递归或栈。二叉树的前/中/后序都是 DFS:

// 前序:根 → 左 → 右(递归版,最好写)
function preorder(root, res = []) {
if (!root) return res;
res.push(root.val);
preorder(root.left, res);
preorder(root.right, res);
return res;
}

// 前序(迭代版,用栈)——面试官常让你把递归改成迭代
function preorderIter(root) {
const res = [], stack = [root];
while (stack.length) {
const node = stack.pop();
if (!node) continue;
res.push(node.val);
stack.push(node.right, node.left); // 先压右,后压左 → 弹出来先左
}
return res;
}

BFS(广度优先):用队列,一层一层扫。层序遍历必考:

function levelOrder(root) {
const res = [];
if (!root) return res;
const queue = [root];
while (queue.length) {
const size = queue.length; // 先记下这一层有几个节点
const level = [];
for (let i = 0; i < size; i++) {
const node = queue.shift();
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
res.push(level);
}
return res;
}

DOM 树的 DFS vs BFS(以一颗小 DOM 树为例):

  • DFS(前序)html → head → body → div → span → p(一条道走到黑再回头);
  • BFShtml → head, body → div, p → span(一层层扫)。

document.querySelectorAll 等选择器匹配、ReactDOM 的递归渲染,本质都是树的 DFS。


8. 排序:快排必须能手写

经典题:手写快排(分治思想:选基准 → 分区 → 递归)

function quickSort(arr) {
if (arr.length <= 1) return arr;
const pivot = arr[0];
const left = [], right = [];
for (let i = 1; i < arr.length; i++) {
(arr[i] < pivot ? left : right).push(arr[i]);
}
return [...quickSort(left), pivot, ...quickSort(right)];
}

(这是最容易手写、最不会出错的版本,代价是空间 O(n)。进阶可以聊原地分区的 in-place 版本,以及"最坏 O(n²) 怎么避免"——随机选基准 / 三数取中。)

排序复杂度与稳定性

算法平均最坏空间稳定
快排O(n log n)O(n²)O(log n)
归并O(n log n)O(n log n)O(n)
冒泡O(n²)O(n²)O(1)
选择O(n²)O(n²)O(1)
插入O(n²)O(n²)O(1)

稳定的意思是"相等的元素排序后保持原来的相对顺序"——需要稳定时(比如按日期排完再按状态排)选归并。

前端场景Array.prototype.sort 底层——现代 V8 用 TimSort(稳定),小数组会走插入排序;但要注意 sort() 默认按字符串排序,排数字必须传比较函数:

[10, 9, 100].sort(); // [10, 100, 9] ← 按字符串排的坑
[10, 9, 100].sort((a, b) => a - b); // [9, 10, 100] ✅

9. 二分查找:有序数组的 O(log n)

经典题:标准二分(注意边界 lo <= hi

function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1; // 取中位
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}

面试升级问法:找左边界(第一个 >= target 的位置):

function lowerBound(arr, target) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}

前端场景:数据库索引就是 B+Tree 上的二分(见本博客 MySQL 索引);前端里的二分常用于"有序列表找插入位置"、降级策略、CDN 最优节点等。


10. 递归与手写深拷贝

递归的核心是"把大问题拆成同样的子问题,直到最小规模"。手写深拷贝是前端最常考的手写题:

function deepClone(obj, seen = new Map()) {
if (obj === null || typeof obj !== 'object') return obj; // 基础类型直接返回
if (seen.has(obj)) return seen.get(obj); // 处理循环引用!
const res = Array.isArray(obj) ? [] : {};
seen.set(obj, res);
for (const key of Object.keys(obj)) {
res[key] = deepClone(obj[key], seen);
}
return res;
}

三个考点,一个都不能少:

  1. 基础类型直接返回(否则 typeof 会误判);
  2. seen 记录已克隆对象,处理循环引用 obj.self = obj——没有它直接爆栈;
  3. 数组要单独判断(用 Array.isArray),否则克隆出来是对象。

递归的坑:层级太深会爆栈(RangeError: Maximum call stack size exceeded)。能改迭代就改迭代,或至少知道"大列表别用深递归"。


11. 动态规划入门:爬楼梯

动态规划(DP)记住三句话:① 大问题能拆成子问题;② 子问题会被反复用到(重叠子问题);③ 边界和递推式。

经典题:爬楼梯(一次爬 1 或 2 阶,到第 n 阶有几种爬法)——答案就是斐波那契:f(n) = f(n-1) + f(n-2)

从"会写"到"不超时"有三个层次:

// 层次 1:纯递归 —— O(2^n),指数爆炸,n 稍大就卡死 ❌
function climb1(n) {
if (n <= 2) return n;
return climb1(n - 1) + climb1(n - 2);
}

// 层次 2:记忆化(自顶向下)—— O(n) ✅
function climb2(n, memo = {}) {
if (n <= 2) return n;
if (memo[n] !== undefined) return memo[n];
return (memo[n] = climb2(n - 1, memo) + climb2(n - 2, memo));
}

// 层次 3:自底向上滚动数组(真正的 DP)—— O(n) 时间 O(1) 空间 ✅
function climb3(n) {
if (n <= 2) return n;
let a = 1, b = 2;
for (let i = 3; i <= n; i++) [a, b] = [b, a + b];
return b;
}

前端场景:diff 算法的 LCS(最长公共子序列)就是二维 DP(见 React diff 系列博客);菜单高亮、路径统计这类"分步决策"问题都是 DP 的形态。


12. 双指针 / 滑动窗口

双指针是解字符串、数组题的高频套路。两个经典:

① 滑动窗口:最长无重复子串

function lengthOfLongestSubstring(s) {
const seen = new Set();
let left = 0, max = 0;
for (let right = 0; right < s.length; right++) {
while (seen.has(s[right])) { // 窗口里有重复 → 右移 left 直到没有
seen.delete(s[left]);
left++;
}
seen.add(s[right]);
max = Math.max(max, right - left + 1);
}
return max;
}

② 双指针:回文判断(字符串预处理后左右夹逼)

function isPalindrome(s) {
s = s.toLowerCase().replace(/[^a-z0-9]/g, '');
let l = 0, r = s.length - 1;
while (l < r) {
if (s[l] !== s[r]) return false;
l++; r--;
}
return true;
}

前端场景:防抖节流里的时间窗口、输入联想、评论分页加载,本质都是"维护一个窗口,只在窗口内做事"。


13. 前端手写题清单(速查表)

面试前最后冲刺,对着这张表过一遍。带链接的已有专题文章,不重复展开:

手写题关键点复杂度位置
数组去重Set / filter + indexOfO(n)本文 §2
数组扁平化递归 + reduceO(n)本文 §2
有效括号O(n)本文 §3
两数之和哈希表O(n)本文 §6
LRU 缓存Map 保持插入顺序 / 双向链表 + 哈希表均摊 O(1)本文 §6.3
反转链表三指针O(n)本文 §5
环形链表快慢指针O(n)本文 §5
二叉树遍历递归 ↔ 迭代O(n)本文 §7
快排分治 + 递归O(n log n)本文 §8
二分查找边界 lo <= hiO(log n)本文 §9
深拷贝递归 + 循环引用 MapO(n)本文 §10
爬楼梯滚动数组 DPO(n)本文 §11
最长无重复子串滑动窗口O(n)本文 §12
防抖 / 节流闭包 + 定时器防抖节流专题
call / apply / bind / Promise状态机 / 链式调用手写 JS 系列

14. 一句话总结

前端面试考算法,考的是数据结构意识 + 复杂度意识:看到"要快速查找"想哈希表、看到"要后进先出"想栈、看到"要先进先出 / 一层层"想队列、看到"递归结构 / 树形"想 DFS/BFS、看到"有序数组"想二分、看到"要排序"想快排、看到"分步决策可拆分"想 DP。而这一切在前端都有真实的落点——调用栈、事件循环、DOM 树、diff、LRU、响应式依赖收集,数据结构从来不是面试的孤岛,它就是前端的日常。


参考

前端首屏优化探索与方案:从指标到落地,大厂是怎么做到"秒开"的

· 阅读需 17 分钟

"首屏优化"是前端工程化里最常被问、也最难答好的话题——因为它不是一个技巧,而是一整套从指标定义 → 网络传输 → 资源加载 → 构建产物 → 渲染执行全链路的方法论。本文把这条链路完整走一遍:先讲清楚"快"到底指什么、慢在哪,再按层给出可落地的方案,最后用淘宝、美团、字节等大厂的真实实践收尾。


1. 为什么要优化首屏

首屏慢的代价是实打实的商业数字。美团技术团队在《前端黑科技:美团网页首帧优化实践》里引用过一组业界数据:

  • 57% 的用户更在乎网页在 3 秒内完成加载;
  • 52% 的在线用户认为网页打开速度影响其对网站的忠诚度;
  • 每慢 1 秒,页面 PV 降低 11%、用户满意度下降 16%
  • 半数移动用户因 10 秒内没打开页面而放弃。

所以"秒开"不是一个技术执念,而是直接关系收入指标(转化率、跳出率、留存)。这也是为什么大厂会把首屏性能做成性能预算(Performance Budget)——把它当"依赖"一样锁进 CI,超了就不让发版。

虚拟列表大厂方案调研:各场景的选型、量级判断与 fallback 策略

· 阅读需 14 分钟

一万条订单直接 map 渲染,浏览器会创建一万多个 DOM 节点——滚动掉帧、内存暴涨,甚至直接卡死。虚拟列表(Virtual List / Windowed Rendering)就是为了解决这个问题:不管数据有多少,始终只渲染视口附近的那几十个节点。但"上虚拟列表"只是第一步,真正难的是选型——不同场景(表格、feed、聊天、分组)该用哪个方案,不同数据量级该不该用、用什么级别的虚拟化,以及各种边界情况下的 fallback。

本文把主流方案和大厂实践完整调研一遍:原理 → 方案全景 → 场景选型 → 量级判断 → fallback。


1. 虚拟列表是什么:先看清问题的量级

不虚拟化的问题有多严重?一个直观对比:

直接渲染虚拟列表
10 万条数据10 万 + 个 DOM 节点约 50~80 个节点
首次渲染可能冻结 / 卡死毫秒级
滚动大量布局 + 绘制只重算视口窗口

为什么 DOM 多就慢:浏览器对每个节点都要维护数据结构、做布局计算、参与绘制。节点多 → 布局(Layout)慢、绘制(Paint)慢、内存占用高。50 万行直接渲染,浏览器基本会冻结甚至崩溃。

虚拟列表的核心思路三件事:

  1. 总高度占位:用一个"撑高"的容器模拟全部内容的高度,让滚动条长度正确;
  2. 只渲染窗口内:根据 scrollTop 算出当前视口应该显示哪些项(startIndex~endIndex),只渲染这些;
  3. 绝对定位 / translateY 偏移:把每一项按它在整条列表中的位置放好,视觉上无缝衔接。

overscan(缓冲区):视口外再多渲染几个,防止快速滚动时出现白屏——一般 5~10 个为宜。


2. 核心分水岭:定高 vs 不定高

这是选型的第一道分岔,也决定实现的复杂度和用哪个库。

维度定高(Fixed Height)不定高(Dynamic Height)
每项高度固定,index * height 直接算位置不一致,需要测量 / 预估
定位O(1)index * itemHeight需位置缓存:测量可见项 + 预估其余
实现成本低,几乎不踩坑高:高度缓存 + 预估修正 + ResizeObserver
滚动稳定性最稳测量偏差会导致滚动条跳动,需补偿
典型场景后台表格、结构规整的列表评论、feed 流、混合复杂布局

决策逻辑一句话:内容可控(表格、纯文本行)→ 定高;内容不可控(feed、评论、图文混排)→ 不定高。定高永远是最优解,不定高是业务逼出来的复杂实现。

不定高是怎么做的:只测量渲染过的,其余靠预估

所有主流实现用的都是同一套策略(react-window 的 VariableSizeList、TanStack 的 measureElement、react-virtuoso 内置的都是):

  1. 给每项一个预估高度 estimateSize(通常 100px 起步,其实不精确也没关系);
  2. 只在视口内实际渲染的项,用 ResizeObserver / getBoundingClientRect 测量真实高度,存进缓存;
  3. 位置由"已测量的精确值 + 未测量的预估值"累加得到——随着滚动,越来越多的项被测量,预估会被真实值逐步替换,位置越来越准;
  4. 当某项尺寸变化(图片加载、行展开)导致视口上方的项高度改变时,做滚动补偿scrollTop += 变化量,让用户看到的视觉位置不跳动。
// 量级化的实现思路:位置 = 已测量项的精确高度 + 未测量项的预估高度
function getItemOffset(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += measuredHeights.get(i) ?? estimateSize(i); // 测过的用真实值,没测的用预估
}
return offset;
}

这就是"只测量渲染过的,其余全靠预估,用多少测多少"的 fallback 思想——它在精度和性能之间取平衡,也是后面"各种量级 fallback"的雏形。


3. 主流方案全景(大厂 / 开源)

方案定位体积定高/不定高擅长
react-window轻量基础组件~4-7KB定高为主(不定高要手写测量)定高列表/表格,性能极致
react-virtuoso开箱即用全能方案~30KB+动态高度内置(ResizeObserver 自动测量)聊天/feed/动态内容
@tanstack/react-virtualheadless 底层 hook~15-18KB定高/不定高都支持,测量可控企业表格、跨框架、自定义渲染
rc-virtual-listAnt Design 内部组件~148KB定高为主antd 系表格/列表
ahooks useVirtualListReact Hook轻量支持动态高度蚂蚁生态快速接入
vue-virtual-scroller / Element PlusVue 生态支持动态高度Vue 项目

3.1 react-window:定高场景的性能标杆

  • 优点:包体积最小、纯组件性能最好、避免布局抖动(layout thrashing),是 2019-2022 年的行业标准,存量项目极多;
  • 缺点动态高度需要自己维护测量逻辑VariableSizeList 只给机制不给自动测量);不支持分组、sticky header、反向滚动、嵌套滚动;官方不建议新项目再用,基本停止新功能开发;
  • 适用:数据看板、定高表格这类"高度可预测 + 极致性能"的场景。

3.2 react-virtuoso:动态场景的开箱即用之王

  • 优点:动态高度全自动(ResizeObserver 测量 + 高度缓存,零手动维护);内置 TableVirtuoso(表格)、VirtuosoGrid(网格/瀑布流)、GroupedVirtuoso(分组 + sticky 组头);反向滚动 + followOutput(聊天消息流跟随最新消息);TypeScript 完整;
  • 生产案例Rocket.Chat 的消息列表就是用它——同时用"双向加载 + 动态高度 + 日期分隔符",是业界最严苛的虚拟化场景;
  • 适用:聊天、feed、评论这类"动态高度 + 内容不可控"的场景,中小团队起步首选。

3.3 @tanstack/react-virtual:headless 的底层基础设施

  • 优点框架无关(React/Vue/Solid 都行)、不绑定 DOM(可接 Canvas/WebGL)、TypeScript-first;底层用 Float64Array 连续数组做位置缓存(O(1) 访问、少 GC),能扛几十万条;与 @tanstack/react-table 组合是企业级数据表格的推荐模式;
  • 缺点:headless,要自己处理 ref、resize、scroll 同步,不是开箱即用;
  • 适用:企业数据表格、内部工具平台、需要自定义渲染管线的复杂布局。

3.4 蚂蚁系:rc-virtual-list + ahooks useVirtualList

  • rc-virtual-list:Ant Design(antd Table、Select 的虚拟滚动)底层的虚拟列表组件,周下载百万级;
  • ahooks useVirtualList:一行 hook 接入,支持动态高度,适合蚂蚁生态 / 快速原型。

4. 各场景的大厂选型

场景 1:后台数据表格(大量定高行)

固定列 + 海量行、每行高度基本一致 → 定高虚拟化。React 侧用 react-window@tanstack/react-virtual + @tanstack/react-table;antd 系直接用 rc-virtual-list 或 Table 的虚拟滚动。

真实案例(企业后台 10 万行 × 20 列表格):初始渲染 12s → 0.5s,CPU 80% → 15%

场景 2:feed / 评论流(动态高度)

图文混排、高度不可控 → 动态高度虚拟化,选 react-virtuoso(自动测量)或 @tanstack/react-virtual(自己接测量)。

真实案例(电商商品列表):首屏 3.2s → 0.8s,内存 400MB → 100MB,帧率 20 → 60 FPS

场景 3:聊天消息流(反向 + 动态 + 跟随)

这是最苛刻的场景:消息从底部往上排(反向滚动)、每条高度不同、还要跟随最新消息。react-virtuoso 是事实标准(Rocket.Chat 生产案例),followOutput 平滑滚动到最新。

场景 4:分组列表 / 树形(sticky 组头)

分组 + 吸顶组头 → react-virtuosoGroupedVirtuoso;树形展开/收起会改高度,属于"动态高度 + 增量测量"的复合场景。

场景 5:网格 / 瀑布流

VirtuosoGrid(多列)+ 每格动态高度。

场景 6:虚拟化 + 分页 / 无限滚动组合

服务端分页管"数据边界",客户端虚拟列表管"渲染边界"。注意一个坑:接口返回 total_count 和实际数据可能不一致,会导致滚动条过长/过短——用"已知高度 + 估算未加载部分"来对齐滚动条。

场景 7:Hybrid H5(移动端)

真实案例(Hybrid H5 饰品列表 1000+ 行):迁移前 DOM 节点 >20000、滚动 30fps 以下;虚拟化后显著改善。移动端选型优先 react-window(轻量)或动态场景的 react-virtuoso


5. 各种量级的 fallback:什么时候该虚拟化,用什么级别

"上不上虚拟列表"和"上哪种虚拟化",本质取决于数据量级。这一步做对,能省掉大量无谓的复杂度。

5.1 量级判断表

数据量级策略说明
< 1000不虚拟化,直接全渲染虚拟列表本身有实现复杂度 + 测量开销,这个量级收益趋近于零,别为了"显得专业"而上
1000 ~ 5000优先分页 / 无限滚动;或简单定高虚拟开始有卡顿风险(经验阈值),但还没到非虚拟不可
5000 ~ 10 万正式虚拟列表定高优先;动态高度必须上"测量 + 预估 + 补偿"
10 万 ~ 100 万高性能虚拟化二分定位(O(log n))、位置缓存、增量重算,避免 O(n) 扫描;注意浏览器像素高度上限
100 万 +特殊架构单容器几乎到极限,考虑数据聚合/分批、服务端、Canvas/WebGL 渲染

5.2 量级对应的实现强度

5k~10 万(普通虚拟化):线性累加位置可接受,overscan 5~10,定高走 O(1) 公式。

10 万~100 万(高性能虚拟化):这一步要跨过三道坎——

  1. 定位别用线性扫描:从 scrollTop 找起始项,用二分查找 O(log n),否则每次滚动都 O(n);
  2. 位置数据别用对象数组:TanStack 用连续 Float64Array 存 start/size,避免几十万个对象的内存与 GC 压力;
  3. 测量别全量做:只测视口内的项,其余用预估;增量地重算受影响位置,别每次滚动全量重算。

浏览器硬边界:DOM 元素/滚动容器高度有物理上限(Chrome 约 2^24 ≈ 1600 万 px,实用上滚动到百万像素级已是极限)。这意味着"百万条 × 每条 20px = 2000 万 px"这种组合,单靠一个滚动容器是放不下的——必须拆(分批加载、按需扩展高度)或换渲染载体。

5.3 fallback 的几种形态

"fallback"在这类系统里至少指三件事:

① 数据量级的降级(上面那张表):数据小到不值得虚拟化 → 直接全渲染。虚拟化不是免费的,它有测量开销、高度缓存维护、定位计算——数据只有几百条时,直接渲染更快更简单。

② 测量机制的降级

异常 / 边界fallback
某项还没被渲染 / 没测到estimateSize 预估值(测量后自动替换)
图片异步加载导致高度变化ResizeObserver 监听 + 增量重算受影响区间
视口上方高度变化导致跳动滚动补偿scrollTop += 高度差值 保持视觉稳定
不支持 ResizeObserver(老环境)降级为滚动事件 + getBoundingClientRect 采样
快速滚动出现白屏加大 overscan 缓冲区(5~10 个)

③ 需求不满足的降级:定高方案在遇到动态内容时,如果不想引入大库,可以退化为"预估高度 + 出现内容后再补测修正"(react-window VariableSizeList 就是让你自己接这套);极端情况(无法测量、必须逐条真实高度)甚至放弃虚拟化、退化为分批渲染 + 懒加载


6. 工程实践:性能归因与常见坑

6.1 先做性能归因,别盲目上虚拟列表

虚拟列表只解决"渲染性能(DOM 规模)"问题,不解决数据获取、状态管理问题。先用 Chrome Performance 归因:

  • DOM 数量过多(经验阈值:<1000 通常没问题;1000~5000 有卡顿风险;>5000 基本要虚拟化)→ 虚拟列表是核心解法;
  • Layout 时间长(不定高元素、图片、复杂 CSS)→ 重点是控制高度、降低布局复杂度,虚拟列表帮不上;
  • Scripting 时间长(频繁 render/diff)→ 应做 memo、分层渲染、状态拆分。

6.2 五个高频坑

  1. key={index}:滚动后状态漂移(第 100 行展开,滚动后变第 98 行)——用 item 的业务 id 做 key
  2. 状态存在 item 组件内部:虚拟列表的项会反复卸载/重建,内部 state 会丢——状态提到父级或全局 store,用 Map<itemId, state> 缓存;
  3. overscan 太小:快速滚动出现白屏——缓冲区放到 5~10 个
  4. 滚动回调里做复杂计算:应使用 requestAnimationFrame 或防抖,别阻塞主线程;
  5. 服务端分页 + 虚拟列表混用total_count 与实际数据不一致会导致滚动条失真——用"已知部分精确高度 + 未加载部分估算高度"对齐。

6.3 一个性能对照

场景优化前优化后
10 万行 × 20 列表格初始渲染 12s,CPU 80%0.5s,CPU 15%
电商商品列表首屏 3.2s,内存 400MB,20 FPS0.8s,内存 100MB,60 FPS
Hybrid H5 饰品列表DOM >20000,滚动 <30fps显著改善

7. 一句话总结

虚拟列表的本质是"总高度占位 + 只渲染视口窗口 + 定位偏移",它只解决 DOM 规模问题,不背数据获取和状态的锅。选型的第一分水岭是定高 vs 不定高:内容可控走定高(react-window / TanStack + Table,O(1) 定位、最稳),内容不可控走动态高度(react-virtuoso 自动测量,或 TanStack 自接测量),聊天/feed/分组等特殊场景用对应内置能力。而上不上虚拟、上到什么强度,看数据量级:千条以内直接渲染、万级上正式虚拟、十万到百万上二分 + 位置缓存 + 增量重算、百万以上得换架构。各种 fallback(预估高度、滚动补偿、overscan 加大、ResizeObserver 降级)本质都是"在精度、性能、稳定性之间取平衡"——先归因性能,再决定手段,永远别为了"用上虚拟列表"而虚拟化。


参考

dart与js的互操作(一)

· 阅读需 1 分钟

https://dart.dev/interop/js-interop/past-js-interop


@js.JSExport()
class _MyAppState extends State<MyApp> {
final _streamController = StreamController<void>.broadcast();
DemoScreen _currentDemoScreen = DemoScreen.counter;
int _counterScreenCount = 0;

@override
void initState() {
super.initState();
final export = js.createJSInteropWrapper(this);
js.globalContext['_appState'] = export;
js.globalContext.callMethod('_stateSet'.toJS); // 调用js方法,将Dart实例化
}

贝塞尔曲线

· 阅读需 3 分钟

二次贝塞尔曲线

二次贝塞尔曲线的公式为:

B(t)=(1t)2P0+2t(1t)P1+t2P2,t[0,1]B(t) = (1-t)^2P_0 + 2t(1-t)P_1 + t^2P_2, t \in [0, 1]

我们先来回归一下高中数学,二次函数的标准形式为:

y=ax2+bx+cy = ax^2 + bx + c

其中,aa 为二次项系数,bb 为一次项系数,cc 为常数项。

二次函数的图像为抛物线,抛物线的顶点坐标为:

x=b2a,y=b24ac4ax = -\frac{b}{2a}, y = -\frac{b^2-4ac}{4a}

我们可以把二次贝塞尔曲线的公式转换为二次函数的标准形式:

B(t)=(P02P1+P2)t2+2(P1P0)t+P0B(t) = (P_0 - 2P_1 + P_2)t^2 + 2(P_1 - P_0)t + P_0

拿二阶过程描述如下:

  1. 将控制点连接起来,得到两条线段;
  2. 取 t 值,计算出两条线段上的点;
  3. 将两条线段上的点连接起来,得到一条线段;
  4. 取 t 值,计算出线段上的点;
  5. 重复 2、3 步骤,直到 t 值为 1。

高阶的不过是需要多重复几次直到剩一条线段,然后再取 t 值计算出点。

此过程可以用向量表示

B(t)=i=0n(ni)(1t)nitiPiB(t) = \sum_{i=0}^n \binom{n}{i} (1-t)^{n-i}t^iP_i

程序设计

一般化贝塞尔曲线公式

B(t)=i=0n(ni)(1t)nitiPiB(t) = \sum_{i=0}^n \binom{n}{i} (1-t)^{n-i}t^iP_i

这个公式是贝塞尔曲线的定义。它描述了如何根据一组控制点 P_i 和一个参数 t 计算贝塞尔曲线上的点 B(t)。

基于多项式的实现

function bezier(points, t) {
const n = points.length - 1;
let x = 0;
let y = 0;
for (let i = 0; i <= n; i++) {
const b = binomial(n, i);
const a = Math.pow(1 - t, n - i);
const c = Math.pow(t, i);
x += b * a * c * points[i].x;
y += b * a * c * points[i].y;
}
return { x, y };
}

function binomial(n, i) {
return factorial(n) / (factorial(i) * factorial(n - i));
}

function factorial(n) {
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
}
return result;
}

binomial 这里查看 二项式定理

基于递归实现

function bezier(points, t) {
const n = points.length - 1;
if (n === 0) {
return points[0];
}
const left = bezier(points.slice(0, n), t);
const right = bezier(points.slice(1, n + 1), t);
return {
x: (1 - t) * left.x + t * right.x,
y: (1 - t) * left.y + t * right.y,
};
}

JS 节流与防抖(附 React Hooks)

· 阅读需 10 分钟

在搜索框里敲「react」:r、re、rea、reac、react——如果每次敲键都发一次请求,一次输入就白白浪费 4 次网络请求,而且前 4 次的结果你根本来不及看,它们已经在回来的路上了。再往下想:滚动页面时 onScroll 一秒钟触发几十次,resize 拖拽时更是每帧一次。

这类问题有一个共同的名字:事件风暴——短时间内触发太多次,而真正需要执行的只有少数几次。

一、回答两个问题

事件风暴的本质是:触发频率远高于执行需求。那么「到底该执行哪几次、什么时候执行」,其实只取决于你对这件事的期望。把期望问清楚,答案自己就出来了。

问题 1:我在乎的是「最后的结果」吗?

输入搜索、窗口 resize、表单项校验……这些场景里,过程不重要,结果才重要。你敲了一长串,真正需要的只是「停下来之后」那一次的搜索结果;窗口拖了一路,真正需要的只是「停住之后」那一次的布局重算。

防抖的定义用一句话说:持续触发时一律不执行,直到停止触发后过一段时间,才执行最后一次。

一个贴切的比喻是电梯关门:有人进出,电梯就重新计时关门,等人流彻底安静了,才关门上楼。你不在乎期间谁进进出出,只在乎「最后走的那一刻」。

问题 2:需要在过程中「持续跟进」,但不能太频繁吗?

滚动加载更多、滚动进度条、拖拽坐标跟随、游戏里的连点……这些场景里,过程是有价值的——滚到一半就该触发加载,进度条该跟着走。但你不能让每一次 scroll 都触发,那太浪费了。

节流的定义用一句话说:保证一定时间间隔内最多执行一次,但会持续执行,直到结束。

贴切的比喻是班车时刻表:每 10 分钟一班,不管站台多少人、什么时候来人,到点就发。你保证的是「规律性」——不会因为人挤就狂发车,也不会因为没人就永远不发。

心智模型:

防抖(debounce)节流(throttle)
模型电梯等人,人静才关门班车到点发车
关心的事最后一次的结果过程中的规律性
触发密集时全部取消,只等最后固定节奏放行

二、防抖

防抖的核心:新来的调用,作废旧的计时。 翻译成代码:

function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer); // ① 取消上一个还没生效的定时器
timer = setTimeout(() => fn(...args), wait); // ② 重新计时
};
}

逐行看为什么必须这么写:

  • 为什么要返回一个新函数、用闭包存 timer 因为每次触发都要「看到」同一个 timer 才能取消它。timer 藏在闭包里,新函数每次调用共享这一个变量。
  • 为什么先 clearTimeoutsetTimeout 这一句就是防抖的全部秘密。时间线:t=0 调用 → 定 300ms → t=100ms 又调用 → 取消 t=0 那次、重排到 t=400ms → 只要你一直在敲,它就永远在重排、永远不执行 → 你一停,300ms 后执行最后一次。

这个「重置」动作,就是前面说的电梯重新计时

补一个坑:this 会丢

如果直接用 () => fn(...args)this 就丢了。当防抖函数被用在事件监听里(el.addEventListener('click', debounced)),监听器内部的 this 是那个元素;但 setTimeout 回调里的 this 不是它。所以要先记住:

function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
const context = this; // 记住调用时的 this
clearTimeout(timer);
timer = setTimeout(() => fn.apply(context, args), wait);
};
}

到这里,一个「停下来才执行」的防抖就好了——这是**后缘(trailing)**版本,也是输入搜索最常用的形态。

进一步:有时候第一次就该立即执行

有个反例:防连点。用户快速双击「提交」按钮,你希望第一次点击就立即执行,后面的点击才被防抖掉。如果只用上面的 trailing 版本,第一次点击也会被延迟 300ms,体验很差。

于是防抖又有了 前缘(leading):一个周期开始先立即执行一次,之后进入等待。

function debounce(fn, wait = 300, { leading = false } = {}) {
let timer = null;
return function (...args) {
const context = this;
const isIdle = timer === null; // 当前没有在等待 = 一个周期的开始
clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // 等待结束,回到空闲
if (!leading) fn.apply(context, args); // trailing 模式在结束时补执行
}, wait);
if (leading && isIdle) fn.apply(context, args); // leading 模式:空闲时立即执行
};
}

注意这里的取舍:leading 模式第一次立即执行、之后防抖;trailing 模式全部延迟、只在安静后执行最后一次。两者通常二选一。

三、从「闸门」长成节流

节流的核心:记上次执行的时间,间隔不够就拦下。

function throttle(fn, wait = 200) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= wait) { // 距上次执行够久了 → 放行
lastTime = now;
fn.apply(this, args);
}
};
}

这就是时间戳闸门:第一次调用时 lastTime=0now 是很大的毫秒时间戳,now - 0 远超 wait,直接放行并记录;之后 wait 毫秒内的调用全被 if 拦下;时间一到,第一次撞上来的调用放行。

它有个优点:首次立即执行,绝不空等。但也有个隐藏的坑:被拦在闸门内的最后一次调用,被永久丢弃了。

场景很真实:滚动到底部触发「加载更多」,如果最后一次 scroll 恰好滚到了底,却撞在闸门内被丢掉,加载就永远不触发。所以你还需要一个兜底——闸门放行的同时,给「卡在门里」的调用排一个定时器,等间隔到了补执行最后一次。

function throttle(fn, wait = 200, { leading = true, trailing = true } = {}) {
let lastTime = 0;
let timer = null;
let lastArgs, lastCtx;

const invoke = () => {
lastTime = Date.now();
timer = null;
fn.apply(lastCtx, lastArgs);
};

return function (...args) {
lastCtx = this;
lastArgs = args;
const now = Date.now();
const remaining = wait - (now - lastTime);

if (remaining <= 0) { // 间隔已到 → 立即放行(leading)
clearTimeout(timer);
invoke();
} else if (!timer) { // 还没到 → 排定时器,兜住最后一次(trailing)
timer = setTimeout(invoke, remaining);
}
};
}

于是节流从「时间戳闸门」生长成了「闸门 + 定时器兜底」:闸门保证节奏(leading),定时器保证不丢最后一次(trailing)。这已经是生产级的形态了。

四、leading / trailing:把两个概念统一到一个视角

看到这里你会发现,防抖和节流的完整版都有 leading / trailing 两个开关——它们其实是同一对「前后缘」概念:

leading(前缘)trailing(后缘)
防抖周期开始立即执行一次,之后防抖周期结束(安静后)执行最后一次
节流间隔到点立即执行结束时补执行被拦住的最后一次
一句话「先做再说」「做完收尾」

配上场景,选择就顺理成章了:

场景选型原因
输入框实时搜索防抖(trailing)只关心停下来的最终结果
按钮防连点防抖(leading)第一次要立刻响应
滚动加载更多 / 滚动进度节流(leading + trailing)过程中要触发,且不能丢最后
resize 重算布局防抖等 resize 停下来再做
mousemove 坐标跟随节流跟住但不每帧执行

一个补充:如果你要的是「每帧执行一次」的视觉类工作(动画、拖拽预览),那其实还有第三个更专门的工具——requestAnimationFrame。它本质上是「以浏览器帧率为间隔的节流」,浏览器会帮你合并到帧回调里,视觉场景下优先用它。

五、React 自定义 Hook

在 React 里,上面的闭包逻辑要套上 hook 的「生命周期」才能用对——尤其注意两点:回调要始终拿到最新闭包(用 ref 兜底),卸载时要清理定时器(避免 setState 在卸载后执行)。

值防抖:useDebounce(输入框最佳搭档)

最常用的形态是「防抖一个值」——输入内容进 state,经过防抖的值才去发请求:

import { useEffect, useState } from 'react';

function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // 依赖变化 → 取消上一次计时 = 电梯重新关门
}, [value, delay]);

return debounced;
}

用法——搜索框只跟着防抖后的值发请求:

function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);

useEffect(() => {
if (debouncedKeyword) fetchSearch(debouncedKeyword);
}, [debouncedKeyword]);

return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

回调防抖:useDebouncedCallback

把「动作」本身防抖,返回一个稳定引用(useCallback 缓存),不会让子组件因每次渲染拿到新函数而重新渲染:

import { useCallback, useEffect, useRef } from 'react';

function useDebouncedCallback<T extends (...args: any[]) => void>(
callback: T,
delay = 300,
) {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout>>();

useEffect(() => {
callbackRef.current = callback; // 始终拿到最新闭包
});

useEffect(() => () => clearTimeout(timerRef.current), []); // 卸载清理

return useCallback(
(...args: Parameters<T>) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
},
[delay],
);
}

回调节流:useThrottledCallback(首次立即 + 末尾兜底)

把「闸门 + 定时器兜底」完整封装:

function useThrottledCallback<T extends (...args: any[]) => void>(
callback: T,
delay = 200,
) {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const lastTimeRef = useRef(0);
const lastArgsRef = useRef<Parameters<T>>();

useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => () => clearTimeout(timerRef.current), []);

return useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
const now = Date.now();
const remaining = delay - (now - lastTimeRef.current);

if (remaining <= 0) {
// 间隔已到 → 立即执行,并取消待决的定时器
clearTimeout(timerRef.current);
timerRef.current = undefined;
lastTimeRef.current = now;
callbackRef.current(...args);
} else if (!timerRef.current) {
// 还没到 → 排定时器,兜住最后一次
timerRef.current = setTimeout(() => {
timerRef.current = undefined;
lastTimeRef.current = Date.now();
callbackRef.current(...lastArgsRef.current!);
}, remaining);
}
},
[delay],
);
}

用法——滚动加载更多,节流 + 不丢最后一次:

const loadMore = useThrottledCallback(() => fetchNextPage(), 200);
useEffect(() => {
window.addEventListener('scroll', loadMore);
return () => window.removeEventListener('scroll', loadMore);
}, [loadMore]);

六、总结

回到开头的问题。节流和防抖不是两个需要背的公式,而是你对同一件事的两个追问:

  • 「我在乎最后的结果吗?」 → 防抖。种子是「重置」——新调用作废旧计时,电梯等人静才关门。
  • 「我需要在过程中跟住吗?」 → 节流。种子是「闸门」——间隔不到就拦下,班车到点才发。

两个概念到完整形态后,会汇聚到同一对开关上:leading(先做再说)和 trailing(做完收尾)。理解了这对开关,你就能按场景自由选择,而不是背模板。

聊聊函数柯里化:来源、解决的问题与优缺点

· 阅读需 7 分钟

柯里化(Currying)是函数式编程里最常被提起、也最常被误解的概念之一。面试喜欢问,但很多人只背得住一句「把多参函数变成单参函数的链式调用」,说不清它从哪来、为什么存在、以及它到底划算不划算。

这篇文章把三件事讲透:它是谁发明的它解决什么问题它的代价是什么

手写 JS 系列:从 apply 到 bind,最后手写一个 Promise

· 阅读需 12 分钟

手写系列的意义不是「记住答案」,而是把 API 背后那句被藏起来的话重新说出来:「改变 this」本质是什么?「异步」到底是怎么串起来的? 顺着 apply → call → bind → Promise 这条线写下来,你会发现它们是一脉相承的——前面三个解决的是一件事(函数调用时的 this 和参数),最后一个把「回调」正式升级成了「一等公民」。

Zepto源码学习-核心篇

· 阅读需 8 分钟

0.前言

Zepto源码1.2.0未压缩带注释约有1835行,之前是当做设计模式来阅读,并没有深入。且在当前前端环境下,JQuery的重要性大大降低了,从事开发工作大多用的是Angular、Vue等,并没有将jQuery用到精通。以训练为目的,尝试将Zepto源码讲的清楚一点