上一篇关于react简介与入门的文章【写了个假react】,文章最后介绍了通过组件的嵌套来实现页面构建的思路,这是react组件化的基本实现方式,依靠这样“单纯”的嵌套关系,我们可以最终构建一个可用的页面。
但是,仅仅依靠这样的方式,在构建比较复杂的web应用的时候是不够的。假设你是开发一个多页面网站,如果你每个页面都这么从零开始搭一套react架构,一方面操作繁琐,一方面体积冗余,而且还得考虑很多其他的问题,比如如何改造原有的开发脚手架以使其适用多页面,不好玩。
大纲
针对这样的问题,我们提倡使用 SPA 架构来搭建你的应用,这一篇我们将了解以下内容:
- SPA介绍
- presentational & container components
-
react
+react-router
实现SPA - SPA带来的问题以及解决方案探索
SPA
单页Web应用(single page web application,SPA),就是只有一张Web页面的应用,是加载单个HTML 页面并在用户与应用程序交互时动态更新该页面的Web应用程序。
—— 《百度百科》
SPA 的概念早已有之,简单说来就是:不管你这个网站有多少页面,我都给你整到一个页面里去。
SPA不做页面刷新,只做局部更新,也就是除了你第一次打开网站的时候需要加载整个页面之外,之后的一切站内跳转都是不重载页面的,而是在当前页面进行局部刷新,达到页面切换的效果。
想象一下,假设网站原本需要两个页面a
和b
,但现在我只做一个index
,然后把a
和b
两个页面的所有html片段都写到index
里去,显示的时候,通过js来判断当前的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的改变一般都是会引起页面跳转的,想要根据路由变化来展示不同的内容,同时页面又不跳转,这是怎么做到的?
我们知道,在url中,#
号之后的内容是不会引起页面跳转的,所以我们利用这一点来实现,当url是#/a
的时候,显示a
的html片段,同理#/b
则显示b
的html片段,这样一来,前端就有了自己的路由控制,可以来实现前端自己的 多页面 。
这种方式我们称为 前端路由。
presentational & container components
在使用前端路由之前,我们先来探讨一下,在react的开发中,什么是页面?
或者说,如果去界定一个页面?
我们知道,react是基于组件(component)来构建页面的,从这个定义上来讲,其实 页面只不过是一个更大的组件 ,它可以用来容纳和组织其他各个组件,从而编织出一个完整可用的页面。
但是这个所谓的 大组件 怎么看都与其他组件有那么些不一样的地方,它的目的不是被复用,而是负责为其他组件处理并注入数据,这样的组件我们称为 容器组件 (container component)。
但请注意,容器组件并不一定就是页面,它可以是各种粒度的组件组合。
相对应的,一个纯粹接收外部数据(不作处理)并只负责展示的组件,我们称为 展示组件 (presentational component)。
展示组件只负责展示而不对数据做任何处理,因此它更容易被各种业务场景集成和复用。
我们还是以上节内容当中的“时钟”例子来做演示:
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)
}
})
这里为了突出问题,我们把上一节里偷懒的部分补回来,把时间的展示格式给美化一下:
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,好看多了(并没有)。
目前为止,这个组件是没有什么问题的,我们把组件的逻辑和界面绑定在了一个组件里。
产品经理看了之后,让你把时间格式补全,不足两位数的前面补零。
你吐槽了几句然后乖乖去改,随手写了个fix2
方法,然后修改jsx
:{_t.fix2(time.getHours())}
。
到了下午,产品经理又走了过来,让你把时间格式显示为12小时制,不要24。
你又吐槽了几句然后乖乖去改,再一次修改jsx
:{_t.fix2(time.getHours() > 12 ? time.getHours() - 12 : time.getHours())}
。
jsx
内心os:鬼知道我都经历了什么。。。
Clock
内心os:你为什么不直接把时分秒三个值计算完再给我?这样一点都不酷!
是啊,这样的jsx很丑。事实上,当只有逻辑改变的时候,我们应该尽量不要牵涉到界面;同理,当只有界面改变的时候,我们也不该去影响逻辑。但是当组件集成了这两者的时候,我们就很难避免这类情况发生,比如不经意间就在jsx里写计算和装饰的逻辑了。
为了更好地实践上面的理论,我们需要把逻辑和视图分开,写成两个部分,一部分负责逻辑计算(container component),一部分负责展示(presentational component),最后,逻辑计算部分会引入展示部分,并且将展示部分需要的参数注入:
// /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
以上,我们便实现了关注点分离,可以看到展示组件非常简洁,而容器组件也只关注逻辑计算。
当然,这个例子还是看不出分离的必要性,因为目前的展示组件结构非常简单,而当这两者都比较复杂的时候,分离的作用就突显出来了。
注意,我们使用
Clock
组件的时候,是引入容器组件,而不是直接引入展示组件。这里有一点小技巧,为了避免组件太零碎,我们把两个组件都放在一个同名的文件夹Clock
中,展示组件用Clock.jsx
命名,而容器组件则以Index.jsx
命名,这样一来,在页面中引用Clock
的时候,我们就可以直接使用import Clock from './components/Clock'
,看着像引用了Clock.jsx
,其实是默认引用./components/Clock/Index.jsx
。
以上,就是关于 容器组件 与 展示组件 概念的介绍。我们回到一开始的问题,什么是页面?
没错,页面就是一个炒鸡大的 容器组件 !
理论上来说,你大可以各种拼装 容器组件,比如页面Index
可以只有:
import React from 'react'
require('../../../style/index')
var Index = function(props) {
return <div className="g-index">
<Header />
<Content />
<Footer />
</div>
}
export default Index
然后再分别去构建<Header />
、<Content />
、<Footer />
三部分,这样一步步细分下去。
但是,深层次的嵌套可能会造成通讯的困难,我们知道子组件之间通讯是需要依赖父组件环境的,也就是说,如果一个子组件处在很深层的位置上,那么它与另外一个处于其他层次的组件通讯就十分的困难了,需要找到他们两者之间的公共父组件,然后再从父组件开始一层层把handler通过props传递下来。
此刻的你:强颜欢笑.jpg
所以,我们在设计页面的时候,应该尽量避免深层次的嵌套关系,尽量保持扁平。
这样对大家都好 :)
当然,这种问题也是有解决方法的,比如可以使用一个event bus
(事件总线)来实现,这也是vue采用的做法,可以参考;另外,也可以借助redux来实现,这是后话了。
react-router
好吧,话题好像跑到了一个很远的地方,回到我们的SPA。
讲页面之前我们讲到了前端路由,react搭配react-router可以很轻易地实现。
使用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')
)
每个Route就是一个路由规则,path对应路径,component即对应要显示的容器组件,也就是我们前面说的页面。所以现在我们访问/
,可以看到链接自动变成了访问/#/
,同时显示Index
的内容,然后我们访问/#/about
,会发现页面变成了About
的内容。
而且页面并没有跳转!
(装作很惊讶的样子)
好吧,如果你已经开始使用react,那么你早该用上router了。
我当前使用的版本是3.0.2
,它会直接在匹配的component的props中注入一个router对象,所以我们可以在component中直接调用:
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)
}
})
上面实现的是,在进入index页面2s后,页面自动跳转到about页面,是不是很简单?
注意:react-router的api设计有变动,在之前的版本,可能还需要配合withRouter来使用,如果你发现本教程使用的方法不起作用,请根据自己当前使用的版本,对应查阅官方文档。
除了使用push之外,replace也是比较常用的方法,在这里我们不多做介绍,一切用法,看文档即可,因为很简单。我们接下来要着重讲的是,使用SPA要注意的事项。
SPA带来的问题以及解决方案探索
我们知道,SPA最终是把所有页面集合到了一个页面当中,那么,就css而言,我们如何做到页面Index
与页面About
的样式不会互相干扰?首页体积会不会变得很大?首屏的加载时间是不是会变得很长?SEO怎么做?
我们总结一下,问题大概有以下几点:
- 全局污染
- SEO
- 体积过大,加载缓慢
我们探讨一下怎么解决。
css规划
我们知道,使用webpack打包后的代码,如果没有使用插件分离的话,我们的css是会打包到js当中的,这样会使得js体积非常庞大,所以出于性能考虑,我们应该把css分离出来,通过使用extract-text-webpack-plugin可以实现。
这个webpack插件会把所有css都打包到一个独立的文件当中。
注意,是一个!一个!!!
但是我们明明有那么多个页面,你这样全塞进去我怎么放心?
所以我们需要一些小技巧,来规避css互相影响的问题。
如果你认真在看我上面的演示代码,在看到页面容器组件的时候,你一定会留意到,我在每个组件的最外层元素,都会定义一个根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
是这个页面的根class,也就是说,凡是属于这个页面的css内容,都必须包裹在它内部,这里粗略贴一下我的index.scss
:
.g-index {
.g-hd {
.m-nav {
...
}
}
.g-bd {
.list {
...
}
}
.g-ft {
.m-copy_right {
...
}
}
}
是不是很粗略?嗯,能理解就好。
关于css模块化的内容,有兴趣的可以看一下我的另一篇文章【从css谈模块化】。
简而言之,就是通过命名空间的方式来隔离这些样式,使得各个页面之间不会互相造成影响。
SEO
这是个比较专业的工作。
由于所有的内容都集中在了一个页面里,百度爬虫能抓取到的页面就变少了;由于页面是由js动态构建的,数据是通过api异步获取的,而百度搜索引擎目前还不支持解析js,所以页面在爬虫眼里是没有内容的。
……
以上一切决定了SEO优化工作的困难重重。
讲道理的话,这个锅也不该是SPA来背,这是由前后端的通讯方式决定的,一旦你决定走api的方式来跟后端打交道(前后端分离的一步),你的页面就不会有太多的静态内容输出。
所以解决这个问题,关键还是在静态内容输出上。
最简单的方式,就是通过后端模板引擎在首页做静态内容输出,其他页面都按照原来的方式走api;比较复杂的,可以考虑使用 同构应用,让js构建的页面也可以在后端完成渲染,最后输出静态页面。
但是以上方案实现起来都比较麻烦,特别是后者,对开发者能力要求比较高。
所以,我们建议,在采用SPA方式之前,先问过你老板意见。。。
如果老板不答应的话,建议换老板。
……
好吧,开玩笑的,自己权衡吧。
code splitting
当我们决定使用SPA的方式来承载一整个网站的时候,意味着整个网站的体积都集中在了一个页面上,那么,我们的首屏加载时间不可避免的会被拖慢,原本秒开的首页,现在需要2~5s不等。
要是被老板发现,这个季度的kpi怕是要狗带。
心里已经开始怀念起以前的多页面了。
不怕,webpack的 code splitting 可以解决这个问题!
code splitting 即代码分割,webpack支持将文件分割成若干份,然后异步地加载到页面上来。
require.ensure(['a', 'b'], function() {
var a = require('a')
var b = require('b')
})
通过使用require.ensure的方式,webpack会把a
和b
这两个模块以及callback里面的代码,单独打包成独立的资源文件,然后在运行的时候通过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')
)
用户进入页面的时候,除了首页Index
需要快速呈现之外,其他的路由都不需要第一时间加载,幸运的是,react-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'
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')
)
这样一组合,就实现了我们所期待的按需加载,同时有效减少了首屏应用的体积,达到加速首屏的目的。
完美!
……
……
……
了吗?
不知道,也许还有其他问题,欢迎补充。
总结
使用SPA可以提供一种更好的浏览体验,由于它是无跳转刷新的,所以我们可以更好来做全站的数据共享,比如跨页面的数据共享和通讯;还可以实现页面切换时候的过渡效果,比如淡入淡出;
……
这些在传统的多页面里都是很难实现的。