Lainbo

Lainbo's Blog

If you’re nothing without the suit, then you shouldn't have it.
github

前端工程化入门06 - 前端監視

いつ監視が必要か#

  1. アプリケーションが頻繁にエラーを報告し、その原因がわからないとき。
  2. ユーザーの興味や購入習慣を分析する必要があるとき。
  3. プログラムを最適化する必要があるとき、監視を行いデータを収集し、ターゲットを絞った最適化を行うことができる。
  4. サービスの信頼性と安定性を保証する必要があるとき。

あなたのアプリケーションが上記のいずれかに該当する場合、監視を実施することができます。監視の役割は二つあります:事前警告と事後分析です。

事前警告:あらかじめ閾値を設定し、監視データが閾値に達したときに、SMS やメールで管理者に通知します。例えば、API リクエスト数が突然増加した場合、警告を発する必要があります。さもなければ、サーバーがダウンする可能性があります。

事後分析:監視ログファイルを通じて、故障の原因と故障発生点を分析します。それにより修正を行い、同様の事態が再発しないようにします。

本章の内容は、フロントエンド監視の原理分析とプロジェクトに対する監視の実施方法の二つの部分に分かれています。第一部では簡易な監視 SDK の作成方法について説明し、第二部では sentry を使用してプロジェクト監視を実現する方法について説明します。

さて、本文に入っていきましょう。

完全なフロントエンド監視プラットフォームは、データ収集と報告、データ整理と保存、データ表示の三つの部分から構成されています。

パフォーマンスデータ収集#

Chrome 開発チームは、ウェブページのパフォーマンスを検出するための指標の一連を提案しました:

  • FP(first-paint):ページの読み込み開始から最初のピクセルが画面に描画されるまでの時間
  • FCP(first-contentful-paint):ページの読み込み開始からページ内容の任意の部分が画面上でレンダリングされるまでの時間
  • LCP(largest-contentful-paint):ページの読み込み開始から最大のテキストブロックまたは画像要素が画面上でレンダリングされるまでの時間
  • CLS(layout-shift):ページの読み込み開始からそのライフサイクル状態が隠れるまでに発生したすべての予期しないレイアウトのオフセットの累積スコア

これらの四つのパフォーマンス指標は、PerformanceObserverを使用して取得する必要があります(performance.getEntriesByName()を使用して取得することもできますが、これはイベントがトリガーされたときに通知されるわけではありません)。PerformanceObserver は、パフォーマンス測定イベントを監視するためのパフォーマンス監視オブジェクトです。

FP#

FP(first-paint):ページの読み込み開始から最初のピクセルが画面に描画されるまでの時間。実際には FP をホワイトスクリーン時間として理解することも問題ありません。

測定コードは以下の通りです:

const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
            observer.disconnect()
        }

       console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
// buffered属性はキャッシュデータを監視するかどうかを示します。つまり、監視コードの追加タイミングがイベントトリガーのタイミングより遅くても問題ありません。
observer.observe({ type: 'paint', buffered: true })

上記のコードを使用して FP の内容を取得できます:

{
    duration: 0,
    entryType: "paint",
    name: "first-paint",
    startTime: 359, // fp時間
}

ここでのstartTimeが私たちが求める描画時間です。

FCP#

FCP(first-contentful-paint):ページの読み込み開始からページ内容の任意の部分が画面上でレンダリングされるまでの時間。この指標において、「内容」とはテキスト、画像(背景画像を含む)、<svg>要素、または非白色の<canvas>要素を指します。

良好なユーザー体験を提供するために、FCP のスコアは 1.8 秒以内に制御されるべきです。

測定コード:

const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
            observer.disconnect()
        }

        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })

上記のコードを使用して FCP の内容を取得できます:

{
    duration: 0,
    entryType: "paint",
    name: "first-contentful-paint",
    startTime: 459, // fcp時間
}

ここでのstartTimeが私たちが求める描画時間です。

LCP#

LCP(largest-contentful-paint):ページの読み込み開始から最大のテキストブロックまたは画像要素が画面上でレンダリングされるまでの時間。LCP 指標は、ページ最初の読み込み開始の時間点に基づいて、可視領域内で見える最大画像またはテキストブロックがレンダリングを完了するまでの相対時間を報告します。

良好な LCP スコアは 2.5 秒以内に制御されるべきです。

測定コード:

const entryHandler = (list) => {
    if (observer) {
        observer.disconnect()
    }

    for (const entry of list.getEntries()) {
        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })

上記のコードを使用して LCP の内容を取得できます:

{
    duration: 0,
    element: p,
    entryType: "largest-contentful-paint",
    id: "",
    loadTime: 0,
    name: "",
    renderTime: 1021.299,
    size: 37932,
    startTime: 1021.299,
    url: "",
}

ここでのstartTimeが私たちが求める描画時間です。element は LCP 描画の DOM 要素を指します。

FCP と LCP の違いは、FCP は任意の内容が描画されるとトリガーされ、LCP は最大内容がレンダリングされるとトリガーされることです。

LCP が考慮する要素のタイプは以下の通りです:

  • <img>要素
  • <svg>要素内に埋め込まれた<image>要素
  • <video>要素(カバー画像を使用)
  • url()関数を使用して(CSS グラデーションを使用せずに)背景画像を持つ要素
  • テキストノードまたは他のインラインテキスト要素の子要素を含むブロックレベル要素

CLS#

CLS(layout-shift):ページの読み込み開始からそのライフサイクル状態が隠れるまでに発生したすべての予期しないレイアウトのオフセットの累積スコア。

レイアウトオフセットスコアの計算方法は以下の通りです:

レイアウトオフセットスコア = 影響スコア * 距離スコア

影響スコアは、不安定な要素が二つのフレーム間の可視領域に与える影響を測定します。

距離スコアは、任意の不安定な要素が一つのフレーム内で移動した最大距離(水平または垂直)を可視領域の最大サイズ次元(幅または高さ、より大きい方)で割ったものです。

CLS はすべてのレイアウトオフセットスコアを合計したものです

DOM が二つのレンダリングフレーム間で移動した場合、CLS がトリガーされます。

同時に、CLS にはセッションウィンドウという用語があります:一回のレイアウトオフセットが一つ以上の迅速に連続して発生し、各オフセットの間隔が 1 秒未満で、ウィンドウ全体の最大持続時間が 5 秒である場合です。

例えば、上の図の第二のセッションウィンドウには四回のレイアウトオフセットがあり、各オフセットの間隔は 1 秒未満であり、最初のオフセットと最後のオフセットの間の時間は 5 秒を超えてはいけません。これが一つのセッションウィンドウと見なされます。この条件に合わない場合は、新しいセッションウィンドウと見なされます。なぜこのように規定されているのか疑問に思う人もいるかもしれませんが、これは Chrome チームが大量の実験と研究に基づいて得た分析結果です Evolving the CLS metric

CLS には三つの計算方法があります:

  1. 累積
  2. すべてのセッションウィンドウの平均
  3. すべてのセッションウィンドウの最大値

累積#

つまり、ページの読み込み開始からのすべてのレイアウトオフセットスコアを合計します。しかし、この計算方法はライフサイクルが長いページには不向きで、ページの滞在時間が長くなるほど CLS スコアが高くなります。

すべてのセッションウィンドウの平均#

この計算方法は、単一のレイアウトオフセットを単位とするのではなく、セッションウィンドウを単位とします。すべてのセッションウィンドウの値を合計して平均値を取ります。しかし、この計算方法にも欠点があります。

上の図からわかるように、最初のセッションウィンドウは比較的大きな CLS スコアを生成し、二番目のセッションウィンドウは比較的小さな CLS スコアを生成します。これらの平均値を CLS スコアとして取ると、ページの実行状況が全くわからなくなります。元々ページは初期にオフセットが多く、後期にオフセットが少なかったが、現在の平均値ではこの状況を反映できません。

すべてのセッションウィンドウの最大値#

この方法は現在最適な計算方法であり、毎回すべてのセッションウィンドウの最大値を取って、ページのレイアウトオフセットの最悪の状況を反映します。詳細は Evolving the CLS metricを参照してください。

以下は第三の計算方法の測定コードです:

let sessionValue = 0
let sessionEntries = []
const cls = {
    subType: 'layout-shift',
    name: 'layout-shift',
    type: 'performance',
    pageURL: getPageURL(),
    value: 0,
}

const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
        // 最近のユーザー入力がないレイアウトシフトのみをカウントします。
        if (!entry.hadRecentInput) {
            const firstSessionEntry = sessionEntries[0]
            const lastSessionEntry = sessionEntries[sessionEntries.length - 1]

            // エントリが前のエントリから1秒未満で発生し、セッション内の最初のエントリから5秒未満であれば、現在のセッションにエントリを含めます。そうでなければ、新しいセッションを開始します。
            if (
                sessionValue
                && entry.startTime - lastSessionEntry.startTime < 1000
                && entry.startTime - firstSessionEntry.startTime < 5000
            ) {
                sessionValue += entry.value
                sessionEntries.push(formatCLSEntry(entry))
            } else {
                sessionValue = entry.value
                sessionEntries = [formatCLSEntry(entry)]
            }

            // 現在のセッション値が現在のCLS値より大きい場合、CLSとそれに寄与するエントリを更新します。
            if (sessionValue > cls.value) {
                cls.value = sessionValue
                cls.entries = sessionEntries
                cls.startTime = performance.now()
                lazyReportCache(deepCopy(cls))
            }
        }
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'layout-shift', buffered: true })

上記のテキスト説明を見た後、コードを見ると理解しやすくなります。一回のレイアウトオフセットの測定内容は以下の通りです:

{
  duration: 0,
  entryType: "layout-shift",
  hadRecentInput: false,
  lastInputTime: 0,
  name: "",
  sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
  startTime: 1176.199999999255,
  value: 0.000005752046026677329,
}

コード内のvalueフィールドがレイアウトオフセットスコアです。

DOMContentLoaded、load イベント#

純粋な HTML が完全に読み込まれ、解析されたとき、DOMContentLoadedイベントがトリガーされ、CSS、img、iframe の読み込みが完了するのを待つ必要はありません。

ページ全体とすべての依存リソース(スタイルシートや画像)が完了して読み込まれたとき、loadイベントがトリガーされます。

これらの二つのパフォーマンス指標は比較的古いですが、ページのいくつかの状況を反映することができます。これらを監視することは依然として必要です。

import { lazyReportCache } from '../utils/report'

['load', 'DOMContentLoaded'].forEach(type => onEvent(type))

function onEvent(type) {
    function callback() {
        lazyReportCache({
            type: 'performance',
            subType: type.toLocaleLowerCase(),
            startTime: performance.now(),
        })

        window.removeEventListener(type, callback, true)
    }

    window.addEventListener(type, callback, true)
}

ファーストスクリーンレンダリング時間#

ほとんどの場合、ファーストスクリーンレンダリング時間はloadイベントを通じて取得できます。特別な状況を除いて、例えば非同期で読み込まれる画像や DOM など。

<script>
    setTimeout(() => {
        document.body.innerHTML = `
            <div>
                <!-- 省略一堆代码... -->
            </div>
        `
    }, 3000)
</script>

このような場合、loadイベントを通じてファーストスクリーンレンダリング時間を取得することはできません。この時、MutationObserverを使用してファーストスクリーンレンダリング時間を取得する必要があります。MutationObserver は、監視対象の DOM 要素の属性が変更されるとイベントをトリガーします。

ファーストスクリーンレンダリング時間の計算プロセス:

  1. MutationObserver を使用して document オブジェクトを監視し、DOM 要素の属性が変更されるたびにイベントをトリガーします。
  2. その DOM 要素がファーストスクリーン内にあるかどうかを判断し、もしそうであれば、requestAnimationFrame()のコールバック関数内でperformance.now()を呼び出して現在の時間を取得し、それを描画時間とします。
  3. 最後の DOM 要素の描画時間とファーストスクリーン内のすべての読み込まれた画像の時間を比較し、最大値をファーストスクリーンレンダリング時間とします。

DOM の監視#

const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']

observer = new MutationObserver(mutationList => {
    const entry = {
        children: [],
    }

    for (const mutation of mutationList) {
        if (mutation.addedNodes.length && isInScreen(mutation.target)) {
             // ...
        }
    }

    if (entry.children.length) {
        entries.push(entry)
        next(() => {
            entry.startTime = performance.now()
        })
    }
})

observer.observe(document, {
    childList: true,
    subtree: true,
})

上記のコードは DOM の変化を監視するコードであり、同時にstylescriptlinkなどのタグをフィルタリングする必要があります。

ファーストスクリーン内にあるかどうかの判断#

ページの内容は非常に多いかもしれませんが、ユーザーは最大で一画面の内容しか見ることができません。したがって、ファーストスクリーンレンダリング時間を統計する際には、範囲を限定し、レンダリング内容を現在の画面内に制限する必要があります。

const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

// domオブジェクトが画面内にあるかどうか
function isInScreen(dom) {
    const rectInfo = dom.getBoundingClientRect()
    if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
        return true
    }

    return false
}

requestAnimationFrame()を使用して DOM 描画時間を取得#

DOM が変更されて MutationObserver イベントがトリガーされたとき、それは DOM 内容が読み取れることを示すだけであり、その DOM が画面に描画されたことを示すわけではありません。

MutationObserver イベントがトリガーされたとき、document.body上にすでに内容があることが読み取れますが、実際には画面には何も描画されていません。したがって、ブラウザが描画に成功した後に現在の時間を取得するためにrequestAnimationFrame()を呼び出す必要があります。

ファーストスクリーン内のすべての画像の読み込み時間と比較#

function getRenderTime() {
    let startTime = 0
    entries.forEach(entry => {
        if (entry.startTime > startTime) {
            startTime = entry.startTime
        }
    })

    // 現在のページのすべての読み込まれた画像の時間と比較し、最大値を取る必要があります
    // 画像リクエスト時間はstartTimeより小さく、応答終了時間はstartTimeより大きい必要があります
    performance.getEntriesByType('resource').forEach(item => {
        if (
            item.initiatorType === 'img'
            && item.fetchStart < startTime
            && item.responseEnd > startTime
        ) {
            startTime = item.responseEnd
        }
    })

    return startTime
}

最適化#

現在のコードはまだ最適化されていません。主に二つの注意事項があります:

  1. いつレンダリング時間を報告するか?
  2. 非同期で DOM が追加される場合にどう対処するか?

第一点、DOM がもはや変化しないときにレンダリング時間を報告する必要があります。一般的に、load イベントがトリガーされた後、DOM はもはや変化しないので、このタイミングで報告を行うことができます。

第二点、LCP イベントがトリガーされた後に報告を行うことができます。同期または非同期で読み込まれた DOM はすべて描画される必要があるため、LCP イベントを監視し、そのイベントがトリガーされた後にのみ報告を許可することができます。

これらの二つの提案を組み合わせると、以下のコードが得られます:

let isOnLoaded = false
executeAfterLoad(() => {
    isOnLoaded = true
})


let timer
let observer
function checkDOMChange() {
    clearTimeout(timer)
    timer = setTimeout(() => {
        // load、lcpイベントがトリガーされた後、DOMツリーがもはや変化しないときにファーストスクリーンレンダリング時間を計算します
        if (isOnLoaded && isLCPDone()) {
            observer && observer.disconnect()
            lazyReportCache({
                type: 'performance',
                subType: 'first-screen-paint',
                startTime: getRenderTime(),
                pageURL: getPageURL(),
            })

            entries = null
        } else {
            checkDOMChange()
        }
    }, 500)
}

checkDOMChange()コードは、MutationObserver イベントがトリガーされるたびに呼び出され、デバウンス関数を使用して処理する必要があります。

インターフェースリクエストの時間#

インターフェースリクエストの時間は、XMLHttpRequest と fetch を監視する必要があります。

XMLHttpRequest の監視

originalProto.open = function newOpen(...args) {
    this.url = args[1]
    this.method = args[0]
    originalOpen.apply(this, args)
}

originalProto.send = function newSend(...args) {
    this.startTime = Date.now()

    const onLoadend = () => {
        this.endTime = Date.now()
        this.duration = this.endTime - this.startTime

        const { status, duration, startTime, endTime, url, method } = this
        const reportData = {
            status,
            duration,
            startTime,
            endTime,
            url,
            method: (method || 'GET').toUpperCase(),
            success: status >= 200 && status < 300,
            subType: 'xhr',
            type: 'performance',
        }

        lazyReportCache(reportData)

        this.removeEventListener('loadend', onLoadend, true)
    }

    this.addEventListener('loadend', onLoadend, true)
    originalSend.apply(this, args)
}

XML リクエストが成功したかどうかを判断するには、ステータスコードが 200〜299 の範囲にあるかどうかを確認できます。もしそうであれば成功、そうでなければ失敗です。

fetch の監視

const originalFetch = window.fetch

function overwriteFetch() {
    window.fetch = function newFetch(url, config) {
        const startTime = Date.now()
        const reportData = {
            startTime,
            url,
            method: (config?.method || 'GET').toUpperCase(),
            subType: 'fetch',
            type: 'performance',
        }

        return originalFetch(url, config)
        .then(res => {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime

            const data = res.clone()
            reportData.status = data.status
            reportData.success = data.ok

            lazyReportCache(reportData)

            return res
        })
        .catch(err => {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime
            reportData.status = 0
            reportData.success = false

            lazyReportCache(reportData)

            throw err
        })
    }
}

fetch の場合、戻りデータのokフィールドを使用してリクエストが成功したかどうかを判断できます。もしtrueであればリクエストは成功し、そうでなければ失敗です。

注意:監視されたインターフェースリクエストの時間と Chrome DevTools で検出された時間は異なる場合があります。これは、Chrome DevTools で検出されたのは HTTP リクエストの送信とインターフェース全体のプロセスの時間であるためです。しかし、xhr と fetch は非同期リクエストであり、インターフェースリクエストが成功した後にコールバック関数を呼び出す必要があります。イベントがトリガーされたとき、コールバック関数はメッセージキューに置かれ、その後ブラウザが処理します。この間にも待機プロセスがあります。

リソースの読み込み時間、キャッシュヒット率#

PerformanceObserverを使用してresourcenavigationイベントを監視できます。ブラウザがPerformanceObserverをサポートしていない場合は、performance.getEntriesByType(entryType)を使用してフォールバック処理を行うことができます。

resourceイベントがトリガーされると、対応するリソースリストを取得でき、各リソースオブジェクトにはいくつかのフィールドが含まれます:
これらのフィールドからいくつかの有用な情報を抽出できます:

{
    name: entry.name, // リソース名
    subType: entryType,
    type: 'performance',
    sourceType: entry.initiatorType, // リソースタイプ
    duration: entry.duration, // リソースの読み込み時間
    dns: entry.domainLookupEnd - entry.domainLookupStart, // DNSの時間
    tcp: entry.connectEnd - entry.connectStart, // TCP接続の確立時間
    redirect: entry.redirectEnd - entry.redirectStart, // リダイレクトの時間
    ttfb: entry.responseStart, // 最初のバイト時間
    protocol: entry.nextHopProtocol, // リクエストプロトコル
    responseBodySize: entry.encodedBodySize, // 応答内容のサイズ
    responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 応答ヘッダーのサイズ
    resourceSize: entry.decodedBodySize, // リソース解凍後のサイズ
    isCache: isCache(entry), // キャッシュがヒットしたかどうか
    startTime: performance.now(),
}

そのリソースがキャッシュにヒットしたかどうかの判断

これらのリソースオブジェクトの中にtransferSizeフィールドがあります。これはリソースのサイズを取得することを示しており、応答ヘッダーと応答データのサイズを含みます。この値が 0 であれば、キャッシュから直接読み取られたことを示します(強制キャッシュ)。この値が 0 でないが、encodedBodySizeフィールドが 0 であれば、協議キャッシュを通過したことを示します(encodedBodySizeはリクエスト応答データのボディのサイズを示します)。

function isCache(entry) {
    // キャッシュから直接読み取るか304
    return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0)
}

上記の条件に合致しない場合は、キャッシュにヒットしていないことを示します。そしてすべてのヒットしたキャッシュデータ/総データを計算することでキャッシュヒット率を得ることができます。

ブラウザの往復キャッシュ BFC(back/forward cache)#

bfcache は、ページ全体をメモリに保存するメモリキャッシュの一種です。ユーザーが戻ると、ページ全体をすぐに見ることができ、再度リフレッシュする必要がありません。この記事によると bfcache、Firefox と Safari は常に bfc をサポートしており、Chrome は高バージョンのモバイルブラウザでのみサポートしています。しかし、私が試したところ、Safari ブラウザのみがサポートされているようです。おそらく私の Firefox バージョンが正しくないのかもしれません。

しかし、bfc にも欠点があります。ユーザーが戻って bfc からページを復元すると、元のページのコードは再度実行されません。そのため、ブラウザはpageshowイベントを提供しており、再度実行する必要があるコードをその中に置くことができます。

window.addEventListener('pageshow', function(event) {
  // この属性がtrueであれば、bfcから復元されたページを示します
  if (event.persisted) {
    console.log('このページはbfcacheから復元されました。');
  } else {
    console.log('このページは通常通り読み込まれました。');
  }
});

bfc から復元されたページについても、FP、FCP、LCP などのさまざまな時間を収集する必要があります。

onBFCacheRestore(event => {
    requestAnimationFrame(() => {
        ['first-paint', 'first-contentful-paint'].forEach(type => {
            lazyReportCache({
                startTime: performance.now() - event.timeStamp,
                name: type,
                subType: type,
                type: 'performance',
                pageURL: getPageURL(),
                bfc: true,
            })
        })
    })
})

上記のコードは理解しやすいです。pageshowイベントがトリガーされた後、現在の時間からイベントのトリガー時間を引くと、その時間差がパフォーマンス指標の描画時間になります。注意:bfc から復元されたページのこれらのパフォーマンス指標の値は一般的に非常に小さく、通常は 10ms 程度です。したがって、これらにbfc: trueという識別フィールドを追加する必要があります。これにより、パフォーマンス統計を行う際にそれらを無視することができます。

FPS#

requestAnimationFrame()を利用して、現在のページの FPS を計算できます。

const next = window.requestAnimationFrame
    ? requestAnimationFrame : (callback) => { setTimeout(callback, 1000 / 60) }

const frames = []

export default function fps() {
    let frame = 0
    let lastSecond = Date.now()

    function calculateFPS() {
        frame++
        const now = Date.now()
        if (lastSecond + 1000 <= now) {
            // now - lastSecondの単位はミリ秒なので、frameは* 1000
            const fps = Math.round((frame * 1000) / (now - lastSecond))
            frames.push(fps)

            frame = 0
            lastSecond = now
        }

        // 上報が早すぎないように、一定数をキャッシュしてから上報します
        if (frames.length >= 60) {
            report(deepCopy({
                frames,
                type: 'performace',
                subType: 'fps',
            }))

            frames.length = 0
        }

        next(calculateFPS)
    }

    calculateFPS()
}

コードのロジックは以下の通りです:

  1. 初期時間を記録し、requestAnimationFrame()がトリガーされるたびにフレーム数を 1 増やします。1 秒が経過した後、フレーム数/経過時間で現在のフレームレートを得ることができます。

連続して 3 回 20 未満の FPS が発生した場合、ページがカクついていると判断できます。詳細は 如何监控网页的卡顿を参照してください。

export function isBlocking(fpsList, below = 20, last = 3) {
    let count = 0
    for (let i = 0; i < fpsList.length; i++) {
        if (fpsList[i] && fpsList[i] < below) {
            count++
        } else {
            count = 0
        }

        if (count >= last) {
            return true
        }
    }

    return false
}

Vue ルーターの変更レンダリング時間#

ファーストスクリーンレンダリング時間は計算方法がわかりましたが、SPA アプリケーションのページルーター切り替えによるページレンダリング時間をどのように計算するかについて説明します。この章では Vue を例にして、私の考えを説明します。

export default function onVueRouter(Vue, router) {
    let isFirst = true
    let startTime
    router.beforeEach((to, from, next) => {
        // 初回ページに入るときは、すでに他の統計のレンダリング時間が利用可能です
        if (isFirst) {
            isFirst = false
            return next()
        }

        // routerに新しいフィールドを追加し、レンダリング時間を計算する必要があるかどうかを示します
        // ルーターの切り替え時のみ計算が必要です
        router.needCalculateRenderTime = true
        startTime = performance.now()

        next()
    })

    let timer
    Vue.mixin({
        mounted() {
            if (!router.needCalculateRenderTime) return

            this.$nextTick(() => {
                // 全体のビューがレンダリングされた後にのみ実行されるコード
                const now = performance.now()
                clearTimeout(timer)

                timer = setTimeout(() => {
                    router.needCalculateRenderTime = false
                    lazyReportCache({
                        type: 'performance',
                        subType: 'vue-router-change-paint',
                        duration: now - startTime,
                        startTime: now,
                        pageURL: getPageURL(),
                    })
                }, 1000)
            })
        },
    })
}

コードのロジックは以下の通りです:

  1. ルーターのフックを監視し、ルーターが切り替わるとrouter.beforeEach()フックがトリガーされ、そのコールバック関数内で現在の時間をレンダリング開始時間として記録します。
  2. Vue.mixin()を利用してすべてのコンポーネントのmounted()に関数を注入します。各関数はデバウンス関数を実行します。
  3. 最後のコンポーネントのmounted()がトリガーされると、それはそのルーター下のすべてのコンポーネントがマウントされたことを示します。this.$nextTick()のコールバック関数内でレンダリング時間を取得できます。

同時に、ある状況を考慮する必要があります。ルーターを切り替えない場合でも、コンポーネントが変更される場合があります。この場合、これらのコンポーネントのmounted()内でレンダリング時間を計算すべきではありません。したがって、needCalculateRenderTimeフィールドを追加し、ルーターを切り替えたときにそれを true に設定し、レンダリング時間を計算できることを示します。

エラーデータ収集#

リソースの読み込みエラー#

addEventListener()を使用して error イベントを監視することで、リソースの読み込み失敗エラーをキャッチできます。

// リソースの読み込み失敗エラーをキャッチ js css img...
window.addEventListener('error', e => {
    const target = e.target
    if (!target) return

    if (target.src || target.href) {
        const url = target.src || target.href
        lazyReportCache({
            url,
            type: 'error',
            subType: 'resource',
            startTime: e.timeStamp,
            html: target.outerHTML,
            resourceType: target.tagName,
            paths: e.path.map(item => item.tagName).filter(Boolean),
            pageURL: getPageURL(),
        })
    }
}, true)

js エラー#

window.onerrorを使用して js エラーを監視できます。

// jsエラーを監視
window.onerror = (msg, url, line, column, error) => {
    lazyReportCache({
        msg,
        line,
        column,
        error: error.stack,
        subType: 'js',
        pageURL: url,
        type: 'error',
        startTime: performance.now(),
    })
}

promise エラー#

addEventListener()を使用して unhandledrejection イベントを監視することで、未処理の promise エラーをキャッチできます。

// promiseエラーを監視しますが、列データを取得できない欠点があります
window.addEventListener('unhandledrejection', e => {
    lazyReportCache({
        reason: e.reason?.stack,
        subType: 'promise',
        type: 'error',
        startTime: e.timeStamp,
        pageURL: getPageURL(),
    })
})

sourcemap#

一般的に、プロダクション環境のコードは圧縮されており、プロダクション環境では sourcemap ファイルがアップロードされません。したがって、プロダクション環境でのコードのエラーメッセージは非常に読みづらいです。そのため、source-mapを利用して、これらの圧縮されたコードのエラーメッセージを復元することができます。

コードがエラーを報告するとき、対応するファイル名、行数、列数を取得できます:

{
    line: 1,
    column: 17,
    file: 'https:/www.xxx.com/bundlejs',
}

次に、以下のコードを呼び出して復元します:

async function parse(error) {
    const mapObj = JSON.parse(getMapFileContent(error.url))
    const consumer = await new sourceMap.SourceMapConsumer(mapObj)
    // webpack://source-map-demo/./src/index.jsファイルの./を削除します
    const sources = mapObj.sources.map(item => format(item))
    // 圧縮されたエラーメッセージに基づいて、未圧縮前のエラーメッセージの行列数とソースファイルを取得します
    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
    // sourcesContentには各ファイルの未圧縮前のソースコードが含まれており、ファイル名に基づいて対応するソースコードを見つけます
    const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
    return {
        file: originalInfo.source,
        content: originalFileContent,
        line: originalInfo.line,
        column: originalInfo.column,
        msg: error.msg,
        error: error.error
    }
}

function format(item) {
    return item.replace(/(\\.\\/)*/g, '')
}

function getMapFileContent(url) {
    return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')
}

プロジェクトをパッケージ化するたびに、sourcemap を有効にすると、各 js ファイルには対応する map ファイルが生成されます。

bundle.js
bundle.js.map

この時、js ファイルは静的サーバー上に配置され、ユーザーがアクセスでき、map ファイルはサーバーに保存され、エラーメッセージの復元に使用されます。source-mapライブラリは、圧縮されたコードのエラーメッセージに基づいて未圧縮前のコードのエラーメッセージを復元できます。例えば、圧縮後のエラーポジションが1行47列であれば、復元後の実際のポジションは4行10列になる可能性があります。位置情報に加えて、ソースコードの原文も取得できます。

Vue エラー#

window.onerrorを使用しても Vue エラーをキャッチすることはできません。Vue が提供する API を使用して監視する必要があります。

Vue.config.errorHandler = (err, vm, info) => {
    // エラーメッセージをコンソールに出力します
    console.error(err)

    lazyReportCache({
        info,
        error: err.stack,
        subType: 'vue',
        type: 'error',
        startTime: performance.now(),
        pageURL: getPageURL(),
    })
}

行動データ収集#

PV、UV#

PV(page view)はページの閲覧数、UV(Unique visitor)はユーザーの訪問数です。PV はページに一度アクセスするごとに 1 回カウントされ、UV は同じ日に複数回アクセスしても 1 回とカウントされます。

フロントエンドにおいては、ページに入るたびに PV を 1 回報告すれば十分です。UV の統計はサーバー側で行い、主に報告されたデータを分析して UV を算出します。

export default function pv() {
    lazyReportCache({
        type: 'behavior',
        subType: 'pv',
        startTime: performance.now(),
        pageURL: getPageURL(),
        referrer: document.referrer,
        uuid: getUUID(),
    })
}

ページ滞在時間#

ユーザーがページに入るとき、初期時間を記録し、ユーザーがページを離れるときに現在の時間から初期時間を引くことで、ユーザーの滞在時間を計算します。この計算ロジックはbeforeunloadイベント内で行うことができます。

export default function pageAccessDuration() {
    onBeforeunload(() => {
        report({
            type: 'behavior',
            subType: 'page-access-duration',
            startTime: performance.now(),
            pageURL: getPageURL(),
            uuid: getUUID(),
        }, true)
    })
}

ページアクセス深度#

ページアクセス深度を記録することは非常に有用です。例えば、異なる活動ページ a と b。a の平均アクセス深度は 50% しかないが、b の平均アクセス深度は 80% である場合、b の方がユーザーに好まれていることを示します。この情報に基づいて、a の活動ページをターゲットを絞って修正することができます。

ページアクセス深度の計算プロセスは少し複雑です:

  1. ユーザーがページに入るとき、現在の時間、scrollTop 値、ページの可視高さ、ページの総高さを記録します。
  2. ユーザーがページをスクロールすると、scrollイベントがトリガーされ、そのコールバック関数内で最初のポイントから得たデータを使用してページアクセス深度と滞在時間を計算します。
  3. ユーザーがページのあるポイントまでスクロールし、止まってページを見続けるとき、現在の時間、scrollTop 値、ページの可視高さ、ページの総高さを記録します。
  4. 第二点を繰り返します...

具体的なコードは以下の通りです:

let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0

export default function pageAccessHeight() {
    window.addEventListener('scroll', onScroll)

    onBeforeunload(() => {
        const now = performance.now()
        report({
            startTime: now,
            duration: now - startTime,
            type: 'behavior',
            subType: 'page-access-height',
            pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        }, true)
    })

    // ページが完全に読み込まれた後、現在のアクセス高さと時間を記録します
    executeAfterLoad(() => {
        startTime = performance.now()
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight
    })
}

function onScroll() {
    clearTimeout(timer)
    const now = performance.now()

    if (!hasReport) {
        hasReport = true
        lazyReportCache({
            startTime: now,
            duration: now - startTime,
            type: 'behavior',
            subType: 'page-access-height',
            pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        })
    }

    timer = setTimeout(() => {
        hasReport = false
        startTime = now
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight
    }, 500)
}

function toPercent(val) {
    if (val >= 1) return '100%'
    return (val * 100).toFixed(2) + '%'
}

ユーザークリック#

addEventListener()を使用してmousedowntouchstartイベントを監視することで、ユーザーの各クリック領域のサイズ、クリック座標がページ全体の具体的な位置、クリック要素の内容などの情報を収集できます。

export default function onClick() {
    ['mousedown', 'touchstart'].forEach(eventType => {
        let timer
        window.addEventListener(eventType, event => {
            clearTimeout(timer)
            timer = setTimeout(() => {
                const target = event.target
                const { top, left } = target.getBoundingClientRect()

                lazyReportCache({
                    top,
                    left,
                    eventType,
                    pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
                    scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
                    type: 'behavior',
                    subType: 'click',
                    target: target.tagName,
                    paths: event.path?.map(item => item.tagName).filter(Boolean),
                    startTime: event.timeStamp,
                    pageURL: getPageURL(),
                    outerHTML: target.outerHTML,
                    innerHTML: target.innerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    viewport: {
                        width: window.innerWidth,
                        height: window.innerHeight,
                    },
                    uuid: getUUID(),
                })
            }, 500)
        })
    })
}

ページ遷移#

addEventListener()を使用してpopstatehashchangeページ遷移イベントを監視します。注意が必要なのは、history.pushState()またはhistory.replaceState()を呼び出してもpopstateイベントはトリガーされないことです。ユーザーがブラウザの戻るボタンをクリックしたとき(または JavaScript コード内でhistory.back()またはhistory.forward()メソッドを呼び出したとき)にのみ、このイベントがトリガーされます。同様に、hashchangeも同じです。

export default function pageChange() {
    let from = ''
    window.addEventListener('popstate', () => {
        const to = getPageURL()

        lazyReportCache({
            from,
            to,
            type: 'behavior',
            subType: 'popstate',
            startTime: performance.now(),
            uuid: getUUID(),
        })

        from = to
    }, true)

    let oldURL = ''
    window.addEventListener('hashchange', event => {
        const newURL = event.newURL

        lazyReportCache({
            from: oldURL,
            to: newURL,
            type: 'behavior',
            subType: 'hashchange',
            startTime: performance.now(),
            uuid: getUUID(),
        })

        oldURL = newURL
    }, true)
}

Vue ルーターの変更#

Vue はrouter.beforeEachフックを利用してルーターの変更を監視できます。

export default function onVueRouter(router) {
    router.beforeEach((to, from, next) => {
        // 初回ページの読み込み時は統計を取らない
        if (!from.name) {
            return next()
        }

        const data = {
            params: to.params,
            query: to.query,
        }

        lazyReportCache({
            data,
            name: to.name || to.path,
            type: 'behavior',
            subType: ['vue-router-change', 'pv'],
            startTime: performance.now(),
            from: from.fullPath,
            to: to.fullPath,
            uuid: getUUID(),
        })

        next()
    })
}

データ報告#

報告方法#

データ報告には以下のいくつかの方法があります:

sendBeacon を使用して報告する利点は非常に明確です。

sendBeacon () メソッドを使用すると、ユーザーエージェントはページのアンロードを遅延させたり、次のナビゲーションの読み込み性能に影響を与えたりすることなく、データをサーバーに非同期で送信できます。これにより、分析データを送信する際のすべての問題が解決されます:データは信頼性が高く、送信は非同期で、次のページの読み込みに影響を与えません。

sendBeacon をサポートしていないブラウザでは、XMLHttpRequest を使用して報告できます。HTTP リクエストは送信と受信の二つのステップを含みます。実際には、報告に関しては、送信できればそれで十分です。つまり、送信が成功すれば問題ありません。これを考慮して、beforeunload で XMLHttpRequest を使用して 30kb のデータを送信する実験を行いました(一般的に報告されるデータはこれほど大きくなることはありません)。異なるブラウザで試したところ、すべて成功しました。もちろん、これはハードウェアの性能やネットワーク状態にも関連しています。

報告のタイミング#

報告のタイミングには三つの方法があります:

  1. requestIdleCallback/setTimeoutを使用して遅延報告。
  2. beforeunload()コールバック関数内で報告。
  3. 報告データをキャッシュし、一定の数量に達した後に報告。

三つの方法を組み合わせて報告することをお勧めします:

  1. まず報告データをキャッシュし、一定の数量に達した後、requestIdleCallback/setTimeoutを使用して遅延報告します。
  2. ページを離れるときに、未報告のデータを一括で報告します。

フロントエンド監視の展開#

前述の内容は監視の原理についてですが、実現するためには自分でコードを書く必要があります。面倒を避けるために、既存のツールsentryを使用してこの作業を行うことができます。

sentry は Python で書かれたパフォーマンスとエラー監視ツールであり、sentry が提供するサービス(無料機能は少ない)を使用することも、自分でサービスを展開することもできます。次に、sentry が提供するサービスを使用して監視を実現する方法を見ていきましょう。

まとめ#

Web 技術の発展に伴い、現在のフロントエンドプロジェクトの規模もますます大きくなっています。監視システムの助けを借りることで、プロジェクトの実行状況をより明確に理解し、収集したエラーデータやパフォーマンスデータに基づいてプロジェクトをターゲットを絞った最適化を行うことができます。

次の章では、パフォーマンス最適化について説明します。

参考資料#

パフォーマンス監視#

エラー監視#

行動監視#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。