前端编程之道系列,欢迎关注,github.com/501351981/H...
写代码也要有原则?
在编码过程中,有时候可能会遇到一些比较纠结的情况,比如应不应该抽取组件,怎么抽取组件,颗粒度究竟要到什么程度,组件或者方法调用怎么传参更好;有时候在review别人代码时,感觉代码写的有问题,但是又说不出来到底哪里有问题,不知道如何评价一段代码是好或者不好,这时候你应该了解下常见的编程原则了。
所谓编程原则,就是前人们经过大量的实践和试错,为我们总结的一些特别有价值的基本准则、规范或原理,这些编程原则能够指导我们如何进行编码以及如何进行决策(取舍),能够用来评价一段代码的质量高低。
编程原则和我们做人的原则道理是一样的,你的种种行为只是做人原则的一种外化,而原则是行为的内在驱动力。就像一个人把如果诚信和道义作为自己的做人原则,那么他就不会去欺骗朋友换取自己的利益,而如果一个人以维护自己最大利益为做人原则,那么他可能就会为了晋升去伤害身边的人。而当面对利益还是道义的取舍时,我们通过做人原则来进行决策。 树立正确的编程原则就像树立正确的做人原则一样重要,正确的编程原则可以指导我们写出高质量的代码,错误的原则或者没有原则,则会让我们的代码陷入混乱。
当然,世界上没有绝对正确的原则,但是经过多年的实践,前人也给我们留下了一些相对正确的编程原则,值得我们去学习、实践,有了这些原则,就相当于有了思想上的武器,由内而外地驱动你写出高质量的代码,这些原则包括不限于:
- 最少知识原则LOD:一个对象应该尽可能少地了解其他对象的内部结构和实现细节
- DRY原则(Don't Repeat Yourself):避免重复代码,尽量使用函数、类或模块来封装可复用的代码片段
- KISS原则(Keep It Simple, Stupid):保持代码简单易懂,避免过度复杂化,尽量使用简洁的解决方案
- YAGNI原则(You Ain't Gonna Need It):不要过度设计
- SOLID原则:这是一组面向对象设计的原则,包括单一职责原则(SRP)、开闭原则(OCP)、里氏替换原则(LSP)、接口隔离原则(ISP)和依赖倒置原则(DIP),旨在提高代码的可维护性、可扩展性和可重用性。
- 高内聚低耦合:模块内部的组件之间应该紧密相关,模块之间的依赖应该尽量减少,以提高代码的可维护性和可测试性。
- 测试驱动开发(TDD):先编写测试用例,再编写代码来满足测试用例,以确保代码的正确性和可靠性。
最少知识原则LOD
最少知识原则是在1987年秋天由美国Northeastern University的Ian Holland提出,被UML的创始者之一Booch等普及。后来,因为在经典著作《 The Pragmatic Programmer》(程序员编程之道)而广为人知。
最少知识原则(The Least Knowledge Principle),也被称为迪米特法则(Law of Demeter),是一种面向对象设计原则,强调对象之间应该尽量减少彼此的依赖关系,即一个类对于其他类知道的越少越好,一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。
在平时前端开发中,面向对象的业务代码可能写的不够多,这里我们可以将对象的概念平替为函数或者组件,比如一个组件应该只和自己的子组件进行直接通信,而不能试图调用孙子组件的data、state或者method,一旦你直接和孙子组件进行通信,说明你就要了解孙子组件的实现,这就增大了维护当前组件的知识量,本来维护一个组件只需要知道当前组件的内部设计和子组件的接口,现在又必须了解孙子组件的接口,无形之中增加了维护的困难。
这是我在微信朋友圈看到的一段真实代码:
javascript
function setEditorContent(content){
this.$refs['editor'].$refs['innerEditor'].setContent(content);
}
可以大概猜测出这段代码的意图,这是一个设置编辑器内容的方法,首先通过 <math xmlns="http://www.w3.org/1998/Math/MathML"> r e f s [ ′ e d i t o r ′ ] 获取到某个子组件实例,然后再继续调用 refs['editor']获取到某个子组件实例,然后再继续调用 </math>refs[′editor′]获取到某个子组件实例,然后再继续调用refs['innerEditor']来获取孙子组件实例,最后调用孙子组件的setContent方法来修改编辑器内容。
这段代码为以后的维护带来风险,为了维护当前这个组件,首先我们要知道子组件内有个ref为innerEditor的孙子组件,这个孙子组件有个setContent方法,这无疑增加了维护时的学习成本;其次如果子组件内部发生了调整,比如内部的ref不再叫innerEditor了,那这里就会出错了,有人可能说了,那子组件不改这里,不就没问题了?如果你现在维护一个组件,你会对什么内容负责?是不是肯定只对暴露的接口负责,也就是只要我保证组件对外提供的props、method、event保持不变,我们就可以认为我们的变更不会影响到别的组件,你肯定不会想到仅仅是修改了内部实现中的某个组件的ref名称,居然导致父组件出错了。如果父组件真的依赖子组件的实现,那你维护这个子组件的时候,是不是就要知道我的某个内部实现是不能变的,这无疑也增加了维护所需的知识量,试想如果团队来了一个新人,这个知识他怎么会知道呢,也就是说因为这种耦合的代码写法,导致维护一个组件的知识量大量上升。
正确的做法应该是子组件提供一个setContent方法,我们只要直接调用子组件方法即可,至于子组件内部怎么实现这个setContent,父组件并不关心。
javascript
function setEditorContent(content){
this.$refs['editor'].setContent(content);
}
更佳的做法是,我根本不需要知道子组件需要通过setContent方法修改其内容,我们只修改子组件的属性即可,子组件内部来通过监听value变化来设置编辑器内容,让父组件无感知。
vue
<template>
<Editor v-model="content"/>
</template>
<script>
export default {
data(){
return {
content: ''
}
},
methods:{
setEditorContent(content){
this.content = content;
}
}
}
</script>
我还曾经见到同事写过这样的代码:
javascript
let codeMirrorDom = this.$refs['code-mirror'].$el;
let scrollTop = codeMirrorDom.querySelector('.CodeMirror-scroll').scrollTop;
这里虽然没有依赖孙子组件,但是你却必须要知道子组件的详细实现,即子组件内部有个class为"CodeMirror-scroll"的元素,一旦子组件的这个元素类名发生变化,则会导致不可预期的bug发生,这都是违反最小知识原则的。
再举个函数调用的示例:
javascript
function getData(rowData){
let type = rowData.row.type;
let status = rowData.row.detail.status; //比如知道参数下的子属性、孙子属性对象结构
//根据id获取数据
}
在这个函数实现中,你必须要知道参数rowData的具体结构,一般我们可能需要了解某个对象参数有哪些属性,但是我们不应该还去了解参数的某个孙子属性、重孙属性结构,假设我们要根据类型type和status去获取数据,那么直接给我传递过来type和status即可,而不必额外去学习参数对象的具体格式。
假如你的同事让你来实现这个getData方法,你觉得下面那种更好理解,需要的知识量更少?
javascript
function getData(rowData){
}
function getData2(type, status){
}
最小知识原则的最大价值就是解耦,在组件开发时,要和子组件、孙子组件的的具体实现解耦;在类开发时,要和子类、孙子类的具体实现解耦;在函数开发时,要和参数的子属性、孙子属性实现解耦。所有的道理都是相通的,都是避免两块代码或者数据耦合太深,减少维护时所需的知识量。
狭义最少知识 VS 广义最少知识
以上关于最少知识原则的概念,我们可以称为狭义最少知识原则,即只规范了对象和子对象、孙子对象的依赖关系,而在编程中,只关注父子孙对象的依赖关系是远远不够的。
广义的最少知识原则我们可以理解为,在开发、维护、使用某段代码时,尽可能的需要最少的知识,简单的说就是别人怎么样只通过少量的业务知识储备,就可以去轻松地维护或者使用你写的代码。
我们通过几个简单的示例来说明什么是广义的最少知识原则。
最常见的就是硬编码一些状态值,比如下面这段代码,当你要维护这段代码时,你需要哪些知识储备?至少要知道不同数字代表的状态含义吧,这就是维护这段代码的前提,维护一段代码的前提肯定越少越好。
javascript
function sync(app){
if(app.status === 0){
}else if(app.status === 1){
}
}
修改方式大家肯定也都知道,只需要用常量字符串代替这些硬编码数字即可。
再比如前端最常见的API调用,我们一般会把所有的业务请求封装起来,简化调用时对后端接口的了解。
我们以封装一个获取新闻详情的调用为例:
javascript
//serve.js
//把各种资源的操作封装起来
import axios from 'axios'
import url from './const/url.js'
export function getNewsDetail(newsId){
return axios.get(url.newsDetail, {
params:{
id: newsId
}
})
}
在需要获取新闻详情时,只需要调用getNewsDetail方法,并把新闻id传递进去即可,这样在开发相关业务时,不需要知道获取新闻的具体url是什么,也不需要知道后端要求的参数结构是什么,只要知道获取新闻详情调用这个方法即可,减少了业务维护的知识储备量。
javascript
import {getNewsDetail} from './serve.js'
getNewsDetail(1)
各种示例不再赘述,总结起来就一点,当你在开发一个组件、一个函数、一个模块时,思考下,如果一个新人来维护,他需要多少知识储备才能顺利上手,需要的知识储备越少越好,这就是我们说的广义最少知识原则。
DRY原则
DRY原则(Don't Repeat Yourself)是一种重要的编程原则,强调避免代码重复。它的核心思想是,任何一段代码都应该在系统中只有唯一的一份存在,而不应该重复出现。
关于代码重复,前面的文章已经花了一个章节来介绍如果提升代码复用性,有兴趣的可以翻看之前的文章。
代码重复无外乎三个原因:
- 不知道有重复:加强团队成员之间的沟通,基础组件和方法进行周知和宣贯,建立前端团队的文档库。
- 知道有重复,懒着抽取:提升对DRY原则的重视,减少技术债,不要只顾开发爽,维护才是耗时最长的阶段
- 知道有重复,没法复用:大概率设计不符合单一职责原则,编码粒度太粗
同时在复用时要注意一个原则,同一个业务可以复用,不同业务即使UI差不多也不能复用,不用因为复用而造成不同业务的深度耦合,得不偿失。
KISS原则
KISS原则是"保持简单和愚蠢(Keep It Simple and Stupid)"的缩写,强调在解决问题或设计产品时要保持简单性和易用性。
经常听到一种说法是要把用户当"傻子",这里并不是对用户人格的一种贬低,而是说用户在使用产品之前,很可能并不具备较深的知识储备,不能要求用户有过高的专业性,这个问题在我们过往的产品反馈中经常遇到,有时候你认为用户应该了解某些专有名词,事实往往事与愿违。
同时由于每个用户的过往知识、经验、学习能力、性格(急躁)等各不相同,为了满足绝大部分用户使用产品的易用性,必须以用户的水平下限作为我们产品设计的依据,而不是看用户的水平上限。只有一个"傻子"一样的人都能用明白,才说明产品真的做到了易用。这就和白居易把自己写的诗读给一个目不识丁的老奶奶听一样,只要老奶奶听不懂他就改,最终白居易的诗才真的达到了通俗易懂。
这个原则不只是应用在产品设计上,编码上也是一样的,大到一个开源项目,小到一个公共组件/函数,只要你的代码是有使用用户的,都要记住,用户是"傻子",不要对用户有过高的预期。
以我的开源项目 vue-office为例,在此之前如果要进行文件预览(docx、excel、pdf)是比较麻烦的,虽然代码不是那么复杂,但是对于一个新手来说,还是有很多学习成本的,以使用pdf.js进行pdf预览为例。
vue
<template>
<div>
<div v-for="pageNum in numPages" :key="pageNum">
<canvas :ref="`pdfCanvas-${pageNum}`"></canvas>
</div>
</div>
</template>
<script>
import pdfjsLib from 'pdfjs-dist';
export default {
name: 'PdfPreview',
props: {
pdfUrl: {
type: String,
required: true
}
},
data() {
return {
numPages: 0
};
},
mounted() {
this.renderPdf();
},
methods: {
renderPdf() {
pdfjsLib.getDocument(this.pdfUrl).promise.then(pdf => {
this.numPages = pdf.numPages;
for (let pageNum = 1; pageNum <= this.numPages; pageNum++) {
pdf.getPage(pageNum).then(page => {
const canvas = this.$refs[`pdfCanvas-${pageNum}`][0];
const context = canvas.getContext('2d');
const viewport = page.getViewport({ scale: 1 });
canvas.width = viewport.width;
canvas.height = viewport.height;
page.render({
canvasContext: context,
viewport: viewport
});
});
}
});
}
}
};
</script>
这还是简化之后的例子,里面没有包括引用Worker文件、cmaps文件等内容,而且针对Vue2和3还需要做一些示例修改,虽然不到100行代码,但是如果让一个新手去做,不一定能做出很好的效果,考虑的也不一定全面,比如大文件的懒加载、宽高自适应、低版本浏览器的兼容等,也正是基于此,才有了vue-office项目,虽然本质上还是借助第三方库来实现,但是简化了实现细节,可以让小白用户几分钟内完成多种文件的预览。
vue-office进行pdf预览:
vue
<vue-office-pdf
src="https://**.pdf"
/>
可以看出,使用非常简单,只要传递一个pdf文件url,即可完成预览,为了支持多种场景属性src支持多种格式,包括url、Blob、ArrayBuffer、Response,同时支持Vue2和Vue3,自动根据用户环境切换不同版本代码。正是基于KISS原则的指导,才有了这个开源项目的成功,短时间内收获1.7k star。
保持简单的重要手段是封装,就像空调遥控器上往往都会有个睡眠模式,按了遥控模式后空调通常会进行以下几个操作:
- 调整温度:自动调整空调的温度设置,使室内温度逐渐升高或降低,以提供更适合睡眠的环境。
- 调整风速:减小风速,以避免直接吹向人体。
- 调整噪音:减小空调的运转声音,以提供更安静的睡眠环境。
- 调整定时功能:空调在一定时间后自动关闭或调整温度,以节省能源并确保你在睡眠过程中保持舒适。 这不就是我们通常说的一键操作嘛,一次按键进行多个动作,在我们设计模式中也可以称为外观模式。
外观模式并不神奇,以项目中常用的Button按钮封装为例,我们项目中通常有这几种操作,添加、编辑、导入、导出、删除等,不同操作按钮通常有不同的样式和图标,如添加按钮通常type设为primary,并且配置一个加号icon,删除按钮type则为danger,并配一个删除icon,然后如果每个人都要记住不同按钮怎么配置则比较麻烦,我们可以通过外观模式来简化这个配置。
vue
// 添加按钮
<el-button type="primary" icon="el-icon-plus"></el-button>
//编辑按钮
<el-button type="warning" icon="el-icon-edit"></el-button>
//删除按钮
<el-button type="danger" icon="el-icon-delete"></el-button>
我们可以封装一个MyButton按钮,提供一个operate属性,当operate为add时,将按钮的type设为primary同时icon设为el-icon-plus,其他操作也一样逻辑,这样使用起来就变的非常简单了。
vue
//添加按钮
<my-button operate="add"/>
//编辑按钮
<my-button operate="edit"/>
//删除按钮
<my-button operate="delete"/>
好了,这样封装后,你觉得简单吗?
当然了易用性和扩展性天然会存在一些冲突,在提升易用性的同时,需要考虑特殊场景如何简单地进行扩展,编程就是平衡的艺术。
YAGNI原则
YAGNI原则是软件开发中的一种原则,它代表着"You Ain't Gonna Need It",意思是"你不会需要它"。这个原则的核心思想是在开发过程中,不要去实现那些当前并不需要的功能。
YAGNI原则的目的是避免过度设计和开发,以及避免浪费时间和资源在不必要的功能上。根据这个原则,开发人员应该专注于当前需要解决的问题,而不是预测未来可能出现的需求。
YAGNI原则的应用可以帮助开发团队更加敏捷地开发软件,减少不必要的复杂性和开发成本。它鼓励开发人员保持简单和精简,只实现当前需要的功能,并在未来需要时再进行扩展和改进。
然而,YAGNI原则并不意味着完全忽视未来的需求。它只是提醒开发人员在做决策时要权衡利弊,并避免过度设计和开发。在实践中,开发人员需要根据具体情况和项目需求来决定是否应用YAGNI原则。
过度设计 VS 不设计
在实际编程工作中,很多人可能拿YAGNI作为挡箭牌,以不要过度设计为由,从而不进行设计,最常见的就是组件抽取。
有的同学可能觉得只要没有其他页面复用,就不用抽取组件,抽取了就是过度设计,个人认为这是不正确的,提前抽取组件,以应对未来可能的复用需求,不一定属于过度设计,就算未来没有复用需求,我们也应该根据业务模块进行组件划分,以简化整个项目的结构,让项目更加清晰易读可维护,如果抽取的组件未来复用了更好,不复用也并没有坏处。
以掘金首页为例,假如我现在接到这样一个页面开发任务:

我会考虑将列表项封装成一个组件ArticleItem,这样我列表的开发逻辑就很清晰,只需要请求相应数据,然后将文章列表循序传递给ArticleItem组件即可渲染出整个列表,其次我可以预见列表展示可能会在多个场景出现,比如未来的文章详情底部可能会有相似文章推荐等,就算没有也没关系,现在的抽取本身就有价值。
但是我不会在此刻把文章标题抽取成为一个组件,首先我现在还没看到别的页面有这个复用场景,其次抽取文章标题并不会对我现在的代码结构带来多大的简化效果,最后如果真的有一天要进行文章标题的抽取我也可以轻松实现,并不会带来很大的成本,综合这些考虑,我决定暂不进行如此细粒度的组件抽取。
是否过度设计取决于抽取的颗粒度,在需求没有出现之前,没有必要进行特别细粒度的组件抽取,但是不进行组件抽取则是一种懒政的行为。
开发中遇到的另一个常见场景是,接到需求后直接进入了开发,而不进行设计,更不编写设计文档,代码写到哪算哪,怎么说呢,写代码和建设大楼一样,就算不是盖一个摩天大楼,哪怕是盖个猪圈不提前设计也有推到重来的可能。
面向现在 VS 面向未来
YAGNI原则告诉我们应该面向现在编程,设计方案应该是刚刚好解决了现在的问题,不多也不少,不要去实现那些当前并不需要的功能。
比如我们实现一个根据状态返回色值的方法,假设现在只有两个状态(成功和失败),那么我们只需要通过if else来实现即可,没有必要因为你觉得以后状态可能会变成3个或者更多,而在当下使用switch或者策略模式来实现当前这个需求。
javascript
//这就够了,恰到好处
function getColorByStatus(status){
if(status === 'success'){
return 'green'
}
return 'red'
}
// 仅仅两个状态没必要
function getColorByStatus2(status){
switch (status){
case 'success':
return 'green';
default:{
return 'red'
}
}
}
虽然编码应该着重基于当前需求,但是我们仍然要重视未来可能的需求变化,确保未来需求变化之后,你是有应对策略的,特别是针对一些老数据升级相关的策略,这些想明白了之后才能大胆的基于当前需求编码,否则适当做一些提前工作,也不失是一种未雨绸缪的好方法。
就像上面我举的这个是用if else 还是 switch的问题,你完全可以大胆的基于当前需求选择最合适的编码方案,等以后真的status变成了多个,你再换成switch也不迟,并不会有多大的重构成本,相比之下,保持当前代码的可读性才是最紧迫的。
前端编程之道系列,欢迎关注,github.com/501351981/H... 欢迎前端同学微信交流: hit757 ,添加务必注明掘金