TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

2019-06-1019:05:44WEB前端开发Comments2,925 views字数 10950阅读模式

GraphQL 和 TypeScript 的使用都程爆发式增长,当两者与React结合使用时,它们可以为开发人员提供理想的开发体验。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

GraphQL 改变了我们对 API 的思考方式,并利用直观的键/值对匹配,客户端可以请求在网页或移动应用屏幕上显示所需的确切数据。 TypeScript 通过为变量添加静态类型来扩展 JavaScript,从而减少了错误并提高了代码的可读性。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

本文将引导你使用 React 和 Apollo 构建客户端应用程序,并调用 SpaceX 的公共 GraphQL API ,来显示有关的发射信息。我们将自动为查询生成 TypeScript 类型,并使用 React Hooks 执行这些查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

本文假设你对 React,GraphQL 和 TypeScript 有一定的了解,并且正在研究怎样通过把它们集成在一起来构建一个正常运行的程序。如果你需要补充一些基础知识的话,可以关注公众号“前端先锋”。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

如果你在学习的过程中遇到困难,可以参考源代码或查看 live app文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

为什么选择 GraphQL + TypeScript?

GraphQL API 需要强类型化,数据从单个端点提供。通过在此端点上调用 GET 请求,客户端可以接收后端的完全自我描述的数据,包括所有可用的数据和相应的类型。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

通过 GraphQL 代码生成器,我们可以扫描 Web 应用目录中的查询文件,并将它们与 GraphQL API 提供的信息进行匹配,这样以来就可以创建 TypeScript 类型所有请求数据。通过使用 GraphQL,我们可以自动且自由地输入我们的 React 组件的属性。这样可以减少产品上的错误并提高迭代速度。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

入门

我们将使用带有 TypeScript 配置的 create-react-app 来创建程序。首先执行以下命令:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

npx create-react-app graphql-typescript-react --typescript
// NOTE - you will need Node v8.10.0+ and NPM v5.2+
复制代码

通过使用 --typescript 标志,CRA 将为你生成项目文件和 .ts.tsx,它将创建一个 tsconfig.json 文件。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

切换到 app 目录:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

cd graphql-typescript-react
复制代码

现在安装附加依赖项。我们的程序用 Apollo 来执行 GraphQL API 请求。 Apollo 所需的库是 apollo-boostreact-apolloreact-apollo-hooksgraphql-taggraphql文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

apollo-boost 包含了查询 API 和在内存中缓存数据所需的工具, react-apollo 为React提供绑定, react-apollo-hooks 在 React Hook 中包装了 Apollo 查询, graphql-tag 用于构建我们的查询文档, graphql 是一个对等依赖项,它提供了 GraphQL 实现的细节。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

yarn add apollo-boost react-apollo react-apollo-hooks graphql-tag graphql
复制代码

graphql-code-generator 用于自动化 TypeScript 的工作流程。接下来安装 codegen CLI 来生成我们需要的配置和插件。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

yarn add -D @graphql-codegen/cli
复制代码

设置 codegen 配置执行以下命令:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

$(npm bin)/graphql-codegen init
复制代码

这将启动CLI向导,并执行以下步骤:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

  1. 要用 React 构建的程序。
  2. schema 位于 https://spacexdata.herokuapp.com/graphql
  3. 将你的操作和代码位置设置为 ./src/components/**/*.{ts,tsx} ,以便它能够搜索到所有的 TypeScript 文件以进行查询声明。
  4. 使用默认插件 “TypeScript”,“TypeScript Operations”,“TypeScript React Apollo”。
  5. 将生成的目标文件夹更新为 src/generated/graphql.tsx (react-apollo 插件需要 .tsx)。
  6. 不要生成 introspection file。
  7. 使用默认的 codegen.yml 文件。
  8. 制作你的运行脚本 codegen

在 CLI 中运行 yarn 命令安装 CLI 工具的插件并添加到 package.json文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

我们还将对 codegen.yml 文件进行一次更新,通过在其中添加 withHooks:true 配置选项来生成类型化的 React Hook 查询。你的配置文件应如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

overwrite: true
schema: 'https://spacexdata.herokuapp.com/graphql'
documents: './src/components/**/*.ts'
generates:
  src/generated/graphql.tsx:
    plugins:
      - 'typescript'
      - 'typescript-operations'
      - 'typescript-react-apollo'
    config:
      withHooks: true
复制代码

编写 GraphQL 查询并生成类型

GraphQL 最主要的好处是它利用声明性数据进行提取。我们能够编写与使用它们的组件并存的查询,并且 UI 能够准确地请求它要呈现的内容。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

在使用 REST API 时,我们所能找的的文档有可能不是最新的。如果 REST 出现什么问题,我们需要用 console.log 配合来调试数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

GraphQL 允许你通过访问 URL 查看完全定义的模式,并在 UI 中执行针对它的请求,从而解决了这个问题。现在访问 spacexdata.herokuapp.com/graphql 查看你将使用的确切数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

虽然我们可以获得大量的 SpaceX 数据,但我们只会显示有关发射任务的信息。我们有两个主要组成部分:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

  1. 用户可以通过单击“发射”任务列表来查看有关它们的更多信息。
  2. 单个发射任务的详细资料。

对于第一个组件,我们将查询 launchs 并请求 flight_numbermission_namelaunch_year。我们将在列表中显示这些数据,当用户点击其中一个项目时,查询 launch 来获取该火箭的更多数据。让我们在 GraphQL playground 中测试第一个查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

要编写我们的查询,首先需要创建一个 src/components 文件夹,然后创建 src/components/LaunchList 文件夹。在此文件夹中,创建 index.tsxLaunchList.tsxquery.tsstyles.css 文件。在 query.ts 文件中,可以从 playground 中发送查询并将其放在 gql 字符串中。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import gql from 'graphql-tag';

export const QUERY_LAUNCH_LIST = gql`
  query LaunchList {
    launches {
      flight_number
      mission_name
      launch_year
    }
  }
`;
复制代码

其他查询将根据 flight_number 获得更详细的单次发射数据。由于这将通过用户交互动态生成,所以需要用到 GraphQL 变量。我们还可以在 playground 上测试带变量的查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

在查询名的后面,你可以通过使用前缀为$及类型去指定变量,然后在查询体中,你可以使用该变量。对于我们的查询,通过传递 $id 变量来设置启动的id,该变量的类型为String!文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

我们传入 id 作为变量,它对应于 LaunchList 查询中的 flight_numberLaunchProfile 查询还会包含嵌套对象或类型,可以通过指定括号内的键来获取对应的值。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

例如,launch 内包含一个 rocket 定义(类型 LaunchRocket),其内部包含 rocket_namerocket_type。为了更好地理解可用于 LaunchRocket 的字段,你可以通过侧面的模式导航器来了解可用数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

现在将此查询转移到我们的程序。创建 src/components/LaunchProfile 文件夹及 index.tsxLaunchProfile.tsxquery.tsstyles.css 文件。在 query.ts 文件中,我们从 playground 上粘贴前面的查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import gql from 'graphql-tag';

export const QUERY_LAUNCH_PROFILE = gql`
  query LaunchProfile($id: String!) {
    launch(id: $id) {
      flight_number
      mission_name
      launch_year
      launch_success
      details
      launch_site {
        site_name
      }
      rocket {
        rocket_name
        rocket_type
      }
      links {
        flickr_images
      }
    }
  }
`;
复制代码

现在我们已经定义了查询,你终于可以生成 TypeScript 接口和类型的 Hook。在终端中执行:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

yarn codegen
复制代码

src/generated/graphql.ts 中,你将会找到定义程序所需的所有类型,以及获取 GraphQL 端点以检索该数据的相应查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

这个文件往往很大,但里面的信息非常有价值。我建议花点时间研究它,并理解我们的 codegen 基于 GraphQL 架构创建的所有类型。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

例如,检查 type Launch,这是我们在 playground 上与 GraphQL 交互的 Launch 对象的 TypeScript 表示。还可以滚动到文件的底部,查看专门为我们将要执行的查询生成的代码 —— 它创建了组件、HOC、类型化props或查询,还有类型化的 hook。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

初始化Apollo客户端

src/index.tsx 中,我们需要初始化 Apollo 客户端并用 ApolloProvider 组件将 client 添加到 React 的上下文中。另外还需要 ApolloProviderHooks 组件来启用 hook 中的上下文。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

我们初始化一个新的 ApolloClient 并给它 GraphQL API 的 URI,然后将 <App/> 组件包装在上下文提供程序中。你的索引文件应如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import React from 'react';
import ReactDOM from 'react-dom';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';
import { ApolloProvider as ApolloHooksProvider } from 'react-apollo-hooks';
import './index.css';
import App from './App';

const client = new ApolloClient({
  uri: 'https://spacexdata.herokuapp.com/graphql',
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <ApolloHooksProvider client={client}>
      <App />
    </ApolloHooksProvider>
  </ApolloProvider>,
  document.getElementById('root'),
);
复制代码

构建组件

现在我们已经具备了通过 Apollo 执行 GraphQL 查询所需的一切条件。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

src/components/LaunchList/index.tsx 中,创建一个使用生成的 useLaunchListQuery 钩子的函数组件。查询钩子返回 dataloadingerror 的值。我们将在容器组件中检查 loadingerror,并将 data 传递给表示组件。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

我们将用这个组件作为智能组件来保持关注点的分离,并且将数据传给只显示给定内容的表示组件。我们还将在等待数据时显示基本的加载和错误状态。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

你的容器组件应如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import * as React from 'react';
import { useLaunchListQuery } from '../../generated/graphql';
import LaunchList from './LaunchList';

const LaunchListContainer = () => {
  const { data, error, loading } = useLaunchListQuery();

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error || !data) {
    return <div>ERROR</div>;
  }

  return <LaunchList data={data} />;
};

export default LaunchListContainer;
复制代码

表示组件将用 data 对象来构建 UI。我们用 <ol> 创建一个有序列表,然后通过映射来显示 mission_namelaunch_year文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

src/components/LaunchList/LaunchList.tsx将如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import * as React from 'react';
import { LaunchListQuery } from '../../generated/graphql';
import './styles.css';

interface Props {
  data: LaunchListQuery;
}

const className = 'LaunchList';

const LaunchList: React.FC<Props> = ({ data }) => (
  <div className={className}>
    <h3>Launches</h3>
    <ol className={`${className}__list`}>
      {!!data.launches &&
        data.launches.map(
          (launch, i) =>
            !!launch && (
              <li key={i} className={`${className}__item`}>
                {launch.mission_name} ({launch.launch_year})
              </li>
            ),
        )}
    </ol>
  </div>
);

export default LaunchList;
复制代码

如果你使用的是 VS Code,IntelliSense 将向你显示可用的值,并提供自动完成列表,因为我们使用的是TypeScript。如果我们使用的数据是 nullundefined,它也会警告我们。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

TypeScript + GraphQL 开发一个SpaceX火箭发射数据查询系统

真是太棒了!编辑将帮助我们进行编码。此外,如果你需要一个类型或函数的定义,可以通过 Cmd + t 快捷键,或用鼠标悬停在它上面,这样会给出所有的细节。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

另外还需要添加一些 CSS 样式,它将显示我们的项目,并允许它们在列表高度不够时滚动。在 src/components/LaunchList/styles.css 里,添加以下代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

.LaunchList {
  height: 100vh;
  overflow: hidden auto;
  background-color: #ececec;
  width: 300px;
  padding-left: 20px;
  padding-right: 20px;
}

.LaunchList__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.LaunchList__item {
  padding-top: 20px;
  padding-bottom: 20px;
  border-top: 1px solid #919191;
  cursor: pointer;
}
复制代码

为了显示有关发射任务的更多详细信息,还要构建我们的 profile 组件。除了 Profile 查询和组件之外,该组件的代码与 index.tsx 文件大致相同。我们还将一个变量传递给 React 钩子,用于启动时的 id。现在先把它硬编码为42,然后在完成程序布局之后再添加动态功能。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

src/components/LaunchProfile/index.tsx 中添加以下代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import * as React from 'react';
import { useLaunchProfileQuery } from '../../generated/graphql';
import LaunchProfile from './LaunchProfile';

const LaunchProfileContainer = () => {
  const { data, error, loading } = useLaunchProfileQuery({ variables: { id: '42' } });

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>ERROR</div>;
  }

  if (!data) {
    return <div>Select a flight from the panel</div>;
  }

  return <LaunchProfile data={data} />;
};

export default LaunchProfileContainer;
复制代码

现在需要创建我们的演示组件。它将在 UI 顶部显示发射任务的名称和详细信息,然后在描述下方显示发射时的照片。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

src/components/LaunchProfile/LaunchProfile.tsx 组件如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import * as React from 'react';
import { LaunchProfileQuery } from '../../generated/graphql';
import './styles.css';

interface Props {
  data: LaunchProfileQuery;
}

const className = 'LaunchProfile';

const LaunchProfile: React.FC<Props> = ({ data }) => {
  if (!data.launch) {
    return <div>No launch available</div>;
  }

  return (
    <div className={className}>
      <div className={`${className}__status`}>
        <span>Flight {data.launch.flight_number}: </span>
        {data.launch.launch_success ? (
          <span className={`${className}__success`}>Success</span>
        ) : (
          <span className={`${className}__failed`}>Failed</span>
        )}
      </div>
      <h1 className={`${className}__title`}>
        {data.launch.mission_name}
        {data.launch.rocket &&
          ` (${data.launch.rocket.rocket_name} | ${data.launch.rocket.rocket_type})`}
      </h1>
      <p className={`${className}__description`}>{data.launch.details}</p>
      {!!data.launch.links && !!data.launch.links.flickr_images && (
        <div className={`${className}__image-list`}>
          {data.launch.links.flickr_images.map(image =>
            image ? <img src={image} className={`${className}__image`} key={image} /> : null,
          )}
        </div>
      )}
    </div>
  );
};

export default LaunchProfile;
复制代码

最后一步是用 CSS 设置这个组件的样式。将以下内容添加到 src/components/LaunchProfile/styles.css 文件中:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

.LaunchProfile {
  height: 100vh;
  max-height: 100%;
  width: calc(100vw - 300px);
  overflow: hidden auto;
  padding-left: 20px;
  padding-right: 20px;
}

.LaunchProfile__status {
  margin-top: 40px;
}

.LaunchProfile__title {
  margin-top: 0;
  margin-bottom: 4px;
}

.LaunchProfile__success {
  color: #2cb84b;
}

.LaunchProfile__failed {
  color: #ff695e;
}

.LaunchProfile__image-list {
  display: grid;
  grid-gap: 20px;
  grid-template-columns: repeat(2, 1fr);
  margin-top: 40px;
  padding-bottom: 100px;
}

.LaunchProfile__image {
  width: 100%;
}
复制代码

现在完成了组件的静态版本,可以在 UI 中查看它们。我们将在 src/App.tsx 文件中包含这些组件,并将 <App /> 转换为函数组件。用函数组件使其更加简单,并允许我们在添加单击功能时使用钩子。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

import React from 'react';
import LaunchList from './components/LaunchList';
import LaunchProfile from './components/LaunchProfile';

import './App.css';

const App = () => {
  return (
    <div className="App">
      <LaunchList />
      <LaunchProfile />
    </div>
  );
};

export default App;
复制代码

为了得到我们想要的样式,将 src/App.css 改为以下内容:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

.App {
  display: flex;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}
复制代码

在终端执行 yarn start,然后在你的浏览器中打开 http://localhost:3000,你应该看到自己程序最基本的版本!文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

添加用户交互

现在需要添加当用户点击面板中的项目时获取完整发射数据的功能。我们将在 App 组件中创建一个钩子来跟踪班次 ID 并将其传递给 LaunchProfile 组件以重新获取发射数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

src/App.tsx 中,我们将添加 useState 来维护和更新 ID 的状态。当用户从列表中进行选择时,我们还将使用名为 handleIdChangeuseCallback 作为点击 handler 来更新ID。我们需要将 id 传递给 LaunchProfile,然后将 handleIdChange 传递给 <LaunchList />文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

更新后的 <App/> 组件应如下所示:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

const App = () => {
  const [id, setId] = React.useState(42);
  const handleIdChange = React.useCallback(newId => {
    setId(newId);
  }, []);

  return (
    <div className="App">
      <LaunchList handleIdChange={handleIdChange} />
      <LaunchProfile id={id} />
    </div>
  );
};
复制代码

LaunchList.tsx 组件中,我们需要为 handleIdChange 创建一个类型并将其添加到 props 的解构中 。然后,在 <li> 班次项目中的 onClick 回调中执行该函数。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

export interface OwnProps {
  handleIdChange: (newId: number) => void;
}

interface Props extends OwnProps {
  data: LaunchListQuery;
}

// ...
const LaunchList: React.FC<Props> = ({ data, handleIdChange }) => (
  
// ...
<li
  key={i}
  className={`${className}__item`}
  onClick={() => handleIdChange(launch.flight_number!)}
>
复制代码

LaunchList/index.tsx 里面,一定要导入 OwnProps 声明来输入传递给容器组件的 props,然后将 props 传播到 <LaunchList data={data} {...props} />文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

最后一步是在 id 改变时 refetch 数据。在 LaunchList/index.tsx 文件中,我们将用 useEffect 来管理 React 生命周期,并在 id 更改时触发提取。以下是实现提取所需做的唯一更改:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

interface OwnProps {
  id: number;
}

const LaunchProfileContainer = ({ id }: OwnProps) => {
  const { data, error, loading, refetch } = useLaunchProfileQuery({
    variables: { id: String(id) },
  });
  React.useEffect(() => {
    refetch();
  }, [id]);
复制代码

由于我们已经把表示与数据分开,因此不需要对 <LaunchProfile /> 组件进行任何更新,只需更新 index.tsx 文件即可,这样在选定的 flight_number 更改时能够重新获取完整的发射数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

好了,如果你按照以上步骤进行操作,现在就应该有了一个功能齐全的 GraphQL 程序了。如果你对什么地方还不清楚,可以在源代码中找到一个可行的解决方案。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

总结

我们可以看到一旦配置好了程序之后,开发速度是非常快的。我们可以轻松构建数据驱动的 UI。 GraphQL 允许我们在组件中定义所需要的数据,并且可以无缝地将其用于组件中的 props。生成的 TypeScript 定义使我们编写的代码具有极高的稳定性。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

如果你希望深入了解该项目,接下来的步骤将是使用 API ​​中的其他字段添加分页和更多的数据关联。要对发射任务列表进行分页,你将获取当前列表的长度并将 offset 变量传递给 LaunchList 查询。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

我鼓励你更深入探索并编写自己的查询,以便巩固这些概念。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

作者:前端先锋
链接:https://juejin.im/post/5cfe29eae51d45105e021298
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html

文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/13558.html
  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/gcs/13558.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定