SPA 的 SEO 方案對比、最終實踐

原文連接:https://taskhub.work/article/...
已獲做者受權轉載。javascript

前端開發技術突飛猛進,因爲現代化構建、用戶體驗的需求,angular/vue/react 等框架已經成爲開發標配,大部分應用都是 SPA,同時也帶來了不少新問題:css

  • SEO 不友好
  • 首屏渲染慢

爲了解決這些問題,開源社區有不少方案,本文主要對這些方案進行對比。html

1、客戶端渲染(CSR)方案

React開發的SPA就是一種CSR方案,如圖所示,在到達瀏覽器以前的html頁面是沒有內容的,要等到瀏覽器執行相應異步請求獲取數據填充後才顯示界面。前端

優勢vue

  • SPA 的優勢(用戶體驗較好)

缺點java

  • SEO不友好(爬蟲若是沒有執行js的能力,如百度,獲取到的頁面是空的,不利於網站推廣)
  • 首屏加載慢(到達瀏覽器端後再加載數據,增長用戶等待時間)

2、服務端渲染 (SSR)方案


基本原理: 在服務端起一個node應用,瀏覽器到來時,先攔截執行部分 js 異步請求,提早將數據填充到 html 頁面中返回瀏覽器。這樣爬蟲抓取到的頁面就是帶數據的,有利於SEOnode

需解決問題:react

  1. 大部分應用開發時都有狀態管理方案(Vuex, Redux),SPA 應用到達瀏覽器前狀態都是空的,使用SSR後意味着須要在服務端提早填充數據到 store
  2. 須要攔截相應 hook(vue 的 created、react 的 componentDidMount),等待異步數據請求完成,確認渲染完成

針對這些問題,社區也有相應框架可參考:webpack

框架 解決方案 Github star
Vue Nuxt.js 28.4k
React Nextjs 50.8k
Angular - -

不想使用框架,也能夠本身修改react、vue 的 render 方法實現(改動工做量更大)nginx

優勢

  • SEO 友好
  • 首屏渲染快(可在服務端緩存頁面,請求到來直接給 html)

缺點

  • 代碼改動大、須要作特定SSR框架的改動(通過咱們實踐、原有SPA代碼改動很是大)
  • 丟失了部分SPA體驗
  • node 容易成爲性能瓶頸

3、構建時預渲染方案

Solution Github Star
prerender-spa-plugin 6k
puppeteer 63.2k
phantomjs 1.4k

基本原理: 利用webpack 等構建工具,針對 SPA 應用開發後只有一個 index.html 文件入口問題,用上述預渲染中間件在前端項目構建時預先獲取頁面數據,生成多個頁面,如 about、help 、contact 等頁面,優化首屏渲染與部分頁面SEO

優勢

  • 代碼侵入性小

缺點

  • 沒法用於大量動態路徑頁面場景(生成的 html 頁面數據大,並且頁面數據會有更新。如 /article/123,文章頁面)
  • 後臺請求數據變更時前端應該同步更新版本

4、服務端動態渲染(利用user-agent)

迴歸到原始需求,爲了提升用戶體驗咱們用了SPA技術、爲了SEO 咱們用了 SSR、預渲染等技術。不一樣技術方案有必定差距,不能兼顧優勢。但仔細想,須要這些技術優勢的「用戶」,其實時不同的,SPA 針對的是瀏覽器普通用戶、SSR 針對的是網頁爬蟲,如 googlebot、baiduspider 等,那爲何咱們不能給不一樣「用戶」不一樣的頁面呢,服務端動態渲染就是這種方案。

基本原理: 服務端對請求的 user-agent 進行判斷,瀏覽器端直接給 SPA 頁面,若是是爬蟲,給通過動態渲染的 html 頁面

PS: 你可能會問,給了爬蟲不一樣的頁面,會不會被認爲是網頁做弊行爲呢?

Google 給了回覆

Dynamic rendering is not cloaking

Googlebot generally doesn't consider dynamic rendering as cloaking. As long as your dynamic rendering produces similar content, Googlebot won't view dynamic rendering as cloaking.

When you're setting up dynamic rendering, your site may produce error pages. Googlebot doesn't consider these error pages as cloaking and treats the error as any other error page.

Using dynamic rendering to serve completely different content to users and crawlers can be considered cloaking. For example, a website that serves a page about cats to users and a page about dogs to crawlers can be considered cloaking.

也就是說,若是咱們沒有刻意去做弊,而是使用動態渲染方案去解決SEO問題,爬蟲通過對比網站內容,沒有明顯差別,不會認爲這是做弊行爲。

優勢

  • 兼顧 SPA優勢同時解決SEO問題

缺點

  • 須要服務端應用(但動態渲染只針對爬蟲、不會成爲性能瓶頸)

總結: 通過前期其餘方案的實踐、優缺點權衡、最終咱們選擇了方案四的動態渲染做爲 SPA 的 SEO 方案。

實現細節

上圖爲最終實現。(存在優化點:右邊CDN整合、能夠考慮使用Node替代nginx部分功能,簡化架構)

社區方案:

方案 github star 描述
puppeteer 63.2k 可用於動態渲染、前端測試、操做模擬。API豐富
rendertron 4.9k 動態渲染
prerender.io 5.6k 動態渲染

選型使用 puppeteer 做爲動態渲染方案。

依賴:

{
  "dependencies": {
    "bluebird": "^3.7.2",
    "express": "^4.17.1",
    "puppeteer": "^5.2.0",
    "redis": "^3.0.2",
    "request": "^2.88.2"
  }
}

代碼參考Google 官方 Demo進行改造,下面是基礎代碼:

server.js

import express from 'express';
import request from 'request';
import ssr from './ssr.js';

const app = express();

const host = 'https://www.abc.com';

app.get('*', async (req, res) => {
    const {html, ttRenderMs} = await ssr(`${host}${req.originalUrl}`);
    res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
    return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl + C to quit'));

ssr.js

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages.
const RENDER_CACHE = new Map();

async function ssr(url) {
    if (RENDER_CACHE.has(url)) {
        return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
    }
    const start = Date.now();

    const browser = await puppeteer.launch({
        args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
    const page = await browser.newPage();
    try {
        // networkidle0 waits for the network to be idle (no requests for 500ms).
        await page.goto(url, {waitUntil: 'networkidle0'});
        await page.waitForSelector('#root'); // ensure #posts exists in the DOM.
    } catch (err) {
        console.error(err);
        throw new Error('page.goto/waitForSelector timed out.');
    }

    const html = await page.content(); // serialized HTML of page DOM.
    await browser.close();

    const ttRenderMs = Date.now() - start;
    console.info(`Puppeteer rendered page: ${url} in: ${ttRenderMs}ms`);

    RENDER_CACHE.set(url, html); // cache rendered page.

    return {html, ttRenderMs};
}

export {ssr as default};

Demo 代碼存在如下問題:

  • 頁面渲染後返回瀏覽器,有時會再次執行異步請求獲取數據(重複請求)
  • 使用了 Map 作頁面緩存,在node服務崩潰時會丟失所有緩存。沒有超時限制,隨着時間增加,內存消耗大(緩存機制)
  • 重複請求 React/Vue 靜態文件,ssr 函數會當成一個頁面進行渲染(錯誤渲染)

下面對這些問題逐個擊破

重複請求:

根本緣由是React/Vue 代碼生命週期函數重複執行。通常咱們在created/componentDidMount hook 進行異步數據請求,這個hook在動態渲染的時候執行了一次,在HTML返回瀏覽器的時候,dom掛載又執行了一次,此問題在Google Support也有說起。能夠經過小小改造前端代碼,判斷頁面是否已被動態渲染再執行異步請求。可參考:

componentDidMount() {
    const PRE_RENDERED = document.querySelector('#posts');
    if(!PRE_RENDERED) {
        // 異步請求
        // 插入含有 #posts id 的 dom 元素
    }
}

緩存機制

針對 Map 緩存的問題,咱們使用了Redis進行改造,增長超時機制,同時能夠避免node崩潰緩存擊穿問題

redis/index.js

import redis from 'redis';
import bluebird from 'bluebird';

bluebird.promisifyAll(redis);

const host = 'www.abc.com';
const port = 6379;
const password = '123456';

const client = redis.createClient({
    host,
    port,
    password,
    retry_strategy: function(options) {
        if (options.error && options.error.code === "ECONNREFUSED") {
            return new Error("The server refused the connection");
        }
        if (options.total_retry_time > 1000 * 60 * 60) {
            return new Error("Retry time exhausted");
        }
        if (options.attempt > 10) {
            return undefined;
        }
        return Math.min(options.attempt * 100, 3000);
    },
});

client.on("error", function(e) {
    console.error('dynamic-render redis error: ', e);
});

export default client;

ssr.js

import puppeteer from 'puppeteer';
import redisClient from './redis/index.js';

async function ssr(url) {
    const REDIS_KEY = `ssr:${url}`;
    const CACHE_TIME = 600; // 10 分鐘緩存
    const CACHE_HTML = await redisClient.getAsync(REDIS_KEY);

    if (CACHE_HTML) {
        return { html: CACHE_HTML, ttRenderMs: 0 };
    }
    const start = Date.now();

    const browser = await puppeteer.launch({
        args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
    const page = await browser.newPage();
    try {
        // networkidle0 waits for the network to be idle (no requests for 500ms).
        await page.goto(url, {waitUntil: 'networkidle0'});
        await page.waitForSelector('#root'); // ensure #posts exists in the DOM.
    } catch (err) {
        console.error(err);
        throw new Error('page.goto/waitForSelector timed out.');
    }

    const html = await page.content(); // serialized HTML of page DOM.
    await browser.close();

    const ttRenderMs = Date.now() - start;
    console.info(`Puppeteer rendered page: ${url} in: ${ttRenderMs}ms`);

    redisClient.set(REDIS_KEY, html, 'EX', CACHE_TIME); // cache rendered page.
    return {html, ttRenderMs};
}

export {ssr as default};

錯誤渲染

渲染後的頁面回到瀏覽器後,有時執行操做會從新加載樣式文件,請求路徑相似:/static/1231234sdf.css,這些路徑會被當作一個頁面路徑,而不是靜態資源進行渲染,致使渲染錯誤。解決方式:增長 path 匹配攔截,資源文件直接向原域名請求

import express from 'express';
import request from 'request';
import ssr from './ssr.js';

const app = express();

const host = 'https://www.abc.com';

app.get('/static/*', async (req, res) => {
    request(`${host}${req.url}`).pipe(res);
});

app.get('/manifest.json', async (req, res) => {
    request(`${host}${req.url}`).pipe(res);
});

app.get('/favicon.ico', async (req, res) => {
    request(`${host}${req.url}`).pipe(res);
});

app.get('/logo*', async (req, res) => {
    request(`${host}${req.url}`).pipe(res);
});

app.get('*', async (req, res) => {
    const {html, ttRenderMs} = await ssr(`${host}${req.originalUrl}`);
    res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
    return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl + C to quit'));

動態渲染相比SSR有幾點明顯好處:

  • 和 SSR 一致的 SEO 效果,經過 puppeteer 還可進一步定製 SEO 方案
  • node 應用負載壓力小,只需應對爬蟲請求,至關於只有爬蟲來了頁面才作SSR
  • 從總體架構上來講至關於一個插件,可隨時插拔,無反作用
  • 不須要大量修改SPA代碼(只在重複請求問題上用一個標誌位去識別,固然也能夠無論這個問題)

(重複請求只在爬蟲有js執行能力時纔出現,通常再次請求數據也沒問題)

附錄

常見爬蟲 user-agent

主體 user-agent 用途
Google googlebot 搜索引擎
Google google-structured-data-testing-tool 測試工具
Google Mediapartners-Google Adsense廣告網頁被訪問後,爬蟲就來訪
Microsoft bingbot 搜索引擎
Linked linkedinbot 應用內搜索
百度 baiduspider 搜索引擎
奇虎 360 360Spider 搜索引擎
搜狗 Sogou Spider 搜索引擎
Yahoo Yahoo! Slurp China 搜索引擎
Yahoo Yahoo! Slurp 搜索引擎
Twitter twitterbot 應用內搜索
Facebook facebookexternalhit 應用內搜索
- rogerbot -
- embedly -
Quora quora link preview -
- showyoubot -
- outbrain -
- pinterest -
- slackbot -
- vkShare -
- W3C_Validator -

模擬爬蟲測試

# 不帶 user-agent 返回SPA頁面,html 上無數據
curl 你的網站全路徑
# 模擬爬蟲、返回頁面應該帶有 title,body 等數據,方便 SEO
curl -H 'User-agent:Googlebot' 你的網站全路徑

參考資料

【1】構建時預渲染:網頁首幀優化實踐

【2】Implement dynamic rendering

【3】Google 抓取工具(用戶代理)概覽

最後分析下團隊作的一個任務管理軟件:TaskHub 文件式任務管理神器

ORLY (2).png

相關文章
相關標籤/搜索