为什么 React 元素有一个 $$typeof 属性?
December 3, 2018
你可能以为你写的是 JSX:
<marquee bgcolor="#ffa7c4">hi</marquee>
但实际上,你是在调用一个函数:
React.createElement(
/* type */ 'marquee',
/* props */ { bgcolor: '#ffa7c4' },
/* children */ 'hi'
)
这个函数会返回一个对象。我们称这个对象为 React 元素。它告诉 React 接下来要渲染什么。你的组件会返回一个由这些元素组成的树。
{
type: 'marquee',
props: {
bgcolor: '#ffa7c4',
children: 'hi',
},
key: null,
ref: null,
$$typeof: Symbol.for('react.element'), // 🧐 这是谁
}
如果你用过 React,可能对 type
、props
、key
和 ref
这些字段很熟悉。但 $$typeof
是什么?为什么它的值是一个 Symbol()
?
这属于那些你不必须知道,但了解后会让你感觉很棒的知识点之一。本文还会提到一些关于安全的小技巧,你或许会用得上。也许有一天你会自己写 UI 库,这些内容就会派上用场。我真心希望如此。
在客户端 UI 库普及、并且增加了基础防护措施之前,应用代码常常直接拼接 HTML 并插入到 DOM 中:
const messageEl = document.getElementById('message');
messageEl.innerHTML = '<p>' + message.text + '</p>';
这种做法一般没问题,除非你的 message.text
是 '<img src onerror="stealYourPassword()">'
这样的内容。你肯定不希望陌生人写的内容原样出现在你应用渲染的 HTML 里。
(有趣的是:如果你只做客户端渲染,这里插入 <script>
标签并不会真的执行 JavaScript。但别因此掉以轻心。)
为了防止此类攻击,你可以使用诸如 document.createTextNode()
或 textContent
这样的安全 API,它们只处理文本内容。你也可以提前“转义”输入,把用户提供的文本中的 <
、>
等潜在危险字符替换掉。
不过,这样做既容易出错,也很麻烦——每次插入用户字符串时都要记得转义。这也是为什么现代库如 React 默认会对字符串内容进行转义:
<p>
{message.text}
</p>
如果 message.text
是包含 <img>
或其他标签的恶意字符串,它不会变成真正的 <img>
标签。React 会先转义内容,然后再插入到 DOM 中。所以你只会看到 <img>
标签的源码,而不会真的渲染出图片。
如果你确实需要在 React 元素中渲染任意 HTML,必须写成 dangerouslySetInnerHTML={{ __html: message.text }}
。这种写法之所以繁琐,正是有意为之。 它让你在代码审查和安全检查时很容易发现。
这是否意味着 React 完全杜绝了注入攻击?并没有。 HTML 和 DOM 依然存在大量攻击面,React 或其他 UI 库很难完全防御,或者会导致性能问题。大多数剩下的攻击向量都和属性有关。例如,如果你渲染 <a href={user.website}>
,要小心用户网站是 'javascript: stealYourPassword()'
这种情况。像 <div {...userData}>
这样直接展开用户输入虽然少见,但也很危险。
React 有可能 随着时间提供更多防护,但很多情况下这些问题本质上还是应该在服务端修复。
不过,转义文本内容依然是非常有效的第一道防线,可以阻止很多潜在攻击。知道下面这样的代码是安全的,难道不让人安心吗?
// 会自动转义
<p>
{message.text}
</p>
但这也不是一直如此。 这正是 $$typeof
派上用场的地方。
React 元素本质上是普通对象:
{
type: 'marquee',
props: {
bgcolor: '#ffa7c4',
children: 'hi',
},
key: null,
ref: null,
$$typeof: Symbol.for('react.element'),
}
虽然通常你会用 React.createElement()
创建它们,但其实并非强制。React 也支持像上面这样直接写普通对象作为元素。这种写法你可能不会主动用,但对于优化编译器、在 worker 之间传递 UI 元素,或将 JSX 与 React 包解耦等场景,是有用的。
然而,如果你的服务端存在漏洞,允许用户存储任意 JSON 对象,而客户端代码却期望拿到字符串,这就可能出问题了:
// 服务端可能有漏洞,允许用户存储 JSON
let expectedTextButGotJSON = {
type: 'div',
props: {
dangerouslySetInnerHTML: {
__html: '/* 在这里放你的攻击代码 */'
},
},
// ...
};
let message = { text: expectedTextButGotJSON };
// 在 React 0.13 中很危险
<p>
{message.text}
</p>
在这种情况下,React 0.13 会受到 XSS 攻击。需要再次强调,这种攻击依赖于服务端已有漏洞。但 React 还是可以做得更好,从 React 0.14 开始,确实做到了。
React 0.14 的修复方式是给每个 React 元素打上一个 Symbol 标记:
{
type: 'marquee',
props: {
bgcolor: '#ffa7c4',
children: 'hi',
},
key: null,
ref: null,
$$typeof: Symbol.for('react.element'),
}
这样做的好处在于,JSON 里不能包含 Symbol
。所以即使服务端有安全漏洞,返回了 JSON 而不是字符串,这个 JSON 也无法包含 Symbol.for('react.element')
。 React 会检查 element.$$typeof
,如果缺失或无效,就拒绝处理这个元素。
使用 Symbol.for()
的另一个好处是,Symbol 在不同环境(如 iframe、worker)之间是全局共享的。 这样即使在更复杂的场景下传递可信元素,修复措施依然有效。同样,即使页面上有多个 React 副本,它们也能“约定”好合法的 $$typeof
值。
那对于不支持 Symbol 的浏览器怎么办?
很遗憾,这些浏览器就得不到这层额外保护了。React 依然会在元素上加上 $$typeof
字段以保持一致,但它的值会设为一个数字——0xeac7
。
为什么选这个数字?0xeac7
看起来有点像 “React”。