React定义组件的方式有两种,class和function。如下:
函数式定义:
function Button() {
return <div>hello</div>
}
class方式定义:
class Button extends React.Component {
render() {
return <div>>hello</div>
}
}
当我们需要渲染Button组件的时候,直接使用即可,无需关心它是通过什么方式定义的。
<Button />
但是React内部会关心它是通过什么方式定义的。
如果是函数,React会直接调用。
// React 内部
const result = Button(props); // <div>Hello</div>
如果是class,React 需要先用 new 操作符将其实例化,然后调用刚才生成实例的 render 方法:
// React 内部
const instance = new Button(props) // Button {}
const result = instance.render() // <div>Hello</div>
无论哪种情况,React的目的是获取渲染后的Node(节点),具体获取方式取决于Button是如何定义的。
所以React是怎么区分class和function的?
答案是:在Component的原型上定义属性 isReactComponent = {}
// React 内部
class Component {}
Component.prototype.isReactComponent = {}
简单吧,但React为什么采用如此简单的方案呢?
以下通过JavaScript的诸多特性去逐步解释,这其中涉及 new
this
class
箭头函数 prototype
__propto__
instanceof
等方面。
首先理解 new
在JavaScript中是干什么的。
在之前,js是没有类这个概念的,但是可以通过常规函数来模拟。通过new操作符调用函数,意味着任何函数都可以作为构造函数使用。
// 只是一个函数
function Person(name) {
this.name = name
}
var jack = new Person('Jack') // Person {name: 'Jack'}
var george = Person('George') // 没用的
如果直接调用函数,函数内部的this在非严格模式下指向全局对象window,在严格模式下为undefined。所以直接调用会操作window属性或者报错。
通过new操作符调用函数,函数内部所做的事情就是,首先创建一个 {}
,赋值给函数内部的this,初始化之后隐式的返回this。同时创建的实例可以共享Person.prototype上的属性和方法。
function Person(name) {
this.name = name
}
Person.prototype.sayHi = function() {
console.log('Hi, I am ' + this.name)
}
var jack = new Person('Jack')
jack.sayHi()
ES6 推出类以后,我们就可以通过class的方式实现上述效果:
class Person {
constructor(name) {
this.name = name
}
sayHi() {
console.log('Hi, I am ' + this.name)
}
}
let jack = new Person('Jack')
jack.sayHi()
类是不允许直接调用的,必须通过 new 操作符去调用,否则报错。
let jack = new Person('Jack')
// 如果 Person 是个函数:有效
// 如果 Person 是个类:依然有效
let george = Person('George') // 忘记使用 `new`
// 如果 Person 是个函数: 只是简单调用,可能会出错
// 如果 Person 是个类:立即失败
这样可以帮助我们在早期就捕获到错误,而不会出现类似上面this.name时this是window还是undefined这种潜在的错误。
所以React在调用类组件时,需要通过new的方式,而不是当作常规函数调用。
以下为调用时加不加 new 的差别:
new Person() | Person() | |
---|---|---|
class | this 是一个 Person 实例 | TypeError |
function | this 是一个 Person 实例 | this 是 window 或 undefined |
这就是 React 正确调用你的组件很重要的原因。如果组件被定义为一个类,React 需要使用 new 来调用它。
单纯的判断是函数还是类,还是比较容易的。事实上,在开发中,React都会经过babel将类等新语法编译成在可在低版本浏览器上运行的代码。所以class会被编译成经过特殊处理的函数。又该如何判断?
以下为class编译后的伪代码:
function Person(name) {
if (!(this instanceof Person)) {
throw new TypeError('Cannot call a class as a function')
}
this.name = name
}
new Person('Jack') // OK
Person('George') // 无法把类当做函数来调用
为什么不干脆都都通过new
的方式调用呢?并不可以。
对于常规函数来说,勉强可取,但是函数内部生成的实例对我们并没有什么用。我们只是关心其返回的节点。
此外还有两个致命的问题:
第一:箭头函数。
箭头函数是不可以被当作构造函数通过new的方式调用的。因为箭头函数没有自己的this,其内部的this指向离它最近的常规函数所处的上下文。
而且箭头函数没有 prototype
属性。那我们是不是可以通过函数是否有prototype属性来判断直接调用还是通过new方式调用?
(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}
不可以!
万一箭头函数被babel编译过呢。
那干脆把class和箭头函数都通过babel编译成常规函数,然后都通过new的方式不行吗? 是的,不行!看第二个原因。
第二:不能都使用new的原因是,React支持组件返回字符串、数字等基本类型的值。
function Title() {
return 'hello title'
}
Title() // 'hello title'
new Title() // {}
返回另一个对象的函数,在使用new调用时会覆盖其创建的实例。但是,如果一个函数的返回值不是一个对象,它会被 new 完全忽略。如果你返回了一个字符串或数字,就好像完全没有 return 一样。
function Answer() {
return 42
}
Answer() // 42
new Answer() // Answer {}
当使用 new 调用函数时,是没办法读取基本类型的返回值的。因此如果 React 总是使用 new,就没办法增加对返回字符串的组件的支持!
至此清晰的一点是,React在使用类(包括Babel编译成函数)组件时使用new调用,使用常规函数或箭头函数(包括Babel编译)时直接调用。但还是没有完美的方式区分。
不要忘了,我们定义class组件时,需要继承自React.Component,以便可以共享其原型上的方法,比如 setState()、forceUpdate()、render()等。与其检查所有自定义的类,为什么不借助React.Component检查呢?
我们知道,x instanceof Y
所做的就是判断 Y.prototype
是否在x的原型链上。
比如:
class Person extends React.Component {
render() {
return <div>hello</div>
}
}
const p = new Person()
p instanceof Person // true
// p.__proto__ ---> Person.prototype
p instanceof React.Component // true
// p.__proto__.__proto__ ---> React.Component.prototype
p instanceof Object // true
// p.__proto__.__proto__.__proto__ ---> Object.prototype
p instanceof Array // false
// p.__proto__.__proto__.__proto__.__proto__ ---> null (原型链顶端)
所以基类和父类之间存在如下关系:
class A {}
class B extends A {}
// 因为
B.prototype.__proto__ === A.prototype // true
// 所以
B.prototype instanceof A // true
所以通过这种方式可以判断是class还是function。但React还不是这么判断的。
因为 instanceof
解决方案还存在不足之处,当页面上有多个 React 副本,并且我们要检查的组件继承自 另一个 React 副本的 React.Component 时,这种方法是无效的。尽管在一个项目里混合多个 React 副本是不好的,但我们不能排除这种问题的存在性。
另一点启发是去检查原型链上的 render 方法。然而,当时还不确定组件的 API 会如何演化。每一次检查都有成本,所以我们不想再多加了。如果 render 被定义为一个实例方法,例如使用类属性语法,这个方法也会失效。
因此, React 通过为基类增加一个特别的标记。React 检查是否有这个标记,以此知道某样东西是否是一个 React 组件类。
最初这个标记是在 React.Component 这个基类自己身上:
// React 内部
class Component {}
Component.isReactClass = {}
// 可以这样检查
class Greeting extends Component {}
Greeting.isReactClass // ok
然而,某些 compile-to-js 的语言,在类的实现上有所不同,子类在继承父类的时候,会丢失静态属性,导致在子类上访问不到父类的 isReactComponent,所以保险起见,React 把标记移到了 React.Component.prototype上,选择把它作为实例属性,以确保子类能够正确继承。
// React 内部
class Component {}
Component.prototype.isReactComponent = {}
class Greeting extends Component {}
Greeting.prototype.isReactComponent // ok
到这里可能还会有疑惑,isReactComponent 为什么是一个对象,而不是boolean值。这是早期版本的Jest(facebook推出的一款测试框架)是默认开始自动模拟功能的,生成的模拟数据省略掉了原始类型属性,破坏了检查。
所以我们编写的class组件一定要继承自React.Component,否则 React 不会在原型上找到 isReactComponent,因此就不会把组件当做类处理。以至于抛出这种错误 Cannot call a class as a function
最终方案很简单,就是 Component.prototype.isReactComponent = {}
,但是用了大量的篇幅来解释为什么 React 最终选择了这套方案。
以下是原文作者的感想,我觉得说的特别好。
为了一个 API 能够简单易用,你经常需要考虑语义化(可能的话,为多种语言考虑,包括未来的发展方向)、运行时性能、有或没有编译时步骤的工程效能、生态的状态以及打包方案、早期的警告,以及很多其它问题。最终的结果未必总是最优雅的,但必须要是可用的。
如果最终的 API 成功的话,它的用户 永远不必思考这一过程。他们只需要专心创建应用就好了。
本文基本是翻译原文,一些基础有自己的理解亦有删节。
网友评论