使用React我们首先要知道如何传递数据,组件如何沟通,才能展示我们想要的数据。下面的列子都是使用ES6语法,不懂的同学需要先学习ES6语法。
数据流
React是单向数据流,从父节点传递到子节点(通过props
)。如果顶层的某个props
改变了,React会重渲染所有的子节点(未做性能优化)。严格意义上React只提供,也强烈建议使用这种数据交流方式。
Props
props
是property的缩写,可以理解为HTML标签的attribute。请把props
当做只读的(不可以使用this.props
直接修改props),props
是用于整个组件树中传递数据和配置。在当前组件访问props
,使用this.props
。在什么情况下可以使用props
,请看组件生命周期
1 | class Component { |
PropTypes
PropsTypes
是React中用来定义props
的类型,不符合定义好的类型会报错。建议可复用组件要使用prop验证!接着上面的列子设置PropsTypes
如下:
1 | class Component { |
React.PropTypes
提供很多验证器 (validator) 来验证传入数据的有效性。官方定义的验证器如下,不是使用ES6语法。
1 | React.createClass({ |
defaultProps
如何设置组件默认的props
?
1 | //React提供的crateClass创建方式 |
state
每个组件都有属于自己的state
,state
和props
的区别在于前者之只存在于组件内部,只能从当前组件调用this.setState
修改state值(不可以直接修改this.state
)。一般我们更新子组件都是通过改变state
值,更新新子组件的props
值从而达到更新。
那如何设置默认state?
1 | //React提供的crateClass创建方式 |
props和state使用方式
尽可能使用props
当做数据源,state
用来存放状态值(简单的数据),如复选框、下拉菜单等。
组件沟通
组件沟通因为React的单向数据流方式会有所限制,下面述说组件之间的沟通方式。
父子组件沟通
这种方式是最常见的,也是最简单的。
- 父组件更新组件状态
父组件更新子组件状态,通过传递props
,就可以了。
- 子组件更新父组件状态
这种情况需要父组件传递回调函数给子组件,子组件调用触发即可。
代码示例:
1 | class Child extends React.Component{ |
codepen例子React组件之父子组件沟通 。
兄弟组件沟通
当两个组件有相同的父组件时,就称为兄弟组件(堂兄也算的)。按照React单向数据流方式,我们需要借助父组件进行传递,通过父组件回调函数改变兄弟组件的props
。
方式一
通过props
传递父组件回调函数。
1 | class Brother1 extends React.Component{ |
codepen例子:React组件之兄弟组件沟通。
方式二
但是如果组件层次太深(如下图),上面的兄弟组件沟通方式就效率低了(不建议组件层次太深)。
React提供了一种上下文方式(挺方便的),可以让子组件直接访问祖先的数据或函数,无需从祖先组件一层层地传递数据到子组件中。
1 | class Brother1 extends React.Component{ |
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 incomponentWillUnmount()
, and callsetState()
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@17 之前的版本,React@16.3.0,添加了一些新的生命周期函数,同时准备废弃一些会造成困扰的生命周期函数。所有如果在React@17 发布之前,这篇文章还是适用的。
新的生命周期请看官网 blog 文章 React v16.3.0: New lifecycles and context API。
前言
组件会随着组件的 props
和 state
改变而发生变化,它的 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.props
。getInitialState
只适合React.createClass
使用。使用 ES6 初始化state方法如下:1
2
3
4
5
6
7
8class Component extends React.Component{
constructor(props){
super(props);
this.state = {
render: true,
}
}
}或者这样
1
2
3
4
5
6class Component extends React.Component{
state = {
render: true
}
render(){return false;}
} -
componentWillMount
改方法会在组件首次渲染之前调用,这个是在 render 方法调用前可修改 state 的最后一次机会。这个方法很少用到。
-
render
这个方法以后大家都应该会很熟悉,JSX 通过这里,解析成对应的
虚拟 DOM
,渲染成最终效果。格式大致如下:1
2
3
4
5
6
7class 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
字面意思组件将会更新,
props
和state
改变后必调用。 -
render
跟实例化时的render一样,不多说
-
componentDidUpdate
这个方法在更新真实的 DOM 成功后调用,当我们需要访问真实的 DOM 时,这个方法就也经常用到。
销毁期
销毁阶段,只有一个函数被调用:
-
componentWillUnmount
没当组件使用完成,这个组件就必须从DOM中销毁,此时该方法就会被调用。当我们在组件中使用了 setInterval,那我们就需要在这个方法中调用 clearInterval。如果手动使用了 addEventListener 绑定了事件,也需要解绑事件。
react - JSX
React 背景介绍
React 起源于 Facebook 的内部项目,因为该公司对市场上所有 JavaScript MVC 框架,都不满意,就决定自己写一套,用来架设 Instagram 的网站。做出来以后,发现这套东西很好用,就在2013年5月开源了。
什么是React
-
A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES
- 用来构建UI的 JavaScript库
- React 不是一个 MVC 框架,仅仅是视图(V)层的库
特点
- 1 使用 JSX语法 创建组件,实现组件化开发,为函数式的 UI 编程方式打开了大门
- 2 性能高的让人称赞:通过
diff算法
和虚拟DOM
实现视图的高效更新 - 3 HTML仅仅是个开始
1 | > JSX --TO--> EveryThing |
为什么要用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 | <!-- 了解: |
-
React中有两种假定:
- 1 两个不同类型的元素会产生不同的树(根元素不同结构树一定不同)
- 2 开发者可以通过key属性指定不同树中没有发生改变的子元素
Diff算法的说明 - 1
- 如果两棵树的根元素类型不同,React会销毁旧树,创建新树
1 | // 旧树 |
Diff算法的说明 - 2
- 对于类型相同的React DOM 元素,React会对比两者的属性是否相同,只更新不同的属性
- 当处理完这个DOM节点,React就会递归处理子节点。
1 | // 旧 |
Diff算法的说明 - 3
- 1 当在子节点的后面添加一个节点,这时候两棵树的转化工作执行的很好
1 | // 旧 |
- 2 但是如果你在开始位置插入一个元素,那么问题就来了:
1 | // 旧 |
key 属性
为了解决以上问题,React提供了一个 key 属性。当子节点带有key属性,React会通过key来匹配原始树和后来的树。
1 | // 旧 |
- 说明: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 | // 1. 导入 react |
createElement()的问题
- 说明:
createElement()
方式,代码编写不友好,太复杂
1 | var dv = React.createElement( |
JSX 的基本使用
- 注意:JSX语法,最终会被编译为 createElement() 方法
- 推荐:使用 JSX 的方式创建组件
- JSX - JavaScript XML
- 安装:
npm i -D babel-preset-react
(依赖与:babel-core/babel-loader)
注意:JSX的语法需要通过 babel-preset-react 编译后,才能被解析执行
1 | /* 1 在 .babelrc 开启babel对 JSX 的转换 */ |
JSX的注意点
-
注意 1: 如果在 JSX 中给元素添加类, 需要使用
className
代替 class- 类似:label 的 for属性,使用
htmlFor
代替
- 类似:label 的 for属性,使用
-
注意 2:在 JSX 中可以直接使用 JS代码,直接在 JSX 中通过 {} 中间写 JS代码即可
-
注意 3:在 JSX 中只能使用表达式,但是不能出现 语句!!!
-
注意 4:在 JSX 中注释语法:
{/* 中间是注释的内容 */}
React组件
React 组件可以让你把UI分割为独立、可复用的片段,并将每一片段视为相互独立的部分。
- 组件是由一个个的HTML元素组成的
- 概念上来讲, 组件就像JS中的函数。它们接受用户输入(
props
),并且返回一个React对象,用来描述展示在页面中的内容
React创建组件的两种方式
- 1 通过 JS函数 创建(无状态组件)
- 2 通过 class 创建(有状态组件)
1 | 函数式组件 和 class 组件的使用场景说明: |
JavaScript函数创建
- 注意:1 函数名称必须为大写字母开头,React通过这个特点来判断是不是一个组件
- 注意:2 函数必须有返回值,返回值可以是:JSX对象或
null
- 注意:3 返回的JSX,必须有一个根元素
- 注意:4 组件的返回值使用
()
包裹,避免换行问题
1 | function Welcome(props) { |
class创建
在es6中class仅仅是一个语法糖,不是真正的类,本质上还是构造函数+原型 实现继承
1 | // ES6中class关键字的简单使用 |
给组件传递数据 - 父子组件传递数据
- 组件中有一个
只读的对象
叫做props
,无法给props添加属性 - 获取方式:函数参数
props
- 作用:将传递给组件的属性转化为
props
对象中的属性
1 | function Welcome(props){ |
封装组件到独立的文件中
1 | // 创建Hello2.js组件文件 |
props和state
props
-
作用:给组件传递数据,一般用在父子组件之间
-
说明:React把传递给组件的属性转化为一个对象并交给
props
-
特点:
props
是只读的,无法给props
添加或修改属性 -
props.children
:获取组件的内容,比如:<Hello>组件内容</Hello>
中的组件内容
1 | // props 是一个包含数据的对象参数,不要试图修改 props 参数 |
state
状态即数据
-
作用:用来给组件提供
组件内部
使用的数据 -
注意:只有通过
class
创建的组件才具有状态 -
注意:状态是私有的,完全由组件来控制
-
注意:不要在
state
中添加render()
方法中不需要的数据,会影响渲染性能!- 可以将组件内部使用但是不渲染在视图中的内容,直接添加给 this
-
注意:不要在
render()
方法中调用 setState() 方法来修改state
的值- 但是可以通过
this.state.name = 'rose'
方式设置state(不推荐!!!)
- 但是可以通过
1 | // 例: |
JSX语法转化过程
1 | // 1、JSX |
评论列表案例
- 巩固有状态组件和无状态组件的使用
- 两个组件:
<CommentList></CommentList>
和<Comment></Comment>
1 | [ |
style样式
1 | // 1. 直接写行内样式: |
相关文章
组件的生命周期
- 简单说:一个组件从开始到最后消亡所经历的各种状态,就是一个组件的生命周期
组件生命周期函数的定义:从组件被创建,到组件挂载到页面上运行,再到页面关闭组件被卸载,这三个阶段总是伴随着组件各种各样的事件,那么这些事件,统称为组件的生命周期函数!
- 通过这个函数,能够让开发人员的代码,参与到组件的生命周期中。也就是说,通过钩子函数,就可以控制组件的行为
- 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 | class Greeting extends React.Component { |
componentWillMount()
- 说明:组件被挂载到页面之前调用,其在render()之前被调用,因此在这方法里
同步
地设置状态将不会触发重渲染 - 注意:无法获取页面中的DOM对象
- 注意:可以调用
setState()
方法来改变状态值 - 用途:发送ajax请求获取数据
1 | componentWillMount() { |
render()
-
作用:渲染组件到页面中,无法获取页面中的DOM对象
-
注意:不要在render方法中调用
setState()
方法,否则会递归渲染- 原因说明:状态改变会重新调用
render()
,render()
又重新改变状态
- 原因说明:状态改变会重新调用
1 | render() { |
componentDidMount()
- 1 组件已经挂载到页面中
- 2 可以进行DOM操作,比如:获取到组件内部的DOM对象
- 3 可以发送请求获取数据
- 4 可以通过
setState()
修改状态的值 - 注意:在这里修改状态会重新渲染
1 | componentDidMount() { |
组件生命周期 - 运行阶段(Updating)
- 特点:该阶段的函数执行多次
- 说明:每当组件的
props
或者state
改变的时候,都会触发运行阶段的函数
componentWillReceiveProps()
- 说明:组件接受到新的
props
前触发这个方法 - 参数:当前组件
props
值 - 可以通过
this.props
获取到上一次的值 - 使用:若你需要响应属性的改变,可以通过对比
this.props
和nextProps
并在该方法中使用this.setState()
处理状态改变 - 注意:修改
state
不会触发该方法
1 | componentWillReceiveProps(nextProps) { |
shouldComponentUpdate()
- 作用:根据这个方法的返回值决定是否重新渲染组件,返回
true
重新渲染,否则不渲染 - 优势:通过某个条件渲染组件,降低组件渲染频率,提升组件性能
- 说明:如果返回值为
false
,那么,后续render()
方法不会被调用 - 注意:这个方法必须返回布尔值!!!
- 场景:根据随机数决定是否渲染组件
1 | // - 参数: |
componentWillUpdate()
- 作用:组件将要更新
- 参数:最新的属性和状态对象
- 注意:不能修改状态 否则会循环渲染
1 | componentWillUpdate(nextProps, nextState) { |
render() 渲染
- 作用:重新渲染组件,与
Mounting
阶段的render
是同一个函数 - 注意:这个函数能够执行多次,只要组件的属性或状态改变了,这个方法就会重新执行
componentDidUpdate()
- 作用:组件已经被更新
- 参数:旧的属性和状态对象
1 | 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 | var createReactClass = require('create-react-class'); |
state和setState
- 注意:使用
setState()
方法修改状态,状态改变后,React会重新渲染组件 - 注意:不要直接修改state属性的值,这样不会重新渲染组件!!!
- 使用:1 初始化state 2 setState修改state
1 | // 修改state(不推荐使用) |
1 | constructor(props) { |
组件绑定事件
-
1 通过React事件机制
onClick
绑定 -
2 JS原生方式绑定(通过
ref
获取元素)- 注意:
ref
是React提供的一个特殊属性 ref
的使用说明
- 注意:
React中的事件机制 - 推荐
- 注意:事件名称采用驼峰命名法
- 例如:
onClick
用来绑定单击事件
1 | <input type="button" value="触发单击事件" |
JS原生方式 - 知道即可
- 说明:给元素添加
ref
属性,然后,获取元素绑定事件
1 | // JSX |
事件绑定中的this
- 1 通过
bind
绑定 - 2 通过
箭头函数
绑定
通过bind绑定
- 原理:
bind
能够调用函数,改变函数内部this的指向,并返回一个新函数 - 说明:
bind
第一个参数为返回函数中this的指向,后面的参数为传给返回函数的参数
1 | // 自定义方法: |
- 在构造函数中使用
bind
1 | constructor() { |
通过箭头函数绑定
- 原理:
箭头函数
中的this由所处的环境决定,自身不绑定this
1 | <input type="button" value="在构造函数中绑定this并传参" onClick={ |
受控组件
- 表单和受控组件
- 非受控组件
在HTML当中,像
input
,textarea
和select
这类表单元素会维持自身状态,并根据用户输入进行更新。
在React中,可变的状态通常保存在组件的state
中,并且只能用setState()
方法进行更新.
React根据初始状态渲染表单组件,接受用户后续输入,改变表单组件内部的状态。
因此,将那些值由React控制的表单元素称为:受控组件。
-
受控组件的特点:
- 1 表单元素
- 2 由React通过JSX渲染出来
- 3 由React控制值的改变,也就是说想要改变元素的值,只能通过React提供的方法来修改
-
注意:只能通过setState来设置受控组件的值
1 | // 模拟实现文本框数据的双向绑定 |
评论列表案例
1 | [ |
props校验
- 作用:通过类型检查,提高程序的稳定性
- 命令:
npm i -S prop-types
- 使用:给类提供一个静态属性
propTypes
(对象),来约束props
1 | // 引入模块 |
React 单向数据流
- React 中采用单项数据流
- 数据流动方向:自上而下,也就是只能由父组件传递到子组件
- 数据都是由父组件提供的,子组件想要使用数据,都是从父组件中获取的
- 如果多个组件都要使用某个数据,最好将这部分共享的状态提升至他们最近的父组件当中进行管理
- 单向数据流
- 状态提升
1 | react中的单向数据流动: |
组件通讯
- 父 -> 子:
props
- 子 -> 父:父组件通过props传递回调函数给子组件,子组件调用函数将数据作为参数传递给父组件
- 兄弟组件:因为React是单向数据流,因此需要借助父组件进行传递,通过父组件回调函数改变兄弟组件的props
- React中的状态管理: flux(提出状态管理的思想) -> Redux -> mobx
- Vue中的状态管理: Vuex
- 简单来说,就是统一管理了项目中所有的数据,让数据变的可控
- 组件通讯
Context特性
-
注意:如果不熟悉React中的数据流,不推荐使用这个属性
- 这是一个实验性的API,在未来的React版本中可能会被更改
-
作用:跨级传递数据(爷爷给孙子传递数据),避免向下每层手动地传递
props
-
说明:需要配合
PropTypes
类型限制来使用
1 | class Grandfather extends React.Component { |
react-router
- react router 官网
- react router github
- 安装:
npm i -S react-router-dom
基本概念说明
Router
组件本身只是一个容器,真正的路由要通过Route组件
定义
使用步骤
-
1 导入路由组件
-
2 使用
<Router></Router>
作为根容器,包裹整个应用(JSX)- 在整个应用程序中,只需要使用一次
-
3 使用
<Link to="/movie"></Link>
作为链接地址,并指定to
属性 -
4 使用
<Route path="/" compoent={Movie}></Route>
展示路由内容
1 | // 1 导入组件 |
注意点
<Router></Router>
:作为整个组件的根元素,是路由容器,只能有一个唯一的子元素<Link></Link>
:类似于vue中的<router-link></router-link>
标签,to
属性指定路由地址<Route></Route>
:类似于vue中的<router-view></router-view>
,指定路由内容(组件)展示位置
路由参数
- 配置:通过
Route
中的path属性来配置路由参数 - 获取:
this.props.match.params
获取
1 | // 配置路由参数 |
路由跳转
- 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 | /* |
跨域获取数据的三种常用方式
- 1 JSONP
- 2 代理
- 3 CORS
JSONP
- 安装:
npm i -S fetch-jsonp
- 利用
JSONP
实现跨域获取数据,只能获取GET请求 fetch-jsonp
- fetch-jsonp
- 限制:1 只能发送GET请求 2 需要服务端支持JSONP请求
1 | /* movielist.js */ |
代理
webpack-dev-server
代理配置如下:- 问题:webpack-dev-server 是开发期间使用的工具,项目上线了就不再使用 webpack-dev-server
- 解决:项目上线后的代码,也是会部署到一个服务器中,这个服务器配置了代理功能即可(要求两个服务器中配置的代理规则相同)
1 | // webpack-dev-server的配置 |
CORS - 服务器端配合
- 示例:NodeJS设置跨域
- 跨域资源共享 CORS 详解 - 阮一峰
1 | // 通过Express的中间件来处理所有请求 |
redux
- 状态管理工具,用来管理应用中的数据
核心
-
Action:行为的抽象,视图中的每个用户交互都是一个action
- 比如:点击按钮
-
Reducer:行为响应的抽象,也就是:根据action行为,执行相应的逻辑操作,更新state
- 比如:点击按钮后,添加任务,那么,添加任务这个逻辑放到 Reducer 中
- 1 创建State
-
Store:
- 1 Redux应用只能有一个store
- 2
getState()
:获取state - 3
dispatch(action)
:更新state
1 | /* action */ |
1 | /* reducer */ |
从本章开始,我们就正式开启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下载:
有经验的开发者可以选择Package Manager,它允许本地安装多个不同版本的Node并切换至不同版本。
在Windows上安装时务必选择全部组件,包括勾选Add to Path
。
安装完成后,在Windows环境下,请打开命令提示符,然后输入node -v
,如果安装正常,你应该看到v22.3.0
这样的输出:
1 | C:\Users\IEUser> node -v |
继续在命令提示符输入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 | C:\>npm -v |
如果直接输入npm
,你会看到类似下面的输出:
1 | C:\> npm |
上面的一大堆文字告诉你,npm
需要跟上命令。现在我们不用关心这些命令,后面会一一讲到。目前,你只需要确保npm正确安装了,能运行就行。
小结
请在本机安装Node.js环境,并确保node
和npm
能正常运行。
在前面的所有章节中,我们编写的JavaScript代码都是在浏览器中运行的,因此,我们可以直接在浏览器中敲代码,然后直接运行。
从本章开始,我们编写的JavaScript代码将不能在浏览器环境中执行了,而是在Node环境中执行,因此,JavaScript代码将直接在你的计算机上以命令行的方式运行,所以,我们要先选择一个文本编辑器来编写JavaScript代码,并且把它保存到本地硬盘的某个目录,才能够执行。
那么问题来了:文本编辑器到底哪家强?
首先,请注意,绝对不能用Word和写字板。Word和写字板保存的不是纯文本文件。如果我们要用记事本来编写JavaScript代码,要务必注意,记事本以UTF-8格式保存文件时,会自作聪明地在文件开始的地方加上几个特殊字符(UTF-8 BOM),结果经常会导致程序运行出现莫名其妙的错误。
所以,用记事本写代码时请注意,保存文件时使用ANSI编码,并且暂时不要输入中文。
如果你的电脑上已经安装了Visual Studio Code,也可以用来编写JavaScript代码,注意用UTF-8格式保存。
输入以下代码:
1 | ; |
第一行总是写上'use strict';
是因为我们总是以严格模式运行JavaScript代码,避免各种潜在陷阱。
然后,选择一个目录,例如C:\Workspace
,把文件保存为hello.js
,就可以打开命令行窗口,把当前目录切换到hello.js
所在目录,然后输入以下命令运行这个程序了:
1 | C:\Workspace> node hello.js |
也可以保存为别的名字,比如first.js
,但是必须要以.js
结尾。此外,文件名只能是英文字母、数字和下划线的组合。
如果当前目录下没有hello.js
这个文件,运行node hello.js
就会报错:
1 | C:\Workspace> node hello.js |
报错的意思就是,没有找到hello.js
这个文件,因为文件不存在。这个时候,就要检查一下当前目录下是否有这个文件了。
命令行模式和Node交互模式
请注意区分命令行模式和Node交互模式。
看到类似PS C:\>
是在Windows提供的命令行模式:
1 | ┌─────────────────────────────────────────────────────────┐ |
在命令行模式下,可以执行node
进入Node交互式环境,也可以执行node hello.js
运行一个.js
文件。
看到>
是在Node交互式环境下:
1 | ┌─────────────────────────────────────────────────────────┐ |
在Node交互式环境下,我们可以输入JavaScript代码并立刻执行。
此外,在命令行模式运行.js
文件和在Node交互式环境下直接运行JavaScript代码有所不同。Node交互式环境会把每一行JavaScript代码的结果自动打印出来,但是,直接运行JavaScript文件却不会。
例如,在Node交互式环境下,输入:
1 | > 100 + 200 + 300; |
直接可以看到结果600
。
但是,写一个calc.js
的文件,内容如下:
1 | 100 + 200 + 300; |
然后在命令行模式下执行:
1 | C:\Workspace> node calc.js |
发现什么输出都没有。
这是正常的。想要输出结果,必须自己用console.log()
打印出来。把calc.js
改造一下:
1 | console.log(100 + 200 + 300); |
再执行,就可以看到结果:
1 | C:\Workspace> node calc.js |
小结
用文本编辑器写JavaScript程序,然后保存为后缀为.js
的文件,就可以用node直接运行这个程序了。
Node的交互模式和直接运行.js
文件有什么区别呢?
直接输入node
进入交互模式,相当于启动了Node解释器,但是等待你一行一行地输入源代码,每输入一行就执行一行。
直接运行node hello.js
文件相当于启动了Node解释器,然后一次性把hello.js
文件的源代码给执行了,你是没有机会以交互的方式输入源代码的。
在编写JavaScript代码的时候,完全可以一边在文本编辑器里写代码,一边开一个Node交互式命令窗口,在写代码的过程中,把部分代码粘到命令行去验证,事半功倍!前提是得有个27’的超大显示器!
参考源码
使用文本编辑器来开发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资源管理器目录上下文菜单”:
这将大大提升将来的操作快捷度。
运行和调试JavaScript
在VS Code中,我们可以非常方便地运行JavaScript文件。
VS Code以文件夹作为工程目录(Workspace Dir),所有的JavaScript文件都存放在该目录下。
假设我们在C:\Work\
目录下创建了一个hello
目录作为工程目录,并编写了一个hello.js
文件,则该工程目录的结构如下:
1 | hello/ <-- workspace dir |
启动VS Code,选择菜单File - Open Folder…,选择hello
目录,就可以直接编辑hello.js
文件:
运行JS代码
确保当前编辑器正在编辑hello.js
文件,然后在VS Code中选择左侧调试按钮,直接点击Run And Debug
按钮,如果弹出环境选项则选择Node
,在右下侧DEBUG CONSOLE
可直接看到运行结果。
如果右下侧面板没有显示,左上角有几个按钮可以切换视图。
调试JS代码
在VS Code中,可以通过断点调试方便进行JavaScript的开发和测试。我们先在hello.js
的编辑器中打一个断点(鼠标点击行号左侧出现小红点即为断点),然后点击Run And Debug
按钮,此时进入调试模式,会自动停在断点处,左侧窗口可查看变量,顶部按钮可选择单步执行或继续执行到下一个断点,也可以随时结束程序:
总的来说,使用VS Code,开发和调试JavaScript代码十分方便。
参考源码
在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。
为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Node环境中,一个.js文件就称之为一个模块(module)。
使用模块有什么好处?
最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Node内置的模块和来自第三方的模块。
使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。
在上一节,我们编写了一个hello.js
文件,这个hello.js
文件就是一个模块,模块的名字就是文件名(去掉.js
后缀),所以hello.js
文件就是名为hello
的模块。
我们把hello.js
改造一下,创建一个函数,这样我们就可以在其他地方调用这个函数:
1 | ; |
函数greet()
是我们在hello
模块中定义的,你可能注意到最后一行是一个奇怪的赋值语句,它的意思是,把函数greet
作为模块的输出暴露出去,这样其他模块就可以使用greet
函数了。
问题是其他模块怎么使用hello
模块的这个greet
函数呢?我们再编写一个main.js
文件,调用hello
模块的greet
函数:
1 | ; |
注意到引入hello
模块用Node提供的require
函数:
1 | const greet = require('./hello'); |
引入的模块作为变量保存在greet
变量中,那greet
变量到底是什么东西?其实变量greet
就是在hello.js
中我们用module.exports = greet;
输出的greet
函数。所以,main.js
就成功地引用了hello.js
模块中定义的greet()
函数,接下来就可以直接使用它了。
在使用require()
引入模块的时候,请注意模块的相对路径。因为main.js
和hello.js
位于同一个目录,所以我们用了当前目录.
:
1 | const greet = require('./hello'); // 不要忘了写相对目录! |
如果只写模块名:
1 | const greet = require('hello'); |
则Node会依次在内置模块、全局模块和当前模块下查找hello.js
,你很可能会得到一个错误:
1 | module.js |
遇到这个错误,你要检查:
- 模块名是否写对了;
- 模块文件是否存在;
- 相对路径是否写对了。
CommonJS规范
这种模块加载机制被称为CommonJS规范。在这个规范下,每个.js
文件都是一个模块,它们内部各自使用的变量名和函数名都互不冲突,例如,hello.js
和main.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 | let s = 'Hello'; |
Node.js加载了hello.js
后,它可以把代码包装一下,变成这样执行:
1 | (function () { |
这样一来,原来的全局变量s
现在变成了匿名函数内部的局部变量。如果Node.js继续加载其他模块,这些模块中定义的“全局”变量s
也互不干扰。
所以,Node利用JavaScript的函数式编程的特性,轻而易举地实现了模块的隔离。
但是,模块的输出module.exports
怎么实现?
这个也很容易实现,Node可以先准备一个对象module
:
1 | // 准备module对象: |
可见,变量module
是Node在加载js文件前准备的一个变量,并将其传入加载函数,我们在hello.js
中可以直接使用变量module
原因就在于它实际上是函数的一个参数:
1 | module.exports = greet; |
通过把参数module
传递给load()
函数,hello.js
就顺利地把一个变量传递给了Node执行环境,Node会把module
变量保存到某个地方。
由于Node保存了所有导入的module
,当我们用require()
获取module时,Node找到对应的module
,把这个module
的exports
变量返回,这样,另一个模块就顺利拿到了模块的输出:
1 | const greet = require('./hello'); |
以上是Node实现JavaScript模块的一个简单的原理介绍。
module.exports vs exports
很多时候,你会看到,在Node环境中,有两种方法可以在一个模块中输出变量:
方法一:对module.exports赋值:
1 | // hello.js |
方法二:直接使用exports:
1 | // hello.js |
但是你不可以直接对exports
赋值:
1 | // 代码可以执行,但是模块并没有输出任何变量: |
如果你对上面的写法感到十分困惑,不要着急,我们来分析Node的加载机制:
首先,Node会把整个待加载的hello.js
文件放入一个包装函数load
中执行。在执行这个load()
函数前,Node准备好了module变量:
1 | let module = { |
load()
函数最终返回module.exports
:
1 | let load = function (exports, module) { |
也就是说,默认情况下,Node准备的exports
变量和module.exports
变量实际上是同一个变量,并且初始化为空对象{}
,于是,我们可以写:
1 | exports.foo = function () { return 'foo'; }; |
也可以写:
1 | module.exports.foo = function () { return 'foo'; }; |
换句话说,Node默认给你准备了一个空对象{}
,这样你可以直接往里面加东西。
但是,如果我们要输出的是一个函数或数组,那么,只能给module.exports
赋值:
1 | module.exports = function () { return 'foo'; }; |
给exports
赋值是无效的,因为赋值后,module.exports
仍然是空对象{}
。
结论
如果要输出一个键值对象{}
,可以利用exports
这个已存在的空对象{}
,并继续在上面添加新的键值;
如果要输出一个函数或数组,必须直接对module.exports
对象赋值。
所以我们可以得出结论:直接对module.exports
赋值,可以应对任何情况:
1 | module.exports = { |
或者:
1 | module.exports = function () { return 'foo'; }; |
最终,我们强烈建议使用module.exports = xxx
的方式来输出模块变量,这样,你只需要记忆一种方法。
练习
编写hello.js
,输出一个或多个函数;
编写main.js
,引入hello
模块,调用其函数。
参考源码
虽然Node.js从诞生起就支持模块,但JavaScript语言本身长期以来却一直没有模块功能,只能由CommonJS或其他AMD等模块系统来“模拟”。
随着ES 6标准的推出,JavaScript语言本身终于也迎来了原生内置的模块支持,称为ECMAScript Modules(简称ESM),不仅可以直接在浏览器中使用模块,也可以在Node.js中使用ESM模块。
不使用ESM模块时,我们用module.exports
导出可供外部使用的JS对象,例如,以下模块导出了两个函数:
1 | ; |
要把上述代码改为ESM模块,我们用export
标识需要导出的函数:
1 | let s = 'Hello'; |
并将其保存为hello.mjs
文件,注意扩展名不是.js
,而是.mjs
。
可以再编写一个main.mjs
文件来调用hello
模块:
1 | import { greet, hi } from './hello.mjs'; |
可见,ESM模块用export
关键字导出一个JS对象,用import
关键字导入一个模块的导出对象。
如果要实现类似如下代码的单个函数导出:
1 | module.exports = greet; |
则可以用export default
导出:
1 | export default function greet(name) { |
相应的,导入代码修改为:
1 | import greet from './hello.mjs'; |
细心的同学还注意到ESM模块文件第一行并没有'use strict'
,这是因为ESM模块默认启用严格模式,因此无需再手动声明'use strict'
。
浏览器加载ESM
对于浏览器来说,也可以直接使用ESM模块。当我们加载一个ESM模块时,需要用type="module"
来表示:
1 | <html> |
或者直接使用import
加载一个模块:
1 | <html> |
练习
在Node环境中使用ESM模块:
小结
使用JavaScript内置的原生模块时,用关键字export
和import
来实现导出与导入;
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 | > global.console |
process
process
也是Node.js提供的一个对象,它代表当前Node.js进程。通过process
对象可以拿到许多有用信息:
1 | > process === global.process; |
JavaScript程序是由事件驱动执行的单线程模型,Node.js也不例外。Node.js不断执行响应事件的JavaScript函数,直到没有任何响应事件的函数可以执行时,Node.js就退出了。
如果我们想要在下一次事件响应中执行代码,可以调用process.nextTick()
:
1 | // test.js |
用Node执行上面的代码node test.js
,你会看到,打印输出是:
1 | nextTick was set! |
这说明传入process.nextTick()
的函数不是立刻执行,而是要等到下一次事件循环。
Node.js进程本身的事件就由process
对象来处理。如果我们响应exit
事件,就可以在程序即将退出时执行某个回调函数:
1 | // 程序即将退出时的回调函数: |
判断JavaScript执行环境
有很多JavaScript代码既能在浏览器中执行,也能在Node环境执行,但有些时候,程序本身需要判断自己到底是在什么环境下执行的,常用的方式就是根据浏览器和Node环境提供的全局变量名称来判断:
1 | if (typeof(window) === 'undefined') { |
导入Node模块
Node内置了许多模块,可以从Node.js的在线文档查询所有模块信息。
以crypto
模块的randomInt()
函数为例,导入模块有两种方法:
方法一:使用传统的require()
:
1 | const { randomInt } = require('node:crypto'); |
方法二:使用import
关键字导入ESM模块:
1 | import { randomInt } from 'node:crypto'; |
后面,我们将介绍Node.js的常用内置模块。
Node.js内置的fs
模块就是文件系统模块,负责读写文件。
和所有其它JavaScript模块不同的是,fs
模块同时提供了异步和同步的方法。
回顾一下什么是异步方法。因为JavaScript的单线程模型,执行IO操作时,JavaScript代码无需等待,而是传入回调函数后,继续执行后续JavaScript代码。比如jQuery提供的getJSON()
操作:
1 | $.getJSON('http://example.com/ajax', function (data) { |
而同步的IO操作则需要等待函数返回:
1 | // 根据网络耗时,函数将执行几十毫秒~几秒不等: |
同步操作的好处是代码简单,缺点是程序将等待IO操作,在等待时间内,无法响应其它任何事件。而异步读取不用等待IO操作,但代码较麻烦。
异步读文件
按照JavaScript的标准,异步读取一个文本文件的代码如下:
1 | // read-text-file-async.mjs |
请注意,sample.txt
文件必须在当前目录下,且文件编码为utf-8
。
异步读取时,传入的回调函数接收两个参数,当正常读取时,err
参数为null
,data
参数为读取到的String。当读取发生错误时,err
参数代表一个错误对象,data
为undefined
。这也是Node.js标准的回调函数:第一个参数代表错误信息,第二个参数代表结果。后面我们还会经常编写这种回调函数。
由于err
是否为null
就是判断是否出错的标志,所以通常的判断逻辑总是:
1 | if (err) { |
执行上述代码,可以看到打印的内容如下:
1 | >>> BEGIN >>> |
因为异步读取,所以,先打印END
后,才会执行回调函数,打印文件内容。
如果我们要读取的文件不是文本文件,而是二进制文件,怎么办?
下面的例子演示了如何读取一个图片文件:
1 | import { readFile } from 'node:fs'; |
当读取二进制文件时,不传入文件编码时,回调函数的data
参数将返回一个Buffer
对象。在Node.js中,Buffer
对象就是一个包含零个或任意个字节的数组(注意和Array不同)。
Buffer
对象可以和String作转换,例如,把一个Buffer
对象转换成String:
1 | // Buffer -> String |
或者把一个String转换成Buffer
:
1 | // String -> Buffer |
同步读文件
除了标准的异步读取模式外,fs
也提供相应的同步读取函数。同步读取的函数和异步函数相比,多了一个Sync
后缀,并且不接收回调函数,函数直接返回结果。
用fs
模块同步读取一个文本文件的代码如下:
1 | // read-text-file-sync.mjs |
可见,原异步调用的回调函数的data
被函数直接返回,函数名需要改为readFileSync
,其它参数不变。
如果同步读取文件发生错误,则需要用try...catch
捕获该错误。
写文件
将数据写入文件是通过fs.writeFile()
实现的:
1 | // write-file-async.mjs |
writeFile()
的参数依次为文件名、数据和回调函数。如果传入的数据是String,默认按UTF-8编码写入文本文件,如果传入的参数是Buffer
,则写入的是二进制文件。回调函数由于只关心成功与否,因此只需要一个err
参数。
和readFile()
类似,writeFile()
也有一个同步方法,叫writeFileSync()
。
stat
如果我们要获取文件大小,创建时间等信息,可以使用fs.stat()
,它返回一个Stat
对象,能告诉我们文件或目录的详细信息:
1 | // read-stat-async.mjs |
运行结果如下:
1 | isFile: true |
stat()
也有一个对应的同步函数statSync()
,请试着改写上述异步代码为同步代码。
使用Promise
我们在介绍JavaScript的Promise时,讲到通过async函数实现异步逻辑,代码更简单。
类似的,Node还提供Promise版本的fs,可以用如下代码在async函数中读取文件:
1 | // async-read.mjs |
在async函数中,用await调用fs/promises
与同步方法类似,但代码却是异步执行的。
异步还是同步
在fs
模块中,提供同步方法是为了方便使用。那我们到底是应该用异步方法还是同步方法呢?
由于Node环境执行的JavaScript代码是服务器端代码,所以,绝大部分需要在服务器运行期反复执行业务逻辑的代码,必须使用异步代码,否则,同步代码在执行时期,服务器将停止响应,因为JavaScript只有一个执行线程。
服务器启动时如果需要读取配置文件,或者结束时需要写入到状态文件时,可以使用同步代码,因为这些代码只在启动和结束时执行一次,不影响服务器正常运行时的异步执行。
如果代码中编写了大量的async函数,那么通过await异步调用fs/promises
模块更加方便。
参考源码
stream
是Node.js提供的又一个仅在服务区端可用的模块,目的是支持“流”这种数据结构。
什么是流?流是一种抽象的数据结构。想象水流,当在水管中流动时,就可以从某个地方(例如自来水厂)源源不断地到达另一个地方(比如你家的洗手池)。我们也可以把数据看成是数据流,比如你敲键盘的时候,就可以把每个字符依次连起来,看成字符流。这个流是从键盘输入到应用程序,实际上它还对应着一个名字:标准输入流(stdin)。
如果应用程序把字符一个一个输出到显示器上,这也可以看成是一个流,这个流也有名字:标准输出流(stdout)。流的特点是数据是有序的,而且必须依次读取,或者依次写入,不能像Array那样随机定位。
有些流用来读取数据,比如从文件读取数据时,可以打开一个文件流,然后从文件流中不断地读取数据。有些流用来写入数据,比如向文件写入数据时,只需要把数据不断地往文件流中写进去就可以了。
在Node.js中,流也是一个对象,我们只需要响应流的事件就可以了:data
事件表示流的数据已经可以读取了,end
事件表示这个流已经到末尾了,没有数据可以读取了,error
事件表示出错了。
下面是一个从文件流读取文本内容的示例:
1 | import { createReadStream } from 'node:fs'; |
要注意,data
事件可能会有多次,每次传递的chunk
是流的一部分数据。
要以流的形式写入文件,只需要不断调用write()
方法,最后以end()
结束:
1 | import { createWriteStream } from 'node:fs'; |
所有可以读取数据的流都继承自stream.Readable
,所有可以写入的流都继承自stream.Writable
。
pipe
就像可以把两个水管串成一个更长的水管一样,两个流也可以串起来。一个Readable
流和一个Writable
流串起来后,所有的数据自动从Readable
流进入Writable
流,这种操作叫pipe
。
在Node.js中,Readable
流有一个pipe()
方法,就是用来干这件事的:
1 | rs.pipe(ws); |
除了直接使用pipe()
方法,Node还提供了pipeline
功能,它可以将一个流输出到另一个流。以下是一个复制文件的程序:
1 | import { createReadStream, createWriteStream } from "node:fs"; |
使用pipeline
的好处是,它可以添加若干个转换器,即输入流经过若干转换后,再进入输出流。如果我们添加的转换器实现了gzip功能,那么实际上就可以把输入流自动压缩后进入输出流。
参考源码
Node.js开发的目的就是为了用JavaScript编写Web服务器程序。因为JavaScript实际上已经统治了浏览器端的脚本,其优势就是有世界上数量最多的前端开发人员。如果已经掌握了JavaScript前端开发,再学习一下如何将JavaScript应用在后端开发,就是名副其实的全栈了。
HTTP协议
要理解Web服务器程序的工作原理,首先,我们要对HTTP协议有基本的了解。如果你对HTTP协议不太熟悉,先看一看HTTP协议简介。
HTTP服务器
要开发HTTP服务器程序,从头处理TCP连接,解析HTTP是不现实的。这些工作实际上已经由Node.js自带的http
模块完成了。应用程序并不直接和HTTP协议打交道,而是操作http
模块提供的request
和response
对象。
request
对象封装了HTTP请求,我们调用request
对象的属性和方法就可以拿到所有HTTP请求的信息;
response
对象封装了HTTP响应,我们操作response
对象的方法,就可以把HTTP响应返回给浏览器。
用Node.js实现一个HTTP服务器程序非常简单。我们来实现一个最简单的Web程序hello.js
,它对于所有请求,都返回Hello world!
:
1 | // 导入http模块: |
在命令提示符下运行该程序,可以看到以下输出:
1 | $ node simple-server.mjs |
不要关闭命令提示符,直接打开浏览器输入http://localhost:8080
,即可看到服务器响应的内容:
同时,在命令提示符窗口,可以看到程序打印的请求信息:
1 | GET: / |
这就是我们编写的第一个HTTP服务器程序!
文件服务器
让我们继续扩展一下上面的Web程序。我们可以设定一个目录,然后让Web程序变成一个文件服务器。要实现这一点,我们只需要解析request.url
中的路径,然后在本地找到对应的文件,把文件内容发送出去就可以了。
观察打印的request.url
,它实际上是浏览器请求的路径和参数,如:
/
/index.html
/hello?name=bob
解析出path部分可以直接用URL对象:
1 | let url = new URL('http://localost' + '/index.html?v=1'); |
处理本地文件目录需要使用Node.js提供的path
模块,它可以方便地构造目录:
1 | import path from 'node:path'; |
使用path
模块可以正确处理操作系统相关的文件路径。在Windows系统下,返回的路径类似于C:\Users\michael\static\index.html
,这样,我们就不关心怎么拼接路径了。
最后,我们实现一个文件服务器simple-file-server.js
:
1 | // 导入http模块: |
没有必要手动读取文件内容。由于response
对象本身是一个Writable Stream
,直接用pipe()
方法就实现了自动读取文件内容并输出到HTTP响应。
在命令行运行node simple-file-server.mjs
,然后在浏览器中输入http://localhost:8080/index.html
:
只要当前目录下存在文件index.html
,服务器就可以把文件内容发送给浏览器。观察控制台输出:
1 | GET: /index.html |
第一个请求是浏览器请求/
页面,后续请求是浏览器解析HTML后发送的其它资源请求。
练习
在浏览器输入http://localhost:8080/
时,会返回404,原因是程序识别出HTTP请求的不是文件,而是目录。请修改simple-file-server.mjs
,如果遇到请求的路径是目录,则自动在目录下依次搜索index.html
、default.html
,如果找到了,就返回HTML文件的内容。
参考源码
crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C++实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。
MD5和SHA1
MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:
1 | import crypto from 'node:crypto'; |
update()
方法默认字符串编码为UTF-8
,也可以传入Buffer。
如果要计算SHA1,只需要把'md5'
改成'sha1'
,就可以得到SHA1的结果。还可以使用更安全的sha256
和sha512
。
Hmac
Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:
1 | import crypto from 'node:crypto'; |
只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。
AES
AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:
1 | import crypto from 'node:crypto'; |
运行结果如下:
1 | AES encrypt: 11cd65e5fe7e7448b491efabee2f326a |
可以看出,加密后的字符串通过解密又得到了原始内容。
注意到AES有很多不同的算法,如aes192
,aes-128-ecb
,aes-256-cbc
等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Node.js全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。
Diffie-Hellman
DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:
- 小明先选一个素数和一个底数,例如,素数
p=97
,底数g=5
(底数是p的一个原根),再选择一个秘密整数a=123
,计算A=g^a mod p=34
,然后大声告诉小红:p=97,g=5,A=34
; - 小红收到小明发来的
p
,g
,A
后,也选一个秘密整数b=456
,然后计算B=g^b mod p=75
,并大声告诉小明:B=75
; - 小明自己计算出
s=B^a mod p=22
,小红也自己计算出s=A^b mod p=22
,因此,最终协商的密钥s
为22
。
在这个过程中,密钥22
并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=97
,g=5
,A=34
,B=75
,由于不知道双方选的秘密整数a=123
和b=456
,因此无法计算出密钥22
。
用crypto模块实现DH算法如下:
1 | import crypto from 'node:crypto'; |
运行后,可以得到如下输出:
1 | Prime: a8224c...deead3 |
注意每次输出都不一样,因为素数的选择是随机的。
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 | import fs from 'node:fs'; |
执行后,可以得到解密后的消息,与原始消息相同。
接下来我们使用公钥加密,私钥解密:
1 | // 使用公钥加密: |
执行得到的解密后的消息仍与原始消息相同。
如果我们把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框架包括:Express,Sails.js,koa,Meteor,DerbyJS,Total.js,restify……
ORM框架比Web框架要少一些:Sequelize,ORM2,Bookshelf.js,Objection.js……
模版引擎PK:Jade,EJS,Swig,Nunjucks,doT.js……
测试框架包括:Mocha,Expresso,Unit.js,Karma……
目前,在npm上已发布的开源Node.js模块数量超过了30万个。
有选择恐惧症的朋友,看到这里可以洗洗睡了。
好消息是这个教程已经帮你选好了,你只需要跟着教程一条道走到黑就可以了。
koa
koa是Express的下一代基于Node.js的web框架,目前有1.x和2.0两个版本。
历史
Express
Express是第一代最流行的web框架,它对Node.js的http进行了封装,用起来如下:
1 | let express = require('express'); |
虽然Express的API很简单,但是它是基于ES5的语法,要实现异步代码,只有一个方法:回调。如果异步嵌套层次过多,代码写起来就非常难看:
1 | app.get('/test', function (req, res) { |
虽然可以用async这样的库来组织异步代码,但是用回调写异步实在是太痛苦了!
koa 1.x
随着新版Node.js开始支持ES6,Express的团队又基于ES6的generator重新编写了下一代web框架koa。和Express相比,koa 1.x使用generator实现异步,代码看起来像同步的:
1 | let koa = require('koa'); |
用generator实现异步比回调简单了不少,但是generator的本意并不是异步。Promise才是为异步设计的,但是Promise的写法……想想就复杂。为了简化异步代码,JavaScript引入了新的关键字async
和await
,可以轻松地把一个function变为异步模式:
1 | async function () { |
这是JavaScript标准的异步代码,非常简洁,并且易于使用。
koa 2.x
koa团队并没有止步于koa 1.x,他们又开发了koa 2,和koa 1相比,koa 2完全使用Promise并配合async
来实现异步。
koa 2.x的代码看上去像这样:
1 | app.use(async (ctx, next) => { |
选择哪个版本?
为了紧跟时代潮流,教程将使用最新的koa 2.x开发!
创建koa工程
首先,我们创建一个目录hello-koa
,作为koa工程根目录。
在根目录下执行npm install koa
,我们就在当前目录下安装好了koa及其依赖项,执行完成后目录结构如下:
1 | hello-koa/ |
package.json
包含了npm的依赖信息,以及项目描述等信息,package-lock.json
是针对当前所有依赖的一个快照,目的是锁定各个依赖项的版本号。
我们打开package.json
,内容如下:
1 | { |
它只有一个koa依赖,是由命令npm install koa
写入的。
node_modules
是所有依赖项安装的地方,可以随时删除node_modules
目录,然后用npm install
重新安装。
直接运行npm install
则根据package.json
的dependencies
信息下载安装依赖项,运行npm install xyz
则将xyz
添加到dependencies
然后再安装xyz
及其依赖项。因此,熟练的开发者可以先自己编辑dependencies
内容,然后删除node_modules
后重新安装所有依赖项。此外,package.json
和package-lock.json
应当添加至版本控制系统中,而node_modules
则无需添加。
我们手动添加如下信息至package.json
:
1 | { |
其中,name
、version
和description
均可任意设置,type
为module
表示以ESM模块执行。
紧接着,我们用VS Code打开hello-koa
目录,创建app.mjs
文件,输入以下代码:
1 | // 导入koa,注意导入的是大写开头的class: |
对于每一个http请求,koa将调用我们传入的异步函数来处理:
1 | async (ctx, next) => { |
其中,参数ctx
是由koa传入的封装了request和response的变量,我们可以通过它访问request和response,next
是koa传入的将要处理的下一个异步函数。
上面的异步函数中,我们首先用await next();
处理下一个异步函数,然后,设置response的Content-Type
和内容。
现在,我们的工程结构如下:
1 | hello-koa/ |
在命令行执行node app.mjs
就启动了Web服务器。我们打开浏览器,输入http://localhost:3000
,即可看到效果:
koa middleware
让我们再仔细看看koa的执行逻辑。核心代码是:
1 | app.use(async (ctx, next) => { |
每收到一个http请求,koa就会调用通过app.use()
注册的async函数,并传入ctx
和next
参数。
我们可以对ctx
操作,并设置返回内容。但是为什么要调用await next()
?
原因是koa把很多async函数组成一个处理链,每个async函数都可以做一些自己的事情,然后用await next()
来调用下一个async函数。我们把每个async函数称为middleware,这些middleware可以组合起来,完成很多有用的功能。
例如,可以用以下3个middleware组成处理链,依次打印日志,记录处理时间,输出HTML:
1 | app.use(async (ctx, next) => { |
middleware的顺序很重要,也就是调用app.use()
的顺序决定了middleware的顺序。
此外,如果一个middleware没有调用await next()
,会怎么办?答案是后续的middleware将不再执行了。这种情况也很常见,例如,一个检测用户权限的middleware可以决定是否继续处理请求,还是直接返回403错误:
1 | app.use(async (ctx, next) => { |
理解了middleware,我们就已经会用koa了!
最后注意ctx
对象有一些简写的方法,例如ctx.url
相当于ctx.request.url
,ctx.type
相当于ctx.response.type
。
参考源码
在hello-koa工程中,我们处理http请求一律返回相同的HTML,这样虽然非常简单,但是用浏览器一测,随便输入任何URL都会返回相同的网页。
正常情况下,我们应该对不同的URL调用不同的处理函数,这样才能返回不同的结果。例如像这样写:
1 | app.use(async (ctx, 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 | import Koa from 'koa'; |
我们使用router.get('/path', async fn)
来注册一个GET请求。可以在请求路径中使用带变量的/hello/:name
,变量可以通过ctx.params.name
访问。
再运行app.mjs
,我们就可以测试不同的URL:
输入:http://localhost:3000/hello/Bob
处理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 | router.get('/', async (ctx, next) => { |
注意到我们用let name = ctx.request.body.name || ''
拿到表单的name
字段,如果该字段不存在,默认值设置为''
。
类似的,put、delete、head请求也可以由router处理。
重构
现在,我们已经可以处理不同的URL了,但是看看app.mjs
,总觉得还是有点不对劲。
所有的URL处理函数都放到app.mjs
里显得很乱,而且,每加一个URL,就需要修改app.mjs
。随着URL越来越多,app.mjs
就会越来越长。
如果能把URL处理函数集中到某个js文件,或者某几个js文件中就好了,然后让app.mjs
自动导入所有处理URL的函数。这样,代码一分离,逻辑就显得清楚了。最好是这样:
1 | url2-koa/ |
于是我们把url-koa
复制一份,重命名为url2-koa
,准备重构这个项目。
我们先在controller
目录下编写signin.mjs
:
1 | // GET / |
这个signin.mjs
通过export default
把两个URL处理函数暴露出来。
类似的,hello.mjs
把一个URL处理函数暴露出来:
1 | async function hello(ctx, next) { |
现在,我们修改app.mjs
,让它自动扫描controller
目录,找到所有js
文件,导入,然后注册每个URL:
1 | // 扫描controller目录: |
Controller Middleware
最后,我们把扫描controller
目录和创建router
的代码从app.mjs
中提取出来,作为一个简单的middleware使用,命名为controller.mjs
:
1 | // controller.mjs: |
这样一来,我们在app.mjs
的代码又简化了:
1 | import controller from './controller.mjs'; |
经过重新整理后的工程url2-koa
目前具备非常好的模块化,所有处理URL的函数按功能组存放在controller
目录,今后我们也只需要不断往这个目录下加东西就可以了,app.mjs
保持不变。
最后我们整理一下koa处理一个HTTP请求的流程:
1 | │ |
一个HTTP请求是按顺序由一系列注册到koa的middleware处理的,首先由log函数处理,并通过await next()
把请求传递到下一个middleware,紧接着是bodyParser
处理,最后是router
处理。在router的内部,又会根据注册到router的HTTP方法+Path来决定由哪个async函数处理请求。如果URL没有匹配到,则简单返回404。以上就是整个基于koa的webapp处理流程,非常清晰易懂。
参考源码
Nunjucks是什么东东?其实它是一个模板引擎。
那什么是模板引擎?
模板引擎就是基于模板配合数据构造出字符串输出的一个组件。比如下面的函数就是一个模板引擎:
1 | function examResult (data) { |
如果我们输入数据如下:
1 | examResult({ |
该模板引擎把模板字符串里面对应的变量替换以后,就可以得到以下输出:
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 | {{ name }}同学, |
所以,我们需要一个功能强大的模板引擎,来完成页面输出的功能。
Nunjucks
我们选择Nunjucks作为模板引擎。Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。
如果你使用过Python的模板引擎jinja2,那么使用Nunjucks就非常简单,两者的语法几乎是一模一样的,因为Nunjucks就是用JavaScript重新实现了jinjia2。
从上面的例子我们可以看到,虽然模板引擎内部可能非常复杂,但是使用一个模板引擎是非常简单的,因为本质上我们只需要构造这样一个函数:
1 | function render(view, model) { |
其中,view
是模板的名称(又称为视图),因为可能存在多个模板,需要选择其中一个。model
就是数据,在JavaScript中,它就是一个简单的Object。render
函数返回一个字符串,就是模板的输出。
下面我们来使用Nunjucks这个模板引擎来编写几个HTML模板,并且用实际数据来渲染模板并获得最终的HTML输出。
我们创建一个use-nunjucks
的VS Code工程结构如下:
1 | use-nunjucks/ |
其中,模板文件存放在view
目录中。
我们先用npm install nunjucks
安装依赖项并在package.json
中添加nunjucks
的依赖:
1 | "nunjucks": "^3.2.4" |
注意,模板引擎是可以独立使用的,并不需要依赖koa。
紧接着,我们要编写使用Nunjucks的函数render
。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.js
中编写代码如下:
1 | import nunjucks from 'nunjucks'; |
变量env
就表示Nunjucks模板引擎对象,它有一个render(view, model)
方法,正好传入view
和model
两个参数,并返回字符串。
创建env
需要的参数可以查看文档获知。我们用关键字参数作为默认值,最后使用new nunjucks.FileSystemLoader('view')
创建一个文件系统加载器,从view
目录读取模板。
我们编写一个hello.html
模板文件,放到view
目录下,内容如下:
1 | <h1>Hello {{ name }}</h1> |
然后,我们就可以用下面的代码来渲染这个模板:
1 | const s = env.render('hello.html', { name: '小明' }); |
获得输出如下:
1 | <h1>Hello 小明</h1> |
咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:
1 | const s = env.render('hello.html', { name: '<script>alert("小明")</script>' }); |
获得输出如下:
1 | <h1>Hello <script>alert("小明")</script></h1> |
这样就避免了输出恶意脚本。
此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:
1 | <!-- 循环输出名字 --> |
Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。
更好的方式是使用继承。先定义一个基本的网页框架base.html
:
1 | <html><body> |
base.html
定义了三个可编辑的块,分别命名为header
、body
和footer
。子模板可以有选择地对块进行重新定义:
1 | {% extends 'base.html' %} |
然后,我们对子模板进行渲染:
1 | console.log(env.render('extend.html', { |
输出HTML如下:
1 | <html><body> |
性能
最后我们要考虑一下Nunjucks的性能。
对于模板渲染本身来说,速度是非常非常快的,因为就是拼字符串嘛,纯CPU操作。
性能问题主要出现在从文件读取模板内容这一步。这是一个IO操作,在Node.js环境中,我们知道,单线程的JavaScript最不能忍受的就是同步IO,但Nunjucks默认就使用同步IO读取模板文件。
好消息是Nunjucks会缓存已读取的文件内容,也就是说,模板文件最多读取一次,就会放在内存中,后面的请求是不会再次读取文件的,只要我们指定了noCache: false
这个参数。
在开发环境下,可以关闭cache,这样每次重新加载模板,便于实时修改模板。在生产环境下,一定要打开cache,这样就不会有性能问题。
Nunjucks也提供了异步读取的方式,但是这样写起来很麻烦,有简单的写法我们就不会考虑复杂的写法。保持代码简单是可维护性的关键。
参考源码
我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!
当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:
1 | ctx.render('home.html', { name: 'Michael' }); |
通过Nunjucks把数据用指定的模板渲染成HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:
1 | ┌─────────────────────────────┐ |
这就是传说中的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 | koa-mvc/ |
在package.json
中,我们将要用到的依赖包有:
1 | "@koa/bodyparser": "^5.1.1", |
先用npm install
安装依赖包,然后,我们准备编写以下两个Controller:
处理首页 GET /
我们定义一个async函数处理首页URL/
:
1 | async function index(ctx, next) { |
注意到koa并没有在ctx
对象上提供render
方法,这里我们假设应该这么使用,这样,我们在编写Controller的时候,最后一步调用ctx.render(view, model)
就完成了页面输出。
处理登录请求 POST /signin
我们再定义一个async函数处理登录请求/signin
:
1 | async function signin(ctx, next) { |
由于登录请求是一个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-mount
和koa-static
组合来处理静态文件:
1 | // 处理静态文件: |
上述代码大致相当于自己手写一个middleware:
1 | app.use(async (ctx, next) => { |
集成Nunjucks
集成Nunjucks实际上也是编写一个middleware,这个middleware的作用是给ctx
对象绑定一个render(view, model)
的方法,这样,后面的Controller就可以调用这个方法来渲染模板了。
我们创建一个view.mjs
来实现这个middleware:
1 | import nunjucks from 'nunjucks'; |
使用的时候,我们在app.mjs
添加如下代码:
1 | import templateEngine from './view.mjs'; |
注意到createEnv()
函数和前面使用Nunjucks时编写的函数是一模一样的。
这里我们判断当前环境是否是production环境。如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启Node程序,会极大地降低开发效率。
Node.js在全局变量process
中定义了一个环境变量env.NODE_ENV
,为什么要使用该环境变量?因为我们在开发的时候,环境变量应该设置为'development'
,而部署到服务器时,环境变量应该设置为'production'
。在编写代码的时候,要根据当前环境作不同的判断。
注意:生产环境上必须配置环境变量NODE_ENV = 'production'
,而开发环境不需要配置,实际上NODE_ENV
可能是undefined
,所以判断的时候,不要用NODE_ENV === 'development'
。
类似的,我们在使用上面编写的处理静态文件的middleware时,也可以根据环境变量判断:
1 | if (!isProduction) { |
这是因为在生产环境下,静态文件是由部署在最前面的反向代理服务器(如Nginx)处理的,Node程序不需要处理静态文件。而在开发环境下,我们希望koa能顺带处理静态文件,否则,就必须手动配置一个反向代理服务器,这样会导致开发环境非常复杂。
编写View
在编写View的时候,非常有必要先编写一个base.html
作为骨架,其他模板都继承自base.html
,这样,才能大大减少重复工作。
编写HTML不在本教程的讨论范围之内。这里我们参考Bootstrap的官网简单编写了base.html
。
运行
一切顺利的话,这个koa-mvc
工程应该可以顺利运行。运行前,我们再检查一下app.mjs
里的middleware的顺序:
第一个middleware是记录URL以及页面执行时间:
1 | app.use(async (ctx, next) => { |
第二个middleware处理静态文件:
1 | if (!isProduction) { |
第三个middleware解析POST请求:
1 | app.use(bodyParser()); |
最后一个middleware处理URL路由:
1 | app.use(await controller()); |
现在,用node app.mjs
运行代码,不出意外的话,在浏览器输入localhost:3000/
,可以看到首页内容:
直接在首页登录,如果输入正确的Email和Password,进入登录成功的页面:
如果输入的Email和Password不正确,进入登录失败的页面:
怎么判断正确的Email和Password?目前我们在signin.js
中是这么判断的:
1 | 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 | app.use(async (ctx, next) => { |
这样就没有必要在每个Controller的async函数中都把user变量放入model中。
参考源码
自从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架构可扩展的一个关键。
REST API规范
编写REST API,实际上就是编写处理HTTP请求的async函数,不过,REST请求和普通的HTTP请求有几个特殊的地方:
- REST请求仍然是标准的HTTP请求,但是,除了GET请求外,POST、PUT等请求的body是JSON数据格式,请求的
Content-Type
为application/json
; - 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 | ctx.body = { |
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 | // /api/users/:id |
处理登录请求的async函数如下:
1 | async function signin(ctx, next) { |
当发生错误时,返回的信息包含error
字段,客户端依靠该字段来判断是否出错。
最后导出URL处理函数:
1 | export default { |
现在,我们可以直接在浏览器测试GET请求:
输入无效的ID,返回错误:
对于POST请求,我们无法直接在浏览器测试,可以用curl
命令测试如下:
1 | $ curl -H 'Content-Type: application/json' \ |
输入错误的口令,返回错误信息:
1 | $ curl -H 'Content-Type: application/json' \ |
由于我们把登录从传统的POST表单改成了REST,因此,前端页面需要编写JavaScript代码来发送REST请求,修改HTML如下:
1 | <!-- 给form加上onsubmit回调函数 --> |
用JavaScript发送REST请求如下:
1 | function signin() { |
可见,在koa中处理REST请求是非常简单的。bodyParser()
这个middleware可以解析请求的JSON数据并绑定到ctx.request.body
上,输出JSON时我们把JavaScript对象赋值给ctx.response.body
就完成了REST请求的处理。
参考源码
程序运行的时候,数据都是在内存中的。当程序终止的时候,通常都需要将数据保存到磁盘上,无论是保存到本地磁盘,还是通过网络保存到服务器上,最终都会将数据写入磁盘文件。
而如何定义数据的存储格式就是一个大问题。如果我们自己来定义存储格式,比如保存一个班级所有学生的成绩单:
名字 | 成绩 |
---|---|
Michael | 99 |
Bob | 85 |
Bart | 59 |
Lisa | 87 |
我们可以用一个文本文件保存,一行保存一个学生,用,
隔开:
1 | Michael,99 |
还可以用JSON格式保存,也是文本文件:
1 | [ |
还可以定义各种保存格式,但是问题来了:
存储和读取需要自己实现,JSON还是标准,自己定义的格式就各式各样了;
不能做快速查询,只有把数据全部读到内存中才能自己遍历,但有时候数据的大小远远超过了内存,根本无法全部读入内存。
为了便于程序保存和读取数据,而且,能直接通过条件快速查询到指定的数据,就出现了数据库(Database)这种专门用于集中存储和查询的软件。
数据库软件诞生的历史非常久远,早在1950年数据库就诞生了。经历了网状数据库,层次数据库,我们现在广泛使用的关系数据库是20世纪70年代基于关系模型的基础上诞生的。
关系模型有一套复杂的数学理论,但是从概念上是十分容易理解的。举个学校的例子:
假设某个XX省YY市ZZ县第一实验小学有3个年级,要表示出这3个年级,可以在Excel中用一个表格画出来:
每个年级又有若干个班级,要把所有班级表示出来,可以在Excel中再画一个表格:
这两个表格有个映射关系,就是根据Grade_ID可以在班级表中查找到对应的所有班级:
也就是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表的多行记录:
由于本教程不涉及到关系数据库的详细内容,如果你想从零学习关系数据库和基本的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 | // 指定模式打开test.db: |
我们传入了OPEN_CREATE
参数,表示如果数据库不存在则自动创建,在开发模式下非常方便。
sqlite3
使用回调模式执行查询和更新操作,代码如下:
1 | // query: |
回调模式写起来非常别扭,由于sqlite3
没有提供Promise接口,因此无法使用await调用,怎么办?
答案是我们自己封装一个Promise调用,以便通过await来实现异步查询和更新:
1 | // db.mjs: |
我们复制前面的koa-mvc
工程,命名为sql
,准备用实际数据库替换写死的登录逻辑。工程结构如下:
1 | sql/ |
通过npm install sqlite3
安装依赖项并添加依赖:
1 | "sqlite3": "^5.1.7" |
增加了db.mjs
,实现了对sqlite数据库的操作。
我们在app.mjs
中初始化一个db
对象并绑定到app.context
中:
1 | import { createDatabase } from './db.mjs'; |
注意到initDb()
中自动创建表和用户的代码都是为了便于开发。
有了数据库支持,我们就可以把signin.mjs
写死的代码替换为查询数据库用户:
1 | // signin: |
观察上述代码,我们查询数据库中某个用户的代码如下:
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 | name | password | |
---|---|---|---|
1 | admin@example.com | Bob | 123456 |
2 | lucy@example.com | Lucy | abcdef |
3 | alice@example.com | Alice | hello123 |
每一行可以用一个JavaScript对象表示,例如第一行:
1 | { |
这就是传说中的ORM技术:Object-Relational Mapping,把关系数据库的表结构映射到对象上。是不是很简单?
但是由谁来做这个转换呢?所以ORM框架应运而生。
我们选择Node的ORM框架Sequelize来操作数据库。这样,我们读写的都是JavaScript对象,Sequelize帮我们把对象变成数据库中的行。
用Sequelize查询users
表,代码像这样:
1 | let users = await User.findAll(); |
根据email
查询一个用户,代码像这样:
1 | let user = await User.find({ |
Sequelize的所有操作都是Promise,所以我们可以用await实现异步调用。
实战
在使用Sequelize操作数据库之前,我们需要告诉Sequelize如何映射数据库中的每一个表。
以users
表为例,我们需要定义如下:
1 | // orm.mjs: |
在定义列的时候,主键以primaryKey: true
指定,具有唯一约束的列用unique: true
表示,数据类型用DataTypes
表示。
这样Sequelize就有了足够的信息来实现ORM。最后将sequelize
和User
对象导出。
我们根据上一节的sql
工程结构创建orm
工程,结构如下:
1 | sql/ |
删除了db.mjs
,增加orm.mjs
。
然后,通过npm install sequelize sqlite3
安装依赖项并添加依赖:
1 | "sequelize": "^6.37.3", |
在app.mjs
中,删除相关SQL操作,改为通过Sequelize初始化数据库:
1 | import { sequelize, User } from './orm.mjs'; |
使用Sequelize时,无需绑定app.context
,因为我们主要通过具体的Model比如User
来操作数据库。修改signin.mjs
如下:
1 | import { User } from '../orm.mjs'; |
运行node app.mjs
启动服务器,可以观察到Sequelize访问数据库时打印的SQL语句。这里需要注意的是,Sequelize会为每个Model自动添加一个createdAt
和updatedAt
字段,用来记录创建和更新时间。因此,创建的users
表的SQL语句为:
1 | CREATE TABLE IF NOT EXISTS `users` ( |
常用操作
Sequelize提供了findAll()
和findOne()
两种查询,分别返回多行和一行。
create()
操作可以存储一个对象到数据库的一行记录,save()
和destroy()
分别对应更新和删除操作。
Sequelize还提供了一对多等高级ORM功能,具体可以参考官方文档。
参考
参考源码:orm
Sequelize:官方网站
underscore
前面我们已经讲过了,JavaScript是函数式编程语言,支持高阶函数和闭包。函数式编程非常强大,可以写出非常简洁的代码。例如Array
的map()
和filter()
方法:
1 | let a1 = [1, 4, 9, 16]; |
现在问题来了,Array
有map()
和filter()
方法,可是Object没有这些方法。此外,低版本的浏览器例如IE6~8也没有这些方法,怎么办?
方法一,自己把这些方法添加到Array.prototype
中,然后给Object.prototype
也加上mapObject()
等类似的方法。
方法二,直接找一个成熟可靠的第三方开源库,使用统一的函数来实现map()
、filter()
这些操作。
我们采用方法二,选择的第三方库就是underscore。
正如jQuery统一了不同浏览器之间的DOM操作的差异,让我们可以简单地对DOM进行操作,underscore则提供了一套完善的函数式编程的接口,让我们更方便地在JavaScript中实现函数式编程。
jQuery在加载时,会把自身绑定到唯一的全局变量$
上,underscore与其类似,会把自身绑定到唯一的全局变量_
上,这也是为啥它的名字叫underscore的原因。
用underscore实现map()
操作如下:
1 | let r = _.map([1, 2, 3], (x) => x * x); |
咋一看比直接用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
和Array
的map()
与filter()
类似,但是underscore的map()
和filter()
可以作用于Object。当作用于Object时,传入的函数为function (value, key)
,第一个参数接收value,第二个参数接收key:
1 | let obj = { |
你也许会想,为啥对Object作map()
操作的返回结果是Array
?应该是Object才合理啊!把_.map
换成_.mapObject
再试试。
every / some
当集合的所有元素都满足条件时,_.every()
函数返回true
,当集合的至少一个元素满足条件时,_.some()
函数返回true
:
1 | // 所有元素都大于0? |
当集合是Object时,我们可以同时获得value和key:
1 | let obj = { |
max / min
这两个函数直接返回集合中最大和最小的数:
1 | let arr = [3, 5, 7, 9]; |
注意,如果集合是Object,max()
和min()
只作用于value,忽略掉key:
1 | _.max({ a: 1, b: 2, c: 3 }); // 3 |
groupBy
groupBy()
把集合的元素按照key归类,key由传入的函数返回:
1 | let scores = [20, 81, 75, 40, 91, 59, 77, 66, 72, 88, 99]; |
可见groupBy()
用来分组是非常方便的。
shuffle / sample
shuffle()
用洗牌算法随机打乱一个集合:
1 | // 注意每次结果都不一样: |
sample()
则是随机选择一个或多个元素:
1 | // 注意每次结果都不一样: |
更多完整的函数请参考underscore的文档:https://underscorejs.org/#collections
underscore为Array
提供了许多工具类方法,可以更方便快捷地操作Array
。
first / last
顾名思义,这两个函数分别取第一个和最后一个元素:
1 | let arr = [2, 4, 6, 8]; |
flatten
flatten()
接收一个Array
,无论这个Array
里面嵌套了多少个Array
,flatten()
最后都把它们变成一个一维数组:
1 | _.flatten([1, [2], [3, [[4], [5]]]]); // [1, 2, 3, 4, 5] |
zip / unzip
zip()
把两个或多个数组的所有元素按索引对齐,然后按索引合并成新数组。例如,你有一个Array
保存了名字,另一个Array
保存了分数,现在,要把名字和分数给对上,用zip()
轻松实现:
1 | let names = ['Adam', 'Lisa', 'Bart']; |
unzip()
则是反过来:
1 | let namesAndScores = [['Adam', 85], ['Lisa', 92], ['Bart', 59]]; |
object
有时候你会想,与其用zip()
,为啥不把名字和分数直接对应成Object呢?别急,object()
函数就是干这个的:
1 | let names = ['Adam', 'Lisa', 'Bart']; |
注意_.object()
是一个函数,不是JavaScript的Object
对象。
range
range()
让你快速生成一个序列,不再需要用for
循环实现了:
1 | // 从0开始小于10: |
更多完整的函数请参考underscore的文档:https://underscorejs.org/#arrays
练习
请根据underscore官方文档,使用_.uniq
对数组元素进行不区分大小写去重:
1 | let arr = ['Apple', 'orange', 'banana', 'ORANGE', 'apple', 'PEAR']; |
因为underscore本来就是为了充分发挥JavaScript的函数式编程特性,所以也提供了大量JavaScript本身没有的高阶函数。
bind
bind()
有什么用?我们先看一个常见的错误用法:
1 | let s = ' Hello '; |
如果你想用fn()
取代s.trim()
,按照上面的做法是不行的,因为直接调用fn()
传入的this
指针是undefined
,必须这么用:
1 | let s = ' Hello '; |
这样搞多麻烦!还不如直接用s.trim()
。但是,bind()
可以帮我们把s
对象直接绑定在fn()
的this
指针上,以后调用fn()
就可以直接正常调用了:
1 | let s = ' 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 | let pow2N = _.partial(Math.pow, 2); |
如果我们不想固定第一个参数,想固定第二个参数怎么办?比如,希望创建一个偏函数cube(x)
,计算x3,可以用_
作占位符,固定住第二个参数:
1 | let cube = _.partial(Math.pow, _, 3); |
可见,创建偏函数的目的是将原函数的某些参数固定住,可以降低新函数调用的难度。
memoize
如果一个函数调用开销很大,我们就可能希望能把结果缓存下来,以便后续调用时直接获得结果。举个例子,计算阶乘就比较耗时:
1 | function factorial(n) { |
用memoize()
就可以自动缓存函数计算的结果:
1 | let factorial = _.memoize(function(n) { |
对于相同的调用,比如连续两次调用factorial(10)
,第二次调用并没有计算,而是直接返回上次计算后缓存的结果。不过,当你计算factorial(9)
的时候,仍然会重新计算。
可以对factorial()
进行改进,让其递归调用:
1 | let factorial = _.memoize(function(n) { |
once
顾名思义,once()
保证某个函数执行且仅执行一次。如果你有一个方法叫register()
,用户在页面上点两个按钮的任何一个都可以执行的话,就可以用once()
保证函数仅调用一次,无论用户点击多少次:
1 | let register = _.once(function () { |
delay
delay()
可以让一个函数延迟执行,效果和setTimeout()
是一样的,但是代码明显简单了:
1 | // 2秒后调用alert(): |
如果要延迟调用的函数有参数,把参数也传进去:
1 | let log = _.bind(console.log, console); |
更多完整的函数请参考underscore的文档:https://underscorejs.org/#functions
和Array
类似,underscore也提供了大量针对Object的函数。
keys / allKeys
keys()
可以非常方便地返回一个object自身所有的key,但不包含从原型链继承下来的:
1 | function Student(name, age) { |
allKeys()
除了object自身的key,还包含从原型链继承下来的:
1 | function Student(name, age) { |
values
和keys()
类似,values()
返回object自身但不包含原型链继承的所有值:
1 | let obj = { |
注意,没有allValues()
,原因我也不知道。
mapObject
mapObject()
就是针对object的map版本:
1 | let obj = { a: 1, b: 2, c: 3 }; |
invert
invert()
把object的每个key-value来个交换,key变成value,value变成key:
1 | let obj = { |
extend / extendOwn
extend()
把多个object的key-value合并到第一个object并返回:
1 | let a = {name: 'Bob', age: 20}; |
注意:如果有相同的key,后面的object的value将覆盖前面的object的value。
extendOwn()
和extend()
类似,但获取属性时忽略从原型链继承下来的属性。
clone
如果我们要复制一个object对象,就可以用clone()
方法,它会把原有对象的所有属性都复制到新的对象中:
1 | let source = { |
注意,clone()
是“浅复制”。所谓“浅复制”就是说,两个对象相同的key所引用的value其实是同一对象:
1 | source.skills === copied.skills; // true |
也就是说,修改source.skills
会影响copied.skills
。
isEqual
isEqual()
对两个object进行深度比较,如果内容完全相同,则返回true
:
1 | let o1 = { name: 'Bob', skills: { Java: 90, JavaScript: 99 }}; |
isEqual()
其实对Array
也可以比较:
1 | let o1 = ['Bob', { skills: ['Java', 'JavaScript'] }]; |
更多完整的函数请参考underscore的文档:https://underscorejs.org/#objects
Chaining
还记得jQuery支持链式调用吗?
1 | $('a').attr('target', '_blank') |
如果我们有一组操作,用underscore提供的函数,写出来像这样:
1 | _.filter(_.map([1, 4, 9, 16, 25], Math.sqrt), x => x % 2 === 1); |
能不能写成链式调用?
能!
underscore提供了把对象包装成能进行链式调用的方法,就是chain()
函数:
1 | let r = _.chain([1, 4, 9, 16, 25]) |
因为每一步返回的都是包装对象,所以最后一步的结果需要调用value()
获得最终结果。
小结
通过学习underscore,是不是对JavaScript的函数式编程又有了进一步的认识?
jQuery
你可能听说过jQuery,它名字起得很土,但却是JavaScript世界中使用最广泛的一个库。
江湖传言,全世界大约有80~90%的网站直接或间接地使用了jQuery。鉴于它如此流行,又如此好用,所以每一个入门JavaScript的前端工程师都应该了解和学习它。
jQuery这么流行,肯定是因为它解决了一些很重要的问题。实际上,jQuery能帮我们干这些事情:
- 消除浏览器差异:你不需要自己写冗长的代码来针对不同的浏览器来绑定事件,编写AJAX等代码;
- 简洁的操作DOM的方法:写
$('#test')
肯定比document.getElementById('test')
来得简洁; - 轻松实现动画、修改CSS等各种操作。
jQuery的理念“Write Less, Do More“,让你写更少的代码,完成更多的工作!
jQuery版本
从jQuery官网可以下载最新版本。jQuery只是一个jquery-xxx.js
文件,但你会看到有compressed(已压缩)和uncompressed(未压缩)两种版本,使用时完全一样,但如果你想深入研究jQuery源码,那就用uncompressed版本。
使用jQuery
使用jQuery只需要在页面的<head>
引入jQuery文件即可:
1 | <html> |
好消息是,当你在学习这个教程时,已经引用了jQuery,所以你可以直接使用:
1 | console.log('jQuery版本:' + $.fn.jquery); |
$符号
$
是著名的jQuery符号。实际上,jQuery把所有功能全部封装在一个全局变量jQuery
中,而$
也是一个合法的变量名,它是变量jQuery
的别名:
1 | window.jQuery; // jQuery(selector, context) |
$
本质上就是一个函数,但是函数也是对象,于是$
除了可以直接调用外,也可以有很多其他属性。
注意,你看到的$
函数名可能不是jQuery(selector, context)
,因为很多JavaScript压缩工具可以对函数名和参数改名,所以压缩过的jQuery源码$
函数可能变成a(b,c)
。
绝大多数时候,我们都直接用$
(因为写起来更简单嘛)。但是,如果$
这个变量不幸地被占用了,而且还不能改,那我们就只能让jQuery
把$
变量交出来,然后就只能使用jQuery
这个变量:
1 | $; // jQuery(selector, context) |
这种黑魔法的原理是jQuery在占用$
之前,先在内部保存了原来的$
,调用jQuery.noConflict()
时会把原来保存的变量还原。
选择器是jQuery的核心。一个选择器写出来类似$('#dom-id')
。
为什么jQuery要发明选择器?回顾一下DOM操作中我们经常使用的代码:
1 | // 按ID查找: |
这些代码实在太繁琐了,并且,在层级关系中,例如,查找<table class="green">
里面的所有<tr>
,一层循环实际上是错的,因为<table>
的标准写法是:
1 | <table> |
很多时候,需要递归查找所有子节点。
jQuery的选择器就是帮助我们快速定位到一个或多个DOM节点。
按ID查找
如果某个DOM节点有id
属性,利用jQuery查找如下:
1 | // 查找<div id="abc">: |
注意,#abc
以#
开头。返回的对象是jQuery对象。
什么是jQuery对象?jQuery对象类似数组,它的每个元素都是一个引用了DOM节点的对象。
以上面的查找为例,如果id
为abc
的<div>
存在,返回的jQuery对象如下:
1 | [<div id="abc">...</div>] |
如果id
为abc
的<div>
不存在,返回的jQuery对象如下:
1 | [] |
总之jQuery的选择器不会返回undefined
或者null
,这样的好处是你不必在下一行判断if (div === undefined)
。
jQuery对象和DOM对象之间可以互相转化:
1 | let div = $('#abc'); // jQuery对象 |
通常情况下你不需要获取DOM对象,直接使用jQuery对象更加方便。如果你拿到了一个DOM对象,那可以简单地调用$(aDomObject)
把它变成jQuery对象,这样就可以方便地使用jQuery的API了。
按tag查找
按tag查找只需要写上tag名称就可以了:
1 | let ps = $('p'); // 返回所有<p>节点 |
按class查找
按class查找注意在class名称前加一个.
:
1 | let a = $('.red'); // 所有节点包含`class="red"`都将返回 |
通常很多节点有多个class,我们可以查找同时包含red
和green
的节点:
1 | let a = $('.red.green'); // 注意没有空格! |
按属性查找
一个DOM节点除了id
和class
外还可以有很多属性,很多时候按属性查找会非常方便,比如在一个表单中按属性来查找:
1 | let email = $('[name=email]'); // 找出<??? name="email"> |
当属性的值包含空格等特殊字符时,需要用双引号括起来。
按属性查找还可以使用前缀查找或者后缀查找:
1 | let icons = $('[name^=icon]'); // 找出所有name属性值以icon开头的DOM |
这个方法尤其适合通过class属性查找,且不受class包含多个名称的影响:
1 | let icons = $('[class^="icon-"]'); // 找出所有class包含至少一个以`icon-`开头的DOM |
组合查找
组合查找就是把上述简单选择器组合起来使用。如果我们查找$('[name=email]')
,很可能把表单外的<div name="email">
也找出来,但我们只希望查找<input>
,就可以这么写:
1 | let emailInput = $('input[name=email]'); // 不会找出<div name="email"> |
同样的,根据tag和class来组合查找也很常见:
1 | let tr = $('tr.red'); // 找出<tr class="red ...">...</tr> |
多项选择器
多项选择器就是把多个选择器用,
组合起来一块选:
1 | $('p,div'); // 把<p>和<div>都选出来 |
要注意的是,选出来的元素是按照它们在HTML中出现的顺序排列的,而且不会有重复元素。例如,<p class="red green">
不会被上面的$('p.red,p.green')
选择两次。
练习
使用jQuery选择器分别选出指定元素:
- 仅选择JavaScript
- 仅选择Erlang
- 选择JavaScript和Erlang
- 选择所有编程语言
- 选择名字input
- 选择邮件和名字input
1 | <!-- HTML结构 --> |
JavaScript
Haskell
Erlang
Python
运行查看结果:
1 | let selected = null; |
除了基本的选择器外,jQuery的层级选择器更加灵活,也更强大。
因为DOM的结构就是层级结构,所以我们经常要根据层级关系进行选择。
层级选择器(Descendant Selector)
如果两个DOM元素具有层级关系,就可以用$('ancestor descendant')
来选择,层级之间用空格隔开。例如:
1 | <!-- HTML结构 --> |
要选出JavaScript,可以用层级选择器:
1 | $('ul.lang li.lang-javascript'); // [<li class="lang-javascript">JavaScript</li>] |
因为<div>
和<ul>
都是<li>
的祖先节点,所以上面两种方式都可以选出相应的<li>
节点。
要选择所有的<li>
节点,用:
1 | $('ul.lang li'); |
这种层级选择器相比单个的选择器好处在于,它缩小了选择范围,因为首先要定位父节点,才能选择相应的子节点,这样避免了页面其他不相关的元素。
例如:
1 | $('form[name=upload] input'); |
就把选择范围限定在name
属性为upload
的表单里。如果页面有很多表单,其他表单的<input>
不会被选择。
多层选择也是允许的:
1 | $('form.test p input'); // 在form表单选择被<p>包含的<input> |
子选择器(Child Selector)
子选择器$('parent>child')
类似层级选择器,但是限定了层级关系必须是父子关系,就是<child>
节点必须是<parent>
节点的直属子节点。还是以上面的例子:
1 | $('ul.lang>li.lang-javascript'); // 可以选出[<li class="lang-javascript">JavaScript</li>] |
过滤器(Filter)
过滤器一般不单独使用,它通常附加在选择器上,帮助我们更精确地定位元素。观察过滤器的效果:
1 | $('ul.lang li'); // 选出JavaScript、Python和Lua 3个节点 |
表单相关
针对表单元素,jQuery还有一组特殊的选择器:
:input
:可以选择<input>
,<textarea>
,<select>
和<button>
;:file
:可以选择<input type="file">
,和input[type=file]
一样;:checkbox
:可以选择复选框,和input[type=checkbox]
一样;:radio
:可以选择单选框,和input[type=radio]
一样;:focus
:可以选择当前输入焦点的元素,例如把光标放到一个<input>
上,用$('input:focus')
就可以选出;:checked
:选择当前勾上的单选框和复选框,用这个选择器可以立刻获得用户选择的项目,如$('input[type=radio]:checked')
;:enabled
:可以选择可以正常输入的<input>
、<select>
等,也就是没有灰掉的输入;:disabled
:和:enabled
正好相反,选择那些不能输入的。
此外,jQuery还有很多有用的选择器,例如,选出可见的或隐藏的元素:
1 | $('div:visible'); // 所有可见的div |
练习
针对如下HTML结构:
1 | <!-- HTML结构 --> |
- JavaScript
- Python
- Lua
- Swift
- Java
- C
选出相应的内容并观察效果:
1 | // 分别选择所有语言,所有动态语言,所有静态语言,JavaScript,Lua,C等: |
通常情况下选择器可以直接定位到我们想要的元素,但是,当我们拿到一个jQuery对象后,还可以以这个对象为基准,进行查找和过滤。
最常见的查找是在某个节点的所有子节点中查找,使用find()
方法,它本身又接收一个任意的选择器。例如如下的HTML结构:
1 | <!-- HTML结构 --> |
- JavaScript
- Python
- Swift
- Scheme
- Haskell
用find()
查找:
1 | let ul = $('ul.lang'); // 获得<ul> |
如果要从当前节点开始向上查找,使用parent()
方法:
1 | let swf = $('#swift'); // 获得Swift |
对于位于同一层级的节点,可以通过next()
和prev()
方法,例如:
当我们已经拿到Swift
节点后:
1 | let swift = $('#swift'); |
过滤
和函数式编程的map、filter类似,jQuery对象也有类似的方法。
filter()
方法可以过滤掉不符合选择器条件的节点:
1 | let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell |
或者传入一个函数,要特别注意函数内部的this
被绑定为DOM对象,不是jQuery对象:
1 | let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell |
map()
方法把一个jQuery对象包含的若干DOM节点转化为其他对象:
1 | let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell |
此外,一个jQuery对象如果包含了不止一个DOM节点,first()
、last()
和slice()
方法可以返回一个新的jQuery对象,把不需要的DOM节点去掉:
1 | let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell |
练习
对于下面的表单:
1 | <form id="test-form" action="#0" onsubmit="return false;"> |
输入值后,用jQuery获取表单的JSON字符串,key和value分别对应每个输入的name和相应的value,例如:{"name":"Michael","email":...}
1 | let json = ???; |
jQuery的选择器很强大,用起来又简单又灵活,但是搞了这么久,我拿到了jQuery对象,到底要干什么?
答案当然是操作对应的DOM节点啦!
回顾一下修改DOM的CSS、文本、设置HTML有多么麻烦,而且有的浏览器只有innerHTML,有的浏览器支持innerText,有了jQuery对象,不需要考虑浏览器差异了,全部统一操作!
修改Text和HTML
jQuery对象的text()
和html()
方法分别获取节点的文本和原始HTML文本,例如,如下的HTML结构:
1 | <!-- HTML结构 --> |
分别获取文本和HTML:
1 | $('#test-ul li[name=book]').text(); // 'Java & JavaScript' |
如何设置文本或HTML?jQuery的API设计非常巧妙:无参数调用text()
是获取文本,传入参数就变成设置文本,HTML也是类似操作,自己动手试试:
- JavaScript
- Java & JavaScript
1 | let j1 = $('#test-ul li.js'); |
一个jQuery对象可以包含0个或任意个DOM对象,它的方法实际上会作用在对应的每个DOM节点上。在上面的例子中试试:
1 | $('#test-ul li').text('JS'); // 是不是两个节点都变成了JS? |
所以jQuery对象的另一个好处是我们可以执行一个操作,作用在对应的一组DOM节点上。即使选择器没有返回任何DOM节点,调用jQuery对象的方法仍然不会报错:
1 | // 如果不存在id为not-exist的节点: |
这意味着jQuery帮你免去了许多if
语句。
修改CSS
jQuery对象有“批量操作”的特点,这用于修改CSS实在是太方便了。考虑下面的HTML结构:
1 | <!-- HTML结构 --> |
要高亮显示动态语言,调用jQuery对象的css('name', 'value')
方法,我们用一行语句实现:
- JavaScript
- Java
- Python
- Swift
- Scheme
1 | $('#test-css li.dy>span').css('background-color', '#ff0').css('color', '#c00'); |
注意,jQuery对象的所有方法都返回一个jQuery对象(可能是新的也可能是自身),这样我们可以进行链式调用,非常方便。
jQuery对象的css()
方法可以这么用:
1 | let div = $('#test-div'); |
为了和JavaScript保持一致,CSS属性可以用'background-color'
和'backgroundColor'
两种格式。
css()
方法将作用于DOM节点的style
属性,具有最高优先级。如果要修改class
属性,可以用jQuery提供的下列方法:
1 | let div = $('#test-div'); |
练习:分别用css()
方法和addClass()
方法高亮显示JavaScript:
1 | <!-- HTML结构 --> |
- Python
- JavaScript
- Swift
- Haskell
1 | let div = $('#test-highlight-css'); |
显示和隐藏DOM
要隐藏一个DOM,我们可以设置CSS的display
属性为none
,利用css()
方法就可以实现。不过,要显示这个DOM就需要恢复原有的display
属性,这就得先记下来原有的display
属性到底是block
还是inline
还是别的值。
考虑到显示和隐藏DOM元素使用非常普遍,jQuery直接提供show()
和hide()
方法,我们不用关心它是如何修改display
属性的,总之它能正常工作:
1 | let a = $('a[target=_blank]'); |
注意,隐藏DOM节点并未改变DOM树的结构,它只影响DOM节点的显示。这和删除DOM节点是不同的。
获取DOM信息
利用jQuery对象的若干方法,我们直接可以获取DOM的高宽等信息,而无需针对不同浏览器编写特定代码:
1 | // 浏览器可视窗口大小: |
attr()
和removeAttr()
方法用于操作DOM节点的属性:
1 | // <div id="test-div" name="Test" start="1">...</div> |
prop()
方法和attr()
类似,但是HTML5规定有一种属性在DOM节点中可以没有值,只有出现与不出现两种,例如:
1 | <input id="test-radio" type="radio" name="test" checked value="1"> |
等价于:
1 | <input id="test-radio" type="radio" name="test" checked="checked" value="1"> |
attr()
和prop()
对于属性checked
处理有所不同:
1 | let radio = $('#test-radio'); |
prop()
返回值更合理一些。不过,用is()
方法判断更好:
1 | let radio = $('#test-radio'); |
类似的属性还有selected
,处理时最好用is(':selected')
。
操作表单
对于表单元素,jQuery对象统一提供val()
方法获取和设置对应的value
属性:
1 | /* |
可见,一个val()
就统一了各种输入框的取值和赋值的问题。
直接使用浏览器提供的API对DOM结构进行修改,不但代码复杂,而且要针对浏览器写不同的代码。
有了jQuery,我们就专注于操作jQuery对象本身,底层的DOM操作由jQuery完成就可以了,这样一来,修改DOM也大大简化了。
添加DOM
要添加新的DOM节点,除了通过jQuery的html()
这种暴力方法外,还可以用append()
方法,例如:
1 | <div id="test-div"> |
如何向列表新增一个语言?首先要拿到<ul>
节点:
1 | let ul = $('#test-div>ul'); |
然后,调用append()
传入HTML片段:
1 | ul.append('<li><span>Haskell</span></li>'); |
除了接受字符串,append()
还可以传入原始的DOM对象,jQuery对象和函数对象:
1 | // 创建DOM对象: |
传入函数时,要求返回一个字符串、DOM对象或者jQuery对象。因为jQuery的append()
可能作用于一组DOM节点,只有传入函数才能针对每个DOM生成不同的子节点。
append()
把DOM添加到最后,prepend()
则把DOM添加到最前。
另外注意,如果要添加的DOM节点已经存在于HTML文档中,它会首先从文档移除,然后再添加,也就是说,用append()
,你可以移动一个DOM节点。
如果要把新节点插入到指定位置,例如,JavaScript和Python之间,那么,可以先定位到JavaScript,然后用after()
方法:
1 | let js = $('#test-div>ul>li:first-child'); |
也就是说,同级节点可以用after()
或者before()
方法。
删除节点
要删除DOM节点,拿到jQuery对象后直接调用remove()
方法就可以了。如果jQuery对象包含若干DOM节点,实际上可以一次删除多个DOM节点:
1 | let li = $('#test-div>ul>li'); |
练习
除了列出的3种语言外,请再添加Pascal、Lua和Ruby,然后按字母顺序排序节点:
1 | <!-- HTML结构 --> |
- JavaScript
- Python
- Swift
1 | // TODO: |
因为JavaScript在浏览器中以单线程模式运行,页面加载后,一旦页面上所有的JavaScript代码被执行完后,就只能依赖触发事件来执行JavaScript代码。
浏览器在接收到用户的鼠标或键盘输入后,会自动在对应的DOM节点上触发相应的事件。如果该节点已经绑定了对应的JavaScript处理函数,该函数就会自动调用。
由于不同的浏览器绑定事件的代码都不太一样,所以用jQuery来写代码,就屏蔽了不同浏览器的差异,我们总是编写相同的代码。
举个例子,假设要在用户点击了超链接时弹出提示框,我们用jQuery这样绑定一个click
事件:
1 | /* HTML: |
实测:点我试试
on
方法用来绑定一个事件,我们需要传入事件名称和对应的处理函数。
另一种更简化的写法是直接调用click()
方法:
1 | a.click(function () { |
两者完全等价。我们通常用后面的写法。
jQuery能够绑定的事件主要包括:
鼠标事件
- click: 鼠标单击时触发;
- dblclick:鼠标双击时触发;
- mouseenter:鼠标进入时触发;
- mouseleave:鼠标移出时触发;
- mousemove:鼠标在DOM内部移动时触发;
- hover:鼠标进入和退出时触发两个函数,相当于mouseenter加上mouseleave。
键盘事件
键盘事件仅作用在当前焦点的DOM上,通常是<input>
和<textarea>
。
- keydown:键盘按下时触发;
- keyup:键盘松开时触发;
- keypress:按一次键后触发。
其他事件
- focus:当DOM获得焦点时触发;
- blur:当DOM失去焦点时触发;
- change:当
<input>
、<select>
或<textarea>
的内容改变时触发; - submit:当
<form>
提交时触发; - ready:当页面被载入并且DOM树完成初始化后触发。
其中,ready
仅作用于document
对象。由于ready
事件在DOM完成初始化后触发,且只触发一次,所以非常适合用来写其他的初始化代码。假设我们想给一个<form>
表单绑定submit
事件,下面的代码没有预期的效果:
1 | <html> |
因为JavaScript在此执行的时候,<form>
尚未载入浏览器,所以$('#testForm)
返回[]
,并没有绑定事件到任何DOM上。
所以我们自己的初始化代码必须放到document
对象的ready
事件中,保证DOM已完成初始化:
1 | <html> |
这样写就没有问题了。因为相关代码会在DOM树初始化后再执行。
由于ready
事件使用非常普遍,所以可以这样简化:
1 | $(document).ready(function () { |
甚至还可以再简化为:
1 | $(function () { |
上面的这种写法最为常见。如果你遇到$(function () {...})
的形式,牢记这是document
对象的ready
事件处理函数。
完全可以反复绑定事件处理函数,它们会依次执行:
1 | $(function () { |
事件参数
有些事件,如mousemove
和keypress
,我们需要获取鼠标位置和按键的值,否则监听这些事件就没什么意义了。所有事件都会传入Event
对象作为参数,可以从Event
对象上获取到更多的信息:
1 | $(function () { |
取消绑定
一个已被绑定的事件可以解除绑定,通过off('click', function)
实现:
1 | function hello() { |
需要特别注意的是,下面这种写法是无效的:
1 | // 绑定事件: |
这是因为两个匿名函数虽然长得一模一样,但是它们是两个不同的函数对象,off('click', function () {...})
无法移除已绑定的第一个匿名函数。
为了实现移除效果,可以使用off('click')
一次性移除已绑定的click
事件的所有处理函数。
同理,无参数调用off()
一次性移除已绑定的所有类型的事件处理函数。
事件触发条件
一个需要注意的问题是,事件的触发总是由用户操作引发的。例如,我们监控文本框的内容改动:
1 | let input = $('#test-input'); |
当用户在文本框中输入时,就会触发change
事件。但是,如果用JavaScript代码去改动文本框的值,将不会触发change
事件:
1 | let input = $('#test-input'); |
有些时候,我们希望用代码触发change
事件,可以直接调用无参数的change()
方法来触发该事件:
1 | let input = $('#test-input'); |
input.change()
相当于input.trigger('change')
,它是trigger()
方法的简写。
为什么我们希望手动触发一个事件呢?如果不这么做,很多时候,我们就得写两份一模一样的代码。
练习
对如下的Form表单:
1 | <!-- HTML结构 --> |
绑定合适的事件处理函数,实现以下逻辑:
当用户勾上“全选”时,自动选中所有语言,并把“全选”变成“全不选”;
当用户去掉“全不选”时,自动不选中所有语言;
当用户点击“反选”时,自动把所有语言状态反转(选中的变为未选,未选的变为选中);
当用户把所有语言都手动勾上时,“全选”被自动勾上,并变为“全不选”;
当用户手动去掉选中至少一种语言时,“全不选”自动被去掉选中,并变为“全选”。
1 | let |
用JavaScript实现动画,原理非常简单:我们只需要以固定的时间间隔(例如,0.1秒),每次把DOM元素的CSS样式修改一点(例如,高宽各增加10%),看起来就像动画了。
但是要用JavaScript手动实现动画效果,需要编写非常复杂的代码。如果想要把动画效果用函数封装起来便于复用,那考虑的事情就更多了。
使用jQuery实现动画,代码已经简单得不能再简化了:只需要一行代码!
让我们先来看看jQuery内置的几种动画样式:
show / hide
直接以无参数形式调用show()
和hide()
,会显示和隐藏DOM元素。但是,只要传递一个时间参数进去,就变成了动画:
1 | let div = $('#test-show-hide'); |
时间以毫秒为单位,但也可以是'slow'
,'fast'
这些字符串:
1 | let div = $('#test-show-hide'); |
toggle()
方法则根据当前状态决定是show()
还是hide()
。
效果实测:
slideUp / slideDown
你可能已经看出来了,show()
和hide()
是从左上角逐渐展开或收缩的,而slideUp()
和slideDown()
则是在垂直方向逐渐展开或收缩的。
slideUp()
把一个可见的DOM元素收起来,效果跟拉上窗帘似的,slideDown()
相反,而slideToggle()
则根据元素是否可见来决定下一步动作:
1 | let div = $('#test-slide'); |
效果实测:
fadeIn / fadeOut
fadeIn()
和fadeOut()
的动画效果是淡入淡出,也就是通过不断设置DOM元素的opacity
属性来实现,而fadeToggle()
则根据元素是否可见来决定下一步动作:
1 | let div = $('#test-fade'); |
自定义动画
如果上述动画效果还不能满足你的要求,那就祭出最后大招:animate()
,它可以实现任意动画效果,我们需要传入的参数就是DOM元素最终的CSS状态和时间,jQuery在时间段内不断调整CSS直到达到我们设定的值:
1 | let div = $('#test-animate'); |
animate()
还可以再传入一个函数,当动画结束时,该函数将被调用:
1 | let div = $('#test-animate'); |
实际上这个回调函数参数对于基本动画也是适用的。
有了animate()
,你就可以实现各种自定义动画效果了:
串行动画
jQuery的动画效果还可以串行执行,通过delay()
方法还可以实现暂停,这样,我们可以实现更复杂的动画效果,而代码却相当简单:
1 | let div = $('#test-animates'); |
因为动画需要执行一段时间,所以jQuery必须不断返回新的Promise对象才能后续执行操作。简单地把动画封装在函数中是不够的。
效果实测:
为什么有的动画没有效果
你可能会遇到,有的动画如slideUp()
根本没有效果。这是因为jQuery动画的原理是逐渐改变CSS的值,如height
从100px
逐渐变为0
。但是很多不是block性质的DOM元素,对它们设置height
根本就不起作用,所以动画也就没有效果。
此外,jQuery也没有实现对background-color
的动画效果,用animate()
设置background-color
也没有效果。这种情况下可以使用CSS3的transition
实现动画效果。
练习
在执行删除操作时,给用户显示一个动画比直接调用remove()
要更好。请在表格删除一行的时候添加一个淡出的动画效果:
Name | Address | Status | |
---|---|---|---|
Bart Simpson | bart.s@primary.school | Springfield | Active |
Michael Scofield | m.scofield@escape.org | Fox River | Locked |
Optimus Prime | prime@cybertron.org | Cybertron | Active |
Peter Parker | spider@movie.org | New York | Active |
Thor Odinson | thor@asgard.org | Asgard | Active |
1 | function deleteFirstTR() { |
在执行JavaScript代码的时候,有些情况下会发生错误。
错误分两种,一种是程序写的逻辑不对,导致代码执行异常。例如:
1 | let s = null; |
对于这种错误,要修复程序。
一种是执行过程中,程序可能遇到无法预测的异常情况而报错,例如,网络连接中断,读取不存在的文件,没有操作权限等。
对于这种错误,我们需要处理它,并可能需要给用户反馈。
错误处理是程序设计时必须要考虑的问题。对于C这样贴近系统底层的语言,错误是通过错误码返回的:
1 | int fd = open("/path/to/file", O_RDONLY); |
通过错误码返回错误,就需要约定什么是正确的返回值,什么是错误的返回值。上面的open()
函数约定返回-1
表示错误。
显然,这种用错误码表示错误在编写程序时十分不便。
因此,高级语言通常都提供了更抽象的错误处理逻辑try … catch … finally,JavaScript也不例外。
try … catch … finally
使用try … catch … finally处理错误时,我们编写的代码如下:
1 | let r1, r2, s = null; |
运行后可以发现,输出提示类似“出错了:TypeError: Cannot read property ‘length’ of null”。
我们来分析一下使用try … catch … finally的执行流程。
当代码块被try { ... }
包裹的时候,就表示这部分代码执行过程中可能会发生错误,一旦发生错误,就不再继续执行后续代码,转而跳到catch
块。catch (e) { ... }
包裹的代码就是错误处理代码,变量e
表示捕获到的错误。最后,无论有没有错误,finally
一定会被执行。
所以,有错误发生时,执行流程像这样:
- 先执行
try { ... }
的代码; - 执行到出错的语句时,后续语句不再继续执行,转而执行
catch (e) { ... }
代码; - 最后执行
finally { ... }
代码。
而没有错误发生时,执行流程像这样:
- 先执行
try { ... }
的代码; - 因为没有出错,
catch (e) { ... }
代码不会被执行; - 最后执行
finally { ... }
代码。
最后请注意,catch
和finally
可以不必都出现。也就是说,try
语句一共有三种形式:
完整的try … catch … finally:
1 | try { |
只有try … catch,没有finally:
1 | try { |
只有try … finally,没有catch:
1 | try { |
错误类型
JavaScript有一个标准的Error
对象表示错误,还有从Error
派生的TypeError
、ReferenceError
等错误对象。我们在处理错误时,可以通过catch(e)
捕获的变量e
访问错误对象:
1 | try { |
使用变量e
是一个习惯用法,也可以以其他变量名命名,如catch(ex)
。
抛出错误
程序也可以主动抛出一个错误,让执行流程直接跳转到catch
块。抛出错误使用throw
语句。
例如,下面的代码让用户输入一个数字,程序接收到的实际上是一个字符串,然后用parseInt()
转换为整数。当用户输入不合法的时候,我们就抛出错误:
1 | let r, n, s; |
实际上,JavaScript允许抛出任意对象,包括数字、字符串。但是,最好还是抛出一个Error对象。
最后,当我们用catch捕获错误时,一定要编写错误处理语句:
1 | let n = 0, s; |
哪怕仅仅把错误打印出来,也不要什么也不干:
1 | let n = 0, s; |
因为catch到错误却什么都不执行,就不知道程序执行过程中到底有没有发生错误。
处理错误时,请不要简单粗暴地用alert()
把错误显示给用户。教程的代码使用alert()
是为了便于演示。
错误传播
如果代码发生了错误,又没有被try … catch捕获,那么,程序执行流程会跳转到哪呢?
1 | function getLength(s) { |
如果在一个函数内部发生了错误,它自身没有捕获,错误就会被抛到外层调用函数,如果外层函数也没有捕获,该错误会一直沿着函数调用链向上抛出,直到被JavaScript引擎捕获,代码终止执行。
所以,我们不必在每一个函数内部捕获错误,只需要在合适的地方来个统一捕获,一网打尽:
1 | function main(s) { |
当bar()
函数传入参数null
时,代码会报错,错误会向上抛给调用方foo()
函数,foo()
函数没有try … catch语句,所以错误继续向上抛给调用方main()
函数,main()
函数有try … catch语句,所以错误最终在main()
函数被处理了。
至于在哪些地方捕获错误比较合适,需要视情况而定。
异步错误处理
编写JavaScript代码时,我们要时刻牢记,JavaScript引擎是一个事件驱动的执行引擎,代码总是以单线程执行,而回调函数的执行需要等到下一个满足条件的事件出现后,才会被执行。
例如,setTimeout()
函数可以传入回调函数,并在指定若干毫秒后执行:
1 | function printTime() { |
上面的代码会先打印done
,1秒后才会打印It is time!
。
如果printTime()
函数内部发生了错误,我们试图用try包裹setTimeout()
是无效的:
1 | function printTime() { |
原因就在于调用setTimeout()
函数时,传入的printTime
函数并未立刻执行!紧接着,JavaScript引擎会继续执行console.log('done');
语句,而此时并没有错误发生。直到1秒钟后,执行printTime
函数时才发生错误,但此时除了在printTime
函数内部捕获错误外,外层代码并无法捕获。
所以,涉及到异步代码,无法在调用时捕获,原因就是在捕获的当时,回调函数并未执行。
类似的,当我们处理一个事件时,在绑定事件的代码处,无法捕获事件处理函数的错误。
例如,针对以下的表单:
1 | <form> |
我们用下面的代码给button绑定click事件:
1 | let btn = document.querySelector('#calc'); |
但是,用户输入错误时,处理函数并未捕获到错误。请修复错误处理代码。
JavaScript可以获取浏览器提供的很多对象,并进行操作。
window
window
对象不但充当全局作用域,而且表示浏览器窗口。
window
对象有innerWidth
和innerHeight
属性,可以获取浏览器窗口的内部宽度和高度。内部宽高是指除去菜单栏、工具栏、边框等占位元素后,用于显示网页的净宽高。
兼容性:IE<=8不支持。
1 | // 可以调整浏览器窗口大小试试: |
对应的,还有一个outerWidth
和outerHeight
属性,可以获取浏览器窗口的整个宽高。
navigator
navigator
对象表示浏览器的信息,最常用的属性包括:
- navigator.appName:浏览器名称;
- navigator.appVersion:浏览器版本;
- navigator.language:浏览器设置的语言;
- navigator.platform:操作系统类型;
- navigator.userAgent:浏览器设定的
User-Agent
字符串。
1 | console.log('appName = ' + navigator.appName); |
请注意,navigator
的信息可以很容易地被用户修改,所以JavaScript读取的值不一定是正确的。很多初学者为了针对不同浏览器编写不同的代码,喜欢用if
判断浏览器版本,例如:
1 | let width; |
但这样既可能判断不准确,也很难维护代码。正确的方法是充分利用JavaScript对不存在属性返回undefined
的特性,直接用短路运算符||
计算:
1 | let width = window.innerWidth || document.body.clientWidth; |
screen
screen
对象表示屏幕的信息,常用的属性有:
- screen.width:屏幕宽度,以像素为单位;
- screen.height:屏幕高度,以像素为单位;
- screen.colorDepth:返回颜色位数,如8、16、24。
1 | console.log('Screen size = ' + screen.width + ' x ' + screen.height); |
location
location
对象表示当前页面的URL信息。例如,一个完整的URL:
1 | http://www.example.com:8080/path/index.html?a=1&b=2#TOP |
可以用location.href
获取。要获得URL各个部分的值,可以这么写:
1 | location.protocol; // 'http' |
要加载一个新页面,可以调用location.assign()
。如果要重新加载当前页面,调用location.reload()
方法非常方便。
1 | if (confirm('重新加载当前页' + location.href + '?')) { |
document
document
对象表示当前页面。由于HTML在浏览器中以DOM形式表示为树形结构,document
对象就是整个DOM树的根节点。
document
的title
属性是从HTML文档中的<title>xxx</title>
读取的,但是可以动态改变:
1 | document.title = '努力学习JavaScript!'; |
请观察浏览器窗口标题的变化。
要查找DOM树的某个节点,需要从document
对象开始查找。最常用的查找是根据ID和Tag Name。
我们先准备HTML数据:
1 | <dl id="drink-menu" style="border:solid 1px #ccc;padding:6px;"> |
摩卡
热摩卡咖啡
酸奶
北京老酸奶
果汁
鲜榨苹果汁
用document
对象提供的getElementById()
和getElementsByTagName()
可以按ID获得一个DOM节点和按Tag名称获得一组DOM节点:
1 | let menu = document.getElementById('drink-menu'); |
document
对象还有一个cookie
属性,可以获取当前页面的Cookie。
Cookie是由服务器发送的key-value标示符。因为HTTP协议是无状态的,但是服务器要区分到底是哪个用户发过来的请求,就可以用Cookie来区分。当一个用户成功登录后,服务器发送一个Cookie给浏览器,例如user=ABC123XYZ(加密的字符串)...
,此后,浏览器访问该网站时,会在请求头附上这个Cookie,服务器根据Cookie即可区分出用户。
Cookie还可以存储网站的一些设置,例如,页面显示的语言等等。
JavaScript可以通过document.cookie
读取到当前页面的Cookie:
1 | document.cookie; // 'v=123; remember=true; prefer=zh' |
由于JavaScript能读取到页面的Cookie,而用户的登录信息通常也存在Cookie中,这就造成了巨大的安全隐患,这是因为在HTML页面中引入第三方的JavaScript代码是允许的:
1 | <!-- 当前页面在wwwexample.com --> |
如果引入的第三方的JavaScript中存在恶意代码,则www.foo.com
网站将直接获取到www.example.com
网站的用户登录信息。
为了解决这个问题,服务器在设置Cookie时可以使用httpOnly
,设定了httpOnly
的Cookie将不能被JavaScript读取。这个行为由浏览器实现,主流浏览器均支持httpOnly
选项,IE从IE6 SP1开始支持。
为了确保安全,服务器端在设置Cookie时,应该始终坚持使用httpOnly
。
history
history
对象保存了浏览器的历史记录,JavaScript可以调用history
对象的back()
或forward ()
,相当于用户点击了浏览器的“后退”或“前进”按钮。
这个对象属于历史遗留对象,对于现代Web页面来说,由于大量使用AJAX和页面交互,简单粗暴地调用history.back()
可能会让用户感到非常愤怒。
新手开始设计Web页面时喜欢在登录页登录成功时调用history.back()
,试图回到登录前的页面。这是一种错误的方法。
对使用AJAX动态加载的页面,如果希望页面更新时同时更新history
对象,应当使用history.pushState()
方法:
1 | // when AJAX is done: |
当用户点击“后退”时,浏览器并不会刷新页面,而是触发popstate
事件,可由JavaScript捕获并更新相应的部分页面内容。
参考
常用浏览器对象参考:
操作DOM
由于HTML文档被浏览器解析后就是一棵DOM树,要改变HTML的结构,就需要通过JavaScript来操作DOM。
始终记住DOM是一个树形结构。操作一个DOM节点实际上就是这么几个操作:
- 更新:更新该DOM节点的内容,相当于更新了该DOM节点表示的HTML的内容;
- 遍历:遍历该DOM节点下的子节点,以便进行进一步操作;
- 添加:在该DOM节点下新增一个子节点,相当于动态增加了一个HTML节点;
- 删除:将该节点从HTML中删除,相当于删掉了该DOM节点的内容以及它包含的所有子节点。
在操作一个DOM节点前,我们需要通过各种方式先拿到这个DOM节点。最常用的方法是document.getElementById()
和document.getElementsByTagName()
,以及CSS选择器document.getElementsByClassName()
。
由于ID在HTML文档中是唯一的,所以document.getElementById()
可以直接定位唯一的一个DOM节点。document.getElementsByTagName()
和document.getElementsByClassName()
总是返回一组DOM节点。要精确地选择DOM,可以先定位父节点,再从父节点开始选择,以缩小范围。
例如:
1 | // 返回ID为'test'的节点: |
第二种方法是使用querySelector()
和querySelectorAll()
,需要了解selector语法,然后使用条件来获取节点,更加方便:
1 | // 通过querySelector获取ID为q1的节点: |
注意:低版本的IE<8不支持querySelector
和querySelectorAll
。IE8仅有限支持。
严格地讲,我们这里的DOM节点是指Element
,但是DOM节点实际上是Node
,在HTML中,Node
包括Element
、Comment
、CDATA_SECTION
等很多种,以及根节点Document
类型,但是,绝大多数时候我们只关心Element
,也就是实际控制页面结构的Node
,其他类型的Node
忽略即可。根节点Document
已经自动绑定为全局变量document
。
练习
如下的HTML结构:
1 | <!-- HTML结构 --> |
请选择出指定条件的节点:
1 | // 选择<p>JavaScript</p>: |
更新DOM
拿到一个DOM节点后,我们可以对它进行更新。
可以直接修改节点的文本,方法有两种:
一种是修改innerHTML
属性,这个方式非常强大,不但可以修改一个DOM节点的文本内容,还可以直接通过HTML片段修改DOM节点内部的子树:
1 | // 获取<p id="p-id">...</p> |
用innerHTML
时要注意,是否需要写入HTML。如果写入的字符串是通过网络拿到的,要注意对字符编码来避免XSS攻击。
第二种是修改innerText
或textContent
属性,这样可以自动对字符串进行HTML编码,保证无法设置任何HTML标签:
1 | // 获取<p id="p-id">...</p> |
两者的区别在于读取属性时,innerText
不返回隐藏元素的文本,而textContent
返回所有文本。另外注意IE<9不支持textContent
。
修改CSS也是经常需要的操作。DOM节点的style
属性对应所有的CSS,可以直接获取或设置。因为CSS允许font-size
这样的名称,但它并非JavaScript有效的属性名,所以需要在JavaScript中改写为驼峰式命名fontSize
:
1 | // 获取<p id="p-id">...</p> |
练习
有如下的HTML结构:
1 | <!-- HTML结构 --> |
请尝试获取指定节点并修改:
1 | // 获取<p>javascript</p>节点: |
当我们获得了某个DOM节点,想在这个DOM节点内插入新的DOM,应该如何做?
如果这个DOM节点是空的,例如,<div></div>
,那么,直接使用innerHTML = '<span>child</span>'
就可以修改DOM节点的内容,相当于“插入”了新的DOM节点。
如果这个DOM节点不是空的,那就不能这么做,因为innerHTML
会直接替换掉原来的所有子节点。
有两个办法可以插入新的节点。一个是使用appendChild
,把一个子节点添加到父节点的最后一个子节点。例如:
1 | <!-- HTML结构 --> |
把<p id="js">JavaScript</p>
添加到<div id="list">
的最后一项:
1 | let |
现在,HTML结构变成了这样:
1 | <!-- HTML结构 --> |
因为我们插入的js
节点已经存在于当前的文档树,因此这个节点首先会从原先的位置删除,再插入到新的位置。
更多的时候我们会从零创建一个新的节点,然后插入到指定位置:
1 | let |
这样我们就动态添加了一个新的节点:
1 | <!-- HTML结构 --> |
动态创建一个节点然后添加到DOM树中,可以实现很多功能。举个例子,下面的代码动态创建了一个<style>
节点,然后把它添加到<head>
节点的末尾,这样就动态地给文档添加了新的CSS定义:
1 | let d = document.createElement('style'); |
可以在Chrome的控制台执行上述代码,观察页面样式的变化。
insertBefore
如果我们要把子节点插入到指定的位置怎么办?可以使用parentElement.insertBefore(newElement, referenceElement);
,子节点会插入到referenceElement
之前。
还是以上面的HTML为例,假定我们要把Haskell
插入到Python
之前:
1 | <!-- HTML结构 --> |
可以这么写:
1 | let |
新的HTML结构如下:
1 | <!-- HTML结构 --> |
可见,使用insertBefore
重点是要拿到一个“参考子节点”的引用。很多时候,需要循环一个父节点的所有子节点,可以通过迭代children
属性实现:
1 | let |
练习
对于一个已有的HTML结构:
1 | <!-- HTML结构 --> |
- Scheme
- JavaScript
- Python
- Ruby
- Haskell
按字符串顺序重新排序DOM节点:
1 | // sort list: |
删除DOM
删除一个DOM节点就比插入要容易得多。
要删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild
把自己删掉:
1 | // 拿到待删除节点: |
注意到删除后的节点虽然不在文档树中了,但其实它还在内存中,可以随时再次被添加到别的位置。
当你遍历一个父节点的子节点并进行删除操作时,要注意,children
属性是一个只读属性,并且它在子节点变化时会实时更新。
例如,对于如下HTML结构:
1 | <div id="parent"> |
当我们用如下代码删除子节点时:
1 | let parent = document.getElementById('parent'); |
浏览器报错:parent.children[1]
不是一个有效的节点。原因就在于,当<p>First</p>
节点被删除后,parent.children
的节点数量已经从2变为了1,索引[1]
已经不存在了。
因此,删除多个节点时,要注意children
属性时刻都在变化。
练习
1 | <!-- HTML结构 --> |
- JavaScript
- Swift
- HTML
- ANSI C
- CSS
- DirectX
把与Web开发技术不相关的节点删掉:
1 | // TODO |
用JavaScript操作表单和操作DOM是类似的,因为表单本身也是DOM树。
不过表单的输入框、下拉框等可以接收用户输入,所以用JavaScript来操作表单,可以获得用户输入的内容,或者对一个输入框设置新的内容。
HTML表单的输入控件主要有以下几种:
- 文本框,对应的
<input type="text">
,用于输入文本; - 口令框,对应的
<input type="password">
,用于输入口令; - 单选框,对应的
<input type="radio">
,用于选择一项; - 复选框,对应的
<input type="checkbox">
,用于选择多项; - 下拉框,对应的
<select>
,用于选择一项; - 隐藏文本,对应的
<input type="hidden">
,用户不可见,但表单提交时会把隐藏文本发送到服务器。
获取值
如果我们获得了一个<input>
节点的引用,就可以直接调用value
获得对应的用户输入值:
1 | // <input type="text" id="email"> |
这种方式可以应用于text
、password
、hidden
以及select
。但是,对于单选框和复选框,value
属性返回的永远是HTML预设的值,而我们需要获得的实际是用户是否“勾上了”选项,所以应该用checked
判断:
1 | // <label><input type="radio" name="weekday" id="monday" value="1"> Monday</label> |
设置值
设置值和获取值类似,对于text
、password
、hidden
以及select
,直接设置value
就可以:
1 | // <input type="text" id="email"> |
对于单选框和复选框,设置checked
为true
或false
即可。
HTML5控件
HTML5新增了大量标准控件,常用的包括date
、datetime
、datetime-local
、color
等,它们都使用<input>
标签:
1 | <input type="date" value="2021-12-02"> |
1 | <input type="datetime-local" value="2021-12-02T20:21:12"> |
1 | <input type="color" value="#ff0000"> |
不支持HTML5的浏览器无法识别新的控件,会把它们当做type="text"
来显示。支持HTML5的浏览器将获得格式化的字符串。例如,type="date"
类型的input
的value
将保证是一个有效的YYYY-MM-DD
格式的日期,或者空字符串。
提交表单
最后,JavaScript可以以两种方式来处理表单的提交(AJAX方式在后面章节介绍)。
方式一是通过<form>
元素的submit()
方法提交一个表单,例如,响应一个<button>
的click
事件,在JavaScript代码中提交表单:
1 | <!-- HTML --> |
这种方式的缺点是扰乱了浏览器对form的正常提交。浏览器默认点击<button type="submit">
时提交表单,或者用户在最后一个输入框按回车键。因此,第二种方式是响应<form>
本身的onsubmit
事件,在提交form时作修改:
1 | <!-- HTML --> |
注意要return true
来告诉浏览器继续提交,如果return false
,浏览器将不会继续提交form,这种情况通常对应用户输入有误,提示用户错误信息后终止提交form。
在检查和修改<input>
时,要充分利用<input type="hidden">
来传递数据。
例如,很多登录表单希望用户输入用户名和口令,但是,安全考虑,提交表单时不传输明文口令,而是口令的MD5。普通JavaScript开发人员会直接修改<input>
:
1 | <!-- HTML --> |
这个做法看上去没啥问题,但用户输入了口令提交时,口令框的显示会突然从几个*
变成32个*
(因为MD5有32个字符)。
要想不改变用户的输入,可以利用<input type="hidden">
实现:
1 | <!-- HTML --> |
注意到id
为md5-password
的<input>
标记了name="password"
,而用户输入的id
为input-password
的<input>
没有name
属性。没有name
属性的<input>
的数据不会被提交。
练习
利用JavaScript检查用户注册信息是否正确,在以下情况不满足时报错并阻止提交表单:
- 用户名必须是3-10位英文字母或数字;
- 口令必须是6-20位;
- 两次输入口令必须一致。
1 | <!-- HTML结构 --> |
1 | window.checkRegisterForm = function () { |
在HTML表单中,可以上传文件的唯一控件就是<input type="file">
。
注意:当一个表单包含<input type="file">
时,表单的enctype
必须指定为multipart/form-data
,method
必须指定为post
,浏览器才能正确编码并以multipart/form-data
格式发送表单的数据。
出于安全考虑,浏览器只允许用户点击<input type="file">
来选择本地文件,用JavaScript对<input type="file">
的value
赋值是没有任何效果的。当用户选择了上传某个文件后,JavaScript也无法获得该文件的真实路径:
通常,上传的文件都由后台服务器处理,JavaScript可以在提交表单时对文件扩展名做检查,以便防止用户上传无效格式的文件:
1 | let f = document.getElementById('test-file-upload'); |
File API
由于JavaScript对用户上传的文件操作非常有限,尤其是无法读取文件内容,使得很多需要操作文件的网页不得不用Flash这样的第三方插件来实现。
随着HTML5的普及,新增的File API允许JavaScript读取文件内容,获得更多的文件信息。
HTML5的File API提供了File
和FileReader
两个主要对象,可以获得文件信息并读取文件。
下面的例子演示了如何读取用户选取的图片文件,并在一个<div>
中预览图像:
1 | let |
上面的代码演示了如何通过HTML5的File API读取文件内容。以DataURL的形式读取到的文件是一个字符串,类似于data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...
,常用于设置图像。如果需要服务器端处理,把字符串base64,
后面的字符发送给服务器并用Base64解码就可以得到原始文件的二进制内容。
回调
上面的代码还演示了JavaScript的一个重要的特性就是单线程执行模式。在JavaScript中,浏览器的JavaScript执行引擎在执行JavaScript代码时,总是以单线程模式执行,也就是说,任何时候,JavaScript代码都不可能同时有多于1个线程在执行。
你可能会问,单线程模式执行的JavaScript,如何处理多任务?
在JavaScript中,执行多任务实际上都是异步调用,比如上面的代码:
1 | reader.readAsDataURL(file); |
就会发起一个异步操作来读取文件内容。因为是异步操作,所以我们在JavaScript代码中就不知道什么时候操作结束,因此需要先设置一个回调函数:
1 | reader.onload = function(e) { |
当文件读取完成后,JavaScript引擎将自动调用我们设置的回调函数。执行回调函数时,文件已经读取完毕,所以我们可以在回调函数内部安全地获得文件内容。
AJAX不是JavaScript的规范,它只是一个哥们“发明”的缩写:Asynchronous JavaScript and XML,意思就是用JavaScript执行异步网络请求。
如果仔细观察一个Form的提交,你就会发现,一旦用户点击“Submit”按钮,表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。如果不幸由于网络太慢或者其他原因,就会得到一个404页面。
这就是Web的运作原理:一次HTTP请求对应一个页面。
如果要让用户留在当前页面中,同时发出新的HTTP请求,就必须用JavaScript发送这个新请求,接收到数据后,再用JavaScript更新页面,这样一来,用户就感觉自己仍然停留在当前页面,但是数据却可以不断地更新。
最早大规模使用AJAX的就是Gmail,Gmail的页面在首次加载后,剩下的所有数据都依赖于AJAX来更新。
用JavaScript写一个完整的AJAX代码并不复杂,但是需要注意:AJAX请求是异步执行的,也就是说,要通过回调函数获得响应。
在现代浏览器上写AJAX主要依靠XMLHttpRequest
对象,如果不考虑早期浏览器的兼容性问题,现代浏览器还提供了原生支持的Fetch API,以Promise方式提供。使用Fetch API发送HTTP请求代码如下:
1 | async function get(url) { |
使用Fetch API配合async写法,代码更加简单。
Fetch API的详细用法可以参考MDN文档。
安全限制
上面代码的URL使用的是相对路径。如果你把它改为'https://www.sina.com.cn/'
,再运行,肯定报错。在Chrome的控制台里,还可以看到错误信息。
这是因为浏览器的同源策略导致的。默认情况下,JavaScript在发送AJAX请求时,URL的域名必须和当前页面完全一致。
完全一致的意思是,域名要相同(www.example.com
和example.com
不同),协议要相同(http
和https
不同),端口号要相同(http默认是:80
端口,它和:8080
就不同)。有的浏览器口子松一点,允许端口不同,大多数浏览器都会严格遵守这个限制。
那是不是用JavaScript无法请求外域(就是其他网站)的URL了呢?方法还是有的,大概有这么几种:
一是通过Flash插件发送HTTP请求,这种方式可以绕过浏览器的安全限制,但必须安装Flash,并且跟Flash交互。不过Flash用起来麻烦,而且现在用得也越来越少了。
二是通过在同源域名下架设一个代理服务器来转发,JavaScript负责把请求发送到代理服务器:
1 | '/proxy?url=https://www.sina.com.cn' |
代理服务器再把结果返回,这样就遵守了浏览器的同源策略。这种方式麻烦之处在于需要服务器端额外做开发。
第三种方式称为JSONP,它有个限制,只能用GET请求,并且要求返回JavaScript。这种方式跨域实际上是利用了浏览器允许跨域引用JavaScript资源:
1 | <html> |
JSONP通常以函数调用的形式返回,例如,返回JavaScript内容如下:
1 | foo('data'); |
这样一来,我们如果在页面中先准备好foo()
函数,然后给页面动态加一个<script>
节点,相当于动态读取外域的JavaScript资源,最后就等着接收回调了。
CORS
如果浏览器支持HTML5,那么就可以一劳永逸地使用新的跨域策略:CORS了。
CORS全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。
了解CORS前,我们先搞明白概念:
Origin表示本域,也就是浏览器当前页面的域。当JavaScript向外域(如sina.com)发起请求后,浏览器收到响应后,首先检查Access-Control-Allow-Origin
是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript将无法获取到响应的任何数据。
用一个图来表示就是:
1 | GET /res/abc.data |
假设本域是my.com
,外域是sina.com
,只要响应头Access-Control-Allow-Origin
为http://my.com
,或者是*
,本次请求就可以成功。
可见,跨域能否成功,取决于对方服务器是否愿意给你设置一个正确的Access-Control-Allow-Origin
,决定权始终在对方手中。
上面这种跨域请求,称之为“简单请求”。简单请求包括GET、HEAD和POST(POST的Content-Type类型 仅限application/x-www-form-urlencoded
、multipart/form-data
和text/plain
),并且不能出现任何自定义头(例如,X-Custom: 12345
),通常能满足90%的需求。
无论你是否需要用JavaScript通过CORS跨域请求资源,你都要了解CORS的原理。最新的浏览器全面支持HTML5。在引用外域资源时,除了JavaScript和CSS外,都要验证CORS。例如,当你引用了某个第三方CDN上的字体文件时:
1 | /* CSS */ |
如果该CDN服务商未正确设置Access-Control-Allow-Origin
,那么浏览器无法加载字体资源。
对于PUT、DELETE以及其他类型如application/json
的POST请求,在发送AJAX请求之前,浏览器会先发送一个OPTIONS
请求(称为preflighted请求)到这个URL上,询问目标服务器是否接受:
1 | OPTIONS /path/to/resource HTTP/1.1 |
服务器必须响应并明确指出允许的Method:
1 | HTTP/1.1 200 OK |
浏览器确认服务器响应的Access-Control-Allow-Methods
头确实包含将要发送的AJAX请求的Method,才会继续发送AJAX,否则,抛出一个错误。
由于以POST
、PUT
方式传送JSON格式的数据在REST中很常见,所以要跨域正确处理POST
和PUT
请求,服务器端必须正确响应OPTIONS
请求。
在JavaScript的世界中,所有代码都是单线程执行的。
由于这个“缺陷”,导致JavaScript的所有网络操作,浏览器事件,都必须是异步执行。异步执行可以用回调函数实现:
1 | function callback() { |
观察上述代码执行,在Chrome的控制台输出可以看到:
1 | before setTimeout() |
可见,异步操作会在将来的某个时间点触发一个函数调用。
AJAX就是典型的异步操作:
1 | request.onreadystatechange = function () { |
把回调函数success(request.responseText)
和fail(request.status)
写到一个AJAX操作里很正常,但是不好看,而且不利于代码复用。
有没有更好的写法?比如写成这样:
1 | let ajax = ajaxGet('http://...'); |
这种链式写法的好处在于,先统一执行AJAX逻辑,不关心如何处理结果,然后,根据结果是成功还是失败,在将来的某个时候调用success
函数或fail
函数。
古人云:“君子一诺千金”,这种“承诺将来会执行”的对象在JavaScript中称为Promise对象。
Promise有各种开源实现,在ES6中被统一规范,由浏览器直接支持。先测试一下你的浏览器是否支持Promise:
1 | new Promise(function () {}); |
我们先看一个最简单的Promise例子:生成一个0-2之间的随机数,如果小于1,则等待一段时间后返回成功,否则返回失败:
1 | function test(resolve, reject) { |
这个test()
函数有两个参数,这两个参数都是函数,如果执行成功,我们将调用resolve('200 OK')
,如果执行失败,我们将调用reject('timeout in ' + timeOut + ' seconds.')
。可以看出,test()
函数只关心自身的逻辑,并不关心具体的resolve
和reject
将如何处理结果。
有了执行函数,我们就可以用一个Promise对象来执行它,并在将来某个时刻获得成功或失败的结果:
1 | let p1 = new Promise(test); |
变量p1
是一个Promise对象,它负责执行test
函数。由于test
函数在内部是异步执行的,当test
函数执行成功时,我们告诉Promise对象:
1 | // 如果成功,执行这个函数: |
当test
函数执行失败时,我们告诉Promise对象:
1 | p2.catch(function (reason) { |
Promise对象可以串联起来,所以上述代码可以简化为:
1 | new Promise(test).then(function (result) { |
实际测试一下,看看Promise是如何异步执行的:
1 | // 清除log: |
Log:
可见Promise最大的好处是在异步执行的流程中,把执行代码和处理结果的代码清晰地分离了:
1 | ┌──────────────────┐ |
Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。
要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。有了Promise,我们只需要简单地写:
1 | job1.then(job2).then(job3).catch(handleError); |
其中,job1
、job2
和job3
都是Promise对象。
下面的例子演示了如何串行执行一系列需要异步计算获得结果的任务:
1 | let logging = document.getElementById('test-promise2-log'); |
Log:
setTimeout
可以看成一个模拟网络等异步执行的函数。
除了串行执行若干异步任务外,Promise还可以并行执行异步任务。
试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()
实现如下:
1 | let p1 = new Promise(function (resolve, reject) { |
有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()
实现:
1 | let p1 = new Promise(function (resolve, reject) { |
由于p1
执行较快,Promise的then()
将获得结果'P1'
。p2
仍在继续执行,但执行结果将被丢弃。
如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。
我们说JavaScript异步操作需要通过Promise实现,一个Promise对象在操作网络时是异步的,等到返回后再调用回调函数,执行正确就调用then()
,执行错误就调用catch()
,虽然异步实现了,不会让用户感觉到页面“卡住”了,但是一堆then()
、catch()
写起来麻烦看起来也乱。
有没有更简单的写法?
可以用关键字async
配合await
调用Promise,实现异步操作,但代码却和同步写法类似:
1 | async function get(url) { |
使用async function
可以定义一个异步函数,异步函数和Promise可以看作是等价的,在async function内部,用await
调用另一个异步函数,写起来和同步代码没啥区别,但执行起来是异步的。
也就是说:
1 | let resp = await fetch(url); |
自动实现了异步调用,它和下面的Promise代码等价:
1 | let promise = fetch(url); |
如果我们要实现catch()
怎么办?用Promise的写法如下:
1 | let promise = fetch(url); |
用await
调用时,直接用传统的try { ... } catch
:
1 | async function get(url) { |
用async定义异步函数,用await调用异步函数,写起来和同步代码差不多,但可读性大大提高。
需要特别注意的是,await
调用必须在async function中,不能在传统的同步代码中调用。那么问题来了,一个同步function怎么调用async function呢?
首先,普通function直接用await调用异步函数将报错:
1 | async function get(url) { |
如果把await
去掉,调用实际上发生了,但我们拿不到结果,因为我们拿到的并不是异步结果,而是一个Promise对象:
1 | async function get(url) { |
因此,在普通function中调用async function,不能使用await,但可以直接调用async function拿到Promise对象,后面加上then()
和catch()
就可以拿到结果或错误了:
1 | async function get(url) { |
因此,定义异步任务时,使用async function比Promise简单,调用异步任务时,使用await比Promise简单,捕获错误时,按传统的try...catch
写法,也比Promise简单。只要浏览器支持,完全可以用async
简洁地实现异步操作。
Canvas是HTML5新增的组件,它就像一块幕布,可以用JavaScript在上面绘制各种图表、动画等。
没有Canvas的年代,绘图只能借助Flash插件实现,页面不得不用JavaScript和Flash进行交互。有了Canvas,我们就再也不需要Flash了,直接使用JavaScript完成绘制。
一个Canvas定义了一个指定尺寸的矩形框,在这个范围内我们可以随意绘制:
1 | <canvas id="test-canvas" width="300" height="200"></canvas> |
由于浏览器对HTML5标准支持不一致,所以,通常在<canvas>
内部添加一些说明性HTML代码,如果浏览器支持Canvas,它将忽略<canvas>
内部的HTML,如果浏览器不支持Canvas,它将显示<canvas>
内部的HTML。在使用Canvas前,用canvas.getContext
来测试浏览器是否支持Canvas:
1 | <!-- HTML代码 --> |
你的浏览器不支持Canvas
1 | let canvas = document.getElementById('test-canvas'); |
getContext('2d')
方法让我们拿到一个CanvasRenderingContext2D
对象,所有的绘图操作都需要通过这个对象完成。
1 | let ctx = canvas.getContext('2d'); |
如果需要绘制3D怎么办?HTML5还有一个WebGL规范,允许在Canvas中绘制3D图形:
1 | gl = canvas.getContext("webgl"); |
本节我们只专注于绘制2D图形。
绘制形状
我们可以在Canvas上绘制各种形状。在绘制前,我们需要先了解一下Canvas的坐标系统:
1 | 0,0 20 |
Canvas的坐标以左上角为原点,水平向右为X轴,垂直向下为Y轴,以像素为单位,所以每个点都是非负整数。
CanvasRenderingContext2D
对象有若干方法来绘制图形:
1 | let |
绘制文本
绘制文本就是在指定的位置输出文本,可以设置文本的字体、样式、阴影等,与CSS完全一致:
1 | let |
Canvas除了能绘制基本的形状和文本,还可以实现动画、缩放、各种滤镜和像素转换等高级操作。如果要实现非常复杂的操作,考虑以下优化方案:
- 通过创建一个不可见的Canvas来绘图,然后将最终绘制结果复制到页面的可见Canvas中;
- 尽量使用整数坐标而不是浮点数;
- 可以创建多个重叠的Canvas绘制不同的层,而不是在一个Canvas中绘制非常复杂的图;
- 背景图片如果不变可以直接用
<img>
标签并放到最底层。
练习
请根据天气API返回的JSON数据在Canvas上绘制未来天气预报,如下图所示:
1 | let data = [ |