微信号:ardays

介绍:android每日绝对干货

探索 headless chrome

2017-12-05 22:27 陈宁

headless 简介

headless chrome 来了,你现在可以在 headless/server 环境中运行浏览器了。什么?让浏览器运行在没有界面的服务器端环境中,那浏览器可以用来干嘛。

想象一下在每次在发版之前,测试都需要测试系统的功能,重复且乏味。于是你决定让程序自动来测试界面上的功能。你不需要浏览器有 GUI 界面,你想通过编程的方法来驱动浏览器进行各种操作,并且希望能在服务器端运行,这样每次发版前就可以自动测试相关功能,提高测试效率。

以上只是一个应用场景,headless 浏览器可以理解为没有 GUI 界面的浏览器程序。由于没有界面,所以在速度上比普通浏览器稍快,它可以在自动化测试、性能检查、获取元数据(例如爬虫)和网页截图等方面发挥用途。


对比

在 chrome 浏览器还没有原生支持 headless 之前,早期浏览器可以通过 Xvfb 服务处理图形显示从而实现 headless 模式,近期火狐也在积极研发原生支持 headless 模式,预计在 firefox 56 版本实现。还有一种是方案是通过封装浏览器内核来实现 headless。比较知名的比如 phantomJS(目前仅维护) 封装了 QTWekit 内核, 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 用 nodejs 实现的 chrome-launcher 库,这个库会自动寻找系统上安装的 chrome 程序的位置,然后通过 child_process 模块来启动chrome 浏览器。

同时 headless 也支持被嵌入到 c++ 程序中,从而可以更加底层的控制浏览器。

当启动完 headless 浏览器后,mac 上会出现 chrome 的图标,但是并不能打开看到界面。然后我们可以通过浏览器访问相应的远程调试端口来参看相应的调试界面,除了客户端能通过远程接口访问外,还可以通过编程实现 DevTools 协议来和浏览器进行相关的通信,从而实现对页面的控制。


架构

 headless chrome 架构图(来自网络)

headless chrome 主要实现了两个功能,一个是实现了 headless api 的 headless shell 应用程序,从命令行参数启动 headless 模式即是启动 headless shell。一个是 headless library,它实现了嵌入式应用程序能控制浏览器并与网页交互的功能。

如果你是通过 c++ 程序嵌入的话,就可以用 headless library 来和浏览器进行通信。浏览器和外界通信有一套协议称为 DevTools 协议。client api 是基于 chrome devTools 协议实现的一套可以和浏览器交互的库。除了上面说的,还有许多库实现 devTools 协议,比如官方推荐的采用 nodejs 实现的 chrome-remote-interface 库,或者采用 python 实现的 chromote 库等。


DevTools 协议

chrome DevTools 协议是一套可以用来和 chrome 浏览器通信的协议,平常我们开发调试 chrome 程序用的开发者工具即是基于该协议实现的一个网页程序。chrome 开发者工具通过 socket 来和 chrome 进行通信,浏览器中的一个 tab 页面即对应一个socket 通道。然后互相进行数据交换,从而实现对网页的检查,调试和监控等功能。

我们可以用命令行参数在客户端来远程调试页面。在命令行中加入参数 —remote-debugging-port=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 方案不需要后端是 nodejs。其实本质 prerender 只是提供一个假的静态首页预先给客户看到样式。不具备应用的功能。

在目前的 SPA 网站中,首屏大多会有一个 id 为 app 的元素。等框架资源加载完成后,框架会动态替换 app 元素为真正的应用样子。而在资源尤其是打包后的 js 文件没加载完成之前。页面基本处于白屏的状态。而 prerender 正是希望用一个固定的样式来代替这个白屏的状态的。目前实现预渲染的简单方案可以采用几张图片。来给用户直观的应用布局样式。从而增加用户等待的时长。复杂一点可以采用 webpack 将预先写好的样式组件打包后内联写入首屏页面,包括写入 js 脚本,写入 html 和 css 等。让用户可以快速了解应用的名字,整体颜色布局信息。 具体做成什么样。需要由应用本身来决定。但是不希望在首页中过多的内嵌代码,否则拖慢初始加载速度导致后续资源加载变慢的话,预渲染效果也不理想。

我们可以用 headless 来实现预渲染,有两种预渲染方案。

一种是在服务器端,当请求过来后,把请求动态挂在到 headless chrome 里,然后把 chrome 里面的 dom 拿到后返回给客户端,这个也可以做成 spa 应用程序通用的 SEO 优化方案。

另一种是在代码发布阶段将静态样式内嵌写入网站首页里面,通过在打包阶段开启静态服务器,然后用 headless chrome 来访问对应的网站并且得到网站的 dom。和骨架图不同的是,这时候的 dom 应该是网站真是渲染后的 dom,在实际应用中,会碰到渲染出页面结构会含有开发时候的脏数据问题,如果把开发时候的数据去掉,那么会影响整体页面的布局,因为有的布局是靠内容撑起来的。所以我们采用了字符替换的方法,把文字数据替换为 &nbsp,这样既保留了站位,又去掉了脏数据。对于图片的处理需要把图片的 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}>&nbsp;</${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 效果图: 


实际中还发现几个问题,一是如果首屏展示在不同的机器上需要对应不同的效果,就需要自己手动写入 js 文件来动态实现,比较麻烦。二是会发现处理完后还是留有杂的 dom 元素,影响效果,所以还需要深度清理下数据才行。


总结

headless 可以帮助开发者更好的进行自动化测试,由于是浏览器原生支持,所以比其他方式实现的 headless 更加稳定,占用内存小,也不容易出现难以解决的问题。不过目前相关的库比较少,如果需要新的功能,需要自己写相应的实现。抛砖引玉,headless 还有许多有趣的用法等着大家一起挖掘。


 
Android每日干货 更多文章 探索ES2015:箭头函数(Arrow Functions) 微信浏览器踩坑集锦 详解Object.defineProperty() 基于css选择器设计交互效果时的一些思考 CSS学习笔记(渐变色文本)
猜您喜欢 Java虚拟机OOM之虚拟机栈和本地方法栈溢出(4) 【交流分享】多终端架构 达内第四季度财报解读:一个更好的达内 8个类搞定插件化— Service 实现方案 [译]我们是如何优化英雄联盟的代码的