博客
博客
文章目录
  1. 什么是组件
  2. 技术选型
  3. 项目搭建
    1. peerDependencies
    2. main
    3. files
  • 组件的新增与构建
    1. 后编译组件
  • 组件的文档
  • 组件如何发布
  • 总结
  • 前端业务组件化实践

    最近一直在做管理端相关的需求,管理端不比 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> 查询 < span class="tag"></el-button>
    <el-button> 批量导入 < span class="tag"></el-button>
    <el-button> 下载模版 < span class="tag"></el-button>
    </el-form>

    <Transfer>
    <el-button> 取消 < span class="tag"></el-button>
    <el-button> 下一步 < span class="tag"></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 文件也无法被忽略。

    • package.json
    • README
    • CHANGES / CHANGELOG / HISTORY
    • LICENSE / LICENCE
    • NOTICE
    • “main” 字段的文件

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

    • .git、.svn
    • .*.swp
    • .DS_Store
    • .npmrc
    • node_modules
    • npm-debug.log
    • package-lock.json

    在定义好目录结构和 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 年写的东西至今一点都不显得过时。

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

    支持一下
    扫一扫,支持一下
    • 微信扫一扫
    • 支付宝扫一扫