React 设计模式实践【Taro × 小程序】

前言

大家好,我是 PakJeon.

这里和大家一起来讨论前端开发中的设计模式,特别是在React项目中的应用。

设计模式对于写出高效、可维护的代码起着关键作用。非常期待能与大家一起分享和探讨这个主题。


基本概念

设计模式的基础知识

设计模式是针对软件设计中常见问题的一种解决方案,它是一些经过验证的最佳实践。设计模式并不是一种可以直接转化为代码的模板,而是一种面向问题的模板,可以在特定的情况下使用。

设计模式的分类

设计模式主要分为三类:

  1. 创建型模式:这类模式处理对象的创建机制,试图以适当的方式创建对象。例如,工厂模式就是创建型模式,它提供了一个创建对象的接口,但允许子类决定实例化哪一个类。
  2. 结构型模式:这类模式涉及类和对象的组合,以形成更大的结构。例如,适配器模式就是结构型模式,它允许一个接口转化为另一个接口以适应不同的环境。
  3. 行为型模式:这类模式专注于对象间的通信。例如,观察者模式就是行为型模式,它定义了对象间的依赖关系,一个对象的状态改变会影响其他的对象。

创建型模式

单例模式

能够保证一个类只有一个实例, 并提供一个访问该实例的全局节点。

js 复制代码
var getSingle = function (fn) {
	var result;
	return function () {
		return result || (result = fn.apply(this, arguments));
	}
}

var createLoginLayer = function () {
	var div = document.createElement('div');
	div.innerHTML = '我是登录浮窗';
	div.style.display = 'none';
	document.body.appendChild(div);
	return div;
}

var creatSingleLoginLayer = getSingle(createLoginLayer);

document.getElementById('loginBtn').onclick = function () {
	var loginLayer = creatSingleLoginLayer();
	loginLayer.style.display = 'block';
}

原型模式

原型模式是一种创建型设计模式, 使你能够复制已有对象, 而又无需使代码依赖它们所属的类。 在 JavaScript 和 React 中,原型模式并不像在一些其他编程语言中那样常用。然而,JavaScript 本身就是基于原型的,每个对象都有一个指向其原型的链接。原型对象本身可能也有自己的原型,直到某个对象的原型为 null,这个 null 是原型链的终点。

1.4 结构型模式

组合模式

组合模式是一种结构型设计模式, 你可以使用它将对象组合成树状结构, 并且能像使用独立对象一样使用它们。 在React 中,组合模式可以被看作是设计和创建组件的基础原则之一。组合模式允许你将对象组合成树形结构来表示"部分-整体"的层次结构,使得客户端可以一致地处理单个对象和组合对象。在 React 中,每个组件可以被视为一个对象,这些组件可以通过父子关系进行组合,形成树形的 DOM 结构。

装饰模式

装饰模式是一种结构型设计模式, 允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。 在 React 中,装饰模式的一个常见实现就是高阶组件(Higher-Order Components,HOC)

js 复制代码
// 这是一个高阶组件,它为传入的组件添加了日志记录功能
function withLogging(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log(WrappedComponent.name + ' 已挂载');
    }
    componentWillUnmount() {
      console.log(WrappedComponent.name + ' 将卸载');
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
}

// 这是一个基本的组件
function SimpleComponent() {
  return <div>我是一个简单的组件</div>;
}

// 使用高阶组件来"装饰"我们的基本组件
const EnhancedComponent = withLogging(SimpleComponent);

// 然后我们就可以像使用普通组件一样使用这个增强版的组件了
function App() {
  return <EnhancedComponent />;
}

适配器模式

适配器模式是一种结构型设计模式, 它能使接口不兼容的对象能够相互合作。

js 复制代码
// 前端需要的数据格式
var guangdongCity = {
	shenzhen: 11,
	guagnzhou: 12,
	foshan: 13,
};

// 模拟后端的返回
var getGuangdongCity = function () {
	var guangdongCity = [
		{
			name: 'shenzhen',
			id: 11,
		},
		{
			name: 'guagnzhou',
			id: 12,
		},
	];
	return guangdongCity;
}

var render = function (fn) {
	console.log('开始渲染广东省地图');
	document.write(JSON.stringify(fn()));
}

// 地址适配器,适配新的数据格式
var addressAdapter = function (oldAddressfn) {
	var address = {};
	var oldAddress = oldAddressfn();

	for (var i = 0, c; c = oldAddress[i++];) {
		address[c.name] = c.id;
	}

	return function () {
		return address;
	}
}

render(addressAdapter(getGuangdongCity));

代理模式

代理模式是一种结构型设计模式, 让你能够提供对象的替代品或其占位符。 代理控制着对于原对象的访问, 并允许在将请求提交给对象前后进行一些处理。

js 复制代码
var myImage = (function () {
	var imgNode = document.createElement('img');
	document.body.appendChild(imgNode);

	return function (src) {
		imgNode.src = src;
	}
})();

var proxyImage = (function () {
	var img = new Image;

	img.onload = function () {
		myImage(this.src);
	}

	return function (src) {
		myImage('file:// /C:/Users/loading.gif');
		img.src = src
	}
})

1.5 行为模式

迭代器模式

迭代器模式是一种行为设计模式, 让你能在不暴露集合底层表现形式 (列表、 栈和树等) 的情况下遍历集合中所有的元素。

JavaScript 的数组和 Set 提供了内建的迭代器,可以使用 for...of 循环来遍历它们 在 React 中,迭代器模式通常出现在列表渲染的场景。例如,你可以将一个数组的每个元素映射到一个React元素,然后在渲染方法中返回这个元素列表。


中介者模式

中介者模式是一种行为设计模式, 能让你减少对象之间混乱无序的依赖关系。 该模式会限制对象之间的直接交互, 迫使它们通过一个中介者对象进行合作。 这种模式在 React 中常见的应用场景是使用状态管理库(如 Redux 或 MobX)或者 React 的 Context API

js 复制代码
const MyContext = React.createContext();

function MyProvider(props) {
  const [value, setValue] = React.useState("initial value");

  return (
    <MyContext.Provider value={{ value, setValue }}>
      {props.children}
    </MyContext.Provider>
  );
}

function MyComponent() {
  const context = React.useContext(MyContext);

  function handleChange(e) {
    context.setValue(e.target.value);
  }

  return <input value={context.value} onChange={handleChange} />;
}

function App() {
  return (
    <MyProvider>
      <MyComponent />
    </MyProvider>
  );
}

在这个例子中,MyProvider 组件就扮演了中介者的角色。它持有并管理共享状态,并提供修改这个状态的方法。MyComponent 组件通过 Context APIMyProvider 中获取状态和操作状态的方法。当需要修改状态时,MyComponent 不是直接与其他组件交互,而是通过 MyProvider 这个中介者。这降低了 MyComponent 和其他组件之间的耦合度,使得你可以更灵活地组织和重用你的组件。


发布订阅模式

发布-订阅模式允许我们创建一种一对多的依赖关系,其中多个对象(订阅者)根据一个对象(发布者)的状态进行更新。

js 复制代码
var Event = (function () {
	var clientList = {}, listen, trigger, remove;
           
        // 订阅消息
	listen = function (key, fn) {
		if (!clientList[key]) {
			clientList[key] = [];
		}
		clientList[key].push(fn);
	};

        // 发布消息,通知订阅者
	trigger = function () {
		var key = Array.prototype.shift.call(arguments);
		var fns = clientList[key];
		if (!fns || fns.length === 0) {
			return false;
		}
		for (var i = 0, fn; fn = fns[i]; i++) {
			fn.apply(this, arguments);
		}
	};

        // 取消订阅
	remove = function (key, fn) {
		var fns = clientList[key];
		if (!fns) {
			return false;
		}
		if (!fn) {
			fns && (fns.length = 0);
		} else {
			for (var len = fns.length - 1; len >= 0; len--) {
				var _fn = fns[len];
				if (_fn === fn) {
					fns.splice(len, 1);
				}
			}
		}
	}

	return {
		listen,
		trigger,
		remove,
	}
})();

Event.listen('squareMeter88', function (price) {  // 订阅消息
	console.log('价格 = ' + price);
})

Event.trigger('squareMeter88', 200000);  // 发布消息

状态模式

状态模式是一种行为设计模式, 让你能在一个对象的内部状态变化时改变其行为, 使其看上去就像改变了自身所属的类一样。

js 复制代码
var Light = function () {
  this.currState = FSM.off; // 设置当前状态
  this.button = null;
};

Light.prototype.init = function () {
  var button = document.createElement('button');
  var self = this;

  button.innerHTML = '已关灯';
  this.button = document.body.appendChild(button);

  this.button.onclick = function () {
    self.currState.buttonWasPressed.call(self); // 把请求委托给 FSM 
  };
};

// Finite State Machine (有限状态机)
// 基本组成部分:状态、转移,事件动作
var FSM = {
  off: {
    buttonWasPressed: function () {
      console.log('关灯');
      this.button.innerHTML = '下一次按我是开灯';
      this.currState = FSM.on;
    },
  },
  on: {
    buttonWasPressed: function () {
      console.log('开灯');
      this.button.innerHTML = '下一次按我是关灯';
      this.currState = FSM.off;
    },
  },
};

var light = new Light();
light.init();

策略模式

策略模式是一种行为设计模式, 它能让你定义一系列算法, 并将每种算法分别放入独立的类中, 以使算法的对象能够相互替换。

js 复制代码
// 例子:定义年终奖策略。评分为 S、A、B 会有不同的年终奖计算策略
// 当要新添、修改策略时,直接在这里处理就行,而不是堆积 if else 或者 switch case
var strategies = {
	"S": function (salary) {
		return salary * 4;
	},
	"A": function (salary) {
		return salary * 3;
	},
	"B": function (salary) {
		return salary * 2;
	}
};

var calculateBonus = function (level, salary) {
	return strategies[level](salary);
}

console.log(calculateBonus('S', 2000)); // 8000
console.log(calculateBonus('A', 1000)); // 3000

模板方法模式

模板方法模式是一种行为设计模式, 它在超类中定义了一个算法的框架, 允许子类在不修改结构的情况下重写算法的特定步骤。

js 复制代码
// 泡茶 or 泡咖啡 定义了 泡** 的模板
var Beverage = function (params) {
	var boilWater = function () {
		console.log('把水煮沸')
	}

	var brew = params.brew || function () {
		throw new Error('必须传递 brew 方法');
	}

	var pourInCup = params.pourInCup || function () {
		throw new Error('必须传递 pourInCup 方法');
	};

	var addCondiments = params.addCondiments || function () {
		throw new Error('必须传递 addCondiments 方法');
	};

	var F = function (){};

	F.prototype.init = function () {
		boilWater();
		brew();
		pourInCup();
		addCondiments();
	}

	return F;
}

var Coffee = Beverage({
	brew: function () {
		console.log('用沸水冲泡咖啡');
	},
	pourInCup: function () {
		console.log('把咖啡倒进被子');
	},
	addCondiments: function () {
		console.log('加糖和牛奶');
	}
});

var Tea = Beverage({
	brew: function () {
		console.log('用沸水冲泡茶叶');
	},
	pourInCup: function () {
		console.log('把茶倒进被子');
	},
	addCondiments: function () {
		console.log('加柠檬');
	}
})

var coffee = new Coffee();
coffee.init();

var tea = new Tea();
Tea.init();

在 React 项目中的应用

会员中心

背景:公司的一个 C 端小程序的会员中心页。设计要求是按照选择不同等级的会员商品时,展示不同主题颜色的界面,不同的描述文案和按钮文案。可以先思考下你会如何开发实现?

经验告诉我,这种会员、商品页面会经常发生变动,商品价格、宣传文案、活动折扣等会不停迭代。如何才能设计一个符合开闭原则的功能,尽量提高代码的可维护性呢?下面是我的实现

js 复制代码
// 父类 (定义一个商品类的父类,定义需要实现的一些模板方法)
function Commodity() {
  this.getBannerBg = () => {
    throw new Error('必须实现 getBannerBg');
  };

  this.getGoodsIcon = () => {
    throw new Error('必须实现 getGoodsIcon');
  };

  this.getClassName = () => {
    throw new Error('必须实现 getClassName');
  };

  this.payType = () => {
    throw new Error('必须实现 payType');
  };

  this.getPrice = () => {
    throw new Error('必须实现 getPrice');
  };
}

// 原型继承(用作方法实现检查,未实现的方法可以在开发阶段抛出异常)
const commodity = new Commodity();
NormalCommodity.prototype = commodity;  // 普通会员
PremiumCommodity.prototype = commodity; // 高级会员
SuperCommodity.prototype = commodity;  // 超级会员

// 创建普通会员的参数
export function NormalCommodity({
	name,
	price, // ...其他商品参数
}) {
	this.name = name;
	this.price = price;
	// ...

  this.getBannerBg = () => {
		// 返回普通会员的 banner
  };

  this.getGoodsIcon = () => {
		// 返回普通会员的 icon
  };

  this.getClassName = () => {
		// 返回普通会员的样式类
  };

  this.payType = (isRenew) => {
		// 返回是购买还是续费等文案
  };

  this.getPrice = () => {
		// 返回通过计算得到的实际应付价格
  };
}

// 此处省略 。。。 高级会员类 PremiumCommodity 和超级会员类SuperCommodity

// 接着是在 JSX 中调用不同商品对应的方法,渲染界面
const vipCenter = () => {
	// 封装了生产商品的过程
	const {personalGoods, enterpriseGoods, activeGoods, setActiveGoods} = useGoods();

	const showGoods = useMemo(() => {
		// 根据 tab 返回个人商品 或 企业商品
	}, [tab])

	return (
		<>
			{
				showGoods.map(item => {
					const { id, name, price /* ... */ } = item;
					return (
						<View
							className={cx(
								c.goodsItem,
								item === activeCommodity && c[`goodsItem__active__${activeGoods?.getClassName()}`],
							)}
							key={id}
							onClick={() => setActiveGoods(item)}
						>
							<Image className={c.iconVip} src={item?.getGoodsIcon()} />
							<View className={c.name}>{name}</View>
							<View className={c.priceRow}>
								<Text className={c.unit}>¥</Text>
								<Text className={c.price}>{price}</Text>
							</View>
						</View>
					)
				})
			}


			<View
				className={cx(
					c.footer,
					c[`footer__${activeGoods?.getClassName()}`],
				)}
			>
				<Button
					className={c.btnSubmit}
					onClick={/* 发起支付 */}
				>
					<View className={c.textWrapper}>
						支付 {activeGoods?.getPrice()} 元 {activeGoods?.payType()} 
					</View>
				</Button>
			</View>
		</>

	)
}
  1. 工厂模式(Factory Pattern)NormalCommodity, PremiumCommoditySuperCommodity函数都是工厂函数,这些函数用于创建特定类型的对象(即普通商品,高级商品和超级商品)。每个工厂函数都接收一组参数并返回一个具有特定属性和方法的新对象。工厂模式是一种创建型模式,它提供了一种创建对象的最佳方式。

  2. 原型模式(Prototype Pattern) :使用Commodity作为原型创建NormalCommodity, PremiumCommoditySuperCommodity。这些子类继承了Commodity的原型。在JavaScript中,可以通过设置对象的prototype属性来实现原型继承。这种模式是一种创建型设计模式,它允许从现有的对象克隆出新的对象,而不需要知道该对象属于哪个具体类。

  3. 模板方法模式(Template Method Pattern)Commodity类中定义了一些方法,这些方法在子类NormalCommodity, PremiumCommoditySuperCommodity中被重写。子类中的实现是基于具体情况的。这就是模板方法模式的一种实现方式,这种模式定义了一个操作中的算法的骨架,子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。


我的页面

背景:有不同功能的入口,它们长得相似,却有不同的处理逻辑。有些需要跳转页面,有些需要判断用户信息,有些是打开客服窗口。下面介绍两种实现方式作为参考。

  1. tabClick 函数的行为根据 item.name 的不同而不同,这是一种典型的条件分发。在这种情况下,一个常用的设计模式是"策略模式"(Strategy Pattern),它可以帮助减少冗余的 if-else 语句,并提高代码的可读性和维护性。

在策略模式中,定义一系列算法(在这种情况下是函数或方法),并在运行时动态地更换它们。这个模式可以帮助代码更加清晰地表达意图,同时也提高了代码的可测试性。

js 复制代码
// 定义一系列策略
const strategies = {
  '在线客服': function() {
    // 打开在线客服
  },
  '企业套餐': function() {
    // 跳转介绍企业套餐
  },
  // ...更多的策略
  
  // 相对普遍的兜底策略,跳转页面
  default: function(item) {
    if (item.url) {
      Taro.navigateTo({ url: item.url });
    }
  }
};

// 定义 tabClick
const tabClick = (item) => {
  // 执行对应的策略
  (strategies[item.name] || strategies.default)(item);
};
  1. 一种类似于"命令模式"的设计模式,也有些类似于策略模式。这种方式的优点是,它将操作的请求者(界面)从操作的执行者(函数)中分离出来,使得请求者不需要知道任何关于执行者的信息,只需要知道执行者具有某种可以执行的接口(onClick)。这种分离使得请求者和执行者可以独立地改变或复用,而不会互相影响。
js 复制代码
const TABS = [
  {
    name: '浏览历史',
    // ...其它属性
    onClick: function() {
      // 执行相关操作
    }
  },
  // 其它元素
];

const tabClick = (item) => {
  // 执行 item 的 onClick 方法
  item.onClick();
};

useUserinfo

背景:用户信息在全局几乎每个页面都会用上,而且有不少付费点,会引起用户信息的变动,而变动需要同步到所有页面。

实现:一种类似中介者模式的实现。全局 Context + 自定义 hooks

js 复制代码
// 入口文件      
<UserContext.Provider value={user}>
	{children}
</UserContext.Provider>

// 自定义 hooks
import Taro from '@tarojs/taro';
import { useContext, useEffect, useState } from 'react';

import { UserContext } from '@/context';
import { EVENT_CENTER_KEY } from '@/constants/eventCenter';
import request from '../request';

export const useUserInfo = () => {
  const [isVip, setIsVip] = useState(false);
  const [vipInfo, setVipInfo] = useState({});
	// 从全局 Context 中取出 userInfo
  const userInfo = useContext(UserContext);

  useEffect(() => {
    setIsVip(userInfo?.vip_info?.vip_status || false);
    setVipInfo(userInfo?.vip_info || {});
  }, [userInfo]);

  const refresh = () => {
		// 刷新全局用户信息
  };

  return {
    isVip,
    vipInfo,
    userInfo,
    refresh,
  };
};

// 具体页面调用
  const { isVip, vipInfo, refresh: refreshVip, userInfo } = useUserInfo();

装饰器模式 before after

背景:有一个复杂而不想增加其复杂度的函数,为其增加一些额外的行为

实现:使用装饰器模式

js 复制代码
Function.prototype.before = function (beforefn) {
  const _this = this; // 保存旧函数的引用
  return function (...args) { // 返回包含旧函数和新函数的"代理"函数
    beforefn.apply(this, args); // 执行新函数,且保证this不被劫持,新函数接受的参数也会被原封不动的传入旧函数,新函数在旧函数之前执行
    return _this.apply(this, args);
  };
};

Function.prototype.after = function (afterfn) {
  const _this = this;
  return function (...args) {
    const ret = _this.apply(this, args);
    afterfn.apply(this, args);
    return ret;
  };
};

// 原函数
const foo = () => {
	// 一段业务逻辑
}

// 用装饰器包裹
const foo = (() => {
	// 一段业务逻辑
}).before(() => {
	// 增加埋点、前置处理...等
});

事件通知

背景:在订阅详情页(一个二级页面),返回到列表页(一级页面),跨页面的数据刷新需求。

实现:发布订阅模式(Taro 提供的 eventCenter 实现)

js 复制代码
// 页面A.js (常驻的一级页面,重新进入不会刷新)
Taro.eventCenter.on('refresh', () => { /* 刷新列表 */});
// 页面销毁时可以把监听删除
Taro.eventCenter.off('refresh');

// 页面B.js (二级页面)
// 进行了订阅操作,需要外面的页面进行刷新
Taro.eventCenter.trigger('refresh');
  • 优点:
  1. 解耦: 在两个或多个模块之间传递信息,无需显式地通过props或者其他方式连接它们,可以降低模块间的耦合度。
  2. 跨组件通信: 可以方便地在任何两个组件之间进行通信,无论它们在组件树中的位置如何,甚至可以跨越多个页面。
  3. 灵活性: 它允许你在任何时间、任何地方发布或者订阅事件,使用非常灵活。
  • 缺点:
  1. 可控性差: 由于事件可以在任何地方被发布或者订阅,因此很难追踪一个事件的完整生命周期,如果使用不当,可能会导致难以排查的问题。
  2. 可能导致组件过于复杂: 如果过度依赖事件中心进行通信,可能会使得组件的状态变得难以理解和管理,从而导致组件过于复杂。

结语

这些设计模式能帮助我们编写更易于维护和扩展的代码。当然,设计模式并非银弹,我们在实际项目中使用它们时需要根据实际需求和上下文进行灵活运用。

参考引用
  1. 《JavaScript设计模式与开发实践》 文中一些设计模式的 JavaScript 例子引用于此
  2. 免费在线学习代码重构和设计模式 文中各种生动形象的图片引用于此
相关推荐
10年前端老司机1 小时前
什么!纯前端也能识别图片中的文案、还支持100多个国家的语言
前端·javascript·vue.js
magic 2452 小时前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
只喜欢赚钱的棉花没有糖7 小时前
http的缓存问题
前端·javascript·http
让梦想疯狂8 小时前
开源、免费、美观的 Vue 后台管理系统模板
前端·javascript·vue.js
葡萄糖o_o8 小时前
ResizeObserver的错误
前端·javascript·html
sunbyte9 小时前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | AnimatedNavigation(动态导航)
前端·javascript·vue.js·tailwindcss
JustHappy10 小时前
啥是Hooks?为啥要用Hooks?Hooks该怎么用?像是Vue中的什么?React Hooks的使用姿势(下)
前端·javascript·react.js
江城开朗的豌豆10 小时前
Vue中Token存储那点事儿:从localStorage到内存的避坑指南
前端·javascript·vue.js
江城开朗的豌豆10 小时前
MVVM框架:让前端开发像搭积木一样简单!
前端·javascript·vue.js
氢灵子10 小时前
Canvas 变换和离屏 Canvas 变换
前端·javascript·canvas