降低前端业务复杂度:状态机(1)

365 阅读9分钟

前言

看到阿里的一位前端工程师的一篇文章,关于前端业务复杂度调整的文章,结合到了我自己项目开发工作中,显得有很多共鸣,想必也能引起在座各位的共鸣......

提出问题: 如何使用状态机范式使得产品-开发-测试-验收整条流水线更加顺畅,且降低业务复杂度?作为前端开发,如何去降低前端业务的复杂度呢?

业务复杂度

随着需求的不断迭代,通常都会出现逻辑复杂、状态混乱的现象,维护和新增功能的成本也变的十分巨大,苦不堪言。下图用需求、业务代码、测试代码做对比:

图中分了 3 个阶段:

  • 阶段 1:正常,都是线性增长。
  • 阶段 2:需求数正常增长,业务代码行数开始增长,测试代码行数大幅度增长。
  • 阶段 3:业务代码行数开始大幅增长,测试代码行数剧增(超出屏幕),而需求数开始下降。

看问题还是要去看本质, 根据复杂度守恒定律(泰斯勒定律) ,每个应用程序都具有其内在的、无法简化的复杂度。这一固有的复杂度都无法依照我们的意愿去除,只能设法调整、平衡。而现在前端的复杂度拆分主要包括:框架、通用组件、业务组件和业务逻辑。

当把框架和通用组件建设完成后,能够承担的复杂度基本稳定了,未来无轮再怎么改善或者更换其他框架,也很难再去突破天花板,对业务的复杂度的改变也微乎其微了(如果你的业务经历过底层框架更换,你就能体会到它到底对你的业务复杂度有没有带来变化了)

简单了解状态机

问题所在


把视角聚焦到 “业务逻辑” 侧,这里就要看所有业务中都会面临的问题,是什么让业务复杂度提升上去了。这里主要存在两点,如下:

  • 代码层面
    • 各种各样的业务状态导致的 flag 变量的剧增:即便是自己,写多了这种变量,也很难清楚的知道每个 flag 是干什么用的。
    • 各种判断业务状态的逻辑:if/else,isA && isB || !(isC || !isD && isE),if/else 嵌套地狱估计在很多大型的业务产品中都能看到吧。还有内部的各种逻辑判断,即便问 PD,时间久了她也不知道了(同时产品文档可能存在没有同步最新的问题),还有因此可能导致一些意识不到的 Bug。
  • 协作层面
    • PD——开发:不太了解产品业务的同学,很难有全局业务视角,所以面对 PD 的需求很难有话语权。如果需求设计不合理,只能等到你做完了,在 UAT 的阶段才能发现,然后 PD 会给你提一个新需求,让你再去修正(虽然是 PD 的问题,但缺乏避免 PD 犯错的途径)。
    • 测试——开发: 测试的内容范围,多数情况下,取决于前端同学给定的测试范围。而很多时候代码的改动,前端也不确定到底哪些页面会受影响。所以要么导致测试同学测试不完整,要么导致测试同学需要全量回归,这可是非常巨大的测试成本。
    • 开发——开发: 当其他前端开发人员,参与到项目中时,面临这种复杂的项目也是头大,需要花费很大的成本梳理清楚业务与代码的关联。导致合作或者交接项目时,困难。

\

代码层面

常用代码

初始需求:

根据输入的关键字进行搜索,并将搜索结果显示出来。

function onSearch(keyword) {
  fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {
    this.setState({ data });
  });
}

如果接口响应比较慢,则需要给一个用户预期的交互,如 Loading 效果:

function onSearch(keyword) {
  this.setState({
    isLoading: true,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {
    this.setState({ data, isLoading: false });
  });
}

还会发生出请求出错的情况:

function onSearch(keyword) {
  this.setState({
    isLoading: true,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword)
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      this.setState({
        isError: true,
      });
    });
}

不能忘了把loading关掉

function onSearch(keyword) {
  this.setState({
    isLoading: true,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword)
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      this.setState({
        isError: true,
        isLoading: false
      });
    });
}

每次搜索的时候,都要把错误清空

function onSearch(keyword) {
  this.setState({
    isLoading: true,
    isError: false
    
  });

  fetch(SEARCH_URL + "?keyword=" + keyword)
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      this.setState({
        isError: true,
        isLoading: false
      });
    });
}

当用户在等待搜索请求的时候,不应该再去搜索,所以搜索结果返回前,禁止再次发送请求:

function onSearch(keyword) {
  if (this.state.isLoading) {
    return;
  }

  this.setState({
    isLoading: true,
    isError: false,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword)
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      this.setState({
        isError: true,
        isLoading: false,
      });
    });
}

可以看到复杂度已经在不断地增大,如果因为搜索接口特别慢,用户希望有一个中断搜索的功能,那么新的需求又来了:

function onSearch(keyword) {
  if (this.state.isLoading) {
    return;
  }
  this.fetchAbort = new AbortController();  

  this.setState({
    isLoading: true,
    isError: false,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword, {
    signal: this.fetchAbort.signal,
  })
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      this.setState({
        isError: true,
        isLoading: false,
      });
    });
}

function onCancel() {
  this.fetchAbort.abort();
}

还要对catch 进行特殊处理,因为中断请求会触发 catch:

function onSearch(keyword) {
  if (this.state.isLoading) {
    return;
  }
  this.fetchAbort = new AbortController();

  this.setState({
    isLoading: true,
    isError: false,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword, {
    signal: this.fetchAbort.signal,
  })
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      if(e.name === 'AbortError') {
        this.setState({
          isLoading: false,
        });
      } else {
        this.setState({
          isError: true,
          isLoading: false,
        });
      }
    });
}

function onCancel() {
  this.fetchAbort.abort();
}

最后处理中断请求时,没有值的情况

function onSearch(keyword) {
  if (this.state.isLoading) {
    return;
  }
  this.fetchAbort = new AbortController();

  this.setState({
    isLoading: true,
    isError: false,
  });

  fetch(SEARCH_URL + "?keyword=" + keyword, {
    signal: this.fetchAbort.signal,
  })
    .then((data) => {
      this.setState({ data, isLoading: false });
    })
    .catch((e) => {
      if (
        e && 
        e.name == "AbortError"
      ) {
        this.setState({
          isLoading: false,
        });
      } else {
        this.setState({
          isError: true,
          isLoading: false,
        });
      }
    });
}

function onCancel() {
  if (
    this.fetchAbort.abort &&
    typeof this.fetchAbort.abort == "function"
  ) {
    this.fetchAbort.abort();
  }
}

仅仅一个简单的需求,从刚开始的几行代码到需要完成所有边界条件判断处理,这种包含各种 flag 变量和嵌套着各种 if/else 的代码,会越来越难维护。即使当初是自己写的代码,过了段时间再维护的时候,需要重新理解它的内部业务逻辑。

\

使用状态机

定义状态空闲搜索中成功失败

\

var menu = {
  // 当前状态
  currentState: 'free',
  // 绑定事件
  initialize: function() {
    var self = this;
    self.on("click", self.transition);
  },
  // 状态转换
  state: {
    free: {
      on: {
        搜索:'searching'
      }
    },
    searching: {
      on: {
        搜索成功: 'success',
        搜索失败: 'fail',
        取消: 'free'
      }
    },
    success: {
      on: {
        搜索:'searching'
      }
    },
    fail: {
      on: {
        搜索:'searching'
      }
    }
  }
};

可以看到,有限状态机的写法,逻辑清晰,表达力强,有利于封装事件。一个对象的状态越多、发生的事件越多,就越适合采用有限状态机的写法。


不需要去写各种各样的if/else了,View 中只需要知道当前是什么状态,已及将事件发送到状态机就可以了,其他什么都不需要做。在新增或者修改需求的情况下,只需要对状态进行新增或者编排就可以了。

而且可视化后,有以下变化:

-清晰的看到有哪些状态

-清晰的看到每个状态可以接受哪些事件

-清晰的看到接受到事件后会转移到什么状态

-清晰的看到到达某个状态的路径是怎么样的

协作方面

那是不是我们在接需求之前就可以在自己的脑袋里生成一个状态图,即使产品不提出来一些细节,开发也可以提出不同状态交互的处理?

  • 与测试人员的协作沟通
  • 与 PD 人员的协作沟通
  • 与其他前端开发人员的协作沟通
  • 与用户的协作沟通

\

文档化

目前对项目需求的描述主要有:

  • 产品需求文档(PRD)
  • 设计稿

状态机方式,要求你在开发之前必须把所有可能的状态都罗列出来状态之间的关联关系必须描述清晰。基于生成的状态图,是可以完全表达清楚所有的状态交互及变化,且它是来源于代码的,所以它是实时同步的,你代码中怎么运行的,这个状态图就是怎么表达的。

角色影响

  • 设计师可以根据状态图中的不同状态,来确定哪种状态合适用什么样的 UI。
  • 对于 PD,可以查看状态图,以了解系统行为,并验证是否满足要求。
  • 对于测试和用户,状态图完全充当说明书用,以前不知道如何才能到达某个状态,现在一目了然。
  • 对于测试还有一个很大的区别,因为基于状态机去写的,所以可以使用 Model-Based Testing,而这部分测试,可以由某些状态机工具自动化掉。
  • 对于交接的前端开发来说,有说明书在手,每个状态都十分清晰,能做的事也十分清晰,在具备状态机基础的情况下,是可以快速上手的。

\

优势

  • 比传统的编码方式,更容易理解。
  • 基于行为建模,与视图解耦。
  • 更容易改变行为:组件中的行为被提取到了状态机中,与 把行为和业务逻辑一起嵌入的组件相比,行为的更改相对容易。
  • 更容易的理解代码。
  • 更容易测试
  • 构建状态图的过程必须探索所有状态,也是让你具备业务全局视角的过程,它迫使你考虑所有可能发生的场景。
  • 基于状态图的代码比传统代码具有更少的 Bug 数。相关数据表示,错误减少了 80% 到 90%,剩下的错误也很少出现在状态图本身。
  • 有助于处理可能会被忽视的特殊情况。
  • 随着复杂性的增加,状态图可以很好地扩展。
  • 状态图是一个很好的交流工具。

劣势

  • 需要学习新的东西,状态机是一种范式的转化,且容易有抵触心里,不愿意走出舒适圈。
  • 新的格式
  • 新的重构技术
  • 新的调试工具
  • 部分人觉得可视化这种东西,没什么用。
  • 陌生的编码方式,在团队内可能出现不同的阻力。
  • 虽然大多数人听过状态机,但实际的编程中离它遥远,所以并不熟悉它。
  • 编程方式的转换,很多人需要弄清楚原来的代码,现在该如何去写,如何映射。
  • 部分人会质疑它的有效性。
  • 必须有人基于这种模式实践过,对它非常了解才可以。
  • 如果从来没用过它,使用这种模式会无从下手,令人生畏。

\

引用

降低前端业务复杂度: 状态机范式

AbortController(一个控制器对象,允许你根据需要中止一个或多个 Web 请求)

jakesgordon - github