如何从头到尾做一个UI组件库

首先我们我们的这个ui组件库是开发的vue版本,如果需要变通其他版本的话,就把vue相关的编译器移除,其他相关的编译器加上就可以了(例如react),打包构建方式是通用的。 组件库的组织发版和项目是不太一样的,这里提下思路。
首先我们要确定我们做的库要满足的需求:

  1. 支持全部加载
  2. 支持按需加载
  3. ts的补充类型支持
  4. 同时支持cjs和esm版本
知道了我们要支持的需求之后,要确定一下我们最后包的目录结构是什么样的,如下:
如何从头到尾做一个UI组件库
文章图片

这简单描述下为何是这样的结构,首先index.esm是 我们整全量的包,里面包含了所有的ui组件,还有一个index.cjs版本,在打包工具不支持esm时会使用cjs版本,两个版本可以更好的支持不同的打包工具。
lib下放的是我们单个组件,用来结合 babel-plugin-import 来做按需加载。
这里先简单做一个概括,后续实现的时候会做详细的解释。
好了,了解了 我们最后的项目结构,就要开始ui库的搭建了,后续所有的操作配置,都是为了在保证程序健壮性通用性的基础上来打出来我们最后要发布的这个包的结构。
设计及流程 代码的组织方式Monorepo
Monorepo 是管理项目代码的一个方式,指在一个项目仓库(repo) 中管理多个模块/包(package),不同于常见的每个模块建一个repo。
例如:
├── packages |├── pkg1 ||├── package.json |├── pkg2 ||├── package.json ├── package.json

这样的结构,可以看到这些项目的第一级目录的内容以脚手架为主,主要内容都在 packages 目录中、分多个package进行管理。
一些知名的库例如vue3.0和react都是采用这种方式来管理项目的。
如何从头到尾做一个UI组件库
文章图片

如何从头到尾做一个UI组件库
文章图片

后续我们会根据这些packages里的小包,来生成按需加载的文件。
包的管理工具采用yarn,因为我们要用到它的workspaces依赖管理
如果不用workspaces时,因为各个package理论上都是独立的,所以每个package都维护着自己的dependencies,而很大的可能性,package之间有不少相同的依赖,而这就可能使install时出现重复安装,使本来就很大的 node_modules 继续膨胀(这就是「依赖爆炸」...)。
为了解决这个问题在这里我们要使用yarn的workspaces特性,这也就是依赖管理我们为什么使用yarn的原因。
而使用yarn作为包管理器的同学,可以在 package.json 中以 workspaces 字段声明packages,yarn就会以monorepo的方式管理packages。
使用方式详情可以查看它的官方文档
文档
我们在package.json开启了yarn 的workspaces工作区之后,当前这个目录被称为了工作区根目录,工作区并不是要发布的,然后这会我们在下载依赖的时候,不同组件包里的相同版本的依赖会下载到工作区的node_modules里,如果当前包依赖的版本和其他不一样就会下载到当前包的node_modules里。
yarn的话突出的是对依赖的管理,包括packages 的相互依赖、packages 对第三方的依赖,yarn 会以semver 约定来分析dependencies 的版本,安装依赖时更快、占用体积更小。
lerna
这里简单提一下lerna,因为目前主流的monorepo解决方案是Lerna 和 yarn 的 workspaces 特性,它主要用来管理工作流,但是它个人感觉如果你需要一次性发布packages里的所有包时,用它会比较方便,我们这里没有过多的用到它。
Storybook开发阶段的调试
组件效果的调试和使用介绍我们通过Storybook来进行管理,这是一个可视化的组件展示平台,它可以让我们在隔离的开发环境 交互地开发和测试组件,最后也可以生成使用说明的静态界面,它支持很多框架例如:vue.react,ng,React Native等。
jest单元测试
单元测试的话我们使用Facebook的jest
plop创建相同模版
我们包的结构是这样的,例如avatar:
├── packages |├── avatar ||├── __test__//单元测试文件 ||├── src //组件文件 ||├── stories //storyBook 开发阶段预览的展示,扫描文件 ||├── index.ts //包入口 ||├── LICENSE ||├── README.MD ||├── package.json ├── package.json

每个UI组件的结构基本都是一样的,所以在这里我们选用plop来统一生成模版,plop主要用于创建项目中特定文件类型的小工具,类似于Yeoman中的sub generator,一般不会独立使用。一般会把Plop集成到项目中,用来自动化的创建同类型的项目文件。
Rollup进行打包
最后是构建操作,这里我们打包不使用webpack,而是用Rollup,。
webpack的话更适合项目工程使用,因为项目里很多静态资源需要处理,再或者构建的项目需要引入很多CommonJS模块的依赖,这样虽然它也有摇树的功能tree-shaking(额外配置),但是因为要处理转换其他文件所以它打出来的包还是会有一些冗余代码。
而rollup 也是支持tree-shaking的,而且它主要是针对js打包使用,它打包结果比webpack更小,开发类库用它会更合适。
下面讲下构建过程: 首先我贴一个我最后完整版本的依赖,如下:
{ "name": "c-dhn-act", "version": "1.0.0", "main": "index.js", "license": "MIT", "gitlabGroup": "component", "devDependencies": { "@babel/cli": "^7.13.16", "@babel/core": "^7.11.4", "@babel/plugin-transform-runtime": "^7.13.15", "@babel/preset-env": "^7.11.5", "@babel/preset-typescript": "^7.13.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^8.4.0", "@storybook/addon-actions": "6.2.9", "@storybook/addon-essentials": "6.2.9", "@storybook/addon-links": "6.2.9", "@storybook/vue3": "6.2.9", "@types/jest": "^26.0.22", "@types/lodash": "^4.14.168", "@vue/compiler-sfc": "^3.1.4", "@vue/component-compiler-utils": "^3.2.0", "@vue/shared": "^3.1.4", "@vue/test-utils": "^2.0.0-rc.6", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^26.6.3", "babel-loader": "^8.2.2", "babel-plugin-lodash": "^3.3.4", "cp-cli": "^2.0.0", "cross-env": "^7.0.3", "css-loader": "^5.2.6", "http-server": "^0.12.3", "inquirer": "^8.0.0", "jest": "^26.6.3", "jest-css-modules": "^2.1.0", "json-format": "^1.0.1", "lerna": "^4.0.0", "plop": "^2.7.4", "rimraf": "^3.0.2", "rollup": "^2.45.2", "rollup-plugin-alias": "^2.2.0", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-vue": "^6.0.0", "sass": "^1.35.1", "sass-loader": "10.1.1", "storybook-readme": "^5.0.9", "style-loader": "^2.0.0", "typescript": "^4.2.4", "vue": "3.1.4", "vue-jest": "5.0.0-alpha.5", "vue-loader": "^16.2.0" }, "peerDependencies": { "vue": "^3.1.x" }, "scripts": { "test": "jest --passWithNoTests", "storybookPre": "http-server build", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook --quiet --docs -o ui", "lerna": "lerna publish", "buildTiny:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.tiny.js", "buildTiny:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.tiny.js", "clean": "lerna clean", "plop": "plop", "clean:lib": "rimraf dist/lib", "build:theme": "rimraf packages/theme-chalk/lib && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib dist/lib/theme-chalk && rimraf packages/theme-chalk/lib", "build:utils": "cross-env BABEL_ENV=utils babel packages/utils --extensions .ts --out-dir dist/lib/utils", "buildAll:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.all.js", "buildAll:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.all.js", "build:type": "node buildProject/gen-type.js", "build:v": "node buildProject/gen-v.js", "build:dev": "yarn build:v && yarn clean:lib&& yarn buildTiny:dev && yarn buildAll:dev && yarn build:utils && yarn build:type && yarn build:theme", "build:prod": "yarn build:v && yarn clean:lib&& yarn buildTiny:prod && yarn buildAll:prod && yarn build:utils&& yarn build:type && yarn build:theme" }, "dependencies": { "comutils": "1.1.9", "dhn-swiper": "^1.0.0", "lodash": "^4.17.21", "vue-luck-draw": "^3.4.7" }, "private": true, "workspaces": [ "./packages/*" ] }

可以看到我这里storyBook 用的是6.2.9版本的,这里不用最新版是因为无法最后开启文档模式,不知道现在问题解决了没有。
项目的初始化可以采用storyBook的脚手架,后续我们再往里面添东西。
初始化我们用的是vue3.0版本,这里大家可以按手册去初始化
storybook 官网vue初始化手册
官网也提供了,其他框架项目的初始化。
初始化完成后,我们找到.storyBook文件夹,我们需要修改他下面的内容:
main.js改成这样,如下:
const path = require('path'); module.exports = { "stories": [ "../stories/**/*.stories.mdx", "../packages/**/*.stories.mdx", "../packages/**/*.stories.@(js|jsx|ts|tsx)", ], "addons": [ "@storybook/addon-links", "@storybook/addon-essentials" ], webpackFinal: async (config, { configType }) => { // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' // You can change the configuration based on that. // 'PRODUCTION' is used when building the static version of storybook.// Make whatever fine-grained changes you need config.module.rules.push({ test: /\.scss$/, use: ['style-loader', 'css-loader', { loader:'sass-loader',//这种是指定dart-sass替代node-sass不然一些数学函数 用不了math函数只有dart-sass可以用 options:{ implementation:require("sass") } }], include: path.resolve(__dirname, '../'), }); // Return the altered config return config; }, }

这里stories 配置项,配置路径里放的是界面要呈现的说明和组件,匹配到的mdx里放的是它的使用指引,mdx是markdowm和jsx的结合。
addons里放的是它的一些插件,addon-essentials是插件集合(集合),包含了一系列的插件 可以保证我们开箱即用,addon-links 用来设置链接的插件。
webpackFinal是针对webpack 的一些扩展,我们这里用dart-sass替代了node-sass,不然一些数学函数 用不了,例如 math函数只有dart-sass可以用。
那我们packages/avatar/stories/avatar.stories.mdx 下语法,你可以参考官网mdx语法
workspaces和private已经在packsge.json里配置了,
"private": true, "workspaces": [ "./packages/*" ]

如果有不了解workspaces的作用的,可以百度下它的作用。
然后就是ts和jest的集成
首先我们先来集成ts,首先下载依赖:
主要的包有俩:
yarn add typescript rollup-plugin-typescript2 -D -W

然后修改tsconfig.json
{ "compilerOptions": { "module": "ESNext",//指定使用的模块标准 "declaration": true,// 生成声明文件,开启后会自动生成声明文件 "noImplicitAny": false,// 不允许隐式的any类型 "strict":true,// 开启所有严格的类型检查 "removeComments": true,// 删除注释 "moduleResolution": "node", //模块解析规则 classic和node的区别https://segmentfault.com/a/1190000021421461 //node模式下,非相对路径模块 直接去node_modelus下查找类型定义.ts 和补充声明.d.ts //node模式下相对路径查找 逐级向上查找 当在node_modules中没有找到,就会去tsconfig.json同级目录下的typings目录下查找.ts或 .d.ts补充类型声明 //例如我们这里的.vue模块的类型补充(.ts 文件不认识.vue模块, 需要我们来定义.vue模块的类型) "esModuleInterop": true,//实现CommonJS和ES模块之间的互操作性。抹平两种规范的差异 "jsx": "preserve",//如果写jsx了,保持jsx 的输出,方便后续babel或者rollup做二次处理 "noLib": false, "target": "es6", //编译之后版本 "sourceMap": true, //生成 "lib": [ //包含在编译中的库 "ESNext", "DOM" ], "allowSyntheticDefaultImports": true, //用来指定允许从没有默认导出的模块中默认导入 }, "exclude": [ //排除 "node_modules" ],}

然后集成一下jest
yarn add @types/jest babel-jest jest jest-css-modules vue-jest @vue/test-utils -D -W

建议下载的依赖包版本,以我的项目的lock为准,因为这个是我校验过得稳定版本,升级新版本可能会导致不兼容。
这里-D -W是安装到工作区根目录并且是开发依赖的意思,这里jest是Facebook 给提供的单元测试库官方推荐的,@vue/test-utils 它是Vue.js的官方测试实用程序库,结合jest一起使用 配置最少,处理单文件组件vue-jest,babel-jest 对测试代码做降级处理,jest-css-modules 用来忽略测试的css文件。
然后我们在根目录新建jest.config.js 单元测试的配置文件:
module.exports = { "testMatch": ["**/__tests__/**/*.test.[jt]s?(x)"],//从哪里找测试文件tests下的 "moduleFileExtensions": [ //测试模块倒入的后缀 "js", "json", // 告诉 Jest 处理 `*.vue` 文件 "vue", "ts" ], "transform": { // 用 `vue-jest` 处理 `*.vue` 文件 ".*\\.(vue)$": "vue-jest", // 用 `babel-jest` 处理 js 降 ".*\\.(js|ts)$": "babel-jest" }, "moduleNameMapper" : { "\\.(css|less|scss|sss|styl)$" : "/node_modules/jest-css-modules" } }

然后再配置一下babel.config.js 我们测试用到了降级处理 ,后续打生产包时我们会通过babel环境变量utils,来使用对应配置转换packages/utils里一些工具函数。
babel.config.js:
module.exports = { // ATTENTION!! // Preset ordering is reversed, so `@babel/typescript` will called first // Do not put `@babel/typescript` before `@babel/env`, otherwise will cause a compile error // See https://github.com/babel/babel/issues/12066 presets: [ [ '@babel/env', //babel转换es6 语法插件集合 ], '@babel/typescript',//ts ], plugins: [ '@babel/transform-runtime', //垫片按需支持Promise,Set,Symbol等 'lodash', //一个简单的转换为精挑细选的Lodash模块,因此您不必这样做。 //与结合使用,可以生成更小的樱桃精选版本! https://download.csdn.net/download/weixin_42129005/14985899 //一般配合lodash-webpack-plugin做lodash按需加载 ], env: { utils: { //这个babel环境变量是utils 覆盖上述 的配置 这里暂时不会用 先注释掉 presets: [ [ '@babel/env', { loose: true,//更快的速度转换 modules: false,//不转换esm到cjs,支持摇树这个上面不配置 不然esm规范会导致jest 测试编译不过 }, ], ], // plugins: [ //[ //'babel-plugin-module-resolver', //{ //root: [''], //alias: {//}, //}, //], // ], }, }, }

然后我们在package.json中修改script命令 "test": "jest",
Jest 单元测试具体怎么写 可以根据自己的 需求去查看官方文档。
plop生成组件模板
我们开头的时候说过我们每个包的结构,长得都是一样的,然后每生成一个组件包的话都要手动创建结构的话太麻烦了。
├── packages |├── avatar ||├── _test_//单元测试文件夹 ||├─────xxx.test.ts //测试文件 ||├── src //组件文件文件夹 ||├───── xxx.vue //组件文件 ||├── stories // 故事书调试的js ||├───── xxx.stories.ts //组件文件 ||├── index.js //包入口 ||├── LICENSE ||├── README.MD ||├── package.json ├── package.json

我们的基本结构是这样,然后我们选择plop生成模板,我们前面提到了plop主要用于创建项目中特定文件类型的小工具。 我们把它安装到项目中 yarn add plop -D -W
然后创建它的配置文件plopfile.js
module.exports = plop => { plop.setGenerator('组件', { description: '自定义组件', prompts: [ { type: 'input', name: 'name', message: '组件名称', default: 'MyComponent' }, { type: "confirm", message: "是否是组合组件", name: "combinationComponent", default:false } ], actions: [ { type: 'add', path: 'packages/{{name}}/src/{{name}}.vue', templateFile: 'plop-template/component/src/component.hbs' }, { type: 'add', path: 'packages/{{name}}/__tests__/{{name}}.test.ts', templateFile: 'plop-template/component/__tests__/component.test.hbs' }, { type: 'add', path: 'packages/{{name}}/stories/{{name}}.stories.ts', templateFile: 'plop-template/component/stories/component.stories.hbs' }, { type: 'add', path: 'packages/{{name}}/index.ts', templateFile: 'plop-template/component/index.hbs' }, { type: 'add', path: 'packages/{{name}}/LICENSE', templateFile: 'plop-template/component/LICENSE' }, { type: 'add', path: 'packages/{{name}}/package.json', templateFile: 'plop-template/component/package.hbs' }, { type: 'add', path: 'packages/{{name}}/README.md', templateFile: 'plop-template/component/README.hbs' }, { type: 'add', path: 'packages/theme-chalk/src/{{name}}.scss', templateFile: 'plop-template/component/template.hbs' } ] }) }

这里通过命令行询问交互 来生成 组件,然后我们来根据我们的配置文件来新建 文件夹和模板。
如何从头到尾做一个UI组件库
文章图片

模板的结构是这样。
然后 我们来看下对应的模板 长什么样子,如下:
component.test.hbs
import { mount } from '@vue/test-utils' import Element from '../src/{{name}}.vue'describe('c-dhn-{{name}}', () => { test('{{name}}-text',() => { const wrapper = mount(Element) expect(wrapper.html()).toContain('div') }) })

component.hbs

component.stories.hbs
import CDhn{{properCase name}} from '../'export default { title: 'DHNUI/{{properCase name}}', component: CDhn{{properCase name}} }export const Index = () => ({ setup() { return {}; }, components: { CDhn{{properCase name}} }, template: ``, });

index.hbs
import CDhn{{properCase name}} from './src/{{name}}.vue' import { App } from 'vue' import type { SFCWithInstall } from '../utils/types'CDhn{{properCase name}}.install = (app: App): void => { app.component(CDhn{{properCase name}}.name, CDhn{{properCase name}}) }const _CDhn{{properCase name}}: SFCWithInstall = CDhn{{properCase name}}export default _CDhn{{properCase name}}

然后我们在package.json 中添加一个script命令 "plop": "plop"
执行之后就可以生产对应的文件了,详细的可以吧项目下载下载看一下。
到这里我们测试,开发环境的storyBook和生产文件的plop,已经完事了。
下面就该看如何打出生产环境的包了。
rollup构建打包
首先新建buildProject文件夹,我们的一些命令脚本都会放在这里。
这里打包分为两种,按需加载和全量包,这两种方式有一些配置是一样的,我们这里写一个公共的配置文件rollup.comon.js
import json from '@rollup/plugin-json' import vue from 'rollup-plugin-vue' //vue相关配置, css抽取到style标签中编译模版 // import postcss from 'rollup-plugin-postcss' import { terser } from 'rollup-plugin-terser'//代码压缩 import { nodeResolve } from '@rollup/plugin-node-resolve' import alias from 'rollup-plugin-alias'; const { noElPrefixFile } = require('./common') const pkg = require('../package.json')const isDev = process.env.NODE_ENV !== 'production' const deps = Object.keys(pkg.dependencies) // 公共插件配置 const plugins = [ vue({ // Dynamically inject css as atag 不插入 css: false, // Explicitly convert template to render function compileTemplate: true, target: 'browser' }), json(), //json文件转换成es6模块 nodeResolve(), //使用Node解析算法定位模块,用于解析node_modules中的第三方模块 //大多数包都是以CommonJS模块的形式出现的,如果有需要使用rollup-plugin-commonjs这个插件将CommonJS模块转换为 ES2015 供 Rollup 处理 // postcss({//和css集成 支持组件库 不能使用私有作用域css不然提供给别人用时覆盖起来太费劲 //// 把 css 插入到 style 中 //// inject: true, //// 把 css 放到和js同一目录 //extract: true // }), alias({ resolve: ['.ts', '.js','.vue','.tsx'], entries:{ '@':'../packages' } }) ] // 如果不是开发环境,开启压缩 isDev || plugins.push(terser())function external(id) { return /^vue/.test(id)|| noElPrefixFile.test(id)|| deps.some(k => new RegExp('^' + k).test(id)) } export {plugins,external};

这里我们把utils下的工具函数(这个排除出去是因为 我们要用babel来做语法转换)和vue库,和我们所有使用的生产包 排除了出去,保证包的小体积.
common.js 目前只用到了utils
module.exports = { noElPrefixFile: /(utils|directives|hooks)/, }

  1. 按需加载:工作区packages里的每个组件都生成对应的js,方便后期配合babel插件做按需引入
    rollup.tiny.js 这个文件是针对工作区组件的配置
import {plugins,external} from './rollup.comon' import path from 'path' import typescript from 'rollup-plugin-typescript2'const { getPackagesSync } =require('@lerna/project')module.exports = getPackagesSync().filter(pkg => pkg.name.includes('@c-dhn-act')).map(pkg => { const name =pkg.name.split('@c-dhn-act/')[1] //包名称 return { input: path.resolve(__dirname, '../packages', name, 'index.ts'), //入口文件,形成依赖图的开始 output: [ //出口输出 { exports: 'auto', file: path.join(__dirname, '../dist/lib', name, 'index.js'), //esm版本 format: 'es', }, ], plugins: [...plugins, typescript({ tsconfigOverride: { compilerOptions: { declaration: false, //不生成类型声明 }, 'exclude': [ 'node_modules', '__tests__', 'stories' ], }, abortOnError: false, }), ], external } })

这样的话通过rollup 启动打包时 会依次在dist下生成 我们对应的文件,包名中包含@c-dhn-act 会认为是我们的小包 (注意点:这里的name文件夹的名称,是原始工作区package.json包的名字,例如avatar,在这我们生成.d.ts类型补充时也要用它,但是这里和我们最后要产出的名字不符合, 后续打包完会进行一个改名操作)
注意:
这里忽略了utils工具函数,这是因为 我们后续提供给其他人使用时,如果不忽略utils就会被打进来,然后如果其他人要是引用了不同的组件,但是不同的组件里引用了相同的工具函数(函数被打包到了组件文件中)。
拿webpack举例这会就会有个问题,在webpack中使用的话,webpack针对模块 会有函数作用域的隔离,所以 即使是工具函数名称相同也不会给覆盖掉,这样就会导致webpack打出来最终结果包变大。
而utils工具函数被忽略之后,不被打到 文件中,而是通过import导入的方式使用, 这样在webpack使用的时候,就可以充分的利用它的模块缓存机制,首先包的大小被减少了,其次因为用到了缓存机制也会提升加载速度。
webpack伪代码
我这里随便手写了一段webpack打包后的伪代码,方便理解,大家看下
utils是单独模块时,大概是这样的
(function(modules){ var installedModules = {}; function __webpack_require__(moduleId){ //缓存中有返回缓存的模块 //定义模块,写入缓存 module // { //i: moduleId, //l: false, //exports: {} // }; //载入执行modules中对应函数 //修改模块状态为已加载 //返回函数的导出module.exports } /**一系列其他定义执行 * xxx * xxx * xxx */ return __webpack_require__(__webpack_require__.s = "xxx/index.js"); })({ "xxx/index.js":(function(module, __webpack_exports__, __webpack_require__){ var button = __webpack_require__(/*! ./button.js */ "xxx/button.js"); var input = __webpack_require__(/*! ./input.js */ "xxx/input.js"); }), "xxx/button.js":(function(module, __webpack_exports__, __webpack_require__){ var btn_is_array = __webpack_require__(/*! ./utils.js */ "xxx/utils.js"); btn_is_array([]) module.exports = 'button组件' }), "xxx/input.js":(function(module, __webpack_exports__, __webpack_require__){ var ipt_is_array = __webpack_require__(/*! ./utils.js */ "xxx/utils.js"); ipt_is_array([]) module.exports = 'input组件' }), "xxx/utils.js":(function(module, __webpack_exports__, __webpack_require__){ module.exports = function isArray(arr){ //xxxxx函数处理,假设是特别长的函数处理 } }) })

第二种 utils里的方法和组件打到了一起,webpack中使用时会是这样的
(function(modules){ var installedModules = {}; function __webpack_require__(moduleId){ //缓存中有返回缓存的模块 //定义模块,写入缓存 module // { //i: moduleId, //l: false, //exports: {} // }; //载入执行modules中对应函数 //修改模块状态为已加载 //返回函数的导出module.exports } /**一系列其他定义执行 * xxx * xxx * xxx */ return __webpack_require__(__webpack_require__.s = "xxx/index.js"); })({ "xxx/index.js":(function(module, __webpack_exports__, __webpack_require__){ var button = __webpack_require__(/*! ./button.js */ "xxx/button.js"); var input = __webpack_require__(/*! ./input.js */ "xxx/input.js"); }), "xxx/button.js":(function(module, __webpack_exports__, __webpack_require__){ function isArray(arr){ //特别长的函数处理 } isArray([]) module.exports = 'button组件' }), "xxx/input.js":(function(module, __webpack_exports__, __webpack_require__){ function isArray(arr){ //特别长的函数处理 } isArray([]) module.exports = 'input组件' }),})

我们可以对比一下,这样可以验证下,首先看看第二份webpack伪代码, 我们看传入的参数,对象的key是文件的路径,值就是一个函数(未打包之前的模块,函数内容就是我们模块的代码),这里有函数作用域做隔离,首先定义上就是重复定义,而且有隔离也不能复用,其次因为这样的一堆重复的冗余代码也会导致最后包变大(我们的组件库导致的),最后就是每次加载模块都需要重新定义isArray函数,无法充分利用webpack的缓存机制。
  1. 全部加载:生成一个包含所有组件的包,引入这个包相当于导入了我们的所有组件
    首先在packages下新建c-dhn-act文件夹,这个文件夹里放的是我们的所有组件的整合,里面有两个文件。
    index.ts
import { App } from 'vue' import CDhnDateCountdown from '../dateCountdown' import CDhnAvatar from '../avatar' import CDhnCol from '../col' import CDhnContainer from '../container' import CDhnRow from '../row' import CDhnText from '../text' import CDhnTabs from '../tabs' import CDhnSwiper from '../swiper' import CDhnTabPane from '../tabPane' import CDhnInfiniteScroll from '../infiniteScroll' import CDhnSeamlessScroll from '../seamlessScroll' export { CDhnDateCountdown, CDhnAvatar, CDhnCol, CDhnContainer, CDhnRow, CDhnText, CDhnTabs, CDhnSwiper, CDhnTabPane, CDhnInfiniteScroll, CDhnSeamlessScroll } const components = [ CDhnDateCountdown, CDhnAvatar, CDhnCol, CDhnContainer, CDhnRow, CDhnText, CDhnTabs, CDhnSwiper, CDhnTabPane, CDhnSeamlessScroll ] const plugins = [CDhnInfiniteScroll] const install = (app: App, opt: Object): void => { components.forEach(component => { app.component(component.name, component) }) plugins.forEach((plugin) => { app.use(plugin) }) } export default { version: 'independent', install }

注意:
  1. 整包的ts文件中export{ } 对组件的导出不能省略,必须要导出,不然最后dist/lib下生成的index.d.ts 的类型补充声明中会缺少对 组件 的导出,就会导致在ts项目中用的时候,推导不出你都导出了哪些东西,我们在package.json的typings中指定了类型声明文件是lib/index.d.ts.
  2. babel-plugin-import 处理了模块js路径的导入,但是ts的类型推导 导出文件的推导 还是按原始写的这个路径来推导的,所以我们的index.d.ts中 必须还是要有对应的组件 类型导出的,不然就会导致在ts项目中,ts找不到导出的组件导致 编译失败。
还有package.json 这个文件经过一些处理后会被copy到 我们的dist下
{ "name": "c-dhn-act", "version": "1.0.16", "description": "c-dhn-act component", "author": "peng.luo@asiainnovations.com>", "main": "lib/index.cjs.js", "module": "lib/index.esm.js", "style": "lib/theme-chalk/index.css", "typings": "lib/index.d.ts", "keywords": [], "license": "MIT" }

然后是我们的rollup全量包的配置
import {plugins,external} from './rollup.comon' import path from 'path' import typescript from 'rollup-plugin-typescript2' const { noElPrefixFile } = require('./common') const paths = function(id){ if ((noElPrefixFile.test(id))) { let index = id.search(noElPrefixFile) return `./${id.slice(index)}` } } module.exports = [ { input: path.resolve(__dirname, '../packages/c-dhn-act/index.ts'), output: [ { exports: 'auto', //默认导出 file: 'dist/lib/index.esm.js', format: 'esm', paths }, { exports: 'named', //默认导出 file: 'dist/lib/index.cjs.js', format: 'cjs', paths } ], plugins: [...plugins, typescript({ tsconfigOverride: { 'include': [ 'packages/**/*', 'typings/vue-shim.d.ts', ], 'exclude': [ 'node_modules', 'packages/**/__tests__/*', 'packages/**/stories/*' ], }, abortOnError: false,}), ], external } ]

然后这里需要对utils的导入做下处理,因为实质这个整合就是把对应组件的打包结果拿到了这个文件中(不同的组件引入相同的包,rollup会给我们处理不会重复导入),而我们又配置忽略 utils下工具函数,所以rollup只给处理了路径,而不会把内容打进来, 但是 因为是直接拿的组件的打包结果,基于它的目录处理的,路径给处理的稍微有点问题,所以配置了path 我们转换了一下 (这里不使用路径别名是因为要配置三份,ts的,rollup,storybook,我们这个库路径相对简单,所以转换的时候处理一下就可以了)。
这里可以关注一下ts的类型声明,是在all.js全量配置中生成的,不是单独一个一个生成的。
在这里设置下要编译哪些文件生成补充类型声明include,我们把packages下的所有包都生成类型声明,vue-shim.d.ts里放的是.vue模块的 类型声明,不然打包过程中 不认识vue文件会报错。
这里输出类型声明时输出的文件夹 会和 packages工作区里的对应,所以我们上面在打但个包的时候rollup.tiny.js 里文件夹的路径和这个是对应的(因为都是通过plop创建的),这样就会把类型的补充声明和 我们前面输出到dist中的单个包放在一块。
但是这会输出的全局的.d.ts补充声明在c-dhn-act里,而且路径也有问题 后续需要我们再处理下。
打包utils
前面的打包操作,没有打包utils里的工具函数。
我们的工具函数都在packages工具区的utils下,这里面可能会用到一些es的新语法,所以它下面的方法最后生产时是需要编译一下的,我们前面ts和jest部分已经把babel配置贴出来了。这里就不重复贴配置了。
然后就是对应package.json里的打包命令
"build:utils": "cross-env BABEL_ENV=utils babel packages/utils --extensions .ts --out-dir dist/lib/utils",

使用extensions 标识下扩展名ts 然后指定下输出目录。
名称修改
buildProject下新建gen-type.js 文件,
该文件主要作用
  1. global.d.ts 全局的ts类型定义 全局的ts补充类型 挪动到dist下 这里放的是.vue模块的补充类型声明 移动过去是为了防止 其他人ts使用的时候 不识别.vue模块
  2. 处理单个包的文件夹的名字 以及c-dhn-act中的全部的类型声明
const fs = require('fs') const path = require('path') const pkg = require('../dist/package.json') const { noElPrefixFile } = require('./common') const outsideImport = /import .* from '..\/(.*)/g // global.d.ts全局的ts类型定义 fs.copyFileSync( path.resolve(__dirname, '../typings/vue-shim.d.ts'), path.resolve(__dirname, '../dist/lib/c-dhn-act.d.ts'), )//设置一下版本号,不通过c-dhn-act的index.ts里导入json写入了因为它是整体导出导入所以会有一些其他冗余信息 不是js模块 无法摇树摇掉所以在这里写入版本 const getIndexUrl = url =>path.resolve(__dirname, '../dist/lib', url) const updataIndexContent = (indexUrl,content) => fs.writeFileSync(getIndexUrl(indexUrl), content.replace('independent',pkg.version))['index.esm.js','index.cjs.js'].map(fileName => ({ fileName, content:fs.readFileSync(getIndexUrl(fileName)).toString() })).reduce((callback,item)=>{ callback(item.fileName,item.content) return callback; },updataIndexContent)// component 这个方法主要是 针对打包之后 包做重命名处理 以及处理typings const libDirPath = path.resolve(__dirname, '../dist/lib') fs.readdirSync(libDirPath).forEach(comp => { //获取所有文件的名称 if (!noElPrefixFile.test(comp)) { //如果不是特殊的文件夹,正则比文件信息查询快 在前面 if (fs.lstatSync(path.resolve(libDirPath, comp)).isDirectory()) { //是文件夹 if(comp === 'c-dhn-act'){ //如果是我们的整包里面放的是.d.ts补充类型声明 fs.renameSync( // 把类型补充声明文件 剪切出来 和package.json 指定的 typings 对应 path.resolve(__dirname, '../dist/lib', comp, 'index.d.ts'), path.resolve(__dirname, '../dist/lib/index.d.ts'), ) fs.rmdirSync(path.resolve(__dirname, '../dist/lib/c-dhn-act'), { recursive: true }) //移动完成 原来的文件就没用了删除掉// re-import 移过去之后 文件里面引用路径不对了 需要调整一下 原来引入的是button而我们最后输出包名是 c-dhn-button 所以要修正一下 const imp = fs.readFileSync(path.resolve(__dirname, '../dist/lib', 'index.d.ts')).toString() if(outsideImport.test(imp)) { const newImp = imp.replace(outsideImport, (i, c) => { //i匹配到的字符串 import CDhnInput from '../input' //c正则中子规则的匹配 inout return i.replace(`../${c}`, `./c-dhn-${c.replace(/([A-Z])/g,"-$1").toLowerCase()}`) //修正引入包名 })fs.writeFileSync(path.resolve(__dirname, '../dist/lib', 'index.d.ts'), newImp) } return; } //给我们的包改下名 方便后续的按需加载引入 const newCompName = `c-dhn-${comp.replace(/([A-Z])/g,"-$1").toLowerCase()}` fs.renameSync( path.resolve(libDirPath, comp), path.resolve(libDirPath, newCompName) ) } } })

修改合成最后dist中的package.json
dist文件夹里放的就是我们最终发布的包,它里面的package.json需要我们修改一下。
新建gen-v.js
const inquirer = require('inquirer') const cp = require('child_process') const path = require('path') const fs = require('fs')const jsonFormat = require('json-format') //美化並轉換js const promptList = [ { type: 'list', message: '选择升级版本:', name: 'version', default: 'patch', // 默认值 choices: ['beta', 'patch', 'minor', 'major'] } ] const updataPkg = function () { const pkg = require('../packages/c-dhn-act/package.json') const { dependencies, peerDependencies } = require('../package.json') fs.writeFileSync( path.resolve(__dirname, '../dist', 'package.json'), jsonFormat({ ...pkg, dependencies, peerDependencies }) ) } inquirer.prompt(promptList).then(answers => { let pubVersion = answers.version if (pubVersion === 'beta') { const { version } = require('../packages/c-dhn-act/package.json') let index = version.indexOf('beta') if (index != -1) { const vArr = version.split('.') vArr[vArr.length - 1] = parseInt(vArr[vArr.length - 1]) + 1 pubVersion = vArr.join('.') } else { pubVersion = `${version}-beta.0` } } cp.exec( `npm version ${pubVersion}`, { cwd: path.resolve(__dirname, '../packages/c-dhn-act') }, function (error, stdout, stderr) { if (error) { console.log(error) } updataPkg() } ) })

这个文件主要是用来更新版本号,并且以packages下的c-dhn-act文件夹下package.json文件为主,然后ge把项目根目录的dependencies 依赖拿过来合并,生成新的package.json放到dist下。
因为我们打包的时候把他们忽略了,但是最后提供给别人用的时候还是需要用的,所以最后在我们发布npm包的json上还是要写进去的,npm包的dependencies依赖当在项目中执行npm install 的时候会自动下载的,devDependencies的不会。
scss打包
scss的生产打包我们选择用gulp,packages/theme-chalk/src放的是对应模块的scss文件。
如何从头到尾做一个UI组件库
文章图片

我们添加gulpfile.js,gulp的配置文件。
'use strict' const { series, src, dest } = require('gulp') //暂时不用series目前就一个任务 const sass = require('gulp-dart-sass') const autoprefixer = require('gulp-autoprefixer') const cssmin = require('gulp-cssmin') const rename = require('gulp-rename')const noElPrefixFile = /(index|base|display)///改名 如果不是这几个加上c-dhnfunction compile(){//编译器 return src('./src/*.scss') //读取所有scss 创建可读流 .pipe(sass.sync()) //管道 插入处理函数 同步编译 sass文件 .pipe(autoprefixer({ cascade: false })) //不启动美化 默认美化属性 .pipe(cssmin()) //压缩代码 .pipe(rename(function (path) { if(!noElPrefixFile.test(path.basename)) { //如果不是这些给加前缀 path.basename = `c-dhn-${path.basename}` } })) .pipe(dest('./lib')) //创建写入流 到管道写入到 }exports.build = compile

到这里可以看我最开始贴的package.json文件。里面的script命令就大都包含了。
scripts列表
  1. test 用来开启jest单元测试
  2. storybookPre 用来查看storyBook打包出来的静态资源预览
  3. storybook 用来开启开发环境组件文档查看
  4. build-storybook 用来生产对应的静态资源文档,方便部署
  5. buildTiny:prod 打包按需加载包 压缩代码版本
  6. buildTiny:dev 不压缩的版本
  7. plop 生产plop模板
  8. clean:lib 清空dist/lib文件夹
  9. build:theme gulp构建scss样式
  10. build:utils babel打包工具函数
  11. buildAll:prod 打包全量包 压缩代码
  12. buildAll:dev 不压缩代码
  13. build:type 修改打包出来的文件名和内部路径,和ts补充类型声明的位置
  14. build:v 修改要发布的新的版本号和更新生产依赖。
  15. build:dev 完整的组合好的打包命令(常用的,不压缩代码)
  16. build:prod 压缩代码
这里 yarn build:dev就是 我们的打包不压缩的测试,方便我们查看打包之后的内容结果是否和我们预期相符。
Yarn build:prod 就是正式发布时 所执行的打包命令。
打包的主要思想顺序是
  1. 修改版本,生成覆盖package.json
  2. 清空文件夹
  3. packages工作区的组件逐个打包(按需加载)
  4. 打全量包(全部加载)
  5. 用babel 编译 utils工具函数
  6. 最后修改dist/lib下的文件夹名称,和.d.ts 的类型补充,和部分文件内容修改。
  7. 最后构建一下scss样式
最后是使用:
这样我们配合 babel-plugin-import 这个插件。
{ plugins: [ [ 'import', { libraryName: 'c-dhn-act', customStyleName: (name) => { return `c-dhn-act/lib/theme-chalk/${name}.css` } } ] ] }

在碰到
import { CDhnAvatar } from "c-dhn-act"
这种情况时就会被解析成
import CDhnAvatar from "c-dhn-act/lib/c-dhn-avatar";

这种形式,这样就直接从我们打的小的组件包里去获取,从而在加载层面就形成了按需加载。
不了解babel-plugin-import的可以,查下这个插件的用法。
【如何从头到尾做一个UI组件库】最后就是我们的代码地址了,有兴趣的可以把代码下载下来跑一跑看看。

    推荐阅读