问题背景
看到这个问题, 大家可能会觉得很诧异, 不是直接用 dangerouslySetInnerHTML
就能实现了吗? 但是实际情况并没有那么简单.
首先介绍我们的技术框架, 项目使用了 react-imvc 实现页面的同构渲染.
针对标题的需求, 我们会实现一个 React 组件, 接受接口下发的 html 字符串(见下, < 是因为内容被框架处理过), 渲染出对应的内容.
1 2 3 4 5 6 7 8 9 10
| const codeBlock = ` <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script> console.log($); </script> <script> console.log(222); </script> <h1>123</h1>`;
|
但是在实现的过程中发现一个问题, 由于我们的页面是同构渲染的, React 组件需要同时支持服务端渲染和客户端渲染.
在服务端渲染的场景下, 简单地 dangerouslySetInnerHTML
能够直接满足我们的需求. 但是在客户端渲染的情况下, 我们发现 script
标签中的内容并没有执行. 那么如何解决这个问题呢?
解决问题
分离 html 中 script 标签与其他标签
JavaScript 代码没有执行的原因是规范不允许在 innerHTML
中插入 script
标签, 即使 DOM 元素中可以查看到, 代码的内容也不会执行.
因此我们要在代码中针对 script
标签和其他标签做出分离的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
export function separateScript(codeBlock = "") { codeBlock = formatScript(codeBlock);
let re = /<script\b[^>]*>([\s\S]*?)<\/script>/gm; let match; let scriptStrList = []; let contentWithoutScript = codeBlock.replace(re, "");
while ((match = re.exec(codeBlock))) { scriptStrList.push(match[0]); }
return [scriptStrList, contentWithoutScript]; }
export function formatScript(codeBlock = "") { let scriptRe = /<\/script/g; return codeBlock.replace(scriptRe, "</script"); }
|
经过处理之后, 我们会得到类似以下形式的 scriptStrList
1 2 3 4 5
| const scriptStrList = [ '<script\n src="https://code.jquery.com/jquery-3.6.0.min.js"></script>', "<script>\nconsole.log($);\n</script>", "<script>\nconsole.log(222);\n</script>" ];
|
我们的 React 组件, 会是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import React, { useMemo } from "react"; import useAttachScript from "../hooks/useAttachScript"; import { separateScript } from "../utils";
export default function CustomCode(props) { const { codeBlock } = props;
let [scriptStrList, markup] = useMemo(() => { return separateScript(codeBlock); }, [codeBlock]);
useAttachScript(scriptStrList);
return <div dangerouslySetInnerHTML={{ __html: markup }} ref={ref} />; }
|
手动构造 script 节点
可以从组件代码中看到这样一个 hook: useAttachScript
. 现在我们开始实现这个 hook, 它做的事情是: 处理 scriptStrList
然后用 appendChild
的形式插入我们的 script
标签:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| import { useEffect } from "react";
export default function useAttachScript(scriptStrList = []) { useEffect(() => { if (scriptStrList.length === 0) return; appendScriptList(scriptStrList.join()); }, [scriptStrList]); }
const appendScriptList = scriptMarkup => { const scriptHTMLCollection = getScriptHTMLCollection(scriptMarkup); const scriptNodeList = [...scriptHTMLCollection].map(str => cloneScript(str)); scriptNodeList.forEach(script => { document.head.appendChild(script); }); };
const getScriptHTMLCollection = scriptMarkup => { let divElem = document.createElement("div"); divElem.innerHTML = scriptMarkup; return divElem.getElementsByTagName("script") };
const cloneScript = sourceScript => { let script = document.createElement("script");
Array.from(sourceScript.attributes).forEach(attr => { script.setAttribute(attr.name, attr.value); });
script.innerHTML = sourceScript.innerHTML;
return script; };
|
以上的代码看似已经没有大问题了, 但是对于最开始的 html 字符串示例, 我们发现了一个问题: 有可能抛出 $ is not defined
的错误, 虽然是代码本身的问题, 但我们还是希望再做出一些优化.
确保各个标签按照先后顺序执行
由于我们的业务场景限制, 接口下发的 html 内容可能是运营人员或者产品经理所编写的. 所以我们在实现时要尽可能地避免代码出错, 在程序层面确保各个标签按照先后顺序执行.
useAttachScript
是在组件渲染完之后执行的, 因此不必担心我们的处理影响页面主要部分的渲染.
那么开始优化 useAttachScript
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| import { useEffect } from "react";
export default function useAttachScript(scriptStrList = []) { useEffect(() => { if (scriptStrList.length === 0) return; appendScriptList(scriptStrList.join()); }, [scriptStrList]); }
const appendScriptList = async scriptMarkup => { const scriptHTMLCollection = getScriptHTMLCollection(scriptMarkup); const scriptNodeList = [...scriptHTMLCollection].map(str => cloneScript(str)); for (const scriptElem of scriptNodeList) { await appendScript(scriptElem); } };
const appendScript = scriptElem => { return new Promise((resolve, reject) => { let { attributes = {} } = scriptElem; let { src } = attributes; if (src) { scriptElem.onload = resolve; scriptElem.onerror = reject; } else { resolve(); } document.head.appendChild(scriptElem); }); };
const getScriptHTMLCollection = scriptMarkup => { let divElem = document.createElement("div"); divElem.innerHTML = scriptMarkup; return divElem.getElementsByTagName("script"); };
const cloneScript = sourceScript => { let script = document.createElement("script");
Array.from(sourceScript.attributes).forEach(attr => { script.setAttribute(attr.name, attr.value); });
script.innerHTML = sourceScript.innerHTML;
return script; };
|
至此, 我们的 React 组件就能够同时支持服务端和客户端渲染了.
最终代码示例: Github Repo
参考