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
中間有一層叫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');
});
}
});
所以,webContents的dom-ready缺少了第一次,單從該場景看,webview的dom-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,樹上每個節點代表一個頁面。例如:
<iframe src="/B"/>
<iframe src-"/C"/>
瀏覽器打開這個頁面的話,Frame Tree上會有3個節點,分別代表A,B,C頁面。那么,在哪里能看到Frame呢?

chrome-devtools-frames
每個Frame對應一個頁面,每個頁面都有自己的window對象,在這里切換window上下文
四.重寫新窗體跳轉
webview默認只支持在當前窗體打開的鏈接跳轉(如_self),對於要求在新窗體打開的,會靜默失敗,例如:
window.open('http://www.ayqy.net/', '_blank');
此類跳轉沒有任何反應,不會開個新“窗體”,也不會在當前頁加載目標頁面,需要重寫掉這種默認行為:
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;
}
`);
簡單有效,看似已經搞定了。實際上跳頁或者刷新,注入的樣式就沒了,所以應該在需要的時候再補一發,這樣做:
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屬性,本文最后有提到)
注意,webview與webContents身上都有這個接口,但存在差異:
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.
.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.
大致原因是不允許重寫webview的display值,只能是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加載的頁面里使用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的場景,為了方便調試,可以通過webview的console-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);