一、简介介绍下React,说说他们都有哪些特性
1.1 简介
React是一个构建用户界面的 JavaScript 库,是一个UI 层面的解决方案。React遵循组件设计模式、声明式编程范式和函数式编程概念,以使前端应用程序开发更高效。同时,React使用虚拟DOM来有效地操作DOM,遵循从高阶组件到低阶组件的单向数据流。同时,React可以帮助我们将界面拆分成各个独立的小块,每一个块就是组件,这些组件之间可以组合、嵌套,构成一个整体页面。
语法上,React 类组件使用一个名为 render() 的方法或者函数组件return,接收输入的数据并返回需要展示的内容,比如:
class HelloMessage extends React.Component {
render() {
return (
Hello {this.props.name}
);
}
}
ReactDOM.render(
,
document.getElementById('hello-example')
);
上述这种类似 XML形式就是 JSX,最终会被babel编译为合法的JS语句调用。被传入的数据可在组件中通过 this.props 在 render() 访问。
1.2 特性
React特性有很多,下面列举几个有特性的:
- JSX语法
- 单向数据绑定
- 虚拟DOM
- 声明式编程
- Component
1.2.1 声明式编程
声明式编程是一种编程范式,它关注的是你要做什么,而不是如何做。它表达逻辑而不显式地定义步骤。这意味着我们需要根据逻辑的计算来声明要显示的组件,如实现一个标记的地图:通过命令式创建地图、创建标记、以及在地图上添加的标记的步骤如下。
// 创建地图
const map = new Map.map(document.getElementById('map'), {
zoom: 4,
center: {lat,lng}
});
// 创建标记
const marker = new Map.marker({
position: {lat, lng},
title: 'Hello Marker'
});
// 地图上添加标记
marker.setMap(map);
而用React实现上述功能,则如下:
声明式编程方式使得React组件很容易使用,最终的代码也更加简单易于维护。
1.2.2 Component
在React 中,一切皆为组件。通常将应用程序的整个逻辑分解为小的单个部分。 我们将每个单独的部分称为组件。组件可以是一个函数或者是一个类,接受数据输入,处理它并返回在UI中呈现的React元素,函数式组件如下:
const Header = () => {
return(
TODO App
)
}
而对于需要改变状态来说,有状态组件的定义如下:
class Dashboard extends React.Component {
constructor(props){
super(props);
this.state = {
}
}
render() {
return (
);
}
}
可以看到,React的组件有如下的一些特性:
- 可组合:个组件易于和其它组件一起使用,或者嵌套在另一个组件内部。
- 可重用:每个组件都是具有独立功能的,它可以被使用在多个UI场景。
- 可维护:每个小的组件仅仅包含自身的逻辑,更容易被理解和维护。
二、Real DOM和 Virtual DOM 的区别
2.1 Real DOM
Real DOM是相对Virtual DOM 来说的, Real DOM指的是文档对象模型,是一个结构化文本的抽象,在页面渲染出的每一个结点都是一个真实DOM结构,我们可以使用浏览器的DevTool来查看,如下。
Virtual Dom,本质上是以 JavaScript 对象形式存在的对 DOM 的描述。创建虚拟DOM目的就是为了更好将虚拟的节点渲染到页面视图中,虚拟DOM对象的节点与真实DOM的属性一一照应。在React中,JSX是其一大特性,可以让你在JS中通过使用XML的方式去直接声明界面的DOM结构。
const vDom = Hello World
// 创建h1标签,右边千万不能加引号
const root = document.getElementById('root') // 找到节点
ReactDOM.render(vDom, root) // 把创建的h1标签渲染到root节点上
在上述代码中,ReactDOM.render()用于将创建好的虚拟DOM节点插入到某个真实节点上,并渲染到页面上。实际上,JSX是一种语法糖,在使用过程中会被babel进行编译转化成JS代码,上述VDOM转化为如下。
const vDom = React.createElement(
'h1',
{ className: 'hClass', id: 'hId' },
'hello world'
)
可以看到,JSX就是为了简化直接调用React.createElement() 方法:
- 第一个参数是标签名,例如h1、span、table...。
- 第二个参数是个对象,里面存着标签的一些属性,例如id、class等。
- 第三个参数是节点中的文本。
通过console.log(VDOM),则能够得到虚拟DOM的相关信息。
所以,从上面的例子可知,JSX通过babel的方式转化成React.createElement执行,返回值是一个对象,也就是虚拟DOM。、
2.2 区别
Real DOM和 Virtual DOM的区别如下:
- 虚拟DOM不会进行排版与重绘操作,而真实DOM会频繁重排与重绘。
- 虚拟DOM的总损耗是“虚拟DOM增删改+真实DOM差异增删改+排版与重绘”,真实DOM的总损耗是“真实DOM完全增删改+排版与重绘”。
2.3 优缺点
真实DOM的优势:
- 易用
缺点:
- 效率低,解析速度慢,内存占用量过高。
- 性能差:频繁操作真实DOM,易于导致重绘与回流。
使用虚拟DOM的优势如下:
- 简单方便:如果使用手动操作真实DOM来完成页面,繁琐又容易出错,在大规模应用下维护起来也很困难。
- 性能好:使用Virtual DOM,能够有效避免真实DOM数频繁更新,减少多次引起重绘与回流,提高性能。
-跨平台:React借助虚拟DOM, 带来了跨平台的能力,一套代码多端运行。
缺点如下:
- 在一些性能要求极高的应用中虚拟 DOM 无法进行针对性的极致优化。
- 首次渲染大量DOM时,由于多了一层虚拟DOM的计算,速度比正常稍慢。
三、super()和super(props)有什么区别
3.1 ES6类
在ES6中,通过extends关键字实现类的继承,方式如下:
class sup {
constructor(name) {
this.name = name
}
printName() {
console.log(this.name)
}
}
class sub extends sup{
constructor(name,age) {
super(name) // super代表的事父类的构造函数
this.age = age
}
printAge() {
console.log(this.age)
}
}
let jack = new sub('jack',20)
jack.printName() //输出 : jack
jack.printAge() //输出 : 20
在上面的例子中,可以看到通过super关键字实现调用父类,super代替的是父类的构建函数,使用super(name)相当于调用sup.prototype.constructor.call(this,name)。如果在子类中不使用super,关键字,则会引发报错,如下:
报错的原因是 子类是没有自己的this对象的,它只能继承父类的this对象,然后对其进行加工。而super()就是将父类中的this对象继承给子类的,没有super() 子类就得不到this对象。如果先调用this,再初始化super(),同样是禁止的行为。
class sub extends sup{
constructor(name,age) {
this.age = age
super(name) // super代表的事父类的构造函数
}
}
所以,在子类constructor中,必须先代用super才能引用this。
3.2 类组件
在React中,类组件是基于es6的规范实现的,继承React.Component,因此如果用到constructor就必须写super()才初始化this。这时候,在调用super()的时候,我们一般都需要传入props作为参数,如果不传进去,React内部也会将其定义在组件实例中。
// React 内部
const instance = new YourComponent(props);
instance.props = props;
所以无论有没有constructor,在render中this.props都是可以使用的,这是React自动附带的,是可以不写的。
class HelloMessage extends React.Component{
render (){
return (
nice to meet you! {this.props.name}
);
}
}
但是也不建议使用super()代替super(props)。因为在React会在类组件构造函数生成实例后再给this.props赋值,所以在不传递props在super的情况下,调用this.props会返回undefined,如下。
class Button extends React.Component {
constructor(props) {
super(); // 没传入 props
console.log(props); // {}
console.log(this.props); // undefined
// ...
}
而传入props的则都能正常访问,确保了 this.props 在构造函数执行完毕之前已被赋值,更符合逻辑。
class Button extends React.Component {
constructor(props) {
super(props); // 没传入 props
console.log(props); // {}
console.log(this.props); // {}
// ...
}
从上面的例子,我们可以得出:
- 在React中,类组件基于ES6,所以在constructor中必须使用super。
- 在调用super过程,无论是否传入props,React内部都会将porps赋值给组件实例porps属性中。
- 如果只调用了super(),那么this.props在super()和构造函数结束之间仍是undefined。
四、谈谈setState执行机制
4.1 什么是setState机制
在React中,一个组件的显示形态可以由数据状态和外部参数所决定,而数据状态就是state。当需要修改里面的值的状态时,就需要通过调用setState来改变,从而达到更新组件内部数据的作用。
比如,下面的例子:
import React, { Component } from 'react'
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
message: "Hello World"
}
}
render() {
return (
{this.state.message}
)
}
changeText() {
this.setState({
message: "JS每日一题"
})
}
}
通过点击按钮触发onclick事件,执行this.setState方法更新state状态,然后重新执行render函数,从而导致页面的视图更新。如果想要直接修改state的状态,那么只需要调用setState即可。
changeText() {
this.state.message = "你好啊,世界";
}
我们会发现页面并不会有任何反应,但是state的状态是已经发生了改变。这是因为React并不像vue2中调用Object.defineProperty数据响应式或者Vue3调用Proxy监听数据的变化,必须通过setState方法来告知react组件state已经发生了改变。
关于state方法的定义是从React.Component中继承,定义的源码如下:
Component.prototype.setState = function(partialState, callback) {
invariant(
typeof partialState === 'object' ||
typeof partialState === 'function' ||
partialState == null,
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
4.2 更新方式
在使用setState更新数据的时候,setState的更新类型分成异步更新、同步更新。
4.2.1 异步更新
举个例子,有下面一段代码。
changeText() {
this.setState({
message: "你好啊"
})
console.log(this.state.message); // Hello World
}
上面的代码最终打印结果为Hello world
,并不能在执行完setState之后立马拿到最新的state的结果。如果想要立刻获取更新后的值,在第二个参数的回调中更新后会执行。
changeText() {
this.setState({
message: "你好啊"
}, () => {
console.log(this.state.message); // 你好啊
});
}
4.2.2 同步更新
下面是使用setTimeout同步更新的例子。
changeText() {
setTimeout(() => {
this.setState({
message: "你好啊
});
console.log(this.state.message); // 你好啊
}, 0);
}
4.2.3 批量更新
有时候,我们需要处理批量更新的情况,先给出一个例子:
handleClick = () => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
}
当我们点击按钮触发事件,打印的都是 1,页面显示 count 的值为 2。对同一个值进行多次 setState , setState 的批量更新策略会对其进行覆盖,取最后一次的执行结果。因此,上面的代码等价于下面的代码:
Object.assign( previousState, {index: state.count+ 1}, {index: state.count+ 1}, ...)
由于后面的数据会覆盖前面的更改,所以最终只加了一次。如果是下一个state依赖前一个state的话,推荐给setState一个参数传入一个function,如下:
onClick = () => { this.setState((prevState, props) => { return {count: prevState.count + 1}; }); this.setState((prevState, props) => { return {count: prevState.count + 1}; });}
而在setTimeout或者原生dom事件中,由于是同步的操作,所以并不会进行覆盖现象。
五、React事件绑定
5.1 事件绑定
当我们需要处理点击事件时,几句需要给事件添加一些绑定操作,即所谓的事件绑定。下面是一个最常见的事件绑定:
class ShowAlert extends React.Component {
showAlert() {
console.log("Hi");
}
render() {
return ;
}
}
可以看到,事件绑定的方法需要使用{}
包住。上述的代码看似没有问题,但是当将处理函数输出代码换成console.log(this)的时候,点击按钮,则会发现控制台输出undefined。
5.2 常见绑定方式
React常见的事件绑定方式有如下几种:
- render方法中使用bind
- render方法中使用箭头函数
- constructor中bind
- 定义阶段使用箭头函数绑定
5.2.1 render方法中使用bind
如果使用一个类组件,在其中给某个组件/元素一个onClick属性,它现在并会自定绑定其this到当前组件,解决这个问题的方法是在事件函数后使用.bind(this)将this绑定到当前组件中。
class App extends React.Component {
handleClick() {
console.log('this > ', this);
}
render() {
return (
test
)
}
}
这种方式在组件每次render渲染的时候,都会重新进行bind的操作,影响性能。
5.2.2 render方法中使用箭头函数
通过ES6的上下文来将this的指向绑定给当前组件,同样再每一次render的时候都会生成新的方法,影响性能。
class App extends React.Component {
handleClick() {
console.log('this > ', this);
}
render() {
return (
this.handleClick(e)}>test
)
}
}
5.2.3 constructor中bind
在constructor中预先bind当前组件,可以避免在render操作中重复绑定。
class App extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
console.log('this > ', this);
}
render() {
return (
test
)
}
}
5.2.4 定义阶段使用箭头函数绑定
跟上述方式三一样,能够避免在render操作中重复绑定,实现也非常的简单。
class App extends React.Component {
constructor(props) {
super(props);
}
handleClick = () => {
console.log('this > ', this);
}
render() {
return (
test
)
}
}
5.3 区别
上述四种方法的方式,区别主要如下:
- 编写方面:方式一、方式二写法简单,方式三的编写过于冗杂
- 性能方面:方式一和方式二在每次组件render的时候都会生成新的方法实例,性能问题欠缺。若该函数作为属性值传给子组件的时候,都会导致额外的渲染。而方式三、方式四只会生成一个方法实例。
综合上述,方式四是最优的事件绑定方式。
六、React中组件通讯
6.1 组件通信
组件是Vue中和React前端框架最核心的基础思想,也是区别其他js框架最明显的特征之一。通常,一个完成的复杂业务页面就是由许多的基础组件构成的。而组件之间需要传递消息,就会涉及到通信。通信指的是发送者通过某种媒体以某种格式来传递信息到收信者以达到某个目的,广义上,任何信息的交通都是通信。
6.2 通信的几种方式
组件传递的方式有很多种,根据传送者和接收者可以分为如下几种:
- 父组件向子组件传递
- 子组件向父组件传递
- 兄弟组件之间的通信
- 父组件向后代组件传递
- 非关系组件传递
6.2.1 父组件向子组件传递消息
由于React的数据流动为单向的,父组件向子组件传递是最常见的方式。父组件在调用子组件的时候,只需要在子组件标签内传递参数,子组件通过props属性就能接收父组件传递过来的参数即可。
function EmailInput(props) {
return (
);
}
const element = ;
6.2.2 子组件向父组件传递消息
子组件向父组件通信的基本思路是,父组件向子组件传一个函数,然后通过这个函数的回调,拿到子组件传过来的值。父组件对应代码如下:
class Parents extends Component {
constructor() {
super();
this.state = {
price: 0
};
}
getItemPrice(e) {
this.setState({
price: e
});
}
render() {
return (
price: {this.state.price}
{/* 向子组件中传入一个函数 */}
);
}
}
子组件对应代码如下:
class Child extends Component {
clickGoods(e) {
// 在此函数中传入值
this.props.getPrice(e);
}
render() {
return (
);
}
}
6.2.3 兄弟组件之间的通信
如果是兄弟组件之间的传递,则父组件作为中间层来实现数据的互通,通过使用父组件传递。
class Parent extends React.Component {
constructor(props) {
super(props)
this.state = {count: 0}
}
setCount = () => {
this.setState({count: this.state.count + 1})
}
render() {
return (
);
}
}
6.2.4 隔代组传递消息
父组件向后代组件传递数据是一件最普通的事情,就像全局数据一样。使用context提供了组件之间通讯的一种方式,可以共享数据,其他数据都能读取对应的数据。通过使用React.createContext创建一个context。
const PriceContext = React.createContext('price')
context创建成功后,其下存在Provider组件用于创建数据源,Consumer组件用于接收数据,使用实例如下:Provider组件通过value属性用于给后代组件传递数据。
如果想要获取Provider传递的数据,可以通过Consumer组件或者或者使用contextType属性接收,对应分别如下:
class MyClass extends React.Component {
static contextType = PriceContext;
render() {
let price = this.context;
/* 基于这个值进行渲染工作 */
}
}
Consumer组件代码如下:
{ /*这里是一个函数*/ }
{
price => price:{price}
}
6.2.5 非关系组件传递消息
如果组件之间关系类型比较复杂的情况,建议将数据进行一个全局资源管理,从而实现通信,例如redux,mobx等。
七、React Hooks
7.1 Hook
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。至于为什么引入hook,官方给出的动机是解决长时间使用和维护React过程中常遇到的问题,例如:
- 难以重用和共享组件中的与状态相关的逻辑
- 逻辑复杂的组件难以开发与维护,当我们的组件需要处理多个互不相关的 local state 时,每个生命周期函数中可能会包含着各种互不相关的逻辑在里面
- 类组件中的this增加学习成本,类组件在基于现有工具的优化上存在些许问题
- 由于业务变动,函数组件不得不改为类组件等等
函数组件也被称为无状态的组件,在刚开始只负责渲染的一些工作。因此,使用Hook技术现在的函数组件也可以是有状态的组件,内部也可以维护自身的状态以及做一些逻辑方面的处理。
7.2 Hooks函数
Hooks让我们的函数组件拥有了类组件的特性,例如组件内的状态、生命周期等。为了实现状态管理,Hook提供了很多有用的Hooks函数,常见的有:
- useState
- useEffect
- 其他
useState
首先给出一个例子,如下:
import React, { useState } from 'react';
function Example() {
// 声明一个叫 "count" 的 state 变量
const [count, setCount] = useState(0);
return (
You clicked {count} times
);
}
在函数组件中通过useState实现函数内部维护state,参数为state默认的值,返回值是一个数组,第一个值为当前的state,第二个值为更新state的函数。该函数组件如果用类组件实现,代码如下:
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
You clicked {this.state.count} times
);
}
}
从上述两种代码分析,可以看出两者区别:
- state声明方式:在函数组件中通过 useState 直接获取,类组件通过constructor 构造函数中设置
- state读取方式:在函数组件中直接使用变量,类组件通过this.state.count的方式获取
- state更新方式:在函数组件中通过 setCount 更新,类组件通过this.setState()
总的来讲,useState 使用起来更为简洁,减少了this指向不明确的情况。
useEffect
useEffect可以让我们在函数组件中进行一些带有副作用的操作。比如,下面是一个计时器的例子:
class Example extends React.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 (
You clicked {this.state.count} times
);
}
}
从上面可以看见,组件在加载和更新阶段都执行同样操作。而如果使用useEffect后,则能够将相同的逻辑抽离出来,这是类组件不具备的方法。
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => { document.title = `You clicked ${count} times`; });
return (
You clicked {count} times
);
}
useEffect的呃第一个参数接受一个回调函数。默认情况下,useEffect会在第一次渲染和更新之后都会执行,相当于在componentDidMount和componentDidUpdate两个生命周期函数中执行回调。
如果某些特定值在两次重渲染之间没有发生变化,你可以跳过对 effect 的调用,这时候只需要传入第二个参数,如下:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新
上述传入第二个参数后,如果 count 的值是 5,而且我们的组件重渲染的时候 count 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较,如果是相等则跳过effects执行。
回调函数中可以返回一个清除函数,这是effect可选的清除机制,相当于类组件中componentwillUnmount生命周期函数,可做一些清除副作用的操作,如下:
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
可以发现,useEffect相当于componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个生命周期函数的组合。
其它 hooks
除了上面两个比较常见的外,React还有很多额外的hooks。
- useReducer
- useCallback
- useMemo
- useRef
7.3 总结
通过对上面的初步认识,可以看到hooks能够更容易解决状态相关的重用的问题:
- 每调用useHook一次都会生成一份独立的状态
- 通过自定义hook能够更好的封装我们的功能
编写hooks为函数式编程,每个功能都包裹在函数中,整体风格更清爽,更优雅。
八、谈谈你对Redux的理解
8.1 概念
React是用于构建用户界面的,帮助我们解决渲染DOM的过程。而在整个应用中会存在很多个组件,每个组件的state是由自身进行管理,包括组件定义自身的state、组件之间的通信通过props传递、使用Context实现数据共享。
如果让每个组件都存储自身相关的状态,理论上来讲不会影响应用的运行,但在开发及后续维护阶段,我们将花费大量精力去查询状态的变化过程。这种情况下,如果将所有的状态进行集中管理,当需要更新状态的时候,仅需要对这个管理集中处理,而不用去关心状态是如何分发到每一个组件内部的。
Redux实现了状态的集中管理,使用时需要遵循三大基本原则:
- 单一数据源
- state 是只读的
- 使用纯函数来执行修改
需要说明的是,Redux并不是只应用在React中,还与其他界面库一起使用,如Vue。
8.2 工作原理
redux状态管理主要分为三个部分: Action Creactor、Store和Reducer。其中, store 是用于数据的公共存储空间。一个组件改变了 store 里的数据内容,其他组件就能感知到 store 的变化,再来取数据,从而间接的实现了这些数据传递的功能。
详细的介绍可以查看:Redux 三大核心概念
8.3 使用
首先,需要创建一个store的公共数据区域。
import { createStore } from 'redux' // 引入一个第三方的方法
const store = createStore() // 创建数据的公共存储区域(管理员)
然后,再创建一个记录本去辅助管理数据,也就是reduecer,本质就是一个函数,接收两个参数state和action,并返回state。
// 设置默认值
const initialState = {
counter: 0
}
const reducer = (state = initialState, action) => {
}
接着,使用createStore函数将state和action建立连接,如下。
const store = createStore(reducer)
如果想要获取store里面的数据,则通过store.getState()来获取当前state,如下。
console.log(store.getState());
下面再看看如何更改store里面数据。是通过dispatch来派发action,通常action中都会有type属性,也可以携带其他的数据。
store.dispatch({
type: "INCREMENT"
})
store.dispath({
type: "DECREMENT"
})
store.dispatch({
type: "ADD_NUMBER",
number: 5
})
接着,我们再来看看修改reducer中的处理逻辑。
const reducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT":
return {...state, counter: state.counter + 1};
case "DECREMENT":
return {...state, counter: state.counter - 1};
case "ADD_NUMBER":
return {...state, counter: state.counter + action.number}
default:
return state;
}
}
注意,reducer是一个纯函数,不需要直接修改state。接着,当派发action之后,既可以通过store.subscribe监听store的变化。
store.subscribe(() => {
console.log(store.getState());
})
在React项目中,会搭配react-redux进行使用。
const redux = require('redux');
const initialState = {
counter: 0
}
// 创建reducer
const reducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT":
return {...state, counter: state.counter + 1};
case "DECREMENT":
return {...state, counter: state.counter - 1};
case "ADD_NUMBER":
return {...state, counter: state.counter + action.number}
default:
return state;
}
}
// 根据reducer创建store
const store = redux.createStore(reducer);
store.subscribe(() => {
console.log(store.getState());
})
// 修改store中的state
store.dispatch({
type: "INCREMENT"
})
// console.log(store.getState());
store.dispatch({
type: "DECREMENT"
})
// console.log(store.getState());
store.dispatch({
type: "ADD_NUMBER",
number: 5
})
// console.log(store.getState());
- createStore可以帮助创建 store。
- store.dispatch 帮助派发 action , action 会传递给 store。
- store.getState 这个方法可以帮助获取 store 里边所有的数据内容。
- store.subscrible 方法订阅 store 的改变,只要 store 发生改变, store.subscrible 这个函数接收的这个回调函数就会被执行。
九、Redux中间件
9.1 什么是中间件
中间件(Middleware)是介于应用系统和系统软件之间的一类软件,它使用系统软件所提供的基础服务(功能),衔接网络上应用系统的各个部分或不同的应用,能够达到资源共享、功能共享的目的。
前面,我们了解到了Redux整个工作流程,当action发出之后,reducer立即算出state,整个过程是一个同步的操作。那么如果需要支持异步操作,或者支持错误处理、日志监控,这个过程就可以用上中间件。
Redux中,中间件就是放在就是在dispatch过程,在分发action进行拦截处理,如下图:
其本质上一个函数,对store.dispatch方法进行了改造,在发出 Action 和执行 Reducer 这两步之间,添加了其他功能。
9.2 常用中间件
优秀的redux中间件有很多,比如:
- redux-thunk:用于异步操作
- redux-logger:用于日志记录
上述的中间件都需要通过applyMiddlewares进行注册,作用是将所有的中间件组成一个数组,依次执行,作为第二个参数传入到createStore中。
const store = createStore(
reducer,
applyMiddleware(thunk, logger)
);
9.2.1 redux-thunk
redux-thunk是官网推荐的异步处理中间件。默认情况下的dispatch(action),action需要是一个JavaScript的对象。
redux-thunk中间件会判断你当前传进来的数据类型,如果是一个函数,将会给函数传入参数值(dispatch,getState)。
- dispatch函数用于我们之后再次派发action。
- getState函数考虑到我们之后的一些操作需要依赖原来的状态,用于让我们可以获取之前的一些状态。
所以,dispatch可以写成下述函数的形式。
const getHomeMultidataAction = () => {
return (dispatch) => {
axios.get("http://xxx.xx.xx.xx/test").then(res => {
const data = res.data.data;
dispatch(changeBannersAction(data.banner.list));
dispatch(changeRecommendsAction(data.recommend.list));
})
}
}
9.2.2 redux-logger
如果想要实现一个日志功能,则可以使用现成的redux-logger,如下。
import { applyMiddleware, createStore } from 'redux';
import createLogger from 'redux-logger';
const logger = createLogger();
const store = createStore(
reducer,
applyMiddleware(logger)
);
9.3 Redux源码分析
首先,我们来看看applyMiddlewares的源码:
export default function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState, enhancer) => {
var store = createStore(reducer, preloadedState, enhancer);
var dispatch = store.dispatch;
var chain = [];
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
};
chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {...store, dispatch}
}
}
可以看到,所有中间件被放进了一个数组chain,然后嵌套执行,最后执行store.dispatch,而中间件内部(middlewareAPI)可以拿到getState和dispatch这两个方法
通过上面的分析,我们了解到了redux-thunk的基本使用。同时,内部会将dispatch进行一个判断,然后执行对应操作,原理如下:
function patchThunk(store) {
let next = store.dispatch;
function dispatchAndThunk(action) {
if (typeof action === "function") {
action(store.dispatch, store.getState);
} else {
next(action);
}
}
store.dispatch = dispatchAndThunk;
}
下面,我们自己实现一个日志输出的拦截。
let next = store.dispatch;
function dispatchAndLog(action) {
console.log("dispatching:", addAction(10));
next(addAction(5));
console.log("新的state:", store.getState());
}
store.dispatch = dispatchAndLog;
十、如何提高组件的渲染效率
我们知道,React 基于虚拟 DOM 和高效 Diff 算法的完美配合,实现了对 DOM 最小粒度的更新,大多数情况下,React 对 DOM 的渲染效率足以我们的业务日常。不过,对于复杂业务场景,性能问题依然会困扰我们。此时需要采取一些措施来提升运行性能,避免不必要的渲染则是业务中常见的优化手段之一。
10.1 实现方案
我们了解到,render的触发时机简单来讲就是类组件通过调用setState方法, 就会导致render,父组件一旦发生render渲染,子组件一定也会执行render渲染。父组件渲染导致子组件渲染,子组件并没有发生任何改变,这时候就可以从避免无谓的渲染,具体实现的方式有如下:
- shouldComponentUpdate
- PureComponent
- React.memo
10.2 涉及生命周期函数
102.1 shouldComponentUpdate
通过shouldComponentUpdate生命周期函数来比对 state 和 props,确定是否要重新渲染。默认情况下返回true表示重新渲染,如果不希望组件重新渲染,返回 false 即可。
10.2.2 PureComponent
跟shouldComponentUpdate 原理基本一致,通过对 props 和 state的浅比较结果来实现 shouldComponentUpdate,源码大致如下:
if (this._compositeType === CompositeTypes.PureClass) {
shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}
shallowEqual对应方法源码如下:
const hasOwnProperty = Object.prototype.hasOwnProperty;
/**
* is 方法来判断两个值是否是相等的值,为何这么写可以移步 MDN 的文档
* https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function is(x: mixed, y: mixed): boolean {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}
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);
// 长度不相等直接返回false
if (keysA.length !== keysB.length) {
return false;
}
// key相等的情况下,再去循环比较
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;
}
10.2.3 React.memo
React.memo用来缓存组件的渲染,避免不必要的更新,其实也是一个高阶组件,与 PureComponent 十分类似。但不同的是, React.memo 只能用于函数组件。
import { memo } from 'react';
function Button(props) {
// Component code
}
export default memo(Button);
如果需要深层次比较,这时候可以给memo第二个参数传递比较函数。
function arePropsEqual(prevProps, nextProps) {
// your code
return prevProps === nextProps;
}
export default memo(Button, arePropsEqual);
10.3 总结
在实际开发过程中,前端性能问题是一个必须考虑的问题,随着业务的复杂,遇到性能问题的概率也在增高。
除此之外,建议将页面进行更小的颗粒化,如果一个过大,当状态发生修改的时候,就会导致整个大组件的渲染,而对组件进行拆分后,粒度变小了,也能够减少子组件不必要的渲染。
十一、对Fiber架构的理解
11.1 背景
JavaScript 引擎和页面渲染引擎两个线程是互斥的,当其中一个线程执行时,另一个线程只能挂起等待。如果 JavaScript 线程长时间地占用了主线程,那么渲染层面的更新就不得不长时间地等待,界面长时间不更新,会导致页面响应度变差,用户可能会感觉到卡顿。
而这也正是 React 15 的 Stack Reconciler 所面临的问题,当 React 在渲染组件时,从开始到渲染完成整个过程是一气呵成的,无法中断。如果组件较大,那么js线程会一直执行,然后等到整棵VDOM树计算完成后,才会交给渲染的线程。这就会导致一些用户交互、动画等任务无法立即得到处理,导致卡顿的情况。
11.2 React Fiber
eact Fiber 是 Facebook 花费两年余时间对 React 做出的一个重大改变与优化,是对 React 核心算法的一次重新实现。从Facebook在 React Conf 2017 会议上确认,React Fiber 在React 16 版本发布。
在React中,主要做了以下的操作:
- 为每个增加了优先级,优先级高的任务可以中断低优先级的任务。然后再重新,注意是重新执行优先级低的任务。
- 增加了异步任务,调用requestIdleCallback api,浏览器空闲的时候执行。
- dom diff树变成了链表,一个dom对应两个fiber(一个链表),对应两个队列,这都是为找到被中断的任务,重新执行。
从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写。从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的虚拟DOM。
一个 fiber 就是一个 JavaScript 对象,包含了元素的信息、该元素的更新操作队列、类型,其数据结构如下:
type Fiber = {
// 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
tag: WorkTag,
// ReactElement里面的key
key: null | string,
// ReactElement.type,调用`createElement`的第一个参数
elementType: any,
// The resolved function/class/ associated with this fiber.
// 表示当前代表的节点类型
type: any,
// 表示当前FiberNode对应的element组件实例
stateNode: any,
// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
return: Fiber | null,
// 指向自己的第一个子节点
child: Fiber | null,
// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,
// 当前处理过程中的组件props对象
pendingProps: any,
// 上一次渲染完成之后的props
memoizedProps: any,
// 该Fiber对应的组件产生的Update会存放在这个队列里面
updateQueue: UpdateQueue | null,
// 上一次渲染的时候的state
memoizedState: any,
// 一个列表,存放这个Fiber依赖的context
firstContextDependency: ContextDependency | null,
mode: TypeOfMode,
// Effect
// 用来记录Side Effect
effectTag: SideEffectTag,
// 单链表用来快速查找下一个side effect
nextEffect: Fiber | null,
// 子树中第一个side effect
firstEffect: Fiber | null,
// 子树中最后一个side effect
lastEffect: Fiber | null,
// 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
expirationTime: ExpirationTime,
// 快速确定子树中是否有不在等待的变化
childExpirationTime: ExpirationTime,
// fiber的版本池,即记录fiber更新过程,便于恢复
alternate: Fiber | null,
}
11.3 解决方案
Fiber把渲染更新过程拆分成多个子任务,每次只做一小部分,做完看是否还有剩余时间,如果有继续下一个任务;如果没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候在继续执行。
即可以中断与恢复,恢复后也可以复用之前的中间状态,并给不同的任务赋予不同的优先级,其中每个任务更新单元为 React Element 对应的 Fiber 节点。
实现的上述方式的是requestIdleCallback方法,window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
首先 ,React 中任务切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间再进行页面的渲染。等浏览器忙完之后有剩余时间,再继续之前 React 未完成的任务,是一种合作式调度。
该实现过程是基于 Fiber 节点实现,作为静态的数据结构来说,每个 Fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件等等)、对应的 DOM 节点等信息。作为动态的工作单元来说,每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作。
每个 Fiber 节点有个对应的 React element,多个 Fiber 节点根据如下三个属性构建一颗树。
// 指向父级Fiber节点
this.return = null
// 指向子Fiber节点
this.child = null
// 指向右边第一个兄弟Fiber节点
this.sibling = null
十二、 React 性能优化的手段有哪些
12.1 render渲染
React凭借virtual DOM和diff算法拥有高效的性能,但是某些情况下,性能明显可以进一步提高。我们知道,类组件通过调用setState方法, 就会导致render,父组件一旦发生render渲染,子组件一定也会执行render渲染。当我们想要更新一个子组件的时候,如更新的绿色部分的内容:
理想状态下,我们只调用该路径下的组件render,执行对应组件的渲染即可。
不过,React的默认做法是调用所有组件的render,再对生成的虚拟DOM进行对比。
因此,默认的做法是非常浪费性能的。
12.2 优化方案
蔚来避免不必要的render,我们前面介绍了可以通过shouldComponentUpdate、PureComponent、React.memo来进行优化。除此之外,性能优化常见的还有如下一些:
- 避免使用内联函数
- 使用 React Fragments 避免额外标记
- 使用 Immutable
- 懒加载组件
- 事件绑定方式
- 服务端渲染
12.2.1 避免使用内联函数
如果我们使用内联函数,则每次调用render函数时都会创建一个新的函数实例,比如:
import React from "react";
export default class InlineFunctionComponent extends React.Component {
render() {
return (
Welcome Guest
{ this.setState({inputValue: e.target.value}) }} value="Click For Inline Function" />
)
}
}
正确的做法是,应该在组件内部创建一个函数,并将事件绑定到该函数本身。这样每次调用 render 时就不会创建单独的函数实例。
import React from "react";
export default class InlineFunctionComponent extends React.Component {
setNewStateData = (event) => {
this.setState({
inputValue: e.target.value
})
}
render() {
return (
Welcome Guest
)
}
}
12.2.2 使用 React Fragments 避免额外标记
用户创建新组件时,每个组件应具有单个父标签。父级不能有两个标签,所以顶部要有一个公共标签,所以我们经常在组件顶部添加额外标签div。
这个额外标签除了充当父标签之外,并没有其他作用,这时候则可以使用fragement。其不会向组件引入任何额外标记,但它可以作为父级标签的作用。
export default class NestedRoutingComponent extends React.Component {
render() {
return (
<>
This is the Header Component
Welcome To Demo Page
>
)
}
}
12.2.3 懒加载组件
从工程方面考虑,webpack存在代码拆分能力,可以为应用创建多个包,并在运行时动态加载,减少初始包的大小。而在react中使用到了Suspense 和 lazy组件实现代码拆分功能,基本使用如下:
const johanComponent = React.lazy(() => import(/* webpackChunkName: "johanComponent" */ './myAwesome.component'));
export const johanAsyncComponent = props => (
}>
);
12.2.4 服务端渲染
采用服务端渲染端方式,可以使用户更快的看到渲染完成的页面。服务端渲染,需要起一个node服务,可以使用express、koa等,调用react的renderToString方法,将根组件渲染成字符串,再输出到响应中:
import { renderToString } from "react-dom/server";
import MyPage from "./MyPage";
app.get("/", (req, res) => {
res.write("My Page ");
res.write("");
res.write(renderToString( ));
res.write("");
res.end();
});
然后,客户端使用render方法来生成HTML即可。
import ReactDOM from 'react-dom';
import MyPage from "./MyPage";
ReactDOM.render( , document.getElementById('app'));
十三、React服务端渲染
13.1 什么是服务端渲染
服务器渲染指的是由服务侧完成页面的 HTML 结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。其解决的问题主要有两个:
- SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面
- 加速首屏加载,解决首屏白屏问题
13.2 怎么做
在React中,实现SSR主要有两种形式:
- 手动搭建一个 SSR 框架
- 使用成熟的SSR 框架,如 Next.JS
下面以手动搭建一个 SSR 框架来说明怎么实现SSR。首先,通过express启动一个app.js文件,用于监听3000端口的请求,当请求根目录时,返回HTML,如下:
const express = require('express')
const app = express()
app.get('/', (req,res) => res.send(`
ssr demo
Hello world
`))
app.listen(3000, () => console.log('Exampleapp listening on port 3000!'))
然后,在服务器中编写React代码,在app.js中进行应引用:
import React from 'react'
const Home = () =>{
return home
}
export default Home
为了让服务器能够识别JSX,这里需要使用webpakc对项目进行打包转换,创建一个配置文件webpack.server.js并进行相关配置,如下所示。
const path = require('path') //node的path模块
const nodeExternals = require('webpack-node-externals')
module.exports = {
target:'node',
mode:'development', //开发模式
entry:'./app.js', //入口
output: { //打包出口
filename:'bundle.js', //打包后的文件名
path:path.resolve(__dirname,'build') //存放到根目录的build文件夹
},
externals: [nodeExternals()], //保持node中require的引用方式
module: {
rules: [{ //打包规则
test: /\.js?$/, //对所有js文件进行打包
loader:'babel-loader', //使用babel-loader进行打包
exclude: /node_modules/,//不打包node_modules中的js文件
options: {
presets: ['react','stage-0',['env', {
//loader时额外的打包规则,对react,JSX,ES6进行转换
targets: {
browsers: ['last 2versions'] //对主流浏览器最近两个版本进行兼容
}
}]]
}
}]
}
}
接着,借助react-dom提供了服务端渲染的 renderToString方法,负责把React组件解析成Html。
import express from 'express'
import React from 'react'//引入React以支持JSX的语法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import Home from'./src/containers/Home'
const app= express()
const content = renderToString( )
app.get('/',(req,res) => res.send(`
ssr demo
${content}
`))
app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))
上面的过程中,已经能够成功将组件渲染到了页面上。但是,像一些事件处理的方法,是无法在服务端完成,因此需要将组件代码在浏览器中再执行一遍,这种服务器端和客户端共用一套代码的方式就称之为同构。重构通俗讲就是一套React代码在服务器上运行一遍,到达浏览器又运行一遍:
- 服务端渲染完成页面结构
- 浏览器端渲染完成事件绑定
浏览器实现事件绑定的方式为让浏览器去拉取JS文件执行,让JS代码来控制,因此需要引入script标签。通过script标签为页面引入客户端执行的react代码,并通过express的static中间件为js文件配置路由,修改如下:
import express from 'express'
import React from 'react'//引入React以支持JSX的语法
import { renderToString } from'react-dom/server'//引入renderToString方法
import Home from './src/containers/Home'
const app = express()
app.use(express.static('public'));
//使用express提供的static中间件,中间件会将所有静态文件的路由指向public文件夹
const content = renderToString( )
app.get('/',(req,res)=>res.send(`
ssr demo
${content}
`))
app.listen(3001, () =>console.log('Example app listening on port 3001!'))
然后,在客户端执行以下react代码,新建webpack.client.js作为客户端React代码的webpack配置文件如下:
const path = require('path') //node的path模块
module.exports = {
mode:'development', //开发模式
entry:'./src/client/index.js', //入口
output: { //打包出口
filename:'index.js', //打包后的文件名
path:path.resolve(__dirname,'public') //存放到根目录的build文件夹
},
module: {
rules: [{ //打包规则
test: /\.js?$/, //对所有js文件进行打包
loader:'babel-loader', //使用babel-loader进行打包
exclude: /node_modules/, //不打包node_modules中的js文件
options: {
presets: ['react','stage-0',['env', {
//loader时额外的打包规则,这里对react,JSX进行转换
targets: {
browsers: ['last 2versions'] //对主流浏览器最近两个版本进行兼容
}
}]]
}
}]
}
}
这种方法就能够简单实现首页的React服务端渲染,过程如下图所示。
通常,一个应用会存在路由的情况,配置信息如下:
import React from 'react' //引入React以支持JSX
import { Route } from 'react-router-dom' //引入路由
import Home from './containers/Home' //引入Home组件
export default (
)
然后,可以通过index.js引用路由信息,如下:
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from'react-router-dom'
import Router from'../Routers'
const App= () => {
return (
{Router}
)
}
ReactDom.hydrate( , document.getElementById('root'))
这时候,控制台会存在报错信息,原因在于每个Route组件外面包裹着一层div,但服务端返回的代码中并没有这个div。解决方法只需要将路由信息在服务端执行一遍,使用使用StaticRouter来替代BrowserRouter,通过context进行参数传递。
import express from 'express'
import React from 'react'//引入React以支持JSX的语法
import { renderToString } from 'react-dom/server'//引入renderToString方法
import { StaticRouter } from 'react-router-dom'
import Router from '../Routers'
const app = express()
app.use(express.static('public'));
//使用express提供的static中间件,中间件会将所有静态文件的路由指向public文件夹
app.get('/',(req,res)=>{
const content = renderToString((
//传入当前path
//context为必填参数,用于服务端渲染参数传递
{Router}
))
res.send(`
ssr demo
${content}
`)
})
app.listen(3001, () => console.log('Exampleapp listening on port 3001!'))
13.3 总结
整体React服务端渲染原理并不复杂,具体如下:
Node server 接收客户端请求,得到当前的请求url 路径,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 props、context或者store 形式传入组件。
然后,基于 React 内置的服务端渲染方法 renderToString()把组件渲染为 html字符串在把最终的 html 进行输出前需要将数据注入到浏览器端.
浏览器开始进行渲染和节点对比,然后执行完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。