使用 PHP 來作 Vue.js 的 SSR 服務端渲染

file
對於客戶端應用來講,服務端渲染是一個熱門話題。然而不幸的是,這並非一件容易的事,尤爲是對於不用 Node.js 環境開發的人來講。html

我發佈了兩個庫讓 PHP 從服務端渲染成爲可能.spatie/server-side-rendering 和 spatie/laravel-server-side-rendering適配 laravel 應用。vue

讓咱們一塊兒來仔細研究一些服務端渲染的概念,權衡優缺點,而後遵循第一法則用 PHP 創建一個服務端渲染。node

什麼是服務端渲染

一個單頁應用(一般也叫作 SPA )是一個客戶端渲染的 App 。這是一個僅在瀏覽器端運行的應用。若是你正在使用框架,好比 React, Vue.js 或者 AngularJS ,客戶端將從頭開始渲染你的 App 。laravel

瀏覽器的工做

在 SPA 被啓動並準備使用以前,瀏覽器須要通過幾個步驟。git

  • 下載 JavaScript 腳本
  • 解析 JavaScript 腳本
  • 運行 JavaScript 腳本
  • 取回數據(可選,但廣泛)
  • 在本來的空容器渲染應用  (首次有意義的渲染)
  • 準備完成! (能夠交互啦)

用戶不會看到任何有意義的內容,直到瀏覽器徹底渲染 App(須要花費一點時間)。這會形成一個明顯的延遲,直到 首次有意義的渲染 完成,從而影響了用戶體驗。github

這就是爲何服務端渲染(通常被稱做 SSR )登場的緣由。SSR 在服務器預渲染初始應用狀態。這裏是瀏覽器在使用服務端渲染後須要通過的步驟:web

  • 渲染來自服務端的 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 知識社區
相關文章
相關標籤/搜索