再见吧👋 React dangerouslySetInnerHTML
背景
在 React 项目中常会遇到渲染 HTML 内容的情况。可以利用 react 的 dangerouslySetInnerHTML 属性,完成基础开发。
示例:
function createMarkup() {
return {__html: 'First · Second'};
}
function MyComponent() {
return <div dangerouslySetInnerHTML={createMarkup()} />;
}
不足
就作者目前查阅的资料和实践结果,上文提到的基础方案有一些不足。
以非虚拟 DOM 的方式渲染节点
React 对虚拟 DOM 设计了优化的算法(主要依赖 data-reactid),放弃走虚拟 DOM 的渲染等同于放弃这些优化。
而 dangerouslySetInnerHTML 的渲染方式类似于原生 JS 的 HTML 渲染,显然放弃了节点优化:
function createMarkup() {
return {__html: '<div style="color: red">I m cool<p></p></div>'};
}
function MyComponent() {
return <div dangerouslySetInnerHTML={createMarkup()} />;
}
实验下来,只有 dangerouslySetInnerHTML 所在的元素上带有 data-reactid ,而子元素都没有。
可以从 React 源码中证实:
// ReactDOMComponent.js 部分源码:
// 为方便阅读,只保留了 _createContentMarkup 函数的相关代码
/**
* Creates markup for the content between the tags.
*
* @private
* @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction
* @param {object} props
* @param {object} context
* @return {string} Content markup.
*/
_createContentMarkup: function (transaction, props, context) {
var ret = '';
var innerHTML = props.dangerouslySetInnerHTML; // 拿到 dangerouslySetInnerHTML 内容
ret = innerHTML.__html;
return ret;
}
// 为方便阅读,只保留了 ReactDOMComponent.Mixin.mountComponent 函数的相关代码
ReactDOMComponent.Mixin = {
/**
* Generates root tag markup then recurses. This method has side effects and
* is not idempotent.
*
* @internal
* @param {string} rootID The root DOM ID for this node.
* @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction
* @param {object} context
* @return {string} The computed markup.
*/
mountComponent: function (rootID, transaction, context) {
this._rootNodeID = rootID;
var props = this._currentElement.props;
var mountImage;
//...
var tagOpen = this._createOpenTagMarkupAndPutListeners(transaction, props);
// 使用 _createContentMarkup
var tagContent = this._createContentMarkup(transaction, props, context);
// 返回最终 dom 字符串只是把 _createContentMarkup 生成的 HTML 包裹一下
mountImage = tagOpen + '>' + tagContent + '</' + this._currentElement.type + '>';
return mountImage;
}
XSS 攻击
关于 XSS 的话题有点大,作者仅以自己的实验说明:
function createMarkup() {
return {__html: `<input type="btn" value="dont touch me" onclick="document.writeln('u idolt!')">`};
}
function MyComponent() {
return <div dangerouslySetInnerHTML={createMarkup()} />;
}
在基础方案下,input 元素完美渲染,点击正常,如果是恶意数据源,很容易造成严重后果。因此过滤必不可少。
解决思路
1.弃用 dangerouslySetInnerHTML,把文本 HTML 内容转化为 React-DOM 对象。
从 React 0.x 过来的小伙伴应该还没忘记没有 JSX 的时代,手写 React DOM 对象的开发方式。就算到了如今 JSX 也是先转换成 React DOM 对象再进行后面的渲染。
把 HTML 翻译成对象数组目前已有成熟的方案,htmlparse2 是个不错的选择。
不过 htmlparse2 生成的对象跟 React 特有的 DOM 对象还有一定距离,需要做进一步的转换,开源库 react-html-parser 这里做了不错的示范。
//使用 react-html-parser 后:
import ReactHtmlParser from 'react-html-parser';
let html = `<input type="btn" value="dont touch me" onclick="document.writeln('u idolt!')">`;
function MyComponent() {
return <div>{ ReactHtmlParser(html) }<div/>;
}
2.过滤高危元素
防止 XSS 攻击的主要手段之一就是过滤危险标签,例如 input 这种类型的元素则是重点「嫌疑人」。基于上面提到的对象转化,做到过滤并不难。安全等级和体验的平衡,取决于我们对转化后的对象的细致过滤。比如发现 tag 类型是 input 时一棍子打死,比如把有 onclick 的元素全部干掉。对于 XSS,最安全的态度是「永远不要相信用户输入的数据」。
//使用 react-html-parser 后:
import ReactHtmlParser from 'react-html-parser';
let html = `<input type="btn" value="dont touch me" onclick="document.writeln('u idolt!')">`;
function MyComponent() {
return <div>
{ ReactHtmlParser(html, {
transform: function transform (node) {
// 过滤 input 标签
if (node.type === 'input') {
return null;
}
}
}) }
<div/>;
}
小结
已上是作者在实践过程中遇到的问题,问题恐怕不止于此,但仅两点足以让我放弃直接使用 dangerouslySetInnerHTML。这也正是 react 官方所提倡的做法,毕竟,这个属性的设计初衷就是要让开发者体会到「dangerous」。所以,再见吧,dangerouslySetInnerHTML ~

Congratulations @pobusama, you have decided to take the next big step with your first post! The Steem Network Team wishes you a great time among this awesome community.
The proven road to boost your personal success in this amazing Steem Network
Do you already know that awesome content will get great profits by following these simple steps, that have been worked out by experts?
Congratulations @pobusama! You received a personal award!
Click here to view your Board of Honor
Congratulations @pobusama! You received a personal award!
You can view your badges on your Steem Board and compare to others on the Steem Ranking
Vote for @Steemitboard as a witness to get one more award and increased upvotes!