Pjax 下動態載入外掛方案

2022-09-28 18:00:22

在純靜態網站裡,有時候會動態更新某個區域往會選擇 Pjax(swup、barba.js)去處理,他們都是使用 ajax 和 pushState 通過真正的永久連結,頁面標題和後退按鈕提供快速瀏覽體驗。

但是實際使用中可能會遇到不同頁面可能會需要載入不同外掛處理,有些人可能會全量選擇載入,這樣會導致載入很多無用的指令碼,有可能在使用者關閉頁面時都不一定會存取到,會很浪費資源。

解決思路

首先想到的肯定是在請求到新的頁面後,我們手動去比較當前 DOM 和 新 DOM 之間 script 標籤的差異,手動給他插入到 body 裡。

處理 Script

一般來說 JavaScript 指令碼都是放在 body 後,避免阻塞頁面渲染,假設我們頁面指令碼也都是在 body 後,並在 script 新增 [data-reload-script] 表明哪些是需要動態載入的。

首先我們直接獲取到帶有 [data-reload-script] 屬性的 script 標籤:

// NewHTML 為 新頁面 HTML
const pageContent = NewHTML.replace('<body', '<div id="DynamicPluginBody"').replace('</body>', '</div>');
let element = document.createElement('div');
element.innerHTML = pageContent;
const children = element.querySelector('#DynamicPluginBody').querySelectorAll('script[data-reload-script]');

然後通過建立 script 標籤插入到 body

children.forEach(item => {
    const element = document.createElement('script');
    for (const { name, value } of arrayify(item.attributes)) {
        element.setAttribute(name, value);
    }
    element.textContent = item.textContent;
    element.setAttribute('async', 'false');
    document.body.insertBefore(element)
})

如果你的外掛都是通過 script 引入,且不需要執行額外的 JavaScript 程式碼,只需要在 Pjax 勾點函數這樣處理就可以了。

執行程式碼塊

實際很多外掛不僅僅需要你引入,還需要你手動去初始化做一些操作的。我們可以通過 src 去判斷是引入的指令碼,還是程式碼塊。

let scripts = Array.from(document.scripts)
let scriptCDN = []
let scriptBlock = []

children.forEach(item => {
    if (item.src)
        scripts.findIndex(s => s.src === item.src) < 0 && scriptCDN.push(item);
    else
        scriptBlock.push(item.innerText)
})

scriptCDN 繼續通過上面方式插入到 body 裡,然後通過 eval 或者 new Function 去執行 scriptBlock 。因為 scriptBlock 裡的程式碼可能是會依賴 scriptCDN 裡的外掛的,所以需要在 scriptCDN 載入完成後在執行 scriptBlock 。

const loadScript = (item) => {
    return new Promise((resolve, reject) => {
        const element = document.createElement('script');
        for (const { name, value } of arrayify(item.attributes)) {
            element.setAttribute(name, value);
        }
        element.textContent = item.textContent;
        element.setAttribute('async', 'false');
        element.onload = resolve
        element.onerror = reject
        document.body.insertBefore(element)
    })
}

const runScriptBlock = (code) => {
    try {
        const func = new Function(code);
        func()
    } catch (error) {
        try {
            window.eval(code)
        } catch (error) {
        }
    }
}

Promise.all(scriptCDN.map(item => loadScript(item))).then(_ => {
    scriptBlock.forEach(code => {
        runScriptBlock(code)
    })
})

解除安裝外掛

按照上面思去處理之後,會存在一個問題。 比如:我們新增了一個 全域性的 'resize' 事件的監聽,在跳轉其他頁面時候我們需要移除這個監聽事件。

這個時候我們需要對程式碼塊的格式進行一個約束,比如像下面這樣,在初次載入時執行 mount 里程式碼,頁面解除安裝時執行 unmount 里程式碼。

<script data-reload-script>
    DynamicPlugin.add({
        // 頁面載入時執行
        mount() {
            this.timer = setInterval(() => {
                document.getElementById('time').innerText = new Date().toString()
            }, 1000)
        },
        // 頁面解除安裝時執行
        unmount() {
            window.clearInterval(this.timer)
            this.timer = null
        }
    })
</script>

DynamicPlugin 大致結構:

let cacheMount = []
let cacheUnMount = []
let context = {}

class DynamicPlugin {
    add(options) {
        if (isFunction(options))
            cacheMount.push(options)

        if (isPlainObject(options)) {
            let { mount, unmount } = options
            if (isFunction(mount))
                cacheMount.push(mount)
            if (isFunction(unmount))
                cacheUnMount.push(unmount)
        }

        // 執行當前頁面載入勾點
        this.runMount()
    }

    runMount() {
        while (cacheMount.length) {
            let item = cacheMount.shift();
            item.call(context);
        }
    }

    runUnMount() {
        while (cacheUnMount.length) {
            let item = cacheUnMount.shift();
            item.call(context);
        }
    }
}

頁面解除安裝時呼叫 DynamicPlugin.runUnMount()。

處理 Head

Head 部分處理來說相對比較簡單,可以通過拿到新舊兩個 Head,然後迴圈對比每個標籤的 outerHTML,用來判斷哪些比是需要新增的哪些是需要刪除的。

結尾

本文範例程式碼完整版本可以 參考這裡