28 сентября 2021

🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков

Работаю в ИТ 6+ лет в сфере разработки ПО для мобильных устройств. Преимущественно работаю с экосистемой Apple. Разрабатываю для iPhone, iPad, iWatch.
В предыдущей статье мы разложили по полочкам навигацию в WebView. Самое время разобраться, как подружить SwiftUI с JavaScript, поскольку без этого языка программирования не обходится ни один современный сайт или мобильное приложение.
🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков

Мы уже добавили в проект (код доступен на GitHub – прим. ред.) перечисление WebViewNavigationAction, которое описывает три действия: назад, вперед, перезагрузить, а затем разобрались с пользовательским интерфейсом приложения, навигацией и получением информации с веб-страницы в Swift. Сегодня более подробно рассмотрим работу с JavaScript.

Создаем local.html

Подготовим простой файл HTML и отобразим его в WebView.

Создадим папку www в проекте. Внутрь положим файл local.html со следующим содержимым:

local.html
        <!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta http-equiv="Content-Style-Type" content="text/css">
  <title>Local HTML</title>
  <style>
    body {
      background: -webkit-linear-gradient(bottom left, #2b2f44 0%,#312442 100%);
      text-align: center;
      font-family: monospace;
      font-weight: bold;
      font-size: large;
    }
    .title {
      color: #FFFFFF;
    }
    .container {
      display: grid;
      justify-content: center;
      align-content: center;
      grid-auto-flow: row;
      gap: 12px;
    }
    .item {
      max-width: 600px;
      padding: 12px;
      background: #000000;
      border-radius: 5px;
      color: #FFFFFF;
    }
    #ok {
      background-color: #000000;
      color: #FFFFFF;
      font-size: medium;
      padding: 12px;
    }
  </style>
</head>
<body>
  <header>
    <h1 class="title">Interaction with JavaScript</h1>
  </header>
    <div class="container">
      <div class="item">
        <h1>Swift</h1>
      </div>
      <div class="item">
        <h1>JavaScript</h1>
      </div>
      <button id="ok">OK</button>
    </div>
</body>
</html>
    

При добавлении не забудьте указать create folder reference.

Чтобы показать локальный файл HTML, нам необходимо доработать WebView, но для начала обновим конфигурацию WebView в ContentView:

        WebView(type: .local, url: "local", viewModel: viewModel)
    

Вернемся в WebView и обновим логику в updateUIView:

        func updateUIView(_ webView: WKWebView, context: Context) {
	if let urlValue = url  {
		if type == .local {
			if let localUrl = Bundle.main.url(forResource: urlValue, withExtension: "html", subdirectory: "www") {
			webView.loadFileURL(localUrl, allowingReadAccessTo: localUrl.deletingLastPathComponent())
				}
			} else if type == .public {
				if let requestUrl = URL(string: urlValue) {
					webView.load(URLRequest(url: requestUrl))
				}
			}
		}
	}
    

Обратите внимание, для загрузки локального файла мы используем следующий метод:

        func loadFileURL(_ URL: URL, allowingReadAccessTo readAccessURL: URL) -> WKNavigation?
    
Важный момент!
Если readAccessURL ссылается на один файл, то Webkit сможет загрузить только его; если это директория, то и файлы внутри каталога могут быть загружены WebKit. В данный момент мы загружаем только один файл и все описываем внутри него (стили, скрипты).

Протестируем что у нас получилось!

А зачем оно все?

На практике встречаются такие задачи, когда нам нужно подгрузить веб-страницу с определенным контентом. Иногда мы хотим показать только часть содержимого и скрыть, например, <header> веб-страницы, который добавляет избыточную навигацию в приложение. В других случаях нам нужно понимать, что пользователь нажал на кнопку OK (когда это событие произошло).

Отладка JavaScript
Далее мы рассмотрим как работать с JavaScript из Swift, и наоборот. Если вы еще не знакомы с Web технологиями, такими как JavaScript, HTML, CSS, то ничего страшного. Я расскажу теоретический минимум, которые необходимо знать. Прежде чем добавлять сценарии JavaScript в приложение, я рекомендую проверить их в браузере. Откройте инструменты разработчика и протестируйте код в консоли.

Когда вы будете запускать код в WKWebView, global scope (глобальная область видимости) может стать проблемой, которая приводит к краху приложения из-за ошибок повторного объявления переменных и т.п.

Пара слов о global scope

В JavaScript используют const для объявления констант и let – для обычных переменных. Вы также можете воспользоваться var, но здесь тоже есть некоторые особенности, которые могут привести в замешательство.

Поэкспериментируйте с объявлением переменных. Например, получите заголовок всей веб-страницы document.title, как мы это уже делали в прошлой статье. Объявите переменные с const, let.

Если вы выполните в консоли несколько раз const title = document.title, то получите ошибку. Это можно исправить, если заменить const на let или ввести локальную область видимости (local scope), заключив код в фигурные скобки:

        { const title  = document.title; }
    

Чтобы проблем, стоит оборачивать код в функции. Поскольку они дают нам дополнительный контекст, вы можете быть уверены, что не получите подобных ошибок.

        function getDocTittle() { 
	const title  = document.title; 
	console.log(title);
}
getDocTitle();
    
Если вы хотите разобраться, как работают переменные и области видимости (scope), прочтите этот пост.

Селекторы

Чтобы произвести какие-либо действия с элементами веб-страницы, нужно получить на них ссылку. Selectors API предоставляет нам простой и эффективный способ получить элемент из DOM (Document-Object-Model), сопоставляя элементы заданному в параметре множеству селекторов. Спецификация добавляет два метода, которые мы можем применять к документу (Document), элементу (Element), и фрагменту документа (DocumentFragment):

  • querySelector(selectors) вернет первое совпадение. Если совпадений не найдено, то null.
  • querySelectorAll(selectors) вернет все совпадения (NodeList), или пустой NodeList, если их нет.

Когда сайты имеют хорошую разметку, чаще всего в качестве параметра (selectors) мы передаем CSS-класс, ID элемента, имя тега элемента. Для для формирования более точных запросов следует передавать множество CSS-селекторов.

Рассмотрим все вышеизложенное на примерах.

Например, в <body> на веб-странице у нас есть:

  • <header> внутри которого заголовок.
  • <h1> c классом title.
  • <div> – блок с классом container, в котором содержатся:
  • два блока <div> с классом item, в котором содержатся:
  • заголовки <h1>;
  • кнопка <button> с идентификатором ok.

Получим наш заголовок из тега <head>:

        const h1 = document.querySelector('h1');
    
Поскольку в нашем примере заголовок в документе самый первый, то мы получили нужное. А если в <head> определили два заголовка? Например, мог быть заголовок в шапке сайта, который описывает бренд, а за ним следует заголовок, который задает название контенту содержимого. В таком случае нам бы пришлось создать более точный запрос.

Классы более общие, а идентификаторы всегда уникальные во всем документе, и у каждого элемента в HTML есть глобальные атрибуты.

Атрибут class – один из них. Он позволяет CSS и JavaScript выбрать и получить элемент при помощи селекторов CSS или функций JavaScript.

Если хотим получить именно название (title), то лучше воспользоваться более точным запросом. В передаваемом селекторе, точка означает, что мы имеем дело с именем класса.

        const title = document.querySelector('.title');
    

Оба запроса вернули один и тот же результат:

        <h1 class="title">Interaction with JavaScript</h1>
    

Во втором случае мы тоже получили нужное, поскольку название класса title говорит само за себя.

Теперь получим кнопку с идентификатором ok:

        const button = document.querySelector('#ok');
    

В передаваемом селекторе решетка означает, что это имя идентификатора.

Результат:

        <button id="ok">OK</button>
    

Воспользуемся следующим запросом, чтобы исследовать элементы тега div:

        let divs =  document.querySelectorAll("div")


    

В результате получим состоящий из трех элементов NodeList:

        [div.container, div.item, div.item]


    

Создадим более точные запросы, чтобы получить контент заголовков <h1> внутри элементов div.item:

         let itemsContent = document.querySelectorAll("div.item h1");


    

C помощью метода forEach мы можем перебирать элементы NodeList. Выведем результат в консоль:

        itemsContent.forEach(function(title) { console.log(title.innerText)) }
    
Управление элементами
Как получить элементы из документа, мы уже разобрались. Давайте научимся ими манипулировать. Стоить отметить, что существует несколько способов как и получения элементов, так и несколько способов выполнить ту или иную задачу с некоторыми тонкостями и особенностями. Например, такие задачи как, скрытия элементов, проверки на существование элемента в документе и т.д. Поэтому если вы уже подкованы в этой области, поделитесь своим опытом в комментариях. Далее мы не будем останавливаться на всевозможных способах и деталях. Просто посмотрим на часто распространенные примеры.

Скрытие элементов

Например, чтобы скрыть title, воспользуемся свойством display:

        title.style.display = 'none';
    

Разумеется, при обновлении WebView элемент снова появится.

Существует ли элемент?

Часто встречающаяся задача – проверка присутствия элемента. Если вы не совсем уверены в получаемом результате, оберните его в условный оператор if. Попробуем получить первый заголовок второго уровня <h2>, которого не существует в примере, и скрыть его:

        const h2 = document.querySelector('h2');
if(h2) { h2.style.display = 'none'; }
    

Клики и события

Давайте разберемся как получить уведомление, когда пользователь нажал на кнопку.

JavaScript предоставляет нам метод addEventListener, в котором два параметра:

  • название события, например, ‘click’.
  • функция, которая выполняет какой-либо код, когда происходит событие.
        button.addEventListener('click', function(e) { 
  console.log(e); 
  console.log('OK Clicked');
});
    

Мы добавили событие click, и по нажатию на кнопку OK должны увидеть в консоли: ”OK Clicked”.

Параметр е, который передается в функцию, описывает само событие click. К самому элементу можно обратиться через e.target.

Стоить отметить, что в JavaScript есть стрелочные функции, однако если вы незнакомы с особенностями языка, это может вызвать замешательство. Чтобы избежать путаницы с тем, куда указывает this, я использовал обычную функцию, где this в теле функции указывает на саму кнопку.

Как получить контент элемента или атрибута?

Существует несколько способов в DOM API получить контент элемента, но у каждого свои особенности и назначения. Допустим мы хотим получить контент нашего названия title.

Например, при помощи свойств:

  • textContent
  • innerText

title.innerText и title.textContent в данном случае возвращают одни и те же результаты. Однако они весьма отличаются. textContent может вернуть нам даже текстовое содержимое блока <style>.

Поскольку чаще всего нам требуется получить читабельный контент, то innerText является лучшим выбором и используется в большинстве случаев, но его результат зависит от стилей. Если элемент скрыт, то мы ничего не получим.

Чтобы разница была явно видна, получите элемент HTML и посмотрите, что будет в этих свойствах.

Для более подробного изучения смотрите документацию, а также обратите внимание на свойства outerText, innerHtml и outerHtml.

Порой нам необходимо вытащить у элемента <img> ссылку на изображение, которая находится в атрибуте src или значение элемента <input>. Сделать это довольно просто:

        let imgSrc = img.src;
    
        const input = document.querySelector('#mylInput');
let inputValue = input.value
    

Run JS! Run!

В предыдущей статье мы использовали метод evaluateJavaScript для получения заголовка документа. Остановимся на нем поподробней.

        open func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil)


    
В этот метод мы передаем строку, которая представляет из себя код на JavaScript и получаем результат. Если мы ничего не возвращаем явно, то JavaScript runtime вернет нам undefined. Для отслеживания, каких либо блоков кода на JavaScript удобно использовать в качестве возвращаемого результата true или false. В completionHandler передается результат исполнения скрипта или ошибка.

В документации сказано, что вызов этого метода равноценен вызову метода, где значение framenil представляет главный фрейм, а значение contentWorldWKContentWorld.pageWorld:

        public func evaluateJavaScript(_ javaScript: String, in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)


    

Данное улучшение доступно начиная с iOS 14.

  • WKFrameInfo содержит информацию о фрейме на странице.
  • WKContentWorld – этот объект определяет область видимости выполнения кода JavaScript, т.е. мы должны использовать его как пространство имен (namespace).
Рассмотрим его более подробно. Это ключевой параметр функции, который дает нам большую гибкость и обеспечивает предотвращение многих конфликтов. Мы можем его использовать для предотвращения конфликтов между скриптами на веб-странице. Выполнение сценарии JavaScript в собственном WKContentWorld дает нам отдельную копию переменных среды для изменения.

Получим заголовок из шапки и покажем его в нашем NavigationView. Для этого создадим файл JSUserScripts.swift, где напишем сценарии JavaScript, которые будут модифицировать страницу и забирать из нее данные.

Напишем две функции для получения и скрытия заголовка на веб-странице:

JSUserScripts.swift
        let getHeaderTitle  = """
function getHeaderTitle() {
	const headerTitle = document.querySelector('h1.title');
	return headerTitle.innerText;
}
getHeaderTitle();
"""

let hideHeaderTitle = """
function hideHeaderTitle() {
	const headerTitle = document.querySelector('h1.title');
	headerTitle.style.display = 'none';
}
hideHeaderTitle();
"""
    

Теперь заменим старый метод evaluateJavaScript улучшенной версией:

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
	print("didFinish")
	self.parent.viewModel.isLoaderVisible.send(false)
			
	webView.evaluateJavaScript(getHeaderTitle, in: nil, in: .defaultClient) { result in
		switch result {
			case .success(let value):
				if let title = value as? String {
					self.parent.viewModel.webTitle.send(title)
				}
						
			case .failure(let error):
				print(error.localizedDescription)
		}
	}
			
	webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .defaultClient)
}

    

Свойство .defaultClient предоставляет пространство имен (namespace) по умолчанию для приложения.

Если нам нужно запустить скрипт внутри пространства имен текущей веб-страницы, мы можем использовать .page

        webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .page)

    

Также мы можем создавать собственные пространства имен:

        webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .world(name: "magic"))


    

В нашем примере это излишество, но если бы мы создавали веб-браузер с расширениями на JavaScript, пришлось бы создавать уникальное пространство имен для каждого расширения.

Еще один интересный метод, который пришел вместе с iOS 14:

         public func callAsyncJavaScript(_ functionBody: String, arguments: [String : Any] = [:], in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)
    

В параметр functionBody, в качестве строки мы передаем само тело функции, а в качестве arguments мы можем передавать переменные, которые будут использованы в функции, что очень удобно. Такая гибкость позволяет нам переиспользовать блоки кода JavaScript.

Как видно из названия, Async означает, что строка будет запущена как асинхронная функция. В этом случае можно использовать await и работать с JavaScript-обещаниями (Promise). Такая техника необходима, когда мы не знаем, когда именно будет завершена работа функции или когда необходимо что-то запустить через определенное время (например setTimeout).

Рассмотрим несколько примеров:

        let hideAnyElement  = """
	const element = document.querySelector(selector);
	element.style.display = 'none';
"""

let getElementInnerText = """
	const element = document.querySelector(selector);
	return element.innerText;
"""
    

Исполним JavaScript:

        webView.callAsyncJavaScript(hideAnyElement, arguments: ["selector":"#ok"], in: nil, in: .defaultClient)

    

Код стал элегантней. Теперь мы можем передавать селекторы в качестве аргументов и скрывать любые элементы веб-страницы.

Напишем простое обещание (promise):

        let setTimeoutFor = """

	const myPromise = new Promise((resolve, reject) => {
        window.setTimeout(function (){
		  resolve('foo');
		}, timeout);
	  });

	
	 await myPromise;
	 return myPromise;
"""
    

Исполним JavaScript:

        let timeout = 3000;
webView.callAsyncJavaScript(setTimeoutFor, arguments: [ "timeout":"\(timeout)"], in: nil, in: .defaultClient) { (result) in
				
				switch result {
				case .success(let response):
					print("Done...");
					print(response);
				case .failure(let error):
					print("Error...");
					print(error)
				}
			}

    

После трех секунд ожидания мы получим результат “foo” в приложении.

Когда мы разобрались с исполнением сценариев JavaScript, инициируя вызов из Swift, рассмотрим и обратный процесс – как выполнить код Swift при помощи JavaScript.

Такую коммуникацию нам помогает реализовать протокол WKScriptMessageHandler и объект WKUserContentController, который предоставляет возможность отправки сообщений JavaScript в WKWebView. Протокол WKScriptMessageHandler определяет всего одну функцию, в которой мы можем получить сообщение из запущенных на веб-странице сценариев.

Напишем расширение для Coordinator, в которое и добавим реализацию этой функции:

        extension WebView.Coordinator: WKScriptMessageHandler {
	func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
		if message.name == "messageAppHandler" {
			if let body = message.body as? String {
				print("Message body: \(body)")
			}
		}
	}
}

    

В методе makeUIView (здесь мы создаем WebView и задаем его конфигурацию) укажем наш класс, который реализует обработчик сообщений и его имя:

        webView.configuration.userContentController.add(context.coordinator, contentWorld: .page, name: "messageAppHandler")

    
  • Аргумент name определяет название нашего обработчика сообщений WKScriptMessageHandler. Обратите внимание, что namе (имя обработчика сообщений) используется для определения, какой именно WKUserContentController отправляет сообщение.
  • Свойство body содержит наше сообщение (строку или объект JSON).
  • На веб-странице, чтобы отправить сообщение нашему обработчику, мы должны вызвать у него метод postMessage.

Добавим скрипт на веб-страницу, и отправим сообщение:

        <script type="text/javascript">
	  sendValueToApp();
	  
	  function sendValueToApp() {
		  window.webkit.messageHandlers.messageAppHandler.postMessage('Hello from web page');
	  }
  </script>
    

Запустим и проверим, что получаем наше сообщение с веб-страницы в консоли.

На этом пока все. Код проекта доступен на Github.

P.S. Happy Code!

Комментарии

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ