上一篇關(guān)于react簡介與入門的文章【寫了個(gè)假react】,文章最后介紹了通過組件的嵌套來實(shí)現(xiàn)頁面構(gòu)建的思路,這是react組件化的基本實(shí)現(xiàn)方式,依靠這樣“單純”的嵌套關(guān)系,我們可以最終構(gòu)建一個(gè)可用的頁面。
但是,僅僅依靠這樣的方式,在構(gòu)建比較復(fù)雜的web應(yīng)用的時(shí)候是不夠的。假設(shè)你是開發(fā)一個(gè)多頁面網(wǎng)站,如果你每個(gè)頁面都這么從零開始搭一套react架構(gòu),一方面操作繁瑣,一方面體積冗余,而且還得考慮很多其他的問題,比如如何改造原有的開發(fā)腳手架以使其適用多頁面,不好玩。
大綱
針對(duì)這樣的問題,我們提倡使用 SPA 架構(gòu)來搭建你的應(yīng)用,這一篇我們將了解以下內(nèi)容:
- SPA介紹
- presentational & container components
-
react+react-router實(shí)現(xiàn)SPA - SPA帶來的問題以及解決方案探索
SPA
單頁Web應(yīng)用(single page web application,SPA),就是只有一張Web頁面的應(yīng)用,是加載單個(gè)HTML 頁面并在用戶與應(yīng)用程序交互時(shí)動(dòng)態(tài)更新該頁面的Web應(yīng)用程序。
—— 《百度百科》
SPA 的概念早已有之,簡單說來就是:不管你這個(gè)網(wǎng)站有多少頁面,我都給你整到一個(gè)頁面里去。
SPA不做頁面刷新,只做局部更新,也就是除了你第一次打開網(wǎng)站的時(shí)候需要加載整個(gè)頁面之外,之后的一切站內(nèi)跳轉(zhuǎn)都是不重載頁面的,而是在當(dāng)前頁面進(jìn)行局部刷新,達(dá)到頁面切換的效果。
想象一下,假設(shè)網(wǎng)站原本需要兩個(gè)頁面a和b,但現(xiàn)在我只做一個(gè)index,然后把a和b兩個(gè)頁面的所有html片段都寫到index里去,顯示的時(shí)候,通過js來判斷當(dāng)前的url,如果是/a,我就只顯示原本屬于a的html片段;同理,如果是/b,我就顯示b的html片段。
a.html:
<!DOCTYPE html>
<html>
<head>
<title>a</title>
</head>
<body>
<div>
a.html
</div>
</body>
</html>
b.html:
<!DOCTYPE html>
<html>
<head>
<title>b</title>
</head>
<body>
<div>
b.html
</div>
</body>
</html>
index.html:
<!DOCTYPE html>
<html>
<head>
<title>index</title>
</head>
<body>
<div class="page a z-crt">
a.html
</div>
<div class="page b">
b.html
</div>
</body>
</html>
但是url的改變一般都是會(huì)引起頁面跳轉(zhuǎn)的,想要根據(jù)路由變化來展示不同的內(nèi)容,同時(shí)頁面又不跳轉(zhuǎn),這是怎么做到的?
我們知道,在url中,#號(hào)之后的內(nèi)容是不會(huì)引起頁面跳轉(zhuǎn)的,所以我們利用這一點(diǎn)來實(shí)現(xiàn),當(dāng)url是#/a的時(shí)候,顯示a的html片段,同理#/b則顯示b的html片段,這樣一來,前端就有了自己的路由控制,可以來實(shí)現(xiàn)前端自己的 多頁面 。
這種方式我們稱為 前端路由。
presentational & container components
在使用前端路由之前,我們先來探討一下,在react的開發(fā)中,什么是頁面?
或者說,如果去界定一個(gè)頁面?
我們知道,react是基于組件(component)來構(gòu)建頁面的,從這個(gè)定義上來講,其實(shí) 頁面只不過是一個(gè)更大的組件 ,它可以用來容納和組織其他各個(gè)組件,從而編織出一個(gè)完整可用的頁面。
但是這個(gè)所謂的 大組件 怎么看都與其他組件有那么些不一樣的地方,它的目的不是被復(fù)用,而是負(fù)責(zé)為其他組件處理并注入數(shù)據(jù),這樣的組件我們稱為 容器組件 (container component)。
但請(qǐng)注意,容器組件并不一定就是頁面,它可以是各種粒度的組件組合。
相對(duì)應(yīng)的,一個(gè)純粹接收外部數(shù)據(jù)(不作處理)并只負(fù)責(zé)展示的組件,我們稱為 展示組件 (presentational component)。
展示組件只負(fù)責(zé)展示而不對(duì)數(shù)據(jù)做任何處理,因此它更容易被各種業(yè)務(wù)場景集成和復(fù)用。
我們還是以上節(jié)內(nèi)容當(dāng)中的“時(shí)鐘”例子來做演示:
var Clock = React.createClass({
getInitialState: function() {
return {
time: new Date().toString()
}
},
render: function() {
var _t = this
return <div className="m-clock">
{_t.props.city}: {_t.state.time}
</div>
},
componentDidMount: function() {
var _t = this
var timer = setInterval(function () {
if (_t.isMounted()) {
_t.setState({
time: new Date().toString()
})
} else {
clearInterval(timer)
}
}, 1000)
}
})
這里為了突出問題,我們把上一節(jié)里偷懶的部分補(bǔ)回來,把時(shí)間的展示格式給美化一下:
var Clock = React.createClass({
getInitialState: function() {
return {
time: new Date()
}
},
render: function() {
var _t = this
var time = _t.state.time
return <div className="m-clock">
{time.getHours()}:
{time.getMinutes()}:
{time.getSeconds()}
</div>
},
componentDidMount: function() {
var _t = this
var timer = setInterval(function () {
if (_t.isMounted()) {
_t.setState({
time: new Date()
})
} else {
clearInterval(timer)
}
}, 1000)
}
})
ok,好看多了(并沒有)。
目前為止,這個(gè)組件是沒有什么問題的,我們把組件的邏輯和界面綁定在了一個(gè)組件里。
產(chǎn)品經(jīng)理看了之后,讓你把時(shí)間格式補(bǔ)全,不足兩位數(shù)的前面補(bǔ)零。
你吐槽了幾句然后乖乖去改,隨手寫了個(gè)fix2方法,然后修改jsx:{_t.fix2(time.getHours())}。
到了下午,產(chǎn)品經(jīng)理又走了過來,讓你把時(shí)間格式顯示為12小時(shí)制,不要24。
你又吐槽了幾句然后乖乖去改,再一次修改jsx:{_t.fix2(time.getHours() > 12 ? time.getHours() - 12 : time.getHours())}。
jsx內(nèi)心os:鬼知道我都經(jīng)歷了什么。。。
Clock內(nèi)心os:你為什么不直接把時(shí)分秒三個(gè)值計(jì)算完再給我?這樣一點(diǎn)都不酷!
是啊,這樣的jsx很丑。事實(shí)上,當(dāng)只有邏輯改變的時(shí)候,我們應(yīng)該盡量不要牽涉到界面;同理,當(dāng)只有界面改變的時(shí)候,我們也不該去影響邏輯。但是當(dāng)組件集成了這兩者的時(shí)候,我們就很難避免這類情況發(fā)生,比如不經(jīng)意間就在jsx里寫計(jì)算和裝飾的邏輯了。
為了更好地實(shí)踐上面的理論,我們需要把邏輯和視圖分開,寫成兩個(gè)部分,一部分負(fù)責(zé)邏輯計(jì)算(container component),一部分負(fù)責(zé)展示(presentational component),最后,邏輯計(jì)算部分會(huì)引入展示部分,并且將展示部分需要的參數(shù)注入:
// /Clock/Clock.jsx
import React from 'react'
var Clock = function(props) {
return <div className="m-clock">
{props.hours}:{props.minutes}:{props.seconds}
</div>
}
export default Clock
// /Clock/Index.jsx
import React from 'react'
import Clock from './Clock'
var ClockContainer = React.createClass({
getInitialState: function() {
return {
time: new Date()
}
},
render: function() {
var _t = this
var time = _t.state.time
var hours = time.getHours()
var minutes = time.getMinutes()
var seconds = time.getSeconds()
hours = _t.fix2(_t.limit(hours))
minutes = _t.fix2(minutes)
seconds = _t.fix2(seconds)
return <Clock hours={hours} minutes={minutes} seconds={seconds} />
},
componentDidMount: function() {
var _t = this
var timer = setInterval(function () {
if (_t.isMounted()) {
_t.setState({
time: new Date()
})
} else {
clearInterval(timer)
}
}, 1000)
},
limit: function(num) {
if (num > 12) {
return num - 12
}
return num
},
fix2: function(num) {
if (num < 10) {
return '0' + num
}
return num
}
})
export default ClockContainer
以上,我們便實(shí)現(xiàn)了關(guān)注點(diǎn)分離,可以看到展示組件非常簡潔,而容器組件也只關(guān)注邏輯計(jì)算。
當(dāng)然,這個(gè)例子還是看不出分離的必要性,因?yàn)槟壳暗恼故窘M件結(jié)構(gòu)非常簡單,而當(dāng)這兩者都比較復(fù)雜的時(shí)候,分離的作用就突顯出來了。
注意,我們使用
Clock組件的時(shí)候,是引入容器組件,而不是直接引入展示組件。這里有一點(diǎn)小技巧,為了避免組件太零碎,我們把兩個(gè)組件都放在一個(gè)同名的文件夾Clock中,展示組件用Clock.jsx命名,而容器組件則以Index.jsx命名,這樣一來,在頁面中引用Clock的時(shí)候,我們就可以直接使用import Clock from './components/Clock',看著像引用了Clock.jsx,其實(shí)是默認(rèn)引用./components/Clock/Index.jsx。
以上,就是關(guān)于 容器組件 與 展示組件 概念的介紹。我們回到一開始的問題,什么是頁面?
沒錯(cuò),頁面就是一個(gè)炒雞大的 容器組件 !
理論上來說,你大可以各種拼裝 容器組件,比如頁面Index可以只有:
import React from 'react'
require('../../../style/index')
var Index = function(props) {
return <div className="g-index">
<Header />
<Content />
<Footer />
</div>
}
export default Index
然后再分別去構(gòu)建<Header />、<Content />、<Footer />三部分,這樣一步步細(xì)分下去。
但是,深層次的嵌套可能會(huì)造成通訊的困難,我們知道子組件之間通訊是需要依賴父組件環(huán)境的,也就是說,如果一個(gè)子組件處在很深層的位置上,那么它與另外一個(gè)處于其他層次的組件通訊就十分的困難了,需要找到他們兩者之間的公共父組件,然后再從父組件開始一層層把handler通過props傳遞下來。
此刻的你:強(qiáng)顏歡笑.jpg
所以,我們?cè)谠O(shè)計(jì)頁面的時(shí)候,應(yīng)該盡量避免深層次的嵌套關(guān)系,盡量保持扁平。
這樣對(duì)大家都好 :)
當(dāng)然,這種問題也是有解決方法的,比如可以使用一個(gè)event bus(事件總線)來實(shí)現(xiàn),這也是vue采用的做法,可以參考;另外,也可以借助redux來實(shí)現(xiàn),這是后話了。
react-router
好吧,話題好像跑到了一個(gè)很遠(yuǎn)的地方,回到我們的SPA。
講頁面之前我們講到了前端路由,react搭配react-router可以很輕易地實(shí)現(xiàn)。
使用react集成router很簡單,直接installreact-router即可:
import React, { Component, PropTypes } from 'react'
import ReactDom from 'react-dom'
import { Router, Route, IndexRoute, hashHistory } from 'react-router'
import Index from './containers/Index'
import About from './containers/About'
ReactDom.render(
<Router history={hashHistory}>
<Route path="/" component={Index}/>
<Route path="/about" component={About}/>
</Router>,
document.getElementById('root')
)
每個(gè)Route就是一個(gè)路由規(guī)則,path對(duì)應(yīng)路徑,component即對(duì)應(yīng)要顯示的容器組件,也就是我們前面說的頁面。所以現(xiàn)在我們?cè)L問/,可以看到鏈接自動(dòng)變成了訪問/#/,同時(shí)顯示Index的內(nèi)容,然后我們?cè)L問/#/about,會(huì)發(fā)現(xiàn)頁面變成了About的內(nèi)容。
而且頁面并沒有跳轉(zhuǎn)!
(裝作很驚訝的樣子)
好吧,如果你已經(jīng)開始使用react,那么你早該用上router了。
我當(dāng)前使用的版本是3.0.2,它會(huì)直接在匹配的component的props中注入一個(gè)router對(duì)象,所以我們可以在component中直接調(diào)用:
const Index = React.createClass({
render: function() {
return <div className="g-index">
...
</div>
},
componentDidMount: function() {
var _t = this
var router = _t.props.router
setTimeout(function () {
router.push({
pathname: '/about'
})
}, 2000)
}
})
上面實(shí)現(xiàn)的是,在進(jìn)入index頁面2s后,頁面自動(dòng)跳轉(zhuǎn)到about頁面,是不是很簡單?
注意:react-router的api設(shè)計(jì)有變動(dòng),在之前的版本,可能還需要配合withRouter來使用,如果你發(fā)現(xiàn)本教程使用的方法不起作用,請(qǐng)根據(jù)自己當(dāng)前使用的版本,對(duì)應(yīng)查閱官方文檔。
除了使用push之外,replace也是比較常用的方法,在這里我們不多做介紹,一切用法,看文檔即可,因?yàn)楹芎唵?。我們接下來要著重講的是,使用SPA要注意的事項(xiàng)。
SPA帶來的問題以及解決方案探索
我們知道,SPA最終是把所有頁面集合到了一個(gè)頁面當(dāng)中,那么,就css而言,我們?nèi)绾巫龅巾撁?code>Index與頁面About的樣式不會(huì)互相干擾?首頁體積會(huì)不會(huì)變得很大?首屏的加載時(shí)間是不是會(huì)變得很長?SEO怎么做?
我們總結(jié)一下,問題大概有以下幾點(diǎn):
- 全局污染
- SEO
- 體積過大,加載緩慢
我們探討一下怎么解決。
css規(guī)劃
我們知道,使用webpack打包后的代碼,如果沒有使用插件分離的話,我們的css是會(huì)打包到j(luò)s當(dāng)中的,這樣會(huì)使得js體積非常龐大,所以出于性能考慮,我們應(yīng)該把css分離出來,通過使用extract-text-webpack-plugin可以實(shí)現(xiàn)。
這個(gè)webpack插件會(huì)把所有css都打包到一個(gè)獨(dú)立的文件當(dāng)中。
注意,是一個(gè)!一個(gè)?。?!
但是我們明明有那么多個(gè)頁面,你這樣全塞進(jìn)去我怎么放心?
所以我們需要一些小技巧,來規(guī)避css互相影響的問題。
如果你認(rèn)真在看我上面的演示代碼,在看到頁面容器組件的時(shí)候,你一定會(huì)留意到,我在每個(gè)組件的最外層元素,都會(huì)定義一個(gè)根className,再看一遍:
import React from 'react'
require('../../../style/index')
var Index = function(props) {
return <div className="g-index">
<Header />
<Content />
<Footer />
</div>
}
export default Index
g-index是這個(gè)頁面的根class,也就是說,凡是屬于這個(gè)頁面的css內(nèi)容,都必須包裹在它內(nèi)部,這里粗略貼一下我的index.scss:
.g-index {
.g-hd {
.m-nav {
...
}
}
.g-bd {
.list {
...
}
}
.g-ft {
.m-copy_right {
...
}
}
}
是不是很粗略?嗯,能理解就好。
關(guān)于css模塊化的內(nèi)容,有興趣的可以看一下我的另一篇文章【從css談模塊化】。
簡而言之,就是通過命名空間的方式來隔離這些樣式,使得各個(gè)頁面之間不會(huì)互相造成影響。
SEO
這是個(gè)比較專業(yè)的工作。
由于所有的內(nèi)容都集中在了一個(gè)頁面里,百度爬蟲能抓取到的頁面就變少了;由于頁面是由js動(dòng)態(tài)構(gòu)建的,數(shù)據(jù)是通過api異步獲取的,而百度搜索引擎目前還不支持解析js,所以頁面在爬蟲眼里是沒有內(nèi)容的。
……
以上一切決定了SEO優(yōu)化工作的困難重重。
講道理的話,這個(gè)鍋也不該是SPA來背,這是由前后端的通訊方式?jīng)Q定的,一旦你決定走api的方式來跟后端打交道(前后端分離的一步),你的頁面就不會(huì)有太多的靜態(tài)內(nèi)容輸出。
所以解決這個(gè)問題,關(guān)鍵還是在靜態(tài)內(nèi)容輸出上。
最簡單的方式,就是通過后端模板引擎在首頁做靜態(tài)內(nèi)容輸出,其他頁面都按照原來的方式走api;比較復(fù)雜的,可以考慮使用 同構(gòu)應(yīng)用,讓js構(gòu)建的頁面也可以在后端完成渲染,最后輸出靜態(tài)頁面。
但是以上方案實(shí)現(xiàn)起來都比較麻煩,特別是后者,對(duì)開發(fā)者能力要求比較高。
所以,我們建議,在采用SPA方式之前,先問過你老板意見。。。
如果老板不答應(yīng)的話,建議換老板。
……
好吧,開玩笑的,自己權(quán)衡吧。
code splitting
當(dāng)我們決定使用SPA的方式來承載一整個(gè)網(wǎng)站的時(shí)候,意味著整個(gè)網(wǎng)站的體積都集中在了一個(gè)頁面上,那么,我們的首屏加載時(shí)間不可避免的會(huì)被拖慢,原本秒開的首頁,現(xiàn)在需要2~5s不等。
要是被老板發(fā)現(xiàn),這個(gè)季度的kpi怕是要狗帶。
心里已經(jīng)開始懷念起以前的多頁面了。
不怕,webpack的 code splitting 可以解決這個(gè)問題!
code splitting 即代碼分割,webpack支持將文件分割成若干份,然后異步地加載到頁面上來。
require.ensure(['a', 'b'], function() {
var a = require('a')
var b = require('b')
})
通過使用require.ensure的方式,webpack會(huì)把a和b這兩個(gè)模塊以及callback里面的代碼,單獨(dú)打包成獨(dú)立的資源文件,然后在運(yùn)行的時(shí)候通過jsonp的方式加載回來。
回頭看我們的路由:
import React, { Component, PropTypes } from 'react'
import ReactDom from 'react-dom'
import { Router, Route, IndexRoute, hashHistory } from 'react-router'
import Index from './containers/Index'
import About from './containers/About'
ReactDom.render(
<Router history={hashHistory}>
<Route path="/" component={Index}/>
<Route path="/about" component={About}/>
</Router>,
document.getElementById('root')
)
用戶進(jìn)入頁面的時(shí)候,除了首頁Index需要快速呈現(xiàn)之外,其他的路由都不需要第一時(shí)間加載,幸運(yùn)的是,react-router允許我們使用回調(diào)的方式來載入頁面!
那么我們可以稍微改造一下:
import React, { Component, PropTypes } from 'react'
import ReactDom from 'react-dom'
import { Router, Route, IndexRoute, hashHistory } from 'react-router'
import Index from './containers/Index'
ReactDom.render(
<Router history={hashHistory}>
<Route path="/" component={Index}/>
<Route path="/index" getComponent={(nextState, cb) => {
require.ensure(['./containers/About'], function(require) {
var About = require('./containers/About').default
cb(null, About)
})
}}/>
</Router>,
document.getElementById('root')
)
這樣一組合,就實(shí)現(xiàn)了我們所期待的按需加載,同時(shí)有效減少了首屏應(yīng)用的體積,達(dá)到加速首屏的目的。
完美!
……
……
……
了嗎?
不知道,也許還有其他問題,歡迎補(bǔ)充。
總結(jié)
使用SPA可以提供一種更好的瀏覽體驗(yàn),由于它是無跳轉(zhuǎn)刷新的,所以我們可以更好來做全站的數(shù)據(jù)共享,比如跨頁面的數(shù)據(jù)共享和通訊;還可以實(shí)現(xiàn)頁面切換時(shí)候的過渡效果,比如淡入淡出;
……
這些在傳統(tǒng)的多頁面里都是很難實(shí)現(xiàn)的。