ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

Partytownでブラウザでお手軽に並列処理をする

初めまして、 ミツモアのエンジニアの@teradonburiです。
2023年度の年度末アドベントカレンダーの先鋒を切らせていただきます。
今回紹介したいテクニックはブラウザ上で比較的簡単に並列処理を導入する方法です。

ブラウザのJavaScriptは通常シングルスレッドで動く

ブラウザのJavaScript処理はシングルスレッドで動きます。
詳細は参考リンクを参照してください。

qiita.com

zenn.dev

Promiseによる非同期処理、つまりはXHRやfetchなどの非同期通信やaddEventListener等のイベントハンドリングはMicroTaskと呼ばれるキューにスタックされ、イベントループにて順次実行されます。
(そのため、通信しながら同時にイベントハンドリングされるということは実際にはありません。)
この仕組み自体はウェブページアプリケーションの整合性が保たれるため、素晴らしいのですが、イベントハンドリング以外のJavaScriptが実行中はユーザのインタラクションをブロックすることになるため、速度的な限界があります。
特にSEO対策が必要なランディングページにおいてGoogle Tag Managerで外部トラッキングタグ等を埋め込んでいる場合にページ速度(Core Web Vitals)のFIDやINPに影響します。

マルチスレッドで処理する

Web Workerと呼ばれるものでブラウザ上でも並列処理をすることができます。

Web Workers | Can I use... Support tables for HTML5, CSS3, etc

Web Workerはほとんどのブラウザでサポートはされているのですが、windowオブジェクトやdocumentオブジェクト等のDOM操作をWeb Worker内から直接行うことができない制約があります。

developer.mozilla.org

またWeb Workerの一種であるService Workerも存在しており、
Web WorkerのコンテキストがDedicatedWorkerGlobalScopeである一方、Service WorkerはServiceWorkerGlobalScopeであるという違いがあります。詳細は以下の記事を参考にしてください。

DedicatedWorkerGlobalScopeServiceWorkerGlobalScope

Web WorkersはWorker()コンストラクタを使ってコードの実行を開始させるが、Service Workersではコードを直接実行させるのではなく、ServiceWorker.register()メソッドを使って実行したいコードを登録することで処理を開始させる。さらに、Web Workersでは呼び出し元コードを実行するページが閉じられるとWeb Workerとして実行されたコードも終了するが、Service Workersでは呼び出し元ページが閉じられても永続的に動作する。Webブラウザを終了させても、再びWebブラウザが起動した際にService Workersは再起動する。ただし、Service Workersは常時バックグラウンドで動作しているわけではない。後述するイベントの発生時に必要に応じて起動され、コード内で定義されたイベントハンドラが実行され、イベントハンドラの実行が完了するとアイドル状態となる。

knowledge.sakura.ad.jp

Web WorkerライブラリのPartytownが凄い理由

Web Workerを直接使うとメインスレッドとの通信制御が煩雑で難しいため、

ウェブワーカーの基本  |  Articles  |  web.dev

使いやすくするために、多くのWeb Workerライブラリが存在しています。

その中で今回はPartytownに関して紹介します。
Partytownが凄いのは、Service WorkerとWeb Workerを組み合わせてDOM操作をしている部分です。
非同期の複数のWeb Workerから共通のService Workerに同期通信を行い、Service Worker経由でメインスレッドに対しpostMessageでDOM操作をするというハックをしています。
これによりWeb WorkerからのDOM操作を擬似的にスレッドセーフで行うことを実現しています。

詳細な説明や擬似コードに関しては以下の参考リンクが詳しいです。

zenn.dev

また、実際にWeb WorkerからDOM操作を行っているtest exampleが以下にあります。

partytown.builder.io

Partytownの導入

PartyTownは様々なJSフレームワークでサポートされています。

partytown.builder.io

今回はReactJSでPartytownを導入します。

partytown.builder.io

package.jsonに追記します。

  "scripts": {
    "partytown": "partytown copylib public/~partytown"
  },
  "dependencies": {
    "@builder.io/partytown": "^0.8.0",
  }

packageダウンロード後に以下のコマンドでPartytownのスクリプトを先に生成する必要があります。

$ npm run partytown

使い方自体はPartytownコンポーネントで初期化し、 scriptタグに対し、type="text/partytown"属性をつけるだけで Web Worker化されます。

import { Partytown } from '@builder.io/partytown/react'

function something() {
  const image = new Image()
  image.onload = function (ev) {
     const elm = document.getElementById('testImg')
     elm.textContent = ev.type
     elm.className = 'testImageOnLoad'
  }
  image.src = 'test.png'
}

export function parallelExec() {
  return (
    <>
      <Partytown debug={true} />
      <img id="testImg" />
      <script type="text/partytown" dangerouslySetInnerHTML={{__html: `(${something.toString()})()`}}></script>
    </>
  );
}

Google Tag ManagerをWeb Worker化する

PartytownはGoogle Tag Managerも同様にWeb Workerをサポートしています。
Google Tag Managerの読み込みもtype="text/partytown"でWeb Worker化することができます。

partytown.builder.io

import { Partytown } from '@builder.io/partytown/react'

export function GoogleTagManager() {
  return (
    <>
      <Partytown
        debug={true}
        forward={['dataLayer.push']}
      />
      {/** Google Tag Manager */}
      <script type="text/partytown" defer dangerouslySetInnerHTML={{__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${process.env.NEXT_PUBLIC_GTM_ID}');
`}}></script>
    </>
  );
}

CORS対策にreverse proxyする

Google Tag ManagerのカスタムHTMLで埋め込んだWeb Worker内の広告トラッキング用等のサードパーティスクリプトは外部ドメインに対して通信をするため、CORSのブラウザセキュリティ制約に引っかかります。
そこでresolveUrlでWeb Worker内の通信処理をフックし、サーバサイド側で通信処理をリバーシプロキシする必要があります。

import { Partytown } from '@builder.io/partytown/react'

export function GoogleTagManager() {
  return (
    <>
      <Partytown
        debug={true}
        forward={['dataLayer.push']}
        resolveUrl={(url, location) => {
          if (url.href.startWith(location.origin + '/proxy')) {
             return url
          }

          // CORS 対策でrequestをreverse proxyする
          if (url.href.startsWith('https://')) {
            const host = url.host
            const path = url.pathname === '/' ? '' : url.pathname
            const search = url.search === '?' ? '' : url.search
            const proxyUrl = new URL(location.origin + '/proxy' + path + search)
            proxyUrl.searchParams.append(
              'target_party_host',
              host
            )
            return proxyUrl
          }
          return url
        }}
      />
      {/** Google Tag Manager */}
      <script type="text/partytown" defer dangerouslySetInnerHTML={{__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${process.env.NEXT_PUBLIC_GTM_ID}');
`}}></script>
    </>
  );
}

例えばNextJSの場合ではsrc/middleware.tsのミドルウェアにてサーバサイドでリバースプロキシすることでCORSを回避することが出来ます。

import { NextRequest, NextResponse } from 'next/server'

export const middleware = async (request: NextRequest) => {
  // reverse proxy
  if (request.nextUrl?.pathname?.startsWith('/proxy')) {
    const nextUrl = request.nextUrl.clone()
    const searchParam = new URLSearchParams(nextUrl.search)
    const pathname = nextUrl.pathname.replace(/^\/proxy/, '')
    const origin = searchParam.get('target_party_host')
    if (origin) {
      searchParam.delete('target_party_host')
      const query =
        searchParam.toString().length > 0 ? `?${searchParam.toString()}` : ''
      const url = 'https://' +
        decodeURIComponent(origin) + (pathname ? pathname : '') + query
      try {
        const requestHeaders = new Headers(request.headers)
        return NextResponse.rewrite(new URL(url), {
          request: { headers: requestHeaders },
        })
      } catch (e) {
        console.warn(`reverse proxy failed: ${url}`)
      }
    }
  }

  return NextResponse.next()
}

今回のGitHubサンプル

GTMカスタムHTMLタグのサンプルは以下となっています

埋め込んだサードパーティスクリプトの実装に依存するため、すべてのGoogle Tag Managerタグに対して適用できるわけではないですが、移行することができればGoogle Tag Manager自体のローディングもWeb Worker化できるため大幅にパフォーマンスを向上させることができます。

最後に

ミツモアでは様々な職種のエンジニアを積極的に採用しています! ご興味がある方はぜひ気軽に面談しましょう!