如何用 React 構建前端架構
早期的前端是由后端開發的,最開始的時候僅僅做展示,點一下鏈接跳轉到另外一個頁面去,渲染表單,再用Ajax的方式請求網絡和后端交互,數據返回來還需要把數據渲染到DOM上。寫這樣的代碼的確是很簡單。在Web交互開始變得復雜時,一個頁面往往有非常多的元素構成,像社交網絡的Feed需要經常刷新,展示內容也五花八門,為了追求用戶體驗需要做很多的優化。
當時說到架構時,可能會想前端需要架構嗎,如果需要,應該使用什么架構呢?也有可能一下就想起了MVC/MVP/MVVM架構。在無架構的狀態下,我們可以寫一個HTML文件,HTML、Script和Style都一股腦的寫在一起,這種原始的方式適合個人開發Demo用,或者只是做個玩玩的東西。
當我們寫的Script和Style越來越多時,就考慮是否將這些片段代碼整理放在同一個文件里,所以,我們就把Script寫到了JS文件,把Style寫到CSS文件。一個項目肯定有許多的邏輯功能,就需要考慮分層,把獨立的功能單獨抽取出來可以復用的組件。 Anguar和Ember作為MVC架構的框架,采用MVVM雙向綁定技術能夠快速開發一個復雜的Web應用。可是,我們不用。
React將自己定位為MVC的V部分,僅僅是一個View庫,這就給我們很大的自由空間,并且引入了基于組件的架構和基于狀態的架構概念。
MVC將Model、Controller和View分離,它們的通信是單向的,View只和Controller通信, Controller只跟Model交互,Model也只更新View,然而前端的重點在View上,導致Controller非常薄,而View卻很重,有些前端框架會把Controller當作Router層處理。
MVP在MVC的基礎上改進了一下,把Controller替換成了Presenter,并將Model放到最后,整個模型的交互變成了View只能和Presenter之間互相傳遞消息,Presenter也只能和Model相互通信,View不能直接和Model交互,這樣又導致Presenter非常的重,所有的邏輯基本上都寫在這里。
MVVM又在MVP的基礎上修改了一下,將Presenter替換成了ViewModel,通信方式基本上和MVP一樣,并且采用雙向綁定,View的變動反應在Model上,Model的變動也反應在View上,所以稱為ViewModel,現在大部分的框架都是基于這種模型,如Angular、Ember和Backbone。
從MVC和MVP可以看到,不是View層太重,就是把業務邏輯都寫到了Presenter層,MVVM也沒有定義狀態的數據流。
最早的時候Elm定義了一套模型,Model -> Update -> View,除了Model之外,其它都是純函數的。之后又有人提出了SAM「State Action Model」模型,SAM主要強調兩個原則:1. 視圖和模型之間的關系必須是函數式的,2. 狀態的變化必須是編程模型的一等公民。另外還有對開發友好的工具,如時光旅行。像Redux和Mobx State Tree都是這種架構模型。
讓我們想象一下國家電網,或者更接近我們的經常接觸的領域——網絡。網絡有非常嚴格的定義,必須是有序的流,因為并不是所有連接到互聯網的計算機都與其他計算機直接連接,它們通過路由節點間接連接。只有這樣,網絡才變得可以理解,因而易于管理。狀態管理也是如此,狀態的流動必須是有序的。
組件架構
你可以將組件視為組成用戶界面的一個個小功能。我們要描述Gitchat的用戶界面,可以看到Tabbar是一個組件,發現頁的達人課是一個組件,Chat也是一個組件。這些組件中都包裝在一個容器內,它們彼此獨立又互相交互。組件有自己的結構,自己的方法和自己的API,組件也是可重用的。
有些組件還有AJAX的請求,直接從客戶端調用服務端,允許動態更新DOM,而無需頁面刷新。組件每個都有自己的接口,可以調用服務端并更新其接口。因為組件是獨立的,所以一個組件可以刷新而不影響其他組件。React使用稱為虛擬DOM的東西,它使用“diffing”算法來檢測組件的更改,并且僅渲染這些更改,而不是重新渲染整個組件。在設計組件的時候最好遵循組件的結構中僅存在與單個組件有關的所有方法和接口。
雖然這種組件的架構鼓勵可重用性和單一責任,但它往往會導致臃腫。MV*的目的是確保應用程序的每個層次都有各自的職責,而基于組件的架構目的是將所有這些職責封裝在一個空間內。當使用許多組件時,可讀性可能會降低。
React提供了兩種組件,Stateful和Stateless,簡單來說,這兩種組件的區別就是狀態管理,Stateful組件內部封裝了State管理,Stateless則是純函數式的,由Props傳遞狀態。
class App extends Component { state = { welcome: 'hello world' } componentDidMount() { ... } componentWillUnmount() { ... } render() { return ( <div> {this.state.welcome} </div> ) } }
我們先從一個簡單的例子開始看,組件內部維護了一個狀態,當狀態發生變化是,會通知render更新。這個組件不僅帶有State,還有和組件相關的Hook。我們可以使用這種組件構建一個高內聚低耦合的組件,將復雜的交互細節封裝在組件內部。當然我們還可以使用PureComponent的組件優化,只有需要更新的時候才執行更新的操作。
const App = ({ welcome }) => (
<div>{welcome}</div>
)
無狀態的組件,狀態由上層傳遞,組件純展示,相比帶狀態的組件來說,無狀態的組件性能更好,沒有不必要的Hook。
import { observer } from 'mobx-react'
const App = observer(({ welcome }) => (
<div>{welcome}</div>
))
observer函數實際上是在組件上包裝了一層,當可觀察的State改變時,它會更新狀態以Props的形式傳遞給組件。這樣的組件設計能夠幫助更好可復用組件。
當我們拿到設計稿的時候,一開始需要做的事情就是劃分一個個小組件,并且保證組件的職責單一,而且越簡單越短小越好。并盡量保持組件是無狀態的。如果需要有狀態,也僅僅是內部關聯的狀態,就是與業務無關的狀態。
狀態架構
如果你之前用過jQuery或Angular或任何其他的框架,通常使用命令式的編程方式調用函數,函數執行數據更新。在使用React就需要調整一下觀念。
有限狀態機是個十分有用的模型,可以用來模擬世界上大部分的事物,其有三個特征:
- 狀態總數是有限的。
- 任一時刻,只處在一種狀態之中。
- 某種條件下,會從一種狀態轉變到另一種狀態。
state定義初始狀態,點擊事件后使counter狀態發生變化,而render則是描述當前狀態呈現的樣子。 React自帶的狀態管理,Redux和MST這里的工具都是一種狀態機的實現,只是不同的是,React的狀態是內置組件里面,將組件渲染為組件樹,而Redux或MST則是將狀態維護成一棵樹--狀態樹。
import React, { Component } from 'react'
import { render } from 'react-dom'
class Counter extends Component {
state = {
counter: 0,
}
increment = (e) => {
e.preventDefault()
this.setState({ counter: this.state.counter++ })
}
decrement = () => {
e.preventDefault()
this.setState({ counter: this.state.counter-- })
}
render() {
return (
<div>
<div id='counter'>{this.state.counter}</div>
<button onClick={this.increment}>+</button>
<button onClick={this.decrement}>-</button>
</div>
)
}
}
render(<Counter />, document.querySelector('#app'))
組件自己管理的狀態數據相關聯的一個缺點,就是將狀態管理與組件生命周期相耦合。如果某些數據存在于組件的本地狀態中,那么它將與該組件一起消失,沒有進一步保存數據,那么只要組件卸載,State的內容就會丟失。
組件的層次結構的在很大程度上取決于通過DOM布局。因為狀態主要通過React中的Props分發,組件之間的父/子關系結構,影響了組件的通信,單向(父到子)對狀態流動是比較容易的。當組件嵌套的層級比較深時,依賴關系變得復雜時,必然會有子級組件需要修改父級組件的狀態,這就需要回調函數在各個組件傳遞,狀態管理又變得非常混亂。這就需要一個獨立于組件之外的狀態管理,能夠中心化的管理狀態,解決多層組件依賴和狀態流動的問題。
現在主流的狀態管理有兩種方案,基于Elm架構的Redux,基于觀察者的Mobx。
import React from 'react' import { render } from 'react-dom' import { createStore } from 'redux'; const counter = (state = 0, action) => { switch (action.type) { case 'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } const store = createStore(counter) const Counter = ({ value, onIncrement, onDecrement }) => ( <div> <div id='counter'>{value}</div> <button onClick={onIncrement}>+</button> <button onClick={onDecrement}>-</button> </div> ); const App = () => ( <Counter value={store.getState()} onIncrement={() => store.dispatch({ type: 'INCREMENT' })} onDecrement={() => store.dispatch({ type: 'DECREMENT' })} /> ) const renderer = render(<App />, document.querySelector('#app')) store.subscribe(renderer)
Redux狀態的管理,它的數據流動必須嚴格定義,要求所有的State都以一個對象樹的形式儲存在一個單一的Store中。惟一改變State的辦法是觸發Action,為了描述Action如何改變State樹,你需要編寫Reducers。Redux的替代方法是保持簡單,使用組件的局部狀態。
import React from 'react' import { render } from 'react-dom' import {observable} from 'mobx' import {observer} from 'mobx-react' class Counter { @observable counter = 0 increment() { this.counter++ } decrement() { this.counter-- } } const store = new CounterStore(); const Counter = observer(() => ( <div> <div id='counter'>{store.counter}</div> <button onClick={store.increment}>+</button> <button onClick={store.decrement}>-</button> </div> )) render(<App />, document.querySelector('#app'))
Mobx覺得這種方式太麻煩了,為了更新一個狀態居然要繞一大圈,它強調狀態應該自動獲得,只要定義一個可觀察的State,讓View觀察State的變化,State的變化之后發出更新通知。在Redux里要實現一個特性,你需要更改至少4個地方。包括reducers、actions、組件容器和組件代碼。Mobx只要求你更改最少2個地方,Store和View。很明顯看到使用Mobx寫出來的代碼非常精簡,OOP風格和良好的開發實踐,你可以快速構建應用。
import React from 'react' import { render } from 'react-dom' import { types } from 'mobx-state-tree' import { observer } from 'mobx-react' const CounterModel = types .model({ counter: types.optional(types.number, 0) }) .actions(self => ({ increment() { self.counter++ }, decrement() { self.counter-- } })) const store = CounterModel.create() const App = observer(({ store }) => ( <div> <div id='counter'>{store.counter}</div> <button onClick={store.increment}>+</button> <button onClick={store.decrement}>-</button> </div> )) render(<App store={store}/>, document.querySelector('#app'))
這種管理狀態看起來很像MVVM的雙向綁定,MST「Mobx State Tree」受Elm和SAM架構的影響,背后的思想也非常簡單:
- 穩定的參考態和直接可變的對象。也就是有一個變量指向一個對象,并對其進行后續的讀取或寫入,不用擔心你正在使用舊的數據。
- 狀態為不可變的、結構性的共享樹。
每次的操作,MST都會將不可變的數據狀態生成一個快照,類似虛擬DOM的實現方案,因為React的render也只是比較差異再渲染的,所以開銷并不會太大。
與MobX不同的是,MST是一種有架構體系的庫,它對狀態組織施行嚴格的管理。修改狀態和方法現在由MST樹處理。使用MobX向父組件注入樹。一旦注入,樹就可以用于父組件及其子組件。父組件不需要通過子組件將任何方法傳遞給子組件B。React組件根本不需要處理任何狀態。子組件B可以直接調用樹中的動作來修改樹的屬性。
異步方案
我們都知道Javascript的代碼運行在主線程上,像DOM事件、定時器運行在工作線程上。一般情況下,我們寫一段異步操作的代碼,一開始可能就想到使用回調函數。
asyncOperation1(data1,function (result1) { asyncOperation2(data2,function(result2){ asyncOperation3(data3,function (result3) { asyncOperation4(data4,function (result4) { // do something }) }) }) })
回調函數使用不當嵌套的層級非常多就造成回調地獄。
Promise方案使用鏈式操作的方案這是將原來層級的操作扁平化。
asyncOperation1(data) .then(function (data1) { return asyncOperation2(data1) }).then(function(data2){ return asyncOperation3(data2) }).then(function(data3){ return asyncOperation(data3) })
ES6語法中引入了generator,使用yeild和*函數封裝了一下Promise,在調用的時候,需要執行next()函數,就像python的yield一樣。
function* generateOperation(data1) { var result1 = yield asyncOperation1(data1); var result2 = yield asyncOperation2(result1); var result3 = yield asyncOperation3(result2); var result4 = yield asyncOperation4(result3); // more }
ES7由出現了async/await關鍵字,其實就是在ES6的基礎上把*換成async,把yield換成了await,從語義上這種方案更容易理解。
async function generateOperation(data1) { var result1 = await asyncOperation1(data1); var result2 = await asyncOperation2(result1); var result3 = await asyncOperation3(result2); var result4 = await asyncOperation4(result3); // more }
在調用generateOperation時,ES6和ES7的異步方案返回的都是Promise。
傳統的異步方案:
- 嵌套太多
- 函數中間太過于依賴,一旦某個函數發生錯誤,就不能繼續執行下去了
- 變量污染
使用Promise方案:
- 結構化代碼
- 鏈式操作
- 函數式
使用Promise你可以像堆積木一樣開發。
多入口與目錄結構
Angluar或者Ember這類框架提供了一套非常完備的工具,對于初學者非常友好,可以通過CLI初始化項目,啟動開發服務器,運行單元測試,編譯生產環境的代碼。React在發布很久之后才發布了它的CLI工具:create-react-app。屏蔽了和React無關的配置,如Babel、Webpack。我們可以使用這個工具快速創建一個項目。
用npm安裝一個全局的create-react-app,npm install -g create-react-app ,然后運行 create-react-app hello-world ,就初始化好了一份React項目了,只要運行npm run start就能啟動開發服務器了。
然而,復雜的項目必然需要自定義Webpack配置,create-react-app提供了eject命令,這個命令是不可逆的,也就是說,當你運行了eject之后,就不能再用之前的命令了。這是必經的過程,所以我們繼續來看Webpack的配置。
Webpack核心配置非常簡單,只要掌握三個主要概念即可:
- entry 入口
- output 出口
- loader 加載器
entry支持字符串的單入口和數組/對象的多入口:
{ entry: './src' }
entry: { // pagesDir是前面準備好的入口文件集合目錄的路徑
pageOne: './src/pageOne.js',
pageTwo: './src/pageTwo.js',
}
output是打包輸出相關的配置,它可以指定打包出來的包名/切割的包名、路徑。
{ output: { path: './dist', filename: '[name].bundle.js', chunkFilename: '[name].chunk.js', publicPath: '/', } }
Loader配置也非常簡單,根據不同的文件類型對應不同的Loader。
{ module: { rules: [ { test: /\.(js|jsx)$/, loader: 'babel-loader', } } }
以及resolve,plugins配置提供更豐富的配置,create-react-app就是利用resolve的module配置支持多個node_modules,如create-react-app目錄下的node_modules和當前項目下的node_modules。
現在再來看看Webpack的多入口方案,Webpack的output內置變量[name]打包出來的包名對應entry對象的key,用HtmlWebpackPlugin插件自動添加JS和CSS。如果使用Commons Chunk可以將Vendor單獨剝離出來,這樣多入口就可以復用同一個Vendor。
通常,我們一個項目有許多獨立的子項目,使用Webpack的多入口方案就會造成打包的速度非常慢,不必要更新的入口也一起打包了。這時應該拆分為多個package,每個package是單入口也是獨立的,這種方案稱為monorepos「之前社區很流行將每個組件都建一個代碼倉庫,使用不太優雅的方案npm link在本地開發,這種方案的缺點非常明顯,代碼太分散,版本管理也是一個災難」。
我們可以使用Monorepos的方案,將子項目都放在packages目錄下,并且使用Lerna「實現Monorepos方案的一個工具」管理packages,可以批量初始化和執行命令。配合Lerna加Yarn的Workspace方案,所有子項目的node_modules都統一放在項目根目錄。
{ "lerna": "2.1.2", "commands": { "publish": { "ignore": ["ignored-file", "*.md"] } }, "packages": ["packages/*"], "npmClient": "yarn", "version": "2.6.1", "private": true }
正因為有Webpack的存在,似乎不怎么關注項目的目錄結構,像Angular或者Ember這樣的框架,開發者必須按照它的建議或者強制要求把按功能把文件放置到指定目錄。React卻沒有這類的束縛,開發者可以隨意定義目錄結構。無論如何,我們依然可以有3種套路來定義目錄結構。
-
扁平化
比如可以將所有的組件都放在components目錄,這種適合簡單組件少或者比較單一的情況。
-
以組件為目錄
組件內需要的文件放在同一個目錄下,如Alert和Notification可以建兩個目錄,目錄內部有代碼、樣式和測試用例。
-
以功能為目錄
如components、containers、stores按其功能放在一個目錄內,將組件都放在components目錄內,containers則是組裝component。
團隊協作
團隊開發必然也會遇到一個問題,每個人寫的代碼風格都不一樣,不同的編輯器也不盡相同。
有人喜歡雙引號,也有人使用單引號,代碼結尾要不要分號,最后一個對象要不要逗號,花括號放哪里,80列還是100列的問題。
還有更賤的情況,有人把代碼格式化綁定在編輯器上,一打開文件就格式化了一下代碼,如果他在提交一下代碼,簡直是異常災難,花了半天寫代碼,又花了半天解決代碼沖突問題。
像Go語言自帶了代碼格式化工具,使每個人寫出來的代碼風格是一致的,消除了程序員的戰爭。
前端也有類似的工具,Prettier配合ESLint最近在前端大受歡迎,再使用husky和lint-staged工具,在提交代碼的時候就將提交的代碼格式化。
Prettier是什么呢?就是強制格式化代碼風格的工具,在這之前也有類似的工具,像Standardjs,這個工具僅格式化JS代碼,無法處理JSX。而Prettier能夠格式化JS和LESS以及JSON。
// lint-staged.config.js
module.exports = { verbose: false, globOptions: { dot: false, }, linters: { '*.{js,jsx,json,less,css}': ['prettier --write', 'git add'], }, }
在package.json的scripts增加一個precommit
{ "scripts": { "precommit": "lint-staged" } }
這樣,在提交代碼時,就自動格式化代碼,使每個開發者的風格強制的保存一致。
測試驅動
很多人都不喜歡寫測試用例代碼,覺得浪費時間,主要是維護測試代碼非常的繁瑣。但是當你嘗試開始寫測試代碼的時候,特別是基礎組件類的,就會發現測試代碼是多么好用。不僅僅提高組件的代碼質量,但是當發生依賴庫更新,版本變化時,就能夠馬上發現這些潛在的問題。如果沒有測試代碼,也談不上自動化測試。
前端有非常多的工具可以選擇,Mocha、Jasmine、Karma、Jest等等太多的工具,要從這些工具里面選擇也是個困難的問題,有個簡單的辦法看社區的推薦,React現在主流推薦使用Jest作為測試框架,Enzyme作為React組件測試工具。
我們做單元測試也主要關注四個方面:組件渲染、狀態變化、事件響應、網絡請求。
而測試的方法論,可以根據自己的喜好實踐,如TDD和BDD,Jest對這兩者都支持。
首先我們測試一個Stateless的組件
import React from 'react' import { string } from 'prop-types' const Link = ({ title, url }) => <a href={url}>{title}</a> Link.propTypes = { title: string.isRequired, url: string.isRequired } export default Link
我們想看看Props屬性是否正確,是否渲染來測試這個組件。在第一次運行測試的時候會自動創建一個快照,然后看看結果是否一致。
import React from 'react' import { shallow } from 'enzyme' import { shallowToJson } from 'enzyme-to-json' import Link from './Link' describe('Link', () => { it('should render correctly', () => { const output = shallow( <Link title="testTitle" url="testUrl" /> ) expect(shallowToJson(output)).toMatchSnapshot() }) })
在運行測試之后,Jest會創建一個快照。
exports[`Link should render correctly 1`] = ` <a href="testUrl" > testTitle </a> `;
浙公網安備 33010602011771號