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
}) 在此我向大家推荐一个前端全栈开发交流圈: 619586920 突破技术瓶颈, 提升思维能力
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"))
在此我向大家推荐一个前端全栈开发交流圈: 619586920 突破技术瓶颈, 提升思维能力
- 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
- }
- }
- 2
- // 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>
)在此我向大家推荐一个前端全栈开发交流圈: 619586920 突破技术瓶颈, 提升思维能力
- }
- // 数据准备好后, render 整个应用
- const HTML = renderToString(<App />)
- // 把预加载的数据挂载在 `window` 下返回, 客户端自己去取
- return
- <HTML>
- <head></head>
- <body>
- <div id="app">${HTML}</div>
- <script>
- Windows.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
- </script>
- </body>
- </HTML>
客户端
为保证两端的应用数据一致, 客户端也要使用同一份数据初始化一次 redux 的 store, 再生成应用. 如果两者的 dom / 数据不一致, 导致浏览器接管的时候 dom 重新生成了一次, 在开发模式下的时候, 控制台会输出错误信息, 开发体验完美. 后续 Ajax 的数据, 在 componentDidMount 和事件中去执行, 和服务端的逻辑天然剥离.
- // 获取服务端提供的初始化数据
- const preloadedState = Windows.__PRELOADED_STATE__ || undefined
- delete Windows.__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, 至于如何配置得赏心悦目, 用起来得心应手, 根据项目要求各显神通吧.
服务端打包
这里既可以把整个服务端入口 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
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 插件自动生成
) 在此我向大家推荐一个前端全栈开发交流圈: 619586920 突破技术瓶颈, 提升思维能力
- 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>
- Windows.STORE = 'love';
- Windows.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
- </script>
- ${scriptTags}
- </body>
- </HTML>
SEO 信息
上面已经透露了. 使用了一个 react-helmet 库. 具体用法可查看官方仓库, 信息可直接写在组件上, 最后根据优先级提升到 head 头部.
结语
感谢您的观看, 如有不足之处, 欢迎批评指正.
获取资料
来源: http://www.qdfuns.com/article/51117/a3058f737637c9d91d6a91fdb728fbab.html