译者:@没有好名字了
译文:https://github.com/lightningminers/article/issues/36,https://juejin.im/post/5c49cff56fb9a049bd42a90f
做者:@Andrew Dinihan
原文:https://engineering.carsguide.com.au/front-end-component-design-principles-55c5963998c9
我在最近的工做中开始使用 Vue 进行开发,可是我在上一家公司积累了三年以上 React 开发经验。虽然在两种不一样的前端框架之间进行切换确实须要学习不少,可是两者之间在不少基础概念、设计思路上是相通的。其中之一就是组件设计,包括组件层次结构设计以及组件各自的职责划分。javascript
组件是大多数现代前端框架的基本概念之一,在 React 和 Vue 以及 Ember 和 Mithril 等框架中均有所体现。组件一般是由标记语言、逻辑和样式组成的集合。它们被建立的目的就是做为可复用的模块去构建咱们的应用程序。html
相似于传统 OOP 语言中 class 的设计,在设计组件的时候须要考虑到不少方面,以便它们能够很好的复用,组合,分离和低耦合,可是功能能够比较稳定的实现,即便是在超出实际测试用例范围的状况下。这样的设计提及来容易作起来却很难,由于现实中咱们每每没有足够的时间按照最优的方式去作。前端
在本文中,我想介绍一些组件相关的设计概念,在进行前端开发时应该考虑这些概念。我认为最好的方法是给每一个概念一个简洁精炼的名字,而后逐一解释每一个概念是什么以及为何重要,对于比较抽象概念的会举一些例子来帮助理解。
vue
如下这个列表并非不全面也不完整,但我注意到的只有 8 件事情值得一提,对于那些已经能够编写基本组件但想要提升他们的技术设计技能的人来讲。因此这是列表:
如下列举的这个列表仅仅是是我注意到的 8 个方面,固然组件设计还有其余一些方面。在此我只是列举出来我认为值得一提的。java
对于已经掌握基本的组件设计而且想要提升自身的组件设计能力的开发者,我认为如下 8 � 项是我认为值得去注意的,固然这并非组件设计的所有。ios
层次结构和 UML 类图git
扁平化、面向数据的 state/propsgithub
更加纯粹的 State 变化web
低耦合算法
辅助代码分离
提炼精华
及时模块化
集中/统一的状态管理
请注意,代码示例可能有一些小问题或有点人为设计。可是它们并不复杂,只是想经过这些例子来帮助更好的理解概念。
应用内的组件共同造成组件树, 而在设计过程当中将组件树可视化展现能够帮助你全面了解应用程序的布局。一个比较好的展现这些的办法就是组件图。
UML 中有一个在 OOP 类设计中常用的类型,称为 UML 类图。类图中显示了类属性、方法、访问修饰符、类与其余类的关系等。虽然 OOP 类设计和前端组件设计差别很大,可是经过图解辅助设计的方法值得参考。对于前端组件,该图表能够显示:
State
Props
Methods
与其余组件的关系( Relationship to other components )
所以,让咱们看一下下面这个基础表组件的组件层次图,该组件的渲染对象是一个数组。该组件的功能包括显示总行数、标题行和一些数据行,以及在单击其单元格标题格时对该列进行排序。在它的 props 中,它将传递列列表(具备属性名称和该属性的人类可读版本),而后传递数据数组。咱们能够添加一个可选的’on row click’功能来进行测试。
虽然这样的事情可能看起来有点多,可是它具备许多优势,而且在大型应用程序开发设计中所须要的。这样会带来的一个比较重要的问题是它会须要你在开始 codeing 以前就须要考虑到具体细节的实现,例如每一个组件须要什么类型的数据,须要实现哪些方法,所需的状态属性等等。
一旦你对如何构建一个组件(或一组组件)的总体有大概的思路,就会很容易认为当本身真正开始编码实现时,它会如本身所指望的循序渐进的完成,但事实上每每会出现一些预料以外的事情, 固然你确定不但愿所以去重构以前的某些部分,或者忍受初始设想中的缺点并所以扰乱你的代码思路。而这些类图的如下优势能够帮助你有效的规避以上问题,优势以下:
一个易于理解的组件组成和关联视图
一个易于理解的应用程序 UI 层次结构的概述
一个结构数据层次及其流动方式的视图
一个组件功能职责的快照
便于使用图表软件建立
顺带一提,上图并非基于某些官方标准,好比 UML 类图,它是我基本上建立的一套表达规则。例如,在 props 、方法的参数和返回值的数据类型定义声明都是基于 Typescript 语法。我尚未找到书写前端组件类图的官方标准,多是因为前端 Javascript 开发的相对较新且生态系统不够完善所致,但若是有人知道主流标准,请在回复中告诉我!
在 state 和 props 频繁被 watch 和 update 的状况下,若是你有使用嵌套数据,那么你的性能可能会受到影响,尤为是在如下场景中,例如一些由于浅对于而触发的从新渲染;在涉及 immutability 的库中,好比 React,你必须建立状态的副本而不是像在 Vue 中那样直接更改它们,而且使用嵌套数据这样作可能会建立笨拙,丑陋的代码。
//Flat, data-oriented state/propsconst state = { clients: { allClients, firstClient, lastClient: { name: 'John', phone: 'Doe', address: { number: 5, street: 'Estin', suburb: 'Parrama', city: 'Sydney' } } }}// 假若咱们须要去修改 address number时须要怎么办?const test = { clients: { ...state.clients, lastClient: { ...state.clients.lastClient, address: { ...state.clients.lastClient.address, number: 10 } } }}
即便使用展开运算符,这种写法也并不够优雅。扁平 props 也能够很好地清除组件正在使用的数据值。若是你传给组件一个对象可是你并不能清楚的知道对象内部的属性值,因此找出实际须要的数据值是来自组件具体的属性值则是额外的工做。但若是 props 足够扁平化,那么起码会方便使用和维护。
// 咱们没法得知 customer 这个对象里面拥有什么属性// 这个组件须要使用这个对象全部的属性值或者只是须要其中的一部分?// 若是我想要将这个组件在别处使用,我应该传入什么样的对象<listItem customer={customer}/>// 下面的这个组件接收的属性就一目了然<listItem phone={customer.phone} name={customer.name} iNumber={customer.iNumber} />
state / props 还应该只包含组件渲染所需的数据。You shouldn’t store entire components in the state/props and render straight from there.
(此外,对于数据繁重的应用程序,数据规范化能够带来巨大的好处,除了扁平化以外,你可能还须要考虑一些别的优化方法)。
对 state 的更改一般应该响应某种事件,例如用户单击按钮或 API 的响应。此外它们不该该由于别的 state 的变化而作出响应,由于 state 之间这种关联可能会致使难以理解和维护的组件行为。state 变化应该没有反作用。
若是你滥用watch而不是有限考虑以上原则,那么在 Vue 的使用中就可能由此引起的问题。咱们来看一个基本的 Vue 示例。我正在研究一个从 API 获取一些数据并将其呈现给表的组件,其中排序,过滤等功能都是后端完成的,所以前端须要作的就是 watch 全部搜索参数,并在其变化时触发 API 调用。其中一个须要 watch 的值是“zone”,这是一个过滤器。当更改时,咱们想要使用过滤后的值从新获取服务端数据。watcher 以下:
//State change purityzone:{ handler() { // 重置页码 if(this.pagination.page > 1){ this.pagination.page = 1 return; } this.getDataFromApi() }}
你会发现一些奇怪的东西。若是他们超出告终果的第一页,咱们重置页码而后结束?这彷佛不对,若是它们不在第一页上,咱们应该重置分页并触发 API 调用,对吧?为何咱们只在第 1 页上从新获取数据?实际上缘由是这样,让咱们来看下完整的 watch:
watch: { pagination() { this.getDataFromApi() }},zone: { handler() { // 重置页码 if(this.pagination.page > 1) { this.pagination.page = 1 return; } this.getDataFromApi() }}
当分页改变时,应用首先会经过 pagination 的处理函数从新获取数据。所以,若是咱们改变了分页,咱们并不须要去关注数据更新这段逻辑。
让咱们一下来考虑如下流程:若是当前页面超出了第 1 页而且更改了 zone,而这个变化会触发另外一个状态(pagination)发生变化,进而触发 pagination 的观察者从新请求数据。这样并非预料之中的行为,并且产生的代码也不够直观。
解决方案是改变页码这个行为的事件处理函数(不是观察者,用户更改页面的实际处理函数)应该更改页面值并触发 API 调用请求数据。这也将消除对观察者的需求。经过这样的设置,直接从其余地方改变分页状态也不会致使从新获取数据的反作用。
虽然这个例子很是简单,但不难看出将更复杂的状态更改关联在一块儿会产生使人难以理解的代码,这些代码不只不可扩展而且是调试的噩梦。
组件的核心思想是它们是可复用的,为此要求它们必须具备功能性和完整性。“耦合”是指实体彼此依赖的术语。松散耦合的实体应该可以独立运行,而不依赖于其余模块。就前端组件而言,耦合的主要部分是组件的功能依赖于其父级及其传递的 props 的多少,以及内部使用的子组件(固然还有引用的部分,如第三方模块或用户脚本)。
紧密耦合的组件每每更不容易被复用,当它们做为特定父组件的子项时,就很难正常工做,当父组件的一个子组件或一系列子组件只能在该父组件才可以正常发挥做用时,就会使得代码写的很冗余。由于父子组件别过分的关联在一块儿了。
在设计组件时,你应该考虑到更加通用的使用场景,而不只仅只是为了知足最开始某个特定场景的需求。虽然通常来讲组件最初都是出于特定目的进行设计,但不要紧,若是在设计它们站在更高的角度去看待,那么不少组件将具备更好的适用性。
让咱们看一个简单的 React 示例,你想在写出一个带有一个 logo 的连接列表,经过链接能够访问特定的网站。最开始的设计多是并无跟内容合理的进行解耦。下面是最初的版本:
const Links = ()=>( <div className="links-container"> <div class="links-list"> <a href="/"> Home </a> <a href="/shop"> Products </a> <a href="/help"> Help </a> </div> <div className="links-logo"> <img src="/default/logo.png"/> </div> </div>)
虽然这这样会知足预期的使用场景,但却很难被复用。若是你想要更改连接地址该怎么办?你必须从新复制一份相同代码,而且手动去替换连接地址。并且, 若是你要去实现一个用户能够更改链接的功能,那么意味着不可能将代码写“死”,也不能指望用户去手动修改代码,那么让咱们来看一下复用性更高的组件应该如何设计:
const DEFAULT_LINKS = [ {route: "/", text: "Home"}, {route: "/shop", text: "Products"}, {route: "/help", text: "Help"}]const DEFAULT_LOGO = "/default/logo.png"const Links = ({links = DEFAULT_LINKS,logoPath = DEFAULT_LOGO }) => ( <div className="links-container"> <div class="links-list"> // 将数组依次渲染为超连接 links.map((link) => <a href={link.route}> {link.text}</a>) </div> <div className="links-logo"> <img src={logoPath}/> </div> </div>)
在这里咱们能够看到,虽然它的原始连接和 logo 具备默认值,但咱们能够经过 props 传入的值去覆盖掉默认值。让咱们来看一下它在实际中的使用:
const adminLinks = { links: [ {route: "/", text: "Home"}, {route: "/metrics", text: "Site metrics"}, {route: "/admin", text: "Admin panel"} ], logoPath: "/admin/logo.png"}<Links {...adminLinks} />
并不须要从新编写新的组件!若是咱们解决上文中用户能够自定义连接的使用场景,能够考虑动态构建连接数组。此外,虽然在这个具体的例子中没有解决,但咱们仍然能够注意到这个组件没有与任何特定的父/子组件创建密切关联。它能够在任何须要的地方呈现。改进后的组件明显比最第一版本具备更好的复用性。
若是不是要设计须要服务于特定的一次性场景的组件,那么设计组件的最终目标是让它与父组件松散耦合,呈现更好的复用性,而不是受限于特定的上下文环境。
这个可能不那么的偏理论,但我仍然认为这很重要。与你的代码库打交道是软件工程的一部分,有时一些基本的组织原则可使事情变得更加顺畅。在长时间与代码相处的过程当中,即便改变一个很小的习惯也能够产生很大的不一样。其中一个有效的原则就是将辅助代码分离出来放在特定的地方,这样你在处理组件时就没必要考虑这些。如下列举一些方面:
配置代码
假数据
大量非技术说明文档
由于在尝试处理组件的核心代码时,你不但愿看到与技术无关的一些说明(由于会多滚动几下鼠标滚轮甚至打断思路)。在处理组件时,你但愿它们尽量通用且可重用。查看与组件当前上下文相关的特定信息可能会使得设计出来的组件不易与具体业务解耦。
虽然这样作起来可能具备挑战性,但开发组件的一个好方法是使它们包含渲染它们所需的最小 Javascript。一些可有可无的东西,好比数据获取,数据整理或事件处理逻辑,理想状况下应该将通用的部分移入外部 js 或或者放在共同的祖先中。
单独从组件分的“视图”部分来看,即你看到的内容(html 和 样式)。其中的 Javascript 仅用于帮助渲染视图,可能还有一些针对特定组件的逻辑(例如在其余地方使用时)。除此以外的任何事情,例如 API 调用,数值的格式化(例如货币或时间)或跨组件复用的数据,均可以移动外部的 js 文件中。让咱们看一下 Vue 中的一个简单示例,使用嵌套列表组件。咱们能够先看下下面这个有问题的版本。
这是第一个层级:
// 组件父级<template> <div> <ul v-for="(topLevelItem,i) in fomattedItems" :key="i"> <img :src="topLevelItem.imagePath"> </ul> <nested-list :items="topLevelItem.nestedItems" /> </div></template><script>import { mapState } from 'vuex'import NestedList from '~/components/NestedList 'import data from '~/data/items.json'import removeFalsyItems from '~/scripts/removeFalsyItems'export default { data() { return {} }, components: { NestedList }, computed: { fomattedItems() { return removeFalsyItems(data) } }}</script>
这是嵌套列表组件:
// nestedList 组件<template> <div> <ul> <li v-for="(secondLevelItem, i) in items" :key="i" @click="updateText(secondLevelItem.text)">{{secondLevelItem.text}}</li> </ul> </div></template><script >import axios from 'axios'export default { props: { items: { type: Array, defafult: () => [] } }, methods: { updataText(text) { this.$store.commit('updateText', text) axios.post('/endpoint', { text }).then(res => { console.log(res) }) } }}</script>
在这里咱们能够看到此列表的两个层级都具备外部依赖关系,最上层导引入外部 js 文件中的函数和 JSON 文件的数据,嵌套组件链接到 Vuex 存储并使用 axios 发送请求。它们还具备仅适用于当前场景的嵌入功能(最上层中源数据处理和嵌套列表的中度 click 时间的特定响应功能)。
虽然这里采用了一些很好的通用设计技术,例如将通用的 数据处理方法移动到外部脚本而不是直接将函数写死,但这样仍然不具有很高的复用性。若是咱们是从 API 的响应中获取数据,可是这个数据跟咱们指望的数据结构或者类型不一样的时候要怎么办?或者咱们指望单击嵌套项时有不一样的行为?在遇到这些需求的场景下,这个组件没法被别的组件直接引用并根据实际需求改变自身的特性。
让咱们看看咱们是否能够经过提高数据并将事件处理做为 props 传递来解决这个问题,这样组件就能够简单地呈现数据而不会封装任何其余逻辑。
这是改进后的第一级别:
// 组件父级<template> <div> <ul v-for="(topLevelItem,i) in fomattedItems" :key="i"> <img :src="topLevelItem.imagePath"> </ul> <nested-list :items="topLevelItem.nestedItems" v-bind="{onNestedItemClick}" /> </div></template><script>import NestedList from '~/components/NestedList 'export default { components: { NestedList }, props: { items:{ type: Array, default: null }, onNestedItemClick:{ type: Function, default: null } }}</script>
而新的第二级:
// nestedList 组件<template> <div> <ul> <li v-for="(secondLevelItem, i) in items" :key="i" @click="onNestedItemClick(secondLevelItem.text)">{{secondLevelItem.text}}</li> </ul> </div></template><script >import axios from 'axios'export default { props: { items: { type: Array, defafult: null }, onNestedItemClick: { type: Function, defafult: null } }}</script>
使用这个新列表,咱们能够得到想要的数据,并定义了嵌套列表的 onClick 处理函数,以便在父级中传入任何咱们想要的操做,而后将它们做为 props 传递给顶级组件。这样,咱们能够将导入和逻辑留给单个根组件,因此不须要为了可以在新的场景下使用去从新再实现一个相似组件。
有关此主题的简短文章能够在这里找到。它由 Redux 的做者 Dan Abramov 编写,虽然是用 React 举例说明。可是组件设计的思想是通用的。
咱们在实际进行组件抽离工做的时候,须要考虑到不要过分的组件化,诚然将大块代码变成松散耦合且可用的部分是很好的实践,可是并非全部的页面结构(HTML 部分)都须要被抽离成组件,也不是全部的逻辑部分都须要被抽出到组件外部。
在决定是否将代码分开时,不管是 Javascript 逻辑仍是抽离为新的组件,都须要考虑如下几点。一样,这个列表并不完整,只是为了让你了解须要考虑的各类事项。(记住,仅仅由于它不知足一个条件并不意味着它不会知足其余条件,因此在作出决定以前要考虑全部条件):
是否有足够的页面结构/逻辑来保证它?
若是它只是几行代码,那么最终可能会建立更多的代码来分隔它,而不只仅是将代码放入其中。
代码重复(或可能重复)?
若是某些东西只使用一次,而且服务于一个不太可能在其余地方使用的特定用例,那么将它嵌入其中可能会更好。若是须要,你能够随时将其分开(但不要在须要作这些工做的时候将此做为偷懒的借口)。
它会减小须要书写的模板吗?
例如,假设你想要一个带有特定样式的 div 属性结构和一些静态内容/功能的组件,其中一些可变内容嵌套在内部。经过建立可重用的包装器(与 React 的 HOC 或 Vue 的 slot 同样),你能够在建立这些组件的多个实例时减小模板代码,由于你不须要从新再写外部的包装代码。
性能会收到影响吗?
更改 state/props 会致使从新渲染,当发生这种状况时,你须要的是 只是从新去渲染通过 diff 以后获得的相关元素节点。在较大的、关联很紧密的组件中,你可能会发现状态更改会致使在不须要它的许多地方从新呈现,这时应用的性能就可能会开始受到影响。
你是否会在测试代码的全部部分时遇到问题?
咱们老是但愿可以进行充分的测试,好比对于一个组件,咱们会指望它的正常工做不依赖特定的用例(上下文),而且全部 Javascript 逻辑都按预期工做。当元素具备某个特定假设的上下文或者分别将一大堆逻辑嵌入到单个函数中时,这样将会很难知足咱们的指望。若是测试的组件是具备比较大模板和样式的单个巨型组件,那么组件的渲染测试也会很难进行。
你是否有一个明确的理由?
在分割代码时,你应该考虑它究竟实现了什么。这是否容许更松散的耦合?我是否打破了一个逻辑上有意义的独立实体?这个代码是否真的可能在其余地方被重复使用?若是你不能清楚地回答这个问题,那最好先不要进行组件抽离。由于这样可能致使一些问题(好比拆解掉本来某些潜在的耦合关系)。
这些好处是否超过了成本?
分离代码不可避免地须要时间和精力,其数量根据具体状况而变化,而且在最终作出此决定时会有许多因素(例如此列表中列举出来的一些)。通常来讲,进行一些对抽象的成本和收益研究能够帮助更快更准确去作出是否须要组件化的决策。最后,我提到了这一点,由于若是咱们过度关注优点,就很容易忘记达成目标所须要作的努力,因此在作出决定之前须要权衡这两个方面。
许多大型应用程序使用 Redux 或 Vuex 等状态管理工具(或者具备相似 React 中的 Context API 状态共享设置)。这意味着他们从 store 得到 props 而不是经过父级传递。在考虑组件的可重用性时,你不只要考虑直接的父级中传递而来的 props,还要考虑 从 store 中获取到的 props。若是你在另外一个项目中使用该组件,则须要在 store 中使用这些值。或许其余项目根本不使用集中存储工具,你必须将其转换为从父级中进行 props 传递 的形式。
因为将组件挂接到 store(或上下文)很容易而且不管组件的层次结构位置如何均可以完成,所以很容易在 store 和 web 应用的组件之间快速建立大量紧密耦合(不关心组件所处的层级)。一般将组件与 store 进行关联只需简单几行代码。可是请注意一点,虽然这种链接(耦合)更方便,但它的含义并无什么不一样,你也须要考虑尽可能符合如同在使用父级传递方式时的要点。
我想提醒你们的是:应该更注重以上这些组件设计的原则和你已知的一些最佳实践在实际中的应用。虽然你应该尽力维护良好的设计,可是不要为了包装 JIRA ticket 或一个取消请求而有损代码完整性,同时老是把理论置于现实世界结果之上的人也每每会让他们的工做受到影响。大型软件项目有许多活动部分,软件工程的许多方面与编码没有特别的关系,但仍然是不可或缺的,例如遵照最后期限和处理非技术指望。
虽然充分的准备很重要,应该成为任何专业软件设计的一部分,但在现实世界中,切实的结果才是最为重要的。当你被雇用来实际创造一些东西时,若是在最后期限到来以前,你有的只是一个如何构建完美产品的惊人计划,但却没有实际的成果,你的雇主可能不会过高兴吧?此外,软件工程中的东西不多彻底按计划进行,所以过分具体的计划每每会在时间使用方面获得拔苗助长的效果。
此外,组件规划和设计的概念也适用于组件重构。虽然用了 50 年的时间来计划一切使人难以忍受的细节,而后从一开始就完美地编写它就会很好,回到现实世界,咱们每每会遇到这种状况,即为了赶进度而不能使代码达到完美的预期。然而,一旦咱们有了空闲时间,那么一个推荐的作法就是回过头来重构早期不够理想的的代码,这样它就能够做为咱们向前发展的坚实基础。
在一天结束时,虽然你的直接责任多是“编写代码”,但你不该忽视你的最终目标,即创建一些东西。建立产品。为了产生一些你能够引觉得豪的东西并帮助别人,即便它在技术上并不完美,永远记得找到一个平衡点。不幸的是,在一周内天天 8 小时盯着眼前的代码会使得眼界和角度变得更为“狭窄”,这个时候你须要的你是退后一步,确保你不要为了一颗树而失去整个森林。