1. 程式人生 > 實用技巧 >如何用 React 構建前端架構

如何用 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就需要調整一下觀念。

有限狀態機是個十分有用的模型,可以用來模擬世界上大部分的事物,其有三個特徵:

  1. 狀態總數是有限的。
  2. 任一時刻,只處在一種狀態之中。
  3. 某種條件下,會從一種狀態轉變到另一種狀態。

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架構的影響,背後的思想也非常簡單:

  1. 穩定的參考態和直接可變的物件。也就是有一個變數指向一個物件,並對其進行後續的讀取或寫入,不用擔心你正在使用舊的資料。
  2. 狀態為不可變的、結構性的共享樹。

每次的操作,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。

傳統的非同步方案:

  1. 巢狀太多
  2. 函式中間太過於依賴,一旦某個函式發生錯誤,就不能繼續執行下去了
  3. 變數汙染

使用Promise方案:

  1. 結構化程式碼
  2. 鏈式操作
  3. 函式式

使用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核心配置非常簡單,只要掌握三個主要概念即可:

  1. entry 入口
  2. output 出口
  3. 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種套路來定義目錄結構。
  1. 扁平化

    比如可以將所有的元件都放在components目錄,這種適合簡單元件少或者比較單一的情況。

  2. 以元件為目錄

    元件內需要的檔案放在同一個目錄下,如Alert和Notification可以建兩個目錄,目錄內部有程式碼、樣式和測試用例。

  3. 以功能為目錄

    如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>
`;