react的SPA实践

上一篇关于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不做页面刷新,只做局部更新,也就是除了你第一次打开网站的时候需要加载整个页面之外,之后的一切站内跳转都是不重载页面的,而是在当前页面进行局部刷新,达到页面切换的效果。

想象一下,假设网站原本需要两个页面ab,但现在我只做一个index,然后把ab两个页面的所有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会把ab这两个模块以及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可以提供一种更好的浏览体验,由于它是无跳转刷新的,所以我们可以更好来做全站的数据共享,比如跨页面的数据共享和通讯;还可以实现页面切换时候的过渡效果,比如淡入淡出;

……

这些在传统的多页面里都是很难实现的。

推荐阅读更多精彩内容