react koa rematch 打造一套服務端渲染架子。

前言

本次講述的內容主要是 react 與 koa 搭建的一套 ssr 框架,是在別人造的輪子上再添加了一些本身的想法和完善一下本身的功能。倉庫地址:javascript

本次用到的技術爲: react | rematch | react-router | koacss

react服務端渲染優點

SPA(single page application)單頁應用雖然在交互體驗上比傳統多頁更友好,但它也有一個天生的缺陷,就是對搜索引擎不友好,不利於爬蟲爬取數據(雖然據說chrome可以異步抓取spa頁面數據了);html

SSR與傳統 SPA(Single-Page Application - 單頁應用程序)相比,服務器端渲染(SSR)的優點主要在於:更好的 SEO 和首屏加載效果。java

在 SPA 初始化的時候內容是一個空的 div,必須等待 js 下載完纔開始渲染頁面,但 SSR 就能夠作到直接渲染html結構,極大地優化了首屏加載時間,但上帝是公平的,這種作法也增長了咱們極大的開發成本,因此你們必須綜合首屏時間對應用程序的重要程度來進行開發,或許還好更好地代替品(骨架屏)。react

react服務端渲染流程

組件渲染

首先確定是根組件的render,而這一部分和SPA有一些小不一樣。git

使用 ReactDOM.render() 來混合服務端渲染的容器已經被棄用,而且會在React 17 中刪除。使用hydrate() 來代替。github

hydrate與 render 相同,但用於混合容器,該容器的HTML內容是由 ReactDOMServer 渲染的。 React 將嘗試將事件監聽器附加到現有的標記。chrome

hydrate 描述的是 ReactDOM 複用 ReactDOMServer 服務端渲染的內容時儘量保留結構,並補充事件綁定等 Client 特有內容的過程。redux

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.hydrate(<App />, document.getElementById('app'));
複製代碼

在服務端中,咱們能夠經過 renderToString 來獲取渲染的內容來替換 html 模版中的東西。數組

const jsx = 
    <StaticRouter location={url} context={routerContext}>
        <AppRoutes context={defaultContext} initialData={data} />
    </StaticRouter>
    
const html = ReactDOMServer.renderToString(jsx);

let ret = `
    <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
        </head>
        <body>
          <div id="app">${html}</div>
        </body>
    </html>
`;

return ret;
複製代碼

服務端返回替換後的 html 就完成了本次組件服務端渲染。

路由同步渲染

在項目中避免不了使用路由,而在SSR中,咱們必須作到路由同步渲染。

首先咱們能夠把路由拆分紅一個組件,服務端入口和客戶端均可以分別引用。

function AppRoutes({ context, initialData }: any) {
  return (
    <Switch>
      {
        routes.map((d: any) => (
          <Route<InitRoute>
            key={d.path}
            exact={d.exact}
            path={d.path}
            init={d.init || ''}
            component={d.component}
          />
        ))
      }
      <Route path='/' component={Home} />
    </Switch>
  );
}
複製代碼

(routes.js)

export const routes = [
  {
    path: '/Home',
    component: Home,
    init: Home.init,
    exact: true,
  },
  {
    path: '/Hello',
    component: Hello,
    init: Hello.init,
    exact: true,
  }
];
複製代碼

這樣咱們的路由基本定義完了,而後客戶端引用仍是老規矩,和SPA沒什麼區別

import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes';
class App extends Component<any, Readonly<State>> {
...
  render() {
    return (
    <Router>
      <AppRoutes/>
    </Router>
    );
  }
}
複製代碼

在服務端中,須要使用將BrowserRouter 替換爲 StaticRouter 區別在於,BrowserRouter 會經過HTML5 提供的 history API來保持頁面與URL的同步,而StaticRouter 則不會改變URL,當一個 匹配時,它將把 context 對象傳遞給呈現爲 staticContext 的組件。

const jsx = 
    <StaticRouter location={url}>
        <AppRoutes />
    </StaticRouter>
    
const html = ReactDOMServer.renderToString(jsx);

複製代碼

至此,路由的同步已經完成了。

redux同構

在寫這個以前必須先了解什麼是注水和脫水,所謂脫水,就是服務器在構建 HTML 以前處理一些預請求,而且把數據注入html中返回給瀏覽器。而注水就是瀏覽器把這些數據當初始數據來初始化組件,以完成服務端與瀏覽器端數據的統一。

組件配置

在組件內部定義一個靜態方法

class Home extends React.Component {
...
  public static init(store:any) {
    return store.dispatch.Home.incrementAsync(5);
  }
  componentDidMount() {
    const { incrementAsync }:any = this.props;
    incrementAsync(5);
  }
  render() {
  ...
  }
}

const mapStateToProps = (state:any) => {
  return {
    count: state.Home.count
  };
};

const mapDispatchToProps = (dispatch:any) => ({
  incrementAsync: dispatch.Home.incrementAsync
});
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Home);

複製代碼

因爲我這邊使用的是rematch,因此咱們的方法都寫在model中。

const Home: ModelConfig= {
  state: {
    count: 1
  }, 
  reducers: {
    increment(state, payload) {
      return {
        count: payload
      };
    }
  },
  effects: {
    async incrementAsync(payload, rootState) {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      this.increment(payload);
    }
  }
};
export default Home;
複製代碼

而後經過根 store 中進行 init。

import { init } from '@rematch/core';
import models from './models';

const store = init({
  models: {...models}
});

export default store;
複製代碼

而後能夠綁定在咱們 redux 的 Provider 中。

<Provider store = {store}>
    <Router>
      <AppRoutes
        context={context}
        initialData={this.initialData}
      />
    </Router>
</Provider>
複製代碼

路由方面咱們須要把組件的 init 方法綁定在路由上方便服務端請求數據時使用。

<Switch>
      {
        routes.map((d: any) => (
          <Route<InitRoute>
            key={d.path}
            exact={d.exact}
            path={d.path}
            init={d.init || ''}
            component={d.component}
          />
        ))
      }
      <Route path='/' component={Home} />
    </Switch>
複製代碼

以上就是客戶端須要進行的操做了,由於 SSR 中咱們服務端也須要進行數據的操做,因此爲了解耦,咱們就新建另外一個 ServiceStore 來提供服務端使用。

在服務端構建 Html 前,咱們必須先執行完當前組件的 init 方法。

import { matchRoutes } from 'react-router-config';
// 用matchRoutes方法獲取匹配到的路由對應的組件數組
const matchedRoutes = matchRoutes(routes, url);
const promises = [];
for (const item of matchedRoutes) {
  if (item.route.init) {
    const promise = new Promise((resolve, reject) => {
      item.route.init(serverStore).then(resolve).catch(resolve);
    });
    promises.push(promise);
  }
}
return Promise.all(promises);
複製代碼

注意咱們新建一個 Promise 的數組來放置 init 方法,由於一個頁面多是由多個組件組成的,咱們必須等待全部的 init 執行完畢後才執行相應的 html 構建。

如今能夠獲得的數據掛在 window 下,等待客戶端的讀取了。

let ret = `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
        </head>
        <body>
          <div id="app">${html}</div>
          <script type="text/javascript">window.__INITIAL_STORE__ = ${JSON.stringify(
            extra.initialStore || {}
          )}</script>
        </body>
      </html>
    `;
複製代碼

而後在咱們的客戶端中讀取剛剛的 initialStore 數據

....
const defaultStore = window.__INITIAL_STORE__ || {};
const store = init({
  models,
  redux: {
    initialState: defaultStore
  }
});

export default store;
複製代碼

至此,redux的同構基本完成了,由於邊幅的限定,我就沒有貼太多代碼,你們能夠到文章底部的點擊個人倉庫看看具體代碼哈,而後我再說說幾個 redux 同構中比較坑的地方。

1.使用不了 @loadable/component 異步組件加載,由於不能獲取組件內部方法。 解決的辦法就是在預請求咱們不放在組件中,直接拆分出來寫在一個文件中統一管理,但我嫌這樣很差管理就放棄了異步加載組件了。

2.在客戶端渲染的時候若是數據一閃而過,那就是初始化數據並無成功,當時這裏卡了我很久喔。

css樣式直出

首先,服務端渲染的時候,解析 css 文件,不能使用 style-loader 了,要使用 isomorphic-style-loader 。使用 style-loader 的時候會有一閃而過的現象,是由於瀏覽器是須要加載完 css 才能把樣式加上。爲了解決這樣的問題,咱們能夠經過isomorphic-style-loader 在組件加載的時候把 css 放置在全局的 context 裏面,而後在服務端渲染時候提取出來,插入到返回的HTML中的 style 標籤。

組件的改造

import withStyles from 'isomorphic-style-loader/withStyles';

@withStyles(style)
class Home extends React.Component {
...
  render() {
    const {count}:any = this.props;
    return (
    ...
    );
  }
}
const mapStateToProps = (state:any) => {
  return {
    count: state.Home.count
  };
};

const mapDispatchToProps = (dispatch:any) => ({
  incrementAsync: dispatch.Home.incrementAsync
});
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Home);
複製代碼

withStyle 是一個柯里化函數,返回的是一個新的組件,並不影響 connect 函數,固然你也能夠像 connect 同樣的寫法。withStyle 主要是爲了把 style 插入到全局的 context 裏面。

根組件的修改

import StyleContext from 'isomorphic-style-loader/StyleContext';

const insertCss = (...styles:any) => {
  const removeCss = styles.map((style:any) => style._insertCss());
  return () => removeCss.forEach((dispose:any) => dispose());
};

ReactDOM.hydrate(
    <StyleContext.Provider value={{ insertCss }}>
        <AppError>
          <Component />
        </AppError>
    </StyleContext.Provider>,
    elRoot
);
複製代碼

這一部分主要是引入了 StyleContext 初始化根部的context,而且定義好一個 insertCss 方法,在組件 withStyle 中觸發。

部分 isomorphic-style-loader 源碼

...
function WithStyles(props, context) {
    var _this;
    _this = _React$PureComponent.call(this, props, context) || this;
    _this.removeCss = context.insertCss.apply(context, styles);
    return _this;
 }

  var _proto = WithStyles.prototype;

  _proto.componentWillUnmount = function componentWillUnmount() {
    if (this.removeCss) {
      setTimeout(this.removeCss, 0);
    }
  };

  _proto.render = function render() {
    return React.createElement(ComposedComponent, this.props);
  };
 ...
複製代碼

能夠看到 context 中的 insert 方法就是根組件中的 定義好的 insert 方法,而且在 componentWillUnmount 這個銷燬的生命週期中把以前 style 清除掉。而 insert 方法主要是爲了給當前的 style 定義好id而且嵌入,這裏就不展開說明了,有興趣的能夠看一下源碼。

服務端中獲取定義好的css

const css = new Set(); // CSS for all rendered React components

const insertCss = (...styles :any) => {
  return styles.forEach((style:any) => css.add(style._getCss()));
};

const extractor = new ChunkExtractor({ statsFile: this.statsFile });

const jsx = extractor.collectChunks(
  <StyleContext.Provider value={{ insertCss }}>
    <Provider store={serverStore}>
        <StaticRouter location={url} context={routerContext}>
          <AppRoutes context={defaultContext} initialData={data} />
        </StaticRouter>
    </Provider>
  </StyleContext.Provider>
);

const html = ReactDOMServer.renderToString(jsx);
const cssString = Array.from(css).join('');
...
複製代碼

其中 cssString 就是咱們最後獲取到的 css 內容,咱們能夠像 html 替換同樣把 css 嵌入到 html 中。

let ret = `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          ...
          <style>${extra.cssString}</style>
        </head>
        <body>
          <div id="app">${html}</div>
          ...
        </body>
      </html>
    `;
複製代碼

那這樣就大功告成啦!!!!

我來講一下在作這個的時候遇到的坑

1.不能使用分離 css 的插件 mini-css-extract-plugin,由於分離 css 和把 css 放置到 style 中會有衝突,引入github大神的一句話

With isomorphic-style-loader the idea was to always include css into js files but render into dom only critical css and also make this solution universal (works the same on client and server side). If you want to extract css into separate files you probably need to find another way how to generate critical css rather than use isomorphic-style-loader.

2.不少文章說到在 service 端的打包中不須要打包 css,那是由於他們使用的是style-loader 的狀況,咱們若是使用 isomorphic-style-loader, 咱們也須要把 css 打包一下,由於咱們在服務端中畢竟要觸發 withStyle。

總結

由於代碼太多了,因此只是展現了整個 SSR 流程的思想,詳細代碼能夠查看倉庫。還有但願大牛們指導一下個人錯誤,萬分感謝!!

相關文章
相關標籤/搜索