手摸手教你封装跨项目复用的 Vue 组件

8,750 阅读7分钟

在前端项目的开发中,往往会根据业务需求,沉淀出一些项目内的UI组件/功能模块(以下通称组件) 等;这些组件初期只在同一个项目中被维护,并被该项目中的不同页面或模块复用,此时的组件逐步被完善,是一个只聚焦于功能和健壮性的成长期。

随着业务的发展,原来的项目可能不得不产生裂变,变成几个相似但各有不同的项目 -- 比如在初始项目中积累经验后,需要推广到相似的业态上或根据不同大客户的需求进行定制,这种情况下往往很难理想化的保持各项目大版本或者后续发展进度的同步,只能逐渐各自发展。这时那些在一开始显得八面玲珑的“可复用组件”,往往就需要手忙脚乱的在各个项目中分头维护,或是出现了意想不到的问题,需要重新规划了。

本文以 Vue 技术栈的前端项目为例,尝试简单的探讨一种抽象提取跨项目可复用组件的方法。

可复用组件的常见现状

  • 组件的复用局限在单个项目中
  • 一次开发,n 次复
  • 项目的裂变让问题成倍放大,每个修正/改动要同步 n 次
  • 兄弟项目的依赖库可能相似但不同,或版本差距较大
  • 单元测试环境或版本的不同也让组件的复用带来问题

关于同一组件在不同项目中的区别方面,以一个二次封装 element-ui 中 el-date-picker 的 DateRange.vue 组件举例:

所在项目基础组件库发现的代表性问题
A element-ui@v1.x
  • picker 的结果显示,在底层 dom 中由单个 input 实现,异于最新版本组件库
  • el-date-picker 尚不支持 value-format 属性
B
C
D
E element-ui@v2.x
  • 内部组件的 v-model 不能正确触发回调,需要用 watch 修正
  • 这几个项目中,还需要添加一些附加的样式
F

由于种种原因,几个项目依赖的 UI 库相似但并不相同;且项目体量过大、维护的团队不同等等,都让统一基础组件库变得🐔乎不可能~ 🐔 你太美,这就很尴尬了嘛~

如何收敛维护点?

  • 仅以例子中的几个项目来说,维护点就在 6 个,工作量×6
  • 如果 收敛到一个统一的库 中,则维护点变为 2 个,仅需区分基础版本库的差别
  • 而大部分较简单的组件,基础组件库的版本不同并不会造成差异的,或是根本没有引用 element-ui 组件库的简单组件,则维护点直接能缩减到 1 个

什么样的组件是通用的?

  • 足够抽象,不包含业务逻辑,或扩展性足够好
  • 尽量不包含 $t$router 等和项目环境有关的依赖
  • 有覆盖率足够高的单元测试
  • 有必要的文档,或通过单元测试描述了足够完整的功能
  • 最好也提供可运行的例子

发布到 npm

在某一个具体项目内,对组件只需引用其源码即可;

对于跨项目的通用组件库,一种方法是在各项目内部维护一个指向组件库源码的子模块(git 的 subtree 或 submodule),但这种方法维护比较麻烦,故不常用。

另一种我们比较习惯的方式是通过 npm 安装后直接引用组件的注册名称(package.json 中的 name)。

当然如果自己的组件多少还是关乎业务逻辑、对外部的项目其实也没那么通用,而公司内部又维护有 npm 的镜像,那么选择将其发布到这个内部环境中也是可以的。

发布 npm 组件的主要步骤:

在 npmjs.com 上注册用户,或通过命令行:

npm adduser

发布前确认登录:

npm login

发布前手动更改 package.json ,或用命令行更新项目版本号,注意每次发布的版本号不能相同:

npm version x.x.x

执行发布:

npm publish

直接在命令行中打开项目主页查看:

npm home [name]

更多的命令参见官方的完整文档: docs.npmjs.com/cli-documen…

另外需要注意的是,正确配置 package.json 里的 repository 字段,可以在组件的 npm 主页上显示代码仓库的链接。

用 rollup 而不是 webpack 打包组件

本例中选择了 rollup 作为打包工具:

  • webpack 虽然功能强大,但配置复杂、生成的代码冗余较多
  • rollup 更适用于库、组件等类型源码的编译
  • rollup 基于插件扩展打包功能,且配置相对简单
  • rollup 的配置项和 webpack 高度相似,便于迁移和适应

一套基本的配置

假设组件库结构规划如下:

├─.babelrc
├─.eslintignore
├─.eslintrc.js
├─.gitignore
├─CHANGELOG.md
├─jest.config.js
├─package.json
├─README.md
├─postcss.config.js
├─rollup.config.js
├─dist/
├─example/
├─node_modules/
├─src/
├─__mocks__/
└─__tests__/

最小化的 npm scripts 如下:

// package.json

"scripts": {
  "build": "rollup --config"
},

较基础的 rollup 配置如下:

// rollup.config.js

import path from 'path';
import json from 'rollup-plugin-json';
import { uglify } from 'rollup-plugin-uglify';
import alias from 'rollup-plugin-alias';
import vue from 'rollup-plugin-vue';
import babel from 'rollup-plugin-babel';
import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import nodeGlobals from 'rollup-plugin-node-globals';
import bundleSize from 'rollup-plugin-filesize';
import { eslint } from 'rollup-plugin-eslint';
import pkg from './package.json';

const pathResolve = p => path.resolve(__dirname, p);

const extensions = ['.js', '.vue'];

module.exports = {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.min.js',
    format: 'umd',
    name: 'MyComponents',
    globals: {
      vue: 'Vue',
      echarts: 'echarts',
      lodash: 'lodash'
    },
    sourcemap: true
  },
  external: Object.keys(pkg.dependencies),
  plugins: [
    resolve({
      extensions,
      browser: true
    }),
    eslint({
      extensions,
      exclude: ['**/*.json'],
      cache: true,
      throwOnError: true
    }),
    bundleSize(),
    commonjs(),
    nodeGlobals(),
    vue({
      template: {
        isProduction: !process.env.ROLLUP_WATCH,
        compilerOptions: { preserveWhitespace: false }
      },
      css: true
    }),
    babel({
      exclude: 'node_modules/**'
    }),
    alias({
      '@': pathResolve('src')
    }),
    json(),
    uglify()
  ]
};

关于该配置,简要说明如下:

  • 上例中插件的顺序是重要的
  • node-globals 插件会将 process 等变量注入打包后的文件
  • eslint 插件会在打包之前检查语法,并且基本能复用平时项目中的 .eslintrc.js 配置文件
  • bundleSize 插件用来在打包后显示目标文件的体积
  • vue 插件中的 css 字段,表示是否将内嵌样式打包到目标 js 中
  • 继续使用 babel,而不是也经常和 rollup 搭配的更轻量的 buble 来编译 ES6 代码,目的也是和 jest 复用
  • json 组件解决源码中可能会直接导入 json 文件的情况
  • external 配置的意思是:package.json 中 dependencies 包含的依赖,都不被打包到组件中,而是需要在具体项目中安装

相关的语法转换和语法检查配置:

// .babelrc

{
  "presets": [["env", { "modules": false }]],
  "env": {
    "test": {
      "presets": [["env", { "targets": { "node": "current" } }]]
    }
  }
}
// .eslintignore

__tests__/*
*.css
// .eslintrc.js

module.exports = {
  extends: [
    "airbnb-base",
    'plugin:vue/essential'
  ],
  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    'no-debugger': 'error',
    'space-before-function-paren': 'off',
    'no-underscore-dangle': 'off',
    'no-param-reassign': 'off',
    'func-names': 'off',
    'no-bitwise': 'off',
    'prefer-rest-params': 'off',
    'no-trailing-spaces': 'off',
    'comma-dangle': 'off',
    'quote-props': 'off',
    'consistent-return': 'off',
    'no-plusplus': 'off',
    'prefer-spread': 'warn',
    semi: 'warn',
    indent: 'warn',
    'no-tabs': 'warn',
    'no-unused-vars': 'warn',
    quotes: 'warn',
    'no-void': 'off',
    'no-nested-ternary': 'off',
    'import/no-unresolved': 'off',
    'no-return-assign': 'warn',
    'linebreak-style': 'off',
    'prefer-destructuring': 'off',
    'no-restricted-syntax': 'warn'
  },
  parserOptions: {
    parser: 'babel-eslint'
  }
}

配置单元测试环境

维护点收敛到了一个库中,需要注意的是,相应的风险也高度集中了,可谓一损俱损一荣俱荣🐶。

所以单元测试也愈发重要起来,库里的组件或模块,凡是有条件的(比如 Vue 中的 directives 就没那么好做单元测试,但 filters 纯函数很容易),想要让各个项目的开发者小伙伴们放心大胆的统一引用,就应该无条件的买一送一,搭配完善的单元测试。

这里以 jest 为例,列举其主要配置:

// jest.config.js

module.exports = {
  modulePaths: [
    '<rootDir>/src/'
  ],
  moduleFileExtensions: [
    'js',
    'json',
    'vue'
  ],
  transform: {
    '^.+\\.vue$': 'vue-jest',
    '^.+\\.jsx?$': 'babel-jest'
  },
  transformIgnorePatterns: [
    '/node_modules/'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss)$': '<rootDir>/__mocks__/emptyMock.js'
  },
  snapshotSerializers: [
    'jest-serializer-vue'
  ],
  collectCoverage: true,
  collectCoverageFrom: [
    '<rootDir>/src/**/*.{js,vue}',
    '!**/node_modules/**'
  ],
  coveragePathIgnorePatterns: [
    '<rootDir>/__tests__/'
  ],
  coverageReporters: [
    'text'
  ]
};

其中 emptyMock.js 是用来在用例中忽略对样式的引用的:

// __mocks__/emptyMock.js

module.exports = {};

对应的 npm scripts:

"scripts": {
  // ...
  "test": "jest"
},
"pre-commit": [
  "test"
],

这里用 pre-commit 包实现了提交前先进行单元测试的钩子功能。

关于 Vue 单元测试的更多内容请参考这篇文章

预览组件实际效果

光说不练假把式,虽然静态语法也检查了、单元测试也跑通了,还是眼见为实比较踏实,对其他开发者也比较直观;借助 rollup-plugin-serve 等插件,可以运行起一个最小配置的浏览器运行环境,人肉看看组件的实际表现。

在 npm scripts 中设置环境参数,分别对完全通用的组件,及适用于特定类型项目的组件启动 demo 页面服务:

"scripts": {
  // ...
  "dev:common": "rollup --watch --config rollup.config.dev.js --environment PROJ_ENV:common",
  "dev:A": "rollup --watch --config rollup.config.dev.js --environment PROJ_ENV:A",
  "dev:B": "rollup --watch --config rollup.config.dev.js --environment PROJ_ENV:B",
},

适当修改 rollup 原配置,增加单独的 rollup.config.dev.js,根据环境参数启动服务:

import serve from 'rollup-plugin-serve';
import postcss from 'rollup-plugin-postcss';
import baseConfig, {
  pathResolve,
  browserGlobals
} from './rollup.config.base';

...

const PORT = 3001;
const PROJECT = process.env.PROJ_ENV;

export default {
  input: pathResolve(`example/${PROJECT}/main.js`),
  output: {
    file: pathResolve(`example/${PROJECT}/dist/example.bundle.${PROJECT}.js`),
    format: 'umd',
    name: 'exampleApp',
    globals: browserGlobals,
    sourcemap: false
  },
  plugins: [
    postcss(),
    ...baseConfig.plugins,
    serve({
      port: PORT,
      contentBase: [
        pathResolve(`example/${PROJECT}`)
      ]
    })
  ]
};

这里假设简单粗暴的都把组件引用到 App.vue 中,暂不考虑分路由等情况,对应的 example 目录的结构可能如下:

+---A
|   |   App.vue
|   |   index.html
|   |   main.js
|   |
|   \---dist
|           example.bundle.A.js
|
+---B
|   |   App.vue
|   |   index.html
|   |   foo.css
|   |   main.js
|   |
|   \---dist
|           example.bundle.B.js
|
\---common
    |   App.vue
    |   index.html
    |   main.js
    |
    +---dist
    |       example.bundle.common.js
    |
    \---fonts

总结

同时维护几个同质化的前端项目时,不可避免的涉及到一些较通用的 UI组件/功能模块 的情况,将其集结后发布到 npm 上,并辅以完善的单元测试和可运行的 demo 展示、必要的文档,就能将维护组件的工作量大大减轻。



--End--

搜索 fewelife 关注公众号

转载请注明出处