低代码海报平台的组件库如何设计?
前面两篇文章如何设计一款营销低代码可视化海报平台、低代码海报平台的编辑器难点剖析分别从海报平台的整体架构和编辑器难点两部分对乔巴海报搭建平台做了讲解,相信看完这两篇,大家对于平台的主体功能和实现也有了大概的了解。今天这一篇,我们会深入到组件库环节,相比传统的element-ui
、ant-design
,低代码平台的组件库往往受众很小(一般都是为自身的平台服务),设计时考虑的点也完全不同。
本篇文章会从组件库初始化、文字组件设计、图片组件设计、素材组件设计、组件库打包、组件库发布几个小节依次展开说明。
组件库初始化
首先是组件库的初始化,由于项目本身是基于vue
的,所以这里直接使用vue create xxx
来创建项目即可。
在我之前的文章从 Element UI 源码的构建流程来看前端 UI 库设计中有提过ElementUI
在使用时有两种引入方式:
- 全局引入
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';
Vue.use(ElementUI);
new Vue({
el: '#app',
render: h => h(App)
});
- 按需引入
import Vue from 'vue';
import { Pagination, Dropdown } from 'element-ui';
import App from './App.vue';
Vue.use(Pagination)
Vue.use(Dropdown)
new Vue({
el: '#app',
render: h => h(App)
});
这两种引入方式都用到了Vue.use
,这便是Vue的插件系统。
来简单了解一下:
通过vue官网,我们知道插件通常用来为 Vue 添加全局功能。插件的功能范围没有严格的限制——一般有下面几种:
- 添加全局方法或者 property。
- 添加全局资源:指令/过滤器/过渡等。
- 通过全局混入来添加一些组件选项。
- 添加 Vue 实例方法,通过把它们添加到
Vue.prototype
上实现。 - 一个库,提供自己的 API,同时提供上面提到的一个或多个功能。
Vue.js 的插件应该暴露一个 install
方法。这个方法的第一个参数是 Vue
构造器,第二个参数是一个可选的选项对象。
了解了插件系统,结合我们组件库的两种引入方式,我们来对最开始的项目进行改造。
首先是按需引入,也就是单个组件导入并且作为插件使用:
import { CImage } from choba-lego-components
app.use(CImage)
这种需要每个组件新建一个文件夹,并且创建一个单独的index.ts
文件。
import { App } from "vue";
import CImage from "./CImage.vue";
CImage.install = (app: App) => {
app.component(CImage.name, CImage);
};
export default CImage;
组件设计为了插件,并拥有install
方法。
最终还需要在全局入口文件导出:
import CImage from "./components/CImage";
export { CImage };
其次是全局引入这种方式,这种需要在全局入口文件index.ts
中将所有组件导入,放到一个数组中,同样创建install
方法,循环调用app.component
方法,最后默认导出install
函数:
import { App } from "vue";
import CText from "./components/CText";
import CImage from "./components/CImage";
import CShape from "./components/CShape";
const components = [CText, CImage, CShape];
const install = (app: App) => {
components.forEach((component) => {
app.component(component.name, component);
});
};
export default {
install,
};
改造完的项目目录结构为:
编写组件代码
目前乐高平台的组件库还比较轻量,只有文字组件、图片组件和素材组件,相比一些成熟的组件库,如ant design
,还会划分为通用组件、布局组件、导航组件、数据录入组件、数据展示组件、反馈型组件和其他。又或者像element ui
,组件划分为基础组件、表单组件、数据呈现组件、通知类组件、导航类组件和其他。
就目前来说,乐高平台使用的配套组件库更偏向于纯展示类(这与海报的展现形式有关),会带一些轻交互。
所以组件设计维度其实是很简单的,更多处理是在样式维度。
通过上一篇文章低代码海报平台的编辑器难点剖析我们知道文字、图片、素材组件有很多通用属性:actionType
、url
、width
、height
、padding
、position
、border
、opacity
等,同时文字组件也拥有本身的一些特有属性:font
、color
、textAlign
、textDecoration
,图片组件拥有:imageSrc
,素材组件拥有:backgroundColor
。
可以看到每一个组件都被分为了两大类:
- 样式属性
- 其他属性
对于样式属性,设计时又有两种方案:
- 直接在外部使用时传入一个
css
对象 - 每一个样式属性分别传入
在平时的业务开发中,我其实用第一种方案更多一些,但如果是在现在的组件库场景,显得就不是那么的合适。
之所以这么说是因为在上面分析的props
中,除了样式属性外还有一些是非样式属性(目前是点击相关),那么第一种方案的话就会多一层结构,而第二种分别传入的方式则是我们已经把纯样式属性筛选后的结果了。
这里拿其中的文字组件最终的代码来做进一步说明:
<template>
<component
:is="tag"
:style="styleProps"
class="c-text-component"
@click.prevent="handleClick"
>
{{ text }}
</component>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import handleStylePick from "../handleStylePick";
import handleComponentClick from "../handleComponentClick";
import {
componentsDefaultProps,
convertToComponentProps,
isEditingProp,
} from "../../defaultProps";
const extraProps = {
tag: {
type: String,
default: "p",
},
...isEditingProp,
};
const defaultProps = convertToComponentProps(
componentsDefaultProps["c-text"].props,
extraProps
);
export default defineComponent({
name: "c-text",
props: {
tag: {
type: String,
default: "div",
},
...defaultProps,
},
setup(props) {
const styleProps = handleStylePick(props);
const handleClick = handleComponentClick(props);
return {
styleProps,
handleClick,
};
},
});
</script>
<style scoped>
h2.c-text-component,
p.c-text-component {
margin-bottom: 0;
}
button.c-text-component {
padding: 5px 10px;
cursor: pointer;
}
.c-text-component {
box-sizing: border-box;
white-space: pre-wrap;
}
</style>
首先通过handleStylePick
拿到纯样式属性
,然后通过handleComponentClick
拿到上面提到的其他属性
。这里的handleStylePick
和handleComponentClick
是三个组件都会用到的通用方法。
handleStylePick
import { pick, without } from "lodash-es";
import { computed } from "vue";
import { textDefaultProps } from "../defaultProps";
export const defaultStyles = without(
Object.keys(textDefaultProps),
"actionType",
"url",
"text"
);
const handleStylePick = (props: any, pickStyles = defaultStyles) => {
return computed(() => pick(props, pickStyles));
};
export default handleStylePick;
handleComponentClick
const handleComponentClick = (props: any) => {
const handleClick = () => {
if (props.actionType && props.url && !props.isEditing) {
window.location.href = props.url;
}
};
return handleClick;
};
export default handleComponentClick;
至于本地组件库开发和调试可以使用
npm link
组件添加测试用例
我们同样以文本组件为例来进行说明。单元测试的目的是为了尽可能发布前用test case
的方式去测试组件功能的完整性和正确性,对于使用方来说也更具说服力。
针对文本组件,我写了三个case:
- CText组件可以正常渲染,包含属性
- 当有actionType和url属性时,点击CText组件可以正常跳转
- 当正在编辑状态时,点击CText组件,即使拥有actionType和url属性也不应该触发跳转
import { shallowMount } from "@vue/test-utils";
import CText from "../../src/components/CText";
import { textDefaultProps } from "../../src/defaultProps";
describe("CText.vue", () => {
const { location } = window;
beforeEach(() => {
Object.defineProperty(window, "location", {
writable: true,
value: { href: "" },
});
});
afterEach(() => {
window.location = location;
});
it("CText组件可以正常渲染,包含属性", () => {
const msg = "test";
const props = {
...textDefaultProps,
text: msg,
};
const wrapper = shallowMount(CText, { props });
expect(wrapper.text()).toBe(msg);
expect(wrapper.element.tagName).toBe("P");
const style = wrapper.attributes().style;
expect(style.includes("font-size")).toBeTruthy();
expect(style.includes("actionType")).toBeFalsy();
});
it("当有actionType和url属性时,点击CText组件可以正常跳转", async () => {
const props = {
...textDefaultProps,
actionType: "url",
url: "http://cosen95.cn/",
tag: "h2",
};
const wrapper = shallowMount(CText, { props });
expect(wrapper.element.tagName).toBe("H2");
await wrapper.trigger("click");
expect(window.location.href).toBe("http://cosen95.cn/");
});
it("当正在编辑状态时,点击CText组件,即使拥有actionType和url属性也不应该触发跳转", async () => {
const props = {
...textDefaultProps,
actionType: "url",
url: "http://cosen95.cn/",
tag: "h2",
isEditing: true,
};
const wrapper = shallowMount(CText, { props });
await wrapper.trigger("click");
expect(window.location.href).not.toBe("http://cosen95.cn/");
});
});
执行测试用例:
组件库打包
说到打包,必不可少的第一步就是打包工具的选择,以目前市面最为常用的两种打包工具来说:
webpack
我相信做前端的同学大家都用过,那么为什么有些场景还要使用rollup
呢?这里我简单对webpack
和rollup
做一个比较:
总体来说webpack
和rollup
在不同场景下,都能发挥自身优势作用。webpack
对于代码分割和静态资源导入有着“先天优势”,并且支持热模块替换(HMR
),而rollup
并不支持。
所以当开发应用时可以优先选择webpack
,但是rollup
对于代码的Tree-shaking
和ES6
模块有着算法优势上的支持,若你项目只需要打包出一个简单的bundle
包,并是基于ES6
模块开发的,可以考虑使用rollup
。
其实webpack
从2.0
开始就已经支持Tree-shaking
,并在使用babel-loader
的情况下还可以支持es6 module
的打包。实际上,rollup
已经在渐渐地失去了当初的优势了。但是它并没有被抛弃,反而因其简单的API
、使用方式被许多库开发者青睐,如React
、Vue
等,都是使用rollup
作为构建工具的。
显然经过一番对比后,rollup
作为组件库的打包方案是最合适不过的。
打包方案确定了,下一个要思考的问题就是应该打包什么类型的文件?
我们可以先去看下目前Element UI
和Ant Design
分别都支持什么样的安装方式:
可以看到,基本都同时支持npm下载或者浏览器直接引入的方式,那么对应的最终打包文件就是ES Module
和UMD
格式。
到这里,我们就可以开始编写rollup
配置文件了 - rollup.config.js
:
import vue from "rollup-plugin-vue";
import css from "rollup-plugin-css-only";
import typescript from "rollup-plugin-typescript2";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import { name } from "../package.json";
const file = (type) => `dist/${name}.${type}.js`;
const overrides = {
compilerOptions: { declaration: true },
exclude: ["tests/**/*.ts", "tests/**/*.tsx"],
};
export { name, file };
export default {
input: "src/index.ts",
output: {
name,
file: file("esm"),
format: "es",
},
plugins: [
nodeResolve(),
typescript({ tsconfigOverride: overrides }),
vue(),
css({ output: "choba-lego-components.css" }),
],
external: ["vue", "lodash-es"],
};
这里面用到的几个插件的含义为:
rollup-plugin-vue
:处理vue文件rollup-plugin-css-only
:单独打包css文件rollup-plugin-typescript2
:处理ts文件@rollup/plugin-node-resolve
:rollup 无法识别node_modules
中的包,帮助 rollup 查找外部模块,然后导入
这里主要还是以介绍项目中的rollup plugin为主,关于rollup更具体的可参考我之前的文章: 一文带你快速上手Rollup
这里着重说一下rollup-plugin-typescript2
,上面也提到了,组件库需要提供类型声明文件,也就是.d.ts
,那么就需要在配置时添加tsconfigOverride
:
const overrides = {
compilerOptions: { declaration: true },
exclude: ["tests/**/*.ts", "tests/**/*.tsx"],
};
这样在打包结果中就会包含对应的类型声明文件了。
有些场景下,虽然我们使用了@rollup/plugin-node-resolve
插件,但可能我们仍然想要某些库保持外部引用状态,这时我们就需要使用external
属性,来告诉rollup.js
哪些是外部的类库。
这里的vue
和lodash-es
都可以放到external
中。
有了这个基础的文件后,针对ES Module
和UMD
还要有单独的配置文件:
rollup.esm.config.js
import basicConfig, { name, file } from "./rollup.config";
export default {
...basicConfig,
output: {
name,
file: file("esm"),
format: "es",
},
};
rollup.umd.config.js
import basicConfig, { file } from "./rollup.config";
export default {
...basicConfig,
output: {
name: "ChobaLegoComponents",
file: file("umd"),
format: "umd",
globals: {
vue: "Vue",
"lodash-es": "_",
},
exports: "named",
},
};
核心区别在于format
,然后umd
的还要配置一下globals
(来提供全局变量名称)和exports
。
配置完成,来看下打包结果:
组件库发布
最后一步就是将组件库发布到npm
了。
第一步肯定是要调整package.json
为符合npm publish
的条件了。有以下几个字段要做相应调整:
name
:包的唯一标识,不能和其他包重名version
:包版本号private
:如果需要发布到npm的话,需要放开限制,也就是设置为false
main
:指定程序的主入口文件(这里是dist/choba-lego-components.umd.js
)module
:指向的应该是一个基于 ES6 模块规范的使用ES5语法书写的模块files
:用于描述你npm publish
后推送到npm
服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来
除了上面这些外还有像keywords
、homepage
这些不是特别重要的我就不赘述了。
下面我要补充说一下上面没讲到的dependencies
、devDependencies
和peerDependencies
。
dependencies
指定了项目运行所依赖的模块,开发环境和生产环境的依赖模块都可以配置到这里。
有一些包有可能你只是在开发环境中用到,例如用于检测代码规范的 eslint
或者是用于进行测试的 jest
,用户使用你的包时即使不安装这些依赖也可以正常运行,反而安装他们会耗费更多的时间和资源,所以你可以把这些依赖添加到 devDependencies
中,这些依赖照样会在你本地进行 npm install
时被安装和管理,但是不会被安装到生产环境。
peerDependencies
用于指定你正在开发的模块所依赖的版本以及用户安装的依赖包版本的兼容性。这个可能有点难理解。就以我们这个组件库为例,组件库依赖指定版本的vue
,那么就要保证组件库使用方的环境也一定要有对应版本的vue
依赖。这个时候,就可以把vue
配置到peerDependencies
中。
聊完这些,在命令行执行npm login
进行npm的登录:
然后可以使用npm whoami
验证是否已登录成功:
登录成功后,修改package.json
中的版本号,然后执行npm publish
:
出现上图的效果,就表明已经发布成功了。
这里还有一个点在于,可以利用npm scripts
的钩子(prepublishOnly
),在publish
前做一些操作,比如对组件做lint
和test
,都通过后才真正执行publish
操作。
去npm官网看下:
1.0.5
就是刚刚发上去的版本。
看完了这些,去组件库使用的地方看一下:
已经正常渲染了。
到这里,组件库从初始化、开发、添加测试用例、打包、使用方引入这一系列流程就已经梳理完毕了~
转载自:https://juejin.cn/post/7161243271233175560