從零開始構建react應用(五)同構之服務端渲染

前言

上文講到使用react進行客戶端渲染頁面,此次講解在服務端利用前端react的代碼來渲染頁面並輸出到客戶端,即構建同構應用。css

PS:同構,我是這樣理解的,同一份代碼能夠同時運行在客戶端和服務端。html

利用ts實現純腳本組件的同構

當咱們的組件不包含樣式,圖片等服務端沒法直接解析處理的時候,咱們能夠直接利用ts的tsc命令將組件編譯成相應的js,服務端則能夠直接運行該js獲得渲染的結果,固然這種狀況實際並不存在,這裏只是做爲例子來說解。前端

服務端bundle.tsx

咱們在server目錄下新建bundle.tsx將其做爲前端react組件的一個打包入口文件。咱們經過將它打包,並在服務端執行獲得咱們須要的渲染結果。vue

// ./src/server/bundle.tsx

import * as React from 'react';

/* tslint:disable-next-line no-submodule-imports */
import { renderToString } from 'react-dom/server';

import App from '../client/component/app';

export default {
  render() {
    return renderToString(<App />);
  },
};

能夠看到,咱們直接將客戶端的App組件引入,並輸出一個擁有render方法的對象,在服務端入口文件中咱們只須要引入該bundle對象,並調用其render方法就能夠獲得渲染出的html字符串了。node

PS:tslint:disable相似於eslint的對應語法,用來使得相應的規則不生效react

// ./src/server/index.tsx

...
router.get('/*', (ctx: Koa.Context, next) => { // 配置一個簡單的get通配路由
  const html = bundle.render(); // 得到渲染出的html字符串
  ctx.type = 'html';
  ctx.body = `
    ...
        <div id="app">${html}</div>
    ...
  `;
  next();
});
...

PS:...表明代碼省略webpack

客戶端/服務端渲染對比

在chrome中打開localhost:3344後能夠看的頁面上的hello world,咱們右鍵頁面選擇View Page Source,能夠看到兩種方法渲染的不一樣:git

客戶端渲染:
客戶端渲染github

服務端渲染:
服務端渲染web

顯而易見,服務端渲染會直接輸出組件渲染的內容,瀏覽器在接收到這些內容後就會直接繪製呈現給咱們,而客戶端渲染會在react框架初始化完畢以後再進行,因此對比兩種狀況,客戶端渲染時白屏時間會更長一些,且刷新頁面時會有閃爍的感受。

利用webpack實現非純腳本組件的同構

在咱們實際開發環境中,必然存在組件裏引用樣式文件,引用圖片的狀況,這種狀況下ts並不具有webpack相應的將這些資源轉換爲js可處理的功能,因此咱們須要使用webpack來處理服務端的bundle.tsx文件,使得服務端能夠運行打包後的js文件。

服務端bundle.tsx的webpack配置文件

在客戶端,像react這樣的庫,webpack會把它打包到輸出的js文件裏,而在服務端咱們並不須要這麼作,因此配置文件和客戶端有很大不一樣。

// ./src/webpack/server.ts

import * as path from 'path';

import * as webpack from 'webpack';

import * as nodeExternals from 'webpack-node-externals';

import { cloneDeep } from 'lodash'; // lodash提供的深度複製方法cloneDeep

// 客戶端+服務端全環境公共配置baseConfig,項目根目錄路徑baseDir,獲取tsRule的方法getTsRule
import baseConfig, { baseDir, getTsRule } from './base';

const serverBaseConfig: webpack.Configuration = cloneDeep(baseConfig); // 服務端全環境公共配置

serverBaseConfig.entry = { // 入口屬性配置
  'server-bundle': [
    './src/server/bundle.tsx',
  ],
};
serverBaseConfig.externals = [nodeExternals()],
serverBaseConfig.node = {
  __dirname: true,
  __filename: true,
};
serverBaseConfig.target = 'node';
serverBaseConfig.output.libraryTarget = 'commonjs2';

const serverDevConfig: webpack.Configuration = cloneDeep(serverBaseConfig); // 服務端開發環境配置

serverDevConfig.cache = false; // 禁用緩存
serverDevConfig.output.filename = '[name].js'; // 使用源文件名做爲打包後文件名
(serverDevConfig.module as webpack.NewModule).rules.push(
  getTsRule('./src/webpack/tsconfig.server.json'),
);
serverDevConfig.plugins.push(
  new webpack.NoEmitOnErrorsPlugin(), // 編譯出錯時跳過輸出階段,以保證輸出的資源不包含錯誤。
);

const serverProdConfig: webpack.Configuration = cloneDeep(serverBaseConfig); // 服務端生產環境配置

// TODO 服務端生產環境配置暫不處理和使用

export default {
  development: serverDevConfig,
  production: serverProdConfig,
};

疑問一:webpack-node-externals是幹啥用的?

答:該庫的做用是讓webpack忽略node_modules裏的庫,避免將他們打包到輸出文件中去。

疑問二:target爲什麼要設置爲node?

答:這是爲了讓webpack打包時忽略node內建的庫,好比fs。

疑問三:配置的node屬性設置__dirname和__filename爲true是什麼意思?

答:這是爲了讓webpack使用真實的相對當前上下文的路徑,能夠避免打包出的文件里路徑錯誤。簡單點說就是在源文件裏使用__dirname,在打包後這個__dirname會被替換爲源文件的相對路徑值,而不是打包輸出的文件的相對路徑值。

疑問四:libraryTarget設置爲commonjs2是什麼意思?

答:將入口起點的返回值將分配給 module.exports 對象,參見官方文檔詳解:output-librarytarget

服務端TypeScript配置文件

相較客戶端配置,服務端須要多include一個入口文件即bundle.tsx

// ./src/webpack/tsconfig.server.json

{
  "compilerOptions": {
    "target": "es5",
    "jsx": "react"
  },
  "include": [
    "../../src/client/**/*",
    "../../src/server/bundle.tsx"
  ]
}

服務端webpack執行時機

目前咱們準備好了服務端的webpack配置文件,如今要選擇一個時機將其執行,那就在客戶端webpack打包完畢以後吧,這樣在一塊兒有序的執行也好管理哈。

// ./src/webpack/webpack-dev-server.ts

...
import webpackServerConfig from './server';

export default (app: Koa, serverCompilerDone) => {
  const clientDevConfig = webpackClientConfig.development;
  const serverDevConfig = webpackServerConfig.development;
  const clientCompiler = webpack(clientDevConfig);
  clientCompiler.plugin('done', () => {
    const serverCompiler = webpack(serverDevConfig);
    serverCompiler.plugin('done', serverCompilerDone);
    serverCompiler.run((err, stats) => {
      if (err) {
        console.error(stats);
      }
    });
  });
  ...
};

咱們經過complier.plugin方法,來實現打包完成後的回調操做,咱們改造了webpack-dev-server.ts輸出的函數,接收第二個參數做爲服務端webpack打包完成後的回調函數。

引用服務端打包輸出的bundle文件

// ./src/server/index.ts

...
let bundle;
const bundleFile = path.join(__dirname, '../../bundle/server-bundle.js');
...
if (isDev) {
  webpackDevServer(app, () => {
    delete require.cache[require.resolve(bundleFile)];
    bundle = require(bundleFile).default;
  }); // 僅在開發環境使用
}
...

咱們定義bundle變量用於接收server-bundle.js的輸出結果,也就是咱們上面提到的擁有一個render方法的對象。因爲node的require緩存機制,因此咱們每次打包完server-bundle.js後都須要先刪除緩存,再給bundle賦值。

疑問五:require.cache的鍵值爲什麼要使用require.resolve包裹文件名?

答:require源碼在進行緩存時以絕對路徑(使用其內部resolve方法得到)爲key,因此這裏須要包裹一下以得到真實的key。

疑問六:bundle爲什麼是require(bundleFile)的default值?

答:由於bundle.tsx輸出的就是default,export default xxx至關於exports.default = xxx。

小結

雖然在上述第二種方法裏,咱們沒有實際引入樣式、圖片等文件,可是這個操做我想應該不難,加一個對應的loader(file-loader, css-loader等)便可實現。在寫這篇文章以前,上述第二種方法裏關於bundle的動態更新方法我一直是參考使用vue裏的create-bundle-runner(利用vm實現本身的require),寫文章的時候發現其實我目前的應用場景並無那麼複雜,效率性能也沒有那麼高要求,因此就使用了原生的require方法來實現。
參見:vuejs:create-bundle-runner

Thanks

By devlee

相關文章
相關標籤/搜索