AstroのScriptタグを扱うときの注意

2022 / 11 / 23

Edit
🚨 This article hasn't been updated in over a year

Astro の<script>は何もインテグレーションを入れなくても使えるお手軽さがある一方、ハマるポイントがあります。

Components An intro to the .astro component syntax.

Script の問題点

以下のコードの場合、Header は共通で使われるものではあるものの Foo は条件により/blog以下でしか HTML 上には出力されません。 つまり、これは他のページではこの Foo.astro 自体が読み込まれないように期待したいところです。 しかし、これは上手くいきません。

---
// Foo.astro
---

<span>foo</span>

<script>
  console.log("hello");
</script>

---
// Header.astro

import Foo from "./Foo.astro";
---

<header>
  <p>site</p>
  {Astro.url.pathname.startsWith("/blog") && <Foo />}
</header>

実際の出力結果はこのようになります。このように全てのページの HTML にこの <script> が挿入されます。

script as global on html

なぜこのようになるかというと、<script>はデフォルトでグローバル(is:global)の扱いとなるためです。 たしかに Foo.astro の中で書いたものではあるものの、どこにでも影響を与えることができてしまうので、保証できずうまく最適化できません。

このサンプルの場合だと大きな影響はないですが、例えばライブラリを import したらどうでしょうか?

---
// Foo.astro
---

<span>foo</span>

<script>
  import { App } from "octokit";

  console.log("hello");
  console.log(App);
</script>

この場合は、ライブラリが hoisting され、HTML から分離されます。 このように、ライブラリと一緒の JS へユーザーランドのコードが入ります。

hoisted js

例えこのように JS に分解されても、やはり扱いはグローバルなのでこの JS は全ての HTML で呼び出しが行われてしまいます。 これは/blog以外のページからすると見過ごせない無駄なコードとなり、最適化を行う必要があります。

これを回避するには、is:inline(or define:vars)をつけることにより、そのファイルのコンポーネントだけがスコープとなります。 しかし、次の問題は、これらはトランスパイラを通しません。つまり、絶対パスではない場合の import が読み込めなかったり、コード自体の最適化することができません。 また、これの一番致命的な点は、TypeScript で書けない点です。

共通レイヤーで処理を分岐させない

この問題を解決するには、共通レイヤーで分岐をせず、親レイヤーで利用しないことを明示する必要があります。

---
// Header.astro
---

<header>
  <p>site</p>
  <slot name="action" />
</header>

---
// Home.astro

import Foo from "./Foo.astro";
import Header from "./Header.astro";
---

<Header>
  <Foo slot="action" />
</Header>

---
// Blog.astro

import Header from "./Header.astro";
---

<Header />

このように書くことにより、Home には Foo の script が入りますが、Blog には入らずバンドルサイズを最適化することができます。

Framework を使う

Framework を使ったらどうなるのでしょうか? 今回は、React を例に見ていきます。

// Foo.tsx
import { App } from "octokit";
import type { FC } from "react";

export const Foo: FC = () => {
  console.log("hello");
  console.log(App);

  return <span>foo</span>;
};

---
// Header.astro

import Foo from "./Foo.astro";
---

<header>
  <p>site</p>
  {Astro.url.pathname.startsWith("/blog") && <Foo client:load />}
</header>

client:loadを使い、React 等で閉じ込めると、以下のようになります。 これは、/blog以下でしかダウンロードされない JS となるため、期待通りの挙動となります。

react

まとめ

このように<script>を扱う場合には、注意点があります。

基本、TS や import 構文を使いたい場合はデフォルトのis:globalを使うことになりますが、その場合予期せぬバンドルサイズの増加や副作用が発生する可能性があります。 それを回避するためにも、この例のように、共通レイヤーでクライアントコードを含んだコンポーネントの分岐を書くのは避けるべきです。 ただこれは自分にとって直感に反するため、もしわかりやすくしたいのであれば React などのフレームワークを使ったほうが可読性が高いかなと思います。

Env の扱い方

import.meta.envは Astro 標準で準備されているものしか<script>内では参照できません。 これを解決するにはdefine:vars経由で入れる必要があります。 しかし、先程の話通り、define:varsは TS で書けなかったりバンドラを通しません。

define:vars を利用する方法

---
const foo = import.meta.env.FOO;
---

<script define:vars={{ foo }}>
  console.log(import.meta.env.FOO); // これは存在しない
  console.log(foo);
</script>

これを回避する方法は、以下のように 1 段階 window に刺すレイヤーを用意して上げる方法です。

Window でラップする方法

---
const foo = import.meta.env.FOO;
---

<script define:vars={{ foo }}>
  window.foo = foo;
</script>

<script>
  const foo: string = "foo";
  console.log(window.foo);
</script>

それか、Vite で回避する方法もあります。この問題自体は Vite の問題でセンシティブな情報が出てしまわないようにする制約です。

.env でフロントエンドに渡したいものにVITE_*をつける方法

Vite Next Generation Frontend Tooling

VITE_SOME_KEY=123
DB_PASSWORD=foobar

console.log(import.meta.env.VITE_SOME_KEY); // 123
console.log(import.meta.env.DB_PASSWORD); // undefined

define で定義してあげる方法

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  define: {
    FOO: JSON.stringify(process.env.FOO),
  },
});

ただ基本、Astro を触っていると vite の設定は触らないのでこの書き方はあまり使わないかもしれません。

まとめ

Astro は JS を削ぎ落として HTML を生成することに特化していますが、ブラウザで動く JS のコードを書く場合は、ライブラリに依存したほうが良いかなと思います。 特定の Astro ファイルに書いているので、そのコンポーネントのスコープのみの<script>になるかと思って書くとハマりがちです。