因为对网页 SEO 的需要, 要把之前的 React 项目改造为服务端渲染, 经过一番调查和研究, 查阅了大量互联网资料成功踩坑
项目地址:
https://github.com/wlx200510/react_koa_ssr
脚手架选型:
webpack3.11.0 + react Router4 + Redux + koa2 + React16 + Node8.x
选型思路: 实现服务端渲染, 想用 React 最新的版本, 并且不对现有的写法做大的改动, 如果一开始就打算服务端渲染, 建议直接用 NEXT 框架来写 主要心得: 对 React 的相关知识更加熟悉, 成功拓展自己的技术领域, 对服务端技术在实际项目上有所积累 注意点: 使用框架前一定确认当前 webpack 版本为 3.x Node 为 8.x 以上, 读者最好用 React 在 3 个月以上, 并有实际 React 项目经验
项目目录介绍:
- assets
- index.CSS // 放置一些全局的资源文件 可以是 js 图片等
- config
webpack.config.dev.js 开发环境 webpack 打包设置
webpack.config.prod.js 生产环境 webpack 打包设置
- package.json
- README.md
server server 端渲染文件, 如果对不是很了解, 建议参考 [koa 教程](http://wlxadyl.cn/2018/02/11/koa-learn/)
- app.js
- clientRouter.js // 在此文件中包含了把服务端路由匹配到 react 路由的逻辑
- ignore.js
- index.js
- src
app 此文件夹下主要用于放置浏览器和服务端通用逻辑
- configureStore.js //redux-thunk 设置
- createApp.js // 根据渲染环境不同来设置不同的 router 模式
- index.js
- router
- index.js
- routes.js // 路由配置文件! 重要
- assets
css 放置一些公共的样式文件
- _base.scss // 很多项目都会用到的初始化 css
- index.scss
- my.scss
- img
components 放置一些公共的组件
FloatDownloadBtn 公共组件样例写法
- FloatDownloadBtn.js
- FloatDownloadBtn.scss
- index.js
- Loading.js
Model.js 函数式组件的写法
- favicon.ico
- index.ejs // 渲染的模板 如果项目需要, 可以放一些公共文件进去
- index.js // 包括热更新的逻辑
pages 页面组件文件夹
- home
- components // 用于放置页面组件, 主要逻辑
- homePage.js
- containers // 使用 connect 来封装出高阶组件 注入全局 state 数据
- homeContainer.js
- index.js // 页面路由配置文件 注意 thunk 属性
- reducer
- index.js // 页面的 reducer 这里暴露出来给 store 统一处理 注意写法
- user
- components
- userPage.js
- containers
- userContainer.js
- index.js
- store
- actions // 各 action 存放地
- home.js
- thunk.js
- constants.js // 各 action 名称汇集处 防止重名
- reducers
- index.js // 引用各页面的所有 reducer 在此处统一 combine 处理
项目的构建思路
本地开发使用 webpack-dev-server, 实现热更新, 基本流程跟之前 react 开发类似, 仍是浏览器端渲染, 因此在编写代码时要考虑到一套逻辑, 两种渲染环境的问题
当前端页面渲染完成后, 其 Router 跳转将不会对服务端进行请求, 从而减轻服务端压力, 从而页面的进入方式也是两种, 还要考虑两种渲染环境下路由同构的问题
生产环境要使用 koa 做后端服务器, 实现按需加载, 在服务端获取数据, 并渲染出整个 html, 利用 React16 最新的能力来合并整个状态树, 实现服务端渲染
本地开发介绍
查看本地开发主要涉及的文件是 src 目录下的 index.js 文件, 判断当前的运行环境, 只有在开发环境下才会使用 module.hot 的 API, 实现当 reducer 发生变化时的页面渲染更新通知, 注意其中的 hydrate 方法, 这是 v16 版本的一个专门为服务端渲染新增的 API 方法, 它在 render 方法的基础上实现了对服务端渲染内容的最大可能重用, 实现了静态 DOM 到动态 NODES 的过程实质是代替了 v15 版本下判断 checksum 标记的过程, 使得重用的过程更加高效优雅
- const renderApp=()=>{
- let application=createApp({store,history});
- hydrate(application,document.getElementById('root'));
- }
- window.main = () => {
- Loadable.preloadReady().then(() => {
- renderApp()
- });
- };
- if(process.env.NODE_ENV==='development'){
- if(module.hot){
- module.hot.accept('./store/reducers/index.js',()=>{
- let newReducer=require('./store/reducers/index.js');
- store.replaceReducer(newReducer)
- })
- module.hot.accept('./app/index.js',()=>{
- let {createApp}=require('./app/index.js');
- let newReducer=require('./store/reducers/index.js');
- store.replaceReducer(newReducer)
- let application=createApp({store,history});
- hydrate(application,document.getElementById('root'));
- })
- }
- }
注意 window.main 这个函数的定义, 结合 index.ejs 可以知道这个函数是所有脚本加载完成后才触发, 里面用的是 react-loadable 的写法, 用于页面的懒加载, 关于页面分别打包的写法要结合路由设置来讲解, 这里有个大致印象即可需要注意的是 app 这个文件下暴露出的三个方法是在浏览器端和服务器端通用的, 接下来主要就是说这部分的思路
路由处理
接下来看以下 src/app 目录下的文件, index.js 暴露了三个方法, 这里面涉及的三个方法在服务端和浏览器端开发都会用到, 这一部分主要讲其下的 router 文件里面的代码思路和 createApp.js 文件对路由的处理, 这里是实现两端路由相互打通的关键点 router 文件夹下的 routes.js 是路由配置文件, 将各个页面下的路由配置都引进来, 合成一个配置数组, 可以通过这个配置来灵活控制页面上下线同目录下的 index.js 是 RouterV4 的标准写法, 通过遍历配置数组的方式传入路由配置, ConnectRouter 是用于合并 Router 的一个组件, 注意到 history 要作为参数传入, 需要在 createApp.js 文件里做单独的处理先大致看一下 Route 组件中的几个配置项, 值得注意的是其中的 thunk 属性, 这是实现后端获取数据后渲染的关键一步, 正是这个属性实现了类似 Next 里面的组件提前获取数据的生命周期钩子, 其余的属性都可以在相关 React-router 文档中找到说明, 这里不在赘述
- import routesConfig from './routes';
- const Routers=({history})=>(
- <ConnectedRouter history={history}>
- <div>
- {
- routesConfig.map(route=>(
- <Route key={route.path} exact={route.exact} path={route.path} component={route.component} thunk={route.thunk} />
- ))
- }
- </div>
- </ConnectedRouter>
- )
- export default Routers;
查看 app 目录下的 createApp.js 里面的代码可以发现, 本框架是针对不同的工作环境做了不同的处理, 只有在生产环境下才利用 Loadable.Capture 方法实现了懒加载, 动态引入不同页面对应的打包之后的 js 文件到这里还要看一下组件里面的路由配置文件的写法, 以 home 页面下的 index.js 为例注意
/* webpackChunkName: 'Home' */
这串字符, 实质是指定了打包后此页面对应的 js 文件名, 所以针对不同的页面, 这个注释也需要修改, 避免打包到一起 loading 这个配置项只会在开发环境生效, 当页面加载未完成前显示, 这个实际项目开发如果不需要可以删除此组件
- import {homeThunk} from '../../store/actions/thunk';
- const LoadableHome = Loadable({
- loader: () =>import(/* webpackChunkName: 'Home' */'./containers/homeContainer.js'),
- loading: Loading,
- });
- const HomeRouter = {
- path: '/',
- exact: true,
- component: LoadableHome,
- thunk: homeThunk // 服务端渲染会开启并执行这个 action, 用于获取页面渲染所需数据
- }
- export default HomeRouter
这里多说一句, 有时我们要改造的项目的页面文件里有从 window.location 里面获取参数的代码, 改造成服务端渲染时要全部去掉, 或者是要在 render 之后的生命周期中使用并且页面级别组件都已经注入了相关路由信息, 可以通过
this.props.location
来获取 URL 里面的参数本项目用的是 BrowserRouter, 如果用 HashRouter 则包含参数可能略有不同, 根据实际情况取用
根据 React16 的服务端渲染的 API 介绍: 浏览器端使用的注入 ConnectedRouter 中的 history 为:
import createHistory from 'history/createBrowserHistory'
服务器端使用的 history 为
import createHistory from 'history/createMemoryHistory'
服务端渲染
这里就不会涉及到 koa2 的一些基础知识, 如果对 koa2 框架不熟悉可以参考我的另外一篇博文这里是看 server 文件夹下都是服务端的代码首先是简洁的 app.js 用于保证每次连接都返回的是一个新的服务器端实例, 这对于单线程的 js 语言是很关键的思路需要重点介绍的就是 clientRouter.js 这个文件, 结合
/src/app/configureStore.js
这个文件共同理解服务端渲染的数据获取流程和 React 的渲染机制
- /*configureStore.js*/
- import {createStore, applyMiddleware,compose} from "redux";
- import thunkMiddleware from "redux-thunk";
- import createHistory from 'history/createMemoryHistory';
- import { routerReducer, routerMiddleware } from 'react-router-redux'
- import rootReducer from '../store/reducers/index.js';
- const routerReducers=routerMiddleware(createHistory());// 路由
- const composeEnhancers = process.env.NODE_ENV=='development'?window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
- const middleware=[thunkMiddleware,routerReducers]; // 把路由注入到 reducer, 可以从 reducer 中直接获取路由信息
- let configureStore=(initialState)=>createStore(rootReducer,initialState,composeEnhancers(applyMiddleware(...middleware)));
- export default configureStore;
这个渲染的具体思路是: 在服务端判断路由的 thunk 方法, 如果存在则需要执行这个获取数据逻辑, 这是个阻塞过程, 可以当作同步, 获取后放到全局 State 中, 在前端输出的 HTML 中注入
window.__INITIAL_STATE__
这个全局变量, 当 html 载入完毕后, 这个变量赋值已有数据的全局 State 作为 initState 提供给 react 应用, 然后浏览器端的 js 加载完毕后会通过复用页面上已有的 dom 和初始的 initState 作为开始, 合并到 render 后的生命周期中, 从而在 componentDidMount 中已经可以从 this.props 中获取渲染所需数据 但还要考虑到页面切换也有可能在前端执行跳转, 此时作为 React 的应用不会触发对后端的请求, 因此在 componentDidMount 这个生命周期里并没有获取数据, 为了解决这个问题, 我建议在这个生命周期中都调用 props 中传来的 action 触发函数, 但在 action 内部进行一层逻辑判断, 避免重复的请求, 实际项目中请求数据往往会有个标识性 ID, 就可以将这个 ID 存入 store 中, 然后就可以进行一次对比校验来提前返回, 避免重复发送 ajax 请求, 具体可看 store/actions/home.js` 中的逻辑处理
- import {ADD,GET_HOME_INFO} from '../constants'
- export const add=(count)=>({type: ADD, count,})
- export const getHomeInfo=(sendId=1)=>async(dispatch,getState)=>{
- let {name,age,id}=getState().HomeReducer.homeInfo;
- if (id === sendId) {
- return // 是通过对请求 id 和已有数据的标识性 id 进行对比校验, 避免重复获取数据
- }
- console.log('footer'.includes('foo'))
- await new Promise(resolve=>{
- let homeInfo={name:'wd2010',age:'25',id:sendId}
- console.log('----------- 请求 getHomeInfo')
- setTimeout(()=>resolve(homeInfo),1000)
- }).then(homeInfo=>{
- dispatch({type:GET_HOME_INFO,data:{homeInfo}})
- })
- }
注意这里的 async/await 写法, 这里涉及到服务端 koa2 使用这个来做数据请求, 因此需要统一返回 async 函数, 这块不熟的同学建议看下 ES7 的知识, 主要是 async 如何配合 Promise 实现异步流程改造, 并且如果涉及 koa2 的服务端工作, 对 async 函数用的更多, 这也是本项目要求 Node 版本为 8.x 以上的原因, 从 8 开始就可以直接用这两个关键字 不过到具体项目中, 往往会涉及到一些服务端参数的注入问题, 但这块根据不同项目需求差异很大, 并且不属于这个 React 服务端改造的一部分, 没法统一分享, 如果真是公司项目要用到对这块有需求咨询可以打赏后加我微信讨论
以 Home 页面为例的渲染流程
为了方便大家理解, 我以一个页面为例整理了一下数据流的整体过程, 看一下思路:
服务端接收到请求, 通过 / home 找到对应的路由配置
判断路由存在 thunk 方法, 此时执行
store/actions/thunk.js
里面的暴露出的函数
异步获取的数据会注入到全局 state 中, 此时的 dispatch 分发其实并不生效
要输出的 HTML 代码中会将获取到数据后的全局 state 放到
window.__INITIAL_STATE__
这个全局变量中, 作为 initState
window.__INITIAL_STATE__
将在 react 生命周期起作用前合并入全局 state, 此时 react 发现 dom 已经生成, 不会再次触发 render, 并且数据状态得到同步
基本的流程已经介绍结束, 至于一些 Reducer 的函数式写法, 还有 actions 的位置都是参考网上的一些分析来组织的, 具体见仁见智, 这个只要符合自己的理解, 并且有助于团队开发就好如果您符合我在文章一开始设定的读者背景, 相信本文的讲述足够您点亮自己的服务端渲染技术点啦如果对 React 了解偏少也没关系, 可以参考这里来补充一些 React 的基础知识, 也可以在我的博客学习交流
来源: https://juejin.im/post/5aab6841f265da238e0d7e35