Web-first мобильное приложение живёт в WebView внутри Capacitor, и весь UI там — обычный React. Но рано или поздно из TypeScript нужно дотянуться до того, чего в браузере нет: камеры, точной геолокации, файловой системы, нативного хранилища. Эту границу закрывает мост (bridge): механизм, через который JS-код асинхронно вызывает нативный, получая результат как обычный промис. Продукт-инженеру важно понимать, где проходит эта граница, какие возможности уже готовы из коробки, а когда придётся написать native-код самому.
Зачем нужен мост между native и JS
WebView исполняет JavaScript в песочнице и сам по себе не имеет доступа к нативным SDK устройства. Мост Capacitor — это двусторонний канал: вызов из JS сериализуется, передаётся в нативный слой (Swift на iOS, Kotlin на Android), там исполняется реальный системный API, а результат возвращается обратно в JS. Канал асинхронный по природе — нативная операция может ждать пользователя (диалог разрешения, снимок) или подсистему, поэтому каждый вызов через мост — это промис, который вы ждёте через await.
import { Geolocation } from "@capacitor/geolocation";
const position = await Geolocation.getCurrentPosition();
const { latitude, longitude } = position.coords;
Здесь getCurrentPosition уходит в нативный location-сервис и возвращается с координатами. Для вызывающего кода это неотличимо от любого другого await — вся машинерия моста скрыта за плагином.
Готовые плагины
Чаще всего писать native-код не нужно: команда Capacitor поддерживает набор официальных плагинов, покрывающих типовые потребности. Они ставятся как обычные npm-пакеты.
npm install @capacitor/camera @capacitor/geolocation @capacitor/filesystem
npm install @capacitor/preferences @capacitor/device
npx cap sync
@capacitor/camera— съёмка и выбор фото из галереи;@capacitor/geolocation— координаты и слежение за позицией;@capacitor/filesystem— чтение и запись файлов на устройстве;@capacitor/preferences— простое key-value хранилище (см. offline и хранилище);@capacitor/device— информация об устройстве и платформе.
Помимо официальных есть большая экосистема community-плагинов (например, под Bluetooth, биометрию, штрихкоды) — их выбирают по поддержке нужной версии Capacitor и активности репозитория. Команда npx cap sync копирует web-ресурсы и подтягивает нативные зависимости плагинов в проекты iOS и Android.
Вызов из TypeScript
Каждый плагин экспортирует объект с методами; импорт — именованный, по имени пакета. Канонический пример — снимок с камеры:
import { Camera, CameraResultType } from "@capacitor/camera";
async function takePhoto(): Promise<string> {
const photo = await Camera.getPhoto({
quality: 90,
resultType: CameraResultType.Uri,
});
return photo.webPath ?? "";
}
resultType: CameraResultType.Uri просит вернуть путь, а не тяжёлую base64-строку: photo.webPath можно сразу подставить в src элемента <img>. Возвращаемое значение типизировано, поэтому промах с полем ловится компилятором — это та же сквозная типобезопасность, что и при загрузке данных на frontend. Вызов оборачивают в try/catch: пользователь может отменить съёмку или отказать в доступе, и плагин бросит ошибку.
Свой плагин
Когда нужного API нет ни в официальных, ни в community-плагинах, пишут свой. Плагин — это пара нативных реализаций (Swift для iOS, Kotlin или Java для Android) под общим JS-интерфейсом, который регистрируется через registerPlugin.
import { registerPlugin } from "@capacitor/core";
export interface EchoPlugin {
echo(options: { value: string }): Promise<{ value: string }>;
}
export const Echo = registerPlugin<EchoPlugin>("Echo");
Нативная часть на iOS наследует CAPPlugin и помечает методы для моста:
import Capacitor
@objc(EchoPlugin)
public class EchoPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "EchoPlugin"
public let jsName = "Echo"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise)
]
@objc func echo(_ call: CAPPluginCall) {
let value = call.getString("value") ?? ""
call.resolve(["value": value])
}
}
На Android — класс с аннотацией @CapacitorPlugin, имя в аннотации должно совпадать с именем в registerPlugin:
@CapacitorPlugin(name = "Echo")
class EchoPlugin : Plugin() {
@PluginMethod
fun echo(call: PluginCall) {
val value = call.getString("value")
val ret = JSObject()
ret.put("value", value)
call.resolve(ret)
}
}
Один TypeScript-интерфейс, две нативные реализации — мост склеивает их по имени. Для React-кода Echo.echo(...) неотличим от любого готового плагина.
Разрешения
Доступ к камере, геолокации или файлам требует согласия пользователя. Плагины, которым нужны разрешения, дают единую пару методов: checkPermissions возвращает текущий статус, requestPermissions запрашивает доступ и показывает системный диалог. Запрашивать стоит лениво — в момент, когда возможность реально понадобилась, а не на старте.
import { Camera } from "@capacitor/camera";
async function ensureCameraAccess(): Promise<boolean> {
const status = await Camera.checkPermissions();
if (status.camera === "granted") return true;
const requested = await Camera.requestPermissions();
return requested.camera === "granted";
}
Сами строки-описания разрешений живут в нативных манифестах: NSCameraUsageDescription в Info.plist на iOS, <uses-permission> в AndroidManifest.xml на Android. Без них системный диалог не покажется, а приложение упадёт при первом обращении. Если приложение упаковано не как полноценный native-контейнер, а как WebView-обёртка на iOS или TWA на Android, часть возможностей доступна и через стандартные web-API браузера — выбор между мостом и web-API зависит от того, насколько глубоко нужна интеграция.
Где это в UCP
Мост и плагины — это граница между web-кодом и устройством, и работа с ней та же дисциплина границ, что между слоями backend: явный контракт (типизированный интерфейс плагина), асинхронность как данность, обработка отказа (отмена, отказ в доступе) на каждом вызове. Владея этим, продукт-инженер доводит web-first приложение до полноценного нативного, не переписывая UI: камера, гео и файлы дотягиваются из того же TypeScript, что и весь интерфейс. Откуда взялся сам контейнер и WebView, в котором всё это живёт, — в статье про Capacitor.