2023-09-04
ReactJS
00
请注意,本文编写于 290 天前,最后修改于 144 天前,其中某些信息可能已经过时。

目录

对编程的一些思考
高阶函数
高阶组件
高阶组件与装饰器
注意事项:
属性代理
页面复用
权限控制
渲染劫持
stateless
反向继承
性能上报
卡片曝光埋点
异步组件
示例
手写异步组件
组件复合
错误边界
埋点上报
React18新特性
自动批处理
batching 对hooks 及 class的影响
任务优先级
常见问题
为什么要使用Hooks?

本文迁移自老博客。本篇文章系统总结了React中的组件技术,并总结了一些小而美的案例如页面复用权限控制渲染劫持异步组件错误边界埋点上报 等。

先粘一下本文所有案例代码

本文所有案例Demo效果 新窗口打开

React中的组件化技术

  • JSX语法
  • 组件与Props (class组件/function组件)
  • 高阶函数
  • 状态管理 Context redux mobx
  • Ref转发
  • PropTypes类型检查

对编程的一些思考

为什要讲编程思想?

  • 我们每天都在写代码开发软件,如同一万个读者,有一万个哈姆雷特,同一个功能我们每个人的书写的代码都不一样。那么怎样优雅的书写代码可以降低软件的开发维护成本,这其中编程思想就非常重要。
  • 组件化技术是编程思想中重要的一部分。
  • 软件工程:软件工程是一门学科,
    • 用工程化方法构建
    • 维护有效、实用和高质量的软件
  • JS的发展背景
    • JS诞生最初是解决表单校验的问题,缓解网络传输太慢的问题(二十多年前的网络非常慢,用户填了半天内容,结果提示表单校验不通过,要重新填写)
    • 因此JS的设计也非常简单,单线程(没有锁和事务的概念),无模块化
  • 无模块化时代的代码质量问题
    • 全局变量的灾难
    • 函数命名的冲突
    • 依赖关系不好管理
  • 模块化的演进
    • 模块化:解决团队中软件开发分工协作的问题,首要确保彼此的代码互不影响
    • IIFE: 匿名函数立即执行表达式,利于函数作用域(又称局部作用域)解决变量命名冲突的问题
    • CommonJS: 09年nodejs项目诞生,标志着 “Javascript模块化”正式诞生
      • 服务端比客户端环境复杂,必须考虑模块化
      • 客户端对应的是单一用户环境,服务端是多用户环境,要考虑隔离环境、内存泄漏等问题
    • AMD: 异步模块定义
      • nodejs require同步加载 (服务端资源是本地加载,无网络问题),而客户端就要考虑网络问题
    • UMD: 兼容CommonJS和AMD两种规范
    • ESM: JavaScipt模块化规范的标准
      • 读一遍(编译)就能知道模块的依赖输入和输出
      • 编译时加载好处是静态分析
  • 组件化:在框架层面上的模块化,更好的实现逻辑提取与复用
    • 比如一个弹出层,你需要分别将HTML/CSS/JS单独提取成公共方法
    • 有了组件化技术可以将HTML/CSS/JS聚合一起封装成模块化。
  • 设计模式: 解决一类问题最受认可的方案
    • 该词来源于建筑领域,建一栋大楼需要提前设计好图纸
    • 五大原则和20多种设计模式。
  • 函数式编程
    • 首先JS是一门多范式编程语言, 编程范式 POP OOP 函数式编程
    • 函数式编程:强调以函数使用为主的软件开发风格
    • 有一些概念:纯函数、幂等、副作用、高阶函数
    • 无论Vue还是React都在朝着函数式编程的方向发展,function组件、钩子函数就是一个佐证
    • 因为函数式编程更容易写出关注点分离的代码,实现模块的高内聚低耦合
  • 高阶函数
    • 操作函数的函数,它接受函数作为参数或返回一个函数。
    • Redux将高阶函数玩的炉火纯青。
  • 总结宏观层面逻辑提取与复用的方案
    • 模块化、组件化
    • npm包
    • 微前端
    • 源码共享方式
      • git submodule
      • 配置构建工具

高阶函数

高阶函数在 《JavaScript权威教程》一书中的定义: “所谓的高阶函数就是操作函数的函数,它接收一个或多个函数作为输出,并返回一个新函数”

那么高阶函数有什么用处呢?我们先看一个示例

javascript
// 假设有这样一种计算器,输入参数,输出一个对象(包含这个参数的计算情况) // 这里只是就算参数的个数 function calculator(...nums) { // 计算参数之和 return { arr: nums, count: nums.length } } console.log(calculator(1,2,3)); // { arr: [ 1, 2, 3 ], count: 3 } // 现在有个需求要求扩展该函数, // 除了计算参数个数,还要计算这些参数的求和与乘积 // 这就是一个高阶函数 增强calculator函数的功能 function enhancer(base, funcs) { return (...args) => { const state = base(...args); funcs.reduce((a, b) => a(b(state))); return state } } function multi(result) { result.multi = result.arr.reduce((a, b) => a*b); return result; } function sum(result) { result.sum = result.arr.reduce((a, b) => a+b); return result; } const complexCalculator = enhancer(calculator, [multi, sum]); console.log(complexCalculator(1,2,3)); // { arr: [ 1, 2, 3 ], count: 3, sum: 6, multi: 6 }

当然有人会说, 我直接写一个方法岂不是更通俗易懂?

javascript
function complexCalculator(...nums) { // 计算参数之和 return { arr: nums, count: nums.length, sum: nums.reduce((a, b) => a+b), multi: nums.reduce((a, b) => a+b), } } console.log(complexCalculator(1,2,3));

的确是通俗易懂了,但这样的话complexCalculator函数就不具有扩展性和灵活性,当功能越来越多你就会发现前者的优势。

我们可以再发散的想一下,前者可以封装成一个库,提供给别人, 别人无需修改该库也能扩展它的功能,功能就具有了想象力,这就是高阶函数的魅力。

其实上述案例是我根据redux原理杜撰的,如果说这就是redux原理肯定有人喷我。redux原理肯定是比这种情况复杂一些的,但也不是太难。

我们在想一下,如果一个高阶函数B,它的返回值是C函数,C函数执行结果可以还是一个函数,and so on ,,, B作为参数传给另一个组件A,A组件中调用了B组件传入自身,生成C函数继续调用传入自身(A函数)的其他参数,用这种高阶函数的形式,已经实现了两个函数之间的通信,进而B函数扩展了A函数的能力。

这样是不是距离redux原理有更近一步了?

这里是知识提一个引子,说明高阶函数的想象力,具体redux的实现详见我的下一篇文章。 下面我们来看一下高阶组件

高阶组件

⾼阶组件是参数为组件,返回值为新组件的函数。

  • 在react中奉行一切皆组件,为了提高组件复用率,可测试性,就要保证组件功能单一性;
  • 但是若要满足复杂需求就要扩展功能单⼀的组件,在React里就有了HOC(Higher-Order Components)的概念。
  • 高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。

使用HOC的优点

  1. 抽取重复代码,实现组件复用:相同功能组件复用
  2. 条件渲染,控制组件的渲染逻辑(渲染劫持):权限控制。
  3. 捕获/劫持被处理组件的生命周期,常见场景:组件渲染性能追踪、日志打点。

高阶组件与装饰器

javascript
import React, {Component} from "react"; const foo = Cmp => props => { return ( <div className="border"> <Cmp {...props} /> </div> ); }; const foo2 = Cmp => props => { return ( <div className="greenBorder"> <Cmp {...props} /> </div> ); }; class Child extends Component { render() { return <div>Child</div>; } } const Foo = foo2(foo(foo(Child))); export default class HocPage extends Component { render() { return ( <div> <h3>HocPage</h3> <Foo /> </div> ); } }

高阶组件(HOC)是 React 中⽤于复⽤组件逻辑的一种⾼级技巧。HOC ⾃身不是 React API 的⼀部分,它是⼀种基于 React 的组合特性而形成的设计模式。

装饰器的使用需要安装 @babel/plugin-proposal-decorators 并更新config-overrides.js配置。

注意事项:

不要在 render 方法中使用 HOC
React 的 diff 算法(称为协调)使⽤组件标识来确定它是应该更新现有⼦树还是将其丢弃并挂载新子树。 如果从 render 返回的组件与前一个渲染中的组件相同(===),则 React 通过将子树与新子树进行区分来递归更新⼦树。 如果它们不相等,则完全卸载前一个子树。

jsx
render() { // 每次调⽤ render 函数都会创建一个新的EnhancedComponent // EnhancedComponent1 !== EnhancedComponent2 const EnhancedComponent = enhance(MyComponent); // 这将导致⼦子树每次渲染都会进行卸载,和重新挂载的操作! return <EnhancedComponent />; }

这不仅仅是性能问题 - 重新挂载组件会导致该组件及其所有⼦组件的状态丢失。

属性代理

使用组合的方式,将组件包装在容器上,依赖父子组件的生命周期关系来;

  1. 返回stateless的函数组件
  2. 返回class组件
  • 操作props
  • 抽象state
  • 通过props实现条件渲染
  • 其他元素wrapper传入的组件

页面复用

jsx
export default function PropsProxy () { const [radio, setRadio] = React.useState(2) return <div> <label htmlFor="radio1">列表页面A</label> <input id="radio1" type="radio" value={1} checked={radio === 1} onChange={() => setRadio(1)}/> <label htmlFor="radio2">列表页面B</label> <input id="radio2" type="radio" value={2} checked={radio === 2} onChange={() => setRadio(2)}/> {radio === 1 ? <PageA/> : <PageB/>} </div> } const PageA = CommonPageList( (props) => props.list.map(it => <p>--{it}</p>), fetchListA ); const PageB = CommonPageList( (props) => props.list.map(it => <p>##{it}</p>), fetchListB ); function fetchListA() { return new Promise(resolve => { setTimeout(() => { resolve([1,2,3]); }, 1000) }) } function fetchListB() { return new Promise(resolve => { setTimeout(() => { resolve(['A', 'B', 'C']); }, 300) }) } function CommonPageList (WrappedComponent, fetchingMethod, defaultProps = { loading: '加载中。。。', emptyText: '没有数据' }) { return class extends React.Component { state = { list: [], isRequesting: true, } async componentDidMount() { const list = await fetchingMethod(); this.setState({ list, isRequesting: false }); } render() { if(this.state.isRequesting) { return <p>{defaultProps.loading}</p> } if(!this.state.list.length) { return <p>{defaultProps.emptyText}</p> } return <WrappedComponent {...defaultProps} {...this.props} list={this.state.list} /> } } }

权限控制

javascript
import React from 'react'; // 属性代理 通过渲染劫持实现权限控制 const apiGetPermission = async () => new Promise(resolve => { setTimeout(() => { resolve(true) }, 1000); }); function PermissionControl(Comp) { return function(props){ let [state, setData] = React.useState({ hasPermission: false, isRequesting: true, }); apiGetPermission().then(res => { setData((state) => ({ ...state, hasPermission: res, isRequesting: false, })) }); if(state.isRequesting) return <p>页面加载中...</p> return state.hasPermission ? <Comp {...props}/> : <h2>您访问的页面不存在</h2> } } export default PermissionControl(Comp); function Comp(props) { return <h2>个人中心-{props.children}</h2> }

渲染劫持

javascript
function AddBorder(Comp) { return (props) => { return <div style={{ border: '6px solid #ccc', display: "inline-block" }}> <Comp {...props}/> </div> } } // @AddBorder // 装饰器写法 function Box(props) { return <div style={{ backgroundColor: 'red', color: 'white', display: 'flex', height: '200px', width: '200px', alignItems: 'center', justifyContent: 'center', }}>{props.children}</div> } // 属性代理 劫持渲染示例 export default function RenderHijack(props) { // return <Box>this is box</Box> const Comp = AddBorder(Box); return <Comp>this is box<br/>{props.children}</Comp> }

stateless

javascript
import React from 'react'; // 属性代理之stateless function WrapperInput(WrappedComponent) { return class extends React.Component { state = { text: '' } constructor(props) { super(props); } handleClick = (e) => { this.setState({ text: e.target.value }) } render() { const newProps = { onChange: this.handleClick, value: this.state.text, } return <div> <WrappedComponent {...newProps} {...this.props}/> <p>{this.state.text}</p> </div> } } } export default function() { const MyInput = (props) => { return <input {...props}/> } const Comp = WrapperInput(MyInput) return <> <p>属性代理之stateless实现双向数据绑定</p> <Comp type='text'></Comp> </>; }

反向继承

使用一个函数接受一个组件作为参数传入,并返回一个继承了该传入组件的类组件,且在返回组件的 render() 方法中返回 super.render() 方法

jsx
const HOC = (WrappedComponent) => { return class extends WrappedComponent { render() { return super.render(); } } }
  1. 允许HOC通过this访问到原组件,可以直接读取和操作原组件的state/ref等;
  2. 可以通过super.render()获取传入组件的render,可以有选择的渲染劫持;
  3. 劫持原组件生命周期方法
  • 读取/操作原组件的state
  • 条件渲染
  • 修改react树

性能上报

jsx
import React from 'react'; // class组件反向继承 实现性能上报 function PerformanceMonitoring (Comp) { return class extends Comp { state = { start: Date.now(), time: 0, } constructor(props) { super(props); } componentDidMount() { super.componentDidMount?.(); this.setState({ time: Date.now() - this.state.start }); console.log('todo 性能上报') } render() { return <> {super.render()} <h2>组件渲染耗时{this.state.time}ms</h2> </> } } } class HeavyComp extends React.Component { state = {} constructor(props) { super(props); } static getDerivedStateFromProps(props, state) { const total = Fibonacci(props.count ?? 35); return { total } } render() { return <h2> Fibonacci (n) = {this.state.total}</h2> } } export default PerformanceMonitoring(HeavyComp);
jsx
class HeavyComp extends React.Component { state = {} constructor(props) { super(props); } static getDerivedStateFromProps(props, state) { const total = Fibonacci(props.count ?? 35); return { total } } render() { return <h2> Fibonacci (n) = {this.state.total}</h2> } } export default PerformanceMonitoring(HeavyComp);

卡片曝光埋点

属性代理和反向继承对比

  1. 属性代理:从“组合”角度出发,有利于从外部操作wrappedComp,可以操作props,或者在wrappedComp 外加一些拦截器(如条件渲染等);
  2. 反向继承:从“继承”角度出发,从内部操作wrappedComp,可以操作组件内部的state,生命周期和render等,功能更加强大;

异步组件

随着项目的扩张,代码包也随之增长, 为避免因体积过大导致加载时间过程 React16.6中引入了React.lazy和React.Susperse两个API, 再配合动态import()语法就可以实现组件代码打包分割和异步加载。

示例

jsx
/** * 异步组件 * 随着项目的扩张,代码包也随之增长, * 为避免因体积过大导致加载时间过程 * React16.6中引入了React.lazy和React.Susperse两个API, * 再配合动态import()语法就可以实现组件代码打包分割和异步加载。 */ import React, {lazy, Suspense} from 'react'; import { useEffect } from 'react'; const About = lazy(() => import(/* webpackChunkName:'about' */ './About')); // const About = lazy( // () => new Promise(resolve => { // setTimeout(() => { // resolve(import(/* webpackChunkName:'about' */ './About')) // }, 500); // }) // ); export default function (){ const [hasLoad, setHasLoad] = React.useState(false); return hasLoad ? <LazyComp/> : <button onClick={ () => setHasLoad(true) }> 加载异步组件 </button> } function LazyComp (){ return <div> <Suspense fallback={<div>loading</div>}> <About/> </Suspense> </div> }

手写异步组件

Suspense组件需要等待异步组件加载完成再渲染异步组件的内容。

  1. lazy wrapper住异步组件,React第一次加载组件的时候,异步组件会发起请求,并且抛出异常,终止渲染;
  2. Suspense里有componentDidCatch生命周期函数,异步组件抛出异常会触发这个函数,然后改变状态使其渲染fallback参数传入的组件;
  3. 异步组件的请求成功返回之后,Suspense组件再次改变状态使其渲染正常子组件(即异步组件);

组件复合

类似vue中的插槽技术

错误边界

React V 16中引入,部分UI的JS错误不会导致整个应用崩溃; 错误边界是一种 React 组件,错误边界在 渲染期间、生命周期方法和整个组件树的构造函数 中捕获错误,且会渲染出备用UI而不是崩溃的组件。

jsx
import React from 'react'; export default function() { return <div> <ErrorBoundary> <h2>APP1</h2> <Child></Child> </ErrorBoundary> <ErrorBoundary> <h2>APP2</h2> <Child></Child> </ErrorBoundary> </div> } class Child extends React.Component { state = { count: 1 } render() { if(this.state.count === 3) { throw new Error('I crashed!'); } return <> <p>{this.state.count}</p> <button onClick={ () => this.setState({count: this.state.count + 1}) }>add</button> </> } } class ErrorBoundary extends React.Component { state = { hasError: false, } static getDerivedStateFromError(error) { // 更新UI return { hasError: true, } } componentDidMount(error, errorInfo) { // todo 这里进行错误上报 // 你同样可以将错误日志上报给服务器 console.log(error, errorInfo); } render() { if(this.state.hasError) { return <h1>Something went wrong.</h1>; } return this.props.children; } }

埋点上报

jsx
import React from 'react'; export default function () { return <ExposureDisappear className="container" exposure={e => console.log('上报A页面曝光')} disappear={e => console.log('上报A页面消失')}> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> <h2>AAA</h2> </ExposureDisappear> } class ExposureDisappear extends React.Component { constructor(props) { super(props); this.id = `${Date.now()}-${Number.parseInt(Math.random() * 1000)}`; } componentDidMount() { this.el = document.getElementById(this.id); this.observer = new IntersectionObserver(([inst]) => { if(inst.intersectionRatio === 0) { this.props?.disappear(); } else { this.props?.exposure(); } }, { root: document.documentElement, rootMargin: '0px', threshold: [0] // 目标元素与根元素相交程度触发cb - [0 - 1] }); this.observer.observe(this.el); document.addEventListener('visibilitychange', this.visibilitychange); } visibilitychange = ()=> { setTimeout(() => { if(document.visibilityState !== 'visible') { this.el.setAttribute('style', 'display: none'); } else { this.el.setAttribute('style', ''); } }, 20) } componentWillUnmount() { this.observer?.disconnect(); this.props?.disappear(); document.removeEventListener('visibilitychange', this.visibilitychange); } render() { return <div id={this.id}> {this.props.children} </div>; } }

React18新特性

自动批处理

  • 将多个状态更新合并成一个重新渲染以取得更好的性能的一种优化方式
  • V18前 默认不batching的scene (setState在哪些场景下是同步的)
    • promise
    • setTimeout
    • 原生事件处理
js
function Comp() { function handleClick() { setCount(c => c + 1); // Does not re-render yet setFlag(f => !f); // Does not re-render yet // React will only re-render once at the end (that's batching!) } function handleClick2() { fetchSomething().then(() => { //  react< 18 会渲染两次 setCount(c => c + 1); setFlag(f => !f); }); } // 若不想batching ? function handleClick3() { fetchSomething().then(() => { flushSync(() => { setCounter(c => c + 1); }); // React has updated the DOM by now flushSync(() => { setFlag(f => !f); }); // React has updated the DOM by now }); } return <> <button onClick={handleClick}>Next</button> </> } import {flushSync} from 'react-dom'

batching 对hooks 及 class的影响

js
handleClick = () => { setTimeout(() => { this.setState(({ count }) => ({ count: count + 1 })); console.log(this.state); // V18前 { count: 1, flag: false } // V18中 { count: 0, flag: false },除非使用flushSync this.setState(({ flag }) => ({ flag: !flag })); }); };
  • 在一些react库中,如react-dom, unstable_batchedUpdates 实现类似功能
js
import { unstable_batchedUpdates } from 'react-dom'; unstable_batchedUpdates(() => { setCount(c => c + 1); setFlag(f => !f); });

任务优先级

  • 标记渲染的优先级,提高用户交互
  • 举例来说:input的value更新,请求了接口有3W个item更新。然而这种多数据让页面无法及时响应,也让用户输入感觉很慢。
  • V18前 update的优先级一样
  • V18 支持优先级手动设置
js
// 紧急的更新:展示用户的输入 startTransition(() => { setInputValue(e.target.value) }); // 非紧急的更新: 展示结果 setContent(e.target.value);

与 setTimeout 的区别

  1. setTimeout是延时执行,startTransition是立即执行的,传给startTransition的函数是同步运行,但是其内部的所有更新都会标记为非紧急,React将在稍后处理更新时决定如何render这些updates,这意味着将会比setTimeout中的更新更早的被render
  2. 另一个重要区别是startTransition中的更新是可中断的
  • LazySSR
  • 并发模式

常见问题

为什么要使用Hooks?

Hooks是react16.8以后新增的钩子API; 目的:增加代码的可复用性,逻辑性,弥补无状态组件没有生命周期,没有数据管理状态state的缺陷。

  1. 开发友好,可扩展性强,抽离公共的方法或组件,Hook 使你在无需修改组件结构的情况下复用状态逻辑;
  2. 函数式编程,将组件中相互关联的部分根据业务逻辑拆分成更小的函数;
  3. class更多作为语法糖,没有稳定的提案,且在开发过程中会出现不必要的优化点,Hooks无需学习复杂的函数式或响应式编程技术;

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!