前言
本文的代码部分基于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 让编辑器可以识别类型文件,提供引用提示
现在类型文件有了,我们怎么才能在实际项目中使用呢
- 首先在types文件夹下新建index.d.ts文件,统一导出类型
typescript
// index.d.ts
import OsPagination from './os-pagination';
import OsTable from './os-table';
....
export { OsPagination, OsTable ...};
- 然后修改package.json文件的
files
字段和typings
字段,
-
files
支持配置一个数组,作用是指定发布包时,要包含哪些文件,这里我们选择包含dist
和types
文件夹。 -
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文件,main
和 module
一定不能配置错,不然会导致组件库安装后无法使用。
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 前言
关于组件库国际化,有下面几点要思考的问题
- 组件库的国际化方案和实际项目中的方案可能不同 ,比如组件库为了更轻,自己简单封装了一份国际化实现,而项目则选择了
vue-i18n
,或者其他国际化插件、或者干脆也自己封装了一份实现,那怎么才能保证在语言环境切换时,项目和组件库的文案都能及时响应变更呢。 - 有时组件库内的组件,会接收实际项目使用时传进来的国际化文案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以及以上的版本进行打包