## 探索 Headless Chrome 文/陈宁 >Headless 模式,较常应用于自动化测试、网络爬虫、自动截图等场景中。本文深入解读了这一模式,并实战分享了利用 Headless 实现预渲染的过程。 ### Headless 简介 Headless Chrome 来了,你现在可以在 Headless/server 环境中运行浏览器了。什么?让浏览器运行在没有界面的服务器端环境中,那浏览器可以用来干嘛。 想象一下每次在发版前,测试人员都需要测试系统的功能,重复且乏味。于是你决定让程序自动测试界面上的功能。你不需要浏览器有 GUI 界面,想通过编程的方法来驱动浏览器进行各种操作,并且希望能在服务器端运行,这样每次发版前就可以自动测试相关功能,提高测试效率。 以上只是一个应用场景,Headless 浏览器可以理解为没有 GUI 界面的浏览器程序。由于没有界面,所以在速度上比普通浏览器稍快,它可以在自动化测试、性能检查、获取元数据(例如爬虫)和网页截图等方面发挥用途。 ### 对比 在 Chrome 浏览器还没有原生支持 Headless 之前,早期浏览器可以通过 Xvfb 服务处理图形显示从而实现 Headless 模式,近期火狐也在积极研发原生支持 Headless 模式,预计在 Firefox 56 版本中实现。还有一种方案是通过封装浏览器内核来实现 Headless。比较知名的比如 PhantomJS(目前仅维护)封装了 QtWebKit 内核,SlimerJS 封装了 Gecko 内核,TrifleJS 封装了 IE 内核。 而使用这些框架的时候,可能会出现很多奇怪的问题。这些程序是运行在封闭环境中的,所以会导致和外部通信很繁琐,并且由于采用的内核比较老,从而很多新特性,新语法不支持,并非真实的用户环境。所以提倡用 Headless 模式替代这些框架,从而获得更好的效果。 ### 使用 Chrome Beta 59开始在 Liunx、Mac、Window(Chrome 60)上支持 Headless 模式。下载并安装好相应版本的浏览器后,可以有多种方式来启动 Chrome Headless 模式。 通过命令行参数— Headless来启动: $ /Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary -- Headless —remote-debugging-port=9222 另外也可以采用封装好的 Chrome 启动库来达到多个平台兼容启动,如 Lighthouse 用 Node.js 实现的 chrome-launcher 库,自动寻找系统中 Chrome 程序的安装位置,然后通过 child _ process 模块来启动 Chrome 浏览器。 同时 Headless 也支持被嵌入到 C++ 程序中,从而可以更加底层地控制浏览器。 当启动完 Headless 浏览器后,Mac 上会出现 Chrome 的图标,但是并不能打开看到界面。然后我们可以通过浏览器访问相应的远程调试端口来参看相应的调试界面。除了客户端能通过远程接口访问外,还可以通过编程实现 DevTools 协议来和浏览器进行相关的通信,从而实现对页面的控制。 图1   Headless Chrome架构图 图1 Headless Chrome 架构图 ### 架构 图1为 Headless Chrome架构图。Headless Chrome 主要实现了两个功能,一个是实现了 Headless API 的 Headless shell 应用程序,通过命令行参数启动 Headless 模式,即启动 Headless shell。一个是 Headless library,它实现了嵌入式应用程序能控制浏览器并与网页交互的功能。 如果你是通过 C++ 程序嵌入的话,就可以用 Headless library 来和浏览器进行通信。浏览器和外界通信有一套协议称为 DevTools。Client API 是基于 Chrome DevTools 协议实现的一套可以和浏览器交互的库。除了上面说的,还有许多库实现 DevTools,如官方推荐采用 Node.js 实现的 chrome-remote-interface 库,或者采用 Python 实现的 chromote 库等。 ### DevTools 协议 Chrome DevTools 是一套可以用来和 Chrome 浏览器通信的协议,平常我们开发调试 Chrome 程序用的开发者工具即是基于该协议实现的一个网页程序。Chrome 开发者工具通过 Socket 和 Chrome 进行通信,浏览器中的一个 Tab 页面即对应一个 Socket 通道。然后互相进行数据交换,从而实现对网页的检查、调试和监控等功能。 我们可以用命令行参数在客户端来远程调试页面。在命令行中加入参数“—remote-debuggingport=9222”启动 Chrome 后,在浏览器进入“localhost:9222”即可看到调试界面。其中我们可以通过网络面板中的 WebSocket 连接来查看调试程序和 Chrome 进行的数据收发。我们可以把该协议当成浏览器的 API,想实现什么功能,需要发送固定格式的信息过去,浏览器接收后会返回相应的数据。 ### Headless 应用 在自动化测试和网络爬虫等领域会经常用到该项新特性。 #### 自动化测试 自动化测试有许多的框架,比较好用的比如 Nightmare,这是一款基于 Electron 的自动化测试库,语法漂亮好用。最近这个库也计划从 Electron 迁移到 Headless Chrome。我们也可以结合 Karma 来实现 UI 的自动化测试,这样可以保证代码在真实环境中运行。 #### 网络爬虫 网络爬虫应用在以前的方案中,会有较多问题,比如数据抓取不全。现在很多的网站都做成了单页应用,采用 AJAX 交互,传统爬虫能拿到的数据有限,如果不执行前端代码,就拿不到有用的信息。此时,我们可以用 Headless Chrome 来执行相关的代码,将页面执行完成后,在对相应的页面进行分析。这比其他方案能有更好的稳定性,不过由于目前这方面的库还不是很成熟,导致需要自己去写一些底层的实现,在开发效率上会比较慢。 #### 自动截图 自动截图也可以被应用到 Headless 中。在前端代码报错后,如果希望能把当前错误页面的截图并发给监控程序,目前的纯前端做法可以采用 html2canvas。但用过这个库后,你会发现有的截图效果很不理想,和原来的界面差距较大。那可以换个角度,采用后端截图的方法。由于页面展示本质是 HTML 和 CSS。我们可以在服务器端部署 Chrome Headless 服务器,里面加载对应网站的资源,等前端报错后,只需将前端整个页面的 Dom 数据 发送给服务器,服务器把相应内容的 Dom 替换后,由于CSS 一般是提取出来的,所以客户端和服务端样式表一致,Dom 结构一致,数据一致,即可以将服务器端截图并发送给监控程序。 ### 实战预渲染 Prerender 和 Server-side render(SSR)两种技术都是解决首屏渲染问题,以此来提高用户体验的方案。Prerender 方案不需要后端是 Node.js。其实本质上,Prerender只提供一个假的静态首页预先给客户看到样式,不具备应用的功能。 在目前的 SPA 网站中,首屏大多会有一个 id 为 app 的元素。等框架资源加载完成后,框架会动态替换 app 元素为真正的应用样子。而在资源尤其是打包后的 JavaScript 文件没加载完成之前,页面基本处于白屏的状态。而 Prerender 正是希望用一个固定的样式来代替这个白屏的状态。 目前实现预渲染的简单方案可以采用几张图片,来给用户直观的应用布局样式,从而增加用户等待的时长。复杂一点可以采用 webpack 将预先写好的样式组件打包后内联写入首屏页面,包括写入 JavaScript 脚本,写入 HTML 和 CSS 等。让用户可以快速了解应用的名字,整体颜色布局信息。具体做成什么样,需要由应用本身来决定。但不希望在首页中有过多的内嵌代码,否则拖慢初始加载速度导致后续资源加载变慢的话,预渲染效果也会不理想。 我们可以用 Headless 来实现预渲染,有两种预渲染方案。 一种是在服务器端,当请求过来后,把请求动态挂在 Headless Chrome 里,然后把 Chrome 里面的 Dom 拿到后返回给客户端,这个也可以做成 SPA 应用程序通用的 SEO 优化方案。 另一种是在代码发布阶段将静态样式内嵌写入网站首页。在打包阶段开启静态服务器,然后用 Headless Chrome 来访问对应的网站,并得到网站的 Dom。和骨架图不同的是,这时候的 Dom 应该是网站真实渲染后的 Dom。 在实际应用中,会碰到渲染出页面结构含有开发时脏数据问题。如果把开发时的数据去掉,会影响整体页面的布局,因为有的布局是靠内容撑起来的。所以我们采用了字符替换的方法,把文字数据替换为 ,这样既保留了占位,又去掉了脏数据。对于图片的处理需要把图片的 href 更换为默认 URL 图片,有的 icon 如果是内联数据,需要去掉。总之一个原则,让页面初始加载骨架看起来和真实结构一致。可以采用在编码的时候,在元素的属性上设置标志符,来表明文字或者图片是否需要被替换。处理完后,需要有效果,其前提是把 CSS 文件在打包的时候单独提取出来,这样在初始加载时才会有效果。然后通过 webpack 打包把处理后的 Dom 数据内嵌到首页中。当用户首次访问的时候,首页就已内嵌有了对应的 Dom 结构,让用户对网站布局有个大概的感知,减少用户等待时间。 示例代码请见下。 ``` const chromeLauncher = require(‘chrome-launcher'); const CDP = require('chrome-remote-interface'); function delay(time) { time = time || 0; return new Promise((resolve, reject) => { setTimeout(function() { resolve(); }, time); }) } async function preRender() { // open chrome const chrome = await chromeLauncher.launch({ port: 9222, }); const { Page, DOM } = await CDP(); await Promise.all([ Page.enable(), DOM.enable(), ]); await Page.navigate({ url: 'https://h5.ele.me/market/#/home' }); await Page.loadEventFired(); // wait for loading data await delay(3000); const rootNode = await DOM.getDocument(); const appNode = await DOM.querySelector({ nodeId: rootNode.root.nodeId,selector: '#app' }); // replace product data to clear data const needReplaceFlag = '#app [shell-replace]'; const defaultImage = 'http://defaultImage.com'; const replaceNode = await DOM.querySelectorAll({ nodeId:rootNode.root.nodeId, selector: needReplaceFlag }); replaceNode.nodeIds.length && await new Promise((resolve, reject) => { const tasks = []; replaceNode.nodeIds.forEach(nodeId => { try { const task = DOM.getOuterHTML({ nodeId }).then(html => { const nodeName = html.outerHTML.split('>')[0].slice(1).split(' ')[0]; if (nodeName === 'img') { return DOM.setAttributeValue({ nodeId, name: 'src',value: defaultImage }); } else { return DOM.setOuterHTML({ nodeId, outerHTML: `<${nodeName}> ` }); } }); tasks.push(task); } catch (e) { reject(e); }; }); Promise.all(tasks).then(() => { resolve(); }).catch(e => reject(e)); }); const shellHTML = await DOM.getOuterHTML({ nodeId: appNode.nodeId }); } ``` 处理后 shell 效果图,见图2。 图2   处理后shell效果图 图2 处理后 shell 效果图 实际中还发现几个问题,一是如果首屏展示在不同的机器上需要对应不同的效果,就需要自己手动写入 JavaScript 文件来动态实现,比较麻烦。二是会发现处理完后还是留有杂的 Dom 元素,影响效果,所以还需要深度清理下数据才行。 ### 总结 Headless可以帮助开发者更好地进行自动化测试,由于是浏览器原生支持,所以比其他方式实现的 Headless 更加稳定,占用内存小,也不容易出现难以解决的问题。不过目前相关的库比较少,如果需要新的功能,需要自己写相应的实现。本文抛砖引玉,Headless 还有许多有趣的用法等着大家一起挖掘。