如何动态添加 script 标签并执行 $(document).ready
结论
不要使用 iframe 或 document.write(),也不要指望通过 innerHTML 插入的脚本自动执行。可以按下面的方式处理:
- 让 API 分别返回
<style>、HTML 和<script>,不要把 CSS 与 JavaScript 混在一段无法区分的纯文本中。 - 使用
<template>解析 HTML。 - 将样式和普通 HTML 插入当前页面。
- 重新创建解析出的
<script>,并按原有顺序执行。 - 在执行依赖 jQuery 的脚本前,确保 jQuery 已加载完成。
即使动态插入 <script> 时 DOMContentLoaded 已经触发,$(document).ready(...) 仍会执行,但前提是 jQuery 已正确加载。
先修正返回内容的格式
问题中的字符串只是把 CSS 和 JavaScript 拼接在了一起,没有真正的 <style> 和 <script> 标签:
const styleScriptText =
' #checkoutmodal .checkoutmodal-box{background:#FFF !important} $(document).ready(function() { console.log("test")}); ';
浏览器无法可靠地判断哪部分是 CSS,哪部分是 JavaScript。API 至少应返回结构清晰的内容:
const styleScriptText = `
<style>
#checkoutmodal .checkoutmodal-box {
background: #FFF !important;
}
</style>
<script>
$(document).ready(function () {
console.log("test");
});
</script>
`;
更合适的方式是直接返回结构化数据:
{
"html": "<div id=\"checkoutmodal\">...</div>",
"css": "#checkoutmodal .checkoutmodal-box { background: #FFF !important; }",
"scripts": [
{
"src": "https://example.com/external-script.js"
},
{
"code": "$(document).ready(function () { console.log('test'); });"
}
]
}
结构化数据便于控制执行顺序,也省去了从字符串中区分 HTML、CSS 和 JavaScript 的麻烦。
动态插入并执行完整 HTML 字符串
通过 innerHTML 或 <template> 解析出来的 <script> 通常不会自动执行。要执行这些脚本,需要使用 document.createElement("script") 重新创建脚本节点。
下面的方法支持:
<style><link rel="stylesheet">- 普通 HTML
- 外部
<script src="..."> - 内联
<script> - 按出现顺序执行多个脚本
async function injectApiHtml(apiHtml, mountElement = document.body) {
const template = document.createElement("template");
template.innerHTML = apiHtml;
// 保存 script 引用。它们插入页面后本身不会执行。
const scripts = Array.from(
template.content.querySelectorAll("script")
);
// 如果脚本依赖外部样式,可以等待 stylesheet 加载完成。
const stylesheetPromises = Array.from(
template.content.querySelectorAll('link[rel="stylesheet"]')
).map((link) => {
return new Promise((resolve, reject) => {
link.addEventListener("load", resolve, { once: true });
link.addEventListener(
"error",
() => reject(new Error(`Stylesheet load failed: ${link.href}`)),
{ once: true }
);
});
});
// 插入普通 HTML、style、link 以及不会执行的原始 script。
mountElement.appendChild(template.content);
await Promise.all(stylesheetPromises);
// 重新创建 script,确保按顺序执行。
for (const oldScript of scripts) {
await activateScript(oldScript);
}
}
function activateScript(oldScript) {
return new Promise((resolve, reject) => {
const newScript = document.createElement("script");
// 复制 type、src、integrity、crossorigin 等属性。
for (const attribute of oldScript.attributes) {
newScript.setAttribute(attribute.name, attribute.value);
}
const isExternal = oldScript.hasAttribute("src");
const isModule =
(oldScript.getAttribute("type") || "").trim().toLowerCase() === "module";
// 动态外部脚本默认可能异步执行,设为 false 便于保持顺序。
if (isExternal && !oldScript.hasAttribute("async")) {
newScript.async = false;
}
if (!isExternal) {
newScript.textContent = oldScript.textContent;
}
if (isExternal || isModule) {
newScript.addEventListener("load", resolve, { once: true });
newScript.addEventListener(
"error",
() => {
reject(
new Error(
`Script load failed: ${
oldScript.getAttribute("src") || "inline module"
}`
)
);
},
{ once: true }
);
}
// 替换由 HTML 解析产生的惰性 script。
oldScript.replaceWith(newScript);
// 普通内联脚本在插入时同步执行。
if (!isExternal && !isModule) {
resolve();
}
});
}
调用方式如下:
try {
await injectApiHtml(styleScriptText, document.getElementById("parentId"));
} catch (error) {
console.error("Failed to install API content:", error);
}
加载外部脚本时,代码会等待当前脚本加载完成,再继续执行下一个脚本。因此,在下面的示例中,初始化代码会等到 jQuery 加载完成后再运行:
<script src="https://cdn.example.com/jquery.min.js"></script>
<script>
$(document).ready(function () {
console.log("test");
});
</script>
为什么 $(document).ready() 可以在之后执行
jQuery 的 ready 机制不要求回调必须在 DOMContentLoaded 触发前注册。
如果 DOM 仍在加载,jQuery 会等待 DOM 就绪。如果 DOM 已经就绪,jQuery 会尽快安排回调执行。因此,动态加载下面的代码通常不会有问题:
$(document).ready(function () {
console.log("test");
});
需要保证的是,执行这段代码时 window.jQuery 已经存在。如果 $ 被其他库占用,可以改成:
jQuery(function ($) {
console.log("test");
});
脚本不依赖 jQuery 时,也可以使用原生实现:
function onDocumentReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
} else {
queueMicrotask(callback);
}
}
onDocumentReady(function () {
console.log("test");
});
为什么其他方式没有生效
直接设置 innerHTML:
element.innerHTML = apiHtml;
这种方式可以插入 <style> 和普通 HTML,但不能把其中的 <script> 当作可靠的自动执行机制。浏览器不会因为插入一段 HTML 就隐式执行其中的脚本。
document.write() 是用于页面解析阶段的旧式 API。在页面加载完成后调用它,可能会清空并替换当前文档。它还会受到跨域、解析阻塞和性能方面的浏览器限制,不适合用来加载 API 返回的动态内容。
必须注意的安全限制
执行 API 返回的脚本,就等于允许该 API 在当前页面中运行任意 JavaScript。这些脚本可以读取页面数据、访问 localStorage、发起请求、修改支付参数或跳转页面。
因此,需要满足以下条件:
- 内容只能来自完全可信且经过身份验证的服务端。
- 不要执行用户提交或第三方可编辑的字符串。
- 最好使用允许列表限制外部脚本域名。
- 对返回内容进行签名或完整性校验。
- 不要用
eval()或new Function()代替动态<script>。 - 页面启用 CSP 时,需要为动态脚本配置正确的
nonce或允许的脚本来源。 - 如果 CSP 禁止内联脚本,动态创建的内联
<script>仍会被拦截。此时应将代码放入受信任的外部文件。
如果返回内容不可信,就需要使用 iframe 进行隔离。无法在允许未知脚本直接进入主应用上下文运行的同时,保证主应用安全。
备注:内容仅供参考。
