设计前端组件是最能考验开发者基本功的测试之一,由于调用Material design、Antd、iView 等现成组件库的 API 每一个人均可以作到,可是不少人并不知道不少经常使用组件的设计原理。css
可否设计出通用前端组件也是区分前端工程师和前端api调用师的标准之一,那么应该如何设计出一个通用组件呢?html
下文中提到的组件库一般是指单个组件,而非集合的概念,集合概念的组件库是 Antd iView这种,咱们所说的组件库是指集合中的单个组件,集合性质的组件库须要考虑的要更多.前端
咱们在学习设计模式的时候会遇到不少种设计原则,其中一个设计原则就是单一职责原则,在组件库的开发中一样适用,咱们原则上一个组件只专一一件事情,单一职责的组件的好处很明显,因为职责单一就能够最大可能性地复用组件,可是这也带来一个问题,过分单一职责的组件也可能会致使过分抽象,形成组件库的碎片化。vue
举个例子,一个自动完成组件(AutoComplete),他实际上是由 Input 组件和 Select 组件组合而成的,所以咱们彻底能够复用以前的相关组件,就好比 Antd 的AutoComplete组件中就复用了Select组件,同时Calendar、 Form 等等一系列组件都复用了 Select 组件,那么Select 的细粒度就是合适的,由于 Select 保持的这种细粒度很容易被复用.node
那么还有一个例子,一个徽章数组件(Badge),它的右上角会有红点提示,多是数字也多是 icon,他的职责固然也很单一,这个红点提示也理所固然也能够被单独抽象为一个独立组件,可是咱们一般不会将他做为独立组件,由于在其余场景中这个组件是没法被复用的,由于没有相似的场景再须要小红点这个小组件了,因此做为独立组件就属于细粒度太小,所以咱们每每将它做为 Badge 的内部组件,好比在 Antd 中它以ScrollNumber的名称做为Badge的内部组件存在。react
因此,所谓的单一职责组件要创建在可复用的基础上,对于不可复用的单一职责组件咱们仅仅做为独立组件的内部组件便可。webpack
咱们要设计的自己就是通用组件库,不一样于咱们常见的业务组件,通用组件是与业务解耦可是又服务于业务开发的,那么问题来了,如何保证组件的通用性,通用性高必定是好事吗?css3
好比咱们设计一个选择器(Select)组件,一般咱们会设计成这样git
这是一个咱们最多见也最经常使用的选择器,可是问题是其通用性大打折扣程序员
当咱们有一个需求是长这样的时候,咱们以前的选择器组件就不符合要求了,由于这个 Select 组件的最下部须要有一个可拓展的条目的按钮
这个时候咱们难道要从新修改以前的选择器组件,甚至再造一个符合要求的选择器组件吗?一旦有这种状况发生,那么只能说明以前的选择器组件通用性不够,须要咱们从新设计.
Antd 的 Select 组件预留了dropdownRender
来进行自定义渲染,其依赖的 rc-select
组件中的代码以下
Antd 依赖了大量以
rc-
开头的底层组件,这些组件被react-component团队(同时也就是Antd 团队)维护,其主要实现组件的底层逻辑,Antd 则是在此基础上添加Ant Design设计语言而实现的
固然相似的设计还有不少,通用性设计实际上是必定意义上放弃对 DOM 的掌控,而将 DOM 结构的决定权转移给开发者,dropdownRender
其实就是放弃对 Select 下拉菜单中条目的掌控,Antd 的 Select 组件其实还有一个没有在文档中体现的方法getInputElement
应该是对 Input 组件的自定义方法,Antd整个 Select 的组件设计很是复杂,基本将全部的 DOM 结构控制权所有暴露给了开发者,其自己只负责底层逻辑和最基本的 DOM 结构.
这是 Antd 所依赖的 re-select 最终 jsx 的结构,其 DOM 结构很简单,可是暴露了大量自定义渲染的接口给开发者.
return ( <SelectTrigger onPopupFocus={this.onPopupFocus} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} dropdownAlign={props.dropdownAlign} dropdownClassName={props.dropdownClassName} dropdownMatchSelectWidth={props.dropdownMatchSelectWidth} defaultActiveFirstOption={props.defaultActiveFirstOption} dropdownMenuStyle={props.dropdownMenuStyle} transitionName={props.transitionName} animation={props.animation} prefixCls={props.prefixCls} dropdownStyle={props.dropdownStyle} combobox={props.combobox} showSearch={props.showSearch} options={options} multiple={multiple} disabled={disabled} visible={realOpen} inputValue={state.inputValue} value={state.value} backfillValue={state.backfillValue} firstActiveValue={props.firstActiveValue} onDropdownVisibleChange={this.onDropdownVisibleChange} getPopupContainer={props.getPopupContainer} onMenuSelect={this.onMenuSelect} onMenuDeselect={this.onMenuDeselect} onPopupScroll={props.onPopupScroll} showAction={props.showAction} ref={this.saveSelectTriggerRef} menuItemSelectedIcon={props.menuItemSelectedIcon} dropdownRender={props.dropdownRender} ariaId={this.ariaId} > <div id={props.id} style={props.style} ref={this.saveRootRef} onBlur={this.onOuterBlur} onFocus={this.onOuterFocus} className={classnames(rootCls)} onMouseDown={this.markMouseDown} onMouseUp={this.markMouseLeave} onMouseOut={this.markMouseLeave} > <div ref={this.saveSelectionRef} key="selection" className={`${prefixCls}-selection ${prefixCls}-selection--${multiple ? 'multiple' : 'single'}`} role="combobox" aria-autocomplete="list" aria-haspopup="true" aria-controls={this.ariaId} aria-expanded={realOpen} {...extraSelectionProps} > {ctrlNode} {this.renderClear()} {this.renderArrow(!!multiple)} </div> </div> </SelectTrigger> );
那么这么多须要自定义的地方,这个 Select 组件岂不是很难用?由于好像全部地方都须要开发者自定义,通用性设计在将 DOM 结构决定权交给开发者的同时也保留了默认结构,在开发者没有显示自定义的时候默认使用默认渲染结构,其实 Select 的基本使用很方便,以下:
<Select defaultValue="lucy" style={{ width: 120 }} disabled> <Option value="lucy">Lucy</Option> </Select>
组件的形态(DOM结构)永远是变幻无穷的,可是其行为(逻辑)是固定的,所以通用组件的秘诀之一就是将 DOM 结构的控制权交给开发者,组件只负责行为和最基本的 DOM 结构
因为CSS 自己的众多缺陷,如书写繁琐(不支持嵌套)、样式易冲突(没有做用域概念)、缺乏变量(不便于一键换主题)等不一而足。为了解决这些问题,社区里的解决方案也是出了一茬又一茬,从最先的 CSS prepocessor(SASS、LESS、Stylus)到后来的后起之秀 PostCSS,再到 CSS Modules、Styled-Components 等。
Antd 选择了 less 做为 css 的预处理方案,Bootstrap 选择了 Scss,这两种方案孰优孰劣已经争论了不少年了:
可是无论是哪一种方案都有一个很烦人的点,就是须要额外引入 css,好比 Antd 须要这样显示引入:
import Button from 'antd/lib/button'; import 'antd/lib/button/style';
为了解决这种尴尬的状况,Antd 用 Babel 插件将这种状况 Hack 掉了
而material-ui
并不存在这种状况,他不须要显示引入 css,这个最流行的 React 前端组件库里面只有 js 和 ts 两种代码,并不存在 css 相关的代码,为何呢?
他们用 jss
做为css-in-js 的解决方案,jsx 的引入已经将 js 和 html 耦合,css-in-js将 css 也耦合进去,此时组件便不须要显示引入 css,而是直接引用 js 便可.
这不是退化到史前前端那种写内联样式的时代了吗?
并非,史前前端的内联样式是整个项目耦合的状态,固然要被抛弃到历史的垃圾堆中,后来的样式和逻辑分离,其实是以页面为维度将 js css html 解耦的过程,现在的时代是组件化的时代了,jsx 已经将 js 和 html 框定到一个组件中,css 依然处于分离状态,这就致使了每次引用组件却还须要显示引入 css,css-in-js 正式完全组件化的解决方案.
固然,我我的目前在用 styled-components,其优势引用以下:
首先,styled-components 全部语法都是标准 css 语法,同时支持 scss 嵌套等经常使用语法,覆盖了全部 css 场景。
在样式复写场景下,styled-components 支持在任何地方注入全局 css,就像写普通 css 同样
styled-components 支持自定义 className,两种方式,一种是用 babel 插件, 另外一种方式是使用 styled.div.withConfig({ componentId: "prefix-button-container" }) 至关于添加 className="prefix-button-container"
className 语义化更轻松,这也是 class 起名的初衷
更适合组件库使用,直接引用 import "module" 便可,不然你有三条路能够走:像 antd 同样,单独引用 css,你须要给 node_modules 添加 css-loader;组件内部直接 import css 文件,若是任何业务项目没有 css-loader 就会报错;组件使用 scss 引用,全部业务项目都要配置一份 scss-loader 给 node_modules;这三种对组件库来讲,都没有直接引用来的友好
当你写一套组件库,须要单独发包,又有统同样式的配置文件需求,若是这个配置文件是 js 的,全部组件直接引用,对外彻底不用关注。不然,若是是 scss 配置文件,摆在面前仍是三条路:每一个组件单独引用 scss 文件,须要每一个业务项目给 node_modules 添加 scss-loader(若是业务用了 less,还要装一份 scss 是不);或者业务方只要使用了你的组件库,就要在入口文件引用你的 scss 文件,好比你的组件叫 button,scss 可能叫 common-css,别人听都没听过,还要查文档;或者业务方在 webpack 配置中单独引用你的 common-css,这也不科学,若是用了3个组件库,每天改 webpack 配置也很不方便。
当 css 设置了一半样式,另外一半真的须要 js 动态传入,你不得不 css + css-in-js 混合使用,项目久了,维护的时候发现某些 css-in-js 不变了,能够固化在 css 里,css 里固定的值又由于新去求变得可变了,你又得拿出来放在 css-in-js 里,实践过就知道有多么烦心。
选 Typescript ,由于巨硬大法好...
能够看看知乎问题下个人回答你为何不用 Typescript
或者看此文TypeScript体系调研报告
组件的具体实现部分固然是组件库的核心,可是在现代前端库中其余部分也必不可少,咱们须要一堆工具来辅助咱们开发,例如编译工具、代码检测工具、打包工具等等。
市面上打包工具数不胜数,最火爆的固然是须要配置工程师专门配置的webpack,可是在类库开发领域它有一个强大的对手就是 rollup。
现代市面上主流的库基本都选择了 rollup 做为打包工具,包括Angular React 和 Vue, 做为基础类库的打包工具 rollup 的优点以下:
虽然上面部分功能已经被 webpack 实现了,可是 rollup 明显引入得更早,而Scope Hoisting更是杀手锏,因为 webpack 不得不在打包代码中构建模块系统来适应 app 开发(模块系统对于单一类库用处很小),Scope Hoisting将模块构建在一个函数内的作法更适合类库的打包.
因为 JavaScript 各类诡异的特性和大型前端项目的出现,代码检测工具已是前端开发者的标配了,Douglas Crockford最先于2002创造出了 JSLint,可是其没法拓展,具备极强的Douglas Crockford我的色彩,Anton Kovalyov因为没法忍受 JSLint 没法拓展的行为在2011年发布了可拓展的JSHint,一时之间JSHint成为了前端代码检测的流行解决方案.
随后的2013年,Nicholas C. Zakas鉴于JSHint拓展的灵活度不够的问题开发了全新的基于 AST 的 Lint 工具 ESLint,并随着 ES6的流行统治了前端界,ESLint 基于Esprima进行 JavaScript 解析的特性极易拓展,JSHint 在很长一段时间没法支持 ES6语法致使被 ESLint 超越.
可是在 Typescript 领域 ESLint 却处于弱势地位,TSLint 的出现要比 ESLint 正式支持 Typescript 早不少,目前 TSLint 彷佛是 TS 的事实上的代码检测工具.
注: 文章成文较早,我也没想到前阵子 TS 官方钦点了 ESLint,TSLint 失宠了,面向将来的官方标配的代码检测工具确定是 ESLint 了,可是 TSLint 目前依然被大量使用,如今仍然能够放心使用
代码检测工具是一方面,代码检测风格也须要咱们作选择,市面上最流行的代码检测风格应该是 Airbnb 出品的eslint-config-airbnb
,其最大的特色就是极其严格,没有给开发者任何选择的余地,固然在大型前端项目的开发中这种严格的代码风格是有利于协做的,可是做为一个类库的代码检测工具而言并不适合,因此咱们选择了eslint-config-standard
这种相对更为宽松的代码检测风格.
如下两种 commit 哪一个更严谨且易于维护?
最开始使用 commit 的时候我也常常犯下图的错误,直到看到不少明星类库的 commit 才意识到本身的错误,写好 commit message 不只有助于他人 review, 还能够有效的输出 CHANGELOG, 对项目的管理实际相当重要.
目前流行的方案是 Angular 团队的规范,其关于 head 的大体规范以下:
固然规范人们不必定会遵照,我最初知道此类规范的时候也并无严格遵循,由于人总会偷懒,直到用commitizen
将此规范集成到工具流中,每一个 commit 就不得不遵循规范了.
我具体参考了这篇文章: 优雅的提交你的 Git Commit Message
业务开发中因为前端需求变更频繁的特性,致使前端对测试的要求并无后端那么高,后端业务逻辑一旦定型变更不多,比较适合测试.
可是基础类库做为被反复依赖的模块和较为稳定的需求是必须作测试的,前端测试库也可谓是种类繁多了,通过比对以后我仍是选择了目前最流行也是被三大框架同时选择了的 Jest 做为测试工具,其优势很明显:
固然以上是主要工具的选择,还有一些好比:
那么以上这么多配置难道要咱们每次都本身写吗?组件的具体实现才是组件库的核心,咱们为何要花这么多时间在配置上面?
咱们在创建 APP 项目时一般会用到框架官方提供的脚手架,好比 React 的 create-react-app,Angular 的 Angular-Cli 等等,那么能不能有一个专门用于组件开发的快速启动的脚手架呢?
有的,我最近开发了一款快速启动组件库开发的命令行工具--create-component
利用
create-component init <name>
来快速启动项目,咱们提供了丰富的可选配置,只要你作好技术选型后,根据提示去选择配置便可,create-component 会自动根据配置生成脚手架,其灵感就来源于 vue-cli和 Angular-cli.
说了不少理论,那么实战如何呢?设计一个通用组件试试吧!
轮播图(Carousel),在 Antd 中被称为走马灯,多是前端开发者最多见的组件之一了,无论是在 PC 端仍是在移动端咱们总能见到他的身影.
那么咱们一般是如何使用轮播图的呢?Antd 的代码以下
<Carousel> <div><h3>1</h3></div> <div><h3>2</h3></div> <div><h3>3</h3></div> <div><h3>4</h3></div> </Carousel>
问题是咱们在Carousel
中放入了四组div
为何一次只显示一组呢?
图中被红框圈住的为可视区域,可视区域的位置是固定的,咱们只须要移动后面div
的位置就能够作到1 2 3 4四个子组件轮播的效果,那么子组件2目前在可视区域是能够被看到的,1 3 4应该被隐藏,这就须要咱们设置overflow 属性为 hidden来隐藏非可视区域的子组件.
复制查看动图: https://images2015.cnblogs.com/blog/979044/201707/979044-20170710105934040-1007626405.gif
所以就比较明显了,咱们设计一个可视窗口组件Frame
,而后将四个 div
共同放入幻灯片组合组件SlideList
中,并用SlideItem
分别将 div
包裹起来,实际代码应该是这样的:
<Frame> <SlideList> <SlideItem> <div><h3>1</h3></div> </SlideItem> <SlideItem> <div><h3>2</h3></div> </SlideItem> <SlideItem> <div><h3>3</h3></div> </SlideItem> <SlideItem> <div><h3>4</h3></div> </SlideItem> </SlideList> </Frame>
咱们不断利用translateX
来改变SlideList
的位置来达到轮播效果,以下图所示,每次轮播的触发都是经过改变transform: translateX()
来操做的
搞清楚基本原理那么实现起来相对容易了,咱们以移动端的实现为例,来实现一个基础的移动端轮播图.
首先咱们要肯定可视窗口的宽度,由于咱们须要这个宽度来计算出SlideList
的长度(SlideList
的长度一般是可视窗口的倍数,好比要放三张图片,那么SlideList
应该为可视窗口的至少3倍),否则咱们没法经过translateX
来移动它.
咱们经过getBoundingClientRect
来获取可视区域真实的长度,SlideList
的长度那么为:
slideListWidth = (len + 2) * width
(len 为传入子组件的数量,width 为可视区域宽度)
至于为何要+2
后面会提到.
/** * 设置轮播区域尺寸 * @param x */ private setSize(x?: number) { const { width } = this.frameRef.current!.getBoundingClientRect() const len = React.Children.count(this.props.children) const total = len + 2 this.setState({ slideItemWidth: width, slideListWidth: total * width, total, translateX: -width * this.state.currentIndex, startPositionX: x !== undefined ? x : 0, }) }
获取到了总长度以后如何实现轮播呢?咱们须要根据用户反馈来触发轮播,在移动端一般是经过手指滑动来触发轮播,这就须要三个事件onTouchStart
onTouchMove
onTouchEnd
.
onTouchStart
顾名思义是在手指触摸到屏幕时触发的事件,在这个事件里咱们只须要记录下手指触摸屏幕的横轴坐标 x 便可,由于咱们会经过其横向滑动的距离大小来判断是否触发轮播
/** * 处理触摸起始时的事件 * * @private * @param {React.TouchEvent} e * @memberof Carousel */ private onTouchStart(e: React.TouchEvent) { clearInterval(this.autoPlayTimer) // 获取起始的横轴坐标 const { x } = getPosition(e) this.setSize(x) this.setState({ startPositionX: x, }) }
onTouchMove
顾名思义是处于滑动状态下的事件,此事件在onTouchStart
触发后,onTouchEnd
触发前,在这个事件中咱们主要作两件事,一件事是判断滑动方向,由于用户可能向左或者向右滑动,另外一件事是让轮播图跟随手指移动,这是必要的用户反馈.
/** * 当触摸滑动时处理事件 * * @private * @param {React.TouchEvent} e * @memberof Carousel */ private onTouchMove(e: React.TouchEvent) { const { slideItemWidth, currentIndex, startPositionX } = this.state const { x } = getPosition(e) const deltaX = x - startPositionX // 判断滑动方向 const direction = deltaX > 0 ? 'right' : 'left' this.setState({ direction, moveDeltaX: deltaX, // 改变translateX来达到轮播组件跟随手指移动的效果 translateX: -(slideItemWidth * currentIndex) + deltaX, }) }
onTouchEnd
顾名思义是滑动完毕时触发的事件,在此事件中咱们主要作一个件事情,就是判断是否触发轮播,咱们会设置一个阈值threshold
,当滑动距离超过这个阈值时才会触发轮播,毕竟没有阈值的话用户稍微触碰轮播图就形成轮播,误操做会形成不好的用户体验.
/** * 滑动结束处理的事件 * * @private * @memberof Carousel */ private onTouchEnd() { this.autoPlay() const { moveDeltaX, slideItemWidth, direction } = this.state const threshold = slideItemWidth * THRESHOLD_PERCENTAGE // 判断是否轮播 const moveToNext = Math.abs(moveDeltaX) > threshold if (moveToNext) { // 若是轮播触发那么进行轮播操做 this.handleSwipe(direction!) } else { // 轮播不触发,那么轮播图回到原位 this.handleMisoperation() } }
咱们常见的轮播图确定不是生硬的切换,通常在轮播中会有一个渐变或者缓动的动画,这就须要咱们加入动画效果.
咱们制做动画一般有两个选择,一个是用 css3自带的动画效果,另外一个是用浏览器提供的requestAnimationFrame API
孰优孰劣?css3简单易用上手快,兼容性好,requestAnimationFrame
灵活性更高,能实现 css3实现不了的动画,好比众多缓动动画 css3都一筹莫展,所以咱们毫无疑问地选择了requestAnimationFrame
.
双方对比请看张鑫旭大神的CSS3动画那么强,requestAnimationFrame还有毛线用?
想用requestAnimationFrame
实现缓动效果就须要特定的缓动函数,下面就是典型的缓动函数
type tweenFunction = (t: number, b: number, _c: number, d: number) => number const easeInOutQuad: tweenFunction = (t, b, _c, d) => { const c = _c - b; if ((t /= d / 2) < 1) { return c / 2 * t * t + b; } else { return -c / 2 * ((--t) * (t - 2) - 1) + b; } }
缓动函数接收四个参数,分别是:
经过这个函数咱们能算出每一帧轮播图所在的位置, 以下:
在获取每一帧对应的位置后,咱们须要用requestAnimationFrame
不断递归调用依次移动位置,咱们不断调用animation
函数是其触发函数体内的this.setState({ translateX: tweenQueue[0], })
来达到移动轮播图位置的目的,此时将这数组内的30个位置依次快速执行就是一个缓动动画效果.
/** * 递归调用,根据轨迹运动 * * @private * @param {number[]} tweenQueue * @param {number} newIndex * @memberof Carousel */ private animation(tweenQueue: number[], newIndex: number) { if (tweenQueue.length < 1) { this.handleOperationEnd(newIndex) return } this.setState({ translateX: tweenQueue[0], }) tweenQueue.shift() this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex)) }
可是咱们发现了一个问题,当咱们移动轮播图到最后的时候,动画出现了问题,当咱们向左滑动最后一个轮播图div4
时,这种状况下应该是图片向左滑动,而后第一张轮播图div1
进入可视区域,可是反常的是图片快速向右滑动div1
出如今但是区域...
由于咱们此时将位置4设置为了位置1,这样才能达到不断循环的目的,可是也形成了这个反作用,图片行为与用户行为产生了相悖的状况(用户向左划动,图片向右走).
目前业界的广泛作法是将图片首尾相连,例如图片1前面链接一个图片4,图片4后跟着一个图片1,这就是为何以前计算长度时要+2
slideListWidth = (len + 2) * width
(len 为传入子组件的数量,width 为可视区域宽度)
当咱们移动图片4时就不会出现上述向左滑图片却向右滑的状况,由于真实状况是:
图片4 -- 滑动为 -> 伪图片1
也就是位置 5 变成了位置 6
当动画结束以后,咱们迅速把伪图片1
的位置设置为真图片1
,这实际上是个障眼法,也就是说动画执行过程当中其实是图片4
到伪图片1
的过程,当结束后咱们偷偷把伪图片1
换成真图片1
,由于两个图如出一辙,因此这个转换的过程用户根本看不出来...
如此一来咱们就能够实现无缝切换的轮播图了
咱们实现了轮播图的基本功能,可是其通用性依然存在缺陷:
以上都是能够对轮播图进行拓展的方向,相关的还有性能优化方面
咱们的具体代码中有一个相关实现,咱们的轮播图实际上是有自动轮播功能的,可是不少时候页面并不在用户的可视页面中,咱们能够根据是否页面被隐藏来取消定时器终止自动播放.
github项目地址
以上 demo 仅供参考,实际项目开发中最好仍是使用成熟的开源组件,要有造轮子的能力和不造轮子的觉悟
想要实时关注笔者最新的文章和最新的文档更新请关注公众号程序员面试官,后续的文章会优先在公众号更新.
简历模板: 关注公众号回复「模板」获取
《前端面试手册》: 配套于本指南的突击手册,关注公众号回复「fed」获取
本文由博客一文多发平台 OpenWrite 发布!