За последние несколько месяцев мы внесли множество улучшений в движок рендеринга Microsoft Edge (EdgeHTML), делая особый акцент на совместимости с современными браузерами и соответствии новым и грядущим стандартам. Помимо того, что EdgeHTML лежит в основе браузера Microsoft Edge, он также доступен для приложений на Universal Windows Platform (UWP) через элемент управления WebView. Сегодня мы хотим рассказать, как можно использовать WebView для создания своего браузера в Windows 10.

Используя стандартные веб-технологии, включая JavaScript, HTML и CSS, мы создали простое UWP-приложение, которое содержит внутри WebView и реализует базовую функциональность: навигацию и работу с избранным. Подобные приемы могут быть использованы в любом UWP-приложении для прозрачной интеграции веб-контента.



В основе нашего примера лежит мощный элемент управления WebView. Помимо комплексного набора API, данный элемент также позволяет преодолеть некоторые ограничения, присущие iframe, например, отслеживание фреймов (когда некоторый сайт меняет свое поведение в случае выполнения внутри iframe) и сложность определения загрузки документа. В дополнение x-ms-webview, — так WebView задается в HTML, — дает доступ к функциональности, не доступной в iframe, в частности, улучшенный доступ к локальному контенту и возможности делать снимки содержимого. Когда вы используете элемент управления WebView, вы получаете тот же самый движок, что и в Microsoft Edge.

Создаем браузер


Как было написано выше, браузер базируется на элементе управления WebView для HTML, а для создания и оживления пользовательского интерфейса в основном используется JavaScript. Проект создан в Visual Studio 2015 и представляет собой универсальное Windows-приложение на JavaScript.

Помимо JavaScript, мы также использовали немного HTML и CSS, а также некоторое количество строк кода на C++ для поддержки комбинаций клавиш, но это не требуется в простом случае.

Также мы пользуемся новыми возможностями нового ECMAScript 2015 (ES2015), поддерживаемыми в Chakra, JavaScript-движке, работающем в Microsoft Edge и элементе управления WebView. ES2015 позволил нам сократить количество генерируемого и шаблонного кода, тем самым существенно упростив реализацию идеи. Мы использовали следующие возможности ES2015 при создании приложения: Array.from(), Array.prototype.find(), arrow functions, method properties, const, for-of, let, Map, Object.assign(), Promises, property shorthands, Proxies, spread operator, String.prototype.includes(), String.prototype.startsWith(), Symbols, template strings и Unicode code point escapes.

Интерфейс пользователя


Пользовательский интерфейс включает следующие десять компонентов:

  • Заголовок
  • Кнопка назад
  • Кнопка вперед
  • Кнопка обновления
  • Favicon
  • Адресная строка
  • Кнопка «пошарить в Твиттере»
  • Кнопка и меню избранного
  • Кнопка и меню настроек
  • Элемент управления WebView



Дополнительная функциональность


Мы также реализовали несколько дополнительных возможностей, чтобы сделать работу с браузером еще более приятной:

  • Сочетания клавиш: нажатие F11 переводит в полноэкранный режим, ESC выходит из полноэкранного режима, Ctrl+L выделяет адресную строку;
  • CSS transitions для анимации меню
  • Управление кэшем
  • Управление избранным
  • Анализ вводимых адресов – например, “bing.com” переводит на http(s)://bing.com, а “seahawks” ищет в Bing
  • Автоматическое изменение выделения адресной строки при фокусе
  • Отзывчивый дизайн

Использование WebView


<div class="navbar">
  <!-- ... -->
</div>
<x-ms-webview id="WebView"></x-ms-webview>

Введенный для JavaScript-приложений в Windows 8.1 элемент управления WebView, иногда также упоминаемый по имени тега x-ms-webview, позволяет хостить веб-контент внутри вашего Windows-приложения. Он доступен как для HTML, так и для XAML.Для начала работы достаточно разместить соответствующий элемент в коде страницы.



Разработка браузера


Мы будем использовать 15 различных API x-ms-webview. Все кроме двух из них управляют навигацией между страницами с некотором смысле. Давайте посмотрим, как можно использовать данные интерфейсы для создания различных элементов UI.

Управление кнопками назад и вперед


Когда вы нажимаете кнопку назад, браузер возвращает предыдущую страницу из истории браузера, если она доступна. Аналогично, когда вы нажимаете кнопку вперед, браузер возвращает последующую страницу из истории, если она также доступна. Для реализации подобной логики мы используем методы goBack() и goForward(), соответственно. Данные функции автоматически осуществят навигацию на корректную страницу из стека навигации.

После перехода на некоторую страницу, мы также обновляем текущее состояние кнопок, чтобы предотвратить «возможность» навигации, когда мы достигаем одного из концов стека навигации. Другими словами, мы отключаем кнопки навигации вперед или назад, проверяя свойства canGoBack или canGoForward на равенство false.

// Update the navigation state
this.updateNavState = () => {
  this.backButton.disabled = !this.webview.canGoBack;
  this.forwardButton.disabled = !this.webview.canGoForward;
};

// Listen for the back button to navigate backwards
this.backButton.addEventListener("click", () => this.webview.goBack());

// Listen for the forward button to navigate forwards
this.forwardButton.addEventListener("click", () => this.webview.goForward());


Управление кнопками обновления и остановки


Кнопки обновления и остановки слегка отличаются от остальных компонент панели навигации тем, что они используют одно и то же место в UI. Когда страница загружается, нажатие на кнопку остановит загрузку, спрячет «кольцо прогресса» и отобразит иконку обновления. И наоборот, когда страница загружена, нажатие на кнопку запустит обновление страницы и (в другой части кода) отобразит иконку остановки. Мы используем методы refresh() или stop() в зависимости от текущих условий.

// Listen for the stop/refresh button to stop navigation/refresh the page
this.stopButton.addEventListener("click", () => {
  if (this.loading) {
    this.webview.stop();
    this.showProgressRing(false);
    this.showRefresh();
  }
  else {
    this.webview.refresh();
  }
});

Управление адресной строкой


В целом, реализация адресной строки может быть очень простой. Когда адрес URL введен в текстовое поле, нажатие Enter вызовет метод navigate(), используя содержимое input-элемента адресной строки в качестве параметра.

Однако современные браузеры пошли сильно дальше и внедряют дополнительную функциональность для удобства пользователей. Это добавляет некоторую сложность в реализации – и тут все зависит от сценариев, которые вы хотите поддержать.

const RE_VALIDATE_URL = /^[-:.&#+()[\]$'*;@~!,?%=\/\w]+$/;

// Attempt a function
function attempt(func) {
  try {
    return func();
  }
  catch (e) {
    return e;
  }
}

// Navigate to the specified absolute URL
function navigate(webview, url, silent) {
  let resp = attempt(() => webview.navigate(url));
  // ...
}

// Navigate to the specified location
this.navigateTo = loc => {
  // ...
  // Check if the input value contains illegal characters
  let isUrl = RE_VALIDATE_URL.test(loc);
  if (isUrl && navigate(this.webview, loc, true)) {
    return;
  }
  // ... Fallback logic (e.g. prepending http(s) to the URL, querying Bing.com, etc.)
};

// Listen for the Enter key in the address bar to navigate to the specified URL
this.urlInput.addEventListener("keypress", e => {
  if (e.keyCode === 13) {
    this.navigateTo(urlInput.value);
  }
});

Вот пример сценария, который мы попробовали реализовать. Допустим, в адресную строку введено значение “microsoft.com”. Адрес не является полным. Если такое значение передать в метод navigate(), он завершится неудачей. Наш браузер должен знать, что URL не полный, и уметь определить, какой корректный протокол подставить: http или https. Более того, возможно, что введенное значение и не предполагалось адресом. К примеру, мы могли ввести в адресную строку значение “seahawks”, надеясь, что, как и во многих браузерах, строка также работает как поле поиска. Браузер должен понять, что значение не является адресом, и попробовать «найти» его в поисковой системе.

Отображение favicon


Запрос favicon – нетривиальная задача, так как существует несколько способов, как икона может быть задана. Самый простой способ – это проверить корень веб-сайта на наличие файла «favicon.ico». Однако некоторые сайты могут быть на поддомене и поэтому иметь отличную иконку. К примеру, иконка на “microsoft.com” отличается от иконки на “windows.microsoft.com”. Чтобы исключить двусмысленность, можно использовать другой способ — проверить разметку страницы на наличие link-тека внутри документа с rel-атрибутом, равным “icon” или “shortcut icon”.

Мы используем метод invokeScriptAsync(), чтобы вставить внутрь элемента управления WebView скрипт, который вернет строку в случае успеха. Наш скрипт ищет внутри страницы все элементы с link-теком, проверяет, если rel-атрибут содержит слово “icon”, и в случае совпадения возвращает значение “href”-атрибута назад в приложение.

// Check if a file exists at the specified URL
function fileExists(url) {
  return new Promise(resolve =>
    Windows.Web.Http.HttpClient()
      .getAsync(new URI(url), Windows.Web.Http.HttpCompletionOption.responseHeadersRead)
      .done(e => resolve(e.isSuccessStatusCode), () => resolve(false))
  );
}

// Show the favicon if available
this.getFavicon = loc => {
  let host = new URI(loc).host;

  // Exit for cached ico location
  // ...

  let protocol = loc.split(":")[0];

  // Hide favicon when the host cannot be resolved or the protocol is not http(s)
  // ...

  loc = `${protocol}://${host}/favicon.ico`;

  // Check if there is a favicon in the root directory
  fileExists(loc).then(exists => {
    if (exists) {
      console.log(`Favicon found: ${loc}`);
      this.favicon.src = loc;
      return;
    }
    // Asynchronously check for a favicon in the web page markup
    console.log("Favicon not found in root. Checking the markup...");
    let script = "Object(Array.from(document.getElementsByTagName('link')).find(link => link.rel.includes('icon'))).href";
    let asyncOp = this.webview.invokeScriptAsync("eval", script);

    asyncOp.oncomplete = e => {
      loc = e.target.result || "";

      if (loc) {
        console.log(`Found favicon in markup: ${loc}`);
        this.favicon.src = loc;
      }
      else {
        this.hideFavicon();
      }
    };
    asyncOp.onerror = e => {
      console.error(`Unable to find favicon in markup: ${e.message}`);
    };
    asyncOp.start();
  });
};

Как упомянуто выше, мы используем в нашем коде возможности из новой спецификации ES2015. Вы могли заметить использование стрелочной нотации во многих примерах выше, а также ряд других возможностей. Вставляемый скрипт – это отличный пример улучшения кода, достигаемого за счет поддержки ES2015.

// Before (ES < 6):
"(function () {var n = document.getElementsByTagName('link'); for (var i = 0; i < n.length; i++) { if (n[i].rel.indexOf('icon') > -1) { return n[i].href; }}})();"

// After (ES6):
"Object(Array.from(document.getElementsByTagName('link')).find(link => link.rel.includes('icon'))).href"


Поддержка комбинаций клавиш


В отличие от возможностей, которые мы реализовали выше, поддержка комбинаций клавиш потребует от нас небольшого куска кода на C++ или C#, обернутого в виде Windows Runtime (WinRT) компонента.



Чтобы определить нажатие горячих клавиш для выполнения тех или иных действий, например, чтобы при нажатии комбинации Ctrl+L выделять адресную строку или по F11 переключаться в полноэкранный режим, нам нужно вставить еще один скрипт в WebView. Для этого мы используем метод invokeScriptAsync(), который мы уже упоминали выше. Однако, нам нужно как-то сообщать назад в слой приложения, когда те или иные клавиши нажаты.

С помощью метода addWebAllowedObject(), мы можем выставить для инжектируемого кода метод, через который можно будет передавать нажимаемые клавиши в слой приложения на JavaScript. Также важно понимать, что в Windows 10, элемент управления WebView выполняется в отдельном потоке. Нам нужно создать диспетчер, который будет передавать события в поток UI, чтобы слой приложения мог их обрабатывать.

KeyHandler::KeyHandler()
{
    // Must run on App UI thread
    m_dispatcher = Windows::UI::Core::CoreWindow::GetForCurrentThread()->Dispatcher;
}

void KeyHandler::setKeyCombination(int keyPress)
{
    m_dispatcher->RunAsync(
        CoreDispatcherPriority::Normal,
        ref new DispatchedHandler([this, keyPress]
    {
        NotifyAppEvent(keyPress);
    }));
}

// Create the C++ Windows Runtime Component
let winRTObject = new NativeListener.KeyHandler();

// Listen for an app notification from the WinRT object
winRTObject.onnotifyappevent = e => this.handleShortcuts(e.target);

// Expose the native WinRT object on the page's global object
this.webview.addWebAllowedObject("NotifyApp", winRTObject);

// ...

// Inject fullscreen mode hot key listener into the WebView with every page load
this.webview.addEventListener("MSWebViewDOMContentLoaded", () => {
    let asyncOp = this.webview.invokeScriptAsync("eval", `
        addEventListener("keydown", e => {
            let k = e.keyCode;
            if (k === ${this.KEYS.ESC} || k === ${this.KEYS.F11} || (e.ctrlKey && k === ${this.KEYS.L})) {
                NotifyApp.setKeyCombination(k);
            }
        });
    `);
    asyncOp.onerror = e => console.error(`Unable to listen for fullscreen hot keys: ${e.message}`);
    asyncOp.start();
});

Внешний вид браузера


Теперь, когда мы разобрались с ключевыми API WebView, давайте немного улучшим внешний вид нашего браузера.

Брендирование заголовка


Используя API Windows Runtime, мы можем поменять свойство ApplicationView.TitleBar, чтобы настроить цветовую палитру все компонентов заголовка приложения. В нашем браузере при загрузке приложения мы меняем цвета так, чтобы они соответствовали панели навигации. Мы также обновляем цвета при открытии меню, чтобы соответствовать фону меню. Каждый цвет нужно задавать как объект с RGBA свойствами. Для удобства мы создали вспомогательную функцию, генерирующую нужный формат из шестнадцатеричной строковой записи.

//// browser.js
// Use a proxy to workaround a WinRT issue with Object.assign
this.titleBar = new Proxy(Windows.UI.ViewManagement.ApplicationView.getForCurrentView().titleBar, {
  "get": (target, key) => target[key],
  "set": (target, key, value) => (target[key] = value, true)
});

//// title-bar.js
// Set your default colors
const BRAND = hexStrToRGBA("#3B3B3B");
const GRAY = hexStrToRGBA("#666");
const WHITE = hexStrToRGBA("#FFF");

// Set the default title bar colors
this.setDefaultAppBarColors = () => {
  Object.assign(this.titleBar, {
    "foregroundColor": BRAND,
    "backgroundColor": BRAND,

    "buttonForegroundColor": WHITE,
    "buttonBackgroundColor": BRAND,

    "buttonHoverForegroundColor": WHITE,
    "buttonHoverBackgroundColor": GRAY,

    "buttonPressedForegroundColor": BRAND,
    "buttonPressedBackgroundColor": WHITE,

    "inactiveForegroundColor": BRAND,
    "inactiveBackgroundColor": BRAND,

    "buttonInactiveForegroundColor": GRAY,
    "buttonInactiveBackgroundColor": BRAND,

    "buttonInactiveHoverForegroundColor": WHITE,
    "buttonInactiveHoverBackgroundColor": BRAND,

    "buttonPressedForegroundColor": BRAND,
    "buttonPressedBackgroundColor": BRAND
  });
};

Прочие возможности


Индикация прогресса, а также меню настроек и избранного используют CSS transitions для анимации. Из меню настроек временные веб-данные можно очистить, используя метод clearTemporaryWebDataAsync(). А в меню избранного отображаемый список хранится в JSON-файле в корневой папке перемещаемого хранилища данных приложения.

Исходный код


Полный пример кода доступен в нашем репозитарии на GitHub. Вы можете также попробовать демонстрационный браузер, установив соответствующее приложение из Windows Store, или развернув приложение из проекта для Visual Studio.



Создайте свое приложение для Windows 10


С помощью WebView мы смогли создать простой браузер, используя веб-стандарты, буквально за день. Интересно, что вы сможете создать для Windows 10?

Комментарии (10)


  1. Zenitchik
    20.10.2015 12:06
    +3

    Угу. В эпоху Visual Basic 6.0 я тоже баловался втыканием подобного контрола (забыл, как он назыался) на экранную форму и написанием некоторой логики управления им.


    1. bigfatbrowncat
      20.10.2015 12:11
      +9

      А я — в Delphi :) А потом показывал одноклассникам: «Смотрите, я браузер свой сделал! Вот тут Refresh, а тут — кнопка „GO!“ Иконки потом нарисую…

      Хорошо, что хоть что-то в нашем мире не меняется.

      Автора очень прошу не обижаться, это совсем не насмешка над вами, статья на самом деле полезная для начинающих.


  1. o_nix
    20.10.2015 12:09

    Статью не читал, но обои хорошие!
    А вообще немного непонятно, каким образом регистрируется заранее (?) NativeListener для того, чтобы мы просто смогли сделать let winRTObject = new NativeListener.KeyHandler()? Через настройки проекта?


    1. kichik
      20.10.2015 12:14

      В проекте добавляется ссылка (через References) на WinRT-компонент, после чего его можно создавать из кода на JavaScript.


  1. nerudo
    20.10.2015 12:54
    +2

    Очень кнопки «спрятать публикацию не хватает». А то мутит от картинки.


    1. reaferon
      20.10.2015 12:55

      Да уж, эпилептиков бы порадовала эта гифка


    1. kichik
      20.10.2015 12:56

      Прошу прощения, поменял.


  1. rogrom
    20.10.2015 12:55
    +3

    Как создать свой собственный браузер
    я думал Вы про Render Tree, DOM или CSSOM расскажете, а тут «элемент управления WebView ..»


  1. aleman
    20.10.2015 13:48

    Если оно универсальное и с отзывчивым интерфейсом, интересно увидеть, как будет выглядеть ваш браузер на мобильном устройстве?


  1. DeSharky
    21.10.2015 14:34
    +3

    Сразу вспомнил про мэилру и яндекс