Electron webview完全指南

Stella981
• 阅读 1170

感谢支持ayqy个人订阅号,每周义务推送1篇(only unique one)原创精品博文,话题包括但不限于前端、Node、Android、数学(WebGL)、语文(课外书读后感)、英语(文档翻译)        
       如果觉得弱水三千,一瓢太少,可以去 http://blog.ayqy.net 看个痛快    

一.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在设计上分为六个概念层:

Electron webview完全指南

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="https://my.oschina.net/B"/>
  <iframe src="https://my.oschina.net/C"/>
</body>
</html>

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

Electron webview完全指南

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见 Tag与webContents

十.Demo地址

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

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

参考资料

  • Electron Intercept HTTP request, response on BrowserWindow:拦截资源请求

  • Chromium网页加载过程简要介绍和学习计划:又见老罗,果然一切都有关联

  • WebView详解与简单实现Android与H5互调

联系ayqy      

如果在文章中发现了什么问题,请查看原文并留下评论,ayqy看到就会回复的(不建议直接回复公众号,看不到的啦)

特别要紧的问题,可以直接微信联系ayqywx      

本文分享自微信公众号 - 前端向后(backward-fe)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
React Hooks简介
感谢支持ayqy个人订阅号,每周义务推送1篇(only_unique_one)原创精品博文,话题包括但不限于前端、Node、Android、数学(WebGL)、语文(课外书读后感)、英语(文档翻译)        如果觉得弱水三千,一瓢太少,可以去http://blog.ayqy.net看个痛快  一.出发点
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Wesley13 Wesley13
3年前
JS内存泄漏排查方法
感谢支持ayqy个人订阅号,每周义务推送1篇(only_unique_one)原创精品博文,话题包括但不限于前端、Node、Android、数学(WebGL)、语文(课外书读后感)、英语(文档翻译)        如果觉得弱水三千,一瓢太少,可以去http://blog.ayqy.net看个痛快  写在前面J
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这