在之前传统的前端页面开发方式时,存在协同困难,可复用性差的问题,致使开发和维护都不是一件简单的事。而组件化思想的提出,以及Vue、React等MV*框架的快速流行,让咱们开始尝试用组件化的思想去开发。因为笔者最近在研究组件库的搭建,故撰文记之。
组件化思想让咱们把页面划分为一个个组件,组件内部维护本身的UI展现、交互逻辑,这样将能够大大提升代码的复用性以及可维护性。css
本文将着重介绍组件库搭建过程当中的准备工做,包括定义合理的项目结构、组件库打包构建、实现按需加载以及组件库所须要完善的其余工做等,但愿对读者有所帮助~(本文将以vue组件库为案例叙述)前端
首先你须要为你的组件库定义一个合理的项目结构,合理的项目结构对后期的代码维护和管理十分有帮助。你也能够先使用vue-cli初始化一个项目结构,这里笔者为了对组件库单独分割出来管理,在根目录下定义一个components文件夹,组件样式统一放置于该文件夹下的theme-chalk下,项目主要结构以下:vue
能够看到,文件结构中有两个关键点,一个是组件库入口,一个是单组件入口,组件库入口须要对项目组件进行注册,同时暴露对象中要含有install方法用于被Vue.use的时候调用。组件库入口文件以下:node
import helloworld from "../components/helloworld/index.js"; import test from "../components/test/index.js"; import { version } from '../package.json'; const components = { helloworld, test } const install = function (Vue) { if (install.installed) return; Object.keys(components).forEach(key => { Vue.component(components[key].name, components[key]); }) install.installed = true; }; if (typeof window !== 'undefined' && window.Vue) { install(window.Vue); } export { helloworld, test } export default { version, install };
其实作的事情很简单,把组件读取进来后,进行统一的Vue.component注册,以后暴露install方法便可。同理,componentA文件夹下的index.js,仅针对A组件作处理,以下:webpack
import helloworld from "./src/main.vue" helloworld.install = function(Vue) { Vue.component(helloworld.name, helloworld); }; export default helloworld;
聪明的你可能已经察觉到了,前面讲样式单独分离出来到theme-chalk下管理,是为何?为何这里又没有引入样式呢?git
一、不嵌套在vue组件的style标签中书写样式,方便对组件样式的单独打包,对于按需加载十分有意义;github
二、单独分离出样式组件进行管理,方便后续对组件进行换肤;web
三、单独分离出样式文件,方便统一管理。vue-cli
所以,若是你也要对本身的组件提取为组件库的话,强烈建议将样式单独分离出来处理。npm
项目结构和入口文件都定义好了,接下来就要考虑打包构建,让咱们的项目跑起来了。
开发环境只须要配置好webpack,利用devserver即可以开始调试,若是你是用vue-cli初始化项目的话,直接npm run serve便可。
在正式发布部署的时候,问题开始逐渐变得复杂起来,不一样于以往的项目,你只须要配置好一份webpack.config.js打包文件,区分开发和发布环境,就能够进行打包构建了,可是组件库或者第三方库不同,你须要将打包构建的结果提供给开发者使用,做为一个贴心的库提供者,你须要考虑如下问题:
一、你可能须要提供不一样模块类型的包:commonjs、umd、es模块;
二、你须要对各组件单独打包处理,方便用户按需加载;
三、因为须要实现按需加载,不避免的,你须要对样式也单独打包处理;
四、提供打包压缩后的.min.js文件
因为这是一个第三方库,而且这是一个组件库,使得咱们对打包这件事变得束手束脚,咱们须要针对不一样的状况进行打包,提供丰富的打包产物给用户,让用户想要啥有啥。为了明确咱们打包构建所须要的产物,笔者画了一张示意图:
或许这样,你会更明白咱们的打包任务是什么,相比以往的webpack一把梭,已经梭不了了,在咱们的打包产物中,存在es模块,而webpack自己打包不支持导出es模块,因此最终的打包构建咱们只能借助于rollup了。(你可能为问,为何咱们要执着于打包出es模块的包?其实,大部分的第三库都已经利用rollup进行打包,除了es模块将成为将来的缘由之外,es模块对于tree-shaking有极大意义,后文将介绍利用其进行按需加载)接下里咱们剖析一下每一个任务要怎么完成(按照图中所标序号):
一、构建任务一:导出组件库总包,利用rollup。若是你不稀罕es模块的导出的话,请选择webpack一把梭,这里为了提供es模块的导出,向主流看齐,咱们选择用rollup进行打包。
二、构建任务二:对各组件单独打包,利用webpack。还记得咱们在第一节中为每一个组件预留了一个index.js的入口吗?没错,这就是为了按需加载埋下的伏笔,本觉得咱们能够用rollup就进行打包的,可是rollup的entry只支持string形式,若是我有100个组件,难道要执行一百次rollup指令吗...不要紧,对于单独打包,仍是webpack香,经过webpack的entry配置hash对象,即可以对各个组件进行单独打包。(这个时候就没法导出es模块了,可是不要紧,毕竟只是单个组件的引入,不treeshaking了呗hh)
三、构建任务三与构建任务四:对样式统一打包和单独打包,利用gulp。因为咱们须要对css文件单独打包,不管是rollup仍是webpack都不能把打包入口指定为css文件,因此咱们只能借助gulp来打包css了。
最后打包文件是如下几个:
└── build ├── gulp.css.js // 针对css的文件处理打包 ├── rollup.config.js // 利用rollup进行最终产物打包 ├── webpack.dev.js // 开发模式配置 本地起dev server └── webpack.component.js // 利用webpack对各组件单独打包
webpack.component.js配置以下:
... // 读取components文件夹下的全部文件 const fs = require('fs'); const items = fs.readdirSync('./components'); const dirs = items.filter(item => { return fs.statSync(path.resolve('./components', item)).isDirectory() }) const entryHash = {} if(dirs.length > 0){ dirs.forEach(ele=>{ // css不做处理 if(ele !== "theme-chalk"){ entryHash[ele] = `./components/${ele}/index.js` } }) } // 不打包第三方模块内容 var externals = [Object.assign({ vue: 'vue' }), nodeExternals()]; module.exports = { ... entry: entryHash, ... externals:externals, ... optimization: { minimize: false, } }
大部分是常规的配置,可是这里注意一点,在实践过程当中,笔者对一个简单逻辑的componentA进行单独打包,结果打包出4000+行的代码,一度感受到生命的绝望,实际上是由于没有设置external,把第三库的内容也打包进去了。因为咱们约定components路径下存放组件,因此直接经过读取文件夹下的文件名来建立hash对象,而不须要本身手动维护,可谓一劳永逸!
其余都是常规的配置,针对js和css单独打包,配置好rollup和gulp的配置文件,因为篇幅有限,这里再也不展现rollup、gulpfile的配置。有兴趣的能够戳个人github看看配置。https://github.com/handsomeguy/oleiwa-demo
最后,咱们的package.json中是这样的配置的:
"gulp": "npx gulp css && npx gulp all", "rollupbuild:es": "npx rollup --config ./build/rollup.config.js", "rollupbuild:umd": "format=umd npx rollup --config ./build/rollup.config.js", "rollupbuild:min": "minify=true npx rollup --config ./build/rollup.config.js", "build:comp": "npx webpack --config ./build/webpack.component.js", "build": "npm run gulp && npm run rollupbuild:es && npm run rollupbuild:umd && npm run rollupbuild:min && npm run build:comp", "serve": "vue-cli-service serve",
后编译指的是在发布npm依赖包的时候,不进行编译构建,跟随npm包把源码也一块儿发出去,以后让用户直接引用未编译的源文件,自行打包编译。业界提倡后编译的典范即是cube-ui。
后编译带来的既有好处也有坏处。
优势:
一、共用公共依赖。
二、bebal转码只有一次,减小代码量。
三、方便换肤功能实现。(直接针对源码sass编译)
缺点:
一、用户的打包配置要兼容,甚至须要额外作配置。
二、配置颇有多是侵入式的,对于用户的接入成本过大。
笔者我的认为,大部分人都会倾向于选择易于接入的组件库,我的并不推荐后编译,可是后编译其实仍是有它的做用的,例如后面咱们要介绍的换肤功能,其实本质就是一种后编译,只不过咱们只针对css文件作了后编译,你须要暴露出一个源码的scss文件入口。
跟大部分的组件库同样,咱们只须要这样,即可以全量引入咱们的组件库:(注意样式文件单独引入)
import oleiwa from "@tencent/oleiwa"; import "@tencent/oleiwa/dist/css/index.css"; Vue.use(oleiwa)
按需引入这个问题,其实不单出如今组件库中,大部分的第三方库都会面临这个问题,用户只须要其中一部分的功能,你要怎么帮助他剔除无用的模块,做为库提供者,你须要作的就是细分模块,让用户能只引入本身须要的功能模块。工具库中的lodash即是一个很好的栗子。回到正题,咱们要怎么让咱们组件库可以按需引入?
最直接、最粗暴的方式,即是直接指定组件路径和样式路径。
import helloworld from "@tencent/oleiwa/dist/helloworld.js" import "@tencent/oleiwa/dist/css/base.css" import "@tencent/oleiwa/dist/css/helloworld.css" Vue.use(helloworld)
能够看到,经过直接指定路径的方式,咱们须要再手动引入css样式,并且还不能落了base.css这个样式文件。
第一种方式,简单粗暴,可是你必定不但愿你的用户写着又臭又长的路径,嘴里一边咒骂:这哪一个XX写的组件库。因此还有一种hack的方式,帮助咱们来实现按需引入,利用plugin的方式,对引入路径作替换,帮助咱们引入须要的组件以及样式。
目前业界的处理方式:
其本质都是在编译阶段,针对引用路径作替换。例如:
import { Button } from 'components'
将被替换成如下代码:
var button = require('components/lib/button') require('components/lib/button/style.css')
固然,路径并非固定的,以babel-plugin-component为例,它容许咱们对lib和样式文件地址进行配置。
可是因为配置能力有限,咱们的组件必须放置在lib路径下,(实际咱们打包在dist路径下,路径可修改)可是组件不容许再嵌套一层路径了,因此咱们须要把咱们的打包配置稍做修改,将每一个组件的打包结果都导出到dist路径下。
最后,用户使用起来的时候,只须要安装好plugin依赖,配置babel.config.js文件以下,即可以实现按需引入:
module.exports = { presets: ["@vue/app"], "plugins": [["component", { libraryName: "@tencent/oleiwa", libDir:"dist", styleLibraryName:"css", }]] };
使用以下(注意:按需引入状况下,插件自己会默认帮你导入base.css,因此你也不用担忧base文件的问题):
import {helloworld} from "@tencent/oleiwa"; Vue.use(helloworld)
sideEffects是webpack4新增的一个特性,须要咱们在package.json中进行配置,其主要做用是告诉webpack咱们这个包有没有反作用。什么是反作用,简单的说就是其导出的模块是否对其之外的模块或变量形成影响。例如是否修改了window上的属性,是否复写了原生对象 Array, Object 方法,是否修改了其自己所导出的其余模块等,若是你还想了解更多,能够戳这里。
其实早些时候,还有这样一篇文章,你的treeshaking并无什么卵用 [](https://zhuanlan.zhihu.com/p/...
有兴趣能够点进去看一下,主要讲的是因为babel转码的缘由,致使最后编译后的代码存在了反作用(getter和setter致使),最后致使咱们不能对第三方库有效的tree-shaking,最后做者提出的方案是在业务中先进行tree-shaking以后再进行转码,同时提供了相关插件。这也是咱们为何最初要用rollup来打包一个es模块的文件,为了方便tree-shaking时判断哪些变量或模块能够直接剔除,除此之外,借助import和export可以更好的发挥tree-shaking的功效。(注:webpack2.X开始和rollup都会感应package.json配置文件中的module属性,来优先加载es模块的包,所以你首先须要为你的包配置此字段)
接下来咱们尝试对oleiwa包进行sideEffect的配置:
{ ... "main": "dist/oleiwa.umd.js", "module": "dist/oleiwa.es.js", "sideEffects": false }
因为咱们使用的时候是经过import {componentA} from "@tencent/oleiwa"的形式引入的,因此咱们的入口文件处还设置了各个组件的export,即export { componentA,componentB ,...}
原本开开心心的配置完,按照import {helloworld} from "@tencent/oleiwa"来引用,按理说应该生效了,可是最后打包的结果却没有剔除其余组件?问题出在了哪里?让咱们回顾一下刚刚的index.js入口文件:
.. //code const install = function(Vue){ .. //code } if (typeof window !== 'undefined' && window.Vue) { install(window.Vue); } export { helloworld, test } export default { version, install };
咱们对组件单独暴露一个属性,同时export default里附带install方法,安装所有组件,最初笔者觉得多是install的执行逻辑致使webpack不敢对其tree-shaking,因而把if部分的判断去掉了,可是最后发现,仍是把test组件打包进来了。其实执行的时候window为undefined,install就已经没有执行了,因此并不会影响到shaking,关键的问题在于最后的export default,笔者删掉export default 的代码后,最后实现了按需引入,打包出来的bundle剔除了test组件。缘由是tree-shaking能够针对单独的export作处理,可是对export default里export出来的对象没法进行shaking,因此若是你要使用tree-shaking,请使用export的方式暴露你的变量。
简单总结一下,若是你要利用sideEffects和tree-shaking来实现按需加载,须要确保如下几点:
一、利用rollup打包,导出es模块;
二、配置package.json文件,若是你确保模块没有反作用,可直接把sideEffects设置为false,同时,指定module入口;
三、导出时使用export,而非export default;
四、用户在实际开发中须要使用webpack4.x 或 rollup进行打包。
前面介绍了项目结构初始化、打包构建以及如何实现按需加载等,大体的组件库架子已经搭好了,你已经能够开始愉快开心的开发你的组件库了,接下里要介绍的是组件库还能够进行完善的其余工做,包括换肤功能的实现、组件库的类型定义以及组件库的单元测试。
大部分的组件库,element-ui、cube-ui、iview等都容许你对UI主题进行定制,其原理十分简单,还记得咱们最开始为咱们的样式文件定义了一个index.scss的总入口吗,只须要把这个入口暴露给用户,让用户再进行额外的设置便可。换肤功能的实现本质即是一种后编译,经过将编译前的源码暴露给用户,让用户在开发过程当中去编译。
用户只须要安装好sass-loader,自定义一个user.scss文件,引入咱们的总入口文件便可:
@import '@tencent/oleiwa/components/theme-chalk/index.scss'; // Here are the variables to cover, such as: @primary-color: #8c0776;
(注:你能够将变量的定义放置于base.css中,供全部组件css共用)
没有作类型定义的组件库,是没有格调的组件库,为了你的用户可以开心愉快地使用你的组件库,你要为你的各个组件定义好类型,方便用户使用。在这以前你须要在package.json里定义好类型校验的入口:
"typings": "types/index.d.ts",
参照Element-ui的实现,咱们能够这样设计类型定义文件的结构:
└── types ├── index.d.ts // 类型定义总入口 ├── oleiwa-ui.d.ts // 类型定义入口,在这里import其余的组件定义 ├── component.d.ts // 定义组件基类 └── helloworld.d.ts // helloworld组件的类型定义
因为咱们的组件库并不是直接的业务组件,因此咱们须要更多关注的是组件交互和渲染的UI测试,而组件库须要提供给用户使用,因此完备的单元测试颇有必要,针对组件的单元测试主要能够细分一下几类:
一、组件渲染,快照对比
二、props传递
三、回调函数执行
四、document.createEvent模拟事件触发,检测核心交互逻辑
一个简单的栗子:
import { expect } from "chai"; import { shallowMount } from "@vue/test-utils"; import HelloWorld from "@/components/HelloWorld.vue"; describe("HelloWorld.vue", () => { it("renders props.msg when passed", () => { const msg = "new message"; const wrapper = shallowMount(HelloWorld, { propsData: { msg } }); expect(wrapper.text()).to.include(msg); }); });
接下来,咱们只须要为每一个组件写好单元测试,放置在tests/unit文件夹下统一管理便可。执行单元测试:
npm run test:unit
开发完了你的组件库,怎么也得教你的用户怎么使用吧,若是你想偷懒的话,能够直接用vuese,快速根据你的组件,生成API文档,其本质是经过AST分析你的文件,提取props、events等参数。
具体使用:安装好vuese后,配置.vueserc以下:
{ "include": [ "./components/**/*.vue" ], "title": "oleiwa-doc", "genType": "docute" }
执行npx vuese gen便可,简直方便到爆炸。
固然,若是你不知足于这个的话,可使用markdown-it来书写本身的文档,业界最广泛的方式都是基于此。同时,为了不demo和code分离,维护两份代码,你能够实现本身的demo-block组件,将本身的 vue 组件插入文档中,有兴趣的话能够戳如下连接:
最后,一个组件库的架子,就被咱们这样手把手的搭起来了。回顾一下咱们学习了啥:
一、为咱们的组件库定义好项目结构,以及定义入口文件;
二、因为组件库不一样于普通的应用,因此在打包构建上咱们要针对性的处理,统一打包和单独打包,css和js各自单独打包,导出的js文件打包要提供umd、es模块支持;
三、在前面的项目结构,以及咱们的打包构建基础上,让咱们为组件库实现按需加载成为了可能,而且讨论了按需加载实现的几种方式;
四、关于组件库所须要完善的其余工做,包括换肤、类型定义以及为你的组件库作单元测试;
五、生成组件库文档,可使用vuese一把梭,也能够和业界同样,采用markdown-it来书写文档。
至此,一个组件库的搭建工做到此结束,可是这只是第一步而已,接下来你须要丰富你的组件库组件,实现更多的功能,相似于动画、内置icon等!组件库之路,道阻且长。