Vue组件库搭建实践与探索

更新日期: 2019-10-11阅读: 3.4k标签: 组件
在以前传统的前端页面开发方式时,存在协同困难,可复用性差的问题,导致开发和维护都不是一件简单的事。而组件化思想的提出,以及vuereact等MV*框架的快速流行,让我们开始尝试用组件化的思想去开发。由于笔者最近在研究组件库的搭建,故撰文记之。


前言

组件化思想让我们把页面划分为一个个组件,组件内部维护自己的UI展示、交互逻辑,这样将可以大大提高代码的复用性以及可维护性。

本文将着重介绍组件库搭建过程中的准备工作,包括定义合理的项目结构、组件库打包构建、实现按需加载以及组件库所需要完善的其他工作等,希望对读者有所帮助~(本文将以vue组件库为案例叙述)


1、定义项目结构

首先你需要为你的组件库定义一个合理的项目结构,合理的项目结构对后期的代码维护和管理十分有帮助。你也可以先使用vue-cli初始化一个项目结构,这里笔者为了对组件库单独分割出来管理,在根目录下定义一个components文件夹,组件样式统一放置于该文件夹下的theme-chalk下,项目主要结构如下:


可以看到,文件结构中有两个关键点,一个是组件库入口,一个是单组件入口,组件库入口需要对项目组件进行注册,同时暴露对象中要含有install方法用于被Vue.use的时候调用。组件库入口文件如下:

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组件做处理,如下:

import helloworld from "./src/main.vue"
helloworld.install = function(Vue) {
  Vue.component(helloworld.name, helloworld);
};
export default helloworld;

聪明的你可能已经察觉到了,前面讲样式单独分离出来到theme-chalk下管理,是为什么?为什么这里又没有引入样式呢?

1、不嵌套在vue组件的style标签中书写样式,方便对组件样式的单独打包,对于按需加载十分有意义;

2、单独分离出样式组件进行管理,方便后续对组件进行换肤;

3、单独分离出样式文件,方便统一管理。

因此,如果你也要对自己的组件提取为组件库的话,强烈建议将样式单独分离出来处理。


2、打包构建配置

项目结构和入口文件都定义好了,接下来就要考虑打包构建,让我们的项目跑起来了。

开发环境

开发环境只需要配置好webpack,利用devserver便可以开始调试,如果你是用vue-cli初始化项目的话,直接npm run serve即可。

正式环境

在正式发布部署的时候,问题开始逐渐变得复杂起来,不同于以往的项目,你只需要配置好一份webpack.config.js打包文件,区分开发和发布环境,就可以进行打包构建了,但是组件库或者第三方库不一样,你需要将打包构建的结果提供给开发者使用,作为一个贴心的库提供者,你需要考虑以下问题:

1、你可能需要提供不同模块类型的包:commonjs、umd、es模块;

2、你需要对各组件单独打包处理,方便用户按需加载;

3、由于需要实现按需加载,不避免的,你需要对样式也单独打包处理;

4、提供打包压缩后的.min.js文件

由于这是一个第三方库,并且这是一个组件库,使得我们对打包这件事变得束手束脚,我们需要针对不同的情况进行打包,提供丰富的打包产物给用户,让用户想要啥有啥。为了明确我们打包构建所需要的产物,笔者画了一张示意图:


或许这样,你会更明白我们的打包任务是什么,相比以往的webpack一把梭,已经梭不了了,在我们的打包产物中,存在es模块,而webpack本身打包不支持导出es模块,所以最终的打包构建我们只能借助于rollup了。(你可能为问,为什么我们要执着于打包出es模块的包?其实,大部分的第三库都已经利用rollup进行打包,除了es模块将成为未来的原因以外,es模块对于tree-shaking有极大意义,后文将介绍利用其进行按需加载)接下里我们剖析一下每个任务要怎么完成(按照图中所标序号):

1、构建任务一:导出组件库总包,利用rollup。如果你不稀罕es模块的导出的话,请选择webpack一把梭,这里为了提供es模块的导出,向主流看齐,我们选择用rollup进行打包。

2、构建任务二:对各组件单独打包,利用webpack。还记得我们在第一节中为每个组件预留了一个index.js的入口吗?没错,这就是为了按需加载埋下的伏笔,本以为我们可以用rollup就进行打包的,但是rollup的entry只支持string形式,如果我有100个组件,难道要执行一百次rollup指令吗...没关系,对于单独打包,还是webpack香,通过webpack的entry配置hash对象,便可以对各个组件进行单独打包。(这个时候就无法导出es模块了,但是没关系,毕竟只是单个组件的引入,不treeshaking了呗hh)

3、构建任务三与构建任务四:对样式统一打包和单独打包,利用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

后编译带来的既有好处也有坏处。

优点:

1、共用公共依赖。

2、bebal转码只有一次,减少代码量。

3、方便换肤功能实现。(直接针对源码sass编译)

缺点:

1、用户的打包配置要兼容,甚至需要额外做配置。

2、配置很有可能是侵入式的,对于用户的接入成本过大。

笔者个人认为,大部分人都会倾向于选择易于接入的组件库,个人并不推荐后编译,但是后编译其实还是有它的作用的,例如后面我们要介绍的换肤功能,其实本质就是一种后编译,只不过我们只针对css文件做了后编译,你需要暴露出一个源码的scss文件入口。


3、全量加载与按需加载

全量加载

跟大部分的组件库一样,我们只需要这样,便可以全量引入我们的组件库:(注意样式文件单独引入)

import oleiwa from "@tencent/oleiwa";
import "@tencent/oleiwa/dist/css/index.css";
Vue.use(oleiwa)

按需引入

按需引入这个问题,其实不单出现在组件库中,大部分的第三方库都会面临这个问题,用户只需要其中一部分的功能,你要怎么帮助他剔除无用的模块,作为库提供者,你需要做的就是细分模块,让用户能只引入自己需要的功能模块。工具库中的lodash便是一个很好的栗子。回到正题,我们要怎么让我们组件库能够按需引入?

1、指定组件路径

最直接、最粗暴的方式,便是直接指定组件路径和样式路径。

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这个样式文件。

2、借助plugin实现

第一种方式,简单粗暴,但是你一定不希望你的用户写着又臭又长的路径,嘴里一边咒骂:这哪个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)  

3、借助sideEffects和tree-shaking

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来实现按需加载,需要确保以下几点:

1、利用rollup打包,导出es模块;

2、配置package.json文件,如果你确保模块没有副作用,可直接把sideEffects设置为false,同时,指定module入口;

3、导出时使用export,而非export default;

4、用户在实际开发中需要使用webpack4.x 或 rollup进行打包。


4、其他工作

前面介绍了项目结构初始化、打包构建以及如何实现按需加载等,大致的组件库架子已经搭好了,你已经可以开始愉快开心的开发你的组件库了,接下里要介绍的是组件库还可以进行完善的其他工作,包括换肤功能的实现、组件库的类型定义以及组件库的单元测试。

4.1、换肤功能的实现

大部分的组件库,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共用)

4.2、类型定义

没有做类型定义的组件库,是没有格调的组件库,为了你的用户能够开心愉快地使用你的组件库,你要为你的各个组件定义好类型,方便用户使用。在这之前你需要在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组件的类型定义

 4.3、单元测试

由于我们的组件库并非直接的业务组件,所以我们需要更多关注的是组件交互和渲染的UI测试,而组件库需要提供给用户使用,所以完备的单元测试很有必要,针对组件的单元测试主要可以细分一下几类:

1、组件渲染,快照对比

2、props传递

3、回调函数执行

4、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


5、组件库文档

开发完了你的组件库,怎么也得教你的用户怎么使用吧,如果你想偷懒的话,可以直接用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 组件插入文档中,有兴趣的话可以戳以下链接:


最后

最后,一个组件库的架子,就被我们这样手把手的搭起来了。回顾一下我们学习了啥:

1、为我们的组件库定义好项目结构,以及定义入口文件;

2、由于组件库不同于普通的应用,所以在打包构建上我们要针对性的处理,统一打包和单独打包,css和js各自单独打包,导出的js文件打包要提供umd、es模块支持;

3、在前面的项目结构,以及我们的打包构建基础上,让我们为组件库实现按需加载成为了可能,并且讨论了按需加载实现的几种方式;

4、关于组件库所需要完善的其他工作,包括换肤、类型定义以及为你的组件库做单元测试;

5、生成组件库文档,可以使用vuese一把梭,也可以和业界一样,采用markdown-it来书写文档。

至此,一个组件库的搭建工作到此结束,但是这只是第一步而已,接下来你需要丰富你的组件库组件,实现更多的功能,类似于动画、内置icon等!组件库之路,道阻且长。

原文:https://segmentfault.com/a/1190000020754678


链接: https://fly63.com/article/detial/6047

Vuetify基于vue2.0,为移动而生的组件框架

Vuetify 支持SSR(服务端渲染),SPA(单页应用程序),PWA(渐进式Web应用程序)和标准HTML页面。 Vuetify是一个渐进式的框架,试图推动前端开发发展到一个新的水平。

Vue中插槽的作用_Vue组件插槽的使用以及调用组件内的方法

通过给组件传递参数, 可以让组件变得更加可扩展, 组件内使用props接收参数,slot的使用就像它的名字一样, 在组件内定义一块空间。在组件外, 我们可以往插槽里填入任何元素。slot-scope的作用就是把组件内的数据带出来

react 函数子组件(Function ad Child Component)

函数子组件(FaCC )与高阶组件做的事情很相似, 都是对原来的组件进行了加强,类似装饰者。FaCC,利用了react中children可以是任何元素,包括函数的特性,那么到底是如何进行增强呢?

Vue和React组件之间的传值方式

在现代的三大框架中,其中两个Vue和React框架,组件间传值方式有哪些?组件间的传值是灵活的,可以有多种途径,父子组件同样可以使用EventBus,Vuex或者Redux

vue.js自定义组件directives

自定义指令:以v开头,如:v-mybind。bind的作用是定义一个在绑定时执行一次的初始化动作,观察bind函数,它将指令绑定的DOM作为一个参数,在函数体中,直接操作DOM节点为input赋值。

vue中prop属性传值解析

prop的定义:在没有状态管理机制的时候,prop属性是组件之间主要的通信方式,prop属性其实是一个对象,在这个对象里可以定义一些数据,而这些数据可以通过父组件传递给子组件。 prop属性中可以定义属性的类型,也可以定义属性的初始值。

Web组件简介

Web组件由三个独立的技术组成:自定义元素。很简单,这些是完全有效的HTML元素,包含使用一组JavaScript API制作的自定义模板,行为和标记名称(例如,<one-dialog>)。

web组件调用其他web资源

web组件可以直接或间接的调用其他web资源。一个web组件通过内嵌返回客户端内容的另一个web资源的url来间接调用其他web资源。在执行时,一个web资源通过包含另一个资源的内容或者转发请求到另一个资源直接调用。

vue中如何实现的自定义按钮

在实际开发项目中,有时我们会用到自定义按钮;因为一个项目中,众多的页面,为了统一风格,我们会重复用到很多相同或相似的按钮,这时候,自定义按钮组件就派上了大用场,我们把定义好的按钮组件导出,在全局引用,就可以在其他组件随意使用啦,这样可以大幅度的提高我们的工作效率。

Vue子组件调用父组件的方法

Vue中子组件调用父组件的方法,这里有三种方法提供参考,第一种方法是直接在子组件中通过this.$parent.event来调用父组件的方法,第二种方法是在子组件里用$emit向父组件触发一个事件,父组件监听这个事件就行了。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!