React.js 模式
前言
我想找一個好的前端前端框架,找了很久。這個框架將能夠幫助我寫出具有可擴展性、可維護性 UI 的代碼。通過對 React.js 優勢的理解,我認為“我找到了它”。在我大量的使用過程中,我發現了一些模式性的東西。這些技術被一次又一次的用于編程開發之中。此時,我將它寫下來、討論和分享這些我發現的模式。
這些所有的代碼都是可用的,能夠在 https://github.com/krasimir/react-in-patterns 中下載。我可能不會更新我的博客,但是我將一直在 GitHub 中發布一些東西。我也將鼓勵你在 GitHub 中討論這些模式,通過 issue 或者直接 pull request 的方式。
一、React 自己的交流方式(Communication)
在使用 React 構建了幾個月的情況下,你將能夠體會到每一個 React Component 都是一個小系統,它能夠自己運作。它有自己的 state、input、output.
Input
React Component 通過 props 作為 input(之后用輸入代替)。下面我們來寫一個例子:
// Title.jsx
class Title extends React.Component {
render() {
return <h1>{ this.props.text }</h1>;
}
};
Title.propTypes = {
text: React.PropTypes.string
};
Title.defaultProps = {
text: 'Hello world'
};
// App.jsx
class App extends React.Component {
render() {
return <Title text='Hello React' />;
}
};</code></pre>
其中的 Title 組件只有一個輸入 - text . 在父組件(App)提供了一個屬性,通過 <Title> 組件。在 Title 組件中我們添加了兩個設置 propTypes 和 defaultProps ,我們來單獨看一下:
-
propTypes - 定義 props 的類型,這將幫助我們告訴 React 我們將傳什么類型的 prop,能夠對這個 prop 進行驗證(或者說是測試)。
-
defaultProps - 定義 props 默認的值,設置一個默認值是一個好習慣。
還有一個 props.children 屬性,能夠讓我們訪問到當前組件的子組件。比如:
class Title extends React.Component {
render() {
return (
<h1>
{ this.props.text }
{ this.props.children }
</h1>
);
}
};
class App extends React.Component {
render() {
return (
<Title text='Hello React'>
<span>community</span>
</Title>
);
}
};
值得注意的是:如果我們沒有在 Title 組件的 render 方法中添加 { this.props.children } 代碼,其中的 span 標簽(孩子組件)將不會被渲染。
對于一個組件的間接性輸入(就是多層組件傳遞數據的時候),我們也可以調用 context 進行數據的訪問。在整個 React tree 中的每一個組件中可能會有一個 context 對象。更多的說明將在 依賴注入 章節講解。
Output
React 的輸出就是渲染過后的 HTML 代碼。在視覺上我們將看到一個 React 組件的樣子。當然,有些組件可能包含一些邏輯,能夠幫助我們傳遞一些數據或者觸發一個事件行為(這類組件可能不會有具體的 UI 形態)。為了實現邏輯類型的組件,我們將繼續使用組件的 props:
class Title extends React.Component {
render() {
return (
<h1>
<a onClick={ this.props.logoClicked }>
<img src='path/to/logo.png' />
</a>
</h1>
);
}
};
class App extends React.Component {
render() {
return <Title logoClicked={ this.logoClicked } />;
}
logoClicked() {
console.log('logo clicked');
}
};
我們通過一個 callback 的方式在子組件中進行調用, logoClicked 方法能夠接受一些數據,這樣我們就能夠從子組件向父組件傳輸一些數據了(這里就是 React 方式的子組件向父組件通信)。
我們之前有提到我們不能夠訪問 child 的 state。或者換句話說,我們不能夠使用 this.props.children[0].state 的方式或者其他什么方式去訪問。正確的姿勢應該是通過 props callback 的方式獲取子組件的一些信息。這是一件好事。這就迫使我們要去定義明確的 APIs,并鼓勵使用單向數據流(在后面的 單向數據流 中將介紹)。
二、組件構成(composition)
另外一個很棒的是 React 的可組合性。對于我來說,除了 React 之外還沒有發現有任何框架能夠如此簡單的方式去創建組件以及合并組件。這段我將探索一些組件的構建方式,來讓開發工作更加棒。
讓我們先來看一個簡單的例子:
(1)假設我們有一個應用,包含 header 部分,header 內部有一個 navigation(導航)組件。
(2)所以,我們將有三個 React 組件:App、Header 和 Navigation。
(3)他們是層級嵌套的關系。
所以最后代碼如下:
<App>
<Header>
<Navigation> ... </Navigation>
</Header>
</App>
我們為了組合這些小組件,并且引用他們,我們需要向下面這樣定義他們:
// app.jsx
import Header from './Header.jsx';
export default class App extends React.Component {
render() {
return <Header />;
}
}
// Header.jsx
import Navigation from './Navigation.jsx';
export default class Header extends React.Component {
render() {
return <header><Navigation /></header>;
}
}
// Navigation.jsx
export default class Navigation extends React.Component {
render() {
return (<nav> ... </nav>);
}
}
然而這樣,我們用這種方式去組織組件會有幾個問題:
-
我們將 App 組件做為程序的入口,在這個組件里面去構建組件是一個不錯的地方。對于 Header 組件,可能會包含其他組件,比如 logo、search 或者 slogan 之類的。它將是非常好處理,可以通過某種方式從外部傳入,因此我們沒有需要創建一個強依賴的組件。如果我們在另外的地方需要使用 Header 組件,但是這個時候又不需要內層的 Navigation 子組件。這個時候我們就不容易實現,因為 Header 和 Navigation 組件是兩個強耦合的組件。
-
這樣編寫組件是不容易測試的,我們可能在 Header 組件中有一些業務邏輯,為了測試 Header 組件,我們就必須要創建一個 Header 的實例(其實就是引用組件來渲染)。然而,又因為 Header 組件依賴了其他組件,這就導致了我們也可能需要創建一些其他組件的實例,這就讓測試不是那么容易。并且我們在測試過程中,如果 Navigation 組件測試失敗,也將導致 Header 組件測試失敗,這將導致一個錯誤的測試結果(因為不會知道是哪個組件測試沒有通過)。(注:然后在測試中 shallow rendering 解決了這個問題,能夠只渲染 Header 組件,不用實例化其他組件)。
使用 React's children API
在 React 中,我們能夠通過 this.props.children 來很方便的處理這個問題。這個屬性能夠讓父組件讀取和訪問子組件。這個 API 將使我們的 Header 組件更抽象和低耦合(原文是 dependency-free 不好翻譯,但是是這個意思)。
// App.jsx
export default class App extends React.Component {
render() {
return (
<Header>
<Navigation />
</Header>
);
}
}
// Header.jsx
export default class Header extends React.Component {
render() {
return <header>{ this.props.children }</header>;
}
}
這將容易測試,因為我們可以讓 Header 組件渲染成一個空的 div 標簽。這就讓組件脫離出來,然后只專注于應用的開發(其實就是抽象了一層父組件,然后讓這個父組件和子組件進行了解耦,然后子組件可能才是應用的一些功能實現)。
將 child 做為一個屬性
每一個 React 組件都接受 props。這非常好,這個 props 屬性能包含一些數據。或者說是其他組件。
// App.jsx
class App extends React.Component {
render() {
var title = <h1>Hello there!</h1>;
return (
<Header title={ title }>
<Navigation />
</Header>
);
}
};
// Header.jsx
export default class Header extends React.Component {
render() {
return (
<header>
{ this.props.title }
<hr />
{ this.props.children }
</header>
);
}
};
這個技術在我們要合并兩個組件,這個組件在 Header 內部的時候是非常有用的,以及在外部提供這個需要合并的組件。
三、高階組件(Higher-order components)
高階組件看起來很像 裝飾器模式 。他是包裹一個組件和附加一些其他功能或者 props 給它。
這里通過一個函數來返回一個高階組件:
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
/>
)
}
};
export default enhanceComponent;
我們經常提供一個工廠函數,接受我們的原始組件,當我們需要訪問的時候,就返回這個 被升級或者被包裹 過的組件版本給它。比如:
var OriginalComponent = () => <p>Hello world.</p>;
class App extends React.Component {
render() {
return React.createElement(enhanceComponent(OriginalComponent));
}
};
首先,高階組件其實也是渲染的原始組件(傳入的組件)。一個好的習慣是直接傳入 state 和 props 給它。這將有助于我們想代理數據和像是用原始組件一樣去使用這個高階組件。
高階組件讓我們能夠控制輸入。這些數據我們想通過 props 進行傳遞。現在像我們說的那樣,我們有一個配置,OriginalComponent 組件需要這個配置的數據,代碼如下:
var config = require('path/to/configuration');
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ config.appTitle }
/>
)
}
};
這個配置是隱藏在高階組件中。OriginalComponent 組件只能通過 props 來調用 title 數據。至于 title 數據從哪里來對于 OriginalComponent 來說并不重要(這就非常棒了!封閉性做的很好)。這是極大的優勢,因為它幫助我們測試獨立組件,以及提供一個好的機制去 mocking 數據。這里能夠這樣使用 title 屬性( 也就是 stateless component[無狀態組件] )。
var OriginalComponent = (props) => <p>{ props.title }</p>;
高階組件是需要另外一個有用的模式-依賴注入(dependency injection)。
四、依賴注入(Dependency injection)
大部分模塊/組件都會有依賴。能夠合理的管理這些依賴能夠直接影響到項目是否成功。有一個技術叫:依賴注入(dependency injection,之后我就簡稱 DI 吧)。也有部分人稱它是一種模式。這種技術能夠解決依賴的問題。
在 React 中 DI 很容易實現,讓我們跟著應用來思考:
// Title.jsx
export default function Title(props) {
return <h1>{ props.title }</h1>;
}
// Header.jsx
import Title from './Title.jsx';
export default function Header() {
return (
<header>
<Title />
</header>
);
}
// App.jsx
import Header from './Header.jsx';
class App extends React.Component {
constructor(props) {
super(props);
this.state = { title: 'React in patterns' };
}
render() {
return <Header />;
}
};
有一個 "React in patterns" 的字符串,這個字符串以某種方式來傳遞給 Title 組件。
最直接的方式是通過: App => Header => Title 每一層通過 props 來傳遞。然而這樣可能在這個三個組件的時候比較方便,但是如果有多個屬性以及更深的組件嵌套的情況下將比較麻煩。大量組件將接收到它們并不需要的屬性(因為是逐層傳遞)。
我們前面提到的高階組件的方式能夠用來注入數據。讓我們用這個技術來注入一下 title 變量。
// enhance.jsx
var title = 'React in patterns';
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ title }
/>
)
}
};
// Header.jsx
import enhance from './enhance.jsx';
import Title from './Title.jsx';
var EnhancedTitle = enhance(Title);
export default function Header() {
return (
<header>
<EnhancedTitle />
</header>
);
}
這個 title 是隱藏在中間層(高階組件)中,我們通過 prop 來傳遞給 Title 組件。這很好的解決了,但是這只是解決了一半問題,現在我們沒有層級的方式去傳遞 title,但是這些數據都在 echance.jsx 中間層組件。
React 有一個 context 的概念,這個 context 能夠在每一個組件中都可以訪問它。這個優點像 event bus 模型,只不過這里是一個數據。這個方式讓我們能夠在任何地方訪問到數據。
// 我們定義數據的地方:context => title
var context = { title: 'React in patterns' };
class App extends React.Component {
getChildContext() {
return context;
}
...
};
App.childContextTypes = {
title: React.PropTypes.string
};
// 我們需要這個數據的地方
class Inject extends React.Component {
render() {
var title = this.context.title;
...
}
}
Inject.contextTypes = {
title: React.PropTypes.string
};
值得注意的是我們必須使用 childContextTypes 和 contextTypes 這兩個屬性,定義這個上下文對象的類型聲明。如果沒有聲明,context 這個對象將為空(經我測試,如果沒有這些類型定義直接報錯了,所以一定要記得加上哦)。這可能有些不太合適的地方,因為我們可能會放大量的東西在這里。所以說 context 定義成一個純對象不是很好的方式,但是我們能夠讓它成為一個接口的方式來使用它,這將允許我們去存儲和獲取數據,比如:
// dependencies.js
export default {
data: {},
get(key) {
return this.data[key];
},
register(key, value) {
this.data[key] = value;
}
}
然后,我們再看一下我們的例子,頂層的 App 組件可能就會像這樣寫:
import dependencies from './dependencies';
dependencies.register('title', 'React in patterns');
class App extends React.Component {
getChildContext() {
return dependencies;
}
render() {
return <Header />;
}
};
App.childContextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
然后,我們的 Title 組件就從這個 context 中獲取數據:
// Title.jsx
export default class Title extends React.Component {
render() {
return <h1>{ this.context.get('title') }</h1>
}
}
Title.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
最好的方式是我們在每次使用 context 的時候不想定義 contextTypes。這就是能夠使用高階組件包裹一層。甚至更多的是,我們能夠寫一個單獨的函數,去更好的描述和幫助我們聲明這個額外的地方。之后通過 this.context.get('title') 的方式直接訪問 context 數據。我們通過高階組件獲取我們需要的數據,然后通過 prop 的方式來傳遞給我們的原始組件,比如:
// Title.jsx
import wire from './wire';
function Title(props) {
return <h1>{ props.title }</h1>;
}
export default wire(Title, ['title'], function resolve(title) {
return { title };
});
這個 wire 函數有三個參數:
(1)一個 React 組件
(2)需要依賴的數據,這個數據以數組的方式定義
(3)一個 mapper 的函數,它能接受上下文的原始數據,然后返回一個我們的 React 組件(比如 Title 組件)實際需要的數據對象(相當于一個 filter 管道的作用)。
這個例子我們只是通過這種方式傳遞來一個 title 字符串變量。然后在實際應用開發過程中,它可能是一個數據的存儲集合,配置或者其他東西。因此,我們通過這種方式,我們能夠通過哪些我們確實需要的數據,不用去污染組件,讓它們接收一些并不需要的數據。
這里的 wire 函數定義如下:
export default function wire(Component, dependencies, mapper) {
class Inject extends React.Component {
render() {
var resolved = dependencies.map(this.context.get.bind(this.context));
var props = mapper(...resolved);
return React.createElement(Component, props);
}
}
Inject.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
return Inject;
};
Inject 是一個高階組件,它能夠訪問 context 對象的 dependencies 所有的配置項數組。這個 mapper 函數能夠接收 context 的數據,并轉換它,然后給 props 最后傳遞到我們的組件。
最后來看一下關于依賴注入
在很多解決方案中,都使用了依賴注入的技術,這些都基于 React 組件的 context 屬性。我認為這很好的知道發生了什么。在寫這篇文憑的時候,大量流行構建 React 應用的方式會需要 Redux 。著名 connect 函數和 Provider 組件,就是使用的 context(現在大家可以看一下源碼了)。
我個人發現這個技術是真的有用。它是滿足了我處理所有依賴數據的需要,使我的組件變得更加純粹和更方便測試。
五、單向數據流(One-way direction data flow)
在 React 中單向數據流的模式運作的很好。它讓組件不用修改數據,只是接收它們。它們只監聽數據的改變和可能提供一些新的值,但是它們不會去改變數據存儲器里面實際的數據。更新會放在另外地方的機制下,和組件只是提供渲染和新的值。
讓我們來看一個簡單的 Switcher 組件的例子,這個組件包含了一個 button。我們點擊它將能夠控制切換(flag 不好翻譯,程序員都懂的~)
class Switcher extends React.Component {
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => this.setState({ flag: !this.state.flag });
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
// ... and we render it
class App extends React.Component {
render() {
return <Switcher />;
}
};
這個時候再我們的組件里面有一個數據。或者換句話說:Switcher 只是一個一個我們需要通過 flag 變量來渲染的地方。讓我們發送它到一個外面的 store 中:
var Store = {
_flag: false,
set: function (value) {
this._flag = value;
},
get: function () {
return this._flag;
}
};
class Switcher extends React.Component {
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => {
this.setState({ flag: !this.state.flag }, () => {
this.props.onChange(this.state.flag);
});
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
class App extends React.Component {
render() {
return <Switcher onChange={ Store.set.bind(Store) } />;
}
};
我們的 Store 對象是 單例 我們有 helper 去設置和獲取 _flag 這個屬性的值。通過 getter,然后組件能夠通過外部數據進行更新。大楷我們的應用工作流看起來是這樣的:
User's input
|
Switcher -------> Store
讓我們假設我們要通過 Store 給后端服務去保存這個 flag 值。當用戶返回的時候,我們必須設置合適的初始狀態。如果用戶離開后在后來,我們必須展示 "lights on" 而不是默認的 "lights off"。現在它變得困難,因為我們的數據是在兩個地方。UI 和 Store 中都有自己的狀態,我們必須在它們之間交流:Store --> Switcher 和 Switcher --> Store。
// ... in App component
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
// ... in Switcher component
constructor(props) {
super(props);
this.state = { flag: this.props.value };
...
我們的模型改變就要通過:
User's input
|
Switcher <-------> Store
^ |
| |
| |
| v
Service communicating
with our backend
所有這些都導致了需要管理兩個狀態而不是一個。如果 Store 的改變是通過其他系統的行為,我們就必須傳送這些改變給 Switcher 組件和我們就增加了自己 App 的復雜度。
單向數據流就解決了這個問題。它消除了這種多種狀態的情況,只保留一個狀態,這個狀態一般是在 Store 里面。為了實現單向數據流這種方式,我們必須簡單修改一下我們的 Store 對象。我們需要一個能夠訂閱改變的邏輯。
var Store = {
_handlers: [],
_flag: '',
onChange: function (handler) {
this._handlers.push(handler);
},
set: function (value) {
this._flag = value;
this._handlers.forEach(handler => handler())
},
get: function () {
return this._flag;
}
};
然后我們將有一個鉤子在主要的 App 組件中,我們將在每次 Store 中的數據變化的時候重新渲染它。
class App extends React.Component {
constructor(props) {
super(props);
Store.onChange(this.forceUpdate.bind(this));
}
render() {
return (
<div>
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
</div>
);
}
};
注:我們使用了 forceUpdate 的方式,但這種方式不推薦使用。一般情況能夠使用高階組件進行重新渲染。我們使用 forceUpdate 只是簡單的演示。
因為這個改變,Switcher 變得比之前簡單。我們不需要內部的 state:
class Switcher extends React.Component {
constructor(props) {
super(props);
this._onButtonClick = e => {
this.props.onChange(!this.props.value);
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.props.value ? 'lights on' : 'lights off' }
</button>
);
}
};
這個好處在于:這個模式讓我們的組件變成了展示 Store 數據的一個填鴨式組件。它是真的讓 React 組件變成了純粹的渲染層。我們寫我們的應用是聲明的方式,并且只在一個地方處理一些復雜的數據。
這個應用的工作流就變成了:
Service communicating
with our backend
^
|
v
Store <-----
| |
v |
Switcher ---->
^
|
|
User input
我們看到這個數據流都是一個方向流動的,并且在我們的系統中,不需要同步兩個部分(或者更多部分)。單向數據流不止能基于 React 應用,這些就是它讓應用變得更簡單的原因,這個模式可能還需要更多的實踐,但是它是確實值得探索的。
六、結語
當然,這不是在 React 中所有的設計模式/技術。還可能有更多的模式,你能夠 checkout github.com/krasimir/react-in-patterns 進行更新。
來自:https://segmentfault.com/a/1190000006846179