React服務端渲染與同構實踐

本文做者:IMWeb IMWeb團隊 原文出處:IMWeb社區 未經贊成,禁止轉載javascript

前兩年服務端渲染和同構的概念火遍了整個前端界,幾乎全部關於前端的分享會議都有提到。在這年頭,不管你選擇什麼技術棧,不會作個服務端渲染可能真的快混不下去了!最近恰好實現了個基於 React&Redux 的同構直出應用,趕忙寫個文章總結總結壓壓驚。css

前言

在瞭解實踐過程以前,讓咱們先明白幾個概念(非新手可直接跳過)。html

什麼是服務端渲染(Server-Side Rendering)

服務端渲染,又能夠叫作後端渲染或直出。前端

早些年前,大部分網站都使用傳統的 MVC 架構進行後端渲染,就是實現一個 Controller,處理請求時在服務端拉取到數據 Model,使用模版引擎結合 View 渲染出頁面,好比 Java + Velocity、PHP 等。但隨着前端腳本 JS 的發展,擁有更強大的交互能力後,先後端分離的概念被提出,也就是拉取數據和渲染的操做由前端來完成。java

關於前端渲染仍是後端渲染之爭,能夠看文章後面的參考連接,這裏不作討論。這裏照搬後端渲染的優點:node

  • 更好的首屏性能,不須要提早先下載一堆 CSS 和 JS 後纔看到頁面
  • 更利於 SEO,蜘蛛能夠直接抓取已渲染的內容

什麼是同構應用(Isomorphic)

同構,在本文特指服務端和客戶端的同構,意思是服務端和客戶端均可以運行的同一套代碼程序。react

SSR 同構也是在 Node 這門服務端語言興起後,使得 JS 能夠同時運行在服務端和瀏覽器,使得同構的價值大大提高:webpack

  • 提升代碼複用率
  • 提升代碼可維護性

基於 React&Redux 的考慮

其實 Vue 和 React 都提供了 SSR 相關的能力,在決定在作以前咱們考慮了一下使用哪一種技術棧,之因此決定使用 React 是由於對於團隊來講,統一技術棧在可維護性上顯得比較重要:nginx

  • 已有一套基於 React 的 UI
  • 已有基於 React&Redux 的腳手架
  • 已在 React 直出上有必定的實踐經驗(僅限於組件同構,Controller 並不通用)

React 提供了一套將 Virtual DOM 輸出爲 HTML 文本的APIgit

Redux 提供了一套將 reducers 同構複用的解決方案

方案與實踐

首先先用腳手架生成了基於 React&Redux 的異步工程目錄:

- dist/ # 構建結果
	- xxx.html
	- xxx_[md5].js
	- xxx_[md5].css
- src/ # 源碼入口
	- assets/
		- css/ # 全局CSS
		- template.html # 頁面模版
	- pages/ # 頁面源碼目錄
		- actions.js # 全局actions
		- reducers.js # 全局reducers
		- xxx/ # 頁面名稱目錄
			- components/ # 頁面級別組件
			- index.jsx # 頁面主入口
			- reducers.js # 頁面reducers
			- actions.js # 頁面actions
	- components/ # 全局級別組件
- webpack.config.js
- package.json
- ...
複製代碼

能夠看到,現有的異步工程,構建會使用web-webpack-plugin將全部src/pages/xxx/index.js當作入口爲每一個頁面編譯出異步 html、js 和 css 文件。

1. 添加 Node Server

既然要作直出,首先須要一個 Web Server 吧,可使用Koa,這裏咱們採用了團隊自研基於KoaIMServer(做者是開源工具whistle的做者,用過whistle的我表示已經離不開它了),Server 工程目錄以下:

- server/
	- app/
		- controller/ # controllers
			- indexReact.js # 通用React直出Controller
		- middleware/ # 中間件
		- router.js   # 路由設置
	- config/
		- config.js # 項目配置
	- lib/ # 內部依賴庫
	- dispatch.js # 啓動入口
	- package.json
	- ...
複製代碼

因爲是一個多頁面應用(非 SPA),上文提到以前團隊的實踐中 Controller 邏輯並非通用的,也就是說只要業務需求新增一個頁面那麼就得手寫多一個 Controller,並且這些 Controllers 都存在共性邏輯,每一個請求過來都要經歷:

  1. 根據頁面 reducer 建立 store
  2. 拉取首屏數據
  3. 渲染結果
  4. ...(其餘自定義鉤子)

那咱們爲何不實現一個通用的 Controller 將這些邏輯都同構了呢:

// server/app/controller/indexReact.js
const react = require('react');
const { renderToString } = require('react-dom/server');
const { createStore, applyMiddleware } = require('redux');
const thunkMiddleware = require('redux-thunk').default;
const { Provider } = require('react-redux');

async function process(ctx) {
  // 建立store
  const store = createStore(
    reducer /* 1.同構的reducer */,
    undefined,
    applyMiddleware(thunkMiddleware)
  );

  // 拉取首屏數據
  /* 2.同構的component靜態方法getPreloadState */
  const preloadedState = await component.getPreloadState(store).then(() => {
    return store.getState();
  });

  // 渲染html
  /* 2.同構的component靜態方法getHeadFragment */
  const headEl = component.getHeadFragment(store);
  const contentEl = react.createElement(
    Provider,
    { store },
    react.createElement(component)
  );
  ctx.type = 'html';
  /* 3.基於頁面html編譯的模版函數template */
  ctx.body = template({
    preloadedState,
    head: renderToString(headEl),
    html: renderToString(contentEl)
  });
}

module.exports = process;
複製代碼

上述代碼至關於將處理過程鉤子化了,只要同構代碼提供相應的鉤子便可。

固然,還得根據頁面生成相應的路由:

// server/app/router.js
const config = require('../config/config');
const indexReact = require('./controler/indexReact');

module.exports = app => {
  // 須要直出頁面路由配置
  const { routes } = config;

  // IMServer會調用此方法,傳入koa-router實例
  return router => {
    Object.entries(routes).forEach(([name, v]) => {
      const { pattern } = v;

      router.get(
        name, // 目錄名稱xxx
        pattern, // 目錄路由配置,好比'/course/:id'
        indexReact
      );
    });
  };
};
複製代碼

至此服務端代碼已基本完成。

2. 同構構建打通

上一步服務端代碼依賴了幾份同構代碼。

  • 頁面數據純函數 reducer.js
  • 頁面組件主入口 component.js
  • 基於web-webpack-plugin生成的頁面 xxx.html 再編譯的模版函數 template

我選擇了經過構建編譯出這些文件,而不是在服務端引入babel-register來直接引入前端代碼,是由於我想保留更高的自由度,即構建能夠作更多babel-register作不了的事情。

// webpack-ssr.config.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const write = require('write');
const webpack = require('webpack');
const FilterPlugin = require('filter-chunk-webpack-plugin');
const { rootDir, serverDir, resolve } = require('./webpack-common.config');
const ssrConf = require('./server/ssr.config');

const { IgnorePlugin } = webpack;

const componentsEntry = {};
const reducersEntry = {};
glob.sync('src/pages/*/').forEach(dirpath => {
  const dirname = path.basename(dirpath);
  const options = { realpath: true };
  componentsEntry[dirname] = glob.sync(
    `${dirpath}/isomorph.{tsx,ts,jsx,js}`,
    options
  )[0];
  reducersEntry[dirname] = glob.sync(
    `${dirpath}/reducers.{tsx,ts,jsx,js}`,
    options
  )[0];
});
const ssrOutputConfig = (o, dirname) => {
  return Object.assign({}, o, {
    path: path.resolve(serverDir, dirname),
    filename: '[name].js',
    libraryTarget: 'commonjs2'
  });
};
const ssrExternals = [/assets\/lib/];
const ssrModuleConfig = {
  rules: [
    {
      test: /\.(css|scss)$/,
      loader: 'ignore-loader'
    },
    {
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')
      ]
    },
    {
      test: /\.(gif|png|jpe?g|eot|woff|ttf|pdf)$/,
      loader: 'file-loader'
    }
  ]
};

const ssrPages = Object.entries(ssrConf.routes).map(([pagename]) => {
  return `${pagename}.js`;
});

const ssrPlugins = [
  new IgnorePlugin(/^\.\/locale$/, /moment$/),
  new FilterPlugin({
    select: true,
    patterns: ssrPages
  })
];

const ssrTemplatesDeployer = assets => {
  Object.entries(assets).forEach(([name, asset]) => {
    const { source } = asset;

    // ssr template
    if (/.html$/.test(name)) {
      const content = source()
        // eslint-disable-next-line
        .replace(/(<head[^>]*>)/, '$1${head}')
        .replace(
          /(<\/head>)/,
          // eslint-disable-next-line
          "<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}</script>$1"
        )
        .replace(/(<div[^>]*id="react-body"[^>]*>)/, '$1${html}'); // eslint-disable-line

      write.sync(path.join(serverDir, 'templates', name), content);
    }
  });
};
const devtool = 'source-map';

function getSSRConfigs(options) {
  const { mode, output } = options;

  return [
    {
      mode,
      entry: componentsEntry,
      output: ssrOutputConfig(output, 'components'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    },
    {
      mode,
      entry: reducersEntry,
      output: ssrOutputConfig(output, 'reducers'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    }
  ];
}

module.exports = {
  ssrTemplatesDeployer,
  getSSRConfigs
};
複製代碼

上述代碼將 Controller 須要的同構模塊和文件打包到了 server/目錄下:

src/
	- pages/
		- xxx
			- template.html # 頁面模版
			- reducers.js # 頁面reducer入口
			- isomorph.jsx # 頁面服務端主入口
server/
	- components/
		- xxx.js
	- reducers/
		- xxx.js
	- templates
		- xxx.html # 在Node讀取並編譯成模版函數便可
複製代碼

webpack-ssr

3. 實現同構鉤子

還須要在同構模塊中實現通用 Controller 約定。

// src/pages/xxx/isomorph.tsx
import * as React from 'react';
import { bindActionCreators, Store } from 'redux';
import * as actions from './actions';
import { AppState } from './reducers';
import Container, { getCourceId } from './components/Container';

Object.assign(Container, {
  getPreloadState(store: Store<AppState>) {
    type ActionCreatorsMap = {
      fetchCourseInfo: (x: actions.CourseInfoParams) => Promise<any>;
    };

    const cid = getCourceId();
    const { fetchCourseInfo } = bindActionCreators<{}, ActionCreatorsMap>(actions, store.dispatch);

    return fetchCourseInfo({ course_id: cid })
  },

  getHeadFragment(store: Store<AppState>) {
    const cid = getCourceId();
    const { courseInfo } = store.getState();
    const { name, summary, agency_name: agencyName } = courseInfo.data;
    const keywords = ['騰訊課堂', name, agencyName].join(',');
    const canonical = `//ke.qq.com/course/${cid}`;

    return (
      <>
        <title>{name}</title>
        <meta name="keywords" content={keywords} />
        <meta name="description" itemProp="description" content={summary} />
        <link rel="canonical" href={canonical} />
      </>
    );
  },
});

export default Container;

複製代碼

至此同構已基本打通。

4. 異步入口&容災

剩下來就好辦了,在異步 JS 入口中使用ReactDOM.hydrate

// src/pages/xxx/index.tsx
import * as React from 'react';
import { hydrate } from 'react-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { Provider } from 'react-redux';
import reducers from './reducers';
import Container from './components/Container';
import './index.css';

let store;
const preloadState = window.__PRELOADED_STATE__;

if (process.env.NODE_ENV === 'production') {
  store = createStore(reducers, preloadState, applyMiddleware(thunkMiddleware));
} else {
  store = createStore(
    reducers,
    preloadState,
    compose(
      applyMiddleware(thunkMiddleware),
      window.devToolsExtension ? window.devToolsExtension() : (f: any) => f
    )
  );
}

hydrate(
  <Provider store={store}> <Container /> </Provider>,
  window.document.getElementById('react-body')
);
複製代碼

hydrate() Same as render(), but is used to hydrate a container whose HTML contents were rendered by ReactDOMServer. React will attempt to attach event listeners to the existing markup.

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them.

容災是指當服務端由於某些緣由掛掉的時候,因爲咱們還有構建生成 xxx.html 異步頁面,能夠在 nginx 層上作一個容災方案,當上層 Svr 出現錯誤時,降級異步頁面。

踩坑

  • 沒法同構的業務邏輯

像由於生命週期的不一樣要在componentDidMount綁定事件,不能在服務端能執行到的地方訪問 DOM API 這些你們都應該很清楚了,其實大概只須要實現最主要幾個同構的基礎模塊便可:

  1. 訪問 location 模塊
  2. 訪問 cookie 模塊
  3. 訪問 userAgent 模塊
  4. request 請求模塊
  5. localStorage、window.name 這種只能降級處理的模塊(儘可能避免在首屏邏輯使用到它們)

固然我要說的還有一些依賴客戶端能力的模塊,好比 wx 的 sdk,qq 的 sdk 等等。

這裏稍微要提一下的是,我最初設計的時候想盡量不破壞團隊現有的編碼習慣,像 location、cookie 之類的這些模塊方法在每次請求過來的時候,拿到的值應該是不同的,如何實現這一點是參考 TSW 的作法:tswjs.org/doc/api/glo…,Node 的domain 模塊使得這類設計成爲可能。

可是依舊要避免模塊局部變量的寫法(有關這部份內容,我另寫了一篇文章可作參考

  • 使用ignore-loader忽略掉依賴的 css 文件
  • core-js包致使內存泄漏
{
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      // 幹掉babel-runtime,其依賴core-js源碼對global['__core-js_shared__']操做引發內存泄漏
      options: {
        babelrc: false,
        presets: [
          ['env', {
            targets: {
              node: true
            }
          }],
          'stage-2',
          'react'
        ],
        plugins: ['syntax-dynamic-import']
      },
      include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')
      ]
    }

複製代碼

這部分 core-js 的上的 issue 也有說明爲何要這麼作:

github.com/babel/babel…

其實在 node 上 es6 的特性是都支持了的,打包出的同構模塊須要儘量的精簡。

後續思考

  • 能夠看齊 Nextjs

這整個設計其實把構建能力抽象出來,鉤子可配置化後,就能夠成爲一個直出框架了。固然也能夠像 Nextjs 那樣實現一些 Document 等組件來使用。

  • 發佈的不便利性

當前設計因爲 Server 的代碼依賴了構建出來的同構模塊,在平常開發中,前端作一些頁面修改是常常發生的事,好比修改一些事件監聽,而這時候由於 js, css 資源 MD5 值的變化,致使 template.html 變化,故而致使 server 包須要發佈,若是業務有有多節點,都要一一無損重啓。確定是有辦法作到發佈代碼而不用重啓 Node 服務的。

  • 性能問題(TODO)

以上就是本文的全部內容,請多多指教,歡迎交流(文中代碼基本都是通過刪減的)~

參考資料:

相關文章
相關標籤/搜索