前言
在上一篇文章中,我們實現了diff演算法,效能有非常大的改進。但是文章末尾也指出了一個問題:按照目前的實現,每次呼叫setState都會觸發更新,如果元件內執行這樣一段程式碼:
for ( let i = 0; i < 100; i++ ) {
this.setState( { num: this.state.num + 1 } );
}
那麼執行這段程式碼會導致這個元件被重新渲染100次,這對效能是一個非常大的負擔。
真正的React是怎麼做的
React顯然也遇到了這樣的問題,所以針對setState做了一些特別的優化:React會將多個setState的呼叫合併成一個來執行,這意味著當呼叫setState時,state並不會立即更新,舉個例子:
class App extends Component {
constructor() {
super();
this.state = {
num: 0
}
}
componentDidMount() {
for ( let i = 0; i < 100; i++ ) {
this.setState( { num: this.state.num + 1 } );
console.log( this.state.num ); // 會輸出什麼?
}
}
render() {
return (
<div className="App">
<h1>{ this.state.num }</h1>
</div>
);
}
}
我們定義了一個App
元件,在元件掛載後,會迴圈100次,每次讓this.state.num
增加1,我們用真正的React來渲染這個元件,看看結果:
元件渲染的結果是1,並且在控制檯中輸出了100次0,說明每個迴圈中,拿到的state仍然是更新之前的。
這是React的優化手段,但是顯然它也會在導致一些不符合直覺的問題(就如上面這個例子),所以針對這種情況,React給出了一種解決方案:setState接收的引數還可以是一個函式,在這個函式中可以拿先前的狀態,並通過這個函式的返回值得到下一個狀態。
我們可以通過這種方式來修正App元件:
componentDidMount() {
for ( let i = 0; i < 100; i++ ) {
this.setState( prevState => {
console.log( prevState.num );
return {
num: prevState.num + 1
}
} );
}
}
這種用法是不是很像陣列的reduce
方法?
現在來看看App元件的渲染結果:
現在終於能得到我們想要的結果了。
所以,這篇文章的目標也明確了,我們要實現以下兩個功能:
- 非同步更新state,將短時間內的多個setState合併成一個
- 為了解決非同步更新導致的問題,增加另一種形式的setState:接受一個函式作為引數,在函式中可以得到前一個狀態並返回下一個狀態
合併setState
回顧一下第二篇文章中對setState的實現:
setState( stateChange ) {
Object.assign( this.state, stateChange );
renderComponent( this );
}
這種實現,每次呼叫setState都會更新state並馬上渲染一次。
setState佇列
為了合併setState,我們需要一個佇列來儲存每次setState的資料,然後在一段時間後,清空這個佇列並渲染元件。
佇列是一種資料結構,它的特點是“先進先出”,可以通過js陣列的push和shift方法模擬
const queue = [];
function enqueueSetState( stateChange, component ) {
queue.push( {
stateChange,
component
} );
}
然後修改元件的setState方法
setState( stateChange ) {
enqueueSetState( stateChange, this );
}
現在佇列是有了,怎麼清空佇列並渲染元件呢?
清空佇列
我們定義一個flush方法,它的作用就是清空佇列
function flush() {
let item;
// 遍歷
while( item = setStateQueue.shift() ) {
const { stateChange, component } = item;
// 如果沒有prevState,則將當前的state作為初始的prevState
if ( !component.prevState ) {
component.prevState = Object.assign( {}, component.state );
}
// 如果stateChange是一個方法,也就是setState的第二種形式
if ( typeof stateChange === 'function' ) {
Object.assign( component.state, stateChange( component.prevState, component.props ) );
} else {
// 如果stateChange是一個物件,則直接合併到setState中
Object.assign( component.state, stateChange );
}
component.prevState = component.state;
}
}
這只是實現了state的更新,我們還沒有渲染元件。渲染元件不能在遍歷佇列時進行,因為同一個元件可能會多次新增到佇列中,我們需要另一個佇列儲存所有元件,不同之處是,這個佇列內不會有重複的元件。
我們在enqueueSetState時,就可以做這件事
const queue = [];
const renderQueue = [];
function enqueueSetState( stateChange, component ) {
queue.push( {
stateChange,
component
} );
// 如果renderQueue裡沒有當前元件,則新增到佇列中
if ( !renderQueue.some( item => item === component ) ) {
renderQueue.push( component );
}
}
在flush方法中,我們還需要遍歷renderQueue,來渲染每一個元件
function flush() {
let item, component;
while( item = queue.shift() ) {
// ...
}
// 渲染每一個元件
while( component = renderQueue.shift() ) {
renderComponent( component );
}
}
延遲執行
現在還有一件最重要的事情:什麼時候執行flush方法。
我們需要合併一段時間內所有的setState,也就是在一段時間後才執行flush方法來清空佇列,關鍵是這個“一段時間“怎麼決定。
一個比較好的做法是利用js的事件佇列機制。
先來看這樣一段程式碼:
setTimeout( () => {
console.log( 2 );
}, 0 );
Promise.resolve().then( () => console.log( 1 ) );
console.log( 3 );
你可以開啟瀏覽器的除錯工具執行一下,它們列印的結果是:
3
1
2
具體的原理可以看阮一峰的這篇文章,這裡就不再贅述了。
我們可以利用事件佇列,讓flush在所有同步任務後執行
function enqueueSetState( stateChange, component ) {
// 如果queue的長度是0,也就是在上次flush執行之後第一次往佇列裡新增
if ( queue.length === 0 ) {
defer( flush );
}
queue.push( {
stateChange,
component
} );
if ( !renderQueue.some( item => item === component ) ) {
renderQueue.push( component );
}
}
定義defer方法,利用剛才題目中出現的Promise.resolve
function defer( fn ) {
return Promise.resolve().then( fn );
}
這樣在一次“事件迴圈“中,最多隻會執行一次flush了,在這個“事件迴圈”中,所有的setState都會被合併,並只渲染一次元件。
別的延遲執行方法
除了用Promise.resolve().then( fn )
,我們也可以用上文中提到的setTimeout( fn, 0 )
,setTimeout的時間也可以是別的值,例如16毫秒。
16毫秒的間隔在一秒內大概可以執行60次,也就是60幀,人眼每秒只能捕獲60幅畫面
另外也可以用requestAnimationFrame
或者requestIdleCallback
function defer( fn ) {
return requestAnimationFrame( fn );
}
試試效果
就試試渲染上文中用React渲染的那兩個例子:
class App extends Component {
constructor() {
super();
this.state = {
num: 0
}
}
componentDidMount() {
for ( let i = 0; i < 100; i++ ) {
this.setState( { num: this.state.num + 1 } );
console.log( this.state.num );
}
}
render() {
return (
<div className="App">
<h1>{ this.state.num }</h1>
</div>
);
}
}
效果和React完全一樣
同樣,用第二種方式呼叫setState:
componentDidMount() {
for ( let i = 0; i < 100; i++ ) {
this.setState( prevState => {
console.log( prevState.num );
return {
num: prevState.num + 1
}
} );
}
}
結果也完全一樣:
後話
在這篇文章中,我們又實現了一個很重要的優化:合併短時間內的多次setState,非同步更新state。
到這裡我們已經實現了React的大部分核心功能和優化手段了,所以這篇文章也是這個系列的最後一篇了。
這篇文章的所有程式碼都在這裡:https://github.com/hujiulong/...
從零開始實現React系列
React是前端最受歡迎的框架之一,解讀其原始碼的文章非常多,但是我想從另一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程中去探索為什麼有虛擬DOM、diff、為什麼setState這樣設計等問題。
整個系列大概會有四篇左右,我每週會更新一到兩篇,我會第一時間在github上更新,有問題需要探討也請在github上回復我~
部落格地址: https://github.com/hujiulong/...
關注點star,訂閱點watch