#

背景

在 React 项目中常会遇到渲染 HTML 内容的情况。可以利用 react 的 dangerouslySetInnerHTML 属性,完成基础开发。

示例:

1
2
3
4
5
6
7
8

function createMarkup() {
return {__html: 'First · Second'};
}

function MyComponent() {
return <div dangerouslySetInnerHTML={createMarkup()} />;
}

不足

就作者目前查阅的资料和实践结果,上文提到的基础方案有一些不足。

以非虚拟 DOM 的方式渲染节点

React 对虚拟 DOM 设计了优化的算法(主要依赖 data-reactid),放弃走虚拟 DOM 的渲染等同于放弃这些优化。

dangerouslySetInnerHTML 的渲染方式类似于原生 JS 的 HTML 渲染,显然放弃了节点优化:

1
2
3
4
5
6
7
8

function createMarkup() {
return {__html: '<div style="color: red">I m cool<p></p></div>'};
}

function MyComponent() {
return <div dangerouslySetInnerHTML={createMarkup()} />;
}

实验下来,只有 dangerouslySetInnerHTML 所在的元素上带有 data-reactid ,而子元素都没有。

示例图

可以从 React 源码中证实:

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
// 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 的话题有点大,作者仅以自己的实验说明:

1
2
3
4
5
6
7
8

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 这里做了不错的示范。

1
2
3
4
5
6
7
8
//使用 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,最安全的态度是「永远不要相信用户输入的数据」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//使用 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