Mar 2

容器React开发:类组件or函数组件问题与容器封装问题

Lrdcq , 2021/03/02 21:51 , 程序 , 閱讀(1710) , Via 本站原創
之前在team里展开过一次“论战”,背景是咱们大型app的容器(Web容器,ReactNative容器)内页面/功能发均是react背景,同时开发人员native背景与h5背景均存在,实际开发过程中类组件与函数组件均有使用,并且在各个方向上有所踩坑。因此发起“论战”,即容器react开发类组件与函数组件谁更合适?

观点

team中的观点基本上围前端技术,移动端容器适配与对应的副作用(effects)来讨论。

不过在讨论观点之前,团队大佬之间至少达成了以下共识:

1. 从react框架本身角度,类组件与函数组件并没有优劣之分,无论是性能,可用性,可靠性都可以说不分伯仲;函数组件并不是为了替代类组件而生。

2. 对于一个足够优秀的开发者来说,类组件与函数组件的使用成本,风险没有显著的区别,因此我们的团队假设是在团队最低可接受的范围内的开发者水平为基准进行讨论的;也可以认为假设团队淘汰率为10%,我们讨论基准应该是位于TP10的开发者。

3. 均在团队的当前开发规范,即es6+ts上进行react开发。

主要观点如下:

1. 观点【支持函数组件】:函数组件相对于类组件在更短的代码下有更好的性能:这里指的并不是react框架的性能,而是实际项目经验上看,使用类组件带来的复杂性与代码质量风险,实际产出代码的平均性能更低,可能影响的点包括:

- 类组件由于this不稳定与props状态问题,一般存在大量的拷贝this/props/states与类似数据处理行为,大量无脑拷贝行为会增大组件运行成本。当然,还有大量的无脑bind(this)行为。

- 类组件由于组件生命周期与数据生命周期的暴露,一些生命周期中的数据处理与误用会导致多余的渲染次数。当然函数组件不仔细编写多余的渲染也是不可避免的,但是整体看“缺陷”内的,而不是“没有优化”的多余渲染,类组件远更容易出现一些,并且基本上是生命周期上出现了问题。

当然第二点也有相反的情况,比如对应真的需要重复渲染的场景(比如组件涉及到宽短测量的情况),有states并暴露生命周期的类组件反而写起来更方便一些。

2. 观点【支持相当】:函数式组件虽然能简化代码,但是并不能提升单个组件代码整体可读性;函数式组件和类组件可读性不分伯仲因人而异。这个观点的背景在于虽然函数式组件的代码的组件部分代码量降低了,但是理解组件业务逻辑的成本并没有降低。

- 基础原因在于,组件的渲染逻辑部分,函数式组件与类组件并没有太大的区别,主要区别集中在数据处理部分。而就原本的数据处理方案来说,setstatus与hook这两种方式各自穿插在代码中,和代码量无关的理解成本各有说法。认为类组件的数据管理更难理解的原因,在于就算非常规范的编写类组件,数据源和setStatus还是不可避免的分散在组件的多处,并且总是无法从代码上catch到当前状态,阅读起来并不直观,或者说大家还是觉得vue的写法是最直观的;而认为函数组件的数据管理更难理解的原因,多半在于hook编写与封装的理解成本,同时实际代码中hook管理的数据还是分散在多处使用和反set,代码分散度并不低。

- 同时,也考虑到使用redux或者mobx管理数据流后,多半会把一个组件的数据逻辑独立到一个或者一类单独的逻辑文件中,这类文件只要习惯数据框架的写法,逻辑可读性是一致的并且确实易于阅读与管理。因此实际工程中组件的逻辑可读性会被磨平。

- 另外还涉及到一个概念是,类组件的开发严谨的面向对象与应用生命周期,函数组件更偏向于函数式编程。虽说这几年前端流行函数式编程或者无状态编程,但是实际接触下来,开发者对函数式编程或者无状态编程的接受度不一,也有开发者更喜欢面向对象编程;同时无状态编程确实也在业务抽象上有相应的缺陷,比如hook就是在react中补足无状态的方案的工具。在这里实际团队中当然会出现大量的争议了。

3. 观点【支持函数组件】:函数组件的设计对于javascript语言的坑的规避是非常优越的。这里虽然实际上是在说javascript本身的问题,但是函数组件确实基本上类组件中可能遇到的大部分问题都规避了。

- 主要问题是this问题,javascript的不稳定this设计给类组件带来的缺陷是类组件的方法不稳定需要用变通写法,大量使用bind或者lambda,在质量较差的代码中很容易遗漏bind或者写法混搭。更要命的是,如果this错误的方法没调用过this,这个问题难以发现。
//方法this问题
class App {
  callbackFoo(event) {
    this.doSomeThing();//这里的this不是app
  }
  
  render() {
    return <Inner callback=this.callbackFoo></Inner>
  }
}
//【修改方案1:方法传递时需要绑定正确的this】
class App {
  callbackFoo(event) {
    this.doSomeThing();//这里的this不是app
  }
  
  render() {
    return <Inner callback=this.callbackFoo.bind(this)></Inner>
  }
}
//【修改方案2:方法改用lambda写法】
class App {
  callbackFoo = (event) => {
    this.doSomeThing();//这里的this不是app
  }
  
  render() {
    return <Inner callback=this.callbackFoo></Inner>
  }
}

还有一个类似的问题是类组件闭包中的this为了保险需要copy,而函数组件直接调用目标函数即可,如:
class App {
  a() {
    somethingAsync(function() {
      this.b();//这里this“可能”不对
    });
  }
  b() {
  }
}
//如果是类组件,则改为
class App {
  a() {//改法1,copythis
    let that = this;
    somethingAsync(function() {
      that.b();
    });
  }
  a() {//改法2,lambda
    somethingAsync(() => that.b());
  }
  b() {
  }
}
//如果是函数组件,则没这问题
function App(props) {
  let a = function() {
    somethingAsync(function() {
      b();//b不可能不对
    });
  }
  let b = function() {}
}

- 类组件的数据持有会引出js对象拷贝问题。如有一个回调会延迟3秒后进行toast提示一个信息,信息来源于props中,直接写的方法有可能会有异步问题,需要进行拷贝:
class App {
  callback = () => {
    setTimeout(() => toast(this.props.message, this.props.duration), 3000);//这里this.props在3s后可能已经变更了
  }
}
//【改为】
class App {
  callback = () => {
    let copyProps = this.props;
    setTimeout(() => toast(copyProps.message, copyProps.duration), 3000);//这里copyProps是3s前拷贝的所以没问题
  }
}

但是偶尔会疏漏的是,react对于props和status的变更处理,虽然是生成新的对象(因为需要进行dif计算),但是总归还是浅拷贝,如果需要的数据有内部包裹,风险依旧:
class App {
  callback = () => {
    let copyProps = this.props;
    setTimeout(() => toast(copyProps.toastData.message, copyProps.toastData.duration), 3000);//虽然copyProps是拷贝了,但是toastData是一个引用,因此有可能依旧发生变更
  }
}
//【改为1】
class App {
  callback = () => {
    let toastMeaasge = this.props.toastData.message;
    let toastDuration = this.props.toastData.duration;
    setTimeout(() => toast(toastMeaasge, toastDuration), 3000);//最合理的方案是无论如何拷贝实际的数据,这样最保险,同时也是最繁琐
  }
}
//【改为2】
// - 从数据流层保障所有props和status生成都是deepcopy

而对于函数组件来说,每次function的调用就像每次类组件render的调用入参,props一定是当前渲染触发的临时数据,不会有this则不会有改变风险,因此也不需要copyProps就完全规避这个问题了。同时类组件中异步调用如果全部通过hook进行数据维护和扭转,则不会把数据使用暴露到异步代码中,更安全。

- 闭包内存泄漏问题,在类组件会严重一些,这个问题是之前https://lrdcq.com/me/read.php/96.htm讨论到的闭包会导致被引用的上下文所有对象保持持有到最长生命周期。虽然这个理由看起来很牵强,但是很直观的判断:类组件闭包可引用到的是整个组件,而函数组件闭包可引用到的只是当前渲染状态切面,因此如果发生页面小时后异步闭包导致内存泄漏,类组件可能导致整个组件甚至整个app泄漏也大概率导致业务异常,而函数组件几乎没有这个风险。

4. 观点【支持类组件】:业务复杂的组件,或者内部状态复杂(比如有非常多的触发式阶段式动效)的组件,类组件更友好。这一点也相对主观,但是确实得到团队成员普遍认可。

- 具体举例子的话,针对的是实际业务抽象上内部状态特别多的pure组件,实际我们项目中遇到的一个例子是一个双向滚轴跑马灯的react-native的prueview组件,由于rn中高性能动画的控制是命令式控制,因此需要大量的生命周期出发:在组件挂载/卸载/props重置的时候进行动画的播放/暂停/重制,与对应的内部status做状态管理与render片段组装,同时因为跑马灯的缘故也涉及到了布局测量。这样的代码当然是通过类组件完成的,并且在我们review相关代码之后,判断转移到函数组件的成本:代码量不会明显减少,本来pureview组件也以动效逻辑为主,方法转移到函数不会有什么改变;可读性变差,生命周期和stategetter/setter全部转换为hook之后,要么通过拆分文件否则全部堆在函数主体/一个代码块中,并且含义通过hook进行了模糊,反倒可读性变差了;由于函数组件是切面状态,动画控制的全局status新增缓存层来维护状态改变,复杂度变高。结论上,确实不适合函数组件。

- 也有观点说业务逻辑极度复杂的组件也不适合函数组件,不过目前实际项目代码中真找不到如此复杂的业务逻辑。唯一实际存在高复杂度的业务逻辑的代码是一个web3d领域编辑器,但是那种复杂的场景也不是用react或者vue这样的框架去构建控件的了。目前整个互联网趋势均会把复杂的业务逻辑放在后端,前端的控件联动/等逻辑均相当标准化,因此确实涉及不到这样的挑战组件。

5. 观点【支持类组件】:类组件对移动端容器相关API暴露更友好。由于团队中解决的主要场景是移动端并且包含一大部分针对移动端容器内的应用开发,因此与容器能力的组合使用优良性也是相当关键的。有几个特殊的容器能力均是类组件更友好:

- 容器生命周期:由于在app的web/rn容器的页面依托于native页面,因此一般来说需要暴露页面生命周期到react,即viewappear(类似于onresume,页面返回前台钩子),viewdisappear(类似于onpause,页面离开前台钩子),viewdestroy(页面销毁钩子,rn安卓封装为onbackpress事件)。同时由于在app中,也需要关注app的常用生命周期如appforeground(应用切换到前台),appbackground(应用切换到后台)。这些生命周期多多少少都会在业务逻辑中用到,当然其中占比最大的是viewappear,用作页面回到前台的数据刷新时机。

- native设备状态/事件:比较常用的像是home/back这样的安卓物理按键事件,和组件相关的也包括窗口状态,是否有输入键盘和键盘高度,键盘变化事件,可能涉及到页面流程和页面排版的信息都有可能在组件中使用。虽然也也可以是通过虚拟组件(provider组件)来提供数据,但是更常见的做法还是一个api解决。而相关信息有明确生命周期与状态的类组件处理起来确实方便很多。

- 结合以上两点,举一个实际场景的例子,我们一个页面需要在页面在前台时,监听物理按钮home的点击并进行一个特殊操作,当然页面如果被切换到后台就应该啥也不干。如果使用函数组件对这个流程进行封装,需要封装一个生命周期useXxx来维护页面的前后台切换状态,需要一个useYyy来提供home事件的数据(由于不是一个状态,有可能封装成一个组件更合适),再通过逻辑处理进行state逻辑绑定。而使用类组件,暴露明确的生命周期,虽然看起来代码low一些,却直接了当代码可读性高多了。
//类组件
class App {
  listener;
  onViewAppear() {//封装过的生命周期
    listener && removeHomeListener(listener);//事先卸载
    listener = addHomeListener(()=>{//封装过的home键听与卸载
      dosomething();
    });
  }
  onViewDisappear() {
    removeHomeListener(listener);
  }
}
//函数组件
function App(props) {
  const [appear] = useAppearState();//封装过的ViewAppear状态
  const [listener, setListener] = useState(null);
  useEffect(() => {//useEffect进行appear处理,可封装
    if (appear) {
      listener && removeHomeListener(listener);
      setListener(addHomeListener(()=>{
        dosomething();
      }));
    } else {
      removeHomeListener(listener);
    }
  }, [appear]);
}

6. 观点【支持相当】:开发者的背景会有明显偏好函数组件或者类组件。一开始也说了团队成员是web背景的成员与native背景的成员混合而成的,而明显web背景的成员整体开发习惯上更喜欢函数组件,而native背景的成员更习惯类组件。

- 确切的说,更大的影响在于native背景的gui开发均为面向对象的,包括目前大火的swiftui与flutter的生命式gui的核心也是面向对象的管理,因此无状态的函数式编程根本不习惯。而对于web开发者来说,从上古时代就更习惯无行为的纯描述性ui,也习惯通过闭包等函数式方式而不是类这种方式去进行状态管理与传递,因此函数式组件的接受度非常高。笔者认为这里的关键是对应用状态维护的方案的理解,习惯面向对象的成员默认对象的属性维护的对象当前状态,而习惯线性/函数式编程的人认为状态应该是流动的数据的一部分。

- 因此这个观点可能对不同的团队会有不同的解答,如果回答问题的团队是主要web背景,那这一条应该是【支持函数组件】,而如果回答团队主要是native这类的前端背景,这一条是【支持类组件】。

结论与最佳实践

汇总以上观点之后,core team的讨论也是相当激烈,虽然会有一种说法是“公说公有理婆说婆有理的技术选型应该以老板为准”,但是明显老板拍板也没法得到符合团队现状业务现状的情况下,我们也需要拿出结论。结论是:

1. 除了后文的特殊规则之外,正常组件开发使用函数组件。

- 无论怎么说,根据631原则,业务中90%的组件应该都不属于复杂组件和有特殊业务场景(比如强依赖native能力)的组件,而这些组件使用类组件并没有任何必要性。而除去必要性之外,函数组件还是有相当的优点的,因此函数组件作为优先使用方案没问题。
- 其中还有团队接受度问题上,由于实际方案是函数和类组件均存在,因此也无所谓了,大家都需要熟练用起来。

2. 在客户端容器中,App组件必须使用类组件。

- 在客户端容器中,react app组件等于native的页面组件(Activity/Fragment or Controller),因此以这个组件承接native的生命周期/页面事件是最合的,并且也为native到js侧的gui关系起到承上启下的作用。因此定义客户端容器中的App组件使用作为复杂生命周期承接更自然的类组件,并且为其准备对应的类组件容器辅助工具。

3. 对于pureview组件,经过设计review后,可以使用类组件。

- 这一条有一个前提是:在团队中,所有的pureview组件均以公共组件为目标进行打造,同时所有的公共组件均要经过设计review。因此在设计review中多人共同判断组件复杂度足够并且类组件为组件的可维护性带来收益,可以选择类组件进行开发。当然对于pureview组件,质量/性能/可维护性相对于效率是更优先的,所以确实有选择的余地。

4. 业务组件,如果团队中最优秀的成员review仍然有极高的复杂度与质量风险,经过review后,可以自由选择技术方案。

- 这里的技术方案不限于类组件/函数组件,也包括是否需要使用react/purejs,微应用构建方案等。当然这样的项目应该也是极少数极少数了。

容器辅助

上面讨论到的内容中,来来回回有一个关键点,即在客户端容器中的js页面开发(react / react-native),需要客户端容器的大量辅助支持以大同native与js的gui开发的边界,让高自由度的动态化的移动端开发变为可能,因此基于上面的结论,同时对几个关键点进行了容器辅助建设。

1. 生命周期事件队列与事件展开

对于传统native-js容器的通信机制,目前业界基本上是基于https://github.com/marcuswestin/WebViewJavascriptBridge 这个方案的2.x版本改建的。这个方案基本上就是iframe通信的标准方式,特点包括:a. 低延迟,iframe的正向通信方式与evaljs的方向通信方式几乎是同步的,并且对js线程不会有阻塞效应(相对于confirm之类的方案);b. 实时通信,类似于消息互发的机制对应的如果一段的listener不在线,会丢消息,同样由于无host感知,重试有风险。

这套方案在命令式api调用时可用性完全ok,但是在native事件上,遇到一些问题即:native事件到js侧时,js状态未知,结果上可能会有丢消息的情况。考虑到如下时间轴与native viewappear/viewdisappear两个事件:
點擊在新視窗中瀏覽此圖片

JSCore侧的生命周期和native页面的生命周期完全不对应,一般来说等于或者晚于pagelaunch开始(当然如果进行过一些预加载优化或者重用机制,会早于),因此js launch完成,bridge的nativecall function ready的时间,有一定概率晚于或者早于页面viewappear的时间。对于页面重建,jscore被回收的disappear后恢复的场景(Android only),也会有这样的问题。通信机制的api调用,一般会通过ready后互相发送ready信息来调用如:
Bridge.ready(() => Bridge.callApi('api', data));
//native同理

但是生命周期事件发送也基于高性能/实时性的原则,一般不能这么玩,而是建立一套事件队列机制来解决这个问题。基本设计如下:
點擊在新視窗中瀏覽此圖片

a. 通过事件队列,顺序扫描/发送/ack确认的机制来保障每个事件发送到位无丢失/事件有序,并且性能消耗不会过分。

b. 辅助以消息机制的ready,可以使得大概率的命中理想事件发送流程。

c. 模拟浏览器的event loop机制,本事上是将双方unready时段是为对应事件线程卡顿的情况,事件循环实质为自旋锁等待,来实现事件发送有效性。

事件发送ok之后,在js侧会对事件进行展开和分发。一般来说事件分发遵循随机平等的原则,不过事件分发侧也有一个队列进行有序展开,事情会方便很多。因此作为js侧react容器App框架的一部分,在接受到native事件队列发送的事件后,在js侧进行了一个顺序展开的事件分发,分发目标包括:

a. 优先分发全局事件,比如一些全局工具库,网络拦截器埋点拦截器等切面代码,他们的区别是react组件是有明确的生命周期的,比如页面重新打开app会被销毁,重新打开时重建,而全局的js代码在jscore启动后就会一直存在,这些代码作为基础代码比组件拥有更优先的事件权限的话,能方便的解决很多数据准备与同步问题。

b. 其次是页面即App组件进行分发,并且和app组件进行深度绑定,通过组件生命周期传递。
點擊在新視窗中瀏覽此圖片

举一个实际在使用的例子的话,这个机制用于在页面之间同步用户登陆token。当前页面一开始再未登陆状态,页面进入后台之后,在其他界面进行了登陆,再返回该页面。该页面的逻辑是在每次viewappear的时候(onresume)刷新接口来同步当前状态下的最新数据。因此每次viewappear事件后,网络拦截器用户模组从native获取最新的token以请求接口,埋点拦截器刷新最新的用户id与设备id做绑定,页面里面在页面onViewAppear生命周期里出发api刷新逻辑。按顺序执行这个流程,页面就能很好的运作下去了。

2. react应用跨容器数据同步

作为容器应用,另一个问题就是多个容器之间的react应用如何进行数据同步并且与数据流打通。这里有几个类比:多个容器实例之间通信其实类似于浏览器间多个window之间同一个网站的不同应用之间通信,类似于window.postMessage这样的主动发送消息的通信方式自然是考虑范围内的;同样,通过同域的应用通过localstorage进行通信也是常有的事;不过不同于浏览器,一般来说同一个应用的内部容器内的应用应该是相互信任的,因此安全问题比较放心不用浏览器一样严格的边界限制,可以做到更多的事情。对应的,native容器的通信方式也有自己的特点,因此可用的通信同步方式与适用场景也更多。

a. 类似于postMessage,我们在bridge提供广播api,publish(name, data),subscribe(name, callback)这样,并且将api广播与native广播(Broadcast, NSNotification)打通。比如所有页面的通用js模块都见听“app_user_change”广播,任意界面如果发生了登出/登入行为则将更新的信息广播到app_user_change,所有页面包括native界面一起刷新这样的行为。这个方法实时性高但是性能消耗大,只适合关键业务流程。

b. 打通容器storage,如果考虑到web与react-native容器,在bridge中新增getstorage/setstorage桥。当然如果是通过native桥去完成,桥大概率是异步桥了(rn提供同步桥方案,js中通过特殊桥实现也能实现同步桥,但是有跨线程阻阻塞/死锁风险,所以一般不会使用),用起来相比于web中的localstorage还是更接近a方案。同时由于storage方案陷入会成为一个高频率读的方案,因此桥native实现的响应性能会成为一个考验。实际在实现storage桥时,都会做3层缓存与内存映射,其中mmkv(https://github.com/Tencent/MMKV)即可作为一个非常优秀的实现方案。storage这里还有一个可以做的方案是跨JSCore共享JSValue,但是目前业界还没有完全可行的方案(有通过SharedWorker来实现的曲线救国的方案)。这个方法需要主动触发没有实时性,但是storage的缘故准确性接近100%,就算回收重用场景或者crash场景也不会制造出异常数据,是在是保险的做法。

c. 考虑到容器的情况,还有一种native常用通信方式即地址化。对于web来说,地址都是打开一个新页面,但是对于native来说,也会用地址去唤起一个页面(onNewIntent),因此地址call也是一个页面间通信的方式。onNewIntent对于native来说是一个独立的生命周期,但是对于js来说确实没有独立生命周期的必要。intent本质上是页面组件的附属属性,因此一个做法是,为我们上文封装过的react页面组件新增一个事件即onIntentPayloadChange,当intent发生变化时触发,页面可以根据变化的url携带的数据执行相应操作。还有一个做法是直接在viewAppear生命周期中携带入参,类似于微信小程序中onShow能拿到navigateBack携带的信息那样。这个方法地址化即路由化,也对应的是配置化,如果相关行为有配置化诉求的化,只能这个方法了。
关键词:react , 函数组件 , 类组件
logo