写在前边
文章中涉及到的知识都是渐进式的讲解开发,当然如果对之间内容不感兴趣(已经了解),也可以直接切入本文内容,每一个章节都和之前不会有很强的耦合。
文章中涉及的代码地址, 戳这里👇查看。
文章中的内容会分为三个步骤:
- 实现
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中的机制去尝试解答这个问题。