對于客戶端應用來說,服務端渲染是一個熱門話題。然而不幸的是,這并不是一件容易的事,尤其是對于不用 Node.js 環境開發的人來說。
我發布了兩個庫讓 PHP 從服務端渲染成為可能.spatie/server-side-rendering 和 spatie/laravel-server-side-rendering適配 laravel 應用。
讓我們一起來仔細研究一些服務端渲染的概念,權衡優缺點,然后遵循第一法則用 PHP 建立一個服務端渲染。
什么是服務端渲染
一個單頁應用(通常也叫做 SPA )是一個客戶端渲染的 App 。這是一個僅在瀏覽器端運行的應用。如果你正在使用框架,比如 React, Vue.js 或者 AngularJS ,客戶端將從頭開始渲染你的 App 。
瀏覽器的工作
在 SPA 被啟動并準備使用之前,瀏覽器需要經過幾個步驟。
- 下載 JavaScript 腳本
- 解析 JavaScript 腳本
- 運行 JavaScript 腳本
- 取回數據(可選,但普遍)
- 在原本的空容器渲染應用 (首次有意義的渲染)
- 準備完成! (可以交互啦)
用戶不會看到任何有意義的內容,直到瀏覽器完全渲染 App(需要花費一點時間)。這會造成一個明顯的延遲,直到 首次有意義的渲染 完成,從而影響了用戶體驗。
這就是為什么服務端渲染(一般被稱作 SSR )登場的原因。SSR 在服務器預渲染初始應用狀態。這里是瀏覽器在使用服務端渲染后需要經過的步驟:
- 渲染來自服務端的 HTML (首次有意義的渲染)
- 下載 JavaScript 腳本
- 解析 JavaScript 腳本
- 運行 JavaScript 腳本
- 取回數據
- 使已存在的 HTML 頁面可交互
- 準備完成! (可以交互啦)
由于服務器提供了 HTML 的預渲染塊,因此用戶無需等到一切完成后才能看到有意義的內容。注意,雖然 交互時間 仍然處于最后,但可感知的表現得到了巨大的提升。
服務端渲染的優點
服務端渲染的主要優點是可以提升用戶體驗。并且,如果你的網站需要應對不能執行 JavaScript 的老舊爬蟲,SSR 將是必須的,這樣,爬蟲才能索引服務端渲染過后的頁面,而不是一個空蕩蕩的文檔。
服務端如何渲染?
記住服務端渲染并非微不足道,這一點很重要。當你的 Web 應用同時運行在瀏覽器和服務器,而你的 Web 應用依賴 DOM 訪問,那么你需要確保這些調用不會在服務端觸發,因為沒有 DOM API 可用。
基礎設施復雜性
假設你決定了服務端渲染你的應用端程序,你如果正在閱讀這篇文章,很大可能正在使用 PHP 構建應用的大部分(功能)。但是,服務端渲染的 SPA 需要運行在 Node.js 環境,所以將需要維護第二個程序。
你需要構建兩個應用程序之間的橋梁,以便它們進行通信和共享數據:需要一個 API。構建無狀態 API 相比于構建有狀態是比較 困難 的。你需要熟悉一些新概念,例如基于 JWT 或 OAUTH 的驗證,CORS,REST ,添加這些到現有應用中是很重要的。
有得必有所失,我們已經建立了 SSR 以增加 Web 應用的用戶體驗,但 SSR 是有成本的。
服務器端渲染權衡取舍
服務器上多了一個額外的操作。一個是服務器增加了負載壓力,第二個是頁面響應時間也會稍微加長。 不過因為現在服務器返回了有效內容,在用戶看來,第二個問題的影響不大。
大部分時候你會使用 Node.js 來渲染你的 SPA 代碼。如果你的后端代碼不是使用 Javascript 編寫的話,新加入 Node.js 堆棧將使你的程序架構變得復雜。
為了簡化基礎架構的復雜度, 我們需要找到一個方法,使已有的 PHP 環境作為服務端來渲染客戶端應用。
在 PHP 中渲染 JavaScript
在服務器端渲染 SPA 需要集齊以下三樣東西:
- 一個可以執行 JavaScript 的引擎
- 一個可以在服務器上渲染應用的腳本
- 一個可以在客戶端渲染和運行應用的腳本
SSR scripts 101
下面的例子使用了 Vue.js。你如果習慣使用其它的框架(例如 React),不必擔心,它們的核心思想都是類似的,一切看起來都是那么相似。
簡單起見,我們使用經典的 “ Hello World ” 例子。
下面是程序的代碼(沒有 SSR):
// app.js
import Vue from 'vue'
new Vue({
template: `
<div>Hello, world!</div>
`,
el: '#app'
})
這短代碼實例化了一個 Vue 組件,并且在一個容器(id 值為 app
的 空 div
)渲染。
如果在服務端運行這點腳本,會拋出錯誤,因為沒有 DOM 可訪問,而 Vue 卻嘗試在一個不存在的元素里渲染應用。
重構這段腳本,使其 可以 在服務端運行。
// app.js
import Vue from 'vue'
export default () => new Vue({
template: `
<div>Hello, world!</div>
`
})
// entry-client.js
import createApp from './app'
const app = createApp()
app.$mount('#app')
我們將之前的代碼分成兩部分。app.js
作為創建應用實例的工廠,而第二部分,即 entry-client.js
,會運行在瀏覽器,它使用工廠創建了應用實例,并且掛載在 DOM。
現在我們可以創建一個沒有 DOM 依賴性的應用程序,可以為服務端編寫第二個腳本。
// entry-server.js
import createApp from './app'
import renderToString from 'vue-server-renderer/basic'
const app = createApp()
renderToString(app, (err, html) => {
if (err) {
throw new Error(err)
}
// Dispatch the HTML string to the client...
})
我們引入了相同的應用工廠,但我們使用服務端渲染的方式來渲染純 HTML 字符串,它將包含應用初始狀態的展示。
我們已經具備三個關鍵因素中的兩個:服務端腳本和客戶端腳本。現在,讓我們在 PHP 上運行它吧!
執行 JavaScript
在 PHP 運行 JavaScript,想到的第一個選擇是 V8Js。V8Js 是嵌入在 PHP 擴展的 V8 引擎,它允許我們執行 JavaScript。
使用 V8Js 執行腳本非常直接。我們可以用 PHP 中的輸出緩沖和 JavaScript 中的 print
來捕獲結果。
$v8 = new V8Js();
ob_start();
// $script 包含了我們想執行的腳本內容
$v8->executeString($script);
echo ob_get_contents();
print('<div>Hello, world!</div>')
這種方法的缺點是需要第三方 PHP 擴展,而擴展可能很難或者不能在你的系統上安裝,所以如果有其他(不需要安裝擴展的)方法,它會更好的選擇。
這個不一樣的方法就是使用 Node.js 運行 JavaScript。我們可以開啟一個 Node 進程,它負責運行腳本并且捕獲輸出。
Symfony 的 Process
組件就是我們想要的。
use Symfony\Component\Process\Process;
// $nodePath 是可執行的 Node.js 的路徑
// $scriptPath 是想要執行的 JavaScript 腳本的路徑
new Process([$nodePath, $scriptPath]);
echo $process->mustRun()->getOutput();
console.log('<div>Hello, world!</div>')
注意,(打印)在 Node 中是調用 console.log
而不是 print
。
讓我們一起來實現它吧!
spatie/server-side-rendering 包的其中一個關鍵理念是 引擎
接口。引擎就是上述 JavaScript 執行的一個抽象概念。
namespace Spatie\Ssr;
/**
* 創建引擎接口。
*/
interface Engine
{
public function run(string $script): string;
public function getDispatchHandler(): string;
}
run
方法預期一個腳本的輸入 (腳本 內容,不是一條路徑),并且返回執行結果。 getDispatchHandler
允許引擎聲明它預期腳本如何展示發布。例如 V8 中的print
方法,或是 Node 中的 console.log
。
V8Js 引擎實現起來并不是很花俏。它更類似于我們上述理念的驗證,帶有一些附加的錯誤處理機制。
namespace Spatie\Ssr\Engines;
use V8Js;
use V8JsException;
use Spatie\Ssr\Engine;
use Spatie\Ssr\Exceptions\EngineError;
/**
* 創建一個 V8 類來實現引擎接口類 Engine 。
*/
class V8 implements Engine。
{
/** @var \V8Js */
protected $v8;
public function __construct(V8Js $v8)
{
$this->v8 = $v8;
}
/**
* 打開緩沖區。
* 返回緩沖區存儲v8的腳本處理結果。
*/
public function run(string $script): string
{
try {
ob_start();
$this->v8->executeString($script);
return ob_get_contents();
} catch (V8JsException $exception) {
throw EngineError::withException($exception);
} finally {
ob_end_clean();
}
}
public function getDispatchHandler(): string
{
return 'print';
}
}
注意這里我們將 V8JsException
重新拋出作為我們的 EngineError
。 這樣我們就可以在任何的引擎視線中捕捉相同的異常。
Node 引擎會更加復雜一點。不像 V8Js,Node 需要 文件 去執行,而不是腳本內容。在執行一個服務端腳本前,它需要被保存到一個臨時的路徑。
namespace Spatie\Ssr\Engines;
use Spatie\Ssr\Engine;
use Spatie\Ssr\Exceptions\EngineError;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
/**
* 創建一個 Node 類來實現引擎接口類 Engine 。
*/
class Node implements Engine
{
/** @var string */
protected $nodePath;
/** @var string */
protected $tempPath;
public function __construct(string $nodePath, string $tempPath)
{
$this->nodePath = $nodePath;
$this->tempPath = $tempPath;
}
public function run(string $script): string
{
// 生成一個隨機的、獨一無二的臨時文件路徑。
$tempFilePath = $this->createTempFilePath();
// 在臨時文件中寫進腳本內容。
file_put_contents($tempFilePath, $script);
// 創建進程執行臨時文件。
$process = new Process([$this->nodePath, $tempFilePath]);
try {
return substr($process->mustRun()->getOutput(), 0, -1);
} catch (ProcessFailedException $exception) {
throw EngineError::withException($exception);
} finally {
unlink($tempFilePath);
}
}
public function getDispatchHandler(): string
{
return 'console.log';
}
protected function createTempFilePath(): string
{
return $this->tempPath.'/'.md5(time()).'.js';
}
}
除了臨時路徑步驟之外,實現方法看起來也是相當直截了當。
我們已經創建好了 Engine
接口,接下來需要編寫渲染的類。以下的渲染類來自于 spatie/server-side-rendering 擴展包,是一個最基本的渲染類的結構。
渲染類唯一的依賴是 Engine
接口的實現:
class Renderer
{
public function __construct(Engine $engine)
{
$this->engine = $engine;
}
}
渲染方法 render
里將會處理渲染部分的邏輯,想要執行一個 JavaScript 腳本文件,需要以下兩個元素:
- 我們的應用腳本文件;
- 一個用來獲取解析產生的 HTML 的分發方法;
一個簡單的 render
如下:
class Renderer
{
public function render(string $entry): string
{
$serverScript = implode(';', [
"var dispatch = {$this->engine->getDispatchHandler()}",
file_get_contents($entry),
]);
return $this->engine->run($serverScript);
}
}
此方法接受 entry-server.js
文件路徑作為參數。
我們需要將解析前的 HTML 從腳本中分發到 PHP 環境中。dispatch
方法返回 Engine
類里的 getDispatchHandler
方法,dispatch
需要在服務器腳本加載前運行。
還記得我們的服務器端入口腳本嗎?接下來我們在此腳本中調用我們的 dispatch
方法:
// entry-server.js
import app from './app'
import renderToString from 'vue-server-renderer/basic'
renderToString(app, (err, html) => {
if (err) {
throw new Error(err)
}
dispatch(html)
})
Vue 的應用腳本無需特殊處理,只需要使用 file_get_contents
方法讀取文件即可。
我們已經成功創建了一個 PHP 的 SSR 。spatie/server-side-rendering 中的完整渲染器 Renderer
跟我們實現有點不一樣,他們擁有更高的容錯能力,和更加豐富的功能如有一套 PHP 和 JavaScript 共享數據的機制。如果你感興趣的話,建議你閱讀下源碼 server-side-rendering 代碼庫 。
三思而后行
我們弄清楚了服務器端渲染的利和弊,知道 SSR 會增加應用程序架構和基礎結構的復雜度。如果服務器端渲染不能為你的業務提供任何價值,那么你可能不應該首先考慮他。
如果你 確實 想開始使用服務器端渲染,請先閱讀應用程序的架構。大多數 JavaScript 框架都有關于 SSR 的深入指南。Vue.js 甚至有一個專門的 SSR 文檔網站,解釋了諸如數據獲取和管理用于服務器端渲染的應用程序方面的坑。
如果可能,請使用經過實戰檢驗的解決方案
有許多經過實戰檢驗的解決方案,能提供很好的 SSR 開發體驗。比如,如果你在構建 React 應用,可以使用 Next.js,或者你更青睞于 Vue 則可用 Nuxt.js,這些都是很引人注目的項目。
還不夠?嘗試 PHP 服務端渲染
你僅能以有限的資源來管理基礎架構上的復雜性。你想將服務端渲染作為大型 PHP 應用中的一部分。你不想構建和維護無狀態的 API。 如果這些原因和你的情況吻合,那么使用 PHP 進行服務端渲染將會是個不錯方案。
我已經發布兩個庫來支持 PHP 的服務端 JavaScript 渲染: spatie/server-side-rendering 和專為 Laravel 應用打造的 spatie/laravel-server-side-rendering 。Laravel 定制版在 Laravel 應用中近乎 0 配置即可投入使用,通用版需要根據運行環境做一些設置調整。當然,詳細內容可以參考軟件包自述文件。
如果你僅是想體驗,從 spatie/laravel-server-side-rendering-examples 檢出項目并參考指南進行安裝。
如果你考慮服務端渲染,我希望這類軟件包可以幫到你,并期待通過 Github 做進一步問題交流和反饋!
更多現代化 PHP 知識,請前往 Laravel / PHP 知識社區