背景
有時候我們會有把一整段 HTML 動態塞進頁面的需求,例如渲染了一個模板,從服務器端獲取了一段廣告代碼等。一般情況下我們使用 container.innerHTML 即可。但是當 HTML 中出現 script 標簽時,直接使用 innerHTML 並不會執行它。
一個例子
<div id="test">Hello HTML</div> <script> document.getElementById('test').innerHTML = 'Hello JS'; </script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react-dom.min.js"></script> <script> ReactDOM.render(React.createElement('div', null, 'Hello React'), document.getElementById('test')); </script>
一個常見的例子里包含普通的 HTML 內容,<script> 里的 inline script,通過 src 引用的外部 script。如果我們嘗試直接用 innerHTML 賦值只會得到一個 Hello HTML。而后面的 <script> 標簽無一例外沒有執行。
appendChild
我們知道通過 appendChild 把 <script> 標簽直接塞進頁面是可以執行和加載里面的 js 的(JSONP就是通過這種方法實現的,參見之前的文章:JSONP 的實現 - 知乎專欄。
所以其實我們需要做的就只是把所有的 <script>找出來,然后通過 appendChild 塞到頁面里即可。
function runScript(script){
// 直接 document.head.appendChild(script) 是不會生效的,需要重新創建一個
const newScript = document.createElement('script');
// 獲取 inline script
newScript.innerHTML = script.innerHTML;
// 存在 src 屬性的話
const src = script.getAttribute('src');
if (src) newScript.setAttribute('src', src);
document.head.appendChild(newScript);
document.head.removeChild(newScript);
}
function setHTMLWithScript(container, rawHTML){
container.innerHTML = rawHTML;
const scripts = container.querySelectorAll('script');
for (let script of scripts) {
runScript(script);
}
}
執行順序
當我們嘗試用上面的 setHTMLWithScript(document.body, html) 時有一個問題,就是 script 的加載和執行並非同步的,我們會得到一個 Hello, JS。
而下面的 <script> 依賴前面的 <script> 執行加載完成是一個非常常見的需求,因為在正常的靜態網頁里就是這樣的,雖然所有的遠程腳本都是異步加載的,但后面的 <script> 會等待前面的加載執行后才開始執行。
為了讓異常處理和異步流程的控制更方便,我們讓 runScript 返回一個 Promise,然后只需要一個簡單的 reduce 就可以把異步邏輯串聯起來:
function runScript(script){ return new Promise((reslove, rejected) => { // 直接 document.head.appendChild(script) 是不會生效的,需要重新創建一個 const newScript = document.createElement('script'); // 獲取 inline script newScript.innerHTML = script.innerHTML; // 存在 src 屬性的話 const src = script.getAttribute('src'); if (src) newScript.setAttribute('src', src); // script 加載完成和錯誤處理 newScript.onload = () => reslove(); newScript.onerror = err => rejected(); document.head.appendChild(newScript); document.head.removeChild(newScript); if (!src) { // 如果是 inline script 執行是同步的 reslove(); } }) } function setHTMLWithScript(container, rawHTML){ container.innerHTML = rawHTML; const scripts = container.querySelectorAll('script'); return Array.prototype.slice.apply(scripts).reduce((chain, script) => { return chain.then(() => runScript(script)); }, Promise.resolve()); }
得到預期的 Hello React。
其實這里有一點和直接渲染不一致的地方,就是腳本的加載也是同步的,后面的腳本會等待之前的腳本執行完才會加載,不過從 js 層面似乎沒有辦法解決這個問題。
JQuery.html
熟悉 JQuery 的同學可能知道 $.html 其實會直接執行里面的 <script> 標簽,不過是同步的,在 $.html 的代碼中,可以看到 jQuery 判斷滿足一定條件下直接使用 innerHTML,隨便執行一個 $('body').html(test<script></script>) 然后打個斷點,
可以看到這里做了一個簡單的正則判斷,如果碰到 <script><style><link> 標簽就用 jQuery 自己實現的 append,繼續追蹤下去,
顯然 jQuery 在這里完全沒有考慮 <script> 前后的依賴。對於 inline script 的標簽也是直接通過 eval 實現的而不是新建一個插入到文檔里。
JQuery 也有幾個 issue 討論是否要按照順序執行,但最后決定保持現狀:Scripts in inner html are not exectuted sequentially in order · Issue #2538 · jquery/jquery。
其他
createContextualFragment
除了寫進去再用 querySelectorAll 把 script 全都拿出來復制一遍外,IE11 以上的瀏覽器也可以通過 createContextualFragment 直接把 html 轉換成 DOM 節點然后 append 到頁面上:
var tagString = "<div>I am a div node</div><script>console.log('test')</script>"; var range = document.createRange(); // make the parent of the first div in the document becomes the context node range.selectNode(document.body); var documentFragment = range.createContextualFragment(tagString); document.body.appendChild(documentFragment)
也可以用這種方法來實現上面的功能。
兼容性
上面的代碼都只是順手的探索,沒有考慮兼容性方面的問題,例如 IE 不支持 script 的 onload 事件等,可能需要 onreadystatechange 來實現。
DOMContentLoaded
DOMContentLoaded 早已經完成,如果有需要,我們可能要在腳本加載完成后,重新觸發一下
setHTMLWithScript(document.body, rawHTML) .then(() => { var DOMContentLoadedEvent = document.createEvent('Event'); DOMContentLoadedEvent.initEvent('DOMContentLoaded', true, true); document.dispatchEvent(DOMContentLoadedEvent); })
document.write
在靜態頁面中,<script> 標簽里如果出現 document.write,會直接在 <script> 插入的位置寫入,這種方法常被用於廣告投放腳本來定位自己的位置。
而當我們在動態插入時文檔已經關閉,會直接 write 到整個頁面上,如果有必要可以暫時替換 document.write 來實現。
getCurrentScript
getCurrentScript 是另一個定位 <script> 標簽所在位置的方法,之所以不太常用是因為 IE 不兼容它,如果我們要考慮兼容這個方法新產生的 <script> 標簽就不應該往 <head> 里 append,而是插入到原來所在的位置。
總結
以上方法都只是模擬靜態 <script> 解析的過程,一般來說我們不要求行為完全一致(畢竟跨域異步加載同步執行這點 JS 就無法模擬),但是可以按照我們的需求去實現它的行為。
這種方法也只適用於一部分場景,如果有更復雜的 JS 動態加載需求應該考慮使用 requirejs 等 AMD Loader。