nodejs抓取动态网页 全面深入理解预渲染技术体系
优采云 发布时间: 2022-06-13 09:01nodejs抓取动态网页 全面深入理解预渲染技术体系
在我们使用现代前端框架,比如Vue、React、Angular开发完毕前端项目之后,会发现这些页面输出的全是js,很不利于搜索引擎去爬取。在已经完成了单页应用(SPA)项目之后,如果要进行SEO优化的改造,这时候就可以使用预渲染技术体系。
下面将从适用场景、概念介绍、如何使用、底层原理几方面全面介绍预渲染技术的各项特性。适用场景在谈使用场景之前,首先需要明白以下几点:现代前端框架比如 Vue 在构建完成之后,页面加载的其实是一堆 js,再由 js 动态生成DOM。当我们访问页面,右键“显示网页源代码”的时候,看不到文字等内容百度、搜狗等爬虫在抓取页面的时候,不会执行页面的 js 源代码(谷歌爬虫可以执行“同步” js 来构建页面,因此 Vue 等框架开发的单页应用可以在谷歌搜索引擎中很好的被收录,这点我们不展开讨论)如果想要让百度、搜狗等收录索引我们的网站,就必须像静态 HTML 那样,让网页在右键查看源代码的时候,显示的是文字、图片等内容在这样的场景下,我们可以使用预渲染来解决此类问题。概念介绍
预渲染概念简单来讲是这样的:在项目构建阶段,我们会模拟访问项目的页面URL,这时候页面上的 js 将会执行并且形成真正的 HTML 片段;此时我们保存这个 HTML 文件到服务器硬盘上,下次不管是用户还是爬虫访问的时候,就直接返回这个物理文件,这样就可以有效解决 SEO 无法收录的问题。大体流程如下图:
可以看出,“预渲染”同“服务端渲染”最大的区别是,前者是在构建期间执行,后者在用户访问期间实时运行;前者在构建完成之后就不再需要 node服务,后者需要使用 node 作为运行时。预渲染也有它不适用的地方。正因为在构建阶段我们需要指明“需要构建的页面URL”,因此对于那些携带动态参数的页面(比如资料详情页),不适用于用预渲染去构建。这种场景下只能使用SSR。反之,一切静态官网、带数据的门户网站,只要是URL固定的,不管其中有没有动态数据,都可以使用预渲染。这里你可能会问一个问题:预渲染技术能否将 ajax 请求的内容也填充到页面上?答案是肯定的。虽然你 ajax 并非“同步执行”的 js,我们无法获知异步请求完成的时机。但通过控制监测页面上某个特定元素是否存在等手段,我们可以变相获知到数据返回的时机,从而进行预渲染。就可以达到“填充 ajax 数据”的效果(这可是谷歌爬虫也无法达到的效果哦)。如何使用预渲染技术有很多种实现方式。最常用的是使用 webpack 的一个插件: prerender-spa-plugin()。但是这个插件有很多局限性,最大的局限性在于必须结合 webpack 来使用,不够灵活,不易调试。也无法通过编程的方式使用。
比如我们的首页有动态的数据(非动态URL。是通过ajax获取的动态数据。)因此我们在使用了预渲染生成了静态首页之后,需要每间隔10分钟或者5分钟重新生成一个新的静态页,来更新数据。这就需要能够通过编程的方式调用渲染指令。在这样的情况下我基于这个插件的原理重新实现了一个包:@waitkafuka/spa-prerender(),可以通过 nodejs 通过编程的方式使用。
同时它剥离了 webpack 的依赖,剥离了对SPA页面的限制。只要有一个静态目录就可以工作。使用的方式非常简单,以 Vue 为例,在我们正常构建生成了 dist 目录之后,只需要编写以下脚本:
const SpaRenderer = require('@waitkafuka/spa-prerender');const path = require('path');<br />const options = {staticDir: path.join(__dirname, 'dist'),basePath: 'base',//可选,当项目有basePath的时候设置。同router中的base和publicPath。确保staticDir/basePath/index.html存在 routes: ['/','/exam', ],puppeteerOptions: {headless: true,maxConcurrentRoutes: 0,renderAfterDocumentEvent: 'render-event',skipThirdPartyRequests: true }};const spaRenderer = new SpaRenderer(options)spaRenderer.render().then(() => {console.log('预渲染完毕。');});
具体的参数解析可以查看 @waitkafuka/spa-prerender。底层原理在底层实现上,主要利用了 express 来驱动静态页面;然后利用 puppeteer 无头浏览器来模拟用户访问所需要预渲染的地址;在访问完成之后,保存页面HTML到文件夹中。
流程如下:
更为详细的流程介绍如下(此部分枯燥乏味,非专业人士可以跳过。):
阶段1:创建渲染器实例
spa-prerender index.js:
1.创建puppeteer实例 @prerenderer/renderer-puppeteer/es6/renderer.js
2.创建server实例@prerenderer/prerenderer/es6/server.js
3.创建renderer实例,将1 2步的puppeteer和server注入this @@prerenderer/prerenderer/es6/index.js
阶段2:渲染器实例初始化:spa-prerender index.js:
1.server初始化 (设置路由:静态资源利用*.*映射到staticDir上,其他请求全部映射到index.html上。设置静态资源和index.html路径,modifyServer也在这个阶段。server 启动)@prerenderer/prerenderer/es6/server.js
2.puppeteer初始化(判断平台是否linux,如果是Linux需要设置一些启动参数;启动puppeteer)@prerenderer/renderer-puppeteer/es6/renderer.js
阶段3:开始访问路由并获取html
1.spa-prerender index.js->@prerenderer/prerenderer/es6/index.js -> @prerenderer/renderer-puppeteer/es6/renderer.js
2.开启一个限流器,每次最多渲染 n 条路由,避免内存崩溃
3.puppeteer开启一个新标签,判断是否有inject,如果有,注入到window上
4.拼接端口、设置viewport,拼接router,形成url,发起页面的请求
5.判断options中是否设置了renderAfterDocumentEvent,如果有,给页面添加一个事件*敏*感*词*器,当触发事件的时候,将__PRERENDER_STATUS属性设置为true
6.访问页面,等待加载完成(如何确定页面加载完成, waituntil: 'networkidle0'//在500ms内没有网络请求,这个设置可以通过puppeteerOptions.navigationOptions.waituntil修改)
等待捕捉,分为三种等待时机:
1.等待固定时间,renderAfterTime(利用settimeout结合promise实现)
2.等待抛出某个事件:renderAfterDocumentEvent,该事件需要事先在页面写好抛出的时机
3.等待某个元素出现:renderAfterElementExists
阶段4:保存html
spa-prerender index.js 遍历结果,拼接文件夹路径并保存html。
实践过程中的常见问题
1. 如果一个项目有10个页面,其中首页需要SEO,于是使用了预渲染,另外9个页面仍然是由js渲染。那么在访问其他9个页面的时候,刷新页面,会先显示首页的内容一闪而过。这种问题如何解决?
产生这种情况的原因在于:在单页应用中,实际上只有一个物理页面,那就是 index.html。我们访问的所有页面实际都是 index.html,先进入 index.html 然后再执行 js 构建 dom。那么此时如果我们首页是已经预渲染好的,那么就会有内容,在 js 构建出对应路由的内容之前,就会先出现首页的内容。
spa-prerender 考虑到了这种问题。在预渲染的过程中,会首先保留原来“纯净”的index.html,并重命名为“index-spa.html”。
而后,在 nginx 配置中,利用 try_files 指令的优先级配置特点,让用户在刷新的情况下,如果有对应物理文件则访问物理文件(预渲染生成的文件),如果没有对应物理文件则降级访问 index-spa.html,就可以解决上述问题。nginx 配置指令如下:
location /{ alias /Users/zks/code/xkw/xop/dist/; try_files $uri $uri/ /index-spa.html; }
此时预渲染生成的文件将被优先返回($uri),如果未匹配,才会交由 index-spa.html 处理。
2. 如果项目打包有前缀怎么办?
如果项目有publicPath,那么在 dist 目录下会多一层根目录。此时需要在预渲染配置中添加 basePath 的选项。此时 express 会从 dist/basePath 目录中去寻找 index.html。
配置如下:
const options = { staticDir: path.join(__dirname, 'dist'), basePath: 'base',//当项目有basePath的时候设置。同router中的base和publicPath。确保staticDir/basePath/index.html存在 routes: ['/','/exam', ], ... }
3. 如何控制渲染器开始进行HTML保存的时机?
我们可以通过 options 中三个参数来控制:
3.1.renderAfterTime:等待固定时间之后(从页面开始发起访问算起)
3.2.renderAfterDocumentEvent:等待抛出某个事件,该事件需要事先在页面定义好触发的时机,比如在 Vue 的 mounted 函数中定义:
new Vue({ router,render: h => h(App), mounted () { //在mounted之后触发custom-render-event // 预渲染器在收到这个事件之后将开始进行页面的保存工作 document.dispatchEvent(new Event('custom-render-event')) }}).$mount('#app')
3.3.renderAfterElementExists:等待某个元素出现。我们可以在异步 ajax 返回之后,增加某个元素。当渲染器检测到此元素出现之后进行页面保存,此时保存的页面就包含了 ajax 返回的数据。
4. 首页预渲染生成的 DOM 元素,会不会被 js 复用?还是删除重新构建?
仿照SSR的思路,我们可以通过在根节点上设置 data-server-rander="true",这样在客户端 js 会知道这个是已经渲染完成的 DOM,不会粗暴去替换它。而是执行一个“注水”的过程(也就是hydration)。可以充分复用预渲染生成的 DOM 元素。
点击下方卡片可以关注