React + Koa 實現服務端渲染(SSR)

⚛️React是目前前端社區最流行的UI庫之一,它的基於組件化的開發方式極大地提高了前端開發體驗,React經過拆分一個大的應用至一個個小的組件,來使得咱們的代碼更加的可被重用,以及得到更好的可維護性,等等還有其餘不少的優勢...javascript

Part II 版本 傳送門css

經過React, 咱們一般會開發一個單頁應用(SPA),單頁應用在瀏覽器端會比傳統的網頁有更好的用戶體驗,瀏覽器通常會拿到一個body爲空的html,而後加載script指定的js, 當全部js加載完畢後,開始執行js, 最後再渲染到dom中, 在這個過程當中,通常用戶只能等待,什麼都作不了,若是用戶在一個高速的網絡中,高配置的設備中,以上先要加載全部的js而後再執行的過程可能不是什麼大問題,可是有不少狀況是咱們的網速通常,設備也可能不是最好的,在這種狀況下的單頁應用可能對用戶來講是個不好的用戶體驗,用戶可能還沒體驗到瀏覽器端SPA的好處時,就已經離開網站了,這樣的話你的網站作的再好也不會有太多的瀏覽量。html

可是咱們總不能回到之前的一個頁面一個頁面的傳統開發吧,現代化的UI庫都提供了服務端渲染(SSR)的功能,使得咱們開發的SPA應用也能完美的運行在服務端,大大加快了首屏渲染的時間,這樣的話用戶既能更快的看到網頁的內容,與此同時,瀏覽器同時加載須要的js,加載完後把全部的dom事件,及各類交互添加到頁面中,最後仍是以一個SPA的形式運行,這樣的話咱們既提高了首屏渲染的時間,又能得到SPA的客戶端用戶體驗,對於SEO也是個必須的功能😀。前端

OK,咱們大體瞭解了SSR的必要性,下面咱們就能夠在一個React App中來實現服務端渲染的功能,BTW, 既然咱們已經處在一個處處是async/await的環境中,這裏的服務端咱們使用koa2來實現咱們的服務端渲染。java

初始化一個普通的單頁應用SPA

首先咱們先無論服務端渲染的東西,咱們先建立一個基於React和React-Router的SPA,等咱們把一個完整的SPA建立好後,再加入SSR的功能來最大化提高app的性能。node

首先進入app入口 App.js:react

import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';

const Home = () => <div>Home</div>;
const Hello = () => <div>Hello</div>;

const App = () => {
  return (
    <Router>
      <Route exact path="/" component={Home} />
      <Route exact path="/hello" component={Hello} />
    </Router>
  )
}

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

上面咱們爲路由//hello建立了2個只是渲染一些文字到頁面的組件。但當咱們的項目變得愈來愈大,組件愈來愈多,最終咱們打包出來的js可能會變得很大,甚至變得不可控,因此呢咱們第一步須要優化的是代碼拆分(code-splitting),幸運的是經過webpack dynamic importreact-loadable,咱們能夠很容易作到這一點。webpack

用React-Loadable來時間代碼拆分

使用以前,先安裝 react-loadable:git

npm install react-loadable
# or
yarn add react-loadable
複製代碼

而後在你的 javascript中:github

//...
import Loadable from 'react-loadable';
//...

const AsyncHello = Loadable({
  loading: <div>loading...</div>,
  //把你的Hello組件寫到單獨的文件中
  //而後使用webpack的 dynamic import
  loader: () => import('./Hello'), 
})

//而後在你的路由中使用loadable包裝過的組件:
<Route exact path="/hello" component={AsyncHello} />
複製代碼

很簡單吧,咱們只須要import react-loadable, 而後傳一些option進去就好了,其中的loading選項是當動態加載Hello組件所需的js時,渲染loading組件,給用戶一種加載中的感受,體驗也會比什麼都不加好。

好了,如今若是咱們訪問首頁的話,只有Home組件依賴的js纔會被加載,而後點擊某個連接進入hello頁面的話,會先渲染loading組件,並同時異步加載hello組件依賴的js,加載完後,替換掉loading來渲染hello組件。經過基於路由拆分代碼到不一樣的代碼塊,咱們的SPA已經有了很大的優化,cheers🍻。更叼的是react-loadable一樣支持SSR,因此你能夠在任意地方使用react-loadable,不論是運行在前端仍是服務端,要讓react-loadable在服務端正常運行的話咱們須要作一些額外的配置,本文後面會講到,先不急🏃。‍

到這裏咱們已經建立好一個基本的React SPA,加上代碼拆分,咱們的app已經有了不錯的性能,可是咱們還能夠更加極致的優化app的性能,下面咱們經過增長SSR的功能來進一步提高加載速度,順便解決一下SPA中的SEO問題🎉。

加入服務端渲染(SSR)功能

首先咱們先搭建一個最簡單的koa web服務器:

npm install koa koa-router
複製代碼

而後在koa的入口文件app.js中:

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();
router.get('*', async (ctx) => {
  ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>React SSR</title> </head> <body> <div id="app"></div> <script type="text/javascript" src="/bundle.js"></script> </body> </html> `;
});

app.use(router.routes());
app.listen(3000, '0.0.0.0');
複製代碼

上面*路由表明任意的url進來咱們都默認渲染這個html,包括html中打包出來的js,你也能夠用一些服務端模板引擎(如:nunjucks)來直接渲染html文件,在webpack打包時經過html-webpack-plugin來自動插入打包出來的js/css資源路徑。

OK, 咱們的簡易koa server好了,接下來咱們開始編寫React SSR的入口文件AppSSR.js,這裏咱們須要使用StaticRouter來代替以前的BrowserRouter,由於在服務端,路由是靜態的,用BrowserRouter的話是不起做用的,後面還會作一些配置來使得react-loadable運行在服務端。

提示: 你能夠把整個node端的代碼用ES6/JSX風格編寫,而不是部分commonjs,部分JSX, 但這樣的話你須要用webpack把整個服務端的代碼編譯成commonjs風格,才能使得它運行在node環境中,這裏的話咱們把React SSR的代碼單獨抽出去,而後在普通的node代碼裏去require它。由於可能在一個現有的項目中,以前都是commonjs的風格,把之前的node代碼一次性轉成ES6的話成本有點大,可是能夠後期一步步的再遷移過去

OK, 如今在你的 AppSSR.js中:

import React from 'react';
//使用靜態 static router
import { StaticRouter } from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import Loadable from 'react-loadable';
//下面這個是須要讓react-loadable在服務端可運行須要的,下面會講到
import { getBundles } from 'react-loadable/webpack';
import stats from '../build/react-loadable.json';

//這裏吧react-router的路由設置抽出去,使得在瀏覽器跟服務端能夠共用
//下面也會講到...
import AppRoutes from 'src/AppRoutes';

//這裏咱們建立一個簡單的class,暴露一些方法出去,而後在koa路由裏去調用來實現服務端渲染
class SSR {
  //koa 路由裏會調用這個方法
  render(url, data) {
    let modules = [];
    const context = {};
    const html = ReactDOMServer.renderToString(
      <Loadable.Capture report={moduleName => modules.push(moduleName)}>
        <StaticRouter location={url} context={context}>
          <AppRoutes initialData={data} />
        </StaticRouter>
      </Loadable.Capture>
    );
    //獲取服務端已經渲染好的組件數組
    let bundles = getBundles(stats, modules);
    return {
      html,
      scripts: this.generateBundleScripts(bundles),
    };
  }
  //把SSR過的組件都轉成script標籤扔到html裏
  generateBundleScripts(bundles) {
    return bundles.filter(bundle => bundle.file.endsWith('.js')).map(bundle => {
      return `<script type="text/javascript" src="${bundle.file}"></script>\n`;
    });
  }

  static preloadAll() {
    return Loadable.preloadAll();
  }
}

export default SSR;
複製代碼

當編譯這個文件的時候,在webpack配置裏使用target: "node"externals,而且在你的打包前端app的webpack配置中,須要加入react-loadable的插件,app的打包須要在ssr打包以前運行,否則拿不到react-loadable須要的各組件信息,先來看app的打包:

//webpack.config.dev.js, app bundle
const ReactLoadablePlugin = require('react-loadable/webpack')
  .ReactLoadablePlugin;

module.exports = {
  //...
  plugins: [
    //...
    new ReactLoadablePlugin({ filename: './build/react-loadable.json', }),
  ]
}
複製代碼

.babelrc中加入loadable plugin:

{
  "plugins": [
      "syntax-dynamic-import",
      "react-loadable/babel",
      ["import-inspector", {
        "serverSideRequirePath": true
      }]
    ]
}
複製代碼

上面的配置會讓react-loadable知道哪些組件最終在服務端被渲染了,而後直接插入到html script標籤中,並在前端初始化時把SSR過的組件考慮在內,避免重複加載,下面是SSR的打包:

//webpack.ssr.js
const nodeExternals = require('webpack-node-externals');

module.exports = {
  //...
  target: 'node',
  output: {
    path: 'build/node',
    filename: 'ssr.js',
    libraryExport: 'default',
    libraryTarget: 'commonjs2',
  },
  //避免把node_modules裏的庫都打包進去,此ssr js會直接運行在node端,
  //因此不須要打包進最終的文件中,運行時會自動從node_modules里加載
  externals: [nodeExternals()],
  //...
}
複製代碼

而後在koa app.js, require它,而且調用SSR的方法:

//...koa app.js
//build出來的ssr.js
const SSR = require('./build/node/ssr');
//preload all components on server side, 服務端沒有動態加載各個組件,提早先加載好
SSR.preloadAll();

//實例化一個SSR對象
const s = new SSR();

router.get('*', async (ctx) => {
  //根據路由,渲染不一樣的頁面組件
  const rendered = s.render(ctx.url);
  
  const html = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <div id="app">${rendered.html}</div> <script type="text/javascript" src="/runtime.js"></script> ${rendered.scripts.join()} <script type="text/javascript" src="/app.js"></script> </body> </html> `;
  ctx.body = html;
});
//...
複製代碼

以上是個簡單的實現React SSR到koa web server, 爲了使react-loadable知道哪些組件在服務端渲染了,rendered裏面的scripts數組裏麪包含了SSR過的組件組成的各個script標籤,裏面調用了SSR#generateBundleScripts()方法,在插入時須要確保這些script標籤在runtime.js以後((經過 CommonsChunkPlugin 來抽出來)),而且在app bundle以前(也就是初始化的時候應該已經知道以前的哪些組件已經渲染過了)。更多react-loadable服務端支持,參考這裏.

上面咱們還把react-router的路由都單獨抽出去了,使得它能夠運行在瀏覽器跟服務端,如下是AppRoutes組件:

//AppRoutes.js
import Loadable from 'react-loadable';
//...

const AsyncHello = Loadable({
  loading: <div>loading...</div>,
  loader: () => import('./Hello'), 
})

function AppRoutes(props) {
  <Switch>
    <Route exact path="/hello" component={AsyncHello} /> <Route path="/" component={Home} /> </Switch> } export default AppRoutes //而後在 App.js 入口中 import AppRoutes from './AppRoutes'; // ... export default () => { return ( <Router> <AppRoutes/> </Router> ) } 複製代碼

服務端渲染的初始狀態

目前爲止,咱們已經建立了一個React SPA,而且能在瀏覽器端跟服務端共同運行🍺,社區稱之爲universal app 或者 isomophic app。可是咱們如今的app還有一個遺留問題,通常來講咱們app的數據或者狀態都須要經過遠端的api來異步獲取,拿到數據後咱們才能開始渲染組件,服務端SSR也是同樣,咱們要動態的獲取初始數據,而後才能扔給React去作SSR,而後在瀏覽器端咱們還要初始化就能同步獲取這些SSR時的初始化數據,避免瀏覽器端初始化時又從新獲取了一遍。

下面咱們簡單從github獲取一些項目的信息做爲頁面初始化的數據, 在koa的app.js中:

//...
const fetch = require('isomorphic-fetch');

router.get('*', async (ctx) => {
  //fetch branch info from github
  const api = 'https://api.github.com/repos/jasonboy/wechat-jssdk/branches';
  const data = await fetch(api).then(res => res.json());
  
  //傳入初始化數據
  const rendered = s.render(ctx.url, data);
  
  const html = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <div id="app">${rendered.html}</div> <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script> <script type="text/javascript" src="/runtime.js"></script> ${rendered.scripts.join()} <script type="text/javascript" src="/app.js"></script> </body> </html> `;
  ctx.body = html;
});
複製代碼

而後在你的Hello組件中,你須要checkwindow裏面(或者在App入口中統一判斷,而後經過props傳到子組件中)是否存在window.__INITIAL_DATA__,有的話直接用來當作初始數據,沒有的話咱們在componentDidMount生命週期函數中再去來數據:

export default class Hello extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      //這裏直接判斷window,若是是父組件傳入的話,經過props判斷
      github: window.__INITIAL_DATA__ || [],
    };
  }
  
  componentDidMount() {
    //判斷沒有數據的話,再去請求數據
    //請求數據的方法也能夠抽出去,以讓瀏覽器及服務端能統一調用,避免重複寫
    if (this.state.github.length <= 0) {
      fetch('https://api.github.com/repos/jasonboy/wechat-jssdk/branches')
        .then(res => res.json())
        .then(data => {
          this.setState({ github: data });
        });
    }
  }
  
  render() {
    return (
      <div> <ul> {this.state.github.map(b => { return <li key={b.name}>{b.name}</li>; })} </ul> </div>
    );
  }
}
複製代碼

好了,如今若是頁面被服務端渲染過的話,瀏覽器會拿到全部渲染過的html, 包括初始化數據,而後經過這些SSR的內容配合加載的js,再組成一個完整的SPA,就像一個普通的SPA同樣,可是咱們獲得了更好的性能,更好的SEO😎。

🎉React-v16 更新

在React的最新版v16中,SSR的API作了不少的優化,而且提供了新的基於流的API來更好的提高性能,經過streaming api, 服務端能夠邊渲染邊把前面渲染好的html發到瀏覽器,瀏覽器端也能夠提早開始渲染頁面而不是等服務端全部組件都渲染完成後才能開始瀏覽器端的初始化,提高了性能也下降了服務端資源的消耗。還有一個在瀏覽器端須要注意的是須要使用ReactDOM.hydrate()來代替以前的ReactDOM.render(),更多的更新參考medium文章whats-new-with-server-side-rendering-in-react-16.

💖要查看完整的demo, 參考 koa-web-kit, koa-web-kit是一個現代化的基於React/Koa的全棧開發框架,包括React SSR支持,能夠直接用來測試服務端渲染的功能😀

結論

好了,以上就是React-SSR + Koa的簡單實踐,經過SSR,咱們既提高了性能,又很好的知足了SEO的要求,Best of the Both Worlds🍺。

PPT in Browser

English Version

相關文章
相關標籤/搜索