「React 進階」 學好這些 React 設計模式,能讓你的 React 項目飛起來
一 前言
今天我們來悉數(shù)一下 React 中一些不錯的設計模式,這些設計模式能夠解決一些功能復雜,邏輯復用 的問題,還能鍛煉開發(fā)者的設計和編程能力,以為多年開發(fā)經(jīng)驗來看,學好這些設計模式,那就是一個字 香!
基本上每一個設計模式,筆者都會絞盡腦汁的想出兩個 demo,希望屏幕前的你能給筆者賞個贊,以此鼓勵我繼續(xù)創(chuàng)作前端硬文。
老規(guī)矩,我們帶著疑問開始今天的閱讀:
1 React 的常見設計模式有哪些? 2 組合模式功能強大,都用于哪些場景。 3 render props 使用,開發(fā)者應該注意些什么? 4 hoc 模式的應用場景。 5 提供者模式實現(xiàn)狀態(tài)管理和狀態(tài)下發(fā)。 6 如何使用繼承模式,繼承模式的優(yōu)缺點是什么?
我相信讀完這篇文章,這些問題全都會迎刃而解。
首先我們想一個問題,那就是 為什么要學習設計模式? 原因我總結(jié)有以下幾個方面。
1 功能復雜,邏輯復用問題。首先 React 靈活多變性,就決定了 React 項目可以應用多種設計模式。但是這些設計模式的產(chǎn)生也確實辦了實事:
場景一:
在一個項目中,全局有一個狀態(tài),可以稱之為 theme (主題),那么有很多 UI 功能組件需要這個主題,而且這個主題是可以切換的,就像 github 切換暗黑模式一樣,那么如何優(yōu)雅的實現(xiàn)這個功能呢?
這個場景如果我們用 React 的提供者模式,就能輕松搞定了,通過 context 保存全局的主題,然后將 theme 通過 Provider 形式傳遞下去,需要 theme ,那么消費 context ,就可以了,這樣的好處是,只要 theme 改變,消費 context 的組件就會重新更新,達到了切換主題的目的。
場景二:
表單設計場景也需要一定程度上的 React 的設計模式,首先對于表單狀態(tài)的整體驗證需要外層的 Form 綁定事件控制,調(diào)度表單的狀態(tài)下發(fā),驗證功能。內(nèi)層對于每一個表單控件還需要 FormItem 收集數(shù)據(jù),讓控件變成受控的。這樣的 Form 和 FormItem 方式,就是通過組合模式實現(xiàn)的。
2 培養(yǎng)設計能力,編程能力
熟練運用 React 的設計模式,可以培養(yǎng)開發(fā)者的設計能力,比如 HOC 的設計 ,公共組件的設計 ,自定義 hooks 的設計,一些開源的優(yōu)秀的庫就是通過 React 的靈活性和優(yōu)秀的設計模式實現(xiàn)的。
例子一:
比如在 React 狀態(tài)管理工具中,無論是 react-redux ,還是 mobx-react,一方面想要把 state 和 dispatch 函數(shù)傳遞給組件,另一方面訂閱 state 變化,來促使業(yè)務組件更新,那么整個流程中,需要一個或多個 HOC 來搞定。于是 react-redux 提供了 connect,mobx-react 提供了 inject ,observer 等優(yōu)秀的 hoc。由此可見,學會 React 的設計模式,有助于開發(fā)者小到編寫公共組件,大到開發(fā)開源項目。
今天我重點介紹 React 的五種設計模式,分別是:
組合模式 render props模式 hoc 模式 提供者模式 類組件繼承
二 組合模式
1 介紹
組合模式適合一些容器組件場景,通過外層組件包裹內(nèi)層組件,這種方式在 Vue 中稱為 slot 插槽,外層組件可以輕松的獲取內(nèi)層組件的 props 狀態(tài),還可以控制內(nèi)層組件的渲染,組合模式能夠直觀反映出 父 -> 子組件的包含關(guān)系,首先我來舉個最簡單的組合模式例子??。
<Tabs onChange={ (type)=> console.log(type) } >
<TabItem name="react" label="react" >React</TabItem>
<TabItem name="vue" label="vue" >Vue</TabItem>
<TabItem name="angular" label="angular" >Angular</TabItem>
</Tabs>
如上 Tabs 和 TabItem 組合,構(gòu)成切換 tab 功能,那么 Tabs 和 TabItem 的分工如下:
Tabs 負責展示和控制對應的 TabItem 。綁定切換 tab 回調(diào)方法 onChange。當 tab 切換的時候,執(zhí)行回調(diào)。 TabItem 負責展示對應的 tab 項,向 Tabs 傳遞 props 相關(guān)信息。
我們直觀上看到 Tabs 和 TabItem 并沒有做某種關(guān)聯(lián),但是卻無形的聯(lián)系起來。這種就是組合模式的精髓所在,這種組合模式的組件,給使用者感覺很舒服,因為大部分工作,都在開發(fā)組合組件的時候處理了。所以編寫組合模式的嵌套組件,對鍛煉開發(fā)者的 React 組件封裝能力是很有幫助的。
接下來我們一起看一下,組合模式內(nèi)部是如何實現(xiàn)的。
2 原理揭秘
實際組合模式的實現(xiàn)并沒有想象中那么復雜,主要分為外層和內(nèi)層兩部分,當然可能也存在多層組合嵌套的情況,但是萬變不離其宗,原理都是一樣的。首先我們看一個簡單的組合結(jié)構(gòu):
<Groups>
<Item name="《React進階實踐指南》" />
</Groups>
那么 Groups 能對 Item 做一些什么操作呢 ?
Item 在 Groups的形態(tài)
首先如果如上組合模式的寫法,會被 jsx 編譯成 React element 形態(tài),Item 可以通過 Groups 的 props.children 訪問到。
function Groups (props){
console.log( props.children ) // Groups element
console.log( props.children.props ) // { name : 'React進階實踐指南》' }
return props.children
}
但是這是針對單一節(jié)點的情況,事實情況下,外層容器可能有多個子組件的情況。
<Groups>
<Item name="《React進階實踐指南》" />
<Item name="《Nodejs深度學習手冊》" />
</Groups>
這種情況下,props.children 就是一個數(shù)組結(jié)構(gòu),如果想要訪問每一個的 props ,那么需要通過 React.Children.forEach 遍歷 props.children。
function Groups (props){
console.log( props.children ) // Groups element
React.Children.forEach(props.children,item=>{
console.log( item.props ) //依次打印 props
})
return props.children
}
隱式混入 props
這個是組合模式的精髓所在,就是可以通過 React.cloneElement 向 children 中混入其他的 props,那么子組件就可以使用容器父組件提供的特有的 props 。我們來看一下具體實現(xiàn):
function Item (props){
console.log(props) // {name: "《React進階實踐指南》", author: "alien"}
return <div> 名稱: {props.name} </div>
}
function Groups (props){
const newChilren = React.cloneElement(props.children,{ author:'alien' })
return newChilren
}
用 React.cloneElement創(chuàng)建一個新的 element,然后混入其他的 props -> author 屬性,React.cloneElement 的第二個參數(shù),會和之前的 props 進行合并 ( merge )。
這里還是 Groups 只有單一節(jié)點的情況,有些同學會問直接在原來的 children 基礎上加入新屬性不就可以了嗎?像如下這樣:
props.children.props.author = 'alien'
這樣會報錯,對于 props ,React 會進行保護,我們無法對 props 進行拓展。所以要想隱式混入 props ,只能通過 cloneElement來實現(xiàn)。
控制渲染
組合模式可以通過 children 方式獲取內(nèi)層組件,也可以根據(jù)內(nèi)層組件的狀態(tài)來控制其渲染。比如如下的情況:
export default ()=>{
return <Groups>
<Item isShow name="《React進階實踐指南》" />
<Item isShow={false} name="《Nodejs深度學習手冊》" />
<div>hello,world</div>
{ null }
</Groups>
}
如上這種情況組合模式,只渲染 isShow = true的 Item 組件。那么外層組件是如何處理的呢?
實際處理這個很簡單,也是通過遍歷 children ,然后通過對比 props ,選擇需要渲染的 children 。接下來一起看一下如何控制:
function Item (props){
return <div> 名稱: {props.name} </div>
}
/* Groups 組件 */
function Groups (props){
const newChildren = []
React.Children.forEach(props.children,(item)=>{
const { type ,props } = item || {}
if(isValidElement(item) && type === Item && props.isShow ){
newChildren.push(item)
}
})
return newChildren
}
通過 newChildren存放滿足要求的 React Element ,通過Children.forEach遍歷 children 。通過 isValidElement排除非 element 節(jié)點;type指向Item函數(shù)內(nèi)存,排除非 Item 元素;獲取 isShow 屬性,只展示 isShow = true 的Item,最終效果滿足要求。
內(nèi)外層通信
組合模式可以輕松的實現(xiàn)內(nèi)外層通信的場景,原理就是通過外層組件,向內(nèi)層組件傳遞回調(diào)函數(shù) callback ,內(nèi)層通過調(diào)用 callback 來實現(xiàn)兩層組合模式的通信關(guān)系。
function Item (props){
return <div>
名稱:{props.name}
<button onClick={()=> props.callback('let us learn React!')} >點擊</button>
</div>
}
function Groups (props){
const handleCallback = (val) => console.log(' children 內(nèi)容:',val )
return <div>
{React.cloneElement( props.children , { callback:handleCallback } )}
</div>
}
Groups向Item組件中隱式傳入回調(diào)函數(shù)callback,將作為新的 props 傳遞。Item可以通過調(diào)用callback向Groups傳遞信息。實現(xiàn)了內(nèi)外層的通信。
復雜的組合場景
組合模式還有一種場景,在外層容器中,進行再次組合,這樣組件就會一層一層的包裹,一次又一次的強化。這里舉一個例子:
function Item (props){
return <div>
名稱:{props.name} <br/>
作者:{props.author} <br/>
對大家說:{props.mes} <br/>
</div>
}
/* 第二層組合 -> 混入 mes 屬性 */
function Wrap(props){
return React.cloneElement( props.children,{ mes:'let us learn React!' } )
}
/* 第一層組合,里面進行第二次組合,混入 author 屬性 */
function Groups (props){
return <Wrap>
{React.cloneElement( props.children, { author:'alien' } )}
</Wrap>
}
export default ()=>{
return <Groups>
<Item name="《React進階實踐指南》" />
</Groups>
}
在 Groups組件里通過Wrap再進行組合。經(jīng)過兩次組合,把author和mes混入到 props 中。

這種組合模式能夠一層層強化原始組件,外層組件不用過多關(guān)心內(nèi)層到底做了些什么? 只需要處理 children 就可以,同樣內(nèi)層 children 在接受業(yè)務層的 props 外,還能使用來自外層容器組件的狀態(tài),方法等。
3 注意細節(jié)
組合模式也有很多細節(jié)值得注意,首先最應該想到的就是對于 children 的類型校驗,因為組合模式,外層容器組件對 children 的屬性狀態(tài)是未知的。如果在不確定 children 的狀態(tài)下,如果直接掛載,就會出現(xiàn)報錯等情況。所以驗證 children 的合法性就顯得非常重要。
驗證children
比如如下,本質(zhì)上形態(tài)是屬于 render props 形式。
<Groups>
{()=> <Item isShow name="《React進階實踐指南》" />}
</<Groups>
上面的情況,如果 Groups 直接用 children 掛載的話。
function Groups (props){
return props.children
}
這樣的情況,就會報 Functions are not valid as a React child 的錯誤。那么需要在 Groups 做判斷,我們來一起看一下:
function Groups (props){
return React.isValidElement(props.children)
? props.children
: typeof props.children === 'function' ?
props.children() : null
}
首先判斷 children 是否是 React.element ,如果是那么直接渲染,如果不是,那么接下來判斷是否是函數(shù),如果是函數(shù),那么直接函數(shù),如果不是那么直接渲染 null就可以了。
綁定靜態(tài)屬性
現(xiàn)在還有一個暴露的問題是,外層組件和內(nèi)層組件通過什么識別身份呢?比如如下的場景:
<Groups>
<Item isShow name="《React進階實踐指南》" />
<Text />
<Groups>
如下,Groups 內(nèi)部有兩個組件,一個是 Item ,一個是 Text ,但是只有 Item 是有用的,那么如何證明 Item 組件呢。那么我們需要給組件函數(shù)或者類綁定靜態(tài)屬性,這里可以統(tǒng)一用 displayName 來標記組件的身份。
那么只需要這么做就可以了:
function Item(){ ... }
Item.displayName = 'Item'
那么在 Groups 中就可以找到對應的 Item 組件,排除 Text 組件。具體可以通過 children 上的 type 屬性找到對應的函數(shù)或者是類,然后判斷 type 上的 displayName 屬性找到對應的 Item 組件,本質(zhì)上 displayName 主要用于調(diào)試,這里要記住組合方式,可以使用子組件的靜態(tài)屬性就可以了。 當然也可以通過內(nèi)存空間相同的方式。
具體參考方式:
function Groups (props){
const newChildren = []
React.Children.forEach(props.children,(item)=>{
const { type ,props } = item || {}
if(isValidElement(item) && type.displayName === 'Item' ){
newChildren.push(item)
}
})
return newChildren
}
通過 displayName 屬性找到 Item。
4 實踐demo
接下來,我們來簡單實現(xiàn)剛開始的 tab,tabItem 切換功能。
tab實現(xiàn)
const Tab = ({ children ,onChange }) => {
const activeIndex = useRef(null)
const [,forceUpdate] = useState({})
/* 提供給 tab 使用 */
const tabList = []
/* 待渲染組件 */
let renderChildren = null
React.Children.forEach(children,(item)=>{
/* 驗證是否是 <TabItem> 組件 */
if(React.isValidElement(item) && item.type.displayName === 'tabItem' ){
const { props } = item
const { name, label } = props
const tabItem = {
name,
label,
active: name === activeIndex.current,
component: item
}
if(name === activeIndex.current) renderChildren = item
tabList.push(tabItem)
}
})
/* 第一次加載,或者 prop chuldren 改變的情況 */
if(!renderChildren && tabList.length > 0){
const fisrtChildren = tabList[0]
renderChildren = fisrtChildren.component
activeIndex.current = fisrtChildren.component.props.name
fisrtChildren.active = true
}
/* 切換tab */
const changeTab=(name)=>{
activeIndex.current = name
forceUpdate({})
onChange && onChange(name)
}
return <div>
<div className="header" >
{
tabList.map((tab,index) => (
<div className="header_item" key={index} onClick={() => changeTab(tab.name)} >
<div className={'text'} >{tab.label}</div>
{tab.active && <div className="active_bored" ></div>}
</div>
))
}
</div>
<div>{renderChildren}</div>
</div>
}
Tab.displayName = 'tab'
我寫的這個 Tab,負責了整個 Tab 切換的主要功能,包括 TabItem 的過濾,狀態(tài)收集,控制對應的子組件展示。
首先通過 Children.forEach找到符合條件的TabItem。收集TabItem的 props,形成菜單結(jié)構(gòu)。找到對應的 children,渲染正確的 children 。提供改變 tab 的方法 changeTab。displayName 標記 Tab組件。這個主要目的方便調(diào)試。
TabItem 的實現(xiàn)
const TabItem = ({ children }) => {
return <div>{children}</div>
}
TabItem.displayName = 'tabItem'
這個 demo 中的 TabItem 功能十分簡單,大部分事情都交給 Tab 做了。
TabItem 做的事情是:
展示 children( 我們寫在 TabItem 里面的內(nèi)容 )綁定靜態(tài)屬性 displayName。
效果

5 總結(jié)
組合模式在日常開發(fā)中,用途還是比較廣泛的,尤其是在一些比較出色的開源項目中,組合模式的總結(jié)內(nèi)容如下:
組合模式通過外層組件獲取內(nèi)層組件 children ,通過 cloneElement 傳入新的狀態(tài),或者控制內(nèi)層組件渲染。 組合模式還可以和其他組件組合,或者是 render props,拓展性很強,實現(xiàn)的功能強大。
總結(jié)流程圖如下:

三 render props模式
1 介紹
render props 模式和組合模式類似。區(qū)別不同的是,用函數(shù)的形式代替 children。函數(shù)的參數(shù),由容器組件提供,這樣的好處,將容器組件的狀態(tài),提升到當前外層組件中,這個是一個巧妙之處,也是和組合模式相比最大的區(qū)別。
我們先來看一下一個基本的 render props 長什么樣子:
export default function App (){
const aProps = {
name:'《React進階實踐指南》'
}
return <Container>
{(cProps) => <Children {...cProps} { ...aProps } />}
</Container>
}
如上是 render props 的基本樣子??梢郧宄目吹剑?/p>
cProps為Container組件提供的狀態(tài)。aProps為App提供的狀態(tài)。這種模式優(yōu)點是,能夠給 App 的子組件 Container 的狀態(tài)提升到 App 的 render 函數(shù)中。然后可以組合成新的 props,傳遞給 Children,這種方式讓容器化的感念更顯而易見。
接下來我們研究一下 render props 原理和細節(jié)。
2 原理和細節(jié)
首先一個問題是 render props 這種方式到底適合什么場景,實際這種模式更適合一種,容器包裝,狀態(tài)的獲取??赡苓@么說有的同學不明白。那么一起看一下 context 中的 Consumer。就采用 render props 模式。
const Context = React.createContext(null)
function Index(){
return <Context.Consumer>
{(contextValue)=><div>
名稱:{contextValue.name}
作者:{contextValue.author}
</div>}
</Context.Consumer>
}
export default function App(){
const value = {
name:'《React進階實踐指南》',
author:'我不是外星人'
}
return <Context.Provider value={value} >
<Index />
</Context.Provider>
}
我們看到 Consumer 就是一個容器組件,包裝即將渲染的內(nèi)容,然后通過 children render 函數(shù)執(zhí)行把狀態(tài) contextValue從下游向上游提取。
那么接下來模擬一下 Consumer 的內(nèi)部實現(xiàn)。
function myConsumer(props){
const contextValue = useContext(Context)
return props.children(contextValue)
}
如上就模擬了一個 Consumer 功能,從 Consumer 的實現(xiàn)看 render props 本質(zhì)就是容器組件產(chǎn)生狀態(tài),再通過 children 函數(shù)傳遞下去。所以這種模式我們應該更在乎的是,容器組件能提供些什么?
派生新狀態(tài)
相比傳統(tǒng)的組合模式,render props 還有一個就是靈活性,可以通過容器組件的狀態(tài)和當前組件的狀態(tài)結(jié)合,派生出新的狀態(tài)。比如如下
<Container>
{(cProps) => {
const const nProps = getNewProps( aProps , cProps )
return <Children {...nProps} />
}}
</Container>
nProps 是通過當前組件的狀態(tài) aProps 和 Container 容器組件 cProps ,合并計算得到的狀態(tài)。
反向狀態(tài)回傳
這種情況比較極端,筆者也用過這種方法,就是可以通過 render props 中的狀態(tài),提升到當前組件中,也就是把容器組件內(nèi)的狀態(tài),傳遞給父組件。比如如下情況。
function GetContanier(props){
const dom = useRef()
const getDom = () => dom.current
return <div ref={dom} >
{props.children({ getDom })}
</div>
}
export default function App(){
/* 保存 render props 回傳的狀態(tài) */
const getChildren = useRef(null)
useEffect(()=>{
const childDom = getChildren.current()
console.log( childDom,'childDom' )
},[])
return <GetContanier>
{({getDom})=>{
getChildren.current = getDom
return <div></div>
}}
</GetContanier>
}

這是一個復雜的狀態(tài)回傳的場景,在 GetContanier將獲取元素的方法getDom通過 render props 回傳給父組件。父組件 App 通過 getChildren保存 render props 回傳的內(nèi)容,在useEffect調(diào)用 getDom 方法,打印內(nèi)容如下:
但是現(xiàn)實情況不可能是獲取一個 dom 這么簡單,真實情景下,回傳的內(nèi)容可能更加復雜。
3 注意問題
render props 的注意問題還是對 children 的校驗,和組合模式不同的是,這種模式需要校驗 children 是一個函數(shù),只有是函數(shù)的情況下,才能執(zhí)行函數(shù),傳遞 props 。打一個比方:
function Container (props){
const renderChildren = props.children
return typeof renderChildren === 'function' ? renderChildren({ name:'《React進階時間指南》' }) : null
}
export default function App(){
return <Container>
{(props)=> <div> 名稱 :{props.name} </div>}
</Container>
}
通過 typeof判斷children是一個函數(shù),如果是函數(shù),那么執(zhí)行函數(shù),傳遞 props 。
4 實踐demo
接下來我們實現(xiàn)一個 demo。通過 render props 實現(xiàn)一個帶 loading 效果的容器組件,被容器組件包裹,會通過 props 回傳開啟 loading 的方法 ( 現(xiàn)實場景下,不一定會這么做,這里只是方便同學學習 render props 模式 ) 。
容器組件 Container
function Container({ children }){
const [ showLoading, setShowLoading ] = useState(false)
const renderChildren = useMemo(()=> typeof children === 'function' ? children({ setShowLoading }) : null ,[children] )
return <div style={{ position:'relative' }} >
{renderChildren}
{showLoading && <div className="mastBox" >
{<SyncOutlined className="icon" spin twoToneColor="#52c41a" />}
</div>}
</div>
}
useState用于顯示 loading 效果,useMemo 用于執(zhí)行children函數(shù),把改變 state 的方法 setShowLoading 傳入 props 中。這里有一個好處就是當 useState 改變的時候,不會觸發(fā)children的渲染。通過 showLoading來顯示 loading 效果。
外層使用
export default function Index(){
const setLoading = useRef(null)
return <div>
<Container>
{({ setShowLoading })=>{
console.log('渲染')
setLoading.current = setShowLoading
return <div>
<div className="index1" >
<button onClick={() => setShowLoading(true)} >loading</button>
</div>
</div>
}}
</Container>
<button onClick={() => setLoading.current && setLoading.current(false)} >取消 loading </button>
</div>
}
通過直接調(diào)用 setShowLoading(true)顯示 loading 效果。用 useRef 保存狀態(tài) setShowLoading , Container外層也可以調(diào)用 setShowLoading 來讓 loading 效果消失。
效果

5 總結(jié)
接下來我們總結(jié)一下 render props 的特點。
容器組件作用是傳遞狀態(tài),執(zhí)行 children 函數(shù)。 外層組件可以根據(jù)容器組件回傳 props ,進行 props 組合傳遞給子組件。 外層組件可以使用容器組件回傳狀態(tài)。
這種模式下的原理圖如下所示:

四 hoc 模式
1 介紹
hoc 高階組件模式也是 React 比較常用的一種包裝強化模式之一,高階函數(shù)是接收一個函數(shù),返回一個函數(shù),而所謂高階組件,就是接收一個組件,返回一個組件,返回的組件是根據(jù)需要對原始組件的強化。
我們來看一下 hoc 的通用模式。hoc 本質(zhì)上就是一個函數(shù)。
function Hoc (Component){
return class Wrap extends React.Component{
//---------
// 強化操作
//---------
render(){
return <Component { ...this.props } />
}
}
}
傳統(tǒng)的 HOC 模式如上,我們可以看清楚一個傳統(tǒng)的 HOC 做了哪些事。
1 HOC 本質(zhì)是一個函數(shù),傳入 Component,也就是原始組件本身。2 返回一個新的包裝的組件 Wrap ,我們可以在 Wrap 中做一些強化原始組件的事。 3 Wrap 中掛載原始組件本身 Component。
2 原理
接下來我們看一下 hoc 的具體實現(xiàn)原理。hoc 的實現(xiàn)有兩種方式,屬性代理和反向繼承。
屬性代理所謂正向?qū)傩源恚褪怯媒M件包裹一層代理組件,在代理組件上,我們可以做一些,對源組件的代理操作。我們可以理解為父子組件關(guān)系,父組件對子組件進行一系列強化操作。而 hoc 本身就是返回強化子組件的父組件。
function HOC(WrapComponent){
return class Advance extends React.Component{
state={
name: '《React 進階實踐指南》',
author:'我不是外星人'
}
render(){
return <WrapComponent { ...this.props } { ...this.state } />
}
}
}
屬性代理特點:
① 正常屬性代理可以和業(yè)務組件低耦合,零耦合,對于條件渲染和 props屬性增強,只負責控制子組件渲染和傳遞額外的props就可以,所以無須知道,業(yè)務組件做了些什么。所以正向?qū)傩源?,更適合做一些開源項目的hoc,目前開源的HOC基本都是通過這個模式實現(xiàn)的。② 同樣適用于 class聲明組件,和function聲明的組件。③ 可以完全隔離業(yè)務組件的渲染,相比反向繼承,屬性代理這種模式??梢酝耆刂茦I(yè)務組件渲染與否,可以避免反向繼承帶來一些副作用,比如生命周期的執(zhí)行。 ④ 可以嵌套使用,多個 hoc 是可以嵌套使用的,而且一般不會限制包裝HOC的先后順序。
反向繼承
反向繼承和屬性代理有一定的區(qū)別,在于包裝后的組件繼承了業(yè)務組件本身,所以我們我無須再去實例化我們的業(yè)務組件。當前高階組件就是繼承后,加強型的業(yè)務組件。這種方式類似于組件的強化,所以你必須要知道當前繼承的組件的狀態(tài),內(nèi)部做了些什么?
class Index extends React.Component{
render(){
return <div> hello,world </div>
}
}
function HOC(Component){
return class wrapComponent extends Component{ /* 直接繼承需要包裝的組件 */
}
}
export default HOC(Index)
① 方便獲取組件內(nèi)部狀態(tài),比如state,props ,生命周期,綁定的事件函數(shù)等 ② es6繼承可以良好繼承靜態(tài)屬性。我們無須對靜態(tài)屬性和方法進行額外的處理。
3 功能及注意事項
上面介紹了 hoc 的二種實現(xiàn)方式,接下來看一下 hoc 能做些什么?以及 hoc 模式的注意事項。
HOC 的功能
對于屬性代理HOC,我們可以:
強化props & 抽離state。 條件渲染,控制渲染,分片渲染,懶加載。 劫持事件和生命周期。 ref控制組件實例。 添加事件監(jiān)聽器,日志
對于反向代理的HOC,我們可以:
劫持渲染,操縱渲染樹。 控制/替換生命周期,直接獲取組件狀態(tài),綁定事件。
如果你對上面的每一個功能的具體場景不清楚的話,建議看一下筆者的另外一篇文章:一文吃透React高階組件(HOC)
HOC 注意事項
1 謹慎修改原型鏈。 2 繼承靜態(tài)屬性,這里推薦一個庫 hoist-non-react-statics自動拷貝所有的靜態(tài)方法。3 跨層級捕獲 ref,通過forwardRef轉(zhuǎn)發(fā)ref。4 render 中不要聲明 HOC,如果在 render 聲明 hoc,可能會造成組件反復掛載情況發(fā)生。
4 實踐demo
之前有同學在面試中,遇到了這樣一個問題,就是如果控制組件掛載的先后順序,比如如下的場景
export default function Index(){
return <div>
<ComponentA />
<ComponentB />
<ComponentC />
</div>
}
如上,有三個子組件,ComponentA ,ComponentB,ComponentC,現(xiàn)在期望執(zhí)行順序是 ComponentA 渲染完成,掛載 ComponentB ,ComponentB 渲染完成,掛載 ComponentC,也就是三個組件是按照先后順序渲染掛載的,那么如何實現(xiàn)呢?
實際上,這種情況完全可以用一個 hoc 來實現(xiàn),那么接下來,請大家跟上我的思路實現(xiàn)這個場景。
首先這個 hoc 是針對當前 index 下面,ComponentA | ComponentB | ComponentC 一組 component 進行功能強化。所以這個 hoc 最好可以動態(tài)創(chuàng)建,而且服務于當前一組組件。那么可以聲明一個生產(chǎn) hoc 的函數(shù)工廠。
function createHoc(){
const renderQueue = [] /* 待渲染隊列 */
return function Hoc(Component){ /* Component - 原始組件 */
return class Wrap extends React.Component{ /* hoc 包裝組件 */
}
}
}
那么我們需要先創(chuàng)建一個 hoc,作為這一組組件的使用。
使用:
const loadingHoc = createHoc()
知道了 hoc 的動態(tài)產(chǎn)生,接下來具體實現(xiàn)一下這個 hoc 。
function createHoc(){
const renderQueue = [] /* 待渲染隊列 */
return function Hoc(Component){
function RenderController(props){ /* RenderController 用于真正掛載原始組件 */
const { renderNextComponent ,...otherprops } = props
useEffect(()=>{
renderNextComponent() /* 通知執(zhí)行下一個需要掛載的組件任務 */
},[])
return <Component {...otherprops} />
}
return class Wrap extends React.Component{
constructor(){
super()
this.state = {
isRender:false
}
const tryRender = ()=>{
this.setState({
isRender:true
})
}
if(renderQueue.length === 0) this.isFirstRender = true
renderQueue.push(tryRender)
}
isFirstRender = false /* 是否是隊列中的第一個掛載任務 */
renderNextComponent=()=>{ /* 從更新隊列中,取出下一個任務,進行掛載 */
if(renderQueue.length > 0 ){
console.log('掛載下一個組件')
const nextRender = renderQueue.shift()
nextRender()
}
}
componentDidMount(){ /* 如果是第一個掛載任務,那么需要 */
this.isFirstRender && this.renderNextComponent()
}
render(){
const { isRender } = this.state
return isRender ? <RenderController {...this.props} renderNextComponent={this.renderNextComponent} /> : <SyncOutlined spin />
}
}
}
}
分析一下主要流程:
首先通過 createHoc來創(chuàng)建需要順序加載的 hoc ,renderQueue存放待渲染的隊列。Hoc 接收原始組件 Component。RenderController用于真正掛載原始組件,用 useEffect 通知執(zhí)行下一個需要掛載的組件任務,在 hooks 原理的文章中,我講過 useEffect 采用異步執(zhí)行,也就是說明,是在渲染之后,瀏覽器繪制已經(jīng)完成。Wrap 組件包裝了一層 RenderController,主要用于渲染更新任務,isFirstRender證明是否是隊列中的第一個掛載任務,如果是第一個掛載任務,那么需要在componentDidMount開始掛載第一個組件。每一個掛載任務本質(zhì)上就是 tryRender方法,里面調(diào)用了 setState 來渲染RenderController。每一個掛載任務的函數(shù) renderNextComponent原理很簡單,就是獲取第一個更新任務,然后執(zhí)行就可以了。還有一些細節(jié)沒有處理,比如說繼承靜態(tài)屬性,ref 轉(zhuǎn)發(fā)等。
使用:
/* 創(chuàng)建 hoc */
const loadingHoc = createHoc()
function CompA(){
useEffect(()=>{
console.log('組件A掛載完成')
},[])
return <div>組件 A </div>
}
function CompB(){
useEffect(()=>{
console.log('組件B掛載完成')
},[])
return <div>組件 B </div>
}
function CompC(){
useEffect(()=>{
console.log('組件C掛載完成')
},[])
return <div>組件 C </div>
}
function CompD(){
useEffect(()=>{
console.log('組件D掛載完成')
},[])
return <div>組件 D </div>
}
function CompE(){
useEffect(()=>{
console.log('組件E掛載完成')
},[])
return <div>組件 E </div>
}
const ComponentA = loadingHoc(CompA)
const ComponentB = loadingHoc(CompB)
const ComponentC = loadingHoc(CompC)
const ComponentD = loadingHoc(CompD)
const ComponentE = loadingHoc(CompE)
export default function Index(){
const [ isShow, setIsShow ] = useState(false)
return <div>
<ComponentA />
<ComponentB />
<ComponentC />
{isShow && <ComponentD />}
{isShow && <ComponentE />}
<button onClick={()=> setIsShow(true)} > 掛載組件D ,E </button>
</div>
}
效果:


完美達成需求。
5 總結(jié)
HOC 在實際項目中,應用還是很廣泛的,尤其是一些優(yōu)秀的開源項目中,這里總結(jié)了一下 HOC 的原理圖:
屬性代理
反向繼承

五 提供者模式
1 介紹
首先我們來思考一下,為什么 React 會有提供者這種模式呢?
帶著這個疑問,首先假設一個場景:在 React 的項目有一個全局變量 theme ( theme 可能是初始化數(shù)據(jù)交互獲得的,也有可能是切換主題變化的),有一些視圖 UI 組件(比如表單 input 框、 button 按鈕),需要 theme 里面的變量來做對應的視圖渲染,現(xiàn)在的問題是怎么能夠把 theme 傳遞下去,合理分配到用到這個 theme 的地方。
如果用 props 解決這個問題,那么需要通過 props 層層綁定,而且還要考慮 pureComponent, memo 策略的影響。
所以這個時候用提供者模式最好不過了。React 提供了 context ‘提供者’模式,具體模式是這樣的,React組件樹 Root 節(jié)點,用 Provider 提供者注入 theme,然后在需要 theme的 地方,用 Consumer 消費者形式取出theme,供給組件渲染使用即可,這樣減少很多無用功。用官網(wǎng)上的一句話形容就是Context 提供了一個無需為每層組件手動添加 props,就能在組件樹間進行數(shù)據(jù)傳遞的方法。
但是必須注意一點是,提供者永遠要在消費者上層,正所謂水往低處流,提供者一定要是消費者的某一層父級。提供者模式的結(jié)構(gòu)圖如下:

2 用法介紹
對于提供者模式的用法,有老版本的 context 和新版本的 context 之分。接下來重點介紹一下兩種方式。
老版本提供者模式
在 React v16.3.0 之前,要實現(xiàn)提供者,就要實現(xiàn)一個 React 組件,不過這個組件要做特殊處理。下面就是一個實現(xiàn)“提供者”的例子,組件名為 ThemeProvider:
提供者
class ThemeProvider extends React.Component {
getChildContext() {
return {
theme: this.props.value
}
}
render() {
return (
<div>
{ this.props.children }
</div>
);
}
}
ThemeProvider.childContextTypes = {
theme: PropTypes.object
}
需要實現(xiàn) getChildContext方法,用于返回數(shù)據(jù)就是向子孫組件傳遞的上下文;需要定義 childContextTypes屬性,聲明“上下文”的結(jié)構(gòu)類型。
使用
<ThemeProvider value={ { color:'pink' } } >
<Index />
</ThemeProvider>
消費者
const ThemeConsumer = (props, context) => {
const {color} = context.theme
return (
<p style={{color }}>
{props.children}
</p>
);
}
ThemeConsumer.contextTypes = {
theme: PropTypes.object
}
這里需要注意的是,需要通過 contextTypes指定將要消費哪個 context ,否則將無效。
新版本提供者模式
到了 React v16.3.0 的時候,新的 Context API 出來了,開發(fā)者可以創(chuàng)建一個 Context , Context 上有兩個屬性就是 Provider 和 Consumer 。
Provider用于提供 context 。Consumer用于消費 context 。
那么接下來介紹一下具體如何使用,首先開發(fā)者需要用 createContext api 創(chuàng)建一個 context。
const ThemeContext = React.createContext();
然后就是新版本 Provider 和 Consumer的實現(xiàn)。
新版提供者
function ThemeProvider(){
const theme = { color:'pink' }
return <ThemeContext.Provider value={ theme } >
<Index />
</ThemeContext.Provider>
}
通過 ThemeContext上的Provider傳遞主題信息theme。Index 是根部組件。
新版消費者
function ThemeConsumer(props){
return <ThemeContext.Consumer>
{ (theme)=>{ /* render children函數(shù) */
const { color } = theme
return <p style={{color }}>
{props.children}
</p>
} }
</ThemeContext.Consumer>
}
Consumer 采用的就是上述講到的 render props 模式。 通過 Consumer 訂閱 context 變化,context 變化, render children 函數(shù)重新執(zhí)行。render children 函數(shù)中第一個參數(shù)就是保存的 context 信息。 在新版消費者中,對于函數(shù)組件還有 useContext自定義 hooks ,對于類組件有contextType靜態(tài)屬性。
3 實踐demo
接下來我們實現(xiàn)一個提供者模式的實踐 demo ,通過動態(tài) context 來讓消費 context 的 Consumer 動態(tài)渲染。
const ThemeContext = React.createContext(null) // 創(chuàng)建一個 context 上下文 ,主題顏色Context
function ConsumerDemo(){
return <div>
<ThemeContext.Consumer>
{
(theme) => <div style={{ ...theme}} >
<p>i am alien!</p>
<p>let us learn React!</p>
</div>
}
</ThemeContext.Consumer>
</div>
}
class Index extends React.PureComponent{
render(){
return <div>
<ConsumerDemo />
</div>
}
}
export default function ProviderDemo(){
const [ theme , setTheme ]= useState({ color:'pink' , background:'#ccc' })
return <div>
<ThemeContext.Provider value={theme} >
<Index />
</ThemeContext.Provider>
<button onClick={()=>setTheme({ color:'blue' , background:'orange' })} >點擊</button>
</div>
}
Provider 改變,消費訂閱 Provider 的 Consumer 會重新渲染。
效果:

4 總結(jié)
提供者模式在日常開發(fā)中,用的頻率還是很高的,比如全局傳遞狀態(tài),保存狀態(tài)。這里用一幅圖總結(jié)提供者模式的原理。

六 類組件繼承
1 介紹
React 有十分強大的組合模式。我們推薦使用組合而非繼承來實現(xiàn)組件間的代碼重用
雖然 React 官方推薦用組合方式,而非繼承方式。但是也不是說明繼承這種方式?jīng)]有用武之地,繼承方式還是有很多應用場景的。
在 class 組件盛行之后,我們可以通過繼承的方式進一步的強化我們的組件。這種模式的好處在于,可以封裝基礎功能組件,然后根據(jù)需要去 extends 我們的基礎組件,按需強化組件,但是值得注意的是,必須要對基礎組件有足夠的掌握,否則會造成一些列意想不到的情況發(fā)生。
我們先來看一個
class Base extends React.Component{
constructor(){
super()
this.state={
name:'《React 進階實踐之指南》'
}
}
componentDidMount(){}
say(){
console.log('base components')
}
render(){
return <div> hello,world <button onClick={ this.say.bind(this) } >點擊</button> </div>
}
}
class Index extends Base{
componentDidMount(){
console.log( this.state.name )
}
say(){ /* 會覆蓋基類中的 say */
console.log('extends components')
}
}
export default Index
Base為基礎組件,提供一些基礎的方法和功能,包括 UIIndex為基于 Base 繼承的組件,可以針對 Index 做一些功能性的強化。
2 特性
繼承增強效果很優(yōu)秀。它的優(yōu)勢如下:
可以控制父類 render,還可以添加一些其他的渲染內(nèi)容; 可以共享父類方法,還可以添加額外的方法和屬性。
但是也有值得注意的地方,就是 state 和生命周期會被繼承后的組件修改。像上述 demo 中, Person 組件中的 componentDidMount 生命周期將不會被執(zhí)行。
3 實踐demo
接下來我們實現(xiàn)一個繼承功能,繼承的組件就是耳熟能詳?shù)?React-Router 中的 Route 組件,強化它,使它變成可以受到權(quán)限的控制。
當頁面有權(quán)限,那么直接展示頁面內(nèi)容。 當頁面沒有權(quán)限,那么展示無權(quán)限頁面。
代碼編寫
import { Route } from 'react-router'
const RouterPermission = React.createContext()
class PRoute extends Route{
static contextType = RouterPermission /* 使用 context */
constructor(...arg){
super(...arg)
const { path } = this.props
/* 如果有權(quán)限 */
console.log(this.context)
const isPermiss = this.context.indexOf(path) >= 0 /* 判斷是否有權(quán)限 */
if(!isPermiss) {
/* 修改 render 函數(shù),如果沒有權(quán)限,重新渲染一個 Route ,ui 是無權(quán)限展示的內(nèi)容 */
this.render = () => <Route {...this.props} >
<div>暫無權(quán)限</div>
</Route>
}
}
}
export default (props)=>{
/* 模擬的有權(quán)限的路由列表 */
const permissionList = [ '/extends/a' , '/extends/b' ]
return <RouterPermission.Provider value={permissionList} >
<Index {...props} />
</RouterPermission.Provider>
}
在根組件傳入權(quán)限路由。通過 context 模式,保存的是存在權(quán)限的路由列表。這里模擬為 /extends/a和/extends/b。編寫 PRoute 權(quán)限路由,繼承 react-router中的Route組件。PRoute 通過 contextType消費指定的權(quán)限上下文RouterPermission context。在 constructor中進行判斷,如果有權(quán)限,那么不用做任何處理,如果沒有權(quán)限,那么重寫 render 函數(shù),用 Route 做一個展示容器,展示無權(quán)限的 UI 。
使用
function Test1 (){
return <div>權(quán)限路由測試一</div>
}
function Test2 (){
return <div>權(quán)限路由測試二</div>
}
function Test3(){
return <div>權(quán)限路由測試三</div>
}
function Index({ history }){
const routerlist=[
{ name:'測試一' ,path:'/extends/a' },
{ name:'測試二' ,path:'/extends/b' },
{ name:'測試三' ,path:'/extends/c' }
]
return <div>
{
routerlist.map(item=> <button key={item.path}
onClick={()=> history.push(item.path)}
>{item.path}</button> )
}
<PRoute component={Test1}
path="/extends/a"
/>
<PRoute component={Test2}
path="/extends/b"
/>
<PRoute component={Test3}
path="/extends/c"
/>
</div>
}
效果

可以看到,只有權(quán)限列表中的 [ '/extends/a' , '/extends/b' ]權(quán)限能展示,無權(quán)限提示暫無權(quán)限,完美達到效果。
4 總結(jié)
繼承模式的應用前提是,你需要知道被繼承的組件是什么,內(nèi)部都有什么狀態(tài)和方法,對繼承的組件內(nèi)部的運轉(zhuǎn)是透明的。接下來用一幅圖表示繼承模式原理。

七 總結(jié)
本章節(jié)講了 React 中常用的幾個設計模式。希望同學們看完可以手動敲起來,把這些設計模式運用到真實的項目中。
參考資料
「react進階」一文吃透React高階組件(HOC)
React進階實踐指南
感興趣的同學請關(guān)注公眾號 前端Sharing 持續(xù)推送優(yōu)質(zhì)好文~
奉上幾個小冊《React進階實踐指南》 7 折 優(yōu)惠碼 F3Z1VXtv ,先到先得~
