使用 Structured-React-Hook 编写"真 ` 易于维护和扩展"的组件(一)

3,160 阅读13分钟

前言

在我的前两篇文章中, 提到了解决组件扩展性的第三条路线, 有别于当下标准件封装和 CV 大法的新思路.

我将在本文聚焦于这第三条路线.

本文的在线 demo 可在此查看

structured-react-hook 项目: git地址

---- 前两文章 ----

论前端技术和前端工程之辩

还在用 redux 全家桶么? 不如试试更轻量更简单的结构化 hook?

正文

让我们通过场景来感受下标准件封装和 CV 大法的问题

小明是一个雄心勃勃刚刚入行满 1 年的前端工程师, 虽然经验不多, 但是他已经成为了他所在的团队的核心人物.

某日小明接到一个需求, 需要开发一个较为复杂的页面, 有多复杂(此处自行脑补, 你自己遇到过最复杂的页面), 但这难不倒小明, 毕竟他是一个拥有包浆开光 HHKB 键盘的男人.

花了几天功夫, 小明已经完成了工作, 代码可能长这样

function 我是个很复杂的组件(){
  // 此处省略 2000 行代码
}

提测后顺利通过了测试, 发布上线, 小明志得意满还得到了产品小美的赞誉.

过了几天, 隔壁业务组新上了一个产品, 为了抢跑市场, 抓住商机, 隔壁业务组的产品小王(和小明一样都暗恋产品小美), 在他的产品设计里加入了小美前几天让小明做的那个巨复杂的页面, 以快速满足业务需求(同时打击情敌)

小明自然识破了情敌的诡计, 在需求评审上迅速提出了复用他之前开发的组件的想法, 然而小王恬不知耻的要求对页面中的一些 UI 和交互做修改, 还要求加入一些定制化的流程. 年轻的小明又岂会认输.

心想 "不就是多加几个 API 和参数么, 等我提炼下就是了"

于是若干天后, 小明写下了如下代码

function 我是个巨复杂的可以被复用的组件(xiaoWangeDeConfig, xiaoMeiDeConfig){
    let config = xiaoMeiDeConfig
    if(是小王这个渣渣的产品){
      config = xiaoWangDeConfig
    }
    // 此处省略 3000 行代码
}

相比第一次, 小明这次熬掉了不少头发, 同时虽然没有增加什么需求, 但是因为各种 if 判断, 代码也从 2000 行增长了到了 3000 行, 好在终于把小王需求都提炼成了 config, 同时为了满足女神小美的要求, 把小美的需求作为默认的 config, 小明乐滋滋的看着自己的组件, 暗自得意的提测发布, 一切如期进行, 小明再次得到了小美的夸赞, 同时狠狠打击了小王的气焰.

遭遇挫折的小王很是不爽, 于是找组里的大明(资深前端老鸟, CV 大法第三十三代嫡传)

小王: 大明你说我怎么提需求才能让小明的组件维护不下去? 加班加到秃瓢? 大明: 这还不简单, 你只要这般这般... 小王 😏 嗯嗯嗯

过了 1 周, 小王再次整理好需求找到小美, 说业务发展很快, 现在要集成下三方服务, 争取快速打造一个商业生态, 所以要在那个巨复杂的组件里继续增加一些新的流程, 大明提出要小明提供一些 callback, 能够加入他们自己的逻辑.

小美问小明能不能搞, 女神亲问哪有不行, 何况小明一直都对 CV 大法嗤之以鼻, 早看不惯那个 30 多岁的大明了, 就喜欢摆资历, 于是一口就答应了.

于是根据小王的需求, 小明再次修改了组件

function 我是个复杂的要死了的可以被复用被扩展的组件(xiaoWangBaDeConfig, xiaoMeiDeConfig){
    let config = xiaoMeiDeConfig
    if(是小王这个渣渣的产品){
      config = xiaoWangBaDeConfig
    }
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }    
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }    
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }    
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }    
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }    
    // 此处省略 500 行代码
    if(config.callBack){
      callBack()
    }
}

熬了几个通宵的小明终于改完了需求, 这次平均每 500 行大明就要求加一个 callback, 好让他执行一段逻辑, 虽然小明内心深处对这种侵入式的参数心生疑虑, 但是大明满口保证

"放心吧, 我就只是执行以下, 绝对不干啥!" 😏

两眼通红的小明精力不济也就无暇多想, 于是就这样提测了.

测试大壮连续测了 3 次这个组件, 虽然心情烦躁, 但还是耐着性子测完. 这次他提了几个 bug

"小明, 这个提测质量有点下降啊, 好几个地方你都影响到小美原来的需求了, 要注意下"

由于提测质量一般, 加上不像之前那样提前完成, 小美这次没有夸赞小明, 辛苦了一周的小明未免有些情绪低落. 但他不知道, 远处狼狈为奸的小王和大明一直盯着他, 露出了阴森森的笑容😏

崩溃的小明

又过了一个月, 这一个月小明一直疲于奔命, 小王和大明总有需求提过来, 特别是大明, 因为传入了各种 callback, 每次修改都需要小明配合联调, 时不时跑不起来就要找小明, 连小美的需求都有些耽搁了, 直到月末, 小美找到小明

"小明, 小王他们那个新产品业务做得不错, 老板要求我们把你提供的那个组件和他们的交互对齐, 你改下吧, 原来我们那些交互就去掉吧, 这个应该不难吧, 这周能搞定么?"

⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️ 小美的话对小明犹如晴天霹雳 ⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️

小明心想 "我@#!@#, 那组件已经被我改得面目全非, 近 10000 行代码你说删就删, 就是我同意, 大壮也要疯了"

"哦对了, 你们技术老板上次会上说你这个组件很有共性, 最好是要提出来让其他业务也可以复用, 时间上抓点紧, 我看好你的 😁"

"我特么..."

随后的一周小明熬夜通宵 007double, 苦逼大壮被拉着不停的回归, 终于在最后一天赶上了发布日期, 同时也掉光了最后的几根秀发.

之后的日子, 小明因为秃瓢被技术部的人奉为大神, 继续维护他那个巨巨巨复杂的组件, 因为代码量太大, 小明也不敢重构, 就不停的往里加参数, 不行就 CV 一下, 虽然以前嗤之以鼻, 但是小明发现 CV 能减轻不少他的维护痛苦, 尤其是那些活不长的业务, C 一份给他们自己搞, 自己就不用这么累, 也不会出很多问题. 同时秃瓢的他也失去了小美的欢心, 小王因为白嫖了小明的资源, 成功快速上线了一个业务, 被老板提拔为新的产品组长, 同时管辖小美的业务, 近水楼台先得月, 加上一头秀发, 不久就和小美在一起了. 大明因为协助有功也被提拔为技术组长, 继续依靠 CV 大法支撑各类短命业务.

全剧终

事实上这样的剧本并不是我杜撰的, 除去狗血的三角恋, 这里的角色大多数都是我职业经历中所遇到过的.

整个剧本最终一个 HappyEnding 都建立在这 20 年互联网的野蛮生长历史之上, 因为大量短命业务的存在, 前端技术在软件工程上难有所成, 大量的前端工程师就这样被这些短命的业务生生耗尽了精力, 无论是雄心勃勃的年轻人还是混迹多年的老油条, 最终都成了业务的工具人, 我们写出的大量代码和积累的大量业务经验就像这些短命的快速被热钱燃烧的业务一样 → 毫无价值.

让我们回到本文的主题吧.

无论你是否对上述例子有切身的感受, 我想我们都可以就现在的组件封装方式达成一些共识. 现有的封装方式主要的问题在于

  1. 由于前端组件基于页面结构进行嵌套, 修改组件内部代码的成本和组件的嵌套层数成正比
  2. 为了满足需求开放的各种 callback, hook, 参数每加一个都需要修改组件内部的代码

两者叠加, 加剧了组件的膨胀效应, 于是组件越改越难用, 越难用越改不动.

而在面向对象软件设计中有一个核心的设计原则 开闭原则, 对修改关闭, 对扩展开放

事实上面向对象语言中的继承多态封装都是基于这一原则来设计的. 但显然, 前端使用

JavaScript 开发的组件却违反了对修改关闭这一条原则, 面向对象是为了解决软件复用问题, 开闭原则更像是软件复用的基础. 而我们现在采用的组件扩展却是反其道而行, 一遇到组件扩展问题, 我们就需要对组件内部代码进行修改. 但我们又有什么办法呢?

JavaScript 本身并没有完整的类特性, 并且像 Java 这样基于类的语言也被实践在现代复杂软件中有诸多问题, 毕竟后端也没比前端好到哪去呀. 一旦类被多次继承, 早就写得不知道自己姓啥了, 但是至少服务端还不用面对逻辑和设计的冲突.

所以有没有什么办法在技术上在前端领域实现开闭原则, 同时避免陷入像 Java 那样复杂的类设计体系呢? 如果可以, 我们是不是能够编写出 "真 ` 易于维护和扩展的组件?"

答案是 Membrane Mode

Membrane Mode

我在最近的文章中多次提到 Membrane Mode, 在这里我解释下 Membrane Mode 的含义. 事实上 Membrane Mode 是对一些语言特性的整合, 从而实现我上述提到的几个点

  • 能够编写符合开闭原则的代码
  • 同时避免复杂的继承体系

要实现上述两个目标, Membrane Mode 必须被限制为

  • 通过继承重载组合来实现对内部代码的扩展, 而不需要修改内部代码
  • 限制继承的层级为两级, 即父与子, 无有孙

下面的例子为了能更具说服性, 我将采用 structured-react-hook, 一个实现了 Membrane Mode 的基于 React Hook 的状态管理框架来编写示例

让我们从一个最简单 Button 组件开始

一个基础的带有基本 UI 的 Button

import React from "react";
import createStore from "structured-react-hook";

function Button() {
  return (
    <div>
      <button> 我是一个按钮 </button>
    </div>
  );
}

export default Button

让我们添加一个点击切换 Loading 文案的效果 效果大概是这样

下面是代码

import React from "react";
import createStore from "structured-react-hook";

function query(res) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(res);
    }, 1000);
  });
}

const storeConfig = {
  initState: {
    loading: false,
    text: "点我发起请求"
  },
  controller: {
    async onButtonClick() {
      this.rc.setState({
        loading: true,
        text: "请求服务器中..."
      });
      const res = await query("请求完成");
      this.rc.setState({
        loading: false,
        text: res
      });
    }
  }
};
const useButtonStore = createStore(storeConfig);
function Button() {
  const store = useButtonStore();
  return (
    <div>
      <button
        disabled={store.state.loading}
        onClick={store.controller.onButtonClick}
      >
        {store.state.text}
      </button>
    </div>
  );
}

export default Button;

接下来来点让小明崩溃的东西

大明说: 我们要复用这个组件, 麻烦你请求完成的时候给个 callback, 我要加逻辑 😏

看 Membrane Mode 如何解决这个场景

Membrane Mode 利用了 AOP 的特性来实现对逻辑的切入

// 独立的给 大明修改的文件
membrane-daming.js
const membrane = {
  controller: {
    async onButtonClick() {
      await this.super.controller.onButtonClick();
      alert("我是大明");
    }
  }
};

那小明需要在原有的 Button 组件中添加 callback 参数么? 答案是不需要. 小明只需要对 storeConifg 增加对 大明提供的 membrane 引用即可

import membrane from 'membrane-daming'
const storeConfig = {
  initState: {
    loading: false,
    text: "点我发起请求"
  },
  controller: {
    async onButtonClick() {
      this.rc.setState({
        loading: true,
        text: "请求服务器中..."
      });
      const res = await query("请求完成");
      this.rc.setState({
        loading: false,
        text: res
      });
    }
  },
  membrane
};

扩展之后大概是这么个效果

眼瞅小明顺利解决了大明的刁难, 作为资深老鸟的大明又岂会善罢甘休, 眼尖的他发现了一个问题

"!!等, 我刚才说错了, 我要在请求逻辑触发之前弹窗询问用户是否要继续请求😏"

看 Membrane Mode 如何解决这个问题

答案是, 用结构替换来代替特殊逻辑.

什么是结构替换?

我记得 Google 曾经计划发布一款手机, 叫模块化手机, 用户可以自行替换其中的模块, 只要这个手机支持. 在建筑行业, 很多大楼的改造都不需要破坏原有的建筑基础, 只需要替换其中的结构就行. 那么代码呢?

面对修改关闭, 就完全不能改自己的代码了么? 显然不是. 面对修改关闭, 我的理解是不要修改你原有的代码来实现扩展, 利用函数式编程的思维, 我们只需要将一个大的函数编程若干小函数, 并允许扩展方替换其中的函数即可, 这样我们既保护了我们自己的代码, 同时又满足了各种细粒度的扩展需求, 让我们回到上面的场景

听到大明的要求, 小明不假思索的重构了自己代码结构, 但依然不增加任何参数, 不给大明侵入自己代码的机会

于是小明切分了自己的代码, 提供了新的结构, 然后让大明扩展他想要的效果, 效果如下

代码如下

大明的文件

const membrane = {
  service: {
    beforeQuery() {
      const res = window.confirm("真的需要发起请求么?(大明)");
      return res;
    }
  },
  controller: {
    async onButtonClick() {
      const res = this.service.beforeQuery();
      if (res) {
        this.super.service.beforeQuery();
        this.super.service.query();
      }
    }
  }
};

小明的文件

const storeConfig = {
  initState: {
    loading: false,
    text: "点我发起请求"
  },
  service: {
    beforeQuery() {
      this.rc.setState({
        loading: true,
        text: "请求服务器中..."
      });
    },
    async query() {
      const res = await query("请求完成");
      this.rc.setState({
        loading: false,
        text: res
      });
    }
  },
  controller: {
    async onButtonClick() {
      this.service.beforeQuery();
      this.service.query();
    }
  },
  membrane
};

两个模块之间依然只保持 membrane 的联系, 同时大明和小明独自维护各自的代码.

重点在于, 小明自始至终没有为大明开放任何参数, 包括 回调, params 等, 小明要做的只是切分, 切分再切分, 就好像函数式一样, 原子化自己的代码, 避免了外部扩展带来的代码侵入和代码膨胀问题.

但是大明会放弃么? 大明又将提出何种尖酸刻薄的新挑战呢, 小明又将如何应对

请看下回分解.............

后话

Membrane Mode 吸取了过往编程范式中的一些有价值的特性, 提供了基于单次继承下的, 函数重载, 继承, 封装, 和采用 AOP 使用结构替换来代替通常的参数和 API 扩展思路. 如果你对本文, 对 Membrane Mode 感兴趣可以联系我, 欢迎交流😏