使用React我们首先要知道如何传递数据,组件如何沟通,才能展示我们想要的数据。下面的列子都是使用ES6语法,不懂的同学需要先学习ES6语法。

数据流

React是单向数据流,从父节点传递到子节点(通过props)。如果顶层的某个props改变了,React会重渲染所有的子节点(未做性能优化)。严格意义上React只提供,也强烈建议使用这种数据交流方式。

Props

props是property的缩写,可以理解为HTML标签的attribute。请把props当做只读的(不可以使用this.props直接修改props),props是用于整个组件树中传递数据和配置。在当前组件访问props,使用this.props。在什么情况下可以使用props,请看组件生命周期

1
2
3
4
5
6
7
8
9
10
11
class Component {
constructor(props){
super(props);
}
render(){
return (
<div title={this.props.title}></div>
)
}
}
<Component title="test"/>//调用title就传进去了

PropTypes

PropsTypes是React中用来定义props的类型,不符合定义好的类型会报错。建议可复用组件要使用prop验证!接着上面的列子设置PropsTypes如下:

1
2
3
4
5
6
class Component {
...
}
Component.PropsType = {
title: React.PropTypes.string,
}

React.PropTypes 提供很多验证器 (validator) 来验证传入数据的有效性。官方定义的验证器如下,不是使用ES6语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
React.createClass({
propTypes: {
// 可以声明 prop 为指定的 JS 基本类型。默认
// 情况下,这些 prop 都是可传可不传的。
optionalArray: React.PropTypes.array,
optionalBool: React.PropTypes.bool,
optionalFunc: React.PropTypes.func,
optionalNumber: React.PropTypes.number,
optionalObject: React.PropTypes.object,
optionalString: React.PropTypes.string,
optionalSymbol: React.PropTypes.symbol,

// 所有可以被渲染的对象:数字,
// 字符串,DOM 元素或包含这些类型的数组(or fragment) 。
optionalNode: React.PropTypes.node,

// React 元素
optionalElement: React.PropTypes.element,

// 你同样可以断言一个 prop 是一个类的实例。
// 用 JS 的 instanceof 操作符声明 prop 为类的实例。
optionalMessage: React.PropTypes.instanceOf(Message),

// 你可以用 enum 的方式
// 确保你的 prop 被限定为指定值。
optionalEnum: React.PropTypes.oneOf(['News', 'Photos']),

// 指定的多个对象类型中的一个
optionalUnion: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number,
React.PropTypes.instanceOf(Message)
]),

// 指定类型组成的数组
optionalArrayOf: React.PropTypes.arrayOf(React.PropTypes.number),

// 指定类型的属性构成的对象
optionalObjectOf: React.PropTypes.objectOf(React.PropTypes.number),

// 特定形状参数的对象
optionalObjectWithShape: React.PropTypes.shape({
color: React.PropTypes.string,
fontSize: React.PropTypes.number
}),

// 你可以在任意东西后面加上 `isRequired`
// 来确保 如果 prop 没有提供 就会显示一个警告。
requiredFunc: React.PropTypes.func.isRequired,

// 不可空的任意类型
requiredAny: React.PropTypes.any.isRequired,

// 你可以自定义一个验证器。如果验证失败需要返回一个 Error 对象。
// 不要直接使用 `console.warn` 或抛异常,
// 因为这在 `oneOfType` 里不起作用。
customProp: function(props, propName, componentName) {
if (!/matchme/.test(props[propName])) {
return new Error('Validation failed!');
}
}
},
/* ... */
});

defaultProps

如何设置组件默认的props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//React提供的crateClass创建方式
var Component = React.createClass({
getDefaultProps(){
return {
//这里设置defaultProps
}
}
})
//ES6
class Component {
...
}
Component.defaultProps = {}
//ES7 stage-0
class Component {
static defaultProps = {

}
...
}

state

每个组件都有属于自己的statestateprops的区别在于前者之只存在于组件内部,只能从当前组件调用this.setState修改state值(不可以直接修改this.state)。一般我们更新子组件都是通过改变state值,更新新子组件的props值从而达到更新。

那如何设置默认state?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//React提供的crateClass创建方式
var Component = React.createClass({
getInitialState(){
return {
//这里设置初始state值
}
}
})
//ES6 && ES7
class Component {
constructor(){
this.state = {}//在ES6中的构造函数中初始化,可以之直接赋值,在其他方法中,只能使用this.setState
}
...
}

props和state使用方式

尽可能使用props当做数据源,state用来存放状态值(简单的数据),如复选框、下拉菜单等。

组件沟通

组件沟通因为React的单向数据流方式会有所限制,下面述说组件之间的沟通方式。

父子组件沟通

这种方式是最常见的,也是最简单的。

  • 父组件更新组件状态

父组件更新子组件状态,通过传递props,就可以了。

  • 子组件更新父组件状态

这种情况需要父组件传递回调函数给子组件,子组件调用触发即可。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Child extends React.Component{
constructor(props){
super(props);
this.state = {}
}

render(){
return (
<div>
{this.props.text}
<br />
<button onClick={this.props.refreshParent}>
更新父组件
</button>
</div>
)
}
}
class Parent extends React.Component{
constructor(props){
super(props);
this.state = {}
}
refreshChild(){
return (e)=>{
this.setState({
childText: "父组件沟通子组件成功",
})
}
}
refreshParent(){
this.setState({
parentText: "子组件沟通父组件成功",
})
}
render(){
return (
<div>
<h1>父子组件沟通</h1>
<button onClick={this.refreshChild()} >
更新子组件
</button>
<Child
text={this.state.childText || "子组件未更新"}
refreshParent={this.refreshParent.bind(this)}
/>
{this.state.parentText || "父组件未更新"}
</div>
)
}
}

codepen例子React组件之父子组件沟通 。

兄弟组件沟通

当两个组件有相同的父组件时,就称为兄弟组件(堂兄也算的)。按照React单向数据流方式,我们需要借助父组件进行传递,通过父组件回调函数改变兄弟组件的props

方式一

通过props传递父组件回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Brother1 extends React.Component{
constructor(props){
super(props);
this.state = {}
}

render(){
return (
<div>
<button onClick={this.props.refresh}>
更新兄弟组件
</button>
</div>
)
}
}
class Brother2 extends React.Component{
constructor(props){
super(props);
this.state = {}
}

render(){
return (
<div>
{this.props.text || "兄弟组件未更新"}
</div>
)
}
}
class Parent extends React.Component{
constructor(props){
super(props);
this.state = {}
}
refresh(){
return (e)=>{
this.setState({
text: "兄弟组件沟通成功",
})
}
}
render(){
return (
<div>
<h2>兄弟组件沟通</h2>
<Brother1 refresh={this.refresh()}/>
<Brother2 text={this.state.text}/>
</div>
)
}
}

codepen例子:React组件之兄弟组件沟通

方式二

但是如果组件层次太深(如下图),上面的兄弟组件沟通方式就效率低了(不建议组件层次太深)。

React提供了一种上下文方式(挺方便的),可以让子组件直接访问祖先的数据或函数,无需从祖先组件一层层地传递数据到子组件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class Brother1 extends React.Component{
constructor(props){
super(props);
this.state = {}
}

render(){

return (
<div>
<button onClick={this.context.refresh}>
更新兄弟组件
</button>
</div>
)
}
}
Brother1.contextTypes = {
refresh: React.PropTypes.any
}
class Brother2 extends React.Component{
constructor(props){
super(props);
this.state = {}
}

render(){
return (
<div>
{this.context.text || "兄弟组件未更新"}
</div>
)
}
}
Brother2.contextTypes = {
text: React.PropTypes.any
}
class Parent extends React.Component{
constructor(props){
super(props);
this.state = {}
}

getChildContext(){
return {
refresh: this.refresh(),
text: this.state.text,
}
}

refresh(){
return (e)=>{
this.setState({
text: "兄弟组件沟通成功",
})
}
}
render(){
return (
<div>
<h2>兄弟组件沟通</h2>
<Brother1 />
<Brother2 text={this.state.text}/>
</div>
)
}
}
Parent.childContextTypes = {
refresh: React.PropTypes.any,
text: React.PropTypes.any,
}

codepen例子:React组件之兄弟组件沟通2

全局事件

For communication between two components that don’t have a parent-child relationship, you can set up your own global event system. Subscribe to events in componentDidMount(), unsubscribe in componentWillUnmount(), and call setState() when you receive an event.Flux pattern is one of the possible ways to arrange this.

官网中提到可以使用全局事件来进行组件间的通信,官网推荐Flux(Facebook官方出的),还有Relay、Redux、trandux等第三方类库。这些框架思想都一致,都是统一管理组件state变化情况,达到数据可控目的。本人使用了Redux,建议要会其中一种。对于EventEmitter或PostalJS这类的第三方库是不建议使用的,这类全局事件框架并没有统一管理组件数据变化,用多了会导致数据流不可控。

这里就不细说,请选择其中一种类库,深入学习下。

总结

简单的组件交流我们可以使用上面非全局事件的简单方式,但是当项目复杂,组件间层次越来越深,上面的交流方式就不太合适(当然还是要用到的,简单的交流)。强烈建议使用Flux、Relay、Redux、trandux等类库其中一种,这些类库不只适合React,像Angular等都可以使用。

留言與分享

React组件生命周期

分類 编程语言, React

此文章适合 React@17 之前的版本,React@16.3.0,添加了一些新的生命周期函数,同时准备废弃一些会造成困扰的生命周期函数。所有如果在React@17 发布之前,这篇文章还是适用的。
新的生命周期请看官网 blog 文章 React v16.3.0: New lifecycles and context API

前言

组件会随着组件的 propsstate 改变而发生变化,它的 DOM 也会有相应的变化。

一个组件就是一个状态机:对于特定的输入,它总会返回一致的输出。

React组件提供了生命周期钩子函数去响应组件不同时刻的状态,组件的生命周期如下:

  • 实例化
  • 存在期
  • 销毁期

钩子函数是我们重点关注的地方,下面来详细了解下生命周期下的钩子函数调用顺序和作用。每个生命周期阶段调用的钩子函数会略有不同。下面的图片或许对你有帮助。

图片描述

可以查看 CodePen 在线 Demo React 生命周期

实例化

首次调用组件时,有以下方法会被调用(注意顺序,从上到下先后执行):

  • getDefaultProps

    这个方法是用来设置组件默认的 props,组件生命周期只会调用一次。但是只适合 React.createClass 直接创建的组件,使用 ES6/ES7 创建的这个方法不可使用, ES6/ES7 可以使用下面方式:

    1
    2
    3
    4
    5
    6
    // es7
    class Component {
    static defaultProps = {}
    }
    // 或者也可以在外面定义es6
    // Compnent.defaultProps
  • getInitialState

    设置state初始值,在这个方法中你已经可以访问到 this.propsgetInitialState 只适合 React.createClass 使用。使用 ES6 初始化state方法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    class Component extends React.Component{
    constructor(props){
    super(props);
    this.state = {
    render: true,
    }
    }
    }

    或者这样

    1
    2
    3
    4
    5
    6
    class Component extends React.Component{
    state = {
    render: true
    }
    render(){return false;}
    }
  • componentWillMount

    改方法会在组件首次渲染之前调用,这个是在 render 方法调用前可修改 state 的最后一次机会。这个方法很少用到。

  • render

    这个方法以后大家都应该会很熟悉,JSX 通过这里,解析成对应的虚拟 DOM,渲染成最终效果。格式大致如下:

    1
    2
    3
    4
    5
    6
    7
    class Component extends React.Component{
    render(){
    return (
    <div></div>
    )
    }
    }

  • componentDidMount

    这个方法在首次真实的 DOM 渲染后调用(仅此一次)当我们需要访问真实的 DOM 时,这个方法就经常用到。如何访问真实的 DOM 这里就不想说了。当我们需要请求外部接口数据,一般都在这里处理。

存在期

实例化后,当props或者state发生变化时,下面方法依次被调用:

  • componentWillReceiveProps

    没当我们通过父组件更新子组件 props 时(这个也是唯一途径),这个方法就会被调用。

    1
    componentWillReceiveProps(nextProps){}
  • shouldComponentUpdate

    字面意思,是否应该更新组件,默认返回 true。当返回 false 时,后期函数就不会调用,组件不会在次渲染。

    1
    shouldComponentUpdate(nextProps,nextState){}
  • componentWillUpdate

    字面意思组件将会更新,propsstate 改变后必调用。

  • render

    跟实例化时的render一样,不多说

  • componentDidUpdate

    这个方法在更新真实的 DOM 成功后调用,当我们需要访问真实的 DOM 时,这个方法就也经常用到。

销毁期

销毁阶段,只有一个函数被调用:

  • componentWillUnmount

    没当组件使用完成,这个组件就必须从DOM中销毁,此时该方法就会被调用。当我们在组件中使用了 setInterval,那我们就需要在这个方法中调用 clearInterval。如果手动使用了 addEventListener 绑定了事件,也需要解绑事件。

留言與分享

React入门教程

分類 编程语言, React

react - JSX

React 背景介绍

React 起源于 Facebook 的内部项目,因为该公司对市场上所有 JavaScript MVC 框架,都不满意,就决定自己写一套,用来架设 Instagram 的网站。做出来以后,发现这套东西很好用,就在2013年5月开源了。

什么是React

  • A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES

    • 用来构建UI的 JavaScript库
    • React 不是一个 MVC 框架,仅仅是视图(V)层的库
  • React 官网

  • React 中文文档

特点

  • 1 使用 JSX语法 创建组件,实现组件化开发,为函数式的 UI 编程方式打开了大门
  • 2 性能高的让人称赞:通过 diff算法虚拟DOM 实现视图的高效更新
  • 3 HTML仅仅是个开始
1
2
3
4
5
6
> JSX --TO--> EveryThing

- JSX --> HTML
- JSX --> native ios或android中的组件(XML)
- JSX --> VR
- JSX --> 物联网

为什么要用React

  • 1 使用组件化开发方式,符合现代Web开发的趋势
  • 2 技术成熟,社区完善,配件齐全,适用于大型Web项目(生态系统健全)
  • 3 由Facebook专门的团队维护,技术支持可靠
  • 4 ReactNative - Learn once, write anywhere: Build mobile apps with React
  • 5 使用方式简单,性能非常高,支持服务端渲染
  • 6 React非常火,从技术角度,可以满足好奇心,提高技术水平;从职业角度,有利于求职和晋升,有利于参与潜力大的项目

React中的核心概念

  • 1 虚拟DOM(Virtual DOM)
  • 2 Diff算法(虚拟DOM的加速器,提升React性能的法宝)

虚拟DOM(Vitural DOM)

React将DOM抽象为虚拟DOM,虚拟DOM其实就是用一个对象来描述DOM,通过对比前后两个对象的差异,最终只把变化的部分重新渲染,提高渲染的效率

为什么用虚拟dom,当dom反生更改时需要遍历 而原生dom可遍历属性多大231个 且大部分与渲染无关 更新页面代价太大

VituralDOM的处理方式

  • 1 用 JavaScript 对象结构表示 DOM 树的结构,然后用这个树构建一个真正的 DOM 树,插到文档当中
  • 2 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异
  • 3 把2所记录的差异应用到步骤1所构建的真正的DOM树上,视图就更新了

Diff算法

当你使用React的时候,在某个时间点 render() 函数创建了一棵React元素树,
在下一个state或者props更新的时候,render() 函数将创建一棵新的React元素树,
React将对比这两棵树的不同之处,计算出如何高效的更新UI(只更新变化的地方)

1
2
3
4
<!-- 了解:

有一些解决将一棵树转换为另一棵树的最小操作数算法问题的通用方案。然而,树中元素个数为n,最先进的算法 的时间复杂度为O(n3) 。
如果直接使用这个算法,在React中展示1000个元素则需要进行10亿次的比较。这操作太过昂贵,相反,React基于两点假设,实现了一个O(n)算法,提升性能: -->
  • React中有两种假定:

    • 1 两个不同类型的元素会产生不同的树(根元素不同结构树一定不同)
    • 2 开发者可以通过key属性指定不同树中没有发生改变的子元素

Diff算法的说明 - 1

  • 如果两棵树的根元素类型不同,React会销毁旧树,创建新树
1
2
3
4
5
6
7
8
9
10
11
// 旧树
<div>
<Counter />
</div>

// 新树
<span>
<Counter />
</span>

执行过程:destory Counter -> insert Counter

Diff算法的说明 - 2

  • 对于类型相同的React DOM 元素,React会对比两者的属性是否相同,只更新不同的属性
  • 当处理完这个DOM节点,React就会递归处理子节点。
1
2
3
4
5
6
7
8
9
10
11
// 旧
<div className="before" title="stuff" />
// 新
<div className="after" title="stuff" />
只更新:className 属性

// 旧
<div style={{color: 'red', fontWeight: 'bold'}} />
// 新
<div style={{color: 'green', fontWeight: 'bold'}} />
只更新:color属性

Diff算法的说明 - 3

  • 1 当在子节点的后面添加一个节点,这时候两棵树的转化工作执行的很好
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 旧
<ul>
<li>first</li>
<li>second</li>
</ul>

// 新
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>

执行过程:
React会匹配新旧两个<li>first</li>,匹配两个<li>second</li>,然后添加 <li>third</li> tree
  • 2 但是如果你在开始位置插入一个元素,那么问题就来了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 旧
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>

// 新
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>

在没有key属性时执行过程:
React将改变每一个子删除重新创建,而非保持 <li>Duke</li> 和 <li>Villanova</li> 不变

key 属性

为了解决以上问题,React提供了一个 key 属性。当子节点带有key属性,React会通过key来匹配原始树和后来的树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 旧
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

// 新
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
执行过程:
现在 React 知道带有key '2014' 的元素是新的,对于 '2015''2016' 仅仅移动位置即可
  • 说明:key属性在React内部使用,但不会传递给你的组件
  • 推荐:在遍历数据时,推荐在组件中使用 key 属性:<li key={item.id}>{item.name}</li>
  • 注意:key只需要保持与他的兄弟节点唯一即可,不需要全局唯一
  • 注意:尽可能的减少数组index作为key,数组中插入元素的等操作时,会使得效率底下

React的基本使用

  • 安装:npm i -S react react-dom
  • react:react 是React库的入口点
  • react-dom:提供了针对DOM的方法,比如:把创建的虚拟DOM,渲染到页面上
1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 导入 react
import React from 'react'
import ReactDOM from 'react-dom'

// 2. 创建 虚拟DOM
// 参数1:元素名称 参数2:元素属性对象(null表示无) 参数3:当前元素的子元素string||createElement() 的返回值
const divVD = React.createElement('div', {
title: 'hello react'
}, 'Hello React!!!')

// 3. 渲染
// 参数1:虚拟dom对象 参数2:dom对象表示渲染到哪个元素内 参数3:回调函数
ReactDOM.render(divVD, document.getElementById('app'))

createElement()的问题

  • 说明:createElement()方式,代码编写不友好,太复杂
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var dv = React.createElement(
"div",
{ className: "shopping-list" },
React.createElement(
"h1",
null,
"Shopping List for "
),
React.createElement(
"ul",
null,
React.createElement(
"li",
null,
"Instagram"
),
React.createElement(
"li",
null,
"WhatsApp"
)
)
)
// 渲染
ReactDOM.render(dv, document.getElementById('app'))

JSX 的基本使用

  • 注意:JSX语法,最终会被编译为 createElement() 方法
  • 推荐:使用 JSX 的方式创建组件
  • JSX - JavaScript XML
  • 安装:npm i -D babel-preset-react (依赖与:babel-core/babel-loader)

注意:JSX的语法需要通过 babel-preset-react 编译后,才能被解析执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 1 在 .babelrc 开启babel对 JSX 的转换 */
{
"presets": [
"env", "react"
]
}

/* 2 webpack.config.js */
module: [
rules: [
{ test: /\.js$/, use: 'babel-loader', exclude: /node_modules/ },
]
]

/* 3 在 js 文件中 使用 JSX */
const dv = (
<div title="标题" className="cls container">Hello JSX!</div>
)

/* 4 渲染 JSX 到页面中 */
ReactDOM.render(dv, document.getElementById('app'))

JSX的注意点

  • 注意 1: 如果在 JSX 中给元素添加类, 需要使用 className 代替 class

    • 类似:label 的 for属性,使用htmlFor代替
  • 注意 2:在 JSX 中可以直接使用 JS代码,直接在 JSX 中通过 {} 中间写 JS代码即可

  • 注意 3:在 JSX 中只能使用表达式,但是不能出现 语句!!!

  • 注意 4:在 JSX 中注释语法:{/* 中间是注释的内容 */}

React组件

React 组件可以让你把UI分割为独立、可复用的片段,并将每一片段视为相互独立的部分。

  • 组件是由一个个的HTML元素组成的
  • 概念上来讲, 组件就像JS中的函数。它们接受用户输入(props),并且返回一个React对象,用来描述展示在页面中的内容

React创建组件的两种方式

  • 1 通过 JS函数 创建(无状态组件)
  • 2 通过 class 创建(有状态组件)
1
2
3
函数式组件 和 class 组件的使用场景说明:
1 如果一个组件仅仅是为了展示数据,那么此时就可以使用 函数组件
2 如果一个组件中有一定业务逻辑,需要操作数据,那么就需要使用 class 创建组件,因为,此时需要使用 state
JavaScript函数创建
  • 注意:1 函数名称必须为大写字母开头,React通过这个特点来判断是不是一个组件
  • 注意:2 函数必须有返回值,返回值可以是:JSX对象或null
  • 注意:3 返回的JSX,必须有一个根元素
  • 注意:4 组件的返回值使用()包裹,避免换行问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Welcome(props) {
return (
// 此处注释的写法
<div className="shopping-list">
{/* 此处 注释的写法 必须要{}包裹 */}
<h1>Shopping List for {props.name}</h1>
<ul>
<li>Instagram</li>
<li>WhatsApp</li>
</ul>
</div>
)
}

ReactDOM.render(
<Welcome name="jack" />,
document.getElementById('app')
)
class创建

在es6中class仅仅是一个语法糖,不是真正的类,本质上还是构造函数+原型 实现继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ES6中class关键字的简单使用

// - **ES6中的所有的代码都是运行在严格模式中的**
// - 1 它是用来定义类的,是ES6中实现面向对象编程的新方式
// - 2 使用`static`关键字定义静态属性
// - 3 使用`constructor`构造函数,创建实例属性
// - [参考](http://es6.ruanyifeng.com/#docs/class)

// 语法:
class Person {
// 实例的构造函数 constructor
constructor(age){
// 实例属性
this.age = age
}
// 在class中定义方法 此处为实例方法 通过实例打点调用
sayHello () {
console.log('大家好,我今年' + this.age + '了');
}

// 静态方法 通过构造函数打点调用 Person.doudou()
static doudou () {
console.log('我是小明,我新get了一个技能,会暖床');
}
}
// 添加静态属性
Person.staticName = '静态属性'
// 实例化对象
const p = new Person(19)


// 实现继承的方式

class American extends Person {
constructor() {
// 必须调用super(), super表示父类的构造函数
super()
this.skin = 'white'
this.eyeColor = 'white'
}
}

// 创建react对象
// 注意:基于 `ES6` 中的class,需要配合 `babel` 将代码转化为浏览器识别的ES5语法
// 安装:`npm i -D babel-preset-env`

// react对象继承字React.Component
class ShoppingList extends React.Component {
constructor(props) {
super(props)
}
// class创建的组件中 必须有rander方法 且显示return一个react对象或者null
render() {
return (
<div className="shopping-list">
<h1>Shopping List for {this.props.name}</h1>
<ul>
<li>Instagram</li>
<li>WhatsApp</li>
</ul>
</div>
)
}
}

给组件传递数据 - 父子组件传递数据

  • 组件中有一个 只读的对象 叫做 props,无法给props添加属性
  • 获取方式:函数参数 props
  • 作用:将传递给组件的属性转化为 props 对象中的属性
1
2
3
4
5
6
7
8
9
10
11
12
function Welcome(props){
// props ---> { username: 'zs', age: 20 }
return (
<div>
<div>Welcome React</div>
<h3>姓名:{props.username}----年龄是:{props.age}</h3>
</div>
)
}

// 给 Hello组件 传递 props:username 和 age(如果你想要传递numb类型是数据 就需要向下面这样)
ReactDOM.reander(<Hello username="zs" age={20}></Hello>, ......)

封装组件到独立的文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建Hello2.js组件文件
// 1. 引入React模块
// 由于 JSX 编译后会调用 React.createElement 方法,所以在你的 JSX 代码中必须首先拿到React。
import React from 'react'

// 2. 使用function构造函数创建组件
function Hello2(props){
return (
<div>
<div>这是Hello2组件</div>
<h1>这是大大的H1标签,我大,我骄傲!!!</h1>
<h6>这是小小的h6标签,我小,我傲娇!!!</h6>
</div>
)
}
// 3. 导出组件
export default Hello2

// app.js中 使用组件:
import Hello2 from './components/Hello2'

props和state

props

  • 作用:给组件传递数据,一般用在父子组件之间

  • 说明:React把传递给组件的属性转化为一个对象并交给 props

  • 特点:props是只读的,无法给props添加或修改属性

  • props.children:获取组件的内容,比如:

    • <Hello>组件内容</Hello> 中的 组件内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// props 是一个包含数据的对象参数,不要试图修改 props 参数
// 返回值:react元素
function Welcome(props) {
// 返回的 react元素中必须只有一个根元素
return <div>hello, {props.name}</div>
}

class Welcome extends React.Component {
constructor(props) {
super(props)
}

render() {
return <h1>Hello, {this.props.name}</h1>
}
}

state

状态即数据

  • 作用:用来给组件提供组件内部使用的数据

  • 注意:只有通过class创建的组件才具有状态

  • 注意:状态是私有的,完全由组件来控制

  • 注意:不要在 state 中添加 render() 方法中不需要的数据,会影响渲染性能!

    • 可以将组件内部使用但是不渲染在视图中的内容,直接添加给 this
  • 注意:不要在 render() 方法中调用 setState() 方法来修改state的值

    • 但是可以通过 this.state.name = 'rose' 方式设置state(不推荐!!!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 例:
class Hello extends React.Component {
constructor() {
// es6继承必须用super调用父类的constructor
super()

this.state = {
gender: 'male'
}
}

render() {
return (
<div>性别:{ this.state.gender }</div>
)
}
}

JSX语法转化过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1、JSX
const element = (
<h1 className="greeting">
Hello, world!
</h1>
)

// 2、JSX -> createElement
const element = React.createElement(
'h1',
{className: 'greeting'},
'Hello, world!'
)

// React elements: 使用对象的形式描述页面结构
// Note: 这是简化后的对象结构
const element = {
type: 'h1',
props: {
className: 'greeting',
},
children: ['Hello, world']
}

评论列表案例

  • 巩固有状态组件和无状态组件的使用
  • 两个组件:<CommentList></CommentList><Comment></Comment>
1
2
3
4
5
6
7
8
9
10
[
{ user: '张三', content: '哈哈,沙发' },
{ user: '张三2', content: '哈哈,板凳' },
{ user: '张三3', content: '哈哈,凉席' },
{ user: '张三4', content: '哈哈,砖头' },
{ user: '张三5', content: '哈哈,楼下山炮' }
]

// 属性扩展
<Comment {...item} key={i}></Comment>

style样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 直接写行内样式:
<li style={{border:'1px solid red', fontSize:'12px'}}></li>

// 2. 抽离为对象形式
var styleH3 = {color:'blue'}
var styleObj = {
liStyle:{border:'1px solid red', fontSize:'12px'},
h3Style:{color:'green'}
}

<li style={styleObj.liStyle}>
<h3 style={styleObj.h3Style}>评论内容:{props.content}</h3>
</li>

// 3. 使用样式表定义样式:
import '../css/comment.css'
<p className="pUser">评论人:{props.user}</p>

相关文章

组件的生命周期

  • 简单说:一个组件从开始到最后消亡所经历的各种状态,就是一个组件的生命周期

组件生命周期函数的定义:从组件被创建,到组件挂载到页面上运行,再到页面关闭组件被卸载,这三个阶段总是伴随着组件各种各样的事件,那么这些事件,统称为组件的生命周期函数!

  • 通过这个函数,能够让开发人员的代码,参与到组件的生命周期中。也就是说,通过钩子函数,就可以控制组件的行为
  • React 生命周期的管理艺术

组件生命周期函数总览

  • 组件的生命周期包含三个阶段:创建阶段(Mounting)、运行和交互阶段(Updating)、卸载阶段(Unmounting)
  • Mounting:

constructor()
componentWillMount()
render()
componentDidMount()

  • Updating

componentWillReceiveProps()
shouldComponentUpdate()
componentWillUpdate()
render()
componentDidUpdate()

  • Unmounting

componentWillUnmount()

组件生命周期 - 创建阶段(Mounting)

  • 特点:该阶段的函数只执行一次
constructor()
  • 作用:1 获取props 2 初始化state
  • 说明:通过 constructor() 的参数props获取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Greeting extends React.Component {
constructor(props) {
// 获取 props
super(props)
// 初始化 state
this.state = {
count: props.initCount
}
}
}

// 初始化 props
// 语法:通过静态属性 defaultProps 来初始化props
Greeting.defaultProps = {
initCount: 0
};
componentWillMount()
  • 说明:组件被挂载到页面之前调用,其在render()之前被调用,因此在这方法里同步地设置状态将不会触发重渲染
  • 注意:无法获取页面中的DOM对象
  • 注意:可以调用 setState() 方法来改变状态值
  • 用途:发送ajax请求获取数据
1
2
3
4
5
6
componentWillMount() {
console.warn(document.getElementById('btn')) // null
this.setState({
count: this.state.count + 1
})
}
render()
  • 作用:渲染组件到页面中,无法获取页面中的DOM对象

  • 注意:不要在render方法中调用 setState() 方法,否则会递归渲染

    • 原因说明:状态改变会重新调用render()render()又重新改变状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
render() {
console.warn(document.getElementById('btn')) // null

return (
<div>
<button id="btn" onClick={this.handleAdd}>打豆豆一次</button>
{
this.state.count === 4
? null
: <CounterChild initCount={this.state.count}></CounterChild>
}
</div>
)
}
componentDidMount()
  • 1 组件已经挂载到页面中
  • 2 可以进行DOM操作,比如:获取到组件内部的DOM对象
  • 3 可以发送请求获取数据
  • 4 可以通过 setState() 修改状态的值
  • 注意:在这里修改状态会重新渲染
1
2
3
4
componentDidMount() {
// 此时,就可以获取到组件内部的DOM对象
console.warn('componentDidMount', document.getElementById('btn'))
}

组件生命周期 - 运行阶段(Updating)

  • 特点:该阶段的函数执行多次
  • 说明:每当组件的props或者state改变的时候,都会触发运行阶段的函数
componentWillReceiveProps()
  • 说明:组件接受到新的props前触发这个方法
  • 参数:当前组件props
  • 可以通过 this.props 获取到上一次的值
  • 使用:若你需要响应属性的改变,可以通过对比this.propsnextProps并在该方法中使用this.setState()处理状态改变
  • 注意:修改state不会触发该方法
1
2
3
componentWillReceiveProps(nextProps) {
console.warn('componentWillReceiveProps', nextProps)
}
shouldComponentUpdate()
  • 作用:根据这个方法的返回值决定是否重新渲染组件,返回true重新渲染,否则不渲染
  • 优势:通过某个条件渲染组件,降低组件渲染频率,提升组件性能
  • 说明:如果返回值为false,那么,后续render()方法不会被调用
  • 注意:这个方法必须返回布尔值!!!
  • 场景:根据随机数决定是否渲染组件
1
2
3
4
5
6
7
8
// - 参数:
// - 第一个参数:最新属性对象
// - 第二个参数:最新状态对象
shouldComponentUpdate(nextProps, nextState) {
console.warn('shouldComponentUpdate', nextProps, nextState)

return nextState.count % 2 === 0
}
componentWillUpdate()
  • 作用:组件将要更新
  • 参数:最新的属性和状态对象
  • 注意:不能修改状态 否则会循环渲染
1
2
3
componentWillUpdate(nextProps, nextState) {
console.warn('componentWillUpdate', nextProps, nextState)
}
render() 渲染
  • 作用:重新渲染组件,与Mounting阶段的render是同一个函数
  • 注意:这个函数能够执行多次,只要组件的属性或状态改变了,这个方法就会重新执行
componentDidUpdate()
  • 作用:组件已经被更新
  • 参数:旧的属性和状态对象
1
2
3
componentDidUpdate(prevProps, prevState) {
console.warn('componentDidUpdate', prevProps, prevState)
}

组件生命周期 - 卸载阶段(Unmounting)

  • 组件销毁阶段:组件卸载期间,函数比较单一,只有一个函数,这个函数也有一个显著的特点:组件一辈子只能执行依次!
  • 使用说明:只要组件不再被渲染到页面中,那么这个方法就会被调用( 渲染到页面中 -> 不再渲染到页面中 )
componentWillUnmount()
  • 作用:在卸载组件的时候,执行清理工作,比如

    • 1 清除定时器
    • 2 清除componentDidMount创建的DOM对象

React - createClass(不推荐)

  • React.createClass({}) 方式,创建有状态组件,该方式已经被废弃!!!
  • 通过导入 require('create-react-class'),可以在不适用ES6的情况下,创建有状态组件
  • getDefaultProps() 和 getInitialState() 方法:是 createReactClass() 方式创建组件中的两个函数
  • React without ES6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var createReactClass = require('create-react-class');
var Greeting = createReactClass({
// 初始化 props
getDefaultProps: function() {
console.log('getDefaultProps');
return {
title: 'Basic counter!!!'
}
},

// 初始化 state
getInitialState: function() {
console.log('getInitialState');
return {
count: 0
}
},

render: function() {
console.log('render');
return (
<div>
<h1>{this.props.title}</h1>
<div>{this.state.count}</div>
<input type='button' value='+' onClick={this.handleIncrement} />
</div>
);
},

handleIncrement: function() {
var newCount = this.state.count + 1;
this.setState({count: newCount});
},

propTypes: {
title: React.PropTypes.string
}
});

ReactDOM.render(
React.createElement(Greeting),
document.getElementById('app')
);

state和setState

  • 注意:使用 setState() 方法修改状态,状态改变后,React会重新渲染组件
  • 注意:不要直接修改state属性的值,这样不会重新渲染组件!!!
  • 使用:1 初始化state 2 setState修改state
1
2
3
// 修改state(不推荐使用)
// https://facebook.github.io/react/docs/state-and-lifecycle.html#do-not-modify-state-directly
this.state.test = '这样方式,不会重新渲染组件';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
constructor(props) {
super(props)

// 正确姿势!!!
// -------------- 初始化 state --------------
this.state = {
count: props.initCount
}
}

componentWillMount() {
// -------------- 修改 state 的值 --------------
// 方式一:
this.setState({
count: this.state.count + 1
})

this.setState({
count: this.state.count + 1
}, function(){
// 由于 setState() 是异步操作,所以,如果想立即获取修改后的state
// 需要在回调函数中获取
// https://doc.react-china.org/docs/react-component.html#setstate
});

// 方式二:
this.setState(function(prevState, props) {
return {
counter: prevState.counter + props.increment
}
})

// 或者 - 注意: => 后面需要带有小括号,因为返回的是一个对象
this.setState((prevState, props) => ({
counter: prevState.counter + props.increment
}))
}

组件绑定事件

  • 1 通过React事件机制 onClick 绑定

  • 2 JS原生方式绑定(通过 ref 获取元素)

    • 注意:ref 是React提供的一个特殊属性
    • ref的使用说明

React中的事件机制 - 推荐

  • 注意:事件名称采用驼峰命名法
  • 例如:onClick 用来绑定单击事件
1
2
3
4
<input type="button" value="触发单击事件"
onClick={this.handleCountAdd}
onMouseEnter={this.handleMouseEnter}
/>

JS原生方式 - 知道即可

  • 说明:给元素添加 ref 属性,然后,获取元素绑定事件
1
2
3
4
5
6
7
8
9
10
11
12
// JSX
// 将当前DOM的引用赋值给 this.txtInput 属性
<input ref={ input => this.txtInput = input } type="button" value="我是豆豆" />

componentDidMount() {
// 通过 this.txtInput 属性获取元素绑定事件
this.txtInput.addEventListener(() => {
this.setState({
count:this.state.count + 1
})
})
}

事件绑定中的this

  • 1 通过 bind 绑定
  • 2 通过 箭头函数 绑定

通过bind绑定

  • 原理:bind能够调用函数,改变函数内部this的指向,并返回一个新函数
  • 说明:bind第一个参数为返回函数中this的指向,后面的参数为传给返回函数的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 自定义方法:
handleBtnClick(arg1, arg2) {
this.setState({
msg: '点击事件修改state的值' + arg1 + arg2
})
}

render() {
return (
<div>
<button onClick={
// 无参数
// this.handleBtnClick.bind(this)

// 有参数
this.handleBtnClick.bind(this, 'abc', [1, 2])
}>事件中this的处理</button>
<h1>{this.state.msg}</h1>
</div>
)
}
  • 在构造函数中使用bind
1
2
3
4
5
6
7
8
constructor() {
super()

this.handleBtnClick = this.handleBtnClick.bind(this)
}

// render() 方法中:
<button onClick={ this.handleBtnClick }>事件中this的处理</button>

通过箭头函数绑定

  • 原理:箭头函数中的this由所处的环境决定,自身不绑定this
1
2
3
4
5
6
7
8
9
<input type="button" value="在构造函数中绑定this并传参" onClick={
() => { this.handleBtnClick('参数1', '参数2') }
} />

handleBtnClick(arg1, arg2) {
this.setState({
msg: '在构造函数中绑定this并传参' + arg1 + arg2
});
}

受控组件

  • 表单和受控组件
  • 非受控组件

在HTML当中,像input,textareaselect这类表单元素会维持自身状态,并根据用户输入进行更新。
在React中,可变的状态通常保存在组件的state中,并且只能用 setState() 方法进行更新.
React根据初始状态渲染表单组件,接受用户后续输入,改变表单组件内部的状态。
因此,将那些值由React控制的表单元素称为:受控组件。

  • 受控组件的特点:

    • 1 表单元素
    • 2 由React通过JSX渲染出来
    • 3 由React控制值的改变,也就是说想要改变元素的值,只能通过React提供的方法来修改
  • 注意:只能通过setState来设置受控组件的值

1
2
3
4
5
6
7
8
9
10
11
// 模拟实现文本框数据的双向绑定
<input type="text" value={this.state.msg} onChange={this.handleTextChange}/>

// 当文本框内容改变的时候,触发这个事件,重新给state赋值
handleTextChange = event => {
console.log(event.target.value)

this.setState({
msg: event.target.value
})
}

评论列表案例

1
2
3
4
5
[
{name: '小明', content: '沙发!!!'},
{name: '小红', content: '小明,居然是你'},
{name: '小刚', content: '小明,放学你别走!!!'},
]

props校验

  • 作用:通过类型检查,提高程序的稳定性
  • 命令:npm i -S prop-types
  • 使用:给类提供一个静态属性 propTypes(对象),来约束props
1
2
3
4
5
6
7
8
9
// 引入模块
import PropTypes from 'prop-types'

// ...以下代码是类的静态属性:
// propTypes 静态属性的名称是固定的!!!
static propTypes = {
initCount: PropTypes.number, // 规定属性的类型
initAge: PropTypes.number.isRequired // 规定属性的类型,且规定为必传字段
}

React 单向数据流

  • React 中采用单项数据流
  • 数据流动方向:自上而下,也就是只能由父组件传递到子组件
  • 数据都是由父组件提供的,子组件想要使用数据,都是从父组件中获取的
  • 如果多个组件都要使用某个数据,最好将这部分共享的状态提升至他们最近的父组件当中进行管理
  • 单向数据流
  • 状态提升
1
2
3
4
5
6
7
react中的单向数据流动:
1 数据应该是从上往下流动的,也就是由父组件将数据传递给子组件
2 数据应该是由父组件提供,子组件要使用数据的时候,直接从子组件中获取

在我们的评论列表案例中:数据是由CommentList组件(父组件)提供的
子组件 CommentItem 负责渲染评论列表,数据是由 父组件提供的
子组件 CommentForm 负责获取用户输入的评论内容,最终也是把用户名和评论内容传递给了父组件,由父组件负责处理这些数据( 把数据交给 CommentItem 由这个组件负责渲染 )

组件通讯

  • 父 -> 子:props
  • 子 -> 父:父组件通过props传递回调函数给子组件,子组件调用函数将数据作为参数传递给父组件
  • 兄弟组件:因为React是单向数据流,因此需要借助父组件进行传递,通过父组件回调函数改变兄弟组件的props
  • React中的状态管理: flux(提出状态管理的思想) -> Redux -> mobx
  • Vue中的状态管理: Vuex
  • 简单来说,就是统一管理了项目中所有的数据,让数据变的可控
  • 组件通讯

Context特性

  • 注意:如果不熟悉React中的数据流,不推荐使用这个属性

    • 这是一个实验性的API,在未来的React版本中可能会被更改
  • 作用:跨级传递数据(爷爷给孙子传递数据),避免向下每层手动地传递props

  • 说明:需要配合PropTypes类型限制来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Grandfather extends React.Component {
// 类型限制(必须),静态属性名称固定
static childContextTypes = {
color: PropTypes.string.isRequired
}

// 传递给孙子组件的数据
getChildContext() {
return {
color: 'red'
}
}

render() {
return (
<Father></Father>
)
}
}

class Child extends React.Component {
// 类型限制,静态属性名字固定
static contextTypes = {
color: PropTypes.string
}

render() {
return (
// 从上下文对象中获取爷爷组件传递过来的数据
<h1 style={{ color: this.context.color }}>爷爷告诉文字是红色的</h1>
)
}
}

class Father extends React.Component {
render() {
return (
<Child></Child>
)
}
}

react-router

基本概念说明

  • Router组件本身只是一个容器,真正的路由要通过Route组件定义

使用步骤

  • 1 导入路由组件

  • 2 使用 <Router></Router> 作为根容器,包裹整个应用(JSX)

    • 在整个应用程序中,只需要使用一次
  • 3 使用 <Link to="/movie"></Link> 作为链接地址,并指定to属性

  • 4 使用 <Route path="/" compoent={Movie}></Route> 展示路由内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1 导入组件
import {
HashRouter as Router,
Link, Route
} from 'react-router-dom'

// 2 使用 <Router>
<Router>

// 3 设置 Link
<Menu.Item key="1"><Link to="/">首页</Link></Menu.Item>
<Menu.Item key="2"><Link to="/movie">电影</Link></Menu.Item>
<Menu.Item key="3"><Link to="/about">关于</Link></Menu.Item>

// 4 设置 Route
// exact 表示:绝对匹配(完全匹配,只匹配:/)
<Route exact path="/" component={HomeContainer}></Route>
<Route path="/movie" component={MovieContainer}></Route>
<Route path="/about" component={AboutContainer}></Route>

</Router>

注意点

  • <Router></Router>:作为整个组件的根元素,是路由容器,只能有一个唯一的子元素
  • <Link></Link>:类似于vue中的<router-link></router-link>标签,to 属性指定路由地址
  • <Route></Route>:类似于vue中的<router-view></router-view>,指定路由内容(组件)展示位置

路由参数

  • 配置:通过Route中的path属性来配置路由参数
  • 获取:this.props.match.params 获取
1
2
3
4
5
// 配置路由参数
<Route path="/movie/:movieType"></Route>

// 获取路由参数
const type = this.props.match.params.movieType

路由跳转

  • react router - history
  • history.push() 方法用于在JS中实现页面跳转
  • history.go(-1) 用来实现页面的前进(1)和后退(-1)
1
this.props.history.push('/movie/movieDetail/' + movieId)

fetch

  • 作用:Fetch 是一个现代的概念, 等同于 XMLHttpRequest。它提供了许多与XMLHttpRequest相同的功能,但被设计成更具可扩展性和高效性。
  • fetch() 方法返回一个Promise对象

fetch 基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
通过fetch请求回来的数据,是一个Promise对象.
调用then()方法,通过参数response,获取到响应对象
调用 response.json() 方法,解析服务器响应数据
再次调用then()方法,通过参数data,就获取到数据了
*/
fetch('/api/movie/' + this.state.movieType)
// response.json() 读取response对象,并返回一个被解析为JSON格式的promise对象
.then((response) => response.json())
// 通过 data 获取到数据
.then((data) => {
console.log(data);
this.setState({
movieList: data.subjects,
loaing: false
})
})

跨域获取数据的三种常用方式

  • 1 JSONP
  • 2 代理
  • 3 CORS

JSONP

  • 安装:npm i -S fetch-jsonp
  • 利用JSONP实现跨域获取数据,只能获取GET请求
  • fetch-jsonp
  • fetch-jsonp
  • 限制:1 只能发送GET请求 2 需要服务端支持JSONP请求
1
2
3
4
/* movielist.js */
fetchJsonp('https://api.douban.com/v2/movie/in_theaters')
.then(rep => rep.json())
.then(data => { console.log(data) })

代理

  • webpack-dev-server 代理配置如下:
  • 问题:webpack-dev-server 是开发期间使用的工具,项目上线了就不再使用 webpack-dev-server
  • 解决:项目上线后的代码,也是会部署到一个服务器中,这个服务器配置了代理功能即可(要求两个服务器中配置的代理规则相同)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// webpack-dev-server的配置
devServer: {
// https://webpack.js.org/configuration/dev-server/#devserver-proxy
// https://github.com/chimurai/http-proxy-middleware#http-proxy-options
// http://www.jianshu.com/p/3bdff821f859
proxy: {
// 使用:/api/movie/in_theaters
// 访问 ‘/api/movie/in_theaters’ ==> 'https://api.douban.com/v2/movie/in_theaters'
'/api': {
// 代理的目标服务器地址
target: 'https://api.douban.com/v2',
// https请求需要该设置
secure: false,
// 必须设置该项
changeOrigin: true,
// '/api/movie/in_theaters' 路径重写为:'/movie/in_theaters'
pathRewrite: {"^/api" : ""}
}
}
}

/* movielist.js */
fetch('/api/movie/in_theaters')
.then(function(data) {
// 将服务器返回的数据转化为 json 格式
return data.json()
})
.then(function(rep) {
// 获取上面格式化后的数据
console.log(rep);
})

CORS - 服务器端配合

1
2
3
4
5
6
7
8
9
10
11
12
// 通过Express的中间件来处理所有请求
app.use('*', function (req, res, next) {
// 设置请求头为允许跨域
res.header('Access-Control-Allow-Origin', '*');

// 设置服务器支持的所有头信息字段
res.header('Access-Control-Allow-Headers', 'Content-Type,Content-Length, Authorization,Accept,X-Requested-With');
// 设置服务器支持的所有跨域请求的方法
res.header('Access-Control-Allow-Methods', 'POST,GET');
// next()方法表示进入下一个路由
next();
});

redux

  • 状态管理工具,用来管理应用中的数据

核心

  • Action:行为的抽象,视图中的每个用户交互都是一个action

    • 比如:点击按钮
  • Reducer:行为响应的抽象,也就是:根据action行为,执行相应的逻辑操作,更新state

    • 比如:点击按钮后,添加任务,那么,添加任务这个逻辑放到 Reducer 中
    • 1 创建State
  • Store:

    • 1 Redux应用只能有一个store
    • 2 getState():获取state
    • 3 dispatch(action):更新state
1
2
3
4
5
6
7
8
9
/* action */

// 在 redux 中,action 就是一个对象
// action 必须提供一个:type属性,表示当前动作的标识
// 其他的参数:表示这个动作需要用到的一些数据
{ type: 'ADD_TODO', name: '要添加的任务名称' }

// 这个动作表示要切换任务状态
{ type: 'TOGGLE_TODO', id: 1 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* reducer */

// 第一个参数:表示状态(数据),我们需要给初始状态设置默认值
// 第二个参数:表示 action 行为
function todo(state = [], action) {
switch(action.type) {
case 'ADD_TODO':
state.push({ id: Math.random(), name: action.name, completed: false })
return state
case 'TOGGLE_TODO':
for(var i = 0; i < state.length; i++) {
if (state[i].id === action.id) {
state[i].completed = !state[i].completed
break
}
}
return state
default:
return state
}
}

// 要执行 ADD_TODO 这个动作:
dispatch( { type: 'ADD_TODO', name: '要添加的任务名称' } )

// 内部会调用 reducer
todo(undefined, { type: 'ADD_TODO', name: '要添加的任务名称' })

留言與分享

JavaScript-Node.js

分類 编程语言, JavaScript

从本章开始,我们就正式开启JavaScript的后端开发之旅。

Node.js是目前非常火热的技术,但是它的诞生经历却很奇特。

众所周知,在Netscape设计出JavaScript后的短短几个月,JavaScript事实上已经是前端开发的唯一标准。

后来,微软通过IE击败了Netscape后一统桌面,结果几年时间,浏览器毫无进步。

没有竞争就没有发展。微软认为IE6浏览器已经非常完善,几乎没有可改进之处,然后解散了IE6开发团队!而Google却认为支持现代Web应用的新一代浏览器才刚刚起步,尤其是浏览器负责运行JavaScript的引擎性能还可提升10倍。

先是Mozilla借助已壮烈牺牲的Netscape遗产在2002年推出了Firefox浏览器,紧接着Apple于2003年在开源的KHTML浏览器的基础上推出了WebKit内核的Safari浏览器,不过仅限于Mac平台。

随后,Google也开始创建自家的浏览器。他们也看中了WebKit内核,于是基于WebKit内核推出了Chrome浏览器。

Chrome浏览器是跨Windows和Mac平台的,并且,Google认为要运行现代Web应用,浏览器必须有一个性能非常强劲的JavaScript引擎,于是Google自己开发了一个高性能JavaScript引擎,名字叫V8,以BSD许可证开源。

现代浏览器大战让微软的IE浏览器远远地落后了,因为他们解散了最有经验、战斗力最强的浏览器团队!回过头再追赶却发现,支持HTML5的WebKit已经成为手机端的标准了,IE浏览器从此与主流移动端设备绝缘。

浏览器大战和Node有何关系?

话说有个叫Ryan Dahl的歪果仁,他的工作是用C/C++写高性能Web服务。对于高性能,异步IO、事件驱动是基本原则,但是用C/C++写就太痛苦了。于是这位仁兄开始设想用高级语言开发Web服务。他评估了很多种高级语言,发现很多语言虽然同时提供了同步IO和异步IO,但是开发人员一旦用了同步IO,他们就再也懒得写异步IO了,所以,最终,Ryan瞄向了JavaScript。

因为JavaScript是单线程执行,根本不能进行同步IO操作,所以,JavaScript的这一“缺陷”导致了它只能使用异步IO。

选定了开发语言,还要有运行时引擎。这位仁兄曾考虑过自己写一个,不过明智地放弃了,因为V8就是开源的JavaScript引擎。让Google投资去优化V8,咱只负责改造一下拿来用,还不用付钱,这个买卖很划算。

于是在2009年,Ryan正式推出了基于JavaScript语言和V8引擎的开源Web服务器项目,命名为Node.js。虽然名字很土,但是,Node第一次把JavaScript带入到后端服务器开发,加上世界上已经有无数的JavaScript开发人员,所以Node一下子就火了起来。

在Node上运行的JavaScript相比其他后端开发语言有何优势?

最大的优势是借助JavaScript天生的事件驱动机制加V8高性能引擎,使编写高性能Web服务轻而易举。

其次,JavaScript语言本身是完善的函数式语言,在前端开发时,开发人员往往写得比较随意,让人感觉JavaScript就是个“玩具语言”。但是,在Node环境下,通过模块化的JavaScript代码,加上函数式编程,并且无需考虑浏览器兼容性问题,直接使用最新的ECMAScript 6标准,可以完全满足工程上的需求。

由于Node.js平台是在后端运行JavaScript代码,所以,必须首先在本机安装Node环境。

安装Node.js

目前Node.js的最新版本是22.3.0,LTS版本是20.14.0,追求稳定性如服务器上长期运行的Node环境可以选择LTS版本,本地开发和测试可以选择最新版本。

要安装Node.js,首先,从Node.js官网下载对应平台的安装程序,初学者建议选择Prebuilt Installer,选择版本,再选操作系统,最后选CPU类型后点Download下载:

download

有经验的开发者可以选择Package Manager,它允许本地安装多个不同版本的Node并切换至不同版本。

在Windows上安装时务必选择全部组件,包括勾选Add to Path

安装完成后,在Windows环境下,请打开命令提示符,然后输入node -v,如果安装正常,你应该看到v22.3.0这样的输出:

1
2
C:\Users\IEUser> node -v
v22.3.0

继续在命令提示符输入node,此刻你将进入Node.js的交互环境。在交互环境下,你可以输入任意JavaScript语句,例如100+200,回车后将得到输出结果。

要退出Node.js环境,连按两次Ctrl+C。

npm

在正式开始Node.js学习之前,我们先认识一下npm。

npm是什么东东?npm其实是Node.js的包管理工具(package manager)。

为啥我们需要一个包管理工具呢?因为我们在Node.js上开发时,会用到很多别人写的JavaScript代码。如果我们要使用别人写的某个包,每次都根据名称搜索一下官方网站,下载代码,解压,再使用,非常繁琐。于是一个集中管理的工具应运而生:大家都把自己开发的模块打包后放到npm官网上,如果要使用,直接通过npm安装就可以直接用,不用管代码存在哪,应该从哪下载。

更重要的是,如果我们要使用模块A,而模块A又依赖于模块B,模块B又依赖于模块X和模块Y,npm可以根据依赖关系,把所有依赖的包都下载下来并管理起来。否则,靠我们自己手动管理,肯定既麻烦又容易出错。

讲了这么多,npm究竟在哪?

其实npm已经在Node.js安装的时候顺带装好了。我们在命令提示符或者终端输入npm -v,应该看到类似的输出:

1
2
C:\>npm -v
10.8.0

如果直接输入npm,你会看到类似下面的输出:

1
2
3
4
5
6
C:\> npm

Usage: npm <command>

where <command> is one of:
...

上面的一大堆文字告诉你,npm需要跟上命令。现在我们不用关心这些命令,后面会一一讲到。目前,你只需要确保npm正确安装了,能运行就行。

小结

请在本机安装Node.js环境,并确保nodenpm能正常运行。

在前面的所有章节中,我们编写的JavaScript代码都是在浏览器中运行的,因此,我们可以直接在浏览器中敲代码,然后直接运行。

从本章开始,我们编写的JavaScript代码将不能在浏览器环境中执行了,而是在Node环境中执行,因此,JavaScript代码将直接在你的计算机上以命令行的方式运行,所以,我们要先选择一个文本编辑器来编写JavaScript代码,并且把它保存到本地硬盘的某个目录,才能够执行。

那么问题来了:文本编辑器到底哪家强?

首先,请注意,绝对不能用Word和写字板。Word和写字板保存的不是纯文本文件。如果我们要用记事本来编写JavaScript代码,要务必注意,记事本以UTF-8格式保存文件时,会自作聪明地在文件开始的地方加上几个特殊字符(UTF-8 BOM),结果经常会导致程序运行出现莫名其妙的错误。

所以,用记事本写代码时请注意,保存文件时使用ANSI编码,并且暂时不要输入中文。

如果你的电脑上已经安装了Visual Studio Code,也可以用来编写JavaScript代码,注意用UTF-8格式保存。

输入以下代码:

1
2
'use strict';
console.log('Hello, world.');

第一行总是写上'use strict';是因为我们总是以严格模式运行JavaScript代码,避免各种潜在陷阱。

然后,选择一个目录,例如C:\Workspace,把文件保存为hello.js,就可以打开命令行窗口,把当前目录切换到hello.js所在目录,然后输入以下命令运行这个程序了:

1
2
C:\Workspace> node hello.js
Hello, world.

也可以保存为别的名字,比如first.js,但是必须要以.js结尾。此外,文件名只能是英文字母、数字和下划线的组合。

如果当前目录下没有hello.js这个文件,运行node hello.js就会报错:

1
2
3
4
5
6
7
C:\Workspace> node hello.js
node:internal/modules/cjs/loader:1227
throw err;
^
Error: Cannot find module 'C:\Workspace\hello.js'
at Module._resolveFilename
...

报错的意思就是,没有找到hello.js这个文件,因为文件不存在。这个时候,就要检查一下当前目录下是否有这个文件了。

命令行模式和Node交互模式

请注意区分命令行模式和Node交互模式。

看到类似PS C:\>是在Windows提供的命令行模式:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> node hello.js │
│Hello, world. │
│ │
│PS C:\Users\liaoxuefeng> │
│ │
└─────────────────────────────────────────────────────────┘

在命令行模式下,可以执行node进入Node交互式环境,也可以执行node hello.js运行一个.js文件。

看到>是在Node交互式环境下:

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> node │
│Welcome to Node.js v22.x.x. │
│> │
│ │
└─────────────────────────────────────────────────────────┘

在Node交互式环境下,我们可以输入JavaScript代码并立刻执行。

此外,在命令行模式运行.js文件和在Node交互式环境下直接运行JavaScript代码有所不同。Node交互式环境会把每一行JavaScript代码的结果自动打印出来,但是,直接运行JavaScript文件却不会。

例如,在Node交互式环境下,输入:

1
2
> 100 + 200 + 300;
600

直接可以看到结果600

但是,写一个calc.js的文件,内容如下:

1
100 + 200 + 300;

然后在命令行模式下执行:

1
C:\Workspace> node calc.js

发现什么输出都没有。

这是正常的。想要输出结果,必须自己用console.log()打印出来。把calc.js改造一下:

1
console.log(100 + 200 + 300);

再执行,就可以看到结果:

1
2
C:\Workspace> node calc.js
600

小结

用文本编辑器写JavaScript程序,然后保存为后缀为.js的文件,就可以用node直接运行这个程序了。

Node的交互模式和直接运行.js文件有什么区别呢?

直接输入node进入交互模式,相当于启动了Node解释器,但是等待你一行一行地输入源代码,每输入一行就执行一行。

直接运行node hello.js文件相当于启动了Node解释器,然后一次性把hello.js文件的源代码给执行了,你是没有机会以交互的方式输入源代码的。

在编写JavaScript代码的时候,完全可以一边在文本编辑器里写代码,一边开一个Node交互式命令窗口,在写代码的过程中,把部分代码粘到命令行去验证,事半功倍!前提是得有个27’的超大显示器!

参考源码

hello.js

calc.js

使用文本编辑器来开发Node程序,最大的缺点是效率太低,运行Node程序还需要在命令行单独敲命令。如果还需要调试程序,就更加麻烦了。

所以我们需要一个IDE集成开发环境,让我们能在一个环境里编码、运行、调试,这样就可以大大提升开发效率。

Java的集成开发环境有Eclipse,Intellij idea等,C#的集成开发环境有Visual Studio,那么问题又来了:Node.js的集成开发环境到底哪家强?

考察Node.js的集成开发环境,重点放在启动速度快,执行简单,调试方便这三点上。当然,免费使用是一个加分项。

综合考察后,我们隆重向大家推荐Node.js集成开发环境:

Visual Studio Code

Visual Studio Code由微软出品,但它不是那个大块头的Visual Studio,它是一个精简版的迷你Visual Studio,并且,Visual Studio Code可以跨!平!台!Windows、Mac和Linux通用。

安装Visual Studio Code

可以从Visual Studio Code的官方网站下载并安装最新的版本。

安装过程中,请务必钩上以下选项“将“通过Code打开”操作添加到Windows资源管理器目录上下文菜单”:

install-vsc

这将大大提升将来的操作快捷度。

运行和调试JavaScript

在VS Code中,我们可以非常方便地运行JavaScript文件。

VS Code以文件夹作为工程目录(Workspace Dir),所有的JavaScript文件都存放在该目录下。

假设我们在C:\Work\目录下创建了一个hello目录作为工程目录,并编写了一个hello.js文件,则该工程目录的结构如下:

1
2
hello/        <-- workspace dir
└── hello.js <-- JavaScript file

启动VS Code,选择菜单File - Open Folder…,选择hello目录,就可以直接编辑hello.js文件:

vscode-edit

运行JS代码

确保当前编辑器正在编辑hello.js文件,然后在VS Code中选择左侧调试按钮,直接点击Run And Debug按钮,如果弹出环境选项则选择Node,在右下侧DEBUG CONSOLE可直接看到运行结果。

vscode-run

如果右下侧面板没有显示,左上角有几个按钮可以切换视图。

调试JS代码

在VS Code中,可以通过断点调试方便进行JavaScript的开发和测试。我们先在hello.js的编辑器中打一个断点(鼠标点击行号左侧出现小红点即为断点),然后点击Run And Debug按钮,此时进入调试模式,会自动停在断点处,左侧窗口可查看变量,顶部按钮可选择单步执行或继续执行到下一个断点,也可以随时结束程序:

vscode-debug

总的来说,使用VS Code,开发和调试JavaScript代码十分方便。

参考源码

hello.js

在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。

为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Node环境中,一个.js文件就称之为一个模块(module)。

使用模块有什么好处?

最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Node内置的模块和来自第三方的模块。

使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。

在上一节,我们编写了一个hello.js文件,这个hello.js文件就是一个模块,模块的名字就是文件名(去掉.js后缀),所以hello.js文件就是名为hello的模块。

我们把hello.js改造一下,创建一个函数,这样我们就可以在其他地方调用这个函数:

1
2
3
4
5
6
7
8
9
'use strict';

const s = 'Hello';

function greet(name) {
console.log(s + ', ' + name + '!');
}

module.exports = greet;

函数greet()是我们在hello模块中定义的,你可能注意到最后一行是一个奇怪的赋值语句,它的意思是,把函数greet作为模块的输出暴露出去,这样其他模块就可以使用greet函数了。

问题是其他模块怎么使用hello模块的这个greet函数呢?我们再编写一个main.js文件,调用hello模块的greet函数:

1
2
3
4
5
6
7
8
'use strict';

// 引入hello模块:
const greet = require('./hello');

let s = 'Michael';

greet(s); // Hello, Michael!

注意到引入hello模块用Node提供的require函数:

1
const greet = require('./hello');

引入的模块作为变量保存在greet变量中,那greet变量到底是什么东西?其实变量greet就是在hello.js中我们用module.exports = greet;输出的greet函数。所以,main.js就成功地引用了hello.js模块中定义的greet()函数,接下来就可以直接使用它了。

在使用require()引入模块的时候,请注意模块的相对路径。因为main.jshello.js位于同一个目录,所以我们用了当前目录.

1
const greet = require('./hello'); // 不要忘了写相对目录!

如果只写模块名:

1
const greet = require('hello');

则Node会依次在内置模块、全局模块和当前模块下查找hello.js,你很可能会得到一个错误:

1
2
3
4
5
6
module.js
throw err;
^
Error: Cannot find module 'hello'
at Module._resolveFilename
...

遇到这个错误,你要检查:

  • 模块名是否写对了;
  • 模块文件是否存在;
  • 相对路径是否写对了。

CommonJS规范

这种模块加载机制被称为CommonJS规范。在这个规范下,每个.js文件都是一个模块,它们内部各自使用的变量名和函数名都互不冲突,例如,hello.jsmain.js都申明了全局变量var s = 'xxx',但互不影响。

一个模块想要对外暴露变量(函数也是变量),可以用module.exports = variable;,一个模块要引用其他模块暴露的变量,用var ref = require('module_name');就拿到了引用模块的变量。

结论

要在模块中对外输出变量,用:

1
module.exports = variable;

输出的变量可以是任意对象、函数、数组等等。

要引入其他模块输出的对象,用:

1
const foo = require('other_module');

引入的对象具体是什么,取决于引入模块输出的对象。

深入了解模块原理

如果你想详细地了解CommonJS的模块实现原理,请继续往下阅读。如果不想了解,请直接跳到最后做练习。

当我们编写JavaScript代码时,我们可以申明全局变量:

1
let s = 'global';

在浏览器中,大量使用全局变量可不好。如果你在a.js中使用了全局变量s,那么,在b.js中也使用全局变量s,将造成冲突,b.js中对s赋值会改变a.js的运行逻辑。

也就是说,在ESM标准之前,JavaScript语言本身并没有一种模块机制来保证不同模块可以使用相同的变量名。

那Node.js是如何实现这一点的?

其实要实现“模块”这个功能,并不需要语法层面的支持。Node.js也并不会增加任何JavaScript语法。实现“模块”功能的奥妙就在于JavaScript是一种函数式编程语言,它支持闭包。如果我们把一段JavaScript代码用一个函数包装起来,这段代码的所有“全局”变量就变成了函数内部的局部变量。

请注意我们编写的hello.js代码是这样的:

1
2
3
4
let s = 'Hello';
let name = 'world';

console.log(s + ' ' + name + '!');

Node.js加载了hello.js后,它可以把代码包装一下,变成这样执行:

1
2
3
4
5
6
7
8
(function () {
// 读取的hello.js代码:
let s = 'Hello';
let name = 'world';

console.log(s + ' ' + name + '!');
// hello.js代码结束
})();

这样一来,原来的全局变量s现在变成了匿名函数内部的局部变量。如果Node.js继续加载其他模块,这些模块中定义的“全局”变量s也互不干扰。

所以,Node利用JavaScript的函数式编程的特性,轻而易举地实现了模块的隔离。

但是,模块的输出module.exports怎么实现?

这个也很容易实现,Node可以先准备一个对象module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 准备module对象:
let module = {
id: 'hello',
exports: {}
};
let load = function (module) {
// 读取的hello.js代码:
function greet(name) {
console.log('Hello, ' + name + '!');
}

module.exports = greet;
// hello.js代码结束
return module.exports;
};
let exported = load(module);
// 保存module:
save(module, exported);

可见,变量module是Node在加载js文件前准备的一个变量,并将其传入加载函数,我们在hello.js中可以直接使用变量module原因就在于它实际上是函数的一个参数:

1
module.exports = greet;

通过把参数module传递给load()函数,hello.js就顺利地把一个变量传递给了Node执行环境,Node会把module变量保存到某个地方。

由于Node保存了所有导入的module,当我们用require()获取module时,Node找到对应的module,把这个moduleexports变量返回,这样,另一个模块就顺利拿到了模块的输出:

1
const greet = require('./hello');

以上是Node实现JavaScript模块的一个简单的原理介绍。

module.exports vs exports

很多时候,你会看到,在Node环境中,有两种方法可以在一个模块中输出变量:

方法一:对module.exports赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hello.js

function hello() {
console.log('Hello, world!');
}

function greet(name) {
console.log('Hello, ' + name + '!');
}

module.exports = {
hello: hello,
greet: greet
};

方法二:直接使用exports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// hello.js

function hello() {
console.log('Hello, world!');
}

function greet(name) {
console.log('Hello, ' + name + '!');
}

function hello() {
console.log('Hello, world!');
}

exports.hello = hello;
exports.greet = greet;

但是你不可以直接对exports赋值:

1
2
3
4
5
// 代码可以执行,但是模块并没有输出任何变量:
exports = {
hello: hello,
greet: greet
};

如果你对上面的写法感到十分困惑,不要着急,我们来分析Node的加载机制:

首先,Node会把整个待加载的hello.js文件放入一个包装函数load中执行。在执行这个load()函数前,Node准备好了module变量:

1
2
3
4
let module = {
id: 'hello',
exports: {}
};

load()函数最终返回module.exports

1
2
3
4
5
6
7
8
let load = function (exports, module) {
// hello.js的文件内容
...
// load函数返回:
return module.exports;
};

let exported = load(module.exports, module);

也就是说,默认情况下,Node准备的exports变量和module.exports变量实际上是同一个变量,并且初始化为空对象{},于是,我们可以写:

1
2
exports.foo = function () { return 'foo'; };
exports.bar = function () { return 'bar'; };

也可以写:

1
2
module.exports.foo = function () { return 'foo'; };
module.exports.bar = function () { return 'bar'; };

换句话说,Node默认给你准备了一个空对象{},这样你可以直接往里面加东西。

但是,如果我们要输出的是一个函数或数组,那么,只能给module.exports赋值:

1
module.exports = function () { return 'foo'; };

exports赋值是无效的,因为赋值后,module.exports仍然是空对象{}

结论

如果要输出一个键值对象{},可以利用exports这个已存在的空对象{},并继续在上面添加新的键值;

如果要输出一个函数或数组,必须直接对module.exports对象赋值。

所以我们可以得出结论:直接对module.exports赋值,可以应对任何情况:

1
2
3
module.exports = {
foo: function () { return 'foo'; }
};

或者:

1
module.exports = function () { return 'foo'; };

最终,我们强烈建议使用module.exports = xxx的方式来输出模块变量,这样,你只需要记忆一种方法。

练习

编写hello.js,输出一个或多个函数;

编写main.js,引入hello模块,调用其函数。

参考源码

module

虽然Node.js从诞生起就支持模块,但JavaScript语言本身长期以来却一直没有模块功能,只能由CommonJS或其他AMD等模块系统来“模拟”。

随着ES 6标准的推出,JavaScript语言本身终于也迎来了原生内置的模块支持,称为ECMAScript Modules(简称ESM),不仅可以直接在浏览器中使用模块,也可以在Node.js中使用ESM模块。

不使用ESM模块时,我们用module.exports导出可供外部使用的JS对象,例如,以下模块导出了两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use strict';
let s = 'Hello';

function out(prompt, name) {
console.log(`${prompt}, ${name}!`);
}

function greet(name) {
out(s, name);
}

function hi(name) {
out('Hi', name);
}

module.exports = {
greet: greet,
hi: hi
};

要把上述代码改为ESM模块,我们用export标识需要导出的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let s = 'Hello';

// out是模块内部函数,模块外部不可见:
function out(prompt, name) {
console.log(`${prompt}, ${name}!`);
}

// greet是导出函数,可被外部调用:
export function greet(name) {
out(s, name);
}

// hi是导出函数,可被外部调用:
export function hi(name) {
out('Hi', name);
}

并将其保存为hello.mjs文件,注意扩展名不是.js,而是.mjs

可以再编写一个main.mjs文件来调用hello模块:

1
2
3
4
5
import { greet, hi } from './hello.mjs';

let name = 'Bob';
greet(name);
hi(name);

可见,ESM模块用export关键字导出一个JS对象,用import关键字导入一个模块的导出对象。

如果要实现类似如下代码的单个函数导出:

1
module.exports = greet;

则可以用export default导出:

1
2
3
export default function greet(name) {
...
}

相应的,导入代码修改为:

1
import greet from './hello.mjs';

细心的同学还注意到ESM模块文件第一行并没有'use strict',这是因为ESM模块默认启用严格模式,因此无需再手动声明'use strict'

浏览器加载ESM

对于浏览器来说,也可以直接使用ESM模块。当我们加载一个ESM模块时,需要用type="module"来表示:

1
2
3
4
5
6
7
8
9
<html>
<head>
<script type="module" src="./example.js"></script>
<script type="module">
greet('Bob');
</script>
</head>
...
</html>

或者直接使用import加载一个模块:

1
2
3
4
5
6
7
8
9
<html>
<head>
<script type="module">
import { greet } from './example.js';
greet('Bob');
</script>
</head>
...
</html>

练习

在Node环境中使用ESM模块:

下载练习

小结

使用JavaScript内置的原生模块时,用关键字exportimport来实现导出与导入;

ESM模块默认启用strict模式,无需声明'use strict'

因为Node.js是运行在服务区端的JavaScript环境,服务器程序和浏览器程序相比,最大的特点是没有浏览器的安全限制了,而且,服务器程序必须能接收网络请求,读写文件,处理二进制内容,所以,Node.js内置的常用模块就是为了实现基本的服务器功能。这些模块在浏览器环境中是无法被执行的,因为它们的底层代码是用C/C++在Node.js运行环境中实现的。

global

在前面的JavaScript课程中,我们已经知道,JavaScript有且仅有一个全局对象,在浏览器中,叫window对象。而在Node.js环境中,也有唯一的全局对象,但不叫window,而叫global,这个对象的属性和方法也和浏览器环境的window不同。进入Node.js交互环境,可以直接输入:

1
2
3
4
5
6
7
8
> global.console
Object [console] {
log: [Function: log],
warn: [Function: warn],
dir: [Function: dir],
time: [Function: time],
...
}

process

process也是Node.js提供的一个对象,它代表当前Node.js进程。通过process对象可以拿到许多有用信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> process === global.process;
true
> process.version;
'v22.3.0'
> process.platform;
'darwin'
> process.arch;
'x64'
> process.cwd(); //返回当前工作目录
'/Users/michael'
> process.chdir('/private/tmp'); // 切换当前工作目录
undefined
> process.cwd();
'/private/tmp'

JavaScript程序是由事件驱动执行的单线程模型,Node.js也不例外。Node.js不断执行响应事件的JavaScript函数,直到没有任何响应事件的函数可以执行时,Node.js就退出了。

如果我们想要在下一次事件响应中执行代码,可以调用process.nextTick()

1
2
3
4
5
6
// test.js
// process.nextTick()将在下一轮事件循环中调用:
process.nextTick(function () {
console.log('nextTick callback!');
});
console.log('nextTick was set!');

用Node执行上面的代码node test.js,你会看到,打印输出是:

1
2
nextTick was set!
nextTick callback!

这说明传入process.nextTick()的函数不是立刻执行,而是要等到下一次事件循环。

Node.js进程本身的事件就由process对象来处理。如果我们响应exit事件,就可以在程序即将退出时执行某个回调函数:

1
2
3
4
// 程序即将退出时的回调函数:
process.on('exit', function (code) {
console.log('about to exit with code: ' + code);
});

判断JavaScript执行环境

有很多JavaScript代码既能在浏览器中执行,也能在Node环境执行,但有些时候,程序本身需要判断自己到底是在什么环境下执行的,常用的方式就是根据浏览器和Node环境提供的全局变量名称来判断:

1
2
3
4
5
if (typeof(window) === 'undefined') {
console.log('node.js');
} else {
console.log('browser');
}

导入Node模块

Node内置了许多模块,可以从Node.js的在线文档查询所有模块信息。

crypto模块的randomInt()函数为例,导入模块有两种方法:

方法一:使用传统的require()

1
2
3
const { randomInt } = require('node:crypto');
const n = randomInt(0, 100); // 0~100之间的随机数
console.log(n);

方法二:使用import关键字导入ESM模块:

1
2
3
import { randomInt } from 'node:crypto';
const n = randomInt(0, 100); // 0~100之间的随机数
console.log(n);

后面,我们将介绍Node.js的常用内置模块。

Node.js内置的fs模块就是文件系统模块,负责读写文件。

和所有其它JavaScript模块不同的是,fs模块同时提供了异步和同步的方法。

回顾一下什么是异步方法。因为JavaScript的单线程模型,执行IO操作时,JavaScript代码无需等待,而是传入回调函数后,继续执行后续JavaScript代码。比如jQuery提供的getJSON()操作:

1
2
3
4
$.getJSON('http://example.com/ajax', function (data) {
console.log('IO结果返回后执行...');
});
console.log('不等待IO结果直接执行后续代码...');

而同步的IO操作则需要等待函数返回:

1
2
// 根据网络耗时,函数将执行几十毫秒~几秒不等:
let data = getJSONSync('http://example.com/ajax');

同步操作的好处是代码简单,缺点是程序将等待IO操作,在等待时间内,无法响应其它任何事件。而异步读取不用等待IO操作,但代码较麻烦。

异步读文件

按照JavaScript的标准,异步读取一个文本文件的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// read-text-file-async.mjs
import { readFile } from 'node:fs';

console.log('BEGIN');

readFile('sample.txt', 'utf-8', function (err, data) {
if (err) {
console.log(err);
} else {
console.log(data);
}
});

console.log('END');

请注意,sample.txt文件必须在当前目录下,且文件编码为utf-8

异步读取时,传入的回调函数接收两个参数,当正常读取时,err参数为nulldata参数为读取到的String。当读取发生错误时,err参数代表一个错误对象,dataundefined。这也是Node.js标准的回调函数:第一个参数代表错误信息,第二个参数代表结果。后面我们还会经常编写这种回调函数。

由于err是否为null就是判断是否出错的标志,所以通常的判断逻辑总是:

1
2
3
4
5
if (err) {
// 出错了
} else {
// 正常
}

执行上述代码,可以看到打印的内容如下:

1
2
3
>>> BEGIN >>>
>>> END >>>
Sample file content...

因为异步读取,所以,先打印END后,才会执行回调函数,打印文件内容。

如果我们要读取的文件不是文本文件,而是二进制文件,怎么办?

下面的例子演示了如何读取一个图片文件:

1
2
3
4
5
6
7
8
9
10
import { readFile } from 'node:fs';

readFile('sample.png', function (err, data) {
if (err) {
console.log(err);
} else {
console.log(data instanceof Buffer); // true
console.log(data); // Buffer(12451) [137, 80, 78, 71, 13, ...]
}
});

当读取二进制文件时,不传入文件编码时,回调函数的data参数将返回一个Buffer对象。在Node.js中,Buffer对象就是一个包含零个或任意个字节的数组(注意和Array不同)。

Buffer对象可以和String作转换,例如,把一个Buffer对象转换成String:

1
2
3
// Buffer -> String
let text = data.toString('utf-8');
console.log(text);

或者把一个String转换成Buffer

1
2
3
// String -> Buffer
let buf = Buffer.from(text, 'utf-8');
console.log(buf);

同步读文件

除了标准的异步读取模式外,fs也提供相应的同步读取函数。同步读取的函数和异步函数相比,多了一个Sync后缀,并且不接收回调函数,函数直接返回结果。

fs模块同步读取一个文本文件的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// read-text-file-sync.mjs
import { readFileSync } from 'node:fs';

console.log('BEGIN');

try {
let s = readFileSync('sample.txt', 'utf-8');
console.log(s);
} catch (err) {
console.log(err);
}
console.log('END');

可见,原异步调用的回调函数的data被函数直接返回,函数名需要改为readFileSync,其它参数不变。

如果同步读取文件发生错误,则需要用try...catch捕获该错误。

写文件

将数据写入文件是通过fs.writeFile()实现的:

1
2
3
4
5
6
7
8
9
// write-file-async.mjs
import { writeFile } from 'node:fs';

let data = 'Hello, Node.js';
writeFile('output.txt', data, function (err) {
if (err) {
console.log(err);
}
});

writeFile()的参数依次为文件名、数据和回调函数。如果传入的数据是String,默认按UTF-8编码写入文本文件,如果传入的参数是Buffer,则写入的是二进制文件。回调函数由于只关心成功与否,因此只需要一个err参数。

readFile()类似,writeFile()也有一个同步方法,叫writeFileSync()

stat

如果我们要获取文件大小,创建时间等信息,可以使用fs.stat(),它返回一个Stat对象,能告诉我们文件或目录的详细信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// read-stat-async.mjs
import { stat } from 'node:fs';

stat('sample.png', function (err, st) {
if (err) {
console.log(err);
} else {
// 是否是文件:
console.log('isFile: ' + st.isFile());
// 是否是目录:
console.log('isDirectory: ' + st.isDirectory());
if (st.isFile()) {
// 文件大小:
console.log('size: ' + st.size);
// 创建时间, Date对象:
console.log('birth time: ' + st.birthtime);
// 修改时间, Date对象:
console.log('modified time: ' + st.mtime);
}
}
});

运行结果如下:

1
2
3
4
5
isFile: true
isDirectory: false
size: 12451
birth time: Mon Jun 17 2024 19:37:24 GMT+0800 (China Standard Time)
modified time: Mon Jun 17 2024 19:37:24 GMT+0800 (China Standard Time)

stat()也有一个对应的同步函数statSync(),请试着改写上述异步代码为同步代码。

使用Promise

我们在介绍JavaScript的Promise时,讲到通过async函数实现异步逻辑,代码更简单。

类似的,Node还提供Promise版本的fs,可以用如下代码在async函数中读取文件:

1
2
3
4
5
6
7
8
// async-read.mjs
import { readFile } from 'node:fs/promises';

async function readTextFile(path) {
return await readFile(path, 'utf-8');
}

readTextFile('sample.txt').then(s => console.log(s));

在async函数中,用await调用fs/promises与同步方法类似,但代码却是异步执行的。

异步还是同步

fs模块中,提供同步方法是为了方便使用。那我们到底是应该用异步方法还是同步方法呢?

由于Node环境执行的JavaScript代码是服务器端代码,所以,绝大部分需要在服务器运行期反复执行业务逻辑的代码,必须使用异步代码,否则,同步代码在执行时期,服务器将停止响应,因为JavaScript只有一个执行线程。

服务器启动时如果需要读取配置文件,或者结束时需要写入到状态文件时,可以使用同步代码,因为这些代码只在启动和结束时执行一次,不影响服务器正常运行时的异步执行。

如果代码中编写了大量的async函数,那么通过await异步调用fs/promises模块更加方便。

参考源码

fs

stream是Node.js提供的又一个仅在服务区端可用的模块,目的是支持“流”这种数据结构。

什么是流?流是一种抽象的数据结构。想象水流,当在水管中流动时,就可以从某个地方(例如自来水厂)源源不断地到达另一个地方(比如你家的洗手池)。我们也可以把数据看成是数据流,比如你敲键盘的时候,就可以把每个字符依次连起来,看成字符流。这个流是从键盘输入到应用程序,实际上它还对应着一个名字:标准输入流(stdin)。

如果应用程序把字符一个一个输出到显示器上,这也可以看成是一个流,这个流也有名字:标准输出流(stdout)。流的特点是数据是有序的,而且必须依次读取,或者依次写入,不能像Array那样随机定位。

stream

有些流用来读取数据,比如从文件读取数据时,可以打开一个文件流,然后从文件流中不断地读取数据。有些流用来写入数据,比如向文件写入数据时,只需要把数据不断地往文件流中写进去就可以了。

在Node.js中,流也是一个对象,我们只需要响应流的事件就可以了:data事件表示流的数据已经可以读取了,end事件表示这个流已经到末尾了,没有数据可以读取了,error事件表示出错了。

下面是一个从文件流读取文本内容的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createReadStream } from 'node:fs';

// 打开流:
let rs = createReadStream('sample.txt', 'utf-8');

// 读取到数据:
rs.on('data', (chunk) => {
console.log('---- chunk ----');
console.log(chunk);
});

// 结束读取:
rs.on('end', () => {
console.log('---- end ----');
});

// 出错:
rs.on('error', err => {
console.log(err);
});

要注意,data事件可能会有多次,每次传递的chunk是流的一部分数据。

要以流的形式写入文件,只需要不断调用write()方法,最后以end()结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createWriteStream } from 'node:fs';

let ws = createWriteStream('output.txt', 'utf-8');
ws.write('使用Stream写入文本数据...\n');
ws.write('继续写入...\n');
ws.write('DONE.\n');
ws.end(); // 结束写入

// 写入二进制数据:
let b64array = [ ... ];

let ws2 = createWriteStream('output.png');
for (let b64 of b64array) {
let buf = Buffer.from(b64, 'base64');
ws2.write(buf); // 写入Buffer对象
}
ws2.end(); // 结束写入

所有可以读取数据的流都继承自stream.Readable,所有可以写入的流都继承自stream.Writable

pipe

就像可以把两个水管串成一个更长的水管一样,两个流也可以串起来。一个Readable流和一个Writable流串起来后,所有的数据自动从Readable流进入Writable流,这种操作叫pipe

在Node.js中,Readable流有一个pipe()方法,就是用来干这件事的:

1
rs.pipe(ws);

除了直接使用pipe()方法,Node还提供了pipeline功能,它可以将一个流输出到另一个流。以下是一个复制文件的程序:

1
2
3
4
5
6
7
8
9
10
11
12
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from 'node:stream/promises';

async function copy(src, dest) {
let rs = createReadStream(src);
let ws = createWriteStream(dest);
await pipeline(rs, ws);
}

copy('sample.txt', 'output.txt')
.then(() => console.log('copied.'))
.catch(err => console.log(err));

使用pipeline的好处是,它可以添加若干个转换器,即输入流经过若干转换后,再进入输出流。如果我们添加的转换器实现了gzip功能,那么实际上就可以把输入流自动压缩后进入输出流。

参考源码

stream

Node.js开发的目的就是为了用JavaScript编写Web服务器程序。因为JavaScript实际上已经统治了浏览器端的脚本,其优势就是有世界上数量最多的前端开发人员。如果已经掌握了JavaScript前端开发,再学习一下如何将JavaScript应用在后端开发,就是名副其实的全栈了。

HTTP协议

要理解Web服务器程序的工作原理,首先,我们要对HTTP协议有基本的了解。如果你对HTTP协议不太熟悉,先看一看HTTP协议简介

HTTP服务器

要开发HTTP服务器程序,从头处理TCP连接,解析HTTP是不现实的。这些工作实际上已经由Node.js自带的http模块完成了。应用程序并不直接和HTTP协议打交道,而是操作http模块提供的requestresponse对象。

request对象封装了HTTP请求,我们调用request对象的属性和方法就可以拿到所有HTTP请求的信息;

response对象封装了HTTP响应,我们操作response对象的方法,就可以把HTTP响应返回给浏览器。

用Node.js实现一个HTTP服务器程序非常简单。我们来实现一个最简单的Web程序hello.js,它对于所有请求,都返回Hello world!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 导入http模块:
import http from 'node:http';

// 创建http server,并传入回调函数:
const server = http.createServer((request, response) => {
// 回调函数接收request和response对象,
// 获得HTTP请求的method和url:
console.log(request.method + ': ' + request.url);
// 将HTTP响应200写入response, 同时设置Content-Type: text/html:
response.writeHead(200, {'Content-Type': 'text/html'});
// 将HTTP响应的HTML内容写入response:
response.end('<h1>Hello world!</h1>');
});

// 出错时返回400:
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

// 让服务器监听8080端口:
server.listen(8080);
console.log('Server is running at http://127.0.0.1:8080/');

在命令提示符下运行该程序,可以看到以下输出:

1
2
$ node simple-server.mjs 
Server is running at http://127.0.0.1:8080/

不要关闭命令提示符,直接打开浏览器输入http://localhost:8080,即可看到服务器响应的内容:

http-page

同时,在命令提示符窗口,可以看到程序打印的请求信息:

1
2
GET: /
GET: /favicon.ico

这就是我们编写的第一个HTTP服务器程序!

文件服务器

让我们继续扩展一下上面的Web程序。我们可以设定一个目录,然后让Web程序变成一个文件服务器。要实现这一点,我们只需要解析request.url中的路径,然后在本地找到对应的文件,把文件内容发送出去就可以了。

观察打印的request.url,它实际上是浏览器请求的路径和参数,如:

  • /
  • /index.html
  • /hello?name=bob

解析出path部分可以直接用URL对象:

1
2
3
let url = new URL('http://localost' + '/index.html?v=1');
let pathname = url.pathname;
console.log(pathname); // index.html

处理本地文件目录需要使用Node.js提供的path模块,它可以方便地构造目录:

1
2
3
4
5
6
7
8
import path from 'node:path';

// 解析当前目录:
let workDir = path.resolve('.'); // '/Users/michael'

// 组合完整的文件路径:当前目录+'pub'+'index.html':
let filePath = path.join(workDir, 'pub', 'index.html');
// '/Users/michael/pub/index.html'

使用path模块可以正确处理操作系统相关的文件路径。在Windows系统下,返回的路径类似于C:\Users\michael\static\index.html,这样,我们就不关心怎么拼接路径了。

最后,我们实现一个文件服务器simple-file-server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 导入http模块:
import http from 'node:http';
import path from 'node:path';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';

// 设定www根目录为当前目录:
const wwwRoot = path.resolve('.');
console.log(`set www root: ${wwwRoot}`);

// 根据扩展名确定MIME类型:
function guessMime(pathname) {
// FIXME:
return 'text/html';
}

// 创建http file server,并传入回调函数:
const server = http.createServer((request, response) => {
// 获得HTTP请求的method和url:
console.log(request.method + ': ' + request.url);
if (request.method !== 'GET') {
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end('<h1>400 Bad Request</h1>');
} else {
// 解析path:
let url = new URL(`http://localhost${request.url}`);
let pathname = url.pathname;
let filepath = path.join(wwwRoot, pathname);
// TODO: 必要的安全检查
// 检查文件状态:
stat(filepath).then(st => {
if (st.isFile()) {
console.log('200 OK');
// 发送200响应:
response.writeHead(200, { 'Content-Type': guessMime(pathname) });
// 将文件流导向response:
createReadStream(filepath).pipe(response);
} else {
console.log('404 Not Found');
response.writeHead(404, { 'Content-Type': 'text/html' });
response.end('<h1>404 Not Found</h1>');
}
}).catch(err => {
console.log('404 Not Found');
response.writeHead(404, { 'Content-Type': 'text/html' });
response.end('<h1>404 Not Found</h1>');
});
}
});

// 出错时返回400:
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

// 让服务器监听8080端口:
server.listen(8080);
console.log('Server is running at http://127.0.0.1:8080/');

没有必要手动读取文件内容。由于response对象本身是一个Writable Stream,直接用pipe()方法就实现了自动读取文件内容并输出到HTTP响应。

在命令行运行node simple-file-server.mjs,然后在浏览器中输入http://localhost:8080/index.html

http-index-page

只要当前目录下存在文件index.html,服务器就可以把文件内容发送给浏览器。观察控制台输出:

1
2
3
4
5
6
GET: /index.html
200 OK
GET: /next/hello.png
200 OK
GET: /favicon.ico
200 OK

第一个请求是浏览器请求/页面,后续请求是浏览器解析HTML后发送的其它资源请求。

练习

在浏览器输入http://localhost:8080/时,会返回404,原因是程序识别出HTTP请求的不是文件,而是目录。请修改simple-file-server.mjs,如果遇到请求的路径是目录,则自动在目录下依次搜索index.htmldefault.html,如果找到了,就返回HTML文件的内容。

参考源码

crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C++实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。

MD5和SHA1

MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:

1
2
3
4
5
6
7
8
9
import crypto from 'node:crypto';

const hash = crypto.createHash('md5');

// 可任意多次调用update():
hash.update('Hello, world!');
hash.update('Hello, nodejs!');

console.log(hash.digest('hex')); // 7e1977739c748beac0c0fd14fd26a544

update()方法默认字符串编码为UTF-8,也可以传入Buffer。

如果要计算SHA1,只需要把'md5'改成'sha1',就可以得到SHA1的结果。还可以使用更安全的sha256sha512

Hmac

Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:

1
2
3
4
5
6
7
8
import crypto from 'node:crypto';

const hmac = crypto.createHmac('sha256', 'secret-key');

hmac.update('Hello, world!');
hmac.update('Hello, nodejs!');

console.log(hmac.digest('hex')); // 80f7e22570...

只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。

AES

AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import crypto from 'node:crypto';

function aes_encrypt(key, iv, msg) {
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
// input encoding: utf8
// output encoding: hex
let encrypted = cipher.update(msg, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}

function aes_decrypt(key, iv, encrypted) {
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}

// key的长度必须为32bytes:
let key = 'Passw0rdPassw0rdPassw0rdPassw0rd';
// iv的长度必须为16bytes:
let iv = 'a1b2c3d4e5f6g7h8';
let msg = 'Hello, world!';
// 加密:
let encrypted_msg = aes_encrypt(key, iv, msg);
// 解密:
let decrypted_msg = aes_decrypt(key, iv, encrypted_msg);

console.log(`AES encrypt: ${encrypted_msg}`);
console.log(`AES decrypt: ${decrypted_msg}`);

运行结果如下:

1
2
AES encrypt: 11cd65e5fe7e7448b491efabee2f326a
AES decrypt: Hello, world!

可以看出,加密后的字符串通过解密又得到了原始内容。

注意到AES有很多不同的算法,如aes192aes-128-ecbaes-256-cbc等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Node.js全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。

Diffie-Hellman

DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:

  1. 小明先选一个素数和一个底数,例如,素数p=97,底数g=5(底数是p的一个原根),再选择一个秘密整数a=123,计算A=g^a mod p=34,然后大声告诉小红:p=97,g=5,A=34
  2. 小红收到小明发来的pgA后,也选一个秘密整数b=456,然后计算B=g^b mod p=75,并大声告诉小明:B=75
  3. 小明自己计算出s=B^a mod p=22,小红也自己计算出s=A^b mod p=22,因此,最终协商的密钥s22

在这个过程中,密钥22并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=97g=5A=34B=75,由于不知道双方选的秘密整数a=123b=456,因此无法计算出密钥22

用crypto模块实现DH算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import crypto from 'node:crypto';

// xiaoming's keys:
let ming = crypto.createDiffieHellman(512);
let ming_keys = ming.generateKeys();

let prime = ming.getPrime();
let generator = ming.getGenerator();

console.log('Prime: ' + prime.toString('hex'));
console.log('Generator: ' + generator.toString('hex'));

// xiaohong's keys:
let hong = crypto.createDiffieHellman(prime, generator);
let hong_keys = hong.generateKeys();

// exchange and generate secret:
let ming_secret = ming.computeSecret(hong_keys);
let hong_secret = hong.computeSecret(ming_keys);

// print secret:
console.log('Secret of Xiao Ming: ' + ming_secret.toString('hex'));
console.log('Secret of Xiao Hong: ' + hong_secret.toString('hex'));

运行后,可以得到如下输出:

1
2
3
4
Prime: a8224c...deead3
Generator: 02
Secret of Xiao Ming: 695308...d519be
Secret of Xiao Hong: 695308...d519be

注意每次输出都不一样,因为素数的选择是随机的。

RSA

RSA算法是一种非对称加密算法,即由一个私钥和一个公钥构成的密钥对,通过私钥加密,公钥解密,或者通过公钥加密,私钥解密。其中,公钥可以公开,私钥必须保密。

RSA算法是1977年由Ron Rivest、Adi Shamir和Leonard Adleman共同提出的,所以以他们三人的姓氏的头字母命名。

当小明给小红发送信息时,可以用小明自己的私钥加密,小红用小明的公钥解密,也可以用小红的公钥加密,小红用她自己的私钥解密,这就是非对称加密。相比对称加密,非对称加密只需要每个人各自持有自己的私钥,同时公开自己的公钥,不需要像AES那样由两个人共享同一个密钥。

在使用Node进行RSA加密前,我们先要准备好私钥和公钥。

首先,在命令行执行以下命令以生成一个RSA密钥对:

1
openssl genrsa -aes256 -out rsa-key.pem 2048

根据提示输入密码,这个密码是用来加密RSA密钥的,加密方式指定为AES256,生成的RSA的密钥长度是2048位。执行成功后,我们获得了加密的rsa-key.pem文件。

第二步,通过上面的rsa-key.pem加密文件,我们可以导出原始的私钥,命令如下:

1
openssl rsa -in rsa-key.pem -outform PEM -out rsa-prv.pem

输入第一步的密码,我们获得了解密后的私钥。

类似的,我们用下面的命令导出原始的公钥:

1
openssl rsa -in rsa-key.pem -outform PEM -pubout -out rsa-pub.pem

这样,我们就准备好了原始私钥文件rsa-prv.pem和原始公钥文件rsa-pub.pem,编码格式均为PEM。

下面,使用crypto模块提供的方法,即可实现非对称加解密。

首先,我们用私钥加密,公钥解密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import fs from 'node:fs';
import crypto from 'node:crypto';

// 从文件加载key:
function loadKey(file) {
// key实际上就是PEM编码的字符串:
return fs.readFileSync(file, 'utf8');
}

let
prvKey = loadKey('./rsa-prv.pem'),
pubKey = loadKey('./rsa-pub.pem'),
message = 'Hello, world!';

// 使用私钥加密:
let enc_by_prv = crypto.privateEncrypt(prvKey, Buffer.from(message, 'utf8'));
console.log(enc_by_prv.toString('hex'));

let dec_by_pub = crypto.publicDecrypt(pubKey, enc_by_prv);
console.log(dec_by_pub.toString('utf8'));

执行后,可以得到解密后的消息,与原始消息相同。

接下来我们使用公钥加密,私钥解密:

1
2
3
4
5
6
7
// 使用公钥加密:
let enc_by_pub = crypto.publicEncrypt(pubKey, Buffer.from(message, 'utf8'));
console.log(enc_by_pub.toString('hex'));

// 使用私钥解密:
let dec_by_prv = crypto.privateDecrypt(prvKey, enc_by_pub);
console.log(dec_by_prv.toString('utf8'));

执行得到的解密后的消息仍与原始消息相同。

如果我们把message字符串的长度增加到很长,例如1M,这时,执行RSA加密会得到一个类似这样的错误:data too large for key size,这是因为RSA加密的原始信息必须小于Key的长度。那如何用RSA加密一个很长的消息呢?实际上,RSA并不适合加密大数据,而是先生成一个随机的AES密码,用AES加密原始信息,然后用RSA加密AES口令,这样,实际使用RSA时,给对方传的密文分两部分,一部分是AES加密的密文,另一部分是RSA加密的AES口令。对方用RSA先解密出AES口令,再用AES解密密文,即可获得明文。

证书

crypto模块也可以处理数字证书。数字证书通常用在SSL连接,也就是Web的https连接。一般情况下,https连接只需要处理服务器端的单向认证,如无特殊需求(例如自己作为Root给客户发认证证书),建议用反向代理服务器如Nginx等Web服务器去处理证书。

参考源码

最早的软件都是运行在大型机上的,软件使用者通过“哑终端”登陆到大型机上去运行软件。后来随着PC机的兴起,软件开始主要运行在桌面上,而数据库这样的软件运行在服务器端,这种Client/Server模式简称CS架构。

随着互联网的兴起,人们发现,CS架构不适合Web,最大的原因是Web应用程序的修改和升级非常迅速,而CS架构需要每个客户端逐个升级桌面App,因此,Browser/Server模式开始流行,简称BS架构。

在BS架构下,客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取Web页面,并把Web页面展示给用户即可。

当然,Web页面也具有极强的交互性。由于Web页面是用HTML编写的,而HTML具备超强的表现力,并且,服务器端升级后,客户端无需任何部署就可以使用到新的版本,因此,BS架构迅速流行起来。

今天,除了重量级的软件如Office,Photoshop等,大部分软件都以Web形式提供。比如,新浪提供的新闻、博客、微博等服务,均是Web应用。

Web应用开发可以说是目前软件开发中最重要的部分。Web开发也经历了好几个阶段:

静态Web页面:由文本编辑器直接编辑并生成静态的HTML页面,如果要修改Web页面的内容,就需要再次编辑HTML源文件,早期的互联网Web页面就是静态的;

CGI:由于静态Web页面无法与用户交互,比如用户填写了一个注册表单,静态Web页面就无法处理。要处理用户发送的动态数据,出现了Common Gateway Interface,简称CGI,用C/C++编写。

ASP/JSP/PHP:由于Web应用特点是修改频繁,用C/C++这样的低级语言非常不适合Web开发,而脚本语言由于开发效率高,与HTML结合紧密,因此,迅速取代了CGI模式。ASP是微软推出的用VBScript脚本编程的Web开发技术,而JSP用Java来编写脚本,PHP本身则是开源的脚本语言。

MVC:为了解决直接用脚本语言嵌入HTML导致的可维护性差的问题,Web应用也引入了Model-View-Controller的模式,来简化Web开发。ASP发展为ASP.Net,JSP和PHP也有一大堆MVC框架。

目前,Web开发技术仍在快速发展中,异步开发、新的MVVM前端技术层出不穷。

由于Node.js把JavaScript引入了服务器端,因此,原来必须使用PHP/Java/C#/Python/Ruby等其他语言来开发服务器端程序,现在可以使用Node.js开发了!

用Node.js开发Web服务器端,有几个显著的优势:

一是后端语言也是JavaScript,以前掌握了前端JavaScript的开发人员,现在可以同时编写后端代码;

二是前后端统一使用JavaScript,就没有切换语言的障碍了;

三是速度快,非常快!这得益于Node.js天生是异步的。

在Node.js诞生后的短短几年里,出现了无数种Web框架、ORM框架、模版引擎、测试框架、自动化构建工具,数量之多,即使是JavaScript老司机,也不免眼花缭乱。

常见的Web框架包括:ExpressSails.jskoaMeteorDerbyJSTotal.jsrestify……

ORM框架比Web框架要少一些:SequelizeORM2Bookshelf.jsObjection.js……

模版引擎PK:JadeEJSSwigNunjucksdoT.js……

测试框架包括:MochaExpressoUnit.jsKarma……

构建工具有:GruntGulpWebpack……

目前,在npm上已发布的开源Node.js模块数量超过了30万个。

有选择恐惧症的朋友,看到这里可以洗洗睡了。

好消息是这个教程已经帮你选好了,你只需要跟着教程一条道走到黑就可以了。

koa

koa是Express的下一代基于Node.js的web框架,目前有1.x和2.0两个版本。

历史

Express

Express是第一代最流行的web框架,它对Node.js的http进行了封装,用起来如下:

1
2
3
4
5
6
7
8
9
10
let express = require('express');
let app = express();

app.get('/', function (req, res) {
res.send('Hello World!');
});

app.listen(3000, function () {
console.log('Example app listening on port 3000!');
});

虽然Express的API很简单,但是它是基于ES5的语法,要实现异步代码,只有一个方法:回调。如果异步嵌套层次过多,代码写起来就非常难看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/test', function (req, res) {
fs.readFile('/file1', function (err, data) {
if (err) {
res.status(500).send('read file1 error');
}
fs.readFile('/file2', function (err, data) {
if (err) {
res.status(500).send('read file2 error');
}
res.type('text/plain');
res.send(data);
});
});
});

虽然可以用async这样的库来组织异步代码,但是用回调写异步实在是太痛苦了!

koa 1.x

随着新版Node.js开始支持ES6,Express的团队又基于ES6的generator重新编写了下一代web框架koa。和Express相比,koa 1.x使用generator实现异步,代码看起来像同步的:

1
2
3
4
5
6
7
8
9
10
let koa = require('koa');
let app = koa();

app.use('/test', function *() {
yield doReadFile1();
let data = yield doReadFile2();
this.body = data;
});

app.listen(3000);

用generator实现异步比回调简单了不少,但是generator的本意并不是异步。Promise才是为异步设计的,但是Promise的写法……想想就复杂。为了简化异步代码,JavaScript引入了新的关键字asyncawait,可以轻松地把一个function变为异步模式:

1
2
3
async function () {
let data = await fs.read('/file1');
}

这是JavaScript标准的异步代码,非常简洁,并且易于使用。

koa 2.x

koa团队并没有止步于koa 1.x,他们又开发了koa 2,和koa 1相比,koa 2完全使用Promise并配合async来实现异步。

koa 2.x的代码看上去像这样:

1
2
3
4
5
6
app.use(async (ctx, next) => {
await next();
let data = await doReadFile();
ctx.response.type = 'text/plain';
ctx.response.body = data;
});

选择哪个版本?

为了紧跟时代潮流,教程将使用最新的koa 2.x开发!



创建koa工程

首先,我们创建一个目录hello-koa,作为koa工程根目录。

在根目录下执行npm install koa,我们就在当前目录下安装好了koa及其依赖项,执行完成后目录结构如下:

1
2
3
4
hello-koa/
├── node_modules/ <-- koa以及所有依赖项
├── package-lock.json
└── package.json <-- npm描述文件

package.json包含了npm的依赖信息,以及项目描述等信息,package-lock.json是针对当前所有依赖的一个快照,目的是锁定各个依赖项的版本号。

我们打开package.json,内容如下:

1
2
3
4
5
{
"dependencies": {
"koa": "^2.15.3"
}
}

它只有一个koa依赖,是由命令npm install koa写入的。

node_modules是所有依赖项安装的地方,可以随时删除node_modules目录,然后用npm install重新安装。

直接运行npm install则根据package.jsondependencies信息下载安装依赖项,运行npm install xyz则将xyz添加到dependencies然后再安装xyz及其依赖项。因此,熟练的开发者可以先自己编辑dependencies内容,然后删除node_modules后重新安装所有依赖项。此外,package.jsonpackage-lock.json应当添加至版本控制系统中,而node_modules则无需添加。

我们手动添加如下信息至package.json

1
2
3
4
5
6
7
8
9
{
"name": "hello-koa",
"version": "1.0",
"description": "Hello koa webapp.",
"type": "module",
"dependencies": {
"koa": "^2.15.3"
}
}

其中,nameversiondescription均可任意设置,typemodule表示以ESM模块执行。

紧接着,我们用VS Code打开hello-koa目录,创建app.mjs文件,输入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 导入koa,注意导入的是大写开头的class:
import Koa from 'koa';

// 创建一个koa实例表示webapp本身:
const app = new Koa();

// 对于任何请求,app将调用该异步函数处理请求:
app.use(async (ctx, next) => {
await next();
// 设置响应类型和文本:
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Hello Koa!</h1>';
});

// 在端口3000监听:
app.listen(3000);
console.log('app started at port 3000...');

对于每一个http请求,koa将调用我们传入的异步函数来处理:

1
2
3
4
5
6
7
async (ctx, next) => {
await next();
// 设置response的Content-Type:
ctx.response.type = 'text/html';
// 设置response的内容:
ctx.response.body = '<h1>Hello Koa!</h1>';
}

其中,参数ctx是由koa传入的封装了request和response的变量,我们可以通过它访问request和response,next是koa传入的将要处理的下一个异步函数。

上面的异步函数中,我们首先用await next();处理下一个异步函数,然后,设置response的Content-Type和内容。

现在,我们的工程结构如下:

1
2
3
4
5
hello-koa/
├── node_modules/
├── app.mjs
├── package-lock.json
└── package.json

在命令行执行node app.mjs就启动了Web服务器。我们打开浏览器,输入http://localhost:3000,即可看到效果:

koa-browser

koa middleware

让我们再仔细看看koa的执行逻辑。核心代码是:

1
2
3
4
5
app.use(async (ctx, next) => {
await next();
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Hello Koa!</h1>';
});

每收到一个http请求,koa就会调用通过app.use()注册的async函数,并传入ctxnext参数。

我们可以对ctx操作,并设置返回内容。但是为什么要调用await next()

原因是koa把很多async函数组成一个处理链,每个async函数都可以做一些自己的事情,然后用await next()来调用下一个async函数。我们把每个async函数称为middleware,这些middleware可以组合起来,完成很多有用的功能。

例如,可以用以下3个middleware组成处理链,依次打印日志,记录处理时间,输出HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.use(async (ctx, next) => {
console.log(`${ctx.request.method} ${ctx.request.url}`); // 打印URL
await next(); // 调用下一个middleware
});

app.use(async (ctx, next) => {
const start = Date.now(); // 当前时间
await next(); // 调用下一个middleware
const ms = Date.now() - start; // 耗费时间
console.log(`Time: ${ms}ms`); // 打印耗费时间
});

app.use(async (ctx, next) => {
await next();
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Hello Koa!</h1>';
});

middleware的顺序很重要,也就是调用app.use()的顺序决定了middleware的顺序。

此外,如果一个middleware没有调用await next(),会怎么办?答案是后续的middleware将不再执行了。这种情况也很常见,例如,一个检测用户权限的middleware可以决定是否继续处理请求,还是直接返回403错误:

1
2
3
4
5
6
7
app.use(async (ctx, next) => {
if (await checkUserPermission(ctx)) {
await next();
} else {
ctx.response.status = 403;
}
});

理解了middleware,我们就已经会用koa了!

最后注意ctx对象有一些简写的方法,例如ctx.url相当于ctx.request.urlctx.type相当于ctx.response.type

参考源码

hello-koa

在hello-koa工程中,我们处理http请求一律返回相同的HTML,这样虽然非常简单,但是用浏览器一测,随便输入任何URL都会返回相同的网页。

buduijin

正常情况下,我们应该对不同的URL调用不同的处理函数,这样才能返回不同的结果。例如像这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app.use(async (ctx, next) => {
if (ctx.request.path === '/') {
ctx.response.body = 'index page';
} else {
await next();
}
});

app.use(async (ctx, next) => {
if (ctx.request.path === '/test') {
ctx.response.body = 'TEST page';
} else {
await next();
}
});

app.use(async (ctx, next) => {
if (ctx.request.path === '/error') {
ctx.response.body = 'ERROR page';
} else {
await next();
}
});

这么写是可以运行的,但是好像有点蠢。

应该有一个能集中处理URL的middleware,它根据不同的URL调用不同的处理函数,这样,我们才能专心为每个URL编写处理函数。

@koa/router

为了处理URL,我们需要引入@koa/router这个middleware,让它负责处理URL映射。

我们把上一节的hello-koa工程复制一份,重命名为url-koa

先用命令npm install @koa/router安装并在package.json中添加依赖项,接下来,我们修改app.mjs,使用@koa/router来处理URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Koa from 'koa';
import Router from '@koa/router';

const app = new Koa();
const router = new Router();

// log url:
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
await next();
});

// 解析request.body:
app.use(bodyParser());

// 设置路由: 处理 /
router.get('/', async (ctx, next) => {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Index Page</h1>';
});

// 设置路由: 处理 /hello/:name
router.get('/hello/:name', async (ctx, next) => {
// 获取:name参数:
let s = ctx.params.name;
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Hello, ${s}</h1>`;
});

// 使用router:
app.use(router.routes());

app.listen(3000);
console.log('app started at port 3000...');

我们使用router.get('/path', async fn)来注册一个GET请求。可以在请求路径中使用带变量的/hello/:name,变量可以通过ctx.params.name访问。

再运行app.mjs,我们就可以测试不同的URL:

输入首页:http://localhost:3000/

url-index

输入:http://localhost:3000/hello/Bob

url-hello

处理post请求

router.get('/path', async fn)处理的是get请求。如果要处理post请求,可以用router.post('/path', async fn)

用post请求处理URL时,我们会遇到一个问题:post请求通常会发送一个表单,或者JSON,它作为request的body发送,但无论是Node.js提供的原始request对象,还是koa提供的request对象,都不提供解析request的body的功能!

所以,我们又需要引入另一个middleware来解析原始request请求,然后,把解析后的参数,绑定到ctx.request.body中。@koa/bodyparser就是用来干这个活的。

我们用命令npm install @koa/bodyparser安装并在package.json中添加依赖项,然后,修改app.mjs,引入@koa/bodyparser

1
import { bodyParser } from '@koa/bodyparser';

在合适的位置加上:

1
app.use(bodyParser());

由于middleware的顺序很重要,这个@koa/bodyparser必须在router之前被注册到app对象上。

现在我们就可以处理post请求了。写一个简单的登录表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
router.get('/', async (ctx, next) => {
ctx.response.type = 'text/html';
ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
<p>Name: <input name="name" value="koa"></p>
<p>Password: <input name="password" type="password"></p>
<p><button type="submit">Submit</button></p>
</form>
`;
});

router.post('/signin', async (ctx, next) => {
let name = ctx.request.body.name || '';
let password = ctx.request.body.password || '';
console.log(`try signin: ${name}, password: ${password}`);
if (name === 'koa' && password === '12345') {
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
} else {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
}
});

注意到我们用let name = ctx.request.body.name || ''拿到表单的name字段,如果该字段不存在,默认值设置为''

类似的,put、delete、head请求也可以由router处理。

重构

现在,我们已经可以处理不同的URL了,但是看看app.mjs,总觉得还是有点不对劲。

still-buduijin

所有的URL处理函数都放到app.mjs里显得很乱,而且,每加一个URL,就需要修改app.mjs。随着URL越来越多,app.mjs就会越来越长。

如果能把URL处理函数集中到某个js文件,或者某几个js文件中就好了,然后让app.mjs自动导入所有处理URL的函数。这样,代码一分离,逻辑就显得清楚了。最好是这样:

1
2
3
4
5
6
7
url2-koa/
├── controller/
│ ├── hello.mjs <-- 处理/hello/:name
│ └── signin.mjs <-- 处理/signin
├── app.mjs
├── package-lock.json
└── package.json

于是我们把url-koa复制一份,重命名为url2-koa,准备重构这个项目。

我们先在controller目录下编写signin.mjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// GET /
async function index(ctx, next) {
ctx.response.type = 'text/html';
ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
<p>Name: <input name="name" value="koa"></p>
<p>Password: <input name="password" type="password"></p>
<p><button type="submit">Submit</button></p>
</form>
`;
}

// POST /signin
async function signin(ctx, next) {
let name = ctx.request.body.name || '';
let password = ctx.request.body.password || '';
console.log(`try signin: ${name}, password: ${password}`);
if (name === 'koa' && password === '12345') {
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
} else {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
}
}

// 导出处理函数:
export default {
'GET /': index,
'POST /signin': signin
};

这个signin.mjs通过export default把两个URL处理函数暴露出来。

类似的,hello.mjs把一个URL处理函数暴露出来:

1
2
3
4
5
6
7
8
9
10
async function hello(ctx, next) {
// 获取:name参数:
let s = ctx.params.name;
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Hello, ${s}</h1>`;
}

export default {
'GET /hello/:name': hello
}

现在,我们修改app.mjs,让它自动扫描controller目录,找到所有js文件,导入,然后注册每个URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 扫描controller目录:
const dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`scan dir ${dirname}...`);
// 列举 *.mjs 文件:
let files = readdirSync(path.join(dirname, 'controller')).filter(f => f.endsWith('.mjs'));
for (let file of files) {
// 导入模块:
console.log(`import controller/${file}...`);
let { default: mapping } = await import(`./controller/${file}`);
// 把每个URL映射添加到router:
for (let url in mapping) {
if (url.startsWith('GET ')) {
// 类似 'GET /hello/:name'
let p = url.substring(4);
router.get(p, mapping[url]);
console.log(`mapping: GET ${p}`);
} else if (url.startsWith('POST ')) {
// 类似 'POST /signin'
let p = url.substring(5);
router.post(p, mapping[url]);
console.log(`mapping: POST ${p}`);
} else {
console.warn(`invalid mapping: ${url}`);
}
}
}

Controller Middleware

最后,我们把扫描controller目录和创建router的代码从app.mjs中提取出来,作为一个简单的middleware使用,命名为controller.mjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// controller.mjs:
async function scan(router, controllerDir) {
// 扫描controller目录:
const dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`scan dir ${dirname}...`);
let files = readdirSync(path.join(dirname, controllerDir)).filter(f => f.endsWith('.mjs'));
for (let file of files) {
// 导入模块:
console.log(`import controller/${file}...`);
let { default: mapping } = await import(`./${controllerDir}/${file}`);
// 把每个URL映射添加到router:
for (let url in mapping) {
if (url.startsWith('GET ')) {
let p = url.substring(4);
router.get(p, mapping[url]);
console.log(`mapping: GET ${p}`);
} else if (url.startsWith('POST ')) {
let p = url.substring(5);
router.post(p, mapping[url]);
console.log(`mapping: POST ${p}`);
} else {
console.warn(`invalid mapping: ${url}`);
}
}
}
}

// 默认扫描目录为 controller:
export default async function (controllerDir = 'controller') {
const router = new Router();
await scan(router, controllerDir);
return router.routes();
}

这样一来,我们在app.mjs的代码又简化了:

1
2
3
4
5
import controller from './controller.mjs';
...
// 使用controller(), 注意controller模块导出的是async函数,要通过await调用:
app.use(await controller());
...

经过重新整理后的工程url2-koa目前具备非常好的模块化,所有处理URL的函数按功能组存放在controller目录,今后我们也只需要不断往这个目录下加东西就可以了,app.mjs保持不变。

最后我们整理一下koa处理一个HTTP请求的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20



┌─────────────────────┐
│log: │
│async(ctx,next) {...}│
└─────────────────────┘


┌─────────────────────┐
│bodyParser() │
└─────────────────────┘ GET / ┌─────────────────────┐
│ ┌──────────────────▶│async(ctx,next) {...}│
▼ │ └─────────────────────┘
┌─────────────────────┐ │ POST /signin ┌─────────────────────┐
│router.routes() ├───┼──────────────────▶│async(ctx,next) {...}│
└─────────────────────┘ │ └─────────────────────┘
│ GET /hello/:name ┌─────────────────────┐
└──────────────────▶│async(ctx,next) {...}│
└─────────────────────┘

一个HTTP请求是按顺序由一系列注册到koa的middleware处理的,首先由log函数处理,并通过await next()把请求传递到下一个middleware,紧接着是bodyParser处理,最后是router处理。在router的内部,又会根据注册到router的HTTP方法+Path来决定由哪个async函数处理请求。如果URL没有匹配到,则简单返回404。以上就是整个基于koa的webapp处理流程,非常清晰易懂。

参考源码

url

Nunjucks是什么东东?其实它是一个模板引擎。

那什么是模板引擎?

模板引擎就是基于模板配合数据构造出字符串输出的一个组件。比如下面的函数就是一个模板引擎:

1
2
3
function examResult (data) {
return `${data.name}同学一年级期末考试语文${data.chinese}分,数学${data.math}分,位于年级第${data.ranking}名。`
}

如果我们输入数据如下:

1
2
3
4
5
6
examResult({
name: '小明',
chinese: 78,
math: 87,
ranking: 999
});

该模板引擎把模板字符串里面对应的变量替换以后,就可以得到以下输出:

1
小明同学一年级期末考试语文78分,数学87分,位于年级第999名。

模板引擎最常见的输出就是输出网页,也就是HTML文本。当然,也可以输出任意格式的文本,比如Text,XML,Markdown等等。

有同学要问了:既然JavaScript的模板字符串可以实现模板功能,那为什么我们还需要另外的模板引擎?

因为JavaScript的模板字符串必须写在JavaScript代码中,要想写出新浪首页这样复杂的页面,是非常困难的。

输出HTML有几个特别重要的问题需要考虑:

转义

对特殊字符要转义,避免受到XSS攻击。比如,如果变量name的值不是小明,而是小明<script>...</script>,模板引擎输出的HTML到了浏览器,就会自动执行恶意JavaScript代码。

格式化

对不同类型的变量要格式化,比如,货币需要变成$12,345.00这样的格式,日期需要变成2016-01-01这样的格式。

简单逻辑

模板还需要能执行一些简单逻辑,比如,要按条件输出内容,需要if实现如下输出:

1
2
3
4
5
6
7
8
{{ name }}同学,
{% if score >= 90 %}
成绩优秀,应该奖励
{% elif score >=60 %}
成绩良好,继续努力
{% else %}
不及格,建议回家打屁股
{% endif %}

所以,我们需要一个功能强大的模板引擎,来完成页面输出的功能。

Nunjucks

我们选择Nunjucks作为模板引擎。Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。

如果你使用过Python的模板引擎jinja2,那么使用Nunjucks就非常简单,两者的语法几乎是一模一样的,因为Nunjucks就是用JavaScript重新实现了jinjia2。

从上面的例子我们可以看到,虽然模板引擎内部可能非常复杂,但是使用一个模板引擎是非常简单的,因为本质上我们只需要构造这样一个函数:

1
2
3
function render(view, model) {
// TODO:...
}

其中,view是模板的名称(又称为视图),因为可能存在多个模板,需要选择其中一个。model就是数据,在JavaScript中,它就是一个简单的Object。render函数返回一个字符串,就是模板的输出。

下面我们来使用Nunjucks这个模板引擎来编写几个HTML模板,并且用实际数据来渲染模板并获得最终的HTML输出。

我们创建一个use-nunjucks的VS Code工程结构如下:

1
2
3
4
5
6
7
8
use-nunjucks/
├── app.mjs
├── package-lock.json
├── package.json
└── view
├── base.html <-- HTML模板文件
├── extend.html <-- HTML模板文件
└── hello.html <-- HTML模板文件

其中,模板文件存放在view目录中。

我们先用npm install nunjucks安装依赖项并在package.json中添加nunjucks的依赖:

1
"nunjucks": "^3.2.4"

注意,模板引擎是可以独立使用的,并不需要依赖koa。

紧接着,我们要编写使用Nunjucks的函数render。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.js中编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import nunjucks from 'nunjucks';

function createEnv(path, { autoescape = true, noCache = false, watch = false, throwOnUndefined = false }, filters = {}) {
const loader = new nunjucks.FileSystemLoader(path, {
noCache: noCache,
watch: watch
});
const env = new nunjucks.Environment(loader, {
autoescape: autoescape,
throwOnUndefined: throwOnUndefined
});
for (let name in filters) {
env.addFilter(name, filters[name]);
}
return env;
}

const env = createEnv('view', {
noCache: true
}, {
hex: function (n) {
return '0x' + n.toString(16);
}
});

变量env就表示Nunjucks模板引擎对象,它有一个render(view, model)方法,正好传入viewmodel两个参数,并返回字符串。

创建env需要的参数可以查看文档获知。我们用关键字参数作为默认值,最后使用new nunjucks.FileSystemLoader('view')创建一个文件系统加载器,从view目录读取模板。

我们编写一个hello.html模板文件,放到view目录下,内容如下:

1
<h1>Hello {{ name }}</h1>

然后,我们就可以用下面的代码来渲染这个模板:

1
2
const s = env.render('hello.html', { name: '小明' });
console.log(s);

获得输出如下:

1
<h1>Hello 小明</h1>

咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:

1
2
const s = env.render('hello.html', { name: '<script>alert("小明")</script>' });
console.log(s);

获得输出如下:

1
<h1>Hello &lt;script&gt;alert("小明")&lt;/script&gt;</h1>

这样就避免了输出恶意脚本。

此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:

1
2
3
4
5
6
7
<!-- 循环输出名字 -->
<body>
<h3>Fruits List</h3>
{% for f in fruits %}
<p>{{ f }}</p>
{% endfor %}
</body>

Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。

更好的方式是使用继承。先定义一个基本的网页框架base.html

1
2
3
4
5
<html><body>
{% block header %} <h3>Unnamed</h3> {% endblock %}
{% block body %} <div>No body</div> {% endblock %}
{% block footer %} <div>copyright</div> {% endblock %}
</body>

base.html定义了三个可编辑的块,分别命名为headerbodyfooter。子模板可以有选择地对块进行重新定义:

1
2
3
4
5
{% extends 'base.html' %}

{% block header %}<h1>{{ header }}</h1>{% endblock %}

{% block body %}<p>{{ body }}</p>{% endblock %}

然后,我们对子模板进行渲染:

1
2
3
4
console.log(env.render('extend.html', {
header: 'Hello',
body: 'bla bla bla...'
}));

输出HTML如下:

1
2
3
4
5
<html><body>
<h1>Hello</h1>
<p>bla bla bla...</p>
<div>copyright</div> <-- footer没有重定义,所以仍使用父模板的内容
</body>

性能

最后我们要考虑一下Nunjucks的性能。

对于模板渲染本身来说,速度是非常非常快的,因为就是拼字符串嘛,纯CPU操作。

性能问题主要出现在从文件读取模板内容这一步。这是一个IO操作,在Node.js环境中,我们知道,单线程的JavaScript最不能忍受的就是同步IO,但Nunjucks默认就使用同步IO读取模板文件。

好消息是Nunjucks会缓存已读取的文件内容,也就是说,模板文件最多读取一次,就会放在内存中,后面的请求是不会再次读取文件的,只要我们指定了noCache: false这个参数。

在开发环境下,可以关闭cache,这样每次重新加载模板,便于实时修改模板。在生产环境下,一定要打开cache,这样就不会有性能问题。

Nunjucks也提供了异步读取的方式,但是这样写起来很麻烦,有简单的写法我们就不会考虑复杂的写法。保持代码简单是可维护性的关键。

参考源码

use-nunjucks

我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!

当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:

1
ctx.render('home.html', { name: 'Michael' });

通过Nunjucks把数据用指定的模板渲染成HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
             ┌─────────────────────────────┐
HTTP Request │GET /Bob │
└─────────────────────────────┘

│ name = Bob

┌─────────────────────────────┐
app.mjs │GET /:name │
│async (ctx, next) { │
│ ctx.render('home.html', {│
│ name: ctx.params.name│
│ }); │
│} │
└─────────────────────────────┘

│ {{ name }} ─▶ Bob

┌─────────────────────────────┐
Template │<html> │
│<body> │
│ <p>Hello, {{ name }}!</p>│
│</body> │
│</html> │
└─────────────────────────────┘

│ Output

┌─────────────────────────────┐
HTML │<html> │
│<body> │
│ <p>Hello, Bob!</p> │
│</body> │
│</html> │
└─────────────────────────────┘

这就是传说中的MVC:Model-View-Controller,中文名“模型-视图-控制器”。

异步函数是C:Controller,Controller负责业务逻辑,比如检查用户名是否存在,取出用户信息等等;

包含变量{{ name }}的模板就是V:View,View负责显示逻辑,通过简单地替换一些变量,View最终输出的就是用户看到的HTML。

MVC中的Model在哪?Model是用来传给View的,这样View在替换变量的时候,就可以从Model中取出相应的数据。

上面的例子中,Model就是一个JavaScript对象:

1
{ name: 'Bob' }

下面,我们根据原来的url2-koa创建工程view-koa,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)的方式。

工程koa-mvc结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
koa-mvc/
├── app.mjs
├── controller
│ ├── index.mjs
│ └── signin.mjs
├── controller.mjs
├── package-lock.json
├── package.json
├── static/ <-- 静态资源文件
│ ├── bootstrap.css
│ └── favicon.ico
├── view/ <-- html模板文件
│ ├── base.html
│ ├── index.html
│ ├── signin-failed.html
│ └── signin-ok.html
└── view.mjs

package.json中,我们将要用到的依赖包有:

1
2
3
4
5
6
"@koa/bodyparser": "^5.1.1",
"@koa/router": "^12.0.1",
"koa": "^2.15.3",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"nunjucks": "^3.2.4"

先用npm install安装依赖包,然后,我们准备编写以下两个Controller:

处理首页 GET /

我们定义一个async函数处理首页URL/

1
2
3
4
5
async function index(ctx, next) {
ctx.render('index.html', {
title: 'Welcome'
});
}

注意到koa并没有在ctx对象上提供render方法,这里我们假设应该这么使用,这样,我们在编写Controller的时候,最后一步调用ctx.render(view, model)就完成了页面输出。

处理登录请求 POST /signin

我们再定义一个async函数处理登录请求/signin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function signin(ctx, next) {
let email = ctx.request.body.email || '';
let password = ctx.request.body.password || '';
if (email === 'admin@example.com' && password === '123456') {
console.log('signin ok!');
ctx.render('signin-ok.html', {
title: 'Sign In OK',
name: 'Mr Bob'
});
} else {
console.log('signin failed!');
ctx.render('signin-failed.html', {
title: 'Sign In Failed'
});
}
}

由于登录请求是一个POST,我们就用ctx.request.body.<name>拿到POST请求的数据,并给一个默认值。

登录成功时我们用signin-ok.html渲染,登录失败时我们用signin-failed.html渲染,所以,我们一共需要以下3个View:

  • index.html
  • signin-ok.html
  • signin-failed.html

编写View

在编写View的时候,我们实际上是在编写HTML页。为了让页面看起来美观大方,使用一个现成的CSS框架是非常有必要的。我们用Bootstrap这个CSS框架。从首页下载zip包后解压,我们把所有静态资源文件放到/static目录下,这样我们在编写HTML的时候,可以直接用Bootstrap的CSS,像这样:

1
<link rel="stylesheet" href="/static/bootstrap.css">

现在,在使用MVC之前,第一个问题来了,如何处理静态文件?

我们把所有静态资源文件全部放入/static目录,目的就是能统一处理静态文件。在koa中,我们需要编写一个middleware,处理以/static/开头的URL。

如果不想自己编写处理静态文件的middleware,可以直接使用koa-mountkoa-static组合来处理静态文件:

1
2
// 处理静态文件:
app.use(mount('/static', serve('static')));

上述代码大致相当于自己手写一个middleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.use(async (ctx, next) => {
// 判断是否以指定的url开头:
if (ctx.request.path.startsWith('/static/')) {
// 获取文件完整路径:
let fp = ctx.request.path;
if (await fs.exists(ctx.request.path)) {
// 根据扩展名设置mime:
ctx.response.type = lookupMime(ctx.request.path);
// 读取文件内容并赋值给response.body:
ctx.response.body = await fs.readFile(fp);
} else {
// 文件不存在:
ctx.response.status = 404;
}
} else {
// 不是指定前缀的URL,继续处理下一个middleware:
await next();
}
});

集成Nunjucks

集成Nunjucks实际上也是编写一个middleware,这个middleware的作用是给ctx对象绑定一个render(view, model)的方法,这样,后面的Controller就可以调用这个方法来渲染模板了。

我们创建一个view.mjs来实现这个middleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
import nunjucks from 'nunjucks';

function createEnv(path, { autoescape = true, noCache = false, watch = false, throwOnUndefined = false }, filters = {}) {
...
return env;
}

const env = createEnv('view', {
noCache: process.env.NODE_ENV !== 'production'
});

// 导出env对象:
export default env;

使用的时候,我们在app.mjs添加如下代码:

1
2
3
4
5
6
7
8
import templateEngine from './view.mjs';

// app.context是每个请求创建的ctx的原型,
// 因此把render()方法绑定在原型对象上:
app.context.render = function (view, model) {
this.response.type = 'text/html; charset=utf-8';
this.response.body = templateEngine.render(view, Object.assign({}, this.state || {}, model || {}));
};

注意到createEnv()函数和前面使用Nunjucks时编写的函数是一模一样的。

这里我们判断当前环境是否是production环境。如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启Node程序,会极大地降低开发效率。

Node.js在全局变量process中定义了一个环境变量env.NODE_ENV,为什么要使用该环境变量?因为我们在开发的时候,环境变量应该设置为'development',而部署到服务器时,环境变量应该设置为'production'。在编写代码的时候,要根据当前环境作不同的判断。

注意:生产环境上必须配置环境变量NODE_ENV = 'production',而开发环境不需要配置,实际上NODE_ENV可能是undefined,所以判断的时候,不要用NODE_ENV === 'development'

类似的,我们在使用上面编写的处理静态文件的middleware时,也可以根据环境变量判断:

1
2
3
if (!isProduction) {
app.use(mount('/static', serve('static')));
}

这是因为在生产环境下,静态文件是由部署在最前面的反向代理服务器(如Nginx)处理的,Node程序不需要处理静态文件。而在开发环境下,我们希望koa能顺带处理静态文件,否则,就必须手动配置一个反向代理服务器,这样会导致开发环境非常复杂。

编写View

在编写View的时候,非常有必要先编写一个base.html作为骨架,其他模板都继承自base.html,这样,才能大大减少重复工作。

编写HTML不在本教程的讨论范围之内。这里我们参考Bootstrap的官网简单编写了base.html

运行

一切顺利的话,这个koa-mvc工程应该可以顺利运行。运行前,我们再检查一下app.mjs里的middleware的顺序:

第一个middleware是记录URL以及页面执行时间:

1
2
3
4
5
6
7
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
const start = Date.now();
await next();
const execTime = Date.now() - start;
ctx.response.set('X-Response-Time', `${execTime}ms`);
});

第二个middleware处理静态文件:

1
2
3
if (!isProduction) {
app.use(mount('/static', serve('static')));
}

第三个middleware解析POST请求:

1
app.use(bodyParser());

最后一个middleware处理URL路由:

1
app.use(await controller());

现在,用node app.mjs运行代码,不出意外的话,在浏览器输入localhost:3000/,可以看到首页内容:

koa-index

直接在首页登录,如果输入正确的Email和Password,进入登录成功的页面:

koa-signin-ok

如果输入的Email和Password不正确,进入登录失败的页面:

koa-signin-failed

怎么判断正确的Email和Password?目前我们在signin.js中是这么判断的:

1
2
3
if (email === 'admin@example.com' && password === '123456') {
...
}

当然,真实的网站会根据用户输入的Email和Password去数据库查询并判断登录是否成功,不过这需要涉及到Node.js环境如何操作数据库,我们后面再讨论。

如果要以production模式启动app,需要设置环境变量,可以通过以下命令启动:

1
$ NODE_ENV=production node app.mjs

这样模板缓存将生效,同时不再响应静态文件请求。

扩展

注意到ctx.render内部渲染模板时,Model对象并不是传入的model变量,而是:

1
Object.assign({}, ctx.state || {}, model || {})

这个小技巧是为了扩展。

首先,model || {}确保了即使传入undefined,model也会变为默认值{}Object.assign()会把除第一个参数外的其他参数的所有属性复制到第一个参数中。第二个参数是ctx.state || {},这个目的是为了能把一些公共的变量放入ctx.state并传给View。

例如,某个middleware负责检查用户权限,它可以把当前用户放入ctx.state中:

1
2
3
4
5
6
7
8
9
app.use(async (ctx, next) => {
var user = tryGetUserFromCookie(ctx.request);
if (user) {
ctx.state.user = user;
await next();
} else {
ctx.response.status = 403;
}
});

这样就没有必要在每个Controller的async函数中都把user变量放入model中。

参考源码

koa-mvc

自从Roy Fielding博士在2000年他的博士论文中提出REST(Representational State Transfer)风格的软件架构模式后,REST就基本上迅速取代了复杂而笨重的SOAP,成为Web API的标准了。

什么是Web API呢?

如果我们想要获取某个电商网站的某个商品,输入http://localhost:3000/products/123,就可以看到id为123的商品页面,但这个结果是HTML页面,它同时混合包含了Product的数据和Product的展示两个部分。对于用户来说,阅读起来没有问题,但是,如果机器读取,就很难从HTML中解析出Product的数据。

如果一个URL返回的不是HTML,而是机器能直接解析的数据,这个URL就可以看成是一个Web API。比如,读取http://localhost:3000/api/products/123,如果能直接返回Product的数据,那么机器就可以直接读取。

REST就是一种设计API的模式。最常用的数据格式是JSON。由于JSON能直接被JavaScript读取,所以,以JSON格式编写的REST风格的API具有简单、易读、易用的特点。

编写API有什么好处呢?由于API就是把Web App的功能全部封装了,所以,通过API操作数据,可以极大地把前端和后端的代码隔离,使得后端代码易于测试,前端代码编写更简单。

此外,如果我们把前端页面看作是一种用于展示的客户端,那么API就是为客户端提供数据、操作数据的接口。这种设计可以获得极高的扩展性。例如,当用户需要在手机上购买商品时,只需要开发针对iOS和Android的两个客户端,通过客户端访问API,就可以完成通过浏览器页面提供的功能,而后端代码基本无需改动。

当一个Web应用以API的形式对外提供功能时,整个应用的结构就扩展为:

REST-arch

把网页视为一种客户端,是REST架构可扩展的一个关键。

REST API规范

编写REST API,实际上就是编写处理HTTP请求的async函数,不过,REST请求和普通的HTTP请求有几个特殊的地方:

  1. REST请求仍然是标准的HTTP请求,但是,除了GET请求外,POST、PUT等请求的body是JSON数据格式,请求的Content-Typeapplication/json
  2. REST响应返回的结果是JSON数据格式,因此,响应的Content-Type也是application/json

REST规范定义了资源的通用访问格式,虽然它不是一个强制要求,但遵守该规范可以让人易于理解。

例如,商品Product就是一种资源。获取所有Product的URL如下:

1
GET /api/products

而获取某个指定的Product,例如,id为123的Product,其URL如下:

1
GET /api/products/123

新建一个Product使用POST请求,JSON数据包含在body中,URL如下:

1
POST /api/products

更新一个Product使用PUT请求,例如,更新id为123的Product,其URL如下:

1
PUT /api/products/123

删除一个Product使用DELETE请求,例如,删除id为123的Product,其URL如下:

1
DELETE /api/products/123

资源还可以按层次组织。例如,获取某个Product的所有评论,使用:

1
GET /api/products/123/reviews

当我们只需要获取部分数据时,可通过参数限制返回的结果集,例如,返回第2页评论,每页10项,按时间排序:

1
GET /api/products/123/reviews?page=2&size=10&sort=time

koa处理REST

既然我们已经使用koa作为Web框架处理HTTP请求,因此,我们仍然可以在koa中响应并处理REST请求。

我们复制上一节的工程,重命名为koa-rest,然后准备添加REST API。

对于controller来说,我们只要返回如下内容即可视为一个REST API:

1
2
3
4
5
ctx.body =  {
id: 12345,
name: 'Bob',
description: 'A rest api'
};

koa检测到ctx.body的赋值是一个JavaScript对象时,自动把这个Object变成JSON字符串输出,无需任何额外配置和代码。

提示

ctx.body是ctx.response.body的引用,两者是等同的。

我们给signin.mjs添加两个REST API:

  • GET /api/users/:id:根据id获取用户信息;
  • POST /api/signin:发送一个POST请求,并返回登录结果。

获取用户信息的async函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// /api/users/:id
async function user_info(ctx, next) {
let id = ctx.params.id;
if (id === '12345') {
ctx.body = {
id: 12345,
email: 'admin@example.com',
name: 'Bob'
};
} else {
ctx.body = {
error: 'USER_NOT_FOUND'
};
}
}

处理登录请求的async函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function signin(ctx, next) {
let email = ctx.request.body.email || '';
let password = ctx.request.body.password || '';
if (email === 'admin@example.com' && password === '123456') {
console.log('signin ok!');
ctx.body = {
id: 12345,
email: email,
name: 'Bob'
};
} else {
console.log('signin failed!');
ctx.body = {
error: 'SIGNIN_FAILED'
};
}
}

当发生错误时,返回的信息包含error字段,客户端依靠该字段来判断是否出错。

最后导出URL处理函数:

1
2
3
4
export default {
'POST /api/signin': signin,
'GET /api/users/:id': user_info
};

现在,我们可以直接在浏览器测试GET请求:

GET OK

输入无效的ID,返回错误:

GET ERROR

对于POST请求,我们无法直接在浏览器测试,可以用curl命令测试如下:

1
2
3
4
5
6
7
8
$ curl -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"123456"}' \
http://localhost:3000/api/signin
{
"id": 12345,
"email": "admin@example.com",
"name": "Bob"
}

输入错误的口令,返回错误信息:

1
2
3
4
5
6
$ curl -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"invalid"}' \
http://localhost:3000/api/signin
{
"error": "SIGNIN_FAILED"
}

由于我们把登录从传统的POST表单改成了REST,因此,前端页面需要编写JavaScript代码来发送REST请求,修改HTML如下:

1
2
3
4
<!-- 给form加上onsubmit回调函数 -->
<form id="signin-form" onsubmit="return signin()">
...
</form>

用JavaScript发送REST请求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function signin() {
// 获取表单输入:
let form = document.querySelector('#signin-form');
let email = form.querySelector('input[name=email]').value;
let password = form.querySelector('input[name=password]').value;
// REST请求的数据:
let data = {
email: email,
password: password
};
// 发送请求:
fetch('/api/signin', {
// 以POST方式发送:
method: 'POST',
headers: {
// Content-Type设置为JSON:
'Content-Type': 'application/json'
},
// 发送数据序列化为JSON:
body: JSON.stringify(data)
}).then(resp => {
// 收到响应后解析JSON数据:
resp.json().then(result => {
// 解析后的数据:
console.log(result);
// 判断是否有error字段:
if (result.error) {
alert(`Sign in failed: ${result.error}`);
} else {
// 登录成功,取出name字段:
alert(`Welcome, ${result.name}!`);
}
});
});
// 必须返回false以取消浏览器自动提交表单:
return false;
}

可见,在koa中处理REST请求是非常简单的。bodyParser()这个middleware可以解析请求的JSON数据并绑定到ctx.request.body上,输出JSON时我们把JavaScript对象赋值给ctx.response.body就完成了REST请求的处理。

参考源码

koa-rest

程序运行的时候,数据都是在内存中的。当程序终止的时候,通常都需要将数据保存到磁盘上,无论是保存到本地磁盘,还是通过网络保存到服务器上,最终都会将数据写入磁盘文件。

而如何定义数据的存储格式就是一个大问题。如果我们自己来定义存储格式,比如保存一个班级所有学生的成绩单:

名字 成绩
Michael 99
Bob 85
Bart 59
Lisa 87

我们可以用一个文本文件保存,一行保存一个学生,用,隔开:

1
2
3
4
Michael,99
Bob,85
Bart,59
Lisa,87

还可以用JSON格式保存,也是文本文件:

1
2
3
4
5
6
[
{"name":"Michael","score":99},
{"name":"Bob","score":85},
{"name":"Bart","score":59},
{"name":"Lisa","score":87}
]

还可以定义各种保存格式,但是问题来了:

存储和读取需要自己实现,JSON还是标准,自己定义的格式就各式各样了;

不能做快速查询,只有把数据全部读到内存中才能自己遍历,但有时候数据的大小远远超过了内存,根本无法全部读入内存。

为了便于程序保存和读取数据,而且,能直接通过条件快速查询到指定的数据,就出现了数据库(Database)这种专门用于集中存储和查询的软件。

数据库软件诞生的历史非常久远,早在1950年数据库就诞生了。经历了网状数据库,层次数据库,我们现在广泛使用的关系数据库是20世纪70年代基于关系模型的基础上诞生的。

关系模型有一套复杂的数学理论,但是从概念上是十分容易理解的。举个学校的例子:

假设某个XX省YY市ZZ县第一实验小学有3个年级,要表示出这3个年级,可以在Excel中用一个表格画出来:

grade

每个年级又有若干个班级,要把所有班级表示出来,可以在Excel中再画一个表格:

class

这两个表格有个映射关系,就是根据Grade_ID可以在班级表中查找到对应的所有班级:

grade-classes

也就是Grade表的每一行对应Class表的多行,在关系数据库中,这种基于表(Table)的一对多的关系就是关系数据库的基础。

根据某个年级的ID就可以查找所有班级的行,这种查询语句在关系数据库中称为SQL语句,可以写成:

1
SELECT * FROM classes WHERE grade_id = '1';

结果也是一个表:

grade_id class_id name
1 11 一年级一班
1 12 一年级二班
1 13 一年级三班

类似的,Class表的一行记录又可以关联到Student表的多行记录:

class-students

由于本教程不涉及到关系数据库的详细内容,如果你想从零学习关系数据库和基本的SQL语句,请参考SQL教程

NoSQL

你也许还听说过NoSQL数据库,很多NoSQL宣传其速度和规模远远超过关系数据库,所以很多同学觉得有了NoSQL是否就不需要SQL了呢?千万不要被他们忽悠了,连SQL都不明白怎么可能搞明白NoSQL呢?

数据库类别

既然我们要使用关系数据库,就必须选择一个关系数据库。目前广泛使用的关系数据库也就这么几种:

付费的商用数据库:

  • Oracle,典型的高富帅;
  • SQL Server,微软自家产品,Windows定制专款;
  • DB2,IBM的产品,听起来挺高端;
  • Sybase,曾经跟微软是好基友,后来关系破裂,现在家境惨淡。

这些数据库都是不开源而且付费的,最大的好处是花了钱出了问题可以找厂家解决,不过在Web的世界里,常常需要部署成千上万的数据库服务器,当然不能把大把大把的银子扔给厂家,所以,无论是Google、Facebook,还是国内的BAT,无一例外都选择了免费的开源数据库:

  • MySQL,大家都在用,一般错不了;
  • PostgreSQL,学术气息有点重,其实挺不错,但知名度没有MySQL高;
  • SQLite,嵌入式数据库,适合桌面和移动应用。

作为一个JavaScript全栈工程师,选择哪个免费数据库呢?这里我们用SQLite,作为嵌入式数据库,优点是不用安装任何软件,直接能用。当然,在生产环境下,应当选择MySQL或者PostgreSQL。

在Node.js中,访问不同的数据库需要安装不同的数据库驱动。

因为我们使用Sqlite,所以需要安装Sqlite的驱动。这里我们选择sqlite3这个驱动,它内置sqlite。

sqlite3通过如下代码可以创建一个db对象:

1
2
// 指定模式打开test.db:
const db = new sqlite3.Database('test.db', sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE | sqlite3.OPEN_FULLMUTEX);

我们传入了OPEN_CREATE参数,表示如果数据库不存在则自动创建,在开发模式下非常方便。

sqlite3使用回调模式执行查询和更新操作,代码如下:

1
2
3
4
5
6
// query:
db.all('SELECT * FROM users WHERE id=?', [1], function (err, rows) {
});
// update:
db.run('UPDATE users SET name=? WHERE id=?', ['Bob', 1], function (err) {
});

回调模式写起来非常别扭,由于sqlite3没有提供Promise接口,因此无法使用await调用,怎么办?

答案是我们自己封装一个Promise调用,以便通过await来实现异步查询和更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// db.mjs:
import sqlite3 from 'sqlite3';

export function createDatabase(file) {
const db = new sqlite3.Database(file, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE | sqlite3.OPEN_FULLMUTEX);
const wrapper = {
db: db
};
// 执行update:
wrapper.update = async function (strs, ...params) {
return new Promise((resolve, reject) => {
let sql = strs.join('?');
db.run(sql, ...params, function (err) {
if (err) {
reject(err);
} else {
resolve(this.changes);
}
});
});
};
// 执行insert并返回lastID:
wrapper.insert = async function (strs, ...params) {
return new Promise((resolve, reject) => {
let sql = strs.join('?');
db.run(sql, ...params, function (err) {
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
};
// 查询数据,返回array:
wrapper.select = async function (strs, ...params) {
return new Promise((resolve, reject) => {
let sql = strs.join('?');
if (debug) {
console.log(`sql = ${sql}, params = [${params.join(', ')}]`);
}
db.all(sql, ...params, function (err, rows) {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
};
// 查询一行数据,不存在返回null:
wrapper.fetch = async function (strs, ...params) {
...
};
return wrapper;
}

我们复制前面的koa-mvc工程,命名为sql,准备用实际数据库替换写死的登录逻辑。工程结构如下:

1
2
3
4
sql/
├── app.mjs
├── db.mjs
└── ...

通过npm install sqlite3安装依赖项并添加依赖:

1
"sqlite3": "^5.1.7"

增加了db.mjs,实现了对sqlite数据库的操作。

我们在app.mjs中初始化一个db对象并绑定到app.context中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createDatabase } from './db.mjs';

async function initDb() {
const email = 'admin@example.com';
const name = 'Bob';
const password = '123456';
// 创建db对象:
const db = createDatabase('test.db');
// 如果users表不存在则创建表:
await db.update`CREATE TABLE IF NOT EXISTS users(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password TEXT NOT NULL)`;
// 查询admin用户:
let user = await db.fetch`SELECT * FROM users WHERE email=${email}`;
// 用户不存在则自动创建:
if (user === null) {
await db.insert`INSERT INTO users (email, name, password) VALUES (${email}, ${name}, ${password})`;
}
return db;
}

// 绑定db到app.context:
app.context.db = await initDb();

注意到initDb()中自动创建表和用户的代码都是为了便于开发。

有了数据库支持,我们就可以把signin.mjs写死的代码替换为查询数据库用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// signin:
async function signin(ctx, next) {
let email = ctx.request.body.email || '';
let password = ctx.request.body.password || '';
// 从数据库查询用户:
let user = await ctx.db.fetch`SELECT * FROM users WHERE email=${email}`;
if (user !== null && user.password === password) {
console.log('signin ok!');
ctx.render('signin-ok.html', {
title: 'Sign In OK',
name: user.name
});
} else {
console.log('signin failed!');
ctx.render('signin-failed.html', {
title: 'Sign In Failed'
});
}
}

观察上述代码,我们查询数据库中某个用户的代码如下:

1
let user = await ctx.db.fetch`SELECT * FROM users WHERE email=${email}`;

这是一个标签函数,它自动将参数变为如下调用:

1
let user = await ctx.db.fetch(['SELECT * FROM users WHERE email=', ''], email);

在函数内部,实际执行的SQL是SELECT * FROM users WHERE email=?,因此,通过标签函数,我们总是以参数化形式执行SQL,避免了SQL注入。

执行node app.mjs,可以看到页面效果,同时,后台会打印出执行的SQL语句与绑定的参数。

参考

参考源码:sql

sqlite数据库:sqlite

sqlite3文档:sqlite3

直接使用sqlite3提供的接口,我们执行数据库操作时必须提供SQL语句,比较底层。

考虑到数据库表是一个二维表,包含多行多列,例如一个users的表:

id email name password
1 admin@example.com Bob 123456
2 lucy@example.com Lucy abcdef
3 alice@example.com Alice hello123

每一行可以用一个JavaScript对象表示,例如第一行:

1
2
3
4
5
6
{
id: 1,
email: 'admin@example.com',
name: 'Bob',
password: '123456'
}

这就是传说中的ORM技术:Object-Relational Mapping,把关系数据库的表结构映射到对象上。是不是很简单?

但是由谁来做这个转换呢?所以ORM框架应运而生。

我们选择Node的ORM框架Sequelize来操作数据库。这样,我们读写的都是JavaScript对象,Sequelize帮我们把对象变成数据库中的行。

用Sequelize查询users表,代码像这样:

1
let users = await User.findAll();

根据email查询一个用户,代码像这样:

1
2
3
4
5
let user = await User.find({
where: {
email: 'admin@example.com'
}
});

Sequelize的所有操作都是Promise,所以我们可以用await实现异步调用。

实战

在使用Sequelize操作数据库之前,我们需要告诉Sequelize如何映射数据库中的每一个表。

users表为例,我们需要定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// orm.mjs:
import { Sequelize, DataTypes } from 'sequelize';

// 创建sequelize对象表示已连接到数据库:
export const sequelize = new Sequelize('sqlite:test.db');

// 定义User:
export const User = sequelize.define('User', {
// 每一列的定义:
id: {
primaryKey: true,
autoIncrement: true,
type: DataTypes.INTEGER,
allowNull: false
},
email: {
unique: true,
type: DataTypes.STRING,
allowNull: false
},
name: {
type: DataTypes.STRING,
allowNull: false
},
password: {
type: DataTypes.STRING,
allowNull: false
}
}, {
// 指定表名:
tableName: 'users'
});

在定义列的时候,主键以primaryKey: true指定,具有唯一约束的列用unique: true表示,数据类型用DataTypes表示。

这样Sequelize就有了足够的信息来实现ORM。最后将sequelizeUser对象导出。

我们根据上一节的sql工程结构创建orm工程,结构如下:

1
2
3
4
sql/
├── app.mjs
├── orm.mjs
└── ...

删除了db.mjs,增加orm.mjs

然后,通过npm install sequelize sqlite3安装依赖项并添加依赖:

1
2
"sequelize": "^6.37.3",
"sqlite3": "^5.1.7"

app.mjs中,删除相关SQL操作,改为通过Sequelize初始化数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { sequelize, User } from './orm.mjs';

...

async function initDb() {
// 自动创建数据库表, 仅限开发模式:
await sequelize.sync();
// 查询admin用户:
const email = 'admin@example.com';
let user = await User.findOne({
where: {
email: email
}
});
// 不存在则自动创建:
if (user === null) {
await User.create({
email: email,
name: 'Bob',
password: '123456'
});
}
}
await initDb();

...

使用Sequelize时,无需绑定app.context,因为我们主要通过具体的Model比如User来操作数据库。修改signin.mjs如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { User } from '../orm.mjs';

// signin:
async function signin(ctx, next) {
let email = ctx.request.body.email || '';
let password = ctx.request.body.password || '';
// 调用Model.findOne()查询一行记录:
let user = await User.findOne({
where: {
email: email
}
});
if (user !== null && user.password === password) {
console.log('signin ok!');
ctx.render('signin-ok.html', {
title: 'Sign In OK',
name: user.name
});
} else {
console.log('signin failed!');
ctx.render('signin-failed.html', {
title: 'Sign In Failed'
});
}
}

运行node app.mjs启动服务器,可以观察到Sequelize访问数据库时打印的SQL语句。这里需要注意的是,Sequelize会为每个Model自动添加一个createdAtupdatedAt字段,用来记录创建和更新时间。因此,创建的users表的SQL语句为:

1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS `users` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`email` VARCHAR(255) NOT NULL UNIQUE,
`name` VARCHAR(255) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`createdAt` DATETIME NOT NULL,
`updatedAt` DATETIME NOT NULL
);

常用操作

Sequelize提供了findAll()findOne()两种查询,分别返回多行和一行。

create()操作可以存储一个对象到数据库的一行记录,save()destroy()分别对应更新和删除操作。

Sequelize还提供了一对多等高级ORM功能,具体可以参考官方文档。

参考

参考源码:orm

Sequelize:官方网站

留言與分享

JavaScript-underscore

分類 编程语言, JavaScript

underscore

前面我们已经讲过了,JavaScript是函数式编程语言,支持高阶函数和闭包。函数式编程非常强大,可以写出非常简洁的代码。例如Arraymap()filter()方法:

1
2
3
let a1 = [1, 4, 9, 16];
let a2 = a1.map(Math.sqrt); // [1, 2, 3, 4]
let a3 = a2.filter((x) => { return x % 2 === 0; }); // [2, 4]

现在问题来了,Arraymap()filter()方法,可是Object没有这些方法。此外,低版本的浏览器例如IE6~8也没有这些方法,怎么办?

方法一,自己把这些方法添加到Array.prototype中,然后给Object.prototype也加上mapObject()等类似的方法。

方法二,直接找一个成熟可靠的第三方开源库,使用统一的函数来实现map()filter()这些操作。

我们采用方法二,选择的第三方库就是underscore。

正如jQuery统一了不同浏览器之间的DOM操作的差异,让我们可以简单地对DOM进行操作,underscore则提供了一套完善的函数式编程的接口,让我们更方便地在JavaScript中实现函数式编程。

jQuery在加载时,会把自身绑定到唯一的全局变量$上,underscore与其类似,会把自身绑定到唯一的全局变量_上,这也是为啥它的名字叫underscore的原因。

用underscore实现map()操作如下:

1
2
let r = _.map([1, 2, 3], (x) => x * x);
console.log(r); // [1, 4, 9]

咋一看比直接用Array.map()要麻烦一点,可是underscore的map()还可以作用于Object:

1
_.map({ a: 1, b: 2, c: 3 }, (v, k) => k + '=' + v); // ['a=1', 'b=2', 'c=3']

后面我们会详细介绍underscore提供了一系列函数式接口。



underscore为集合类对象提供了一致的接口。集合类是指Array和Object,暂不支持Map和Set。

map/filter

Arraymap()filter()类似,但是underscore的map()filter()可以作用于Object。当作用于Object时,传入的函数为function (value, key),第一个参数接收value,第二个参数接收key:

1
2
3
4
5
6
7
8
9
10
11
let obj = {
name: 'bob',
school: 'No.1 middle school',
address: 'xueyuan road'
};

let upper = _.map(obj, function (value, key) {
return ???;
});

console.log(JSON.stringify(upper));

你也许会想,为啥对Object作map()操作的返回结果是Array?应该是Object才合理啊!把_.map换成_.mapObject再试试。

every / some

当集合的所有元素都满足条件时,_.every()函数返回true,当集合的至少一个元素满足条件时,_.some()函数返回true

1
2
3
4
// 所有元素都大于0?
_.every([1, 4, 7, -3, -9], (x) => x > 0); // false
// 至少一个元素大于0?
_.some([1, 4, 7, -3, -9], (x) => x > 0); // true

当集合是Object时,我们可以同时获得value和key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {
name: 'bob',
school: 'No.1 middle school',
address: 'xueyuan road'
};

// 判断key和value是否全部是小写:
let r1 = _.every(obj, function (value, key) {
return ???;
});
let r2 = _.some(obj, function (value, key) {
return ???;
});

console.log('every key-value are lowercase: ' + r1 + '\nsome key-value are lowercase: ' + r2);

max / min

这两个函数直接返回集合中最大和最小的数:

1
2
3
4
5
6
7
8
9
let arr = [3, 5, 7, 9];
_.max(arr); // 9
_.min(arr); // 3

// 空集合会返回-Infinity和Infinity,所以要先判断集合不为空:
_.max([])
-Infinity
_.min([])
Infinity

注意,如果集合是Object,max()min()只作用于value,忽略掉key:

1
_.max({ a: 1, b: 2, c: 3 }); // 3

groupBy

groupBy()把集合的元素按照key归类,key由传入的函数返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let scores = [20, 81, 75, 40, 91, 59, 77, 66, 72, 88, 99];
let groups = _.groupBy(scores, function (x) {
if (x < 60) {
return 'C';
} else if (x < 80) {
return 'B';
} else {
return 'A';
}
});
// 结果:
// {
// A: [81, 91, 88, 99],
// B: [75, 77, 66, 72],
// C: [20, 40, 59]
// }

可见groupBy()用来分组是非常方便的。

shuffle / sample

shuffle()用洗牌算法随机打乱一个集合:

1
2
// 注意每次结果都不一样:
_.shuffle([1, 2, 3, 4, 5, 6]); // [3, 5, 4, 6, 2, 1]

sample()则是随机选择一个或多个元素:

1
2
3
4
5
// 注意每次结果都不一样:
// 随机选1个:
_.sample([1, 2, 3, 4, 5, 6]); // 2
// 随机选3个:
_.sample([1, 2, 3, 4, 5, 6], 3); // [6, 1, 4]

更多完整的函数请参考underscore的文档:https://underscorejs.org/#collections

underscore为Array提供了许多工具类方法,可以更方便快捷地操作Array

first / last

顾名思义,这两个函数分别取第一个和最后一个元素:

1
2
3
let arr = [2, 4, 6, 8];
_.first(arr); // 2
_.last(arr); // 8

flatten

flatten()接收一个Array,无论这个Array里面嵌套了多少个Arrayflatten()最后都把它们变成一个一维数组:

1
_.flatten([1, [2], [3, [[4], [5]]]]); // [1, 2, 3, 4, 5]

zip / unzip

zip()把两个或多个数组的所有元素按索引对齐,然后按索引合并成新数组。例如,你有一个Array保存了名字,另一个Array保存了分数,现在,要把名字和分数给对上,用zip()轻松实现:

1
2
3
4
let names = ['Adam', 'Lisa', 'Bart'];
let scores = [85, 92, 59];
_.zip(names, scores);
// [['Adam', 85], ['Lisa', 92], ['Bart', 59]]

unzip()则是反过来:

1
2
3
let namesAndScores = [['Adam', 85], ['Lisa', 92], ['Bart', 59]];
_.unzip(namesAndScores);
// [['Adam', 'Lisa', 'Bart'], [85, 92, 59]]

object

有时候你会想,与其用zip(),为啥不把名字和分数直接对应成Object呢?别急,object()函数就是干这个的:

1
2
3
4
let names = ['Adam', 'Lisa', 'Bart'];
let scores = [85, 92, 59];
_.object(names, scores);
// {Adam: 85, Lisa: 92, Bart: 59}

注意_.object()是一个函数,不是JavaScript的Object对象。

range

range()让你快速生成一个序列,不再需要用for循环实现了:

1
2
3
4
5
6
7
8
9
10
11
// 从0开始小于10:
_.range(10); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

// 从1开始小于11:
_.range(1, 11); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 从0开始小于30,步长5:
_.range(0, 30, 5); // [0, 5, 10, 15, 20, 25]

// 从0开始大于-10,步长-1:
_.range(0, -10, -1); // [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

更多完整的函数请参考underscore的文档:https://underscorejs.org/#arrays

练习

请根据underscore官方文档,使用_.uniq对数组元素进行不区分大小写去重:

1
2
3
4
5
6
7
8
9
let arr = ['Apple', 'orange', 'banana', 'ORANGE', 'apple', 'PEAR'];
let result = ???

// 测试
if (result.toString() === ["Apple", "orange", "banana", "PEAR"].toString()) {
console.log('测试成功!');
} else {
console.log('测试失败!');
}

因为underscore本来就是为了充分发挥JavaScript的函数式编程特性,所以也提供了大量JavaScript本身没有的高阶函数。

bind

bind()有什么用?我们先看一个常见的错误用法:

1
2
3
4
5
6
7
let s = ' Hello  ';
s.trim();
// 输出'Hello'

let fn = s.trim;
fn();
// Uncaught TypeError: String.prototype.trim called on null or undefined

如果你想用fn()取代s.trim(),按照上面的做法是不行的,因为直接调用fn()传入的this指针是undefined,必须这么用:

1
2
3
4
5
let s = ' Hello  ';
let fn = s.trim;
// 调用call并传入s对象作为this:
fn.call(s)
// 输出Hello

这样搞多麻烦!还不如直接用s.trim()。但是,bind()可以帮我们把s对象直接绑定在fn()this指针上,以后调用fn()就可以直接正常调用了:

1
2
3
4
let s = ' Hello  ';
let fn = _.bind(s.trim, s);
fn();
// 输出Hello

结论:当用一个变量fn指向一个对象的方法时,直接调用fn()是不行的,因为丢失了this对象的引用。用bind可以修复这个问题。

partial

partial()就是为一个函数创建偏函数。偏函数是什么东东?看例子:

假设我们要计算xy,这时只需要调用Math.pow(x, y)就可以了。

假设我们经常计算2y,每次都写Math.pow(2, y)就比较麻烦,如果创建一个新的函数能直接这样写pow2N(y)就好了,这个新函数pow2N(y)就是根据Math.pow(x, y)创建出来的偏函数,它固定住了原函数的第一个参数(始终为2):

1
2
3
4
let pow2N = _.partial(Math.pow, 2);
pow2N(3); // 8
pow2N(5); // 32
pow2N(10); // 1024

如果我们不想固定第一个参数,想固定第二个参数怎么办?比如,希望创建一个偏函数cube(x),计算x3,可以用_作占位符,固定住第二个参数:

1
2
3
4
let cube = _.partial(Math.pow, _, 3);
cube(3); // 27
cube(5); // 125
cube(10); // 1000

可见,创建偏函数的目的是将原函数的某些参数固定住,可以降低新函数调用的难度。

memoize

如果一个函数调用开销很大,我们就可能希望能把结果缓存下来,以便后续调用时直接获得结果。举个例子,计算阶乘就比较耗时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function factorial(n) {
console.log('start calculate ' + n + '!...');
let s = 1, i = n;
while (i > 1) {
s = s * i;
i --;
}
console.log(n + '! = ' + s);
return s;
}

factorial(10); // 3628800
// 注意控制台输出:
// start calculate 10!...
// 10! = 3628800

memoize()就可以自动缓存函数计算的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let factorial = _.memoize(function(n) {
console.log('start calculate ' + n + '!...');
let s = 1, i = n;
while (i > 1) {
s = s * i;
i --;
}
console.log(n + '! = ' + s);
return s;
});

// 第一次调用:
factorial(10); // 3628800
// 注意控制台输出:
// start calculate 10!...
// 10! = 3628800

// 第二次调用:
factorial(10); // 3628800
// 控制台没有输出

对于相同的调用,比如连续两次调用factorial(10),第二次调用并没有计算,而是直接返回上次计算后缓存的结果。不过,当你计算factorial(9)的时候,仍然会重新计算。

可以对factorial()进行改进,让其递归调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let factorial = _.memoize(function(n) {
console.log('start calculate ' + n + '!...');
if (n < 2) {
return 1;
}
return n * factorial(n - 1);
});

factorial(10); // 3628800
// 输出结果说明factorial(1)~factorial(10)都已经缓存了:
// start calculate 10!...
// start calculate 9!...
// start calculate 8!...
// start calculate 7!...
// start calculate 6!...
// start calculate 5!...
// start calculate 4!...
// start calculate 3!...
// start calculate 2!...
// start calculate 1!...

factorial(9); // 362880
// console无输出

once

顾名思义,once()保证某个函数执行且仅执行一次。如果你有一个方法叫register(),用户在页面上点两个按钮的任何一个都可以执行的话,就可以用once()保证函数仅调用一次,无论用户点击多少次:

1
2
3
4
5
6
7
8
let register = _.once(function () {
console.log('Register ok!');
});

// 测试效果:
register();
register();
register();

delay

delay()可以让一个函数延迟执行,效果和setTimeout()是一样的,但是代码明显简单了:

1
2
// 2秒后调用alert():
_.delay(alert, 2000);

如果要延迟调用的函数有参数,把参数也传进去:

1
2
3
let log = _.bind(console.log, console);
_.delay(log, 2000, 'Hello,', 'world!');
// 2秒后打印'Hello, world!':

更多完整的函数请参考underscore的文档:https://underscorejs.org/#functions

Array类似,underscore也提供了大量针对Object的函数。

keys / allKeys

keys()可以非常方便地返回一个object自身所有的key,但不包含从原型链继承下来的:

1
2
3
4
5
6
7
function Student(name, age) {
this.name = name;
this.age = age;
}

let xiaoming = new Student('小明', 20);
_.keys(xiaoming); // ['name', 'age']

allKeys()除了object自身的key,还包含从原型链继承下来的:

1
2
3
4
5
6
7
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype.school = 'No.1 Middle School';
let xiaoming = new Student('小明', 20);
_.allKeys(xiaoming); // ['name', 'age', 'school']

values

keys()类似,values()返回object自身但不包含原型链继承的所有值:

1
2
3
4
5
6
let obj = {
name: '小明',
age: 20
};

_.values(obj); // ['小明', 20]

注意,没有allValues(),原因我也不知道。

mapObject

mapObject()就是针对object的map版本:

1
2
3
let obj = { a: 1, b: 2, c: 3 };
// 注意传入的函数签名,value在前,key在后:
_.mapObject(obj, (v, k) => 100 + v); // { a: 101, b: 102, c: 103 }

invert

invert()把object的每个key-value来个交换,key变成value,value变成key:

1
2
3
4
5
6
let obj = {
Adam: 90,
Lisa: 85,
Bart: 59
};
_.invert(obj); // { '59': 'Bart', '85': 'Lisa', '90': 'Adam' }

extend / extendOwn

extend()把多个object的key-value合并到第一个object并返回:

1
2
3
4
let a = {name: 'Bob', age: 20};
_.extend(a, {age: 15}, {age: 88, city: 'Beijing'}); // {name: 'Bob', age: 88, city: 'Beijing'}
// 变量a的内容也改变了:
a; // {name: 'Bob', age: 88, city: 'Beijing'}

注意:如果有相同的key,后面的object的value将覆盖前面的object的value。

extendOwn()extend()类似,但获取属性时忽略从原型链继承下来的属性。

clone

如果我们要复制一个object对象,就可以用clone()方法,它会把原有对象的所有属性都复制到新的对象中:

1
2
3
4
5
6
7
8
let source = {
name: '小明',
age: 20,
skills: ['JavaScript', 'CSS', 'HTML']
};

let copied = _.clone(source);
console.log(JSON.stringify(copied, null, ' '));

注意,clone()是“浅复制”。所谓“浅复制”就是说,两个对象相同的key所引用的value其实是同一对象:

1
source.skills === copied.skills; // true

也就是说,修改source.skills会影响copied.skills

isEqual

isEqual()对两个object进行深度比较,如果内容完全相同,则返回true

1
2
3
4
5
let o1 = { name: 'Bob', skills: { Java: 90, JavaScript: 99 }};
let o2 = { name: 'Bob', skills: { JavaScript: 99, Java: 90 }};

o1 === o2; // false
_.isEqual(o1, o2); // true

isEqual()其实对Array也可以比较:

1
2
3
4
5
let o1 = ['Bob', { skills: ['Java', 'JavaScript'] }];
let o2 = ['Bob', { skills: ['Java', 'JavaScript'] }];

o1 === o2; // false
_.isEqual(o1, o2); // true

更多完整的函数请参考underscore的文档:https://underscorejs.org/#objects

Chaining

还记得jQuery支持链式调用吗?

1
2
3
$('a').attr('target', '_blank')
.append(' <i class="uk-icon-external-link"></i>')
.click(function () {});

如果我们有一组操作,用underscore提供的函数,写出来像这样:

1
2
_.filter(_.map([1, 4, 9, 16, 25], Math.sqrt), x => x % 2 === 1);
// [1, 3, 5]

能不能写成链式调用?

能!

underscore提供了把对象包装成能进行链式调用的方法,就是chain()函数:

1
2
3
4
5
let r = _.chain([1, 4, 9, 16, 25])
.map(Math.sqrt)
.filter(x => x % 2 === 1)
.value();
console.log(r); // [1, 3, 5]

因为每一步返回的都是包装对象,所以最后一步的结果需要调用value()获得最终结果。

小结

通过学习underscore,是不是对JavaScript的函数式编程又有了进一步的认识?



留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau