函数式状态管理探索前端开发

33次阅读

共计 6496 个字符,预计需要花费 17 分钟才能阅读完成。

起因

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

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

问题

不管是面向对象还是函数式, 十分重要的一点是关注点分离。对于一个关注点、功能点, 面向对象主要是实现细节的封装,只对外提供简单的 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

正文完
 0