本文作者系360奇舞团前端开发工程师
现在的前端招聘JD里大概率会有一条要求,“Vue,React 有其一经验”,引申的意思可能是:一个成熟且有着现代前端开发经验的开发者,要学会一门框架应该是成本很低的事情。人常说,框架都是相通的,是否如此呢?
确实,随着各家不停探索,框架的基本形态和功能设计日趋完善,最根本差异点最终演变成了框架开发者各自的理念差异,不同的理念让框架有了各自的设计模式和最佳实践,如果你要准备从现在开始学习掌握 React ,那么先理解 React 的设计理念至关重要。
这是一份适合有着前端开发经验,并习惯使用现代前端框架,却还没深入使用过 React 的老鸟快速入门指南,如果你还没有接触过前端框架,那建议直接在官方文档仔细从头看起。
当我们决定要正式去学习,那我们起码要以掌握它为基本目标,一个有经验的开发者,看一篇技术文档,写一个 Hello world 轻而易举,你即使没仔细了解过 React 的设计理念,翻翻文档应该也能把简单的功能写出来。
但是,这不叫掌握,顶多叫能用,掌握是指你要在了解过所有功能之后,理解框架设计者的设计理念和最佳实践,并最好懂得其实现的基本原理,就基本算是掌握了。
本文我会简单说说我理解的 React 设计理念、常识、和一些最佳实践,有些地方对照 Vue 会更容易理解,帮助你快速了解 React。文章略长,请耐心阅读。
React核心原理就是:当数据发生变化时,UI随之更新,就是所谓的数据驱动,之所以说 React 很有野心,是因为它完全抛弃了前端熟悉的开发模式,创造出一个全新的思路,试图颠覆前端工作者的开发方式,确实这是个很伟大的尝试。
当然你说 Vue 同样是数据驱动,但不同的是 Vue 做了更上层的封装,Vue 设计了新的类似 HTML 的模板语法,通过选项属性为开发者提供编写逻辑和 state 的地方,再通过一个 viewModel,当监听到 state 变化时再去更新 view ,总结起来就是在前端现有开发习惯下,以“糖水”的方式“注入”了更多让开发变的更加容易的功能,但无疑设计上会变的非常的复杂。
而 React 选择了完全不同的一条路,React 选择让前端开发回归语言本身,从 js 自身找到解决方案,一次性解决以下的所有问题:
最终 React 选择了 js 中的 【函数】 去承载所有的功能,所以 React 的函数组件本质上真的就只是 js 中的普通函数而已,而非 .vue 这种专门需要复杂编译的新产物,一定要理解它就是一个普通函数。
那一个简单的函数是如何实现上述这么多复杂的功能的呢:
首先 React 将 HTML 与 js 相融合,我们可以在函数组件中直接写 DOM 语法,DOM 在 js 中成为了合法的结构,最终函数组件返回一个 DOM ,就是该函数作为一个 React 组件最终要渲染的 UI,这种写法是和传统开发分歧最大的地方,也是最不容易被开发者接受的地方,很多开发者习惯了单独开发 HTML 模板,第一次开发会有种 HTML 太过于散乱,不直观很难理解的感觉。
但这正是 React 对开发方式的一种颠覆,让开发者抹平传统开发中 HTML 与 JS 之间巨大的割裂感,更专注于 js 逻辑。
export default function({foo}){
const dom = <div>React dom 1</div>
const dom = <div>React dom 2</div>
if(foo){
return dom1
}else{
return dom2
}
}
第二是逻辑,函数本身天然就是可以直接写 js 执行逻辑的,所以将业务逻辑直接写在函数中即可,无需像 Vue 一样提供各种选项API,让开发者将不同的逻辑写进预置好的各种接口里
第三是组件的状态,需要通过逻辑改变状态从而触发页面的更新,但函数组件是一个纯函数,通过函数自身的执行去渲染页面,所以函数组件本身天然无法留存状态,所以 React 采用 hooks 的方式为组件提供状态,什么是 hooks 这个下面会将,最终实现就是:状态可以写在函数中,既不破坏纯函数的特性,又能在状态变更时使函数组件以最新的状态重新执行,更新页面
import { useState } from 'react'
export default function(){
// 当函数组件中逻辑更新data时,当前函数组件会重新执行,生成最新的dom区更新视图
const [data, setData] = useState(1)
return <div>{data}</div>
}
第四是生命周期,因为现在大多传统框架架构都会引入生命周期的概念,以在组件的不同阶段去更新视图,但是 React 的函数组件完全摒弃了这一概念,它只是在特定的时机会触发整个函数组件的重新执行,自然会生成最新的视图,不需要做不同逻辑的处理。
最后是渲染,函数组件本身最终返回一个 DOM 结构,可以理解它本身就是执行的一个渲染逻辑,所以只需要让函数组件在需要更新的时候反复执行自身就行了。
至此,React 几乎全部的核心功能就都通过一个简单函数实现了,所谓函数组件,几乎完全遵循 js 函数的特性,对于开发者来说几乎没有新的概念引入,这相比于 Vue 极大的降低的心智负担,谁不喜欢简洁而纯粹的东西呢。
没有模板、没有生命周期、没有指令、没有各种各样的语法糖、没有复杂的执行过程,这是一个只有函数的世界,太优雅了!
写 Vue 时你可能会觉得以后可能随时会出个新功能,让开发变的更舒适,写 React 你会觉得,这就到头了,弟弟!
这玩意谁研究的呢你说,不得不说是个人才!
再聊一个广大网友最津津乐道的问题,所以 React 与 Vue 到底谁更好呢?
我个人的结论是,虽然两者从框架的功能实现上很类似,但他俩的定位其实完全不一样,Vue 想让大家写的舒服,React 是要从更底层让前端开发方式变的更合理,从格局和定位上讲,我认为还是 React 的设计明显更胜一筹。
理念不同,导致开发方式不同,注定他们会有各自的用户群体,这两种用户群体的存在都是有道理的,都不会消失,所以我也觉得以后也不会存在 React 完全取代 Vue 的情况,因为他们有各自的用户群体,开发方式就是他们的壁垒。
但是因为 Vue 更贴近传统前端开发方式(传统开发方式更符合人的直觉,其实就是更简单),而 React 让函数变的更加复杂,需要非常熟悉 js 语言特性,所以 Vue 确实会受很多初学者的青睐,而技术成熟的开发更容易被 React 的优雅吸引。
为了你更容易理解 React 的各种概念,在此我们先梳理下 React 重要的历史版本。
从 V16.8.0 开始,React 引入了 hooks 的概念,可以算是 React 里程碑式的更新,hooks 的引入弥补了之前函数组件的缺陷,函数组件因此大放异彩。
从此时开始 React 真正同时并存两种截然不同的开发方式,Class 组件与函数式组件,在22年最新 V18 新版的官方文档更新之前,React 还一直默认使用 Class 组件讲解 React ,所以你可能会因为选择哪种组件而觉得困惑。
Class 组件和函数组件到底该用哪个?
回答:只需要用函数组件,class组件已经成为历史,可以完全抛弃了
已经写好 Class 组件需不需要改?
回答:不需要
能不能在老项目里写函数组件?
回答:可以,Class组件与函数组件完全可以共存,只要注意 React 版本就可以
React 组件演变顺序经过三个阶段:
在上面讲理念的段落,已经讲过,函数才是贯彻 React 思想最好的载体,所以函数作为组件是最符合的,可是在当时有个局限就是,函数组件内部无法留存状态,函数也更没办法设计一套生命周期,用函数作为组件有严重缺功能陷的,所以在当时选用 Class 作为组件的载体。
Class 组件我现在看起来依然觉得很难接受,原因有几点:
可以说 Class 只是为了实现组件基本功能而用,所以最初看到 React Class 组件时,反而觉得没有 .vue 单文件组件来的优雅和直接。
但这一切问题都随着 hooks 的推出迎刃而解,彻底抛弃生命周期,并通过 hooks 引入一个不受函数组件重复执行影响的外部变量作为函数组件内的状态,当这个状态变更时,函数组件随之重新渲染,将最新的状态渲染到页面。
现在时间来到了2023年,函数组件 + Hooks 的开发方式广受好评,你已经没有任何理由再去使用 Class 组件了
顺着上面说函数组件早期有很大的不足,就是不能留存状态与很难设计生命周期,那 Hooks 是如何解决的呢。
想象一下如果将一个纯函数作为组件,纯函数通过自身的重复执行来做到渲染与重复更新,需要在函数多次执行期间保存其中的状态,那我们肯定是需要这个函数之外的空间来存储状态,并且当这个状态被改变时,能监听到并触发函数组件的重新渲染。
Hooks 就是这种方式,字面意思就是钩子,Hooks 将函数钩到一个可能会变化的数据源上,当这个数据变化时,被钩在上面的函数会重新执行,生成新的结果。
useState 用法很简单,如下代码,引入后会在当前函数外声明一个变量:
最终复杂的视图渲染就在函数组件一遍又一遍简单的重复执行中完成了。
而且 Hooks 虽然是将状态声明在函数外部,但写法上仍然是写在函数组件的内部的,这让人写起来并不会有函数组件的割裂感
import { useState } from 'react'
export default function(){
// 当函数组件中逻辑更新data时,当前函数组件会重新执行,生成最新的dom区更新视图
const [data, setData] = useState(1)
return <div>{data}</div>
}
所谓生命周期是指传统设计上组件是有生命的,组件会经过一个从出生--更新--销毁的完整的流程,但是函数是没有生命的,函数是纯粹的执行过程根据 state 的变化 每次执行返回不同的渲染结果。所以函数组件不存在生命周期的概念,一定要尝试转变思路。
useEffect 和生命周期无关,他是函数组件形态下的另一种设计模式,只是说他能解决我们过去认知中的使用生命周期的一些问题。所以我们首先要转变的思想就是忘掉生命周期,将思考放在如何通过函数组件的重复执行解决上。
useEffect 的设计是用来执行一段和当前渲染无关的副作用代码,因为每次函数组件重新执行都是因为最终的渲染结果要改变,每次重新执行意味着函数内部的逻辑要从头再执行一遍,如果有些逻辑与渲染结果无关仍然会被重新执行,这显然是不正常的,所以 React 设计了 useEffect 用来处理与渲染无关的副作用代码,如下
const [state1, setState1] = useState(1)
useEffect(()=>{
//只有state1发生变化时才执行这段副作用代码
}, [state1])
如果有些逻辑既与 state 无关又与渲染结果无关,那说明这段副作用只需要执行一次,可以不传入依赖状态,则只会在组件首次渲染时触发,同时 useEffect 可以返回一个方法,该方法会在组件被销毁时执行,这样也顺便实现了类似传统生命周期中 componentDidMount 与 componentWillUnmount 的功能。
useEffect(()=>{
//只有state1发生变化时才执行这段副作用代码
document.addEventListener('click', fn);
return ()=>{
document.removeEventListener('click', fn);
}
}, [])
最后还缺少一个非常重要的功能,就是在函数重复执行渲染过程中的数据共享,我们需要一个能从纯函数重复执行中逃脱,贯穿整个组件渲染的变量,有人说,useState 不就是做这个的吗?但是 state 是和渲染绑定的特殊状态,有以下绑定的特性:
state 状态一定和视图渲染有关
state 值的变更会触发函数组件重新执行
state 变更后有一系列复杂的逻辑,要先触发渲染,再执行副作用,在最新的函数组件执行过程中才能拿到最新的state的值
export default function () {
const [count, setCount] = useState(0);
function click(){
setCount(2);
console.log(count) // 打印 0
}
return <>
<span onClick={click}>点击</span>
<p>{count}</p>
</>
}
所以需要在一个复杂的时机才能拿到 state 最新的值,而我们需要一个与渲染无关的数据,能贯穿重复的函数执行,变更后不需要触发函数重新渲染,并且不需要在意此时函数的渲染过程是非常有必要的
useRef 就实现了这个功能,他在函数组件首次执行时创建,你可以在函数任何逻辑中直接更改 useRef 的值,它会立即同步更改,并贯穿重复的函数执行,无需任何心理负担。
除了能存储函数组件重复执行过程的共享数据,useRef 在存储DOM节点,和清理某次渲染过程产生的闭包逻辑有非常重要的意义
import { useState, useRef } from "react";
export default function Timer() {
const [time, setTime] = useState(1)
const timer = useRef(null);
const domRef = useRef(null);
const click = function(){
// 需要再每次执行前清理掉之前的定时器,
// 如果不使用 useRef ,函数组件重复渲染后无法找到上一次函数执行产生的定时器对象
window.clearInterval(timer.current);
// 延时器对象赋值给 useRef
timer.current = window.setTimeout(() => {
setTime(time + 1);
}, 5000);
}
const getDOm = function(){
// 我们可以毫无负担的获取到domRef节点,不受函数组件重复执行的影响
console.log(domRef)
}
return (
<div ref={domRef}>
<p>{time}</p>
<button onClick={click}>add</button>
<button onClick={getDOm}>getDom</button>
</div>
);
}
理解了函数组件极其简洁的设计理念,再加上三个十分简单的 Hooks,那我们是不是已经可以自如的开发 React 代码了呢?
我的答案是,是的
虽然 React 到现在已经推出了十几个 Hooks,但是正常的开发过程中 useState useEffect useRef 几乎可以解决所有问题,我认为除了这三个之外其他所有的 Hooks 都只是为了优化而存在,无论你用与不用,都不影响组件的开发
除了这三个核心 Hooks 以外,还需要提一提的也就是三个用来优化组件的 Hooks :useCallback、useMemo、useContext
现代框架通常都采用单向数据流的设计,让数据的流转更加清晰,React 与 Vue 都是如此,相比较于 Vue 多给出了一些双向绑定、自定义事件等语法糖,React 对于数据的流转更加的简单和严格,通常情况下只可以通过父组件向子组件传递 props 方式实现组件之间的通信。
为了解决跨组件通信问题,React 提供了 useContext Hooks,具体用法比较简单,移步官网查看吧。
我个人不太喜欢 useContext 这种功能,会破坏组件中简洁的数据流转结构。从设计上 useContext 可以按业务模块去使用,比如一个 Header 头模块或者一个页脚模块下分别设计一个 useContext,在各自模块中使用,我也觉得是一种很不好的设计,建议不要使用,会让数据流转变的非常混乱不可控,如果真的必须要夸组件通信,不如直接上 redux 来的简单直接,统一管理,方便可控。
这两个 Hooks 都是用于组件中的缓存,useCallback 可缓存一个方法,useMemo 可以缓存一个计算后的值,当他们依赖无变化时他们就不会重新声明或计算,具体用法也移步官网吧。
先解释下为什么需要缓存,是因为函数组件通过重复执行的方式不停的渲染视图,意味着函数组件可能会被重复执行非常多次,而你在函数组件中声明的内部方法每次都会被重新声明,函数组件中写的逻辑每次都会被重新执行,这确实会带来一定的浪费,极端情况下会影响页面性能。如下代码:
import { useState, useRef } from "react";
export default function ({a,b}) {
// 函数内部计算,每次函数重新渲染都会重新计算,无论 a b 是否变化
// 如果这里是非常占耗CPU的计算,可能会阻塞页面渲染
const c = a + b
// 每次函数重新渲染方法都会重新声明
const click = function(){
// 声明一个内部方法
}
return (
<div>
<p>{c}</p>
<button onClick={click}>click</button>
</div>
);
}
那到底要不要用,什么时候去用,如何衡量呢,我的看法是我们应该先去理解函数的本质:作为纯函数每次重新执行,内部逻辑重新执行、重新声明、重新计算,那就是函数的特点啊,这再正常不过了,React 正是以纯函数作为组件才会如此的简洁和优雅,这么简洁清晰和优雅的函数,你非要加个 useCallback 和 useMemo ,这不是画蛇添足么。
有的人一听到有浪费,赶紧给缓上,能省点是点嘛,这是种错误的观念,你一定要透彻理解这两个 Hooks 之所以存在是为了 【优化】 和 【解决问题】,所以应当在真的存在【问题】的时候再去考虑用它【解决】,比如你真的在函数组件里写了一个cpu计算了超级大的逻辑,频繁执行确实会影响组件的渲染,此时你再考虑用 useMemo 去缓存计算结果。
全文说了这么多,其中强调的最重要一条就是开发思维的转换,思维不转换你永远无法灵活的去使用,这里要重点提到 React 带来的另外一个给我们开发方式带来巨大的转变的特性:自定义 Hooks。
在以前无论是 Vue 还是 React 组件内的逻辑复用都异常艰难,通常情况我们只能封装 js 自身的对象和方法,比如封装一个函数,但组件内部的功能却是无法封装的,比如我们只能封装一个普通方法,不可能封装出一个带有响应式的方法。
这个过程中 React 出现过一些组件内逻辑封装的设计模式,比如高阶组件HOC、混入Mixin,Vue 也曾采用过 Mixin ,但使用度很低现在也都被官方废弃了,因为他们的使用实在太过牵强,很多人宁愿复制代码,也不想使用他们,我就是其中一员。所以我们需要一个能将组件内逻辑再次封装复用的功能,自定义Hooks的推出就是解决了这一点。
比如我们要在多个组件中实现获取视口宽度的功能,在以前如果我们不想在每个组件中都写一套事件监听程序,那就需要在父组件中写一个监听程序,监听到变化后将视口宽度通过 props 传递给子组件,子组件才能响应式更新,而现在我们可以在自定义 Hooks 直接使用 useState 给组件返回一个响应式的 state。
// useWindowSize.js
export default function useWindowSize (){
const [size, setSize] = useState(getSize());
useEffect(() => {
const handler = () => {
setSize(window.innerWidth)
};
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}, []);
return [size];
};
// jsx中使用
export default function(){
const [size] = useWindowSize();
if (size >1000 ) {
return <SmallComponent />;
}else{
return <LargeComponent />;
}
};
从此在 React 中,又多了一种新的封装形态,自定义 Hooks,让相同业务逻辑拆分的更清晰,降低代码的冗余,提高代码的复用程度
在函数式编程中,函数是头等对象即头等函数,这意味着一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。λ演算是这种范型最重要的基础,λ演算的函数可以接受函数作为输入参数和输出返回值。
比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。(来自维基百科)
文章的最后我想探讨下我对React 设计的理解。
很多人在从 Vue 转 React 觉得不太适应,或者从 Class 组件换成函数式组件觉得无法 get 到函数式组件设计的意义,我想这可能和编程习惯有关,传统前端开发方式我们受面向对象的编程思想影响颇深,我们常习惯在 类->继承->对象 的基础上去思考我们的开发方式,我们继承一个类,执行一个方法,改变一个属性,更新一个视图,我们在对象这个基础上,可以构建出很多复杂的功能。
但是 React 似乎更愿意在函数上做文章,React 用函数作为组件的基础,用纯函数简单的重复执行来替代复杂的视图更新流程,Hooks 也为是函数,useState 触发函数组件的执行并作为纯函数执行的不同输入,useEffect 将函数组件中所有的副作用从函数中隔离出去,自定义 Hooks 也是函数可无缝的和函数组件组合使用,所以如果你真的理解 【函数】 在 React 中的意义,那你就能体会到,React 看起来如此复杂的框架,归根结底只是通过函数的执行去渲染出一个视图而已,就是如此的简洁。
所以如果你能用函数式编程的思想去思考如何通过函数的执行实现你想要的功能,我想你才真正的掌握了 React 的精髓。
- END -
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。