【响应式编程的思维艺术】响应式Vs面向对象

219 阅读8分钟
原文链接: zhuanlan.zhihu.com

【小宅按】本文是Rxjs 响应式编程-第一章:响应式这篇文章的学习笔记。示例代码托管在:http://www.github.com/dashnowords/blogs

一. 三句非常重要的话

  • 从理念上来理解,Rx模式引入了一种新的“一切皆流”的编程范式
  • 从设计模式的角度来看,Rx模式发布订阅模式迭代器模式的组合使用
  • Rxjs对事件(流)的变换处理,可以对比lodash对数据的处理进行理解。

原文对很多基础却核心的概念都有详细的讲解,本文不再赘述。需要注意的是,理解原理是一方面,但能够熟练使用运算符来转换或查询流信息是需要很长时间积累的,建议在学习过程中,每次遇到新的运算符就主动查阅资料理解其用法,这样积少成多慢慢地就总结出开发模(tao)式(lu)了。

为了更直观地感受面向对象和响应式编程中的不同,笔者分别用两种模式实现了两个一样的小动画,Demo比较简单,就是一个不断奔跑的角色和一个无限滚动的背景图。但是就体会和理解两种开发模式而言基本够用了。

二. 面向对象编程实例

2.1 动画的基本编程范式

动画实例使用canvas画布来完成,简单动画的基本编程模式如下:

//启动函数
function startCanvasAnimation(){
   //初始化舞台,舞台对象(或者叫做精灵动画类,帧动画类)
   let background = new Background(ctx1,bgImg);
   let bird = new Bird(ctx1,roleImg);
   //把精灵动画实例集中管理
   spirits.push(background);
   spirits.push(bird);
   //启动一个无限循环绘制暂态动画的递归函数
   return requestAnimationFrame(paint)
}

//每个绘制周期重复调用的绘制函数
function paint() {
   //遍历精灵动画实例集合
   for(let spirit of spirits){
       spirit.update();//更新自己的参数
       spirit.paint();//绘制精灵动画
   }
   return requestAnimationFrame(paint);//尾递归调用绘制函数
}

当然示例中没有涉及局部更新或其他有关渲染性能的部分,更复杂的动画需求可以直接使用引擎来实现,这不是本篇的重点。

2.2 参考代码

/**
* 角色类
*/
class Role{
   constructor(ctx,img){
       this.ctx = ctx; //传入画布上下文实例
       this.img = img; //传入帧动画用的图片
       this.pos = [0,0]; //记录帧动画初始位置
       this.step = 68; //帧动画不同帧位置间距
       this.index = 0; 
       this.ratio = 4;
   }

   //更新自身状态
   update(){
       //此处通过速率控制实现了帧动画待绘制区域在雪碧图中的起始位置
       if (!(this.index++ % this.ratio)) {   
          this.pos[1] = this.pos[1] === 748 ? 0 : this.pos[1] + this.step;
       }
   }

   //绘制
   paint(){
       //将角色绘制在画布的指定位置
      this.ctx.drawImage(this.img, this.pos[0] , this.pos[1] , 54 ,  64 , 120 , 304, 54, 64);
   }
}

背景也可以当做是一个精灵动画实例,以同样的模式定义即可,示例中的角色并没有实现相对画布的运动(也就是视差),感兴趣的读者可以自己尝试实现,完整的示例代码见附件。

2.3 小结

面向对象编程中,具体的精灵类可以继承抽象精灵类,且将具体的实现封装在自己的类定义中,最后使用类似于建造者模式的方法将各个实例组织起来,有面向对象编程经验的读者对这个流程应该不会陌生。

三. 响应式编程实现

在响应式编程中,我们需要构建角色动画流背景动画流这两个可观测对象,然后将这两个流合并起来,此时就得到了一个尚未启动的动画信息流,通过subscribe( )方法启动这个流,并将绘制方法传入回调函数,就可以实现一个同样的动画了。

/**动画的rxjs响应式编程实现*/
//定义动画帧率
var rxjsRatio = 50;
var rxjsFrame = parseInt(1000/rxjsRatio,10);
//构建角色动画流
var roleStream = Rx.Observable.interval(rxjsFrame).map(i=>{return {x:0,y:(i%12)*68}});
//构建背景动画流
var bgiStream = Rx.Observable.interval(rxjsFrame).map(i=> i%800);
//合并流
var rxjsAnim = Rx.Observable.combineLatest(roleStream,bgiStream,(role, bgi)=>{
                                      return {role,bgi}
                                  }).subscribe(rxjsRender);

//绘制角色
function rxjsPaintRole(rolePos) {
      ctx2.drawImage(roleImg, rolePos.x , rolePos.y , 54 ,  64 , 120 , 304, 54, 64);
}

//绘制背景
function rxjsPaintBgi(offset) {
     let delta = 92;
      //绘制左半部分
      ctx2.drawImage(bgImg , offset + delta , 0 , 800 + delta - offset , 576 , 0 , 0 , 800 + delta - offset , 400);
      //绘制右半部分
      ctx2.drawImage(bgImg , delta, 0 , offset, 576 , 800 - offset , 0 , offset , 400);
}

//绘制
function rxjsRender(actors) {
   rxjsPaintBgi(actors.bgi);
   rxjsPaintRole(actors.role);
}

四. 差异对比

4.1 编程理念差异

面向对象编程用类和继承封装多台来聚合关系,响应式编程用流和变换来聚合信息。

通过代码对比可以发现,在响应式编程中,我们不再用对象的概念来对现实世界进行建模,而是使用的思想对信息进行拆分和聚合。在面向对象编程中,数据信息数据更新方法绘制方法这三大要素都是描述具体类的,他们被类的定义聚合在了一起;而在响应式编程中,不再强调“关系”,而是将数据和变化聚合在一起,将处理方式聚合在一起。试想假如上面的示例中增加不同的类,障碍,怪物,积分等等,那么面向对象编程中就需要增加新的类定义,而响应式编程中就需要增加新的数据流,但是在每一个绘制的时间点拿到的暂态数据和根据这些暂态数据进行的绘制动作,其实都是一致的,区别只是关键信息的聚合方式不一样了

4.2 编程体验差异

在传统编程中,我们常常会得到一个无法直接用于最终场景的数据集合,然后需要手动做一些后处理,最终把生成可被使用的数据提供给消费模块;而响应式编程中强调的,是“直接告诉程序你最终想要获得什么数据”,然后将程序的加工流程内化到生产过程中,从而当消费模块得到数据时,直接就可以使用,而不需要再做更多的后处理,这对于消费者来说无疑是体验的提升,就好像你去买组装电脑时,商家都会帮你推荐组件送货上门还会帮你组装好,你肯定感觉服务很到位,因为大部分人的目的是使用电脑,而不是享受买电脑的过程。

4.3 数学思想差异

如果说面向对象编程思想是在描述客观世界,那么响应式编程就更像是在尝试揭示规律。

回过头再来看我们上面实现的Demo,在传统的编程中,我们的思维模式更加倾向于一种微积分的思想,也就是说我们试图描述一个精灵动画的变化时,关注的是如何从x[i]得到x[i+1],当我们得到这样一个变换方法x[i+1]=g(x[i])后,只需要在对象的属性中记录每一个时刻的x[i],然后在下一个绘制周期开始时运行这个方法计算出x[i+1],按照新的值绘制元素,用新值覆盖旧值,然后循环这个过程就可以了;而在响应式编程中,我们采取的方式是为x[i]求出一个通项公式,也就是x = f(i)这样一种数学形式的描述,它们之间的关键区别并不是函数体内逻辑的表达形式,而是在面向对象中实现的方法是有状态的(你需要用某个实例属性来标记帧动画实例当前的执行状态),而响应式编程中的方法是无状态的,是不是联想到什么了?没错,函数式编程中的纯函数。响应式编程本来就是建立在函数式编程基础之上的,只通过纯函数实现集合的映射变换。

如果你听说过傅里叶变换,应该不难发现响应式编程的思维模式和它很像,傅里叶变换可以将一个混杂的信号,拆分成若干个不同振幅频率和相位的正弦波的,这样工程师就可以独立分析自己感兴趣的部分,这是信号分析中很基本的手段。在响应式编程中,系统中的状态变化以类似的方式被拆分成了很多独立的流,如果开发者关注的某个流出现异常,只需要单独关注其数据源和用于流变换的函数链即可(当然它的数据源也可能会被拆分成若干个独立的流),而不必陷入巨大的逻辑关系网,这对于提升大型系统的调试效率来说是非常重要的。在面向对象编程中,这一点是很难做到的,更常见的情况是你修改了A方法,然后B方法就报错了,紧接着你发现这个过程竟然是递归的,最后程序崩溃了,你也崩溃了。

4.3 小结

笔者认为程序的世界里终究是“没有更好的技术,只有更适合的方案”,在合适的场景做到合适的技术选型才更重要,至于什么样的场景更适合响应式编程,还需要在后续的学习和实践中慢慢体会,但无论如何,响应式编程中蕴含的工程思想和数学之美让我赞叹。

demo.rar

更多精彩内容,请滑至顶部点击右上角关注小宅哦~


来源:华为云原创