并不是所有的问题都会有正确而完美的答案,对于那些没有明确答案的问题不必穷究到底,我们需要寻找的,是在当下条件允许的情况下,一个能够解决问题的足够好的答案。
但求够好,不求最好
大家好,我是柒八九。
在前面的前端框架中,我们从Fiber
的实现机制描绘了React
在页面渲染和数据处理方面做了的努力。其中有很多源码级别的概念。例如,React-Element
/React-Fiber
/Current Tree
和 workInProgress Tree
等。
我们其实在React_Fiber机制已经讲过React-Element
和React-Fiber
之间的关系。但是,都是一带而过。
今天,我们来谈谈React-Element
/React-Componment
/React-Instance
之间的区别和联系。
话不多说,开干。
React元素
、组件
和实例
是React
中的不同术语,它们密切相关。
假设存在如下的代码:
const App = () => { return <p>Hello 789</p>; }; 复制代码
React-Componment
就是一个组件的声明
在上面例子中,它是一个函数组件,但它也可以是任何其他类型的React组件(例如React类组件)
在函数组件的情况下,它被声明为一个JavaScript函数,返回React
的JSX
。更复杂的JSX
是HTML
和JavaScript
的混合体,但这里我们处理的是一个简单的例子,它只返回一个带有内部内容的HTML
元素。
(props) => JSX 复制代码
我们可以进行组件的嵌套处理。只要我们在另一个组件中把目标组件作为带角括号的React元素(例如:)即可。
const Greeting = ({ text }) => { return <p>{text}</p>; }; const App = () => { return <Greeting text="Hello 789" />; }; 复制代码
也可以将一个组件作为「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
继续从一个简单的例子入手:
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
)
针对上面的
元素,没有属性被赋值。然而,React
将 children
视为伪HTML属性,而children
代表在HTML标签之间呈现的一切。
当向
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
的项目开发中,我们进行弹窗的处理。
比较常规的处理方式
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()/
输出略有不同。
当使用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()
)和将其作为元素()效果是一样的。
const App = () => { return ( <div> {Counter({ initialCount: 42 })} </div> ); }; 复制代码
让我们再看一个例子,就会发现,不应该调用React函数组件用于渲染页面内容。我们将对渲染的子组件使用条件渲染,可以通过点击按钮来切换。
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
减少而挂掉了。
将上面调用组件的方式用另外一种代码来实现。它们是等价的。
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>); 复制代码
后记
分享是一种态度。
参考资料:
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。