React 怎么区分类组件和函数组件
文章目录
原文标题: How Does React Tell a Class from a Function?
原文链接: https://overreacted.io/how-does-react-tell-a-class-from-a-function/
太长不看版:
React 会顺着原型链检查isReactComponent
这个属性:
|
|
存在就是 class,不存在就是 function。
正文从这里开始
先来看下面的一个Greeting
组件,这是一个函数组件:
|
|
React 也支持定义类组件:
|
|
(在最近的 Hooks出现之前,类组件是唯一可以使用state
之类属性的方式。)
当我们想要渲染<Greeting />
,我们并不需要在意它是怎么定义的:
|
|
但是 React 会关心他们的不同!
如果Greeting
是一个函数,React 需要去调用这个函数:
|
|
但是如果Greeting
是一个类,React 需要用new
操作符来创造它的一个实例并且接下来会调用它的render
方法:
|
|
两种情况中,React 的目的都是得到要渲染的节点(上例中是<p>Hello</p>
)。但是更具体的步骤取决于Greeting
是怎么定义的。
所以 React 是怎么知道定义的组件是 class 还是 function 呢?
和上一篇博客一样,我们并不需要了解这个知识点一样可以用 React 进行开发。这篇文章更多的是在讲关于 JavaScript 的知识点而不是 React。
(这篇文章适合对 React 内部如何工作好奇的人阅读。你是这样的人吗?那让我们一起来深入学习呀~~)
这是一个很长的旅途,让我们系好安全带~这篇文章没有多少关于 React 的信息,但是我们会深入一些概念比如new
,this
,class
,箭头函数,prototype
,__proto__
,instanceof
,以及他们在 JavaScript 中如何一起生效。幸运的是,我们在使用 React 的时候不需要知道这么多。
(如果只是想知道答案,可以直接到文章末尾。)
首先,我们需要理解函数和类是不一样的,这是很重要的。注意我们在调用类的时候需要使用new
操作符:
|
|
让我们看一下new
操作符在 JavaScript 中的作用。
在之前,JavaScript 中并没有 class。然而,我们可以使用普通函数表达类似类的模式。具体来说,我们可以通过在调用函数之前添加new
,使任何函数像类构造函数:
|
|
我们现在依然可以这样写!可以在 devTools 中试一下~
当我们不用new
调用Person('Fred')
时,函数内部的this
会指向全局和无用的东西(例如window
或undefined
)。所以我们的代码会垮掉或者执行一些奇怪的操作比如设置了window.name
。
通过使用new
操作符调用,我们大致传达了这样的意思:“Hey JavaScript,我知道Person
只是一个函数,但是让我们假装他是一个类构造函数。创建一个空的对象{}
并且将Person
内的this
指向这个对象,这样我可以将一些属性赋值给它比如this.name
。然后将这个新对象返回给我。”
这就是new
操作符做的事情。
|
|
new
操作符也使我们放在Person.prototype
上面的属性或方法可以被fred
对象访问到:
|
|
这是 JavaScript 增加 class 语法前人们模仿 class 语法的方式。
所以new
操作符已经在 JavaScript 中存在了一段时间。相比之下,class 语法是最近的语法,允许我们使用更接近我们意图的方式重写这段代码:
|
|
语言和 API 设计中捕获开发者的意图是非常重要的。
如果我们写了一个函数,JavaScript 不能猜出这个函数是会像alert()
一样被调用,还是类似于new Person()
的构造函数被调用。忘记使用new
调用Person
这样的函数会导致很奇怪的行为。
使用 class 语法大致就是在对 JavaScript 说:“这不仅仅是个函数–这是一个类并且它拥有构造函数。” 如果我们在调用的时候忘记使用new
操作符,JavaScript 会报错哒:
|
|
这会帮助我们提早捕获错误,而不是等到奇怪的 bug 出现,例如this.name
被处理成window.name
而不是george.name
。
然而,这意味着 React 需要在调用类前使用new
操作符。不能被作为平常的函数调用,因为 JavaScript 会把这种情况视为错误!
|
|
这意味着有麻烦啦!
在我们学习 React 怎么处理这种情况之前,我们需要知道大多数人用 React 都会使用像 Babel 这样的编译工具,以便于旧版本浏览器能识别像 class 这样的新语法。所以我们的设计中需要考虑编译。
在 Babel 的早期版本,是可以不用new
来调用类的。然而,这点已经被修好了–通过生成一些额外的代码:
|
|
我们之前在打包完的代码里可能见到过这种代码。这是_classCallCheck
函数做的所有的事情。(我们可以通过选择“松散模式”而不进行检查来减小打包完后的代码体积,但是这样做可能会使转换代码的过程变的复杂。)
到现在为止,我们应该粗略地理解了调用函数用new
和不用new
的区别:
new Person() |
Person() |
|
---|---|---|
class |
this 是Person 的实例 |
TypeError |
function |
this 是Person 的实例 |
this 指向window 或undefined |
这就是 React 需要正确调用组件的原因。如果组件定义为一个类组件,那么 React 需要用new
来调用它。
所以 React 可以检查出组件是不是类组件吗?
不是那么简单的!尽管JavaScript 可以区分是 class 还是函数,但是依然不适用于被 Babel 处理过的情况。对于浏览器来说,他们都是单纯的函数。
所以,React 可以在每次都用new
来调用函数吗?不幸的是,这并不是每次都奏效。
对于普通的函数,用new
来调用他们会返回一个对象实例。作为构造函数来说这样做是没问题的(比如上面的Person
),但是对于函数组件来说就很奇怪:
|
|
但是这样还可以忍,这里有别的两个原因会杜绝我们的这个想法。
不能总使用new调用的
第一个原因是原生的箭头函数(不是被 Babel 编译过的),使用new
调用会报错:
|
|
这个行为是有意的,并且遵循了箭头函数的设计。箭头函数的一个特点就是没有他自己的this
值–相反的,this
指向的是最近的外层函数:
|
|
所以 箭头函数没有自己的this
。这意味着它作为构造函数是完全无用的。
|
|
JavScript 不允许使用new
来调用箭头函数。如果我们这样做了,就了一个错误,所以我们需要避免这样做。这和 JavaScript 不允许没有new
调用类组件是一样的。
这是一个很好的规定但是它会把我们的计划打乱了。React 不能用new
来调用每个函数因为在箭头函数中会报错的。我们可以尝试通过缺少prototype
来检测箭头函数,而不是直接使用new
来调用:
|
|
但是这个对于 Babel 编译出的函数不起作用。这可能也不是什么大问题,但是还有另一个原因让这种方式不成立。
我们不能总是用new
调用函数的另一个原因是 React 需要支持返回字符串或者别的原生类型的组件。
|
|
这里又需要来解决new
操作符引起的奇怪行为。与我们之前看到的一样,new
告诉 JavaScript 引擎去创建一个新的对象,让这个函数的this
指向这个对象,并且之后将new
后的结果返回。
然而,JavaScript 也允许使用new
调用的函数返回别的对象来覆盖使用new
来调用的结果。如果我们想复用这个实例时,这是一个有用的模式:
|
|
然而,new
操作符会在函数返回值不是对象时忽略它的返回值。意思就是说如果返回了一个字符串或者数字,就跟没写return
是一样一样的。
|
|
使用new
调用函数时,无法从返回原始值(如字符串或数字)的函数中读取返回值,所以如果 React 始终通过new
来调用函数,是不能支持返回字符串的组件的。
以上的种种问题导致我们需要妥协。
到现在我们学习了什么呢?React 需要使用new
来调用类,对于寻常的函数或者箭头函数则需要直接调用而不能使用new
。并且也没有可靠的方式可以区分他们。
如果我们不能解决一般的情况,那我们可以解决一些更具体的吗?
当定义一个类组件时,我们都会扩展React.Component
以便于可以使用内置的方法例如this.setState()
。相比于检测所有的类,我们不能只检查React.Component
的子组件吗?
剧透:这正是 React 所采取的方式。
或许,常用来检查Greeting
是不是 React 组件的方法是检查Greeting.prototype instanceof React.Component
是否为true
:
|
|
我知道你在想什么。这里发生了什么呢?为了理解这里,我们需要知道 JavaScript 原型。
我们都对“原型链”的概念很熟悉。JavaScript 的每个对象都有一个“原型”。当我们写下fred.sayHi()
但是fred
并没有一个sayHi
的属性时,JavaScript 就会在fred
的原型上寻找sayHi
属性。如果还是找不到,就会沿着原型链寻找–fred
的原型的原型,以此类推。
令人困惑的是,类和函数的prototype
属性并没有指向相应的原型,我不是在开玩笑。
|
|
所以“原型链”更像是__proto__.__proto__.__proto__
而不是prototype.prototype.prototype
。
那函数或类的prototype
属性是什么呢?它是使用new
创建的类或者函数的对象的__proto__
|
|
__proto__
链就是 JavaScript 寻找属性的地方:
|
|
通常,除非是要调试有关于原型链的代码,否则很少会触及到__proto__
属性。如果想在fred.__proto__
上增加一些功能,最好是加在Person.prototype
上面。至少这就是当初设计的方式。
__proto__
属性甚至都不应该被浏览器暴露出来,因为原型链被视为语言内部的概念。但是一些浏览器增加了__proto__
属性,渐渐地被勉强标准化(但赞成使用Object.getPrototypeOf()
)。
到现在我依然觉得一个叫做prototype
的属性并没有指向该值的原型是很奇怪的。(例如,fred.prototype
是 undefined 因为fred
不是一个函数。)就个人而言,我认为这是即便经验丰富的开发者都会误解 JavaScript 的原型的主要原因。
这是很长的一篇文章,我们已经到了 80%,让我们继续!
我们知道当我们调用obj.foo
时,JavaScript 会在obj.__proto__
,obj.__proto__.__proto__.
中寻找。
使用类,我们并不能直接接触到这种机制,但是extends
也可以在原型链中起到很好的作用。这是 React 的类实例能调用setState
等方法的原因:
|
|
换句话说,当我们使用了类,它的实例的__proto__
链就是这个类继承结构的一个“镜像”。
|
|
双链!
既然__proto__
链反映了 class 继承结构,我们可以通过从Greeting.prototype
来检查Greeting
是否从React.Component
继承而来,然后再沿着它的__proto__
链:
|
|
更方便的方法,可以使用X instanceof Y
来进行这种搜索,它会沿着x.__proto__
链寻找Y.prototype
。
通常的,它可以用来检查某个对象是不是某个类的实例:
|
|
它也可以用来检查类是否继承自另一个类:
|
|
这个检查就可以帮助我们确定一个对象是类还是普通函数。
但这并不是 React 所做的。
需要注意的一点是instanceof
操作符在项目中有多个 React 副本时失效,我们当前检查的组件会继承自另一个React.Component
的 React 副本。在一个项目中混合使用多个 React 版本是不好的,原因有好多,但是从历史上看我们尽可能避免这种写法。(使用 Hooks 的话,我们可能需要删除一些副本。)
另一种启发是检查在原型上是不是有render
方法。但是,当时并不清楚组件的 API 会如何发展。每一次的检查都会有代价,所以我们不能增加很多个检查。如果render
作为实例的方法存在,例如使用类属性语法,这个检查也不会起作用。
所以,React 为基础组件增加了一个特殊标志。React 会检查这个标志的存在,这就是它判断一个组件是不是 React 组件类的方法。
起初,标志是在基础的React.Component
类上:
|
|
然而,一些我们想要定位的 class 实例没有复制静态属性(或者手动设置了__proto__
),所以标志会丢失。
这是 React 将这个标志移动到React.Component.prototype
上去的原因。
|
|
这才是 React 所使用的方式。
可能会疑惑为什么这是一个对象而不是一个布尔值。其实在实践中无所谓什么类型,但是 Jest 的早期版本(Jest 的 Good^TM 之前)的自动锁定功能???
默认是开启的,会忽略原生属性,会破坏检查。
isReactComponent
的检查直到今天还在 React 中使用。
如果不是继承自React.Component
,React 不会在原型中找到isReactComponent
属性,也就不会将组件视为一个类组件。现在我们知道了为什么关于不能像调用函数一样调用类
的最推荐的回答是需要继承React.Component
。最后,当存在prototype.render
但是没有prototype.isReactComponent
属性时会触发一个 warning。
你可能会觉得这篇文章是假的吧!最后的解决方案其实是很简单的,但是我花费了巨大的篇幅来解释为什么 React 最终使用了这种解决方案,以及备选方案都有哪些。
在我的开发过程中,很多库的 API 通常就是这种情况,一个 API 想要易于使用,通常需要考虑语言语义(甚至对于某些语言来说,要涵盖未来的发展方向),运行性能,有没有编译步骤,生态建设和打包的解决方案,早期 warning 和很多别的方面。最后的结果可能不是最优雅的,但是它一定是最实用的。
如果最后的 API 是成功的,使用者是不会思考实现过程的。相反他们可以更多的专注于开发自己的应用。
但是如果你是很好奇的,了解是如何起作用的也是很好的。
文章作者 youting
上次更新 2020-03-08