🖊

インストール不要。ペライチHTMLでReact+TSX+Tailwind のフロントエンド一式を動かす

2023/11/23に公開

プロトタイピング向けにペライチで最低限のフロントエンドスタックを動かす方法について。

注意: 本番で使わないでください。tailwind は CDN モードで動かしているし、 esm.sh はスクリプトを動的にビルドするのでパフォーマンスは良くないです。

前提

jsconf.jp で色々なツールを使えばそれっぽいバンドルレス実現できる(けどパフォーマンスに難)という話を書きました。

https://jsconfjp-slide.pages.dev/

具体的には NativeESM + importmaps + esm.sh 等の組み合わせます。

<script type="importmap"> - HTML: ハイパーテキストマークアップ言語 | MDN

ESM>CDN

これに、 esm.sh の v135 の新機能を使って tsx をバンドルするのを組み合わせる話です。

https://github.com/esm-dev/esm.sh/releases/tag/v135

esm.sh/run

使い方は簡単。

  <!-- esm.sh からランナーをロード -->
  <script type="module" src="https://esm.sh/run" defer></script>

  <!-- text/babel -->
  <script type="text/babel">
    // code here
  </script>

昔からある babel/standalone と、一緒といえば一緒。

一応 text/tsx で tsx も動かせるんですが... vscode で <script type=text/tsx> は未対応でハイライトがつかないし、そもそもインライン TypeScript を型チェックを動かす方法が現状ないです。

たぶん、このファイルスコープでやりたいことは JSX の展開ぐらいだとして text/babel なら vscode が対応しているので、 text/babel が推奨されています。

Playground でも試せます。

https://code.esm.sh/?template=run

Babel + React + Tailwind

簡単な例。

CDN の tailwind と importmaps で react を引くように設定します。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    import { createRoot } from "react-dom/client";
    const App = () => {
      return <div className="bg-red-400">
        Hello World
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

これを適当な場所に index.html で保存して、 Chrome でローカルファイルとして開きます。 (open index.html 等)

動きました。

react ライブラリをサクッと試すぐらいだったら、これで良さそうですね。

shadcn-ui/react

もう少しリアルワールドっぽい複雑な例として、shadcn-ui/react が生成したコードを埋め込みます。shadcn-ui/react の中身は Tailwind + radix-ui です。

shadcn-ui/ui: Beautifully designed components built with Radix UI and Tailwind CSS.

Tailwind の設定はグローバルの tailwind.config に tailwind.config.js 相当の情報を書き込みます。

  <script>
    tailwind.config = {/**/}
  </script>

Try Tailwind CSS using the Play CDN - Tailwind CSS

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

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style type="text/tailwindcss">
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    @layer base {
      :root {
        --background: 0 0% 100%;
        --foreground: 222.2 84% 4.9%;

        --card: 0 0% 100%;
        --card-foreground: 222.2 84% 4.9%;
    
        --popover: 0 0% 100%;
        --popover-foreground: 222.2 84% 4.9%;
    
        --primary: 222.2 47.4% 11.2%;
        --primary-foreground: 210 40% 98%;
    
        --secondary: 210 40% 96.1%;
        --secondary-foreground: 222.2 47.4% 11.2%;
    
        --muted: 210 40% 96.1%;
        --muted-foreground: 215.4 16.3% 46.9%;
    
        --accent: 210 40% 96.1%;
        --accent-foreground: 222.2 47.4% 11.2%;
    
        --destructive: 0 84.2% 60.2%;
        --destructive-foreground: 210 40% 98%;

        --border: 214.3 31.8% 91.4%;
        --input: 214.3 31.8% 91.4%;
        --ring: 222.2 84% 4.9%;
    
        --radius: 0.5rem;
      }
    
      .dark {
        --background: 222.2 84% 4.9%;
        --foreground: 210 40% 98%;
    
        --card: 222.2 84% 4.9%;
        --card-foreground: 210 40% 98%;
    
        --popover: 222.2 84% 4.9%;
        --popover-foreground: 210 40% 98%;
    
        --primary: 210 40% 98%;
        --primary-foreground: 222.2 47.4% 11.2%;
        --secondary: 217.2 32.6% 17.5%;
        --secondary-foreground: 210 40% 98%;
        --muted: 217.2 32.6% 17.5%;
        --muted-foreground: 215 20.2% 65.1%;
        --accent: 217.2 32.6% 17.5%;
        --accent-foreground: 210 40% 98%;
        --destructive: 0 62.8% 30.6%;
        --destructive-foreground: 210 40% 98%;
        --border: 217.2 32.6% 17.5%;
        --input: 217.2 32.6% 17.5%;
        --ring: 212.7 26.8% 83.9%;
      }
    }
 
    @layer base {
      * {
        @apply border-border;
      }
      body {
        @apply bg-background text-foreground;
      }
    }
  </style>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: {
            border: "hsl(var(--border))",
            input: "hsl(var(--input))",
            ring: "hsl(var(--ring))",
            background: "hsl(var(--background))",
            foreground: "hsl(var(--foreground))",
            primary: {
              DEFAULT: "hsl(var(--primary))",
              foreground: "hsl(var(--primary-foreground))",
            },
            secondary: {
              DEFAULT: "hsl(var(--secondary))",
              foreground: "hsl(var(--secondary-foreground))",
            },
            destructive: {
              DEFAULT: "hsl(var(--destructive))",
              foreground: "hsl(var(--destructive-foreground))",
            },
            muted: {
              DEFAULT: "hsl(var(--muted))",
              foreground: "hsl(var(--muted-foreground))",
            },
            accent: {
              DEFAULT: "hsl(var(--accent))",
              foreground: "hsl(var(--accent-foreground))",
            },
            popover: {
              DEFAULT: "hsl(var(--popover))",
              foreground: "hsl(var(--popover-foreground))",
            },
            card: {
              DEFAULT: "hsl(var(--card))",
              foreground: "hsl(var(--card-foreground))",
            },
          },
          borderRadius: {
            lg: "var(--radius)",
            md: "calc(var(--radius) - 2px)",
            sm: "calc(var(--radius) - 4px)",
          },
          keyframes: {
            "accordion-down": {
              from: { height: 0 },
              to: { height: "var(--radix-accordion-content-height)" },
            },
            "accordion-up": {
              from: { height: "var(--radix-accordion-content-height)" },
              to: { height: 0 },
            },
          },
          animation: {
            "accordion-down": "accordion-down 0.2s ease-out",
            "accordion-up": "accordion-up 0.2s ease-out",
          },
        },
      }
    }
  </script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client",
        "class-variance-authority": "https://esm.sh/class-variance-authority",
        "clsx": "https://esm.sh/clsx",
        "tailwind-merge": "https://esm.sh/tailwind-merge"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>

<body>
  <div id="root"></div>
  <script type="text/babel">
    import { forwardRef } from "react";
    import { createRoot } from "react-dom/client";
    import { cva } from "class-variance-authority";
    import { Slot } from "@radix-ui/react-slot";
    import { clsx } from "clsx";
    import { twMerge } from "tailwind-merge";

    export function cn(...inputs) {
      return twMerge(clsx(inputs));
    }

    const buttonVariants = cva(
      "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
      {
        variants: {
          variant: {
            default: "bg-primary text-primary-foreground hover:bg-primary/90",
            destructive:
              "bg-destructive text-destructive-foreground hover:bg-destructive/90",
            outline:
              "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
            secondary:
              "bg-secondary text-secondary-foreground hover:bg-secondary/80",
            ghost: "hover:bg-accent hover:text-accent-foreground",
            link: "text-primary underline-offset-4 hover:underline",
          },
          size: {
            default: "h-10 px-4 py-2",
            sm: "h-9 rounded-md px-3",
            lg: "h-11 rounded-md px-8",
            icon: "h-10 w-10",
          },
        },
        defaultVariants: {
          variant: "default",
          size: "default",
        },
      },
    );

    export const Button = forwardRef(
      ({ className, variant, size, asChild = false, ...props }, ref) => {
        const Comp = asChild ? Slot : "button";
        return (
          <Comp
            className={cn(buttonVariants({ variant, size, className }))}
            ref={ref}
            asChild={asChild}
            {...props}
          />
        );
      },
    );
    Button.displayName = "Button";

    const App = () => {
      return <div>
        <Button variant="outline">
          Hello World
        </Button>
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

動きました。

結構複雑でしたが、これでも動いてます。ただ、プレイグラウンドで確認する限りは、プレビュー速度はそんなに速くないです。

今回は問題なかったんですが、 tailwind.config.js の plugins で指定する外部ライブラリが読み込みを表現する方法がありません。

Devtools Source

ここまで来たら開発環境もブラウザで完結させたくなってきました。

Devtools の Source タブで自身をマウントします。

できました。書き換えて手動でリロードで更新します。(頑張れば自己監視でオートリロードできる?)

ちょっと残念なことに Devtools 組み込みのエディタでは <script type="text/typescript"> は対応していましたが、 <script type="text/tsx"> はダメでした。

<script type="text/tsx" src="./main.tsx"></script> もだめでした。これがあれば(ネストした import 先以外は) tsx で書けるんですが...

Thx jexia_ (author of esm.sh)

esm.sh/run 以外の部分を作ったのを Twitter で投げたら esm.sh の作者に教えてもらいました。感謝。

https://twitter.com/jexia_/status/1727069200072741122

あとは vscode で text/tsx のハイライトが効かない部分と、LSP を動かす方法、あわよくばフォーマット方法さえ見つかればもうちょっと体験良く出来そうです。前に作った markdown のコードブロックで LSP を動かす実装が転用できそう。

Markdown のコードブロックでLSPを動かす VSCode 拡張を作った

esm.sh/run の部分は自前の実装で LSP 動かしてしまってもいいかも。

Discussion