node.js与puppeteer试用(SSR)

目的:服务端渲染——SSR

  1. 环境及目的
    测试服务器是 ubuntuserver18.04。网站运行环境是 lnmp。网站程序基本都是 spa,所以想通过 ssr 实现 seo。不想改代码,不想改代码,不想改代码~~
  2. 安装 node 和 npm
    因为很久之前安装的,忘了步骤,大概和安装一般软件没区别吧。
  3. 建立服务文件夹
    在准备运行服务的地方建立文件夹。比如:
cd /home/wwwroot
mkdir node-render
  1. 进入文件夹安装 puppeteer
cd ./node-render
npm i puppeteer -s
  1. 测试 node
vim test.js
// 键入下面文字并保存
let a = 2
let b = 3
console.log(a * b)
// 保存并运行
node test.js
// 结果: 6
  1. 尝试通过 puppeteer 进行网站截图
    首先安装一些依赖:
sudo apt-get install -yq --no-install-recommends libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 libnss3

新建文件并键入如下内容:

// screenshoot.js
const puppeteer = require('puppeteer');
const url = process.argv[2];
if (!url) {
    throw "Please provide URL as a first argument";
}
async function run () {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto(url);
    await page.screenshot({path: 'screenshot.png'});
    browser.close();
}
run();

运行:

node screenshot.js http://www.baidu.com

如果是root用户,可能报错:

Running as root without --no-sandbox is not supported.

修改 screenshot.js 代码:

...
const browser = await puppeteer.launch({headless: true, args:['--no-sandbox']});
...

再次运行:

node screenshot.js http://www.baidu.com

稍等一会,文件夹下会多一个 screenshot.png 的图片文件。截图成功。

  1. 尝试服务端渲染(ssr)
    https://developers.google.com/web/tools/puppeteer/articles/ssr
    其实我想找一种简单的方式,比如服务器上安装一个程序,nginx 配置一下反向代理,然后就可以了。所以并没有直接按照这篇文章中的内容实现。后来实在找不到,就从 github 上下载了一个实现。
    https://github.com/wayou/ssr-demo
    这个 demo 也是根据上面的文章写的。安装好后启动。192.168.0.110:8080。访问成功。然后就是改造了。
`${req.protocol}://${req.get("host")}/public/index.html`
// 改成下面。shici.tony93-dev.top 是虚拟机中原 spa 应用的地址
`${req.protocol}://shici.tony93-dev.top/`

nginx 新增虚机并反向代理。

server {
    listen 80;
    server_name  poemssr.tony93-dev.top;

    access_log  logs/poem.access.log;
    error_log  logs/poem.error.log;
    root   /home/wwwroot/poem/;
    index  index.html index.htm index.php;


    location ~ .*\.(js|css|ico)$ {
        proxy_pass http://shici.tony93-dev.top;
    }

    location / {
        proxy_pass  http://localhost:8080;

        #Proxy Settings
        proxy_redirect     off;
        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
        proxy_max_temp_file_size 0;
        proxy_connect_timeout      90;
        proxy_send_timeout         90;
        proxy_read_timeout         90;
        proxy_buffer_size          4k;
        proxy_buffers              4 32k;
        proxy_busy_buffers_size    64k;
        proxy_temp_file_write_size 64k;
   }
}

新增网址:poemssr.tony93-dev.top并进行反向代理。代理服务器地址为:http://localhost:8080。但是静态资源依然要访问原地址。所以,后缀名为js|css|ico的文件访问地址为http://shici.tony93-dev.top

这里没有修改应用源码,所以,服务端渲染时和客户端加载后都会发起 ajax 请求,如果有随机生成的内容,那么渲染结果和加载结果将不一致。此外,只代理了首页。准确来说是所有页面请求都返回的首页。懒得改了,因为 spa 用的 hash 模式,要想每个页面都代理需要改为 history 模式。

听说百度已经可以理解 JavaScript 了。百度,加油,以后要不要服务端渲染就看你的了。


更新一下 router 采用 history 模式的配置。采用 hash 模式的缺点是 sharp 符号(#)后面的内容无法发送到服务器端,无法渲染。采用 history 模式可以解决这个问题,但服务器端需要配置一下,解决没有与路径对于的静态资源而报错的问题。关于配置,vue 官网是有例子的。
https://router.vuejs.org/zh/guide/essentials/history-mode.html

我这边是 nginx,所以配置如下:

location / {
  try_files $uri $uri/ index.html;
}

部分优化。

  • 重用 chrome 实例实现交叉渲染。
  • 中止不必要的请求。
  • 缓存页面。
    以上三点可以在https://www.jianshu.com/p/ce84c77ecd53文章中找到。
    关于缓存页面,我做了些改变,可以根据时间决定是否调用缓存。
\\ ssr.js
……
if (RENDER_CACHE.has(url)) {
    // 在缓存时间内,则用缓存,否则重新渲染
    if (Date.now() - RENDER_CACHE.get(url).time < 1000 * 10) {
      console.info(`hasCached`);
      return { html: RENDER_CACHE.get(url).html, ttRenderMs: 0 };
    }
  }
……
RENDER_CACHE.set(url, {html, time: Date.now()}); // cache rendered page.
……
  • 项目地址只用一个域名。
    实际上这个算不上优化,只是之前没做好,防止 puppeteer 访问原页面地址造成死循环,所以用了两个域名,一个域名可以直接访问,另一个需要代理到 ssr 服务,然后 ssr 访问真实域名,渲染页面。
    现在进行了修改,只用一个域名,通过 nginx 来区分访问者。如果是 ssr 中的
    headless chrome,不进行反向代理,否则反向代理到 ssr 。
        # 这些资源不进行反向代理
        location ~ .*\.(js|css|ico|json|map)$ {
            allow   all;
        }

        # 对页面进行反向代理
        location / {
            proxy_redirect     off;
            proxy_set_header   Host             $host;
            proxy_set_header   X-Real-IP        $remote_addr;
            proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
            proxy_max_temp_file_size 0;
            proxy_connect_timeout      90;
            proxy_send_timeout         90;
            proxy_read_timeout         90;
            proxy_buffer_size          4k;
            proxy_buffers              4 32k;
            proxy_busy_buffers_size    64k;
            proxy_temp_file_write_size 64k;
            set $notHeadless yes;
            if ($http_user_agent ~* "HeadlessChrome" )
            {
                set $notHeadless no;
            }
            if ($notHeadless = yes) {
                proxy_pass  http://localhost:8080;
            }
            try_files $uri $uri/ index.html;
            index  index.html;
            autoindex on;
            autoindex_localtime on;
            autoindex_exact_size off;
        }

顺带说一下 nginx 的坑。nginx 的变量类型只有字符串。set $notHeadless false;. 这里的 false 是字符串,不是布尔值。所以,if ($notHeadless). 是 true,会进入相应代码块。正确的使用方式是if ($notHeadless = true). 这样才能得到预期的结果,不进入相应代码块。

另外,nginx 没有与或非的逻辑判断。

这一条不可用。nginxif会导致try_files失效。所以还是根据不同的请求进行代理。如果是爬虫,则代理到渲染服务,否则代理到另一个端口/域名。

# 目前有效的nginx配置
server
    {
        listen 80;
        #listen [::]:80 default_server ipv6only=on;
        server_name xxx.xxx.com;
        root   /home/wwwroot/xxxx/;
        
        // 默认代理到渲染服务,可以根据 userAgent 重置 $proxy
        set $proxy http://localhost:8080;

        location ~ /\.
        {
            deny all;
        }
        
        # 这些资源不进行反向代理
        location ~ .*\.(js|css|ico|json|map)$ {
            allow   all;
        }

        # 对页面进行反向代理
        location / {
            proxy_redirect     off;
            proxy_set_header   Host             $host;
            proxy_set_header   X-Real-IP        $remote_addr;
            proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
            proxy_max_temp_file_size 0;
            proxy_connect_timeout      90;
            proxy_send_timeout         90;
            proxy_read_timeout         90;
            proxy_buffer_size          4k;
            proxy_buffers              4 32k;
            proxy_busy_buffers_size    64k;
            proxy_temp_file_write_size 64k;
            proxy_pass  $proxy;
            # try_files $uri $uri/ /index.html; # 这句不要,否则路径不对
        }

        access_log  /home/wwwlogs/poem.tony93-dev.top.log;
        error_log  /home/wwwlogs/poem.tony93-dev.top.log;
    }

server
    {
        listen 8081;
        
        server_name xxx.xxx.com; # 和上面相同,只是在不同的端口下。也可以直接设置不同的域名。渲染服务那改个地址。
        root   /home/wwwroot/xxxx/;

        location ~ /\.
        {
            deny all;
        }

        location / {
            try_files $uri $uri/ /index.html;
        }

        access_log  /home/wwwlogs/poem.tony93-dev.top.log;
        error_log  /home/wwwlogs/poem.tony93-dev.top.log;
    }

闪屏问题。

不管是 ssr 还是 预渲染,客户端接收到页面后会出现闪屏现象。因该是客户端再次渲染导致的。官网教程也提供了解决方法:https://ssr.vuejs.org/zh/guide/hydration.html

SSR:

// ssr(node.js + pupeteer, 不是 vue 官网的 ssr):
……
// 将 const 改为 let
let html = await page.content(); // serialized HTML of page DOM.
……
// 解决闪屏
html = html.replace('id="app"', 'id="app" data-server-rendered="true"')
……

预渲染:

// 预渲染
new PrerenderSpaPlugin({
      staticDir: config.build.assetsRoot,
      routes: ['/','/home','/product'],
      postProcess(context) {
        context.html = context.html.replace('id="app"', 'id="app" data-server-rendered="true"');
        return context;
      }
    })