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.