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

一开始想学学服务端渲染,脑海中第一个浮现出来的就是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则会根据路由信息,提前获取该路由页面的初始数据,返回页面时已经有了初步的内容,不至于空白,也便于搜索引擎收录。

路由匹配

浏览器端的路由匹配还是照着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头部。

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

(0)

相关推荐

  • 浅谈react前后端同构渲染

    前后端同构渲染:当客户端请求一个包含React组件页面的时候,服务端首先响应输出这个页面,客户端和服务端有了第一次交互.然后,如果加载组件的过程需要向服务端发出Ajax请求等,客户端和服务端又进行了一次交互,这样,耗时相对较长.前后端同构渲染可以在页面初次加载时把所有地方渲染好一次性响应给客户端 实现方式:保证包管理工具和模块依赖方式一致 包管理工具-npm管理,保证前后端都使用同一个兼容包 模块依赖方式-webpack,保证前后端都采用commonjs的依赖方式,确保代码可以互相依赖 服务端如

  • 浅谈React前后端同构防止重复渲染

    什么叫前后端同构? 为了解决某些问题(比如SEO.提升渲染速度等)react 提供了2个方法在服务端生成一个HTML文本格式的字符串.在得到了这个HTML格式的字符串之后,通常会将其组装成一个页面直接返回给用户的浏览器. 到这里,服务端的活已经干完了,然后就是浏览器这边干活. 浏览器拿到HTML文本后,立刻进行渲染将内容呈现给用户.然后加载页面所需的 .js 文件,然后执行 JavaScript 脚本,然后开始初始化 react 组件---- 到这里问题就来了.react 初始化组件后会执行组件

  • 浅谈react 同构之样式直出

    前言 上文讲到通过同构服务端渲染,可以直出html结构,虽然讲解了样式,图片等静态资源在服务端引入问题的解决方案,但是并没有实际进行相关操作,这篇文章就讲解一下如何让样式像html一样直出. PS: 直出,我的理解就是输入url发起get请求访问服务端,直接得到完整响应结果,而不是同过ajax异步去获取. React 同构的关键要素 完善的 Compponent 属性及生命周期与客户端的 render 时机是 React 同构的关键. DOM 的一致性 在前后端渲染相同的 Compponent,

  • 详解react服务端渲染(同构)的方法

    学习react也有一段时间了,使用react后首页渲染的速度与seo一直不理想.打算研究一下react神奇服务端渲染. react服务端渲染只能使用nodejs做服务端语言实现前后端同构,在后台对react组件进行解析并生成html字符串后返回视图页面. 后台为什么可以解析react组件?因为Node.js是一个Javascript运行环境,nodejs与javascript语法基本是相同的,所以nodejs可以正常解析react组件. 一.准备动作 1.安装nodejs与安装express 安

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

    一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案.看了一两天,有趣,优雅,但是封装好了,原理不甚清楚,也感觉无法灵活嵌合到老项目上去.于是看各种资料,想整理出同构的线索,一步一步地实现自己的同构模板.相关代码可查看我的GitHub.感谢阅读!! TODO List 数据:如何保持前后端应用状态一致 路由:路由在服务端和客户端中的匹配方案 代码:同构,哪些地方可以共享,哪些地方需要差异化 静态资源:服务端如何引入css/图片等 ssr直出资源:服务端在渲染路由页面时如

  • ReactQuery系列React Query 实践示例详解

    目录 引言 客户端状态 vs 服务端状态 React Query 关于默认行为的解释 使用React Query DevTools 把query key理解成一个依赖列表 一个新的缓存入口 把服务端状态和客户端状态分开 enabled属性是很强大的 创建自定义hook 引言 当2018年GraphQL特别是Apolllo Client开始流行之后,很多人开始认为它将替代Redux,关于Redux是否已经落伍的问题经常被问到. 我很清晰地记得我当时对这些观点的不理解.为什么一些数据请求的库会替代全

  • 详解Immutable及 React 中实践

    有人说 Immutable 可以给 React 应用带来数十倍的提升,也有人说 Immutable 的引入是近期 JavaScript 中伟大的发明,因为同期 React 太火,它的光芒被掩盖了.这些至少说明 Immutable 是很有价值的,下面我们来一探究竟. JavaScript 中的对象一般是可变的(Mutable),因为使用了引用赋值,新的对象简单的引用了原始对象,改变新的对象将影响到原始对象.如 foo={a: 1}; bar=foo; bar.a=2 你会发现此时 foo.a 也被

  • 记录一次完整的react hooks实践

    写在前面 React在16.8版本正式发布了Hooks.关注了很久,最近正好有一个小需求,赶紧来试一下. 需求描述 需求很简单,部门内部的一个数据查询小工具.大致长成下面这样: 用户首次访问页面,会拉取数据展示.输入筛选条件,点击查询后,会再次拉取数据在前端展示. 需求实现 使用React Class Component的写法 如果使用以前的class写法,简单写一下,代码可能大概长成下面这样: import React from 'react'; import { Tabs, Input, R

  • react项目实践之webpack-dev-serve

    模块热替换(Hot Module Replacement) HMR是webpack最令人兴奋的特性之一,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新.HMR是一个非常值得去深入研究的东西,它绝不是目前我们看到的大多数技术文章说的配置一个hot参数这么简单,有兴趣的小伙伴可以去看看它的实现原理,目前为止我也只看过一点点. 其实实现HMR的插件有很多,webpack-dev-s

  • Java如何实现树的同构?

    树的同构 备忘! 定义:给定两棵树r1.r2,如果r1可以通过若干次的左子树和右子树互换,使之与r2完全相同,这说明两者同构. 举例 树的构造 树可以由数组或链表来构造: 举例:上图左上角的树通过数组可表示为 0 1 2 3 4 5 6 7 8 9 10 11 12 A B C D E G - - - F - H - 该方式浪费了部分空间,但适合表示完全二叉树 链表方式则比较直观 除上述两种方式外,还可以采用"类数组"的方式 public static class Node{ Stri

  • 带你用Java方法轻松实现树的同构

    目录 树的同构 总结 树的同构 举例 树的构造 树可以由数组或链表来构造: 举例:上图左上角的树通过数组可表示为 0 1 2 3 4 5 6 7 8 9 10 11 12 A B C D E G - - - F - H - 该方式浪费了部分空间,但适合表示完全二叉树 链表方式则比较直观 除上述两种方式外,还可以采用"类数组"的方式 public static class Node{ String data; int left; int right; } 举例:上图左上角的树可表示为 数

  • 从零开始搭建一个react项目开发

    本文介绍了从零开始搭建一个react项目开发,分享给大家,具体如下: 1.npm init 生成 package.json 文件. 2.安装各种需要的依赖: npm install  --save react - 安装React. npm install  --save react-dom 安装React Dom,这个包是用来处理virtual DOM.这里提一下用React Native的话,这里就是安装react-native. npm install  --save-dev webpack

  • 浅谈react性能优化的方法

    React性能优化思路 软件的性能优化思路就像生活中去看病,大致是这样的: 使用工具来分析性能瓶颈(找病根) 尝试使用优化技巧解决这些问题(服药) 使用工具测试性能是否确实有提升(疗效确认) 初识react只是为了尽快完成项目,后期进行代码审查时候发现有很多地方需要优化,因此做了个小结. Code Splitting shouldComponentUpdate避免重复渲染 使用不可突变数据结构 组件尽可能的进行拆分.解耦 列表类组件优化 bind函数优化 不要滥用props ReactDOMSe

随机推荐