Electron webview完全指南

一.webview标签

Electron提供了webview标签,用来嵌入Web页面:

Display external web content in an isolated frame and process.

作用上类似于HTML里的iframe标签,但跑在独立进程中,主要出于安全性考虑

从应用场景来看,类似于于Android的WebView,外部对嵌入页面的控制权较大,包括CSS/JS注入、资源拦截等,而嵌入页面对外部的影响很小,是个相对安全的沙盒,例如仅可以通过一些特定方式与外部通信(如Android的addJavascriptInterface()

二.webContents

BrowserWindow一样,webview也拥有与之关联的webContents对象

本质上,webContents是个EventEmitter,用来连通页面与外部环境:

webContents is an EventEmitter. It is responsible for rendering and controlling a web page and is a property of the BrowserWindow object.

三.webContents与webview的关系

从API列表上来看,似乎webContents身上的大多数接口,在webview身上也有,那么二者是什么关系

这个问题不太容易弄明白,文档及GitHub都没有相关信息。实际上,这个问题与Electron关系不大,与Chromium有关

Chromium在设计上分为六个概念层:

Chromium-conceptual-application-layers

Chromium-conceptual-application-layers

中间有一层叫webContents

WebContents: A reusable component that is the main class of the Content module. It’s easily embeddable to allow multiprocess rendering of HTML into a view. See the content module pages for more information.

(引自How Chromium Displays Web Pages

用于在指定的视图区域渲染HTML

暂时回到Electron上下文,视图区域当然由webview标签来指定,我们通过宽高/布局来圈定这块区域。确定了画布之后,与webview关联的webContents对象负责渲染HTML,把要嵌入的页面内容画上去

那么,正常情况下,二者的关系应该是一对一的,即每个webview都有一个与之关联的webContents对象,所以,有理由猜测webview身上的大多数接口,应该都只是代理到对应的webContents对象,如果这个对应关系保持不变,那么用谁身上的接口应该都一样,比如:

webview.addEventListener('dom-ready', onDOMReady);
// 与
webview.getWebContents().on('dom-ready', onDOMReady);

在功能上差不多等价,都只在页面载入时触发一次,已知的区别是初始时还没有关联webContents对象,要等到webview第一次dom-ready才能拿到关联的webContents对象:

webview.addEventListener('dom-ready', () => {
  console.log('webiew dom-ready');
});
//!!! Uncaught TypeError: webview.getWebContents is not a function
const webContents = webview.getWebContents();

需要这样做:

let webContents;
webview.addEventListener('dom-ready', e => {
  console.log('webiew dom-ready');
  if (!webContents) {
    webContents = webview.getWebContents();
    webContents.on('dom-ready', e => {
      console.log('webContents dom-ready');
    });
  }
});

所以,webContentsdom-ready缺少了第一次,单从该场景看,webviewdom-ready事件更符合预期

P.S.异常情况指的是,这个一对一关系并非固定不变,而是可以手动修改的,比如能够把某个webview对应的DevTools塞进另一个webview,具体见Add API to set arbitrary WebContents as devtools

P.S.当然,Electron的webContents与Chromium的webContents确实有紧密联系,但二者从概念上和实现上都是完全不同的,Chromium的webContents明显是负责干活的,而Electron的webContents只是个EventEmitter,一方面把内部状态暴露出去(事件),另一方面提供接口允许从外部影响内部状态和行为(方法)

Frame

除了webContents,还会经常见到Frame这个概念,同样与Chromium有关。但很容易理解,因为Web环境天天见,比如iframe

每个webContents对象都关联一个Frame Tree,树上每个节点代表一个页面。例如:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>A</title>
</head>
<body>
  <iframe src="/B"/>
  <iframe src="/C"/>
</body>
</html>

浏览器打开这个页面的话,Frame Tree上会有3个节点,分别代表A,B,C页面。那么,在哪里能看到Frame呢?

chrome-devtools-frames

chrome-devtools-frames

每个Frame对应一个页面,每个页面都有自己的window对象,在这里切换window上下文

四.重写新窗体跳转

webview默认只支持在当前窗体打开的链接跳转(如_self),对于要求在新窗体打开的,会静默失败,例如:

<a href="http://www.ayqy.net/" target="_blank">黯羽轻扬</a>
<script>
  window.open('http://www.ayqy.net/', '_blank');
</script>

此类跳转没有任何反应,不会开个新“窗体”,也不会在当前页加载目标页面,需要重写掉这种默认行为:

webview.addEventListener('dom-ready', () => {
  const webContents = webview.getWebContents();
  webContents.on('new-window', (event, url) => {
    event.preventDefault();
    webview.loadURL(url);
  });
});

阻止默认行为,并在当前webview加载目标页面

P.S.有个allowpopups属性也与window.open()有关,说是默认false不允许弹窗,实际使用没发现有什么作用,具体见allowpopups

五.注入CSS

可以通过insertCSS(cssString)方法注入CSS,例如:

webview.insertCSS(`
  body, p {
    color: #ccc !important;
    background-color: #333 !important;
  }
`);

简单有效,看似已经搞定了。实际上跳页或者刷新,注入的样式就没了,所以应该在需要的时候再补一发,这样做:

webview.addEventListener('dom-ready', e => {
  // Inject CSS
  injectCSS();
});

每次加载新页或刷新都会触发dom-ready事件,在这里注入,恰到好处

六.注入JS

有2种注入方式:

  • preload属性

  • executeJavaScript()方法

preload

preload属性能够在webview内所有脚本执行之前,先执行指定的脚本

注意,要求其值必须file协议或者asar协议:

The protocol of script’s URL must be either file: or asar:, because it will be loaded by require in guest page under the hood.

所以,要稍微麻烦一些:

// preload
const preloadFile = 'file://' + require('path').resolve('./preload.js');
webview.setAttribute('preload', preloadFile);

preload环境可以使用Node API,所以,又一个既能用Node API,又能访问DOM、BOM的特殊环境,我们熟悉的另一个类似环境是renderer

另外,preload属性的特点是只在第一次加载页面时执行,后续加载新页不会再执行preload脚本

executeJavaScript

另一种注入JS的方式是通过webview/webContents.executeJavaScript()来做,例如:

webview.addEventListener('dom-ready', e => {
  // Inject JS
  webview.executeJavaScript(`console.log('open <' + document.title + '> at ${new Date().toLocaleString()}')`);
});

executeJavaScript在时机上更灵活一些,可以在每个页面随时注入(比如像注入CSS一样,dom-ready时候补一发,实现整站注入),但默认无法访问Node API(需要开启nodeintegration属性,本文最后有提到)

注意,webviewwebContents身上都有这个接口,但存在差异:

  • contents.executeJavaScript(code[, userGesture, callback])

    Returns Promise – A promise that resolves with the result of the executed code or is rejected if the result of the code is a rejected promise.

  • <webview>.executeJavaScript(code[, userGesture, callback])

    Evaluates code in page. If userGesture is set, it will create the user gesture context in the page. HTML APIs like requestFullScreen, which require user action, can take advantage of this option for automation.

最明显的区别是一个有返回值(返回Promise),一个没有返回值,例如:

webContents.executeJavaScript(`1 + 2`, false, result =>
  console.log('webContents exec callback: ' + result)
).then(result =>
  console.log('webContents exec then: ' + result)
);
// 而webview只能通过回调来取
webview.executeJavaScript(`3 + 2`, false, result =>
  console.log('webview exec callback: ' + result)
)
// Uncaught TypeError: Cannot read property 'then' of undefined
// .then(result => console.log('webview exec then: ' + result))

从作用上没感受到太大区别,但这样的API设计确实让人有些混乱

七.移动设备模拟

webview提供了设备模拟API,可以用来模拟移动设备,例如:

// Enable Device Emulation
webContents.setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1');
const size = {
  width: 320,
  height: 480
};
webContents.enableDeviceEmulation({
  screenPosition: 'mobile',
  screenSize: size,
  viewSize: size
});

但实际效果很弱,不支持touch事件。另外,通过webview/webContents.openDevTools()打开的Chrome DevTools也不带Toggle device按钮(小手机图标),相关讨论具体见webview doesn’t render and support deviceEmulation

所以,要像浏览器DevTools一样模拟移动设备的话,用webview是做不到的

那么,可以通过另一种更粗暴的方式来做,开个BrowserWindow,用它的DevTools:

// Create the browser window.
let win = new BrowserWindow({width: 800, height: 600});
// Load page
mainWindow.loadURL('http://ayqy.net/m/');

// Enable device emulation
const webContents = win.webContents;
webContents.enableDeviceEmulation({
  screenPosition: 'mobile',
  screenSize: { width: 480, height: 640 },
  deviceScaleFactor: 0,
  viewPosition: { x: 0, y: 0 },
  viewSize: { width: 480, height: 640 },
  fitToView: false,
  offset: { x: 0, y: 0 }
});

// Open the DevTools.
win.webContents.openDevTools({
  mode: 'bottom'
});

这样就不存在webview特殊环境的限制了,设备模拟非常靠谱,touch事件也是可用的。但缺点是要开独立窗体,体验比较难受

八.截图

webview还提供了截图支持,contents.capturePage([rect, ]callback),例如:

// Capture page
const delay = 5000;
setTimeout(() => {
  webContents.capturePage(image => {
    const base64 = image.toDataURL();
    // 用另一个webview把截屏展示出来
    captureWebview.loadURL(base64);
    // 写入本地文件
    const buffer = image.toPNG();
    const fs = require('fs');
    const tmpFile = '/tmp/page.png';
    fs.open(tmpFile, 'w', (err, fd) => {
      if (err) throw err;
      fs.write(fd, buffer, (err, bytes) => {
        if (err) throw err;
        console.log(`write ${bytes}B to ${tmpFile}`);
      })
    });
  });
}, delay);

5s后截屏,不传rect默认截整屏(不是整页,长图不用想了,不支持),返回的是个NativeImage实例,想怎么捏就怎么捏

P.S.实际使用发现,webview设备模拟再截屏,截到的东西是不带模拟的。。。而BrowserWindow开的设备模拟截屏是正常的

九.其它问题及注意事项

1.控制webview显示隐藏

常规做法是webview.style.display = hidden ? 'none' : '',但会引发一些奇怪的问题,比如页面内容区域变小了

webview has issues being hidden using the hidden attribute or using display: none;. It can cause unusual rendering behaviour within its child browserplugin object and the web page is reloaded when the webview is un-hidden. The recommended approach is to hide the webview using visibility: hidden.

大致原因是不允许重写webviewdisplay值,只能是flex/inline-flex,其它值会引发奇怪问题

官方建议采用:visibility: hidden来隐藏webview,但仍然占据空间,不一定能满足布局需要。社区有一种替代display: none的方法:

webview.hidden { width: 0px; height: 0px; flex: 0 1; }

P.S.关于显示隐藏webview的更多讨论,见webview contents don’t get properly resized if window is resized when webview is hidden

2.允许webview访问Node API

webview标签有个nodeintegration属性,用来开启Node API访问权限,默认不开

<webview src="http://www.google.com/" nodeintegration></webview>

像上面开了之后可以在webview加载的页面里使用Node API,如require()process

P.S.preload属性指定的JS文件允许使用Node API,无论开不开nodeintegration,但全局状态修改会被清掉:

When the guest page doesn’t have node integration this script will still have access to all Node APIs, but global objects injected by Node will be deleted after this script has finished executing.

3.导出Console信息

对于注入JS的场景,为了方便调试,可以通过webviewconsole-message事件拿到Console信息:

// Export console message
webview.addEventListener('console-message', e => {
  console.log('webview: ' + e.message);
});

能满足一般调试需要,但缺陷是,消息是跨进程通信传过来的,所以e.message会被强转字符串,所以输出的对象会变成toString()后的[object Object]

4.webview与renderer通信

有内置的IPC机制,简单方便,例如:

// renderer环境
webview.addEventListener('ipc-message', (event) => {
  //! 消息属性叫channel,有些奇怪,但就是这样
  console.log(event.channel)
})
webview.send('our-secrets', 'ping')

// webview环境
const {ipcRenderer} = require('electron')
ipcRenderer.on('our-secrets', (e, message) => {
  console.log(message);
  ipcRenderer.sendToHost('pong pong')
})

P.S.webview环境部分可以通过注入JS小节提到的preload属性来完成

如果处理了上一条提到的console-message事件,将看到Console输出:

webview: ping
pong pong

5.前进/后退/刷新/地址跳转

webview默认没有提供这些控件(不像video标签之类的),但提供了用来实现这些行为的API,如下:

// Forwards
if (webview.canGoForward()) {
  webview.goForward();
}
// Backwords
if (webview.canGoBack()) {
  webview.goBack();
}
// Refresh
webview.reload();
// loadURL
webview.loadURL(url);

完整示例见下面Demo,更多API见<webview> TagwebContents

十.Demo地址

GitHub仓库:ayqy/electron-webview-quick-start

一个简单的单tab浏览器,本文中提到的所有内容在Demo中都有涉及,注释详尽

参考资料

Electron webview完全指南》上有4条评论

    1. Gary

      梳理得真好,是仅见的完整文章。进一步请教: 有没有合适的方法在主窗口中获取webview里DOM的内容,而非仅用console.log 打印出来?

      回复
      1. onaug6th

        如果考虑在主进程中操作webview,可以使用browserView来替代webview

        回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code