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

前言

本文的代码部分基于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

九、优化包体积大小

相关推荐
捂月2 分钟前
Spring Boot 深度解析:快速构建高效、现代化的 Web 应用程序
前端·spring boot·后端
深度混淆9 分钟前
实用功能,觊觎(Edge)浏览器的内置截(长)图功能
前端·edge
Smartdaili China10 分钟前
如何在 Microsoft Edge 中设置代理: 快速而简单的方法
前端·爬虫·安全·microsoft·edge·社交·动态住宅代理
秦老师Q11 分钟前
「Chromeg谷歌浏览器/Edge浏览器」篡改猴Tempermongkey插件的安装与使用
前端·chrome·edge
滴水可藏海12 分钟前
Chrome离线安装包下载
前端·chrome
m512722 分钟前
LinuxC语言
java·服务器·前端
运维-大白同学44 分钟前
将django+vue项目发布部署到服务器
服务器·vue.js·django
Myli_ing1 小时前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维2 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~2 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存