# 正文

react ssr 到现在已经不是新技术,技术原理也不复杂,但是真要自己弄出一个完整的轮子并非易事,有非常多的细节和边边角角需要处理。

所以除了自己来造轮子,我们还可以站在巨人的肩膀上,直接使用业内现成的框架进行开发。

造轮子可以,但是不要闭门造车,所以本节我们来了解下业内框架他们是怎样实现的,也侧面的看下我们已有功能的实现是否合理,是为了验证我们的方案,更是学习。

这里主要来看下next.jsegg-react-ssr的实现。

当然还有umi,不过umi ssr代码核心部分也是egg-react-ssr团队贡献的代码,所以就不做对比了。

另外在客户端组件渲染时会使用服务端直出的数据问题,也是参考egg-react-ssr来实现的,只是细节不同。

# 数据预取

看下这两个框架在服务端如何获取组件数据的。

  • next.js 数据预取代码
import React from 'react'

export default class extends React.Component {
  static async getInitialProps({ req }) {
    const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
    return { userAgent }
  }

  render() {
    return (
      <div>
        Hello World {this.props.userAgent}
      </div>
    )
  }
}

当页面渲染时加载数据,使用了一个异步方法getInitialProps。它能异步获取数据,并绑定在props上。当服务渲染时,getInitialProps将会把数据序列化,就像JSON.stringify

当第一次进入页面时,getInitialProps只会在服务端执行。只有当路由跳转(Link组件跳转或 API 方法跳转)时,客户端才会执行getInitialProps

另外此方法只能用于页面组件内,不能在子组件内使用。

  • egg-react-ssr 数据预取代码
import React from 'react'
import { Link } from 'react-router-dom'
import './index.less'

function Page (props) {
  return (
    <div className='normal'>
      <div className='welcome' />
      <ul className='list'>
        {
          props.news && props.news.map(item => (
            <li key={item.id}>
              <div>文章标题: {item.title}</div>
              <div className='toDetail'><Link to={`/news/${item.id}`}>点击查看详情</Link></div>
            </li>
          ))
        }
      </ul>
    </div>
  )
}

Page.getInitialProps = async (ctx) => {
  // ssr渲染模式只在服务端通过Node获取数据,csr渲染模式只在客户端通过http请求获取数据,getInitialProps方法在整个页面生命周期只会执行一次
  return __isBrowser__ ? (await window.fetch('/api/getIndexData')).json() : ctx.service.api.index()
}

export default Page

页面初始化时,服务端根据当前请求的path,来确定我们要渲染哪一个组件,getComponent可以理解为一个根据path从路由表中找到匹配的组件的方法,检测该组件上有没有getInitialProps静态方法,这里之所以要用静态方法,是为了不需要实例化就可以拿到方法。

如果有的话,将调用这个方法,将数据作为组件的props传入,使得组件可以通过props.xxx的方式来读取到服务端获取的数据。

# 本应用的数据预取

import React from 'react';
import {
    Link
} from 'react-router-dom';
import './index.scss';
import img from '../../public/img.jpg';
import PageContainer from '../../common/components/page-container';


function Index(props) {
        return <div className="page-index-box">
            <p>首页</p>
            <img src={img} />
        </div>
}

Index.getInitialProps= async ()=>{
    console.log('fetch data index');
    //模拟数据请求方法
    //...

    return {
        page: {
            tdk: {
                title: '首页 - koa-react-ssr',
                keywords: '关键词 - koa-react-ssr',
                description: '描述'
            }
        }
    };
}

export default PageContainer(Index);

同样为组件添加getInitialProps静态方法,服务端根据当前请求的path,调用matchRoute方法查找到对应的路由,得到具体的组件,判断组件上是否有getInitialProps此方法,然后进行数据预取。

最后把数据作为组件的props,在组件内可以通过props.initialData固定属性来获取。

整体来看,本应用的实现方式和egg-react-ssr,next.js非常相似,可能这也是业内一种默认的通用做法吧。

# 数据脱水

从运行时的页面看下服务端直出数据的方式。

  • next.js

数据直出到页面后,通过script标签来进行包裹,且type="application/json",标签内直接是 json数据。

image-20210214221123926

  • egg-react-ssr

也是作为脚本加载,然后将数据保存在了window.__INITIAL_DATA__全局变量内。

image-20210214221135415

  • 本应用

为了防止xss攻击,将数据放在了textarea标签内,客户端使用前先进行一次获取

image-20210214221159958

最后,本应用和他们两个框架的结果相同,只是表现形式不同。

# 热更新

都实现了模块热替换的功能。

  • next.js采用hot-middleware + webpackHotDevClient.js实现
  • egg-react-ssr采用社区成熟库webpack-dev-server实现,使用代理抹平了双服务模式,对外看到的是一个服务
  • 本应用采用社区成熟库webpack-dev-server实现,未做代理转发,目前是使用双服务模式

再次确认了下这两个框架是否支持了模块热更新的同时,是否能保存组件的状态,不过都不支持。

# 路由配置

  • next.js

该框架是约定式路由,没有路由配置文件,只要在 pages 文件夹下创建的文件,都会默认生成以文件名命名的路由,的确很方便,但是有些过度封装了。

image-20210214221212572

  • 本骨架和egg-react-ssr 直接使用react-router,依旧使用传统的spa应用的使用方式,手动编写路由规则,更加方便你去控制你的项目结构。

egg-react-ssr 配置方式

const resolvePath = (path) => require('path').resolve(__dirname, path)

module.exports = {
  type: 'ssr', // 指定运行类型可设置为csr切换为客户端渲染
  routes: [
    {
      path: '/',
      exact: true,
      Component: () => (require('@/page/index').default), // 这里使用一个function包裹为了让它延迟require
      controller: 'page',
      handler: 'index'
    },
    {
      path: '/news/:id',
      exact: true,
      Component: () => (require('@/page/news').default),
      controller: 'page',
      handler: 'index'
    },
    {
      path: '/test',
      exact: true,
      Component: () => (require('@/page/test').default),
      controller: 'page',
      handler: 'index'
    }
  ],
  baseDir: resolvePath('../'),
  injectCss: [
    `/static/css/Page.chunk.css`
  ], // 客户端需要加载的静态样式表
  injectScript: [
    `<script src='/static/js/runtime~Page.js'></script>`,
    `<script src='/static/js/vendor.chunk.js'></script>`,
    `<script src='/static/js/Page.chunk.js'></script>`
  ], // 客户端需要加载的静态资源文件表
  serverJs: resolvePath(`../dist/Page.server.js`)
}

本骨架的配置方式

//路由配置文件
import React from 'react';

//组件动态加载容器
import AsyncLoader from './async-loader';

function pageNotFound() {
    return <div>404页面</div>
}

export default [
    {
        path: ['/','/index'],
        component: AsyncLoader(() => import('../pages/index')),
        exact:true
    },
    {
        path: '/list',
        component: AsyncLoader(() => import('../pages/list')),
        exact: true
    },
    {
        path: '/about',
        component: AsyncLoader(() => import('../pages/about')),
        exact: true
    },
    {
        path: '*',
        component: pageNotFound,
        exact: true
    }
]

这两者都属于集中式路由配置,更加直观和更灵活的进行配置。多人开发的时候可能维护上有点小瑕疵,容易有冲突。

# 路由分割

# next.js

自动根据页面进行代码分割,无需配置。

# egg-react-ssr 实现方式

使用react-loadable库实现,实现方式和官方的方式不同。

没有将服务端bundle打包成多个文件,依然保持一个文件,因为服务端直接处理的是静态路由。

可以参考以下配置

  {
        path: '/news/:id',
        exact: true,
        Component: () => (__isBrowser__ ? require('react-loadable')({
        loader: () => import(/* webpackChunkName: "news" */ '@/page/news'),
        loading: function Loading () {
            return React.createElement('div')
        }
        }) : require('@/page/news').default // 通过这种方式来让服务端bundle不要分块打包
        ),
        controller: 'page',
        handler: 'index'
    }

这样配置有个坑,导致Loadable没办法预先知道你有哪些组件被包裹了,所以没办法直接调用Loadable.preloadReady()来预加载。

只能自己写一个preloadComponen方法来手动调用组件的preload方法了。

import { pathToRegexp } from 'path-to-regexp'
import cloneDeepWith from 'lodash.clonedeepwith'
import { RouteItem } from './interface/route'

const preloadComponent = async (Routes: RouteItem[]) => {
  const _Routes = cloneDeepWith(Routes)
  for (let i in _Routes) {
    const { Component, path } = _Routes[i]
    let activeComponent = Component()
    if (activeComponent.preload && pathToRegexp(path).test(location.pathname)) {
        // 只有在你访问的path和组件为同一个path才拿到真实的组件,其他情况还是返回Loadable Compoennt来让首屏不要去加载这些组件
      activeComponent = (await activeComponent.preload()).default
    }
    _Routes[i].Component = () => activeComponent
  }
  return _Routes
}

export {
    preloadComponent
}

然后在客户端渲染的时候调用一下该方法

const clientRender = async () => {
 //预加载
  const clientRoutes = await preloadComponent(Routes)
  // 客户端渲染||hydrate
  ReactDOM[window.__USE_SSR__ ? 'hydrate' : 'render'](
    <BrowserRouter>
      {
        // 使用高阶组件getWrappedComponent使得csr首次进入页面以及csr/ssr切换路由时调用getInitialProps
        clientRoutes.map(({ path, exact, Component }) => {
          const activeComponent = Component()
          const WrappedComponent = getWrappedComponent(activeComponent)
          const Layout = WrappedComponent.Layout || defaultLayout
          return <Route exact={exact} key={path} path={path} render={() => <Layout><WrappedComponent /></Layout>} />
        })
      }
    </BrowserRouter>
    , document.getElementById('app'))

  if (process.env.NODE_ENV === 'development' && module.hot) {
    module.hot.accept()
  }
}

# 本骨架实现方式

没有使用react-loadable,而是依据动态导入原理,包装了一个自定义异步组件加载器AsyncBundle,基本原理和react-loadable都是一样的。

  1. 通过高阶函数对返回一个函数组件,同时为函数添加异步属性,后面服务端和客户端预加载直接通过此属性进行判断
  2. 服务端代码会被打包成多个文件
  3. 服务端在请求前对组件进行预加载,也就是转换为静态组件
  4. 客户端代码会打包成多个文件
  5. 客户端bundle预加载后再渲染页面
容器组件
import React from 'react';
import LoadingCompoent from './loading-compoent';


/**
 * 动态加载组件组的容器组件
 *
 * @class Bundle
 * @extends {Component}
 */
export default class AsyncBundle extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            mod: null
        };
    }

    componentDidMount() {
        if (!this.state.mod) {
            this.load(this.props);
        }
    }

    load(props) {
        this.setState({
            mod: null
        });
        //注意这里,使用Promise对象; mod.default导出默认
        props.load().then((mod) => {
            this.setState({
                mod: mod.default ? mod.default : mod
            });
        });
    }

    render() {
        return this.state.mod ? this.props.children(this.state.mod) : <LoadingCompoent/>;
    }
}
高阶函数,返回异步加载组件的包装组件
import AsyncBundle from './async-bundle';
import proConfig from '../../share/pro-config';
import React from 'react';
function AsyncLoader (loader) {

    function asyncFn(props) {
       return <AsyncBundle load={loader}>
            {(Comp) => <Comp {...props} />}
        </AsyncBundle>
    }

    //标记为异步组件,双端会根据此属性进行预加载
    asyncFn[proConfig.asyncComponentKey] = true;

    return asyncFn;
}

export default AsyncLoader;
参考一个路由配置

AsyncLoader函数内会标记此组件为异步组件

    {
        path: '/list',
        component: AsyncLoader(() => import('../pages/list')),
        exact: true
    }
服务端组件预加载
//将路由转换为静态路由,进行组件预加载
async function getStaticRoutes(routes) {

    const key ='__dynamics_route_to_static';
    if (global[key]){
        console.log('cache route');
        return global[key];
    }

    let len = routes.length,
        i = 0;
    const staticRoutes = [];

    for (; i < len; i++) {
        let item = routes[i];
        if (checkIsAsyncRoute(item.component)) {
            staticRoutes.push({
                ...item,
                ...{
                    component: (await item.component().props.load()).default
                }
            });
        } else {
            staticRoutes.push({
                ...item
            });
        }
    }
    global[key]=staticRoutes;
    return staticRoutes; //返回静态路由
}
客户端渲染
function clientRender(routeList) {


        let initialData = JSON.parse(document.getElementById('ssrTextInitData').value);
        window.__INITIAL_DATA__ = initialData;

        //查找路由
        let matchResult = matchRoute(document.location.pathname, routeList);
        let { targetRoute } = matchResult;
        if (targetRoute) {
                //预加载完成后进行 render
                if (targetRoute.component[proConfig.asyncComponentKey]) {
                        targetRoute.component().props.load().then(res => {
                                //异步组件加载完成后再渲染页面
                                console.log('异步组件加载完成.....');
                                renderDom(routeList,initialData);
                        });
                }

        } else {
                renderDom(routeList);

        }
}

# CSS 资源

主要看下 css 资源是如何处理的。

  • next.js

该框架采用的是将css代码最终打包到一个文件内,作为资源进行加载。

image-20210214221231029

  • egg-react-ssr

也是最终将css代码提取到一个文件内。

image-20210214221242918

  • 本骨架实现方式

我们目前有两种方式来处理css,一种是将代码进行提取到一个文件内作为资源进行加载。

另外一种是同构处理,页面初始化时服务端会搜集组件所需的 css,然后作为内联形式输出。

客户端渲染时会判断当前页面内是否已存在,只有不存在的情况下才会动态的插入样式。

image-20210214221256113

其实css同构处理的配置比较繁琐,直接提取为一个css文件也不失为一种便捷的处理方式。

# csr/ssr 双模式

  • next.js是一个纯粹的ssr应用服务框架
  • 本应用和egg-react-ssr即支持ssr也支持csr,且支持本地开发与生产环境ssr/csr两种渲染模式无缝切换

# 总结

本骨架的方案和egg-react-ssr比较相似,不过仍然有很多区别,还有很多地方可以借鉴和学习,但整体方向比较相似。

next.js是很成熟的React SSR应用开发框架,进行了大量的封装,很多东西都是黑盒的,只能按照他已有的模式进行开发,很难进行改造,且只支持ssr一种渲染模式。

比较方便的一个是它的约定式路由,根据你的目录和文件来处理的,不需要对路由进行维护,但是需要按照他的规则来创建文件,让你可以有更多时间来关注业务,而无需关注底层和配置。

我们的骨架是完全透明的,所有的配置和代码都在项目里,可以很方便的进行改造,路由仍然是传统的集中配置的路由,符合我们以往的开发习惯,但是多人开发可能有些问题,会产生冲突。不过也可以进行优化一下,在每个页面内增加一个路由配置,分开维护,最后通过一些手段将各个页面的路由合并为一个再使用。

另外本骨架同时支持csrssr两种渲染模式无缝切换。

生产环境来说,大家都采用的是将所有的css打包合并到为一个文件方式,如果项目过大的话 css文件可能会过大,导致页面渲染变慢。

所以本骨架使用同构对 css进行按需加载,消除了独立css文件,css代码在服务端渲染时会和html内容一起直出,客户端渲染时会动态的创建style标签插入到head内。

这样的方式可以让客户端加载更少的代码,不好的地方就是css代码会打包进js,修改css代码也会导致相关的js模块缓存失效,另外同构配置比较繁琐,坑较多,另外对代码侵入性较大,这也可能是其他框架不具备此能力的原因吧。

阅读全文