buxuku / react-handwritten

手写实现React
0 stars 1 forks source link

6.组件的ref, createRef, forwardRef #6

Open buxuku opened 3 years ago

buxuku commented 3 years ago

在React里面,我们处理的都是虚拟Dom,为了能够操作真实Dom,React提供了ref这个API.通过调用ref就能获取到真实Dom并对其进行操作.

绑定Ref有三种情况

对于创建ref,React提供了一个createRef方法,其生成的就是一个{current: null}的对象,在组件渲染完成之后,把真实的Dom挂载到这个current上面的.

src/react/index.js里面新增一个createRef的API.

+const createRef = () => {
+    return {current: null}
+}

const React = {
    createElement,
    Component,
+    createRef
}

原生HTML标签绑定ref

假如有这样一个组件,实现两个数相加,得出之和:

class Sum extends React.Component{
    constructor(props) {
        super(props);
        this.number1 = React.createRef();
        this.number2 = React.createRef();
        this.result = React.createRef();
    }
    handleSum = () => {
        this.result.current.value = parseFloat(this.number1.current.value) + parseFloat(this.number2.current.value);
    }
    render(){
        return (
            <div>
                <input ref={this.number1} /> + <input ref={this.number2}/> = <input ref={this.result}/>
                <p>
                    <button onClick={this.handleSum}>Sum</button>
                </p>
            </div>
        )
    }
}

实现的思路就是,取出ref属性,在createDom里面生成真实Dom的时候,将这个真实Dom挂载到它的current上面.

src/react/index.jscreateElement方法中,取出ref属性

const createElement = (type, config = {}, ...children) => {
+    const { ref, ...props } = config;
    if (children.length) {
        props.children = children.length > 1 ? children : children[0];
    }
    return {
        $$typeof: Symbol.for('react.element'),
        type,
        props,
        key: null,
-        ref: null,
+        ref,
    }
}

src/react-dom/index.js里面的createDom方法中新增挂载操作

function createDom(vdom) {
    let dom;
    if (typeof vdom !== 'object') { // render可以直接渲染一个字符串或者数字,它不是一个React.element
        dom = document.createTextNode(vdom);
        return dom;
    }
-   const {type, props} = vdom;
+   const {type, props, ref} = vdom;
    if (typeof type === 'string') {
        dom = document.createElement(type);
        renderAttributes(dom, props);
    }
    if (typeof type === 'function') {
        if (type.isReactComponent) { // 是一个类组件
            return mountClassComponent(vdom);
        }
        let renderVdom = type(props);
        vdom.oldVdom = renderVdom;
        // 让type执行,返回虚拟DOM,继续处理返回的虚拟DOM
        return createDom(renderVdom);
    }
    if (props) {
        const {children} = props;
        if (Array.isArray(children)) {
            reconcileChildren(children, dom);
        } else {
            render(children, dom);
        }
    }
    vdom.dom = dom;
+    if(ref){
+        ref.current = dom;
+    }
    return dom;
}

这样我们前面的Sum组件就可以正常操作真实的Dom了.

类组件绑定ref

类组件上面绑定的ref指向的是这个类的实例,可以调用这个实例上面的方法,这对于我们在父组件想调用子组件里面的方法是非常不错的一个选择.

把实例绑定到ref上面,那就是在生成实例的时候进行这样的一个绑定就可以了.

修改src/react-dom/index.js里面的mountClassComponent方法

function mountClassComponent(vdom) {
-   const {type, props} = vdom;
+   const {type, props, ref} = vdom;
    const classInstance = new type(props);
    const classInstanceVdom = classInstance.render();
    classInstance.oldVdom = classInstanceVdom; // 将虚拟dom挂载到当前组件实例上面.接下来的真实dom会挂到classInstanceVdom和classInstance.oldVdom上面;
+    if(ref){
+        ref.current = classInstance;
+    }
    return createDom(classInstanceVdom);
}

把前面的Sum组件拆分一下,试试看效果.

class Sum extends React.Component {
    constructor(props) {
        super(props);
        this.result = React.createRef();
        this.numbers = React.createRef();
    }

    handleSum = () => {
        this.result.current.value = this.numbers.current.getResult();
    }

    render() {
        return (
            <div>
                <Numbers ref={this.numbers}/> <input ref={this.result}/>
                <p>
                    <button onClick={this.handleSum}>Sum</button>
                </p>
            </div>
        )
    }
}

class Numbers extends React.Component {
    constructor(props) {
        super(props);
        this.number1 = React.createRef();
        this.number2 = React.createRef();
    }

    getResult = () => {
        return parseFloat(this.number1.current.value) + parseFloat(this.number2.current.value);
    }

    render() {
        return <p><input ref={this.number1}/> + <input ref={this.number2}/> = </p>
    }
}

函数组件

对于函数组件,因为它没有实例,所以不能进行ref的绑定,在React中如果对其进行绑定,则会抛出一个错误:

Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

可以看到,想要对函数组件绑定ref,需要通过forwardRef这个API来实现.它入参是一个函数组件,返回的是一个支持ref的组件,是不是有一点高阶组件的感觉了?既然函数组件不能绑定ref,那么就通过这个API把它给转成类组件来支持ref怎么样?但通过我们上面的步骤我们知道,类组件的ref指向的是类的实例,而在函数组件里面,我们的ref是绑定在return里面的元素上面的.所以包装成类组件是不行的.那么怎么来包装一层呢?既然它返回的是别外一个组件,那其实返回的也就是一个由createElement创建出来的虚拟Dom类似.我们返回一个拥有特殊标识的的虚拟DOM,就像createElement会返回一个{$$typeof: Symbol.for('react.element')}的标识一样.

src/react/index.js新增forwardRef这个API.render参数就是我们的函数组件.

/**
 * 返回一个指定标识符的虚拟Dom
 * @param render
 * @returns {{$$typeof: string, render}}
 */
function forwardRef(render) {
    return {
        $$typeof: 'REACT_FORWARD_COMPONENT',
        render,
    }
}

然后在src/react-dom/index.js里面的createDom来处理一下这个特殊标识.

function createDom(vdom) {
    let dom;
    if (typeof vdom !== 'object') { // render可以直接渲染一个字符串或者数字,它不是一个React.element
        dom = document.createTextNode(vdom);
        return dom;
    }
    const {type, props, ref} = vdom;
+    if(type && type.$$typeof === 'REACT_FORWARD_COMPONENT'){
+        return mountForwardComponent(vdom);
+    }
    if (typeof type === 'string') {
        dom = document.createElement(type);
        renderAttributes(dom, props);
    }
    if (typeof type === 'function') {
        if (type.isReactComponent) { // 是一个类组件
            return mountClassComponent(vdom);
        }
        let renderVdom = type(props);
        vdom.oldVdom = renderVdom;
        // 让type执行,返回虚拟DOM,继续处理返回的虚拟DOM
        return createDom(renderVdom);
    }
    if (props) {
        const {children} = props;
        if (Array.isArray(children)) {
            reconcileChildren(children, dom);
        } else {
            render(children, dom);
        }
    }
    vdom.dom = dom;
    if(ref){
        ref.current = dom;
    }
    return dom;

并在这个文件里面实现mountForwardComponennt这个方法,

/**
 * 获取forwardRef包装过的组件的虚拟Dom
 * @param vdom
 * @returns {Text|*|Text|HTMLElement|HTMLElement}
 */
function mountForwardComponent(vdom){
    const {type, props, ref} = vdom;
    const renderVdom = type.render(props, ref);
    vdom.oldVdom = renderVdom;
    return createDom(renderVdom);
}

接下来继续拆分上面的Sum组件如下,来测试一下函数组件的ref

class Sum extends React.Component {
    constructor(props) {
        super(props);
        this.result = React.createRef();
        this.numbers = React.createRef();
    }

    handleSum = () => {
        this.result.current.value = this.numbers.current.getResult();
    }

    render() {
        return (
            <div>
                <Numbers ref={this.numbers}/>
                <WrapperResult ref={this.result}/>
                <p>
                    <button onClick={this.handleSum}>Sum</button>
                </p>
            </div>
        )
    }
}

class Numbers extends React.Component {
    constructor(props) {
        super(props);
        this.number1 = React.createRef();
        this.number2 = React.createRef();
    }

    getResult = () => {
        return parseFloat(this.number1.current.value) + parseFloat(this.number2.current.value);
    }

    render() {
        return <p><input ref={this.number1}/> + <input ref={this.number2}/> = </p>
    }
}

function Result(props, ref) {
    return <input ref={ref}/>
}

const WrapperResult = React.forwardRef(Result);

这里最后抛一下疑问,在我们目前写的这个版本里面,其实不需要forwardRef这个API也可以直接支持ref的,就是在createDom里面处理函数组件时传入ref这个参数即可.

if (typeof type === 'function') {
    if (type.isReactComponent) { // 是一个类组件
        return mountClassComponent(vdom);
    }
-    let renderVdom = type(props,ref);
+    let renderVdom = type(props,ref);
    vdom.oldVdom = renderVdom;
    // 让type执行,返回虚拟DOM,继续处理返回的虚拟DOM
    return createDom(renderVdom);
}

这里因为我们还没实现Dom-diff,每次组件的更新都是全量更新,所以目前这样做也能达到效果.