Open6

deno フロントエンドの基盤を考える

mizchimizchi

課題感

deno でフロントエンドをやろうとすると、最適化の問題でnodeを前提としたフロントエンド用のバンドラを使うことになるので、 deno と違うセマンティクスを要求される。例えば https の network imports ができない。

つまり、これが動かない。

import { delay } from "https://deno.land/x/delay/mod.ts"
await delay(sub);

フロントエンド用のバンドラを使いたい理由

チャンク分割をしたい。deno emit は中間実行ファイルを作るには便利だが、これは chunk の生成等は考慮されない。

denoland/deno_emit: Transpile and bundle JavaScript and TypeScript under Deno and Deno Deploy

しかし、 deno のモジュール解決ルールは守りたい。
dnt は node 用の変換で、バンドラとはちょっと違う。

mizchimizchi

こういうコードをバンドルしたいとする

// main.ts
import { delay } from "https://deno.land/x/delay/mod.ts"
import { sub } from "./sub.ts";

async function main() {
  console.log("started");
  await delay(sub);
  console.log("waited");
}

main();

// sub.ts
export const sub = 500;

delay はブラウザでも実行可能な実装。

Rollup で https import の外部モジュールの解決だけをさせてみる。

import { bundle } from "https://deno.land/x/emit@0.32.0/mod.ts";
import { rollup } from "npm:rollup@2.56.3";

const bundled = await rollup({
  input: './main.ts',
  plugins: [
    {
      name: "emit",
      resolveId(id: string) {
        return id;
      },
      async load(id: string) {
        if (id.startsWith("https://")) {
          const result = await bundle(id);
          return {
            code: result.code,
            map: result.map,
          }
        }
      },
    },
  ],
});

const out = await bundled.generate({
  format: "esm",
  sourcemap: true,
});

console.log(out.output[0].code);

これはバンドル可能で、ブラウザ/Node.js で動くコードを生成する。

mizchimizchi

deno は vite を実行できるので, vite plugin として deno bundle を動かすことができる

import { bundle } from "https://deno.land/x/emit@0.32.0/mod.ts";
import { build } from "npm:vite@5.0.10";
await build({
  plugins: [
    {
      name: "deno-network-imports",
      enforce: "pre",
      resolveId(id: string) {
        if (id.startsWith("https://")) {
          console.log("Resolving imports", id);
          return id;
        }
      },
      async load(id: string) {
        if (id.startsWith("https://")) {
          console.log("Bundling imports", id);
          const result = await bundle(id);
          return {
            code: result.code,
            map: result.map,
          }
        }
      },
    },
  ],
});

じゃあ createServer が動くかというと、これが動かない。

import { bundle } from "https://deno.land/x/emit@0.32.0/mod.ts";
import { createServer } from "npm:vite@5.0.10";

const server = await createServer({
  plugins: [
    {
      name: "deno-network-imports",
      enforce: "pre",
      resolveId(id: string) {
        if (id.startsWith("https://")) {
          console.log("Resolving imports", id);
          return id;
        }
      },
      async load(id: string) {
        if (id.startsWith("https://")) {
          console.log("Bundling imports", id);
          const result = await bundle(id);
          return {
            code: result.code,
            map: result.map,
          }
        }
      },
    },
  ],
});

await server.listen();

動かない理由としては、 基本的に vite dev の開発モードは no bundle モードで実行されるので、 https://* への変形が発動せず、次のように直接 deno.land でホストされているコードを読みにいってしまって落ちる

https://deno.land/x/delay@v0.2.0/mod.ts?source=

// @ts-ignore allowing typedoc to build
export * from './src/delay.ts';

これはCDN側で事前バンドルされる https://esm.sh/delay を使えば一応回避可能ではある。

optmizeDeps が使えないか

vite は 事前最適化で node_modules/* を事前にビルドするのだが、同様に https://* を事前ビルドしてしまえば解決するのでは?と思って試した。が、ダメそう。

await build({
  optimizeDeps: {
    include: ['https://**'],
  },
  // ...

認識しない。

ドキュメント読むと、実験的機能として末尾 glob pattern に対応するとあるので、prefix 部分は認識できない
https://ja.vitejs.dev/config/dep-optimization-options.html#optimizedeps-include

mizchimizchi

実行速度は度外視で、一旦 vite build -w と静的サーバーみたいな組み合わせで試してみる

import { bundle } from "https://deno.land/x/emit@0.32.0/mod.ts";
import { build } from "npm:vite@5.0.10";

import { serve } from "https://deno.land/std@0.141.0/http/mod.ts";
import { serveDir } from "https://deno.land/std@0.141.0/http/file_server.ts";
serve((request) => serveDir(request, {
  fsRoot: "./dist",
  showDirListing: true,
}), { port: 8000 });

await build({
  build: {
    watch: {
    }
  },
  plugins: [
    {
      name: "deno-network-imports",
      enforce: "pre",
      resolveId(id: string) {
        if (id.startsWith("https://")) {
          console.log("Resolving imports", id);
          return id;
        }
      },
      async load(id: string) {
        if (id.startsWith("https://")) {
          console.log("Bundling imports", id);
          const result = await bundle(id);
          return {
            code: result.code,
            map: result.map,
          }
        }
      },
    },
  ],
});

一応動く。no bundle 機能がなくなってるので、vite を使っているという嬉しみは少ない。

mizchimizchi

結論

とりあえずのベストプラクティス

フロントエンドの外部ライブラリは esm.sh を使う。
esm.sh が中間形式を変えたら影響を受ける可能性がある。

deno lsp で一応型はつく

Vite 側の missing parts

optimizeDeps.include で https:// を事前ビルドできれば嬉しい。
ただ、この場合はライブラリ間の共有チャンクの重複を弾けない。

mizchimizchi

Deno の package.json 互換モードを使う

Runtime は Deno を使うが、依存は全部 node_modules から解決するとする。
エディタの LSPは全部 deno 側で動かす。

vscode 側の設定

.vscode/settings.json
{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true,
}

プロジェクトを pnpm と deno 両方で初期化

$ pnpm init
$ pnpm add react react-dom @types/react @types/react-dom
# node_modules/* を vite と deno から解決する

$ deno init
deno.jsonc
{
  "tasks": {
    "dev": "deno run -A npm:vite@4.5.0 . --config ./vite.config.mts",
    "build": "deno run -A npm:vite@4.5.0 build --config ./vite.config.mts --mode production",
  },
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  }
}

vscode の補完は こちらの compilerOptions が使われる。

vite の設定

vite.config.mts
import { defineConfig } from 'npm:vite@4.5.0';

export default defineConfig({
  plugins: []
});

vite が tsconfig.json を見るので、jsx の解決のために型定義ファイルを置く

tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "Bundler",
    "target": "es2020",
    "module": "ESNext",
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "skipLibCheck": true
  }
}

vite source

簡単なものをコンパイルしてみる

index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="root"></div>
  <script type="module" src="./src/main.tsx"></script>
</body>

</html>
src/main.tsx
import { renderToString } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { z } from "zod";

console.log(z.string().parse("hello"));
console.log(renderToString(<div>hello</div>));

const root = document.getElementById("root");

createRoot(root!).render(<div>hello</div>);

このままだと deno lsp が React の型を解決できなかったので、 env.d.ts を置く

src/env.d.ts
/// <reference types="npm:@types/react" />

これが deno lsp でエラーなく動いている、というのがミソ

Pros/Cons

  • Pros
    • node と同じやり方で動く
    • vscode 上の型が付く
  • Cons
    • deno 特有の機能(httpsの解決等)を使えていない