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

jasonboy7發表於2019-03-04

⚛️React是目前前端社群最流行的UI庫之一,它的基於元件化的開發方式極大地提升了前端開發體驗,React通過拆分一個大的應用至一個個小的元件,來使得我們的程式碼更加的可被重用,以及獲得更好的可維護性,等等還有其他很多的優點…

Part II 版本 傳送門

通過React, 我們通常會開發一個單頁應用(SPA),單頁應用在瀏覽器端會比傳統的網頁有更好的使用者體驗,瀏覽器一般會拿到一個body為空的html,然後載入script指定的js, 當所有js載入完畢後,開始執行js, 最後再渲染到dom中, 在這個過程中,一般使用者只能等待,什麼都做不了,如果使用者在一個高速的網路中,高配置的裝置中,以上先要載入所有的js然後再執行的過程可能不是什麼大問題,但是有很多情況是我們的網速一般,裝置也可能不是最好的,在這種情況下的單頁應用可能對使用者來說是個很差的使用者體驗,使用者可能還沒體驗到瀏覽器端SPA的好處時,就已經離開網站了,這樣的話你的網站做的再好也不會有太多的瀏覽量。

但是我們總不能回到以前的一個頁面一個頁面的傳統開發吧,現代化的UI庫都提供了服務端渲染(SSR)的功能,使得我們開發的SPA應用也能完美的執行在服務端,大大加快了首屏渲染的時間,這樣的話使用者既能更快的看到網頁的內容,與此同時,瀏覽器同時載入需要的js,載入完後把所有的dom事件,及各種互動新增到頁面中,最後還是以一個SPA的形式執行,這樣的話我們既提升了首屏渲染的時間,又能獲得SPA的客戶端使用者體驗,對於SEO也是個必須的功能?。

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

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

首先我們先不管服務端渲染的東西,我們先建立一個基於React和React-Router的SPA,等我們把一個完整的SPA建立好後,再加入SSR的功能來最大化提升app的效能。

首先進入app入口 App.js:

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,我們可以很容易做到這一點。

用React-Loadable來時間程式碼拆分

使用之前,先安裝 react-loadable:

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

然後在你的 javascript中:

//...
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>
`;
    });
  }

  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

相關文章