(不是俺写的,资源分享!U老板牛逼)

从零开始的React.JS

React.js是如今最流行的前端框架之一,也是我们今天所要学习的内容。

Getting started

首先,我们需要创建一个React.js工程,为此我们使用官方的工具create-react-app来创建,而在此之前首先需要安装本工具。

1
$ npm install -g create-react-app
  • npmNode 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>

这段代码是不是很像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原生元素,比如divp之类的。接下来我们会看到,JSX最强大的地方之一在于,你可以定义自己的元素,来提高模块化程度、减少代码重复、提高开发效率。

请用下面的代码替换App.js的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react';
import './App.css';

function MyElement({ n }) {
return <div>{n + 1}</div>
}

function App() {
return (
<div className="App">
<MyElement n={0}/>
<MyElement n={1}/>
<MyElement n={2}/>
</div>
);
}

export default App;

保存,等待页面刷新,你应该会看到页面上显示了123

  • App里的JSX中,我们的元素变成了MyElement,这是我们自己定义的新元素。
  • 语法上,MyElement只是一个javascript函数,和其他任何函数没有区别。不过,当我们将他用于JSX的元素名时,React.js就会把他当作一个元素来使用。
  • 注意MyElement的参数,它接受一个对象,对象中的属性n正是在后面的JSX代码中作为参数传入的n。注意传给JSX元素的参数可以是任何类型,不一定得是HTML中那样的字符串。
  • 通常而言,自定义元素都应当以驼峰命名法命名,首字母大写。

如果我们需要一百个MyElement元素,那手写起来可能就比较累了,也许你会希望用代码来生成,那么请看下面的代码:

1
2
3
4
5
6
7
8
9
function App() {
return (
<div className="App">
{[0, 1, 2, 3].map(n => (
<MyElement n={n}/>
))}
</div>
);
}

保存运行,你应该会看到多出来了一个4

  • 大括号{}中包含的代码会被执行,达到一个React元素的数组,用作div元素的孩子,作为其children属性。
  • 大括号中的代码可以是任何javascript代码,不过一般不太使用控制流,另一个常见的用法是使用&&||运算符,来进行条件渲染。

最后,我们介绍JSX一些重要的细节。

  • JSX中,我们使用的是className来定义类名,而不是HTML中的class,这是因为JSX只是js的语法糖,而js不允许将class用作标识符。
  • 你可能会受到警告,说map中的对象没有key属性,这个我们以后有空再讲。

数据驱动、React Hooks和渲染流程

之前,我们提到了数据驱动。什么是数据驱动?打开b站,映入眼帘的是各种视频,显然这个网页不是手写的,它的内容也就是所谓的数据——是数据让页面拥有了血肉。

另外,前端中拥有各种各样的状态,比如“一个对话框的打开状态”、“动画播放当前的帧数”等等,这些数据显然不适合保存在服务器,我们需要一种方法在客户端保存、处理、更新他们,并且在这些状态改变时改变页面内容。

我们来看一个例子(替换App.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React, { useState } from 'react';
import './App.css';

function App() {
const [n, setN] = useState(0);
const plusOne = () => setN(n + 1);

return (
<div className="App">
<div>{n}</div>
<button onClick={plusOne}>CLICK ME</button>
</div>
);
}

export default App;

保存刷新,点击页面上的按钮,上面显示的数字不断增加。

  • useState()是一个React Hook,用于声明一个状态,括号中的参数是状态的初始值。
  • useState()的返回值是一个二元数组,第一个值是状态本身,第二个是一个用于改变状态的函数。
  • useState()是怎么工作的?说来话长。首先,React.js使用声明式编程,这将整个应用分为三部分,一部分是一组状态,或者说数据,另一部分是通过状态生成界面,最后一部分是用来更新状态的行为。作为声明式渲染的特征,状态本身是纯粹的数据,生成界面用的函数是引用透明的,而更新状态的行为只更新状态,别的什么都不做。这样就做到了很好的分划。
  • useState()的工作原理是,每次需要生成界面时,React.js调用App函数,并且保存一个上下文,而App函数调用useState()时,useState()会在上下文中寻找上一次调用时的状态,作为这次的返回值——除非你用setN在之前主动改变了状态。
  • 本质上来说,useState()是一个补丁,让你忽略这个状态实际存在于App函数之内,从而你可以把App中的JSX部分写成一个引用透明的东西,虽然严格意义上整个函数其实并不是引用透明的。这里有历史的原因,也有js语法的局限。
  • 从较高的视角上,状态与渲染过程独立,不过实际上整个应用经常会被切成多个元素,每个元素内是一个“状态-渲染-更新”的三元对。

js有个毛病,就是——const只表示引用不变,不保证内容不改变。比如下面这段:

1
2
3
4
5
const a = { foo: 1 };
const a1 = a;
a.foo = 2;
const a2 = a;
console.log(a1 === a2); // true

a的内容改变了,然而其引用本身没有改变,在React.js中这就会导致一个问题:我们很难知道应用的状态是否发生了改变,从而应当重新渲染生成界面,因为做深比较是一件很耗时的事情。

因此,我们希望——任何内容的变化都应当导致引用的变化,这样只要对比引用即可,比如:

1
2
3
4
5
6
function MyElement() {
const [a, setA] = useState({ foo: 1 });
const { foo } = a;
const update = () => setA({ foo: foo + 1 });
return <button onClick={update}>{foo}</button>;
}

这里,每次setA()的参数都是一个全新创建的对象,这很好地满足了我们的目的。然而,还有另一个问题,就是我们不希望在内容未改变时就改变引用,比如:

1
2
3
4
5
6
7
8
9
10
function MyElement() {
const [a, setA] = useState(0);
const updateA = () => setA(a + 1);
const [b, setB] = useState(0);
const updateB = () => setB(b + 1);
return <div>
<button onClick={updateA}>{a}</button>
<button onClick={updateB}>{b}</button>
</div>;
}

我们这里创建了两个状态,对应两个按钮,然而如果你点击第一个按钮,导致a加一,setA()的调用会导致React重新渲染改元素,即重新执行MyElement函数,而由于js本身的语法,updateB也会是一个全新的lambda函数。然而,无论是b的值还是setB的功能(实际上setB的引用也)都没有改变,明明没有必要重新创建updateB函数。

新的函数导致了新的引用,导致第二个按钮的onClick参数发生改变,需要被重新渲染,导致了性能的浪费。这不是我们想要的,因此我们应该使用useCallback

1
2
3
4
5
6
7
8
9
10
11
12
import React, { useState, useCallback } from 'react';

function MyElement() {
const [a, setA] = useState(0);
const updateA = useCallback(() => setA(a + 1), [a]);
const [b, setB] = useState(0);
const updateB = useCallback(() => setB(b + 1), [b]);
return <div>
<button onClick={updateA}>{a}</button>
<button onClick={updateB}>{b}</button>
</div>;
}
  • 现在,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元素树,这被称作DOMDocument 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,应当渲染到#rootDOM元素处。因此,React调用App函数,App函数调用useState创建一个状态,并赋予它一个初始值,然后根据这个状态用JSX生成一颗VDOM树。

React检查VDOM树,找到其中所有非原生的元素,然后对他们再一一展开,重复这一过程,直到得到一颗仅包括原生元素的VDOM树,这样React就可以将他转换成DOM树(或者说用于生成DOM树的操作),应用到实际的页面上,成为了我们看到的内容。

当用户点击第一个按钮时,浏览器调用对应DOM元素的onclick属性,在React的包装下,它会调用了App元素中的updateA函数,而updateA又会调用setAsetA告诉React有一个状态需要更新,并且这个更新发生于App元素(也就是最初创建这个状态的元素),然后React就会更新状态,然后用新的状态重新运行App函数,得到新的VDOM树,对比得到一组DOM操作用于更新界面。

这就是React.js中的渲染流程。

Demystifying Frontend Build Process

在运行npm start之后,你会看到这样的内容:

1
2
3
4
5
6
7
8
Compiled successfully!

You can now view my-react-app in the browser.

Local: http://localhost:3000/

Note that the development build is not optimized.
To create a production build, use npm run build.

你也许认识Compile这个词,是“编译”的意思,但是这不是js么,为什么需要编译呢?

事实上,比“编译”更好的说法是“构建”,即从项目源代码构建出用于部署的目标文件。在现代前端中,我们往往使用静态的前端代码,一切数据都在页面以及前端代码加载之后,通过调用API从后端获得,而非在访问时就嵌入到网页中,因此我们的“目标文件”实际上就是HTMLJSCSS文件。

你可能会说,但是我一开始写的不就是这些文件吗?事实上,构建系统有几个核心功能:

  • js文件中的import代码为引导,找出全部被依赖的模块,并将它们打包成一个bundle文件,作为目标文件。
  • bundle文件本质上是个js文件,CSS代码往往会内嵌在里面,同时还会生成一个简单的HTML文件,负责导入该js文件。
  • 其次,构建系统可以被配置来对各种文件类型做处理,比如将md文件预先渲染成HTML,将图片转换成各种格式之类的。
  • 另外,构建系统需要复制所依赖的各种媒体文件,保证便于在代码中使用的是同时也能在生成的目标文件中访问。有时,也可以将CSS代码整合成一个独立的文件,放在和其他媒体文件的同一个位置。
  • 构建系统是通过webpack实现的,配置脚本由react-scripts包提供,你可以用npm run eject导出配置脚本,可以自己编辑。
  • npm start所启动的开发服务器中运行的代码是未优化的版本,你也可以运行npm run build来生成一个优化过的、用于部署的版本。具体的部署相关知识,我们会在之后的内训中涉及到。

那么,我们本次的React.js入门介绍就到这里了。

以上。