一.Class Component VS Function Component
1.Capture(捕获) 特性
对比下面两段代码
Function Component
function Counter() {
const [count, setCount] = useState(0);
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(count); // 0 1 2
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
Class Component
class Counter extends Component {
state = { count: 0 };
log = () => {
this.setState({
count: this.state.count + 1
});
setTimeout(() => {
console.log(this.state.count); // 3 3 3
}, 3000);
};
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.log}>Click me</button>
</div>
);
}
}
在3s内连续点击button3次,Class和Function的结果完全不同
Class Component
首先 state 是 Immutable(不可变) 的,setState 后一定会生成一个全新的 state 引用。但 Class Component 通过 this.state 方式读取 state,这导致了每次代码执行都会拿到最新的 state 引用,所以快速点击三次的结果是 3 3 3。
Function Component
useState 产生的数据也是 Immutable(不可变) 的,通过数组第二个参数 Set 一个新值后,原来的值会形成一个新的引用在下次渲染时。但由于对 state 的读取没有通过 this. 的方式,使得每次 setTimeout 都读取了当时渲染闭包环境的数据,虽然最新的值跟着最新的渲染变了,但旧的渲染里,状态依然是旧值。
为了更容易理解,来模拟三次 Function Component 模式下点击按钮时的状态:
第一次点击,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此时状态为:
function Counter() {
const [0, setCount] = useState(0);
const log = () => {
setCount(0 + 1);
setTimeout(() => {
console.log(0);
}, 3000);
};
return ...
}
第二次点击,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此时状态为:
function Counter() {
const [1, setCount] = useState(0);
const log = () => {
setCount(1 + 1);
setTimeout(() => {
console.log(1);
}, 3000);
};
return ...
}
第三次点击,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此时状态为:
function Counter() {
const [2, setCount] = useState(0);
const log = () => {
setCount(2 + 1);
setTimeout(() => {
console.log(2);
}, 3000);
};
return ...
}
可以看到,每一个渲染都是一个独立的闭包,在独立的三次渲染中,count 在每次渲染中的值分别是 0 1 2,所以无论 setTimeout 延时多久,打印出来的结果永远是 0 1 2。
2.如何规避掉capture特性?
第一种方法是useRef
function Counter() {
const count = useRef(0);
const log = () => {
count.current++;
setTimeout(() => {
console.log(count.current);
}, 3000);
};
return (
<div>
<p>You clicked {count.current} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
这样打印结果就是 3 3 3。
useRef 的功能:通过 useRef 创建的对象,其值只有一份,而且在所有 Rerender 之间共享。
所以我们对 count.current 赋值或读取,读到的永远是其最新值,而与渲染闭包无关,因此如果快速点击三下,必定会返回 3 3 3 的结果。
但这种方案有个问题,就是使用 useRef 替代了 useState 创建值,那么很自然的问题就是,如何不改变原始值的写法,达到同样的效果呢?
function Counter() {
const [count, setCount] = useState(0);
const currentCount = useRef(count);
useEffect(() => {
currentCount.current = count;
});
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
可以写成自定义hook
function useCurrentValue(value) {
const ref = useRef(0);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
function Counter() {
const [count, setCount] = useState(0);
const currentCount = useCurrentValue(count);
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
将 setTimeout 换成 setInterval 会怎样?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
运行的结果是每次都输出1
把useEffect的依赖加上代码的功能就会正常
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
return <h1>{count}</h1>;
}
这样还是有一个问题,每次计数器都会重新实例化,性能成本会增加
如何解决这个问题?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
利用 useState 的第二种赋值用法,不直接依赖 count,而是以函数回调方式进行赋值
这种写法的好处是
1.不依赖 count。
2.依赖项为 [],只有初始化会对 setInterval 进行实例化。
而之所以输出还是正确的 1 2 3 …,原因是 setCount 的回调函数中,c 值永远指向最新的 count 值,因此没有逻辑漏洞。
但是依旧还有一个需要考虑到的地方,如果同时用到了两个变量的话,这种方法将会失效,那么要怎么解决?
useReducer
const [state, dispatch] = useReducer(reducer, initialState);
useReducer 返回的结构与 useState 很像,只是数组第二项是 dispatch,而接收的参数也有两个,初始值放在第二位,第一位就是 reducer。
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
function reducer(state, action) {
switch (action.type) {
case "tick":
return {
...state,
count: state.count + state.step
};
}
}
可以看到,我们通过 reducer 的 tick 类型完成了对 count 的累加,而在 useEffect 的函数中,竟然完全绕过了 count、step 这两个变量。
这个例子还是有一个依赖的,那就是 dispatch,然而 dispatch 引用永远也不会变,因此可以忽略它的影响。
最后补充一个父组件 “坑” 子组件的经典案例。
function App() {
const [count, forceUpdate] = useState(0);
const schema = { b: 1 };
return (
<div>
<Child schema={schema} />
<div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
</div>
);
}
子组件的代码如下:
const Child = memo(props => {
useEffect(() => {
console.log("schema", props.schema);
}, [props.schema]);
return <div>Child</div>;
});
只要父级 props.schema 变化就会打印日志。结果自然是,父组件每次刷新,子组件都会打印日志,也就是 子组件 [props.schema] 完全失效了,因为引用一直在变化。
其实 子组件关心的是值,而不是引用,所以一种解法是改写子组件的依赖:
const Child = memo(props => {
useEffect(() => {
console.log("schema", props.schema);
}, [JSON.stringify(props.schema)]);
return <div>Child</div>;
});
可是真正罪魁祸首是父组件,我们需要利用 Ref 优化一下父组件:
function App() {
const [count, forceUpdate] = useState(0);
const schema = useRef({ b: 1 });
return (
<div>
<Child schema={schema.current} />
<div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
</div>
);
}
这样 schema 的引用能一直保持不变。如果你完整读完了本文,应该可以充分理解第一个例子的 schema 在每个渲染快照中都是一个新的引用,而 Ref 的例子中,schema 在每个渲染快照中都只有一个唯一的引用。