react同构实践之实现自己的同构模板


Posted in Javascript onMarch 13, 2019

一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案。看了一两天,有趣,优雅,但是封装好了,原理不甚清楚,也感觉无法灵活嵌合到老项目上去。于是看各种资料,想整理出同构的线索,一步一步地实现自己的同构模板。相关代码可查看我的GitHub。感谢阅读!!

TODO List

  • 数据:如何保持前后端应用状态一致
  • 路由:路由在服务端和客户端中的匹配方案
  • 代码:同构,哪些地方可以共享,哪些地方需要差异化
  • 静态资源:服务端如何引入css/图片等
  • ssr直出资源:服务端在渲染路由页面时如何匹配css/chunks资源
  • 打包方案:服务端和浏览器端如何写各自的webpack配置文件
  • SEO: head头处理方案

同构的基础

正常的网页运行,需要生成dom,在dom树loaded之后由js绑定相关的dom事件,监听页面的交互。服务端并不具备dom的执行环境,因而所有的服务端渲染其实都是返回了一个填充了初始数据的静态文本。在react中,除了常用的render这个用于生成dom的方法,还提供了renderToString,renderToStaticMarkup方法用来生成字符串,由于VitualDOM的存在,结合这些方法就可以像以前的字符串模板那样生成普通的字符串,返回给客户端接管,再接着进行事件相关的绑定。最新的React v16+使用hydrate和ssr配套,能让客户端把服务端的VitualDOM渲染出来后得以复用,客户端加载js后不会重刷一边,减小了开销,也避免浏览器重刷dom时带来的闪屏体验。而react的组件,还是和往常写spa一样编写,前后端共享。不同的只是入口的渲染方法换了名字,且客户端会挂载dom而已。

// clinet.js
ReactDom.hydrate(<App />, document.getElementById('app'))

// server.js
const html = ReactDom.renderToString(<App />)

同构后网站运行流程图

盗用一张图,来自阿里前端。乍一看,ssr与csr的区别就在于2 3 4 5,spa模式简单粗暴地返回一个空白的html页面,然后在11里才去加载数据进行页面填充,在此之前,页面都处于空白状态。而ssr则会根据路由信息,提前获取该路由页面的初始数据,返回页面时已经有了初步的内容,不至于空白,也便于搜索引擎收录。

react同构实践之实现自己的同构模板

路由匹配

浏览器端的路由匹配还是照着spa来做应该无需费心。略过了...

服务端的路由需要关注的,一个是后端服务的路由(如koa-router)匹配的问题,一个是匹配到react应用后react-router路由表的匹配问题。

服务端路由,可通过/react前缀来和api接口等其他区别开来,这种路由匹配方式甚至能让服务端渲染能同时支持老项目诸如ejs等的模板渲染方式,在系统升级改造方面可实现渐进式地升级。

// app.js文件(后端入口)
import reactController from './controllers/react-controller'
// API路由
app.use(apiController.routes())

// ejs页面路由
app.use(ejsController.routes())

// react页面路由
app.use(reactController.routes())

// react-controller.js文件
import Router from 'koa-router'

const router = new Router({
 prefix: '/react'
})

router.all('/', async (ctx, next) => {

 const html = await render(ctx)

 ctx.body = html

})

export default router

react-router专供了给ssr使用的StaticRouter接口,称之为静态的路由。诚然,服务端不像客户端,对应于一次网络请求,路由就是当前的请求url,是唯一的,不变的。在返回ssr直出的页面后,页面交互造成地址栏的变化,只要用的是react-router提供的方法,无论是hash方式,还是history方式,都属于浏览器端react-router的工作了,于是完美继承了spa的优势。只有在输入栏敲击Enter,才会发起新一轮的后台请求。

import { StaticRouter } from 'react-router-dom'
 const App = () => {

  return (
   <Provider store={store}>

    <StaticRouter
     location={ctx.url}
     context={context}>
     
     <Layout />

    </StaticRouter>

   </Provider>
  )
 }

应用状态数据管理

以往的服务端渲染,需要在客户端网页下载后马上能看到的数据就放在服务器提前准备好,可延迟展示,通过ajax请求的数据的交互逻辑放在页面加载的js文件中去。

换成了react,其实套路也是一样一样的。但是区别在于:

传统的字符串模板,组件模板是彼此分离的,可各自单独引入数据,再拼装起来形成一份html。而在react的ssr里,页面只能通过defaultValue和defaultProps一次性render,无法rerender。

不能写死defaultValude,所以只能使用props的数据方案。在执行renderToString之前,提前准备好整个应用状态的所有数据。全局的数据管理方案可考虑redux和mobx等。

需要准备初始渲染数据,所以要精准获取当前地址将要渲染哪些组件。react-router-config和react-router同源配套,是个支持静态路由表配置的工具,提供了matchRoutes方法,可获得匹配的路由数组。

import { matchRoutes } from 'react-router-config'

import loadable from '@loadable/component'

const Root = loadable((props) => import('./pages/Root'))
const Index = loadable(() => import("./pages/Index"))
const Home = loadable(() => import("./pages/Home"))

const routes = [
 {
  path: '/',
  component: Root,
  routes: [
   {
    path: '/index',
    component: Index,
   },
   {
    path: '/home',
    component: Home,
    syncData () => {}
    routes: []
   }
  ]
 }
]

router.all('/', async (url, next) => {
 const branch = matchRoutes(routes, url)
})

组件的初始数据接口请求,最美的办法当然是定义在各自的class组件的静态方法中去,但是前提是组件不能被懒加载,不然获取不到组件class,当然也无法获取class static method了,很多使用@loadable/component(一个code split方案)库的开发者多次提issue,作者也明示无法支持。不支持懒加载是绝对不可能的了。所以委屈一下代码了,在需要的route对象中定义一个asyncData方法。

服务端

// routes.js
{
 path: '/home',
 component: Home,
 asyncData (store, query) {
  const city = (query || '').split('=')[1]
 
  let promise = store.dispatch(fetchCityListAndTemperature(city || undefined))
  
  let promise2 = store.dispatch(setRefetchFlag(false))
 
  return Promise.all([promise, promise2])
  return promise
 }
}
// render.js
import { matchRoutes } from 'react-router-config'
import createStore from '../store/redux/index'

const store = createStore()
const branch = matchRoutes(routes, url)

const promises = branch.map(({ route }) => {
 // 遍历所有匹配路由,预加载数据
 return route.asyncData
  ? route.asyncData(store, query)
  : Promise.resolve(null)

})
// 完成store的预加载数据初始化工作
await Promise.all(promises)
// 获取最新的store
const preloadedState = store.getState()

const App = (props) => {

 return (
  <Provider store={store}>

   <StaticRouter
    location={ctx.url}
    context={context}>
    
    <Layout />

   </StaticRouter>

  </Provider>
 )
}
// 数据准备好后,render整个应用
const html = renderToString(<App />)

// 把预加载的数据挂载在`window`下返回,客户端自己去取
return 
  <html>
   <head></head>
   <body>
    <div id="app">${html}</div>
    <script>
     window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
    </script>
   </body>
  </html>

客户端

为保证两端的应用数据一致,客户端也要使用同一份数据初始化一次redux的store,再生成应用。如果两者的dom/数据不一致,导致浏览器接管的时候dom重新生成了一次,在开发模式下的时候,控制台会输出错误信息,开发体验完美。后续ajax的数据,在componentDidMount和事件中去执行,和服务端的逻辑天然剥离。

// 获取服务端提供的初始化数据
const preloadedState = window.__PRELOADED_STATE__ || undefined

delete window.__PRELOADED_STATE__

// 客户端store初始化
const store = createStore(preloadedState)

const App = () => {

 return (
  <Provider store={store}>

   <BrowserRouter>

    <Layout />
    
   </BrowserRouter>

  </Provider>
 )
}

// loadableReady由@loadabel/component提供,在code split模式下使用
loadableReady().then(() => {
 
 ReactDom.hydrate(<App />, document.getElementById('app'))

})

服务端调用的接口客户端也必须有。这就带来了如何避免重复请求的问题。我们知道componentDidMount方法只执行一次,如果服务器已经请求的数据带有一个标识,就可以根据这个标识决定是否在客户端需要发起一个新的请求了,需要注意的是判断完成后重置该标识。

import { connect } from 'react-redux'

@connect(
 state => ({
  refetchFlag: state.weather.refetchFlag,
  quality: state.weather.quality
 }),
 dispatch => ({
  fetchCityListAndQuality: () => dispatch(fetchCityListAndQuality()),
  setRefetchFlag : () => dispatch(setRefetchFlag(true))
 })
)
export default class Quality extends Component {
 componentDidMount () {

  const {
   location: { search },
   refetchFlag,
   fetchCityListAndQuality,
   setRefetchFlag
  } = this.props

  const { location: city } = queryString.parse(search)

  refetchFlag 
   ? fetchCityListAndQuality(city || undefined)
   : setRefetchFlag()
 }
}

打包方案

客户端打包

我想说的是“照旧”。因为在浏览器端运行的还是spa。入门级的具体见github,至于如何配置得赏心悦目,用起来得心应手,根据项目要求各显神通吧。

服务端打包

和客户端的异同:

同:

需要bable兼容不同版本的js语法

webpack v4+/babel v7+ ... 真香

... 留白

异:

入口文件不一样,出口文件不一样

这里既可以把整个服务端入口app.js作为打包入口,也可以把react路由的起点文件作为打包入口,配置输出为umd模块,再由app.js去require。以后者为例(好处在于升级改造项目时尽可能地降低对原系统的影响,排查问题也方便,断点调试什么的也方便):

// webpack.server.js
const webpackConfig = {
 entry: {
  server: './src/server/index.js'
 },
 output: {
  path: path.resolve(__dirname, 'build'),
  filename: '[name].js',
  libraryTarget: 'umd'
 }
}

// app.js
const reactKoaRouter = require('./build/server').default
app.use(reactKoaRouter.routes())

css、image资源正常来说服务端无需处理,如何绕开

偷懒,还没开始研究,占个坑

require的是node自带的模块时避免被webpack打包

const serverConfig = { ... target: 'node' }

require第三方模块时如何避免被打包

const serverConfig = { ... externals: [ require('webpack-node-externals')() ]

生产环境代码无需做混淆压缩

... 留白

服务端直出时资源的搜集

服务端输出html时,需要定义好css资源、js资源,让客户端接管后下载使用。如果没啥追求,可以直接把客户端的输出文件全加上去,暴力稳妥,简单方便。但是上面提到的@loadable/component库,实现了路由组件懒加载/code split功能后,也提供了全套服务,配套套装的webpack工具,ssr工具,帮助我们做搜集资源的工作。

// webpack.base.js
const webpackConfig = {
 plugins: [ ..., new LoadablePlugin() ]
}

// render.js
import { ChunkExtractor } from '@loadable/server'

const App = () => {

 return (
  <Provider store={store}>

   <StaticRouter
    location={ctx.url}
    context={context}>
    
    <Layout />

   </StaticRouter>

  </Provider>
 )
}

const webStats = path.resolve(
 __dirname,
 '../public/loadable-stats.json', // 该文件由webpack插件自动生成
)

const webExtractor = new ChunkExtractor({ 
 entrypoints: ['client'],  // 为入口文件名
 statsFile: webStats
})


const jsx = webExtractor.collectChunks(<App />)

const html = renderToString(jsx)

const scriptTags = webExtractor.getScriptTags()
const linkTags = webExtractor.getLinkTags() 
const styleTags = webExtractor.getStyleTags()

const preloadedState = store.getState()

const helmet = Helmet.renderStatic()

return `
 <html>
  <head>
   ${helmet.title.toString()}
   ${helmet.meta.toString()}
   ${linkTags}
   ${styleTags}
  </head>
  <body>
   <div id="app">${html}</div>
   <script>
    window.STORE = 'love';
    window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
   </script>
   ${scriptTags}
  </body>
 </html>
`

SEO信息

上面已经透露了。使用了一个react-helmet库。具体用法可查看官方仓库,信息可直接写在组件上,最后根据优先级提升到head头部。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
jQuery UI AutoComplete 使用说明
Jun 20 Javascript
js触发onchange事件的方法说明
Mar 08 Javascript
jquery动画效果学习笔记(8种效果)
Nov 13 Javascript
js控制文本框只能输入中文、英文、数字与指定特殊符号的实现代码
Sep 09 Javascript
js 中获取制定的cook信息实现方法
Nov 19 Javascript
移动开发之自适应手机屏幕宽度
Nov 23 Javascript
angular 数据绑定之[]和{{}}的区别
Sep 25 Javascript
详解Vue前端对axios的封装和使用
Apr 01 Javascript
JavaScript实现背景自动切换小案例
Sep 27 Javascript
实例分析JS中的相等性判断===、 ==和Object.is()
Nov 17 Javascript
VUE UPLOAD 通过ACTION返回上传结果操作
Sep 07 Javascript
JavaScript实现切换多张图片
Jan 27 Javascript
使用Node.js实现一个多人游戏服务器引擎
Mar 13 #Javascript
你可能不知道的CORS跨域资源共享
Mar 13 #Javascript
react项目如何使用iconfont的方法步骤
Mar 13 #Javascript
使用jquery的cookie实现登录页记住用户名和密码的方法
Mar 13 #jQuery
深入Node TCP模块的理解
Mar 13 #Javascript
详解如何使用微信小程序云函数发送短信验证码
Mar 13 #Javascript
vue计算属性computed的使用方法示例
Mar 13 #Javascript
You might like
[原创]php实现数组按拼音顺序排序的方法
2017/05/03 PHP
php判断IP地址是否在多个IP段内
2020/08/18 PHP
JavaScript 学习笔记(七)字符串的连接
2009/12/31 Javascript
HTML5附件拖拽上传drop &amp; google.gears实现代码
2011/04/28 Javascript
JS实现距离上次刷新已过多少秒示例
2014/05/23 Javascript
node.js实现多图片上传实例
2014/06/03 Javascript
JavaScript实现防止网页被嵌入Frame框架的代码分享
2014/12/29 Javascript
编写自己的jQuery提示框(Tip)插件
2015/02/05 Javascript
通过javascript进行UTF-8编码的实现方法
2016/06/27 Javascript
webpack多入口文件页面打包配置详解
2018/01/09 Javascript
webpack中如何使用雪碧图的示例代码
2018/11/11 Javascript
vue中$nextTick的用法讲解
2019/01/17 Javascript
vue项目添加多页面配置的步骤详解
2019/05/22 Javascript
JS回调函数 callback的理解与使用案例分析
2019/09/09 Javascript
JS实现可视化音频效果的实例代码
2020/01/16 Javascript
原生js实现轮播图特效
2020/05/04 Javascript
原生JavaScript实现幻灯片效果
2021/02/19 Javascript
[03:59]第二届DOTA2亚洲邀请赛选手传记-VGJ.rOtk
2017/04/03 DOTA
布同 Python中文问题解决方法(总结了多位前人经验,初学者必看)
2011/03/13 Python
解决python xx.py文件点击完之后一闪而过的问题
2019/06/24 Python
使用OpenCV实现仿射变换—平移功能
2019/08/29 Python
keras-siamese用自己的数据集实现详解
2020/06/10 Python
keras分类模型中的输入数据与标签的维度实例
2020/07/03 Python
pandas按照列的值排序(某一列或者多列)
2020/12/13 Python
安纳塔拉酒店度假村及水疗官方网站:Anantara Hotel
2016/08/25 全球购物
美国时尚孕妇装品牌:A Pea in the Pod
2017/07/16 全球购物
Java 中访问数据库的步骤?Statement 和PreparedStatement 之间的区别?
2012/06/05 面试题
商务英语专业应届毕业生求职信
2013/10/28 职场文书
2014学习全国两会精神心得体会2000字
2014/03/11 职场文书
公务员学习习总书记“三严三实”思想汇报
2014/09/19 职场文书
基层党员四风问题自我剖析材料
2014/09/29 职场文书
MySQL查询学习之基础查询操作
2021/05/08 MySQL
MySQL 逻辑备份与恢复测试的相关总结
2021/05/14 MySQL
MySQL 数据恢复的多种方法汇总
2021/06/21 MySQL
SQL SERVER实现连接与合并查询
2022/02/24 SQL Server
十大最强妖精系宝可梦,哲尔尼亚斯实力最强,第五被称为大力士
2022/03/18 日漫