Web対応のしくみ
React Native SkiaがブラウザでどうやってSkiaを動かしているか、CanvasKitのCDNロードから.web.ts解決の落とし穴まで、本書自身のビルド設定を題材に種明かしします。
この本のデモは、すべて実機やシミュレーターではなくブラウザの中でSkiaを動かしています。ここまで読んで「なぜReact NativeのコンポーネントがブラウザでそのままCanvasに描画されているのか」と気になった方のために、この章ではその仕組みを掘り下げます。公式ドキュメントの内容に加えて、本書自身のビルド設定(astro.config.mjs)を実例として使います。
CanvasKit: WASM版のSkia
React Native SkiaがWebで動く根本的な理由は、CanvasKitというSkiaのWebAssembly(WASM)ビルドが存在するからです。ネイティブ版のReact Native SkiaがC++のSkiaを直接JSIバインディング経由で呼び出しているのに対して、Web版はCanvasKitというWASMバイナリをブラウザにロードし、そこにSkiaの各APIを橋渡ししています。
CanvasKitのWASMファイルはgzip圧縮した状態で2.9MBあり、これは非同期にロードされます。サイズこそ小さくありませんが、裏を返せば「いつ・どうやってロードするか」を自分でコントロールできるということでもあります。
Skiaのロード方法
公式には2つのロード方法が用意されています。
<WithSkiaWeb />: コード分割を使い、Skiaに依存するコンポーネントの読み込み自体を遅延させるLoadSkiaWeb(): ルートコンポーネントの登録自体をSkiaのロード完了まで遅らせる
import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
export default function App() {
return (
<WithSkiaWeb
getComponent={() => import("./MySkiaComponent")}
fallback={<Text>Skiaを読み込み中...</Text>}
/>
);
}
import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
LoadSkiaWeb().then(async () => {
const App = (await import("./src/App")).default;
AppRegistry.registerComponent("Example", () => App);
});
どちらの方法でも、CanvasKitのWASMファイル自体をどこから取得するかはlocateFileオプションで指定できます。自前でホスティングする方法のほかに、CDNから読み込む方法もサポートされています。
import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
import { version } from "canvaskit-wasm/package.json";
<WithSkiaWeb
opts={{ locateFile: (file) => `https://cdn.jsdelivr.net/npm/canvaskit-wasm@${version}/bin/full/${file}` }}
getComponent={() => import("./MySkiaComponent")}
/>;
本書のデモは、まさにこのCDNロードのパターンを使っています。islands/Demo.tsxの実装を見てみましょう。
let canvasKitPromise: Promise<void> | null = null;
function loadCanvasKit(): Promise<void> {
if (!canvasKitPromise) {
canvasKitPromise = (async () => {
const [{ LoadSkiaWeb }, { version }] = await Promise.all([
import("@shopify/react-native-skia/lib/module/web"),
import("canvaskit-wasm/package.json"),
]);
await LoadSkiaWeb({
locateFile: (file: string) =>
`https://cdn.jsdelivr.net/npm/canvaskit-wasm@${version}/bin/full/${file}`,
});
})();
}
return canvasKitPromise;
}
<Demo>コンポーネントがマウントされるたびにこの関数が呼ばれますが、canvasKitPromiseをモジュールスコープでキャッシュしているため、実際にCanvasKitがロードされるのはページ内で最初の1回だけです。2つ目以降のデモは、この共有Promiseの解決を待つだけで済みます。読み込み中は各デモが「Skiaを読み込み中…」を表示し、完了後にそれぞれのデモコンポーネントを動的importで取得して描画に切り替えます。
.web.ts解決という落とし穴
React Native Skiaやreact-native-webのソースコードは、Metro/webpackの「拡張子なしのimportに対して、Web向けビルドでは.web.jsを.jsより優先して解決する」という規約に依存しています。たとえば../specs/NativeSkiaModuleという拡張子なしのimportに対して、同じディレクトリにNativeSkiaModule.web.jsがあればネイティブ版のNativeSkiaModule.jsより優先されます。ネイティブ版はTurboModuleRegistryのようなReact Native専用APIをimportしているため、Web環境でこれが解決されてしまうとその時点でクラッシュします。
Viteにも同様の機能(resolve.extensions)があり、本書のastro.config.mjsでも設定しています。
resolve: {
extensions: [".web.js", ".web.ts", ".web.tsx", ".web.jsx", ".mjs", ".js", /* ... */],
}
ところが、これだけでは不十分でした。実際に開発サーバーの変換後の出力をcurlで確認したところ、resolve.extensionsは本書自身のソースコードが書いた拡張子なしimportには効くものの、node_modules内部のパッケージが書いている拡張子なしの相対import(../specs/NativeSkiaModuleのような)には効かないことが分かりました。Viteの依存関係の事前バンドル(pre-bundling)処理は、resolve.extensionsとは別の解決ロジックを使っているためです。
この問題を実際に解決したのが、astro.config.mjs内のpreferWebPlatformExtensionという自作のViteプラグインです。resolveIdフックで「importerがnode_modules内で、importが拡張子なしの相対パスなら、まず.web付きの解決を試す」というルールを実装しています。
function preferWebPlatformExtension() {
return {
name: "prefer-web-platform-extension",
enforce: "pre",
async resolveId(source, importer, options) {
if (!importer || !importer.includes("/node_modules/")) return null;
if (!(source.startsWith("./") || source.startsWith("../"))) return null;
if (/\.[a-z]+(\?.*)?$/i.test(source)) return null;
try {
const resolved = await this.resolve(`${source}.web`, importer, {
...options,
skipSelf: true,
});
return resolved ?? null;
} catch {
return null;
}
},
};
}
Metro/webpackが標準機能として持っているプラットフォーム別解決を、Viteでは自分でリゾルバーとして書く必要があった、というのがこの落とし穴の正体です。React Native系のライブラリをVite上で動かそうとすると、同種の問題に何度も当たることになります。
require()はブラウザでは使えない
もうひとつ、本書のビルドで実際にはまった問題を紹介します。react-native-reanimatedとの連携(前部の該当章参照)を実装したとき、React Native Skiaの内部コード(external/ModuleProxy.jsなど)が、Reanimatedを「インストールされていれば使う、なければ使わない」というオプショナルな依存として扱うために、次のような素朴なrequire()呼び出しを含んでいることが分かりました。
// @shopify/react-native-skia内部のコード(抜粋)
const reanimated = require("react-native-reanimated");
MetroやwebpackはグローバルなCommonJSのrequire関数を用意しているためこれで問題なく動きますが、ブラウザにはrequireというグローバル関数自体が存在しません。何もしなければReferenceError: require is not definedが発生し、しかも呼び出し元のtry/catchがこのエラーを握りつぶして「Reanimatedはインストールされていません」という誤解を招くメッセージに変換してしまうため、原因の特定が難しくなります。
対処として、該当する3つのファイルに限定してrequire("react-native-reanimated")という呼び出しパターンを検出し、実際の静的importに書き換えるreanimatedRequireShimPluginというViteプラグインをastro.config.mjsに追加しました。グローバルなrequireシムを用意する方法(defineでrequireを何かに置き換える)も検討しましたが、Viteの依存事前バンドルが持つCommonJS→ESM変換自体がrequireという識別子の存在を前提にしているため、その仕組みを壊すリスクがありました。特定の3ファイルだけをピンポイントで書き換える、より手術的なアプローチを選んでいます。
WebGLコンテキストの上限
ブラウザは1ページあたり最大16個までしかWebGLコンテキストを保持できません。Canvasを画面に多数並べると、この上限を超えてWARNING: Too many active WebGL contexts.という警告とともに古いコンテキストが失われることがあります。
アニメーションを伴わない静的なCanvasであれば、__destroyWebGLContextAfterRender={true}propを付けることで描画後にWebGLコンテキストを破棄し、コンテキストの枯渇を避けられます。アニメーションするCanvasにも使えますが、毎回コンテキストを再生成するため描画コストは上がります。
Webで未対応の機能
以下のAPIは、2026年時点でReact Native SkiaのWeb版では未対応です。
PathEffectFactory.MakeSum()PathEffectFactory.MakeCompose()PathFactory.MakeFromText()ShaderFilter
対応状況は今後のバージョンで変わる可能性があるため、実際に使う際は公式ドキュメントで最新の一覧を確認してください。
バンドルサイズへの影響
React Native SkiaをWebプロジェクトに追加すると、CDN経由で配信されるCanvasKitのWASMファイル分(gzip後で約2.9MB)がアプリの初期ロードに追加されます。ネイティブ(iOS約6MB・Android約4MB)と比べると単体では大きめですが、CDN配信かつ非同期ロードのため、初期表示自体をブロックしない構成にできるのが実務上の救いです。この本のデモも、ページの初期表示自体はCanvasKitのロードを待たず、各デモがマウントされたタイミングで個別に読み込みを開始します。
Expo・Remotionとの連携
Expo Routerプロジェクトでは、with-skiaテンプレートを使うとWeb対応の設定が最初から済んだ状態で始められます。
npx create-expo-app my-app -e with-skia
Remotionとの連携も公式にサポートされています。動画生成パイプラインにSkiaの描画を組み込みたい場合は、Remotion側のインストール手順を参照してください。
セットアップの詳細な手順は公式ドキュメントに譲りますが、要点は「CanvasKitのWASMファイルをどう配信するか」と「拡張子なしimportの.web優先解決をバンドラーがどう扱うか」の2点に集約されます。本章で見た2つの落とし穴は、まさにこの2点が実際のプロジェクトでどうつまずくかの具体例です。
公式ドキュメントのWeb Support・Bundle Sizeも参照してください。