阅读 885

【译】如何在React Hooks中获取数据?

原文链接: www.robinwieruch.de/react-hooks…

在本教程中,我想通过state和effect hook来像你展示如何用React Hooks来获取数据。我将会使用Hacker News的API来获取热门的技术文章。你将会实现一个属于你自己的自定义hook来在你程序的任何地方复用,或者是作为一个npm包发布出来。

如果你还不知道这个React的新特性,那么点击React Hooks介绍,如果你想直接查看最后的实现效果,请点击这个github仓库

注意:在未来,React Hooks将不会用于React的数据获取,一个叫做Suspense的特性将会去负责它。但下面的教程仍会让你去更多的了解关于React中的state和effect hook。

用React Hooks去获取数据

如果你对在React中获取数据还不熟悉,可以查看我其他的React获取数据的文章。它将会引导你通过使用React的class组件来获取数据,并且还可以和render props或者高阶组件一起使用,以及结合错误处理和加载状态。在这篇文章中,我将会在function组件中使用React Hooks来展示这些功能。

import React, { useState } from 'react';

function App() {
  const [data, setData] = useState({ hits: [] });

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}

export default App;
复制代码

这个App组件展示了一个包含很多项的list(hits = Hacker News 文章)。state和state的更新函数来自于state hook中useState的调用,它负责管理我们用来渲染list数据的本地状态,初始状态是一个空数组,此时还没有为其设置任何的状态。

我们将使用axios来获取数据,当然你也可以使用其他的库或者fetch API,如果你还没安装axios,你可以在命令行使用npm install axios来安装它。然后来实现用于数据获取的effect hook:

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

function App() {
  const [data, setData] = useState({ hits: [] });

  useEffect(async () => {
    const result = await axios(
      'http://hn.algolia.com/api/v1/search?query=redux',
    );

    setData(result.data);
  });

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}

export default App;
复制代码

通过axios在useEffect中获取数据,然后通过setData将数据放到组件本地的state中,并通过async/await来处理Promise。

然而当你运行程序的时候,你应该会遇到一个讨厌的循环。effect hook不仅在组件mount的时候也会在update的时候运行。因为我们在每一次的数据获取之后,会去通过setState设置状态,这时候组件update然后effect就会运行一遍,这就造成了数据一次又一次的获取。我们仅仅是想要在组件mount的时候来获取一次数据,这就是为什么我们需要在useEffect的第二个参数提供一个空数组,从而实现只在mount的时候触发数据获取而不是每一次update。

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

function App() {
  const [data, setData] = useState({ hits: [] });

  useEffect(async () => {
    const result = await axios(
      'http://hn.algolia.com/api/v1/search?query=redux',
    );

    setData(result.data);
  }, []);

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}

export default App;
复制代码

第二个参数可以定义hooks所依赖的变量(在一个数组中去分配),如果一个变量改变了,hooks将会执行一次,如果是一个空数组的话,hooks将不会在组件更新的时候执行,因为它没有监听到任何的变量。

这里还有一个陷阱,在代码中,我们使用async/await从第三方的API中获取数据,根据文档,每一个async函数都将返回一个promise,async函数声明定义了一个异步函数,它返回一个asyncFunction对象,异步函数是通过事件循环异步操作的函数,使用隐式Promise返回其结果。但是,effect hook应该不返回任何内容或清除功能,这就是为什么你会在控制台看到以下警告:07:41:22.910 index.js:1452 Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => …) are not supported, but you can call an async function inside an effect..这就是为什么不允许在useEffect函数中直接使用async的原因。让我们通过在effect内部使用异步函数来实现它的解决方案。

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

function App() {
  const [data, setData] = useState({ hits: [] });

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        'http://hn.algolia.com/api/v1/search?query=redux',
      );

      setData(result.data);
    };

    fetchData();
  }, []);

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}

export default App;
复制代码

简而言之,这就是用React Hooks获取数据。但是,如果你对错误处理、加载提示、如何从表单中触发数据获取以及如何实现可重用的数据获取hook感兴趣,请继续阅读。

如何通过编程方式/手动方式触发hook?

好的,我们在mount后获取了一次数据,但是,如果使用input的字段来告诉API哪一个话题是我们感兴趣的呢?“Redux”可以作为我们的默认查询,如果是关于“React”的呢?让我们实现一个input元素,使某人能够获取“Redux”以外的话题。因此,为input元素引入一个新的状态。

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

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        'http://hn.algolia.com/api/v1/search?query=redux',
      );

      setData(result.data);
    };

    fetchData();
  }, []);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}

export default App;
复制代码

目前,这两个状态彼此独立,但现在希望将它们耦合起来,以获取由input中的输入来查询指定的项目。通过下面的更改,组件应该在挂载之后通过查询词获取所有数据。

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );

      setData(result.data);
    };

    fetchData();
  }, []);

  return (
    ...
  );
}

export default App;
复制代码

还差一部分:当你尝试在input中输入一些内容时,在mount之后就不会再获取任何数据了,这是因为我们提供了空数组作为第二个参数,effect没有依赖任何变量,因此只会在mount的时候触发,但是现在的effect应该依赖query,每当query改变的时候,就应该触发数据的获取。

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );

      setData(result.data);
    };

    fetchData();
  }, [query]);

  return (
    ...
  );
}

export default App;
复制代码

现在每当input的值更新的时候就可以重新获取数据了。但这又导致了另一个问题:对于input中键入的每个字符,都会触发该效果,并执行一个数据提取请求。如何提供一个按钮来触发请求,从而手动hook呢?

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [search, setSearch] = useState('');

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );

      setData(result.data);
    };

    fetchData();
  }, [query]);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button type="button" onClick={() => setSearch(query)}>
        Search
      </button>

      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}
复制代码

现在,effect依赖于于search,而不是随输入字段中变化的query。一旦用户点击按钮,新的search就会被设置,并且应该手动触发effect hook。

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [search, setSearch] = useState('redux');

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${search}`,
      );

      setData(result.data);
    };

    fetchData();
  }, [search]);

  return (
    ...
  );
}

export default App;
复制代码

此外,search的初始值也设置为与query相同,因为组件也在mount时获取数据,因此结果应反映输入字段中的值。但是,具有类似的query和search状态有点令人困惑。为什么不将实际的URL设置为状态而来代替search?

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState(
    'http://hn.algolia.com/api/v1/search?query=redux',
  );

  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(url);

      setData(result.data);
    };

    fetchData();
  }, [url]);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>

      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}
复制代码

这就是使用effect hook获取隐式编程数据的情况。你可以决定effect依赖于哪个状态。一旦在点击或其他effect中设置此状态,此effect将再次运行。在这种情况下,如果URL状态发生变化,effect将再次运行以从API获取数据。

React Hooks和loading

让我们为数据获取引入一个加载提示。它只是另一个由state hook管理的状态。loading被用于在组件中渲染一个loading提示。

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

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState(
    'http://hn.algolia.com/api/v1/search?query=redux',
  );
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);

      const result = await axios(url);

      setData(result.data);
      setIsLoading(false);
    };

    fetchData();
  }, [url]);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>

      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

export default App;
复制代码

一旦调用该effect进行数据获取(当组件mount或URL状态更改时发生),加载状态将设置为true。一旦请求完成,加载状态将再次设置为false。

React Hooks和错误处理

如果在React Hooks中加上错误处理呢,错误只是用state hook初始化的另一个状态。一旦出现错误状态,应用程序组件就可以为用户提供反馈。使用async/await时,通常使用try/catch块进行错误处理。你可以在effect内做到:

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

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState(
    'http://hn.algolia.com/api/v1/search?query=redux',
  );
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);

      try {
        const result = await axios(url);

        setData(result.data);
      } catch (error) {
        setIsError(true);
      }

      setIsLoading(false);
    };

    fetchData();
  }, [url]);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>

      {isError && <div>Something went wrong ...</div>}

      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

export default App;
复制代码

React在表单中获取数据

到目前为止,我们只有input和按钮的组合。一旦引入更多的输入元素,您可能需要用一个表单元素包装它们。此外,表单还可以通过键盘上的“enter”来触发。

function App() {
  ...

  return (
    <Fragment>
      <form
        onSubmit={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      {isError && <div>Something went wrong ...</div>}

      ...
    </Fragment>
  );
}
复制代码

但是现在浏览器在单击提交按钮时页面会重新加载,因为这是浏览器在提交表单时的固有行为。为了防止默认行为,我们可以通过event.preventDefault()取消默认行为。这也是在React类组件中实现的方法。

function App() {
  ...

  const doFetch = () => {
    setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
  };

  return (
    <Fragment>
      <form onSubmit={event => {
        doFetch();

        event.preventDefault();
      }}>
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      {isError && <div>Something went wrong ...</div>}

      ...
    </Fragment>
  );
}
复制代码

现在,当你单击提交按钮时,浏览器不会再重新加载。它和以前一样工作,但这次使用的是表单,而不是简单的input和按钮组合。你也可以按键盘上的“回车”键。

自定义数据获取hook

为了提取用于数据获取的自定义hook,请将属于数据获取的所有内容,移动到一个自己的函数中。还要确保能够返回App组件所需要的全部变量。

const useHackerNewsApi = () => {
  const [data, setData] = useState({ hits: [] });
  const [url, setUrl] = useState(
    'http://hn.algolia.com/api/v1/search?query=redux',
  );
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);

      try {
        const result = await axios(url);

        setData(result.data);
      } catch (error) {
        setIsError(true);
      }

      setIsLoading(false);
    };

    fetchData();
  }, [url]);

  const doFetch = () => {
    setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
  };

  return { data, isLoading, isError, doFetch };
}
复制代码

现在,你可以在App组件中使用新的hook了。

function App() {
  const [query, setQuery] = useState('redux');
  const { data, isLoading, isError, doFetch } = useHackerNewsApi();

  return (
    <Fragment>
      ...
    </Fragment>
  );
}
复制代码

接下来,从dofetch函数外部传递URL状态:

const useHackerNewsApi = () => {
  ...

  useEffect(
    ...
  );

  const doFetch = url => {
    setUrl(url);
  };

  return { data, isLoading, isError, doFetch };
};

function App() {
  const [query, setQuery] = useState('redux');
  const { data, isLoading, isError, doFetch } = useHackerNewsApi();

  return (
    <Fragment>
      <form
        onSubmit={event => {
          doFetch(
            `http://hn.algolia.com/api/v1/search?query=${query}`,
          );

          event.preventDefault();
        }}
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      ...
    </Fragment>
  );
}
复制代码

初始状态也可以变为通用状态。把它简单地传递给新的自定义hook:

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

const useDataApi = (initialUrl, initialData) => {
  const [data, setData] = useState(initialData);
  const [url, setUrl] = useState(initialUrl);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);

      try {
        const result = await axios(url);

        setData(result.data);
      } catch (error) {
        setIsError(true);
      }

      setIsLoading(false);
    };

    fetchData();
  }, [url]);

  const doFetch = url => {
    setUrl(url);
  };

  return { data, isLoading, isError, doFetch };
};

function App() {
  const [query, setQuery] = useState('redux');
  const { data, isLoading, isError, doFetch } = useDataApi(
    'http://hn.algolia.com/api/v1/search?query=redux',
    { hits: [] },
  );

  return (
    <Fragment>
      <form
        onSubmit={event => {
          doFetch(
            `http://hn.algolia.com/api/v1/search?query=${query}`,
          );

          event.preventDefault();
        }}
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      {isError && <div>Something went wrong ...</div>}

      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

export default App;
复制代码

这就是使用自定义hook获取数据的方法。hook本身对API一无所知。它从外部接收所有参数,只管理必要的状态,如数据、加载和错误状态。它执行请求并将数据作为自定义数据获取hook返回给组件。

Reducer的数据获取hook

reducer hook返回一个状态对象和一个改变状态对象的函数。dispatch函数接收type和可选的payload。所有这些信息都在实际的reducer函数中使用,从以前的状态、包含可选payload和type的action中提取新的状态。让我们看看这在代码中是如何工作的:

import React, {
  Fragment,
  useState,
  useEffect,
  useReducer,
} from 'react';
import axios from 'axios';

const dataFetchReducer = (state, action) => {
  ...
};

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });

  ...
};
复制代码

Reducer Hook接受reducer函数和一个初始化的状态对象作为参数,在我们的例子中,数据、加载和错误状态的初始状态的参数没有改变,但是它们被聚合到由一个reducer hook管理的一个状态对象,而不是单个state hook。

const dataFetchReducer = (state, action) => {
  ...
};

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });

  useEffect(() => {
    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });

      try {
        const result = await axios(url);

        dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
      } catch (error) {
        dispatch({ type: 'FETCH_FAILURE' });
      }
    };

    fetchData();
  }, [url]);

  ...
};
复制代码

现在,在获取数据时,可以使用dispatch向reducer函数发送信息。dispatch函数发送的对象包括一个必填的type属性和可选的payload。type告诉Reducer函数需要应用哪个状态转换,并且Reducer还可以使用payload来提取新状态。毕竟,我们只有三种状态转换:初始化获取过程,通知成功的数据获取结果,以及通知错误的数据获取结果。

在自定义hook的最后,状态像以前一样返回,但是因为我们有一个状态对象,而不再是独立状态,所以需要用扩展运算符返回state。这样,调用useDataApi自定义hook的用户仍然可以访问data、isloading和isError:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });

  ...

  const doFetch = url => {
    setUrl(url);
  };

  return { ...state, doFetch };
};
复制代码

最后,还缺少了reducer函数的实现。它需要处理三种不同的状态转换,即FETCH_INIT、FETCH_SUCCESS和FETCH_FAILURE。每个状态转换都需要返回一个新的状态对象。让我们看看如何用switch case语句实现这一点:

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return { ...state };
    case 'FETCH_SUCCESS':
      return { ...state };
    case 'FETCH_FAILURE':
      return { ...state };
    default:
      throw new Error();
  }
};
复制代码

reducer函数可以通过其参数访问当前状态和action。到目前为止,switch case语句中的每个状态转换只会返回原来的状态。...语句用于保持状态对象不变(意味着状态永远不会直接改变),现在,让我们重写一些当前状态返回的属性,以便在每次状态转换时更改状态:

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return {
        ...state,
        isLoading: true,
        isError: false
      };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        isLoading: false,
        isError: false,
        data: action.payload,
      };
    case 'FETCH_FAILURE':
      return {
        ...state,
        isLoading: false,
        isError: true,
      };
    default:
      throw new Error();
  }
};
复制代码

现在,每个状态转换(由操作的type决定)都将基于先前的状态和可选的payload返回一个新的状态。例如,在成功请求的情况下,payload用于设置新状态对象的数据。

总之,reducer hook确保状态管理的这一部分是用自己的逻辑封装的。通过提供type和可选payload,你将始终已一个可预测的状态结束。此外,你将永远不会进入无效状态。例如,以前可能会意外地将isloading和isError状态设置为true。在这个案例的用户界面中应该显示什么?现在,reducer函数定义的每个状态转换都会导致一个有效的状态对象。

在effect hook中禁止数据获取

即使组件已经卸载(例如,由于使用react路由器导航而离开),设置组件状态也是react中的一个常见问题。我以前在这里写过这个问题,它描述了如何防止在各种场景中为unmount的组件设置状态。让我们看看如何防止在自定义hook中为数据获取设置状态:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });

  useEffect(() => {
    let didCancel = false;

    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });

      try {
        const result = await axios(url);

        if (!didCancel) {
          dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' });
        }
      }
    };

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [url]);

  const doFetch = url => {
    setUrl(url);
  };

  return { ...state, doFetch };
};
复制代码

每个effect hook都有一个clean功能,在组件卸载时运行。clean函数是从hook返回的一个函数。在我们的例子中,我们使用一个名为didCancel的布尔标志,让我们的数据获取逻辑知道组件的状态(已装载/未装载)。如果组件已卸载,则标志应设置为“tree”,这将导致在最终异步解决数据提取后无法设置组件状态。

注意:事实上,数据获取不会中止——这可以通过axios的Cancellation实现——但是对于未安装的组件,状态转换会不再执行。因为在我看来,axios的Cancellation并不是最好的API,所以这个防止设置状态的布尔标志也能起到作用。


你已经了解了在React中state和effect hook如何用于获取数据。如果您对使用render props和高阶组件在类组件(和函数组件)中获取数据很感兴趣,请从一开始就去我的另一篇文章。否则,我希望本文对您了解react hook以及如何在现实场景中使用它们非常有用。

关注下面的标签,发现更多相似文章
评论