
我见过太多这样的故事。
一个朋友,用了4年React。某个下午,他跟我说了个bug,我听完差点没绷住——
// 他的代码
const [loading, setLoading] = useState(false);
const handleClick = () => {
setLoading(true);
console.log(loading); // 他期望是true,结果是false
fetch('/api/data')
.then(res => res.json())
.then(data => {
setLoading(false);
});
};
他说:"为什么setState之后立马log,值还是旧的?是不是React有bug?"
我问他:"你有没有想过,也许不是React的问题,而是你对JavaScript本身的理解有gap?"
他沉默了。
然后说出了最扎心的话:"我这4年,其实就是在学React怎么用,从来没想过为什么会这样。"
还有个更夸张的。
一个3年经验的开发者,在生产环境发现一个bug——页面加载时会闪烁两次。他折腾了三天,最后是我随口问了句:"你是不是在useEffect里直接改state超过一次?"
他翻出代码一看:
// useEffect里的代码
useEffect(() => {
const data = fetchUserData();
setUser(data); // 这里setUser
setLoading(false); // 这里又setLoading
// 结果触发了三次render:初始 → setUser → setLoading
}, []);
他说:"我从来不知道一个effect里调用多个setState会这样。"
我说:"这就是问题所在啊——你不知道你的代码执行顺序。"
或者这个例子——我见过一个人,用了5年React和Redux,有天被问:"说说闭包在Hooks里的作用。"
他支支吾吾地说:"那个……就是……能记住状态?"
我接着问:"那为什么你的useCallback依赖数组里不加某个变量,代码就会bug?"
他又卡住了。最后承认:"我其实就是靠着ESLint提示来填依赖数组,从来没想过为什么。"
听了太多这样的故事后,我开始问自己:为什么框架开发者普遍存在这个问题?
答案很残酷——因为框架做得太好了。好到你可以不懂JavaScript也能写代码。
我坦白说,我曾经就是这种人。
用React 5年,却在某次技术分享时被问"你理解事件循环吗?"时彻底卡壳。
我能用async/await,但说不清为什么Promise的.then会先于setTimeout执行。
我知道useCallback的依赖数组很重要,但只是因为ESLint警告,而不是真的懂为什么。
最尴尬的是,有个前端新手问我:"为什么setState之后console.log拿到的还是旧值?"
我当时的回答是:"哦,React的setState是异步的……"
但当他继续问"为什么异步"时,我就结巴了。因为我从未真正思考过这个问题。
那一刻我意识到——我可能根本不懂JavaScript,只是会用React。
框架最厉害的地方,也最危险的地方,就是它把JavaScript的复杂性完全隐藏了。
你看不到状态怎么被记住。你看不到UI怎么被更新。你看不到为什么有些代码会按这样的顺序执行。
你只看到代码能工作。
这可能是最困扰新手的:
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(1);
console.log(count); // 期望1,实际0
setCount(2);
console.log(count); // 期望2,实际还是0
};
有人会说"React的setState是异步的"。但这个解释不完全。真正的原因是——闭包。
当handleClick被创建时,它的闭包里捕获了count这个变量。无论调用多少次setCount,这个闭包里的count还是初始值。
但即使理解了这个,新手还是会困惑:"那什么时候count才会更新?"
答案是:"下一次render时"。
但这需要理解——setState触发render,render才会重新执行你的组件函数,这时候闭包里才会是新的count。
无数人被这个坑过:
useEffect(() => {
console.log('userId:', userId);
fetch(`/api/user/${userId}`)
.then(setUser);
}, []); // 依赖数组为空
// userId改变时,fetch还是用的旧userId
ESLint会警告你"missing dependency"。你加上userId:
useEffect(() => {
// ...
}, [userId]);
// 现在userId改变时,effect会重新运行,没问题了
但有的人就此停止思考。他们不知道为什么需要加userId。
答案是闭包。effect的回调函数形成的闭包,会捕获该scope里的变量。如果没加依赖项,闭包里的值永远不会更新。
这是个生产bug的温床:
const [results, setResults] = useState([]);
const handleSearch = (query) => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
};
// 用户快速搜索:"a" → "ab" → "abc"
// 三个请求同时发出
// 但返回顺序可能是 response 3 → response 1 → response 2
// 最后显示的是response 2的过时数据,用户看到的搜索结果是错的
理解这个问题需要知道:
但在React里,你可能永远遇不到这个问题——因为你会直接用react-query或SWR这样的库。
库帮你处理了,所以你不需要理解。
学习难度 ▲
│
5 │ ╱─── React高级特性
│ ╱
4 │ ╱
│ ╱ ← 你在这就能写出工作代码
3 │ ╱
│ ╱
2 │ ╱
│ ╱
1 │ ─────── JavaScript基础
│
└──────────────────────► 时间
你用一周可以学到足够的React知识,写出能工作的应用。
但学JavaScript基础要花三个月。
所以大多数人选择——继续用React,假装不知道下面发生了什么。
反正能工作。反正有ESLint。反正有Stack Overflow。
这是框架的本质。比如状态管理:
在原生JavaScript里,你要自己做:
let count = 0;
const increment = () => {
count++;
render(); // 还要手动重新渲染
};
function render() {
document.querySelector('#count').textContent = count;
}
在React里,一行代码搞定:
const [count, setCount] = useState(0);
// React自动处理:状态更新、render触发、DOM更新
这太棒了。但代价是你永远看不到"状态更新怎么触发DOM更新"这个过程。
实际工作中,没人会问你"为什么"。只要代码工作就行。
领导:这个bug怎么修?
你:看看代码……可能改这里试试?
结果:好了
领导:很好,继续
没有人追究为什么会有这个bug。没有人要求你理解根本原因。
久而久之,你就习惯了"碰巧解决问题"这种开发方式。
对我来说,转折点是这样的:
某个周三下午,一个同事来问我一个问题。他在一个useEffect里写了这样的代码:
useEffect(() => {
// 这样对吗?
const fetchData = async () => {
const res = await fetch('/api/data');
const data = await res.json();
setData(data);
};
fetchData();
}, []);
他问我:"为什么不能直接在useEffect里写async?"
我那时的回答是:"因为……useEffect的return应该是cleanup函数,不能是Promise……"
但他继续问:"为什么?"
我支支吾吾。
那一刻我意识到——我知道规则,但根本不知道为什么有这条规则。
这触发了我的危机感。
那个周末,我决定做一个实验。我要搞清楚——为什么useEffect不能直接async?为什么setState看起来不同步?为什么闭包这么重要?
我的方法很极端——用纯JavaScript重写一个小项目,不用任何框架。
我选了一个简单但有意思的项目——一个待办应用。有状态管理、异步保存、实时搜索,没有任何框架。
一开始,我想:状态管理有那么难吗?
const state = {
todos: [],
filter: 'all'
};
// 添加todo
const addButton = document.querySelector('#add');
addButton.addEventListener('click', () => {
const input = document.querySelector('#input');
state.todos.push(input.value);
// 现在需要更新UI
// 怎么更新?
});
问题立即出现——有多个地方会修改状态,每个地方都要记得更新UI。这很容易遗漏。
我花了一天时间写了一个简单的观察者模式:
class StateManager {
constructor(initialState) {
this.state = initialState;
this.listeners = [];
}
setState(updates) {
this.state = { ...this.state, ...updates };
this.listeners.forEach(fn => fn());
}
subscribe(fn) {
this.listeners.push(fn);
}
}
写完后,我突然get到了——Redux或Zustand就是这个原理!
这一个小小的StateManager,让我理解了为什么状态管理库那么重要。
实现"编辑todo"功能时,我踩到了经典的闭包坑:
state.todos.forEach((todo, index) => {
const editBtn = document.createElement('button');
editBtn.textContent = '编辑';
editBtn.addEventListener('click', () => {
console.log(`编辑第${index}个`); // 问题:这输出的总是最后一个index
});
li.appendChild(editBtn);
});
我Google了解决方案,学到了用let代替var。但知道解决方案不是真的理解。
我继续挖掘。我问自己:为什么let就行了?
答案是块级作用域。每个let声明都会创建新的作用域。但这还是太抽象。
直到我写了这样的代码:
function makeEditHandler(todoId) {
return function() {
console.log(`编辑todo ${todoId}`);
};
}
state.todos.forEach((todo, i) => {
const editBtn = document.createElement('button');
editBtn.addEventListener('click', makeEditHandler(todo.id));
});
我才真正理解——闭包就是函数"记住"了它被创建时的上下文。
在这个例子里,makeEditHandler返回的函数"记住"了todoId。即使makeEditHandler执行完了,返回的函数还能访问todoId。
那一刻,我突然想到React Hooks:
function useState(initialValue) {
let state = initialValue;
return [
state,
function setState(newValue) {
state = newValue; // 这个setState会"记住"这个特定的state变量
}
];
}
// 每次调用useState,都会创建新的闭包
const [count, setCount] = useState(0); // 这个setCount的闭包里的state是count
const [name, setName] = useState(''); // 这个setName的闭包里的state是name
哦天哪。这就是为什么Hooks不能改变调用顺序。
如果改变了顺序,setCount可能会改变name的state,因为闭包捕获的是"第一个useState的返回值",不管那个useState里存的是什么。
这一个顿悟,让我理解了为什么useCallback的依赖数组那么重要、为什么useEffect的依赖数组那么重要。
当我要实现"自动保存"功能时,事件循环的知识变得必需:
input.addEventListener('change', () => {
const version = ++requestVersion;
fetch('/api/todos', { method: 'POST', body: JSON.stringify(state.todos) })
.then(res => res.json())
.then(data => {
// 问题1:如果有多个fetch,返回顺序可能不同
// 问题2:数据怎样才能及时更新
// 问题3:用户快速操作时会发生什么
setTodos(data);
});
});
我遇到的第一个问题是——执行顺序不对:
console.log('1');
fetch('/api').then(() => console.log('2'));
console.log('3');
// 输出是 1 → 3 → 2,不是 1 → 2 → 3
这时我才真正理解了事件循环。JavaScript的异步不是什么魔法,而是:
console.log('1');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
console.log('2');
// 输出:1 → 2 → promise → setTimeout
// 因为Promise是微任务,setTimeout是宏任务
// 微任务总是在宏任务之前执行
第二个问题是竟态条件:
// 用户快速改三个值,触发三个fetch
// request 1, 2, 3同时发出
// 但response可能这样返回:3先到 → 2后到 → 1最后到
// 结果UI会显示过时的数据
// 解决方案:添加版本号
let latestVersion = 0;
input.addEventListener('change', () => {
const version = ++latestVersion;
fetch('/api/save', {...})
.then(res => res.json())
.then(data => {
if (version === latestVersion) {
// 只处理最新的请求
updateUI(data);
}
});
});
这个版本号模式,我见过在React中以不同形式出现——cancelToken、AbortController等。
但如果我没有经历过这个问题,我永远不会理解为什么需要这些机制。
30天后,我回到React。代码看起来完全不同。
同样的useEffect代码:
useEffect(() => {
let mounted = true;
const fetchData = async () => {
const res = await fetch('/api/data');
const data = await res.json();
if (mounted) {
setData(data);
}
};
fetchData();
return() => { mounted = false; };
}, []);
现在我知道这段代码在干什么:
这不是记住的规则,而是我理解的原理。
最关键的改变是——我能debug了。
以前遇到bug,我会:
现在遇到bug,我会:
30天的原生JavaScript,和5年的React,让我看清了两者:
React 原生JavaScript
────────────────────────────────────
开发速度 超快 很慢
学习难度 中等 高
代码量 少 多
性能 好 取决于你
理解深度 浅 深
对初学者:React让你快速建立成就感
对老手:原生JavaScript让你真正懂编程
但更重要的是——现在我知道React在做什么了。
useState = 闭包 + 状态存储 + render触发 useEffect = 闭包 + 生命周期 + cleanup useCallback = 闭包 + 依赖比较
框架没有魔法。只有JavaScript。
如果你也想改变这个现状,不需要像我那样极端。但你要有意识地学。
这是最重要的。理解闭包,你就理解了Hooks的核心。
// React Hooks的工作原理简化版
let componentHooks = [];
let hookIndex = 0;
function useState(initialValue) {
const currentIndex = hookIndex++;
if (!componentHooks[currentIndex]) {
componentHooks[currentIndex] = initialValue;
}
const setState = (newValue) => {
componentHooks[currentIndex] = newValue;
reRender();
};
return [componentHooks[currentIndex], setState];
}
// 现在你明白了:
// 1. 为什么不能在条件里调用Hooks(会导致hookIndex错误)
// 2. 为什么不能改变调用顺序(顺序就是mapping关键)
// 3. 为什么setCount能记住count(闭包)
理解这个,你就能debug异步bug。
console.log('sync 1');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('sync 2');
// 思考为什么这个顺序是 sync 1 → sync 2 → promise → timeout
竟态条件是常见bug的根源。理解这个,你能写出健壮的代码。
// ❌ 容易出bug
const handleSearch = (query) => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults); // 如果有多个请求,返回顺序不同就buggy
};
// ✅ 正确
const handleSearch = (() => {
let latestQuery = null;
return(query) => {
latestQuery = query;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (latestQuery === query) {
setResults(data);
}
});
};
})();
不一定要像我那样花30天。但你可以:
不要太简单(hello world没用),不要太复杂(会失败)。
一个待办应用就很好。
不要马上Google。问自己:
原生版本100行,React版本30行。
想想React为你隐藏了什么复杂性。
不用读全部。但看看useState是怎么实现的,你会get到闭包的威力。
从"怎么用"改成"为什么这样"。
从"代码能工作"改成"我知道为什么能工作"。
如果你现在有3-5年框架经验,但对基础有些模糊……
不是你的问题。是框架成功的代价。
但你完全可以改变。
可能不需要30天。可能只需要5天、一周、一个月。
关键是有意识地去学JavaScript的基础。
一旦你理解了——
闭包不再是"函数能访问外层变量"这种抽象定义。
事件循环不再是"微任务比宏任务先执行"这种记忆。
异步不再是"奇怪的执行顺序"。
它们都变成——"啊,原来如此"。
那一刻,你写的代码会不一样。你debug的方式会不一样。你对编程的理解会不一样。
你会从"会用框架的工程师",变成"真正懂编程的开发者"。
我想听你的故事。
在你的React/Vue生涯中,有没有那么一个时刻,你意识到自己好像不懂某个基础概念?
或者——有没有一个bug,因为不理解基础,折腾了很久才解决?
分享在评论区。
我很好奇,有多少人有过我这样的经历。
有多少人,也在框架的舒适区里,假装不知道下面发生了什么。
也许,一个故事,就能改变一个人的学习路径。