从零开始搭建一个私有前端组件库

前言

本文的代码部分基于vue2 + ts ,最终的文件目录如下

一、让vue能够正确use组件

1.1 准备工作

首先用cli,根据自己的需求创建一个项目,然后根据自己习惯或团队的规范可以做一些项目初始化配置,比如husky和eslint等等。

第一步:把src文件夹重命名为examples,然后修改vue.config.js文件,把构建入口改为examples路径下,改完执行下serve命令,看看开发环境还能不能正常启动 第二步:创建packages文件夹,存放组件文件 第三步:创建src文件夹,然后在该文件夹下创建index.ts文件,该文件为组件库打包的入口文件。

1.2 简单介绍下vue是怎么把外部组件加载进来的

还记得我们用elementui的时候,需要在main.ts文件中使用下面的代码吗

typescript 复制代码
// 整体引入
Vue.use(ElementUI);
// 部分引入
Vue.use(Pagination);

vue的 官方文档 是这么写的

如果我们想让插件可以正确被vue.use加载,插件就必须暴露一个install方法,在使用时,通过调用Vue.use(插件),然后插件的install方法就会被调用,我们就可以拿到传入的Vue实例,再然后就可以对这个Vue实例做各种骚操作,实现各种功能。

想更深入了解的推荐去网上搜一下Vue.use和install的实现,有很多优秀的博客。

1.3 单个导入

准备步骤和知识铺垫完成后,可以来搞组件了,我的组件文件目录是这样的 如果你不喜欢像我这样把文件拆分开,可以按正常的写法,src文件放一个.vue文件和单元测试文件就够了,另外单元测试文件你可以也拿出来按默认的放到单独的tests文件夹内。

index文件是必须的,注意层级,index文件跟src文件夹属于同级。

组件没什么好讲的,就正常组件的写法,关键点在于index.ts文件,还记得我们上面说的必须要暴露一个install方法吗

typescript 复制代码
import OsPagination from './src/os-pagination.vue';

(OsPagination as any).install = (Vue: any): void => {
  Vue.component((OsPagination as any).extendOptions.name, OsPagination);
};
export default OsPagination;

这里大量用了any类型,是因为我偷懒了,懒得补全Vue的类型...

踩坑点: 这里有个坑,如果你的组件库是用 class component 的写法,这里就必须用extendOptions.name 来指定组件的标签名,不然该组件就无法被使用,控制台直接报这个标签没有被注册(直接.name拿到的是undefined,导致组件没有被注册进去),正常使用vue的同学直接.name即可。

typescript 复制代码
 Vue.component((OsPagination as any).extendOptions.name, OsPagination);

现在我们通过为组件暴露install方法,达到了让vue能正常使用我们封装的组件的成果。

接着找到我们之前新建的src文件夹下的index文件,添加如下代码

typescript 复制代码
import OsPagination from '../packages/os-pagination';
export {
  OsPagination,
};

组件使用方式: 在你要使用该组件的项目中

typescript 复制代码
// main.ts 文件
import { OsPagination } from 'os-ui';
Vue.use(OsPagination);

目前的暴露方式是单独为每个组件提供一份暴露install的代码,使用的时候也是一个一个通过Vue.use来使用的。

1.4 整体导入

下面来介绍一下统一暴露,使用时整体引入的实现方式

修改 src/index.ts 文件,我们循环全局注册组件即可

typescript 复制代码
import OsPagination from '../packages/os-pagination';

// 存储组件列表 
const components = [OsPagination];

// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,则所有的组件都将被注册
const install: any = function (Vue: any, opts: any): void {
  // 判断是否安装
  if (install.installed) return;
  // 遍历注册全局组件
  components.map((component: any) => Vue.component(component.extendOptions.name, component));
};
// 判断是否是直接引入文件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

export {
  install as OSUI,
  // 以下是单个导出的组件
  OsPagination
};

使用方式

typescript 复制代码
import { OSUI } from 'os-ui';
Vue.use(OSUI);

二、打包

2.1 使用cli提供的打包类库命令

官方文档 是这么写的 所以我们直接根据官方说明在package.json的scripts里,加一条构建库的命令

typescript 复制代码
 "lib": "vue-cli-service build --target lib --name os-ui ./src/index.ts",

然后执行yarn lib或者npm run lib即可。

用这种方式打包的优势在于省心省力,因为vue cli内部已经针对构建lib的webpack做好了配置,属于傻瓜式操作。

但对应缺陷就是无法加入自己的需求,cli并没有像构建应用那样,为我们打包提供针对构建类库的链式操作,这也就意味着我们不能加入自己的打包需求或者针对打包做优化。

比如我需要打esm的包,我需要组件库按需引入,vue cli并不能支持。这个在后面按需引入的那一节会再谈到。

2.2 使用rollup打包

具体可以参考这篇文章 选择rollup,主要原因是 更小支持输出esm支持tree-shaking,下面是我的rollup配置文件

typescript 复制代码
// rollup.config.js
import { terser } from 'rollup-plugin-terser';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import vue from 'rollup-plugin-vue';
import scss from 'rollup-plugin-scss';
import babel from 'rollup-plugin-babel';
import typescript from 'rollup-plugin-typescript2';
import commonjs from '@rollup/plugin-commonjs';
import replace from 'rollup-plugin-replace';

export default {
  input: 'src/index.ts',
  // external: ['vue', 'lodash-es', './lang/zh'],
  external: ['vue', 'lodash-es'],
  output: [
    {
      file: 'dist/os-ui.esm.js',
      format: 'esm',
    },
    {
      file: 'dist/os-ui.umd.js',
      format: 'umd',
      name: 'os-ui',
      globals: {
        vue: 'Vue',
        'lodash-es': 'lodashEs',
      },
    },
  ],
  plugins: [
    nodeResolve({
      extensions: ['.js', '.ts'],
    }),
    commonjs(),
    replace({
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),
    typescript(),
    vue(),
    babel({
      extensions: ['.vue', '.ts', '.js', '.tsx', '.jsx'],
      exclude: 'node_modules/**',
    }),
    scss(),
    terser(),
  ],
};

配置文件中输出esm和umd两种模块文件,相当于在一个包内同时发布了两种模块规范的版本。具体使用哪一种,交由使用组件库的应用打包时自动判断,我们只需要在package.json文件中配置main 和 module属性即可。 当打包工具遇到我们的模块时:

  • 如果它已经支持 package.module 字段则会优先使用 es6 模块规范的版本,这样可以启用 tree-shaking 机制。
  • 如果它还不识别 package.module 字段则会使用我们已经编译成 common js 规范的版本,也不会阻碍打包流程。 具体的使用优先级可以参考这篇文章

三、如何为组件库提供一份类型文件

3.1 编写类型文件

一个好的组件库,必然不能缺少一份类型文件,详细的类型文件,可以帮助使用者快速上手,参考element ui 的类型文件

在根目录新建一个types文件夹,下面新建一个对应组件文件名的文件,但是文件的后缀名为d.ts(d.ts结尾的文件,会被认为是类型文件,在ts编译时会被排除在外),以文中的os-pagination为例,新建文件os-pagination.d.ts。 下面是我的类型文件,类型定义中包含了一些public 属性,除此之外,如果组件内需要暴露一些方法供外部使用,也需要在类型定义中体现出来,详细的可以参考element ui

typescript 复制代码
export interface Paging {
  currentPage: number;
  showCount: number;
}

export declare class OsPagination {
  /**
   *  Prop 分页对象
   *  required
   */
  public paging: Paging;

  /**
   *  Prop 数据总条数
   *  required
   */
  public total: number;

  /**
   * Prop 分页尺码可选项配置
   * 默认为 [50, 100, 200],如果paging.showCount不为该配置的首位,paging.showCount会自动加入到该数组的头部
   */
  public pageSizeOption: Array<number>;
}

3.2 让编辑器可以识别类型文件,提供引用提示

现在类型文件有了,我们怎么才能在实际项目中使用呢

  1. 首先在types文件夹下新建index.d.ts文件,统一导出类型
typescript 复制代码
// index.d.ts
import OsPagination from './os-pagination';
import OsTable from './os-table';
....
export { OsPagination, OsTable ...};
  1. 然后修改package.json文件的files字段和typings字段,
  • files支持配置一个数组,作用是指定发布包时,要包含哪些文件,这里我们选择包含disttypes文件夹。

  • typings的作用是指定包的类型文件,当别人install了你的包之后,编辑器会根据typings指定的类型文件,提供智能类型提示。

四、使用verdaccio搭建npm私服,发布组件库

4.1 思路

我们的组件库或者其他公共的包可能包含一些业务信息,所以组件库肯定不能放到npm仓库,那么就要搭建一个私服。 搭建npm私服的方案有挺多的,比如nexus或者sinopia(仓库已经很多年不维护了)等等,不过我最终选择了verdaccio,这里是verdacio官方文档

4.2 安装和配置

1、首先准备一台服务器,安装好node 2、安装

powershell 复制代码
npm install -g verdaccio

或者

powershell 复制代码
yarn global add verdaccio

3、执行verdaccio,启动服务

powershell 复制代码
verdaccio

这里要注意第一行打印的信息,这个yaml文件就是verdaccio的配置文件,后面我们需要修改该文件进行相关配置。

powershell 复制代码
 warn --- config file  - /root/.config/verdaccio/config.yaml

另外建议在服务器装一个pm2(进程守护),使用pm2启动verdaccio,或者使用其他进程守护方案

4、启动成功后,直接在浏览器输入服务器的ip地址 + 最后一行打印出的端口号,看到这样的页面就说明安装成功了。如果访问不了可能是因为服务器防火墙没关,可以检查下防火墙。

5、修改配置文件,如果是linux服务器,直接执行

powershell 复制代码
vi /root/.config/verdaccio/config.yaml

vi 后面的地址就是启动verdaccio服务时,第一行打印出的地址,下面是具体的配置文件内容,verdaccio的作者对配置文件做了很详细的注释

yaml 复制代码
#
# This is the default config file. It allows all users to do anything,
# so don't use it on production systems.
#
# Look here for more config file examples:
# https://github.com/verdaccio/verdaccio/tree/master/conf
#

# path to a directory with all packages
storage: ./storage
# path to a directory with plugins to include
plugins: ./plugins
# 是否开启检索功能
search: true

web:
  # 私有仓库的标题
  title: Os-Component
  # comment out to disable gravatar support
  # gravatar: true
  # by default packages are ordercer ascendant (asc|desc)
  # sort_packages: asc
  # convert your UI to the dark side
  # darkMode: true
  # logo: http://somedomain/somelogo.png
  # favicon: http://somedomain/favicon.ico | /path/favicon.ico

# translate your registry, api i18n not available yet
# i18n:
# list of the available translations https://github.com/verdaccio/ui/tree/master/i18n/translations
# web: zh-CN

auth:
  htpasswd:
    file: ./htpasswd
    # 允许注册的用户最大数量, 默认值是 "+inf",即不限制
    # 可以将此值设置为-1 以禁用新用户注册。此时npm adduser被禁用
    max_users: -1

# 如果你要安装的包不在该私有库中,会自动去url配置的地址中查找
uplinks:
  npmjs:
    url: https://registry.npmjs.org/

packages:
  '@*/*':
    # scoped packages
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

  '**':
    # 设为$all为允许所有用户(包括未经身份验证的用户)访问和发布包
    # 您可以指定用户名/组名(取决于您的身份验证插件)
    # 和三个关键字:"$all"、"$anonymous"、"$authenticated"
    # $all 表示不限制,任何人可访问;$anonymous 表示未经认证的可访问(其实等同于all);$authenticated 表示只有经过认证的用户可访问
    access: $all

    # 设置允许发包的权限
    publish: $authenticated
    # 设置删除包的权限
    unpublish: $authenticated

    # if package is not available locally, proxy requests to 'npmjs' registry
    proxy: npmjs

# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections.
# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout.
# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough.
server:
  keepAliveTimeout: 60
# 服务启动时的端口配置
listen: 0.0.0.0:8080
middlewares:
  audit:
    enabled: true

# log settings
logs: { type: stdout, format: pretty, level: http }

#experiments:
#  # support for npm token command
#  token: false
#  # disable writing body size to logs, read more on ticket 1912
#  bytesin_off: false
#  # enable tarball URL redirect for hosting tarball with a different server, the tarball_url_redirect can be a template string
#  tarball_url_redirect: 'https://mycdn.com/verdaccio/${packageName}/${filename}'
#  # the tarball_url_redirect can be a function, takes packageName and filename and returns the url, when working with a js configuration file
#  tarball_url_redirect(packageName, filename) {
#    const signedUrl = // generate a signed url
#    return signedUrl;
#  }

# This affect the web and api (not developed yet)
#i18n:
#web: en-US

6、关于权限配置 建议把 max_users 设为 -1,禁止注册用户,一般情况下都应该由管理员派发账号,禁用后再添加用户会直接报错

禁用后我们可以通过这个网站 添加账号密码,然后分发给对应的人 把生成的密码复制出来,添加到htpasswd文件中即可 verdaccio 还支持和其他更多的权限配置方式,有需求的可以去网上找下资料。

4.3 发布

1、切换npm源到我们私有的仓库地址

你可能会好奇,我切换了源之后,要装其他包怎么办,私有仓库又没有这些包!其实不是这样的,前面的verdaccio配置文件里有一个配置项,可以配置一个或多个其他的源。当我们私有仓库里找不到包时,会自动去配置的其他源里面找。

推荐安装nrm,管理npm registry,

powershell 复制代码
npm install -g nrm

// 添加自定义的源 源就是启动verdaccio时打印出来的地址
nrm add os-ui http://xxxx:8080/

// 查看所有可用的源
nrm ls 

// 切换源到我们的私有仓库
nrm use os-ui

2、package.json文件

前面的步骤已经介绍了关于package.json需要修改的地方,这里贴一下我的package.json文件,mainmodule一定不能配置错,不然会导致组件库安装后无法使用。

typescript 复制代码
  "name": "这里是组件库的名字",
  "version": "0.1.30",
  "private": false,
  "description": "这里写你组件库的描述",
  // umd入口
  "main": "./dist/os-ui.umd.js",
  // esm入口
  "module": "./dist/os-ui.esm.js",
  // 关键字,方便搜索
  "keywords": [
    "vue",
    "typescript",
    "os-ui",
    "osui",
    "element-ui"
  ],
  // 发布时包含哪些文件夹
  "files": [
    "dist",
    "types",
    "src/local"
  ],
  // 类型文件
  "typings": "./types/index.d.ts",
  // 作者
  "author": "james",

3、登录到我们的私有仓库,发布包

一定要先用nrm,把源切换到我们的私有仓库

powershell 复制代码
// 执行完login后,输入之前加入到htpasswd文件内的账户登录
npm login  

// 更新版本号
npm version patch

// 发布包
npm publish

发布成功后,再访问私有仓库地址,就会发现自己的包已经发上去了。

4、新建一个用于测试的项目,就像安装elementui那样,直接npm install 你的组件库,就可以安装使用了。

5、具体使用方式:

typescript 复制代码
// main.ts
import { OsPagination, OSUI } from 'os-ui';
// 按需引入
Vue.use(OsPagination);
// 整体引入
Vue.use(OSUI);

五、组件库国际化问题

5.1 前言

关于组件库国际化,有下面几点要思考的问题

  1. 组件库的国际化方案和实际项目中的方案可能不同 ,比如组件库为了更轻,自己简单封装了一份国际化实现,而项目则选择了vue-i18n,或者其他国际化插件、或者干脆也自己封装了一份实现,那怎么才能保证在语言环境切换时,项目和组件库的文案都能及时响应变更呢。
  2. 有时组件库内的组件,会接收实际项目使用时传进来的国际化文案key,在组件库内做翻译的情况,比如我封装的基于el-table的 table组件。这时组件库内又没有你项目里那份国际化文件,肯定会导致文案翻译失败的。

5.2 实现思路

5.2.1

上面那两个问题,对于第一个,我可以让组件库暴露一个方法,接收一个语言环境的参数,去动态改变组件库当前的语言环境。在实际项目中,在切换语言后,动态调用这个方法。

对于第二个问题,必须要做到合并组件库和项目两者的语言文件,在解决第一个问题的基础上,我们在捕捉到语言环境变更时,把本地项目的语言文件传入到组件库中去,在组件库中合并文件。

5.2.2

在有了思路之后我突然又想到可以再去扒一下饿了么ui的实现,看看有没有更好的实现方式。 看了饿了么ui源码后,我发现思路有相似之处,但又不完全一致。饿了么ui是这么玩的:

你可以使用任何i18n插件,只需要传入项目本地使用插件具体的翻译方法即可,这个方法传入进去之后,会替换掉饿了么ui默认的国际化实现方法,从而达到统一国际化的目的(除此之外你还需要手动把饿了么ui内部的翻译文件与项目中的合并一下。)

除此之外你也可以使用vue-i18n,这里他不是暴露一个方法去修改语言环境,他是通过vue.config.lang去设置的,然后把组件库本身的语言文件与项目的合并即可,他直接用了vue-i18n提供的方法去实现。 当前你项目中如果不需要进行国际化,也不影响,饿了么ui自身也实现了一套国际化方案,他默认语言环境中文,你想用英文直接设置一下即可

下面是饿了么ui的部分源码

综上所述,我最终选择抄一下饿了么ui的解决方案,毕竟有源码可搬,不搬白不搬

5.3 具体实现

在src文件夹下新建这些文件 lang文件夹下是文案的翻译文件,没什么好说的,形如下图 src / local / format.ts 该文件是组件库本身国际化方案的核心实现

typescript 复制代码
import { hasOwn } from '../utils';

const RE_NARGS = /(%|)\{([0-9a-zA-Z_]+)\}/g;
/**
 *  String format template
 *  - Inspired:
 *    https://github.com/Matt-Esch/string-template/index.js
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function (Vue: any): any {
  /**
   * template
   *
   * @param {String} string
   * @param {Array} ...args
   * @return {String}
   */

  function template(string: string, ...args: any): any {
    if (args.length === 1 && typeof args[0] === 'object') {
      // eslint-disable-next-line no-param-reassign
      args = args[0];
    }

    if (!args || !args.hasOwnProperty) {
      // eslint-disable-next-line no-param-reassign
      args = {};
    }

    // eslint-disable-next-line max-params
    return string.replace(RE_NARGS, (match, prefix, i, index) => {
      let result;

      if (string[index - 1] === '{' && string[index + match.length] === '}') {
        return i;
      } else {
        result = hasOwn(args, i) ? args[i] : null;
        if (result === null || result === undefined) {
          return '';
        }

        return result;
      }
    });
  }

  return template;
}


// 关于hasOwn方法
export function hasOwn(obj: any, key: any): any {
  const hasOwnProperty = Object.prototype.hasOwnProperty;
  return hasOwnProperty.call(obj, key);
}

src / local / index.ts 对外暴露一些方法

typescript 复制代码
import defaultLang from './lang/zh';
import Vue from 'vue';
import deepmerge from 'deepmerge';
import Format from './format';

const format = Format(Vue);
let lang: { [P: string]: any } = defaultLang;
let merged = false;

/**
 * i18n适配器
 */
let i18nHandler = function (this: any): string | undefined {
  // 如果存在 vue-i18n 组件,那么就将本ui组件的多语言文件合并到 i18n 组件对应的语言文件中,这样子后面要用本ui组件的词条的时候,就直接调用 i18n 组件的方法就行了
  const vuei18n = Object.getPrototypeOf(this || Vue).$t;
  // 有 Vue.locale 这个方法,那么就直接内置兼容
  // 其实就是用他原有的 lang 对象再跟 os-ui 的 lang 对象进行覆盖合并
  if (typeof vuei18n === 'function' && !!Vue.locale) {
    if (!merged) {
      merged = true;
      Vue.locale(Vue.config.lang, deepmerge(lang, Vue.locale(Vue.config.lang) || {}, { clone: true }));
    }
    return vuei18n.apply(this, arguments);
  }
  return undefined;
};

/**
 * 对外暴露的翻译服务方法
 */
export const t = function (this: any, path: string, options: any): string {
  // 先从 i18n 里面找,找不到再从 本ui组件的语言文件中查找
  let value = i18nHandler.apply<any, any, any>(this, arguments);
  if (value !== null && value !== undefined) return value;

  const array = path.split('.');
  let current: any = lang;

  for (let i = 0, j = array.length; i < j; i++) {
    const property = array[i];
    value = current[property];
    if (i === j - 1) return format(value, options);
    if (!value) return '';
    current = value;
  }
  return '';
};

/**
 * 设置默认语言的方法
 */
export const use = function (l: any): void {
  lang = l || lang;
};

/**
 * 兼容其他 i18n 插件,替换默认的翻译实现
 */
export const i18n = function (fn: () => string): void {
  i18nHandler = fn || i18nHandler;
};

export default { use, t, i18n };

src / mixins / local.ts 这里选择使用mixin,方便组件使用

typescript 复制代码
import { Vue, Component } from 'vue-property-decorator';
import { t } from '../local';

@Component
export class I18nMixin extends Vue {
  public t(...args: any): string {
    return t.apply(this, args);
  }
}

src / index.ts 需要修改下入口文件

typescript 复制代码
import OsPagination from '../packages/os-pagination';
import localeUtil from './local/index';

// 存储组件列表
const components = [OsPagination];

// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,则所有的组件都将被注册
const install: any = function (Vue: any, opts: any): void {
  // 添加语言参数,让其初始化组件的时候,可以传进去其他的语言
  localeUtil.use(opts.locale);
  localeUtil.i18n(opts.i18n);
  // 判断是否安装
  if (install.installed) return;
  // 遍历注册全局组件
  components.map((component: any) => Vue.component(component.extendOptions.name, component));
};

const i18n = localeUtil.i18n;

export {
  install as OSUI,
  localeUtil,
  i18n,
  // 导出的对象必须具有 install,才能被 Vue.use() 方法安装
  // 以下是单个导出的组件
  OsPagination,
};

发布组件库的时候还需要把local文件包含在里面,修改package.json文件

5.4 在组件库的使用方式

就正常使用mixin,

5.5 在实际项目中使用

使用方式于饿了么ui基本一致,可以参考饿了么ui的使用方法 地址

按需引入有些区别

typescript 复制代码
import Vue from 'vue';
import i18n from './lang';
import { Pagination, localeUtil } from 'os-ui';
Vue.use(Pagination);
localeUtil.i18n((key: string, value: string) => i18n.t(key, value));

六、为组件支持按需引入

研究了很久,发现如果以vue cli的lib模式打包,暂时不支持多个出口,没法打包成每个组件一个文件的形式,除非自己配置webpack进行打包,网上有很多教程。

另外一个思路是使用rollup进行打包,相比webpack来说,rollup更适合打包库,打包后的文件也会小很多,同时rollup可以配置输出为代码es版本,天生支持tree shaking,相比每个组件打包成一个文件的做法,使用组件库的人也免除了再使用babel-plugin-import 完成导入语句的转换的步骤 但要注意为了避免treeshaking莫名失效,尽量使用rollup 7以及以上的版本进行打包

七、组件库主题定制

八、为组件库编写文档

VuePress

九、优化包体积大小

相关推荐
加班是不可能的,除非双倍日工资3 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi3 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip3 小时前
vite和webpack打包结构控制
前端·javascript
excel4 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国4 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼4 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy4 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
草梅友仁4 小时前
草梅 Auth 1.4.0 发布与 ESLint v9 更新 | 2025 年第 33 周草梅周报
vue.js·github·nuxt.js
ZXT4 小时前
promise & async await总结
前端
Jerry说前后端4 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化