如何手把手打造自己的Vue组件库

6,604 阅读7分钟

和大家分享一下创建 Vue 组件库的过程, 方便大家参考, 项目的地址在这里:

github.com/gaoljie/vue…

项目的结构如下

build (webpack配置)
lib (打包文件位置)
- button (按需加载组件位置)
- - index.js
- theme (组件样式)
- - base.css (公用样式)
- - button.css(组件样式)
- index.js (全局引用入口)
src (项目文件)
- assets (公共资源)
- components (vue组件)
- button
- - button.vue (组件逻辑)
- - button.scss (组件样式)
- - button.test.js (组件测试)
- - button.story.js (组件用例)
- - index.js (组件入口)
- directives (vue directive 命令)
- locale (国际化)
- mixins (vue mixins)
- styles (样式)
- - variables (样式变量)
- - - index.scss (变量入口)
- - - color.scss (颜色变量)
- - vendors (公共样式, 样式重置)
- - index.scss (样式入口)
- utils (公用方法)
- index.js (项目入口)

初始化项目

创建项目

mkdir vue-uikit
cd vue-uikit
mkdir src

初始化 git

git init

在项目根目录创建 .gitignore文件

node_modules/

初始化 npm

yarn init

安装 vue

yarn add vue -D

之所以把vue安装在devDependencies, 是因为我们的组件库是依赖于使用者的安装的vue包的, 我们打包自己组件并不需要把vue一起打包进去.

安装 webpack

yarn add webpack webpack-cli webpack-merge -D

格式化

格式化用到了 eslintprettier

yarn add eslint prettier eslint-config-prettier eslint-plugin-jest eslint-plugin-vue husky pretty-quick -D

  • husky (pre commit 的工具)
  • pretty-quick (用 prettier 格式化 git changed 文件)
  • eslint-plugin-jest, eslint-plugin-vue, eslint-plugin-prettier (eslint 相关插件, 用于和 jest 单元测试, vue 文件和 prettier 兼容)

添加相应的配置到package.json

"husky": {
    "hooks": {
      "pre-commit": "pretty-quick --staged && eslint --ext .js,.vue src"
    }
  },
  "eslintConfig": {
    "env": {
      "node": true
    },
    "extends": [
      "eslint:recommended",
      "plugin:jest/recommended",
      "plugin:vue/recommended",
      "plugin:prettier/recommended"
    ]
  },

添加 .eslintrc 到根目录

{
  "env": {
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:jest/recommended",
    "plugin:vue/recommended",
    "prettier"
  ]
}

每次 commit 代码之前会用husky格式化代码, 确保代码风格统一.

初始化样式结构

按照最上面的项目结构在 src/styles/variables 创建 color.scss 文件

$color_primary: #ff5722;
$color_disabled: #d1d1d1;

在同级目录下创建 index.scss引用 color.scss

@import "color";

之后如果创建其他类型的样式变量也是同样的步骤

src/styles/vendors创建normalize.scss文件, 用来规范各个浏览器之间的样式差异, 源码可以在 github 上面看.

之后在src/styles下创建index.scss作为样式入口

@import "vendors/normalize";

安装相应 npm 包

yarn add sass-loader node-sass style-loader css-loader -D

创建组件

src/components/button 创建 button.vue 文件

<template>
  <button class="ml-button" :class="btnClass">
    <slot></slot>
  </button>
</template>

<script>
const prefix = "ml-button";
export default {
  name: "MlButton",
  props: {
    type: {
      type: String,
      default: "primary"
    },
    disabled: {
      type: Boolean,
      default: false
    },
    round: {
      type: Boolean,
      default: false
    }
  },
  computed: {
    btnClass() {
      return [
        `${prefix}--${this.type}`,
        this.disabled ? `${prefix}--disabled` : "",
        this.round ? `${prefix}--round` : ""
      ];
    }
  }
};
</script>

<style lang="scss">
@import "../../styles/variables/index";
.ml-button {
  color: white;
  font-size: 14px;
  padding: 12px 20px;
  border-radius: 5px;
  &:focus {
    cursor: pointer;
  }
  &--primary {
    background-color: $color-primary;
  }
  &--disabled {
    background-color: $color_disabled;
    color: rgba(255, 255, 255, 0.8);
  }
  &--round {
    border-radius: 20px;
  }
}
</style>

同级目录下创建index.js用作组件入口

import MlButton from "./button.vue";
export default MlButton;

安装 storybook

storybook 为你的组件提供良好的生产环境下的展示, 你可以通过它写组件的各种用例, 结合它提供的各种插件, 你的组件使用文档可以实现实时交互, 组件点击监听, 查看源代码, 写 markdown 文档, 不同视窗下展示组件等功能.

有了 storybook 的帮助, 我们就没必要安装各种 npm 包, 节省了我们搭建组件用例的时间, 并且可以更直观地给使用者展示组件的各种用法.

下面是 storybook 的安装配置

yarn add @storybook/vue vue-loader vue-template-compiler @babel/core babel-loader babel-preset-vue -D
  • @storybook/vue (storybook vue 核心包)
  • vue-loader (webpack loader, 用于 webpack 解析 vue 单文件组件)
  • vue-template-compiler (vue loader 要用到的 vue 编译器, 需要和 vue 包版本保持一致)
  • @babel/core (babel 核心包)
  • babel-loader (webpack loader, 让 webpack 使用 babel 解析 js 文件)
  • babel-preset-vue (babel 用于解析 vue jsx 的插件)

.storybook/config.js创建 storybook config 文件

import { configure } from "@storybook/vue";

function loadStories() {
  const req = require.context("../src", true, /\.story\.js$/);
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);

这个配置文件会自动加载src目录下的*.story.js文件

因为 vue 组件用到了scss, 需要在.storybook目录下创建 storybook 的 webpack 配置 webpack.config.js

const path = require("path");

module.exports = async ({ config, mode }) => {
  config.module.rules.push({
    test: /\.scss$/,
    use: ["vue-style-loader", "css-loader", "sass-loader"],
    include: path.resolve(__dirname, "../")
  });

  // Return the altered config
  return config;
};

src/components/button 创建 storybook 用例 button.story.js

import { storiesOf } from "@storybook/vue";
import MlButton from "./button.vue";
storiesOf("Button", module).add("Primary", () => ({
  components: { MlButton },
  template: '<ml-button type="primary">Button</ml-button>'
}));

package.json添加 npm script, 便于启动以及打包 storybook 服务

"scripts": {
 "dev": "start-storybook",
 "build:storybook": "build-storybook -c .storybook -o dist",
}

启动 storybook

yarn dev

有了 storybook, 我们不必再辛苦的写组件的用例, 也不用去搭建 webpack 的配置了~

项目打包

storybook 只是帮我们用来展示组件, 我们还需要打包组件, 让其他的项目使用.

首先在src目录下创建entry.js用来引入组件

export { default as MlButton } from "./components/button";

之后创建其他组件也需要添加到这个文件里面

src目录下创建index.js作为项目入口

// 引入样式
import "./styles/index.scss";
// 引入组件
import * as components from "./entry";

//创建 install 方法, 方法里面将所有组件注册到vue里面
const install = function(Vue) {
  Object.keys(components).forEach(key => {
    Vue.component(key, components[key]);
  });
};

// auto install
if (typeof window !== "undefined" && window.Vue) {
  install(window.Vue);
}

const plugin = {
  install
};

export * from "./entry";

export default plugin;

webpack 配置

build文件夹下创建webpack.base.js文件, 填入公用的一些配置

const VueLoaderPlugin = require("vue-loader/lib/plugin");

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules)/,
        use: {
          loader: "babel-loader"
        }
      },
      {
        test: /\.vue$/,
        use: {
          loader: "vue-loader"
        }
      }
    ]
  },
  plugins: [new VueLoaderPlugin()],
  externals: {
    vue: {
      root: "Vue",
      commonjs: "vue",
      commonjs2: "vue",
      amd: "vue"
    }
  }
};

将 vue 添加到 externals 里面, 这样就不会把vue一起打包进去了.

同个目录下面创建webpack.config.js作为打包入口

const path = require("path");
const merge = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base.js");

module.exports = merge(webpackBaseConfig, {
  entry: {
    main: path.resolve(__dirname, "../src/index")
  },
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "vue-uikit.js",
    library: "vue-uikit",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ["vue-style-loader", "css-loader", "sass-loader"]
      }
    ]
  }
});

package.json添加打包命令

"scripts": {
    "build": "webpack --mode production --config build/webpack.config.js"
 }

运行yarn build进行打包

验证打包文件

打包之后可以看到主项目多了一个lib目录, 里面有一个vue-uikit文件, 我们要先验证下这个文件是否有正常打包, 首先要改package.json的入口

  "main": "lib/vue-uikit.js"

这样在其他项目引入组件库的时候, 才可以知道入口在哪: lib/vue-uikit.js

在正式打包之前, 可以运行yarn link, 会有以下提示

success Registered "vue-uikit".
info You can now run `yarn link "vue-uikit"` in the projects where you want to use this package and it will be used instead.

你可以在自己的项目, 或者用 vue-cli 创建一个新的项目, 里面运行 yarn link "vue-uikit", 你的项目 node_modules 会临时添加你的组件库, 这时候就可以正常引入组建库了

<template>
  <div id="app">
    <ml-button type="primary">button</ml-button>
  </div>
</template>

<script>
import UIKit from "vue-uikit";
import Vue from "vue";
Vue.use(UIKit);

export default {
  name: "app"
};
</script>

按需加载

有时候页面只需要用到几个组件, 并不想引入整个组件库, 所以组件库最好能够实现按需加载的功能, 参考element-ui 的例子, 它用了一个 babel 组件: babel-plugin-component.

在你的项目引入这个 plugin 之后, 类似这样的代码

import { Button } from "components";

会被解析成

var button = require("components/lib/button");
require("components/lib/button/style.css");

但是之前我们打包的时候只打包了一个 lib/vue-uikit.js 文件, 组件库不进行相应的配置是没办法直接使用这个插件的, 在上面的代码我们可以看到它按需引用了 lib/button文件夹里的index.js文件, 还引用了相应的样式文件, 所以我们组件库打包也要按照组件分类打包到对应的文件夹, 还需要把样式提取出来.

我们先把组件分类打包, 首先在build/webpack.component.js创建相应的 webpack 配置

const path = require("path");
const merge = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base.js");

module.exports = merge(webpackBaseConfig, {
  entry: {
    "ml-button": path.resolve(__dirname, "../src/components/button/index.js")
  },
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "[name]/index.js",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ["vue-style-loader", "css-loader", "sass-loader"]
      }
    ]
  }
});

然后在package.json创建相应的脚本

"build:com": "webpack --mode production --config build/webpack.component.js"

之后运行 yarn build:com, 打包好之后, 可以看到当前的目录结构是这样的

lib
 - ml-button
   - index.js
 - vue-uikit.js

在你的主项目改一下引入的代码, 看是否可以正常使用

<template>
  <div id="app">
    <ml-button type="positive">button</ml-button>
  </div>
</template>

<script>
import MlButton from "vue-uikit/lib/ml-button";

export default {
  name: "app",
  components: {
    MlButton
  }
};
</script>

现在还有几个问题, webpack.component.js里面的entry是这样的

 entry: {
    "ml-button": path.resolve(__dirname, "../src/components/button/index.js")
  }

以后每次添加一个新组件的话还需要手动添加一个entry, 最好是能够按照文件目录自动生成, 还有按照babel-plugin-compoent的要求,我们是需要把样式文件提取出来的.

首先解决entry的问题, 我们可以安装glob包, 来获取匹配相应规则的文件 yarn add glob -D

const glob = require("glob");

console.log(glob.sync("./src/components/**/index.js"));

// [ './src/components/button/index.js' ]

通过对返回的数组进行处理, 就可以生成相应的entry

const entry = Object.assign(
  {},
  glob
    .sync("./src/components/**/index.js")
    .map(item => {
      return item.split("/")[3];
    })
    .reduce((acc, cur) => {
      acc[`ml-${cur}`] = path.resolve(
        __dirname,
        `../src/components/${cur}/index.js`
      );
      return { ...acc };
    }, {})
);

关于样式文件的提取, 可以安装 mini-css-extract-plugin包并进行配置:

yarn add mini-css-extract-plugin -D

最后的webpack.component.js是这样的

const path = require("path");
const merge = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base.js");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const glob = require("glob");

const entry = Object.assign(
  {},
  glob
    .sync("./src/components/**/index.js")
    .map(item => {
      return item.split("/")[3];
    })
    .reduce((acc, cur) => {
      acc[`ml-${cur}`] = path.resolve(
        __dirname,
        `../src/components/${cur}/index.js`
      );
      return { ...acc };
    }, {})
);

module.exports = merge(webpackBaseConfig, {
  entry: entry,
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "[name]/index.js",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "theme/[name].css"
    })
  ]
});

生成的样式文件会单独放在lib/theme文件夹.

打包完之后会发现主项目(引用组件库的项目)的样式不见了, 因为样式已经提取出来, import MlButton from 'vue-uikit/lib/ml-button' 是不会包含样式的, 需要重新引入:

<template>
  <div id="app">
    <ml-button type="positive">button</ml-button>
  </div>
</template>

<script>
import MlButton from "vue-uikit/lib/ml-button";
import "vue-uikit/lib/theme/ml-button.css";
export default {
  name: "app",
  components: {
    MlButton
  }
};
</script>

我们肯定不希望按需加载的时候每次新加一个组件就需要添加一次样式:

import MlButton from "vue-uikit/lib/ml-button";
import "vue-uikit/lib/theme/ml-button.css";
import MlMessage from "vue-uikit/lib/ml-message";
import "vue-uikit/lib/theme/ml-message.css";

这时候就需要babel-plugin-compnent的帮助了.

主项目(不是组件库)安装完babel-plugin-compnent之后, 在 babel 的配置文件添加如下代码

plugins: [
  [
    "component",
    {
      libraryName: "vue-uikit", //引入vue-uikit的时候会用此插件
      styleLibraryName: "theme" //样式文件在theme里面找
    }
  ]
];

重新运行主项目, 会发现有报错

* vue-uikit/lib/theme/base.css in ./node_modules/cache-loader/dist/cjs.js??ref--12-0!./node_modules/babel-loader/lib!./node_modules/cache-loader/dist/cjs.js??ref--0-0!./node_modules/vue-loader/lib??vue-loader-options!./src/App.vue?vue&type=script&lang=js&

提示无法找到theme/base.css文件, babel-plugin-compnent会默认引入所有组件需要的公共样式, 所有我们要重新改一下 webpack 配置

const entry = Object.assign(
  {},
  {
    base: path.resolve(__dirname, "../src/styles/index.scss")
  },
  glob
    .sync("./src/components/**/index.js")
    .map(item => {
      return item.split("/")[3];
    })
    .reduce((acc, cur) => {
      acc[`ml-${cur}`] = path.resolve(
        __dirname,
        `../src/components/${cur}/index.js`
      );
      return { ...acc };
    }, {})
);

打包之后发现多了一个lib/base文件夹, 这是因为 webpack 打包index.scss 的时候回默认生成一个 js 文件, 我们可以手动删掉或者用 webpack 组件, 这里我安装了一个rimraf

yarn add rimraf -D

重新改一下 npm script

 "scripts": {
    "build": "rimraf lib && yarn build:web && yarn build:com && rimraf lib/base",
    "build:web": "webpack --mode production --config build/webpack.config.js",
    "build:com": "webpack --mode production --config build/webpack.component.js"
  },

运行 yarn build, 这时候主项目应该可以正常运行了

样式优化

除了把样式文件抽取出来, 我们还可以做一些优化, 比如压缩和加 prefix

安装postcss和相关插件

yarn add postcss autoprefixer cssnano -D

autoprefix 用来添加前缀, cssnano用来压缩样式文件

安装完成之后在根目录添加相应的配置 postcss.config.js

module.exports = {
  plugins: {
    autoprefixer: {},
    cssnano: {}
  }
};

添加postcss-loader到 webpack 中.

另外, 最好把所有的样式都抽取成一个文件, 这样在全局引入组件库的时候,方便独立在 html 头部引入样式文件, 避免 FOUC 问题. 最后的 webpack 配置是这样的

webpack.component.js

const path = require("path");
const merge = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base.js");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const glob = require("glob");

const entry = Object.assign(
  {},
  {
    base: path.resolve(__dirname, "../src/styles/index.scss")
  },
  glob
    .sync("./src/components/**/index.js")
    .map(item => {
      return item.split("/")[3];
    })
    .reduce((acc, cur) => {
      acc[`ml-${cur}`] = path.resolve(
        __dirname,
        `../src/components/${cur}/index.js`
      );
      return { ...acc };
    }, {})
);

module.exports = merge(webpackBaseConfig, {
  entry: entry,
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "[name]/index.js",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "postcss-loader", //添加postcss-loader
          "sass-loader"
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "theme/[name].css"
    })
  ]
});

webpack.config.js

const path = require("path");
const merge = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base.js");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = merge(webpackBaseConfig, {
  entry: {
    main: path.resolve(__dirname, "../src/index")
  },
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "vue-uikit.js",
    library: "vue-uikit",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "postcss-loader",
          "sass-loader"
        ]
      }
    ]
  },
  plugins: [
    //把所有样式文件提取到index.css
    new MiniCssExtractPlugin({
      filename: "index.css"
    })
  ]
});

重新打包 yarn build

因为我们把样式提出起来, 全局引入需要另外引入样式

import Vue from 'vue' import UIkit from 'vue-uikit' import
'vue-uikit/lib/index.css' Vue.use(UIkit)

单元测试

单元测试我们会用到@vue/test-utils

yarn add jest @vue/test-utils jest-serializer-vue vue-jest babel-jest @babel/preset-env babel-core@^7.0.0-bridge.0 -D

  • jest-serializer-vue (快照测试)
  • babel-core@^7.0.0-bridge.0 (jest 用的不是 babel 7, 需要另外下载这个包兼容)

添加 jest 配置到根目录jest.config.js

module.exports = {
  moduleFileExtensions: ["js", "json", "vue"],
  transform: {
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
    ".*\\.(vue)$": "vue-jest"
  },
  snapshotSerializers: ["jest-serializer-vue"]
};

添加 babel 配置到根目录 .babelrc

{
  "presets": [
    "@babel/preset-env"
  ],
  "env": {
    "test": {
      "presets": [
        [
          "@babel/preset-env",
          {
            "targets": {
              "node": "current"
            }
          }
        ]
      ]
    }
  }
}

添加测试命令到 npm script: "test": "jest"

添加测试用例到src/components/button/button.test.js

import { shallowMount } from "@vue/test-utils";
import MlButton from "./button.vue";

describe("Button", () => {
  test("is a Vue instance", () => {
    const wrapper = shallowMount(MlButton);
    expect(wrapper.isVueInstance()).toBeTruthy();
  });

  test("positive color", () => {
    const wrapper = shallowMount(MlButton, {
      propsData: {
        type: "positive"
      }
    });
    expect(wrapper.classes("ml-button--positive")).toBeTruthy();
  });
});

运行yarn test

接下来最好把单元测试放到 precommit, 在每次代码提交之前检查一次

 "scripts": {
    "test": "jest",
    "lint": "pretty-quick --staged && eslint --ext .js,.vue src",
    "dev": "start-storybook",
    "build:storybook": "build-storybook -c .storybook -o dist",
    "build": "rimraf lib && yarn build:web && yarn build:com && rimraf lib/base",
    "build:web": "webpack --mode production --config build/webpack.config.js",
    "build:com": "webpack --mode production --config build/webpack.component.js"
  },
  "husky": {
    "hooks": {
      "pre-commit": "yarn test && yarn lint"
    }
  },

发布

先到 www.npmjs.com/ 注册账号, 注册完之后

在组件库登录

npm login

之后就可以发布了

npm publish