要定义一个Greeting组件,我们可以定义成一个函数。
function Greeting() {
return <p>Hello</p>;
}
React也支持定义成一个类。
class Greeting extends React.Component {
render() {
return <p>Hello</p>;
}
}
(直至目前,这是使用state特性的唯一方式)
当你要渲染一个<Greeting />
时,你不会介意它是怎么被定义的,使用起来都是下面这样:
// 无论是 Class 或者 function
<Greeting />
但是React自身是介意这中间的区别的!
如果Greeting
是一个函数,React需要调用它:
// 你的代码
function Greeting() {
return <p>Hello</p>;
}
// React内部
const result = Greeting(props); // <p>Hello</p>
但是,如果Greeting
是一个class,React需要用new运算符去实例化它,然后调用刚创建的实例的render函数。
// 你的代码
class Greeting extends React.Component {
render() {
return <p>Hello</p>;
}
}
// React内部
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>
这两种情况,React
的目的都是为了获取渲染的节点(本例中,<p>Hello</p>
)。但是具体的步骤依赖于Greeting
是怎么被定义的。
所以,React是怎么知道这个东西是一个class还是一个function?
就像我之前的博文中提到的,你不需要知道这在React中是高效的。这几年我都不知道这个,请不要将这个当成一个面试题目。事实上,这篇文章讨论JavaScript更多于React。
这篇文章是为一个好奇的读者,他很想知道React具体的工作原理。你也是这样的吗?那就让我们一起深入探讨下吧。
这是一段很长的路,先洗好安全带。这篇文章没有很多关于React自身的信息,但是我们将通过new
、this
、class
、arrow functions(箭头函数)
、prototype
、__proto__
和 instanceof
的一些方面,和这些东西是怎样在JavaScript中一起合作的。幸运的是,当你在使用React的时候,你不必思考这么多。如果你在实现React……。
(如果你真的只是想知道答案,滑动到最后。)
首先,我们需要理解为什么分别对待functions
和classes
很重要。注意一下我们在调用一个class
时是怎么使用new
运算符的:
// 如果 Greeting 是一个 function
const result = Greeting(props); // <p>Hello</p>
// 如果 Greeting 是一个 class
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>
让我们粗略地看一下new
运算符在JavaScript中都做了什么。
在以前,JavaScript还没有classes。然而,你可以使用普通的function来表示一个相似的模式。具体地,你可以使用任何function扮演一个类似class的构造函数的角色,并在它调用之前添加new
:
// 只是一个 function
function Person(name) {
this.name = name;
}
var fred = new Person('Fred'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 不工作
今天你仍然可以写这种代码!在DevTools中试一下吧。
如果你调用Person('Fred')
时没有用new
,它里面的this
会指向某个全局的东西且没有任何用处(比如,window
或undefined
)。所以我们的代码会崩溃掉,或者会做一些愚蠢的操作,比如设置window.name
。
在调用之前加一个new
,相当于我们说:“Hey JavaScript,我知道Person
只是一个function,但是让我们假装它像一个class的构造函数之类的东西。创建一个{}
对象,并且将Person
function内的this
指向这个对象,所以我可以像this.name
一样分配内容。然后将那个对象返回给我。”
这就是new
运算符做的事情。
var fred = new Person('Fred'); // Same object as `this` inside `Person`
new
运算符也让我们设置到Person.prototype
上的任何东西,都能在fred
对象上访问到:
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
alert('Hi, I am ' + this.name);
}
var fred = new Person('Fred');
fred.sayHi();
这就是在JavaScript直接添加class之前,开发者是仿制class的。
JavaScript已经支持new
有一段时间了。然而,class是最近才支持的。它们可以让我们重写上面的代码来更尽量地满足我们的要求:
class Person {
constructor(name) {
this.name = name;
}
sayHi() {
alert('Hi, I am ' + this.name);
}
}
let fred = new Person('Fred');
fred.sayHi();
在语言和API设计中,获取开发者的意图是很重要的。
如果你写一个function,JavaScript不知道这个function是否像alert()
一样被调用,或者它是否像new Person()
一样扮演一个构造函数。如果没有给像Person
这样的function指定new
的话,会引起异常的行为。
Class
语法让我们说:“这不是一个function - 它是一个class且有一个构造函数”。如果你在调用的时候忘记new
,JavaScript会提示一个异常:
let fred = new Person('Fred');
// ✅ 如果 Person 是一个 function: 工作正常
// ✅ 如果 Person 是一个 class: 也工作正常
let george = Person('George'); // 没有加 `new`
// 😳 如果 Person 是一个像function的构造函数: 混乱的行为
// 🔴 如果 Person 是一个 class: 立即失败
这帮助我们提前捕获错误,而不是等像this.name
这种被当成window.name
而不是george.name
隐晦的bug出现。
然而,这意味着React需要在调用任何class之前放一个new
。它不能像普通function一样被调用,因为JavaScript会将它看成一个异常。
class Counter extends React.Component {
render() {
return <p>Hello</p>;
}
}
// 🔴 React can't just do this:
const instance = Counter(props);
这就麻烦了。
在我们看React怎么解决这个之前,有一点很重要,就是大多数开发者在使用React的时候,也使用像Babel这样的编辑器将像class这样现代化的特性编译成老浏览器可以使用。所以我们需要在设计的时候考虑这些编译器。
在Babel早期的版本,class被调用的时候可以不用new
。然而,这个问题是通过产生更多的其他代码来解决的:
function Person(name) {
// Babel output 的一个小简化版本
if (!(this instanceof Person)) {
throw new TypeError("Cannot call a class as a function");
}
// 我们的代码
this.name = name;
}
new Person('Fred'); // ✅ Okay
Person('George'); // 🔴 不能把class当成一个function调用
你可能已经在你的代码片段中看到这种代码。这就是那些_classCallCheck
函数做的事情。(你可以通过配置“loose mode”不做检查来减少代码块的大小,但是这样可能会使你的代码转换成原生class复杂化。)
现在,你可以大致上理解一下调用时使用new
还不使用new
时的区别:
new Person() | Person() | |
---|---|---|
class | ✅ this 是一个Person 实例 | 🔴 TypeError |
function | ✅ this 是一个Person 实例 | 😳 this 是window 或undefined |
这也是为什么对React来说正确的调用你的组件很重要的原因。如果你的组件是用class定义的,React在调用的时候需要用new
。
所以,React只需要检查一下组件是class还是function就行了吗?
没有那么容易!即使我们从JavaScript中判断出是一个class,仍然不能跟像使用Babel处理过的class正常工作。对浏览器来说,它们都是普通的函数。真是React的不幸啊。
所以,React可以再每次调用时都使用new
吗?不幸的是,这也不是每次都正常工作。
对于一般的function来说,用new
调用它们时会给它们一个对象实例当作this
。虽然将functions重写成构造函数(像上面讲的Person
),但是它对function组件来说是令人疑惑的:
function Greeting() {
// 我们不会在这里期望`this`指向任何类型的实例
return <p>Hello</p>;
}
然而,这些都是可以忍受的,有两个其他的原因放弃这个想法。
使用new
不能正常工作的第一个原因是,对于原生箭头函数(不是被Babel编译之后的),用new
调用的时候会抛一个异常:
const Greeting = () => <p>Hello</p>;
new Greeting(); // 🔴 Greeting is not a constructor
这个行为是故意遵循箭头函数的设计,箭头函数主要的一点是箭头函数没有属于自己的this
值,这个问题可以从一般函数来解决:
class Friends extends React.Component {
render() {
const friends = this.props.friends;
return friends.map(friend =>
<Friend
// 从`render`函数中解决`this`
size={this.props.size}
name={friend.name}
key={friend.id}
/>
);
}
}
OK,所以箭头函数没有他们自己的this
,这意味着他们完全不能被用作构造函数。
const Person = (name) => {
// 🔴 这没有作用
this.name = name;
}
所以,JavaScript不允许用new
调用一个箭头函数,如果你这样做了,你肯定已经犯错了,还是最好早点告诉你这个。这就像为什么JavaScript不让你在调用class的时候漏掉new
。
这一点虽然很好,但是让我们很受挫。React不能在所有的东西前面都加new
,因为它在箭头函数里面不能用!我们尝试着发现箭头函数没有prototype
,且不能只是new
它们:
(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}
但是这个对由Babel编译的函数不起作用。这或许不是一个大问题,但是还有另一个原因让这个走进一个死胡同。
我们不能一直使用new
的另一个原因,是它会阻碍React支持那些返回字符串或原生类型。
function Greeting() {
return 'Hello';
}
Greeting(); // ✅ 'Hello'
new Greeting(); // 😳 Greeting {}
这次又要必须面对new
操作符设计的怪癖了。就像我们前面见到的,new
会告诉JavaScript引擎去创建一个对象,并在function里面将this
指向那个对象,然后将new
出来的对象作为结果返回给我们。
然而,JavaScript也允许一个函数被调用时使用new
来重写,返回一些其他的对象。大概,对于像在我们想要集中复用示例的模式来说,是有用的:
// 懒创建
var zeroVector = null;
function Vector(x, y) {
if (x === 0 && y === 0) {
if (zeroVector !== null) {
// 复用同一个示例
return zeroVector;
}
zeroVector = this;
}
this.x = x;
this.y = y;
}
var a = new Vector(1, 1);
var b = new Vector(0, 0);
var c = new Vector(0, 0); // 😲 b === c
然而,如果一个function的返回值不是一个对象,new
也完全会忽略这个返回值。如果你返回的是一个字符串或数字,就相当于没有任何返回值。
function Answer() {
return 42;
}
Answer(); // ✅ 42
new Answer(); // 😳 Answer {}
当调用一个function时使用了new
,是没有办法从它的返回值里取到一个原生值(像数字或字符串)的。所以,如果React一直使用new
,将不能支持那些返回字符串的组件。
这是无法接受的,所以我们做了妥协。
到现在为止,我们学到了什么呢?
React在调用class(包括Babel输出的)时,需要使用new
;但是,在调用普通函数或箭头函数(包括Babel输出的)时,不需要使用new
。并且,没有可靠的方法来区分它们。
如果我们不能解决一个普遍的问题,又怎么能解决一个更特殊的呢?
当你将一个组件定义成一个class时,你将要继承自React.Component
,为了一些内置的函数,比如this.setState()
。与其尝试查明所有的class,还不如看看我们是否可以只查明React.Component
的后代呢?
剧透:这就是React真正做的事情。
也许,检查Greeting
是否是一个React组件class的符合语言习惯的方式,是测试是不是Greeting.prototype instanceof React.Component
:
class A {}
class B extends A {}
console.log(B.prototype instanceof A); // true
我知道你在想什么。这里刚才发生了什么?要回答这个问题,我们需要理解JavaScript的原型。
你应该了解“原型链”。JavaScript中每一个对象都应该有一个原型(prototype
)。当我们写fred.sayHi()
,但是fred
对象没有sayHi
属性,我们会从fred
的原型上去找sayHi
属性。如果我们没有找到它,我们会从原型链上的下一个原型(fred
原型的原型)上找,等等。
令人疑惑的是,一个class或function的prototype
属性不指向它们原型的值,不是开玩笑的。
function Person() {}
console.log(Person.prototype); // 🤪 不是Person的原型
console.log(Person.__proto__); // 😳 Person的原型
所以,“原型链”更像是__proto__.__proto__.__proto__
这样的,而不是prototype.prototype.prototype
,这让我花了好几年才明白。
一个function或class的prototype
属性是什么?它是所有用new
创建的class或function都有的__proto__
属性。
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
alert('Hi, I am ' + this.name);
}
var fred = new Person('Fred'); // Sets `fred.__proto__` to `Person.prototype`
并且,__proto__
链就是JavaScript怎么寻找属性的方式:
fred.sayHi();
// 1. fred有sayHi属性吗? 没有.
// 2. fred.__proto__ 有sayHi属性吗? 有,调用它
fred.toString();
// 1. fred 有toString属性吗? 没有.
// 2. fred.__proto__有toString属性吗? 没有.
// 3. fred.__proto__.__proto__有toString属性吗? 有,调用它
在实践中,你在代码中应该几乎不需要直接接触__proto__
,除非你在调试与原型链相关的东西。如果你想在fred.__proto__
上面添加一些东西,你应该将它放到Person.prototype
上面。至少它就是这样设计的。
因为原型链被当作一个内部概念,所以__proto__
属性不应该首先暴露给浏览器。但是一些浏览器添加了__proto__
,最终不得不被标准化了(单不能取代Object.getPrototypeOf()
)。
可是我仍然发现很困惑的地方,一个原型的属性不会给你一个值的原型(比如,fred.prototype
是undefined
,因为fred
不是一个function)。个人觉得,这是即使有经验的开发者仍不理解JavaScript原型最大的原因。
这是一个很长的文章,嗯?我想说我们已经到了80%了,再坚持一下。
我们知道,当说obj.foo
时,JavaScript实际上会从obj
,obj.__proto__
,obj.__proto__.__proto__
,等等上查找foo
。
在class中,不能直接暴露这个机制,但是继承
也是工作在旧的原型链上面的。这就是我们React类的实例是怎么获取到函数的权限的,比如setState
:
class Greeting extends React.Component {
render() {
return <p>Hello</p>;
}
}
let c = new Greeting();
console.log(c.__proto__); // Greeting.prototype
console.log(c.__proto__.__proto__); // React.Component.prototype
console.log(c.__proto__.__proto__.__proto__); // Object.prototype
c.render(); // Found on c.__proto__ (Greeting.prototype)
c.setState(); // Found on c.__proto__.__proto__ (React.Component.prototype)
c.toString(); // Found on c.__proto__.__proto__.__proto__ (Object.prototype)
换句话说,当你使用class时,一个实例的__proto__
链“映射”class的层级:
// `extends` chain
Greeting
→ React.Component
→ Object (implicitly)
// `__proto__` chain
new Greeting()
→ Greeting.prototype
→ React.Component.prototype
→ Object.prototype
两个链式反应。
既然__proto__
链映射class的层级,那么通过Greeting.prototype
,我们可以检查一个Greeting
是否继承自React.Component
,然后顺着它的__proto__
链向下:
// `__proto__` chain
new Greeting()
→ Greeting.prototype // 🕵️ 我们从这里开始
→ React.Component.prototype // ✅ 找到它了!
→ Object.prototype
很方便地就可以发现,x instanceof Y
就确切是这一类的查找。它顺着x.__proto__
链去寻找Y.prototype
的地方。
通常,这就被用来确定一个东西是不是一个class的实例:
let greeting = new Greeting();
console.log(greeting instanceof Greeting); // true
// greeting (🕵️ 我们从这里开始)
// .__proto__ → Greeting.prototype (✅ 找到它了!)
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype
console.log(greeting instanceof React.Component); // true
// greeting (🕵️ 我们从这里开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype (✅ 找到它了!)
// .__proto__ → Object.prototype
console.log(greeting instanceof Object); // true
// greeting (🕵️ 我们从这里开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype (✅ 找到它了!)
console.log(greeting instanceof Banana); // false
// greeting (🕵️ 我们从这里开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype (🙅 没找到它!)
但是,它只会在确定一个class是否继承自另一个class时才能工作正常。
console.log(Greeting.prototype instanceof React.Component);
// greeting
// .__proto__ → Greeting.prototype (🕵️ 我们从这里开始)
// .__proto__ → React.Component.prototype (✅ 找到它了!)
// .__proto__ → Object.prototype
并且,这种检查也是我们怎么确定一些东西是一个React组件或是一个有规律的function。
但是,React不是这样做的。 😳
使用instanceof的方案有一个警告,当一个页面有多个React的拷贝时不起作用,并且其他React拷贝的React.Component同样不起作用。在单个项目内将多个React的拷贝混合到一起是不好的,不好的原因有很多,但是我们已经尝试了尽可能避免这个问题。(对于Hooks
,我们应该需要强制去重。)
另一个有可能启发的,能够检查原型上一个render
函数是否存在。然而,现在还不能清晰地知道组件API可以怎么进化。每一个检查都有成本,所以我们不想添加多个。如果render
被定义成一个实例函数,仍然不起作用,比如class属性语法。
所以,React给基础组件添加了一个特殊的标识
,React检查这个标识
是否存在,且这就是怎么知道一个东西是不是一个React组件。
起初,基础的React.Component
class自身上的标识:
// React内部
class Component {}
Component.isReactClass = {};
// 我们可以这样检查
class Greeting extends Component {}
console.log(Greeting.isReactClass); // ✅ Yes
然而,一些我们想作为目标的class实现没有拷贝静态函数(或设置了非标准的__proto__
),所以这个标识
丢失了。
这就是为什么React将这个标识
移到React.Component.prototype
:
// React里面
class Component {}
Component.prototype.isReactComponent = {};
// 我们可以这样检查
class Greeting extends Component {}
console.log(Greeting.prototype.isReactComponent); // ✅ Yes
这就是它的全部。
你可能想知道为什么这是一个对象而不是一个布尔值?在实践中没有多大关系,但是早期版本的Jest
(Jest
之前叫Good™️
)已经默认开启自动模拟。生成的模拟数据遗漏了原生属性,中断了检查。多谢Jest
。
这个isReactComponent
检查在React中被使用至今。现在你知道
如果你没有继承React.Component
,React不能从原型中发现isReactComponent
,不能将组件当做一个class。现在你知道为什么“Cannot call a class as a function error(不能将一个class按照function调用)”最高赞的回答是“添加继承自React.Component
”。最后,当存在prototype.render
存在,而prototype.isReactComponent
不存在的时候,会抛出一个警告。
你可能会说,这个故事有一点引诱骗人。实际的解决方案真的是很简单,但是我花了很大的篇幅解释“React为什么最终选择了这个方案,也选择了这个方案”。
从我的经验来看,库的API
经常会遇到这种场景。想要一个API
简单好用,你经常需要考虑语言的语义(可能对于一些语言,还包括未来的方向),运行时性能,包含和不包含编译时间步骤的工程学,生态系统和包解决方案的状态,早期预警,和很多其它的东西。最终的结果可能不总是最完美的,但确实实用的。
如果最终API
是成功的,它的用户永远不用考虑这个过程。相反,他们可以专注于创建应用。
但是如果你还好奇,了解它是如何工作的还是很不错的。