函数式实践-从0到1写个播放器

1,569 阅读9分钟

起因

不管是用主流的前端框架业务开发还是在写一些 sdk,通常的编程范式都是面向对象的,尤其 es6 新增 Class 语法糖后,功能模块的划分都基于类的力度。在写过和维护过不少代码后,渐渐觉的在状态复杂的应用中,按局部状态、行为来划分并不能让整体代码结构很清晰,且 js 天生的函数灵活性在类的场景下也很受约束, 所以尝试从函数式的角度来寻找一些突破口。

用了小半年的时间,在自己相对熟悉的音视频领域,采用函数式编程+状态管理的编程思路,写了一个简易但功能完备的 hls 播放器,算是对函数式编程有了一些理解和实践。

features

  • hls with ts format

  • hls with fmp4 format

  • hls ts with AES-128 decrypt

  • abr

  • hls ts live with abr

  • http-flv live

  • websocket flv live

问题

不管是面向对象还是函数式,十分重要的一点是关注点分离。对于一个关注点、功能点,面向对象主要是实现细节的封装,只对外提供简单的 api 暴露。而对于一个关注点内部,又可分为轻薄的控制层、对状态抽象管理的模型层、具体业务逻辑实现,IO 操作等的服务层。

控制层:是各功能模块之间交互的衔接点,串联在一起实现整体的功能,而功能模块的划分是否合理直接影响这一层的设计。是在 AController 中实例化一个 BController 还是在 CController 中实例化 AController、BController?

模型层: 对状态的管理,对于局部状态,遇到的最多的问题就是a.b.c,b为undefined的运行时报错,代码中充斥着大量的防御性检测。更严重的是全局状态,随着应用的复杂,全局状态越来越多,模块依赖其他模块的状态导致需要大量的 getter、setter,a.bInstance.cProp 怎么看都不爽。

服务层: 代码量随着迭代越来越多,代码不容易复用,横跨整个文件的通过 this 对属性的获取和修改

面向对象的层级结构设计并不简单、以类为力度划分功能带来了各个模块之间状态,方法的冗余调用、而通过 this 对状态的处理路径也难以跟踪、限制了函数的灵活性

函数式

很好的讲函数式的书

函数式以函数为核心,讲究把一个大的功能模块拆分成一个个小的函数,再由这些小函数组合成完整的功能。使用函数来抽象操作和控制流程。

操作: 函数在数学层面代表值的映射y=f(x),在函数式层面重在引用透明,即函数内的操作只依赖输入参数,不受其他外部状态影响,保证函数的纯粹性,我认为这是不现实的....,不可能把所有的依赖都以参数的形式传入函数,函数的结果也不只是产生一个新的值。参见下面对状态的管理。

控制流:函数式的强大在我看来在于对控制流的抽象,使得在对状态的处理过程中(同步的计算逻辑、异步的操作等),能以统一的口径在各个函数中流转,最终产生结果

函数式范式重在思维的转换,由命令式转向声明式,命令式给人的感觉是从一个方法进入另一个方法,层层递进,越来越深,是一种纵向的概念,而函数式是把所有操作都放在一个水平面上,在同一水平面,数据从一个流程进入下一个流程,是一种横向的概念,包括对同步的处理,异步的处理,产生副作用的IO操作,都抽象在一个维度

***还是从最基本的看起。。。。*****

curry+compose

小函数组合成大功能,面临的第一个问题是参数的数量, y=f(x) z=f1(t,y) n=f2(z),把 f,f1,f2 组合在一起由参数 x 得到结果 n,中间过程是匹配不上的,那就规定组合的函数都只接受一个参数吧!(对于接受多个参数的函数,通过 curry,暂存前面的参数,转换成只接受最后一个参数的部分函数)

const curry = fn => {
  let len = fn.length;
  return function _curry(...args) {
    if (args.length < len) {
      return _curry.bind(null, ...args);
    }
    return fn.apply(null, args);
  };
};

const compose = (...fns) => {
  const fnReversed = fns.reverse();
  return args => {
    return fnReversed.reduce((ret, fn) => fn(ret), args);
  };
};

y=f(x)
z=f1(t,y)
n=f2(z)

--->

let f1_1 = curry(f1)(t);
let getN = compose(
  f2,
  f1_1,
  f
)
getN(x) = n

container

对控制流的处理才是函数式的优雅所在,单纯的函数组合并不能处理复杂的流程,能将控制流与操作抽象在同一水平面,需要借助容器的概念,容器作为输入值的载体,容器上定义一些统一的接口,对输入值应用某些操作,并且数据可以从一种容器进入另一种容器进行进一步操作

针对不同的场景,容器又可细分为不同的子类,子类提供统一的接口不同的实现,根据存储值的不同状态,调用相同的API却执行不同操作

class Container {

 constructor(v){
   this._value = v;
 }

 static of(v){
   return new Container(v)
 }

 map(f){
   return new Container(f(this._value))
 }

}

Continer 定义map方法,对存储的值应用一个fn

**对于带有map方法的这一类数据结构叫做 functor,Array 有 map方法,Array就是一个functor**

Container.of(1).map(x=>x+1) --> Container(2)

Container 的衍生 Maybe、Either、Task、IO 等

Maybe: 专注处理空值监测,可以很好的处理 a.b.c 的问题

Either: 专注处理异常

Task: 异步处理,类似 Promise, 参见实现,单元测试

IO: 专注对副作用的处理

Maybe 的实现

class Maybe {
  static of(value) {
    if (value === undefined || value === null) {
      return Empty.of();
    }
    return Just.of(value);
  }
}

class Empty extends Maybe {
  static of(value) {
    return new Empty(value);
  }

  map() {
    return this;
  }
  join() {
    return this;
  }
  chain() {
    return this;
  }
  ap() {
    return this;
  }
  value() {
    return this._value;
  }
  getOrElse(f) {
    if (typeof f === 'function') {
      return f();
    }
    return f;
  }
  toString() {
    return 'Empty';
  }
}

class Just extends Maybe {
  static of(value) {
    return new Just(value);
  }

  map(fn) {
    const v = fn(this._value);
    return Maybe.of(v);
  }

  join() {
    return this.value();
  }

  chain(f) {
    return this.map(f).join();
  }

  ap(f) {
    return f.map(this.value());
  }

  getOrElse(f) {
    let v = this.value();
    if (typeof f === 'function' && v && v.constructor === Empty) {
      return f(v.value());
    }
    return this.value();
  }
}

eg:
Maybe.of(null).map(() => {}); // do nothing
Maybe.of(1).map(x => x + 1); // Maybe(2)

// Just Empty 提供相同的API,对于不同的输入值,空值检测发生在内部,自动选择使用不同的容器,针对对相同的操作,为空时自动略过

eg:
// 处理if逻辑判断
maybe(
  ()=>{
    //levels not exist,do some things,eg: load master m3u8
  },
  levels=>{
    // levels exist,do some things with levels
  },
  Maybe.of(store).map(prop('levels))
)


在实际使用中,我们可以把所有状态数据存储在中心 store 中,而从 store 中 getState()获取到的数据都是 Maybe 化的,对数据的操作和子属性的访问通过 map(f),这样可以很好的避免a.b.c类的运行时异常

对 Either、Task 等介绍可参见 上文提到的 很好的讲函数式的书,另 自己对函数式基本组件的封装

curry,在这里主要用于简化函数组合的复杂性,还有延迟执行,部分暂存等用处

compose,类似于传送带,将数据抽象在同一水平面流转

容器,类似于传送带上一个个小盒子,提供统一的接口标准,使数据从一个盒子无缝进入另一个盒子,完成操作和流程控制

对状态的管理

上面将函数式的处理流程比喻成状态(数据)在传送带上流转,但前端应用是复杂的,我们会有很多条传送带,各传送带之间会有状态的交互,如何能很好的将全局状态分发到各传送带?

在实践中,借鉴了 react-redux 的思想,提供一个中心 Store 的功能,各模块从 store 中 getState,发送命令对 store 中数据进行更新,store 和各函数式模块通过 connect 连接.

import { combineActions, combineStates, createStore } from 'vod-fp-utility';

let store = createStore(initState,actions)
let {id,connect,dispatch,getState,getConfig,subscribe.subOnce} = store;

connect:// `将store实例注入科里化后的功能模块函数,始终作为科里化的函数第一个参数`
dispatch:// 执行命令操作,可以是修改store的某个状态,可以是分发某个事件
getState: //从store中获取状态
subscribe:// 订阅某个事件,响应dispatch
getConfig:// 类似getState。只用来获取config配置信息
subOnce://类似subscribe,只监听执行一次


**connect是作为状态管理和函数式结合重要的中间桥梁!!!**

使用:
import {initState,ACTIONS} from "./store.js"
const store = createStore(initState,ACTIONS)
const manageHls = curry(({ dispatch, connect }, media, url)=>{
  // 这里,manageHls中可以轻松的从 store中获取state,dispatch动作
  // 通过`connect` loadPlaylist,createMediaSource等,在loadPlaylist和createMediaSource中
  // 可以同样的和中心store进行交互
  Task.resolve(connect(bootstrap))
    .ap(connect(loadPlaylist)(url))
    .ap(connect(createMediaSource)(media))
    .error(e => {
      dispatch(ACTION.ERROR, e);
    });
})
store.connect(manageHls)(videNode,m3u8Url)

函数式的应用

看一个例子

简化的需求背景:

hls 点播播放有标清、高清等档位,切换档位时,1. 先检查档位信息是否存在,2. 不存在要请求档位 m3u8 文件,解析 m3u8 3. 存在的话直接切换

可能存在异常的场景: 1. http 请求失败 2. m3u8 解析失败

it('# test transform Task -> Either -> Task', done => {
  let store = {};
  let loadSuccessSpy = chai.spy();
  let changeSuccessSpy = chai.spy();

  let loadErrorFlag = 'loadSourceError';
  let parseM3u8ErrorFlag = 'parseM3u8Error';
  let parsedM3u8Data = 'parsedM3u8Data';

  let getState = key => Maybe.of(store).map(prop(key));
  let setState = (key, v) => (store[key] = v);

  let _doStoreLevels = text => {
    store['levels'] = text;
    return text;
  };

  let _loader = flag => {
    return Task.of((resolve, reject) => {
      setTimeout(
        () => (flag === loadErrorFlag ? reject(flag) : resolve(flag)),
        200
      );
    });
  };

  let parseM3u8 = flag => {
    if (flag === parseM3u8ErrorFlag) {
      return Fail.of(flag);
    }
    return Success.of(flag);
  };

  // loadSource :: boolean -> (Task(error) | Either(success|error))
  let loadSource = flag => {
    return _loader(flag)
      .chain(parseM3u8)
      .map(_doStoreLevels)
      .map(x => {
        loadSuccessSpy();
        return x;
      });
  };

  // changePlaylist :: boolean -> (Either(success) | loadSource)
  let changePlaylist = flag => {
    return maybe(
      () => loadSource(flag),
      levels => {
        changeSuccessSpy();
        return Success.of(levels);
      },
      getState('levels')
    );
  };

  changePlaylist(loadErrorFlag).error(e => {
    e.should.be.equal(loadErrorFlag);
    loadSuccessSpy.should.not.be.called();
    changeSuccessSpy.should.not.be.called();
  });

  setTimeout(() => {
    changePlaylist(parseM3u8ErrorFlag).error(e => {
      e.should.be.equal(parseM3u8ErrorFlag);
      changeSuccessSpy.should.not.be.called();
      loadSuccessSpy.should.not.be.called();
    });
  }, 350);

  setTimeout(() => {
    changePlaylist(parsedM3u8Data).map(x => {
      x.should.be.equal(parsedM3u8Data);
      loadSuccessSpy.should.be.called.once;
      changeSuccessSpy.should.not.be.called();
    });
  }, 700);

  setTimeout(() => {
    changePlaylist(parsedM3u8Data).map(x => {
      x.should.be.equal(parsedM3u8Data);
      loadSuccessSpy.should.be.called.once;
      changeSuccessSpy.should.be.called();
      done();
    });
  }, 1000);
});

最后

本文并不能让你对函数式有多少了解,至少我自己目前也只有一些基本的认识(虽然这本书看了两三遍)),但函数式的思想还是值得在项目中不断实践的。

mostly-adequate-guide

程序员的范畴轮

vod-fp-utility