前言
以下都是我个人遇到的前端JS原生编码设计上的一些案例记录,希望能帮助新手开拓写代码的思想,并且能够结合自己的想法应用在实际的项目中,写出更加易读,拓展,维护的代码。
在其中会有一些案例展示,并不是说某个写法只能用于该案例上,要学会举一反三。
还有一点就是不要死记这些东西(我自己也记不住,叫我重写都未必能写出),留有个印象就好,等到某天你发现某个场景可以使用到下面的写法时,再对应的拿取用。
对象配置
就是一个总函数,可以通过传入的对象配置项,开启函数内部的一些特定模块的处理,例子如下:
// 总函数
function fn(target, config = {}) {
// A模块处理默认开启
if (config.handleA === true || config.handleA === undefined) {
handleA()
}
// B模块手动开启
if (config?.handleB === true) {
handleB()
}
}
// handleA处理模块
function handleA(){
// ...
}
// handleB处理模块
function handleB(){
// ...
}
// ...
举个使用在项目上的例子,例如我封装axios的时候,如果是一些简单的项目,就只需要做一层封装即可,然后在写接口请求方法的时候可以传入配置项:
import request from "@/utils/request";
/**
* 登录请求
*/
export const login = (data) => {
return request({
url: "/sys/login",
method: "POST",
data,
needLoading: true, // 是否有请求动画,默认给true
handleErrer: false, // 是否要手动处理错误信息,默认会自动报出接口错误信息
// ...
});
};
然后在axios的请求和响应拦截器中实现对应的功能即可。
链式调用
这是我个人认为最好理解和记忆的编写方法:
class _Print {
// 初始化
constructor() {
this.queue = [this.init] // 执行栈
this.next()
}
// 初始化钩子
init() {
console.log('初始化钩子')
// 这里要开启下一轮事件循环再执行栈中的任务,保证链式调用的任务已推入
setTimeout(() => {
this.next();
}, 0)
}
// 同步执行
print(msg) {
let fn = function () {
console.log(msg);
this.next()
}
this.queue.push(fn)
return this
}
// 延迟
delay(time) {
let fn = function () {
setTimeout(() => {
this.next()
}, time)
}
this.queue.push(fn)
return this
}
// 弹出栈任务并执行
next() {
let fn = this.queue.shift() // 这里重新定义了函数,不再是指向实例了
fn && fn.call(this)
}
}
new _Print().print('1').delay(3000).delay(3000).print('2')
数据的连续处理可采用链式调用方案。例如后端返回了这样的数据:
const users = [
{ firstName: 'Alice', lastName: 'Smith', age: 25 },
{ firstName: 'Bob', lastName: 'Johnson', age: 17 },
{ firstName: 'Charlie', lastName: 'Brown', age: 30 }
];
我们需要处理成:1 剔除未成年人 2 按年龄排序 3 拼接全名 4 做一些其他处理。
此时我们想要的输出应该是:['Alice Smith', 'Charlie Brown']
先看看正常写法:
// 特殊处理函数
function preprocess(users) {
console.log('Preprocessing users...');
// 假设这里有一些预处理逻辑
return users;
}
function postprocess(users) {
console.log('Postprocessing users...');
// 假设这里有一些后处理逻辑
return users;
}
// 数据处理函数
function filterAdults(users) {
return users.filter(user => user.age >= 18);
}
function sortUsersByProperty(users, property) {
return users.sort((a, b) => a[property] - b[property]);
}
function mapFullNames(users) {
return users.map(user => `${user.firstName}${user.lastName}`);
}
function handleUserData(users) {
// 特殊处理代码
console.log('Starting user processing...');
users = preprocess(users);
// 数据处理流程
let adultUsers = filterAdults(users);
let sortedAdultUsers = sortUsersByProperty(adultUsers, 'age');
let fullNamesSortedByAge = mapFullNames(sortedAdultUsers);
// 特殊后处理
fullNamesSortedByAge = postprocess(fullNamesSortedByAge); // 例如保存在redux
console.log(fullNamesSortedByAge); // 输出: ['Alice Smith', 'Charlie Brown']
}
export { handleUserData };
如果采用链式处理方案:
class UserProcessor {
constructor(users) {
this.users = users;
}
// 预处理,可以在链式调用之前执行
preprocess() {
// 特殊处理代码,例如数据验证、初始化等
console.log('Preprocessing users...');
// 假设这里有一些预处理逻辑
return this;
}
filterAdults() {
this.users = this.users.filter(user => user.age >= 18);
return this;
}
mapFullNames() {
this.users = this.users.map(user => `${user.firstName}${user.lastName}`);
return this;
}
sortUsersByProperty(property) {
this.users = this.users.sort((a, b) => a[property] - b[property]);
return this;
}
// 后处理,可以在链式调用结束后执行
postprocess() {
// 特殊处理代码,例如结果验证、数据导出等
console.log('Postprocessing users...');
// 假设这里有一些后处理逻辑
return this;
}
run() {
return this.users;
}
}
// 其他文件拿到数据后直接处理
// 链式调用,包括预处理、数据处理和后处理
const fullNamesSortedByAge = new UserProcessor(users)
.preprocess()
.filterAdults()
.sortUsersByProperty('age')
.mapFullNames()
.postprocess()
.run();
console.log(fullNamesSortedByAge); // 输出: ['Alice Smith', 'Charlie Brown']
个人认为这样做的好处如下:
- 调用直观,通过连续的函数名就能知道经过了哪些处理。
- 改造时能够针对性的进行调整,而不是在一个大的方法中疯狂改写(handleUserData)。例如多一个操作,我们可以在链式调用中再插入一个函数。又例如有函数逻辑变动,可以针对这个函数内部进行调整。
缺点: - 个人还没在项目中实战过,实际情况更复杂,可能对编码能力是一种挑战。
队列调用
就是把要经过的任务都推入到任务队列里,然后挨个执行,例子如下:
function p() {
let promise = Promise.resolve()
function fn1(result) { // 功能封装1
console.log('fn1');
return Promise.resolve('fn1')
}
function fn2(result) { // 功能封装2
console.log('fn2');
return Promise.resolve('fn2')
}
let arr = [fn1, fn2]
while (arr.length) {
promise = promise.then(arr.shift())
}
return promise
}
p('1') // fn1 fn2 轮流执行
可以用在对axios进行更深入的封装,可以参考我这篇文章:【场景方案】如何去设计并二次封装一个好用的axios,给你提供一个另类写法,另加一些思考
并发执行
并发执行任务的时候,我们要做好每次的并发量。一般这种并发场景都是异步请求,所以必然涉及到Promise,这里也就拿Promise去写示例:
// 模拟100个异步请求
const arr = [];
for (let i = 0; i < 100; i++) {
arr.push(() => new Promise((resolve) => {
setTimeout(() => {
console.log('done', i);
resolve();
}, 100 * i);
}));
};
const parallelRun = () => {
const runingTask = new Map(); // 记录正在发送的异步请求(闭包存储)
const inqueue = (totalTask, max) => { // 异步请求队列,每组请求的最大数量
// 当正在请求的任务数量小于每组请求的最大数量,并且还有任务未发起时,就推入请求
while (runingTask.size < max && totalTask.length) {
const newTask = totalTask.shift(); // 弹出新任务
const tempName = totalTask.length; // 以长度命名?
runingTask.set(tempName, newTask);
newTask().finally(() => {
runingTask.delete(tempName);
inqueue(totalTask, max); // 每次一个任务完成后就继续塞入新任务
});
}
}
return inqueue;
};
parallelRun()(arr, 6);
有人会问为啥不直接用all方法呢?因为只要期中一个任务失败了,整个队列都没用了。详细可以看【es6入门】好好捋一捋Promise与Async的异步写法,细节满满
条件判断优化
我们会经常遇到一种场景,在一个函数中有很多的判断路线:
function process(item) {
if (item.status === 'a' && isFinished()) {
// ...
}
if (['a', 'b'].includes(item.status) && !isFinished()) {
// ...
}
}
像这种,我们可以用一个对象去整改,使得整个易读性更好:
function process(item) {
const statusMap = {
a: isFinished(),
b: isFinished()
}
if (statusMap[item.status]) {
// ...
}
}
要学会灵活写statusMap
,例如条件变成了:
function process(item) {
if ((item.status === 'a' && isFinished()) || (item.status === 'b' && !isFinished())) {
// ...
}
}
可以改成:
function process(item) {
const statusMap = {
a: isFinished(),
b: !isFinished()
}
if (statusMap[item.status]) {
// ...
}
}
看起来还是非常易读的。
如果场景变得更复杂,每个if判断条件是不一样的,并且执行的内容也不一样,那么可以设计成:
const statusMap = [
[
()=>{ /* 条件1 */ },
()=>{ /* 执行内容1 */ },
],
[
()=>{ /* 条件2 */ },
()=>{ /* 执行内容2 */ },
],
]
const target = map.find(status => status[0]) // 找到条件满足的那一项数组
if (target) {
target[1]()
} else {
// 没有命中的逻辑
}
给数据做封装
啥意思呢,就是当我们有一个数据要维护时,可以选择给这个数据做一层封装,让他拥有一些能力,还能给他再拓展其他信息等等。
具体如何做的可以参考如下,一个购物车里有很多商品,每一个商品的数据是以一个对象的形式去储存的,将来会对这些对象做一些操作。那么我们就可以封装成这样:
function createUIGoods(aGoods) {
class UIGoods {
// 获取总价格
get totalPrice() { // 在class中Object.defineProperty的get可以这样简写
return this.choose * this.data.price;
}
// 是否选中
get isChoose() {
return this.choose > 0;
}
constructor(g) {
// 通过拷贝后冻结拷贝后的对象,让外部无法直接修改其中的属性
g = { ...g };
Object.freeze(g);
// 定义商品所有信息
Object.defineProperty(this, 'data', {
get: function () {
return g;
},
set: function () {
throw new Error('data 属性是只读的,不能重新赋值');
},
configurable: false,
});
// 选中后的数量值
var internalChooseValue = 0; // 用个临时变量存放值,防止get返回值的无限递归
Object.defineProperty(this, 'choose', {
configurable: false,
get: function () {
return internalChooseValue;
},
set: function (val) {
if (typeof val !== 'number') {
throw new Error('choose属性必须是数字');
}
let temp = parseInt(val);
if (temp !== val) {
throw new Error('choose属性必须是整数');
}
if (val < 0) {
throw new Error('choose属性必须大于等于 0');
}
internalChooseValue = val;
},
});
// 这里写其他可以修改的属性
this.fromWhere = 'China';
Object.seal(this); // 这里用seal代替freeze保证其他属性能够不被冻结,能够修改
}
}
Object.freeze(UIGoods.prototype); // 禁止外部修改原型
let g = new UIGoods(aGoods);
return g
}
let g = createUIGoods(aGoods)
g.data.price = 100; // 无法修改data里的属性
g.fromWhere = 'USA' // 其他属性可以修改
console.log(g);
每个商品数据经过createUIGoods的处理,变得既安全又功能强大,为我们为后续购物车的功能实现打下了良好的基础。
看到没数据再也不是普普通通简单的数据了。
此思想从渡一前端那里看来的
尝试先写JS再写界面
我相信我们大多数开发的时候都是先把UI设计稿搭建起来,再去填充对应的JS内容。
其实我们有条件的时候可以尝试先写JS再写界面,大致就是从获取到的数据开始设计,从里到外的去写代码,例如获取数据--->数据处理---->功能封装---->界面搭建---->样式完善
,这种方式去写代码,就比较容易让JS与界面解耦,很有可能把每个JS函数单独拿出来可以独立运行。
可以这样概括:要由JS决定HTML,而不是HTML决定JS。
当然这只是一个建议,可以去尝试。