大家好,我是「柒八九」。
在前面的「前端框架」中,我们从Fiber的实现机制描绘了React在页面渲染和数据处理方面做了的努力。其中有很多源码级别的概念。例如,React-Element/React-Fiber/Current Tree 和 workInProgress Tree等。
我们其实在React_Fiber机制已经讲过React-Element和React-Fiber之间的关系。但是,都是一带而过。
今天,我们来谈谈React-Element/React-Componment/React-Instance之间的区别和联系。
话不多说,开干。
React元素、组件和实例是React中的不同术语,它们密切相关。
假设存在如下的代码:
代码语言:javascript复制const App = () => {
return <p>Hello 789</p>;
};
❝
React-Componment就是一个「组件的声明」 ❞
在上面例子中,它是一个函数组件,但它也可以是任何其他类型的React组件(例如React类组件)
在函数组件的情况下,它被声明为一个「JavaScript函数」,返回React的JSX。更复杂的JSX是HTML和JavaScript的「混合体」,但这里我们处理的是一个简单的例子,它只返回一个带有内部内容的HTML元素。
(props) => JSX
我们可以进行组件的嵌套处理。只要我们在另一个组件中把目标组件作为「带角括号的React元素」(例如:<Greeting />)即可。
const Greeting = ({ text }) => {
return <p>{text}</p>;
};
const App = () => {
return <Greeting text="Hello 789" />;
};
代码语言:javascript复制❝也「可以将一个组件作为「React元素」多次渲染」。 「每当一个组件被渲染成元素时,就会创建一个该组件的实例」。 ❞
const Greeting = ({ text }) => {
return <p>{text}</p>;
};
const App = () => {
return (
<>
<Greeting text="Greeting-元素1" />
<Greeting text="Greeting-元素2" />
<Greeting text="Greeting-元素3" />
</>
);
};
❝
React组件被「声明一次」- 但
组件可以作为JSX中的React元素被「多次使用」 - 当
元素被使用时,它就成为该组件的「一个实例」,挂载在React的组件树中
❞

React-Element
继续从一个简单的例子入手:
代码语言:javascript复制const App = () => {
return <p>Hello 789</p>;
};
每当React组件被「调用(渲染)时」,React 在「内部调用」其React.createElement()方法,该方法返回以下对象。
{
$$typeof: Symbol(react.element)
"type": "p",
"key": null,
"ref": null,
"props": {
"children": "Hello 789"
},
"_owner": null,
"_store": {}
}
挑几个重点的属性来解释下:
type:代表「实际的HTML元素」props:传递给这个HTML元素的「所有」HTML属性(加上文本内容{Hello 789},读作:children)
针对上面的<p>元素,没有属性被赋值。然而,React 将 children 视为「伪HTML属性」,而children代表在「HTML标签之间呈现的一切」。
当向<p>HTML元素添加属性时,props中的就会包含对应的信息。
const App = () => {
return <p className="greet">Hello 789</p>;
};
console.log(App());
{
$$typeof: Symbol(react.element)
"type": "p",
"key": null,
"ref": null,
"props": {
"children": "Hello 789",
"className": "greet"
},
"_owner": null,
"_store": {}
}
❝本质上,
React除了将所有HTML属性转换成React-props外,还将「内部内容」添加为children属性。 ❞
如前所述,React 的createElement()方法是内部调用的。因此,我们可以用它来替代返回的JSX。React的createElement方法需要一个type、props和children作为参数。
- HTML标签
p作为「第一个参数」 - 「第二个参数」--
props用一个带有className的对象填充 children作为「第三个参数」
const App = () => {
// return <p className="greet">Hello 789</p>;
return React.createElement(
'p',
{ className: 'greet' },
'Hello 789'
);
};
该方法的调用并没有1:1地反映出返回的对象,其中「children 元素是props对象的一部分」。我们可以直接将children作为第二个参数props中的属性。
const App = () => {
// return <p className="greet">Hello 789</p>;
return React.createElement(
'p',
{
className: 'greet',
children: 'Hello 789'
}
);
};
「默认情况下,children是作为第三个参数使用的」。
下面的例子显示了一个React组件,它将HTML树渲染成JSX,并通过React的createElement()方法转化为React元素。
const App = () => {
return (
<div className="container">
<p className="greet">Hello 789</p>
<p className="info">你好!</p>
</div>
);
};
console.log(App());
{
$$typeof: Symbol(react.element)
"type": "div",
"key": null,
"ref": null,
"props": {
"className": "container",
"children": [
{
$$typeof: Symbol(react.element)
"type": "p",
"key": null,
"ref": null,
"props": {
"className": "greet",
"children": "Hello 789"
},
"_owner": null,
"_store": {}
},
{
$$typeof: Symbol(react.element)
"type": "p",
"key": null,
"ref": null,
"props": {
className: "info",
children: "你好!"
},
"_owner": null,
"_store": {}
}
]
},
"_owner": null,
"_store": {}
}
❝「在内部,所有的
JSX被React的createElement()方法转换」。 ❞
所以,我们在使用JSX的地方,都可以用createElement()进行同等效果替换。
const App = () => {
// return (
// <div className="container">
// <p className="greet">Hello 789</p>
// <p className="info">你好!</p>
// </div>
// );
return React.createElement(
'div',
{
className: 'container',
},
[
React.createElement(
'p',
{ className: 'greet' },
'Hello 789'
),
React.createElement(
'p',
{ className: 'info' },
'你好!'
),
]
);
};
同时,我们还可以对上述的代码进行组件的封装和抽离。将用于显示的信息,封装成Text组件。并且,在同样的位置,进行组件调用。
const Text = ({ className, children }) => {
return <p className={className}>{children}</p>;
};
const App = () => {
return (
<div className="container">
<Text className="greet">Hello 789</Text>
<Text className="info">你好!</Text>
</div>
);
};
然后,将用JSX的实现准换为用createElement实现,代码如下:
const Text = ({ className, children }) => {
return <p className={className}>{children}</p>;
};
const App = () => {
// return (
// <div className="container">
// <Text className="greet">Hello 789</Text>
// <Text className="info">你好!</Text>
// </div>
// );
return React.createElement(
'div',
{
className: 'container',
},
[
React.createElement(
Text,
{ className: 'greet' },
'Hello 789'
),
React.createElement(
Text,
{ className: 'info' },
'你好!'
),
]
);
};
虽然,使用createElement()能达到与JSX同等的效果,但是在一般情况下,我们不推荐。
但是,但是,但是,在有些场景下,利用createElement(),可以达到很好的效果。
在前面的文章中,我们介绍了关键渲染路径,其中针对React的项目中,我们可以使用React.lazy()在「页面加载阶段」,对代码进行分割处理。而在「页面运行阶段」,可以使用import()来做按需处理。
而在工程化之webpack打包过程中我们介绍到,「一个动态导入(即import()函数)会产生一个新的子ChunkGroup」,从而能够对业务逻辑进行分割处理。

这里我们举一个比较简单的例子。在React的项目开发中,我们进行弹窗的处理。
比较常规的处理方式
代码语言:javascript复制import React,{ FC, useState } from 'react';
import TestModal from './TestModal';
type TTest = React.PropsWithChildren<{
}>
const Test:FC<TTest> = ({}) => {
const [visible,setVisible] = useState(false);
return (
<div >
<button onClick={() => setVisible(true)}></button>
{visible
&& <TestModal handleCancelCB={() => setVisible(false) }/>}
</div>
)
}
export default Test;
有关键的几步
- 页面同步引入资源(
import) - 在调用处,需要一个变量(
visible)来控制TestModal显隐 - 将
() => setVisible(false)传入到TestModal中,用于控制一堆操作后,将弹窗进行隐藏处理
利用import()处理
import React,{ FC, ReactNode, useState } from 'react';
type TTest = React.PropsWithChildren<{
}>
const Test:FC<TTest> = ({}) => {
const [TestModal,setTestModal] = useState<ReactNode|null>(null);
const triggerModalShow = async () => {
const module = await import(/* webpackChunkName: "TestModal" */ './QRCodeModal')
const TestModal = module.default;
const instance = React.createElement(TestModal,{
handleCancelCB:()=>setTestModal(null),
})
setTestModal(instance);
}
return (
<div >
<button onClick={() => triggerModalShow()}></button>
{TestModal}
</div>
)
}
export default Test;
这种处理方式,虽然看起来,代码量增多了,但是有几个好处
- 利用
import()实现了按需加载,在代码运行阶段,减少了非关键的资源的加载 - 逻辑相对集中,相当于针对
Modal的所有处理,都被限制在triggerModalShow中了 - 页面结构相对简介,在
return不需要if/else或者三元进行代码逻辑的处理
由于例子比较简介,import()的代码看起来比常规方式多,但是,一个真正的逻辑复杂的弹窗需要更多的参数,到时候就会看到使用import()的好处了。---自我感觉,这种处理方式,还是值得一试。
调用函数组件会发生啥?
调用React函数组件与将其作为React元素的实际区别是什么?在前面的介绍中,我们「调用」函数组件,在React内部 调用createElement()方法返回函数组件。当把它作为React元素使用时,其输出有什么不同。
const App = () => {
return <p>Hello 789</p>;
};
console.log(App());
{
$$typeof: Symbol(react.element),
"type": "p",
"key": null,
"ref": null,
"props": {
"children": "Hello 789"
},
"_owner": null,
"_store": {}
}
console.log(<App />);
{
$$typeof: Symbol(react.element),
"type": () => {…},
"key": null,
"ref": null,
"props": {},
"_owner": null,
"_store": {}
}
App()/<App />输出略有不同。
❝当使用React组件作为元素,
type属性变成了一个「函数」,其中包含了所有函数组件的实现细节(例如,children、hooks)。 ❞
props 是被传递给组件的所有属性。代码如下:
console.log(<App className="greet" />);
{
$$typeof: Symbol(react.element),
"key": null,
"ref": null,
"props": {
"className": "greet"
},
"type": () => {…},
"_owner": null,
"_store": {}
}
对于一个真正的React应用来说,type变成了一个函数,而不再是一个字符串,这意味着什么?让我们通过一个例子来看看这个问题,它展示了为什么「我们不应该调用React函数组件」。
首先,我们通过使用<>,按原意使用组件。
const Counter = ({ initialCount }) => {
const [count, setCount] = React.useState(initialCount);
return (
<div>
<button onClick={() => setCount(count 1)}> </button>
<button onClick={() => setCount(count - 1)}>-</button>
<div>{count}</div>
</div>
);
};
const App = () => {
return (
<div>
<Counter initialCount={42} />
</div>
);
};
此时,针对Counter,使用函数调用(Counter())和将其作为元素(<Counter />)效果是一样的。
const App = () => {
return (
<div>
{Counter({ initialCount: 42 })}
</div>
);
};
让我们再看一个例子,就会发现,不应该调用React函数组件用于渲染页面内容。我们将对渲染的子组件使用「条件渲染」,可以通过点击按钮来切换。
代码语言:javascript复制const App = () => {
const [isVisible, setVisible] = React.useState(true);
return (
<div>
<button
onClick={() => setVisible(!isVisible)}
>
切换
</button>
{isVisible
? Counter({ initialCount: 42 })
: null
}
</div>
);
};
当我们将子组件切换为不可见时,我们得到一个「错误提示」:Uncaught Error: Rendered fewer hooks than expected。这个错误,在使用hook的时候,一不小心就会出现。原因是,组件中的hook数量和上一次不一致了。
出错原因我们知道了,但是我们按照我们代码的意愿来分析。首先hook被分配在子组件中(Counter),这意味着如果子组件被卸载,hook应该被移除而不会有任何错误。只有当一个被挂载的组件改变了它的 hook 的数量(App),它才会崩溃。
但确实它崩溃了,因为一个被挂载的组件(App)改变了它的hook数量。因为我们是「以函数的形式调用子组件(Counter),React并没有把它当作React组件的一个实例」。相反,它只是将子组件的所有实现细节(如hook)直接放在其父组件中。
在App中触发了条件渲染,部分代码变的不可见了。但是,在这部分代码中,存在hook的使用。进而触发了hook的减少。最终结果就是React应用由于hook减少而挂掉了。
将上面调用组件的方式用另外一种代码来实现。它们是等价的。
代码语言:javascript复制const App = () => {
const [isVisible, setVisible] = React.useState(true);
return (
<div>
<button
onClick={() => setVisible(!isVisible)}
>
切换
</button>
{isVisible
? (() => {
const [count, setCount] = React.useState(42);
return (
<div>
<button onClick={() => setCount(count 1)}>
</button>
<button onClick={() => setCount(count - 1)}>
-
</button>
<div>{count}</div>
</div>
);
})()
: null
}
</div>
);
};
❝这违反了hook的规则,
Hook必须在组件的「顶层」作用域调用 ❞
我们可以通过告诉React这个React组件来解决这个错误,作为回报,React会被当作一个实际的组件实例。然后它就可以在这个组件的实例中分配实现细节了。当有条件的渲染开始时,该组件就会取消挂载,并随之取消其实现细节(如钩子)。
为了解决上面的问题,我们就需要换一种处理方式,用函数组件(Counter)的「实例」替换函数调用。我们上面讲过,经过JSX处理后组件,会生成对应组件的实例。
const App = () => {
const [isVisible, setVisible] = React.useState(true);
return (
<div>
<button onClick={() => setVisible(!isVisible)}>
切换
</button>
{isVisible
? <Counter initialCount={42} />
: null
}
</div>
);
};
❝每个组件实例都会将组件内部实现封存起来,而不会泄漏给其他组件。 ❞
因此在利用组件来处理各种封装和业务逻辑时,「使用React元素而不是在JSX中调用一个函数组件」。
React-Element VS React-Component
让我们总结一下React-Element和React-Component之间的关系。
❝
React-Component是一个组件的「一次性声明」,但它可以作为JSX中的React-Element使用一次或多次。 也就是说React-Component和React-Element是「1对多」的关系 ❞
在JSX中,它可以使用<>,然而,在React底层实现中,React调用createElement方法,为每个HTML元素创建React-Element。
const Text = ({ children }) => {
console.log('Text作为实例被调用');
return <p>{children}</p>;
};
console.log('此时Text为组件',Text );
const App = () => {
console.log('App作为实例被调用');
const paragraphOne = <p>Hello 789!</p>;
const paragraphTwo = <Text>React</Text>;
console.log('此时是React-Element:', paragraphOne);
console.log('此时是React-Element:', paragraphTwo);
return (
<div>
<p>Hello React</p>
{paragraphOne}
{paragraphTwo}
</div>
);
};
console.log('此时是React-Component', App);
console.log('此时是React-Element', <App />);
console.log('此时是React-Element', <p>too</p>);
后记
「分享是一种态度」。
参考资料:
- 关键渲染路径
- 工程化之webpack打包过程
- React官网
- react-element-component


