《如何写出高质量的前端代码》学习笔记
概述
面向对象编程(OOP)是一种程序设计范式,通过将数据和操作数据的方法组织成对象,实现程序的功能。虽然前端开发中经常接触面向对象的概念,但在实际业务开发中,很多开发者并没有充分利用这一思想。本文将通过具体对比,展示面向对象在前端的应用优势。
一切皆对象
在前端开发中,几乎所有事物都是对象,包括页面、组件、字符串和数组等。JavaScript 中的 DOM 元素、字符串、数组等均可视为对象,这也使得面向对象的思想在 JavaScript 中得以广泛应用。例如:
console.dir(document.querySelector('div')); // 打印 div 元素
'aabb'.length;
'aabb'.toUpperCase();
//虽然字面量字符串不是严格的Object类型,但是可以认为它是一个对象,因为它有属性和方法
[1,2,3].length;
[1,2,3].forEach(item => console.log(item));
许多流行的JavaScript库,如jQuery、Vue、React等,都采用了面向对象的编程思想,尽管有些库可能用工厂模式封装了创建新对象的过程。
//Vue源码,Vue的构造函数
function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
//axios源码
class Axios {
constructor(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
async request(configOrUrl, config) {
//...
}
}
面向对象编程核心概念
- 类(Class) : 用于描述具有相似属性和行为的对象的模板。
- 对象(Object) : 类的实例,是具体的数据实体。
- 抽象(Abstraction) : 对现实世界或问题的简化和概括。
- 封装(Encapsulation) : 将数据和操作封装在对象中,隐藏内部实现。比如我们对缓存Cache进行抽象和封装,缓存Cache一般包含属性:size,方法:get、set、has、clear,简单的实现如下。
//利用Symbol实现私有变量
const _data = Symbol('privateData')
//只把Cache类导出去,_data变量不导出,外部无法访问到_data
export class Cache {
constructor() {
this[_data] = new Map();
}
get size() {
return this[_data].size;
}
add(key, value) {
this[_data].set(key, value);
}
//...
}
- 继承(Inheritance) : 允许一个类继承另一个类的属性和方法。
class LocalStorageCache extends Cache {
constructor() {
super();
}
}
class SessionStorageCache extends Cache {
constructor() {
super();
}
}
let localCache = new LocalStorageCache();
localCache.add('a', 1);
console.log(localCache.get('a'));
- 多态(Polymorphism) : 同一方法可以根据对象的不同类型表现出不同的行为。
class Cache {
add(key, value) {
this._data.set(key, value);
}
}
class LocalStorageCache extends Cache {
constructor() {
super();
}
add(key, value) {
//修改父类的实现
localStorage.setItem(key, value);
}
}
class SessionStorageCache extends Cache {
constructor() {
super();
}
add(key, value) {
//修改父类的实现
sessionStorage.setItem(key, value);
}
}
编程范式对比
以菜单高亮功能为例,展示不同编程范式的实现方式:
1. 命令式编程
它通过一系列的命令或语句来描述程序的执行步骤。在命令式编程中,程序员需要明确地指定程序的每个细节,包括数据的存储、计算的顺序以及控制流程。
let menu = [{label: '用户管理', index: 'user'}, ...];
let activeMenu = null;
let currentPath = 'user';
for(let i = 0; i < menu.length; i++){
if(menu[i].index === currentPath){
activeMenu = menu[i];
break;
}
}
2. 面向过程编程
以过程或函数为基本单位,通过顺序执行一系列操作来解决问题。
function getActiveMenu(menu, currentPath) {
let activeMenu = null;
for (let i = 0; i < menu.length; i++) {
if (menu[i].index === currentPath) {
activeMenu = menu[i];
break;
}
}
return activeMenu;
}
3. 函数式编程
将计算视为函数的求值过程,函数被视为一等公民,函数可以作为参数传递给其他函数,也可以作为返回值返回;强调函数的纯粹性和不可变性,避免副作用。
menu.find(item => item.index === 'user');
4. 面向对象编程
使用面向对象封装后,获取高亮菜单变得简单。通过实例化Menu类,调用getActiveMenu方法获取当前菜单,而不必关心具体查询过程。随后,新的需求出现,要求找出菜单的子菜单。在Menu类中添加getSubMenus方法即可实现此功能。
class Menu {
constructor(menuList) {
this.menuList = menuList;
}
getActiveMenu(currentPath){
return this.menuList.find(item => item.index === currentPath);
}
getSubMenus(parentIndex){
let activeMenu = this.getActiveMenu(parentIndex);
return activeMenu?.children || [];
}
}
面向对象编程的优势
1. 有数据有方法
传统方式(分离数据和方法)
// 存储在 Vuex 中的数据
let userInfo = {
name: 'admin',
permissions: [
{ resource: 'user', action: ['add', 'delete'] }
]
};
// 工具函数
function hasPermission(permissions, resource, action) {
return permissions.find(item =>
item.resource === resource &&
item.action.includes(action)
);
}
// 使用时
if(userInfo.name === 'admin') { ... }
if(hasPermission(userInfo.permissions, 'user', 'add')) { ... }
我们通常只保存用户数据而没有提供操作这些数据的方法,导致需要额外编写代码来进行权限检查。如果我们在共享数据中存储的是User类的实例而不是简单的对象,就可以直接使用类中的方法来进行权限判断。
面向对象方式
class User {
constructor(userInfo) {
this.userInfo = userInfo;
}
isSuperAdmin() {
return this.userInfo.name === 'admin';
}
hasPermission(resource, action) {
let permissions = this.userInfo.permissions || [];
return permissions.find(item =>
item.resource === resource &&
item.action.includes(action)
);
}
}
// 使用时
let user = new User(userInfo);
if(user.isSuperAdmin()) { ... }
if(user.hasPermission('user', 'add')) { ... }
这样是不是比之前简单很多?现在共享的user对象,已经不再只有用户信息了,还具备了用户相关的操作方法。
2. 有上下文
使用面向对象方式可以方便调用方法,而无需携带额外的上下文信息。
传统方式(每次需要传递完整上下文)
我们封装了画圆的drawCircle函数,需要传入画布上下文、圆心坐标和半径。若要移动圆,得先改坐标再调用函数。每次调用都要传入ctx、x、y和radius,即便有些没变,因为面向过程编程缺乏状态保持。
function drawCircle(ctx, x, y, radius) {
// 画圆
}
const ctx = canvas.getContext('2d');
let x = 50, y = 50, radius = 20;
drawCircle(ctx, x, y, radius);
x += 10;
drawCircle(ctx, x, y, radius);
面向对象方式
如果我们用Circle类封装画圆,创建实例时设置好画布和圆的参数,之后只需传入变化的参数即可。
class Circle {
constructor(ctx, x, y, radius) {
this.ctx = ctx;
this.x = x;
this.y = y;
this.radius = radius;
}
draw() {
// 画圆
}
movex(offset) {
this.x += offset;
}
}
const circle = new Circle(ctx, 50, 50, 20);
circle.draw();
circle.movex(10);
circle.draw();
3. 复用性
传统方式(封装工具函数)
前端开发中常遇到处理url的场景,如判断是否包含某参数、获取参数值、拼接参数等,可抽象为若干方法。
// url.js
function hasParam(url, param) { ... }
function getParam(url, param) { ... }
function removeParam(url, param) { ... }
function appendParams(url, params) { ... }
// 新需求:修改协议
function changeProtocol(url, protocol) { ... }
面向对象方式(URL类)
尽管可能已经封装了很多URL处理方法,但你不能确定覆盖了所有场景,需要重新封装protocol的替换方法。应该抽象出URL所应包含的属性和方法,使用内置的URL类和URLSearchParams类封装,提高复用性。
let url = new URL('https://www.example.com?a=1');
url.searchParams.has('a'); // 检查参数
url.searchParams.get('a'); // 获取参数
url.searchParams.delete('a'); // 删除参数
url.searchParams.append('b', 2); // 添加参数
url.protocol = 'http'; // 修改协议
何时使用面向对象?
当你遇到以下场景时,可以考虑使用面向对象:
- 有数据及对数据的操作时(如用户信息及权限判断)
- 需要维护状态且有相关操作时(如画布中的图形对象)
- 需要高度复用且可能扩展的功能时(如URL处理)
- 处理复杂业务逻辑,需要良好组织代码时(如低代码平台)
总结
面向对象编程通过将数据和方法封装在一起,提供了更清晰的代码组织方式和更简洁的调用方式。它不仅提高了代码的可维护性和可复用性,还能帮助我们更好地处理复杂的业务逻辑。在前端开发中,合理使用面向对象编程可以显著提升代码质量和开发效率。