一.webview タグ
Electron は Web ページを埋め込むための webview タグを提供しています:
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 は設計的に 6 つの概念層に分かれています:
[caption id="attachment_1689" align="alignnone" width="473"]
Chromium-conceptual-application-layers[/caption]
中間に 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 をレンダリングし、埋め込むページの内容を描画します
では、通常の場合、両者の関係は 1 対 1 であるべきです。つまり、各 webview には対応する webContents オブジェクトが 1 つあります。したがって、webview のほとんどのインターフェースは、対応する webContents オブジェクトに委譲されていると推測する理由があります。この対応関係が変わらない場合、どちらのインターフェースを使用しても同じです。例えば:
webview.addEventListener('dom-ready', onDOMReady);
// と
webview.getWebContents().on('dom-ready', onDOMReady);
機能的にはほぼ同等で、どちらもページ読み込み時に 1 回だけトリガーされます。既知の違いは、初期状態では関連する 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.例外的情況 とは、この 1 対 1 の関係が固定されておらず、手動で変更できることを指します。例えば、ある 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 はどこで見ることができるでしょうか?
[caption id="attachment_1690" align="alignnone" width="908"]
chrome-devtools-frames[/caption]
各 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 => {
// 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 にアクセスできるもう 1 つの特殊環境です。私たちがよく知っているもう 1 つの類似環境は renderer です
また、preload 属性の特徴は最初のページ読み込み時のみ実行され、その後の新しいページの読み込みでは preload スクリプトは実行されないことです
executeJavaScript
JS を注入するもう 1 つの方法は webview/webContents.executeJavaScript() を使用することです。例えば:
webview.addEventListener('dom-ready', e => {
// 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.
-
<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.
最も明らかな違いの 1 つは、一方には戻り値があり(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 を提供しており、モバイルデバイスをシミュレートするために使用できます。例えば:
// デバイスエミュレーションを有効にする
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 を使用します:
// ブラウザウィンドウを作成
let win = new BrowserWindow({width: 800, height: 600});
// ページを読み込む
mainWindow.loadURL('http://ayqy.net/m/');
// デバイスエミュレーションを有効にする
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 }
});
// DevTools を開く
win.webContents.openDevTools({
mode: 'bottom'
});
こうすれば、webview の特殊な環境の制限がなくなり、デバイスシミュレーションが非常に信頼できるようになり、touch イベントも使用可能 になります。ただし、欠点は独立したウィンドウを開く必要があり、ユーザー体験があまり良くないことです
八.スクリーンショット
webview はスクリーンショットのサポートも提供しており、contents.capturePage([rect, ]callback) を使用します。例えば:
// ページをキャプチャ
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);
5 秒後にスクリーンショットを撮影し、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 src="http://www.google.com/" nodeintegration></webview>
上記のように有効にすると、webview に読み込まれたページで Node API(require()、process など)を使用できます
P.S.preload 属性で指定された JS ファイルは、nodeintegration を有効かどうかに関わらず Node API を使用できますが、グローバル状態の変更はクリアされます:
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 情報を取得できます:
// Console メッセージをエクスポート
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 を提供しています:
// 進む
if (webview.canGoForward()) {
webview.goForward();
}
// 戻る
if (webview.canGoBack()) {
webview.goBack();
}
// リフレッシュ
webview.reload();
// loadURL
webview.loadURL(url);
完全な例は以下の Demo を参照してください。より多くの API は <webview> Tag と webContents を参照してください
十.Demo アドレス
GitHub リポジトリ:ayqy/electron-webview-quick-start
シンプルなシングルタブブラウザで、本記事で言及したすべての内容が Demo に含まれており、詳細なコメントが付いています
参考資料
-
Electron Intercept HTTP request, response on BrowserWindow: リソースリクエストのインターセプト
-
Chromium 网页加载过程简要介绍和学习计划: 再び老羅、すべてが関連している
コメントはまだありません