代码要写成别人看不懂的样子(二十一)

本文介绍了JavaScript中的参与者模式,用于解决在不使用框架时如何在事件处理函数中传递额外参数的问题。通过结合函数绑定和柯里化技术,实现了在特定作用域执行函数并传递自定义数据。此外,还讲解了函数柯里化的概念,它是将多参数函数转化为接受部分参数的新函数,常用于创建具有多态性的函数。文中提供了兼容旧版浏览器的bind函数实现,并探讨了函数绑定和柯里化在性能和资源消耗上的考虑。

本篇文章参考书籍《JavaScript设计模式》–张容铭

前言

  熟悉 react 的同学,给绑定事件传递额外参数,这种操作是不是信手拈来,这是得益于 jsx 语法,所有代码都是 js 当然可以为所欲为了,但是当不使用框架的时候,我们又应该怎么实现这种功能呢?

  本节就来学一个参与者模式,帮我们解决传参问题。

参与者模式

  在特定的作用域中执行给定的函数,并将参数原封不动地传递

  之前我们封装过可以兼容各个浏览器的事件框架,如下:

//事件绑定方法
A.event.on = function(dom, type, fn) {
	//w3c标准事件绑定
	if(dom.addEventListener) {
		dom.addEventListener(type, fn, false);
	//ie事件绑定
	} else if(dom.attachEvent) {
		dom.attachEvent('on' + type, fn)
	//dom 0级事件绑定
	} else {
		dom['on' + type] = fn;
	}
}

  我们知道 addEventListener 方法不能传递自定义参数,那么我们想要传递额外的数据的话,就得在回调函数中做文章。

A.event.on = function(dom, type, fn, data) {
	//w3c标准事件绑定
	if(dom.addEventListener) {
		dom.addEventListener(type, function(e) {
			//在dom环境中调用fn, 并传入事件对象与 data 数据参数
			fn.call(dom, e, data);
		}, false);
	}
	//ie绑定事件...
}

   JavaScript 中的 call apply 方法很神奇,它可以使我们在特定的作用域中执行某个函数并传入参数,所以,在回调函数中,我们借助 call 函数实现了需求。

  不过上面代码有一个问题,添加的事件回调函数不能移除。因为此时事件回调函数是匿名函数,这就需要借用参与者模式来帮我们解决这一问题了。让更多的对象执行参与执行时的函数。

  大家知道函数的绑定方法不? 如果熟悉框架的话,那一定接触过 bind ,这东西的实现思想很简单,就是让一个函数在一个作用域中执行,根据这一条原理我们实现它就简单多了,只需要一个闭包就行。

//函数绑定 bind
function bind(fn, context) {
	//闭包返回新函数
	return function() {
		//对 fn 装饰并返回
		return fn.apply(context, arguments);
	}
}

  我们可以测试一下,新建一个对象 demoObj 和一个函数 demoFn ,然后让 demoObj 对象参与 demoFn 的执行,并保存在 bindFn 变量中,我们来观察一下 demoFn bindFn 执行的结果。

//测试对象
var demoObj = {
	title: '这是一个例子'
}
//测试方法
function demoFn() {
	console.log(this.title);
}
//让 demoObj 参与 demoFn 的执行
var bindFn = bind(demoFn, demoObj);
demoFn();  //undefined
bindFn();  //这是一个例子

   bindFn 函数返回了结果,这说明 demoObj 参与进来并提供了作用域,不过注意 bindFn 是让 demoObj 寄生其中,并在执行时才让 demoObj 加入的,所以说 bindFn demoFn 是两个不同的函数。应用于事件如下:

var btn = document.getElementsByTagName('button')[0];
var p = document.getElementsByTagName('p')[0];
//对 demoFn 改进,在控制台输出参数与 this 对象
function demoFn() {
	console.log(arguments, this);
}
//未设置提供参与对象
var bindFn = bind(demoFn);
//绑定事件
btn.addEventListener('click', bindFn);
//chrome 输出:[MouseEvent] Window

//提供 btn 元素参与对象
var bidnFn = bind(demoFn, btn);
//chrome 输出:[MouseEvent] <button>按钮</button>

//提供 p 元素参与对象
var bidnFn = bind(demoFn, btn);
//chrome 输出:[MouseEvent] <p>hello</p>

//移除事件
btn.removeEventListener('click', bindFn);

  当未提供参与对象时,执行的结果是返回 this 对象指向全局对象 Window , 说明此时是在全局作用域中执行的,当提供 btn 元素参与对象时,返回的 this 对象时元素自身,说明此时是在 btn 元素作用域中执行的,当提供 p 元素参与对象时,返回的是 p 元素,说明此时是在 p 元素作用域中执行的。并且我们发现函数的参数中还为我们传递了事件对象。并且这种方式添加的事件还可以通过 removeEventListener 来移除。

  有个好消息是,在一些标准浏览器如高版本的 FireFox, chrome, Safari 等中还是为我们提供了原生 bind 方法,所以你还可以像下面这种方式使用原生 bind 方法。

//提供 p 元素参与对象
var bindFn = demoFn.bind(p);

  这只是实现需求的第一步,将添加的事件成功移除。下面我们要完成第二部,为运行的函数添加额外的自定义数据参数。这就需要借助函数柯里化了。

函数柯里化

   bind 函数的本质,其实就是柯里化,函数柯里化思想是对函数的参数分割,根据参数的不同,让一个函数存在多种状态,只不过函数柯里化处理的是函数,因此要以函数为基础,借助柯里化器伪造其他函数,这些伪造的函数在执行时调用这个计函数完成不同的功能。

  怎对柯里化举个例子大家就明白了。

function add(x, y) {
    return x + y
}

// 柯里化后
function curryingAdd(x) {
    return function (y) {
        return x + y
    }
}

add(1, 2)           // 3
curryingAdd(1)(2)   // 3

  大家有没有觉得上面代码很熟悉,没错,上一节我们学习惰性模式的时候,就i是通过这种返回函数来实现的。下面创建一个函数柯里化器

//函数柯里化
function curry(fn) {
	//缓存数组 slice 方法 Array.prototype.slice
	var Slice = [].slice;
	//从第二个参数开始截取参数
	var args = Slice.call(arguments, 1);
	//闭包返回新函数
	return function() {
		//将参数(类数组)转化为数组
		var addArgs = Slice.call(arguments),
			//拼接参数
			allArgs = args.concat(addArgs);
		//返回新函数
		return fn.apply(null, allArgs);
	}
}

  接下来测试下这个柯里化器,老规矩,拿加法器进行拓展。

//加法器
function add(num1, num2) {
	return num1 + num2;
}
//加 5 加法器
function add5(num) {
	return add(5, num);
}

//测试 add 加法器
console.log(add(1, 2));   //3
//测试加 5 加法器
console.log(add5(6));   //11
//函数柯里化创建加5加法器
var add_5 = curry(add, 5);
console.log(add_5(7))  //12
//7 + 8
var add7add8 = curry(add, 7, 8);
console.log(add7add8())    //15

  通过柯里化器对 add 方法实现的多态拓展且不需要像以前那样明确声明函数了,因为函数的声明过程已经在柯里化器中完成了。

  接下来回归到一开始的需求,我们需求的第二部是传递额外的自定义数据参数,所以我们需要用函数柯里化思想来拓展函数执行的参数就可以了。我们重写一下 bind 函数。

//重写 bind
function bind (fn, context) {
	//缓存数组 slice 方法
	var Slice = Array.prototype.slice,
		//从第三个参数开始截取参数(包括第三个参数)
		args = Slice.call(arguments, 2);
	//返回新方法
	return function() {
		//将参数转化为数组
		var addArgs = Slice.call(arguments),
			//拼接参数
			allArgs = addArgs.concat(args);
		//对 fn 装饰并返回
		return fn.apply(context, allArgs);
	}
}

  现在我们创建两个数据对象, demoData1 demoData2 ,然后传入事件的回调函数中,我们在控制台看看输出结果。

var demoData1 = {
	text: '这是第一组数据'
},
	demoData2 = {
		text: '这是第二组数据'
	};
//提供 btn 元素、demoData1 参与对象
var bindFn = bind(demoFn, btn, demoData1);
//chrome 输出:[MouseEvent, Object] <button>按钮</button>
//提供 btn 元素、demoData1 demoData2 参与对象
var bindFn = bind(demoFn, btn, demoData1, demoData2);
//chrome 输出:[MouseEvent, Object, Object] <button>按钮</button>
//提供 p 元素、demoData1 参与对象
var bindFn = bind(demoFn, p, demoData1);
//chrome 输出:[MouseEvent, Object] <p>hello</p>

  在回调函数中果然可以访问到传入的自定义数据对象,浏览器内置的 bind 方法也可以这样用。

var bindFn = demoFn.bind(p, demoData1);
//chrome 输出: [Object, MouseEvent] <p>hello</p>

  跟我们自己封装的函数不同的是,内置的 bind 把事件对象放在了后面。

  接下来我们写个兼容版本,对未提供 bind 方法的浏览器的原生 Function 对象添加 bind 方法,这样在各个浏览器中就可以兼容了。

//兼容各个浏览器
if(Function.prototype.bind === undefined) {
	Function.prototype.bind === function(context) {
		//缓存数组 slice 方法
		var Slice = Array.prototype.slice,
			//从第二个参数截取参数
			args = Slice.call(arguments, 1);
			//保存当前函数引用
			that = this;
		//返回新函数
		return fucntion() {
			//将参数数组化
			var addArgs = Slice.call(arguments),
				//拼接参数,注意:传入的参数放在了后面
				allArgs = args.concat(addArgs);
			//对当前函数装饰并返回
			return that.apply(context, allArgs);
		}
	}
}

  参与者模式其实是两种技术的结晶,函数绑定和函数柯里化,早期浏览器未能提供 bind 方法,因此工程师们为了使添加的事件能够移除,事件回调函数中能够访问到事件源,并可以向事件回调函数中传入自定义数据,才发明了函数绑定与函数柯里化技术。

  对于函数绑定,他将函数以函数指针的形式传递,是函数在被绑定的对象作用域中执行,因此函数的执行中可以顺利的访问到对象内部数据,由于函数绑定构造复杂,执行时需要消耗更多的内存,因此执行速度上稍慢一些,不过相对于解决的问题来说,这种程度的消耗还是可以接受的。

  对于函数柯里化即是将接受多个参数的函数转化为接收一部分参数的新函数,余下的参数保存下来,当函数调用时,但会传入的参数与保存的参数共同执行的结果。通常保存下来的参数保存于闭包中,所以函数柯里化需要消耗一定的资源。

  柯里化有点类似类的重载,不同点是类的重载是同一个对象,函数柯里化是两个不同的函数。随着柯里化的发展,现在又衍生出一种反柯里化函数,目的是方便我们对方法的调用。

//反柯里化
Function.prototype.uncurry = function() {
	//保存当前对象
	var that = this;
	return function() {
		return Function.prototype.call.apply(that, arguments);
	}
}

  当用 Object.prototype.toString 校验对象类型时:

//获取校验方法
var toString = Object.prototype.toString.uncurry();
//测试对象数据类型
console.log(toString(function() {})) //chrome: [object Function]
console.log(toString([]));           //chrome: [object Array]

  用数组的 push 方法给对象添加成员

//保存数组 push 方法
var push = [].push.uncurry();
//创建一个对象
var demoArr = {};
//通过 push 方法为对象添加数据成员
push(demoArr, '第一个成员', '第二个成员');
console.log(demoArr);  //chrome: Object { 0: '第一个成员', 1: '第二个成员', length: 2}




完全没关系!你说“**我全都看不懂**”,那我们就从最最基础的开始,像教一个刚学编程的人一样,**一句一句、一步一步地解释这段代码到底在做什么**。 我们不跳步、不假设你知道任何图形学或数学知识。准备好,我们现在开始: --- ## 🌟 目标:画一个“圆柱体”(比如像一根水管、一个罐头) 我们要用 OpenGL(一种图形库)在屏幕上画出这个圆柱体。 但计算机不能直接理解“圆柱”这种东西,它只能画: - 点 - 线 - 三角形 所以我们必须把“圆柱”拆成很多小三角形拼起来 —— 就像乐高积木搭模型。 --- ## 🔧 第一步:准备材料(定义参数) ```cpp void Cylinder::initData(DataParam *param) { dataParam = *param; ``` ### ✅ 这句什么意思? - `DataParam *param` 是一个“装数据的盒子”的指针。 - 它里面可能有这些信息: ```cpp struct DataParam { float radius; // 圆柱半径(粗细) GLuint longSegments; // 把圆周切成几段(越多样子越圆) float height; // 高度(多长) }; ``` - `dataParam = *param;` 表示:“把这个盒子的内容复制到我自己的成员变量里保存下来。” 👉 所以现在我们知道要画一个多粗、多高、多精细的圆柱了。 --- ### 继续往下: ```cpp GLfloat radius = dataParam.radius; GLuint longSegments = dataParam.longSegments; GLfloat height = dataParam.height; ``` ✅ 把刚才复制过来的数据取出来,起个短名字方便后面用。 比如: - `radius` 就是半径(比如 1.0) - `height` 是高度(比如 2.0) - `longSegments` 是“把圆切几块”(比如 8 块 → 八边形) --- ## 📐 第二步:计算角度(怎么画一个圆?) ```cpp float fTheta = (glm::pi<float>() * 2.0f) / longSegments; ``` ### ❓这是什么? 我们来慢慢讲。 ### 💡 想象你在一个操场上,想绕着中心走一圈,走出一个“圆形”。 你可以: 1. 每次走一小步 2. 走完后转一个小角度 3. 再走下一步…… 这样走很多次,就画出了一个近似的圆。 而每次转的角度是多少呢? > 一圈总共是 **360 度**,也就是 **2π 弧度**(程序员喜欢用弧度) 如果你把圆分成 `longSegments = 8` 段,那每段就是: $$ \frac{2\pi}{8} = \frac{\pi}{4} $$ 👉 所以这行代码的意思是: > “我要把一个完整的圆($2\pi$)平均分成 `longSegments` 份,每一份的角度差是 `fTheta`” 🎯 `fTheta` 就是你每画一个点时增加的角度。 --- ## 🧮 第三步:需要多少个顶点?(提前申请内存) ```cpp int numVertices = 2 * (longSegments + 1) + (longSegments + 2) + (longSegments + 2); ``` 这句话看起来复杂,其实就是在数:**一共要用多少个“点”来组成这个圆柱?** 我们把它拆开来看! ### 🔹 第一部分:侧面(筒身)→ `2 * (longSegments + 1)` - 我们要在上下两个圈上各放一堆点: - 下面一圈:`longSegments + 1` 个点(因为首尾重合,所以比段数多1) - 上面一圈:同样 `longSegments + 1` 个点 - 所以侧面一共需要:`2 * (L+1)` 个点 📌 举个例子:如果 `longSegments = 8`,那么每圈9个点,共 `2×9=18` 个点 --- ### 🔹 第二部分:上底盖(顶上的圆)→ `longSegments + 2` - 画一个圆盖,要用 `GL_TRIANGLE_FAN`(扇形模式),需要: - 1 个中心点 - `longSegments + 1` 个边缘点(闭合) - 所以总共:`1 + (L+1) = L+2` 个点 --- ### 🔹 第三部分:下底盖(底下的圆)→ 又一个 `longSegments + 2` - 同理,下面也要画一个圆盖,也需要 `L+2` 个点 --- ### ✅ 加起来总数: ``` 侧面: 2*(L+1) 上盖: L+2 下盖: L+2 总点数 = 2L+2 + L+2 + L+2 = 4L + 6 ``` 代入 `L=8` → `4×8 + 6 = 38` 个点 所以我们要准备一个能装 **38 个点** 的数组。 --- ## 🗃️ 第四步:创建一个“点”的数组 ```cpp if (vertices) { delete[] vertices; } vertices = new TextureColorVertex[numVertices]; ``` ### ✅ 解释: - `vertices` 是一个数组,用来存所有的“顶点” - 每个顶点包含: - 坐标(x, y, z) - 颜色(r, g, b) - 纹理坐标(s, t) 👉 就像 Excel 表格的一行,记录一个点的所有信息。 #### 为什么先 `delete[]`? - 如果之前已经画过一次圆柱,`vertices` 已经分配过内存了 - 再次初始化前必须先释放旧内存,否则会**内存泄漏** #### `new TextureColorVertex[38]` 是什么? - 在电脑内存中开辟一块空间,可以放 38 个这样的“点” - 就像租了一个有 38 个格子的柜子,每个格子放一个点的信息 --- ## 🌀 第五步:生成侧面的点(最关键的一步) ```cpp for (int i = 0; i < (longSegments + 1); i++) { ``` 👉 循环 `L+1` 次(比如 9 次),每次生成一对点:一个在底下,一个在顶上。 --- ### 🟢 第一个点:底部的点 ```cpp vertices[2 * i].coordinate.x = radius * cosf(i * fTheta); vertices[2 * i].coordinate.y = -(height / 2.0f); vertices[2 * i].coordinate.z = radius * sinf(i * fTheta); ``` 我们来理解这三行。 #### 数学知识:如何用角度算出 x 和 z? 想象你在画一个圆: - 角度为 θ - 半径为 r - 那么: - $ x = r \cdot \cos(\theta) $ - $ z = r \cdot \sin(\theta) $ 这里的 `i * fTheta` 就是当前的角度。 例如: - i=0 → 角度=0 → x=r, z=0 → 正右方 - i=1 → 角度=π/4 → 斜上方 - ... - i=8 → 角度=2π → 回到起点 🎯 所以这一系列点就在 XY 平面上绕了一圈(其实是 XZ 平面,Y 是上下) Y 固定为 `-height / 2.0f` → 所有底边点都在“最下面” --- ### 🔵 第二个点:顶部的点 ```cpp vertices[2 * i + 1].coordinate.x = radius * cosf(i * fTheta); vertices[2 * i + 1].coordinate.y = (height / 2.0f); vertices[2 * i + 1].coordinate.z = radius * sinf(i * fTheta); ``` 和上面几乎一样,只是 Y 是 `+height/2` → 所有点都在“最上面” --- ### 📦 存储位置是怎么安排的? | i | 底部点索引 | 顶部点索引 | |---|------------|------------| | 0 | 0 | 1 | | 1 | 2 | 3 | | 2 | 4 | 5 | | ... | ... | ... | 所以整个侧面变成了这样一排点: ``` [底0][顶0][底1][顶1][底2][顶2]... ``` OpenGL 用 `GL_TRIANGLE_STRIP` 把它们连成带状三角形,形成筒壁。 --- ### 🎨 设置纹理坐标(简单理解:贴图位置) ```cpp vertices[2 * i].texture.s = (longSegments - i) * (1.0f / longSegments); vertices[2 * i].texture.t = 0; ``` - `s` 和 `t` 类似于图片上的横纵坐标(0~1之间) - 这里 `s` 是 `(L - i)/L` → 随着 i 增大而减小 → **从右往左贴图** - `t=0` → 贴图最下面 - 顶点设 `t=1` → 贴图最上面 ⚠️ 缺点:纹理是倒着贴的!正常应该用 `i * (1.0/L)` --- ### 🖍️ 设置颜色 ```cpp vertices[2 * i].color.r = ... = 1.0f; ``` - RGB 都设为 1 → 白色 --- ## 🛑 到这里为止:完成了侧面 18 个点 接下来要写“上底盖”和“下底盖” 所以我们需要知道:**下一个该写哪个位置?** --- ## 📍 关键行来了! ```cpp int start = 2 * (longSegments + 1); ``` 👉 这句话的意思是: > “我已经写了 `2*(L+1)` 个点(侧面),所以下一个可用的位置是第 `2*(L+1)` 号。” 比如 `L=8` → `2*9 = 18` → 所以下一个点从 `vertices[18]` 开始写 🎯 完全就是一个“记账”操作:前面用了多少格子?现在该从哪开始? --- ## ✅ 总结:你现在明白了吗? | 代码 | 实际含义 | |------|----------| | `fTheta = 2π / L` | 每一步转多少角度 | | `numVertices = ...` | 总共要多少个点 | | `new TextureColorVertex[N]` | 开辟内存存点 | | `for(...)` | 一个个生成点 | | `x = r*cosθ`, `z = r*sinθ` | 用三角函数画圆 | | `start = 2*(L+1)` | 记录“接下来从哪个位置开始写” | --- ## 🧩 最后一句话类比: 想象你要做一盘饺子: 1. 先看要做几个(`longSegments`) 2. 准备面粉和馅(`new` 内存) 3. 擀皮:一个个切好(生成侧面点) 4. 包完一圈后,开始做盖子(上底、下底) 5. `start` 就像是你对老婆说:“前面18个饺子包完了,现在该包盖子了,从第19个位置摆起。” ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值