本文的代码部分基于vue2 + ts ,最终的文件目录如下
首先用cli,根据自己的需求创建一个项目,然后根据自己习惯或团队的规范可以做一些项目初始化配置,比如husky和eslint等等。
第一步:把src文件夹重命名为examples,然后修改vue.config.js文件,把构建入口改为examples路径下,改完执行下serve命令,看看开发环境还能不能正常启动
第二步:创建packages文件夹,存放组件文件
第三步:创建src文件夹,然后在该文件夹下创建index.ts文件,该文件为组件库打包的入口文件。
还记得我们用elementui的时候,需要在main.ts文件中使用下面的代码吗
// 整体引入 Vue.use(ElementUI); // 部分引入 Vue.use(Pagination);
vue的 官方文档 是这么写的
如果我们想让插件可以正确被vue.use加载,插件就必须暴露一个install方法,在使用时,通过调用Vue.use(插件)
,然后插件的install方法就会被调用,我们就可以拿到传入的Vue实例,再然后就可以对这个Vue实例做各种骚操作,实现各种功能。
想更深入了解的推荐去网上搜一下Vue.use和install的实现,有很多优秀的博客。
准备步骤和知识铺垫完成后,可以来搞组件了,我的组件文件目录是这样的
如果你不喜欢像我这样把文件拆分开,可以按正常的写法,src文件放一个.vue文件和单元测试文件就够了,另外单元测试文件你可以也拿出来按默认的放到单独的tests文件夹内。
index文件是必须的,注意层级,index文件跟src文件夹属于同级。
组件没什么好讲的,就正常组件的写法,关键点在于index.ts文件,还记得我们上面说的必须要暴露一个install方法吗
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即可。
Vue.component((OsPagination as any).extendOptions.name, OsPagination);
现在我们通过为组件暴露install方法,达到了让vue能正常使用我们封装的组件的成果。
接着找到我们之前新建的src文件夹下的index文件,添加如下代码
import OsPagination from '../packages/os-pagination'; export {
OsPagination, };
组件使用方式:
在你要使用该组件的项目中
// main.ts 文件 import {
OsPagination } from 'os-ui'; Vue.use(OsPagination);
目前的暴露方式是单独为每个组件提供一份暴露install的代码,使用的时候也是一个一个通过Vue.use来使用的。
下面来介绍一下统一暴露,使用时整体引入的实现方式
修改 src/index.ts
文件,我们循环全局注册组件即可
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 };
使用方式
import {
OSUI } from 'os-ui'; Vue.use(OSUI);
官方文档 是这么写的
所以我们直接根据官方说明在package.json的scripts里,加一条构建库的命令
"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并不能支持。这个在后面按需引入的那一节会再谈到。
具体可以参考这篇文章
选择rollup,主要原因是 更小
、支持输出esm
、支持tree-shaking
,下面是我的rollup配置文件
// 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属性即可。
当打包工具遇到我们的模块时:
一个好的组件库,必然不能缺少一份类型文件,详细的类型文件,可以帮助使用者快速上手,参考element ui 的类型文件
在根目录新建一个types文件夹,下面新建一个对应组件文件名的文件,但是文件的后缀名为d.ts(d.ts结尾的文件,会被认为是类型文件,在ts编译时会被排除在外),以文中的os-pagination为例,新建文件os-pagination.d.ts。
下面是我的类型文件,类型定义中包含了一些public 属性,除此之外,如果组件内需要暴露一些方法供外部使用,也需要在类型定义中体现出来,详细的可以参考element ui
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>; }
现在类型文件有了,我们怎么才能在实际项目中使用呢
// index.d.ts import OsPagination from './os-pagination'; import OsTable from './os-table'; .... export {
OsPagination, OsTable ...};
files
字段和typings
字段,files
支持配置一个数组,作用是指定发布包时,要包含哪些文件,这里我们选择包含dist
和types
文件夹。
typings
的作用是指定包的类型文件,当别人install了你的包之后,编辑器会根据typings指定的类型文件,提供智能类型提示。
我们的组件库或者其他公共的包可能包含一些业务信息,所以组件库肯定不能放到npm仓库,那么就要搭建一个私服。
搭建npm私服的方案有挺多的,比如nexus
或者sinopia
(仓库已经很多年不维护了)等等,不过我最终选择了verdaccio
,这里是verdacio官方文档。
1、首先准备一台服务器,安装好node
2、安装
npm install -g verdaccio
或者
yarn global add verdaccio
3、执行verdaccio,启动服务
verdaccio
这里要注意第一行打印的信息,这个yaml文件就是verdaccio的配置文件,后面我们需要修改该文件进行相关配置。
warn --- config file - /root/.config/verdaccio/config.yaml
另外建议在服务器装一个pm2
(进程守护),使用pm2
启动verdaccio
,或者使用其他进程守护方案
4、启动成功后,直接在浏览器输入服务器的ip地址 + 最后一行打印出的端口号,看到这样的页面就说明安装成功了。如果访问不了可能是因为服务器防火墙没关,可以检查下防火墙。
5、修改配置文件,如果是linux服务器,直接执行
vi /root/.config/verdaccio/config.yaml
vi 后面的地址就是启动verdaccio服务时,第一行打印出的地址,下面是具体的配置文件内容,verdaccio
的作者对配置文件做了很详细的注释
# # 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
还支持组和其他更多的权限配置方式,有需求的可以去网上找下资料。
1、切换npm源到我们私有的仓库地址
你可能会好奇,我切换了源之后,要装其他包怎么办,私有仓库又没有这些包!其实不是这样的,前面的verdaccio配置文件里有一个配置项,可以配置一个或多个其他的源。当我们私有仓库里找不到包时,会自动去配置的其他源里面找。
推荐安装nrm,管理npm registry,
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
一定不能配置错,不然会导致组件库安装后无法使用。
"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,把源切换到我们的私有仓库
// 执行完login后,输入之前加入到htpasswd文件内的账户登录 npm login // 更新版本号 npm version patch // 发布包 npm publish
发布成功后,再访问私有仓库地址,就会发现自己的包已经发上去了。
4、新建一个用于测试的项目,就像安装elementui那样,直接npm install 你的组件库,就可以安装使用了。
5、具体使用方式:
// main.ts import {
OsPagination, OSUI } from 'os-ui'; // 按需引入 Vue.use(OsPagination); // 整体引入 Vue.use(OSUI);
关于组件库国际化,有下面几点要思考的问题
vue-i18n
,或者其他国际化插件、或者干脆也自己封装了一份实现,那怎么才能保证在语言环境切换时,项目和组件库的文案都能及时响应变更呢。上面那两个问题,对于第一个,我可以让组件库暴露一个方法,接收一个语言环境的参数,去动态改变组件库当前的语言环境。在实际项目中,在切换语言后,动态调用这个方法。
对于第二个问题,必须要做到合并组件库和项目两者的语言文件,在解决第一个问题的基础上,我们在捕捉到语言环境变更时,把本地项目的语言文件传入到组件库中去,在组件库中合并文件。
在有了思路之后我突然又想到可以再去扒一下饿了么ui的实现,看看有没有更好的实现方式。
看了饿了么ui源码后,我发现思路有相似之处,但又不完全一致。饿了么ui是这么玩的:
你可以使用任何i18n插件,只需要传入项目本地使用插件具体的翻译方法即可,这个方法传入进去之后,会替换掉饿了么ui默认的国际化实现方法,从而达到统一国际化的目的(除此之外你还需要手动把饿了么ui内部的翻译文件与项目中的合并一下。)
除此之外你也可以使用vue-i18n,这里他不是暴露一个方法去修改语言环境,他是通过vue.config.lang去设置的,然后把组件库本身的语言文件与项目的合并即可,他直接用了vue-i18n提供的方法去实现。
当前你项目中如果不需要进行国际化,也不影响,饿了么ui自身也实现了一套国际化方案,他默认语言环境中文,你想用英文直接设置一下即可
下面是饿了么ui的部分源码
综上所述,我最终选择抄一下饿了么ui的解决方案,毕竟有源码可搬,不搬白不搬
在src文件夹下新建这些文件
lang文件夹下是文案的翻译文件,没什么好说的,形如下图
src / local / format.ts 该文件是组件库本身国际化方案的核心实现
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 对外暴露一些方法
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,方便组件使用
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 需要修改下入口文件
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文件
就正常使用mixin,
使用方式于饿了么ui基本一致,可以参考饿了么ui的使用方法 地址
按需引入有些区别
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