文章中涉及到的知识都是渐进式的讲解开发,当然如果对之间内容不感兴趣(已经了解),也可以直接切入本文内容,每一个章节都和之前不会有很强的耦合。
文章中的内容会分为三个步骤:
React
中原生DOM
元素的Ref
--- 获取DOM
节点。React
中Class Component
的Ref
--- 获取Class Component
实例。React
中Function Component
的Ref
--- forwordRef
。Dom
的ref
众所周知在React
中如果想要获取原生Dom
节点的实例的话是需要通过Ref
来获取的。
我们先看看看它是如何使用的:
class ClassComponent extends React.Component {
constructor() {
super();
this.refInputPrefix = React.createRef();
this.refInputSuffix = React.createRef();
this.ref = React.createRef();
}
handleClick = () => {
const prefix = this.refInputPrefix.current.value;
const suffix = this.refInputSuffix.current.value;
const result = parseInt(prefix) + parseInt(suffix);
this.ref.current.value = result;
};
render() {
return (
<div>
<input ref={this.refInputPrefix}></input>
<input ref={this.refInputSuffix}></input>
<button onClick={this.handleClick}>点击计算结果</button>
<input ref={this.ref}></input>
</div>
);
}
}
const element = <ClassComponent></ClassComponent>;
ReactDOM.render(element, document.getElementById('root'));
复制代码
我们通过React.createRef()
方法创建了一个具有current
属性的ref
对象,然后在jsx
模板上通过ref={ref}
赋值给Dom
节点,接下来就可以通过this.[ref...].current
获取到对应的Dom
元素了。
Dom
节点的ref
获取的是页面上渲染真实的Dom
元素节点。
明白了用法之后我们来实现一下这个api
,其实他的实现非常简单。(当然推荐稍微去了解一下文章中的前置知识,当然如果对文章中之前的代码有不明白的地方再去查阅之前的相关文章也是可以的~)
首先,我们明白在class
组件中要使用ref
的话需要通过React.createRef()
来创建一个ref
对象挂载在this
上。
那么我们就先从crateRef
下手,我们在之前的React.js
同级别创建一个ref.js
:
export function createRef() {
return { current: null };
}
复制代码
它的实现非常简单,就是返回一个空的引用对象,拥有一个current
属性。
import { createRef } from './ref';
const React = {
// 通过createElement将jsx转化成为vdom
createElement(type, config, children) {
let ref; // 额外定义Ref属性
if (config) {
// 他们并不属于props
ref = config.ref;
}
const props = {
...config,
};
if (arguments.length > 3) {
props.children = Array.prototype.slice
.call(arguments, 2)
.map((i) => wrapTextNode(i));
} else {
props.children = wrapTextNode(children);
}
return {
type,
props,
ref,
};
},
// 引入
createRef
};
复制代码
接着我们在同级的React.js
中引入这个方法。
接下来我们看看babel中针对jsx
的ref
会编译成为什么样子:
我们可以看到其实针对jsx
转译后的vDom
元素,传入的ref
是会保存在vDom
的props
上的,接下来我们来改造一下React.js
中的createElement
方法:
...
createElement(type, config, children) {
let ref; // 额外定义Ref属性
if (config) {
// 他们并不属于props
ref = config.ref;
}
const props = {
...config,
};
if (arguments.length > 3) {
props.children = Array.prototype.slice
.call(arguments, 2)
.map((i) => wrapTextNode(i));
} else {
props.children = wrapTextNode(children);
}
return {
type,
props,
ref,
};
},
复制代码
我们将React.createElement
方法中针对于ref
属性做了单独的处理,最终返回的对象上和type
、props
同级存在一个ref
。
(此时这个ref
其实就是我们传入的React.createRef() => { current:null }
这个对象)
相信上边的代码并不是很难理解,接下来我们已经在React.createElement()
方法之后返回了一个vDom
对象,并且给这个vDom
对象上增加了一个{current:null}
的Ref
对象。
想一想我们需要最终实现的结果: 将**{ current:null }
**这个对象的**current
**属性指向对应**vDom
**渲染出来的真实**Dom
**节点。
这时我们想到之前在实现setState
时,我们在createDom
方法中,给每一个vDom
渲染时都添加了一个dom
属性指向真实的Dom
节点。
那不难想到,
vDom
渲染成为dom
时,我们传入了React.createElement
方法返回的vDom
对象.vDom
对象,拥有props
,type
,ref
这三个属性。ref
是一个object
,它是一个引用类型。vDom
渲染成为真实Dom
的过程中,只需要将{ current:null }
中的current
属性指向对应生成的真实Dom
节点。顺着上边的思路我们来捋一捋代码应该如何实现:
jsx
中传入ref
的属性,值为{ current:null }
jsx
元素通过babel
转译成为React.createElement(...)
React.createElement(...)
返回一个vDom
对象,{ ref:..., props:..., type:... }
createDom(vDom)
传入vDom
将vDom
渲染成为真实Dom
元素后,我们修改传入的ref.current
的指向为真实的Dom
元素。React.creatRef
返回的的{ current:null }
已经变成{ current: [Dom] }
this.xxx
拿到真实Dom
元素.// react-dom.js
...
// 将vDom转化成为真实Dom
// 将vDom转化成为真实Dom
function createDom(vDom) {
const { type, props, ref } = vDom;
let dom;
if (type == REACT_TEXT) {
dom = document.createTextNode(props.content);
} else if (isPlainFunction(type)) {
if (isClassComponent(type)) {
return mountClassComponent(vDom);
} else {
return mountFunctionComponent(vDom);
}
} else {
dom = document.createElement(type);
}
// 更新props
if (props) {
updateProps(dom, {}, props);
// 更新children
if (props.children) {
// 更新递归调用children
if (Array.isArray(props.children)) {
reconcileChildren(props.children, dom);
} else {
render(props.children, dom);
}
}
}
// 虚拟DOM上的dom属性指向真实dom 这里只有renderVDom才会挂载dom
vDom.dom = dom;
// 赋值Ref 属性上存在ref,那么在每次创建完成真实DOM后,将对应真实Dom元素赋值给ref.current
if (ref) {
ref.current = dom;
}
return dom;
}
...
复制代码
createDom
方法中判断如果传入了ref
的话,那么就将ref.current = dom
。
这样对于普通Dom
元素的ref
属性已经实现了,其实它很简单。就是利用了对象的引用地址,修改对象的属性值从而达到实例中可以访问到对应的dom
元素。
class
组件的ref
上边我们已经实现了Dom
的ref
,那么实现class component
的ref
就更加简单了~
老样子,我们先来看看针对class component
中的ref
是什么:
// 类组件的ref实现
class ChildrenComponent extends React.Component {
constructor() {
super();
this.inputRef = React.createRef();
}
handleFocus = () => {
this.inputRef.current.focus();
};
render() {
return <input ref={this.inputRef}>children</input>;
}
}
class ClassComponent extends React.Component {
constructor() {
super();
this.childrenCmp = React.createRef();
}
handleClick = () => {
this.childrenCmp.current.handleFocus();
};
render() {
return (
<div>
<ChildrenComponent ref={this.childrenCmp}></ChildrenComponent>
<button onClick={this.handleClick}>聚焦儿子节点input</button>
</div>
);
}
}
const element = <ClassComponent></ClassComponent>;
ReactDOM.render(element, document.getElementById('root'));
复制代码
运行这段代码,当我们点击按钮的时候ChildComponent
中的input
会被聚焦。
看到这里,也许你已经明白了: React
**中通过类组件上的**ref
**属性,可以获取对应的类组件实例**。
从而可以通过这个ref
获得的类组件实例调用类组件上的实例方法。
写到这里,上边我们实现Dom
的ref api
时,是通过createDom
方法在将vDom
生成真实Dom
后给ref
对应赋值就达到了效果。
那么这里我们不禁想到,如果针对于class component
它的ref
指向它的实例的话,那么我们在将Class Component
时将ref.current
指向对应的类组件实例是不是也就可以了?
如果你是这样想的话,那么我告诉你。没错~有了上边的基础我们再来实现类组件的ref
就会很简单了。
我们来看看相关的react-dom.js
:
// 将vDom转化成为真实Dom
function createDom(vDom) {
const { type, props, ref } = vDom;
let dom;
if (type == REACT_TEXT) {
dom = document.createTextNode(props.content);
} else if (isPlainFunction(type)) {
if (isClassComponent(type)) {
return mountClassComponent(vDom);
} else {
return mountFunctionComponent(vDom);
}
} else {
dom = document.createElement(type);
}
// 更新props
if (props) {
updateProps(dom, {}, props);
// 更新children
if (props.children) {
// 更新递归调用children
if (Array.isArray(props.children)) {
reconcileChildren(props.children, dom);
} else {
render(props.children, dom);
}
}
}
// 虚拟DOM上的dom属性指向真实dom 这里只有renderVDom才会挂载dom
vDom.dom = dom;
// 赋值Ref 属性上存在ref,那么在每次创建完成真实DOM后,将对应真实Dom元素赋值给ref.current
if (ref) {
ref.current = dom;
}
return dom;
}
复制代码
在createDom
方法中,当我们碰到class Component
时,直接进入return mountFunctionComponent(vDom)
这个分支语句。
我们来在mountFunctionComponent(vDom)
这个方法上稍稍做一些改动就可以了:
// 挂载ClassComponent
function mountClassComponent(vDom) {
// 这里应该可以拿到ref 类组件的ref是类的实例对象
const { type, props, ref } = vDom;
const instance = new type(props);
if (ref) {
// 如果ref属性存在 类的实例赋值给ref.current
ref.current = instance;
}
const renderVDom = instance.render();
// 考虑根节点是class组件 所以 vDom.oldRenderVDom = renderVDom
instance.oldRenderVDom = vDom.oldRenderVDom = renderVDom; // 挂载时候给类实例对象上挂载当前RenderVDom
return createDom(renderVDom);
}
复制代码
在我们初始化类组件实例之后,我们只需要将生成的类组件实例instance
赋值给ref.current
属性。
在外层类组件的实例上就可以通过this.[xxx]
放到到对应的ref
对象,然后通过this.[xxx].current
就可以访问到对应的类组件实例从而调用对应实例的方法。
Function Component
的ref
在React
中,我们清楚Function Component
中是没有ref
的,如果直接给FC
组件上使用ref
的话,你会获得这样的一段警告:
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
也就是说Function Component
是不允许使用ref
的,结合上边的结论我们来想一想。
Dom
**节点的**ref
**属性可以指向对应的**dom
**保存在**class
**的实例上class
**组件同样可以通过**ref
**获得对应的势力对象保存在对应的父组件实例上作为属性调用**结合上边这两个结论其实我们不难理解为什么FC
是允许拥有Ref
属性:
函数组件并没有实例,也就是说每次运行结束函数也就会销毁,不会返回任何实例,自然而然,函数组件根节点并不会渲染成为真实dom
元素所以它无法和原生dom
保持一致,同时我们也就无法通过ref
获取函数组件的实例。
此时我们如果想要给函数组件使用ref
怎么办呢? 相信一部分同学已经使用过了forwardRef
这个api
。它的含义是做一层转发。
Ref forwarding 是一种通过组件自动将ref传递给其子组件的技术。对于应用程序中的大多数组件,这通常不是必需的。但是,它对某些类型的组件很有用,尤其是在可重用的组件库中
具体他的实用很简单,就是通过一层转发。给函数组件传递ref
,函数内部接受这个ref
参数然后通过Ref
来转发到其他元素上使用。
// 函数组件的Ref
const Child = React.forwardRef(function (props, ref) {
return <input ref={ref}>{props.name}</input>;
});
class Parent extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
handleClick = () => {
this.ref.current.focus();
};
render() {
return (
<div>
{/* 类组件 上 存在 ref 和 name */}
<Child ref={this.ref} name="wang.haoyu">
类组件
</Child>
<button onClick={this.handleClick}>点击聚焦</button>
</div>
);
}
}
const element = <Parent></Parent>;
ReactDOM.render(element, document.getElementById('root'));
复制代码
此时我们点击button
时,函数组件内部的input
会聚焦。
也就是我们通过forwardRef
将传递给函数组件的ref
转发给了对应的input
组件。
上边我们说到了FC
中其实是没有实例的概念的,所以我们要实现FC
中的ref
首先就需要实现对应的forwardRef
。
针对FC
中的FC
,React
内部是这样做的,通过forwardRef
这个Api
传入一个函数组件,将传入的函数组件通过forwardRef
包裹成为一个类组件。然后返回这个类组件,这样的话在进行渲染的时候forwardRef
其实返回了一个类组件的实例,这样就可以通过ref
来实现转发了。
我们先来看看关于forwardRef
实现的代码吧:
// react.js
import { wrapTextNode } from '../utils/index';
import Component from './component';
import { createRef } from './ref';
const React = {
// 通过createElement将jsx转化成为vdom
createElement(type, config, children) {
let ref; // 额外定义Ref属性
if (config) {
// 他们并不属于props
ref = config.ref;
}
const props = {
...config,
};
if (arguments.length > 3) {
props.children = Array.prototype.slice
.call(arguments, 2)
.map((i) => wrapTextNode(i));
} else {
props.children = wrapTextNode(children);
}
return {
type,
props,
ref,
};
},
// FunctionComponent的ref转发
forwardRef(functionComponent) {
return class extends Component {
render() {
return functionComponent(this.props, this.props.ref);
}
};
},
createRef,
// 类组件
Component,
};
export default React;
复制代码
我们看到其实forwardRef
这里实现的很简单,类似HOC
,接受了一个函数组件作为参数返回一个类组件。
在类组件的render
方法中返回这个函数组件的调用返回对应函数组件的jsx
返回值,同时传入对应的props
和props.ref
这个对象。
稍微来梳理一下这个流程:
当我们通过forwardRef
包裹一个函数组件,外层给这个forwardRef
返回的组件传入ref
:
const Child = function (props, ref) {
return <input ref={ref}>{props.name}</input>;
};
class Parent extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
handleClick = () => {
this.ref.current.focus();
};
render() {
return (
<div>
{/* 类组件 上 存在 ref 和 name */}
<Child ref={this.ref} name="wang.haoyu">
类组件
</Child>
<button onClick={this.handleClick}>点击聚焦</button>
</div>
);
}
}
复制代码
我们给Child
这个forwardRef
包裹的组件传入了props
中name='wang.haoyu
,ref={ current:null }
。
此时我们通过forwardRef
返回的是一个类组件,这个类组件转化为vDom
时,props
为
{
name:'wang.haoyu',
ref: { current:null }
}
复制代码
在类组件中,在createDom
方法中我们创建了这个类组件的实例并且传入了对应的props
。
然后我们通过类组件的render
方法返回了一个函数调用结果,这个函数传递了两个参数分别是this.props
和this.props.ref => { current:null }
。
接下来在我们的函数组件内部:
const Child = function (props, ref) {
return <input ref={ref}>{props.name}</input>;
};
复制代码
我们使用了传入的这个ref
对象,然后input
元素在渲染是调用了createDom
方法重新修改了这个ref.current
的指向,让他的current
指向为input
元素的真实Dom
节点。
这样在外层的Parent
上我们就可以通过this.ref.current
获得对应Child
组件的input
这个真实DOM
元素,从而实现函数组件的ref
转发效果了。
此时此刻,我们三种类型的ref
都已经基本实现了,可能一次性看下来多多少少会有些不太理解。
没关系,针对源码的学习路程总是陡峭而循序渐进的,多看几遍尝试自己跟着demo
试一下。我相信你可以的!
文章中所有的代码和源码是有出入的,因为真实的源码会比文章中多处很多分支一下子拉出来看会有很多疑问。所以文章中进行了精简拿出核心的思路和最简单的方式实现源码中的思想和核心原理。
其实这里我一直存在一个疑问,如果说forwardRef
本质上我理解是利用{ current:null }
修改current
的指向从而达到转发ref
的话。
那么为什么不直接在挂载函数组件时直接让所有函数组件支持第二个参数为传入的ref
,这样就完全不需要源码中的操作了。
本地代码中我尝试了直接修改成为这个样子,实际上也是可以直接实现函数组件的ref
转发而完全不需要forwardRef
这个api
。
// react-dom.js
// 挂载FunctionComponent
function mountFunctionComponent(vDom) {
const { type, props, ref } = vDom;
// 这里存在
const renderDom = type(props, ref);
// 考虑根节点是FunctionComponent
vDom.oldRenderVDom = renderDom;
const dom = createDom(renderDom);
return dom;
}
复制代码
这样修改之后,此时每次函数组件的挂载过程都会检测是否传入了ref
属性。
如果传入也会修改同步调用函数传入第二个参数ref
,我们只要在函数组件中修改ref.current
的指向,外层通过传入的ref
不也可以达到转发的效果吗?
当然,这之后我会继续去深入react
中的机制去尝试解答这个问题。