Giter Club home page Giter Club logo

blog's Introduction

blog's People

Contributors

beichensky avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

blog's Issues

从零到一实现 react-redux

前言

十分钟学会 react-redux 一文中详细讲解了 react-redux 的使用。

从零到一实现 Redux 中,实现了关于 redux 的核心代码。

下面我们按照上一篇的节奏,继续实现一下 react-redux 的核心代码。

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

核心 API

包含以下核心 API

  • Provider: 上下文组件
  • connect: 带参的高阶函数
  • useSelector: 获取需要的 state 数据
  • useDispatch: 获取 dispatch

一、Provider

全局只有一个 store 对象,需要在多层级组件中传递 store
并且 store 中的 state 发生变化,组件需要相应的做出更新。
所以这里我们使用 Context 进行数据传递

  • 创建 Context
import React, { useContext, useEffect, useReducer } from "react";
import { bindActionCreators } from "./redux";

const StoreContext = React.createContext();
  • 创建 Provider 组件
/**
 * Provider 组件,用来传递 Context 中数据,进行跨层级组件通信
 */
const Provider = ({ store, children }) => (
  // 将 store 作为 value 传递下去
  <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);
export { Provider, connect, useDispatch, useSelector };

二、connect 高阶函数

  • 订阅监听事件,state 发生变化,强制更新组件

  • statePropsdispatchPropsmergeProps 合并到组件的 props

  • APIconnect([mapStateToProps],[mapDispatchToProps],[mergeProps],[options])

connect 的用法相对复杂一些,接受四个参数(我们这里暂时不管第四个参数),返回的是一个高阶组件。用来连接当前组件和 Redux store

1. mapStateToProps

mapStateToProps:函数类型,接受两个参数: stateownProps(当前组件的 props,不建议使用,会导致重渲染,损耗性能),必须返回一个纯对象,这个对象会与组件的 props 合并

  • (state[, ownProps]) => ({ count: state.count, todoList: state.todos })

2. mapDispatchToProps

mapDispatchToProps:object | 函数

  • 不传递这个参数时,dispatch 会默认挂载到组件的的 props

  • 传递 object 类型时,会把 object 中的属性值使用 dispatch 包装后,与组件的 props 合并

    • 对象的属性值都必须是 ActionCreator

    • dispatch 不会再挂载到组件的 props

  • 传递函数类型时,接收两个参数:dispatchownProps(当前组件的 props,不建议使用,会导致重渲染,损耗性能),必须返回一个纯对象,这个对象会和组件的 props 合并

3. mergeProps

mergeProps(很少使用) 函数类型。如果指定了这个参数,mapStateToProps()mapDispatchToProps()的执行结果和组件自身的 props 将传入到这个回调函数中。该回调函数返回的对象将作为 props 传递到被包装的组件中。你也许可以用这个回调函数,根据组件的 props 来筛选部分的 state 数据,或者把 props 中的某个特定变量与 ActionCreator 绑定在一起。如果你省略这个参数,默认情况下组件的 props 返回 Object.assign({}, ownProps, stateProps, dispatchProps) 的结果

  • mergeProps(stateProps, dispatchProps, ownProps): props

connect

/**
 * connect 函数,源码中包含四个参数,我们这里只用到这些,所以就暂时只实现了前三个参数
 *
 * @param {*} mapStateToProps 将 state 合并到组件的 props 中的函数
 * @param {*} mapDispatchToProps 将 actionCreator 合并到组件的 props 中的函数
 * @param {*} mergeProps 自定义属性合并到组件的 props
 */
const connect = (
  mapStateToProps,
  mapDispatchToProps,
  mergeProps
) => WrapperComponent => {
  return props => {
    const { getState, dispatch, subscribe } = useContext(StoreContext);
    const [, forceUpdate] = useReducer(x => x + 1, []);
    // 执行 mapStateToProps,获取用户需要的 state 数据
    const stateProps = mapStateToProps(getState());
    // 默认将 dispatch 挂载到 props 上
    let dispatchProps = { dispatch };

    // 判断 mapDispatchToProps 是函数还是对象,函数的话,执行获取返回的对象
    if (typeof mapDispatchToProps === "function") {
      dispatchProps = mapDispatchToProps(dispatch);
    } else if (mapDispatchToProps === "object") {
      // 对象的话,直接将对象中的 actionCreator 使用 dispatch 进行包装
      dispatchProps = bindActionCreators(mapDispatchToProps, dispatch);
    }

    mergeProps = mergeProps(stateProps, dispatchProps, props);

    useEffect(() => {
      // 添加事件订阅,state 发生变化时会触发,更新组件
      const unsubscribe = subscribe(() => forceUpdate());
      return () => {
        unsubscribe();
      };
    }, [subscribe]);

    return (
      <WrapperComponent
        {...props}
        {...stateProps}
        {...dispatchProps}
        {...mergeProps}
      />
    );
  };
};

三、useDispatch 获取 dispatch

useDispatch

/**
 * 获取 store 对象
 */
const useStore = () => {
  const store = useContext(StoreContext);
  return store;
};

/**
 * 获取 store 中 dispatch
 */
const useDispatch = () => {
  const store = useStore();
  return store.dispatch;
};

四、useSelector 获取需要的 state 值

  • 订阅事件监听,state 发生变化,强制更新组件

  • 接受一个函数作为参数,函数的返回值作为 useSelector 的返回值传递出去

/**
 * useSelector 从 store 中获取当前组件所需要的 state
 *
 * @param {(state) => props} selector 用户传入的函数,接收 store 当前的 state,返回一个组织好的数据对象
 */
const useSelector = selector => {
  const [, forceUpdate] = useReducer(x => x + 1, []);
  const { subscribe, getState } = useStore();

  useEffect(() => {
    // 添加事件订阅,state 发生变化时会触发,更新组件
    const unsubscribe = subscribe(() => forceUpdate());
    return () => {
      unsubscribe();
    };
  }, [subscribe]);
  return selector(getState());
};

系列文章

写在后面

至此, react-redux 的核心功能基本已经实现,不过有很多细节和参数的兼容都没有进行处理,有兴趣的朋友可以参照源码完善。

源码地址

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

Vue3:watch 使用场景及常见问题

前言

在使用 Vue3 提供的 watch API

  • 有时会遇到监听的数据变了,但是不触发 watch 的情况;

  • 有时修改数据会触发 watch,重新赋值无法触发;

  • 有时重新赋值能触发 watch,但是修改内部数据又不触发;

  • 再或者监听外部传入的数据时,是否和直接监听组件内部数据时的行为一致?

面临这些问题,决心通过下面的应用场景一探究竟!避免重复踩坑,对应不同的问题,找到合适的解决方案。

本文已收录在 Github: github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

一、Vue3 中响应式数据的两种类型

  • 使用 reactive 声明的响应式数据,类型是 Proxy

  • 使用 ref 声明的响应式数据,类型是 RefImpl

  • 使用 computed 得到的响应式数据,类型也属于 RefImpl

  • 使用 ref 声明时,如果是引用类型,内部会将数据使用 reactive 包裹成 Proxy

二、Watch API

watch(source, callback, options)

  • source: 需要监听的响应式数据或者函数

  • callback:监听的数据发生变化时,会触发 callback

    • newValue:数据的新值

    • oldValue:数据的旧值

    • onCleanup:函数类型,接受一个回调函数。每次更新时,会调用上一次注册的 onCleanup 函数

  • options:额外的配置项

    • immediateBoolean类型,是否在第一次就触发 watch

    • deepBoolean 类型,是否开启深度监听

    • flushpre | post | sync

      • pre:在组件更新前执行副作用

      • post:在组件更新后运行副作用

      • sync:每个更改都强制触发 watch

    • onTrack:函数,具备 event 参数,调试用。将在响应式 propertyref 作为依赖项被追踪时被调用

    • onTrigger:函数,具备 event 参数,调试用。将在依赖项变更导致副作用被触发时被调用。

三、watch 监听 reactive 声明的响应式数据

1. 监听 reactive 声明的响应式数据时

  • 当监听的 reactive 声明的响应式数据时,修改响应式数据的任何属性,都会触发 watch

    const state = reactive({
      name: '张三',
      address: {
        city: {
          cityName: '上海',
        },
      },
    });
    
    watch(
      state,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      },
      {
        deep: false,
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);

可以发现,namecityName 发生变化时,都会触发 watch。但是,这里会发现两个问题:

  1. 无论是修改 name 或者 cityName 时,oldValuenewValue 的值是一样的;

  2. 尽管我们将 deep 属性设置成了 false,但是 cityName 的变化依然会触发 watch

这里得出两个结论:

  1. 当监听的响应式数据是 Proxy 类型时,newValueoldValue 由于是同一个引用,所以属性值是一样的;

  2. 当监听的响应式数据是 Proxy 类型时,deep 属性无效,无论设置成 true 还是 false,都会进行深度监听。

2. 监听 Proxy 数据中的某个属性时

由于在业务开发中,定义的数据中可能属性比较多,我们指向监听其中某一个属性,那我们看看该如何操作

当监听的属性是基本类型时

  • 如果只想监听 name 属性时,由于 name 是个基本类型,所以 source 参数需要用回调函数的方式进行监听:

    watch(
      () => state.name,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);

这是可以看到,newValue张三oldValue李四,并且在修改 cityName 时,不会再触发 watch

当监听的属性为引用类型时

  • 监听 address 属性时,我们也可以使用回调函数的方式进行监听

    watch(
      () => state.address,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);

豁。。。发现控制台现在一次日志 都不打印了,按道理说,修改 name 时,不触发 watch是正常的,但是修改 cityName 时,是想要触发的啊。

先看一下现在这种情况,如何触发 watch

watch(
  () => state.address,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

这个时候,发现 1秒 和 2秒 之后,控制台出现打印结果了。那我们知道了,需要修改 address 属性,才能触发监听,修改更深层的属性,触发不了,这个时候明白了,应该是没有深度监听,Ok,那我们把 deep 属性设置为 true 试试:

watch(
  () => state.address,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

果不其然,控制台中,正常的打印了两次日志,说明,无论直接修改 address 还是修改 address 内部的深层属性,都可以正常的触发 watch

好的,到这里,可能有些同学说了:那我直接监听 state.address 不就可以了吗?这样 deep 属性也不用加。

那我们演示一下看看会不会存在问题:

watch(state.address, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

在控制台,只打印了第一次修改 cityName 时的日志,第二次修改 address 时,无法触发 watch

好,现在把上面两次修改调换一下位置:

watch(state.address, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

控制台里,一次日志都没有了,也就意味着,修改 address 时,无法触发监听,并且之后,由于 address 的引用发生变化,导致后续 address 内部的任何修改也都触发不了 watch 了。这是一个致命问题。

这里也得出了两个结论:

  1. 当指向监听响应式数据的某一个属性时,需要使用函数的方式设置 source 参数:

    • 如果属性类型是基本类型,可以正常监听,并且 newValueoldValue ,可以正常返回;

    • 如果属性类型是引用类型,需要将 deep 设置为 true 才能进行深度监听。

  2. 如果属性类型时引用类型,并且没有用函数的方式注册 watch,那么在使用时,一旦重新对该属性赋值,会导致监听失效。

四、watch 监听 ref 声明的响应式数据

1. ref 声明的数据为基本类型时

ref 声明的数据为基本类型时,直接使用 watch 监听即可

const state = ref('张三');

watch(state, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = '李四';
}, 1000);

1秒 后,在控制台可以看到,打印出了 李四张三

众所周知,ref 声明的数据,都会自带 value 属性。所以下面这种写法效果同上:

const state = ref('张三');

watch(() => state.value, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = '李四';
}, 1000);

2. ref 声明的数据为引用类型时

ref 声明的数据为引用类型时,内部会接入 reactive 将数据转化为 Proxy 类型。所以该数据的 value 对应的是 Proxy 类型。

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(state, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

1秒后,控制台打印出了日志,但是 2秒后,却没有日志再出现了,这又是什么原因呢,我们把上面的代码转个形。在 ref 声明的数据为基本类型时,这段里说过,监听 state() => state.value ,效果是一样的,那我们看一下转换后的代码:

watch(() => state.value, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

上面说了,当 ref 声明的数据是引用类型时,内部会借助 reactive 转化为 Proxy 类型。那这段代码是不是感觉似曾相识?哈哈,不就是将 deep 属性设置为 true 就可以了么。

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(
  state,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

加上 deep 之后,可以看到,在 1秒及 2秒后,都会在控制台打印出日志。说明此时,无论是修改 statevalue,还是修改深层属性,都会触发 watch

有些同学可能说了,我直接函数返回 state 行不行:

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(
  () => state,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

好的,这里我帮大家试过了,跟上面的效果有些区别:

  • deepfalse 时,修改 value 或者深层属性,都不会触发 watch

  • 而设置deeptrue 时,修改 vaue 或者深层属性,都会触发 watch

五、watch 监听传入的 prop 时

1. Proxy 作为 prop 传递时

既然是 Proxy 类型的数据,那么我们直接按照之前演示的方式,直接使用不就好了么:

App 组件

<script setup>
import { reactive } from 'vue';
import Child from './Child.vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);
</script>

<template>
  <Child :data="state" />
</template>

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(props.data, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});
</script>

好的,在 1秒 和 2秒之后,可以看到控制台打印出的有两次日志。Ok,乍一看感觉没有问题哈,那我们修改一下 App 组件里的数据传递:

App 组件

<script setup>
import { reactive, ref } from 'vue';
import Child from './Child.vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

const otherState = reactive({
  name: '李四',
});

const flag = ref(true);

setTimeout(() => {
  flag.value = false;
}, 500);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);
</script>

<template>
  <Child :data="flag ? otherState : state" />
</template>

有些同学可能就问,flag ? otherState : state 这里用 computed 包装一下不行吗?当然可以,但是这里不是为了演示问题嘛,一切写法皆有可能对吧。

修改完 App 组件之后,按道理应该会打印三次日志,但是惊讶的发现:无论多久,控制台里都不会有日志打印,也就是说,data 属性的变化根本没有触发 watch。这是为啥呢?又该怎么处理呢?

  • 因为在 App 组件中,我们切换了要传递给 Child 组件的数据,所以 watch 监听的 prop 不是同一个了

  • 所以需要使用函数的方式监听 prop

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(() => props.data, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});
</script>

确实哈,修改完之后,控制台里打印了一次日志,而且新旧值不同,说明切换数据的时候监听到了,但还是不对,还少了两次。

到了这里,还记得我们上面讨论过的,使用函数作为 source 监听时,想监听深层的属性,那就需要添加 deep 属性为 true 才可以。

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(
  () => props.data,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);
</script>

好的,添加了 deep: true 之后,控制台中分别在 500ms、1秒、2秒后打印出了日志。此时达到了想要的效果。很棒!

2. ref 定义的数据作为 prop 传递时

当 ref 定义的数据作为 prop 进行传递时,会进行脱 ref 的操作,也就是说,基本类型会直接将数据作为 prop 传递,引用类型会作为 Proxy 传入

ref 定义数据为基本类型时

直接使用函数作为 source 参数,进行监听即可:

App 组件

<script setup>
import {  ref } from 'vue';
import Child from './Child.vue';

const state = ref('张三');

setTimeout(() => {
  state.value = '李四';
}, 1000);

</script>

<template>
  <Child :data="state" />
</template>

Child 组件,此时由于 ref 定义的是基本数据类型,所以也不存在是否需要深度监听的问题

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(
  () => props.data,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);
</script>

当 ref 定义数据为引用类型时

上面说过,ref 作为 prop 传递时,会脱 ref,也就意味着,传给子组件的就是 Proxy 类型的数据,用法及可能遇到的问题,请参照 proxy 作为 prop 传递时 里的代码和示例。

六、watch 监听 provide 提供的数据时

1. 提供的数据为 Proxy 时

proxy 作为 prop 传递时,请参照 proxy 作为 prop 传递时 里的代码和示例。

2. 提供的数据为 ref 时

provide API 提供的数据为 ref 时,不会进行脱 ref 操作,同 四、watch 监听 ref 声明的响应式数据,请参照 四、watch 监听 ref 声明的响应式数据 里的代码和示例

3. 提供的数据不是响应式数据时

可以在 watch 中使用函数的方式进行监听,前提是需要将 deep 设置 true 哦,这样对象内部如果包含了响应式的数据,也是可以触发监听的。

七、监听多个数据

在实际开发过程中,可能会需要同时监听多个值,我们看一下多个值的情况,watch 是如何处理以及响应的:

import { reactive, ref, watch } from 'vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

consotherState = reactive({
  name: '李四',
});

const flag = ref(true);

watch([state, () => otherState.name, flag], (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  flag.value = false;
}, 500);

setTimeout(() => {
  otherState.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);

可以再控制台看到,三次变化都会输出日志,并且 newValueoldValue 都是一个数组,里面值的顺序对应着 source 里数组的顺序。

八、竞态问题

在业务开发的过程中,时常面临这样的需求:监听某个数据的变化,当数据发生变化时,重新进行网络请求。下面写一段代码,来模拟这个需求:

<script setup>
import { reactive, ref, watch } from 'vue';

let count = 2;
const loadData = (data) =>
  new Promise((resolve) => {
    count--;
    setTimeout(() => {
      resolve(`返回的数据为${data}`);
    }, count * 1000);
  });

const state = reactive({
  name: '张三',
});

const data = ref('');

watch(
  () => state.name,
  (newValue) => {
    loadData(newValue).then((res) => {
      data.value = res;
    });
  }
);

setTimeout(() => {
  state.name = '李四';
}, 100);
setTimeout(() => {
  state.name = '王五';
}, 200);
</script>

<template>
  <div>{{ data }}</div>
</template>

可以看到界面上展示的结果是:返回的数据为李四,显然这不是我们想要的结果。最后一次是将 name 修改为了王五,所以肯定是希望返回的结果为王五。那出现这个异常的原因是什么呢?

数据每次变化,都会发送网络请求,但是时间长短不确定,所以就有可能导致,后发的请求先回来了,所以会被先发的请求返回结果给覆盖掉。

那么该如何解决呢?上面提到过,watchcallback 中具备第三个参数 onCleanup,我们来尝试着用一下:

watch(
  () => state.name,
  (newValue, oldValue, onCleanup) => {
    let isCurrent = true;
    onCleanup(() => {
      isCurrent = false;
    });
    loadData(newValue).then((res) => {
      if (isCurrent) {
        data.value = res;
      }
    });
  }
);

此时,在浏览器上,只会出现:返回的数据为王五

onCleanup 接受一个回调函数,这个回调函数,在触发下一次 watch 之前会执行,因此,可以在这里,取消上一次的网络请求,亦或做一些内存清理及数据变更等任何操作。

九、watchEffect

上面说了很多 watch 的应用场景和常见问题。在需要监听多个数据时,可以使用数组作为 source。但是多个数据,如果是很多个呢,可能比较负责的逻辑,其中使用了较多的响应式数据,这个时候,使用 watch 去监听,显然不太适合。

这里可以使用新的 API:watchEffect

1. watchEffect API

watchEffect(effect, options)

  • effect: 函数。内部依赖的响应式数据发生变化时,会触发 effect 重新执行

    • onCleanup:形参,函数类型,接受一个回调函数。每次更新时,会调用上一次注册的 onCleanup 函数。作用同 watch 中的 onCleanup 参数。
  • options

    • flushpre | post | sync

      • pre:在组件更新前执行副作用;

      • post:在组件更新后运行副作用,可以使用 watchPostEffect 替代;

      • sync:每个更改都强制触发 watch,可以使用 watchSyncEffect 替代。

    • onTrack:函数,具备 event 参数,调试用。将在响应式 propertyref 作为依赖项被追踪时被调用

    • onTrigger:函数,具备 event 参数,调试用。将在依赖项变更导致副作用被触发时被调用。

2. watchEffect 用法

import { reactive, ref, watchEffect } from 'vue';

const state = reactive({
  name: '张三',
});

const visible = ref(false);

watchEffect(() => {
  console.log(state.name, visible.value);
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  visible.value = true;
}, 2000);

2秒之后,查看控制台,发现打印了三次日志:

  • 第一次是初始值

  • 第二次是修改 name 触发的监听

  • 第三次是修改 visible 触发的监听

  • 而且每次打印的都是当前最新值

由此可以看出:

  1. watchEffect 默认监听,也就是默认第一次就会执行;

  2. 不需要设置监听的数据,在 effect 函数中,用到了哪个数据,会自动进行依赖,因此不用担心类似 watch 中出现深层属性监听不到的问题;

  3. 只能获取到新值,由于没有提前指定监听的是哪个数据,所以不会提供旧值。

3. watchEffect 可能会出现的问题

在上面的用法中,感觉 watchEffect 使用起来还是很方便的,会自动依赖,而且还不用考虑各种深度依赖的问题。那 watchEffect 会不会有什么陷阱需要注意呢?

import { reactive, ref, watchEffect } from 'vue';

const state = reactive({
  name: '张三',
});

const visible = ref(false);

watchEffect(() => {
  setTimeout(() => {
    console.log(state.name, visible.value);
  })
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  visible.value = true;
}, 2000);

这次在 effect 函数中添加了异步任务,在 setTimeout 中使用响应式数据,会发现,控制台一直都只展示一个日志:第一次进入时打印的。

也就是说,在异步任务(无论是宏任务还是微任务)中进行的响应式操作,watchEffect 无法正确的进行依赖收集。所以后面无论数据如何变更,都不会触发 effect 函数。

如果真的需要用到异步的操作,可以在外面先取值,再放到异步中去使用

watchEffect(() => {
  const name = state.name;
  const value = visible.value;
  setTimeout(() => {
    console.log(name, value);
  });
});

修改之后,在控制台中可以正常的看到三次日志。

十、总结

  1. wacthsourceProxy 类型时:

    • deep 属性失效,强制进行深度监听;

    • 新旧值指向同一个引用,导致内容是一样的。

  2. watchsourceRefImpl 类型时:

    • 直接监听 state 和 监听 () => state.value 是等效的;

    • 如果 ref 定义的是引用类型,并且想要进行深度监听,需要将 deep 设置为 true

  3. watchsource 是函数时,可以监听到函数返回值的变更。如果想监听到函数返回值深层属性的变化,需要将 deep 设置为 true

  4. 如果想监听多个值的变化,可以将 source 设置为数组,内部可以是 Proxy 对象,可以是 RefImpl 对象,也可以是具有返回值的函数;

  5. 在监听组件 props 时,建议使用函数的方式进行 watch,并且希望该 prop 深层任何属性的变化都能触发,可以将 deep 属性设置为 true

  6. 使用 watchEffect 时,注意在异步任务中使用响应式数据的情况,可能会导致无法正确进行依赖收集。如果确实需要异步操作,可以在异步任务外先获取响应式数据,再将值放到异步任务里进行操作。

十一:相关链接

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React Hooks 用法详解

本文对 16.8 版本之后 React 发布的新特性 Hooks 进行了详细讲解,并对一些常用的 Hooks 进行代码演示,希望可以对需要的朋友提供些帮助。

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

一、Hooks 简介

HooksReact v16.7.0-alpha 中加入的新特性。它可以让你在 class 以外使用 state 和其他 React 特性。
本文就是演示各种 Hooks API 的使用方式,对于内部的原理这里就不做详细说明。


二、Hooks 初体验

Foo.js

import React, { useState  } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

useState 就是一个 Hook,可以在我们不使用 class 组件的情况下,拥有自身的 state,并且可以通过修改 state 来控制 UI 的展示。


三、常用的两个 Hooks

1、useState

语法

const [state, setState] = useState(initialState)

  • 传入唯一的参数: initialState,可以是数字,字符串等,也可以是对象或者数组。
  • 返回的是包含两个元素的数组:第一个元素,state 变量,setState 修改 state 值的方法。

与在类中使用 setState 的异同点:

  • 相同点:在一次渲染周期中调用多次 setState,数据只改变一次。
  • 不同点:类中的 setState 是合并,而函数组件中的 setState 是替换。

使用对比

之前想要使用组件内部的状态,必须使用 class 组件,例如:

Foo.js

import React, { Component } from 'react';

export default class Foo extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        };
    }

    render() {
        return (
            <div>
            <p>You clicked {this.state.count} times</p>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
                Click me
            </button>
            </div>
        );
    }
}

而现在,我们使用函数式组件也可以实现一样的功能了。也就意味着函数式组件内部也可以使用 state 了。

Foo.js

import React, { useState } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

优化

创建初始状态是比较昂贵的,所以我们可以在使用 useState API 时,传入一个函数,就可以避免重新创建忽略的初始状态。

普通的方式:

// 直接传入一个值,在每次 render 时都会执行 createRows 函数获取返回值
const [rows, setRows] = useState(createRows(props.count));

优化后的方式(推荐):

// createRows 只会被执行一次
const [rows, setRows] = useState(() => createRows(props.count));

2、useEffect

之前很多具有副作用的操作,例如网络请求,修改 UI 等,一般都是在 class 组件的 componentDidMount 或者 componentDidUpdate 等生命周期中进行操作。而在函数组件中是没有这些生命周期的概念的,只能 return 想要渲染的元素。
但是现在,在函数组件中也有执行副作用操作的地方了,就是使用 useEffect 函数。

语法

useEffect(() => { doSomething });

两个参数:

  • 第一个是一个函数,是在第一次渲染以及之后更新渲染之后会进行的副作用。

    • 这个函数可能会有返回值,倘若有返回值,返回值也必须是一个函数,会在组件被销毁时执行。
  • 第二个参数是可选的,是一个数组,数组中存放的是第一个函数中使用的某些副作用属性。用来优化 useEffect

    • 如果使用此优化,请确保该数组包含外部作用域中随时间变化且 effect 使用的任何值。 否则,您的代码将引用先前渲染中的旧值。
    • 如果要运行 effect 并仅将其清理一次(在装载和卸载时),则可以将空数组([])作为第二个参数传递。 这告诉 React 你的 effect 不依赖于来自 propsstate 的任何值,所以它永远不需要重新运行。

虽然传递 [] 更接近熟悉的 componentDidMountcomponentWillUnmount 执行规则,但我们建议不要将它作为一种习惯,因为它经常会导致错误。

使用对比

假如此时我们有一个需求,让 documenttitleFoo 组件中的 count 次数保持一致。

使用 类组件:

Foo.js

import React, { Component } from 'react';

export default class Foo extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        };
    }

    componentDidMount() {
        document.title = `You clicked ${ this.state.count } times`;
    }

    componentDidUpdate() {
        document.title = `You clicked ${ this.state.count } times`;
    }

    render() {
        return (
            <div>
            <p>You clicked {this.state.count} times</p>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
                Click me
            </button>
            </div>
        );
    }
}

而现在在函数组件中也可以进行副作用操作了。

Foo.js

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

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    // 类似于 componentDidMount 和 componentDidUpdate:
    useEffect(() => {
        // 使用浏览器API更新文档标题
        document.title = `You clicked ${count} times`;
    });

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

不仅如此,我们可以使用 useEffect 执行多个副作用(可以使用一个 useEffect 执行多个副作用,也可以分开执行)

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
});

const handleClick = () => {
    console.log('鼠标点击');
}

useEffect(() => {
    // 给 window 绑定点击事件
    window.addEventListener('click', handleClick);
});

现在看来功能差不多了。但是在使用类组件时,我们一般会在 componentWillMount 生命周期中进行移除注册的事件等操作。那么在函数组件中又该如何操作呢?

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
});

const handleClick = () => {
    console.log('鼠标点击');
}

useEffect(() => {
    // 给 window 绑定点击事件
    window.addEventListener('click', handleClick);

    return () => {
        // 给 window 移除点击事件
        window.removeEventListener('click', handleClick);
    }
});

可以看到,我们传入的第一个参数,可以 return 一个函数出去,在组件被销毁时,会自动执行这个函数

优化 useEffect

上面我们一直使用的都是 useEffect 中的第一个参数,传入了一个函数。那么 useEffect 的第二个参数呢?

useEffect 的第二个参数是一个数组,里面放入在 useEffect 使用到的 state 值,可以用作优化,只有当数组中 state 值发生变化时,才会执行这个 useEffect

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
}, [ count ]);

Tip:如果想模拟 class 组件的行为,只在 componetDidMount 时执行副作用,在 componentDidUpdate 时不执行,那么 useEffect 的第二个参数传一个 [] 即可。(但是不建议这么做,可能会由于疏漏出现错误)


四、其他 Hooks API

1、useContext

语法

const value = useContext(MyContext);

接受上下文对象(从中 React.createContext 返回的值)并返回该上下文的当前上下文值。当前上下文值由树中调用组件上方 value 最近的 prop 确定 <MyContext.Provider>

useContext(MyContext) 则相当于 static contextType = MyContext 在类中,或者 <MyContext.Consumer>

用法

App.js 文件中创建一个 context,并将 context 传递给 Foo 子组件

App.js

import React, { createContext } from 'react';
import Foo from './Foo';

import './App.css';

export const ThemeContext = createContext(null);

export default () => {

    return (
        <ThemeContext.Provider value="light">
            <Foo />
        </ThemeContext.Provider>
    )
}

Foo 组件中,使用 useContext API 可以获取到传入的 context

Foo.js

import React, { useContext } from 'react';

import { ThemeContext } from './App';

export default () => {
    
    const context = useContext(ThemeContext);

    return (
        <div>Foo 组件:当前 theme 是:{ context }</div>   
    )
}

注意事项

useContext 必须是上下文对象本身的参数:

  • 正确: useContext(MyContext)
  • 不正确: useContext(MyContext.Consumer)
  • 不正确: useContext(MyContext.Provider)

useContext(MyContext) 只允许您阅读上下文并订阅其更改。您仍然需要 <MyContext.Provider> 在树中使用以上内容来为此上下文提供值。

2、useReducer

语法

const [state, dispatch] = useReducer(reducer, initialArg, init);

useState 的替代方案。 接受类型为 (state, action) => newState 的reducer,并返回与 dispatch 方法配对的当前状态。

当你涉及多个子值的复杂 state(状态) 逻辑时,useReducer 通常优于 useState

用法

Foo.js

import React, { useReducer } from 'react';

const initialState = {count: 0};

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        default:
            throw new Error();
    }
}

export default () => {
    
    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );
}

优化:延迟初始化

还可以惰性地创建初始状态。为此,你可以将init函数作为第三个参数传递。初始状态将设置为 init(initialArg)

它允许你提取用于计算 reducer 外部的初始状态的逻辑。这对于稍后重置状态以响应操作也很方便:

Foo.js

import React, { useReducer } from 'react';

function init(initialCount) {
    return {count: initialCount};
}
  
function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        case 'reset':
            return init(action.payload);
        default:
            throw new Error();
    }
}

export default ({initialCount = 0}) => {
    
    const [state, dispatch] = useReducer(reducer, initialCount, init);
    return (
        <>
            Count: {state.count}
            <br />
            <button
                onClick={() => dispatch({type: 'reset', payload: initialCount})}>
                Reset
            </button>
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );

}

与 useState 的区别

  • state 状态值结构比较复杂时,使用 useReducer 更有优势。
  • 使用 useState 获取的 setState 方法更新数据时是异步的;而使用 useReducer 获取的 dispatch 方法更新数据是同步的。

针对第二点区别,我们可以演示一下:
在上面 useState 用法的例子中,我们新增一个 button

useState 中的 Foo.js

import React, { useState } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
            <button onClick={() => {
                setCount(count + 1);
                setCount(count + 1);
            }}>
                测试能否连加两次
            </button>
        </div>
    );
}

export default Foo;

点击 测试能否连加两次 按钮,会发现,点击一次, count 还是只增加了 1,由此可见,useState 确实是 异步 更新数据;

在上面 useReducer 用法的例子中,我们新增一个 button
useReducer 中的 Foo.js

import React, { useReducer } from 'react';

const initialState = {count: 0};

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        default:
            throw new Error();
    }
}

export default () => {
    
    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            <button onClick={() => {
                dispatch({type: 'increment'});
                dispatch({type: 'increment'});
            }}>
                测试能否连加两次
            </button>
        </>
    );
}

点击 测试能否连加两次 按钮,会发现,点击一次, count 增加了 2,由此可见,每次 dispatch 一个 action 就会更新一次数据,useReducer 确实是 同步 更新数据;

3、useCallback

语法

const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);

返回值 memoizedCallback 是一个 memoized 回调。传递内联回调和一系列依赖项。useCallback 将返回一个回忆的memoized版本,该版本仅在其中一个依赖项发生更改时才会更改。

当将回调传递给依赖于引用相等性的优化子组件以防止不必要的渲染(例如 shouldComponentUpdate)时,这非常有用。

配合子组件使用 PureComponentmemo 时,可以减少子组件不必要的渲染次数

用法

  • 不使用 useCallback 的情况下给子组件传递函数

    Foo.js

    import React from 'react';
    
    const Foo = ({ onClick }) => {
        
        console.log('Foo:', 'render');
        return <button onClick={onClick}>Foo 组件按钮</button>
    
    }
    
    export default Foo

    Bar.js

    import React from 'react';
    
    const Bar = ({ onClick }) => {
    
        console.log('Bar:', 'render');
        return <button onClick={onClick}>Bar 组件按钮</button>;
    
    };
    
    export default Bar;

    App.js

    import React, { useState } from 'react';
    import Foo from './Foo';
    import Bar from './Bar';
    
    function App() {
        const [count, setCount] = useState(0);
    
        const fooClick = () => {
            console.log('点击了 Foo 组件的按钮');
        };
    
        const barClick = () => {
            console.log('点击了 Bar 组件的按钮');
        };
    
        return (
            <div style={{ padding: 50 }}>
                <p>{count}</p>
                <Foo onClick={fooClick} />
                <br />
                <br />
                <Bar onClick={barClick} />
                <br />
                <br />
                <button onClick={() => setCount(count + 1)}>count increment</button>
            </div>
        );
    }
    
    export default App;

    此时我们点击上面任意 count increment 按钮,都会看到控制台打印了两条输出, Foo 和 Bar 组件都会被重新渲染。但其实在我们当前的逻辑中,Foo 和 Bar 组件根本不需要重新 render

    现在我们使用 useCallback 进行优化

  • 使用 useCallback 优化后的版本

    Foo.js

    import React from 'react';
    
    const Foo = ({ onClick }) => {
    
        console.log('Foo:', 'render');
        return <button onClick={onClick}>Foo 组件按钮</button>;
    
    };
    
    export default React.memo(Foo);

    Bar.js

    import React from 'react';
    
    const Bar = ({ onClick }) => {
    
        console.log('Bar:', 'render');
        return <button onClick={onClick}>Bar 组件按钮</button>;
    
    };
    
    export default React.memo(Bar);

    App.js

    import React, { useCallback, useState } from 'react';
    import Foo from './Foo';
    import Bar from './Bar';
    
    function App() {
        const [count, setCount] = useState(0);
    
        const fooClick = useCallback(() => {
            console.log('点击了 Foo 组件的按钮');
        }, []);
    
        const barClick = useCallback(() => {
            console.log('点击了 Bar 组件的按钮');
        }, []);
    
        return (
            <div style={{ padding: 50 }}>
                <p>{count}</p>
                <Foo onClick={fooClick} />
                <br />
                <br />
                <Bar onClick={barClick} />
                <br />
                <br />
                <button onClick={() => setCount(count + 1)}>count increment</button>
            </div>
        );
    }
    
    export default App;

    此时点击 count increment 按钮,可以看到控制台没有任何输出。

    如果将 useCallback 或者 React.memo 移除,可以看到对应的组件又会出现不必要的 render

4、useMemo

语法

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一个memoized值。
传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

useMemo在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

用法

  • 可以进行数据的缓存,类似于 Vuecomputed,可以根据依赖变化自动重新计算
  • 可以帮助我们优化子组件的渲染,比如这种场景:
    在 App 组件中有两个子组件 Foo 和 Bar,当 App 组件中传给 Foo 组件的 props 发生变化时,App 组件状态会改变,重新渲染。此时 Foo 组件 和 Bar 组件 也都会重新渲染。其实这种情况是比较浪费资源的,现在我们就可以使用 useMemo 进行优化,Foo 组件用到的 props 变化时,只有 Foo 组件进行 render,而 Bar 却不会重新渲染。

例子:

Foo.js

import React from 'react';

export default ({ text }) => {
    
    console.log('Foo:', 'render');
    return <div>Foo 组件:{ text }</div>

}

Bar.js

import React from 'react';

export default ({ text }) => {
    
    console.log('Bar:', 'render');
    return <div>Bar 组件:{ text }</div>

}

App.js

import React, { useState } from 'react';
import Foo from './Foo';
import Bar from './Bar';

export default () => {

    const [a, setA] = useState('foo');
    const [b, setB] = useState('bar');

    return (
        <div>
            <Foo text={ a } />
            <Bar text={ b } />
            <br />
            <button onClick={ () => setA('修改后的 Foo') }>修改传给 Foo 的属性</button>
            &nbsp;&nbsp;&nbsp;&nbsp;
            <button onClick={ () => setB('修改后的 Bar') }>修改传给 Bar 的属性</button>
        </div>
    )
}

此时我们点击上面任意一个按钮,都会看到控制台打印了两条输出, A 和 B 组件都会被重新渲染。

现在我们使用 useMemo 进行优化

App.js

import React, { useState, useMemo } from 'react';
import Foo from './Foo';
import Bar from './Bar';

import './App.css';

export default () => {

    const [a, setA] = useState('foo');
    const [b, setB] = useState('bar');

+    const foo = useMemo(() => <Foo text={ a } />, [a]);
+    const bar = useMemo(() => <Bar text={ b } />, [b]);

    return (
        <div>
+            {/* <Foo text={ a } />
+            <Bar text={ b } /> */}
+            { foo }
+            { bar }
            <br />
            <button onClick={ () => setA('修改后的 Foo') }>修改传给 Foo 的属性</button>
            &nbsp;&nbsp;&nbsp;&nbsp;
            <button onClick={ () => setB('修改后的 Bar') }>修改传给 Bar 的属性</button>
        </div>
    )
}

此时我们点击不同的按钮,控制台都只会打印一条输出,改变 a 或者 b,A 和 B 组件都只有一个会重新渲染。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

5、useRef

语法

const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

  • 从本质上讲,useRef 就像一个“盒子”,可以在其 .current 财产中保持一个可变的价值。
  • useRef Hooks 不仅适用于 DOM 引用。 “ref” 对象是一个通用容器,其 current 属性是可变的,可以保存任何值(可以是元素、对象、基本类型、甚至函数),类似于类上的实例属性。
  • useRef 具有闭包穿透的能力

注意:useRef() 比 ref 属性更有用。与在类中使用 instance(实例) 字段的方式类似,它可以 方便地保留任何可变值。

注意,内容更改时useRef 不会通知您。变异.current属性不会导致重新渲染。如果要在 React 将引用附加或分离到DOM节点时运行某些代码,则可能需要使用回调引用。

用法

下面这个例子中展示了可以在 useRef() 生成的 refcurrent 中存入元素、字符串

Example.js

import React, { useRef, useState, useEffect } from 'react'; 

export default () => {
    
    // 使用 useRef 创建 inputEl 
    const inputEl = useRef(null);

    const [text, updateText] = useState('');

    // 使用 useRef 创建 textRef 
    const textRef = useRef();

    useEffect(() => {
        // 将 text 值存入 textRef.current 中
        textRef.current = text;
        console.log('textRef.current:', textRef.current);
    });

    const onButtonClick = () => {
        // `current` points to the mounted text input element
        inputEl.current.value = "Hello, useRef";
    };

    return (
        <>
            {/* 保存 input 的 ref 到 inputEl */}
            <input ref={ inputEl } type="text" />
            <button onClick={ onButtonClick }>在 input 上展示文字</button>
            <br />
            <br />
            <input value={text} onChange={e => updateText(e.target.value)} />
        </>
    );

}

点击 在 input 上展示文字 按钮,就可以看到第一个 input 上出现 Hello, useRef;在第二个 input 中输入内容,可以看到控制台打印出对应的内容。

6、useLayoutEffect

语法

useLayoutEffect(() => { doSomething });

useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

进行副作用操作时尽量优先选择 useEffect,以免阻止视觉更新。与 DOM 无关的副作用操作请使用 useEffect

用法

用法与 useEffect 类似。但会在 useEffect 之前执行

Foo.js

import React, { useRef, useState, useLayoutEffect } from 'react'; 

export default () => {

    const divRef = useRef(null);

    const [height, setHeight] = useState(100);

    useLayoutEffect(() => {
        // DOM 更新完成后打印出 div 的高度
        console.log('useLayoutEffect: ', divRef.current.clientHeight);
    })
    
    return <>
        <div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
        <button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
    </>

}

7、useImperativeHandle

在函数组件中,没有组件实例,所以无法像类组件中,通过绑定子组件的实例调用子组件中的状态或者方法。

那么在函数组件中,如何在父组件调用子组件的状态或者方法呢?答案就是使用 useImperativeHandle

语法

useImperativeHandle(ref, createHandle, [deps])

  • 第一个参数是 ref 值,可以通过属性传入,也可以配合 forwardRef 使用

  • 第二个参数是一个函数,返回一个对象,对象中的属性都会被挂载到第一个参数 refcurrent 属性上

  • 第三个参数是依赖的元素集合,同 useEffectuseCallbackuseMemo,当依赖发生变化时,第二个参数会重新执行,重新挂载到第一个参数的 current 属性上

用法

注意:

  • 第三个参数,依赖必须按照要求填写,少了会导致返回的对象属性异常,多了会导致 createHandle 重复执行
  • 一个组件或者 hook 中,对于同一个 ref,只能使用一次 useImperativeHandle,多次的话,后面执行的 useImperativeHandlecreateHandle 返回值会替换掉前面执行的 useImperativeHandlecreateHandle 返回值

Foo.js

import React, { useState, useImperativeHandle, useCallback } from 'react';

const Foo = ({ actionRef }) => {
    const [value, setValue] = useState('');

    /**
     * 随机修改 value 值的函数
     */
    const randomValue = useCallback(() => {
        setValue(Math.round(Math.random() * 100) + '');
    }, []);

    /**
     * 提交函数
     */
    const submit = useCallback(() => {
        if (value) {
            alert(`提交成功,用户名为:${value}`);
        } else {
            alert('请输入用户名!');
        }
    }, [value]);

    useImperativeHandle(
        actionRef,
        () => {
            return {
                randomValue,
                submit,
            };
        },
        [randomValue, submit]
    );

    /* !! 返回多个属性要按照上面这种写法,不能像下面这样使用多个 useImperativeHandle
      useImperativeHandle(actionRef, () => {
          return {
              submit,
          }
      }, [submit])

      useImperativeHandle(actionRef, () => {
          return {
              randomValue
          }
      }, [randomValue])
  */

    return (
        <div className="box">
            <h2>函数组件</h2>
            <section>
                <label>用户名:</label>
                <input
                    value={value}
                    placeholder="请输入用户名"
                    onChange={e => setValue(e.target.value)}
                />
            </section>
            <br />
        </div>
    );
};

export default Foo;

App.js

import React, { useRef } from 'react';
import Foo from './Foo'

const App = () => {
    const childRef = useRef();

    return (
        <div>
            <Foo actionRef={childRef} />
            <button onClick={() => childRef.current.submit()}>调用子组件的提交函数</button>
            <br />
            <br />
            <button onClick={() => childRef.current.randomValue()}>
                随机修改子组件的 input 值
            </button>
        </div>
    );
};

五、尝试编写自定义 Hooks

这里我们就仿照官方的 useReducer 做一个自定义的 Hooks

1、编写自定义 useReducer

src 目录下新建一个 useReducer.js 文件:

useReducer.js

import React, { useState } from 'react';

function useReducer(reducer, initialState) {
    const [state, setState] = useState(initialState);

    function dispatch(action) {
        const nextState = reducer(state, action);
        setState(nextState);
    }

    return [state, dispatch];
}

Tip: Hooks 不仅可以在函数组件中使用,也可以在别的 Hooks 中进行使用。

2、使用自定义 useReducer

好了,自定义 useReducer 编写完成了,下面我们看一下能不能正常使用呢?

改写 Foo 组件

Example.js

import React from 'react';

// 从自定义 useReducer 中引入
import useReducer from './useReducer';

const initialState = {count: 0};

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        default:
            throw new Error();
    }
}

export default () => {
    
    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );
}

五、Hooks 使用及编写规范

  • 不要从常规 JavaScript 函数调用 Hooks;

  • 不要在循环,条件或嵌套函数中调用 Hooks;

  • 必须在组件的顶层调用 Hooks;

  • 可以从 React 功能组件调用 Hooks;

  • 可以从自定义 Hooks 中调用 Hooks;

  • 自定义 Hooks 必须使用 use 开头,这是一种约定;


六、使用 React 提供的 ESLint 插件

根据上一段所写,在 React 中使用 Hooks 需要遵循一些特定规则。但是在代码的编写过程中,可能会忽略掉这些使用规则,从而导致出现一些不可控的错误。这种情况下,我们就可以使用 React 提供的 ESLint 插件:eslint-plugin-react-hooks。下面我们就看看如何使用吧。

安装 ESLint 插件

$ npm install eslint-plugin-react-hooks --save

在 .eslintrc 中使用插件

// Your ESLint configuration
// "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
// "react-hooks/exhaustive-deps": "warn" // Checks effect dependencies
{
  "plugins": [
    "react-hooks"
  ],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

七、参考文档

React 官网

React Hooks FAQ

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 18 新特性(一):自动批量更新

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

18 版本之前

经典面试题:setState 是同步还是异步

react 18 版本之前,在面试中经常会出现这个问题,那么答案又是什么样的呢?

  • React 合成事件中是异步的

  • hooks 中是异步的

  • 其他情况皆是同步的,例如:原生事件、setTimeoutPromise

看看下面这段代码的执行结果,就知道所言非虚了

class App extends React.Component {

    state = {
        count: 0
    }
    
    componentDidMount() {
        this.setState({count: this.state.count + 1})
        console.log(this.state.count);
        this.setState({count: this.state.count + 1})
        console.log(this.state.count);

        setTimeout(() => {
            this.setState({count: this.state.count + 1})
            console.log(this.state.count);
            this.setState({count: this.state.count + 1})
            console.log(this.state.count);
        });
        
    }

    render() {
        return <h1>Count: {this.state.count}</h1>
    }
}

有经验的同学肯定都知道,最终的结果是: 0 0 2 3

原因就是因为 componentDidMount 中的 setState 是批量更新,在整体逻辑没走完之前,不会进行更新。所以前两次打印结果都是 0,并且将两次更新合并成了一次。

而在 setTimeout 中,脱离了 React 的掌控,变成了同步更新,因为下方的 log 可以实时打印出即时的状态。

此时 React 的内部的处理逻辑我们可以写一段代码简单模拟一下:

  • 先声明三个变量,用来记录数据

    • isBatchUpdate: 判断是否批量更新的标志

    • count: 状态

    • queue: 存储状态的数组

  • 声明一个 handleClick 方法,来模拟 React 合成事件

  • 声明一个 setState 方法,来模拟 ReactsetState

// 判断是否批量更新的标志
let isBatchUpdate = false;
// 状态
let count = 0;
// 存储最新状态的数组
let queue = [];
const setState = (state) => {
    // 批量更新,则将状态暂存,否则直接更新
    if (isBatchUpdate) {
        queue.push(state);
    } else {
        count = state;
    }
}

const handleClick = () => {
    // 进入事件,先将 isBatchUpdate 设置为 true
    isBatchUpdate = true

    setState(count + 1)
    console.log(count);
    setState(count + 1)
    console.log(count);
    setTimeout(() => {
        setState(count + 1)
        console.log(count);
        setState(count + 1)
        console.log(count);
    })
    
    // 事件结束,将 isBatchUpdate 置为 false
    isBatchUpdate = false;
}

handleClick();

count = queue.pop();

// 更新完成,重置状态数组 queue
queue = [];

可以看到,上面这段代码的打印结果也是 0 0 2 3

手动批量更新

上面提到,在原生事件以及 setTimeout 等情况下,setState 是同步的,那如果我们仍然希望这种情况下可以同步更新,该怎么办呢?

React 也提供了一种解决方案:从 react-dom 包中暴露了一个 API: unstable_batchedUpdates

那我们简单用一下看看效果:

class App extends React.Component {

    state = {
        count: 0
    }
    
    componentDidMount() {
        this.setState({count: this.state.count + 1})
        console.log(this.state.count);
        this.setState({count: this.state.count + 1})
        console.log(this.state.count)

        setTimeout(() => {
            ReactDOM.unstable_batchedUpdates(() => {
                this.setState({count: this.state.count + 1})
                console.log(this.state.count)
                this.setState({count: this.state.count + 1})
                console.log(this.state.count)
            }) 
        })
    }

    render() {
        return <h1>Count: {this.state.count}</h1>
    }
}

可以看到此时的打印结果为 0 0 1 1

Ok,React 18 之前 setState 的更新方式就说到这里,那 React 18 里做了什么改动呢?

React 18 版本之后

上面提到了默认批量更新以及手动批量更新,那有些同学不满足了呀,觉得手动的还是不够智能,在很多情况下还得手动去调用 unstable_batchedUpdates 这个函数,用起来不爽。

别急,React 18 新版本就可以解决这些同学的痛点了!

Ok,直接上代码,看看 React 18 到底怎么用的

class App extends React.Component {

    state = {
        count: 0
    }
    
    componentDidMount() {
        this.setState({count: this.state.count + 1})
        console.log(this.state.count);
        this.setState({count: this.state.count + 1})
        console.log(this.state.count)

        setTimeout(() => {
            this.setState({count: this.state.count + 1})
            console.log(this.state.count)
            this.setState({count: this.state.count + 1})
            console.log(this.state.count)
        })
    }

    render() {
        return <h1>Count: {this.state.count}</h1>
    }
}

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('#root')!).render(<App />)

组件代码保持和第一版的一致,没有使用 unstable_batchedUpdates

可以看到,此时的打印结果也是: 0 0 1 1

仅仅是使用了新的 APIReactDOM.createRoot(root).render(jsx)。React 就能实现自动的批量更新了。感觉有点神奇。

我们依然写一段代码来模拟一下这个过程:

  • 此时不需要 isBatchUpdate 来判断是否批量更新了,而是通过更新的优先级来进行判断

  • 每次更新会进行优先级的判定,相同优先级的任务会被合并。

  • 事件执行完毕,进行任务的执行和更新

// 状态
let count = 0;
// 存储状态的数组
let queue = [];
const setState = (state) => {
    const newState = {payload: state, priority: 0 }
    // 判断当前优先级的任务集合是否存在,不存在则初始化,存在则存到对应由县级的任务集合中
    if (queue[newState.priority]) {
        queue[newState.priority].push(newState.payload)
    } else {
        queue[newState.priority] = [newState.payload]
    }
}

const handleClick = () => {
    setState(count + 1)
    console.log(count);
    setState(count + 1)
    console.log(count);
    setTimeout(() => {
        setState(count + 1);
        console.log(count);
        setState(count + 1)
        console.log(count);
    })
}

handleClick();

count = queue.pop().pop();

setTimeout(() => {
    count = queue.pop().pop();
})

可以看到,上面这段代码的执行结果也是 0 0 1 1

上述模拟代码仅为了展示优先级批量更新,不代表任何 React 源码的逻辑和**

好了,自动批量更新的新特性就说到这里了。这里引入了三个问题:

  1. Q: React 18 之后提供了 ReactDOM.createRoot(root).render(jsx) 的 API,那之前 ReactDOM.render 的 API 还支持吗?

    A: 支持的,并且行为和之前版本是一致的。只有使用了 ReactDOM.createRoot 这种方式,才会启用新的并发模式。

  2. Q: React 全自动更新后,那如果我就是想拿到更新之后的数据怎么办呢?
    A: 类组件中可以使用 setState(state, callback) 的方式,在 callback 中取到最新的值,函数组件可以使用 useEffect,将 state 作为依赖。即可以拿到最新的值。

  3. Q: 文章中说到的优先级的概念是怎么回事呢?
    A: 这个涉及到 React 最新的调度以及更新的机制,优先级的概念以及其他优先级的任务如何创建,我们之后会一一展开来说。

    目前的话,可以理解为 React 的更新机制进行了变化,不再依赖于批量更新的标志。而是根据任务优先级来进行更新:高优先级的任务先执行,低优先级的任务后执行。

写在后面

代码量很少,主要是修改了 ReactDOM 的渲染方式,可以亲自尝试一下,有疑惑的地方可以说出来一起进行讨论。

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 超详细入门知识

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

Overview

本文共用 19 个例子,详细讲解了 React 基础入门知识,列举了相关 API 的使用方式,并且在每个 API 的说明中给出了详细的使用规则、建议以及注意事项。

对于不熟悉 React 的朋友,可以作为入门文档进行学习

对于已经掌握 React 的朋友,也可以作为 API 参考手册,用来查漏补缺

Index

HTML Template

demo 相关的代码都依赖以下模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React JSX</title>
    
    <style>
        .blue {
            color: blue;
        }
    </style>
</head>
<body>
    <div id="root"></div>

    <script src="../libs/react.min.js"></script>
    <script src="../libs/react-dom.min.js"></script>
    <script src="../libs/babel.min.js"></script>
    <script type="text/jsx">
        // 真正编写 jsx 代码的地方
    </script>
</body>

</html>

Demo01

demo / source

jsx 语法的基本使用

类似于 xml 的代码格式,但是可以书写 js 逻辑

// 利用 babel 可以直接在 javascript 环境下使用 jsx 语法

// 由于 class 是关键字,所以在 jsx 中给元素设置 class 需要使用 className
const jsx = (
  <div>
    <h1 className="blue">Hello React</h1>
    <p>用于构建用户界面的 JavaScript 库</p>
  </div>
);

ReactDOM.render(jsx, root);

Demo02

demo / source

如何在 jsx 语法中编写 js 代码

  • 在标签内需要使用 js 语法的时候,使用 {}js 表达式包裹起来即可

  • {} 中可以是 js 基础类型、引用类型(对象,数组等),也可以是 js 表达式

  • 无论是标签内部还是标签属性,都需要在 {} 内才能使用 js 语法

  • 使用 {/* */} 可以在 jsx 语法中书写注释

下面演示在 jsx 中使用 js 的循环、条件判断语法

const todoList = ['吃饭', '睡觉', '敲代码'];

function handleAlert() {
  alert('Hello React!')
}

const a = 1;
const b = 2;
const showModal = true;
const loadingStatus = 'refreshing';

const jsx = (
  <div>
    {/* style 可以使用对象的形式来写,style 的属性必须使用驼峰法则 */}
    <h1 style={{ fontSize: 24, color: 'blue' }}>Hello React</h1>

    {/* 逻辑运算符 */}
    {a === b && <section>等于</section>}

    {/* 三目运算符 */}
    {showModal ? <section>弹窗组件</section> : null}

    {/* 列表循环生成新数组,数组内元素会被直接渲染到界面,
            每个节点可以给一个 key 值,方便 react 在更新时的 diff 对比 */}
    <ul>
      {todoList.map(todo => <li key={todo}>{todo}</li>)}
    </ul>
    <p>
      {
        {
          'loading': '加载中。。。。',
          'refreshing': '点击刷新重试!',
          'no-more': '没有更多了'
        }[loadingStatus] /** loadingStatus 是 `loading`、`refreshing`、`no-more`  其中一种状态 **/
      }
    </p>

    {/* 添加事件 */}
    <button onClick={handleAlert}>弹出提示</button>
  </div>
);

ReactDOM.render(jsx, root);

Demo03

demo / source

类组件和函数组件的声明和使用

下面的代码演示 React 组件的声明和使用

// 1、类组件,需要继承 React.Component,render 函数的执行结果会被作为界面展示内容
class ClassComponent extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, Class Component</h1>
      </div>
    );
  }
}

// 2、函数组件,本身就是一个函数,函数的执行结果会被作为界面展示内容
const FunctionComponent = () => {
  return (
    <div>
      <h1>Hello, Function Component</h1>
    </div>
  );
}

const App = () => (
  <div>
    <ClassComponent/>
    <FunctionComponent/>
  </div>
)

ReactDOM.render(<App/>, root);

Demo04

demo / source

如何为函数组件和类组件中的元素添加事件绑定?

在类组件中,为元素绑定事件时,事件函数内可能会用到组件的一些属性或者方法,那么此时 this 指向会出现问题。目前可以使用以下三种解决办法:

  • 使用箭头函数代替普通函数

  • 使用 bind 函数绑定 this 指向

  • 使用匿名函数的方式调用组件的属性或者方法

而函数组件中不存在这个问题

class ClassComponent extends React.Component {

    // 箭头函数
    arrowFunction = () => {
        console.log('使用箭头函数,this 指向:', this);
    }

    // bind 绑定 this
    bindFunction() {
        console.log('使用 bind 改变 this 指向:', this);
    }

    render() {
        return (
            <React.Fragment>
                <h3>类组件</h3>
                <div>
                    <button onClick={ this.arrowFunction }>箭头函数打印 this</button>
                    <br /><br />
                    <button onClick={ this.bindFunction.bind(this) }>bind 函数打印 this</button>
                    <br /><br />
                    <button onClick={() => console.log('匿名函数调用,this 指向:', this)}>匿名函数打印 this</button>
                </div>
            </React.Fragment>
        );
    }
}

/**
  * 在函数组件中,是不存在组件的 this 实例的,因此绑定事件时,不需要有类组件中的顾虑
  */
const FunctionComponent = () => {
    // 箭头函数
    const arrowFunction = () => {
        console.log('使用箭头函数');
    }

    // bind 绑定函数
    const bindFunction = function() {
        console.log('使用 bind 调用函数');
    }

    // 普通函数
    const normalFunction = function() {
        console.log('调用普通函数');
    }
    return (
        <React.Fragment>
            <h3>函数组件</h3>
            <div>
                <button onClick={ arrowFunction }>普通函数</button>
                <br /><br />
                <button onClick={ arrowFunction }>箭头函数</button>
                <br /><br />
                <button onClick={ bindFunction.bind(this) }>bind 函数</button>
                <br /><br />
                <button onClick={() => console.log('匿名函数调用')}>匿名函数</button>
            </div>
        </React.Fragment>
    );
}

const App = () => (
    <div>
        <ClassComponent />
        <FunctionComponent />
    </div>
)

ReactDOM.render(<App />, root);

Demo05

demo / source

React 组件中父子组件传值的方式:props

  • 类组件的实例上会挂载 props 属性,包含父组件传递过来的所有参数
  • 函数组件会接受一个 props 参数,包含父组件传递过来的所有参数
  • props 中会包含一个 children 属性,标签内的所有内容都会被存放到 children 中。可以是标签、组件或者文本
/**
  * 1、类组件的实例上会挂载 props 属性,包含父组件传递过来的所有参数
  *
  * props 中会包含一个 children 属性,标签内的所有内容都会被存放到 children 中。
  * 可以是标签、组件或者文本
  */
class ClassComponent extends React.Component {
    render() {
        return (
            <div className="box">
                <h1>Class Component</h1>
                <p>Receive Message: { this.props.msg }</p>
                { this.props.children }
            </div>
        );
    }
}

/**
  * 2、函数组件会接受一个 props 参数,包含父组件传递过来的所有参数
  *
  * props 中会包含一个 children 属性,标签内的所有内容都会被存放到 children 中。
  * 可以是标签、组件或者文本
  */
const FunctionComponent = (props) => {
    return (
        <div className="box">
            <h1>Function Component</h1>
            <p>Receive Message: { props.msg }</p>
            { props.children }
        </div>
    );
}

const App = () => (
    <div>
        <h1>App</h1>
        <ClassComponent msg="App 传递过来的 msg 信息">
            App 传递过来的 children 是文本
        </ClassComponent>
        <FunctionComponent  msg="App 传递过来的 msg 信息">
            App 传递过来的 children 是 ClassComponent 组件:
            <ClassComponent msg="Function Component 传递过来的 msg 信息"/>
        </FunctionComponent>
    </div>
);

ReactDOM.render(<App />, root);

Demo06

demo / source

React 类组件如何控制自身状态变化并触发界面更新?

  • 类组件中通过 state 属性控制自身状态变化,导致组件重新进行渲染(render

  • 类组件在 state 属性中对数据进行初始化,state 是一个对象

  • 通过实例的 setState 函数可以更新 state 内容,重新渲染界面

  • setState 接受两个参数,第一个参数是对象或者是函数,第二个参数回调函数 callback

    • 第一个参数是对象时,直接将对象和 state 进行合并,设置为新的 state

    例如 state 初始值为 {a: 1, b: 2}, setState({ b, 3 }),那么新的 state 为 { a: 1, b: 3 }

    • 第一个参数为函数时,函数的参数就是上一次 state 值,返回值就是希望更新的对象,更新规则同上

    例如 state 初始值为 {a: 1, b: 2},setState((prevState) => ({ b: prevState.b - 1 })),那么新的 state 为 { a: 1, b: 1 }

class App extends React.Component {

    state = {
        count: 0
    }

    increment = () => {
        this.setState({
            count: this.state.count + 1
        }, () => {
            console.log(`最新的 state 值:${this.state.count}`)
        });
    }

    decrement = () => {
        this.setState((prevState) => ({
            count: prevState.count - 1
        }));
    }

    render() {
        return (
            <div>
                <p>count: { this.state.count }</p>
                <button onClick={ this.increment }>increment</button>
                <br />
                <br />
                <button onClick={ this.decrement }>decrement</button>
            </div>
        );
    }
}

ReactDOM.render(<App />, root);

Demo07

demo / source

类组件中 setState 用法详解

  • 在同一 React 事件中,多次 setState 会被合并
  • 在原生事件中,多个 setState 不会被合并,会按顺序执行
  • 在定时器中(无论是多个定时器中分别执行 setState,还是一个定时器中执行 setState )执行 setState,不会被合并,会按照代码顺序执行
class App extends React.Component {

    state = {
        count: 0
    }

    handleClick = () => {
        // 1、同一 React 事件内的多次 setState 会被合并,最终的结果 count 只会 + 1

        this.setState({
            count: this.state.count + 2
        });

        this.setState({
            count: this.state.count + 1
        });
    }

    handleCallbackClick = () => {
        /*
          * 2、setState 第二个参数是一个回调函数 callback,当上一次 setState 完成时,会触发这个回调函数
          * 在 callback 内部可以获取到最新的 state 值
          */

        // 3、这种写法 setState 也不会被合并,两次操作都会按顺序执行

        this.setState({
            count: this.state.count + 2
        }, () => {
            this.setState({
                count: this.state.count + 1
            })
        });
    }

    handleSetTimeoutClick = () => {
        // 3、使用 setTimeout 的方式使用 setState 不会被合并,两次操作都会按顺序执行

        setTimeout(() => this.setState({ count: this.state.count + 2 }));
        setTimeout(() => this.setState({ count: this.state.count + 1 }));
    }

    handleOriginClick = () => {
        // 4、在绑定的原生事件中多次调用 setState 不会被合并,两次操作都会执行

        this.setState({
            count: this.state.count + 2
        });

        this.setState({
            count: this.state.count + 1
        });
    }

    componentDidMount() {
        const originBtn = document.querySelector('#originBtn');
        originBtn.addEventListener('click', this.handleOriginClick, false);
    }

    componentWillUnmount() {
        const originBtn = document.querySelector('#originBtn');
        originBtn.removeEventListener('click', this.handleOriginClick, false);
    }

    render() {
        return (
            <div>
                <p>count: { this.state.count }</p>
                <button onClick={ this.handleClick }>increment</button>
                <br />
                <br />
                <button onClick={ this.handleCallbackClick }>callback increment</button>
                <br />
                <br />
                <button onClick={ this.handleSetTimeoutClick }>setTimeout increment</button>
                <br />
                <br />
                <button id="originBtn">origin event increment</button>
            </div>
        );
    }
}

ReactDOM.render(<App />, root);

Demo08

demo / source

在类组件中进行 异步操作 或者 为元素绑定原生事件 的时机:componentDidMount

function fetchData() {
    return new Promise(rseolve => {
        setTimeout(() => {
            const todoList = [
                { id: 1, name: '吃饭'},
                { id: 2, name: '睡觉'},
                { id: 3, name: '敲代码'},
            ];
            rseolve(todoList);
        }, 1000)
    });
}

class App extends React.Component {

    constructor(props) {
        super(props);

        this.state = {
            todos:[],
            toggle: true,
            loading: true,
        }
    }

    handleWindowClick = () => {
        this.setState({ toggle: !this.state.toggle });
    }

    componentDidMount() {

        // 1、网络请求
        fetchData()
            .then(result => {
                this.setState({ todos: result });
            })
            .finally(() => {
                this.setState({ loading: false });
            });

        // 2、添加事件监听
        window.addEventListener('click', this.handleWindowClick, false);
    }

    componentWillUnmount() {
        // 移除事件监听
        window.removeEventListener('click', this.handleWindowClick, false);
    }
    
    render() {
        const { todos, toggle, loading } = this.state;
        return (
            <React.Fragment>
                <span style={{ color: 'gray', fontSize: 14 }}>随便点点试试</span>
                <h1 className="ani" style={{ height: toggle ? 50 : 200 }}>Hello React</h1>
                {
                    loading ? 
                        <p>Loading ...</p> :
                        <ul>
                            { todos.map(todo => <li key={ todo.id }>{ todo.name }</li>) }
                        </ul>
                }
                
            </React.Fragment>
        );
    }
}

ReactDOM.render(<App />, root);

Demo09

demo / source

React 类组件各生命周期触发时机

更多内容可参考这里:React 类组件生命周期详解

/**
  * 生命周期执行过程
  *
  * 初始化:constructor -> static getDerivedStateFromProps -> render -> componentDidMount
  * 更新:static getDerivedStateFromProps -> shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate
  * 销毁:componentWillUnmount
  */

class LifeCycleComponent extends React.Component {

    /**
      * 组件初次渲染或者更新之前触发
      *
      * 返回值会作为新的 state 值与组件中之前的 state 进行合并
      */
    static getDerivedStateFromProps(nextProps, prevState) {
        console.log('LifeCycleComponent >>>', 'getDerivedStateFromProps ----', 'init or update');
        return null;
    }

    /**
      * 组件创建时调用
      * 可以在这里做一些初始化操作
      */
    constructor(props) {
        super(props);
        console.log('LifeCycleComponent >>>', 'constructor ----', 'init');
        this.state = {
            count: 0
        }
    }

    /**
      * 组件初次挂载完成时触发
      * 可以在这里处理一些异步操作,比如:事件监听,网络请求等
      */
    componentDidMount() {
        console.log('LifeCycleComponent >>>', 'componentDidMount ----', 'mounted');
    }

    /**
      * 组件触发更新时调用,决定组件是否需要更新
      * 返回 true,则组件会被更新,返回 false,则组件停止更新
      */
    shouldComponentUpdate(nextProps, nextState) {
        console.log('LifeCycleComponent >>>', 'shouldComponentUpdate ----', 'need update ? ');
        return true;
    }

    /**
      * 组将 render 之后,提交更新之前触发,返回值会作为 componentDidUpdate 的第三个参数传入
      */
    getSnapshotBeforeUpdate(prevProps, prevState) {
        console.log('LifeCycleComponent >>>', 'getSnapshotBeforeUpdate ----', 'before update');
        return null;
    }

    /**
      * 组件更新结束后触发
      */
    componentDidUpdate(prevProps, prevState, snapshot) {
        console.log('LifeCycleComponent >>>', 'componentDidUpdate ----', 'updated');
    }

    /**
      * 组将即将被卸载时触发
      */
    componentWillUnmount() {
        console.log('LifeCycleComponent >>>', 'componentWillUnmount ----', 'will unmount');
    }

    increment = () => {
        const { count } = this.state;
        this.setState({
            count: count + 1
        });
    }

    /**
      * 渲染函数 render
      */
    render() {
        console.log('LifeCycleComponent >>>', 'render');
        const { msg } = this.props;
        const { count } = this.state;
        return (
            <div>
                <h1>LifeCycleComponent</h1>
                <p>Receive Message: { msg }</p>
                <p>count: { count }</p>
                <button onClick={ this.increment }>increment</button>
            </div>
        );
    }
}

class App extends React.Component {

    state = {
        message: 'Hello World',
        show: true
    }

    render() {
        const { message, show } = this.state;
        return (
            <div>
                <button onClick={ () => this.setState({ message: 'Hello React' }) }>修改 message </button> | {' '}
                <button onClick={ () => this.setState({ show: !show }) }>
                    { show ? '销毁 LifeCycleComponent' : '创建 LifeCycleComponent' }
                </button>
                { show && <LifeCycleComponent msg={ message } /> }
            </div>
        );
    }
}

ReactDOM.render(<App />, root);

Demo10

demo / source

React 中受控组件的定义和使用

类似于 vue 中的 v-model

定义:组件的值受 React 中状态的控制,组件的变化会导致 React 中状态的更新

/**
  * 受控组件:
  *      组件的属性值会受到 React state 的控制,
  *      并且在组件的属性值发生变化时,React 的 state 值会做相应的修改。
  */

class App extends React.Component {

    state = {
        username: ''
    }

    handleNameChange = (e) => {
        this.setState({ username: e.target.value });
    }

    handleSubmit = () => {
        const { username } = this.state;
        if (username) {
            alert(`提交成功,username = ${ username }`);
        } else {
            alert('请填写用户名!');
        }
    }

    render() {
        const { username } = this.state
        return (
            <div>
                <p>username: { username }</p>
                <section>
                    <label>用户名:</label>
                    <input
                        placeholader="请输入用户名"
                        value={ username }
                        onChange={ this.handleNameChange }
                    />
                </section>
                <br />
                <button onClick={ this.handleSubmit }>submit</button>
            </div>
        );
    }
}

ReactDOM.render(<App />, root);

Demo11

demo / source

如何在 React 函数组件中控制自身状态变化以及副作用处理?

函数组件中控制自身状态的相关 hooksuseStateuseReducer

函数组件中处理副作用相关的 hooks: useLayoutEffectuseEffect

  • useState: [state, setState] = useState(initState)

    • initState 可以是函数,也可以是值,函数的话,仅会在组件创建时执行一次,返回值作为 state 的初始值

    • 组件中可以使用多次 useState,创建出不同的状态

    • 与 类组件中的 setState 不同的地方在于,同一 React 事件中,多次 setState 会被最后一次的替换,其他逻辑相似

  • useReducer: [state, dispatch] = useState(reducer)

    • reducer 是一个函数,第一个参数是上一次的 state 值;第二个参数是传入的 action,不传的话则没有。返回值会作为新的 state 进行使用
  • useEffect: useEffect(() => doSomething, deps)

    • useEffect 是副作用执行 hook,第一次组件渲染完毕或依赖的 deps 发生变化时,doSomething 逻辑都会被执行

    • deps 是一个数组,发生变化的判断标准是将 deps 中的依赖进行前后两次的浅比较

  • useLayoutEffect: useLayoutEffect(() => doSomething, deps)

    • useLayoutEffect 也是副作用执行 hook,同 useEffect,第一次组件渲染完毕或依赖的 deps 发生变化时,doSomething 逻辑都会被执行
    • useEffect 不同的地方在于,组件渲染完毕,会同步执行 useLayoutEffect,而异步执行 useEffect
    • 当需要处理与界面元素尺寸相关的逻辑时,可以使用 useLayoutEffect
  • 同一帧的 useLayoutEffect 会在 useEffect 前执行

/**
  * 为了能够在函数组件中也能使用状态、执行副作用等操作,引入了 hooks 的概念
  * 
  * useState: 函数组件也可以拥有自身状态
  *
  * useReducer: useState 的升级版,可以根据不同操作返回不同的状态值
  *
  * useEffect 用法:
  *      1、第一个参数是副作用函数,第二个参数是依赖项集合
  *      2、副作用函数的返回值可以是一个函数,会在当前 useEffect 被销毁时执行,可以在这里做一些状态回收,事件解除等操作
  *      3、依赖项发生变化时,副作用操作会重新执行
  *      4、希望 useEffect 只执行一次,则可以给依赖项一个空数组
  *      5、希望组件的每次更新都执行 useEffect,可以不写依赖项
  */

const { useState, useEffect, useReducer, useLayoutEffect } = React;

const App = () => {
    // 
    const [count, setCount] = useState(0);
    const [num, dispatch] = useReducer((state, action) => {
        switch(action.type) {
            case 'INCREMENT': 
                return state + 1;
            case 'DECREMENT': 
                return state - 1;
            default:
                return state;
        }
    }, 0)

    useEffect(() => {
        console.log('useEffect')
        // 执行异步操作获取数据
        setCount(10);
    }, [])

    useLayoutEffect(() => {
        console.log('useLayoutEffect')
        // 绑定事件
        const handleClick = () => {
            alert(count);
        }
        const box = document.querySelector('#box');
        box.addEventListener('click', handleClick, false);

        return () => {
            box.removeEventListener('click', handleClick, false);
        }
    }, [count])

    return (
        <div>
            <p>count: { count }</p>
            <button onClick={() => setCount(count + 1)}>count increment</button>
            <p>num: { num }</p>
            <button onClick={() => dispatch({ type: 'INCREMENT' })}>num increment</button> | {' '}
            <button onClick={() => dispatch({ type: 'DECREMENT' })}>num decrement</button>
            <br />
            <br />
            <button id="box">alert count</button>
        </div>
    );
}

ReactDOM.render(<App />, root)

Demo12

demo / source

React 中错误边界处理

  • 组件出现异常,会触发 static getDerivedStateFromErrorcomponentDidCatch 生命周期

  • static getDerivedStateFromError 的返回值会合并到组件的 state 中作为最新的 state

/**
  * 错误边界处理:组件出现异常,会触发 static getDerivedStateFromError 和 componentDidCatch 生命周期
  * 
  * static getDerivedStateFromError 的返回值会合并到组件的 state 中作为最新的 state 值
  */

class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            hasError: false,
            errorMsg: ''
        };
    }
    
    static getDerivedStateFromError(error) {
        // 更新 state 使下一次渲染能够显示降级后的 UI
        return { hasError: true };
    }
    
    componentDidCatch(error, errorInfo) {
        this.setState({errorMsg: error.message})
        console.log('异常信息:', error, ' , ', errorInfo )
    }
    
    render() {
        const { hasError, errorMsg } = this.state;
        if (hasError) {
            // 你可以自定义降级后的 UI 并渲染
            return <h1>Something went wrong, Error Message: {errorMsg}</h1>;
        }
    
        return this.props.children; 
    }
}

const App = () => {
    const [count, setCount] = React.useState(0);

    if (count > 0) {
        throw TypeError('数据异常');
    }

    return (
        <div>
            <h2>App 组件</h2>
            <p>count: { count }</p>
            <button onClick={() => setCount(count + 1)}>increment</button>
        </div>
    );
}

ReactDOM.render(<ErrorBoundary><App /></ErrorBoundary>, root);

Demo13

demo / source

React 高阶组件(HOC)的两种创建方式

  • 属性代理(Props Proxy):类组件和函数组件都可以使用

  • 反向继承(Inheritance Inversion,缩写 II ):只用类组件可以使用

/**
  * 高阶组件的两种创建方式
  *  1、属性代理(Props Proxy):类组件和函数组件都可以使用
  *  2、反向继承(Inheritance Inversion, 缩写II):只用类组件可以使用
  */

  /**
  * 通过属性代理的方式向组件中注入 permission 属性
  */
function ComposeHOC(OriginComponent) {
    const permission = 'edit permission from ComposeHOC'
    return (props) => <OriginComponent {...props} permission={permission} />
}

/**
  * 通过反向继承的方式向组件中注入 DOM 节点
  */
function iiHOC(OriginComponent) {
    return class WrapperComponent extends OriginComponent {

        render() {
            return <div>
                    <h1>Title from iiHOC</h1>
                    { super.render() }
                </div>;
        }
    }
}

const ComponentA = (props) => <h2 className="box">ComponentA props permission: { props.permission }</h2>
class ComponentB extends React.Component {
    render() {
        return <h2 className="box">ComponentB</h2>;
    }
}

// 使用高阶组件包裹 A、B 组件
const WrapperComponentA = ComposeHOC(ComponentA);
const WrapperComponentB = iiHOC(ComponentB);


const App = () => (<div><WrapperComponentA /><WrapperComponentB /></div>);

ReactDOM.render(<App />, root);

Demo14

demo / source

Context 在类组件和函数组件中的使用

  • 创建 Contextconst { Provider, Consumer } = React.createContext()
  • 使用
    • 在类组件或者函数组件中,均可使用 Provider 标签包裹住父组件,则在任意深度的子孙组件中,都可以在 Consumer 标签中通过 renderProps 的方式获取到对应 Context 的值
  • 在函数组件中亦可以通过更简便的方式 useContext() 获取到对应 Context 的值

更多 Context 用法可参考这里:React 中 Context 用法详解

const { createContext, Component, useContext, useState } = React;

/**
 * createContext:    创建 context 上下文
 * Context.Provider: 需要对子组件进行包裹,在能在子组件中获取到 context 中的 value
 * Context.Consumer: 在类组件中使用 Render Props 的方式 context 上下文
 * useContext:       在函数组件中使用 context 上下文
 */

const UserContext = React.createContext();
const { Provider, Consumer } = UserContext;

class ClassComponent extends React.Component {
    render() {
        return (
            <div className="box">
                <h2>类组件</h2>
                <Consumer>
                    {user => (<div>name: { user.name }</div>)}
                </Consumer>
            </div>
        );
    }
}

const FunctionComponent = () => {
    const user = useContext(UserContext);
    return (
        <div className="box">
            <h2>函数组件</h2>
            <div>name: { user.name }</div>
        </div>
    );
}

const App = () => {
    const [user, setUser] = useState({ name: '孙悟空' });

    return (
        <Provider value={user}>
            <h1>App</h1>
            <label>Change name:</label>
            <input
                placeholder="请输入用户名称"
                value={user.name}
                onChange={(e) => setUser({ name: e.target.value })}
            />
            <ClassComponent />
            <FunctionComponent />
        </Provider>
    );
}

ReactDOM.render(<App />, root);

Demo15

demo / source

类组件和函数组件中 ref 的使用

  • 作用

    • 可以作为 DOM 节点或者 React 组件的引用
    • 在函数组件中还可以用来将任意值转化为带有 current 属性的对象
  • 创建 ref

    • 类组件中:ref = React.createRef()
    • 函数组件中:ref = useRef()
  • 使用:

    • 类组件中,ref 直接作为元素的 ref 属性使用,给子组件设置 ref 时,需要配合 forwardRef 包裹子组件
    • 函数组件中,ref 可以作为元素或者组件的 ref,也可以只作为一个变量使用,将变量随函数组件的创建而创建,销毁而销毁
const { createRef, useRef } = React;

/**
 * createRef:在类组件中为元素设置 ref
 * useRef: 在函数组件中为元素设置 ref
 *
 * 之前使用受控组件的方式进行表单提交。其实也可以使用 ref 的方式操作非受控组件
 */

class ClassComponent extends React.Component{
    inputRef = createRef();

    submit = () => {
        const { value } = this.inputRef.current;
        if (value) {
            alert(`提交成功,用户名为:${ value }`);
        } else {
            alert('请输入用户名!');
        }
    }

    render() {
        return (
            <div className="box">
                <h2>类组件</h2>
                <section>
                    <label>用户名:</label>
                    <input ref={ this.inputRef } placeholder="请输入用户名" />
                </section>
                <br />
                <button onClick={ this.submit }>提交</button>
            </div>
        );
    }
}

const FunctionComponent = () => {
    const inputRef = useRef();

    const submit = () => {
        const { value } = inputRef.current;
        if (value) {
            alert(`提交成功,用户名为:${ value }`);
        } else {
            alert('请输入用户名!');
        }
    }

    return (
        <div className="box">
            <h2>函数组件</h2>
            <section>
                <label>用户名:</label>
                <input ref={ inputRef } placeholder="请输入用户名" />
            </section>
            <br />
            <button onClick={ submit }>提交</button>
        </div>
    );
}

const App = () =>  (
    <div>
        <ClassComponent />
        <FunctionComponent />
    </div>
)

ReactDOM.render(<App />, root);

Demo16

demo / source

在函数组件中,父组件如何调用子组件中的状态或者函数:使用useImperativeHandle

用法:useImperativeHandle(ref, createHandle, [deps])

  • 第一个参数是 ref 值,可以通过属性传入,也可以配合 forwardRef 使用

  • 第二个参数是一个函数,返回一个对象,对象中的属性都会被挂载到第一个参数 refcurrent 属性上

  • 第三个参数是依赖的元素集合,同 useEffectuseCallbackuseMemo,当依赖发生变化时,第二个参数会重新执行,重新挂载到第一个参数的 current 属性上

注意事项

  • 第三个参数,依赖必须按照要求填写,少了会导致返回的对象属性异常,多了会导致 createHandle 重复执行

  • 一个组件或者 hook 中,对于同一个 ref,只能使用一次 useImperativeHandle,多次的话,后面执行的 useImperativeHandlecreateHandle 返回值会替换掉前面执行的 useImperativeHandlecreateHandle 返回值

const { useState, useRef, useImperativeHandle, useCallback } = React;

const ChildComponent = ({ actionRef }) => {

  const [value, setValue] = useState('')

  /**
   * 随机修改 value 值的函数
   */
  const randomValue = useCallback(() => {
    setValue(Math.round(Math.random() * 100) + '');
  }, []);

  /**
   * 提交函数
   */
  const submit = useCallback(() => {
    if (value) {
      alert(`提交成功,用户名为:${value}`);
    } else {
      alert('请输入用户名!');
    }
  }, [value]);

  useImperativeHandle(actionRef, () => {
    return {
      randomValue,
      submit,
    }
  }, [randomValue, submit])

  /* !! 返回多个属性要按照上面这种写法,不能像下面这样使用多个 useImperativeHandle
      useImperativeHandle(actionRef, () => {
          return {
              submit,
          }
      }, [submit])

      useImperativeHandle(actionRef, () => {
          return {
              randomValue
          }
      }, [randomValue])
  */

  return (
    <div className="box">
      <h2>函数组件</h2>
      <section>
        <label>用户名:</label>
        <input value={value} placeholder="请输入用户名" onChange={e => setValue(e.target.value)}/>
      </section>
      <br/>
    </div>
  );
}

const App = () => {

  const childRef = useRef();

  return (
    <div>
      <ChildComponent actionRef={childRef}/>
      <button onClick={() => childRef.current.submit()}>调用子组件的提交函数</button>
      <br/>
      <br/>
      <button onClick={() => childRef.current.randomValue()}>随机修改子组件的 input 值</button>
    </div>
  )
}

ReactDOM.render(<App/>, root);

Demo17

demo / source

React 中传送门 Portals 的使用:可以将指定 React 元素挂载到任意的 DOM 节点上去, 虽然在层级关系上,看起来实在父组件下,但在界面上是挂载到了指定的 DOM 节点上

官网解释:Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

用法: ReactDOM.createPortal(child, container)

  • 第一个参数是任何可渲染的 React 元素
  • 第二个参数是一个真实 DOM 元素

Portals 的典型用例是当父组件有 overflow: hiddenz-index 样式时, 但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。

注意:尽管 portal 可以被放置在 DOM 树中的任何地方,但在任何其他方面,其行为和普通的 React 子节点行为一致。
由于 portal 仍存在于 React 树, 且与 DOM 树 中的位置无关,那么无论其子节点是否是 portal,像 context 这样的功能特性都是不变的。包含事件冒泡。

/**
 * 通过 createPortal API,将 Modal 组件的真实节点挂载到新建的 div 元素上去
 * 虽然在 React 树中,Modal 组件仍然在 App 组件中,但是在界面上,Modal 节点其实是挂载在了新的 div 节点上
 */

const { useEffect, useState } = React;
const { createPortal } = ReactDOM;

const modalRoot = document.createElement('div');

/**
 * Modal: 弹窗组件
 */
function Modal({ children, onCancel }) {

  useEffect(() => {
    document.body.appendChild(modalRoot);
    return () => {
      document.body.removeChild(modalRoot);
    }
  })

  return createPortal(
    <div className="modal">
      <div className="modal-inner">
        <div className="mask"/>
        <section className="modal-content-wrapper">
          <div className="modal-content">
            <header>
              <h1>提示弹窗</h1>
            </header>
            <hr/>
            <content>{children}</content>
            <footer>
              <button onClick={onCancel}>关闭</button>
            </footer>
          </div>
        </section>
      </div>
    </div>,
    modalRoot
  );
}

const App = () => {
  const [visible, setVisible] = useState(false);
  return (
    <div>
      <h1>App</h1>
      <br/>
      <button onClick={() => setVisible(true)}>展示弹窗</button>
      {visible && <Modal onCancel={() => setVisible(false)}>
        自定义内容
      </Modal>}
    </div>
  );
}

ReactDOM.render(<App/>, root);

Demo18

demo / source

优化 React 组件的几种方式

  • Fragment: 可以作为标签包裹子元素,并不会在 DOM 中生成真实节点
  • PureComponent: 会对类组件的 propsstate 做一次浅比较,只有当数据发生变化式,组件才会重新渲染
  • memo: 作用和 PureComponent 类似,只不过是作为高阶组件,作用在函数组件上
const { Fragment, PureComponent, memo, useState, Component } = React;

class ClassComponent extends PureComponent {

    render() {
        console.log('PureComponent render');
        return (
            <div className="box">
                <h1>PureComponent 组件</h1>
                <p>count: {this.props.count}</p>
            </div>
        );
    }
}

const FunctionComponent = memo((props) => {
    console.log('memo function render');

    return (
        <div className="box">
            <h1>memo 函数组件</h1>
            <p>num: {props.num}</p>
        </div>
    );
})

const App = () => {
    const [count, setCount] = useState(0);
    const [num, setNum] = useState(0);
    return (
        <Fragment>
            <p>打开控制台查看 render 日志</p>
            <div>
                <button onClick={ () => setCount(count + 1) }>increment count</button> | {' '}
                <button onClick={ () => setNum(num + 1) }>increment num</button>
                <ClassComponent count={ count }/>
                <FunctionComponent num={ num }/>
            </div>
        </Fragment>
    );
}

ReactDOM.render(<App />, root);

Demo19

demo / source

提升函数组件性能常用的两个 hooks: useCallbackuseMemo

  • useCallback(fn, deps): 会对函数进行缓存,当第二个参数依赖项发生变化时,才会重新生成新的函数

    • fn: 返回的函数
    • deps: 依赖项集合,是个数组
    • 返回值是一个被缓存的函数
  • useMemo(fn, deps): 会对函数的返回值进行缓存,当第二个参数依赖项发生变化时,才会重新执行,返回新的数据

    • fn: 需要执行的函数
    • deps: 依赖项集合,是个数组
    • 返回值是一个被缓存的值,可以是基础类型或者对象类型,也可以是函数,甚至是 React 组件

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

const { Fragment, useCallback, useMemo, useState } = React;

const App = () => {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const doubleCount = useMemo(() => {
    return count * 2;
  }, [count])

  const alertNum = useCallback(() => {
    alert(`num 值:${num}`);
  }, [num])

  console.log('render');

  return (
    <Fragment>
      <p>count: {count}</p>
      <p>doubleCount: {doubleCount}</p>
      <p>num: {num}</p>
      <button onClick={() => setCount(count + 1)}>increment count</button>
      | {' '}
      <button onClick={() => setNum(num + 1)}>increment num</button>
      | {' '}
      <button onClick={alertNum}>alert num</button>
    </Fragment>
  );
}

ReactDOM.render(<App/>, root);

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React PureComponent 使用注意事项以及源码解析

本文简要介绍了 React 中 PureComponent 与 Component 的区别以及使用时需要注意的问题,并在后面附上了源码解析,希望对有疑惑的朋友提供一些帮助。

前言

先介绍一下 PureComponent,平时我们创建 React 组件一般是继承于 Component,而 PureComponent 相当于是一个更纯净的 Component,对更新前后的数据进行了一次浅比较。只有在数据真正发生改变时,才会对组件重新进行 render。因此可以大大提高组件的性能。

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!


对比 Component 和 PureComponent

继承 Component 创建组件

App.js

里面的 state 有两个属性,text 属性是基本数据类型,todo 属性是引用类型。针对这两种数据类型分别进行对比:

import React, { Component, PureComponent } from 'react';
import './App.css';

class App extends Component {

    constructor(props) {
        super(props)

        this.state = {
            text: 'Hello',
            todo: {
                id: 1,
                message: '学习 React'
            }
        }
    }

    /**
     * 修改 state 中 text 属性的函数
     */
    changeText = () => {
        this.setState({
            text: 'World'
        });
    }

    /**
     * 修改 state 中 todo 对象的函数
     */
    changeTodo = () => {
        this.setState({
            id: 1,
            message: '学习 Vue'
        });
    }

    render() {
        // 打印 log,查看渲染情况
        console.log('tag', 'render');
        
        const { text, todo } = this.state;
        return (
            <div className="App">
                <div>
                    <span>文字:{ text }</span>
                    <button onClick={ this.changeText }>更改文字</button>
                </div>
                <br />
                <div>
                    <span>计划:{ todo.message }</span>
                    <button onClick={ this.changeTodo }>更改计划</button>
                </div>
            </div>
        );
    }
}

export default App;

浏览器中界面

界面显示

测试

运行项目,打开控制台,此时看到只有一个 logtag render

  • 点击 5 次 ·更改文字· 按钮,可以看到控制台再次多打印了 5 次 log,浏览器中的 Hello 文字变成了 World

  • 点击 5 次 ·更改计划· 按钮,控制台一样多打印 5 次 log,浏览器中的 学习 React 计划变成了 学习 Vue

分析一下,其实 5 次点击中只有一次是有效的,后来的数据其实并没有真正改变,但是由于依然使用了 setState(),所以还是会重新 render。所以这种模式是比较消耗性能的。

继承 PureComponent

其实 PureComponent 用法也是和 Component 一样,只不过是将继承 Component 换成了 PureComponent

App.js

...
// 上面的代码和之前一致

class App extends PureComponent {
    // 下面的代码也和之前一样
    ...
}

export default App;

浏览器中界面

界面显示

测试

和上面 Component 的测试方式一样

  • 点击 5 次 ·更改文字· 按钮,可以看到控制台只多打印了 1 次 log,浏览器中的 Hello 文字变成了 World

  • 点击 5 次 ·更改计划· 按钮,控制台只多打印了 1 次 log,浏览器中的 学习 React 计划变成了 学习 Vue

由此可以看出,使用 PureComponent 还是比较节省性能的,即便是使用了 setState(),也会在数据真正改变时才会重新渲染组件

使用时可能遇到的问题

下面我们将代码中 changeTextchangeTodo 方法修改一下

/**
 * 修改 state 中 text 属性的函数
 */
changeText = () => {
    let { text } = this.state;
    text = 'World';
    this.setState({
        text
    });
}

/**
 * 修改 state 中 todo 对象的函数
 */
changeTodo = () => {
    let { todo } = this.state;
    todo.message = "学习 Vue";
    this.setState({
        todo
    });
}

此时我们再重新测试一下:

  • 点击 ·更改文字· 按钮,控制台多打印一次 log,浏览器中的 Hello 文字变成了 World

  • **注意:**点击 ·更改计划· 按钮,控制台没有 log 打印,浏览器中的计划也没有发生改变

为什么代码修改之后,明明 todo 里的 message 属性也已经发生变化了,调用 setState(),却不进行渲染了呢?这是因为 PureComponent 在调用 shouldComponent 生命周期的时候,对数据进行了一次浅比较,判断数据是否发生改变,没发生改变,返回 false,改变了,就返回 true。那这个浅比较的机制是怎么做的呢?我们一起看下面源码解析,来分析一下。


PureComponent 源码解析

ReactBaseClasses.jsGithub 代码位置

function ComponentDummy() {}
ComponentDummy.prototype = Component.prototype;

/**
 * Convenience component with default shallow equality check for sCU.
 */
function PureComponent(props, context, updater) {
    this.props = props;
    this.context = context;
    // If a component has string refs, we will assign a different object later.
    this.refs = emptyObject;
    this.updater = updater || ReactNoopUpdateQueue;
}

const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());
pureComponentPrototype.constructor = PureComponent;
// Avoid an extra prototype jump for these methods.
Object.assign(pureComponentPrototype, Component.prototype);
pureComponentPrototype.isPureReactComponent = true;

可以看到 PureComponent 的使用和 Component 一致,只时最后为其添加了一个 isPureReactComponent 属性。ComponentDummy 就是通过原型模拟继承的方式将 Component 原型中的方法和属性传递给了 PureComponent。同时为了避免原型链拉长导致属性查找的性能消耗,通过 Object.assign 把属性从 Component 拷贝了过来。

但是这里只是 PureComponent 的声明创建,没有显示如何进行比较更新的,那我们继续看下面的代码。

ReactFiberClassComponent.jsGithub 代码位置

function checkShouldComponentUpdate(
  workInProgress,
  ctor,
  oldProps,
  newProps,
  oldState,
  newState,
  nextContext,
) {
    ...

    // 这里根据上面 PureComponent 设置的属性 isPureReactComponent 判断一下,如果是 PureComponent,就会走里面的代码,将比较的值返回出去
    if (ctor.prototype && ctor.prototype.isPureReactComponent) {
        return (
            !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
        );
    }
}

shallowEqual 是在 share 包中一个工具方法,看一下其中的内部实现吧。

shallowEqual.jsGithub 代码位置

import is from './objectIs';

const hasOwnProperty = Object.prototype.hasOwnProperty;

/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

export default shallowEqual;

这里面还调用了 is 函数,这个函数也是 share 包中的一个工具方法。

objectIs.jsGithub 代码位置

/**
 * inlined Object.is polyfill to avoid requiring consumers ship their own
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x: any, y: any) {
    return (
        (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
    );
}

export default is;

PureComponent 源码分析总结

由上面的源码可以发现,其实 PureComponentComponent 中的方法和属性基本一致,只不过 PureComponent 多了一个 isPureReactComponenttrue 的属性。在 checkShouldComponentUpdate 的时候,会根据这个属性判断是否是 PureComponent,如果是的话,就会根据 !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState) 这个判断语句的返回值作为更新依据。所以,查看了 shallowEqualobjectIs 的文件源码,我们可以得出 PureComponent 的浅比较结论:

  • 先通过 is 函数判断两个参数是否相同,相同则直接返回 ture,也就是不更新组件。

    • 根据 objectIs.js 代码可知,基本属性类型判断值是否相同(包括 NaN),引用数据类型判断是否是一个引用
  • is 函数判断为 false,则判断两个参数是否都为 对象 且 都不为 null,若任意一个 不是对象 或 任意一个为 null,直接返回 false,也就是更新组件

  • 若前两个判断都通过,则可断定两个参数皆为对象,此时判断它们 keys 的长度是否相同,若不同,则直接返回 false,即更新组件

  • keys 长度不同,则对两个对象中的第一层属性进行比较,若都相同,则返回 true,有任一属性不同,则返回 false


总结

阅读源码之后,可以发现之前我们修改了 changeTodo 方法的逻辑之后,为什么数据改变,组件却依然不更新的原因了。是因为修改的是同一个对象,所以 PureComponent 默认引用相同,不进行组件更新,所以才会出现这个陷阱,在使用的过程中希望大家注意一下这个问题。

  • 对比 PureComponentComponent,可以发现,PureComponent 性能更高,一般有几次有效修改,就会进行几次有效更新

  • 为了避免出现上面所说的陷阱问题,建议将 ReactImmutable.js 配合使用,因为 Immutable.js 中的数据类型都是不可变,每个变量都不会相同。但是由于 Immutable 学习成本较高,可以在项目中使用 immutability-helper 插件,也能实现类似的功能。关于 immutability-helper 的使用,可以查看我的另一篇博客:immutability-helper 插件的基本使用

  • 虽然 PureComponent 提高了性能,但是也只是对数据进行了一次浅比较,最能优化性能的方式还是自己在 shouldComponent 生命周期中实现响应逻辑

  • 关于 PureComponent 浅比较的总结可以查看上面的 PureComponent 源码分析总结

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

十分钟学会 react-redux

前言

本文主要介绍了 react-redux 的用法,以及在各种场景下不同 API 的使用方式和区别。

本文代码是在上一篇博客的基础上进行的,有疑惑的地方可以先查看上一篇博客:轻松掌握 Redux 核心用法

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

一、准备工作

安装

npm install react-redux
# or
yarn add react-redux

常规用法

  • Provider: 使用 Provider 标签包裹根组件,将 store 作为属性传入,后续的子组件才能获取到 store 中的 statedispatch

  • connect:返回一个高阶组件,用来连接 React 组件与 Redux store,返回一个新的已与 Redux store 连接的组件类

hooks 用法

  • useDispatch:返回一个 dispatch 对象

  • useSelector:接受一个函数,将函数的返回值返回出来

二、Provider 的使用

根目录下 index.js 文件

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from "./App";
import store from "./store";
import "./index.css";

ReactDOM.render(
  <React.StrictMode>
    {/* 使用 Provider 标签包裹住根组件,并将 store 作为参数传入 */}
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById("root")
);

三、connect 的使用

  • APIconnect([mapStateToProps],[mapDispatchToProps],[mergeProps],[options])

connect 的用法相对复杂一些,接受四个参数,返回的是一个高阶组件。用来连接当前组件和 Redux store

1. mapStateToProps

mapStateToProps:函数类型,接受两个参数: stateownProps(当前组件的 props,不建议使用,会导致重渲染,损耗性能),必须返回一个纯对象,这个对象会与组件的 props 合并

  • (state[, ownProps]) => ({ count: state.count, todoList: state.todos })

2. mapDispatchToProps

mapDispatchToProps:object | 函数

  • 不传递这个参数时,dispatch 会默认挂载到组件的的 props
  • 传递 object 类型时,会把 object 中的属性值使用 dispatch 包装后,与组件的 props 合并
{
    increment: () => ({ type: "INCREMENT" }),
    decrement: () => ({ type: "DECREMENT" }),
}
  • 对象的属性值都必须是 ActionCreator

  • dispatch 不会再挂载到组件的 props

  • 传递函数类型时,接收两个参数:dispatchownProps(当前组件的 props,不建议使用,会导致重渲染,损耗性能),必须返回一个纯对象,这个对象会和组件的 props 合并

(state[, ownProps]) => ({
    dispatch,
    increment: dispatch({ type: "INCREMENT" }),
    decrement: dispatch({ type: "DECREMENT" })
})

3. mergeProps

mergeProps(很少使用) 函数类型。如果指定了这个参数,mapStateToProps()mapDispatchToProps()的执行结果和组件自身的 props 将传入到这个回调函数中。该回调函数返回的对象将作为 props 传递到被包装的组件中。你也许可以用这个回调函数,根据组件的 props 来筛选部分的 state 数据,或者把 props 中的某个特定变量与 ActionCreator 绑定在一起。如果你省略这个参数,默认情况下组件的 props 返回 Object.assign({}, ownProps, stateProps, dispatchProps) 的结果

  • mergeProps(stateProps, dispatchProps, ownProps): props

4. options

  • context?: Object
  • pure?: boolean
  • areStatesEqual?: Function
  • areOwnPropsEqual?: Function
  • areStatePropsEqual?: Function
  • areMergedPropsEqual?: Function
  • forwardRef?: boolean

下面 改写一下 App.jsredux 的用法

  • mapDispatchToProps 参数不传时
import React, { useEffect, useCallback, useState, useMemo } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { actionCreators } from "./store";

function App({ count, todos, dispatch }) {
  const [value, setValue] = useState("");

  // 生成包装后的 actionCreator,执行之后就会触发 store 数据的更新
  const { increment, decrement, getAsyncTodos, addTodo } = useMemo(
    () => bindActionCreators(actionCreators, dispatch),
    [dispatch]
  );

  // 初始化 TodoList
  useEffect(() => {
    getAsyncTodos();
  }, [getAsyncTodos]);

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      addTodo(value);
      setValue("");
    }
  }, [value, addTodo]);

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

// count、todos 也会被挂载到组件的 props 中
const mapStateToProps = ({ count, todos }) => ({ count, todos });

// 第二个参数没有传递,dispatch 默认会挂载到组件的 props 中
export default connect(mapStateToProps)(App);
  • mapDispatchToProps 参数为对象时
import React, { useEffect, useCallback, useState } from "react";
import { connect } from "react-redux";
import { actionCreators } from "./store";

function App({ count, todos, increment, decrement, getAsyncTodos, addTodo }) {
  const [value, setValue] = useState("");

  // 初始化 TodoList
  useEffect(() => {
    getAsyncTodos();
  }, [getAsyncTodos]);

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      addTodo(value);
      setValue("");
    }
  }, [value, addTodo]);

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

// count、todos 会被挂载到组件的 props 中
const mapStateToProps = ({ count, todos }) => ({ count, todos });

// actionCreators 中的 actionCreator 会被 dispatch 进行包装,之后合并到组建的 props 中去
const mapDispatchToProps = { ...actionCreators };

export default connect(mapStateToProps, mapDispatchToProps)(App);
  • mapDispatchToProps 参数为函数时
import React, { useEffect, useCallback, useState } from "react";
import { connect } from "react-redux";
import { actionCreators } from "./store";
import { bindActionCreators } from "redux";

function App({
  count,
  todos,
  increment,
  decrement,
  getAsyncTodos,
  addTodo,
  dispatch
}) {
  const [value, setValue] = useState("");

  // 初始化 TodoList
  useEffect(() => {
    getAsyncTodos();
  }, [getAsyncTodos]);

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      addTodo(value);
      setValue("");
    }
  }, [value, addTodo]);

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

// count、todos 会被挂载到组件的 props 中
const mapStateToProps = ({ count, todos }) => ({ count, todos });

// mapDispatchToProps 为函数时,actionCreators 中的 actionCreator 需要自己处理,返回的对象会被合并到组件的 props 中去
const mapDispatchToProps = dispatch => ({
  dispatch,
  ...bindActionCreators(actionCreators, dispatch)
});

export default connect(mapStateToProps, mapDispatchToProps)(App);
  • 如果不需要更新 store 中的数据,则不需要传 mapDispatchToProps 参数
  • 如果不需要自己控制 dispatch,则传递 ActionCreators 对象即可
  • 如果需要自己完全控制,则传递一个回调函数

虽然上面使用 connect 是在 class 组件,但是在函数组件中依然适用。

四、useDispatch 和 useSelector 的使用

上面我们在组件中使用的是 connect,但是在现在这个 hooks 盛行的时代,怎么能只有高阶组件呢,所以下面我们来探究一下 useDispatchuseSelector 的用法。

改写 App.js 文件

import React, { useEffect, useCallback, useState, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { bindActionCreators } from "redux";
import { actionCreators } from "./store";

function App() {
  const [value, setValue] = useState("");

  // 从 useDispatch 中获取 dispatch
  const dispatch = useDispatch();

  // 生成包装后的 actionCreator,执行之后就会触发 store 数据的更新
  const { increment, decrement, getAsyncTodos, addTodo } = useMemo(
    () => bindActionCreators(actionCreators, dispatch),
    [dispatch]
  );

  // 通过 useSelector 获取需要用到 state 值
  const { count, todos } = useSelector(({ count, todos }) => ({
    count,
    todos
  }));

  // 初始化 TodoList
  useEffect(() => {
    getAsyncTodos();
  }, [getAsyncTodos]);

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      addTodo(value);
      setValue("");
    }
  }, [value, addTodo]);

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

使用 hooks 方式改写之后,感觉简洁了不少,数据来源也很清晰。至于用 connect 还是 hook 的方式,可以根据情况自己选择。

五、总结

  • ReduxAction、Reducer、Store 组成

    • 通过 Reducer 创建 Store
    • 通过 store.dispatch(action) 触发更新函数 reducer
    • 通过 reducer 更新数据
    • 数据更新触发订阅 subscribe
  • 通过 combineReducers 可以合并多个 reducer

  • 通过 applyMiddleware 可以使用插件

  • 通过 bindActionCreators 可以将 ActionCreator 转化成 dispatch 包装后的 ActionCreator

是不是还没有看过瘾呢?没有的话请看我的下一篇博客,详细讲解了 Redux 以及 React-Redux 的实现原理。

六、源码位置

react-redux-demo

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React Hooks 常见问题及解决方案

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

常见问题

相信看完本文,你可以得到需要的答案。

一、函数组件渲染过程

先来看一下函数组件的运作方式:

Counter.js

function Counter() {
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>count: {count}</p>;
}

每次点击 p 标签,count 都会 + 1,setCount 会触发函数组件的渲染。函数组件的重新渲染其实是当前函数的重新执行。
在函数组件的每一次渲染中,内部的 state、函数以及传入的 props 都是独立的。

比如:

// 第一次渲染
function Counter() {
    // 第一次渲染,count = 0
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}


// 点击 p 标签触发第二次渲染
function Counter() {
    // 第二次渲染,count = 1
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

// 点击 p 标签触发第三次渲染
function Counter() {
    // 第三次渲染,count = 2
    const [count, setCount] = useState(0);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

// ...

在函数组件中声明的方法也是类似。因此,在函数组件渲染的每一帧对应这自己独立的 statefunctionprops

二、useState / useReducer

useState VS setState

  • useState 只能作用在函数组件,setState 只能作用在类组件

  • useState 可以在函数组件中声明多个,而类组件中的状态值都必须声明在 thisstate 对象中

  • 一般的情况下,state 改变时:

    • useState 修改 state 时,同一个 useState 声明的值会被 覆盖处理,多个 useState 声明的值会触发 多次渲染

    • setState 修改 state 时,多次 setState 的对象会被 合并处理

  • useState 修改 state 时,设置相同的值,函数组件不会重新渲染,而继承 Component 的类组件,即便 setState 相同的值,也会触发渲染

useState VS useReducer

初始值

  • useState 设置初始值时,如果初始值是个值,可以直接设置,如果是个函数返回值,建议使用回调函数的方式设置
const initCount = c => {
    console.log('initCount 执行');
    return c * 2;
};

function Counter() {
    const [count, setCount] = useState(initCount(0));

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

会发现即便 Counter 组件重新渲染时没有再给 count 重新赋初始值,但是 initCount 函数却会重复执行

修改成回调函数的方式:

const initCount = c => {
    console.log('initCount 执行');
    return c * 2;
};

function Counter() {
    const [count, setCount] = useState(() => initCount(0));

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这个时候,initCount 函数只会在 Counter 组件初始化的时候执行,之后无论组件如何渲染,initCount 函数都不会再执行

  • useReducer 设置初始值时,初始值只能是个值,不能使用回调函数的方式
    • 如果是个执行函数返回值,那么在组件重新渲染时,这个执行函数依然会执行

修改状态

  • useState 修改状态时,同一个 useState 声明的状态会被覆盖处理
function Counter() {
    const [count, setCount] = useState(0);

    return (
        <p
            onClick={() => {
                setCount(count + 1);
                setCount(count + 2);
            }}
        >
            clicked {count} times
        </p>
    );
}

当前界面中 countstep 是 2

useState 效果

  • useReducer 修改状态时,多次 dispatch 会按顺序执行,依次对组件进行渲染
function Counter() {
    const [count, dispatch] = useReducer((x, payload) => x + payload, 0);

    return (
        <p
            onClick={() => {
                dispatch(1);
                dispatch(2);
            }}
        >
            clicked {count} times
        </p>
    );
}

当前界面中 countstep 是 3

useReducer 效果

还原 useReducer 的初始值,为什么还原不了

比如下面这个例子:

const initPerson = { name: '小明' };

const reducer = function (state, action) {
    switch (action.type) {
        case 'CHANGE':
            state.name = action.payload;
            return { ...state };
        case 'RESET':
            return initPerson;
        default:
            return state;
    }
};

function Counter() {
    const [person, dispatch] = useReducer(reducer, initPerson);
    const [value, setValue] = useState('小红');

    const handleChange = useCallback(e => setValue(e.target.value), []);

    const handleChangeClick = useCallback(() => dispatch({ type: 'CHANGE', payload: value }), [value]);

    const handleResetClick = useCallback(() => dispatch({ type: 'RESET' }), []);

    return (
        <>
            <p>name: {person.name}</p>
            <input type="text" value={value} onChange={handleChange} />
            <br />
            <br />
            <button onClick={handleChangeClick}>修改</button> |{' '}
            <button onClick={handleResetClick}>重置</button>
        </>
    );
}

点击修改按钮,将对象的 name 改为 小红,点击重置按钮,还原为原始对象。但是我们看看效果:

unreset

可以看到 name 修改小红后,无论如何点击重置按钮,都无法还原。

这是因为在 initPerson 的时候,我们改变了 state 的属性,导致初始值 initPerson 发生了变化,所以之后 RESET,即使返回了 initPerson``,但是name 值依然是小红。

所以我们在修改数据时,要注意,不要在原有数据上进行属性操作,重新创建新的对象进行操作即可。比如进行如下的修改:

// ...

const reducer = function (state, action) {
    switch (action.type) {
        case 'CHANGE':
            // !修改后的代码
            const newState = { ...state, name: action.payload }
            return newState;
        case 'RESET':
            return initPerson;
        default:
            return state;
    }
};

// ...

看看修改后的效果,可以正常的进行重置了:

reset

三、useEffect

useEffect 基本用法:

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        console.log('count: ', count);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

每次点击 p 标签,Counter 组件都会重新渲染,都可以在控制台看到有 log 打印。

使用 useEffect 模拟 componentDidMount

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        console.log('count: ', count);
        // 设置依赖为一个空数组
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 的依赖设置为空数组,可以看到,只有在组件初次渲染时,控制台会打印输出。之后无论 count 如何更新,都不会再打印。

使用 useEffect 模拟 componentDidUpdate

  • 使用条件判断依赖项是否是初始值,不是的话走更新逻辑
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        if (count !== 0) {
          console.log('count: ', count);
        }
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

但是这样处理有个弊端,当有多个依赖项时,需要多次比较,因此可以选择使用下面这种方式。

  • 使用 useRef 设置一个初始值,进行比较
function Counter() {
    const [count, setCount] = useState(0);
    const firstRender = useRef(true);

    useEffect(() => {
        if (firstRender.current) {
          firstRender.current = false;
        } else {
          console.log('count: ', count);
        }
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

使用 useEffect 模拟 componentWillUnmount

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        console.log('count: ', count);

        return () => {
          console.log('component will unmount')
        }
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 中包裹函数中返回的函数,会在函数组件重新渲染时,清理上一帧数据时触发执行。因此这个函数可以做一些清理的工作。
如果 useEffect 给定的依赖项是一个空数组,那么返回函数被执行时,代表着组件真正被卸载了。

useEffect 设置 依赖项为空数组,并且 返回一个函数,那么这个返回的函数就相当于是 componentWillUnmount

请注意,必须要设置依赖项为空数组。如果不是空数组,那么这个函数并不是在组件被卸载时触发,而是会在组件重新渲染,清理上一帧的数据时触发。

useEffect 正确的为 DOM 设置事件监听

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const handleClick = function() {
            console.log('count: ', count);
        }
        window.addEventListener('click', handleClick, false)

        return () => {
          window.removeEventListener('click', handleClick, false)
        };
    }, [count]);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

useEffect 中设置事件监听,在 return 的函数中对副作用进行清理,取消监听事件

useEffect、useCallback、useMemo 中获取到的 state、props 为什么是旧值

正如我们刚才所说,函数组件的每一帧会有自己独立的 state、function、props。而 useEffect、useCallback、useMemo 具有缓存功能。

因此,我们取的是当前对应函数作用域下的变量。如果没有正确的设置依赖项,那么 useEffect、useCallback、useMemo 就不会重新执行,其中使用的变量还是之前的值。

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      const handleClick = function() {
        console.log('count: ', count);
      }
        window.addEventListener('click', handleClick, false)

        return () => {
          window.removeEventListener('click', handleClick, false)
        };
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

还是上一个例子,如果此时给 useEffect 设置空数组为依赖项,那么无论 count 改变了多少次,点击 window,打印出来的 count 依然是 0

useEffect 中为什么会出现无限执行的情况

  • 没有为 useEffect 设置依赖项,并且在 useEffect 中更新 state,会导致界面无限重复渲染
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      setCount(count + 1);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这种情况会导致界面无限重复渲染,因为没有设置依赖项,如果我们想在界面初次渲染时,给 count 设置新值,给依赖项设置空数组即可。

修改后:只会在初始化时设置 count

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      setCount(count + 1);
    }, []);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

上面这个例子是依赖项缺失的时候,会出现问题,那么在依赖项正常设置的情况下,也会出现问题。

  • 此时有一个需求:每次 count 增加的时候,我们需要进行翻页(page + 1),看看如何写:

由于此时我们依赖 count,依赖项中要包含 count,而修改 page 时又需要依赖 page,所以依赖项中也要包含 page

function Counter() {
    const [count, setCount] = useState(0);
    const [page, setPage] = useState(0);

    useEffect(() => {
        setPage(page + 1);
    }, [count, page]);

    return (
        <>
            <p onClick={() => setCount(count + 1)}>clicked {count} times</p>
            <p>page: {page}</p>
        </>
    );
}

此时也会导致界面无限重复渲染的情况,那么此时修改 page 时改成函数的方式,并从依赖性中移除 page 即可

修改后:既能实现效果,又避免了重复渲染

function Counter() {
    const [count, setCount] = useState(0);
    const [page, setPage] = useState(0);

    useEffect(() => {
        setPage(p => p + 1);
    }, [count]);

    return (
        <>
            <p onClick={() => setCount(count + 1)}>clicked {count} times</p>
            <p>page: {page}</p>
        </>
    );
}

四、竞态

执行更早但返回更晚的情况会错误的对状态值进行覆盖

useEffect 中,可能会有进行网络请求的场景,我们会根据父组件传入的 id,去发起网络请求,id 变化时,会重新进行请求。

function App() {
    const [id, setId] = useState(0);

    useEffect(() => {
        setId(10);
    }, []);

    // 传递 id 属性
    return <Counter id={id} />;
}


// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            resolve(result);
        }, Math.random() * 1000 + 1000);
    });


function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        // 发送网络请求,修改界面展示信息
        const getData = async () => {
            const result = await fetchData(id);
            setData(result);
        };
        getData();
    }, [id]);

    return <p>result: {data}</p>;
}

展示结果:

竞态问题

上面的实例,多次刷新页面,可以看到最终结果有时展示的是 id 为 0 的请求结果,有时是 id 为 10 的结果
正确的结果应该是 ‘id 为 10 的请求结果’。这个就是竞态带来的问题。

解决办法:

  • 取消异步操作
// 存储网络请求的 Map
const fetchMap = new Map();

// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        const timer = setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            // 请求结束移除对应的 id
            fetchMap.delete(id);
            resolve(result);
        }, Math.random() * 1000 + 1000);

        // 设置 id 到 fetchMap
        fetchMap.set(id, timer);
    });

// 取消 id 对应网络请求
const removeFetch = (id) => {
  clearTimeout(fetchMap.get(id));
}

function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        const getData = async () => {
            const result = await fetchData(id);
            setData(result);
        };
        getData();
        return () => {
            // 取消对应网络请求
            removeFetch(id)
        }
    }, [id]);

    return <p>result: {data}</p>;
}

展示结果:

解决竞态问题

此时无论如何刷新页面,都只展示 id 为 10 的请求结果

  • 设置布尔值变量进行追踪
// 模拟网络请求
const fetchData = id =>
    new Promise(resolve => {
        setTimeout(() => {
            const result = `id 为${id} 的请求结果`;
            resolve(result);
        }, Math.random() * 1000 + 1000);
    });

function Counter({ id }) {
    const [data, setData] = useState('请求中。。。');

    useEffect(() => {
        let didCancel = false;
        const getData = async () => {
            const result = await fetchData(id);
            if (!didCancel) {
                setData(result);
            }
        };
        getData();
        return () => {
            didCancel = true;
        };
    }, [id]);

    return <p>result: {data}</p>;
}

可以发现,此时无论如何刷新页面,也都只展示 id 为 10 的请求结果

五、如何在函数组件中保存住非 stateprops 的值

函数组件是没有 this 指向的,所以为了可以保存住组件实例的属性,可以使用 useRef 来进行操作

函数组件的 ref 具有可以 穿透闭包 的能力。通过将普通类型的值转换为一个带有 current 属性的对象引用,来保证每次访问到的属性值是最新的。

保证在函数组件的每一帧里访问到的 state 值是相同的

  • 先看看不使用 useRef 的情况下,每一帧里的 state 值是如何打印的
function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const handleClick = function() {
            console.log('count: ', count);
        }
        window.addEventListener('click', handleClick, false)
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

先点击 p 标签 5 次,之后点击 window 对象,可以看到打印结果:

不使用 ref 时

  • 使用 useRef 之后,每一帧里的 ref 值是如何打印的
function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);

    useEffect(() => {
        // 将最新 state 设置给 countRef.current
        countRef.current = count;
        const handleClick = function () {
            console.log('count: ', countRef.current);
        };
        window.addEventListener('click', handleClick, false);
    });

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

和之前一样的操作,先点击 p 标签 5 次,之后点击 window 界面,可以看到打印结果

使用 ref 时

使用 useRef 即可以保证函数组件的每一帧里访问到的 state 值是相同的。

如何保存住函数组件实例的属性

函数组件是没有实例的,因此属性也无法挂载到 this 上。那如果我们想创建一个非 stateprops 变量,能够跟随函数组件进行创建销毁,该如何操作呢?

同样的,还是可以通过 useRefuseRef 不仅可以作用在 DOM 上,还可以将普通变量转化成带有 current 属性的对象

比如,我们希望设置一个 Model 的实例,在组件创建时,生成 model 实例,组件销毁后,重新创建,会自动生成新的 model 实例

class Model {
    constructor() {
        console.log('创建 Model');
        this.data = [];
    }
}

function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(new Model());

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

按照这种写法,可以实现在函数组件创建时,生成 Model 的实例,挂载到 countRefcurrent 属性上。重新渲染时,不会再给 countRef 重新赋值。

也就意味着在组件卸载之前使用的都是同一个 Model 实例,在卸载之后,当前 model 实例也会随之销毁。

仔细观察控制台的输出,会发现虽然 countRef 没有被重新赋值,但是在组件在重新渲染时,Model 的构造函数却依然会多次执行

所以此时我们可以借用 useState 的特性,改写一下。

class Model {
    constructor() {
        console.log('创建 Model');
        this.data = [];
    }
}

function Counter() {
    const [count, setCount] = useState(0);
    const [model] = useState(() => new Model());
    const countRef = useRef(model);

    return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}

这样使用,可以在不修改 state 的情况下,使用 model 实例中的一些属性,可以使 flag,可以是数据源,甚至可以作为 Mobxstore 进行使用。

六、useCallback

如题,当依赖频繁变更时,如何避免 useCallback 频繁执行呢?

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        setCount(count + 1);
    }, [count]);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

这里,我们把 click 事件提取出来,使用 useCallback 包裹,但其实并没有起到很好的效果。

因为 Counter 组件重新渲染目前只依赖 count 的变化,所以这里的 useCallback 用与不用没什么区别。

使用 useReducer 替代 useState

可以使用 useReducer 进行替代。

function Counter() {
    const [count, dispatch] = useReducer(x => x + 1, 0);

    const handleClick = useCallback(() => {
        dispatch();
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

useReducer 返回的 dispatch 函数是自带了 memoize 的,不会在多次渲染时改变。因此在 useCallback 中不需要将 dispatch 作为依赖项。

setState 中传递函数

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        setCount(c => c + 1);
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

setCount 中使用函数作为参数时,接收到的值是最新的 state 值,因此可以通过这个值执行操作。

通过 useRef 进行闭包穿透

function Counter() {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);

    useEffect(() => {
        countRef.current = count;
    }, [count]);

    const handleClick = useCallback(() => {
        setCount(countRef.current + 1);
    }, []);

    return <p onClick={handleClick}>clicked {count} times</p>;
}

这种方式也可以实现同样的效果。但是不推荐使用,不仅要编写更多的代码,而且可能会产生出乎预料的问题。

七、useMemo

上面讲述了 useCallback 的一些问题和解决办法。下面看一看 useMemo

useMemoReact.memo 不同:

  • useMemo 是对组件内部的一些数据进行优化和缓存,惰性处理。
  • React.memo 是对函数组件进行包裹,对组件内部的 stateprops 进行浅比较,判断是否需要进行渲染。

useMemouseCallback 的区别

  • useMemo 的返回值是一个值,可以是属性,可以是函数(包括组件)
  • useCallback 的返回值只能是函数

因此,useMemo 一定程度上可以替代 useCallback,等价条件:useCallback(fn, deps) => useMemo(() => fn, deps)

所以,上述关于 useCallback 一些优化点同样适用于 useMemo

八、useCallback 和 useMemo 是否应该频繁使用

这里先说一下我的浅见:不建议频繁使用

各位大佬先别开喷,容我说一说自己的观点

原因:

  • useCallback 和 useMemo 其实在函数组件中是作为函数进行调用,那么第一个参数就是我们传递的回调函数,无论是否使用 useCallback 和 useMemo,这个回调函数都会被创建,所以起不到降低函数创建成本的作用
  • 不仅无法降低创建成本,使用 useCallback 和 useMemo 后,第二个参数依赖项在每次 render 的时候还需要进行一次浅比较,无形中增加了数据对比的成本
  • 所以使用 useCallback 和 useMemo 不仅不能减少工作量,反而还会增加对比成本,因此不建议频繁的进行使用

原因解释了一波,那 useCallback 和 useMemo 是不是就没有意义呢,当然不是,一点作用没有的话,React 何必提供出来呢。

用还是要用的,不过我们需要根据情况进行判断,什么时候去使用。

下面介绍一些 useCallback 和 useMemo 适用的场景

useCallback 的使用场景

  1. 场景一:需要对子组件进行性能优化

    这个例子中,App 会向子组件 Foo 传递一个函数属性 onClick

    使用 useCallback 进行优化前的代码

    App.js

    import React, { useState } from 'react';
    import Foo from './Foo';
    
    function App() {
        const [count, setCount] = useState(0);
    
        const fooClick = () => {
            console.log('点击了 Foo 组件的按钮');
        };
    
        return (
            <div style={{ padding: 50 }}>
                <Foo onClick={fooClick} />
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>count increment</button>
            </div>
        );
    }
    
    export default App;

    Foo.js

    import React from 'react';
    
    const Foo = ({ onClick }) => {
    
        console.log('Foo 组件: render');
        return <button onClick={onClick}>Foo 组件中的 button</button>;
    
    };
    
    export default Foo;

    点击 App 中的 count increment 按钮,可以看到子组件 Foo 每次都会重新 render,但其实在 count 变化时,父组件重新 render,而子组件却不需要重新 render,当前情况自然没有什么问题。

    但是如果 Foo 组件是一个非常复杂庞大的组件,那么此时就有必要对 Foo 组件进行优化,useCallback 就能派上用场了。

    使用 useCallback 进行优化后的代码

    App.js 中将传递给子组件的函数属性用 useCallback 包裹起来

    import React, { useCallback, useState } from 'react';
    import Foo from './Foo';
    
    function App() {
        const [count, setCount] = useState(0);
    
        const fooClick = useCallback(() => {
            console.log('点击了 Foo 组件的按钮');
        }, []);
    
        return (
            <div style={{ padding: 50 }}>
                <Foo onClick={fooClick} />
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>count increment</button>
            </div>
        );
    }
    
    export default App;

    Foo.js 中使用 React.memo 对组件进行包裹(类组件的话继承 PureComponent 是同样的效果)

    import React from 'react';
    
    const Foo = ({ onClick }) => {
    
        console.log('Foo 组件: render');
        return <button onClick={onClick}>Foo 组件中的 button</button>;
    
    };
    
    export default React.memo(Foo);

    此时再点击 count increment 按钮,可以看到,父组件更新,但是子组件不会重新 render

  2. 场景二:需要作为其他 hooks 的依赖,这里仅使用 useEffect 进行演示

    这个例子中,会根据状态 page 的变化去重新请求网络数据,当 page 发生变化,我们希望能触发 useEffect 调用网络请求,而 useEffect 中调用了 getDetail 函数,为了用到最新的 page,所以在 useEffect 中需要依赖 getDetail 函数,用以调用最新的 getDetail

    使用 useCallback 处理前的代码

    App.js

    import React, { useEffect, useState } from 'react';
    
    const request = (p) =>
        new Promise(resolve => setTimeout(() => resolve({ content: `第 ${p} 页数据` }), 300));
    
    function App() {
        const [page, setPage] = useState(1);
        const [detail, setDetail] = useState('');
    
        const getDetail = () => {
            request(page).then(res => setDetail(res));
        };
    
        useEffect(() => {
            getDetail();
        }, [getDetail]);
    
        console.log('App 组件:render');
    
        return (
            <div style={{ padding: 50 }}>
                <p>Detail: {detail.content}</p>
                <p>Current page: {page}</p>
                <button onClick={() => setPage(page + 1)}>page increment</button>
            </div>
        );
    }
    
    export default App;

    但是按照上面的写法,会导致 App 组件无限循环进行 render,此时就需要用到 useCallback 进行处理

    使用 useCallback 处理后的代码

    App.js

    import React, { useEffect, useState, useCallback } from 'react';
    
    const request = (p) =>
        new Promise(resolve => setTimeout(() => resolve({ content: `第 ${p} 页数据` }), 300));
    
    function App() {
        const [page, setPage] = useState(1);
        const [detail, setDetail] = useState('');
    
        const getDetail = useCallback(() => {
            request(page).then(res => setDetail(res));
        }, [page]);
    
        useEffect(() => {
            getDetail();
        }, [getDetail]);
    
        console.log('App 组件:render');
    
        return (
            <div style={{ padding: 50 }}>
                <p>Detail: {detail.content}</p>
                <p>Current page: {page}</p>
                <button onClick={() => setPage(page + 1)}>page increment</button>
            </div>
        );
    }
    
    export default App;

    此时可以看到,App 组件可以正常的进行 render 了。这里仅使用 useEffect 进行演示,作为其他 hooks 的依赖项时,也需要照此进行优化

  3. useCallback 使用场景总结:

    1. 向子组件传递函数属性,并且子组件需要进行优化时,需要对函数属性进行 useCallback 包裹

    2. 函数作为其他 hooks 的依赖项时,需要对函数进行 useCallback 包裹

useMemo 的使用场景

  1. useCallback 场景一:需要对子组件进行性能优化时,用法也基本一致

  2. useCallback 场景二:需要作为其他 hooks 的依赖时,用法也基本一致

  3. 需要进行大量或者复杂运算时,为了提高性能,可以使用 useMemo 进行数据缓存

    这里也是用到了 useMemo 的数据缓存功能,在依赖项发生变化之前,useMemo 中包裹的函数不会重新执行

    看下面这个例子,App 组件中两个状态:countNumber 数组 dataSource,点击 increment 按钮,count 会增加,点击 fresh 按钮,会重新获取 dataSource,但是界面上并不需要展示 dataSource,而是需要展示 dataSource 中所有元素的和,所以我们需要一个新的变量 sum 来承载,展示到页面上。

    下面看代码

    使用 useMemo 优化前的代码

    App.js

    import React, { useState } from 'react';
    
    const request = () =>
        new Promise(resolve =>
            setTimeout(
                () => resolve(Array.from({ length: 100 }, () => Math.floor(100 * Math.random()))),
                300
            )
        );
    
    function App() {
        const [count, setCount] = useState(1);
        const [dataSource, setDataSource] = useState([]);
    
        const reduceDataSource = () => {
            console.log('reduce');
            return dataSource.reduce((reducer, item) => {
                return reducer + item;
            }, 0);
        };
    
        const sum = reduceDataSource();
    
        const refreshClick = () => {
            request().then(res => setDataSource(res));
        };
    
        return (
            <div style={{ padding: 50 }}>
                <p>DataSource 元素之和: {sum}</p>
                <button onClick={refreshClick}>Refresh</button>
                <p>Current count: {count}</p>
                <button onClick={() => setCount(count + 1)}>increment</button>
            </div>
        );
    }
    
    export default App;

    打开控制台,可以看到,此时无论点击 increment 或者 Refresh 按钮,reduceDataSource 函数都会执行一次,但是 dataSource 中有 100 个元素,所以我们肯定是希望在 dataSource 变化时才重新计算 sum 值,这时候 useMemo 就排上用场了。

    使用 useMemo 优化后的代码

    App.js

    import React, { useMemo, useState } from 'react';
    
    const request = () =>
        new Promise(resolve =>
            setTimeout(
                () => resolve(Array.from({ length: 100 }, () => Math.floor(100 * Math.random()))),
                300
            )
        );
    
    function App() {
        const [count, setCount] = useState(1);
        const [dataSource, setDataSource] = useState([]);
    
        const sum = useMemo(() => {
            console.log('reduce');
            return dataSource.reduce((reducer, item) => {
                return reducer + item;
            }, 0);
        }, [dataSource]);
    
        const refreshClick = () => {
            request().then(res => setDataSource(res));
        };
    
        return (
            <div style={{ padding: 50 }}>
                <p>DataSource 元素之和: {sum}</p>
                <button onClick={refreshClick}>Refresh</button>
                <p>Current count: {count}</p>
                <button onClick={() => setCount(count + 1)}>increment</button>
            </div>
        );
    }
    
    export default App;

    此时可以看到,只有点击 Refresh 按钮 时,useMemo 中的函数才会重新执行。点击 increment 按钮时,sum 还是之前的缓存结果,不会重新计算。

  4. useMemo 使用场景总结:

    1. 向子组件传递 引用类型 属性,并且子组件需要进行优化时,需要对属性进行 useMemo 包裹

    2. 引用类型值,作为其他 hooks 的依赖项时,需要使用 useMemo 包裹,返回属性值

    3. 需要进行大量或者复杂运算时,为了提高性能,可以使用 useMemo 进行数据缓存,节约计算成本

所以,在 useCallback 和 useMemo 使用过程中,如非必要,无需使用,频繁使用反而可能会增加依赖对比的成本,降低性能。

九、如何在父组件中调用子组件的状态或者方法

在函数组件中,没有组件实例,所以无法像类组件中,通过绑定子组件的实例调用子组件中的状态或者方法。

那么在函数组件中,如何在父组件调用子组件的状态或者方法呢?答案就是使用 useImperativeHandle

语法

useImperativeHandle(ref, createHandle, [deps])

  • 第一个参数是 ref 值,可以通过属性传入,也可以配合 forwardRef 使用

  • 第二个参数是一个函数,返回一个对象,对象中的属性都会被挂载到第一个参数 ref 的 current 属性上

  • 第三个参数是依赖的元素集合,同 useEffect、useCallback、useMemo,当依赖发生变化时,第二个参数会重新执行,重新挂载到第一个参数的 current 属性上

用法

注意:

  • 第三个参数,依赖必须按照要求填写,少了会导致返回的对象属性异常,多了会导致 createHandle 重复执行
  • 一个组件或者 hook 中,对于同一个 ref,只能使用一次 useImperativeHandle,多次的话,后面执行的 useImperativeHandlecreateHandle 返回值会替换掉前面执行的 useImperativeHandlecreateHandle 返回值

Foo.js

import React, { useState, useImperativeHandle, useCallback } from 'react';

const Foo = ({ actionRef }) => {
    const [value, setValue] = useState('');

    /**
     * 随机修改 value 值的函数
     */
    const randomValue = useCallback(() => {
        setValue(Math.round(Math.random() * 100) + '');
    }, []);

    /**
     * 提交函数
     */
    const submit = useCallback(() => {
        if (value) {
            alert(`提交成功,用户名为:${value}`);
        } else {
            alert('请输入用户名!');
        }
    }, [value]);

    useImperativeHandle(
        actionRef,
        () => {
            return {
                randomValue,
                submit,
            };
        },
        [randomValue, submit]
    );

    /* !! 返回多个属性要按照上面这种写法,不能像下面这样使用多个 useImperativeHandle
      useImperativeHandle(actionRef, () => {
          return {
              submit,
          }
      }, [submit])

      useImperativeHandle(actionRef, () => {
          return {
              randomValue
          }
      }, [randomValue])
  */

    return (
        <div className="box">
            <h2>函数组件</h2>
            <section>
                <label>用户名:</label>
                <input
                    value={value}
                    placeholder="请输入用户名"
                    onChange={e => setValue(e.target.value)}
                />
            </section>
            <br />
        </div>
    );
};

export default Foo;

App.js

import React, { useRef } from 'react';
import Foo from './Foo'

const App = () => {
    const childRef = useRef();

    return (
        <div>
            <Foo actionRef={childRef} />
            <button onClick={() => childRef.current.submit()}>调用子组件的提交函数</button>
            <br />
            <br />
            <button onClick={() => childRef.current.randomValue()}>
                随机修改子组件的 input 值
            </button>
        </div>
    );
};

十、参考文档

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

通俗易懂的 React Fiber 实现教程

本文详细介绍了 Fiber 的**以及代码实现过程。对于理解和入门 Fiber 架构有一定帮助。

不过仅限于模拟实现,更多细节仍需参考官方源码进行学习。

代码实现已经进行整理:源码地址

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

渲染过程

fiber 渲染过程

react-fiber

之前的树形结构渲染过程:

tree-render

diff 策略

  • 同级比较,Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。如出现跨层级的移动操做,则直接将原 DOM 树删除,重新创建

所以下图的 D 节点所在 DOM 树会被删除重新创建,F 节点也会被删除后,重新创建

tree-render

  • 拥有不同类型的两个组件将会生成不同的树形结构。不同类型的两个组件则认为是删除原 DOM 树之后重新创建

下图的 D 节点所在 DOM 树会被删除后重新创建

tree-render

  • 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定

    • 不使用 key 或使用 index 作为 key 时,下图会进行 F、B、C、D 节点的更新,并新增 E 节点

    tree-render

    • 使用唯一 key 值时,只会新增 F 节点添加到 B 节点之前,B、C、D、E 节点都不会发生更新操作

    tree-render

fiber **

将树形结构转化成链表结构

tree-render

Fiber 对象

fiber 是一个链表元素对象,包含以下基本属性

  • type:节点类型
  • key:节点 key
  • props:节点属性
  • child:第一个子 fiber
  • node:对应的真实 DOM
  • base:对应的上一次的 fiber
  • return:父 fiber
  • sibling:下一个兄弟 fiber

有了这些属性就可以构成一个链表结构,可以通过当前 fiber 找到父 fiber 以及兄弟 fiber

requestIdleCallback

  • api: window.requestIdleCallback(callback)

  • 作用

    • 当浏览器处于空闲状态时,会调用传入的 callback 函数

    • callback 会接受一个参数 deadLine

    • 可以通过 deadLine.timeRemaining 判断浏览器是否处于空闲状态

  • 参考地址

一、初始化

执行 fiber

定义 perforUnitOfWork 函数用来对 fiber 进行操作和更新

perforUnitOfWork 有两个作用

  • 执行 fiber 任务
  • 返回下一个需要执行的 fiber 任务
function perforUnitOfWork(fiber) {
  if (!fiber) {
    return null;
  }
  // 1、执行 fiber 操作
  const { type } = fiber;
  if (typeof type === "function") {
    type.prototype && type.prototype.isReactComponent
      ? updateClassComponent(fiber)
      : updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }

  /**
   * 2、返回下一个 fiber
   *  优先返回子节点 fiber
   *  如果没有子 fiber,返回兄弟节点 fiber
   *  没有兄弟节点 fiber,返回父节点的 fiber
   */
  if (fiber.child) {
    return fiber.child;
  }

  while (fiber) {
    if (fiber.sibling) {
      return fiber.sibling;
    }
    fiber = fiber.return;
  }
  return null;
}

updateHostComponent function 更新原始 DOM 标签

  • fiber 设置 node 属性
  • 协调子元素,生成链表结构
function updateHostComponent(fiber) {
  if (!fiber.node) {
    fiber.node = createNode(fiber);
  }
  if (fiber.props && fiber.props.children) {
    reconcileChildren(fiber, fiber.props.children);
  }
}

updateClassComponent function 更新类组件

  • 将类组件创建后执行 render
  • render 产生的虚拟 DOM 和类组件的 fiber 生成链表结构
function updateClassComponent(fiber) {
  const { type: Type, props } = fiber;
  const vNode = new Type(props).render();
  reconcileChildren(fiber, [vNode]);
}

updateFunctionComponent function 更新函数式组件

  • 执行函数组件
  • 将函数组件返回的虚拟 DOM 和 函数组件的 fiber 生成链表结构
function updateFunctionComponent(fiber) {
  const { type, props } = fiber;
  const vNode = type(props);
  reconcileChildren(fiber, [vNode]);
}

创建真实 DOM

createNode 创建真实 DOM

function createNode(fiber) {
  let node = null;
  const { type, props } = fiber;
  if (type === "TEXT") {
    node = document.createTextNode("");
  } else if (typeof type === "string") {
    node = document.createElement(type);
  } else {
    node = document.createDocumentFragment();
  }

  updateNode(node, props);

  return node;
}

updateNode 更新 DOM 属性

function updateNode(node, nextProps) {
  if (!nextProps) {
    return;
  }
  Object.keys(nextProps)
    .filter(propName => propName !== "children")
    .forEach(propName => {
      // 设置事件监听
      if (propName.startsWith("on")) {
        const eventName = propName.slice(2).toLowerCase();
        node.addEventListener(eventName, nextProps[propName]);
      } else {
        // 设置节点属性
        node[propName] = nextProps[propName];
      }
    });
}

协调子元素,生成链表结构

reconcileChildren 协调子元素

function reconcileChildren(returnFiber, children) {
  let prevFiber = null;

  // 循环子元素,生成链表结构
  for (let i = 0; i < children.length; i++) {
    const child = children[i];

    const newFiber = {
      type: child.type,
      key: child.key,
      props: child.props,
      node: null,
      base: null,
      return: returnFiber,
      effectTag: "PLACEMENT"
    };
    if (prevFiber === null) {
      returnFiber.child = newFiber;
    } else {
      prevFiber.sibling = newFiber;
    }
    prevFiber = newFiber;
  }
}

上面只是写了执行 fiber 任务的 perforUnitOfWork 函数,但是还没有调用过,下面我们看看 perforUnitOfWork 函数是什么时候被调用的

workLoop

作用:轮询判断是否需要执行 fiber 操作。

通过 requestIdleCallback 方法判断,在浏览器处于空闲状态时才会继续执行 perforUnitOfWork 函数

// 下一个将被执行的 fiber
let nextUnitWork = null;
// `root fiber`
let wipRoot = null;

function workLoop(deadLine) {
  while (nextUnitWork && deadLine.timeRemaining() > 0) {
    nextUnitWork = perforUnitOfWork(nextUnitWork);
  }

  if (nextUnitWork == null && wipRoot) {
    commitRoot();
  }

  window.requestIdleCallback(workLoop);
}

window.requestIdleCallback(workLoop);

提交更新

commitRoot:提交 root fiber 任务

function commitRoot() {
  commitWorker(wipRoot.child);
  wipRoot = null;
}

commitWorker:更新 fiber 节点

function commitWorker(fiber) {
  if (!fiber) {
    return;
  }

  if (fiber.node && fiber.effectTag === "PLACEMENT") {
    let parentFiber = fiber.return;
    let parentNode = null;
    while (parentFiber) {
      if (parentFiber.node && parentFiber.node.nodeType !== 11) {
        parentNode = parentFiber.node;
        break;
      }
      parentFiber = parentFiber.return;
    }
    parentNode.appendChild(fiber.node);
  }

  // 提交子 fiber 任务
  commitWorker(fiber.child);
  // 提交兄弟 fiber 任务
  commitWorker(fiber.sibling);
}

render 渲染函数

workLoop 函数中,执行 perforUnitOfWork 方法的条件是 nextUnitWork 存在。
因此我们需要给 nextUnitWork 一个初始值,我们在界面上调用 render 函数的时候可以给 nextUnitWork 和 wipRoot 赋上初始值

render

const render = (vNode, container) => {
  wipRoot = {
    props: {
      children: [vNode]
    },
    node: container,
    base: null,
    return: null,
    sibling: null
  };

  nextUnitWork = wipRoot;
};

二、useState

更新的话:

1、需要重新启动 fiber 任务,所以这里我们新增一个 currentRootFiber 用来存储上一次的 wipRoot

2、需要知道当前执行的 fiber 是哪个,所以需要新增一个 wipFunctionFible 变量在 updateFunctionComponent 的时候赋值

3、不同类型的元素进行切换时,需要删除之前的元素节点,因此需要新增一个 deletions 变量用来存储需要删除的 fiber

let currentRootFiber = null;

let wipFunctionFible = null;

let deletions = [];

更新函数 useState

  • 初始化阶段:直接返回初始值和 setState 函数

  • 更新阶段:从上一次的 fiber 中取出之前的 hook 值,为 state 赋上最新的值

  • setState 函数会给将新的 state 值填充到 hookqueue 属性中

  • 函数组件每次更新会重新执行 useState

    • 会从 hookqueue 属性中取出最新的 state
    • 并将 hookIndex 后移,执行下一个 hook
/**
 * 函数组件更新 state 的 hook
 * @param {*} init
 */
export const useState = init => {
  /**
   * 获取到上一次的 fiber ,如果存在,则取出上一次的 fiber 中存储的 hooks
   * 根据当前执行 fiber 的 hookIndex 找到当前 useState 值的 oldHook
   * 如果 oldHook 存在,则使用 oldHook,不存在则使用初始值
   *
   * 循环 hook 中队列 queue 存储的值,为 state 设置最新的值
   */
  const oldFiber = wipFunctionFible.base && wipFunctionFible.base;
  const oldHook = oldFiber && oldFiber.hooks[wipFunctionFible.hookIndex];
  const hook = oldHook
    ? { state: oldHook.state, queue: oldHook.queue }
    : { state: init, queue: [] };
  hook.queue.forEach(i => (hook.state = i));
  hook.queue = [];

  /**
   * 设置 state,将接收到的 aciton push 到队列 queue 中
   *
   * 为 wipRoot 赋值,并将 wipRoot 赋值给 nextUnitWork,启动 fiber 任务
   */
  const setState = action => {
    // 若新的 action 和 上一次的 state 相同,则无需更新
    if (action === hook.state) {
      return;
    }
    // 不同,则 push 到 queue 中
    hook.queue.push(action);
    wipRoot = {
      props: currentRootFiber.props,
      node: currentRootFiber.node,
      base: currentRootFiber
    };
    nextUnitWork = wipRoot;
    deletions = [];
  };

  /**
   * 将 hook push 到 wipFunctionFible 的 hooks 属性中,用于下一次的更新操作。
   * 并将 wipFunctionFible 的 hookIndex 后移,执行下一个 useState
   */
  wipFunctionFible.hooks.push(hook);
  wipFunctionFible.hookIndex++;

  return [hook.state, setState];
};

修改 updateFunctionComponent 函数

updateFunctionComponent 中为 fiber 设置 hookshookIndex 属性,用来在后面操作 useState 时使用

  • hooks 属性:保存函数组件中的 hook

  • hookIndex 属性:当前函数组件中执行到某个 hook 的下标

function updateFunctionComponent(fiber) {
    fiber.hooks = [];
    fiber.hookIndex = 0;
    wipFunctionFible = fiber;

    const { type, props } = fiber;
    const vNode = type(props);
    reconcileChildren(fiber, [vNode]);
}

修改 reconcileChildren 函数

function reconcileChildren(returnFiber, children) {
  let prevFiber = null;

  let oldFiber = returnFiber.base && returnFiber.base.child;
  // 循环子元素,生成链表结构
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    let newFiber = null;

    // 判断 oldFiber 和 child 是否可以复用
    const sameType =
      oldFiber &&
      child &&
      oldFiber.key === child.key &&
      oldFiber.type === child.type;

    // 1、如果可以复用,则将 oldFiber 设置为 newFiber 的 base 属性,并将 effectTag 设置为 UPDATE
    if (sameType) {
      newFiber = {
        type: child.type,
        key: child.key,
        props: child.props,
        node: oldFiber.node,
        base: oldFiber,
        return: returnFiber,
        effectTag: "UPDATE"
      };
    }

    // 2、如果不能复用,则将 newFiber 的 effectTag 属性设置为 PLACEMENT
    if (!sameType && child) {
      newFiber = {
        type: child.type,
        key: child.key,
        props: child.props,
        node: null,
        base: null,
        return: returnFiber,
        effectTag: "PLACEMENT"
      };
    }

    // 3、如果 oldFiber 存在但是不能复用,则将 oldFiber 的 effectTag 属性设置为 DELETION,
    //  并添加到 deletions 数组中
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    // oldFiber 后移,找到兄弟节点
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (prevFiber === null) {
      returnFiber.child = newFiber;
    } else {
      prevFiber.sibling = newFiber;
    }
    prevFiber = newFiber;
  }
}

commitWorker 函数中新增更新和删除操作

  • 判断 effectTagPLACEMENT,执行新增操作

  • 判断 effectTagUPDATE,则进行属性更新

  • 判断 effectTagDELETION,则将上一次的 fibernode 节点,从文档中删除

function commitWorker(fiber) {
  if (!fiber) {
    return;
  }

  // 执行新增插入操作
  if (fiber.node && fiber.effectTag === "PLACEMENT") {
    let parentFiber = fiber.return;
    let parentNode = null;
    // 有可能 parentFiber 是fragment 或者 Provider 等这些组件,是没有 node 属性的,所以要循环向上找到 parentNode
    while (parentFiber) {
      if (parentFiber.node && parentFiber.node.nodeType !== 11) {
        parentNode = parentFiber.node;
        break;
      }
      parentFiber = parentFiber.return;
    }
    parentNode.appendChild(fiber.node);
  } else if (fiber.node && fiber.effectTag === "UPDATE") {
    // 执行属性更新操作
    updateNode(fiber.node, fiber.base.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    // 执行删除操作
    commitDeletion(fiber);
    // !!! 这里要 return 掉,不能执行 child 和 sibling,因为 child 和 sibling 的 effectTag 值并没有修改成 DELETION
    return;
  }

  // 提交子 fiber 任务
  commitWorker(fiber.child);
  // 提交兄弟 fiber 任务
  commitWorker(fiber.sibling);
}

删除操作 commitDeletion

function commitDeletion(fiber) {
  // 当前 fiber 上不存在 node,则删除子 fiber 的 node
  if (fiber.node) {
    fiber.node.remove();
  } else {
    commitDeletion(fiber.child);
  }
}

修改更新函数

对比新旧属性,删除旧属性,设置新属性。

function updateNode(node, prevProps, nextProps) {
  if (!node || !nextProps || !prevProps) {
    return;
  }

  /**
   * 移除上一次的事件监听
   * 若当前 nextProps 中不包含上一次的某个属性,则将该属性值置为空
   */
  Object.keys(prevProps)
    .filter(propName => propName !== "children")
    .forEach(propName => {
      if (propName.startsWith("on")) {
        const eventName = propName.slice(2).toLowerCase();
        node.removeEventListener(eventName, prevProps[propName], false);
      } else if (!([propName] in nextProps)) {
        node[propName] = "";
      }
    });

  // 将新的属性设置到 node 上
  Object.keys(nextProps)
    .filter(propName => propName !== "children")
    .forEach(propName => {
      // 设置事件监听
      if (propName.startsWith("on")) {
        const eventName = propName.slice(2).toLowerCase();
        node.addEventListener(eventName, nextProps[propName], false);
      } else {
        // 设置节点属性
        node[propName] = nextProps[propName];
      }
    });
}

但是此时组件只能进行插入和更新,还不能进行复用,也不能计算出新的组件应该插入的位置。所以我们需要改写一下 reconcileChildren 函数

三、更新过程

就是新旧组件的 diff 过程,遵循上面提到的 react diff **。

定义变量

function reconcileChildren(returnFiber, children) {
  // 找到上一次的 fiber
  let oldFiber = returnFiber.base && returnFiber.base.child;

  let prevFiber = null;

  // fiber 上一次的位置下标
  let lastPlacedIndex = 0;

  // 用来循环的下标
  let newIdx = 0;

  // 用来临时存放 oldFiber 的变量
  let nextOldFiber = null;

  // 判断初次渲染还是更新的 flag
  const shouldTrackSideEffects = !!oldFiber;
}

记录组件对应的 index 值

placeChild 函数

function placeChild(newFiber, lastPlacedIndex, newIdx, shouldTrackSideEffects) {
    // 将 newFiber 在当前层级的位置设置到 newFiber 的 index 属性上
    newFiber.index = newIdx;
    if (!shouldTrackSideEffects) {
        return lastPlacedIndex;
    }
    const base = newFiber.base;
    if (base !== null) {
        if (base.index < lastPlacedIndex) {
            return lastPlacedIndex;
        } else {
            return base.index;
        }
    } else {
        newFiber.effectTag = PLACEMENT;
        return lastPlacedIndex;
    }
}

组件更新时

/ 1、组件更新时,走这个条件分支
for (; oldFiber && newIdx < children.length; newIdx++) {
  const newChild = children[newIdx];
  if (oldFiber.index > newIdx) {
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    nextOldFiber = oldFiber.sibling;
  }

  if (oldFiber === null) {
    oldFiber = nextOldFiber;
  }

  const sameType =
        newChild &&
        oldFiber &&
        newChild.key === oldFiber.key &&
        newChild.type === oldFiber.type;

  if (!sameType) {
    break;
  }

  const newFiber = {
    type: newChild.type,
    key: newChild.key,
    props: newChild.props,
    return: returnFiber,
    node: oldFiber.node,
    base: oldFiber,
    effectTag: UPDATE,
  };

  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx, shouldTrackSideEffects);

  if (prevFiber === null) {
    returnFiber.child = newFiber;
  } else {
    prevFiber.sibling = newFiber;
  }
  prevFiber = newFiber;

  oldFiber = nextOldFiber;
}

新组件循环完毕时

// 2、如果是循环结束,会走到这个条件分支,则将剩余的 oldFiber 删除
if (newIdx === children.length) {
  while (oldFiber) {
    deletions.push({
      ...oldFiber,
      effectTag: DELETION
    });
    oldFiber = oldFiber.sibling;
  }
}

初始化或者新增时

// 3、如果 oldFiber 不存在,代表新增元素,可能是初始化,也可能是新插入的元素
if (!oldFiber) {
  for (; newIdx < children.length; newIdx++) {
    const newChild = children[newIdx];
    if (!newChild) {
      continue;
    }
    const newFiber = {
      type: newChild.type,
      key: newChild.key,
      props: newChild.props,
      return: returnFiber,
      node: null,
      base: null,
      effectTag: PLACEMENT
    };
    lastPlacedIndex = placeChild(
      newFiber,
      lastPlacedIndex,
      newIdx,
      shouldTrackSideEffects
    );

    if (prevFiber === null) {
      returnFiber.child = newFiber;
    } else {
      prevFiber.sibling = newFiber;
    }
    prevFiber = newFiber;
  }
  return;
}

新旧组件 index 不同但可以复用时

// 将链表结构转化成 map 结构
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 4、当 oldFiber 存在并且 oldFiber 不能被复用的时候,会走到这个条件分支
for (; newIdx < children.length; newIdx++) {
  const newChild = children[newIdx];
  let newFiber = {
    type: newChild.type,
    key: newChild.key,
    props: newChild.props,
    return: returnFiber
  };

  /**
   * 和第 1 个条件分支不同:
   *  1 中是一一对比,看是否能够复用 oldFiber
   *  这里是根据 key(没有 key 使用 index) 从剩余的 oldFiber 中查找出是否有对应的 oldFiber
   */

  const matchedFiber = existingChildren.get(
    newChild.key == null ? newIdx : newChild.key
  );

  // 如果匹配到对应 key / index 的 oldFiber,并且 type 也是相同的,则可以进行复用,更新即可
  if (matchedFiber && matchedFiber.type === newChild.type) {
    newFiber = {
      ...newFiber,
      node: matchedFiber.node,
      base: matchedFiber,
      effectTag: UPDATE
    };
    // 匹配到 oldFiber 之后,则从 map 中移除对应的 fiber,避免重复匹配
    existingChildren.delete(newChild.key == null ? newIdx : newChild.key);
  } else {
    // 没有匹配到的话,则新增 fiber
    newFiber = {
      ...newFiber,
      node: null,
      base: null,
      effectTag: PLACEMENT
    };
  }
  lastPlacedIndex = placeChild(
    newFiber,
    lastPlacedIndex,
    newIdx,
    shouldTrackSideEffects
  );
  if (prevFiber === null) {
    returnFiber.child = newFiber;
  } else {
    prevFiber.sibling = newFiber;
  }
  prevFiber = newFiber;
}

// 更新阶段,fiber 操作执行完毕,map 中仍有未被匹配的 oldFiber ,则进行删除
if (shouldTrackSideEffects) {
  existingChildren.forEach(child => {
    deletions.push({
      ...child,
      effectTag: DELETION
    });
  });
}

将链表转化为 Map 结构的 mapRemainingChildren 函数

查询是否有可复用 oldFiber 时,从 Map 中查找比链表要更方便,所以可以提前将链表结构转为 Map

function mapRemainingChildren(returnFiber, currentChildFiber) {
  const existingChildren = new Map();
  while (currentChildFiber) {
    if (currentChildFiber.key !== null) {
      existingChildren.set(currentChildFiber.key, currentChildFiber);
    } else {
      existingChildren.set(currentChildFiber.index, currentChildFiber);
    }
    currentChildFiber = currentChildFiber.sibling;
  }
  return existingChildren;
}

修改 DOM 新增的方式

修改 commitWorker 函数

function commitWorker(fiber) {
  if (!fiber) {
    return;
  }

  // 执行新增插入操作
  if (fiber.node && fiber.effectTag === PLACEMENT) {
    let parentFiber = fiber.return;
    let parentNode = null;
    // 有可能 parentFiber 是 Fragment 或者 Provider 等这些组件,是没有 node 属性的,所以要循环向上找到 parentNode
    while (parentFiber) {
      if (parentFiber.node && parentFiber.node.nodeType !== FRAGMENT) {
        parentNode = parentFiber.node;
        break;
      }
      parentFiber = parentFiber.return;
    }
    if (fiber.type !== "TEXT") {
      console.log("新增", fiber);
    }
    // parentNode.appendChild(fiber.node);
    insertOrAppend(fiber, parentNode);
  } else if (fiber.node && fiber.effectTag === UPDATE) {
    // 执行属性更新操作
    updateNode(fiber.node, fiber.base.props, fiber.props);
  } else if (fiber.effectTag === DELETION) {
    // 执行删除操作
    commitDeletion(fiber);
    // !!! 这里要 return 掉,不能执行 child 和 sibling,因为 child 和 sibling 的 effectTag 值并没有修改成 DELETION
    return;
  }

  // 提交子 fiber 任务
  commitWorker(fiber.child);
  // 提交兄弟 fiber 任务
  commitWorker(fiber.sibling);
}

insertOrAppend 函数

function getHostSibling(fiber) {
  let sibling = fiber.return.child;
  while (sibling) {
    // !!! 这里判断 fiber.index 小于它的兄弟节点 index 即可,因为此时 index 可能不是连续的,不能直接使用 fiber.index + 1 === sibling.index 来判断
    if (fiber.index < sibling.index && sibling.effectTag === "UPDATE") {
      return sibling.node;
    }
    sibling = sibling.sibling;
  }

  return null;
}

function insertOrAppend(fiber, parentNode) {
  let before = getHostSibling(fiber);
  let node = fiber.node;
  if (before) {
    parentNode.insertBefore(node, before);
  } else {
    parentNode.appendChild(node);
  }
}

写在后面

源码地址

至此,fiber 的初始化和更新操作已经基本完成。这里只是简单的对 fiber **进行了一个实现,还有很多功能没有完善。比如重新排序的组件还不能按照正确的顺序执行。有兴趣的朋友可以继续向下拓展。

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 类组件生命周期详解

本文详细介绍了 React 生命周期的用法以及各个阶段的生命周期进行,并且用实例代码做了详细演示。

Demo 地址

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

话不多说,先上图

React生命周期图解

上图是基于 React 16.4 之后的生命周期图解。如感觉不对,请先查看 React 版本

React 生命周期详解

各个阶段的生命周期函数

constructor 构造函数

在 React 组件挂载之前被调用,实现 React.Component 的子类的构造函数时,要在第一行加上 super(props)。

React 构造函数通常只用于两个目的:

  • 通过分配一个对象到 this.state 来初始化本地 state
  • 将 事件处理程序 方法绑定到实例

如果没有初始化状态(state),并且没有绑定方法,通常不需要为 React 组件实现一个构造函数。

不需要在构造函数中调用 setState(),只需将初始状态设置给 this.state 即可 。

static getDerivedStateFromProps(nextProps, prevState)

getDerivedStateFromProps 在每次调用 render 方法之前调用。包括初始化和后续更新时。

包含两个参数:第一个参数为即将更新的 props 值,第二个参数为之前的 state

返回值:返回为 null 时,不做任何副作用处理。倘若想更新某些 state 状态值,则返回一个对象,就会对 state 进行修改

该生命周期是静态函数,属于类的方法,其作用域内是找不到 this

render()

render() 方法是类组件中唯一必须的方法,其余生命周期不是必须要写。
组件渲染时会走到该生命周期,展示的组件都是由 render() 生命周期的返回值来决定。

注意:
如果 shouldComponentUpdate() 方法返回 false ,render() 不会被调用。

componentDidMount()

在 React 组件装载(mounting)(插入树)后被立即调用。

componentDidMount 生命周期是进行发送网络请求、启用事件监听的好时机

如果有必要,可以在此生命周期中立刻调用 setState()

shouldComponentUpdate(nextProps, nextState)

在组件准备更新之前调用,可以控制组件是否进行更新,返回 true 时组件更新,返回 false 组件不更新。

包含两个参数,第一个是即将更新的 props 值,第二个是即将跟新后的 state 值,可以根据更新前后的 props 或 state 进行判断,决定是否更新,进行性能优化

不要 shouldComponentUpdate 中调用 setState(),否则会导致无限循环调用更新、渲染,直至浏览器内存崩溃

getSnapshotBeforeUpdate(prevProps, prevState)

getSnapshotBeforeUpdate() 在最近一次的渲染输出被提交之前调用。也就是说,在 render 之后,即将对组件进行挂载时调用。

它可以使组件在 DOM 真正更新之前捕获一些信息(例如滚动位置),此生命周期返回的任何值都会作为参数传递给 componentDidUpdate()。如不需要传递任何值,那么请返回 null

componentDidUpdate(prevProps, prevState, snapshot)

componentDidUpdate() 在更新发生之后立即被调用。这个生命周期在组件第一次渲染时不会触发。

可以在此生命周期中调用 setState(),但是必须包含在条件语句中,否则会造成无限循环,最终导致浏览器内存崩溃

componentWillUnmount()

componentWillUnmount() 在组件即将被卸载或销毁时进行调用。

此生命周期是取消网络请求、移除监听事件清理 DOM 元素清理定时器等操作的好时机

注意:
componentWillMount()、componentWillUpdate()、componentWillReceiveProps() 即将被废弃,请不要再在组件中进行使用。因此本文不做讲解,避免混淆。

生命周期执行顺序

挂载时

  • constructor()

  • static getDerivedStateFromProps()

  • render()

  • componentDidMount()

更新时

  • static getDerivedStateFromProps()

  • shouldComponentUpdate()

  • render()

  • getSnapshotBeforeUpdate()

  • componentDidUpdate()

卸载时

  • componentWillUnmount()

生命周期中是否可调用 setState()

初始化 state

  • constructor()

可以调用 setState()

  • componentDidMount()

根据判断条件可以调用 setState()

  • componentDidUpdate()

禁止调用 setState()

  • shouldComponentUpdate()

  • render()

  • getSnapshotBeforeUpdate()

  • componentWillUnmount()


实例演示

源码地址

下面根据一个父子组件的props 改变、state 改变以及子组件的挂载/卸载等事件,对各生命周期执行顺序进行理解,有兴趣的同学可以一起看一下,也可以下载代码自己进行测试。

编写组件代码

父组件:Parent.js

import React, { Component } from 'react';

import Child from './Child.js';

const parentStyle = {
    padding: 40,
    margin: 20,
    border: '1px solid pink'
}

const TAG = "Parent 组件:"

export default class Parent extends Component {

    constructor(props) {
        super(props);
        console.log(TAG, 'constructor');
        this.state = {
            num: 0,
            mountChild: true
        }
    }

    static getDerivedStateFromProps(nextProps, prevState) {
        console.log(TAG, 'getDerivedStateFromProps');
        return null;
    }

    componentDidMount() {
        console.log(TAG, 'componentDidMount');
    }

    shouldComponentUpdate(nextProps, nextState) {
        console.log(TAG, 'shouldComponentUpdate');
        return true;
    }

    getSnapshotBeforeUpdate(prevProps, prevState) {
        console.log(TAG, 'getSnapshotBeforeUpdate');
        return null;
    }
    
    componentDidUpdate(prevProps, prevState, snapshot) {
        console.log(TAG, 'componentDidUpdate');
    }

    componentWillUnmount() {
        console.log(TAG, 'componentWillUnmount');
    }


    /**
     * 修改传给子组件属性 num 的方法
     */
    changeNum = () => {
        let { num } = this.state;
        this.setState({
            num: ++ num
        });
    }

    /**
     * 切换子组件挂载和卸载的方法
     */
    toggleMountChild = () => {
        let { mountChild } = this.state;
        this.setState({
            mountChild: !mountChild
        });
    }

    render() {
        console.log(TAG, 'render');
        const { num, mountChild } = this.state;
        return (
            <div style={ parentStyle }>
                <div>
                    <p>父组件</p>
                    <button onClick={ this.changeNum }>改变传给子组件的属性 num</button>
                    <br />
                    <br />
                    <button onClick={ this.toggleMountChild }>卸载 / 挂载子组件</button>
                </div>
                {
                    mountChild ? <Child num={ num } /> : null
                }
            </div>
        )
    }
}

子组件:Child.js

import React, { Component } from 'react'


const childStyle = {
    padding: 20,
    margin: 20,
    border: '1px solid black'
}

const TAG = 'Child 组件:'

export default class Child extends Component {

    constructor(props) {
        super(props);
        console.log(TAG, 'constructor');
        this.state = {
            counter: 0
        };
    }

    static getDerivedStateFromProps(nextProps, prevState) {
        console.log(TAG, 'getDerivedStateFromProps');
        return null;
    }

    componentDidMount() {
        console.log(TAG, 'componentDidMount');
    }

    shouldComponentUpdate(nextProps, nextState) {
        console.log(TAG, 'shouldComponentUpdate');
        return true;
    }

    getSnapshotBeforeUpdate(prevProps, prevState) {
        console.log(TAG, 'getSnapshotBeforeUpdate');
        return null;
    }
    
    componentDidUpdate(prevProps, prevState, snapshot) {
        console.log(TAG, 'componentDidUpdate');
    }

    componentWillUnmount() {
        console.log(TAG, 'componentWillUnmount');
    }

    changeCounter = () => {
        let { counter }= this.state;
        this.setState({
            counter: ++ counter
        });
    }

    render() {
        console.log(TAG, 'render');
        const { num } = this.props;
        const { counter } = this.state;
        return (
            <div style={ childStyle }>
                <p>子组件</p>
                <p>父组件传过来的属性 num : { num }</p>
                <p>自身状态 counter : { counter }</p>
                <button onClick={ this.changeCounter }>改变自身状态 counter</button>
            </div>
        )
    }
}

从五种组件状态改变的时机来探究生命周期的执行顺序

一、父子组件初始化

父子组件第一次进行渲染加载时,界面展示为:
初始化展示界面

控制台中的 log 打印顺序为:

  • Parent 组件: constructor()
  • Parent 组件: getDerivedStateFromProps()
  • Parent 组件: render()
  • Child 组件: constructor()
  • Child 组件: getDerivedStateFromProps()
  • Child 组件: render()
  • Child 组件: componentDidMount()
  • Parent 组件: componentDidMount()

二、修改子组件自身状态 state 时

点击子组件中的 改变自身状态 按钮,则界面上 自身状态 counter: 的值会 + 1,控制台中的 log 打印顺序为:

  • Child 组件: getDerivedStateFromProps()
  • Child 组件: shouldComponentUpdate()
  • Child 组件: render()
  • Child 组件: getSnapshotBeforeUpdate()
  • Child 组件: componentDidUpdate()

三、修改父组件中传入子组件的 props 时

点击父组件中的 改变传给子组件的属性 num 按钮,则界面上 父组件传过来的属性 num: 的值会 + 1,控制台中的 log 打印顺序为:

  • Parent 组件: getDerivedStateFromProps()
  • Parent 组件: shouldComponentUpdate()
  • Parent 组件: render()
  • Child 组件: getDerivedStateFromProps()
  • Child 组件: shouldComponentUpdate()
  • Child 组件: render()
  • Child 组件: getSnapshotBeforeUpdate()
  • Parent 组件: getSnapshotBeforeUpdate()
  • Child 组件: componentDidUpdate()
  • Parent 组件: componentDidUpdate()

四、卸载子组件

点击父组件中的 卸载 / 挂载子组件 按钮,则界面上子组件会消失,控制台中的 log 打印顺序为:

  • Parent 组件: getDerivedStateFromProps()
  • Parent 组件: shouldComponentUpdate()
  • Parent 组件: render()
  • Parent 组件: getSnapshotBeforeUpdate()
  • Child 组件: componentWillUnmount()
  • Parent 组件: componentDidUpdate()

五、重新挂载子组件

再次点击父组件中的 卸载 / 挂载子组件 按钮,则界面上子组件会重新渲染出来,控制台中的 log 打印顺序为:

  • Parent 组件: getDerivedStateFromProps()
  • Parent 组件: shouldComponentUpdate()
  • Parent 组件: render()
  • Child 组件: constructor()
  • Child 组件: getDerivedStateFromProps()
  • Child 组件: render()
  • Parent 组件: getSnapshotBeforeUpdate()
  • Child 组件: componentDidMount()
  • Parent 组件: componentDidUpdate()

父子组件生命周期执行顺序总结:

  • 当子组件自身状态改变时,不会对父组件产生副作用的情况下,父组件不会进行更新,即不会触发父组件的生命周期

  • 当父组件中状态发生变化(包括子组件的挂载以及)时,会触发自身对应的生命周期以及子组件的更新

    • render 以及 render 之前的生命周期,则 父组件 先执行

    • render 以及 render 之后的声明周期,则子组件先执行,并且是与父组件交替执行

  • 当子组件进行卸载时,只会执行自身的 componentWillUnmount 生命周期,不会再触发别的生命周期

可能总结的不好,不是很完整。只是根据一般情况进行的总结。有不妥之处,希望各位朋友能够多多指正。


示例代码下载

源码地址(欢迎 Star,谢谢!)

还没看够?移步至:React Component 官网

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

TypeScript 高级类型及用法

本文详细介绍了 TypeScript 高级类型的使用场景,对日常 TypeScript 的使用可以提供一些帮助。

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,走过路过点个 Star 呗

一、高级类型

交叉类型(&)

交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。

  • 语法: T & U

    其返回类型既要符合 T 类型也要符合 U 类型

  • 用法:假设有两个接口:一个是 Ant 蚂蚁接口,一个是 Fly 飞翔接口,现在有一只会飞的蚂蚁:

interface Ant {
    name: string;
    weight: number;
}

interface Fly {
    flyHeight: number;
    speed: number;
}

// 少了任何一个属性都会报错
const flyAnt: Ant & Fly = {
    name: '蚂蚁呀嘿',
    weight: 0.2,
    flyHeight: 20,
    speed: 1,
};

联合类型(|)

联合类型与交叉类型很有关联,但是使用上却完全不同。

  • 语法: T | U

    其返回类型为连接的多个类型中的任意一个

  • 用法:假设声明一个数据,既可以是 string 类型,也可以是 number 类型

let stringOrNumber: string | number = 0

stringOrNumber = ''

再看下面这个例子,start 函数的参数类型既是 Bird | Fish,那么在 start 函数中,想要直接调用的话,只能调用 BirdFish 都具备的方法,否则编译会报错

class Bird {
    fly() {
        console.log('Bird flying');
    }
    layEggs() {
        console.log('Bird layEggs');
    }
}

class Fish {
    swim() {
        console.log('Fish swimming');
    }
    layEggs() {
        console.log('Fish layEggs');
    }
}

const bird = new Bird();
const fish = new Fish();

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    // 会报错:Property 'fly' does not exist on type 'Bird | Fish'
    // pet.fly();

    // 会报错:Property 'swim' does not exist on type 'Bird | Fish'
    // pet.swim();
}

start(bird);

start(fish);

二、关键字

类型约束(extends)

语法:T extends K

这里的 extends 不是类、接口的继承,而是对于类型的判断和约束,意思是判断 T 能否赋值给 K

可以在泛型中对传入的类型进行约束

const copy = (value: string | number): string | number => value

// 只能传入 string 或者 number
copy(10)

// 会报错:Argument of type 'boolean' is not assignable to parameter of type 'string | number'
// copy(false)

也可以判断 T 是否可以赋值给 U,可以的话返回 T,否则返回 never

type Exclude<T, U> = T extends U ? T : never;

类型映射(in)

会遍历指定接口的 key 或者是遍历联合类型

interface Person {
    name: string
    age: number
    gender: number
}

// 将 T 的所有属性转换为只读类型
type ReadOnlyType<T> = {
    readonly [P in keyof T]: T[P]
}

// type ReadOnlyPerson = {
//     readonly name: Person;
//     readonly age: Person;
//     readonly gender: Person;
// }
type ReadOnlyPerson = ReadOnlyType<Person>

类型谓词(is)

  • 语法:parameterName is Type

    parameterName 必须是来自于当前函数签名里的一个参数名,判断 parameterName 是否是 Type 类型。

具体的应用场景可以跟着下面的代码思路进行使用:

看完联合类型的例子后,可能会考虑:如果想要在 start 函数中,根据情况去调用 Birdfly 方法和 Fishswim 方法,该如何操作呢?

首先想到的可能是直接检查成员是否存在,然后进行调用:

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    if ((pet as Bird).fly) {
        (pet as Bird).fly();
    } else if ((pet as Fish).swim) {
        (pet as Fish).swim();
    }
}

但是这样做,判断以及调用的时候都要进行类型转换,未免有些麻烦,可能会想到写个工具函数判断下:

function isBird(bird: Bird | Fish): boolean {
    return !!(bird as Bird).fly;
}

function isFish(fish: Bird | Fish): boolean {
    return !!(fish as Fish).swim;
}

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    if (isBird(pet)) {
        (pet as Bird).fly();
    } else if (isFish(pet)) {
        (pet as Fish).swim();
    }
}

看起来简洁了一点,但是调用方法的时候,还是要进行类型转换才可以,否则还是会报错,那有什么好的办法,能让我们判断完类型之后,就可以直接调用方法,不用再进行类型转换呢?

OK,肯定是有的,类型谓词 is 就派上用场了

  • 用法:
function isBird(bird: Bird | Fish): bird is Bird {
    return !!(bird as Bird).fly
}

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    if (isBird(pet)) {
        pet.fly();
    } else {
        pet.swim();
    }
};

每当使用一些变量调用 isFish 时,TypeScript 会将变量缩减为那个具体的类型,只要这个类型与变量的原始类型是兼容的。

TypeScript 不仅知道在 if 分支里 pet 是 Fish 类型; 它还清楚在 else 分支里,一定不是 Fish 类型,一定是 Bird 类型

待推断类型(infer)

可以用 infer P 来标记一个泛型,表示这个泛型是一个待推断的类型,并且可以直接使用

比如下面这个获取函数参数类型的例子:

type ParamType<T> = T extends (param: infer P) => any ? P : T;

type FunctionType = (value: number) => boolean

type Param = ParamType<FunctionType>;   // type Param = number

type OtherParam = ParamType<symbol>;   // type Param = symbol

判断 T 是否能赋值给 (param: infer P) => any,并且将参数推断为泛型 P,如果可以赋值,则返回参数类型 P,否则返回传入的类型

再来一个获取函数返回类型的例子:

type ReturnValueType<T> = T extends (param: any) => infer U ? U : T;

type FunctionType = (value: number) => boolean

type Return = ReturnValueType<FunctionType>;   // type Return = boolean

type OtherReturn = ReturnValueType<number>;   // type OtherReturn = number

判断 T 是否能赋值给 (param: any) => infer U,并且将返回值类型推断为泛型 U,如果可以赋值,则返回返回值类型 P,否则返回传入的类型

原始类型保护(typeof)

  • 语法:typeof v === "typename"typeof v !== "typename"

用来判断数据的类型是否是某个原始类型(numberstringbooleansymbol)并进行类型保护

"typename"必须是 "number", "string", "boolean"或 "symbol"。 但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。

看下面这个例子, print 函数会根据参数类型打印不同的结果,那如何判断参数是 string 还是 number 呢?

function print(value: number | string) {
    // 如果是 string 类型
    // console.log(value.split('').join(', '))

    // 如果是 number 类型
    // console.log(value.toFixed(2))
}

有两种常用的判断方式:

  1. 根据是否包含 split 属性判断是 string 类型,是否包含 toFixed 方法判断是 number 类型

    弊端:不论是判断还是调用都要进行类型转换

  2. 使用类型谓词 is

    弊端:每次都要去写一个工具函数,太麻烦了

  • 用法:这就到了 typeof 一展身手的时候了
function print(value: number | string) {
    if (typeof value === 'string') {
        console.log(value.split('').join(', '))
    } else {
        console.log(value.toFixed(2))
    }
}

使用 typeof 进行类型判断后,TypeScript 会将变量缩减为那个具体的类型,只要这个类型与变量的原始类型是兼容的。

类型保护(instanceof)

typeof 类似,不过作用方式不同,instanceof 类型保护是通过构造函数来细化类型的一种方式。

instanceof 的右侧要求是一个构造函数,TypeScript 将细化为:

  • 此构造函数的 prototype 属性的类型,如果它的类型不为 any 的话
  • 构造签名所返回的类型的联合

还是以 类型谓词 is 示例中的代码做演示:

最初代码:

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    if ((pet as Bird).fly) {
        (pet as Bird).fly();
    } else if ((pet as Fish).swim) {
        (pet as Fish).swim();
    }
}

使用 instanceof 后的代码:

function start(pet: Bird | Fish) {
    // 调用 layEggs 没问题,因为 Bird 或者 Fish 都有 layEggs 方法
    pet.layEggs();

    if (pet instanceof Bird) {
        pet.fly();
    } else {
        pet.swim();
    }
}

可以达到相同的效果

索引类型查询操作符(keyof)

  • 语法:keyof T

对于任何类型 Tkeyof T 的结果为 T 上已知的 公共属性名联合

interface Person {
    name: string;
    age: number;
}

type PersonProps = keyof Person; // 'name' | 'age'

这里,keyof Person 返回的类型和 'name' | 'age' 联合类型是一样,完全可以互相替换

  • 用法:keyof 只能返回类型上已知的 公共属性名
class Animal {
    type: string;
    weight: number;
    private speed: number;
}

type AnimalProps = keyof Animal; // "type" | "weight"

例如我们经常会获取对象的某个属性值,但是不确定是哪个属性,这个时候可以使用 extends 配合 typeof 对属性名进行限制,限制传入的参数只能是对象的属性名

const person = {
    name: 'Jack',
    age: 20
}

function getPersonValue<T extends keyof typeof person>(fieldName: keyof typeof person) {
    return person[fieldName]
}

const nameValue = getPersonValue('name')
const ageValue = getPersonValue('age')

// 会报错:Argument of type '"gender"' is not assignable to parameter of type '"name" | "age"'
// getPersonValue('gender')

索引访问操作符(T[K])

  • 语法:T[K]

类似于 js 中使用对象索引的方式,只不过 js 中是返回对象属性的值,而在 ts 中返回的是 T 对应属性 P 的类型

  • 用法:
interface Person {
    name: string
    age: number
    weight: number | string
    gender: 'man' | 'women'
}

type NameType = Person['name']  // string

type WeightType = Person['weight']  // string | number

type GenderType = Person['gender']  // "man" | "women"

三、映射类型

只读类型(Readonly<T>)

  • 定义:
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}

用于将 T 类型的所有属性设置为只读状态。

  • 用法:
interface Person {
    name: string
    age: number
}

const person: Readonly<Person> = {
    name: 'Lucy',
    age: 22
}

// 会报错:Cannot assign to 'name' because it is a read-only property
person.name = 'Lily'

readonly 只读, 被 readonly 标记的属性只能在声明时或类的构造函数中赋值,之后将不可改(即只读属性)

只读数组(ReadonlyArray<T>)

  • 定义:
interface ReadonlyArray<T> {
    /** Iterator of values in the array. */
    [Symbol.iterator](): IterableIterator<T>;

    /**
     * Returns an iterable of key, value pairs for every entry in the array
     */
    entries(): IterableIterator<[number, T]>;

    /**
     * Returns an iterable of keys in the array
     */
    keys(): IterableIterator<number>;

    /**
     * Returns an iterable of values in the array
     */
    values(): IterableIterator<T>;
}

只能在数组初始化时为变量赋值,之后数组无法修改

  • 使用:
interface Person {
    name: string
}

const personList: ReadonlyArray<Person> = [{ name: 'Jack' }, { name: 'Rose' }]

// 会报错:Property 'push' does not exist on type 'readonly Person[]'
// personList.push({ name: 'Lucy' })

// 但是内部元素如果是引用类型,元素自身是可以进行修改的
personList[0].name = 'Lily'

可选类型(Partial<T>)

用于将 T 类型的所有属性设置为可选状态,首先通过 keyof T,取出类型 T 的所有属性,
然后通过 in 操作符进行遍历,最后在属性后加上 ?,将属性变为可选属性。

  • 定义:
type Partial<T> = {
    [P in keyof T]?: T[P];
}
  • 用法:
interface Person {
    name: string
    age: number
}

// 会报错:Type '{}' is missing the following properties from type 'Person': name, age
// let person: Person = {}

// 使用 Partial 映射后返回的新类型,name 和 age 都变成了可选属性
let person: Partial<Person> = {}

person = { name: 'pengzu', age: 800 }

person = { name: 'z' }

person = { age: 18 }

必选类型(Required<T>)

Partial 的作用相反

用于将 T 类型的所有属性设置为必选状态,首先通过 keyof T,取出类型 T 的所有属性,
然后通过 in 操作符进行遍历,最后在属性后的 ? 前加上 -,将属性变为必选属性。

  • 定义:
type Required<T> = {
    [P in keyof T]-?: T[P];
}
  • 使用:
interface Person {
    name?: string
    age?: number
}

// 使用 Required 映射后返回的新类型,name 和 age 都变成了必选属性
// 会报错:Type '{}' is missing the following properties from type 'Required<Person>': name, age
let person: Required<Person> = {}

提取属性(Pick<T>)

  • 定义:
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
}

T 类型中提取部分属性,作为新的返回类型。

  • 使用:比如我们在发送网络请求时,只需要传递类型中的部分属性,就可以通过 Pick 来实现。
interface Goods {
    type: string
    goodsName: string
    price: number
}

// 作为网络请求参数,只需要 goodsName 和 price 就可以
type RequestGoodsParams = Pick<Goods, 'goodsName' | 'price'>
// 返回类型:
// type RequestGoodsParams = {
//     goodsName: string;
//     price: number;
// }
const params: RequestGoodsParams = {
    goodsName: '',
    price: 10
}

排除属性(Omit<T>)

  • 定义:type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

    Pick 作用相反,用于从 T 类型中,排除部分属性

  • 用法:比如长方体有长宽高,而正方体长宽高相等,所以只需要长就可以,那么此时就可以用 Omit 来生成正方体的类型

interface Rectangular {
    length: number
    height: number
    width: number
}

type Square = Omit<Rectangular, 'height' | 'width'>
// 返回类型:
// type Square = {
//     length: number;
// }

const temp: Square = { length: 5 }

摘取类型(Extract<T, U>)

  • 语法:Extract<T, U>

    提取 T 中可以 赋值U 的类型

  • 定义:type Extract<T, U> = T extends U ? T : never;

  • 用法:

type T01 = Extract<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "a" | "c"

type T02 = Extract<string | number | (() => void), Function>;  // () => void

排除类型(Exclude<T, U>)

  • 语法:Exclude<T, U>

    Extract 用法相反,从 T 中剔除可以赋值给 U的类型

  • 定义:type Exclude<T, U> = T extends U ? never : T

  • 用法:

type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"

type T01 = Exclude<string | number | (() => void), Function>;  // string | number

属性映射(Record<K, T>)

  • 定义:
type Record<K extends string | number | symbol, T> = {
    [P in K]: T;
}

接收两个泛型,K 必须可以是可以赋值给 string | number | symbol 的类型,通过 in 操作符对 K 进行遍历,每一个属性的类型都必须是 T 类型

  • 用法:比如我们想要将 Person 类型的数组转化成对象映射,可以使用 Record 来指定映射对象的类型
interface Person {
    name: string
    age: number
}

const personList = [
    { name: 'Jack', age: 26 },
    { name: 'Lucy', age: 22 },
    { name: 'Rose', age: 18 },
]

const personMap: Record<string, Person> = {}

personList.map((person) => {
    personMap[person.name] = person
})

比如在传递参数时,希望参数是一个对象,但是不确定具体的类型,就可以使用 Record 作为参数类型

function doSomething(obj: Record<string, any>) {
}

不可为空类型(NonNullable<T>)

  • 定义:type NonNullable<T> = T extends null | undefined ? never : T

从 T 中剔除 nullundefinednever 类型,不会剔除 voidunknow 类型

type T01 = NonNullable<string | number | undefined>;  // string | number

type T02 = NonNullable<(() => string) | string[] | null | undefined>;  // (() => string) | string[]

type T03 = NonNullable<{name?: string, age: number} | string[] | null | undefined>;  // {name?: string, age: number} | string[]

构造函数参数类型(ConstructorParameters<typeof T>)

返回 class 中构造函数参数类型组成的 元组类型

  • 定义:
/**
 * Obtain the parameters of a constructor function type in a tuple
 */
type ConstructorParameters<T extends new (...args: any) => any> = T extends new (...args: infer P) => any ? P : never;
  • 使用:
class Person {
    name: string
    age: number
    weight: number
    gender: 'man' | 'women'

    constructor(name: string, age: number, gender: 'man' | 'women') {
        this.name = name
        this.age = age;
        this.gender = gender
    }
}

type ConstructorType = ConstructorParameters<typeof Person>  //  [name: string, age: number, gender: "man" | "women"]

const params: ConstructorType = ['Jack', 20, 'man']

实例类型(InstanceType<T>)

获取 class 构造函数的返回类型

  • 定义:
/**
 * Obtain the return type of a constructor function type
 */
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;
  • 使用:
class Person {
    name: string
    age: number
    weight: number
    gender: 'man' | 'women'

    constructor(name: string, age: number, gender: 'man' | 'women') {
        this.name = name
        this.age = age;
        this.gender = gender
    }
}

type Instance = InstanceType<typeof Person>  // Person

const params: Instance = {
    name: 'Jack',
    age: 20,
    weight: 120,
    gender: 'man'
}

函数参数类型(Parameters<T>)

获取函数的参数类型组成的 元组

  • 定义:
/**
 * Obtain the parameters of a function type in a tuple
 */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
  • 用法:
type FunctionType = (name: string, age: number) => boolean

type FunctionParamsType = Parameters<FunctionType>  // [name: string, age: number]

const params:  FunctionParamsType = ['Jack', 20]

函数返回值类型(ReturnType<T>)

获取函数的返回值类型

  • 定义:
/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
  • 使用:
type FunctionType = (name: string, age: number) => boolean | string

type FunctionReturnType = ReturnType<FunctionType>  // boolean | string

四、总结

  • 高级类型

    用法 描述
    & 交叉类型,将多个类型合并为一个类型,交集
    | 联合类型,将多个类型组合成一个类型,可以是多个类型的任意一个,并集
  • 关键字

    用法 描述
    T extends U 类型约束,判断 T 是否可以赋值给 U
    P in T 类型映射,遍历 T 的所有类型
    parameterName is Type 类型谓词,判断函数参数 parameterName 是否是 Type 类型
    infer P 待推断类型,使用 infer 标记类型 P,就可以使用待推断的类型 P
    typeof v === "typename" 原始类型保护,判断数据的类型是否是某个原始类型(numberstringbooleansymbol
    instanceof v 类型保护,判断数据的类型是否是构造函数的 prototype 属性类型
    keyof 索引类型查询操作符,返回类型上已知的 公共属性名
    T[K] 索引访问操作符,返回 T 对应属性 P 的类型
  • 映射类型

    用法 描述
    Readonly 将 T 中所有属性都变为只读
    ReadonlyArray 返回一个 T 类型的只读数组
    ReadonlyMap<T, U> 返回一个 T 和 U 类型组成的只读 Map
    Partial 将 T 中所有的属性都变成可选类型
    Required 将 T 中所有的属性都变成必选类型
    Pick<T, K extends keyof T> 从 T 中摘取部分属性
    Omit<T, K extends keyof T> 从 T 中排除部分属性
    Exclude<T, U> 从 T 中剔除可以赋值给 U 的类型
    Extract<T, U> 提取 T 中可以赋值给 U 的类型
    Record<K, T> 返回属性名为 K,属性值为 T 的类型
    NonNullable 从 T 中剔除 null 和 undefined
    ConstructorParameters 获取 T 的构造函数参数类型组成的元组
    InstanceType 获取 T 的实例类型
    Parameters 获取函数参数类型组成的元组
    ReturnType 获取函数返回值类型

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持

使用 Vite 尝鲜 React 18

React 18 目前已经开放 alpha 版本可以供我们体验了,那为了更方便快捷的体验 React 18 新特性,我们今天使用 Vite 搭建一个简易版的 React 开发环境,帮助我们快速尝鲜。

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

初始化项目

新建一个 react-react18-demo 文件作为我们的项目

mkdir vite-react18-demo

cd vite-react-react18-demo

npm init -y

依赖安装 / 项目配置

  • 使用 Vite 以及 React 18 进行开发,那自然需要先安装

    注意:node 版本需要大于 12,否则的话,即便 Vite 安装成功,启动时也会报错:Cannot find module worker_threads

npm install react@alpha react-dom@alpha

npm install vite -D
  • 使用 Webpack 时需要启动 devServer,但使用 Vite 时不需要再去额外配置
    想要使用 Vite 启动项目,直接在 package.json 文件中添加命令:

    • 启动:"start": "vite"`

    • 打包:"build": "vite build"

  • 那我们之前用 Webpack 时可以热更新,那 Vite 可以吗?当然是可以的了。

npm install @vitejs/plugin-react-refresh -D

安装完之后,和 Webpack 一样,新建 vite.config.js 配置文件,在 plugins 属性中添加热更新插件即可

import { defineConfig } from 'vite'
import refreshReactPlugin from '@vitejs/plugin-react-refresh'

export default defineConfig({
    plugins: [refreshReactPlugin()]
})
  • ok,那我们现在许多项目使用了 typescript,使用 vite 开发时可以集成吗?当然可以了。步骤如下:

    • 安装依赖

      npm install typescript @types/react @types/react-dom -D
    • 根目录下添加 tsconfig.json 配置文件

      {
          "compilerOptions": {
              "target": "ESNext",
              "lib": ["DOM", "DOM.Iterable", "ESNext"],
              "allowJs": false,
              "skipLibCheck": false,
              "esModuleInterop": false,
              "allowSyntheticDefaultImports": true,
              "strict": true,
              "forceConsistentCasingInFileNames": true,
              "module": "ESNext",
              "moduleResolution": "Node",
              "resolveJsonModule": true,
              "isolatedModules": true,
              "noEmit": true,
              "jsx": "react",
              "types": ["react/next", "react-dom/next"]
          },
          "include": ["./src"]
      }
  • 添加完之后,由于使用了 typescript,那我们修改一下打包命令:"build": "tsc && vite build"

React 18 体验

  • 创建入口文件:新建 src 目录,在 src 下创建一个 index.tsx 文件

index.tsx

import React from 'react'
import ReactDOM from 'react-dom'

const App = () => {
    return <h1>Hello, React 18</h1>
}

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
  • 现在入口文件有了,但是还没有承载的页面,所以我们可以在根目录下创建 index.html 作为页面

    • 创建 script 标签,src 指向刚才创建的入口文件 index.tsx

    • 设置 script 标签 typemodule:可以导入 ES6 模块,可以启用 ESM 模块机制

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
     <div id="root"></div>
     <script type="module" src="/src/index.tsx"></script>
</body>
</html>
  • 启动项目

执行 npm run start 命令后,会启动 3000 端口(被占用的话会向后顺延)。打开浏览器,输入 http://localhost:3000,就可以看到:Hello, React 18 这几个大字了。

  • 项目打包

执行 npm run build 命令后,会进行项目打包,生成 dist 文件夹。我们使用 live-server 插件(需要提前进行全局安装哦,npm i live-server -g),看看有没有打包成功

cd dist

liver-server

打开浏览器,输入 http://localhost:8080/,同样可以看到:Hello, React 18 这几个大字。说明打包成功了。

Ok,Vite 搭配 React 18 的环境搭建到这里就成功了。后面我们会详细介绍一下 React 18 更新的新特性。

注意:node 版本需要大于 12,否则的话,即便 Vite 安装成功,启动时也会报错:Cannot find module worker_threads

写在后面

代码都在文中了,有想要快速搭建 React 调试环境的,可以速度冲了。

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 18 新特性(二):Suspense & SuspenseList

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

前言

本文介绍了 React 18 版本中 Suspense 组件和新增 SuspenseList 组件的使用以及相关属性的用法。并且和 18 之前的版本做了对比,介绍了新特性的一些优势。

一、回顾 Suspense 用法

早在 React 16 版本,就可以使用 React.lazy 配合 Suspense 来进行代码拆分,我们来回顾一下之前的用法。

  1. 在编写 User 组件,在 User 组件中进行网络请求,获取数据

    User.jsx

    import React, { useState, useEffect } from 'react';
    
    // 网络请求,获取 user 数据
    const requestUser = id =>
        new Promise(resolve =>
            setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 1000)
        );
    
    const User = props => {
        const [user, setUser] = useState({});
    
        useEffect(() => {
            requestUser(props.id).then(res => setUser(res));
        }, [props.id]);
     
        return <div>当前用户是: {user.name}</div>;
    };
    
    export default User;
  2. 在 App 组件中通过 React.lazy 的方式加载 User 组件(使用时需要用 Suspense 组件包裹起来哦)

    App.jsx

    import React from "react";
    import ReactDOM from "react-dom";
    
    const User = React.lazy(() => import("./User"));
    
    const App = () => {
        return (
            <>
                <React.Suspense fallback={<div>Loading...</div>}>
                    <User id={1} />
                </React.Suspense>
            </>
        );
    };
    
    ReactDOM.createRoot(document.getElementById("root")).render(<App />);
  3. 效果图:

    Suspense 老版用法图例

  4. 此时,可以看到 User 组件在加载出来之前会 loading 一下,虽然进行了代码拆分,但还是有两个美中不足的地方

    • 需要在 User 组件中进行一些列的操作:定义 stateeffect 中发请求,然后修改 state,触发 render

    • 虽然看到 loading 展示了出来,但是仅仅只是组件加载完成,内部的请求以及用户想要看到的真实数据还没有处理完成

    Ok, 带着这两个问题,我们继续向下探索。

二、Suspense 的实现原理

内部流程

  • Suspense 让子组件在渲染之前进行等待,并在等待时显示 fallback 的内容

  • Suspense 内的组件子树比组件树的其他部分拥有更低的优先级

  • 执行流程

    • render 函数中可以使用异步请求数据

    • react 会从我们的缓存中读取

    • 如果缓存命中,直接进行 render

    • 如果没有缓存,会抛出一个 promise 异常

    • promise 完成后,react 会重新进行 render,把数据展示出来

    • 完全同步写法,没有任何异步 callback

简易版代码实现

  • 子组件没有加载完成时,会抛出一个 promise 异常

  • 监听 promise,状态变更后,更新 state,触发组件更新,重新渲染子组件

  • 展示子组件内容

import React from "react";

class Suspense extends React.Component {
    state = {
        loading: false,
    };

    componentDidCatch(error) {
        if (error && typeof error.then === "function") {
            error.then(() => {
                this.setState({ loading: true });
            });
            this.setState({ loading: false });
        }
    }

    render() {
        const { fallback, children } = this.props;
        const { loading } = this.state;
        return loading ? fallback : children;
    }
}

export default Suspense;

三、新版 User 组件编写方式

针对上面我们说的两个问题,来修改一下我们的 User 组件

const User = async (props) => {
    const user = await requestUser(props.id);
    return <div>当前用户是: {user.name}</div>;
};

多希望 User 组件能这样写,省去了很多冗余的代码,并且能够在请求完成之前统一展示 fallback

但是我们又不能直接使用 asyncawait 去编写组件。这时候怎么办呢?

结合上面我们讲述的 Suspense 实现原理,那我们可以封装一层 promise,请求中,我们将 promise 作为异常抛出,请求完成展示结果。

wrapPromise 函数的含义:

  • 接受一个 promise 作为参数

  • 定义了 promise 状态和结果

  • 返回一个包含 read 方法的对象

  • 调用 read 方法时,会根据 promise 当前的状态去判断抛出异常还是返回结果。

function wrapPromise(promise) {
    let status = "pending";
    let result;
    let suspender = promise.then(
        (r) => {
            status = "success";
            result = r;
        },
        (e) => {
            status = "error";
            result = e;
        }
    );
    return {
        read() {
            if (status === "pending") {
                throw suspender;
            } else if (status === "error") {
                throw result;
            } else if (status === "success") {
                return result;
            }
        },
    };
}

使用 wrapPromise 重新改写一下 User 组件

// 网络请求,获取 user 数据
const requestUser = (id) =>
    new Promise((resolve) =>
        setTimeout(
            () => resolve({ id, name: `用户${id}`, age: 10 + id }),
            id * 1000
        )
    );

const resourceMap = {
    1: wrapPromise(requestUser(1)),
};

const User = (props) => {
    const resource = resourceMap[props.id];
    const user = resource.read();
    return <div>当前用户是: {user.name}</div>;
};

这时候可以看到界面首先展示 loading,请求结束后,直接将数据展示出来。不需要编写副作用代码,也不需要在组件内进行 loading 的判断。

Suspense 新版用法图例

四、SuspenseList

上面我们讲述了 Suspense 的用法,那如果有多个 Suspense 同时存在时,我们想控制他们的展示顺序以及展示方式,应该怎么做呢?

React 中也提供了一个新的组件:SuspenseList

SuspenseList 属性

SuspenseList 组件接受三个属性

  • revealOrder: 子 Suspense 的加载顺序

    • forwards: 从前向后展示,无论请求的速度快慢都会等前面的先展示

    • Backwards: 从后向前展示,无论请求的速度快慢都会等后面的先展示

    • together: 所有的 Suspense 都准备好之后同时显示

  • tail: 指定如何显示 SuspenseList 中未准备好的 Suspense

    • 不设置:默认加载所有 Suspense 对应的 fallback

    • collapsed:仅展示列表中下一个 Suspense 的 fallback

    • hidden: 未准备好的项目不限时任何信息

  • children: 子元素

    • 子元素可以是任意 React 元素

    • 当子元素中包含非 Suspense 组件时,且未设置 tail 属性,那么此时所有的 Suspense 元素必定是同时加载,设置 revealOrder 属性也无效。当设置 tail 属性后,无论是 collapsed 还是 hiddenrevealOrder 属性即可生效

    • 子元素中多个 Suspense 不会相互阻塞

SuspenseList 使用

User 组件

import React from "react";

function wrapPromise(promise) {
    let status = "pending";
    let result;
    let suspender = promise.then(
        (r) => {
            status = "success";
            result = r;
        },
        (e) => {
            status = "error";
            result = e;
        }
    );
    return {
        read() {
            if (status === "pending") {
                throw suspender;
            } else if (status === "error") {
                throw result;
            } else if (status === "success") {
                return result;
            }
        },
    };
}

// 网络请求,获取 user 数据
const requestUser = (id) =>
    new Promise((resolve) =>
        setTimeout(
            () => resolve({ id, name: `用户${id}`, age: 10 + id }),
            id * 1000
        )
    );

const resourceMap = {
    1: wrapPromise(requestUser(1)),
    3: wrapPromise(requestUser(3)),
    5: wrapPromise(requestUser(5)),
};

const User = (props) => {
    const resource = resourceMap[props.id];
    const user = resource.read();
    return <div>当前用户是: {user.name}</div>;
};

export default User;

App 组件

import React from "react";
import ReactDOM from "react-dom";

const User = React.lazy(() => import("./User"));
// 此处亦可以不使用 React.lazy(),直接使用以下 import 方式引入也可以
// import User from "./User"

const App = () => {
    return (
        <React.SuspenseList revealOrder="forwards" tail="collapsed">
            <React.Suspense fallback={<div>Loading...</div>}>
                <User id={1} />
            </React.Suspense>
            <React.Suspense fallback={<div>Loading...</div>}>
                <User id={3} />
            </React.Suspense>
            <React.Suspense fallback={<div>Loading...</div>}>
                <User id={5} />
            </React.Suspense>
        </React.SuspenseList>
    );
};

ReactDOM.createRoot(document.getElementById("root")).render(<App />);

使用 SuspenseList 后效果图

SuspenseList 用法图例

相关链接

后记

好了,关于 React 中 Suspense 以及 SuspenseList 组件的用法,就已经介绍完了,在 SuspenseList 使用章节,所有的代码均已贴出来了。有疑惑的地方可以说出来一起进行讨论。

文中有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star。

从零到一实现 Redux

前言

上一篇文章 轻松掌握 Redux 核心用法 详细讲解了 redux 的使用。

这篇文章我们按照上一篇的节奏,实现一下 redux 的核心代码。

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

核心方法

  • createStore: 创建 store

  • combineReducers: 合并 reducer

  • applyMiddleware: 应用插件

  • bindActionCreators: 将一个 actionCreator 组成的对象转换成 dispatcher 组成的对象

  • bindActionCreator: 将 bindActionCreator 使用 dispatch 包装后转换成 dispatcher

一、createStore:创建 store 对象

接受两个参数

  • reducer: 数据更新函数
  • ehancer: 应用中间件之后的增强型函数

返回一个 store 对象,包含以下属性

  • getState: 获取当前 state 数据
  • dispatch: 派发 action 对象
  • subscribe: 订阅 state 监听事件

createStore

/**
 * 创建 store 对象
 * @param {function} reducer reducer 更新函数
 * @param {*} prevState 进行服务端渲染时传入的已有的 state 数据(这里我们没有用到,暂时没做处理)
 * @param {function} ehancer 插件应用结果
 */
export function createStore(reducer, prevState, ehancer) {
  // 做一次兼容,可能第二个参数传递的就是 插件应用结果
  if (typeof prevState === "function") {
    ehancer = prevState;
  }

  // 如果 ehancer 确实传入的是一个函数,则在 ehancer 函数中执行 store 的创建
  if (typeof ehancer === "function") {
    return ehancer(createStore)(reducer);
  }

  // 当前 state 数据
  let currentState;

  // 存放订阅监听对象的数组
  let listeners = [];

  // 获取当前 state 数据
  function getState() {
    return currentState;
  }

  /**
   * 订阅 state 数据变化
   * @param {function} listener 订阅监听对象
   */
  function subscribe(listener) {
    const index = listeners.length;
    listeners.push(listener);

    // unsubscribe
    return () => listeners.splice(index, 1);
  }

  /**
   * 派发 action,触发 reducer 更新 state
   * @param {plain object} action
   */
  function dispatch(action) {
    currentState = reducer(currentState, action);
    listeners.forEach(listener => listener());
  }

  // 率先执行一次 dispatch,用来为 state 设置初始值
  dispatch({ type: Math.random() });

  return {
    getState,
    subscribe,
    dispatch
  };
}

二、combineReducers:合并多个 reducer 函数

  • 接受一个对象参数,对象的属性值是 reducer 函数

  • 返回一个新的 reducer 函数

  • 调用新的 reducer 函数的时候,会依次调用原始的 reducer 函数,并将各个属性对应的 state 合并到一个 state 对象上

combineReducers

/**
 * combineReducers 将多个 reducer 合成一个新的 reducer 函数
 * @param {object} reducerTarget 包含多个 reducer 的对象
 */
export function combineReducers(reducerTarget) {
  const finalReducer = {};

  // 将 reducerTarget 中值不是 function 的属性过滤掉
  Object.entries(reducerTarget).forEach(([key, reducer]) => {
    if (typeof reducer === "function") {
      finalReducer[key] = reducer;
    }
  });

  // 返回一个新的 reducer 函数
  return (state = {}, action) => {
    let hasChange = false;

    // 将各个 reducer 对应的 state 值合并到同一个对象中
    let nextState = {};

    // 遍历所有的 reducer
    for (const [key, reducer] of Object.entries(finalReducer)) {
      const prevStateForKey = state[key];

      // 执行 reducer 函数,设置最新的 state 值
      const nextStateForKey = reducer(prevStateForKey, action);
      nextState[key] = nextStateForKey;

      hasChange = hasChange || nextStateForKey !== prevStateForKey;
    }
    return hasChange ? nextState : state;
  };
}

三、applyMiddleware: 应用插件

接受多个插件作为参数

返回一个增强型的函数 ,用来创建 store,也就是在 createStore 方法中调用的 ehancer

createStore 还需要接受一个 reducer,因此增强型函数 ehancer 执行后仍然返回一个函数,接收 reducer,用来真正的创建 store

ehancer 函数中需要做的操作:

  • 创建 store

  • 应用中间件,执行所有中间件

    • 由于中间件主要用来包装 dispatch,所以 dispatch 必须作为参数传递
    • 而有些中间价中可能需要获取到当前 state 值,所以 getState 也要作为参数传递
  • 使用 compose 函数,依次使用中间件对 dispatch 进行包装,获取到包装后的 dispatch 返回出去

compose

/**
 * 将多个函数按顺序合成,返回一个新的函数
 * @param  {...any} funcs 多个函数
 */
export function compose(...funcs) {
  if (!funcs) {
    return;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  return funcs.reduce((fn1, fn2) => (...args) => fn1(fn2(...args)));
}

applyMiddleware

/**
 * 应用插件
 * @param  {...function} middlewares 多个插件
 */
export function applyMiddleware(...middlewares) {
  // 在 createStore 中走 ehancer 时会来到这里
  return createStore => reducer => {
    const store = createStore(reducer);
    let dispatch = store.dispatch;

    /**
     * 执行插件函数,将 getState 和 被插件包装后的 dispatch 传给插件
     * 这里为了避免多个插件使用同一个 dispatch 互相影响,所有使用箭头函数包裹了一层
     *
     * 插件执行完毕后,将执行结果合并成一个执行链,是一个函数的数组
     *
     * 将执行链中的函数通过 compose 合成,生成一个新的函数,传入 dispatch 并执行,获取到一个被插件包装后的 dispatch
     */
    const middleParams = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    };
    const middlewareChains = middlewares.map(middleware =>
      middleware(middleParams)
    );
    dispatch = compose(...middlewareChains)(dispatch);

    // 将 store 返回出去
    return {
      ...store,
      dispatch
    };
  };
}

四、bindActionCreators

  • actionCreator: 是一个函数,返回值是 action 对象
  • dispatcher: 是一个函数,执行后会直接派发 action,触发 reducer 更新,不需要再通过 dispatch 转一次

bindActionCreator 方法

  • 接受两个参数

    • actionCreator: Function,返回 action 对象
    • dispatch: Dispatch
  • 返回一个函数

    • dispatch: 直接执行即可派发 action,触发 reducer 更新数据
/**
 * 将 actionCreator 使用 dispatch 进行包裹,生成一个可以直接触发更新的 dispatcher
 * @param {function} func actionCreator 函数
 * @param {function} dispatch
 */
export function bindActionCreator(func, dispatch) {
  return (...args) => dispatch(func(...args));
}

bindActionCreators

  • 接收两个参数

    • actionCreators: Object,对象属性值都是 actionCreator
    • dispatch: Dispatch
  • 返回一个对象

    • 属性值都是 dispatcher 组成的对象
/**
 * 将 actionCreators 中的 actionCreator 使用 dispatch 进行包裹,返回包含 多个 dispatcher 的对象
 * @param {object} actionCreators 包含多个 actionCreator 的对象
 * @param {function} dispatch
 */
export function bindActionCreators(actionCreators, dispatch) {
  const dispatchers = {};
  Object.entries(actionCreators).forEach(([key, actionCreator]) => {
    dispatchers[key] = bindActionCreator(actionCreator, dispatch);
  });
  return dispatchers;
}

写在后面

源码地址

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 18 新特性(三):渐变更新

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star,欢迎 Follow!

React 18 新特性(一):自动批量更新 一文中提到:在 React 新版本中,更新会有优先级的顺序。

那如果希望更新时进行低优先级的处理,应该如何做呢,就是今天讲到的主题:渐变更新

如果还不知道如何搭建 React18 的体验环境,可以先查看这篇文章:使用 Vite 尝鲜 React 18

一、startTransition:渐变更新

  • startTransition 接受一个回调函数,可以将放入其中的 setState 更新推迟

  • 允许组件将速度较慢的更新延迟渲染,以便能够立即渲染更重要的更新

举个例子

先来看一个例子,在使用谷歌或者百度搜索时,都会遇到如下的场景:

搜索

这里的展示分为两部分

  • 一部分是输入框中的搜索内容

  • 另一部分是展示的联想内容。

从用户的角度进行分析:

  • 输入框中的内容是需要即时更新的

  • 而联想出来的内容是需要进行请求或者加载的,甚至于最开始的时候联想的不准确,用不到。所以用户可以接受这部分内容有一定延迟。

那在这种情况下,用户的输入就是高优先级操作,而联想区域的变化就属于低优先级的操作。

模拟代码实现这个例子

我们写一段代码来实现一下这个搜索框。

App.jsx

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

const App = () => {
    const [value, setValue] = useState('');
    const [keywords, setKeywords] = useState([]);

    useEffect(() => {
        const getList = () => {
            const list = value
                ? Array.from({ length: 10000 }, (_, index) => ({
                      id: index,
                      keyword: `${value} -- ${index}`,
                  }))
                : [];
            return Promise.resolve(list);
        };
        getList().then(res => setKeywords(res));
    }, [value]);

    return (
        <>
            <input value={value} onChange={e => setValue(e.target.value)} />
            <ul>
                {keywords.map(({ id, keyword }) => (
                    <li key={id}>{keyword}</li>
                ))}
            </ul>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

然后我们先看一下现在的效果(这里暂时不讨论防抖或者节流):

缓慢加载

可以看到,不仅联想区域的内容加载缓慢,甚至用户的交互内容也反应迟钝。

既然刚才说到了低优先级更新,那么此时,我们是否可以让联想区域的内容低优更新,以避免抢占用户操作的更新呢?

接下来主角登场,使用 startTransition 对代码进行改造。

启用渐变更新

App.jsx

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

const App = () => {
    const [value, setValue] = useState('');
    const [keywords, setKeywords] = useState([]);

    useEffect(() => {
        const getList = () => {
            const list = value
                ? Array.from({ length: 10000 }, (_, index) => ({
                      id: index,
                      keyword: `${value} -- ${index}`,
                  }))
                : [];
            return Promise.resolve(list);
        };
-        //getList().then(res => setKeywords(res));
        // 仅仅只是将 setKeywords 用 startTransition 包裹一层,即可启用渐变更新
+        getList().then(res => startTransition(() => setKeywords(res)));
    }, [value]);

    return (
        <>
            <input value={value} onChange={e => setValue(e.target.value)} />
            <ul>
                {keywords.map(({ id, keyword }) => (
                    <li key={id}>{keyword}</li>
                ))}
            </ul>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

重新执行后,看看此时的效果:

迅速响应

可以看到,此时界面的响应速度比之前快了许多。

二、useDeferredValue:返回一个延迟响应的值

useDeferredValue 相当于是 startTransition(() => setState(xxx)) 的语法糖,在内部会调用一次 setState,但是此更新的优先级更低

那么我们用 useDeferredValue 改写一下上面的代码,看看是否有哪里不一样呢?

App.jsx

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

const App = () => {
    const [value, setValue] = useState('');
    const [keywords, setKeywords] = useState([]);
+    const text = useDeferredValue(value);

    useEffect(() => {
        const getList = () => {
            const list = value
                ? Array.from({ length: 10000 }, (_, index) => ({
                      id: index,
                      keyword: `${value} -- ${index}`,
                  }))
                : [];
            return Promise.resolve(list);
        };
        getList().then(res => setKeywords(res));
        // 只是将依赖的值由 value 更新为 text
+    }, [text]);

    return (
        <>
            <input value={value} onChange={e => setValue(e.target.value)} />
            <ul>
                {keywords.map(({ id, keyword }) => (
                    <li key={id}>{keyword}</li>
                ))}
            </ul>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

看看此时界面的响应速度:

同样迅速响应

可以看到此时的响应速度和使用 startTransition 时相差无几。

三、useTransition

还记得在 React 18 新特性(二):Suspense & SuspenseList 一文中使用过的 Suspense 组件以及 User 组件吗?我们在这两个组件的基础上,展示一下 useTransition 的用法和特性。

举个异步加载的例子

假设我们目前需要使用 Suspense 来包裹 User 组件,此时 User 组件内部会有网络请求等耗时操作。点击按钮,会触发 User 组件的更新,重新进行耗时操作获取数据

App.jsx

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

// 对 promise 进行一层封装
function wrapPromise(promise) {
    let status = 'pending';
    let result;
    let suspender = promise.then(
        r => {
            status = 'success';
            result = r;
        },
        e => {
            status = 'error';
            result = e;
        }
    );
    return {
        read() {
            if (status === 'pending') {
                throw suspender;
            } else if (status === 'error') {
                throw result;
            } else if (status === 'success') {
                return result;
            }
        },
    };
}

// 网络请求,获取 user 数据
const requestUser = id =>
    new Promise(resolve =>
        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
    );

// User 组件
const User = props => {
    const user = props.resource.read();
    return <div>当前用户是: {user.name}</div>;
};

// 通过 id 获取对应 resource
const getResource = id => wrapPromise(requestUser(id));

const App = () => {
    const [resource, setResource] = useState(getResource(10));

    return (
        <>
            <Suspense fallback={<div>Loading...</div>}>
                <User resource={resource} />
            </Suspense>
            <button onClick={() => setResource(wrapPromise(requestUser(1)))}>切换用户</button>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

OK,那我们看一下此时的效果哈:

loading效果

可以看到,第一次加载时,会出现 loading 效果,这是正常的,但是在点击按钮,切换用户时,依然会有 loading 效果的出现,这本来没有问题,但是当请求速度很快时,就会出现闪一下的问题。此时应该不需要 loading 的出现。

这个时候,useTransition 就派上用场了。

概念

  • useTransition 允许组件再切换到下一个界面之前等待内容加载,从而避免出现不必要的加载状态

  • 允许组件将速度较慢的数据获取更新推迟到随后渲染(低优先级更新),以便能够立即渲染更重要的更新

  • useTransition 返回包含两个元素的数组:

    • isPending: Boolean,通知我们是否正在等待过渡效果的完成
    • startTransition: Function,用它来包裹需要延迟更新的状态

使用 useTransition 修改上述的例子

使用 useTransition 中返回的 startTransition 包裹需要更新的 setState,就会降低更新的优先级,并且会对界面进行缓冲,等待下一个界面准备就绪后直接进行更新。

App.jsx

import React, { Suspense, useState, useTransition } from 'react';
import ReactDOM from 'react-dom';

// 对 promise 进行一层封装
function wrapPromise(promise) {
    let status = 'pending';
    let result;
    let suspender = promise.then(
        r => {
            status = 'success';
            result = r;
        },
        e => {
            status = 'error';
            result = e;
        }
    );
    return {
        read() {
            if (status === 'pending') {
                throw suspender;
            } else if (status === 'error') {
                throw result;
            } else if (status === 'success') {
                return result;
            }
        },
    };
}

// 网络请求,获取 user 数据
const requestUser = id =>
    new Promise(resolve =>
        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
    );

// User 组件
const User = props => {
    const user = props.resource.read();
    return <div>当前用户是: {user.name}</div>;
};

// 通过 id 获取对应 resource
const getResource = id => wrapPromise(requestUser(id));

const App = () => {
    const [resource, setResource] = useState(getResource(10));
+    const [isPending, startTransition] = useTransition();

    return (
        <>
            <Suspense fallback={<div>Loading...</div>}>
                <User resource={resource} />
            </Suspense>
+            <button onClick={() => startTransition(() => setResource(wrapPromise(requestUser(1))))}>
                切换用户
            </button>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

可以看到,加载状态的 loading 就不会出现了,闪一下的情况消失了:

减少不必要的 loading

那么问题来了,如果耗时操作确实会花费很久的时间,没有 loading 的话,对于用户来说就没有任何的反馈了呀。

别急,这个时候第一个元素 isPending 就可以用起来啦:

App.jsx

import React, { Suspense, useState, useTransition } from 'react';
import ReactDOM from 'react-dom';

// 对 promise 进行一层封装
function wrapPromise(promise) {
    let status = 'pending';
    let result;
    let suspender = promise.then(
        r => {
            status = 'success';
            result = r;
        },
        e => {
            status = 'error';
            result = e;
        }
    );
    return {
        read() {
            if (status === 'pending') {
                throw suspender;
            } else if (status === 'error') {
                throw result;
            } else if (status === 'success') {
                return result;
            }
        },
    };
}

// 网络请求,获取 user 数据
const requestUser = id =>
    new Promise(resolve =>
        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
    );

// User 组件
const User = props => {
    const user = props.resource.read();
    return <div>当前用户是: {user.name}</div>;
};

// 通过 id 获取对应 resource
const getResource = id => wrapPromise(requestUser(id));

const App = () => {
    const [resource, setResource] = useState(getResource(10));
    const [isPending, startTransition] = useTransition();

    return (
        <>
            <Suspense fallback={<div>Loading...</div>}>
                <User resource={resource} />
            </Suspense>
+            {isPending ? <div>Loading</div> : null}
            <button
                onClick={() => startTransition(() => setResource(wrapPromise(requestUser(20))))}
            >
                切换用户
            </button>
        </>
    );
};

// 使用 react 18 新的并发模式写法进行 dom render
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// legacy 旧模式
// ReactDOM.render(<App />, document.getElementById('root')!)

此时点击按钮切换用户,会有 2s 左右的等待时间,就可以展示出 loading 状态,用来提示用户:

isPending

所以,在使用 useTransition 时,一定要注意场景:

  • 在明确知道耗时操作速度极快的情况下,可以直接使用返回值中的 startTransition

  • 如果不能保证响应速度,还是需要使用 isPending 进行过渡状态的判断和展示

  • 如果对于更新的优先级有较高的要求,可以不使用 useTransition

相关链接

后记

好啦,关于 startTransitionuseDeferredValueuseTransition 的用法和使用场景都已经介绍完了。

所有的代码均已在文中贴出。

文中有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star。

轻松掌握 Redux 核心用法

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

写在前面

本文代码通过 create-react-app 脚手架进行搭建,所有的代码均可直接复制运行。

代码位置:react-redux-demo

本文主要讲解了 Redux 和 React-Redux 的使用,详细的概念以及设计**请看 Redux 中文官网

# npx
npx create-react-app my-app

# npm
npm init react-app my-app

# yarn
yarn create react-app my-app
  • 安装 redux
cd my-app

npm install redux
# or
yarn add redux

一、Action 描述更新对象

是把数据从组件传到 store 的载体,是修改 store 数据的唯一来源。
是一个普通的 javascript 对象,必须包含一个 type 属性,用来通知 reducer 这个 action 需要做的操作类型。
比如:

{
    type: 'ADD',
    payload: 1
}

通过 store.dispatch(action)action 传给 store

二、Reducer 执行更新函数

描述 store 数据如何更新的纯函数,接受两个参数

  • statestore 中的 state 值,可以给 state 设置初始值

  • action:通过 store.dispatch(action) 传递的 action 对象

通过 actiontype 类型来判断如何更新 state 数据

比如:

function reducer(state = 0, { type, payload }) {
  switch (type) {
    case "ADD":
      return state + payload;
    case "DELETE":
      return state - payload;
    default:
      return state;
  }
}

三、Store

actionreducer 联系到一起的对象,具有以下职责

  • 维持应用的 state
  • 提供 getState() 方法获取 state
  • 提供 dispatch(action) 方法更新 state
  • 通过 subscribe(listener) 注册监听器;
  • 通过 subscribe(listener) 返回的函数注销监听器

store 的创建方式

const store = createStore(reducer[, prevState, ehancer]);

四、实现一个简单的 React 计数器

  • 通过 store.dispatch(action) 通知数据更新
  • 通过 store 获取 state 数据
  • 编写 reducer 实现数据更新

store/index.js

import { createStore } from "redux";

// 创建 reducer 函数 ,更新 state 数据
const reducer = function(state = 0, { type }) {
  switch (type) {
    case "INCREMENT":
      return ++state;
    case "DECREMENT":
      return --state;
    default:
      return state;
  }
};

// 创建 store
const store = createStore(reducer);

export default store;

App.js

import React, { useEffect, useReducer, useCallback } from "react";
import store from "./store";

function App() {
  // 模拟 forceUpdate 方法
  const [, forceUpdate] = useReducer(x => x + 1, 0);

  useEffect(() => {
    // 订阅 store 监听事件
    const unsubscribe = store.subscribe(() => {
      forceUpdate();
    });
    return () => {
      // 组件销毁时移除事件订阅
      unsubscribe();
    };
  }, []);

  const increment = useCallback(
    // 分发 action
    () => store.dispatch({ type: "INCREMENT" }),
    []
  );

  const decrement = useCallback(
    // 分发 action
    () => store.dispatch({ type: "DECREMENT" }),
    []
  );

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      {/* 获取当前 state 值 */}
      <p>count: {store.getState()}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
    </div>
  );
}

export default App;

这个时候,计数器已经实现了,点击 increment 或者 decrement 按钮,会更新界面上的数据

那假如说,此时我们可能会处理多个业务场景,比如一个是计数器,一个是 TodoList,会有两个 reducer,这个时候该如何创建呢?请看下一个 API

五、combineReducers 合并 reducer

是一个高阶函数,作用是将多个 reducer 函数按照合并生成一个 reducer 函数。
接受一个对象,返回一个 reducer 函数。对象的键可以设置任意属性名,对象的值是对应的 reducer 函数。

在使用 store 中的 state 值时,state 中的对应的属性名就是之前传给 combineReducers 方法的对象的属性名。

比如:

const reducer = combineReducers({
  count: counterReducer,
  todos: todoReducer
});

获取 state 时:

const state = store.getState();
// state: { count: xxx, todos: xxx }

我们在上面的例子中再加一个展示 TodoList 的功能

store/index.js

import { createStore, combineReducers } from "redux";

// 创建 counterReducer 函数 ,更新 state 数据
const counterReducer = function(state = 0, { type }) {
  switch (type) {
    case "INCREMENT":
      return ++state;
    case "DECREMENT":
      return --state;
    default:
      return state;
  }
};

// 创建 todoReducer 函数,更新 state 数据
const todoReducer = function(state = [], { type, payload }) {
  switch (type) {
    case "INIT":
      return payload;
    case "ADD":
      state.push(payload);
      return [...state];
    default:
      return state;
  }
};

// 合并 reducer
const reducer = combineReducers({
  count: counterReducer,
  todos: todoReducer
});

// 创建 store
const store = createStore(reducer);

export default store;

App.js

import React, { useEffect, useReducer, useCallback, useState } from "react";
import store from "./store";

function App() {
  // 模拟 forceUpdate 方法
  const [, forceUpdate] = useReducer(x => x + 1, 0);
  const [value, setValue] = useState("");

  useEffect(() => {
    // 订阅 store 监听事件
    const unsubscribe = store.subscribe(() => {
      forceUpdate();
    });
    return () => {
      // 组件销毁时移除事件订阅
      unsubscribe();
    };
  }, []);

  const increment = useCallback(
    // 分发 action
    () => store.dispatch({ type: "INCREMENT" }),
    []
  );

  const decrement = useCallback(
    // 分发 action
    () => store.dispatch({ type: "DECREMENT" }),
    []
  );

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      store.dispatch({ type: "ADD", payload: value });
      setValue("");
    }
  }, [value]);

  // 解构 state
  const { count, todos } = store.getState();

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

至此,计数器和 TodoList 的功能都已经实现了

我们现在一直用的都是 redux 自己的功能,如果我想使用一些插件该怎么做呢,比如我想使用 logger 插件打印一些日志,请看下一个 API

六、applyMiddleware 应用插件

使用 applyMiddleware 可以应用插件,扩展 redux 功能。

applyMiddleware 是一个函数,接受多个参数值,返回一个高阶函数供 createStore 使用。

const ehancer = applyMiddleware(middleware1[, middleware2, middleware3, ...]);

下面我们以 redux-logger 插件为例,使用 applyMiddleware

安装 redux-logger

npm install redux-logger -D
# or
yarn add redux-logger -D

store/index.js

// ...

// 合并 reducer
const reducer = combineReducers({
  count: counterReducer,
  todos: todoReducer
});

// 应用插件
const ehancer = applyMiddleware(logger);

// 创建 store
const store = createStore(reducer, ehancer);

export default store;

上面我们修改数据的时候一直都是在同步状态下进行,那如果现在有一个副作用操作,需要异步执行完成才能进行 state 更新,又该怎么做呢?就要用到 redux-thunk 插件了

七、redux-thunk 支持异步 action

这个插件把 storedispatch 方法做了一层封装,可以接受一个函数作为 action

当判断当前 action 是一个函数的时候,会自动执行,并将 dispatch 作为参数传给我们。

安装 redux-thunk

npm install redux-thunk -D
# or
yarn add redux-thunk -D

下面我们看看 redux-thunk 的用法以及使用场景

还是在刚才的例子上,我们想要在组件加载完成之后对 TodoList 添加一些初始值,这个过程是一个异步过程

redux-thunk 插件应用到 store 中去
store/index.js

import thunk from "redux-thunk";
// ...

// 应用插件
const ehancer = applyMiddleware(thunk, logger);

// 创建 store
const store = createStore(reducer, ehancer);

export default store;

App.js

// ...

useEffect(() => {
  // 派发一个异步 action,是一个函数
  store.dispatch(dispatch => {
    setTimeout(() => {
      dispatch({ type: "INIT", payload: ["吃饭", "睡觉", "敲代码"] });
    }, 1000);
  });
}, []);

// ...

我们现在分发 action 的时候,都是直接 dispatch 一个对象,代码少的情况下还好,多的话可能就比较复杂,还要和 reducer 中的 type 对应,所以写起来比较麻烦,下面我们介绍一个概念:ActionCreator

八、ActionCreator: action 创建函数

这不是一个 API 或者方法,只是一种**和实现。就是通过调用一个函数生成一个对应的 action,在需要的时候我们直接调用这个函数,进行 dispatch 就可以了

比如:

const addTodo = todo => ({ type: "ADD", payload: todo });

这种写法我们 dispatch 的时候不用考虑 type ,也不用写键值对,只要传入正确的参数,就可以了。
下面我们就把刚才我们写的例子修改一下,使用这种 ActionCreator 的**去编写 action

store/index.js 中添加 actionCreator 并导出

// ...

const increment = () => ({ type: "INCREMENT" });
const decrement = () => ({ type: "DECREMENT" });

const initTodos = todos => ({ type: "INIT", payload: todos });
const addTodo = todo => ({ type: "ADD", payload: todo });

// 异步 action,执行完成之后调用同步 action
const getAsyncTodos = () => dispatch =>
  setTimeout(() => dispatch(initTodos(["吃饭", "睡觉", "写代码"])), 1000);

export const actionCreators = { increment, decrement, getAsyncTodos, addTodo };

App.jsdispatch 中调用 actionCreator

import React, { useEffect, useReducer, useCallback, useState } from "react";
import store, { actionCreators } from "./store";

const {
  increment as inc,
  decrement as dec,
  getAsyncTodos,
  addTodo
} = actionCreators;

function App() {
  // 模拟 forceUpdate 方法
  const [, forceUpdate] = useReducer(x => x + 1, 0);
  const [value, setValue] = useState("");

  useEffect(() => {
    // 订阅 store 监听事件
    const unsubscribe = store.subscribe(() => {
      forceUpdate();
    });
    return () => {
      // 组件销毁时移除事件订阅
      unsubscribe();
    };
  }, []);

  useEffect(() => {
    // 派发一个异步 action,是一个函数
    store.dispatch(getAsyncTodos());
  }, []);

  const increment = useCallback(
    // 分发 action
    () => store.dispatch(inc()),
    []
  );

  const decrement = useCallback(
    // 分发 action
    () => store.dispatch(dec()),
    []
  );

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      store.dispatch(addTodo(value));
      setValue("");
    }
  }, [value]);

  // 解构 state
  const { count, todos } = store.getState();

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

但是这个时候,感觉还是有点麻烦,每次触发 store 更新都要使用 dispatch(actionCreator(args)) 才行,能不能直接调用方法,就能触发 store 更新呢。
当然也可以了,redux 为我们提供了一个 bindActionCreators 函数

九、bindActionCreators

这个函数是将 dispatch 绑定到了 actionCreator 方法上,之后只要我们执行 actionCreator 就会触发 store 更新了,不用每次都 dispacth 了。

接受两个参数:

  • actionCreators:是一个对象,对象的属性名可以任意命名,属性值是对应的 actionCreator 方法
  • dispatchstore 中的 dipatch 属性

返回一个新的对象,对象的属性名是刚才传入的 actionCreators 中的属性名,属性值时包装后的方法,执行即可触发 store 更新

比如:

const finalActions = bindActionCreators({
    increment: () => ({ type: 'INCREMENT }),
    decrement: () => ({ type: 'DECREMENT })
},  dispatch)

// finalActions: { increment, decrement }

下面我们将 App.js 中的代码进行一波优化,看看最后的效果

import React, { useEffect, useReducer, useCallback, useState } from "react";
import { bindActionCreators } from "redux";
import store, { actionCreators } from "./store";

// 生成包装后的 actionCreator,执行之后就会触发 store 数据的更新
const { increment, decrement, getAsyncTodos, addTodo } = bindActionCreators(
  actionCreators,
  store.dispatch
);

function App() {
  // 模拟 forceUpdate 方法
  const [, forceUpdate] = useReducer(x => x + 1, 0);
  const [value, setValue] = useState("");

  useEffect(() => {
    // 订阅 store 监听事件
    const unsubscribe = store.subscribe(() => {
      forceUpdate();
    });
    return () => {
      // 组件销毁时移除事件订阅
      unsubscribe();
    };
  }, []);

  // 初始化 TodoList
  useEffect(() => {
    getAsyncTodos();
  }, []);

  const add = useCallback(() => {
    if (value) {
      // 分发 action
      addTodo(value);
      setValue("");
    }
  }, [value]);

  // 解构 state
  const { count, todos } = store.getState();

  return (
    <div className="App">
      <h1>Hello Redux</h1>
      <p>count: {count}</p>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <br />
      <br />
      <input
        placeholder="请输入待办事项"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button onClick={add}>add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

有没有发现,到目前为止,数据变化时,我们更新 React 组件还是通过自己去添加 subscribe 订阅,一个组件还好,那在项目开发的过程中,每个组件都这么写,岂不是太麻烦了。
可以看我的下一篇博客:十分钟学会 react-redux,详细介绍了 react-redux 的用法。

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

React 中 Context 用法详解

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

Demo 地址

Context 的创建

API: const MyContext = React.createContext(initialValue)

  • initialValuecontext 初始值

  • 返回值

    • MyContext.Provider: 提供者,是一个 React 组件,使用 Provider 标签包裹后的组件,自身以及后代组件都可以访问到 MyContext 的值

    • MyContext.Consumer: 消费者,是 React 组件,使用 Consumer 包裹后,可以使用 render props 的方式渲染内容,获取到 MyContext 的值

import React from "react";

const defaultTheme = { color: "black" };

const ThemeContext = React.createContext(defaultTheme);

Context.Provider 的使用

每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化。

使用一个 Provider 来将当前的 context 传递给以下的组件树,无论多深,任何组件都能读取这个值。

接受一个属性 value,子组件中获取到的 context 值就是 value

  • 单个 Provider 使用
function App() {
  return (
    <ThemeContext.Provider value={{ color: "blue" }}>
      <p>Hello World</p>
    </ThemeContext.Provider>
  );
}
  • 多个 Provider 使用
function App() {
  const [count, setCount] = useState(0);
  return (
    <ThemeContext.Provider value={{ color: "blue" }}>
      <CounterContext.Provider value={{ count, setCount }}>
        <p>Hello World</p>
      </CounterContext.Provider>
    </ThemeContext.Provider>
  );
}

动态 Context

Context.Provider 不仅可以设置 value,也可以动态的修改 value

value 值发生变化的时候,所有依赖改 Context 的子组件都会进行渲染

创建动态 CounterContext

const defaultTheme = { color: "black" };
const defaultCounter = {
  count: 1,
  setCount: () => {},
};

const ThemeContext = React.createContext(defaultTheme);

const CounterContext = React.createContext(defaultCounter);

将修改 value 的方法作为 value 的属性传递下去

function App() {
  const [count, setCount] = useState(0);
  return (
    <ThemeContext.Provider value={{ color: "blue" }}>
      <CounterContext.Provider value={{ count, setCount }}>
        <p>App 页面 count: {count}</p>
        <ContextType />
        <HookContext />
        <ConsumerContext />
      </CounterContext.Provider>
    </ThemeContext.Provider>
  );
}

在子组件中调用修改 value 的方法

export default function HookContext() {
  const { color } = useContext(ThemeContext);
  const { count, setCount } = useContext(CounterContext);
  return (
    <>
      <h2 style={{ color }}>useContext 使用</h2>
      <p>
        <div>HookContext 页面 count: {count}</div>
        <button onClick={() => setCount(count + 1)}>increment</button>
      </p>
    </>
  );
}

消费 Context

  • static contextType

  • Context.Consumer

  • useContext

Class.contextType

  • 在类组件中设置静态属性 contextType 为某个 Context

  • 在使用的时候通过 this.context 获取到 Context 的值

export default class ContextType extends Component {
  static contextType = ThemeContext;
  render() {
    const { color } = this.context;
    return <h2 style={{ color }}>ContextType 使用</h2>;
  }
}

Context.Consumer

Context.Consumer 是一个 React 组件可以订阅 context 的变更,既可以在函数组件中使用也可以在类组件中使用

这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。

传递给函数的 value 值等等价于组件树上方离这个 context 最近的 Provider 提供的 value 值。如果没有对应的 Providervalue 参数等同于传递给 createContext()defaultValue

  • 消费一个 Context
export default function ConsumerContext() {
  return (
    <ThemeContext.Consumer>
      {(theme) => {
        const { color } = theme;
        return <h2 style={{ color }}>Context.Consumer</h2>;
      }}
    </ThemeContext.Consumer>
  );
}
  • 消费多个 Context
export default function ConsumerContext() {
  return (
    <ThemeContext.Consumer>
      {(theme) => (
        <CounterContext.Consumer>
          {(context) => {
            const { color } = theme;
            const { count } = context;
            return (
              <>
                <h2 style={{ color }}>Context.Consumer</h2>
                <p>ConsumerContext 页面 count: {count}</p>
              </>
            );
          }}
        </CounterContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}

useContext

通过 useContext 可以获取到 value 值,参数是对应的 Context

可以再一个组件中使用多次 useContext 获取多个 Context 对应的 value

export default function HookContext() {
  const { color } = useContext(ThemeContext);
  const { count, setCount } = useContext(CounterContext);
  return (
    <>
      <h2 style={{ color }}>useContext 使用</h2>
      <p>
        <div>HookContext 页面 count: {count}</div>
        <button onClick={() => setCount(count + 1)}>increment</button>
      </p>
    </>
  );
}

displayName

context 对象接受一个名为 displayNameproperty,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。

示例,下述组件在 DevTools 中将显示为 MyDisplayName

const MyContext = React.createContext(/* some value */);
MyContext.displayName = 'MyDisplayName';

<MyContext.Provider> // "MyDisplayName.Provider" 在 DevTools 中
<MyContext.Consumer> // "MyDisplayName.Consumer" 在 DevTools 中

总结

  • 创建 Context: const MyContext = React.createContext(defaultValue)

  • 使用 Context.Provider 组件将子组件进行包裹,则无论子组件层级多深,都可以获取到对应的 value

  • 使用 Class.contextType 的方式获取 Context,可以在组件中通过 this.context 的方式获取到对应的 value

    • 只能在类组件中使用
    • 组件中只能使用一个 Context
  • 使用 Context.Consumer 组件消费 Context

    • 需要一个函数作为子元素,函数参数是距离最近的 Contextvalue 值,返回一个组件
    • 可以在类组件中使用也可以在函数组件中使用
    • 可以消费多个 Context
  • 使用 useContext 消费 Context

    • 只能在函数组件中使用
    • 可以通过调用多次 useContext 消费多个 Context
  • Context.Provider 中的 value 发生变化,则依赖当前 Context 的子组件都会发生进行更新

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.