React 17在2020年发布了,虽然没有带来太多用户可见的新功能,但在底层做了很多重要的改进,比如事件委托的变化、JSX转换的升级、并发模式的准备等。这些变化,在升级的时候,可能会遇到一些坑。我在几个项目中,升级了React 17,踩了不少坑,也积累了一些实战经验。今天,就把React 17的新特性、踩坑总结和实战经验,分享出来,希望对大家有所帮助。
先说说背景。我做前端开发已经好几年了,React是我最常用的前端框架,从React 15、16,一直用到现在的17,每个版本的升级,我都经历过。React 16的时候,带来了Fiber架构、Hooks、Context API等重大特性,升级的时候,也踩了不少坑。
React 17发布的时候,官方说,这是一个"无新特性"的版本,主要是为了未来的并发模式做准备,同时做了一些底层的改进和清理。虽然没有用户可见的新功能,但底层的变化,还是不少的,升级的时候,也不是一帆风顺,踩了很多坑。
今天,就详细聊聊React 17的新特性、升级踩坑、以及实战经验。
一、React 17的核心变化
虽然React 17被称为"无新特性"的版本,但实际上,还是有不少重要的变化,这些变化,主要集中在底层,是为了未来的并发模式和渐进式升级做准备。
1. 事件委托的变化
这是React 17最重要的变化之一。在React 16及之前的版本,React会把所有的事件,都委托到document上,也就是说,不管你在哪个元素上绑定了事件,最终,事件处理函数,都是在document上执行的。
这样做的好处是,实现简单,性能好,因为不需要在每个元素上绑定事件监听器。但也有一些问题:
- 和其他库冲突:如果页面上还有其他库,也在document上绑定了事件,就可能会有冲突,比如,React的事件处理函数,调用了stopPropagation,但因为事件已经冒泡到document了,其他库的事件处理函数,还是会执行。
- 和React版本共存困难:如果页面上有多个版本的React,它们的事件,都委托到document上,就会有冲突,很难共存。
- 和Shadow DOM不兼容:在Shadow DOM里,事件的冒泡,和普通的DOM不一样,委托到document上,会有问题。
React 17,把事件委托,从document,改成了渲染React应用的根节点(root node)。也就是说,React会把事件,委托到你调用ReactDOM.render()的那个DOM元素上,而不是document。
这样做的好处是:
- 更容易和其他库共存:因为事件只委托到根节点,不会影响到根节点之外的元素,和其他库的冲突,就少了很多。
- 多版本React更容易共存:不同版本的React,渲染在不同的根节点上,事件委托在各自的根节点上,互不干扰,就可以在一个页面上,同时运行多个版本的React,方便渐进式升级。
- 和Shadow DOM更兼容:事件委托在根节点上,和Shadow DOM的配合,更好。
这个变化,对于大部分应用来说,是无感的,因为大部分应用,都是渲染在一个根节点上,事件委托在根节点还是document,效果是一样的。但对于一些特殊的场景,比如,和其他库混用、多版本React共存、使用Shadow DOM等,就会有影响。
2. JSX转换的升级
在React 17之前,JSX的转换,是把JSX语法,转换成React.createElement()的调用,所以,每个使用JSX的文件,都必须import React,不然就会报错,因为React.createElement找不到。
React 17,配合新的JSX转换(@babel/plugin-transform-react-jsx的新版本,或者TypeScript 4.1+),不再需要把JSX转换成React.createElement,而是自动从react/jsx-runtime里,导入jsx函数,所以,使用JSX的文件,不再需要import React了。
这个变化,有几个好处:
- 代码更简洁:不需要在每个文件里,都写import React from 'react'了,尤其是函数组件,代码更简洁。
- 减少打包体积:因为不需要导入整个React对象,只导入需要的jsx函数,打包体积,可能会小一些。
- 更符合标准:新的JSX转换,是按照JSX的标准规范来的,不依赖React,未来,其他框架也可以用同样的转换方式。
需要注意的是,这个变化,需要配合新的编译器(Babel 7.9+或者TypeScript 4.1+),如果编译器还是旧版本,还是需要import React的。而且,即使升级了React 17,如果不升级编译器,还是用旧的转换方式,也是可以的,React 17依然支持React.createElement。
3. 事件池的移除
在React 16及之前的版本,React为了性能,会复用事件对象(SyntheticEvent),也就是说,事件处理函数执行完之后,事件对象会被放回池里,属性会被清空。如果在异步代码里,访问事件对象的属性,就会得到null,因为事件对象已经被复用了。
所以,以前,如果需要在异步代码里使用事件对象,需要调用e.persist(),把事件对象从池里取出来,不被复用。
React 17,移除了事件池,不再复用事件对象了,事件对象在事件处理函数执行完之后,不会被清空,在异步代码里,也能正常访问属性。所以,e.persist()不再需要了,当然,调用了也不会报错,只是没有效果了。
这个变化,对于大部分应用来说,是好事,因为很多人,都踩过事件对象在异步代码里变null的坑,现在不需要了。但如果你的代码里,依赖了事件池的行为,比如,手动复用事件对象,就需要改一下。
4. useEffect清理函数的执行时机
在React 16及之前的版本,useEffect的清理函数,是同步执行的,也就是说,组件卸载的时候,会同步执行清理函数,然后再卸载组件。
React 17,把useEffect的清理函数,改成了异步执行,也就是说,组件卸载的时候,不会同步执行清理函数,而是会异步执行,在浏览器绘制之后执行。
这个变化,是为了并发模式做准备,因为在并发模式下,组件的卸载,可能会被中断,同步执行清理函数,会有问题。异步执行清理函数,更安全。
但这个变化,可能会导致一些问题,比如,如果你的清理函数里,有一些需要同步执行的操作,比如,移除DOM元素、取消请求等,异步执行的话,可能会有延迟,导致一些意想不到的问题。
不过,对于大部分应用来说,这个变化,是无感的,因为清理函数,一般都是一些副作用的清理,异步执行,影响不大。只有一些特殊的场景,才会有问题。
5. 组件返回undefined的处理
在React 16及之前的版本,如果组件返回undefined,React会报错,因为组件必须返回一个有效的React元素,或者null。
React 17,对这个行为,做了一些调整, forwardRef和memo包裹的组件,如果返回undefined,会有更清晰的错误提示,而不是之前的那种模糊的错误。
这个变化,主要是为了提升开发体验,让错误提示更清晰,更容易定位问题。
6. 原生组件的属性处理
React 17,对一些原生DOM组件的属性,做了一些调整和清理:
- onScroll事件不再冒泡:在React 17,onScroll事件,不再冒泡了,也就是说,父元素的onScroll,不会被子元素的滚动触发了。这和原生DOM的行为一致,以前React的onScroll是冒泡的,和原生不一致,现在改过来了。
- onFocus和onBlur的变化:在React 17,onFocus和onBlur,在底层用的是focusin和focusout事件,和原生的行为更一致了。同时,onFocusCapture和onBlurCapture,也用的是捕获阶段的focus和blur事件。
- 移除了一些废弃的属性:比如,textarea的onChange,以前有一些特殊的处理,现在和原生一致了;还有一些废弃的DOM属性,也被移除了。
这些变化,都是为了让React的事件和属性处理,和原生DOM更一致,减少React的特殊行为,让开发者更容易理解和使用。
二、升级踩坑:这些问题你可能会遇到
了解了React 17的核心变化之后,再来说说升级的时候,可能会遇到的坑。我在几个项目中升级了React 17,踩了不少坑,这里,把最常见的几个坑,分享出来。
1. 事件委托变化导致的事件问题
第一个坑,就是事件委托从document改到根节点,导致的一些事件问题。
我们有一个项目,用了一个第三方的UI库,这个库,会在document上绑定一些事件,比如,点击document的时候,关闭下拉菜单。在React 16的时候,因为React的事件也委托在document上,所以,React组件里的点击事件,调用e.stopPropagation(),是可以阻止document上的事件处理函数执行的,因为都在document上,stopPropagation可以阻止。
但升级到React 17之后,React的事件委托在根节点上,React组件里的点击事件,调用e.stopPropagation(),只能阻止事件冒泡到根节点之上,但事件还是会继续冒泡到document,所以,第三方库在document上的事件处理函数,还是会执行,导致下拉菜单,点击组件内部的时候,也会被关闭。
这个问题,排查了很久,才发现是事件委托变化导致的。解决方法,有几种:
- 修改第三方库的配置:有些第三方库,可以配置事件绑定的元素,不绑定在document上,而是绑定在指定的元素上,这样,就可以和React的根节点配合。
- 在React组件里,手动处理:在React组件里,监听原生的点击事件,而不是React的合成事件,这样,就可以在原生事件里,调用stopPropagation,阻止事件冒泡到document。
- 用React的方式重写:如果第三方库不好改,可以考虑用React的方式,重写需要的功能,避免和第三方库的事件冲突。
我们最后,是修改了第三方库的配置,把事件绑定在React的根节点上,这样,就和React的事件委托一致了,问题就解决了。
所以,升级React 17的时候,如果你的项目里,有其他库也在document上绑定事件,一定要注意,可能会有事件冲突,需要仔细测试。
2. 移除事件池导致的问题
第二个坑,是移除事件池,导致的一些问题。
我们有一个项目,里面有一些代码,是依赖事件池的行为的。比如,有一个自定义的事件系统,会手动复用事件对象,把事件对象的属性,拷贝到另一个对象里,然后清空原事件对象,供下次使用。
升级到React 17之后,因为事件池被移除了,事件对象不再被复用,这些手动复用事件对象的代码,就出问题了,事件对象的属性,不会被清空,导致一些逻辑错误。
还有一些代码,是在异步代码里,访问事件对象的属性,以前,因为事件池的原因,异步访问的时候,属性已经被清空了,所以,代码里调用了e.persist()。升级到React 17之后,e.persist()不再需要了,但调用了也不会报错,所以,这些代码,还是能正常运行,只是e.persist()变成了空操作。
这个问题,解决方法,就是把那些依赖事件池行为的代码,改掉,不再手动复用事件对象,因为React 17已经不复用了。对于调用e.persist()的代码,可以保留,也可以去掉,不影响运行。
所以,升级的时候,如果你的代码里,有手动操作事件对象、依赖事件池行为的地方,一定要注意,可能会有问题。
3. useEffect清理函数异步执行的问题
第三个坑,是useEffect清理函数异步执行,导致的问题。
我们有一个项目,里面有一个组件,是一个模态框(Modal),组件卸载的时候,需要同步移除模态框的DOM元素,并且移除body上的overflow: hidden样式,恢复页面滚动。
在React 16的时候,useEffect的清理函数,是同步执行的,所以,组件卸载的时候,会同步执行清理函数,移除DOM元素,恢复body的样式,一切正常。
升级到React 17之后,useEffect的清理函数,变成了异步执行,组件卸载的时候,不会马上执行清理函数,而是要等浏览器绘制之后,才执行。这就导致,组件卸载之后,有一小段时间,模态框的DOM元素还在,body的overflow还是hidden,页面不能滚动,用户体验不好。
这个问题,排查了很久,才发现是useEffect清理函数异步执行导致的。解决方法,是用useLayoutEffect代替useEffect,因为useLayoutEffect的清理函数,还是同步执行的,在DOM更新之后,浏览器绘制之前执行,这样,就能保证,组件卸载的时候,同步执行清理函数,移除DOM元素,恢复样式。
useLayoutEffect和useEffect的区别,就是执行时机不同,useLayoutEffect是同步执行的,在DOM更新之后,浏览器绘制之前执行;useEffect是异步执行的,在浏览器绘制之后执行。对于需要同步操作DOM的副作用,比如,测量DOM尺寸、同步修改DOM样式等,应该用useLayoutEffect,而不是useEffect。
所以,升级React 17的时候,如果你的useEffect清理函数里,有需要同步执行的DOM操作,一定要注意,可能会有延迟,需要考虑改成useLayoutEffect。
4. onScroll不再冒泡的问题
第四个坑,是onScroll事件不再冒泡,导致的问题。
我们有一个项目,里面有一个无限滚动的列表,父元素有onScroll事件,监听滚动,当滚动到底部的时候,加载更多数据。列表的每一项,里面也有一个可滚动的区域。
在React 16的时候,onScroll是冒泡的,所以,列表项里的可滚动区域滚动的时候,事件会冒泡到父元素,触发父元素的onScroll,导致父元素以为是自己在滚动,错误地触发了加载更多。
当时,我们为了解决这个问题,在列表项的滚动区域,加了事件处理,调用stopPropagation,阻止冒泡。
升级到React 17之后,onScroll不再冒泡了,所以,列表项里的滚动,不会再触发父元素的onScroll了,我们之前加的stopPropagation,就不需要了。但因为我们的代码里,还有一些其他的逻辑,依赖了onScroll冒泡的行为,比如,在父元素里,统一处理所有子元素的滚动事件,升级之后,这些逻辑就不工作了。
解决方法,就是把依赖onScroll冒泡的逻辑,改掉,在每个需要监听滚动的元素上,单独绑定onScroll事件,不要依赖冒泡。或者,用原生的addEventListener,监听scroll事件,因为原生的scroll事件,在某些浏览器里,是可以冒泡的(通过捕获阶段)。
所以,升级的时候,如果你的代码里,有依赖onScroll冒泡的逻辑,一定要注意,React 17之后,onScroll不再冒泡了,需要修改。
5. 第三方库的兼容性问题
第五个坑,是第三方库的兼容性问题。
React 17,虽然大部分API,都是向后兼容的,但还是有一些底层的变化,可能会导致一些老的第三方库,不兼容。
我们升级的时候,就遇到了几个第三方库,在React 17下,有问题:
- 一个老的表单库:这个库,内部依赖了React的事件池,手动调用了事件对象的persist方法,并且依赖了事件对象在异步代码里被清空的行为,升级到React 17之后,事件池被移除了,这些逻辑就出问题了,表单的一些状态,不正确。
- 一个动画库:这个库,内部用了React的不稳定的API,比如,ReactDOM.unstable_renderSubtreeIntoContainer,这个API在React 17里,被移除了,导致这个库,直接报错,不能用。
- 一个UI组件库:这个库,内部依赖了事件委托在document上的行为,升级到React 17之后,事件委托改到根节点了,一些组件的事件处理,就出问题了,比如,下拉菜单、日期选择器等,点击外部关闭的功能,不工作了。
这些问题,解决方法,有几种:
- 升级第三方库:如果第三方库,已经发布了支持React 17的版本,就升级到最新版本,一般都能解决问题。
- 替换第三方库:如果第三方库,已经不维护了,没有支持React 17的版本,就考虑替换成其他的、支持React 17的库。
- 打补丁:如果第三方库,问题不大,可以自己打补丁,修改有问题的代码,或者用patch-package,给第三方库打补丁。
- 暂时不升级:如果第三方库,很重要,又没有替代方案,也没有支持React 17的版本,可以暂时不升级React 17,等第三方库支持了,再升级。
我们最后,大部分第三方库,都升级到了支持React 17的版本,有一个不维护的库,替换成了其他的库,还有一个小问题,自己打了补丁,解决了。
所以,升级React 17之前,一定要先检查,项目里用到的第三方库,是不是都支持React 17,如果有不支持的,要提前做好准备,是升级、替换,还是打补丁。
6. 测试工具的兼容性问题
第六个坑,是测试工具的兼容性问题。
我们的项目,用了Jest和Enzyme做单元测试。升级到React 17之后,Enzyme的旧版本,不支持React 17,因为Enzyme需要适配React的内部API,React 17的内部API,有一些变化,旧版本的Enzyme,不能用。
解决方法,就是升级Enzyme到最新版本,并且安装对应的适配器(enzyme-adapter-react-17)。如果用的是React Testing Library,就不需要太担心,因为React Testing Library,对React版本的兼容性,比较好,升级到最新版本就行。
还有一些其他的测试工具,比如,react-test-renderer,也要升级到和React 17对应的版本,不然可能会有问题。
所以,升级React 17的时候,测试工具,也要一起升级,不然,测试可能会跑不起来。
三、实战经验:如何平稳升级React 17
说了这么多坑,再来说说,如何平稳地升级React 17,减少踩坑。根据我的实战经验,总结了以下几个步骤和建议。
1. 升级前做好准备
升级之前,一定要做好充分的准备,不要上来就升级,不然,可能会遇到各种问题,手忙脚乱。
准备工作,包括:
- 阅读官方升级指南:React官方,有详细的升级指南,列出了所有的破坏性变化和注意事项,一定要仔细阅读,了解所有的变化。
- 检查第三方库的兼容性:列出项目里用到的所有第三方库,检查它们是不是支持React 17,如果有不支持的,提前做好准备,是升级、替换,还是打补丁。
- 确保测试覆盖:升级之前,确保项目有足够的测试覆盖,尤其是核心功能,这样,升级之后,可以通过测试,快速发现问题。
- 创建升级分支:在Git里,创建一个专门的升级分支,不要在主分支上直接升级,这样,出了问题,可以随时回滚,也不影响正常的开发。
做好这些准备,升级的时候,就会从容很多。
2. 逐步升级,不要一步到位
升级React 17,不要一步到位,把所有的东西,都一起升级,这样,出了问题,很难定位是哪里的问题。应该逐步升级,一步一步来,每升级一步,就测试一下,确保没问题了,再升级下一步。
比如,可以按照以下步骤,逐步升级:
- 先升级React和ReactDOM:只升级React和ReactDOM到17版本,其他的库,暂时不升级,然后跑测试,手动测试核心功能,确保React本身的升级,没有问题。
- 升级编译器:React升级没问题之后,再升级Babel或者TypeScript,启用新的JSX转换,去掉不必要的import React,然后测试,确保JSX转换没有问题。
- 升级第三方库:然后,逐个升级第三方库,每升级一个,就测试一下,确保这个库的升级,没有问题。
- 清理废弃代码:最后,清理废弃的代码,比如,去掉不必要的e.persist(),把需要同步执行的useEffect改成useLayoutEffect等。
这样逐步升级,每一步,都能确保是正确的,出了问题,也能很快定位,是哪一步的问题。
3. 充分测试,覆盖各种场景
升级之后,一定要充分测试,覆盖各种场景,不要只测核心功能,一些边缘场景,也要测,因为很多破坏性变化,都是在边缘场景下,才会出现问题。
测试的重点,包括:
- 事件相关的功能:因为React 17的事件系统,变化比较大,所以,所有和事件相关的功能,都要重点测试,比如,点击、滚动、焦点、键盘事件等,尤其是和其他库混用的地方,更要重点测。
- 副作用相关的功能:useEffect、useLayoutEffect、componentDidMount、componentWillUnmount等生命周期和副作用相关的功能,也要重点测试,因为清理函数的执行时机,有变化。
- 第三方组件:所有用到的第三方组件,都要重点测试,因为第三方库,最容易出现兼容性问题。
- 表单和输入:表单、输入框、选择器等,和用户输入相关的功能,也要重点测试,因为事件的变化,可能会影响这些功能。
- 移动端和各种浏览器:在移动端、各种浏览器上,都要测试,因为有些变化,在不同的浏览器上,表现可能不一样。
充分测试,才能确保升级之后,没有大的问题。
4. 利用React 17的新特性,改进代码
升级到React 17之后,不要只是升级了版本,就完事了,还要利用React 17的新特性,改进代码,提升代码质量和开发体验。
比如:
- 去掉不必要的import React:启用新的JSX转换之后,使用JSX的文件,不再需要import React了,可以把这些不必要的import,都去掉,让代码更简洁。
- 去掉不必要的e.persist():事件池被移除了,不再需要e.persist()了,可以把代码里的e.persist(),都去掉,让代码更简洁。
- 合理使用useLayoutEffect:对于需要同步操作DOM的副作用,用useLayoutEffect代替useEffect,避免异步执行导致的问题。
- 为并发模式做准备:React 17,是为并发模式做准备的版本,升级之后,可以开始了解并发模式的概念,写代码的时候,注意一些并发模式下的最佳实践,比如,不要在render里有副作用,保持组件的纯函数特性等,为未来升级到并发模式,做好准备。
利用这些新特性,改进代码,才能真正发挥React 17的价值,而不只是换了个版本号。
5. 渐进式升级,降低风险
如果项目很大,直接升级React 17,风险很高,可以考虑渐进式升级,就是先升级一部分,或者,在一个页面上,先试用React 17,没问题了,再逐步推广到整个项目。
React 17的事件委托,改到了根节点,这就使得,在一个页面上,同时运行多个版本的React,成为可能。可以把一个页面,或者一个模块,先升级到React 17,渲染在一个单独的根节点上,和其他版本的React,互不干扰。等这个模块,稳定运行一段时间,没问题了,再逐步升级其他模块。
这样,渐进式升级,可以大大降低升级的风险,即使出了问题,也只影响一个模块,不会影响整个项目。
当然,渐进式升级,也有一些成本,比如,需要维护多个版本的React,打包体积可能会大一些,模块之间的通信,可能会复杂一些。但对于大型项目来说,这些成本,是值得的,因为可以大大降低升级的风险。
四、写在最后
React 17,虽然被称为"无新特性"的版本,但实际上,底层的变化,还是不少的,这些变化,主要是为了未来的并发模式和渐进式升级做准备。虽然升级的时候,可能会遇到一些坑,但只要做好准备,逐步升级,充分测试,还是可以平稳升级的。
今天,分享了React 17的核心变化、升级踩坑、以及实战经验,包括事件委托的变化、JSX转换的升级、事件池的移除、useEffect清理函数的执行时机、onScroll不再冒泡等核心变化,以及事件冲突、第三方库兼容性、测试工具兼容性等常见的坑,还有升级前的准备、逐步升级、充分测试、利用新特性、渐进式升级等实战建议。
希望这些内容,能帮助大家,在升级React 17的时候,少走弯路,平稳升级。
最后,想说的是,React的每个版本升级,都不是一件小事,都需要认真对待,仔细阅读官方文档,做好充分的准备,逐步升级,充分测试,才能确保升级的顺利。不要怕升级,也不要盲目升级,根据自己项目的实际情况,选择合适的升级时机和升级方式,才是最重要的。
如果有什么问题或者不同的看法,欢迎在评论区交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录