从零开始的React.JS
(不是俺写的,资源分享!U老板牛逼)
从零开始的React.JS
React.js是如今最流行的前端框架之一,也是我们今天所要学习的内容。
Getting started
首先,我们需要创建一个React.js工程,为此我们使用官方的工具create-react-app
来创建,而在此之前首先需要安装本工具。
1 | $ npm install -g create-react-app |
npm
是Node Package Manager,负责管理Node.js下的各种包。install
不解释,-g
是global,即安装到全局。create-react-app
是安装的包名,注意不要打错。- 有时命令的执行会很慢,这时可以执行
npm config edit
,用默认的编辑器打开npm
的配置文件,在开头加上一行registry=https://registry.npm.taobao.org/
,这样就可以使用淘宝的npm
仓库镜像了,会快很多。
接着,cd
到一个方便写代码的地方,然后:
1 | $ create-react-app my-react-app |
create-react-app
是一个可执行文件,在linux
环境下可以用which create-react-app
查看其所在的位置。- 这个文件是我们刚刚的命令所安装的,这也是
npm install -g
的主要目的,即安装某个工具。 - 这个文件实际上是一个Node.js脚本,这意味着它需要Node.js的存在来运行。另外,如果你使用某种Node.js版本管理工具(比如
nvm
)的话,那你很可能需要在更新Node.js版本之后重新安装之前装过的包。 create-react-app
的名字和之前安装的包名一模一样,这并不是巧合,一般而言所安装的命令名称都和包名相同。- 这条命令会在当前目录下创建一个叫做
my-react-app
的文件夹,里面有一个完整的工程,以及Node.js的包描述文件package.json
和一个空的git
仓库。
最后,我们再cd
进入新的工程,运行:
1 | $ npm start |
- 这个命令用
npm
运行了start
脚本,也就是package.json
里面的start
,一般它的内容是react-scripts start
。 - 稍等一段时间,你就会看到默认浏览器中打开了一个页面,地址应该是
http://localhost:3000/
,里面是一个正在运行的React.js应用。 - 你可以修改
src/App.js
(以my-react-app
目录为基础),在保存之后,不需要做任何多余的工作,之前的网页就会自动更新内容。如果代码有错误的话,页面上也会提示。 - 这是因为
npm start
实际上启动了一个dev server
,它会自动侦听src
文件夹中的更改,并且自动构建并刷新页面。不过,如果你修改了src
文件夹以外的内容,那你有可能需要重启一下npm start
。
恭喜你,你迈出了通向React的第一步!
JSX和JS
观察src/App.js
:
1 | <div className="App"> |
这段代码是不是很像HTML
代码?
- 这种语法叫做
JSX
,它内嵌于javascript
代码中,作为一种特殊的表达式存在。当然,他不是javascript
本身的语法,如果不使用构建工具(之后再解释)的话就会出错。 - 这种语法的目的就是为了模拟
HTML
,以一种“所见即所得”的形式构建GUI,大大提高开发效率。这就叫声明式编程,declarative programming。 JSX
中可以内嵌javascript
代码,很容易实现两者之间的相互操作,执行表达式,将结果作为所显示的内容,比如<div>{n}</div>
。这一点符合数据驱动的特征。JSX
本质上是一种语法糖,最终是调用了React.js的API,因此使用JSX
的时候需要导入React
,也就是App.js
中第一行的import React from 'react';
。
现在,你可以随意修改App.js
中的代码,改改文字,执行一些计算等等,然后看看会发生什么。
上面的JSX
代码中,我们所使用到的元素都是HTML
原生元素,比如div
、p
之类的。接下来我们会看到,JSX
最强大的地方之一在于,你可以定义自己的元素,来提高模块化程度、减少代码重复、提高开发效率。
请用下面的代码替换App.js
的内容:
1 | import React from 'react'; |
保存,等待页面刷新,你应该会看到页面上显示了1
、2
和3
。
- 在
App
里的JSX
中,我们的元素变成了MyElement
,这是我们自己定义的新元素。 - 语法上,
MyElement
只是一个javascript
函数,和其他任何函数没有区别。不过,当我们将他用于JSX
的元素名时,React.js就会把他当作一个元素来使用。 - 注意
MyElement
的参数,它接受一个对象,对象中的属性n
正是在后面的JSX
代码中作为参数传入的n
。注意传给JSX
元素的参数可以是任何类型,不一定得是HTML
中那样的字符串。 - 通常而言,自定义元素都应当以驼峰命名法命名,首字母大写。
如果我们需要一百个MyElement
元素,那手写起来可能就比较累了,也许你会希望用代码来生成,那么请看下面的代码:
1 | function App() { |
保存运行,你应该会看到多出来了一个4
。
- 大括号
{}
中包含的代码会被执行,达到一个React元素的数组,用作div
元素的孩子,作为其children
属性。 - 大括号中的代码可以是任何
javascript
代码,不过一般不太使用控制流,另一个常见的用法是使用&&
和||
运算符,来进行条件渲染。
最后,我们介绍JSX
一些重要的细节。
- 在
JSX
中,我们使用的是className
来定义类名,而不是HTML
中的class
,这是因为JSX
只是js
的语法糖,而js
不允许将class
用作标识符。 - 你可能会受到警告,说
map
中的对象没有key
属性,这个我们以后有空再讲。
数据驱动、React Hooks和渲染流程
之前,我们提到了数据驱动。什么是数据驱动?打开b站,映入眼帘的是各种视频,显然这个网页不是手写的,它的内容也就是所谓的数据——是数据让页面拥有了血肉。
另外,前端中拥有各种各样的状态,比如“一个对话框的打开状态”、“动画播放当前的帧数”等等,这些数据显然不适合保存在服务器,我们需要一种方法在客户端保存、处理、更新他们,并且在这些状态改变时改变页面内容。
我们来看一个例子(替换App.js
):
1 | import React, { useState } from 'react'; |
保存刷新,点击页面上的按钮,上面显示的数字不断增加。
useState()
是一个React Hook,用于声明一个状态,括号中的参数是状态的初始值。useState()
的返回值是一个二元数组,第一个值是状态本身,第二个是一个用于改变状态的函数。useState()
是怎么工作的?说来话长。首先,React.js使用声明式编程,这将整个应用分为三部分,一部分是一组状态,或者说数据,另一部分是通过状态生成界面,最后一部分是用来更新状态的行为。作为声明式渲染的特征,状态本身是纯粹的数据,生成界面用的函数是引用透明的,而更新状态的行为只更新状态,别的什么都不做。这样就做到了很好的分划。useState()
的工作原理是,每次需要生成界面时,React.js调用App
函数,并且保存一个上下文,而App
函数调用useState()
时,useState()
会在上下文中寻找上一次调用时的状态,作为这次的返回值——除非你用setN
在之前主动改变了状态。- 本质上来说,
useState()
是一个补丁,让你忽略这个状态实际存在于App
函数之内,从而你可以把App
中的JSX
部分写成一个引用透明的东西,虽然严格意义上整个函数其实并不是引用透明的。这里有历史的原因,也有js
语法的局限。 - 从较高的视角上,状态与渲染过程独立,不过实际上整个应用经常会被切成多个元素,每个元素内是一个“状态-渲染-更新”的三元对。
js
有个毛病,就是——const
只表示引用不变,不保证内容不改变。比如下面这段:
1 | const a = { foo: 1 }; |
a
的内容改变了,然而其引用本身没有改变,在React.js中这就会导致一个问题:我们很难知道应用的状态是否发生了改变,从而应当重新渲染生成界面,因为做深比较是一件很耗时的事情。
因此,我们希望——任何内容的变化都应当导致引用的变化,这样只要对比引用即可,比如:
1 | function MyElement() { |
这里,每次setA()
的参数都是一个全新创建的对象,这很好地满足了我们的目的。然而,还有另一个问题,就是我们不希望在内容未改变时就改变引用,比如:
1 | function MyElement() { |
我们这里创建了两个状态,对应两个按钮,然而如果你点击第一个按钮,导致a
加一,setA()
的调用会导致React重新渲染改元素,即重新执行MyElement
函数,而由于js
本身的语法,updateB
也会是一个全新的lambda
函数。然而,无论是b
的值还是setB
的功能(实际上setB
的引用也)都没有改变,明明没有必要重新创建updateB
函数。
新的函数导致了新的引用,导致第二个按钮的onClick
参数发生改变,需要被重新渲染,导致了性能的浪费。这不是我们想要的,因此我们应该使用useCallback
:
1 | import React, { useState, useCallback } from 'react'; |
- 现在,
a
的值变化只会导致updateA
的引用变化,不会导致updateB
的引用变化,这样第二个按钮就不需要重新渲染了。 useCallback
的第一个参数就是之前的lambda
函数,而后面数组里的a
是这个函数所依赖的变量,如果这里面有值(的引用)发生变化,就会导致返回值的变化,反之如果这里面的值不变,React也会保证返回值引用不变。- 这里要注意的是,由于
js
的语法,每次运行MyElement
函数时,无论变化的是a
还是b
,() => setA(a + 1)
这个表达式都会被执行,生成一个lambda
函数,useCallback
并不能防止这件事的发生。 useCallback
的意义在于,它将引用变化和内容变化同步,减少了重新渲染的次数,因为React如果发现某个元素的所有参数引用都没有变化,就会复用之前渲染的结果,从而优化性能。- 由于
js
的闭包性质,useCallback
的依赖项数组中应当包括所有函数所依赖的变量,无论你是否觉得它永远不会改变,具体的原因我们这里不多解释,读者可以自行学习。事实上,如今的eslint
已经能够提示你是否依赖了过少或者过多的项。
还有一些其他的Hook,不过总的思路和原理都类似,这里就先不展开了。
最后,让我们理解一下React这类前端框架工作的一个重要原理。
当你喂给浏览器一段HTML
代码时,它会将其解析为一颗HTML
元素树,这被称作DOM
,Document Object Model。在古代,我们从一颗初始的树出发,然后用js
动态更新元素的内容和属性,或者创建和删除元素。
然而,这些操作(称作DOM
操作)的性能消耗很大,我们需要尽可能少地执行执行DOM
操作,尤其在一个非常复杂的前端应用中的时候。理想的情况是,我们可以得知每个变化前后的DOM
树状态,然后使用某种diff
算法找出最少的变化。
这,正是React的主要功能之一。
实际上,在我们写的JSX
经过构建时,它会被展开成许多React.createElement
调用,而这一调用并非直接创建DOM
元素,而是创造某种纯js
的数据结构,一般称作Virtual DOM,或VDOM
。
由于VDOM
是纯js
的,它的创建、更改等操作成本都很低,因此React可以对比状态改变前后的两颗VDOM
树,找出其中的变化,将变化翻译为DOM
操作,应用到实际的DOM
树上。
不过,完美的diff
算法时间复杂度太高,对此React用了一系列假设来加速对比,比如在一个元素的所有参数引用都没有改变时就默认渲染结果不会改变,无需进行对比。这正是为什么我们要求元素的渲染过程引用透明,并且要求内容改变和引用改变的同步。
现在,让我们拼上最后一块缺失的拼图。
查看index.js
,你会看到:
1 | ReactDOM.render(<App />, document.getElementById('root')); |
显然,这是在告诉React,根元素是App
,应当渲染到#root
的DOM
元素处。因此,React调用App
函数,App
函数调用useState
创建一个状态,并赋予它一个初始值,然后根据这个状态用JSX
生成一颗VDOM
树。
React检查VDOM
树,找到其中所有非原生的元素,然后对他们再一一展开,重复这一过程,直到得到一颗仅包括原生元素的VDOM
树,这样React就可以将他转换成DOM
树(或者说用于生成DOM
树的操作),应用到实际的页面上,成为了我们看到的内容。
当用户点击第一个按钮时,浏览器调用对应DOM
元素的onclick
属性,在React的包装下,它会调用了App
元素中的updateA
函数,而updateA
又会调用setA
。setA
告诉React有一个状态需要更新,并且这个更新发生于App
元素(也就是最初创建这个状态的元素),然后React就会更新状态,然后用新的状态重新运行App
函数,得到新的VDOM
树,对比得到一组DOM
操作用于更新界面。
这就是React.js中的渲染流程。
Demystifying Frontend Build Process
在运行npm start
之后,你会看到这样的内容:
1 | Compiled successfully! |
你也许认识Compile这个词,是“编译”的意思,但是这不是js
么,为什么需要编译呢?
事实上,比“编译”更好的说法是“构建”,即从项目源代码构建出用于部署的目标文件。在现代前端中,我们往往使用静态的前端代码,一切数据都在页面以及前端代码加载之后,通过调用API
从后端获得,而非在访问时就嵌入到网页中,因此我们的“目标文件”实际上就是HTML
、JS
和CSS
文件。
你可能会说,但是我一开始写的不就是这些文件吗?事实上,构建系统有几个核心功能:
- 以
js
文件中的import
代码为引导,找出全部被依赖的模块,并将它们打包成一个bundle
文件,作为目标文件。 bundle
文件本质上是个js
文件,CSS
代码往往会内嵌在里面,同时还会生成一个简单的HTML
文件,负责导入该js
文件。- 其次,构建系统可以被配置来对各种文件类型做处理,比如将
md
文件预先渲染成HTML
,将图片转换成各种格式之类的。 - 另外,构建系统需要复制所依赖的各种媒体文件,保证便于在代码中使用的是同时也能在生成的目标文件中访问。有时,也可以将
CSS
代码整合成一个独立的文件,放在和其他媒体文件的同一个位置。 - 构建系统是通过
webpack
实现的,配置脚本由react-scripts
包提供,你可以用npm run eject
导出配置脚本,可以自己编辑。 - 用
npm start
所启动的开发服务器中运行的代码是未优化的版本,你也可以运行npm run build
来生成一个优化过的、用于部署的版本。具体的部署相关知识,我们会在之后的内训中涉及到。
那么,我们本次的React.js入门介绍就到这里了。
以上。