react服务端渲染教你一步步搭建并实战社团活动管理项目

931 阅读10分钟

activity_management 项目准备

项目说明:在已有后端项目的基础上,进行前端的业务开发。本项目的功能在于管理学校社团活动,简化学生在活动申请、审核、创建以及报名参与一系列流程。文章将按照作者的开发进度更新,一边开发一边记录

目标:客户端访问的第一屏,由服务端渲染,之后的页面变化都是SPA 优势:(1)解决了第一次白屏时间过长的缺点(2)第一次请求就有实质性的内容,SEO优化

如下图所示,服务端渲染需要完成Component、Router和Store的同构

创建项目

当前为一个node项目,通过webpack的配置让它兼容react的语法,简化开发

npm init -y

npm install webpack

webpack-cli --save-dev

同构html

1. 渲染HTML

  • 创建根目录 mkdir ./src
  • 创建服务端根目录 cd ./src mkdir ./server
  • 把require引入方式,改成import方式以求风格统一,解决方案: webpack打包成js,直接跑打包后的js就行了
    • 配置webpack.base.js
module.exports = {
  resolve: {
    // 使用 [resolve.extensions] 选项作为文件扩展名来解析,此选项告诉解析器在解析中能够接受哪些扩展名(例如 .js, .jsx)
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /(.js|.jsx)$/,
        use: ['babel-loader'],
        exclude: /node_modules/
      }
    ]
  }
}
    • 配置webpack.server.js
const path = require('path')
const webpackMerge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'build')
  },
  externals: [nodeExternals()]
}
module.exports = webpackMerge(baseConfig, serverConfig)
  • 配置完成后运行打包命令 webpack --config ./webpack.server.js
  • 打包之后运行打包之后的文件bundle.js,此后在项目中,commonJS和ES6模块化可以混用了 nodemon bundle.js
  • 开启一个express服务,前置条件有一个生产html页面的方法,取名为render
const React = require('react');
const Express = require('express')
const render = require('./render')

const app = new Express();

app.get('*', (req, res) => {
  const html = render();
  res.send(html)
})
app.listen(3000, () => {
  console.log('server is runing http://localhost:3000');
})
  • render的实现是基于renderToString,它能够将react-dom变成String
    • npm i react-dom 从react-dom/server引入其中的renderToString
    • 增加一个babel配置
{
  "presets": [
    "@babel/preset-react"
  ]
}

再次打包,跑一下bundle.js,成功获得如下结果,表示html渲染完成,但是此时的点击事件事件是无效的,服务端渲染页面,客户端绑定事件

2. 绑定事件

JS事件放在script标签中,只需要在返回的html中增加一个指向JS事件的script标签

  1. 把客户端打包到一个JS文件中
    • 入口,使用ReactDom.hydrate复用已有的html,负责事件绑定
import React from 'react'
import ReactDom from 'react-dom'
import Header from '../components/Header'
const App = function() {
  return (
    <Header />
  )
}
{/* 复用已有的html, 负责事件绑定 */}
ReactDom.hydrate(<App />, document.getElementById('app'))
    • webpack配置,将JS打包到public目录下
const path = require('path');
const webpackMerge = require('webpack-merge');
const config = require('./webpack.base.js');
const clientConfig = {
  mode: 'development',
  entry: './src/client/index.jsx',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  }
}
module.exports = webpackMerge(config, clientConfig);
  1. 安装npm install npm-run-all小工具,并在package.json中配置执行命令,即监听webpack.server.js和webpack.client.js的变化,如任一变化则重新打包一下,同时使用nodemon运行bundle.js,监听它的build事件
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"dev:build:server": "webpack --config ./webpack.server.js --watch",
"dev:build:client": "webpack --config ./webpack.client.js --watch"
  1. 增加script标签,把前端访问资源指向打包文件
<div id="app">${renderToString(App)}</div>
<script src="/index.js"></script>
  1. /index.js这个资源其实指的就是client打包过后的文件。所以,需要接到请求之前改变资源文件的指向。app.use是用来给path注册中间函数的,这个path默认是’/’,也就是处理用户的任何url请求,同时会处理path下的子路径:比如设置path为’/hello’,那么当请求路径为’/hello/’、’/hello/nihao’、’/hello/nihao/1’等等这样的请求也都会交给中间函数处理的。
app.use(express.static('public'))
  1. 上诉代码的意思就是把所有的资源请求交给expres.static()来处理,而这个函数则是把public作为对外提供文件服务的目录

完成上述步骤后,事件绑定就已经完成了。至此,已经完成的html渲染和事件绑定,即Component的同构

同构路由

1. 建路由表以及需要的页面

export default [
  {
    path: '/',
    component: App,
    routes: [
      {
        path: '/home',
        component: Home,
        exact: true
      },
      {
        path: '/login',
        component: Login,
      }
    ]
  }
]

2. 客户端用BrowserRouter管理路由

<BrowserRouter>
  <div>{ renderRoutes(routers) }</div>
</BrowserRouter>

3. 服务端用StaticRoute管理路由

<StaticRouter location={req.path}>
  <div>{renderRoutes(routes)}</div>
</StaticRouter>

完成上述步骤后,就完成的路由的同构即首屏加载的内容是已经生成好的html,且匹配路由

同构Store

1. redux流程

redux的流程:创建一个全局对象,对它的访问和修改只能通过特定的方法实现。

  • 用provide包裹整个#app, app即整个项目。然后再provide上挂一个store,此后app的所有子组件均可访问到变量,创建好了这个全局对象
<Provider store={store}>
  <StaticRouter location={req.path}>
   <div>{renderRoutes(routes)}</div>
  </StaticRouter>
</Provider>
  • 创建这个全局对象:
  1. 首先它应该是一个reducer,开始访问或事件会修改它。reducer会有一个默认的对象和一个抛出的方法,调用这个方法时这个action需要是一个字符串匹配下面的switch,且必须要是唯一的,所以应当用一个文件管理这些case字符串
import constant from '../../store/constant'
const defaultState = {
  activity: []
};
export const activityReducer = (state = defaultState, actions) => {
  switch(actions.type){
    case constant.ADD_ACTIVITY:
      return { ... state, activity:actions.activity };
    case constant.REMOVE_ACTIVITY:
      return { ... state, activity:actions.activity };
    default: return state
  }
}
  1. reducer可以有许多,来自不同的地方,但是一般统一起来放在要给store里面
import { combineReducers } from 'redux'
import * as activityReducer from '../pages/activitiesSquare/activityListReducer'
export default combineReducers(activityReducer)
  1. 最后再将这个reducer变成store就完成了,至此store已经创建好了
import { createStore } from 'redux'
import { combineReducers } from './reducer'
export default store = () => {
  return createStore(combineReducers)
}
  • 使用store: 访问和修改
  1. 使用Provide包裹抛出store自不必说,让它的子节点,孙子节点都能访问到这个props,用这一对标签包裹app让整个项目都能获取到这个store
<Provider store={store()}>
</Provider>
  1. 对react-redux的使用,即connect,将store中的数据和dispatch映射到props上
const mapStateToProps = (state) => {
  console.log(state);
  return {
    activityList: state.activityReducer.activity
  }
}
export default connect(mapStateToProps, mapDispatchToProps)(activitySquare);
  1. 另外,dispatch除了可以提交一个纯对象外,还可以dispatch一个方法,此时还需要在创建store时引入中间件thunk
function getActivityList() {
  return (dispatch) => {
    return axios.get('http://localhost:3003/mapi/comment').then(res => {
      const data = res.data.list;
      console.log(data);
      dispatch({
        type: 'ACtIVITY_LIST',
        activity: data
      })
    })
  }
}
const mapDispatchToProps = (dispatch) => {
  return {
    getActivityList: () => {
      dispatch(getActivityList())
    }
  }
}

2. 获得服务端Store

流程是先创建一个store,然后往里面填充数据。这些数据就是被这个路由命中的所有组件的所有数据,包括dispatch之后的数据。所以,需要收集到所有的loadData,并等待它执行完成。

  1. 命中当前路径下的所有组件
const matchRouters = matchRoutes(routes, req.path);
  1. 执行当中的所有请求,完成所有的loadData。获取组件上的数据填充方法,过程:
    • 把数据填充的方法loadData挂在组件上
Home.loadData = function(store) {
  // loadData的起点
  // 这里是Promis则所有的loadData都是Promis, Promise.all
  // getCommentList是一个action
  return  store.dispatch(getCommentList())
}
    • 从被命中的组件上取下loadData方法执行,执行其中的请求就是填充redux中的数据,即完成所有的dispatch
  matchRouters.forEach(mRouter => {
    if(mRouter.route.loadData){
      promises.push(mRouter.route.loadData(store))
    }
  })
  1. 等待loadData执行结束,此时store已经有数据了
Promise.all(promises)
  // promise完成
  .then(resArray => {
    // console.log(store)
    const html = render(req, store);
    res.send(html)
  })
  1. 等待所有请求执行完毕后把有数据的store传过去,再渲染页面

3. 服务端和客户端store统一

  • 把服务端的数据以字符串的形式塞进html文本中
<script>
    window.__context__  = {state: ${JSON.stringify(store.getState())}} 
</script>
  • 客户端获取数据时,从hmtl中拿到值
export const Clientstore = () => {
  // store的默认值,把数据JSON.Stringfy放在了script中
  const defaultStore = window.__context__ || {};
  return createStore(
    reduce,
    defaultStore.state,
    applyMiddleware(thunk));
}
  • 服务端渲染完毕!
    源码Git地址:github.com/3460741663/… 使用说明:该项目是一个完整的完成了SSR的项目,先使用node跑mock-server下的js程序(模拟数据请求的服务器),然后npm run dev跑项目

Css服务端渲染

1. 新建css文件,引入并使用

2. 配置webpack,让其支持css文件的编译

需要npm install style-loader css-loader --D俩个工具

rules: [{
  test: /\.css?$/,
  use: ['style-loader', {
    loader: 'css-loader',
    options: {
      modules: true
    }
  }]
}]

配置好之后就就可以让css生效了

3. 服务端css加载

  • 服务端没有document,无法返回给html直接添加样式,只能获取到所有的css,然后一个以style标签插入html中生效,以达到服务端css渲染
  • 使用工具npm install -D isomorphic-style-loader,给css文件添加_getCss()方法,查看源码后得知返回css文件内容tostring后的结果
  • 配置服务端的webpack
rules: [{
  test: /\.css?$/,
  use: ['isomorphic-style-loader', {
    loader: 'css-loader',
    options: {
      modules: true
    }
  }]
}]
  • staticRouter提供一个钩子变量context

会以props的形式在组件之间传递,也可以在渲染的过程中拿到它。所以,生命一个空数组让它在组件内走一遭之后再把内容沿途需要的css拼起来,再返回的内容里插入style标签。

let context = { css: [] }
// context从外界传入
<StaticRouter location={req.path} context={context}>
    <div>
        {renderRoutes(routes)}
    </div>
</StaticRouter>
  • 我们只在服务端渲染的使用使用了staticRouter,所以可以用来判断是否为服务端渲染环境
componentWillMount() {
  // 判断是否为服务端渲染环境
  // context是props,也可以被外界取到
  // 通过context收集匹配到当前路由的所有组件的css
  if (this.props.staticContext) {
    this.props.staticContext.css.push(styles._getCss())
  }
}
  • 把获得的css以style标签插入到返回的html中即可
// 拼接收集到的css
const cssStr = context.css.length ? context.css.join('\n') : '';
/ 在返回的html字符串插入style标签
<style>${cssStr}</style>

达到如下效果,说明css的服务端渲染成功了

4. 利用高阶组件优化css服务端渲染

  • 高阶组件首先自己是一个组件,且接受一个组件作为参数,作用就是把这个参数组件增强成为另外一个组件。扩充组件的作用,提高了代码的复用性,减少重复代码;
  • 控制组件的渲染逻辑,比如:鉴权;
  • 生命周期捕获/劫持:借助父组件子组件生命周期规则捕获子组件的生命周期,常见case:打点。 这里使用的就是生命周期捕获,父组件捕获子组件的componentWillMount生命周期,达到收集css的目的
import React, { Component } from 'react';
//函数返回组件
export default (DecoratedComponent, styles) => {
  return class NewComponent extends Component {
    componentWillMount() {
      if (this.props.staticContext) {
        // styles._getCss来自isomorphic-style-loader
        this.props.staticContext.css.push(styles._getCss());
        console.log(this.props.staticContext)
      }
      
    }
    render() {
      return <DecoratedComponent {...this.props} />
    }
  };
}
const activity = connect(mapStateToProps, mapDispatchToProps)(withStyles(activitySquare, styles));
activity.loadData = (store) => {
  return store.dispatch(getActivityList())
};
export default activity;

使用是时候需要注意的是,这里有俩个高阶组件connect,自定义高阶组件withStyles他俩的包裹顺序以及loadData需要挂载在被导出的组件上,不然路由表出错

{
  path: '/activitySquare',
  component: activitySquare,
  loadData: activitySquare.loadData,
}

SSR完成了

业务开发

写在业务开发前

1. 跨域错误

产生跨域问题的罪魁祸首是浏览器同源策略,当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同域,不同域之间的网络请求就会触发跨域问题。跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。

ctx.set('Access-Control-Allow-Origin', 'http://localhost:8080');
ctx.set('Access-Control-Allow-Methods', 'POST, GET');
ctx.set('Access-Control-Allow-Headers', 'x-custom, Content-Type');
// 允许 是否发送 cookie ... 凭证
ctx.set('Access-Control-Allow-Credentials', true);

后端设置上述响应消息头即可

2. axios的封装

在使用axios进行异步操作时,可能会遇到以下情况:

  • 对一个按钮频繁点击,发送多次请求
  • axios的规范写法中:axios.post(url, data).then(res=>{}).catch(err=>{}) 复制代码这里我们发现我们每一次写的时候,都需要写.catch(err=>{}),会造成代码的冗余

所以可以使用拦截器来对axios进行封装,如下:

// 对axios的封装
let fetch = axios.create({
    baseURL: 'http://127.0.0.1:8090', // 这里是后端服务器地址
    credentials: 'include',// 即便是跨域,也携带cookie
    timeout: 5000 // request timeout
})
// 添加请求拦截器
fetch.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    if (config.method === 'post' || config.method === 'put' || config.method === 'delete') {
        if (typeof (config.data) !== 'string' && config.headers['Content-Type'] !== 'multipart/form-data') {
            config.data = qs.stringify(config.data)
        }
    }
    return config;
}, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
});
// 添加响应拦截器
fetch.interceptors.response.use(response => {
    // 对响应数据做点什么
    // 把响应字符串转换成JSON数据格式(后端数据请求造的孽)
    var reg = /([\w-.]+)/g;
    var temp = response.data.replace(reg, '"$1"')
    var result = temp.replace(/=/g, ":")
    response.data = JSON.parse(result);
    return response;
}, error => {
    // 错误响应应该
    if (error.response) {
        if (error.response.status === 500) {
            console.log('服务器错误,请联系管理员处理')
        }
        return Promise.reject(error.response.data)
    } else {
        return Promise.reject(error)
    }
})

3. 数据结构不兼容,正则来解决

  • 多方原因导致后端传递过来的数据并不能直接使用,需要对数据进行处理,恰好可以在上述拦截器中使用,即对响应数据的处理。
  • 后端传递来的数据[{duration=120, start_time=2019-12-19}]。它并不是规范的JSON,无法使用JSON.parse来解析,需要经过一番处理变成[{"duration":"120","start_time":"2019-12-19"}]简单学习之后,得到如下正则来处理
var reg = /([\w-.]+)/g;
var temp = response.data.replace(reg, '"$1"')
var result = temp.replace(/=/g, ":")
response.data = JSON.parse(result);

对上述正则的简单解释。用//包裹的表达式称为正则字面量,其中是对需要匹配的模式的一种描述。[abc]表示这一个字符可以选择a,b,c之一、\w是表示所有大小写字符数字和下划线、+是量词即形容出现的次数至少一次、最后一个g表示全局范围。$1表示正则中第一个小括号(最外面、最左边的是一)匹配到的内容,以此类推$2、$3,在千分位表示的面试题可以使用恰到好处。说明,replace函数的第二个参数可以是一个函数,参数表示所匹配到的字符串。

4. 使用Ant Design来搭页面,不支持(暂未按需加载)

  • 在返回的html中插入link标签,加载cdn的样式库<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/antd/3.26.7/antd.css" >
  • 安装、引入即可使用

并没有实现按需加载,觉得可以使用treeshaking

具体业务实现

登录

  1. 使用iconfont适量图标库中的图标
  2. 图片、文件资源放在public目录下,资源路径./就是public目录
  3. 调用已经封装好的验证方法,登入成功则将个人信息保存在store种,供其他页面的使用
const userName = React.createRef();
const passWord = React.createRef();
// submit事件触发
onClick={()=>{
    // 接口请求参数对象
    let account = {
     userName:userName.current.state.value,
     passWord:passWord.current.state.value
    };
    verify(account).then(res => {
     if(res.data){
        // mapDispatchToProps来的一个方法,用于登录成功后把用户信息存储在store中去
        this.props.loginSuccess(res);
      }else{
        Toast.info('账号或密码错误!');
      }
    })
}}

主页

  1. 首页使用andt的tabs管理切换
  2. 需要把钩子函数context传递下去收集子组件的css,所以需要把props传递下去<ActivitySqare {...this.props} />
首页
  1. 使用node中间层代理请求数据,把请求来的数据保存至到redux中
  2. 新闻数据来自阿凡达数据中心,我向他申请了一个实时新闻类的数据接口,在输入框输入关键字,即可返回新闻列表
活动
  1. 用户一进入,默认请求长度为10的无类别筛选数据请求,保存入redux中
  2. 下拉加载更多,长度增加5
  3. 自定义Menu组件,把每次的选择保存,进行下一次筛选是需要携带上次的筛选信息。如选择了分类信息后,进一步选择所属组织
动态
  1. 自定义TimeLine组件,切入添加动态添加样式,实现切入动画
  2. 纯css实现三角形
  3. 伪元素before、after实现轴线

活动详情页

  1. 点击活动、动态的item跳转进入活动的详情页
  2. 使用link进行页面跳转,this.props.location.param获取到路由跳转时携带的参数
  3. 获取当前活动、当前用户的参与状态,保存到state中,通过step组件来显示当前用户的状态
  4. 从redux中获取用户信息,如未登录则不允许他进行相关操作

总结

从零开始的一个原创项目,边学边写项目加深对react的理解,本项目的有一半在实现react的服务端渲染,实现的也较为完整Git地址。本项目大量使用了redux做数据管理,在app中数据是核心,不同页面的数据共享使用redux确实很方便,但是成本很大,所以需要在保存时需要甄别是否需要把数据保存至redux中。

完整项目源码地址: Git