自然醒的博客

前端业务组件化实践

· shenfq

最近一直在做管理端相关的需求,管理端不比h5每天都有高流量,需要不断地做性能上的优化,以及适配不同设备兼容性。但是管理端也面临着自己的挑战,因为项目越来越大,可配置化的东西就越来越多,管理端的页面也就越多,同时面向不同的用户群也催生出了不同管理端,这就导致相同的业务组件在不同项目、不同页面中不停地被copy,一旦组件出现改动,就需要打开多个项目进行修改,出现遗漏还得背锅。

毋庸置疑,管理端是有很多优秀的组件库的,比如ElementUI、iView、Antd,但是这些组件库仅仅提供了很基础的组件(比如,表单、表格、弹窗)。那么我们是不是可以把这些多个地方使用的业务组件也进行封装,打包成NPM包放到公司的私有源上,然后通过tnpm进行安装,当组件出现变动的时候,只需要进行tnpm update component即可。这样做的好处就是提高代码的复用性,并且组件有一个专门的仓库进行维护,所有内部管理端项目都能引入这个组件库。

可能有人会问,管理端中真的会有很多公共的业务组件吗,不同的管理端端之间肯定会有不同的特殊需求如何保证组件的一致性?首先我目前所做的属于电商业务,做电商相关的东西都会有很多业务组件,比如商品选择器、excel批量导入、文件上传等等。再者管理端组件的通用性远高于面向用户侧的组件,因为不管是中秋节、端午节、国庆节,选择商品的弹窗大致逻辑与样式基本不变,而对于面向用户侧的前端组件,不同节日,不同玩法,组件样式肯定会有变动,但是基础的业务逻辑还是能够进行抽象复用,这里不继续展开讨论。

什么是组件§

这个问题可能比较抽象,每个人对组件可能都有不同的理解,针对组件的颗粒度、通用性回答也会千奇百怪。其实组件一直都存在,没有前端的那个年代,大家管它叫控件,想要在pc端做个软件直接去拖一块一块的控件放到某个位置就好了。在C/S架构的软件向B/S迁移的过程中,控件的概念也慢慢延伸到了Web前端。对于现代前端框架,封装好的组件对于外部来说只是一个自定义的标签,标签可以进行属性和事件的自定义,所以狭义得说,前端组件只能说是一个个的自定义标签,有其固有的样式和自定义的属性与事件。

关于组件的设计,有一个基本原则:一个组件只做一件事,且把这件事做好。

通俗来讲,就是基础组件尽量做到细颗粒度的拆分,对一件事的定义可大可小,往小了说,一个输入框、一个按钮,这就是一个基础组件,他们就只做一件事那就是输入和点击。而我们的业务组件就是要把这些小组件组成一个大组件,比如由弹窗+输入框+按钮+穿梭框组成的一个商品选择器,本质上也只做一件事,那就是选择商品。

封装前:
<template>
  <el-dialog>
    <el-form>
      <el-input></el-input>
      <el-input></el-input>
	  <el-button>查询</el-button>
	  <el-button>批量导入</el-button>
	  <el-button>下载模版</el-button>
    </el-form>

    <Transfer>
      <el-button>取消</el-button>
      <el-button>下一步</el-button>
    </Transfer>
  </el-dialog>
</template>

封装后:
<select-goods></select-goods>

所以一个业务组件的形成其实就是对一些基础组件的组装,当我们有一系列逻辑代码和标签需要在多个地方使用的时候,就要考虑把它封装成一个新的组件,提升代码的复用性。

技术选型§

需要构建业务组件库,底层肯定是要基于前端框架和基础组件库的。

目前的三大前端框架都支持组件化的功能,只是内部的原理有些差异,比如React、Vue的组件化都是基于虚拟DOM。对于底层选择什么框架,其实问题不大,跟随团队方向就好,如果团队的项目都使用Vue,你偏偏要使用Angular,但是可能其他同事对这个框架都不熟悉,需要一定的学习成本,这肯定不是一个好选择。由于我们团队项目基本使用Vue,所以我们底层框架选用Vue,基础组件库使用ElementUI。接下来的业务组件库的搭建都是在此基础上进行的。

项目搭建§

先看看项目目录,目录结构有参考ElementUI,具体可以看我之前写的文章:《ElementUI构建流程》

├─build  构建相关的脚本
├─docs  组件文档
├─examples  组件示例
├─packages  组件目录,每个组件一个单独文件夹
│  ├─componentA
│  │  └─src
│  ├─componentB
│  │  └─src
│  └─componentC
│  │  └─src
│  └─...
├─src  // 组件的入口文件以及一些工具方法
│  └─main.js
└─components.json //组件列表

既然最后要发布为NPM包,package.json肯定必不可少的,这其中有几个配置需要注意。

peerDependencies§

// package.json
"peerDependencies": {
    "element-ui": "^2.4.6"
}

在我们实际的应用中,使用得比较多的就是dependencies和devDependencies。但是peerDependencies,很少需要使用,因为这个依赖一般只要做插件开发时才会经常使用。有这种依赖意味着安装包的用户需要同时安装这些依赖,从npm@3.0开始不会自动帮你安装,需要你的应用中的dependencies或devDependencies中也有同样的依赖。这里我们的组件库需要依赖element-ui,同时element-uipackage.json也同步依赖了vue。

main§

这个字段表示库被引入时,默认引入的js文件是哪个。这里我们设置为src/main.js,该文件通过脚本自动生成。

const components = require('my-components')

//实际引入的js为

const components = require('node_modules/my-components/src/main.js')

files§

这个字段表示发布到NPM上时,只发布哪些指定文件夹/文件。

"files": [
    "src",
    "packages",
    "README.md",
    "components.json"
]

当然你也可以在项目根目录配置.npmignore文件,写法与.gitignore一样,作用就是publish到NPM上时要进行忽略的文件。但是下面文件是会默认在files字段中的,即使你加入到.npmignore文件也无法被忽略。

还有一些文件是默认被npm忽略的。

在定义好目录结构和pkg.json后,我们就需要构建组件库的入口文件。因为组件库是在Vue的基础上构建,那么肯定要符合Vue插件的写法,入口js需要对外暴露一个install方法,用于将所有业务组件注册为全局组件,这样只需要引入之后进行use。

// main.js
import componentA from '../packages/componentA/index.js'
import componentB from '../packages/componentB/index.js'
import componentC from '../packages/componentC/index.js'

const components = [
  componentA,
  componentB,
  componentC
]

const install = function(Vue, opts = {}) {
  components.map(component => {
    Vue.component(component.name, component)
  })
}

export default {
  install, // 对外暴露install方法
  componentA,
  componentB,
  componentC
}

-------------------------------------

// 使用方式
import Vue from 'vue'
import ElementUI from 'element-ui'
import Components from 'my-components'
Vue.use(ElementUI)
Vue.use(Components)

这样做使用的时候很方便,但是每次新增组件就需要手动修改这个main.js,有没有办法让这个文件自动生成呢?当然是有的,我们可以手动维护一个components.json文件,用来存储所有组件名和组件路径。

{
  "componentA": "../packages/componentA/index.js",
  "componentB": "../packages/componentB/index.js",
  "componentC": "../packages/componentC/index.js"
}

然后根据这个json文件和模版文件进行入口文件的生成,提高效率。

// build/entry.js
const fs = require('fs')
const path = require('path')

const endOfLine = require('os').EOL;
const uppercamelcase = require('uppercamelcase')
const Components = require('../components.json')
const ComponentNames = Object.keys(Components)
const OUTPUT_PATH = path.join(__dirname, '../src/main.js')

let includeComponentTemplate = []
let installTemplate = []

ComponentNames.forEach(name => {
  var componentName = uppercamelcase(name);

  includeComponentTemplate.push(
	`import ${componentName} from '${Components[name]}'`
  )

  installTemplate.push(
	`  ${componentName}`
  )
})

const MAIN_TEMPLATE = `
${includeComponentTemplate.join(endOfLine)}

const components = [
${installList}
]

const install = function(Vue, opts = {}) {
  components.map(component => {
    Vue.component(component.name, component)
  })
}

export default {
  install,
${installTemplate.join(',' + endOfLine)}
}
`;

fs.writeFileSync(OUTPUT_PATH, MAIN_TEMPLATE);
console.log('[build entry] DONE:', OUTPUT_PATH);

新增组件之后,只需要执行node build/entry.js即可,同时也可以将这个命令添加到pkg的scripts中。

"scripts": {
  "build:entry": "node build/entry.js"
}

组件的新增与构建§

用components.json和js脚本的方式进行入口文件的生成,可以一旦程度上提高代码维护性,但是一旦有组件新增还是得修改components.json文件,还是比较繁琐。这里我们也可以通过js脚本的方式来新增组件。

// build/new.js

'use strict'

// 读取命令行参数
if (!process.argv[2]) {
	console.error('[组件名]必填')
	process.exit(1)
}

const path = require('path')
const fileSave = require('file-save')
const uppercamelcase = require('uppercamelcase')
const componentname = process.argv[2]
const ComponentName = uppercamelcase(componentname) // 转为驼峰表示
const PackagePath = path.resolve(__dirname, '../packages', componentname)
const Files = [
	{
		filename: 'index.js',
		content: `
import ${ComponentName} from './src/index.vue'
${ComponentName}.install = function (Vue) {
  Vue.component(${ComponentName}.name, ${ComponentName})
}
export default ${ComponentName}
`
	},
	{
		filename: 'src/index.vue',
		content: `
<template>
  <div></div>
</template>
<script>
export default {
  name: 'Cm${ComponentName}'
};
</script>
`
	},
	{
		filename: `../../docs/${componentname}.md`,
		content: `## ${ComponentName}`
	}
];

// 添加到 components.json
const componentsFile = require('../components.json')

if (componentsFile[componentname]) {
	console.error(`${componentname} 已存在.`)
	process.exit(1);
}
componentsFile[componentname] = `../packages/${componentname}/index.js`

const componentsPath = path.join(__dirname, '../components.json')
fileSave(componentsPath)
.write(JSON.stringify(componentsFile, null, '  '), 'utf8')
.end('\n')
console.log('modified: ', componentsPath)

// 创建 package
Files.forEach(file => {
	let filePath = path.join(PackagePath, file.filename)
	fileSave(filePath)
	.write(file.content, 'utf8')
	.end('\n')
	console.log('created: ', filePath)
})

console.log('DONE!')

--------------------------------------------

// package.json
"scripts": {
  "build:entry": "node build/build.entry.js",
  "new": "node build/new.js"
},

最后运行命令添加到npm scripts中,现在新增一个组件只需要运行一条命令即可,比如我要新增商品选择器组件(select-goods)。

npm run new select-goods

这里主要做了下面三件事。

  1. 修改components.json文件,如果某个组件存在,直接退出进程。
  2. 在packages目录下新建组件文件夹,并新建index.jssrc/index.vue
  3. 在doc文件夹下新建一个组件的markdown文件,用来编写该组件的使用文档。

有一点需要注意,组件的index.vue文件中的name字段,都是以Cm打头(name: 'Cm${ComponentName}'),这里主要是为了更符合HTML5的自定义组件规范,让我们的组件都带有一个小横线-。比如我们新建的组件名为select-goods,组件名就为CmSelectGoods,实际使用时为<cm-select-goods />

添加好新的组件,然后生成入口文件,接下来只要通过webpack打包后发布到NPM包就可以了,但是我们在这一步是没有做的,而是选用了后编译模式。

后编译组件§

即组件在发布到NPM包的时候,不进行编译,而是和实际引入组件库的项目一起交给webpack打包,这里参考了滴滴的cube-ui的做法(去看看)。

所以我们在组件的package.json的main字段处的值为src/main.js,这是整个组建的入口文件,而不是经过webpack或者rollup打包后的模块。这样做有几个好处。

首先当我们的组件库依赖了某个包,比如:lodash,然后我们的项目也依赖了lodash。如果我们的组件库在发布到NPM之前已经进行了打包,我们在项目中引入的又是打包后的文件,那么项目会再次把lodash打包一次,因为项目不知道我们的组件依赖了lodash,明显造成了资源浪费。

除了依赖包,一些工具方法也可以放到组件库中,使用的时候直接进行require,这样就不用在多个项目中来回copy代码,毕竟业务代码库,大部分工具方法都是通用的。

其次组件库与业务代码使用同一babel版本进行代码转换,可以保持代码的一致性。同时在业务代码中直接使用babel-polyfill,减少两次打包多次引入某些es6api的polyfill。

说了这么多,要做到后编译其实很简单,只需要发布到NPM上时,不进行webpack打包。然后在项目的webpack的babel-loader的inclues添加组件库的路径即可,省时又省力。

{
  test: /\.js$/,
  loader: 'babel-loader',
  include: [
    resolve('src'),
    resolve('node_modules/my-components')
  ]
}

组件的文档§

之前我们在新增组件的时候,为每个组件都在docs文件夹下新建了一个markdown文件。如果每次都要下载项目到docs目录下查看文档对开发者不太友好,那么我们可以将markdown文件转为vue组件,然后部署到demo网站上。这样就只要编写好markdown文档,就能直接同步在demo网站同步显示。这里也是参考的ElementUI的做法,使用vue-markdown-loader将markdown文件转为vue组件。

{
	test: /\.md$/,
	use: [
		{
			loader: 'vue-loader'
		},
		{
			loader: 'vue-markdown-loader/lib/markdown-compiler',
			options: {}
		}
	]
}

该插件通过markdown-it进行markdown文件解析,同时可以自定义markdown-it插件,进行一些个性化的markdown写法,比如代码高亮、可执行的代码,具体使用方式可以参考ElementUI的webpack配置

组件如何发布§

因为我们要发布到tnpm上,首先需要创建一个tnpm账户,通过tnpm adduser进行账户创建。创建好账户之后,发布到tnpm上,需要使用tnpm publish

发布到tnpm因为是公司的内源,所有pkg的name字段一定要包名前面加上@tencent/

"name": "@tencent/my-components"

发布了包之后,只有发布者有权限更新这个包,这个时候需要添加其他用户到这个包,可以使用如下命令:

 tnpm owner add rtx_at_tencent @tencent/my-components

发布如果有很多步骤,可以通过shell脚本的方式进行发布,下面这个是我们目前项目中使用的。

#!/usr/bin/env bash

set -e

if test -n "$(git status --porcelain)"; then
  echo 'push main.js to remote.' >&2;
  git add src/main.js
  git commit -m "feat: push main.js to remote."
  git push
fi

echo "Enter release version: "
read VERSION

read -p "Releasing $VERSION - are you sure? (y/n)" -n 1 -r
echo    # (optional) move to a new line
if [[ $REPLY =~ ^[Yy]$ ]]
then
  npm version $VERSION --message "[release] $VERSION"
  tnpm publish
fi

总结§

更多组件化相关知识可以看看民工叔15年的文章:《Web应用组件化的权衡》,看完之后很有收获,大佬就是大佬,15年写的东西至今一点都不显得过时。

在基础组件层封装好业务组件后,下一步是否就能够使用拖拽方式,将组件拼接直接生成页面呢?还记得最开始说的,一个组件只做一件事,但是这件事可大可小,如果一个页面做的事就是新建商品、编辑商品,那页面也是一个大组件,最后也是在进行组件的拼接。既然写代码做组件拼接,为什么不能直接用拖拽的方式进行组件拼接呢?这个可能就是我们下一步要做的事情了。