衆所周知,hooks在 React@16.8 中已經正式發佈了。而下週週會,咱們團隊有個同窗將會仔細介紹分享一下hooks。最近網上呢有很多hooks的文章,這難免激起了我本身的好奇心,想先行探探hooks到底好很差用。javascript
react hooks在其文檔的最開頭,就闡明瞭hooks的一個鮮明做用跟幾個動機(或者說hooks的好處)。html
它可讓你在不編寫 class 的狀況下使用 state 以及其餘的 React 特性。前端
意思很明瞭,就是拓展函數式組件的邊界。結果也很清晰,只要Class 組件能實現的,函數式組件+Hooks都能勝任。java
文檔中列了三點:react
關於這三點的詳細介紹文檔裏也有,還有中文的,我就很少說了。git
動機也便是hooks能帶來的好處。其中第三點,文檔中所說class的弊端對於我本人,仍是有點兒不痛不癢。this的問題,箭頭函數解決的差很少了;語法提案也到stage-3了;代碼壓縮什麼的,本身的資源代碼大小每每不是核心問題。github
如今說利用hooks能夠勝任class組件全部的能力。但你勝任歸你勝任,我寫class又有什麼不能夠。我繼承、高階騷的一逼,要啥hooks。編程
然而第一、2兩點仍是吸引了個人注意。狀態邏輯的複用,以前我主要採用高階組件+繼承,雖然也能解決,但hooks彷佛有更優雅的方案。複雜組件變得難以理解,這個也確實是日常中遇到的問題,一個組件寫着寫着狀態愈來愈多,抽成子組件吧props跟state又傳來傳去。三個月後,本身的代碼本身已經看不懂了。redux
那hooks真的就能更好的解決這些問題麼?文檔裏輕飄飄的幾句話,對於實際業務來講,確實沒有太多體感。因而我決定簡單寫幾個場景,探一探這hooks的活到底好很差。api
這種場景其實挺常見。只要頁面中有須要複用的組件,且這個組件又有較爲複雜的狀態邏輯,就會有這樣的需求。舉個例子:中後臺系統常見的各類列表,表格內容各不相同,可是都要有分頁的行爲,因而分頁組件就須要去抽象。按照正常的寫法,咱們會怎麼作呢?
最開始,咱們可能不會想着通用,就寫一個列表+分頁的組件。以最簡單的分頁爲例,可能會以下寫(爲方便閱讀,不作太多異常處理):
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的結構與樣式有變動。唔.....仔細想一想有幾種方案:
這也不行,那也很差。那用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。
若是對此仍是不理解,建議先看下相關文檔。若是關於反作用不理解,能夠到文章最後再看。在咱們當下的場景中,知道異步請求數據並更新組件內部狀態值就屬於反作用的一種便可。
知道基本概念之後,咱們看上述的代碼,其實也大體能理解其機制。
list
,進而又觸發第二次render。list
值,而不是初始值,進而頁面渲染新的列表。至於react如何作到能數據的匹配,文檔裏有簡單介紹。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便不會觸發。想一下,也有兩個思路:
綜合考慮來講,我採起第二個方案。由於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的相關文檔,揣摩了一番,終於大體領悟。
這一點同咱們過去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);
// ...
};
複製代碼
咱們能夠明確知道兩點不對的:
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後,返回一個全新的狀態值。具體用法能夠見文檔。其中主要記住兩點:
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公式
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
本文做者:螞蟻保險-體驗技術組-阿相
掘金地址:相學長