deno フロントエンドの基盤を考える
課題感
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 用の変換で、バンドラとはちょっと違う。
こういうコードをバンドルしたいとする
// 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 で動くコードを生成する。
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 でホストされているコードを読みにいってしまって落ちる
// @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 部分は認識できない
実行速度は度外視で、一旦 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 を使っているという嬉しみは少ない。
結論
とりあえずのベストプラクティス
フロントエンドの外部ライブラリは esm.sh を使う。
esm.sh が中間形式を変えたら影響を受ける可能性がある。
deno lsp で一応型はつく
Vite 側の missing parts
optimizeDeps.include で https://
を事前ビルドできれば嬉しい。
ただ、この場合はライブラリ間の共有チャンクの重複を弾けない。
Deno の package.json 互換モードを使う
Runtime は Deno を使うが、依存は全部 node_modules から解決するとする。
エディタの LSPは全部 deno 側で動かす。
vscode 側の設定
{
"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
{
"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 の設定
import { defineConfig } from 'npm:vite@4.5.0';
export default defineConfig({
plugins: []
});
vite が tsconfig.json を見るので、jsx の解決のために型定義ファイルを置く
{
"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
簡単なものをコンパイルしてみる
<!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>
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 を置く
/// <reference types="npm:@types/react" />
これが deno lsp でエラーなく動いている、というのがミソ
Pros/Cons
- Pros
- node と同じやり方で動く
- vscode 上の型が付く
- Cons
- deno 特有の機能(httpsの解決等)を使えていない