简介
在react社区有这么一句话
在react,一切皆js
这句话是有一定道理的,react本身并没有创造什么新的语法,唯一多出来的jsx语法也如同html一般自然。基本上,学习react,你只要会jsx+组件就可以了入门了,剩下的,都是js。
本文将从js的角度出发,带你理解react,顺便稍微入门 react
react 基础知识
在理解 react 之前,我们先来理解一些 react 的基本知识
我们先从一个 hello world 的最小代码开始
function Hello () {
return (
<div>
Hello World
</div>
)
}
ReactDOM.render(
<Hello />,
document.getElementById('root')
)
不出意外的话,这段代码渲染出来的结果是 Hello World
JSX
没有听过这个东西? 那E4X呢? 也没有? 那也不慌,JSX 是react 创造出来的东西,它的前世可能就是被 废弃的 js 标准 E4X,有兴趣的可以了解一下 E4X。即使是现在的浏览器也不支持E4X,而 JSX 也是React 创造的私有语法,所以直接运行上面的代码是会报错的。
我们先来看看什么是 jsx。
在react, jsx分两种
一种是元素, 像
另一种就是组件,像
刚才我们也说过,上面的代码直接在浏览器跑是有语法错的,因为 jsx。但jsx实际上也就是react一个函数的语法糖,jsx代码需要 babel 来编译成 正常的js代码,这些后面再说,因为现在react的脚手架工具都已经帮你处理好这些东西了,不用你太操心。
在揭开 jsx 面纱之前,你可以把jsx当成一个对象就行了。
组件
前面说到,组件是 React 中一个重要的概念。
组件的声明有两种,上文使用的是函数式组件,可以看到,它的声明其实就和普通的函数没有什么区别。
一个函数,返回了另一段 jsx(元素、组件) 的内容。而该组件最终渲染出来的内容,也就是这段jsx的内容。
函数是js中复用代码的方式,在react 中也是如此。
像上文的 Hello 组件,最终就会渲染出 一个 里面是 Hello World 的div
另一种声明组件的方式是 class ,因为比较繁琐,所以本文不会出现这种声明方式。
因为一些历史遗留问题,才有两种声明方式,但可以确定的是,函数的声明方式将会成为主流。
了解了组件,知道组件最后渲染出来的东西,就是函数返回的jsx时, 我们就可以从js的角度出发,来理解各种渲染控制方式。
组件渲染
条件渲染
在react中,推荐的条件渲染是 &&
function Hello () {
const show = true
return (
<div>
{show && (
<div>Hello World</div>
)}
</div>
)
}
在这里,我们看到在如果要在jsx中插入 js代码,需要使用花括号{},可以想像成在 es6 template string 里面写js代码 需要用 ${} 一样。
而为什么是用 &&,大家可记得这里是 {} 内的js区,也就是说 这个 && 其实就是 js 的&&
我们来复习一下js 的 &&
const a = true && 1 // 1
const b = false && 1 // false
如果 && 的左边是真值,则会返回右边,
如果 && 的左边是假值,则会返回左边,
听起来有点绕,其实就是这样
const x = true
x && 1 // 1
x ? 1 : x // 1
因为 js && 的特殊性,所以这两种写法是等价的。
提问:这样的话如果 && 的左边是 false,那会渲染出 'false' 文本吗?
不会,react 渲染 js 值(即花括号的值)时,会过滤布尔类型、Null 以及 Undefined,所以,下面的代码渲染结果都是一样的
<div />
<div></div>
<div>{false}</div>
<div>{null}</div>
<div>{undefined}</div>
<div>{true}</div>
这里要注意的是,0不会被过滤,所以如果用数字来用渲染条件,像 0 && 1 会渲染出 0
所以渲染条件是数字的情况,可以用 !! 转 boolean 类型 !!0 && 1
提问:如果 && 相当 if 控制显示隐藏,那 else 呢?
这个问题问得好,这个可以用更强大的 三目运算 来渲染
function Hello () {
const sayHello = true
return (
<div>
{sayHello ? (
<div>Hello World</div>
) : (
<div>.....</div>
)}
</div>
)
}
再提问:那 else if 呢?
可以再变通,嵌套三目运算
function Hello () {
const x = 1
return (
<div>
{x === 1 ? (
<div>Hello World</div>
) : x === 2 ? (
<div>.....</div>
) : x === 3 ? (
<div>.....</div>
) : (
<div>other</div>
)}
</div>
)
}
注意,这些都是讲的都是 js,和react 一点关系也没有,也就是说,你可以不必墨守成规,完全可以这么写,只要符合js,都是走得通的
你甚至可以用 if 提前 return
这一切取决于你对js的熟悉程度,和代码风格。
function Hello () {
const hidden = true
if (hidden) return null
return (
<div>Hello World </div>
)
}
列表渲染
推荐的方式是使用数组的 map 方法 .map
function List() {
return (
<ul>
{[1,2,3,4].map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
上面代码不出意外,渲染的结果应该是
<ul>
<li>1</div>
<li>2</div>
<li>3</div>
<li>4</div>
</ul>
熟悉map方法的应该已经明白了
不熟悉的我们先来复习一下。
map 方法是将数组 转换成另一个数组
const arr = [1,2,3,4].map((item, i, arr) => (
// item 是当前遍历数组的项,i 是当前项的索引,从0开始, arr就被遍历的数组本身
item + 1
))
console.log(arr) // [2,3,4,5]
这个例子将每个项加了1
前面的列表,则是map成了jsx数组.
等等,还没说完,为什么有个key呢?
这个是 react 内部优化用的,建议所有列表都给个key,这个key一般是数据的 id,一个稳定值。
在数据经常变动的情况下,建议不要使用索引作为key,不然可能造成负优化。
提问:如果列表项有些是不要展示的,怎么做?
回顾一下前面讲的,你可能已经想到可以使用 && 大法,但仔细再想想,一切皆js,为何不用数组的 .filter方法呢
function List() {
// 过滤掉 1 的 li
return (
<ul>
{[1,2,3,4].filter(item => (
item !== 1
)).map(item => (
<li key={item}>{item}</li>
))}
</ul>
)
}
当然了,如果你依旧想用 && 也没有问题
function List() {
// 过滤掉 1 的 li
return (
<ul>
{[1,2,3,4].map(item => (
item !== 1 && <li key={item}>{item}</li>
))}
</ul>
)
}
好,组件的渲染就讲到这里
一般来说,一个框架掌握了列表渲染与条件渲染,就能组合成各种渲染手段。
到这里其实已经可以看出,react对渲染没有创造新的语法,一切皆js语法。
所以说会js就是可以快速上手react的。
下期从js的角度分析组件之间的通讯。
未完待续。。
组件之间的通讯
之前我们简单介绍过组件,但没有介绍过组件之前的通讯
这里仅介绍父子组件的通讯,深层子代组件通讯可以拓展阅读 React context,其它通讯方式可以拓展阅读 全局状态管理
但万变不离其宗,理解父子组件通讯,其它的也是如此。
在这之前,我们先来看下面这段代码,看看jsx怎么传参 (父组件与子组件的通讯)
父组件与子组件的通讯
function Parent () {
return <Chil name="jack"> is a man.</Chil>
}
function Chil(props) {
return <div>{props.name} {props.children}</div>
}
不出意外,Parent 渲染结果为 jack is a man.
这里父组件给子组件传递一个name的字符串参数 name="jack",
这里传参数以jsx的属性来写,写法如同 html 一般自然
同理给元素传参数也是如此
子组件本身作为一个函数,自然可以定义参数
React 会把所有 jsx 传递的参数,包裹成一个props对象作为参数给 Chil
所以没有传递任何参数时,props将会是一个空对象 {}
而组件的子节点将作为 children 属性传递。
如果有多个节点children 将会是一个数组,如果没有节点,children 将会是 undefined
children有多种情况,但一般来说,我们只需要将 children 传递下去即可
但如果需要对 children 做额外处理,可以借助 React.Children 这个api但简化一些判断
不过话说回来,为什么属性会成为一个 props 对象,而不是这样分开。
function Chil(name, children) {
// ...
}
还记得之前 说过,JSX 其实只是一个语法糖吗?
我们现在来揭开 JSX 的真面目。
JSX 的真面目
之前说过,jsx不过是语法糖,需要babel来编译成正常的js语法
编译前
function Parent () {
return <Chil name="jack"> is a man.</Chil>
}
编译后
function Parent() {
return React.createElement(Chil, {
name: "jack"
}, " is a man.");
}
可以看到的是, JSX被编译成一个React.createElement的函数调用,这个函数被传入 3 个参数,第一个是函数 Chil,第二个是 props对象,但三个是 children
第一个参数就是jsx的类型,babel 会对jsx 编译,如果以大写开关的jsx,认为是组件,编译成变量的引用,小写开头的,则认为是元素,编译字符串。
如果写成
这也就是jsx组件为什么要以大写开关的原因。
第二个参数是 props,如果没有的话,会传一个 null 的参数。
剩下的参数都是 ...children,有多个的话,会以多个参数传进去,而不是以数组的形式。
第二个参数props包不包括children其实就是
可以看到 props 可能为 null,但我为什么还说它只可能是个空对象 {},别忘了这是一个给 React.createElement 参数,这个函数会做一些处理,这也就解释了为什么 children 最后会在 props 里面
然后就是为什么是直接传个对象作为参数,而不是一个一个属性传进来作为参数。
这个也很好理解,首先是babel会把它编译成对象,对象的属性没有顺序可言,不可以按你写的顺序一个个传进来
当然,更多的我觉得还是属性有可为空的情况,这种时候,还一个一个写,多捞啊,而且一个一个写,jsx写的时候也要按顺序一个一个写,这明显不人性化。
顺带一提, React.createElement 返回的是一颗虚拟 dom tree,这个时候,你的组件还不会被执行,组件执行机制是 react-dom 这个包来调节的,和 React.createElement 没有关系。你要记住的是,在渲染的时候,这个 dome tree 的 props 会传给你的函数组件就是了,而你的函数又返回另一个dom tree。
jsx的真面目其实就是一颗 虚拟 DOM tree。
看透jsx,组件就更好理解了。函数组件它还是个函数,以jsx的形式使用函数,其实就是jsx在渲染时函数会被调用。
再总结一下
返回jsx的函数是函数式组件 (组件的声明)
这样的组件又可以出现在其它的jsx中 (组件的使用)
说白了,无非就是js中函数的声明与调用
而class组件,就是js中类的声明与实例化、render方法的调用
虽然两者不能完全画上等号(不然还要组件这个概念干嘛),但实质上就是如此。
子组件与父组件的通讯
绕了一大圈,我们只讲了父组件与子组件的通讯,子组件怎么和父组件通讯呢?
实际上,该讲的我都讲完了,剩下的都是js的知识,还记得js中有个语法叫做回调函数吗,我们刚才传给name的是一个字符串,但如果是一个函数呢?
函数的调用时机由子组件控制,回调函数的作用域在父组件这里,这样不就实现了子组件与父组件的通讯了嘛。
function Parent () {
const NAME = 'PARENT'
return (
<Chil onClick={() => {
alert(`我是${NAME},子组件它刚才跟我说它被点了`)}
}> click me.</Chil>
)
}
function Chil(props) {
return <div onClick={props.onClick}>{props.children}</div>
}
因为是回调的关系,我们习惯以 on 开头来命名,这是react的潜规则,就像 vue 中子组件通知父组件是以事件 @ 来开头的一样。当然你也可以不遵守这个规则,因为它就是js,js并没有这样的限制,只是统一规范有利于代码的阅读。
说个题外话,因为js的回调已经可以实现这个功能,但vue还加了个事件的概念来 $emit,所以在vue中,你会发现,在vue中,即使不用 $emit,在子组件声明一个 props 的回调函数,也可以做到子组件向父组件通讯的效果。硬要说区别的话,$emit 在父组件没有监听这个事件的时候,$emit 也不会报错,但回调的形式就需要手动判断 props.onClick && props.onClick(),不过好在有最新语法,可选链,现在react调回调也没那么苦B了 props.onClick?.()
再拓展一下,React中有许多概念,但只是概念,没有新的api,这是因为,这全是js概念的组件版,这个概念放在文档中,其实只是提供一些情况的解决方案帮助开发者,本身react 并没有多做什么,像
render props 其实和回调一样,只是个函数,只不过它返回jsx,解决的是一个父组件想获取子组件状态来渲染视图的情况
而高阶组件呢,其实就是js中的闭包概念的react版,一个组件作为一个参数传入一个函数中,返回另一个新的组件。
未完待续。。。
下期接着讲组件的状态。(因为我们使用了函数式组件,所以会直接上 hook api,组件状态是组件的一个核心,此处也不能用js的知识来白嫖了,毕竟人家是组件,不是函数,总是有区别的。)
组件状态存储
倘若组件没有状态的存储,那react 可能就真成了函数式编程了,不过幸运的是,即使我们使用函数来声明组件,我们依旧可以让组件拥有状态,这一些得益于react 16 的新功能 react hook.
在学习 hook 之前,我们先来了解一下,什么是组件的内部状态。
组件的状态分两种,一种是外部的状态,也就是前面说的 props,组件的状态由外部控制;
还有一种就是内部状态,也就外部不能动态控制组件的行为,全部由组件自己内部做处理;
前者的写法,也被喻为 [受控组件],而后者,则被称为 [非受控组件]。
感受一下受控与非受控组件的区别
我们来举例一个经典的例子:input输入框
// 受控组件
<input value="1" />
// 非受控组件
<input defaultValue="1" />
前者表现为input的值永远是1,不管你在输入框中如何输入,键盘如何敲烂,它都是1,除非react 出bug。这有个很明显的表现,它完全受外部控制,外部说它的值是1,它就不能是2
后者则是你给input一个默认值,表现为 input 的值一开始是 1,但后面随着你的输入,input的值也会发生改变。它只接受一个默认值,甚至可以默认值都不给,后面无论你如何改变默认值,input也无动于衷。这有一个很明显的表现,它除了默认值外,后续的值将无法再控制。
好,我们再回来,无论是哪种组件,我们编写一个应用都不会离开组件的状态存储,如果一个应用连组件的状态存储都用不上,那和一张jpg又有什么区别?
非受控组件本身内部就需要组件的状态存储,而受控的组件而是把状态交给外部,当外部想修改这个状态时,同样也需要用到状态存储。
useState
先来一个最简单的[计数器]
import React, { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return <div onClick={() => setCount(count + 1)}>你点了 {count} 次。</div>
}
没错,就是这个人如其名的api,useState -> 使用状态。。。。。
使用解构声明得到的两个变量,分别是用来获取和修改状态的。
是不是很简单? 对,就是这么简单。
这时候,细心的同学可能会想到,组件可以复用,那如果两个相同的组件同时出现,它们的状态是否又会冲突?
答:不会,每个组件的内部,状态都是独立的,像这个例子
function Example() {
return (
<div>
<Counter />
<Counter />
</div>
)
}
你点击了上面的 Counter ,下面的Counter 仍显示[你点了0次。],而上面的Counter则[你显示点了1次。]
因此,编写组件时,只需要考虑好这个组件,无需想什么它会被用多少次什么的。
打开 useState 黑盒子
回到Counter这个例子,我们还有些东西没有搞清楚:我们调用了 setCount,这个 setCount 到底做了什么?
还记得之前说的组件是怎么渲染的吗? 其实就是把函数调用了一次,而 setCount 就是再把函数调用一次,达成了组件的更新操作。
这个时候,有人可能会问:第一次渲染是useState(0),第二次渲染也是 useState(0),怎么第一次渲染count是0,第二次渲染它就成1了,凭什么?不都是同一个函数吗?
这位同学问得很好,不过先坐下,别太激动了。我们回想一下,我是不是说过,react hook不是函数式编程来的,所以第二次调用结果和第一次不一样也说得过去。看个简单的例子
let count = 0
function getCount() {
return count
}
function setCount(newCount) {
count = newCount
}
getCount() // 第一次获取count: 0
setCount(count + 1)
getCount() // 第二次获取count: 1
是吧,调用同一个函数,得到不同结果完全是有可以的,setCount 函数也被称为有副作用的函数。
而react内部,每个函数式组件都有一个hook的状态存储列表,保证了每个组件状态的独立,以及下次渲染时能获得新的值。套用官方的话来解释一下:
每个组件内部都有一个「记忆单元格」列表。它们只不过是我们用来存储一些数据的 JavaScript 对象。当你用 useState() 调用一个 Hook 的时候,它会读取当前的单元格(或在首次渲染时将其初始化),然后把指针移动到下一个。这就是多个 useState() 调用会得到各自独立的本地 state 的原因。
从这段解释,我们还能知道,hook 只能在组件里面使用,而且一个组件里面可以写多个 hook。
既然组件各自独立,那么组件也会有它生命周期(创建、更新、销毁),未完待续。。
组件的生命周期
组件是有生命周期的,或者我们应该问,组件为什么要有生命周期?
在此之前,我们先思考一下,何为组件的生命周期?
组件的生命周期最主要的就是组件的挂载,更新以及卸载
通过前面的学习,我们知道 通过jsx可以通过 js 的&& 等语法控制组件的显示与隐藏,不过这和 display: none 这种显示与隐藏还不同,它会将某元素/组件 完全从dom树中移除,所以更精确来说,应该是 控制了组件的 挂载与卸载,组件加入 虚拟 dom tree 即为挂载,从虚拟dom tree从移除为卸载。至于更新,就是之前说的通过 useState 更新组件,组件会再次�被渲染更新。这样一来,三个主要的生命同期也就都有了。
现代主流前端框架,只要有组件的思想,不可避免的也会有这三个生命周期,至于「为什么一定要有? 」,这个问题很简单,如果组件连生命周期都没有了,那不就相当一个组件没有生命,我的天啊,你想想,如果组件没有生命,那之前的 Counter 组件将会永远更新不了,因为它没有生命周期,每次执行更新渲染操作,它都会认为是这是第一次渲染,然后 useState(0) 返回的 count 永远是0,因为它没有生命,每次渲染都将会被认为是初始化。当然,这一些都是假设,现在有了 react hook ,函数式组件也有生命周期,它就是 useEffect
useEffect
useEffect 的出现,缓解了函数式组件没有生命周期的尴尬
但因为组件生命周期的思想来源于更早的class组件的关系,将这三个生命组件设计成三个 hook 给函数式组件似乎又有些生拉硬扯。于是 useEffect 诞生了,一个综合了三个生命周期的hook出现了。
这个 hook 最多只做两件事:,清理上一个effect (如果有的话),然后执行此次渲染的effect (如果有的话)。
这样讲可能有些抽象,我们结合这个例子,分析一下:
function Hello() {
const [count, setCount] = useState(0)
useEffect(() => {
console.log('执行渲染effect: ' + count)
return () => console.log('清理上一个渲染effect: ' + count)
})
return <div onClick={() => setCount(count + 1)}>你点了 {count} 次。</div>
}
-> 挂载 这个 Hello 组件后 (挂载),
<- 会先打印出 '执行渲染effect: 0'
-> 点击一次组件 (更新)
<- 先打印 '清理上一个渲染effect: 0' ,再打印 '执行渲染effect: 1'
-> 卸载这个组件 (卸载)
<- 打印 '清理上一个渲染effect: 1'
如此看来,说它只做两件事也没有冤枉它吧? 挂载的时候,它没有上一个effect可以清理,所以只做第二件事,更新时做了两件事,然后就是卸载组件时没有执行渲染函数,所以也没有渲染effect,就只做了第一件事。
因为它最多只做两件事的缘故,所以不在useEffect里面return一个清理函数也是可以的。
但是话说回来,这个useEffect组件每次挂载/更新都会执行,似乎很不实用啊,比如我想在组件挂载后从服务器获取一些数据,然后更新数据,更新的过程中,又会触发effect,导致无限循环。
我们固然可以用useState设置变量,并在effect中使用if,根据变量来决定要不要执行effect,不过幸运的是,react已经帮我们做好了这一些,这就是第二个参数,它可以跳过某些effect,使它不会每次都执行。第二个参数是一个数组,在更新组件时,如果数组与上一次的数据浅比较相同,则跳过本次 effect。注意,挂载组件时一定会执行 effect,因为此时它还没有上一次的数据。
所以,上述需求就可以用第二个参数来实现,这里只要保证第二个参数永相同,就可以了:
function Hello() {
const [count, setCount] = useState(0)
useEffect(() => {
console.log('你尽管点,能执行两次算我输。')
}, []) // 看到没,这里多了个空数组
return <div onClick={() => setCount(count + 1)}>你点了 {count} 次。</div>
}
同理,第二个参数你也可以用 ['我永远是我'],但应该没人这样做,因为可以但没有必要,反正只要保证它里面的项永远相同,那么它就只在挂载时渲染,如果此时还有 return个清理函数,同样只在卸载时执行,至于为什么,同样可以用只做两件事的思路来分析:在卸载时,最近一个执行的effect是挂载,所以和它配套的清理函数自然称之为卸载周期。
如此一来,挂载、卸载、更新也都在useEffect中找到了。
什么? 你说我没有把更新的生命周期单独主拿出来?
这个,,,,可以,但没有必要。先来讲一个可能用到的情况:
比如现在我们有一个组件,是一个用户的详细信息展示
同理,挂载周期也可以填写变量参数,而不是一个空数组,因为它也可能需要在更新组件时重新执行。(这里建议配合eslint-plugin-react-hooks + 自动修正,可以自动填写更新依赖变量)
现在,我们再来思考:为什么 useEffect 返而更适合 函数式组件,而不是分别拆成三个hook (没有class组件基础请酌情阅读)?
首先,useEffect 的思考方式更适合函数式组件的思维方式,每次函数都会执行,它不像旧的class组件,生命周期写在 render 函数外面其次,从上面也可以看到,这种新的思维模式比原先的更棒,它可以写出更少代码。那为什么class组件不采用这种模式,要知道,这种便利得益于 useEffect 的第二个参数,而class组件的钩子自然无法传入第二个参数,除非使用不稳定的装饰器才能做到。而且目前class 组件还不能使用 hook,这也算是函数组件的一个优势吧。所以,现在,再重新审视 useEffect,它将三个生命周期融合成一个也在情理之中。
在编写函数式组件的生命周期时,我们只要思考, 这个effect 是否需要每次都执行,如果不是,就要加第二个参数,然后让eslint帮我们填好参数,万事大吉,如果eslint修正效果达我不要求,或者有更好的写法,这此我们再更改,如果还有什么副作用,比如setTimeout需要取消,就再加个清理函数。
而不是思考着,这个effect要在什么时候执行,是挂载呢,更新呢,还是卸载。
如果说元素的复用就是组件,那么 hook的复用又是什么呢。 下一期,讲 自定义hook,然后本帖也就告一段落。
自定义hook
所谓的自定义 hook,并不是什么新鲜的东西,如果说一个函数,不小心返回一段jsx而成为组件,那么自定义hook,就是一个函数,不小心使用了其它hook,而不得不成为自定义hook
组件的命名,我们约定使用大写开始,而自定义hook的命名,我们约定使用use开始
来一个很简单,但又很实用的,[生命周期:挂载] 的封装
function useDidMount(effect) {
useEffect(() => effect(), [])
}
function Hello () {
useDidMount(() => {
console.log('我挂载了')
})
return <div>hello</div>
}
就是这么简单。