AstroのScriptタグを扱うときの注意
2022 / 11 / 23
EditAstro の<script>
は何もインテグレーションを入れなくても使えるお手軽さがある一方、ハマるポイントがあります。
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>
はデフォルトでグローバル(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 へユーザーランドのコードが入ります。
例えこのように 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 となるため、期待通りの挙動となります。
まとめ
このように<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_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>
になるかと思って書くとハマりがちです。