什么是网络请求封装的最佳实践

前言

众所周知,自从 web 端经历过刀耕火种的年代后,ajax 技术的出现给动态页面的大量普及踢上了临门一脚,前后端真正脱离了模板开始分离,属于 web 端自己的视图层框架也如雨后春笋般出现。在此其中,对于网络请求的封装成为了一个不大不小的、人人都要掌握的入门技能,毕竟对于互联网应用来说,数据的交互是实现各种业务的基础。

近期浏览了不少关于网络请求封装的文章,其中有很多精彩的观点,也不乏引起争议的地方;结合自身长期以来的实践经验,对网络请求的封装又有了新的想法。在此就以不同的需求层次为出发点,循序渐进地讨论,不同需求情况下需要对网络请求做怎样程度的封装。

axios.create 出发

axios想必不用多做介绍,我们直接以axios为例进行网络请求的封装;另外这里对于环境变量的配置与读取、相关构建配置全都基于vite,其他项目换种写法就行。以下人物、情节、需求均为虚构。

极简情况

组长张三:

封装一个网络请求服务,后端已经做了星号的跨域处理,我们的业务也比较头铁只有一个环境就是生产环境,接口不需要鉴权,不需要提示请求结果。

需求分析:

这和没封装也区别不大了。

封装过程:

创建文件/src/services/request.ts

ts 复制代码
import axios from 'axios';

const instance = axios.create({
  baseURL:'https://service.prod/api/',
  timeout: 120000
});

export default instance;

功能完成,开始使用:

ts 复制代码
import request from '@/services/request';

const getUser = async () => {  
  try {  
    const res = await request.get('/user/info?id=12345');  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

节目效果,请勿模仿。不要把域名、账号、密码等重要信息硬编码在源码里。

对请求与响应需要做些什么

组长李四:

我接替张三的工作,你把request服务改改,我们要加上鉴权,请求如果不成功也需要提示出来。对于业务逻辑层的错误,会通过响应体的code字段体现,200为成功,其余值表示失败;对于协议层的错误,会通过http status code体现;其中表示未鉴权的错误将在协议层返回,值为401

需求分析:

重点在于弄清楚什么是业务逻辑层的错误(指http status code200),以及协议层的错误(http协议的错误,http status code不为200)。

封装过程:

编辑文件/src/services/request.ts

ts 复制代码
// ...

// 判断业务逻辑层是否请求失败
const requestFailed = (response: any) => {
  const { code, message } = response['data'];
  if (code !== 200) {
    return [true, message];
  }

  return [false];
};

// 判断是否可以提示异常
const canTip = (request: any) => {
  return request.noErrorTip !== true;
};

// 提示异常
const tip = (msg: string) => {
  // 自行使用相应技术栈的提示组件
  ElMessage.error({
    message: msg,
    duration: 3000
  });
};

instance.interceptors.request.use((request: any) => {
  const hasToken: boolean = /* 自己判断 */;

  // 有token,且没有阻止发送token时,带上token
  if (hasToken && !request.noSendingToken) {
    const token: string = /* 自己判断 */;
    request.headers['Authorization'] = `Bearer ${token}`;
  }

  return request;
}, (err: any) => {
  return Promise.reject(err);
});

instance.interceptors.response.use((response: any) => {
  const [isFailed, msg] = requestFailed(response);

  // 业务逻辑层请求失败
  if (isFailed) {
    // 如果可以提示异常
    if (canTip(response.config)) {
      // 提示异常
      tip(msg);
    }
    return Promise.reject({ code: response.data.code, message: msg });
  }

  return response;
}, (err: any) => {
  // 请求不通
  if (!err.response) {
    return Promise.reject(err);
  }

  // 协议层请求失败
  const code = err.response.status;
  // 如果可以提示异常
  if (canTip(err.response.config)) {
    // 分情况提示异常
    switch (code) {
      case 401:
        tip('登录过期,请重新登录');
        break;
      // ...
      case 500:
        tip('内部服务器错误,请稍后再试');
        break;
      default:
        tip(err.message || '网络不佳,请刷新后重试');
        break;
    }
  }

  // 处理鉴权过期的问题
  if (code === 401) {
    // ...
  }

  return Promise.reject(err);
});

export default instance;

功能完成,使用方式无变化。

区分环境

组长王五:

李四回家去了,没有测试环境还真是不行,你把request服务改改,要加上对开发环境、测试环境、生产环境的支持。

需求分析:

无非就是请求的baseURL根据环境改变而改变,另外在开发模式下,baseURL有可能是任何环境(走代理)以方便调试问题;对于环境变量一般使用.env文件管理,再用应用层面的配置config来引用,应用逻辑层只应该接触config引用的配置,不建议直接使用环境变量。

封装过程:

新建目录/src/services/request,并将/src/services/request.ts移入,重命名为service.ts

编辑文件.env.dev, .env.test, .env.prod

shell 复制代码
# 此处以.env.dev为例,填写的是dev,以此类推
VITE_ENV=dev

VITE_DOMAIN_DEV=https://service.dev/
VITE_DOMAIN_TEST=https://service.test/
VITE_DOMAIN_PROD=https://service.prod/

编辑文件vite.config.ts

ts 复制代码
// ...
proxy: {
  '/dev': {
    target: env.VITE_DOMAIN_DEV,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/dev/, '')
  },
  '/test': {
    target: env.VITE_DOMAIN_TEST,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/test/, '')
  },
  '/prod': {
    target: env.VITE_DOMAIN_PROD,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/prod/, '')
  }
};
// ...

编辑文件/src/config/index.ts

ts 复制代码
const env = import.meta.env.VITE_ENV as keyof typeof configMap;

const baseConfig = {
  api: {
    prefix: 'api', // 接口前缀
    timeout: 120000, // 默认120秒超时
    commonHeaders: {
      version: '1.0'
    } // 公共请求头
  },
  env
};

const configMap = {
  dev: {
    domain: import.meta.env.VITE_DOMAIN_DEV
  },
  test: {
    domain: import.meta.env.VITE_DOMAIN_TEST
  },
  prod: {
    domain: import.meta.env.VITE_DOMAIN_PROD
  }
};

const config = { ...baseConfig, ...configMap[env] };

export default config;

新建文件/src/services/request/host.ts

ts 复制代码
import config from '@/config';

let baseUrl = '/';
if (import.meta.env.DEV) {
  // 开发模式下,始终走代理
  baseUrl = `/${config.env}/`;
} else {
  // 非开发模式直接使用域名
  baseUrl = config.domain;
}

export default baseUrl;

编辑文件/src/services/request/service.ts

ts 复制代码
// ...
import baseUrl from './host';
import config from '@/config';

const instance = axios.create({
  baseURL: `${baseUrl}${config.api.prefix}`,
  timeout: config.api.timeout,
  withCredentials: false,
  headers: config.api.commonHeaders
});
// ...

编辑文件package.json

json 复制代码
{
  "scripts": {
    "dev": "vite --mode dev",
    "test": "vite build --mode test",
    "prod": "vite build --mode prod"
  }
}

功能完成,使用方式无变化。但在非开发环境下,各环境都能自动使用各自的域名;在开发环境下,可以通过修改VITE_ENV的值快速切换环境以便调试。

按模块管理api

组长赵六:

王五被优化了,虽然现在request服务基本可用,但还是不太方便,比如对于同样的接口,每次都需要重新组织参数与填写url,最好能和后端的模块对齐,一类api封装一为一个文件,方便复用。

需求分析:

api目录管理接口,每个文件的名称与后端模块对应,此处以用户类接口为例。

封装过程:

新建目录/src/api

新建文件/src/api/user.ts

ts 复制代码
import request from '@/services/request';

const group = '/user';

export const getUserInfo = (params) =>
  request({
    url: `${group}/info`,
    method: 'get'.
    params
  });

功能完成,开始使用:

ts 复制代码
import { getUserInfo } from '@/api/user';

const getUser = async () => {  
  try {  
    const res = await getUserInfo({ id: 12345 });  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

处理请求地址本身包含的参数

组长孙七:

赵六身体不适回家调养,现在request服务几乎可以满足所有场景了,就是有一个场景使用还有问题------请求地址本身包含参数的时候,尤其是包含多个参数时,写起来就比较麻烦,而且有可能每个人的写法都不一样,最好能用一个统一的方式去处理。

需求分析:

处理的是类似/api/user/1/12345这种情况,最好能和data, params一样,直接传一个args对象就能解决问题。

封装过程:

新建目录/src/services/request/interceptors

新建文件/src/services/request/interceptors/url-args.ts

ts 复制代码
const urlArgsHandler = {
  request: {
    onFulfilled: (request) => {
      const { url, args } = request;
      // 检查request中是否有args属性,没有则跳过以下代码逻辑
      if (args) {
        const lostParams: string[] = [];
        // 使用String.prototype.replace和正则表达式进行匹配替换
        const replacedUrl = (url as string).replace(/\{([^}]+)\}/g, (res, arg: string) => {
          if (!args[arg]) {
            lostParams.push(arg);
          }
          return args[arg] as string;
        });
        // 如果url存在未替换的路径参数,则会直接报错
        if (lostParams.length) {
          return Promise.reject(new Error('在args中找不到对应的路径参数'));
        }
        return { ...request, url: replacedUrl };
      }
      return request;
    }
  }
};

export default urlArgsHandler;

编辑文件/src/services/request/service.ts

ts 复制代码
import urlArgs from './interceptors/url-args';

// ...

instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);

// ...

编辑文件/src/api/user.ts

ts 复制代码
// ...

export const getUserDetail = (args) =>
  request({
    url: `${group}/{agentId}/{uid}`,
    method: 'get',
    args
  });

功能完成,开始使用:

ts 复制代码
import { getUserDetail } from '@/api/user';

const getUser = async () => {  
  try {  
    const res = await getUserDetail({ agentId: 1, uid: 12345 });  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

多实例支持

组长周八:

由于需求调整,孙七不再负责这一块的工作内容,现在我们的另外两个子平台需要整合进这个工程中,对应的接口地址不一样,需要携带的请求头不一样,响应体的数据结构也不一样,所以request服务需要进行一个重构,以满足需求。

需求分析:

显然需要用面向对象的方式来解决,对于每一套接口,都有各自的实例。

封装过程:

新建文件/src/services/request/interceptors/format.ts,将/src/services/request/service.ts中的拦截器相关代码搬运过来

ts 复制代码
// ...

const formatHandler = {
  request: {
    onFulfilled: /* 搬运过来的onRequest */
    onRejected: /* 搬运过来的onRequestError */
  },
  response: {
    onFulfilled: /* 搬运过来的onResponse */
    onRejected: /* 搬运过来的onResponseError */
  }
};

export default formatHandler;

编辑文件/src/services/request/service.ts

ts 复制代码
import axios from 'axios';
import urlArgs from './interceptors/url-args';
import format from './interceptors/format';

class RequestService {
  protected instance;
  protected responseHandler;

  constructor(options: Object, responseHandler: Function, interceptors?: any[]) {
    this.instance = axios.create(options);
    this.responseHandler = responseHandler;

    // 实现路径参数替换
    this.instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);

    // 自定义拦截器
    if (interceptors?.length) {
      interceptors.forEach((interceptor) => {
        if (Reflect.has(interceptor, 'request')) {
          this.instance.interceptors.request.use(interceptor.request.onFulfilled, interceptor.request.onRejected ?? undefined);
        }
        if (Reflect.has(interceptor, 'response')) {
          this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.response.onRejected ?? undefined);
        }
      });
    }

    // 请求拦截
    this.instance.interceptors.request.use(format.request.onFulfilled, format.request.onRejected);

    // 响应拦截
    this.instance.interceptors.response.use(format.response.onFulfilled, format.response.onRejected);
  }

  async request(request) {
    const res = await this.instance.request(request);
    if (res) {
      return this.responseHandler(res);
    }
  };
}

export default RequestService;

新建文件/src/services/request/index.ts

ts 复制代码
import RequestService from './service';
import baseUrl from './host';
import config from '@/config';

const options = {
  baseURL: `${baseUrl}${config.api.prefix}`,
  timeout: config.api.timeout,
  withCredentials: false,
  headers: config.api.commonHeaders
};

const responseHandler = (response) => {
  const { data } = response;
  return data;
};

export const { request } = new RequestService(options, responseHandler);

编辑文件/src/api/user.ts

ts 复制代码
// 此时已经不是直接导入axios的实例,而是RequestService实例的request方法
import { request } from '@/services/request';

// ...

功能完成,使用方式无变化。只不过当需要新一套接口时,只需要在request服务的入口文件实例化并导出一个实例的request方法即可。当某一套接口需要特殊请求头时,实例化request服务时可以传入任意拦截器,通过自定义request参数控制是否生效。

统一处理catch

组长吴九:

这套封装还有说小不小的问题,比如完全依赖使用者用try/catch包裹调用代码,不然就有可能出现Unhandled promise rejection异常;即便在catch代码块中提前return,也无法终止后续在正常逻辑下才应该执行的代码。周八没有意识到这个问题,现在大家的业务代码都玩儿花了,希望你能尽早修改。

需求分析:

参考await-to-js库的思想。

封装过程:

编辑文件/src/services/request/service.ts

ts 复制代码
// ...

async request(request) {
  try {
    const res = await this.instance.request(request);
    return this.responseHandler(res);
  } catch (err: any) {
    return { err, data: null, response: null };
  }
};

编辑文件/src/services/request/index.ts

ts 复制代码
// ...

const responseHandler = (response) => {
  const { data } = response;
  return { err: null, data, response };
};

功能完成,开始使用:

ts 复制代码
import { getUserDetail } from '@/api/user';

const getUser = async () => {
  const { err, data } = await getUserDetail({ agentId: 1, uid: 12345 });
  if (err) {
    return;
  }

  // 正常读取data的代码逻辑
}

类型推导

组长郑十:

已经很长时间没有出问题了,我建议咱们做一次代码优化。这不是使上vue3了吗,但是咱们一直用的是AnyScript,我建议从request服务开始,把静态类型检查用上,各种类型体操都做起来。这样至少在写业务代码的时候,可以享受到类型提示的好处,重构的时候也下得去手。

需求分析:

封装过程:

新建文件/src/services/request/type.ts

ts 复制代码
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';

// 定义返回结果的数据类型
export interface AppResponse<T = any> {
  data: T | null;
  err: AxiosError | null;
  response: AxiosResponse<T> | null;
}

// 重新定义AppRequestConfig,在AxiosRequestConfig基础上再加args等数据
export interface AppRequestConfig extends AxiosRequestConfig {
  args?: Record<string, any>; // endpoint上的参数对象
  noErrorTip?: boolean; // 是否不提示报错信息
  noSendingToken?: boolean; // 是否不发送令牌
}

// 发起请求方法的类型
// 泛型设计:支持传入响应数据类型、request body数据类型、query string数据类型、url args数据类型
// 请求参数设计:根据传入的泛型情况,依次从AppRequestConfig类型中剔除data, params, args字段,加上泛型中对相应字段的定义,最后用可选值类型包裹
// 响应数据设计:最内层泛型为传入的响应数据类型
export interface CustomRequest {
  <Payload = any>(requestConfig?: Partial<AppRequestConfig>): Promise<AppResponse<Payload>>;

  <Payload, Data>(requestConfig: Partial<Omit<AppRequestConfig, 'data'>> & { data: Data }): Promise<AppResponse<Payload>>;

  <Payload, Data, Params>(
    requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params'>> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) & {
        params: Params;
      }
  ): Promise<AppResponse<Payload>>;

  <Payload, Data, Params, Args>(
    requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params' | 'args'>> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) &
      (Params extends undefined ? { params?: undefined } : { params: Params }) & {
        args: Args;
      }
  ): Promise<AppResponse<Payload>>;
}

编辑文件/src/services/request/service.ts

ts 复制代码
// ...
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, CustomRequest } from './type';

// ...

request: CustomRequest = async <T>(request?: Partial<AppRequestConfig>) => {
  try {
    const res: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(request);
    return this.responseHandler(res);
  } catch (err: any) {
    return { err, data: null, response: null };
  }
};

export default RequestService;

新建目录/src/types/api

新建文件/src/types/api/common.ts

ts 复制代码
export interface Limit {
  index: number;
  size: number;
}

interface Pagination extends Limit {
  total: number;
}

export interface Sort {
  direction?: number;
  field?: string;
}

export type SortAndLimit = Sort & Limit;

export interface ListResult<T> {
  items?: T[];
  pagination?: Pagination;
  sort?: Sort;
}

新建文件/src/types/api/user.ts

ts 复制代码
export interface User {
  id: number;
  name: string;
  mobile?: string;
}

编辑文件/src/api/user.ts

ts 复制代码
import { request } from '@/services/request';
import type { User } from '@/types/api/user';
import type { SortAndLimit, ListResult } from '@/types/api/common';

// ...

export const getUserList = (params: SortAndLimit) =>
  request<ListResult<User>, undefined, SortAndLimit>({
    url: `${group}/list`,
    method: 'get',
    params
  });

功能完成,开始使用:

ts 复制代码
import { getUserList } from '@/api/user';

const getUsers = async () => {
  const { err, data } = await getUserList({
    index: 1,
    size: 20
  });
  if (err) {
    return;
  }

  // 正常读取data的代码逻辑
}

融入下载逻辑

老板:

业务新增了下载功能,需要加上。

需求分析:

下载接口如果和常规接口是同一套的话,一般鉴权方式、公共请求头什么的也都一样,无非是返回的响应类型不同;因此可以把下载和接口请求都视为某种功能类,他们有各自的处理信息方法,前者返回文件流,后者返回解析并格式化后的JSON数据;同时,他们也基于某一个基类,去鉴权、去设定请求头、去处理响应等等。另外,上传文件就不考虑了,一般情况把文件上传到第三方存储时是可以不通过自家后端中转的,也就是前端直接上传,因此不值得封到一起。

封装过程:

新建目录/src/services/request/core,并将/src/services/request/service.ts移入,重命名为base-service.ts

编辑文件/src/services/request/core/base-service.ts

ts 复制代码
import axios from 'axios';
import urlArgs from '../interceptors/url-args';
import format from '../interceptors/format';

class BaseService {
  protected instance;
  protected responseHandler;

  constructor(options: Object, responseHandler: Function, interceptors?: any[]) {
    this.instance = axios.create(options);
    this.responseHandler = responseHandler;

    // 实现路径参数替换
    this.instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);

    // 自定义拦截器
    if (interceptors?.length) {
      interceptors.forEach((interceptor) => {
        if (Reflect.has(interceptor, 'request')) {
          this.instance.interceptors.request.use(interceptor.request.onFulfilled, interceptor.request.onRejected ?? undefined);
        }
        if (Reflect.has(interceptor, 'response')) {
          this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.response.onRejected ?? undefined);
        }
      });
    }

    // 请求拦截
    this.instance.interceptors.request.use(format.request.onFulfilled, format.request.onRejected);

    // 响应拦截
    this.instance.interceptors.response.use(format.response.onFulfilled, format.response.onRejected);
  }
}

export default BaseService;

新建文件/src/services/request/core/http-service.ts

ts 复制代码
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, CustomRequest } from '../type';
import BaseService from './base-service';

class HttpService extends BaseService {
  request: CustomRequest = async <T>(request?: Partial<AppRequestConfig>) => {
    try {
      const res: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(request);
      return this.responseHandler(res);
    } catch (err: any) {
      return { err, data: null, response: null };
    }
  };
}

export default HttpService;

新建文件/src/services/request/core/download-service.ts

ts 复制代码
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig } from '../type';
import BaseService from './base-service';

class DownloadService extends BaseService {
  download = async (request?: Partial<AppRequestConfig>) => {
    try {
      const res: AxiosResponse<Blob, AppRequestConfig> = await this.instance.request<Blob>(request);
      return this.responseHandler(res, request.extraInfo ?? {} as any);
    } catch (err: any) {
      return { err, data: null, response: null };
    }
  };
}

export default DownloadService;

编辑文件/src/services/request/index.ts

ts 复制代码
import HttpService from './core/http-service';
import DownloadService from './core/download-service';
import baseUrl from './host';
import config from '@/config';

const httpOptions = {
  baseURL: `${baseUrl}${config.api.prefix}`,
  timeout: config.api.timeout,
  withCredentials: false,
  headers: config.api.commonHeaders
};

const httpResponseHandler = (response) => {
  const { data } = response;
  return { err: null, data, response };
};

const downloadOptions = {
  ...httpOptions,
  responseType: 'blob'
};

const downloadResponseHandler = (response, extraInfo: any) => {
  const { data } = response;

  let fileName = '';
  if (extraInfo && extraInfo.fileName) {
    fileName = extraInfo.fileName;
  } else {
    fileName = response.headers['content-disposition']
      ? decodeURI(
        response.headers['content-disposition'].split(';')[1].split('=')[1]
      )
      : '';
  }

  try {
    const type = response.headers['content-type'];
    let blob: Blob;
    if (type) {
      blob = new Blob([data], {
        type
      });
    } else {
      blob = new Blob([data]);
    }

    if ('msSaveBlob' in window.navigator) {
      (window.navigator as any).msSaveBlob(blob);
    } else {
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.setAttribute('download', fileName);
      link.style.display = 'none';
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      // 释放内存
      window.URL.revokeObjectURL(link.href);
    }
  } catch (err) {
    console.error(err);
  }
};

export const { http } = new HttpService(httpOptions, httpResponseHandler);
export const { download } = new DownloadService(downloadOptions, downloadResponseHandler);

编辑文件/src/api/user.ts

ts 复制代码
// 注意此时的RequestService实例导出的是http方法
import { http } from '@/services/request';

// ...

功能完成。

后记

对网络请求的封装,做到这个程度应该已经是极致(毕竟全组只剩萧十一了);再往后应该可以往swagger自动生成api模型方向考虑,本文不做讨论。

相关推荐
多多*1 分钟前
Spring之Bean的初始化 Bean的生命周期 全站式解析
java·开发语言·前端·数据库·后端·spring·servlet
linweidong6 分钟前
在企业级应用中,你如何构建一个全面的前端测试策略,包括单元测试、集成测试、端到端测试
前端·selenium·单元测试·集成测试·前端面试·mocha·前端面经
满怀101526 分钟前
【HTML 全栈进阶】从语义化到现代 Web 开发实战
前端·html
东锋1.337 分钟前
前端动画库 Anime.js 的V4 版本,兼容 Vue、React
前端·javascript·vue.js
满怀10151 小时前
【Flask全栈开发指南】从零构建企业级Web应用
前端·python·flask·后端开发·全栈开发
小杨升级打怪中1 小时前
前端面经-webpack篇--定义、配置、构建流程、 Loader、Tree Shaking、懒加载与预加载、代码分割、 Plugin 机制
前端·webpack·node.js
Yvonne爱编码2 小时前
CSS- 4.4 固定定位(fixed)& 咖啡售卖官网实例
前端·css·html·状态模式·hbuilder
SuperherRo2 小时前
Web开发-JavaEE应用&SpringBoot栈&SnakeYaml反序列化链&JAR&WAR&构建打包
前端·java-ee·jar·反序列化·war·snakeyaml
大帅不是我2 小时前
Python多进程编程执行任务
java·前端·python
前端怎么个事2 小时前
框架的源码理解——V3中的ref和reactive
前端·javascript·vue.js