使用 PHP 來做 Vue.js 的 SSR 服務端渲染
對於客戶端應用來說,服務端渲染是一個熱門話題。然而不幸的是,這並不是一件容易的事,尤其是對於不用 Node.js 環境開發的人來說。
讓我們一起來仔細研究一些服務端渲染的概念,權衡優缺點,然後遵循第一法則用 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 配置即可投入使用,通用版需要根據執行環境做一些設定調整。當然,詳細內容可以參考軟體包自述檔案。
如果你考慮服務端渲染,我希望這類軟體包可以幫到你,並期待通過 Github 做進一步問題交流和反饋!