开发者社区> l72sshxfx2ceg> 正文
阿里云
为了无法计算的价值
打开APP
阿里云APP内打开

React系列八 - 深入理解setState

简介: setState是React中使用频率最高的一个API(当然hooks出现之前),它的用法灵活多样,并且也是React面试题经常会考的一个知识点。 在这篇文章中,我对React的setState进行了很多解析,希望可以帮助大家真正理解setState。(其中涉及到一个源码,我有贴出,但是没有详细展开,有机会我们再对源码进行解析,大家不是很懂也不影响你的学习,只需要知道React内部是这样做的即可,面试时也可以回答出来)
+关注继续查看

一. setState的使用


1.1. 为什么使用setState


回到最早的案例,当点击一个 改变文本 的按钮时,修改界面显示的内容:

image.png

案例

案例的基础代码如下:

import React, { Component } from 'react'
export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      message: "Hello World"
    }
  }
  render() {
    return (
      <div>
        <h2>{this.state.message}</h2>
        <button onClick={e => this.changeText()}>改变文本</button>
      </div>
    )
  }
  changeText() {
  }
}

关键是changeText中应该如何实现:

我们是否可以通过直接修改state中的message来修改界面呢?

  • 点击不会有任何反应,为什么呢?
  • 因为我们修改了state之后,希望React根据最新的State来重新渲染界面,但是这种方式的修改React并不知道数据发生了变化;
  • React并没有实现类似于Vue2中的Object.defineProperty或者Vue3中的Proxy的方式来监听数据的变化;
  • 我们必须通过setState来告知React数据已经发生了变化;
changeText() {
    this.state.message = "你好啊,李银河";
  }

我们必须通过setState来更新数据:

  • 疑惑:在组件中并没有实现setState的方法,为什么可以调用呢?
  • 原因很简单,setState方法是从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');
};

image.png

所以,我们可以通过调用setState来修改数据:

  • 当我们调用setState时,会重新执行render函数,根据最新的State来创建ReactElement对象;
  • 再根据最新的ReactElement对象,对DOM进行修改;
changeText() {
  this.setState({
    message: "你好啊,李银河"
  })
}


1.2. setState异步更新


我们来看下面的代码:

  • 最终打印结果是Hello World;
  • 可见setState是异步的操作,我们并不能在执行完setState之后立马拿到最新的state的结果
changeText() {
  this.setState({
    message: "你好啊,李银河"
  })
  console.log(this.state.message); // Hello World
}

为什么setState设计为异步呢?

我对其回答做一个简单的总结:

  • setState设计为异步,可以显著的提升性能;
    • 如果每次调用 setState都进行一次更新,那么意味着render函数会被频繁调用,界面重新渲染,这样效率是很低的;
    • 最好的办法应该是获取到多个更新,之后进行批量更新;
  • 如果同步更新了state,但是还没有执行render函数,那么state和props不能保持同步;
    • state和props不能保持一致性,会在开发中产生很多的问题;

那么如何可以获取到更新后的值呢?

  • setState接受两个参数:第二个参数是一个回调函数,这个回调函数会在更新后会执行;
  • 格式如下:setState(partialState, callback)
changeText() {
  this.setState({
    message: "你好啊,李银河"
  }, () => {
    console.log(this.state.message); // 你好啊,李银河
  });
}

当然,我们也可以在生命周期函数:

componentDidUpdate(prevProps, provState, snapshot) {
  console.log(this.state.message);
}


1.3. setState一定是异步?


疑惑:setState一定是异步更新的吗?

验证一:在setTimeout中的更新:

changeText() {
  setTimeout(() => {
    this.setState({
      message: "你好啊,李银河"
    });
    console.log(this.state.message); // 你好啊,李银河
  }, 0);
}

验证二:原生DOM事件:

componentDidMount() {
  const btnEl = document.getElementById("btn");
  btnEl.addEventListener('click', () => {
    this.setState({
      message: "你好啊,李银河"
    });
    console.log(this.state.message); // 你好啊,李银河
  })
}

其实分成两种情况:

  • 在组件生命周期或React合成事件中,setState是异步;
  • 在setTimeout或者原生dom事件中,setState是同步;

React中其实是通过一个函数来确定的:enqueueSetState部分实现(react-reconciler/ReactFiberClassComponent.js)

enqueueSetState(inst, payload, callback) {
  const fiber = getInstance(inst);
  // 会根据React上下文计算一个当前时间
  const currentTime = requestCurrentTimeForUpdate();
  const suspenseConfig = requestCurrentSuspenseConfig();
  // 这个函数会返回当前是同步还是异步更新(准确的说是优先级)
  const expirationTime = computeExpirationForFiber(
    currentTime,
    fiber,
    suspenseConfig,
  );
  const update = createUpdate(expirationTime, suspenseConfig);
  
  ...
}

image.png  enqueueSetState源码

computeExpirationForFiber函数的部分实现:

  • Sync是优先级最高的,即创建就更新;
currentTime: ExpirationTime,
  fiber: Fiber,
  suspenseConfig: null | SuspenseConfig,
): ExpirationTime {
  const mode = fiber.mode;
  if ((mode & BlockingMode) === NoMode) {
    return Sync;
  }
  const priorityLevel = getCurrentPriorityLevel();
  if ((mode & ConcurrentMode) === NoMode) {
    return priorityLevel === ImmediatePriority ? Sync : Batched;
  }

image.png  computeExpirationForFiber


1.4. setState的合并


1.4.1. 数据的合并


假如我们有这样的数据:

this.state = {
  name: "coderwhy",
  message: "Hello World"
}

我们需要更新message:

  • 我通过setState去修改message,是不会对name产生影响的;
changeText() {
  this.setState({
    message: "你好啊,李银河"
  });
}

为什么不会产生影响呢?源码中其实是有对 原对象新对象进行合并的:

  • 事实上就是使用 Object.assign(target, ...sources) 来完成的;

image.png  React源码合并数据


1.4.2. 多个setState合并


比如我们还是有一个counter属性,记录当前的数字:

  • 如果进行如下操作,那么counter会变成几呢?答案是1;
  • 为什么呢?因为它会对多个state进行合并;
increment() {
    this.setState({
      counter: this.state.counter + 1
    });
    this.setState({
      counter: this.state.counter + 1
    });
    this.setState({
      counter: this.state.counter + 1
    });
  }

其实在源码的processUpdateQueue中有一个do...while循环,就是从队列中取出多个state进行合并的;

image.png  React源码

如何可以做到,让counter最终变成3呢?

increment() {
  this.setState((state, props) => {
    return {
      counter: state.counter + 1
    }
  })
  this.setState((state, props) => {
    return {
      counter: state.counter + 1
    }
  })
  this.setState((state, props) => {
    return {
      counter: state.counter + 1
    }
  })
  }

为什么传入一个函数就可以变出3呢?

  • 原因是多个state进行合并时,每次遍历,都会执行一次函数:

image.png  传入的函数被多次执行


二. setState性能优化


2.1. React更新机制


我们在前面已经学习React的渲染流程:

image.png  jsx到虚拟DOM到真实DOM

那么React的更新流程呢?

image.png

React在props或state发生改变时,会调用React的render方法,会创建一颗不同的树。

React需要基于这两颗不同的树之间的差别来判断如何有效的更新UI:

  • 如果一棵树参考另外一棵树进行完全比较更新,那么即使是最先进的算法,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量;
  • https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf
  • 如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围;
  • 这个开销太过昂贵了,React的更新性能会变得非常低效;

于是,React对这个算法进行了优化,将其优化成了O(n),如何优化的呢?

  • 同层节点之间相互比较,不会垮节点比较;
  • 不同类型的节点,产生不同的树结构;
  • 开发中,可以通过key来指定哪些节点在不同的渲染下保持稳定;


2.2. Diffing算法


2.2.1. 对比不同类型的元素


当节点为不同的元素,React会拆卸原有的树,并且建立起新的树:

  • 当一个元素从 <a> 变成 <img>,从 <Article> 变成 <Comment>,或从 <Button> 变成 <div> 都会触发一个完整的重建流程;
  • 当卸载一棵树时,对应的DOM节点也会被销毁,组件实例将执行 componentWillUnmount() 方法;
  • 当建立一棵新的树时,对应的 DOM 节点会被创建以及插入到 DOM 中,组件实例将执行 componentWillMount() 方法,紧接着 componentDidMount() 方法;

比如下面的代码更改:

  • React 会销毁 Counter 组件并且重新装载一个新的组件,而不会对Counter进行复用;
<div>
  <Counter />
</div>
<span>
  <Counter />
</span>


2.2.2. 对比同一类型的元素


当比对两个相同类型的 React 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性。

比如下面的代码更改:

  • 通过比对这两个元素,React 知道只需要修改 DOM 元素上的 className 属性;
<div className="before" title="stuff" />
<div className="after" title="stuff" />

比如下面的代码更改:

  • 当更新 style 属性时,React 仅更新有所更变的属性。
  • 通过比对这两个元素,React 知道只需要修改 DOM 元素上的 color 样式,无需修改 fontWeight
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />

如果是同类型的组件元素:

  • 组件会保持不变,React会更新该组件的props,并且调用componentWillReceiveProps()componentWillUpdate() 方法;
  • 下一步,调用 render() 方法,diff 算法将在之前的结果以及新的结果中进行递归;


2.2.3. 对子节点进行递归


在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation。

我们来看一下在最后插入一条数据的情况:

  • 前面两个比较是完全相同的,所以不会产生mutation;
  • 最后一个比较,产生一个mutation,将其插入到新的DOM树中即可;
<ul>
  <li>first</li>
  <li>second</li>
</ul>
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

但是如果我们是在中间插入一条数据:

  • React会对每一个子元素产生一个mutation,而不是保持 <li>星际穿越</li><li>盗梦空间</li>的不变;
  • 这种低效的比较方式会带来一定的性能问题;
<ul>
  <li>星际穿越</li>
  <li>盗梦空间</li>
</ul>
<ul>
  <li>大话西游</li>
  <li>星际穿越</li>
  <li>盗梦空间</li>
</ul>


2.3. keys的优化


我们在前面遍历列表时,总是会提示一个警告,让我们加入一个key属性:

image.png  key的警告

我们来看一个案例:

import React, { Component } from 'react'
export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      movies: ["星际穿越", "盗梦空间"]
    }
  }
  render() {
    return (
      <div>
        <h2>电影列表</h2>
        <ul>
          {
            this.state.movies.map((item, index) => {
              return <li>{item}</li>
            })
          }
        </ul>
        <button onClick={e => this.insertMovie()}>插入数据</button>
      </div>
    )
  }
  insertMovie() {
  }
}

方式一:在最后位置插入数据

  • 这种情况,有无key意义并不大
insertMovie() {
  const newMovies = [...this.state.movies, "大话西游"];
  this.setState({
    movies: newMovies
  })
}

方式二:在前面插入数据

  • 这种做法,在没有key的情况下,所有的li都需要进行修改;
insertMovie() {
  const newMovies = ["大话西游", ...this.state.movies];
  this.setState({
    movies: newMovies
  })
}

当子元素(这里的li)拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素:

  • 在下面这种场景下,key为111和222的元素仅仅进行位移,不需要进行任何的修改;
  • 将key为333的元素插入到最前面的位置即可;
<ul>
  <li key="111">星际穿越</li>
  <li key="222">盗梦空间</li>
</ul>
<ul>
  <li key="333">Connecticut</li>
  <li key="111">星际穿越</li>
  <li key="222">盗梦空间</li>
</ul>

key的注意事项:

  • key应该是唯一的;
  • key不要使用随机数(随机数在下一次render时,会重新生成一个数字);
  • 使用index作为key,对性能是没有优化的;


2.4. SCU的优化


2.4.1. render函数被调用


我们使用之前的一个嵌套案例:

  • 在App中,我们增加了一个计数器的代码;
  • 当点击+1时,会重新调用App的render函数;
  • 而当App的render函数被调用时,所有的子组件的render函数都会被重新调用;
import React, { Component } from 'react';
function Header() {
  console.log("Header Render 被调用");
  return <h2>Header</h2>
}
class Main extends Component {
  render() {
    console.log("Main Render 被调用");
    return (
      <div>
        <Banner/>
        <ProductList/>
      </div>
    )
  }
}
function Banner() {
  console.log("Banner Render 被调用");
  return <div>Banner</div>
}
function ProductList() {
  console.log("ProductList Render 被调用");
  return (
    <ul>
      <li>商品1</li>
      <li>商品2</li>
      <li>商品3</li>
      <li>商品4</li>
      <li>商品5</li>
    </ul>
  )
}
function Footer() {
  console.log("Footer Render 被调用");
  return <h2>Footer</h2>
}
export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0
    }
  }
  render() {
    console.log("App Render 被调用");
    return (
      <div>
        <h2>当前计数: {this.state.counter}</h2>
        <button onClick={e => this.increment()}>+1</button>
        <Header/>
        <Main/>
        <Footer/>
      </div>
    )
  }
  increment() {
    this.setState({
      counter: this.state.counter + 1
    })
  }
}

image.png

嵌套树结构

那么,我们可以思考一下,在以后的开发中,我们只要是修改了App中的数据,所有的组件都需要重新render,进行diff算法,性能必然是很低的:

  • 事实上,很多的组件没有必须要重新render;
  • 它们调用render应该有一个前提,就是依赖的数据(state、props)发生改变时,再调用自己的render方法;

如何来控制render方法是否被调用呢?

  • 通过shouldComponentUpdate方法即可;


2.4.2. shouldComponentUpdate


React给我们提供了一个生命周期方法 shouldComponentUpdate(很多时候,我们简称为SCU),这个方法接受参数,并且需要有返回值:

  • 该方法有两个参数:
    • 参数一:nextProps 修改之后,最新的props属性
    • 参数二:nextState 修改之后,最新的state属性
  • 该方法返回值是一个boolean类型
    • 返回值为true,那么就需要调用render方法;
    • 返回值为false,那么久不需要调用render方法;
    • 默认返回的是true,也就是只要state发生改变,就会调用render方法;
shouldComponentUpdate(nextProps, nextState) {
  return true;
}

我们可以控制它返回的内容,来决定是否需要重新渲染。

比如我们在App中增加一个message属性:

  • jsx中并没有依赖这个message,那么它的改变不应该引起重新渲染;
  • 但是因为render监听到state的改变,就会重新render,所以最后render方法还是被重新调用了;
export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0,
      message: "Hello World"
    }
  }
  render() {
    console.log("App Render 被调用");
    return (
      <div>
        <h2>当前计数: {this.state.counter}</h2>
        <button onClick={e => this.increment()}>+1</button>
        <button onClick={e => this.changeText()}>改变文本</button>
        <Header/>
        <Main/>
        <Footer/>
      </div>
    )
  }
  increment() {
    this.setState({
      counter: this.state.counter + 1
    })
  }
  changeText() {
    this.setState({
      message: "你好啊,李银河"
    })
  }
}

这个时候,我们可以通过实现shouldComponentUpdate来决定要不要重新调用render方法:

  • 这个时候,我们改变counter时,会重新渲染;
  • 如果,我们改变的是message,那么默认返回的是false,那么就不会重新渲染;
shouldComponentUpdate(nextProps, nextState) {
  if (nextState.counter !== this.state.counter) {
    return true;
  }
  return false;
}

但是我们的代码依然没有优化到最好,因为当counter改变时,所有的子组件依然重新渲染了:

  • 所以,事实上,我们应该实现所有的子组件的shouldComponentUpdate;

比如Main组件,可以进行如下实现:

  • shouldComponentUpdate默认返回一个false;
  • 在特定情况下,需要更新时,我们在上面添加对应的条件即可;
class Main extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    return false;
  }
  render() {
    console.log("Main Render 被调用");
    return (
      <div>
        <Banner/>
        <ProductList/>
      </div>
    )
  }
}


2.4.3. PureComponent和memo


如果所有的类,我们都需要手动来实现 shouldComponentUpdate,那么会给我们开发者增加非常多的工作量。

我们来设想一下shouldComponentUpdate中的各种判断的目的是什么?

  • props或者state中的数据是否发生了改变,来决定shouldComponentUpdate返回true或者false;

事实上React已经考虑到了这一点,所以React已经默认帮我们实现好了,如何实现呢?

  • 将class基础自PureComponent。

比如我们修改Main组件的代码:

class Main extends PureComponent {
  render() {
    console.log("Main Render 被调用");
    return (
      <div>
        <Banner/>
        <ProductList/>
      </div>
    )
  }
}

PureComponent的原理是什么呢?

  • 对props和state进行浅层比较;

查看PureComponent相关的源码:

react/ReactBaseClasses.js中:

  • 在PureComponent的原型上增加一个isPureReactComponent为true的属性

image.png  PureComponent

React-reconcilier/ReactFiberClassComponent.js:

image.png  checkShouldComponentUpdate

这个方法中,调用 !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState),这个shallowEqual就是进行浅层比较:

image.png  shallowEqual

那么,如果是一个函数式组件呢?

我们需要使用一个高阶组件memo:

  • 我们将之前的Header、Banner、ProductList都通过memo函数进行一层包裹;
  • Footer没有使用memo函数进行包裹;
  • 最终的效果是,当counter发生改变时,Header、Banner、ProductList的函数不会重新执行,而Footer的函数会被重新执行;
import React, { Component, PureComponent, memo } from 'react';
const MemoHeader = memo(function() {
  console.log("Header Render 被调用");
  return <h2>Header</h2>
})
class Main extends PureComponent {
  render() {
    console.log("Main Render 被调用");
    return (
      <div>
        <MemoBanner/>
        <MemoProductList/>
      </div>
    )
  }
}
const MemoBanner = memo(function() {
  console.log("Banner Render 被调用");
  return <div>Banner</div>
})
const MemoProductList = memo(function() {
  console.log("ProductList Render 被调用");
  return (
    <ul>
      <li>商品1</li>
      <li>商品2</li>
      <li>商品3</li>
      <li>商品4</li>
      <li>商品5</li>
    </ul>
  )
})
function Footer() {
  console.log("Footer Render 被调用");
  return <h2>Footer</h2>
}
export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0,
      message: "Hello World"
    }
  }
  render() {
    console.log("App Render 被调用");
    return (
      <div>
        <h2>当前计数: {this.state.counter}</h2>
        <button onClick={e => this.increment()}>+1</button>
        <button onClick={e => this.changeText()}>改变文本</button>
        <MemoHeader/>
        <Main/>
        <Footer/>
      </div>
    )
  }
  increment() {
    this.setState({
      counter: this.state.counter + 1
    })
  }
  shouldComponentUpdate(nextProps, nextState) {
    if (nextState.counter !== this.state.counter) {
      return true;
    }
    return false;
  }
  changeText() {
    this.setState({
      message: "你好啊,李银河"
    })
  }
}

memo的原理是什么呢?

react/memo.js:

  • 最终返回一个对象,这个对象中有一个compare函数

memo函数


2.4.4. 不可变数据的力量


我们通过一个案例来演练我们之前说的不可变数据的重要性:

import React, { PureComponent } from 'react'
export default class App extends PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      friends: [
        { name: "lilei", age: 20, height: 1.76 },
        { name: "lucy", age: 18, height: 1.65 },
        { name: "tom", age: 30, height: 1.78 }
      ]
    }
  }
  render() {
    return (
      <div>
        <h2>朋友列表</h2>
        <ul>
          {
            this.state.friends.map((item, index) => {
              return (
                <li key={item.name}>
                  <span>{`姓名:${item.name} 年龄: ${item.age}`}</span>
                  <button onClick={e => this.incrementAge(index)}>年龄+1</button>
                </li>
              )
            })
          }
        </ul>
        <button onClick={e => this.insertFriend()}>添加新数据</button>
      </div>
    )
  }
  insertFriend() {
     
  }
  incrementAge(index) {
    
  }
}

我们来思考一下inertFriend应该如何实现?

实现方式一:

  • 这种方式会造成界面不会发生刷新,添加新的数据;
  • 原因是继承自PureComponent,会进行浅层比较,浅层比较过程中两个friends是相同的对象;
insertFriend() {
  this.state.friends.push({name: "why", age: 18, height: 1.88});
  this.setState({
    friends: this.state.friends
  })
}

实现方式二:

  • [...this.state.friends, {name: "why", age: 18, height: 1.88}]会生成一个新的数组引用;
  • 在进行浅层比较时,两个引用的是不同的数组,所以它们是不相同的;
insertFriend() {
  this.setState({
    friends: [...this.state.friends, {name: "why", age: 18, height: 1.88}]
  })
}

我们再来思考一下incrementAge应该如何实现?

实现方式一:

  • 和上面方式一类似
incrementAge(index) {
  this.state.friends[index].age += 1;
  this.setState({
    friends: this.state.friends
  })
}

实现方式二:

  • 和上面方式二类似
incrementAge(index) {
  const newFriends = [...this.state.friends];
  newFriends[index].age += 1;
  this.setState({
    friends: newFriends
  })
}

所以,在真实开发中,我们要尽量保证state、props中的数据不可变性,这样我们才能合理和安全的使用PureComponent和memo。

当然,后面项目中我会结合immutable.js来保证数据的不可变性。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
JavaScript深入之bind的模拟实现
JavaScript深入系列第十一篇,通过bind函数的模拟实现,带大家真正了解bind的特性
28 0
深入理解 React JS 中的 setState
此文主要探讨了 React JS 中的 setState 背后的机制,供深入学习 React 研究之用。 在课程 React.js入门基础与案例开发 中,有些同学会发现 React JS 中的 setState 的表现好像有点怪异,和理解中的 state 更新机制不太一样,下面我们就来简单探讨下 setState 背后的机制。
1267 0
Java-static关键字详解
从学习Java开始,学习static关键字相关已有多次,但还是未能明确区分static在不同地方的作用,以及被修饰的内容在JVM中内存的分配,还有程序执行时的加载过程。 所以在此从到到尾总结下Java中static的使用。
799 0
深入理解JavaScript系列(33):设计模式之策略模式(转)
介绍 策略模式定义了算法家族,分别封装起来,让他们之间可以互相替换,此模式让算法的变化不会影响到使用算法的客户。 正文 在理解策略模式之前,我们先来一个例子,一般情况下,如果我们要做数据合法性验证,很多时候都是按照swith语句来判断,但是这就带来几个问题,首先如果增加需求的话,我们还要再次修...
708 0
javascript深入理解js闭包
一、变量的作用域 要理解闭包,首先必须理解Javascript特殊的变量作用域。 变量的作用域无非就是两种:全局变量和局部变量。 Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。
531 0
SYS_并发管理系列4_并发程序管理器程序优先级Priority(案例)
2012-03-04 Created By BaoXinjian 一、摘要 当的Oracle E-Business Suite并发请求(Concurrent Request)提交了好久,但还是一直在排队,等了好久还没有执行。
507 0
90
文章
0
问答
文章排行榜
最热
最新
相关电子书
更多
低代码开发师(初级)实战教程
立即下载
阿里巴巴DevOps 最佳实践手册
立即下载
冬季实战营第三期:MySQL数据库进阶实战
立即下载