背景
有時候我們會有把一整段 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);
undefined
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。