React服務端渲染原理解析與實踐

關於服務端渲染也就是我們說的SSR大多數人都聽過這個概念,很多同學或許在公司中已經做過服務端渲染的項目瞭,主流的單頁面應用比如說Vue或者React開發的項目采用的一般都是客戶端渲染的模式也就是我們說的CSR。

但是這種模式會帶來明顯的兩個問題,第一個就是TTFP時間比較長,TTFP指的就是首屏展示時間,同時不具備SEO排名的條件,搜索引擎上排名不是很好。所以我們可以借助一些工具來進行改良我們的項目,將單頁面應用編程服務器端渲染項目,這樣就可以解決掉這些問題瞭。

目前主流的服務器端渲染框架也就是SSR框架有針對於Vue的Nuxt.js和針對React的Next.js這兩個。這裡我們並不使用這些SSR框架,而是從零開始完整搭建一套SSR框架,來熟悉他的底層原理。

服務器端編寫 React 組件

如果是客戶端渲染,瀏覽器首先會向瀏覽器發送請求,服務器返回頁面的html文件,然後html中再向服務器發送請求,服務器返回js文件,js文件在瀏覽器中執行繪制出頁面結構渲染到瀏覽器完成頁面渲染。

如果是服務器端渲染這個流程就不同瞭,瀏覽器發送請求,服務器端運行React代碼生成頁面,然後服務器將生成好的頁面返回給瀏覽器,瀏覽器進行渲染。這種情況下React代碼就是服務器的一部分而不是前端部分瞭。

這裡我們進行代碼的演示,首選需要npm init初始化項目,然後安裝react,express,webpack,webpack-cli,webpack-node-externals。

我們首先編寫一個React的組件。 .src/components/Home/index.js, 因為我們這個js是在node環境執行的所以我們要遵循CommonJS規范,使用require和module.exports進行導入導出。

const React = require('react');

const Home = () => {
  return <div>home</div>
}

module.exports = {
  default: Home
};

我們這裡開發的Home組件是不能直接在node中運行的,需要借助webpack工具將jsx語法打包編譯成js語法,讓nodejs可以爭取的識別,我們需要創建一個webpack.server.js文件。

在服務器端使用webpack需要添加一個target為node的鍵值對。我們知道在服務器端如果使用path路徑是不需要打包到js中的,如果在瀏覽器端使用瞭path是需要打包到js中的,所以在服務器端和在瀏覽器端需要編譯出來的js是完全不同的。所以我們在打包的時候要告訴webpack打包的是服務器端的代碼還是瀏覽器端的代碼。

entry入口文件就是我們node的啟動文件,這裡我們寫成./src/index.js,輸出的output文件名稱為bundle,目錄在跟目錄的build文件夾中。

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服務端運行webpack需要運行NodeExternals, 他的作用是將express這類node模塊不被打包到js裡。

module.exports = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: Path.resolve(__dirname, 'build')
  },
  externals: [NodeExternals()],
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

安裝依賴模塊

npm install babel-loader babel-core babel-preset-react babel-preset-stage-0 babel-preset-env --save

接著我們這裡基於express模塊來編寫一個簡單的服務。./src/server/index.js

var express = require('express');
var app = express();
const Home = require('../Components/Home');
app.get('*', function(req, res) {
  res.send(`<h1>hello</h1>`);
})

var server = app.listen(3000);

運行webpack使用webpack.server.js配置文件來執行。

webpack --config webpack.server.js

打包之後在我們的目錄下會出現一個bundle.js,這個js就是我們打包生成的最終可以運行的代碼。我們可以使用node運行這個文件, 就啟動瞭一個3000端口的服務器。我們訪問127.0.0.1:3000可以訪問這個服務,看到瀏覽器輸出Hello。

node ./build/bundile.js

上面的代碼我們運行前會使用webpack進行編譯,所以也就支持瞭ES Modules規范,不再強制使用CommonJS瞭。

src/components/Home/index.js

import React from 'react';

const Home = () => {
  return <div>home</div>
}

export default Home;

/src/server/index.js中我們可以使用Home組件,這裡我們首先需要安裝react-dom,借助renderToString將Home組件轉換為標簽字符串,當然這裡需要依賴React所以我們需要引入React。

import express from 'express';
import Home from '../Components/Home';
import React from 'react';
import { renderToString } from 'react-dom/server';

const app = express();
const content = renderToString(<Home />);
app.get('*', function(req, res) {
  res.send(`
    <html>
      <body>${content}</body>
    </html>
  `);
})

var server = app.listen(3000);

# 重新打包
webpack --config webpack.server.js
# 運行服務
node ./build/bundile.js

這時候頁面就顯示出瞭我們React組件的代碼。

React的服務端渲染是建立在虛擬DOM上的服務器端渲染,而且服務端渲染會讓頁面的首屏渲染速度大大加快。不過服務端渲染也有弊端,客戶端渲染React代碼在瀏覽器端執行,他消耗的是用戶瀏覽器端的性能,但是服務器端渲染消耗的是服務器端的性能,因為React代碼在服務器上運行。極大的消耗瞭服務器的性能,因為React代碼是很消耗計算性能的。

如果你的項目完全沒有必要使用SEO優化並且你的項目訪問速度已經很快瞭的情況下,建議還是不要使用SSR的技術瞭,因為他的成本開銷還是比較大的。

上面我們的代碼每次修改之後都需要重新執行webpack打包和啟動服務器,這樣調試起來太過麻煩,為瞭解決這個問題我們需要做一下webpack的自動打包和node的重啟。我們在package.json中加入build命令,並且通過–watch監聽文件變化進行自動打包。

{
  ...
  "scripts": {
    "build": "webpack --config webpack.server.js --watch"
  }
  ...
}

隻是重新打包還不夠,我們還需要重啟node服務器,這裡我們需要借助nodemon模塊,這裡我們使用全局安裝nodemon, 在package.json文件中添加一個start命令來啟動我們的node服務器。使用nodemon監聽build文件並且發生改變之後重新exec運行”node ./build/bundile.js”, 這裡需要保留雙引號,轉譯一下就好瞭。

{
  ...
  "scripts": {
    "start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "build": "webpack --config webpack.server.js --watch"
  }
  ...
}

這時我們啟動服務器,這裡需要在兩個窗口運行下面的命令,因為build後不允許再輸入其他命令瞭。

npm run build
npm run start

這個時候我們修改代碼之後頁面就會自動更新瞭。

但是上面的流程還是有些麻煩,我們需要兩個窗口來執行命令,我們想要一個窗口將兩個命令執行完畢,我們需要借助一個第三方模塊npm-run-all,可以全局安裝這個模塊。然後再package.json中來修改一下。

我們在打包和調試應該是在開發環境,我們創建一個dev命令, 裡面執行npm-run-all, –parallel表示並行執行, 執行dev:開頭的所有命令。我們將start和build前面追加一個dev:,這個時候我想啟動服務器同時監聽文件改變運行npm run dev就可以瞭。

{
  ...
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "dev:build": "webpack --config webpack.server.js --watch"
  }
  ...
}

什麼叫做同構

比如下面的代碼,我們給div綁定一個click事件,希望點擊的時候可以彈出click提示。但是運行之後我們會發現這個事件並沒有被綁定上,因為服務器端沒辦法綁定事件。

src/components/Home/index.js

import React from 'react';

const Home = () => {
  return <div onClick={() => { alert('click'); }}>home</div>
}

export default Home;

一般我們的做法是先將頁面渲染出來,然後將相同的代碼在瀏覽器端像傳統的React項目一樣再去運行一遍,這樣的話這個點擊事件就有瞭。

這就衍生出一個同構的概念,我的理解是一套React代碼在服務器端執行一次,在客戶端再執行一次。

同構就可以解決點擊事件無效的問題,首先服務器端執行一次能夠正常的展示頁面,客戶端再執行一次就可以綁定上事件。

我們可以在頁面渲染的時候加載一個index.js, 使用app.use創建靜態文件的訪問路徑, 這樣訪問的index.js就會請求到/public/index.js文件中。

app.use(express.static('public'));

app.get('/', function(req, res) {
  res.send(`
    <html>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
})

public/index.js

console.log('public');

基於這種情況我們就可以將React代碼在瀏覽器中執行一次,我們這裡新建一個/src/client/index.js。將客戶端執行的代碼帖進去。這裡我們同構代碼使用hydrate代替render。

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

import Home from '../Components/Home';

ReactDOM.hydrate(<Home />, document.getElementById('root'));

然後我們還需要在根目錄創建一個webpack.client.js文件。入口文件為./src/client/index.js,出口文件到public/index.js

const Path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: Path.resolve(__dirname, 'public')
  },
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

package.json文件中添加一條打包client目錄的命令

{
  ...
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "dev:build": "webpack --config webpack.server.js --watch",
    "dev:build": "webpack --config webpack.client.js --watch",
  }
  ...
}

這樣我們啟動的時候會編譯client運行的文件。再去訪問頁面的時候就可以綁定好事件瞭。

下面我們對上面工程的代碼進行整理,上面webpack.server.js和webpack.client.js文件有很多重復的地方,我們可以使用webpack-merge插件對內容進行合並。

webpack.base.js

module.exports = {
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

webpack.server.js

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服務端運行webpack需要運行NodeExternals, 他的作用是將express這類node模塊不被打包到js裡。

const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const serverConfig = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: Path.resolve(__dirname, 'build')
  },
  externals: [NodeExternals()],
}

module.exports = merge(config, serverConfig);

webpack.client.js

const Path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const clientConfig = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: Path.resolve(__dirname, 'public')
  }
};

module.exports = merge(config, clientConfig);

src/server中放置的是服務端運行的代碼,src/client放置的是瀏覽器端運行的js。

到此這篇關於React服務端渲染原理解析與實踐的文章就介紹到這瞭,更多相關React服務端渲染內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: