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,或者激活新的右键菜单(这里我们需要做延时显示,以展示先消失菜单,后展示菜单的动画效果)。
- 本文作者: fishedee
- 版权声明: 本博客所有文章均采用 CC BY-NC-SA 3.0 CN 许可协议,转载必须注明出处!