原文连接: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 文件式任务管理神器