前言
眾所周知,hooks在 React@16.8 中已經正式釋出了。而下週週會,我們團隊有個同學將會仔細介紹分享一下hooks。最近網上呢有不少hooks的文章,這不免激起了我自己的好奇心,想先行探探hooks到底好不好用。
react hooks在其文件的最開頭,就闡明瞭hooks的一個鮮明作用跟幾個動機(或者說hooks的好處)。
明確的作用
它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。
意思很明瞭,就是擴充函式式元件的邊界。結果也很清晰,只要Class 元件能實現的,函式式元件+Hooks都能勝任。
動機
文件中列了三點:
- 在元件之間複用狀態邏輯很難;
- 複雜元件變得難以理解;
- class讓開發人員與計算機都難理解;
關於這三點的詳細介紹文件裡也有,還有中文的,我就不多說了。
我對動機的理解
動機也即是hooks能帶來的好處。其中第三點,文件中所說class的弊端對於我本人,還是有點兒不痛不癢。this的問題,箭頭函式解決的差不多了;語法提案也到stage-3了;程式碼壓縮什麼的,自己的資原始碼大小往往不是核心問題。
現在說利用hooks可以勝任class元件所有的能力。但你勝任歸你勝任,我寫class又有什麼不可以。我繼承、高階騷的一逼,要啥hooks。
然而第1、2兩點還是吸引了我的注意。狀態邏輯的複用,之前我主要採用高階元件+繼承,雖然也能解決,但hooks似乎有更優雅的方案。複雜元件變得難以理解,這個也確實是平常中遇到的問題,一個元件寫著寫著狀態越來越多,抽成子元件吧props跟state又傳來傳去。三個月後,自己的程式碼自己已經看不懂了。
那hooks真的就能更好的解決這些問題麼?文件裡輕飄飄的幾句話,對於實際業務來說,確實沒有太多體感。於是我決定簡單寫幾個場景,探一探這hooks的活到底好不好。
狀態邏輯的複用
這種場景其實挺常見。只要頁面中有需要複用的元件,且這個元件又有較為複雜的狀態邏輯,就會有這樣的需求。舉個例子:中後臺系統常見的各種列表,表格內容各不相同,但是都要有分頁的行為,於是分頁元件就需要去抽象。按照正常的寫法,我們會怎麼做呢?
傳統流派
最開始,我們可能不會想著通用,就寫一個列表+分頁的元件。以最簡單的分頁為例,可能會如下寫(為方便閱讀,不做太多異常處理):
import { Component } from 'react';
import { range } from 'lodash';
// 模擬列表資料請求
const fetchList = ({ page = 1, size = 10 }) =>
new Promise(resolve => resolve(range((page - 1) * size, page * size)))
export default class ListWithPagination extends Component {
state = {
page: 1,
data: [],
}
componentDidMount() {
this.fetchListData(this.setState);
}
handlePageChange = newPage =>
this.setState({ page: newPage }, this.fetchListData)
fetchListData = () => {
const { page } = this.state;
fetchList({ page }).then(data => this.setState({ data }));
}
render() {
const { data, page } = this.state;
return (
<div>
<ul className="list">
{data.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
<div className="nav">
<button type="button" onClick={() => this.handlePageChange(page - 1)}>
上一頁
</button>
<label>當前頁: {page}</label>
<button type="button" onClick={() => this.handlePageChange(page + 1)}>
下一頁
</button>
</div>
</div>
);
}
}
複製程式碼
然後我們就會想,每個地方都要有分頁,唯一不太一樣的僅是 列表渲染 跟資料請求api而已,那何不抽個高階元件呢?於是程式碼變成了:
export default function ListHoc(ListComponent) {
return class ListWithPagination extends Component {
// ...同上述code,省略
// 資料請求方法,從props中傳入
fetchListData = () => {
const { fetchApi } = this.props;
const { page } = this.state
return fetchApi({ page }).then(data => this.setState({ data }));
}
render() {
const { data, page } = this.state;
return (
<div>
<ListComponent data={data} />
<div className="nav">...省略</div>
</div>
);
}
};
}
複製程式碼
這麼一來,未來再寫列表時,使用高階元件包裹一下,再把資料請求方法 以props傳入,就能達到一個複用狀態邏輯與分頁元件的效果了。
就在我們得意之際,又來了一個新需求,說有一個列表的分頁導航,需要在 列表上面,而不是 列表下面,換成程式語言意思就是Dom的結構與樣式有變更。唔.....仔細想想有幾種方案:
- 傳遞一個props叫“theme”,控制不同的順序跟樣式....乍一看還行,但如果未來兩種列表風格越來越遠,這個高階元件會越來越重....不行不行。
- 再寫一個類似的高階元件,dom結構不一樣,但其他一模一樣。唔,程式碼重複度這麼高,真low,不行不行。
- 再寫一個元件,繼承這個這個高階元件,重寫render。好像還可以,就是這個繼承關係略略有點兒奇怪,應該是兄弟關係,而不是繼承關係。當然我可以再抽象一層包含狀態邏輯處理的通用Component,兩種列表形式的高階元件都是繼承它,而不是繼承 React.Component。但是即使如此,通過繼承來複寫render的方式,無法清晰感知元件到底有哪些狀態值,尤其在狀態較多,邏輯較為複雜的情況下。這樣日後維護,或者擴充render時,就舉步維艱。
這也不行,那也不好。那用hooks來做又能做成哪樣呢?
Hooks流派
注:為了簡化,下文中的 effect 都指代 side effect。
嘗試改造
首先,我們把最開始那個 ListWithPagination
以hooks改寫,那就成了:
import { useState, useEffect } from 'react';
import { range } from 'lodash';
// 模擬列表資料請求
const fetchList = ({ page = 1, size = 10 }) =>
new Promise(resolve => resolve(range((page - 1) * size, page * size)));
export default function List() {
const [page, setPage] = useState(1); // 初始頁碼為: 1
const [list, setList] = useState([]); // 初始列表資料為空陣列: []
useEffect(() => {
fetchList({ page }).then(setList);
}, [page]); // 當page變更時,觸發effect
const prevPage = () => setPage(currentPage => currentPage - 1);
const nextPage = () => setPage(currentPage => currentPage + 1);
return (
<div>
<ul>
{list.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
<div>
<button type="button" onClick={prevPage}>
上一頁
</button>
<label>當前頁: {page}</label>
<button type="button" onClick={nextPage}>
下一頁
</button>
</div>
</div>
);
}
複製程式碼
為防止部分同學不理解,我再簡單介紹下 useState 與 useEffect。
- useState: 執行後,返回一個陣列,第一個值為狀態值,第二個值為更新此狀態值的對應方法。useState函式入參為state初始值。
- useEffect:執行副作用操作。第一個引數為副作用方法,第二個引數是一個陣列,填寫副作用依賴項。當依賴項變了時,副作用方法才會執行。若為空陣列,則只執行一次。如不填寫,則每次render都會觸發。
如果對此還是不理解,建議先看下相關文件。如果關於副作用不理解,可以到文章最後再看。在我們當下的場景中,知道非同步請求資料並更新元件內部狀態值就屬於副作用的一種即可。
知道基本概念以後,我們看上述的程式碼,其實也大致能理解其機制。
- 元件初始化也即第一次render後,會觸發一次effect,請求第一頁資料後,更新列表資料
list
,進而又觸發第二次render。 - 在第二次render中,useState會獲取當前的
list
值,而不是初始值,進而頁面渲染新的列表。至於react如何做到能資料的匹配,文件裡有簡單介紹。 - 在後續的使用者點選行為中,觸發了setPage,進而更新了
page
,由於它的變更觸發了effect,effect執行後又更新list
,觸發新的render,渲染最新的列表。
在瞭解機制以後,我們就要開始做正經事了。上述傳統流派中,通過高階元件抽象公共邏輯。現在我們通過hooks改造了最初的class元件。下一步應該抽離狀態邏輯。類似剛剛高階元件的結果,我們期望將分頁的行為抽離,那太簡單了,把處理狀態的相關程式碼封裝成函式,抽離出元件,再傳遞一下資料請求api就好:
// 傳遞獲取資料api,返回 [當前列表,分頁資料,分頁行為]
const usePagination = (fetchApi) => {
const [page, setPage] = useState(1); // 初始頁碼為: 1
const [list, setList] = useState([]); // 初始列表資料為空陣列: []
useEffect(() => {
fetchApi({ page }).then(setList);
}, [page]); // 當page變更時,觸發effect
const prevPage = () => setPage(currentPage => currentPage - 1);
const nextPage = () => setPage(currentPage => currentPage + 1);
return [list, { page }, { prevPage, nextPage }];
};
export default function List() {
const [list, { page }, { prevPage, nextPage }] = usePagination(fetchList);
return (
<div>...省略</div>
);
}
複製程式碼
如果你希望分頁的dom結構也想複用,那就再抽個函式便好。
function renderCommonList({ ListComponent, fetchApi }) {
const [list, { page }, { prevPage, nextPage }] = usePagination(fetchApi);
return (
<div>
<ListComponent list={list} />
<div>
<button type="button" onClick={prevPage}>
上一頁
</button>
<label>當前頁: {page}</label>
<button type="button" onClick={nextPage}>
下一頁
</button>
</div>
</div>
);
}
export default function List() {
function ListComponent({ list }) {
return (
<ul>
{list.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
);
}
return renderCommonList({
ListComponent,
fetchApi: fetchList,
});
}
複製程式碼
如果你希望有一個新的分頁結構與樣式,那就重寫一個結構,並引用 usePagination
。總之,最核心的狀態處理邏輯已經被我們抽離出來,因為無關this,於是它與元件無關、與dom也可以無關。愛插哪插哪,誰愛用誰用。百花叢中過,片葉不沾身。
這麼一來,資料層與dom更加的分離,react元件更加的退化成一層UI層,進而更易閱讀、維護、擴充。
場景深入
不過不能開心的太早。做事如果淺嘗則止,往往後續會遇到深坑。就以剛剛的需求來說,有些特殊邏輯還未考察到。假如說,我們的分頁請求會失敗,而頁碼已經更新,這該怎麼辦?一般來說有幾個思路:
- 請求失敗以後回滾頁碼。但實現不優雅,且頁碼跳來跳去,放棄。
- 資料請求成功以後再更新頁碼。比較適合移動端滾動載入的情況。
- 不回滾頁碼,列表頁提示異常,點選觸發重試。比較適合上述中分頁列表的情況。
那我們就按方案3,暴露一個error的狀態,提供一個重新整理頁面的方法。我們突然意識到一個問題,如何重新整理頁面資料呢?我們的effect依賴於page變更,而重新整理頁面不變更page,effect便不會觸發。想一下,也有兩個思路:
- 再加一個關於重新整理的狀態值,重新整理頁面資料的方法,每次執行都會為其+1,觸發effect。不過這樣會導致元件平白無故加個狀態值。
- 依賴項改為一個物件,page為物件中一個屬性,日後也方便擴充。由於物件無法對比的特性,每次setState都會觸發effect。不過有可能會導致資料無意義的重複獲取,比如快速點選同一個頁碼時,觸發了兩次資料獲取。
綜合考慮來說,我採取第二個方案。因為effect強依賴於入參的變更也不合理,畢竟這是一個有副作用的方法。相同的分頁入參下,服務端也有可能返回不同的結果。資料重複獲取的問題,可以手動加入防抖等手段優化。具體程式碼如下:
const usePagination = (fetchApi) => {
const [query, setQuery] = useState({ page: 1, size: 15 }); // 初始頁碼為: 1
const [isError, setIsError] = useState(false); // 初始狀態為false
const [list, setList] = useState([]); // 初始列表資料為空陣列: []
useEffect(() => {
setIsError(false);
fetchApi(query)
.then(setList)
.catch(() => setIsError(true));
}, [query]); // 當頁面查詢引數變更時,觸發effect
const { page, size } = query;
const prevPage = () => setQuery({ size, page: page - 1 });
const nextPage = () => setQuery({ size, page: page + 1 });
const refreshPage = () => setQuery({ ...query });
// 如果資料過多,陣列解構麻煩,也可以選擇返回物件
return [list, query, { prevPage, nextPage, refreshPage }, isError];
};
複製程式碼
但是如果按照方案2呢?「資料請求成功以後再更新頁碼」。在移動端的長列表滾動載入時,頁面並不透出頁碼,滾動載入失敗時,toast提示失敗,再滾動依舊載入剛剛失敗的那一頁。然而在我們的 usePagination
中,資料請求的effect必須是通過query變更來觸發的,無法實現請求結束以後再更改頁碼。如果是通過方案1「請求失敗以後回滾頁碼」,那由於回滾了頁面,又會觸發一次effect請求,這也不是我們想看到的。
其實這是鑽了牛角尖,這本身已經是不同的場景了。在移動端的滾動載入中,是否載入並非是由“頁碼變更”控制,而是由“是否滾動到底部”控制。於是程式碼應該是:
// 滾動到底部時,執行
const useBottom = (action, dependencies) => {
function doInBottom() {
// isScrollToBottom 的程式碼不貼了
return isScrollToBottom() && action();
}
useEffect(() => {
window.addEventListener('scroll', doInBottom);
// 元件解除安裝或函式下一次執行時,會先執行上一次函式內部return的方法
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
// 因為每次是請求下一頁資料,所以現在初始頁碼為: 0
const [query, setQuery] = useState({ page: 0, size: 50 });
const [list, setList] = useState([]); // 初始列表資料為空陣列: []
const fetchAndSetQuery = () => {
// 每次請求下一頁資料
const newQuery = {
...query,
page: query.page + 1,
};
fetchApi(newQuery)
.then((newList) => {
// 成功後插入新列表資料,並更新最新分頁引數
setList([...list, ...newList]);
setQuery(newQuery);
})
.catch(() => window.console.log('載入失敗,請重試'));
};
// 首次mount後觸發資料請求
useEffect(fetchAndSetQuery, []);
// 滾動到底部觸發資料請求
useBottom(fetchAndSetQuery);
return [list];
};
複製程式碼
其中在 useBottom
內的effect函式中,返回了一個解綁滾動事件的函式,在元件解除安裝或者下一次effect觸發時,會先執行此函式進行解綁行為。在傳統的class元件中,我們一般是在unmount階段去解綁事件。如果副作用依賴了props或state,在update階段可能也需要清除老effect,執行新effect。如此一來,處理統一邏輯的函式就被分散在多個地方,導致元件複雜度的上升。
另外眼尖的同學會發現,為什麼useBottom內部的useEffect的依賴項,在我們這個場景中不設定呢?滾動事件,不是應該mount的時候初始化就好了嗎?按之前的理解,應該是寫一個空陣列[],這樣滾動事件只繫結一次。然而如果我們真的這樣寫: useBottom(fetchAndSetQuery, [])
的話,就會發現一個大bug。 fetchAndSetQuery
中的query與list 永遠都是初始化時的資料,也即是 { page: 0, size: 50 }
與 []
。結果就是每次滾動到底部,載入的還是第一頁資料,渲染的也還是第一頁資料([...[], ...第一頁資料])。
Why!!!
於是我又閱讀了一次uesEffect的相關文件,揣摩了一番,終於大致領悟。
useState與useEffect的正確使用姿勢
state永遠都是新的值
這一點同我們過去class元件中的state是完全不一樣的。在class元件中,state一直是掛載在當前例項下,保持著同一個引用。而在函式式元件中,根本沒有this。不管你的state是一個基本資料型別(如string、number),還是一個引用資料型別(如object),**只要是通過useState獲取的state,每一次render,都是新的值。**useState返回的狀態更新方法,只是讓下一次render時的state能獲取到當前最新的值。而不是保持一個引用、更新那個引用值。(這一段如果看不懂,就多看幾遍,如果還看不懂,請評論區溫柔的指出,我想想再怎麼通俗的去解釋)
讀懂這個概念,並把這個概念作為hooks使用的第一準則後,我們就能清晰的明白,為什麼上述程式碼中,如果useBottom
中的useEffect的依賴項設為空陣列,則內部的state,也即query與list,永遠都是初始值。因為設為空陣列後,其內部的 useEffect 中的滾動監聽函式 內執行的 fetchAndSetQuery函式,其內部的query與list,也一直是第一次render時 useState 返回的值。
而如果不是空陣列,每次render後,useBottom
中的滾動監聽函式,會重新解綁舊函式,繫結新函式。新的函式帶來的是 最新一次render時,useState 返回的最新狀態值,故而實現正確的邏輯。
正確認識依賴項
於是我們更能深刻的認識到,為什麼useEffect的依賴項設定如此重要。其實並非是設定依賴項後,依賴變更會觸發effect。而是effect本應該每次render都觸發,但因為effect內部依賴了外部資料,外部資料不變則內部effect執行無意義。因此只有當外部資料變更時,effect才會重新觸發。
所以科學的來說,只要內部使用了某個外部變數,函式也好、變數也好,都應該填寫到依賴配置中。所以我們上述編寫的 useBottom
與使用方法其實並不嚴謹,我們再review一遍:
const useBottom = (action, dependencies) => {
function doInBottom() {
// isScrollToBottom 的程式碼不貼了
return isScrollToBottom() && action();
}
useEffect(() => {
window.addEventListener('scroll', doInBottom);
// useEffect內部return的方法,會在下一次render時執行
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
// ...
useBottom(fetchAndSetQuery);
// ...
};
複製程式碼
我們可以明確知道兩點不對的:
- 在這個場景中,useEffect明確依賴了
doInBottom
,因此,useEffect的依賴項至少應該填寫doInBottom
。當然,我們也選擇把doInBootom
寫到useEffect內部中,這樣這個函式就成了內部引用,而不是外部依賴。 action
是一個未知的函式,其內部可能包含了外部依賴,我們傳遞的dependencies
應該是滿足action
的明確依賴的,而不是自己瞎想到底是不填還是空陣列。當然,更粗暴的方法是,直接把action
作為依賴項。
所以最終科學的程式碼應該是:
const useBottom = (action) => {
useEffect(() => {
function doInBottom() {
return isScrollToBottom() && action();
}
window.addEventListener('scroll', doInBottom);
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, [action]);
};
const usePagination = (fetchApi) => {
// ...
useBottom(fetchAndSetQuery);
// ...
};
複製程式碼
偏要勉強
但還是有些同學,不喜歡這個依賴項,嫌傳來傳去的太麻煩,那有沒有辦法不傳?還是有一些辦法的。
首先,useState 返回的setState可以接受一個函式,函式的入參即是當前最新的狀態值。在剛剛滾動載入的例子中,就可以避免了 list
成為副作用的依賴。不過 query
依舊沒辦法,因為請求資料需要最新狀態值。但如果我們每一頁資料的數量是固定的,我們可以把頁碼狀態封裝在請求方法裡,如:
// 利用閉包維持分頁狀態
const fetchNextPage = ({ initPage, size }) => {
let page = initPage - 1;
return () => fetchList({ page: page + 1, size }).then((rs) => {
page += 1;
return rs;
});
};
複製程式碼
然後我們的 useBottom
可以真的不管關心依賴了,只需要第一次render時繫結滾動事件即可,程式碼如下:
const useBottom = (action, dependencies) => {
useEffect(() => {
function doInBottom() {
return isScrollToBottom() && action();
}
window.addEventListener('scroll', doInBottom);
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
const [list, setList] = useState([]); // 初始列表資料為空陣列: []
const fetchData = () => {
fetchApi()
.then((newList) => {
setList(oldList => [...oldList, ...newList]);
})
.catch(() => window.console.log('載入失敗,請重試'));
};
useEffect(fetchData, []);
useBottom(fetchData, []);
return [list];
};
export default function List() {
const [list] = usePagination(fetchNextPage({ initPage: 1, size: 50 }));
return (...略);
}
複製程式碼
其實useState返回的setState還有一個小弊端。如果頁面狀態較多,在某些非同步行為(請求、定時器等)的回撥中的setState是不會合並更新的(具體可自行研究react狀態更新事務機制)。那分散的setState會帶來多次render,這必然不是我們想看到的。
解決辦法就是 useReducer
,其執行後返回 [state, dispatch]
,基本類似redux中的reducer。其中state是複雜狀態的合集,dispatch觸發reducer後,返回一個全新的狀態值。具體用法可以見文件。其中主要記住兩點:
- dispatch本身是穩定的,不會隨多次render而導致變化,且dispatch觸發的reducer函式,其入參的state始終是當下最新值。所以若是新狀態的設定依賴於舊狀態值,通過dispatch來更新,也可以避免effect依賴外部state。
useReducer
返回的state(並非reducer函式中的入參state),依舊遵循useState那套邏輯,每次render中獲取的都是全新值而非同一個引用。
既然有了useReducer,那有沒有 useRedux
呢?抱歉,並沒有。不過 Redux
目前已有issue在討論其hooks的實現了。也有外國網友做了一個簡版的 useRedux,實現機制也非常簡單,自己也能維護。如果有全域性狀態管理的需求,也可以做一下程式碼的搬運工。
相信在19年,將會有很多基於hooks的工具甚至是hooks庫的出現。通過對狀態邏輯的抽象、更方便的狀態管理、更科學的函式組合與拆分,最開始所說的動機第二點「難以理解的複雜元件」在將來可能真的可以更好的避免。
總結
探到這裡,我個人對hooks已經基本有個數了。它脫離了我傳統的class元件開發方式,對state的定義也不同於元件中的this.state,對effect的概念與處理需要更加清晰明瞭。
使用hooks的明顯好處是可以更好的抽象包含狀態的邏輯,隱藏的一些功能是基於hooks的各種花式輪子。當然其“不好的地方”是有一個明顯的認知與學習成本。雖然比不上幾年前 直接操作dom 躍遷到** 資料驅動DOM** 這樣的革命性變更,但確實是react內部明顯的革命性成就。
不知道各位看完以後,未來是傾向於 函式式元件+hooks 還是傾向於 class元件。可以在評論區進行一下小投票。就我個人而言,我站hooks。
關於React副作用
有些同學可能會對「副作用」這個概念不理解。我簡單的說一下我的看法。很多人都看過一個React公式
UI = F(props)
翻譯成普通話就是:一個元件最終的dom結構與樣式是由父級傳遞的props決定的。
瞭解過函數語言程式設計的同學,應該知道過一個概念,叫「純函式」。意思是固定的輸入必然有固定的輸出,它不依賴任何外部因素,也不會對外部環境產生影響。
react希望自己的元件渲染也是個純函式,所以有了純函式元件。然而真正的業務場景是有各種狀態的,實際影響UI的還有內部的state。(其實還有context,暫時先不討論)。
UI = F(props, state, context)
這個state可能會因為各種原因產生變化,從而導致元件的渲染結果不一致。相同的入參(props)下,每次render都有可能返回不同的UI。因此任何導致此現象的行為都是副作用(side effects)。比如使用者點選下一頁,導致頁碼與列表發生變化,這就是副作用。同樣的props,不點選時是第一頁資料,點選一下後,變成了第二頁的資料or請求失敗的頁面or其他UI互動。
當然state是明面上影響了UI,暗地裡,可能還有其他因素會影響UI。比如元件內運用了快取,導致每次渲染可能都不一樣,這也是副作用。
關於我們:
我們是螞蟻保險體驗技術團隊,來自螞蟻金服保險事業群。我們是一個年輕的團隊(沒有歷史技術棧包袱),目前平均年齡92年(去除一個最高分8x年-團隊leader,去除一個最低分97年-實習小老弟)。我們支援了阿里集團幾乎所有的保險業務。18年我們產出的相互寶轟動保險界,19年我們更有多個重量級專案籌備動員中。現伴隨著事業群的高速發展,團隊也在迅速擴張,歡迎各位前端高手加入我們~
我們希望你是:技術上基礎紮實、某領域深入(Node/互動營銷/資料視覺化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。
如有興趣加入我們,歡迎傳送簡歷至郵箱:fengxiang.zfx@antfin.com
本文作者:螞蟻保險-體驗技術組-阿相
掘金地址:相學長