原文連接:https://taskhub.work/article/...
已獲做者受權轉載。javascript
前端開發技術突飛猛進,因爲現代化構建、用戶體驗的需求,angular/vue/react 等框架已經成爲開發標配,大部分應用都是 SPA,同時也帶來了不少新問題:css
- SEO 不友好
- 首屏渲染慢
爲了解決這些問題,開源社區有不少方案,本文主要對這些方案進行對比。html
React開發的SPA就是一種CSR方案,如圖所示,在到達瀏覽器以前的html頁面是沒有內容的,要等到瀏覽器執行相應異步請求獲取數據填充後才顯示界面。前端
優勢vue
缺點java
基本原理: 在服務端起一個node應用,瀏覽器到來時,先攔截執行部分 js 異步請求,提早將數據填充到 html 頁面中返回瀏覽器。這樣爬蟲抓取到的頁面就是帶數據的,有利於SEOnode
需解決問題:react
針對這些問題,社區也有相應框架可參考:webpack
框架 | 解決方案 | Github star |
---|---|---|
Vue | Nuxt.js | 28.4k |
React | Nextjs | 50.8k |
Angular | - | - |
不想使用框架,也能夠本身修改react、vue 的 render 方法實現(改動工做量更大)nginx
優勢
缺點
Solution | Github Star |
---|---|
prerender-spa-plugin | 6k |
puppeteer | 63.2k |
phantomjs | 1.4k |
基本原理: 利用webpack 等構建工具,針對 SPA 應用開發後只有一個 index.html 文件入口問題,用上述預渲染中間件在前端項目構建時預先獲取頁面數據,生成多個頁面,如 about、help 、contact 等頁面,優化首屏渲染與部分頁面SEO
優勢
缺點
迴歸到原始需求,爲了提升用戶體驗咱們用了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 方案。
上圖爲最終實現。(存在優化點:右邊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 代碼存在如下問題:
下面對這些問題逐個擊破
重複請求:
根本緣由是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有幾點明顯好處:
(重複請求只在爬蟲有js執行能力時纔出現,通常再次請求數據也沒問題)
主體 | user-agent | 用途 |
---|---|---|
googlebot | 搜索引擎 | |
google-structured-data-testing-tool | 測試工具 | |
Mediapartners-Google | Adsense廣告網頁被訪問後,爬蟲就來訪 | |
Microsoft | bingbot | 搜索引擎 |
Linked | linkedinbot | 應用內搜索 |
百度 | baiduspider | 搜索引擎 |
奇虎 360 | 360Spider | 搜索引擎 |
搜狗 | Sogou Spider | 搜索引擎 |
Yahoo | Yahoo! Slurp China | 搜索引擎 |
Yahoo | Yahoo! Slurp | 搜索引擎 |
twitterbot | 應用內搜索 | |
facebookexternalhit | 應用內搜索 | |
- | rogerbot | - |
- | embedly | - |
Quora | quora link preview | - |
- | showyoubot | - |
- | outbrain | - |
- | - | |
- | slackbot | - |
- | vkShare | - |
- | W3C_Validator | - |
# 不帶 user-agent 返回SPA頁面,html 上無數據 curl 你的網站全路徑 # 模擬爬蟲、返回頁面應該帶有 title,body 等數據,方便 SEO curl -H 'User-agent:Googlebot' 你的網站全路徑
【2】Implement dynamic rendering
最後分析下團隊作的一個任務管理軟件:TaskHub 文件式任務管理神器