面向对象在前端的应用最佳实践(理论)

234 阅读6分钟

如何写出高质量的前端代码》学习笔记

概述

面向对象编程(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) {
        //...
    }
}

面向对象编程核心概念

  1. 类(Class) : 用于描述具有相似属性和行为的对象的模板。
  2. 对象(Object) : 类的实例,是具体的数据实体。
  3. 抽象(Abstraction) : 对现实世界或问题的简化和概括。
  4. 封装(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);
    }
    //...
}
  1. 继承(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'));
  1. 多态(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';           // 修改协议

何时使用面向对象?

当你遇到以下场景时,可以考虑使用面向对象:

  1. 有数据及对数据的操作时(如用户信息及权限判断)
  2. 需要维护状态且有相关操作时(如画布中的图形对象)
  3. 需要高度复用且可能扩展的功能时(如URL处理)
  4. 处理复杂业务逻辑,需要良好组织代码时(如低代码平台)

总结

面向对象编程通过将数据和方法封装在一起,提供了更清晰的代码组织方式和更简洁的调用方式。它不仅提高了代码的可维护性和可复用性,还能帮助我们更好地处理复杂的业务逻辑。在前端开发中,合理使用面向对象编程可以显著提升代码质量和开发效率。