前端性能优化之SSR

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战

过去一直在用React + Dva + Antd写中后台项目,从最初的6小时一个页面,到现在的两小时一套页面,其中的秘诀就是不断总结与熟悉,写一些适合业务的轮子。今年随着业务稳定,有机会去尝试一些自己感兴趣的方向,比如前端工程化、SSR、 小程序;最近由于苹果对App上架流程的调整,部门需要写一个官网。虽然说一个上午就写出来了,但从官网的角度,以及对成品不断追求的态度,现在这个官网太Low了, 无Seo,无移动端适配,无首屏渲染。所以最近开始接触SSR,试图用一个更加专业的方案去重新打造这个官网。

方案筛选

其实没啥筛选的,页面框架React是铁打的。而React的服务端渲染,市面上一般就有完全自己搭和选择NextJs

  • NextJs不用多说,只要开始写好了配置,就可以像写中后台一样,安心的写页面就行了,无需过多关心服务端路由,打包这些(但不得不说的是,写配置也真的是一项浩大的工程, 10个问题,9个是关于配置的)。
  • 但秉承学习的态度,而不是交任务,从一开始就选择了自己去搭,自己曾经看到有人用react + Dva + Express搭SSR的文章,所以基于对Dva的熟悉与钟爱,就直接选择了这个方案,只是将Express换成了Koa。但问题真的是一堆一堆的出现,当在Issues中看到这张图,我是奔溃的,究其原因是随着React 16用Fiber进行了重写,同构渲染(hydrate)与客户端(render)进行了分离。而Dva2.0并没有对这一个特性进行支持。

细说方案

同构渲染一套代码两端运行:即可以像SPA项目一样,打包一套静态资源代码,在浏览器独立运行;又可以像传统jsp,php页面一样,由服务端页面直出,但又高于这些技术,因为在首屏时依赖直出,而在后面的操作又有SPA一样的操作体验,这就是同构的优势所在。但这也要求了更高的架构思想,对前端提出了更高的要求,主要体现在下面几个方面:

  • 怎么同时兼容浏览器端和服务端两种模式的路由;
  • 数据流管理怎么通用;
  • 服务端的开发及负债均衡;
  • 服务的部署

路由的兼容

如果对SPA和SSR了解的话,就知道:SPA一般我们用Hash路由HashRoutr(#/home),而SSR在浏览器端则采用传统路由,即浏览器路由BrowserRouter(/home),但只有去尝试SSR后我才知道,还有一种路由被称之为静态路由StaticRouter(/home),看起来和BrowserRouter相似,之所以称之为静态的,就是它没有前进,后退,跳转这些路由操作。具体可参考React-Router的相关介绍。这三种路由分别对应三种入口:

  • 浏览器端HashRoutr
import { HashRouter as Router } from 'react-router-dom';
import { createHashHistory as createHistory } from 'history';
import Layout from './Layout';
import createApp from './model/createApp';
import './style/index.less';

const history = createHistory();
const app = createApp({ history });
app.start();

const App = () => (
  <Provider store={app._store}>
    <Router history={history}>
      <Layout path="/" />
    </Router>
  </Provider>
);

render(<App />, document.getElementById('app'));
复制代码
  • 服务端StaticRouter
import { StaticRouter as Router } from 'react-router-dom';
import createHistory from 'history/createMemoryHistory';
import createApp from './model/createApp';
import Layout from './Layout';

export default function CreateDom({ location, context }) {
  const history = createHistory(location);
  const app = createApp({ history });
  app.start();
  return {
    app,
    render: () => (
      <Provider store={app._store}>
        <Router location={location} context={context} history={history}>
          <Layout location={location} context={context} history={history} />
        </Router>
      </Provider>)
  };
}
复制代码
  • 服务器渲染浏览器同构端BrowserRouter, 后面会解释为什么这里没有用BrowserRouter,而是采用Router作为代替
import { Router } from 'react-router-dom';
import { createBrowserHistory as createHistory } from 'history';
import Layout from './Layout';
import createApp from './model/createApp';
import './style/index.less';

// ssr渲染,浏览器端渲染入口
const history = createHistory();

const app = createApp({
  history,
  initialState: window.states && JSON.parse(window.states),
});
app.start();
delete window.states;
const App = () => (
  <Provider store={app._store}>
    <Router history={history}>
      <Layout isWindow />
    </Router>
  </Provider>
);

ReactDOM.hydrate(<App />, document.getElementById('app'));
复制代码

从上面三种代码也可以看出,在reactDom的渲染方式上我们也分别对应三种:

  • render: spa常用;
  • renderToString:服务端渲染专用,用于将React对象渲染成Dom字符串;
  • hydrate:服务端渲染专用,用于延用已存在的dom节点

数据流的管理

在上面的三段代码中,都看到了两个共同的模块,一个Layout,一个createApp,分别对应页面与数据流。自己搭框架,其实难点就在怎么公用数据流管理,让差异最小化。由于自己中后台写的太多,对Dva这一套情有独钟,所以怎么绕,最后都把眼神聚焦到了这里。所以最后数据流的管理,还是选择了Dva-core。createApp源码:

import { create } from 'dva-core';
import hook from '@doddle/dva';
import * as models from './model';

export default function createApp(opts) {
 const app = create(opts);
 app._history = opts.history;
 hook({ app }); // 扩展对象, 增加listen, update, loading等插件
 Object.keys(models).forEach(key => app.model(models[key]));
 return app;
}
复制代码

代码非常简短,没有做什么差异化的兼容处理,只做了dva数据对象的初始化;扩展了这个数据对象;加了一个history属性,目的是listen插件需要;model对象的加载。

SPA渲染与SSR渲染数据流处理的差异就在首屏。通常我们在做SPA时,将获取页面初始状态的操作都放在页面监听中(dva model的subsciption),而不是最初的componentDidMount这个钩子里。但在服务端做首屏渲染时,这种方案就不可取,没有history变化这一说,所以需要采用其他方案。最早写React的人都知道,曾今还有个方法叫getInitialState,但后面这个方法被弃用。在NextJs中也存在一个这样一个方法,其目的就是做服务端渲染的首屏数据获取。我在自己的设计中也沿用了这个思想,具体是:

  • 我将页面组件,需要做首屏数据获取的,组件增加getInitialState这个方法,并在方法中返回需要做的操作,like:{ type: 'index/add', payload }
  • 在服务器获取到路由后,匹配到对应的页面组件。判断是否有getInitialState属性,即需要在首屏做数据获取的,如果有,获取数据;
  • 获取到数据后,数据对象被更新,渲染对应页面html做出响应,并保存数据对象,将其转化成js文件作为html的引用;
  • 待首屏渲染后,同构js获取数据对象js保存的数据对象作为初始化浏览器端的数据对象,以保证浏览器端渲染获得和服务端相同的dom结果;

这样做还有一个好处就是,BrowerRouter由于是初次进这个页面,所以listen监听不会生效,所以不会存在重复获取初始状态这个问题。以上就是数据流方案的整体思路,也是整个SSR中比较重点的。

服务端代码实现

SSR渲染和纯前端渲染最大的区别就是,你需要写一个服务器。而Node给我们提供了这样的能力,让我们可以用js语言写后端服务。之所以从众多的后端框架中选择了Koa,是因为前段时间刚好对Koa有一个比较全面的了解。Koa经典的洋葱模型,将服务实现插件化,非常易于扩展,Async Await的插件语法,也非常符合时代的潮流。后端服务主要在功能上要实现:

  • 静态资源服务:koa-static完成
  • 路由的转发与拦截:koa-router完成
  • html的动态生成: renderToString服务端渲染

静态资源服务和路由的转发拦截比较简单,基本几行代码就搞定。

const path = require('path');
const Koa = require('koa');
const staticSource = require('koa-static');
const router = require('./router');

const app = new Koa();
const staticPath = '../public';

app.use(staticSource(path.join(__dirname, staticPath)));

app.use(router.routes())
  .use(router.allowedMethods());

// router.js
const Router = require('koa-router');
const stateMiddleaWare = require('./stateMiddleaWare');
const ssrMiddleware = require('./ssrMiddleware');

const router = new Router();
router.get('/states/:key.js', stateMiddleaWare); // 提供同构的初始状态对象
router.get('/:url', ssrMiddleware); // 提供页面的服务端渲染
复制代码

重点还是在服务端渲染这一块,在我的项目里,这部分是由ssrMiddleware中间件来完成的,源码也比较简单,如果认真读且理解了前面讲的,那这一部分的源码就比较好理解了。大体上讲,做了三件事:

  • 非目标路由重定向,
  • 初始状态获取;
  • 初始状态保存,提供给stateMiddleaWare,生成初始状态js
  • 动态html生成
async (ctx, next) => {
  const { url } = ctx;
  const renderProps = { location: url };

  // redirect to home when route is not a validRoutes
  if (url === '/' || !validRoutes.includes(url)) {
    ctx.redirect('/home');
    return;
  }
  const title = routesTitle[url];

  const server = CreateDom(renderProps);
  const store = server.app._store;
  const dataRequirements = routes
    .filter(route => matchPath(url, route)) // filter matching paths
    .map(route => route.component) // map to components
    .filter(comp => comp.getInitialState) // check if components have data requirement
    .map(comp => store.dispatch(comp.getInitialState({ count: 5 }))); // dispatch data requirement
  // get initialState
  await Promise.all(dataRequirements);

  // cache states to genrate dynamic js
  const initialState = store.getState();
  const stateKey = stateServe.set(JSON.stringify(initialState));

  // generate html source
  const html = renderToString(server.render());
  ctx.body = renderFullPage(html, stateKey, title);
  await next();
}
复制代码

好了以上,就是主要代码的实现,关于stateMiddleaWare,实现就简单了,感兴趣的,可以看源码了解。

一些没提到但又很重要的点

  • window的处理,由于Layout内的代码,既要在服务端(Node)执行,又要在Browser执行,所以要注意window使用时,执行环境的检测;
  • fetch的使用,由于fetch仅仅存在于浏览器端,所以服务端获取初始状态时,就需要替代品,isomorphic-unfetch是个很好的替代;
  • 前面提到SSR的浏览器端渲染,将BrowserRouter换成了低阶的Router,是因为,由于我进页面会用到history的监听,以获取这个页面的初始状态。

おすすめ

転載: juejin.im/post/7031523402804363277