一.webview 태그
Electron 은 웹 페이지를 임베딩하기 위한 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 객체가 하나씩 있습니다. 따라서 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.예외적인 상황 이란 이 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 과 관련이 있습니다. 하지만 이해하기 쉽습니다. 웹 환경에서는 매일 보니까요. 예를 들어 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 주입
두 가지 주입 방법이 있습니다:
-
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 => {
// 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.
가장 명확한 차이 중 하나는 하나는 반환 값이 있고 (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 网页加载过程简要介绍和学习计划: 다시 노루, 모든 것이 관련되어 있음
아직 댓글이 없습니다