React经验汇总

2021-07-20 fishedee 前端

0 概述

React 经验汇总

1 类型

1.1 VDOM

interface ReactElement<
  P = any,
  T extends string | JSXElementConstructor<any> =
    | string
    | JSXElementConstructor<any>
> {
  type: T
  props: P
  key: Key | null
}

ReactElement,就是自动jsx自动生成出来的代码了

namespace JSX {
    // ...
    interface Element extends React.ReactElement<any, any> { }
    // ...
}

JSXElement就是ReactElement本身(仅仅是泛型参数固定为any而已)

type ReactText = string | number;
type ReactChild = ReactElement | ReactText;

interface ReactNodeArray extends Array<ReactNode> {}
type ReactFragment = {} | ReactNodeArray;
type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;

ReactNode是支持字符串,数字,布尔,空值,ReactElement数组,ReactElement等的组合,换句话说,任意能在Component的render函数返回的值都属于ReactNode

JSX.Element ≈ ReactElement ⊂ ReactNode

1.2 VDOM生成器

interface FunctionComponent<P = {}> {
    (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
    propTypes?: WeakValidationMap<P> | undefined;
    contextTypes?: ValidationMap<any> | undefined;
    defaultProps?: Partial<P> | undefined;
    displayName?: string | undefined;
}

FunctionComponent组件,泛型参数为props的类型

2 render与portal

代码在这里

2.1 虚拟DOM与父子关系的不同

import React, { useEffect } from 'react';
import ReactDOM, { createPortal } from 'react-dom';

const HelloFromPortal: React.FC<any> = (props) => {
    return (
        <div
            onClick={() => {
                alert('我爸应该知道我被点击了');
            }}
        >
            我是传送门里出来的Portal
        </div>
    );
};

const AmISameAsPortal: React.FC<any> = (props) => {
    return (
        <div
            onClick={() => {
                alert('是不是从传送门里出来呢? 我妈应该知道我被点击了');
            }}
        >
            是不是从传送门里出来呢? not Portal
        </div>
    );
};

const HelloReact: React.FC<any> = (props) => {
    useEffect(() => {
        //render是没有返回值的
        //只有AmISameAsPortal自身会响应,虚拟节点上没有父节点,不能响应
        ReactDOM.render(
            <AmISameAsPortal />,
            document.getElementById('another-container')!,
        );
    }, []);
    return (
        <div>
            <h1>父组件</h1>
            <div
                onClick={() => {
                    alert('YES  Dispaly');
                }}
            >
                {
                    //createPortal是有返回值的,它在指定DOM节点上渲染数据,但是挂载在虚拟的DOM节点下面。
                    //所以,能看到一个神奇的现象,点击指定DOM节点上的标记,
                    //不仅HelloFromPortal会响应,虚拟节点的父节点也会响应
                    createPortal(
                        <HelloFromPortal />,
                        document.getElementById('another-root')!,
                    )
                }
            </div>
            XXXX XXXX
            <div
                onClick={() => {
                    alert('No display');
                }}
            ></div>
        </div>
    );
};

export default HelloReact;

要点如下:

  • ReactDOM.render是没有返回节点的,React.createPortal是返回节点的
  • ReactDOM.render与React.createPortal都是挂载在一个指定的DOM节点上。但是React.createPortal竟然还能与原有的节点组成虚拟DOM的父子关系,而ReactDOM.render与原节点时没有虚拟DOM的父子关系

2.2 命令式与声明式的不同

import React, { useEffect, useRef, useState } from 'react';
import ReactDOM, { createPortal } from 'react-dom';

const HelloFromPortal: React.FC<any> = (props) => {
    return <div>Protal对话框</div>;
};

const AmISameAsPortal: React.FC<any> = (props) => {
    return <div>Not Portal对话框</div>;
};

const HelloReact: React.FC<any> = (props) => {
    let [firstPortalShow, setFirstPortalShow] = useState(false);
    let data = useRef({
        isShow: false,
        ref: document.getElementById('another-container')!,
    });
    const hiddenDialog = () => {
        ReactDOM.unmountComponentAtNode(data.current.ref);
        data.current.isShow = false;
    };
    const showDialog = () => {
        ReactDOM.render(<AmISameAsPortal />, data.current.ref);
        data.current.isShow = true;
    };
    return (
        <div>
            <h1>父组件</h1>
            <button
                onClick={() => {
                    if (firstPortalShow == true) {
                        setFirstPortalShow(false);
                    } else {
                        setFirstPortalShow(true);
                    }
                }}
            >
                是否显示Protal
            </button>
            <div>
                {
                    //createPortal的另外一个好处是,可以套用state的方式来控制是否显示该节点。
                    firstPortalShow
                        ? createPortal(
                              <HelloFromPortal />,
                              document.getElementById('another-root')!,
                          )
                        : null
                }
            </div>
            <button
                onClick={() => {
                    //ReactDOM.render的方式就是只能为命令式的
                    if (data.current.isShow == false) {
                        showDialog();
                    } else {
                        hiddenDialog();
                    }
                }}
            >
                是否显示NotProtal
            </button>
        </div>
    );
};

export default HelloReact;

要点如下:

  • ReactDOM.render使用命令式的编程方式,使用ReactDOM.render来触发首次渲染,以及触发后续的重render。最后使用ReactDOM.unmountComponentAtNode来卸载节点。
  • React.createPortal使用声明式的编程方式,数据驱动的编程方式。因为createPortal自身返回的就是个Element,我们在render的时候简单地返回null来卸载这个节点。

2.3 构造命令式对话框

import React, { ReactElement } from 'react';
import ReactDOM from 'react-dom';

type ProtalRender<T> = (portal: MyPortal<T>) => ReactElement;

const MyPortalWrapper: React.FC<{ render: ProtalRender<any>; portal: MyPortal<any> }> = (
    props,
) => {
    const dom = props.render(props.portal);
    return <>{dom}</>;
};

class MyPortal<T = any> {
    private ref: HTMLElement | null = null;

    private resultNotify: ((data: T) => void) | null = null;

    constructor(private render: ProtalRender<T>) { }

    public open() {
        if (this.ref) {
            throw new Error('对话框已经打开了');
        }
        this.ref = document.createElement('div');
        document.body.appendChild(this.ref);

        const node = <MyPortalWrapper render={this.render} portal={this} />;

        ReactDOM.render(node, this.ref);
    }

    //仅仅调用了setResult的时候才会返回
    //如果portal没有触发setResult的话不会返回。
    public awaitOpen(): Promise<T> {
        return new Promise<T>((resolve, reject) => {
            this.open();
            this.resultNotify = resolve;
        });
    }

    public rerender() {
        if (!this.ref) {
            throw new Error('对话框未打开');
        }

        const node = <MyPortalWrapper render={this.render} portal={this} />;
        ReactDOM.render(node, this.ref);
    }

    public setResult(data: T) {
        if (this.resultNotify != null) {
            this.resultNotify(data);
            this.resultNotify = null;
        }
    }

    public close() {
        if (!this.ref) {
            throw new Error('对话框未打开');
        }
        ReactDOM.unmountComponentAtNode(this.ref);
        this.ref.parentElement?.removeChild(this.ref);
        this.ref = null;
    }
}

export default MyPortal;

先创建一个MyPortal类

import React, {
    ReactElement,
    ReactNode,
    useEffect,
    useRef,
    useState,
} from 'react';
import ReactDOM, { createPortal } from 'react-dom';
import MyPortal from './MyPortal';

type SamplePortal = {
    onClick: () => void;
    counter: number;
};
const SamplePortal: React.FC<SamplePortal> = (props) => {
    return (
        <div>
            Sample Protal对话框
            <span style={{ color: 'red' }}>{props.counter}</span>
            <button onClick={props.onClick}>关闭</button>
        </div>
    );
};

const HelloReact: React.FC<any> = (props) => {
    const [state, setState] = useState(0);
    const data = useRef<MyPortal<string>>();
    const counter = useRef<number>(0);
    return (
        <div>
            <h1>父组件</h1>
            <button
                onClick={async () => {
                    if (data.current) {
                        return;
                    }
                    data.current = new MyPortal((protal) => {
                        const onClick = () => {
                            protal.setResult("cc");
                            protal.close();
                            data.current = undefined;
                        };
                        return (
                            <SamplePortal
                                onClick={onClick}
                                counter={counter.current}
                            />
                        );
                    });
                    let result = await data.current.awaitOpen();
                    console.log(result);
                }}
            >
                显示Protal
            </button>
            <button
                onClick={() => {
                    if (data.current) {
                        data.current.close();
                        data.current = undefined;
                    }
                }}
            >
                隐藏Protal
            </button>
            <div>计算器:{counter.current}</div>
            <button
                onClick={() => {
                    counter.current++;
                    setState(state + 1);
                    //因为对话框是用命令的方式,而不是state的方式生成出来的。
                    //所以对话框依赖的数据变更了以后,需要手动触发rerender
                    if (data.current) {
                        data.current.rerender();
                    }
                }}
            >
                递增计数器
            </button>
        </div>
    );
};

export default HelloReact;

然后使用这个MyPortal来创建对话框

大部分的React库都是使用声明方式来构造对话框的,我们试试用命令方式来构造对话框。使用时的特点如下:

  • 优点,命令式对话框,使用起来更简单直观,不需要创建一个额外的节点。
  • 优点,命令式对话框,避免声明式需要一个独立的visible的state
  • 优点,命令式对话框的事件不会冒泡到原有节点上。
  • 缺点,原有的父节点不太容易同步状态到命令式对话框。

3 html

有时候,我们需要直接赋值html到元素里面。代码看这里

3.1 div的Html声明式赋值

import { useState } from 'react';
import { Button } from 'antd';

const divTest: React.FC<any> = (props) => {
    const [state, setState] = useState('<p>欢迎<span style="color:red;">fish</p>');
    return (
        <div>
            <h1>{'Div的dangerHtml测试'}</h1>
            <Button onClick={() => {
                setState('<p>欢迎<span style="color:blue;">cat</p>');
            }}>切换</Button>
            <div dangerouslySetInnerHTML={{ __html: state }}></div>
        </div>
    );
}

export default divTest;

简单,没啥好说的,性能稍差一点

3.2 div的Html命令式赋值

import { useEffect, useRef, useState } from 'react';
import { Button } from 'antd';

const divTest: React.FC<any> = (props) => {
    const divRef = useRef(null as unknown as HTMLDivElement);
    useEffect(() => {
        divRef.current.innerHTML = '<p>欢迎<span style="color:red;">fish</p>';
    }, []);
    return (
        <div>
            <h1>{'Div的dangerHtml测试'}</h1>
            <Button onClick={() => {
                divRef.current.innerHTML = '<p>欢迎<span style="color:blue;">cat</p>';
            }}>切换</Button>
            <div ref={divRef}></div>
        </div>
    );
}

export default divTest;

性能最好,命令式赋值

3.3 iframe的Html声明式赋值

import { useState } from 'react';
import { Button } from 'antd';

const divTest: React.FC<any> = (props) => {
    const [state, setState] = useState('<p>欢迎<span style="color:red;">fish</p>');
    return (
        <div>
            <h1>{'Div的dangerHtml测试'}</h1>
            <Button onClick={() => {
                let result = '<div>';
                for (var i = 0; i != 100; i++) {
                    result += `<p>欢迎<span style="color:blue;">cat${i}</p>`
                }
                result += "</div>";
                setState(result);
            }}>切换</Button>
            <div style={{ width: '100%', height: '500px', border: '1px solid black' }}>
                <iframe style={{ width: '100%', height: '100%', border: 0 }} srcDoc={state} />
            </div>
        </div>
    );
}

export default divTest;

使用srcDoc就能做iframe的声明式赋值

3.4 iframe的Html命令式赋值

import { useState, useRef, useEffect } from 'react';
import { Button } from 'antd';

const divTest: React.FC<any> = (props) => {
    const frameRef = useRef(null as unknown as HTMLIFrameElement);
    useEffect(() => {
        frameRef.current.contentWindow?.document.write('<p>欢迎<span style="color:red;">fish</p>');
    }, []);
    return (
        <div>
            <h1>{'Div的dangerHtml测试'}</h1>
            <Button onClick={() => {
                let result = '<div>';
                for (var i = 0; i != 100; i++) {
                    result += `<p>欢迎<span style="color:blue;">cat${i}</p>`
                }
                result += "</div>";
                //write是续写,要先调用close清除数据
                frameRef.current.contentWindow?.document.close();
                frameRef.current.contentWindow?.document.write(result);
            }}>切换</Button>
            <div style={{ width: '100%', height: '500px', border: '1px solid black' }}>
                <iframe ref={frameRef} style={{ width: '100%', height: '100%', border: 0 }} />
            </div>
        </div>
    );
}

export default divTest;

使用iframe的ref下面的contentWindow?.document来赋值,要注意write是后续写操作,重新写需要先用close清空。

4 ReactElement与JSXElementConstructor

代码在这里

4.1 cloneElement

import React from 'react';

const Item:React.FC<{value:string}> = (props)=>{
    return (<div>{props.value}</div>);
}

const Container:React.FC<{}> = (props)=>{
    const data = ["1","2","3"];
    
    let result = [];
    for( var i in data ){
        var single = data[i];
        //提供通用修改属性的能力
        let singleElem =  React.cloneElement(<Item value=""/>,{
            key:i,
            value:"++++"+single+"++++",
        });
        result.push(singleElem);
    }
    return (
        <div>{result}</div>
    );
}

export default Container;

cloneElement这点也没啥好说的,就是将JSX生成的结果,取出来做一个merge操作。

4.2 JSXElementConstructor

type JSXElementConstructor<P> = ((props: P) => ReactElement<any, any> | null) | (new (props: P) => Component<any, any, any>)

JSXElementConstructor的定义也比较简单,相当于就是React.Func与React.ClassComponent两种了。既然JSXElement只是一个函数,或者一个new方法,为什么我们不能用高阶函数来生成一个动态的Component类型。

import { ReactElement ,useState,JSXElementConstructor } from "react"
import {Input} from 'antd';

type MyComponentType<T> = (props:{data:T,dataIndex:keyof T,manualRefresh:()=>void})=>ReactElement;

function ComponentFactory<T>(data:T):MyComponentType<T>{
    const result:MyComponentType<T> = (props)=>{
        return (<div>
            <span>Input</span><Input value={props.data[props.dataIndex] as any} onChange={(e)=>{
                props.data[props.dataIndex] = e.target.value as any;
                props.manualRefresh();
            }}/></div>);
    }
    return result;
}

const data = {
    name:'fish',
    age:123,
}

const MyComponent = ComponentFactory(data);

const MyComponentConstructor:JSXElementConstructor<{data:typeof data,dataIndex:keyof typeof data,manualRefresh:()=>void}> = MyComponent;

const Page:React.FC<any> = (props)=>{
    const [state,setState] = useState(0);

    const manualRefresh = ()=>{
        setState((v)=>v+1);
    }
    return (
        <div>
            <MyComponent data={data} dataIndex={'name'} manualRefresh={manualRefresh}/>
            <MyComponent data={data} dataIndex={'age'} manualRefresh={manualRefresh}/>
        </div>
    );
}

export default Page;

我们用ComponentFactory来生成Component,然后用这个动态的Component来创建JSX.Element,一切都是正常好用的。这种做法的好处生成的MyComponent有更好的编译提示,dataIndex只能为name或者age,否则会报错。

4.3 过度动态的JSXElementConstructor

import { ReactElement ,useState } from "react"
import {Input} from 'antd';

type MyComponentType<T> = (props:{data:T,dataIndex:keyof T,manualRefresh:()=>void})=>ReactElement;

function ComponentFactory<T>(data:T):MyComponentType<T>{
    const result:MyComponentType<T> = (props)=>{
        return (<div>
            <span>Input</span><Input value={props.data[props.dataIndex] as any} onChange={(e)=>{
                props.data[props.dataIndex] = e.target.value as any;
                props.manualRefresh();
            }}/></div>);
    }
    return result;
}

const data = {
    name:'fish',
    age:123,
}

const Page:React.FC<any> = (props)=>{
    const [state,setState] = useState(0);

    const MyComponent = ComponentFactory(data);


    const manualRefresh = ()=>{
        setState((v)=>v+1);
    }
    return (
        <div>
            <MyComponent data={data} dataIndex={'name'} manualRefresh={manualRefresh}/>
            <MyComponent data={data} dataIndex={'age'} manualRefresh={manualRefresh}/>
        </div>
    );
}

export default Page;

但是这种方法也有局限的地方,如果MyComponent不是初始化一次生成,而是在每次render的时候重新生成。使用React的diff操作就会出问题,因为两个VDOM的比较,不仅比较key,而且比较JSXElementConstructor的引用是否一致。

所有,以上的代码产生的问题在于,当在Input输入框修改文字的时候,就会触发render,产生一个不同引用的JSXElementConstructor,最终导致每次的Input都要卸载重新挂载,Input上的焦点丢失了。

5 再谈类型

代码看这里

import React, { JSXElementConstructor, ReactElement, ReactNode } from 'react';

//函数与类组件
const ComponentA:React.FC<{title:string}> = (props)=>{
    return (<h1>{props.title}</h1>);
}

class ComponentB extends React.Component<{size:number}>{
    render(){
        return (<div>{this.props.size}</div>);
    }
}

//使用Typescript获取组件的props,这个很好用
type componentAProps = React.ComponentProps<typeof ComponentA>;

type componentBProps = React.ComponentProps<typeof ComponentB>;

//函数与类组件都属于JSXElementConstructor
function componentConstructor(data: JSXElementConstructor<any>){
    
}
componentConstructor(ComponentA);
componentConstructor(ComponentB);

//凡是组件都能返回JSXElement(等价于ReactElement),而字符串和数字等等都不属于ReactElement
const element1:ReactElement = <ComponentA title={"13"}/>
//const element2:ReactElement = "23"
//const element3:ReactElement = 123

//而ReactNode是更为基础的渲染元素了,唯一的缺点在于ReactNode不能使用cloneElement等的方法
const node1:ReactNode = <ComponentA title={"13"}/>
const node2:ReactNode = "string"
const node3:ReactNode = 123
const node4:ReactNode = null
const node5:ReactNode = undefined

没啥好说的,如上面代码的注释所说的

6 数据源与两步编辑

代码在这里

6.1 外部数据源

import styles from './index.less';
import {Input,Button} from 'antd';
import { useState } from 'react';

const MyInput:React.FC<any> = (props)=>{
    return (<Input value={props.value} onChange={(e)=>{
      props.onChange(e.target.value);
    }}/>);
}

const ShowTip:React.FC<any> = (props)=>{
  return <h2>状态为:{props.value}</h2>
}
export default function IndexPage() {
  const [state,setState] = useState('');
  const [state2,setState2] = useState('');
  console.log('render');
  return (
    <div>
      <Button onClick={()=>{
        setState('Fish');
      }} type="primary">{'外部设置数据为:Fish'}</Button>
      <Button onClick={()=>{
        console.log('state',state);
        console.log('state2',state2);
      }}>{'获取全部数据'}</Button>
      <MyInput value={state} onChange={setState}/>
      <MyInput value={state2} onChange={setState2}/>
      <ShowTip value={state}/>
    </div>
  );
}

我们有两个Input,其中一个Input是独立的,无需更新其他部分。另外一个Input需要与ShowTip的同步数据。以上代码比较简单,将数据抽取为外部数据源就可以了,缺点是:

  • MyInput2,无需更新其他部分,但每次都触发全局Render
  • MyInput1,需要同步更新ShowTip,但是更新频率太高,Render次数太多。

6.2 内部数据源

import styles from './index.less';
import {Input,Button} from 'antd';
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';

const MyInput:React.FC<any> = forwardRef((props,ref)=>{
    const [state,setState] = useState('');
    useImperativeHandle(ref,()=>({
        getValue(){
            return state;
        },
        setValue(value:string){
            setState(value);
        }
    }));
    return (<Input value={state} onChange={(e)=>{
        setState(e.target.value);
        setTimeout(props.onChange,0);
    }}/>);
});

const ShowTip:React.FC<any> = (props)=>{
    return <h2>状态为:{props.value}</h2>
};

export default function IndexPage() {
    const [tip,setTip] = useState('');
    const ref = useRef<any>();
    const ref2 = useRef<any>();
  console.log('render');
  return (
    <div>
      <Button onClick={()=>{
        ref.current.setValue('Fish');
        setTip('Fish');
      }} type="primary">{'外部设置数据为:Fish'}</Button>
      <Button onClick={()=>{
          console.log('state',ref.current.getValue());
          console.log('state2',ref2.current.getValue());
      }}>{'获取全部数据'}</Button>
      <MyInput ref={ref} onChange={()=>{
          setTip(ref.current.getValue());
      }}/>
      <MyInput ref={ref2}/>
      {<ShowTip value={tip}/>}
    </div>
  );
}

将Input的数据源移入到Input内部,我们得到了以下优化:

  • MyInput2,无需更新其他部分,这次不再需要触发全局Render了
  • MyInput1,需要同步更新ShowTip,但是更新频率太高,Render次数太多。

缺点是,取数据的时候不太方便,需要用ref来取

6.3 内部数据源2

import styles from './index.less';
import {Input,Button} from 'antd';
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';

const useManualRefresh = ()=>{
    const [state,setState] = useState(0);
    return {
        manualRefresh:()=>{
            setState(v=>v+1);
        }
    }
}

const MyInput:React.FC<any> = forwardRef((props,ref)=>{
    const {manualRefresh} = useManualRefresh();
    return (<Input value={props.value[props.name]} onChange={(e)=>{
        props.value[props.name] = e.target.value;
        if( props.onChange ){
            props.onChange();
        }
        manualRefresh();
    }}/>);
});

const ShowTip:React.FC<any> = (props)=>{
    return <h2>状态为:{props.value}</h2>
};

export default function IndexPage() {
    const {manualRefresh} = useManualRefresh();
    const refData = useRef({name:''});
  console.log('render');
  return (
    <div>
      <Button onClick={()=>{
        refData.current.name = "Fish";
        manualRefresh();
      }} type="primary">{'外部设置数据为:Fish'}</Button>
      <Button onClick={()=>{
          console.log('state',refData.current);
      }}>{'获取全部数据'}</Button>
      <MyInput value={refData.current} name="name" onChange={()=>{
          manualRefresh();
      }}/>
      <MyInput value={refData.current} name="name2"/>
      {<ShowTip value={refData.current.name}/>}
    </div>
  );
}

我们进一步优化一下,内部数据源更新数据的时候,做原地更新就可以了。保留了6.2的优点,同时改进了6.2的缺点

6.4 两步编辑

import styles from './index.less';
import {Input,Button} from 'antd';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';

const useManualRefresh = ()=>{
    const [state,setState] = useState(0);
    return {
        manualRefresh:()=>{
            setState(v=>v+1);
        }
    }
}

const MyInput:React.FC<any> = forwardRef((props,ref)=>{
    const [state,setState] = useState(props.value);
    const ref2 = useRef<any>();
    useImperativeHandle(ref,()=>({
        getValue(){
            return state;
        }
    }));
    useEffect(()=>{
        ref2.current.focus();
        ref2.current.select();
    },[]);
    return (<Input ref={ref2} value={state} onChange={(e)=>{
        setState(e.target.value);
    }}/>);
});

const WrapInput:React.FC<any> = (props)=>{
    const [isEdit,setIsEdit] = useState(false);
    const ref = useRef<any>();
    if( isEdit == false ){
        return (<div style={{border:'1px solid black',height:'30px'}} onClick={()=>{
            setIsEdit(true);
        }}>{props.value[props.name]}</div>);
    }else{
        return (
        <div onBlur={()=>{
            setIsEdit(false);
            props.value[props.name] = ref.current.getValue();
            if(props.onChange){
                props.onChange();
            }
        }} style={{height:'30px'}}>
            <MyInput ref={ref} value={props.value[props.name]}/>
        </div>
        );
    }
}


const ShowTip:React.FC<any> = (props)=>{
    return <h2>状态为:{props.value}</h2>
};

export default function IndexPage() {
    const {manualRefresh} = useManualRefresh();
    const refData = useRef({name:''});
  console.log('render');
  return (
    <div>
      <Button onClick={()=>{
        refData.current.name = "Fish";
        manualRefresh();
      }} type="primary">{'外部设置数据为:Fish'}</Button>
      <Button onClick={()=>{
          console.log('state',refData.current);
      }}>{'获取全部数据'}</Button>
      <WrapInput value={refData.current} name="name" onChange={()=>{
          manualRefresh();
      }}/>
      <WrapInput value={refData.current} name="name2"/>
      {<ShowTip value={refData.current.name}/>}
    </div>
  );
}

我们使用WrapInput来实现两步编辑法,这个时候同时改进了两个问题:

  • MyInput2,无需更新其他部分,这次不再需要触发全局Render了
  • MyInput1,需要同步更新ShowTip,而且仅在onBlur的时候进行更新,Render次数大大减少。

6.5 小结

在表单中,两步编辑和一步编辑是相当重要的事情,它们的区别在于:

一步编辑,

  • 优点1,在blur和focus情况能显示输入组件的完整外貌,能使用输入组件的clear,contextMenu,hover等功能。对于Checkbox,地址栏,树形选择组件,两步编辑是无法实现的。
  • 优点2,更为强烈地表达给用户哪些组件是允许输入的,哪些组件是纯展示的
  • 缺点1,批量展示的时候,会显示过多的输入组件的外貌,影响查看观感,特别是Grid中
  • 缺点2,blur和focus都必须使用同一个数据源和同一个数据类型。这是好事也是坏事,好事时,前端提供一个options,后端提供一个id就可以了,实现较为简单。坏事是,当后端提供的id,前端的options不存在的时候,就会产生问题,例如是权限看不到,或者停用资料的时候。
  • 缺点3,任何时刻只有一个输入组件,所以校验和onChange总是发生在输入的任何时刻,而不能在blur的时候才进行校验或onChange。例如,我们不能在InputNumber的blur的时候,才去触发onChange通知,我们每次按下键盘的时候,都会触发onChange。
  • 缺点4,任何时刻只有一个输入组件,没有输入的撤销操作。在没有PressEnter,也没有onSelect的时候,我们希望点击页面的其他部分触发blur操作的时候,能撤销当前的输入数据。

两步编辑:

  • 优点1,在blur情况下使用普通的Label组件显示,在focus的情况下使用输入组件来展示。对于批量显示数据的情况,例如是Grid中,观感要好得多。
  • 优点2,blur和focus可以使用不同的数据源和不同的数据类型,前端提供一个options,但是后端需要提供一个id和name,甚至是info对象。因为blur的时候用name显示,但是focus的时候用id来选择。
  • 优点3,只有在blur的时候才进行校验或者onChange操作,这大大简化了输入组件的实现。特别像InputNumber,Select组件的实现
  • 优点4,在blur的时刻,如果没有PressEnter,也没有onSelect的时候,则会触发撤销操作
  • 缺点1,在blur的时候,只有Label显示,没有输入组件的完整外貌。也没有输入组件的clear,contextMenu,hover等功能
  • 缺点2,用户无法直观感觉哪些组件是允许输入的,哪些组件是纯展示的

一个合格的form或者grid应该两者都支持

2023-01-03,其他要点为:

  • 两步编辑适合对中间状态敏感(大量中间状态的校验是不通过的),数据变更的计算量大的表单。确认编辑的触发,既可以用onBlur来隐式实现(有一定风险,特别的是从一个field切换到另外一个field),也可以用Button来显式实现。
  • 一步编辑适合对中间状态不敏感(中间状态的校验是允许的),数据变更的计算量小的表单,更实时反馈的UI体验。也不会产生onBlur出现的问题。

7 事件

以React 17为例,代码在这里

7.1 合成事件

7.1.1 事件冒泡顺序

import React, { useEffect } from 'react';

const App:React.FC<any> = (props)=>{
    useEffect(()=>{
        //React事件有两步
        //原生事件冒泡,从底层一起触发到document的冒泡
        //合成事件冒泡,React创建的事件,从React底层组件到React顶层组件的冒泡。合成事件绑定是通过隐式绑定document的原生事件来实现的。
        //所以,事件的方式是底层原生事件->document第一次隐式绑定事件(合成事件冒泡)->document第二次显式绑定的事件
        /*
        合成事件的意义在抹平不同浏览器上的事件差异,而且避免在列表的每个DOM上都挂载一个事件,造成灾难
        */
        const documentClick = ()=>{
            console.log('document click');
        }
        const divClick = ()=>{
            console.log('原生outClick');
        }
        document.addEventListener('click', documentClick);
        document.getElementById('div1')?.addEventListener('click',divClick);
        return ()=>{
            document.removeEventListener('click',documentClick);
            document.getElementById('div1')?.removeEventListener('click',divClick);
        }
    },[]);

    const outerClick = ()=>{
        console.log('outerClick');
    }

    const innerClick = ()=>{
        console.log('innerClick');
    }
    //http://www.qiutianaimeili.com/html/page/2020/04/2020426gbkc8mhwpfi.html
    /*
    因此我们点击inner的div的时候,输出是:
    原生outClick
    innerClick
    outerClick
    document click
    */
    return(
        <div id="div1" onClick={outerClick}>
            this is outer
            <div onClick={innerClick}>
                this is inner
            </div>
        </div>
    )
}

export default App;

react事件的冒泡顺序

  • 直接绑定到非document的原生事件
  • react的合成事件,document第一次隐式绑定事件(合成事件冒泡)
  • 直接绑定到document的原生事件

7.1.2 stopPropagation禁止冒泡

import React, { useEffect ,MouseEvent} from 'react';

const App:React.FC<any> = (props)=>{
    useEffect(()=>{
        const documentClick = ()=>{
            console.log('document click');
        }
        const divClick = ()=>{
            console.log('原生outClick');
        }
        document.addEventListener('click', documentClick);
        document.getElementById('div1')?.addEventListener('click',divClick);
        return ()=>{
            document.removeEventListener('click',documentClick);
            document.getElementById('div1')?.removeEventListener('click',divClick);
        }
    },[]);

    const outerClick = ()=>{
        console.log('outerClick');
    }

    const innerClick = (e:MouseEvent<HTMLDivElement>)=>{
        console.log('innerClick');
        //这个方法只能阻止合成事件,不能阻止原生事件
        e.stopPropagation();
    }
    //http://www.qiutianaimeili.com/html/page/2020/04/2020426gbkc8mhwpfi.html
    /*
    因此我们点击inner的div的时候,输出是:
    原生outClick
    innerClick
    document click
    这个时候,少了outerClick这个合成事件的触发
    */
    return(
        <div id="div1" onClick={outerClick}>
            this is outer
            <div onClick={innerClick}>
                this is inner
            </div>
        </div>
    )
}

export default App;

从冒泡事件中可以看到,

  • stopPropagation只能阻止仅第二步的事件,react的合成事件
  • 不能阻止直接绑定document上的原生事件(第三步)
  • 不能阻止直接绑定到非document上的原生事件(第一步)

7.1.3 nativeEvent.stopImmediatePropagation禁止冒泡

import React, { useEffect ,MouseEvent} from 'react';

const App:React.FC<any> = (props)=>{
    useEffect(()=>{
        const documentClick = ()=>{
            console.log('document click');
        }
        const divClick = ()=>{
            console.log('原生outClick');
        }
        document.addEventListener('click', documentClick);
        document.getElementById('div1')?.addEventListener('click',divClick);
        return ()=>{
            document.removeEventListener('click',documentClick);
            document.getElementById('div1')?.removeEventListener('click',divClick);
        }
    },[]);

    const outerClick = ()=>{
        console.log('outerClick');
    }

    const innerClick = (e:MouseEvent<HTMLDivElement>)=>{
        console.log('innerClick');
        //这个方法只能阻止合成事件,不能阻止原生事件
        e.stopPropagation();
        //这个方法能阻止原生事件,但只能阻止document上的原生事件,不能触发原始click
        e.nativeEvent.stopImmediatePropagation();
    }
    //http://www.qiutianaimeili.com/html/page/2020/04/2020426gbkc8mhwpfi.html
    /*
    因此我们点击inner的div的时候,输出是:
    原生outClick
    innerClick
    document click
    这个时候,少了outerClick这个合成事件的触发,以及少了document上的原生事件
    但是!!,不会少了div1上的原生事件触发,这是因为合成事件是在document上的原生事件上实现的。合成事件是div的原生事件冒泡上来后的产物,不可能在停止冒泡后,能回滚之前的事件输出   
    */
    return(
        <div id="div1" onClick={outerClick}>
            this is outer
            <div onClick={innerClick}>
                this is inner
            </div>
        </div>
    )
}

export default App;

e.nativeEvent.stopImmediatePropagation()

  • 能阻止直接绑定document上的原生事件(第三步)
  • 不能阻止直接绑定到非document上的原生事件(第一步)

7.2 键盘事件

7.2.1 基础

import React, { useEffect ,KeyboardEvent} from 'react';

const App:React.FC<any> = (props)=>{
    const onKeyDown = (e:KeyboardEvent<HTMLInputElement>)=>{
        console.log('e.key',e.key);
        console.log('e.code',e.code);

        //https://reactjs.org/docs/events.html#keyboard-events
        //https://www.w3.org/TR/uievents-key/#named-key-attribute-values
        if( e.key == 'Enter' ){
            console.log('Enter键或者Numpad Enter键按下了');
        }

        //https://blog.saeloun.com/2021/04/23/react-keyboard-event-code.html
        //https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
        if( e.code == 'Enter'){
            console.log('只有大键盘Enter键按下了');
        }
        if( e.code == 'NumpadEnter'){
            console.log('只有Numpad Enter键按下了');
        }

        //数字
        if( e.key == '3' ){
            console.log('3键或者Numpad 3键按下了');
        }
        if( e.code == 'Digit3' ){
            console.log('只有3键按下了');
        }
        if( e.code == 'Numpad3' ){
            console.log('只有Numpad3键按下了');
        }
    }
    return(
        <input style={{width:'300px',fontSize:'16px'}} onKeyDown={onKeyDown}/>
    )
}

export default App;

键盘事件中,要仔细区分:

  • key是输入字符,能屏蔽不同按键的差异。我们在开发中优先使用该事件。可用列表在这里
  • code是原生的键盘输入码,保留了不同按键的差异。例如,能区分Enter与Numpad Enter,能区分Digit3与Numpad3。这个特性很少使用,尽量避免。可用列表在这里

7.2.2 全局键盘事件

import useDataRef from '@/useDataRef';
import React, { useEffect, useLayoutEffect } from 'react';

class Model {

    public constructor() {
        document.addEventListener('keydown', this.onKeyDown);
    }

    public destoryEvent = () => {
        document.removeEventListener('keydown', this.onKeyDown);
    }

    private onKeyDown = (e: KeyboardEvent) => {
        console.log('---- key down ---');
        console.log('key', e.key);
        console.log('hasCtrl', e.ctrlKey);
        console.log('hasAtl', e.altKey);
        console.log('e', e.target);//无input的情况下,来自于body
    }
}
const App: React.FC<any> = (props) => {
    const model = useDataRef(() => {
        return new Model();
    }).current;
    useLayoutEffect(() => {
        return () => {
            model.destoryEvent();
        }
    }, []);
    return (
        <div>{'Hello World'}</div>
    )
}

export default App;

只能通过document.addEventListener来实现

7.3 mousemove与drag事件

import { MutableRefObject, useLayoutEffect } from "react";
import useDataRef from "../../useDataRef";
import { useManualRefresh } from "../../useManualRefresh";
import './basic.css';

class Point {
    public readonly x: number;
    public readonly y: number;

    public constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    public sub(r: Point): Point {
        return new Point(this.x - r.x, this.y - r.y);
    }

    public add(r: Point): Point {
        return new Point(this.x + r.x, this.y + r.y);
    }
}

class Model {
    public startMouseMove = new Point(0, 0);
    public startBoundMove = new Point(0, 0);
    public bound = new Point(20, 20);
    public active = false;
    public divRef: MutableRefObject<HTMLDivElement | null> = { current: null };

    public manualRefresh = () => { };

    public onMouseDown = (e: React.MouseEvent) => {
        this.startMouseMove = new Point(
            e.clientX,
            e.clientY,
        )
        this.startBoundMove = this.bound;
        this.active = true;
        this.manualRefresh();

        //init事件
        document.addEventListener('mousemove', this.onMouseMove);
        document.addEventListener('mouseup', this.onMouseUp);
    }
    public onMouseMove = (ev: MouseEvent) => {
        //clientX与clientY是相对于浏览器内部的x,y
        console.log('client x,y', ev.clientX, ev.clientY);
        //screenX与screenY是相对于整个屏幕的x,y
        console.log('screen x,y', ev.screenX, ev.screenY);
        //boundingClientRect可以算出包含wrapper滚动条后的位置
        const boundRect = this.divRef.current!.getBoundingClientRect();
        console.log('bounding x,y', boundRect.x, boundRect.y);
        console.log('mouse point in bounding x,y', ev.clientX - boundRect.x, ev.clientY - boundRect.y);
        const point = new Point(ev.clientX, ev.clientY);
        const diff = point.sub(this.startMouseMove);

        this.bound = this.startBoundMove.add(diff);
        console.log('bound', this.bound);
        this.manualRefresh();
    }
    public onMouseUp = (ev: MouseEvent) => {
        this.active = false;
        this.manualRefresh();
        document.removeEventListener('mousemove', this.onMouseMove);
        document.removeEventListener('mouseup', this.onMouseUp);
    }
}

const Page: React.FC<any> = (props) => {
    const { manualRefresh } = useManualRefresh();
    const model = useDataRef(() => {
        const result = new Model();
        result.manualRefresh = manualRefresh;
        return result;
    }).current;
    useLayoutEffect(() => {
        return () => {
            model.manualRefresh = () => { };
        }
    }, []);
    return (
        <div
            onDragStart={(e) => {
                //避免与mouseMove冲突
                e.stopPropagation();
                e.preventDefault();
                e.nativeEvent.stopImmediatePropagation();
                e.nativeEvent.stopPropagation();
            }}
            ref={model.divRef}
            style={{ margin: '10px', position: 'absolute', width: '2000px', height: '2000px', border: '1px solid black' }}>
            <div
                className="moveTarget"
                onMouseDown={model.onMouseDown}
                style={{
                    border: model.active ? '1px solid blue' : '1px solid grey',
                    position: 'absolute',
                    width: '80px',
                    height: '50px',
                    top: model.bound.y + 'px',
                    left: model.bound.x + 'px'
                }}>拖动我试一下</div>
        </div>
    );
}

export default Page;

tsx代码

.moveTarget::selection {
    color: inherit;
    background-color: inherit;
}

样式文件

一个简单的拖动Demo,要点有:

  • mousemove挂在document而不是某个div上,这样可以尽可能捕捉所有情况的move事件,但是我们只有在mousedown的情况才进行addEventLister.
  • 注意MousePoint的细节,clientX,screenX,clientX-boundRect.x
  • 屏蔽onDragStart事件,避免与我们现有的mousemove冲突
  • 在拖动的过程中,会产生其他对象的selection伪属性,这个伪属性是用来选中多行文本的,我们也需要屏蔽这个功能。

7.4 contextMenu事件

import { MutableRefObject, useLayoutEffect } from "react";
import useDataRef from "../../useDataRef";
import { useManualRefresh } from "../../useManualRefresh";
import { Dropdown, MenuProps } from 'antd';

class Point {
    public readonly x: number;
    public readonly y: number;

    public constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    public sub(r: Point): Point {
        return new Point(this.x - r.x, this.y - r.y);
    }

    public add(r: Point): Point {
        return new Point(this.x + r.x, this.y + r.y);
    }
}

const menuItems: MenuProps['items'] = [
    {
        label: '1st menu item',
        key: '1',
    },
    {
        label: '2nd menu item',
        key: '2',
    },
    {
        label: '3rd menu item',
        key: '3',
    },
];


class Model {
    public showPoint = new Point(0, 0);
    public showMenu = false;
    public divRef: MutableRefObject<HTMLDivElement | null> = { current: null };
    public manualRefresh = () => { };

    public onContextMenu = (ev: React.MouseEvent) => {
        if (this.showMenu == true) {
            this.closeContextMenu();
            setTimeout(() => {
                this.onContextMenu(ev);
            }, 100);
            return;
        }
        const boundRect = this.divRef.current!.getBoundingClientRect();
        this.showPoint = new Point(ev.clientX - boundRect.x, ev.clientY - boundRect.y);
        this.showMenu = true;
        this.manualRefresh();

        //initEvent
        window.addEventListener('click', this.closeContextMenu);
        window.addEventListener('contextmenu', this.closeContextMenu);
    }

    private closeContextMenu = () => {
        if (!this.showMenu) {
            return;
        }
        window.removeEventListener('contextmenu', this.closeContextMenu);

        this.showMenu = false;
        this.manualRefresh();
    }
}

const Page: React.FC<any> = (props) => {
    const { manualRefresh } = useManualRefresh();
    const model = useDataRef(() => {
        const result = new Model();
        result.manualRefresh = manualRefresh;
        return result;
    }).current;
    useLayoutEffect(() => {
        return () => {
            model.manualRefresh = () => { };
        }
    }, []);
    return (
        <div
            ref={model.divRef}
            onContextMenu={(e) => {

                e.preventDefault();
                e.nativeEvent.stopPropagation();
                e.nativeEvent.stopImmediatePropagation();
                model.onContextMenu(e);
            }}
            style={{ margin: '10px', position: 'absolute', width: '2000px', height: '2000px', border: '1px solid black' }}>
            {model.showMenu ? <Dropdown menu={{ items: menuItems }} open={true} >
                <div style={{
                    position: 'absolute',
                    left: model.showPoint.x + "px",
                    top: model.showPoint.y + "px",
                }}>
                </div></Dropdown > : null}
        </div>
    );
}

export default Page;

contextMenu就是重写右键菜单了,要点有:

  • onContextMenu要先屏蔽默认操作再执行渲染右键菜单
  • 渲染的top, left是需要考虑clientRect的
  • 取消当前的contextMenu取决于,全局的click,或者激活新的右键菜单(这里我们需要做延时显示,以展示先消失菜单,后展示菜单的动画效果)。

相关文章