--- url: /index.md --- # Yumengjh's Wiki ### 👋 你好,欢迎来到 Yumengjh's Wiki 这里是我的「个人知识沉淀与成长自留地」,一份持续迭代的数字笔记与开发手册。 建立这个知识库的初衷很简单:**对抗遗忘,沉淀自我**,也希望能为偶然路过的你提供一点微小的帮助。 你可以通过顶部的搜索框快速定位内容,也可以借助 Algolia AI 用自然语言问答的方式,直接获取知识的总结与指引——就像和一个熟悉我知识库的助手对话一样便捷。 *** ### 📚 关于这里的内容 * 后端开发:Nest.js 全栈实践、Node.js 项目搭建、TypeORM/GraphQL 集成 * 前端技术:Vue3/React/Svelte 开发、Tailwind/Unocss 样式、Vite 构建配置 * 容器与运维:Docker/Compose 编排、Nginx 部署、服务器配置与运维 * 数据存储:PostgreSQL/Supabase 应用、文档存储与版本控制 * 工具效率:Git/Linux 用法、VS Code 技巧、AI 提示词设计 * 编程基础:JS/TS 、模块化开发、类型体操 * 网络安全:Web 认证、动态密钥、网络分层模型 ### 🔗 更多链接 * 知识库:[blog.yumgjs.com](https://blog.yumgjs.com/) * 导航站:[yumgjs.com/site](https://yumgjs.com/site) * 联系: --- --- url: /docker/index.md --- # README **Docker** 是一个开源的 **容器化平台**,它可以将应用及其依赖打包到一个轻量、可移植的容器中,从而保证应用在任何环境下都能一致运行。 核心思想:**“一次构建,到处运行”**。 * 官网口号:*“Build, Ship, and Run Any App, Anywhere.”* * 容器(Container)是轻量级虚拟化技术,但比传统虚拟机更高效。 **优势** 1. **轻量化**:启动快,占用资源少 2. **可移植**:打包应用及依赖,环境一致 3. **隔离性**:不同容器互不影响 4. **易扩展**:方便与 Kubernetes 等编排工具集成 **应用场景** * 本地开发环境统一(开发者不再 “在我机子上能跑”) * 持续集成/持续部署(CI/CD) * 微服务架构部署 * 快速搭建测试环境 --- --- url: /docker/2brzyehy/index.md --- ## 简介 Docker Compose 是 Docker 官方的开源项目,用于快速编排和管理多个 Docker 容器,其功能类似于 OpenStack 中的 Heat。 * **定位**:定义和运行多容器 Docker 应用。 * **前身**:源于开源项目 Fig。 * **源码地址**:https://github.com/docker/compose **解决的核心问题** 虽然单个容器可以通过 `Dockerfile` 来定义,但在实际项目中(例如一个 Web 应用),通常需要多个容器(如 Web 服务器、数据库、负载均衡器等)协同工作。Docker Compose 正是为了简化这种多容器应用的定义和管理而生。 **核心概念** 1. **服务**:代表一个应用的容器实例,基于同一个镜像可以运行多个容器实例。 2. **项目**:由一组关联的应用容器构成的完整业务单元,通过 `docker-compose.yml` 文件定义。 **工作方式与优势** 用户通过一个单一的 `docker-compose.yml` 配置文件(YAML 格式),即可定义整个应用栈的所有服务。Compose 通过 Docker API 来管理这些容器的生命周期,其默认管理对象是“项目”,提供了一套简单的子命令来统一管理项目中的所有容器。 **技术实现** Compose 由 Python 编写,通过调用 Docker 服务提供的 API 实现对容器的管理。因此,任何支持 Docker API 的平台,都可以使用 Compose 进行编排。 ### 核心总结 可以将 Docker Compose 的核心价值总结为以下几点: 1. **“一键式”多容器应用管理**:通过一个命令(如 `docker-compose up`)就能启动、停止或重建整个由多个服务组成的应用环境。 2. **声明式配置**:使用易于理解和编写的 `YAML` 文件来定义复杂的多容器架构,配置即代码,易于版本控制和共享。 3. **服务编排**:自动化处理容器间的依赖关系、网络连接和数据卷挂载,解决了手动管理多个关联容器的繁琐问题。 4. **提高开发效率**:极大简化了本地开发、测试和 CI/CD 流程中多容器环境的搭建与管理,实现了环境的一致性。 简而言之,**Docker Compose 是一个用于定义和运行多容器 Docker 应用的自动化工具,它通过一个配置文件和一套简单命令,将用户从繁琐的容器联动配置工作中解放出来。** ### 区别 | 特性 | Docker Compose | Docker Swarm | Kubernetes (k8s) | | ------------ | -------------------- | ---------------------------------------- | ---------------------------------- | | **核心定位** | **单机编排** | **内置集群** | **企业级容器编排** | | **适用场景** | 开发、测试、单机部署 | 中小型生产集群 | 大规模、高可用的生产环境 | | **节点管理** | 仅限单台机器 | 多台机器组成**集群** | 强大的多集群、联邦集群管理 | | **高可用** | 无 | **有**(服务副本、故障转移) | **企业级高可用**(自愈、滚动更新) | | **伸缩性** | 手动修改配置重启 | 命令式动态伸缩(`docker service scale`) | 声明式自动伸缩(HPA) | | **网络** | 自定义桥接网络 | **Overlay网络**(跨主机通信) | 更复杂的CNI网络插件 | | **服务发现** | 通过容器名 | 内置Swarm DNS | 强大的Service和Ingress机制 | | **学习曲线** | **低** | **中** | **高** | ### Compose vs Swarm **本质区别:编排维度** * **Compose**:是一个**定义和运行**的工具。它关注的是“如何把一组容器正确地启动并连接起来”。它的核心是 `docker-compose.yml` 文件。 * **Swarm**:是一个**调度和管理**的平台。它关注的是“如何把我的服务部署到一个由多台机器组成的集群中,并保证它一直运行”。它的核心是 `docker service` 命令。 **一个绝妙的组合:Compose + Swarm** 实际上,Compose 和 Swarm 可以很好地协同工作。您可以用 `docker-compose.yml` 文件来定义 Swarm 中的**堆栈**。 ```bash # 使用 Compose 文件在 Swarm 集群中部署一个“堆栈” docker stack deploy -c docker-compose.yml my_app_stack ``` 这就像是: * **Compose 文件是蓝图**(定义了服务的架构)。 * **Swarm 是施工队和物业管理系统**(负责在集群的各个机器上创建并维护服务状态)。 ### 一个简单的场景对比 假设我们要部署一个包含 Web 应用和数据库的服务。 **使用 Docker Compose:** * 在 `docker-compose.yml` 中定义 `web` 和 `db` 服务。 * 运行 `docker-compose up`。 * **结果**:在当前机器上启动了 1 个 `web` 容器和 1 个 `db` 容器。这台机器挂了,整个服务就挂了。 **使用 Docker Swarm:** * 将三台机器组建成一个 Swarm 集群。 * 运行 `docker service create --name web --replicas 3 my-web-app`。 * **结果**:Swarm 调度器会在集群中的 1-3 台机器上运行 3 个 `web` 容器实例。如果一台机器宕机,上面的实例会自动在别的健康节点上重建。 **使用 Kubernetes:** * 编写一个包含 `Deployment` 和 `Service` 的 YAML 文件。 * 使用 `kubectl apply` 部署。 * **结果**:Kubernetes 不仅能在节点故障时自愈,还能根据 CPU 负载自动扩容缩容,并提供精细的流量管理、安全策略和存储配置。 **总结** * **Docker Compose** 是 **“单机上的多容器一键启动和串联工具”**。它完美解决了开发和测试环境中多容器应用的依赖和配置问题,简单直接。 * **Docker Swarm** 是 **“轻量级集群管理工具”**,提供了基础的生产级别高可用和伸缩能力。 * **Kubernetes** 是 **“全面的容器编排平台”**,功能最强大,但也最复杂,适用于大规模、要求苛刻的生产环境。 对于绝大多数个人项目和小型应用,**Docker Compose 已经足够强大和实用**。当您需要将服务部署到多台服务器并确保其高可用时,才需要考虑 Swarm 或 Kubernetes。 ## Compose V2 | 特性维度 | Docker Compose V1 (Python) | Docker Compose V2 (Go) | | ------------ | --------------------------- | ----------------------------------------------- | | **开发语言** | Python | Go | | **安装方式** | 主要通过 `pip` 安装 | 作为 Docker CLI **插件**,内置于 Docker Desktop | | **命令形式** | `docker-compose` (带短横线) | `docker compose` (空格,无短横线) | | **当前状态** | **已不再受官方支持** | **当前主流和推荐版本** | ### 版本更迭的缘由 Docker 官方团队做出这一重大变更,主要出于以下几方面的考量: * **提升性能与效率**:Go 语言编译后生成的是**静态二进制文件**,部署和运行更加高效,无需像 Python 版本那样依赖解释器和一系列库文件。这在启动速度和资源消耗上带来了积极影响。 * **增强可维护性**:Docker 生态系统的其他核心组件(如 Docker Engine 和 Docker CLI)主要也是用 Go 语言编写的。将 Compose 也改用 Go,有利于**统一技术栈**,降低开发和维护的复杂度,促进不同组件间的集成。 * **改善交付与部署**:作为静态二进制文件的 Go 版本 Compose,可以更方便地打包和分发。这尤其体现在它与 **Docker Desktop** 的深度集成上,安装 Docker 时就会自动附带 Compose 插件,大大简化了用户的安装配置步骤。 ### 建议 * **拥抱新版本**:对于新项目,请直接使用 **Docker Compose V2** (Go 版本)。它不仅是未来,也带来了更好的性能和体验。 * **注意命令变化**:最直观的变化就是从原来的命令 `docker-compose` 切换到了现在的 `docker compose`(去掉了中间的短横线)。如果您在脚本或文档中大量使用了旧命令,需要花些时间更新它们。 * **检查现有环境**:您可以通过在终端运行 `docker compose version` 来检查当前使用的版本。如果您的环境已经更新,这个命令会输出 Go 版本的 Docker Compose 信息。 总的来说,这次语言转向是 Docker 官方为了提升工具性能和维护性的一次战略性重构,目前的 Go 版本已经成为绝对的主流。 ## 核心命令 ```bash docker compose up -d # 启动所有服务 docker compose down # 停止并删除容器 docker compose ps # 查看服务状态 docker compose logs # 查看日志 ``` ## 安装与卸载 ### 官方二进制包(通用) #### 下载可执行文件 **官方地址**:https://github.com/docker/compose/releases/latest 以 Linux x86\_64 为例: ```bash sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" \ -o /usr/local/bin/docker-compose ``` #### 授权执行权限 ```bash sudo chmod +x /usr/local/bin/docker-compose ``` #### 验证安装 ```bash docker-compose version ``` ### 作为 Docker 插件(推荐) 从 Docker 20.10+ 开始,Compose v2 已集成为官方插件。 #### 一键安装(Linux) ```bash mkdir -p ~/.docker/cli-plugins/ curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) \ -o ~/.docker/cli-plugins/docker-compose chmod +x ~/.docker/cli-plugins/docker-compose ``` 验证: ```bash docker compose version ``` ### 使用包管理器 #### Ubuntu / Debian ```bash sudo apt-get update sudo apt-get install docker-compose-plugin ``` 验证: ```bash docker compose version ``` ### macOS(使用 Docker Desktop) * 直接安装 **Docker Desktop for Mac** 即可,Compose v2 已自带。 * 也可用 **Homebrew**: ``` brew install docker-compose ``` ### Windows * 安装 **Docker Desktop for Windows** 即可。 * Compose 插件自动包含在 Docker CLI 中。 * 验证: ``` docker compose version ``` ### 卸载方法 ```bash sudo rm /usr/local/bin/docker-compose # 或者 rm ~/.docker/cli-plugins/docker-compose ``` ## 使用 ### 术语 在使用 **Docker Compose** 时,有两个核心概念需要了解: * **服务(Service)**:指一个应用容器的定义,可以包含多个运行相同镜像的实例,用于实现负载均衡或水平扩展。 * **项目(Project)**:由一组相互关联的服务(容器)组成的完整业务单元,通常对应一个应用系统或开发环境。 换句话说,一个 **项目** 由多个 **服务** 组成,而每个服务又可能包含多个容器实例。 **Docker Compose** 正是以「项目」为管理单位,对其所有服务进行统一的构建、启动、停止和维护。 ### 场景 场景示例:使用 Docker Compose 构建一个简单的 Node.js + Redis Web 项目 最常见的项目类型是 **Web 网站**,通常由多个服务组成,例如: * **Web 应用服务**:负责业务逻辑与 HTTP 响应。 * **缓存服务(Redis)**:用于存储计数或共享数据。 接下来,我们使用 **Node.js + Express + Redis** 构建一个能够统计页面访问次数的简单网站。 #### 创建项目目录 ```bash mkdir node-redis-demo cd node-redis-demo ``` #### 编写 Web 应用(app.js) 在项目目录中新建 `app.js` 文件,内容如下: ```bash import express from 'express'; import { createClient } from 'redis'; const app = express(); // 创建 Redis 客户端(Compose 会自动通过服务名解析) const redis = createClient({ url: 'redis://redis:6379' }); redis.on('error', err => console.error('Redis 连接错误:', err)); await redis.connect(); app.get('/', async (req, res) => { const count = await redis.incr('hits'); res.send(`Hello World! 该页面已被访问 ${count} 次。\n`); }); app.listen(3000, () => { console.log('服务已启动:http://localhost:3000'); }); ``` 说明: * `express` 提供 HTTP 服务; * `redis` 用于存储访问次数; * `host` 填写 Compose 服务名 `redis`,Compose 会自动进行服务发现。 #### 创建 package.json 在项目目录中创建 `package.json` 文件: ```json { "name": "node-redis-demo", "version": "1.0.0", "type": "module", "scripts": { "start": "node app.js" }, "dependencies": { "express": "^4.19.2", "redis": "^4.6.14" } } ``` #### 编写 Dockerfile 在项目目录下创建 `Dockerfile` 文件: ```dockerfile # 使用官方 Node.js 轻量镜像 FROM node:20-alpine # 设置工作目录 WORKDIR /app # 复制项目文件 COPY package*.json ./ RUN npm install --production COPY . . # 启动应用 CMD ["npm", "start"] ``` #### 创建 docker-compose.yml 在项目根目录下创建 `docker-compose.yml` 文件: ```yaml version: '3' services: web: build: . ports: - "3000:3000" depends_on: - redis redis: image: "redis:alpine" ``` 说明: * `web` 服务:使用当前目录的 Dockerfile 构建 Node 应用; * `redis` 服务:直接使用官方 Redis 镜像; * `depends_on`:保证 Redis 在 Web 服务启动前被创建。 #### 启动项目 执行以下命令: ```bash docker-compose up ``` 启动后访问浏览器: ```http http://localhost:3000 ``` 每刷新一次页面,访问计数就会自动加 1。 #### 总结 | 文件 | 作用 | | -------------------- | ---------------------------- | | `app.js` | Node.js Web 应用逻辑 | | `package.json` | 依赖与启动脚本定义 | | `Dockerfile` | 定义 Web 服务镜像构建规则 | | `docker-compose.yml` | 定义项目的服务结构与依赖关系 | **总结:** * Compose 以「项目」为管理单位; * 项目由多个「服务」组成; * 服务之间通过服务名自动网络互联; * 一条命令即可完成项目的构建与运行。 ## 命令说明 ### 命令对象与格式 在使用 **Docker Compose** 时,绝大多数命令的作用对象既可以是整个 **项目(Project)**,也可以针对其中的某个 **服务(Service)** 或具体 **容器(Container)**。 如果命令中未指定具体的服务或容器,Compose 会默认将操作作用于整个项目,即所有服务都会受到该命令的影响。 例如,`docker-compose up` 会启动项目中定义的全部服务,而不仅仅是单个容器。 要查看某个命令的详细用法,可以使用以下任一方式: ```bash docker-compose [COMMAND] --help ``` 或 ```bash docker-compose help [COMMAND] ``` 这两种形式等价,都会输出该命令的语法、参数和使用示例。 Docker Compose 命令的通用格式如下: ```bash docker-compose [-f ...] [options] [COMMAND] [ARGS...] ``` 参数说明: | 参数 | 说明 | | ----------- | ------------------------------------------------------------ | | `-f ` | 指定要使用的 Compose 文件(默认是 `docker-compose.yml`)。可以指定多个。 | | `[options]` | 命令级别的全局选项,如 `--project-name`、`--env-file` 等。 | | `[COMMAND]` | 具体要执行的操作,如 `up`、`down`、`logs`、`build` 等。 | | `[ARGS...]` | 针对某个命令的附加参数,例如指定服务名。 | **总结** * Compose 命令可作用于 **项目 / 服务 / 容器** 三个层级; * **未指定对象时默认作用于整个项目**; * 通过 `--help` 可以查看每个命令的详细说明; * 命令格式统一、简洁,方便组合使用。 ### 命令选项 * `-f, --file FILE` 指定使用的 Compose 模板文件,默认为 `docker-compose.yml`,可以多次指定。 * `-p, --project-name NAME` 指定项目名称,默认将使用所在目录名称作为项目名。 * `--verbose` 输出更多调试信息。 * `-v, --version` 打印版本并退出。 ### 命令使用说明 **Docker Compose 是一个全栈应用管理工具,它不仅能通过 `docker compose up` 一键拉起多个容器组合成完整业务,还能通过 `docker compose build` 一键构建业务所需的所有自定义镜像。它将镜像构建配置和运行时配置统一管理,实现了从开发、构建到部署的完整生命周期标准化,让多服务应用的编排变得高效而统一**。 #### build 格式为 `docker-compose build [options] [SERVICE...]`。 构建(重新构建)项目中的服务容器。 服务容器一旦构建后,将会带上一个标记名,例如对于 web 项目中的一个 db 容器,可能是 web\_db。 可以随时在项目目录下运行 `docker-compose build` 来重新构建服务。 选项包括: * `--force-rm` 删除构建过程中的临时容器。 * `--no-cache` 构建镜像过程中不使用 cache(这将加长构建过程)。 * `--pull` 始终尝试通过 pull 来获取更新版本的镜像。 #### config 验证 Compose 文件格式是否正确,若正确则显示配置,若格式错误显示错误原因。 #### down 此命令将会停止 `up` 命令所启动的容器,并移除网络。 #### exec 进入指定的容器。 #### help 获得一个命令的帮助。 #### images 列出 Compose 文件中包含的镜像。 #### kill 格式为 `docker-compose kill [options] [SERVICE...]`。 通过发送 `SIGKILL` 信号来强制停止服务容器。 支持通过 `-s` 参数来指定发送的信号,例如通过如下指令发送 `SIGINT` 信号。 ```bash docker-compose kill -s SIGINT ``` #### logs 格式为 `docker-compose logs [options] [SERVICE...]`。 查看服务容器的输出。默认情况下,docker-compose 将对不同的服务输出使用不同的颜色来区分。可以通过 `--no-color` 来关闭颜色。 该命令在调试问题的时候十分有用。 #### pause 格式为 `docker-compose pause [SERVICE...]`。 暂停一个服务容器。 #### port 格式为 `docker-compose port [options] SERVICE PRIVATE_PORT`。 打印某个容器端口所映射的公共端口。 选项: * `--protocol=proto` 指定端口协议,tcp(默认值)或者 udp。 * `--index=index` 如果同一服务存在多个容器,指定命令对象容器的序号(默认为 1)。 #### ps 格式为 `docker-compose ps [options] [SERVICE...]`。 列出项目中目前的所有容器。 选项: * `-q` 只打印容器的 ID 信息。 #### pull 格式为 `docker-compose pull [options] [SERVICE...]`。 拉取服务依赖的镜像。 选项: * `--ignore-pull-failures` 忽略拉取镜像过程中的错误。 #### push 推送服务依赖的镜像到 Docker 镜像仓库。 #### restart 格式为 `docker-compose restart [options] [SERVICE...]`。 重启项目中的服务。 选项: * `-t, --timeout TIMEOUT` 指定重启前停止容器的超时(默认为 10 秒)。 #### rm 格式为 `docker-compose rm [options] [SERVICE...]`。 删除所有(停止状态的)服务容器。推荐先执行 `docker-compose stop` 命令来停止容器。 选项: * `-f, --force` 强制直接删除,包括非停止状态的容器。一般尽量不要使用该选项。 * `-v` 删除容器所挂载的数据卷。 #### run 格式为 `docker-compose run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]`。 在指定服务上执行一个命令。 例如: ```bash docker-compose run ubuntu ping docker.com ``` 将会启动一个 ubuntu 服务容器,并执行 `ping docker.com` 命令。 默认情况下,如果存在关联,则所有关联的服务将会自动被启动,除非这些服务已经在运行中。 该命令类似启动容器后运行指定的命令,相关卷、链接等等都将会按照配置自动创建。 两个不同点: * 给定命令将会覆盖原有的自动运行命令; * 不会自动创建端口,以避免冲突。 如果不希望自动启动关联的容器,可以使用 `--no-deps` 选项,例如 ```bash docker-compose run --no-deps web python manage.py shell ``` 将不会启动 web 容器所关联的其它容器。 选项: * `-d` 后台运行容器。 * `--name NAME` 为容器指定一个名字。 * `--entrypoint CMD` 覆盖默认的容器启动指令。 * `-e KEY=VAL` 设置环境变量值,可多次使用选项来设置多个环境变量。 * `-u, --user=""` 指定运行容器的用户名或者 uid。 * `--no-deps` 不自动启动关联的服务容器。 * `--rm` 运行命令后自动删除容器,`d` 模式下将忽略。 * `-p, --publish=[]` 映射容器端口到本地主机。 * `--service-ports` 配置服务端口并映射到本地主机。 * `-T` 不分配伪 tty,意味着依赖 tty 的指令将无法运行。 #### scale 格式为 `docker-compose scale [options] [SERVICE=NUM...]`。 设置指定服务运行的容器个数。 通过 `service=num` 的参数来设置数量。例如: ```bash docker-compose scale web=3 db=2 ``` 将启动 3 个容器运行 web 服务,2 个容器运行 db 服务。 一般的,当指定数目多于该服务当前实际运行容器,将新创建并启动容器;反之,将停止容器。 选项: * `-t, --timeout TIMEOUT` 停止容器时候的超时(默认为 10 秒)。 #### start 格式为 `docker-compose start [SERVICE...]`。 启动已经存在的服务容器。 #### stop 格式为 `docker-compose stop [options] [SERVICE...]`。 停止已经处于运行状态的容器,但不删除它。通过 `docker-compose start` 可以再次启动这些容器。 选项: * `-t, --timeout TIMEOUT` 停止容器时候的超时(默认为 10 秒)。 #### top 查看各个服务容器内运行的进程。 #### unpause 格式为 `docker-compose unpause [SERVICE...]`。 恢复处于暂停状态中的服务。 #### up 格式为 `docker-compose up [options] [SERVICE...]`。 该命令十分强大,它将尝试自动完成包括构建镜像,(重新)创建服务,启动服务,并关联服务相关容器的一系列操作。 链接的服务都将会被自动启动,除非已经处于运行状态。 可以说,大部分时候都可以直接通过该命令来启动一个项目。 默认情况,`docker-compose up` 启动的容器都在前台,控制台将会同时打印所有容器的输出信息,可以很方便进行调试。 当通过 `Ctrl-C` 停止命令时,所有容器将会停止。 如果使用 `docker-compose up -d`,将会在后台启动并运行所有的容器。一般推荐生产环境下使用该选项。 默认情况,如果服务容器已经存在,`docker-compose up` 将会尝试停止容器,然后重新创建(保持使用 `volumes-from` 挂载的卷),以保证新启动的服务匹配 `docker-compose.yml` 文件的最新内容。如果用户不希望容器被停止并重新创建,可以使用 `docker-compose up --no-recreate`。这样将只会启动处于停止状态的容器,而忽略已经运行的服务。如果用户只想重新部署某个服务,可以使用 `docker-compose up --no-deps -d ` 来重新创建服务并后台停止旧服务,启动新服务,并不会影响到其所依赖的服务。 选项: * `-d` 在后台运行服务容器。 * `--no-color` 不使用颜色来区分不同的服务的控制台输出。 * `--no-deps` 不启动服务所链接的容器。 * `--force-recreate` 强制重新创建容器,不能与 `--no-recreate` 同时使用。 * `--no-recreate` 如果容器已经存在了,则不重新创建,不能与 `--force-recreate` 同时使用。 * `--no-build` 不自动构建缺失的服务镜像。 * `-t, --timeout TIMEOUT` 停止容器时候的超时(默认为 10 秒)。 #### version 格式为 `docker-compose version`。 打印版本信息。 ## 模板文件 Docker Compose 的核心在于编写模板文件,其默认名称为 `docker-compose.yml`,采用 YAML 格式。尽管文件中涉及的指令关键字较多,但请不必担心,因为它们中的大部分都与 `docker run` 命令的相关参数含义相似,理解起来并不困难。 一个基本的服务定义必须通过 `image` 指令指定镜像,或使用 `build` 指令(需配合 Dockerfile)来自动构建镜像。 ```yaml version: "3" services: webapp: image: examples/web ports: - "80:80" volumes: - "/data" ``` **一个重要提示是**:如果使用 `build` 指令,那么在 Dockerfile 中已设置的选项(如 `CMD`、`EXPOSE`、`VOLUME`、`ENV` 等)会被自动继承,无需在 `docker-compose.yml` 中再次声明。 ### build 指定 `Dockerfile` 所在文件夹的路径(可以是绝对路径,或者相对 docker-compose.yml 文件的路径)。 `Compose` 将会利用它自动构建这个镜像,然后使用这个镜像。 ```yaml version: '3' services: webapp: build: ./dir ``` 你也可以使用 `context` 指令指定 `Dockerfile` 所在文件夹的路径。 使用 `dockerfile` 指令指定 `Dockerfile` 文件名。 使用 `arg` 指令指定构建镜像时的变量。 ```yaml version: '3' services: webapp: build: context: ./dir dockerfile: Dockerfile-alternate args: buildno: 1 ``` 使用 `cache_from` 指定构建镜像的缓存 ```yaml build: context: . cache_from: - alpine:latest - corp/web_app:3.14 ``` ### cap\_add, cap\_drop 指定容器的内核能力(capacity)分配。 例如,让容器拥有所有能力可以指定为: ```yaml cap_add: - ALL ``` 去掉 NET\_ADMIN 能力可以指定为: ```yaml cap_drop: - NET_ADMIN ``` ### command 覆盖容器启动后默认执行的命令。 ```yaml command: echo "hello world" ``` ### configs 仅用于 `Swarm mode` ### cgroup\_parent 指定父 `cgroup` 组,意味着将继承该组的资源限制。 例如,创建了一个 cgroup 组名称为 `cgroups_1`。 ```yaml cgroup_parent: cgroups_1 ``` ### container\_name 指定容器名称。默认将会使用 `项目名称_服务名称_序号` 这样的格式。 ```yaml container_name: docker-web-container ``` **注意**: 指定容器名称后,该服务将无法进行扩展(scale),因为 Docker 不允许多个容器具有相同的名称。 ### deploy 仅用于 `Swarm mode` ### devices 指定设备映射关系。 ```yaml devices: - "/dev/ttyUSB1:/dev/ttyUSB0" ``` ### depends\_on 解决容器的依赖、启动先后的问题。以下例子中会先启动 `redis` `db` 再启动 `web` ```yaml version: '3' services: web: build: . depends_on: - db - redis redis: image: redis db: image: postgres ``` **注意**:`web` 服务不会等待 `redis` `db` 「完全启动」之后才启动。 ### dns 自定义 `DNS` 服务器。可以是一个值,也可以是一个列表。 ```yaml dns: 8.8.8.8 dns: - 8.8.8.8 - 114.114.114.114 ``` ### dns\_search 配置 `DNS` 搜索域。可以是一个值,也可以是一个列表。 ```yaml dns_search: example.com dns_search: - domain1.example.com - domain2.example.com ``` ### tmpfs 挂载一个 tmpfs 文件系统到容器。 ```yaml tmpfs: /run tmpfs: - /run - /tmp ``` ### env\_file 从文件中获取环境变量,可以为单独的文件路径或列表。 如果通过 `docker-compose -f FILE` 方式来指定 Compose 模板文件,则 `env_file` 中变量的路径会基于模板文件路径。 如果有变量名称与 `environment` 指令冲突,则按照惯例,以后者为准。 ```yaml env_file: .env env_file: - ./common.env - ./apps/web.env - /opt/secrets.env ``` 环境变量文件中每一行必须符合格式,支持 `#` 开头的注释行。 ```yaml # common.env: Set development environment PROG_ENV=development ``` ### environment 设置环境变量。你可以使用数组或字典两种格式。 只给定名称的变量会自动获取运行 Compose 主机上对应变量的值,可以用来防止泄露不必要的数据。 如果变量名称或者值中用到 `true|false,yes|no` 等表达 [布尔](https://yaml.org/type/bool.html)含义的词汇,最好放到引号里,避免 YAML 自动解析某些内容为对应的布尔语义。这些特定词汇,包括 ```bash y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF ``` ### expose 暴露端口,但不映射到宿主机,只被连接的服务访问。 仅可以指定内部端口为参数 ```yaml expose: - "3000" - "8000" ``` ### external\_links **注意**:不建议使用该指令。 链接到 `docker-compose.yml` 外部的容器,甚至并非 `Compose` 管理的外部容器。 ```yaml external_links: - redis_1 - project_db_1:mysql - project_db_1:postgresql ``` ### extra\_hosts 类似 Docker 中的 `--add-host` 参数,指定额外的 host 名称映射信息。 ```yaml extra_hosts: - "googledns:8.8.8.8" - "dockerhub:52.1.157.61" ``` 会在启动后的服务容器中 `/etc/hosts` 文件中添加如下两条条目。 ```bash 8.8.8.8 googledns 52.1.157.61 dockerhub ``` ### healthcheck 通过命令检查容器是否健康运行。 ```yaml healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] interval: 1m30s timeout: 10s retries: 3 ``` ### image 指定为镜像名称或镜像 ID。如果镜像在本地不存在,`Compose` 将会尝试拉取这个镜像。 ```yaml image: ubuntu image: orchardup/postgresql image: a4bc65fd ``` ### labels 为容器添加 Docker 元数据(metadata)信息。例如可以为容器添加辅助说明信息。 ```yaml labels: com.startupteam.description: "webapp for a startup team" com.startupteam.department: "devops department" com.startupteam.release: "rc3 for v1.0" ``` ### links **注意**:不推荐使用该指令。 ### logging 配置日志选项。 ```yaml logging: driver: syslog options: syslog-address: "tcp://192.168.0.42:123" ``` 目前支持三种日志驱动类型。 ```yaml driver: "json-file" driver: "syslog" driver: "none" ``` `options` 配置日志驱动的相关参数。 ```yaml options: max-size: "200k" max-file: "10" ``` ### network\_mode 设置网络模式。使用和 `docker run` 的 `--network` 参数一样的值。 ```yaml network_mode: "bridge" network_mode: "host" network_mode: "none" network_mode: "service:[service name]" network_mode: "container:[container name/id]" ``` ### networks 配置容器连接的网络。 ```yaml version: "3" services: some-service: networks: - some-network - other-network networks: some-network: other-network: ``` ### pid 跟主机系统共享进程命名空间。打开该选项的容器之间,以及容器和宿主机系统之间可以通过进程 ID 来相互访问和操作。 ```yaml pid: "host" ``` ### ports 暴露端口信息。 使用宿主端口:容器端口 `(HOST:CONTAINER)` 格式,或者仅仅指定容器的端口(宿主将会随机选择端口)都可以。 ```yaml ports: - "3000" - "8000:8000" - "49100:22" - "127.0.0.1:8001:8001" ``` **注意**:当使用 `HOST:CONTAINER` 格式来映射端口时,如果你使用的容器端口小于 60 并且没放到引号里,可能会得到错误结果,因为 `YAML` 会自动解析 `xx:yy` 这种数字格式为 60 进制。为避免出现这种问题,**建议数字串都采用引号包括起来的字符串格式**。 ### secrets 存储敏感数据,例如 `mysql` 服务密码。 ```yaml version: "3.1" services: mysql: image: mysql environment: MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password secrets: - db_root_password - my_other_secret secrets: my_secret: file: ./my_secret.txt my_other_secret: external: true ``` ### security\_opt 指定容器模板标签(label)机制的默认属性(用户、角色、类型、级别等)。例如配置标签的用户名和角色名。 ```yaml security_opt: - label:user:USER - label:role:ROLE ``` ### stop\_signal 设置另一个信号来停止容器。在默认情况下使用的是 SIGTERM 停止容器。 ```yaml stop_signal: SIGUSR1 ``` ### sysctls 配置容器内核参数。 ```yaml sysctls: net.core.somaxconn: 1024 net.ipv4.tcp_syncookies: 0 sysctls: - net.core.somaxconn=1024 - net.ipv4.tcp_syncookies=0 ``` ### ulimits 指定容器的 ulimits 限制值。 例如,指定最大进程数为 65535,指定文件句柄数为 20000(软限制,应用可以随时修改,不能超过硬限制) 和 40000(系统硬限制,只能 root 用户提高)。 ```yaml ulimits: nproc: 65535 nofile: soft: 20000 hard: 40000 ``` ### volumes 数据卷所挂载路径设置。可以设置为宿主机路径(`HOST:CONTAINER`)或者数据卷名称(`VOLUME:CONTAINER`),并且可以设置访问模式 (`HOST:CONTAINER:ro`)。 该指令中路径支持相对路径。 ```yaml volumes: - /var/lib/mysql - cache/:/tmp/cache - ~/configs:/etc/configs/:ro ``` 如果路径为数据卷名称,必须在文件中配置数据卷。 ```yaml version: "3" services: my_src: image: mysql:8.0 volumes: - mysql_data:/var/lib/mysql volumes: mysql_data: ``` ### 其它指令 此外,还有包括 `domainname, entrypoint, hostname, ipc, mac_address, privileged, read_only, shm_size, restart, stdin_open, tty, user, working_dir` 等指令,基本跟 `docker run` 中对应参数的功能一致。 指定服务容器启动后执行的入口文件。 ```yaml entrypoint: /code/entrypoint.sh ``` 指定容器中运行应用的用户名。 ```yaml user: nginx ``` 指定容器中工作目录。 ```yaml working_dir: /code ``` 指定容器中搜索域名、主机名、mac 地址等。 ```yaml domainname: your_website.com hostname: test mac_address: 08-00-27-00-0C-0A ``` 允许容器中运行一些特权命令。 ```yaml privileged: true ``` 指定容器退出后的重启策略为始终重启。该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 `always` 或者 `unless-stopped`。 ```yaml restart: always ``` 以只读模式挂载容器的 root 文件系统,意味着不能对容器内容进行修改。 ```yaml read_only: true ``` 打开标准输入,可以接受外部输入。 ```yaml stdin_open: true ``` 模拟一个伪终端。 ```yaml tty: true ``` ### 读取变量 Compose 模板文件支持动态读取主机的系统环境变量和当前目录下的 `.env` 文件中的变量。 例如,下面的 Compose 文件将从运行它的环境中读取变量 `${MONGO_VERSION}` 的值,并写入执行的指令中。 ```yaml version: "3" services: db: image: "mongo:${MONGO_VERSION}" ``` 如果执行 `MONGO_VERSION=3.2 docker-compose up` 则会启动一个 `mongo:3.2` 镜像的容器;如果执行 `MONGO_VERSION=2.8 docker-compose up` 则会启动一个 `mongo:2.8` 镜像的容器。 若当前目录存在 `.env` 文件,执行 `docker-compose` 命令时将从该文件中读取变量。 在当前目录新建 `.env` 文件并写入以下内容。 ```bash # 支持 # 号注释 MONGO_VERSION=3.6 ``` 执行 `docker-compose up` 则会启动一个 `mongo:3.6` 镜像的容器。 --- --- url: /docker/2tmeelv6/index.md --- # GHCR CLI 命令整理 ## 登录 GHCR ::: info 官方文档 [GitHub 容器注册表](https://docs.github.com/zh/packages/working-with-a-github-packages-registry/working-with-the-container-registry) ::: Docker 登录 ```bash docker login ghcr.io ``` **Username**:你的 GitHub 用户名 **Password**:GitHub Personal Access Token (PAT),需包含 `read:packages`(拉取)和 `write:packages`(推送)权限 成功登录后可以推送或拉取私有镜像 ::: tip 生成 **GitHub Personal Access Token(PAT)**,用于认证 **步骤:** * 访问 [GitHub 设置 → Developer settings → Personal access tokens → Fine-grained tokens](https://github.com/settings/tokens) * 创建新 token,勾选 `write:packages`、`read:packages`、`delete:packages` 权限 * 保存 token(只显示一次) ::: ## 安装GitHub CLI 通过官方安装包,访问 [GitHub CLI Releases](https://github.com/cli/cli/releases) ```bash gh --version ``` ## 给本地镜像打 GHCR 标签 ```bash docker tag <本地镜像名>:<标签> ghcr.io/<用户名>/<仓库名>:<标签> ``` 示例: ```bash docker tag mynapp:1.0.0 ghcr.io/yumengjh/mynestapp:1.0.0 ``` 把本地镜像 `mynapp:1.0.0` 赋予远程 GHCR 地址标签,不会复制镜像,只是加标签引用 ## 推送镜像到 GHCR ```bash docker push ghcr.io/<用户名>/<仓库名>:<标签> ``` 示例: ```bash docker push ghcr.io/yumengjh/mynestapp:1.0.0 ``` 推送私有镜像需要先登录,公共镜像推送无需限制 ## 拉取镜像 ```bash docker pull ghcr.io/<用户名>/<仓库名>:<标签> ``` 示例: ```bash docker pull ghcr.io/yumengjh/mynestapp:1.0.0 ``` 拉取私有镜像需登录 GHCR,公共镜像无需登录 ## 查看镜像列表 **本地镜像**: ```bash docker images ``` **远程 GHCR 镜像**:需要 GitHub API 或 `gh` CLI ```bash gh api -H "Accept: application/vnd.github+json" /user/packages/container ``` 或者查看某个仓库: ```bash gh api -H "Accept: application/vnd.github+json" /users/<用户名>/packages/container/<包名>/versions ``` ## 修改镜像可见性 GHCR 私有镜像默认私有,可以改为公共: 使用 GitHub CLI ```bash gh api \ -X PATCH \ -H "Accept: application/vnd.github+json" \ /user/packages/container/<包名> \ -f visibility=public ``` `visibility=public` → 设置为公开 `visibility=private` → 设置为私有 ## 删除镜像版本 删除单个版本: ```bash gh api \ -X DELETE \ -H "Accept: application/vnd.github+json" \ /user/packages/container/<包名>/versions/ ``` 删除整个镜像包: ```bash gh api \ -X DELETE \ -H "Accept: application/vnd.github+json" \ /user/packages/container/<包名> ``` 注意:版本 ID 可以通过: ```bash gh api /user/packages/container/<包名>/versions ``` 查到。 ## 重命名或迁移仓库 GHCR 本身不支持直接改名,通常做法: 给原镜像打新标签: ```bash docker tag ghcr.io/olduser/oldrepo:1.0 ghcr.io/newuser/newrepo:1.0 ``` 推送到新仓库: ```bash docker push ghcr.io/newuser/newrepo:1.0 ``` 删除旧仓库(可选) ## 更新镜像 新版本更新:先 build 新镜像,然后打标签推送即可: ```bash docker build -t mynapp:1.1.0 . docker tag mynapp:1.1.0 ghcr.io/yumengjh/mynestapp:1.1.0 docker push ghcr.io/yumengjh/mynestapp:1.1.0 ``` 可以同时推 `latest` 标签: ```bash docker tag mynapp:1.1.0 ghcr.io/yumengjh/mynestapp:latest docker push ghcr.io/yumengjh/mynestapp:latest ``` --- --- url: /docker/3keo8bqp/index.md --- ```sh # 基础镜像 alpine debian ubuntu # CI/CD & 运维必备 docker # 常见语言构建环境 golang node python openjdk maven # 消息队列类 rabbitmq # 前端静态服务必备 nginx # 最常见数据库和缓存 mongo mysql postgres # 其他应用 fatedier/frp ghcr.io/umami-software/umami:postgresql-latest ghcr.io/umami-software/umami:mysql-latest imaegoo/twikoo redis portainer/portainer-ce phpmyadmin minio/minio jenkins/jenkins ``` --- --- url: /docker/9rjy2nwt/index.md --- # 常见问题总结 ## 镜像相关 ### 清理临时镜像文件 ```bash docker image prune ``` ### 查看镜像支持的环境变量 ```bash docker run IMAGE env ``` ### 本地的镜像文件存放的位置 与 Docker 相关的本地资源默认存放在 `/var/lib/docker/` 目录下,以 `overlay2` 文件系统为例,其中 `containers` 目录存放容器信息,`image` 目录存放镜像信息,`overlay2` 目录下存放具体的镜像层文件。 ### 构建镜像应该遵循的原则 整体原则上,尽量保持镜像功能的明确和内容的精简,要点包括 * 尽量选取满足需求但较小的基础系统镜像,例如大部分时候可以选择 `alpine` 镜像,仅有不足六兆大小; * 清理编译生成文件、安装包的缓存等临时文件; * 安装各个软件时候要指定准确的版本号,并避免引入不需要的依赖; * 从安全角度考虑,应用要尽量使用系统的库和依赖; * 如果安装应用时候需要配置一些特殊的环境变量,在安装后要还原不需要保持的变量值; * 使用 Dockerfile 创建镜像时候要添加 .dockerignore 文件或使用干净的工作目录。 ## 容器相关 ### 停止所有正在运行的容器 ```yaml docker stop $(docker container ls -q) ``` ### 批量清理已经停止的容器 ```yaml docker container prune ``` ### 获取某个容器的 PID 信息 ```bash docker inspect --format '{{ .State.Pid }}' ``` ### 获取某个容器的 IP 地址 ```bash docker inspect --format '{{ .NetworkSettings.IPAddress }}' ``` ### 给容器指定一个固定 IP 地址,而不是每次重启容器 IP 地址都会变 使用以下命令启动容器可以使容器 IP 固定不变 ```bash docker network create -d bridge --subnet 172.25.0.0/16 my-net docker run --network=my-net --ip=172.25.3.3 -itd --name=my-container busybox ``` ### 临时退出一个正在交互的容器的终端,而不终止它 按 `Ctrl-p Ctrl-q`。如果按 `Ctrl-c` 往往会让容器内应用进程终止,进而会终止容器。 ### 控制容器占用系统资源(CPU、内存)的份额 使用 `docker create` 命令创建容器或使用 `docker run` 创建并启动容器的时候,可以使用 -c|--cpu-shares\[=0] 参数来调整容器使用 CPU 的权重;使用 -m|--memory\[=MEMORY] 参数来调整容器使用内存的大小。 ## 配置相关 ### Docker 的配置文件位置 使用 `systemd` 的系统(如 Ubuntu 16.04、Centos 等)的配置文件在 `/etc/docker/daemon.json` ### 更改 Docker 的默认存储位置 Docker 的默认存储位置是 `/var/lib/docker`,如果希望将 Docker 的本地文件存储到其他分区, 可以使用 Linux 软连接的方式来完成, 或者在启动 daemon 时通过 `-g` 参数指定, 或者修改配置文件 `/etc/docker/daemon.json` 的 "data-root" 项 。 可以使用 `docker system info | grep "Root Dir"` 查看当前使用的存储位置。 --- --- url: /docker/bem34iju/index.md --- # Docker 快速上手 ## 安装 在 Ubuntu 上用阿里云镜像源安装 Docker CE(稳定版) ```bash sudo apt-get install ca-certificates curl curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - sudo add-apt-repository "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable" sudo apt-get install docker-ce docker-ce-cli containerd.io ``` Docker 官方推荐的一键安装脚本 ```bash apt-get update apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release curl -fsSL https://get.docker.com | sh ``` ## 介绍 Docker 简单来说就是一个 **开源的应用容器引擎**。 它让开发者可以 **将他们的应用程序及其所有依赖项打包到一个标准化的、可移植的容器中**。 这个容器可以在任何支持 Docker 的环境中运行,**无论是开发者的笔记本电脑、测试服务器还是生产环境的云服务器,都可以保持一致的行为**。 通过 Docker,**应用程序的部署变得更加快速、可靠和高效,因为它消除了不同运行环境之间的兼容性问题**。它使得 **开发、测试和生产环境的一致性成为可能,大大简化了软件的交付流程**。 ## 拉取镜像 ```bash docker pull docker.io/library/nginx:latest ``` `dock pull` 命令用于从配置的镜像仓库(registry),例如 Docker Hub 或私有镜像仓库中,下载特定的 Docker 镜像到本地系统。 `docker.io` 是 Docker 仓库的注册表地址,表示这是 docker hub 的官方仓仓库,官方仓库可以省略仓库地址 `library` 是命名空间,docker hub 是公共仓库,每个人都可以上传自己的镜像,所以要用名称进行区分,library 是 docker 官方仓库的命名空间,如果一个镜像属于是官方的命名空间,那这部分可以省略不写 `nginx` 是镜像名称,表示要下载的镜像是 nginx `latest` 是镜像的标签,表示要下载的镜像是 nginx 的最新版本,标签可以省略,默认就是 latest,也可以指定其他版本的标签,例如 `nginx:1.21` 简化后的命令为:`docker pull nginx`,表示从 Docker Hub 上拉取最新版本的 nginx 镜像。 这些镜像包含了运行一个或多个容器实例所需的所有应用程序组件、依赖库、运行时环境、以及相关的代码和配置文件。在用户创建并启动容器之前,必须确保其所依赖的基础或特定应用镜像已通过 `pull` 操作下载到本地。 该命令是 Docker 工作流中的一个基本且重要的步骤,它保证了用户能够获取到最新版本或特定标签(tag)的镜像,从而为容器的正确部署和运行奠定基础。 *** `docker pull --platform=xxx` 命令用于从 Docker 仓库中拉取特定平台的镜像。这个命令允许用户指定所需的操作系统架构和平台类型,以确保拉取到的镜像能够在目标环境中正确运行。 默认情况 Docker 会根据当前系统的架构自动选择合适的镜像,但有时用户可能需要拉取特定平台的镜像,例如在 ARM 架构的设备上运行 x86 架构的镜像,或者在 x86 架构的设备上运行 ARM 架构的镜像。 例如,`docker pull --platform=linux/amd64 nginx` 将拉取适用于 Linux 操作系统和 AMD64 架构的 Nginx 镜像。 *** [Docker Hub](https://hub.docker.com/) 是 Docker 官方提供的公共镜像仓库,用户可以在其中找到各种开源和官方维护的 Docker 镜像,它提供了一个集中式的平台,允许用户上传、分享和下载 Docker 镜像。其他镜像站:[Docker Hub 镜像搜索](https://docker.fxxk.dedyn.io) 在国内的网络环境中执行命令可能会遇到报错,可以通过修改配置文件来使用国内的镜像源。详细资源:https://github.com/tech-shrimp/docker\_installer ## 管理镜像 `docker image` 命令用于管理本地的 Docker 镜像,包括查看、删除、标记等操作。常用的子命令包括: * `docker image ls`:列出本地所有的镜像 * `docker image rm`:删除一个或多个镜像 * `docker image tag`:为镜像添加一个新的标签 `docker rmi` 命令也可以用于删除一个或多个 Docker 镜像,将需要删除的镜像 ID 或名称作为参数传递给该命令,这个命令可以帮助用户清理不再需要的镜像,释放存储空间。 ## 创建容器 `docker run` 命令用于创建并启动一个新的容器实例。 这个命令可以接受多个参数,以便在启动容器时进行配置。 将需要运行的镜像名称作为参数传递给该命令,这个命令是 Docker 的核心命令之一,允许用户从指定的镜像创建一个新的容器,并在该容器中运行指定的命令或应用程序。 每次运行 `docker run` 命令时,Docker 会根据指定的镜像创建一个新的容器实例。容器是镜像的一个运行时实例,它包含了应用程序及其所有依赖项。 `docker run -d`,其中的 `d` 表示 detached mode(分离模式),容器将在后台运行,而不是绑定到当前终端,执行后会返回一个容器 ID。这个命令常用于需要长时间运行的服务或应用程序,例如 Web 服务器、数据库等。 当直接使用 `docker run` 命令时,如果发现本地不存在 指定的镜像,Docker 会自动尝试从默认的 Docker Hub 仓库中拉取该镜像。如果需要从其他仓库拉取镜像,可以使用 `docker pull` 命令手动拉取。 `docker run -p` 命令用于将容器的端口映射到宿主机的端口。通过指定 `-p` 选项,用户可以将容器内部的服务暴露到宿主机上,从而实现外部访问。例如,`docker run -p 8080:80 nginx` 将容器的 80 端口映射到宿主机的 8080 端口。 `-p` 参数用于端口映射,其主要作用是将 Docker 容器内部的网络端口与宿主机的网络端口进行关联,从而允许外部访问容器内运行的服务。 **核心概念**: * **网络隔离**:Docker 容器拥有独立的虚拟网络环境,与宿主机网络默认是隔离的。这意味着,即使容器内运行着一个服务(如 Nginx),在不进行端口映射的情况下,也无法直接通过宿主机的 IP 地址和端口访问到该服务。 * **端口映射的作用:** 解决网络隔离问题,实现宿主机与容器之间的通信。通过端口映射,当访问宿主机的特定端口时,流量会被转发到容器内部对应的端口,从而能够访问容器内运行的服务。 使用 `-p` 参数进行端口映射的格式为:`宿主机端口:容器内端口`。 * **冒号前:** 宿主机的端口(外部端口)。 * **冒号后:** 容器内部的端口(内部端口)。 示例: ` docker run -p 80:80 nginx`,这个命令会将宿主机的 80 端口映射到 Nginx 容器内部的 80 端口,这样,当在宿主机浏览器中访问 `localhost:80` 时,实际上就是访问到了 Nginx 容器内的服务,网页因此可以正常显示。 一个容器端口可以映射到多个宿主端口。要实现这一点,你需要在 `docker run` 命令中使用 `-p` 参数多次,每个所需的映射使用一次。 例如,要将容器的端口 33 映射到宿主端口 1253 和宿主端口 2313,你可以使用以下命令: ```bash docker run -p 1253:33 -p 2313:33 my_image ``` 这允许你从宿主机的两个不同端口访问在容器内部运行在端口 33 上的服务。 在运行的时候可以通过 `-name` 参数为容器指定一个名称,这样在删除容器时可以使用名称而不是容器 ID。例如: ```bash docker run --rm -d --name mynginx -p 8080:80 nginx ``` 并且在后续的操作中可以使用名称而不是容器 ID,从而简化命令,这个名称必须是 **唯一** 的。 `--name mynginx`:给容器起一个名字,方便下次用这个名字操作(如 docker stop mynginx)。 `--rm`:容器一旦停止就自动删除,不会出现在 docker ps -a 中,这个参数可以帮助用户在容器不再需要时自动清理资源,避免积累大量已停止的容器。 `docker run -e` 命令用于在创建容器时设置环境变量。这个命令允许用户在容器内定义特定的环境变量,以便在运行时使用。 ```bash docker run -e MY_ENV_VAR=value nginx ``` 可以传递多个环境变量,使用多个 `-e` 参数: ```bash docker run -e MY_ENV_VAR1=value1 -e MY_ENV_VAR2=value2 nginx ``` `docker run -it` 命令用于创建并启动一个新的容器实例,并进入该容器的交互式终端。这个命令常用于需要与容器内的应用程序进行交互的场景,例如调试或测试: ```bash docker run -it --rm alpine sh ``` 进入容器后,可以使用 `exit` 命令退出容器的交互式终端。由于使用了 `--rm` 参数,容器停止后会自动被删除。 `--restart` 策略定义了容器在 **退出后**(无论是因为进程结束、崩溃还是宿主机重启),由 Docker 守护进程自动重新启动的规则。 **`no` - 不自动重启(默认值)** * **行为**:容器 **永远不会** 自动重启。无论容器以何种状态退出(即使是异常退出),它都会保持停止状态。 * **使用场景**:临时性的任务、一次性执行的脚本、或者您希望手动控制其生命周期的开发/测试容器。 ```bash docker run --restart=no my-app # 或者直接省略,因为它是默认值 docker run my-app ``` **`on-failure` - 失败时重启** * **行为**:只有当容器 **以非零状态码退出**(即表示失败)时,Docker 才会尝试重启它。如果容器正常退出(状态码为 0),则不会被重启。 * 可以限制最大重启次数:`on-failure:3` 表示最多尝试重启 3 次。 * **使用场景**:需要确保在程序崩溃或发生错误时能自动恢复的服务,但任务成功完成后就不需要再运行。例如,一个定时的数据处理任务,如果中途出错,我们希望它重试。 ```bash # 在失败时无限次重启 docker run --restart=on-failure my-flaky-app # 最多重启 5 次 docker run --restart=on-failure:5 my-flaky-app ``` **`always` - 总是重启** * **行为**:**无论容器因何原因退出**,Docker 都会无条件地重启它。这包括: * 容器进程正常结束(退出码为 0)。 * 容器进程异常结束(退出码非 0)。 * 容器被 `docker stop` 或 `docker restart` 命令正常停止后,**当 Docker 守护进程启动时(例如宿主机重启后),它也会被重新启动**。 * **注意**:如果容器被 `docker stop` 明确停止,在手动再次启动它之前,它不会立即重启。但宿主机重启会触发它的重启。 * **使用场景**:需要持续运行、保持高可用的长期服务,如 Web 服务器(Nginx)、API 后端等。 ```bash docker run -d --restart=always --name nginx nginx ``` **`unless-stopped` - 除非被停止,否则总是重启** * **行为**:与 `always` 类似,但有一个 **关键区别**: * 如果容器是 **被 `docker stop` 命令明确停止的**,那么即使 Docker 守护进程重启(或宿主机重启),**该容器也不会被自动启动**。 * 除此之外的其他退出情况(进程自己退出、系统崩溃等),它都会在守护进程启动时被重启。 * **使用场景**:这是最常用和最智能的策略。适用于那些您希望默认一直运行,但有时可能需要手动停止并使其保持停止状态的服务。它比 `always` 更尊重用户的意图。 ```bash docker run -d --restart=unless-stopped --name database mysql:8.0 ``` **策略对比总结** 为了更直观地理解,请看下表: | 重启策略 | 容器正常退出 (exit 0) | 容器异常退出 (exit 非 0) | 宿主机重启后 | 被 `docker stop` 停止后,宿主机重启 | | -------------------- | --------------------- | ----------------------- | ------------ | ----------------------------------- | | **`no`** | ❌ | ❌ | ❌ | ❌ | | **`on-failure`** | ❌ | ✅ | ✅ | ❌ | | **`always`** | ✅ | ✅ | ✅ | ✅ | | **`unless-stopped`** | ✅ | ✅ | ✅ | ❌ | 注:`on-failure` 在宿主机重启后,只有之前是因为异常退出而停止的容器才会被重启。 **查看当前策略**: ```bash docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' ``` 使用 `docker update` 命令修改运行中容器的策略: **注意**:某些更改可能在运行时不生效 ```bash docker update --restart=unless-stopped ``` 如果不想反复的创建和删除容器,可以使用 `docker start` 和 `docker stop` 命令来管理已存在的容器。这样可以避免重复的资源开销,提高效率,之前使用 `docker run` 运行时传入的配置已经被保留,下次启动时会使用相同的配置。 ## 管理容器 `docker ps` 命令用于列出当前正在运行的容器,`ps` 就是 process status (进程状态) 的缩写,它显示了容器的基本信息,包括容器 ID、镜像名称、创建时间、状态、端口映射等。 ```bash docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES c710212735c9 mysql:8.0 "docker-entrypoint.s…" 5 seconds ago Up 3 seconds 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp my-mysql-container ``` `CONTAINER ID` 是容器的 id,每创建一个容器,Docker 就会分配一个唯一的 ID,用于标识该容器 `IMAGE` 列显示了容器所基于的镜像名称 `COMMAND` 列显示了容器启动时执行的命令 `CREATED` 列显示了容器创建的时间 `STATUS` 列显示了容器的当前状态(如运行中、已停止等) `PORTS` 列显示了容器暴露的端口映射信息 `NAMES` 列显示了容器的名称 `docker rm 容器ID` 命令用于删除一个或多个 Docker 容器。 这个命令可以帮助用户清理不再需要的容器,释放系统资源,在删除正在运行的容器可以使用 `-f` 参数强制删除。 列出所有容器可以使用 `docker ps -a` 命令,`-a` 参数表示列出所有容器,包括已停止的容器。 `docker ps` 命令默认只列出正在运行的容器。 使用 `-a` 列出的所有容器中,状态为 `Exited` 的容器表示已经停止运行的容器。可以使用 `docker rm` 命令删除这些已停止的容器,这些停止运行的容器不会占用系统资源,但如果不删除,它们仍然会占用存储空间。 可以使用 `docker start/stop` 启动或者停止已有容器,前提是你没有用 `--rm` ```bash docker start mynginx docker stop mynginx ``` 批量删除所有停止的容器: ```bash docker container prune ``` 会提示确认,删除所有“Exited”或“Created”的容器。 删除所有容器 **慎用**: ```bash docker rm $(docker ps -aq) ``` `-q`: 只输出容器的 ID,不显示其他信息。 `docker inspect` 命令用于查看容器或镜像的详细信息,包括其配置、状态、网络设置等信息。 ```bash docker inspect mynginx ``` `docker create` 命令用于创建一个新的容器实例,但不会立即启动它。这个命令可以用于预先配置容器的设置,然后在需要时手动启动。 ```bash docker create --name mynginx -p 8080:80 nginx ``` 后续启动使用 `docker start` 命令 `docker logs 容器ID` 命令用于查看容器的日志输出。这个命令可以帮助用户调试和监控容器内运行的应用程序。 ```bash docker logs mynginx ``` 可以加上 `-f` 参数来实时查看日志输出 `docker exec 进程ID 执行的命令` 命令用于在运行中的容器内执行命令。这个命令允许用户在容器内运行特定的命令或进入容器的交互式终端。 ```bash docker exec b12a3072d8b3 ps -ef ``` 其中 `ps -ef` 命令用于列出容器内的所有进程。 `docker exec -it 进程ID /bin/bash` 命令用于进入正在运行的容器的交互式终端。这个命令允许用户在容器内执行命令,就像在本地终端一样。 ```bash docker exec -it mynginx /bin/bash ``` `/bin/bash` 是容器内的一个常用 shell,允许用户在容器内执行命令,可以使用 `exit` 命令退出容器的交互式终端。 容器内部的命令行是一个极简的 Linux 环境,通常只包含最基本的命令和工具。为了在容器内执行更复杂的操作,可能需要安装额外的软件包。 执行命令前先执行 `cat /etc/os-release` 查看容器的操作系统版本。 比如: ```bash cat /etc/os-release PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" NAME="Debian GNU/Linux" VERSION_ID="12" VERSION="12 (bookworm)" VERSION_CODENAME=bookworm ID=debian HOME_URL="https://www.debian.org/" SUPPORT_URL="https://www.debian.org/support" BUG_REPORT_URL="https://bugs.debian.org/" ``` 其中显示了容器的操作系统信息,包括名称、版本等。 比如上述的系统的软件的包管理工具是 `apt`,可以使用以下命令安装软件包: ```bash apt update apt install [package_name] ``` 安装 Vim 编辑器的命令为: ```bash apt install vim ``` ## 镜像位置 在 Windows 上,Docker 镜像的默认下载位置取决于 Docker 的设置: **在 Windows 的 Docker Desktop 上,镜像的物理存储位置取决于其运行后端:** * **当使用 WSL2 后端时(推荐且默认)**:镜像实际存储在一个名为 `docker-desktop-data` 的 WSL2 发行版内。 * **访问路径**:在文件资源管理器的地址栏输入 `\\wsl$\docker-desktop-data\data\docker\overlay2`。 * 在这里,您可以看到所有镜像层和容器读写层的具体文件。 * **传统 Hyper-V 后端**:镜像存储在一个 Docker Desktop 管理的虚拟硬盘 (VHD) 文件中,默认位于 `C:\ProgramData\DockerDesktop`。该文件对用户不直接可见或可操作。 请注意,通常通过 Docker CLI 命令与 Docker 镜像交互,而不是直接访问或修改这些文件。 您可以使用 `docker info` 命令查看 Docker Root Dir,它指示 Docker 存储其数据的位置。 **在 Linux 系统中**,Docker 的默认数据存储位置通常是 `/var/lib/docker` **。修改此位置需要配置 Docker 守护进程**。 **步骤:** 1. **停止 Docker 服务:** 在修改任何配置前,务必停止 Docker 服务以避免数据损坏。 ```bash sudo systemctl stop docker ``` 2. **备份(可选但强烈建议):** 为了安全起见,可以备份当前的 Docker 数据目录。 ```bash sudo cp -rp /var/lib/docker /var/lib/docker_backup ``` 3. **创建新的存储目录:** 选择一个新的位置来存储 Docker 数据,并创建该目录。例如,在 `/mnt/new_docker_data`: ```bash sudo mkdir -p /mnt/new_docker_data ``` 4. **移动现有 Docker 数据到新目录:** 将旧目录中的所有内容移动到新创建的目录。这一步 **非常重要**,因为这将保留你现有的镜像、容器和卷等数据。 ```bash sudo mv /var/lib/docker/* /mnt/new_docker_data/ ``` **注意:** 如果新目录是空的,你也可以选择不移动,让 Docker 重新创建一个全新的环境。但如果你有重要的现有数据,那么移动是必须的。 5. **配置 Docker 守护进程:** Docker 的配置文件是 `/etc/docker/daemon.json`。如果文件不存在,则创建它;如果存在,则编辑它。 * 使用你喜欢的文本编辑器打开或创建 `daemon.json` 文件: ```bash sudo nano /etc/docker/daemon.json ``` * 添加或修改 `data-root` 字段指向新的存储路径。如果文件中有其他配置,请确保这是一个有效的 JSON 格式(例如,用逗号分隔多个条目)。 ```json { "data-root": "/mnt/new_docker_data" } ``` 保存并关闭文件。 6. **启动 Docker 服务:** ```bash sudo systemctl start docker ``` 7. **验证:** 运行 `docker info` 命令,查找 "Docker Root Dir" 行,确认其指向新的路径。 ```bash docker info | grep "Docker Root Dir" ``` 输出应显示你刚刚配置的新路径。 Docker 的存储位置和行为取决于您使用的是 **Docker Desktop**(绝大多数用户的选择)还是经典的 **Windows Server 版 Docker**。以下内容主要针对 Docker Desktop。 Docker Desktop 默认通过 **WSL 2**(推荐后端)或 **Hyper-V** 在虚拟机中运行 Linux 内核,因此其数据(包括镜像、容器)存储在专用的虚拟磁盘中,而非 Windows 宿主目录。 * **WSL 2 后端**:数据存储在一个名为 `docker-desktop-data` 的 WSL 发行版内。其物理文件路径通常为 `\\wsl$\docker-desktop-data`,对用户透明。 * **Hyper-V 后端**:数据存储在一个 Hyper-V 虚拟硬盘(`.vhdx` 文件)中,默认位于 `C:\Users\Public\Documents\Hyper-V\` 下。 如果您希望将 Docker 数据移动到其他驱动器以释放 C 盘空间,请根据您的后端类型选择以下方法。 **对于使用 WSL 2 后端的用户** 这是最常见的情况。由于 Docker Desktop 的设置界面可能不直接提供 WSL 发行版的迁移选项,需要通过命令行操作。 **步骤如下:** 1. **关闭 Docker 与 WSL**: 在 PowerShell(以管理员身份运行)中执行以下命令,确保所有相关进程关闭: ```bash wsl --shutdown ``` 2. **导出 `docker-desktop-data` 发行版**: 将现有数据导出为一个备份文件(例如,导出到 E 盘): ```bash wsl --export docker-desktop-data E:\docker-desktop-data.tar ``` 3. **注销原有发行版**: 此操作会删除当前的数据,请确保上一步导出成功。 ```bash wsl --unregister docker-desktop-data ``` 4. **在新位置导入发行版**: 将备份文件导入到新的目标目录(例如,`E:\Docker\wsl`),这将在此目录创建新的虚拟磁盘文件。 ```bash wsl --import docker-desktop-data E:\Docker\wsl E:\docker-desktop-data.tar --version 2 ``` 完成后,可手动删除 `E:\docker-desktop-data.tar` 备份文件以节省空间。 5. **重启 Docker Desktop**: 完成以上步骤后,启动 Docker Desktop。它将自动使用新位置的数据。 **对于使用 Hyper-V 后端的用户** 此方法更简单,可直接通过 Docker Desktop 图形界面完成。 **步骤如下:** 1. 点击系统托盘中的 Docker Desktop 图标,选择 **“Settings”**。 2. 导航至 **“Resources”** -> **“Advanced”** 选项卡。 3. 找到 **“Disk image location”** 设置项。 4. 点击 **“Browse”**,选择一个新的目标文件夹(例如 `E:\Docker`)。 5. 点击 **“Apply & Restart”**。 Docker Desktop 将自动在后台完成虚拟磁盘文件的迁移工作,并重启服务。 **注意事项:** * Docker Desktop 对数据存储的管理比原生 Linux 或 Windows Server 版更集成和自动化。 * 直接在 Windows 文件系统中找到并修改 Docker 数据文件是不可取的,因为它们通常在 WSL 或 Hyper-V 虚拟磁盘内部。 * 如果 Docker Desktop 没有直接的 GUI 选项来移动数据,WSL 版本的解决方案会涉及到 WSL 命令,它移动的是整个 WSL 虚拟磁盘。 **Windows Server (原生 Docker Daemon)** 如果你在 Windows Server 上手动安装了 Docker 引擎而不是 Docker Desktop,其行为更类似于 Linux 版本。 **步骤:** 1. **停止 Docker 服务:** ```powershell Stop-Service docker ``` 2. **创建新的存储目录:** 选择一个新的位置,例如 `D:\DockerData`: ```powershell New-Item -ItemType Directory -Path D:\DockerData ``` 3. **移动现有 Docker 数据到新目录:** 默认路径通常是 `C:\ProgramData\Docker`。 ```powershell Move-Item -Path "C:\ProgramData\Docker\daemon.json" -Destination "D:\DockerData" -Force # 移动 daemon.json 如果存在,通常它可能不存在 Move-Item -Path "C:\ProgramData\Docker\containers" -Destination "D:\DockerData" -Force -Recurse -ErrorAction SilentlyContinue # 移动容器数据 Move-Item -Path "C:\ProgramData\Docker\image" -Destination "D:\DockerData" -Force -Recurse -ErrorAction SilentlyContinue # 移动镜像数据 # 根据需要移动其他子目录,如 volumes, swarm, network等 ``` * **注意:** 确保移动了 `C:\ProgramData\Docker` 下所有重要的子目录。或者,你可以直接将整个 `C:\ProgramData\Docker` 文件夹移动到新位置,但请确保在移动前没有任何进程占用这些文件。 4. **配置 Docker 守护进程:** 创建一个或编辑 `daemon.json` 文件。在 Windows Server 上,这个文件通常位于 `C:\ProgramData\Docker\config` 或 `C:\ProgramData\Docker`。如果文件不存在,你需要创建它。 * 使用记事本或其他文本编辑器打开或创建此文件: 例如,打开 `C:\ProgramData\Docker\config\daemon.json`。 * 添加或修改 `data-root` 字段指向新的存储路径: ```json { "data-root": "D:\\DockerData" } ``` 请注意 Windows 路径中使用双反斜杠 `\\`,或者使用正斜杠 `/`。 保存并关闭文件。 5. **启动 Docker 服务:** ```powershell Start-Service docker ``` 6. **验证:** 运行以下命令并查找 "Docker Root Dir" 行: ```bash docker info | Select-String "Docker Root Dir" ``` 输出应显示你配置的新路径。 **一般注意事项:** * **权限:** 确保新的存储目录具有 Docker 服务账户的读写权限。在 Linux 上,通常是 `root` 或 `docker` 组。 * **空间:** 确保新的存储位置有足够的磁盘空间来容纳现有的 Docker 数据和未来的增长。 * **重启:** 每次修改 `daemon.json` 后,都需要重启 Docker 服务才能使更改生效。 * **数据丢失风险:** 在操作前最好进行备份。错误的操作可能导致镜像和容器数据的丢失。 ## 挂载卷 `docker run -v` 参数用于 **挂载卷(Volume Mounting)** ,其核心功能是实现宿主机与容器之间文件目录的双向绑定。 **核心功能和作用:** 1. **文件目录绑定:** `-v` 参数将宿主机上的一个文件目录与容器内部的某个指定目录关联起来。 2. **双向同步:** 一旦绑定建立,对其中任何一端(宿主机或容器)该目录内容的修改,都会实时反映到另一端。宿主机与容器通过这个共享目录紧密联系。 3. **数据持久化:** 这是挂载卷最重要的作用。当容器被删除时,容器内部的所有数据通常也会被一并删除。然而,如果使用了挂载卷,容器中与宿主机目录绑定的数据实际上是存储在宿主机上的。这意味着,即使容器被删除,这些数据也会安全地保留在宿主机上,从而实现了数据的持久化保存。 比如: ```bash docker run -v /website/html:/usr/share/nginx/html nginx ``` `-v 宿主机路径:容器内路径` 这种挂载方式被称为绑定挂载 还有一种方式是让 Docker 自动创建一个存储位置,为存储空间提供一个名称,然后在挂载的时候直接使用名称就行了,这种方式叫 **命名卷挂载**。 `docker volume create 卷名称` 命令用于创建一个新的 Docker 卷(挂载卷)。卷是 Docker 中用于持久化数据的机制,它允许容器之间共享数据,并在容器删除后保留数据。 创建的挂载卷的位置取决于 Docker 的配置和操作系统。默认情况下,Docker 卷存储在以下位置: * **Linux**:`/var/lib/docker/volumes/` * **Windows**:`C:\ProgramData\Docker\volumes\` `docker volume inspect` 命令可以查看卷的详细信息,包括其存储位置、创建时间、挂载点等。 在下次 `docker run` 命令中使用 `-v 卷名称:容器内路径` 参数来挂载这个卷。例如: ```bash docker run -d -v my_volume_name:/data nginx docker run -d -p 3000:80 -v nginx_html:/usr/share/nginx/html nginx ``` `sudo -i` 用于在 Linux 系统中以 root 用户身份运行命令。使用 `sudo -i` 可以获得一个交互式的 root shell,允许用户执行需要更高权限的操作。在使用 Docker 时,如果需要访问 Docker 的系统目录或执行特权操作,可能需要使用 `sudo -i` 切换到 root 用户。 **注意**:命名卷在第一次使用时 Docker 会把容器的文件同步到卷中进行一个初始化,但是绑定挂载不会进行初始化,绑定挂载会直接使用宿主机的文件内容。 `docker volume list` 命令用于列出所有 Docker 卷。这个命令可以帮助用户查看当前系统中存在的所有卷,包括它们的名称、驱动程序和挂载点等信息。 `docker volume rm 卷名称` 命令用于删除一个或多个 Docker 卷。这个命令可以帮助用户清理不再需要的卷,释放存储空间。 `docker volume prune` 命令用于删除所有未使用的 Docker 卷。这个命令可以帮助用户清理不再需要的卷,释放存储空间,可以加上 `-a` 参数来删除所有未使用的卷,不加 `-a` 参数只会删除那些没有被任何容器使用的卷。 加 `-a` 和不加的区别可以简单理解为: * \*\*不加 \*\*​ **​`-a`​**:只会删除那些完全没有被任何容器使用的卷(即“孤立”的卷)。 * \*\*加 \*\*​ **​`-a`​**:会删除所有未被容器挂载的卷,无论这些卷是否曾经被容器使用过。这样可以更彻底地清理无用的卷。 一般情况下,建议先不加 `-a`,以免误删还可能需要的数据卷。 ## 网络模式 Docker 网络默认是桥接模式(bridge),它允许容器之间通过虚拟网络进行通信。Docker 提供了多种网络模式,包括: * **bridge**:默认的网络模式,容器通过虚拟网桥连接到宿主机的网络。 * **host**:容器直接使用宿主机的网络栈,不进行网络隔离。 * **none**:容器没有网络连接。 * **overlay**:用于跨多个 Docker 主机的网络连接,通常用于 Swarm 集群。 所有的容器都链接到这个默认的桥接网络上,每个容器内部都分配了一个虚拟 IP 地址。容器之间可以通过这个 IP 地址进行通信。 容器网络和宿主机的网络是隔离的,容器内部的 IP 地址和宿主机的 IP 地址是不同的。容器可以通过端口映射将其服务暴露到宿主机上,从而允许外部访问。 `docker network create` 命令用于创建一个新的 Docker 子网络。默认也是桥接模式的一种。 可以指定容器加入不同的子网络,以实现容器之间的隔离和通信。 同一个子网络中的容器可以通过容器名称或 IP 地址进行通信,跨子网络则不能。 子网络中的容器可以通过名称互相访问,而不需要使用内部 IP 地址。 ```bash docker network create network1 ``` ```bash docker run -d --name container1 --network network1 nginx ``` `--network` 参数用于指定容器要加入的网络。 比如同一个子网络中,有两个容器 `container1` 和 `container2`,可以在 `container2` 中通过 ping 命令测试与 `container1` 的连接: ```bash docker exec -it container2 ping container1 ping container1 ``` Docker 子网络内部有一套 DNS 系统,可以通过容器名称进行解析。 `host` 网络模式允许容器直接使用宿主机的网络栈,不进行网络隔离。在这种模式下,容器将共享宿主机的 IP 地址和端口,在启动时候无需进行端口映射,因为容器的服务直接运行在宿主机的网络上。 在启动的时候使用 `--network host` 参数来指定容器使用宿主机网络模式。 ```bash docker run --network host nginx ``` 启动后可以进入容器内查询 IP 地址: ```bash docker exec -it container_id /bin/sh apt update apt install iproute2 ip addr show ``` `docker network list` 命令用于列出当前 Docker 主机上所有的网络,包括默认的桥接网络和用户创建的自定义网络。 其中列出的以下种网络是不能删除的: * `bridge`:默认的桥接网络。 * `host`:宿主机网络模式。 * `none`:无网络模式。 `docker network rm [network_name/ID]` 命令用于删除用户创建的自定义网络。 ## 镜像构建(DockerFile) docker file 是一个文本文件,包含了一系列的指令和参数,用于定义如何构建一个 Docker 镜像。Dockerfile 中的每一条指令都会创建一个新的镜像层,这些层会被组合在一起形成最终的镜像。 如果要将一个项目打包成 Docker 镜像,先在项目的根目录下创建一个名为 `Dockerfile` 的文件。这个文件将包含构建镜像所需的所有指令。 所有的 `Dockerfile` 文件的第一行都是 `FROM` 指令,用于指定基础镜像。基础镜像可以是一个操作系统镜像,也可以是一个已经存在的应用程序镜像。 ```bash FROM nginx:latest ``` `WORKDIR` 指令用于设置工作目录。所有后续的指令都会在这个目录下执行。如果目录不存在,Docker 会自动创建它。 ```bash WORKDIR app ``` `COPY` 指令用于将文件从宿主机复制到镜像中,可以指定源路径和目标路径。 `COPY 宿主机路径 镜像路径` 指令将宿主机上的文件或目录复制到镜像中的指定路径。 ```bash COPY . . ``` 上述代码表示将宿主机当前目录下的所有文件和子目录复制到镜像中的工作目录 `app` 下。 `RUN` 指令用于在镜像构建过程中执行命令。可以使用 `RUN` 指令安装软件包、复制文件等。 ```bash RUN npm install --production ``` `EXPOSE` 指令用于指定容器运行时要暴露的端口。这个指令不会实际打开端口,但它会在镜像的元数据中记录下要暴露的端口。 ```bash EXPOSE 8090 ``` 实际使用还是以 `-p` 参数来进行端口映射。 `CMD` 指令用于指定容器启动时要执行的命令。这个指令只能出现一次,如果有多个 `CMD` 指令,只有最后一个会生效。 ```bash CMD ["npm", "run", "start"] ``` `ENTRYPOINT` 指令用于设置容器启动时的默认命令和参数。与 `CMD` 不同,`ENTRYPOINT` 指令指定的命令无法被 `docker run` 命令的参数覆盖,它的优先级更高。 ```bash ENTRYPOINT ["npm", "run", "start"] ``` 最后就可以在终端执行 ```bash docker build -t [myAppName]:[myAppVersion] . ``` 可以在项目的根目录中创建 `.dockerignore` 文件来指定在构建镜像时要忽略的文件和目录。这个文件的语法与 `.gitignore` 类似,可以使用通配符来匹配文件和目录。 例如,以下是一个 `.dockerignore` 文件的示例: ```bash node_modules .git Dockerfile .dockerignore *.log ``` 为了将镜像推送到 Docker Hub,先进行登录操作: ```bash docker login ``` 推送到 Docker Hub 的镜像需要有一个唯一的名称。通常镜像名称的格式为 `用户名/镜像名:标签`,所以在打包的时候需要指定用户名。 ```bash docker build -t username/myapp:latest . ``` 推送到 Docker Hub 的命令为: ```bash docker push username/myapp:latest ``` 推送镜像到 Docker Hub 后,可以在任何地方使用 `docker pull username/myapp:latest` 命令来拉取这个镜像。 ## 多容器编排(Docker Compose) Docker compose 是一个用于定义和运行多容器 Docker 应用程序的工具。它使用 YAML 文件来配置应用程序的服务、网络和卷等。 它使用 `yaml` 文件来管理多个容器,其中配置了容器之间是如何相互连接的,以及它们的环境变量、端口映射等。 Docker 会为每一个 compose 文件创建一个独立的子网络,容器之间可以通过服务名称进行通信。 同一个 Compose 文件中的容器都会自动加入到同一个子网络中,这样它们可以通过服务名称进行互相访问。 Docker compose 还可以自定义启动顺序,确保某些服务在其他服务之前启动。 创建好 `docker-compose.yml` 文件后,可以使用以下命令来启动所有服务: ```bash docker compose up -d ``` `up`: 子命令,用于根据 YAML 文件中的定义创建并启动所有服务。 `-d`: 代表 "detached" mode,即 **后台运行**。如果不加 `-d`,所有服务的日志都会在前台终端输出,退出终端可能会导致服务停止。 如果需要停止所有服务并且删除容器,可以使用以下命令: ```bash docker compose down ``` 如果只想停止服务而不删除容器,可以使用以下命令: ```bash docker compose stop ``` 使用 `docker compose start` 命令可以重新启动已停止的服务。 如果容器已经在运行了,再次执行 `docker compose up` 命令不会重新创建容器,而是会根据 `docker-compose.yml` 文件中的配置更新现有容器。 `docker compose up` 可以用于更新,但它的行为是“重新创建”容器,而不是“原地更新”容器。 当您对 `docker-compose.yml` 文件做了修改(例如:更新了镜像标签、改变了环境变量、映射了新的端口等),并在已经运行服务的目录下再次执行 `docker compose up -d` 时,会发生以下事情: 1. **构建/拉取新镜像**:如果配置有变更,Compose 会先根据配置拉取新的镜像或重新构建镜像。 2. **停止旧容器**:Compose 会 **停止** 当前正在运行的、与服务同名的旧容器。 3. **创建新容器**:Compose 会 **创建一个新的容器**,这个新容器应用了 `docker-compose.yml` 中的所有新配置。 4. **启动新容器**:新的容器被启动。 5. **移除旧容器**:默认情况下,旧的容器会被移除(除非使用了 `--no-recreate` 参数,但这样更新会不生效)。 所以,这个过程是 **“停止 -> 创建新的 -> 启动新的 -> 删除旧的”**,我们称之为 **“重新创建”**。 **用于更新的最佳实践命令**:为了确保更新过程干净、可靠,推荐在使用 `docker compose up` 时结合以下参数: ```bash # 最佳实践:拉取最新镜像并重新创建容器 docker compose pull && docker compose up -d # 或者,强制重新构建镜像并重新创建容器(如果使用 build: 选项) docker compose up -d --build # 最严格的更新:即使配置没变,也强制重新创建所有容器 docker compose up -d --force-recreate ``` 对于非标准的 yaml 文件名,可以使用 `-f` 参数指定文件名: ```bash docker compose -f custom-compose.yml up -d ``` 在包含 `docker-compose.yml` 文件的目录下,您可以执行以下命令来管理整个应用的生命周期: ```bash # 1. 启动所有服务(后台运行) docker compose up -d # 2. 查看服务运行状态 docker compose ps # 3. 查看服务的实时日志(所有服务) docker compose logs -f # 查看特定服务的日志 docker compose logs -f webapp # 4. 停止所有服务,但保留容器和数据 docker compose stop # 5. 停止并移除所有容器、网络(但默认会保留数据卷) docker compose down # 6. 停止并移除所有容器、网络、数据卷(彻底清理) docker compose down -v # 7. 在运行中重启某个服务(例如,更新了代码后) docker compose restart webapp ``` **文件名称**:默认情况下,`docker compose` 会寻找当前目录下名为 `docker-compose.yml` 的文件。如果您的文件是其他名称,需要使用 `-f` 参数指定: ```bash docker compose -f my-compose-file.yml up -d ``` **项目名称**:默认情况下,Compose 会使用当前目录的名称作为 "项目名",并为所有资源(容器、网络、卷)加上项目名前缀,从而隔离不同项目。您也可以使用 `-p` 参数自定义项目名: ```bash docker compose -p my-project up -d ``` **构建镜像**:如果您的 `docker-compose.yml` 文件中包含 `build:` 指令(用于从 Dockerfile 构建镜像),`docker compose up -d` 会在启动服务前自动构建或重新构建镜像。 **经典“硬核”工作流:** ```bash # 1. 核弹发射井开启(销毁一切) docker compose down # 2. 确保灰飞烟灭(加上 -v 删除所有匿名卷,--rmi all 甚至删除所有相关镜像) docker compose down -v --rmi all # 3. 在一片净土上重建世界 docker compose up -d ``` 适用场景: * **“我不管了” 场景**:各种端口冲突、网络问题、容器状态诡异,懒得一点点排查。 * **“从头来过” 场景**:`docker-compose.yml` 文件做了大量结构性修改,确保旧容器 100%会影响新配置。 * **“求个心安” 场景**:虽然可能有些小题大做,但这样操作一遍,心里特别踏实,知道环境绝对是干净的。 * **部署生产前**:在最终部署前,用一个绝对干净的环境测试整个编排流程。 **注意**: **数据丢失警告**:如果您的 Compose 项目里定义了 **匿名卷**(在 `docker-compose.yml` 里只写了容器路径,没起名字),使用 `docker compose down -v` 会 **一并删除这些卷**,导致数据永久丢失。 * **安全做法**:如果卷里有重要数据(比如数据库内容),请确保在 `docker-compose.yml` 中使用了 **命名卷**,这样即使执行 `down -v`,再次 `up` 时命名卷的数据通常还在。 ## 最佳实践 ### 容器设计原则 #### 单一职责原则 每个容器应该只运行一个主要进程,保持容器的专注性和可维护性。 **反例**: ```bash # 一个容器中同时运行Web服务器和数据库 docker run -d my-app-that-runs-nginx-and-mysql ``` **正例**: ```bash # 使用多个容器,各司其职 docker run -d --name web nginx docker run -d --name db mysql docker run -d --name app my-python-app ``` #### 无状态设计 容器应该是无状态的,所有状态数据都应该外部化存储。 **实现方式**: ```bash # 将状态数据存储在卷中 docker run -d \ -v app-data:/var/lib/app/data \ -v app-logs:/var/log/app \ my-app ``` ### 镜像构建最佳实践 #### 使用多阶段构建 减少最终镜像大小,提高安全性。 ```dockerfile # 第一阶段:构建环境 FROM node:16 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # 第二阶段:运行环境 FROM node:16-alpine WORKDIR /app COPY --from=builder /app/node_modules ./node_modules COPY . . USER node EXPOSE 3000 CMD ["node", "server.js"] ``` #### 优化镜像层缓存 合理安排指令顺序,充分利用Docker的构建缓存。 ```dockerfile # 错误的顺序 - 每次代码变更都会导致依赖重新安装 COPY . . RUN npm install # 正确的顺序 - 依赖变更时才重新安装 COPY package*.json ./ RUN npm install COPY . . ``` #### 使用特定版本标签 避免使用 `latest` 标签,确保构建的可重复性。 ```dockerfile # 不推荐 FROM ubuntu:latest # 推荐 FROM ubuntu:20.04 ``` ### 安全最佳实践 #### 使用非root用户运行 ```dockerfile FROM node:16-alpine RUN addgroup -g 1001 -S nodejs RUN adduser -S nextjs -u 1001 USER nextjs COPY --chown=nextjs:nodejs . . CMD ["node", "server.js"] ``` #### 定期更新基础镜像 ```dockerfile # 定期检查并更新基础镜像版本 FROM ubuntu:20.04 # 而不是使用旧的、可能存在漏洞的版本 ``` #### 扫描镜像漏洞 ```bash # 使用Docker Scout或类似工具扫描镜像 docker scout cves my-app:latest ``` ### 资源配置最佳实践 #### 设置资源限制 防止单个容器消耗过多资源。 ```yaml # docker-compose.yml services: web: image: nginx deploy: resources: limits: memory: 512M cpus: '1.0' reservations: memory: 256M cpus: '0.5' ``` #### 配置健康检查 ```dockerfile # 在Dockerfile中定义健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 ``` ```yaml # 或在docker-compose.yml中定义 services: web: image: my-app healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s ``` ### 网络最佳实践 #### 使用自定义网络 ```yaml # docker-compose.yml version: '3.8' services: frontend: image: nginx networks: - frontend-network backend: image: node:16 networks: - frontend-network - backend-network database: image: postgres:13 networks: - backend-network networks: frontend-network: driver: bridge backend-network: driver: bridge ``` #### 避免使用主机网络 ```yaml # 不推荐 - 安全性差 services: web: network_mode: "host" # 推荐 - 使用端口映射 services: web: ports: - "80:80" ``` ### 数据管理最佳实践 #### 使用命名卷 ```yaml services: database: image: postgres:13 volumes: - db-data:/var/lib/postgresql/data - ./backups:/backups # 绑定挂载用于备份 volumes: db-data: driver: local ``` #### 定期备份重要数据 ```bash # 备份数据库卷 docker run --rm \ -v db-data:/source \ -v /host/backups:/backup \ alpine tar czf /backup/db-$(date +%Y%m%d).tar.gz -C /source . ``` ### 日志管理最佳实践 #### 配置日志轮转 ```yaml services: web: image: nginx logging: driver: "json-file" options: max-size: "10m" max-file: "3" ``` #### 使用结构化日志 ```dockerfile # 在应用中使用JSON格式日志 CMD ["node", "-r", "pino", "server.js"] ``` ### 编排最佳实践 #### 使用环境变量文件 ```yaml # docker-compose.yml services: web: image: my-app env_file: - .env environment: - NODE_ENV=production ``` #### 定义服务依赖关系 ```yaml services: web: image: nginx depends_on: database: condition: service_healthy backend: condition: service_started backend: image: node:16 depends_on: database: condition: service_healthy database: image: postgres:13 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 ``` ### 监控与维护 #### 设置监控和告警 ```bash # 使用cAdvisor监控容器资源使用 docker run \ --volume=/:/rootfs:ro \ --volume=/var/run:/var/run:ro \ --volume=/sys:/sys:ro \ --volume=/var/lib/docker/:/var/lib/docker:ro \ --publish=8080:8080 \ --detach=true \ --name=cadvisor \ gcr.io/cadvisor/cadvisor:v0.47.0 ``` #### 定期清理无用资源 ```bash # 清理无用镜像、容器、卷和网络 docker system prune -f docker volume prune -f docker network prune -f # 更激进的清理(谨慎使用) docker system prune -a -f --volumes ``` ### 开发环境最佳实践 #### 使用开发专用配置 ```yaml # docker-compose.dev.yml services: web: build: . volumes: - .:/app # 开发时挂载源代码 - /app/node_modules # 防止覆盖容器内的node_modules environment: - NODE_ENV=development ports: - "3000:3000" ``` #### 热重载配置 ```yaml services: web: build: . develop: watch: - action: sync path: ./ target: /app - action: rebuild path: package.json ``` 通过遵循这些最佳实践,您可以构建出更安全、更稳定、更易维护的Docker化应用。记住,Docker不仅仅是容器技术,更是一套完整的应用交付方法论。 --- --- url: /docker/cbgeo4xk/index.md --- **Docker** 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护。使得 `Docker` 技术比虚拟机技术更为轻便、快捷。 ::: info Docker 本身不是容器技术,它是容器技术的一个管理工具。 "在容器的基础上进行封装"这句话的意思是: * 容器技术的底层:在 Linux 系统里,早就有了实现隔离的基础技术,比如 Namespace 和 Cgroups。这就像已经有了砖块、水泥这些盖房子的原材料。 * Docker 的封装:Docker 做的事情,就是把这些复杂的底层技术打包成一个简单好用的工具。它提供了标准化的镜像、一键启动、网络管理等功能。这就像用现成的材料,提供了一套可以快速盖好房子的"乐高积木"或"模板"。 所以,Docker 是让创建和管理容器变得极其简单的工具,而不是容器技术本身。 ::: 传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程; 而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。 ## 更高效的利用系统资源 由于容器不需要进行硬件虚拟以及运行完整操作系统等额外开销,`Docker` 对系统资源的利用率更高。无论是应用执行速度、内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的主机,往往可以运行更多数量的应用。 ## 更快速的启动时间 传统的虚拟机技术启动应用服务往往需要数分钟,而 `Docker` 容器应用,由于直接运行于宿主内核,无需启动完整的操作系统,因此可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。 ## 一致的运行环境 开发过程中一个常见的问题是环境一致性问题。由于开发环境、测试环境、生产环境不一致,导致有些 bug 并未在开发过程中被发现。而 `Docker` 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 **这段代码在我机器上没问题啊** 这类问题。 ## 持续交付和部署 对开发和运维([DevOps ](https://zh.wikipedia.org/wiki/DevOps))人员来说,最希望的就是一次创建或配置,可以在任意地方正常运行。 使用 `Docker` 可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员可以通过 Dockerfile 来进行镜像构建,并结合 [持续集成(Continuous Integration) ](https://zh.wikipedia.org/wiki/%E6%8C%81%E7%BA%8C%E6%95%B4%E5%90%88)系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合 [持续部署(Continuous Delivery/Deployment) ](https://zh.wikipedia.org/wiki/%E6%8C%81%E7%BA%8C%E4%BA%A4%E4%BB%98)系统进行自动部署。 而且使用 `Dockerfile` 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。 ## 更轻松的迁移 由于 `Docker` 确保了执行环境的一致性,使得应用的迁移更加容易。`Docker` 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云,甚至是笔记本,其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。 ## 更轻松的维护和扩展 `Docker` 使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得非常简单。此外,`Docker` 团队同各个开源项目团队一起维护了一大批高质量的 [官方镜像 ](https://hub.docker.com/search/?type=image\&image_filter=official),既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务的镜像制作成本。 ## 对比传统虚拟机总结 | 特性 | 容器 | 虚拟机 | | ---------- | ------------------ | ----------- | | 启动 | 秒级 | 分钟级 | | 硬盘使用 | 一般为 `MB` | 一般为 `GB` | | 性能 | 接近原生 | 弱于 | | 系统支持量 | 单机支持上千个容器 | 一般几十个 | ## 基本概念 **Docker** 包括三个基本概念:**镜像**(`Image`),**容器**(`Container`),**仓库**(`Repository`),理解了这三个概念,就理解了 **Docker** 的整个生命周期。 ## Docker 镜像 **镜像即根文件系统**:Docker 镜像本质上是一个轻量级的、独立的软件包,包含了运行一个应用所需的完整环境:程序、库、资源、配置以及参数(如环境变量)。它相当于一个 Linux 操作系统的 `root(根文件夹)` 文件系统。 **镜像是静态的**:镜像内容在构建完成后就不可更改,不包含任何动态数据。 **核心特性:分层存储** * **目的**:为了解决镜像体积庞大的问题,借鉴了 Union FS 技术。 * **工作原理**:镜像由多个只读的层(Layer)叠加而成。构建时,每一层都是在前一层的基础上进行修改。每一层构建完成后就不会再改变。 * **关键优势**: * **空间效率与复用**:不同的镜像可以共享相同的基础层,节省存储空间和下载时间。 * **可追溯性与缓存**:层的设计使得镜像的构建过程清晰,并且可以利用缓存加速构建。 * **重要注意事项**:在构建镜像的时候如果对文件进行了修改(如删除),事实上非真正删除底层数据,而是在当前层标记为删除,在分发使用的时候这个删除的文件(层)依旧占用空间。因此,在构建镜像时,应确保每一层只添加必要的内容,并及时清理临时文件,以避免镜像臃肿。 **简单来说,Docker 镜像是一个通过分层技术实现的、静态的、包含完整运行环境的文件系统,这种分层结构使其非常高效和灵活。** ## Docker 容器 **容器与镜像的关系**:容器是镜像的运行时实例。这种关系类似于面向对象编程中的**类和对象**。镜像是静态的、不可变的定义,而容器是动态的、可操作的实体。 **容器的本质**:容器的实质是一个**进程**,但是一个具有**隔离特性**的进程。它运行在独立的命名空间中,拥有自己的根文件系统、网络配置、进程空间和用户ID空间,从而与宿主机和其他容器隔离,提供了安全、独立的运行环境。 **容器的存储层**: * 容器基于镜像的分层之上,创建一个临时的、可读写的**容器存储层**。 * 该存储层的生命周期与容器**完全一致**:容器被删除,存储层中的数据也会丢失。 **数据持久化的最佳实践**: * **核心原则**:容器存储层应保持**无状态化**,不应向其写入需要持久化的数据。 * **正确方法**:必须使用**数据卷** 或**绑定宿主目录**来实现数据持久化。 * **优势**:这些方式的数据读写直接作用于宿主(或网络存储),性能更好、稳定性更高,并且生命周期独立于容器,容器删除后数据依然存在。 **简单来说,Docker 容器是镜像的运行实例,是一个隔离的进程。为了实现数据持久化,必须遵循最佳实践,使用数据卷而非容器内部的临时存储层。** ## Docker Registry * **Registry**:一个镜像仓库服务,其中可以管理多个**仓库**。 * **Repository**:一个**仓库**对应一个项目或软件(如 `ubuntu`),其中存放了该软件的不同版本。 * **Tag**:**标签**用于区分同一仓库中的不同版本(如 `18.04`, `latest`)。完整的镜像地址格式为 `<仓库名>:<标签>`。 **Registry 的两种主要类型**: 1. **公开服务** * **功能**:提供公共的镜像存储和分发。用户可以免费上传、下载公开镜像。 * **代表**: * [Docker Hub](https://hub.docker.com/):默认的官方仓库,拥有大量高质量官方镜像。 * **其他**:[Quay.io](https://quay.io/repository/)、[GitHub Container Registry (ghcr.io)](https://docs.github.com/cn/packages/working-with-a-github-packages-registry/working-with-the-container-registry), [Google Container Registry](https://cloud.google.com/container-registry/) 等。 * **国内特色**:由于网络原因,国内用户常使用**加速器**(如阿里云、DaoCloud加速器)或国内的公有仓库(如网易云、阿里云镜像库)来提升下载速度。 2. **私有服务** * **功能**:用户在本地或私有云环境下搭建,用于存储和分发内部使用的私有镜像。 * **搭建方式**: * [Docker Registry](https://hub.docker.com/_/registry/):官方提供的基础镜像,仅包含核心API服务,无图形界面。 * **第三方软件**:如 [Harbor](https://github.com/goharbor/harbor) 和 [Nexus](https://hub.docker.com/r/sonatype/nexus),提供图形界面、用户管理、安全扫描等高级功能 **一句话总结:Docker Registry 是镜像的“应用商店”,公开服务如同官方应用市场,而私有服务则如同企业内部私有的应用市场,用于安全地管理自有应用。** ## 使用 Docker 镜像 Docker 运行容器前需要本地存在对应的镜像,如果本地不存在该镜像,Docker 会从镜像仓库下载该镜像。 ### 获取镜像 从 Docker 镜像仓库获取镜像的命令是 `docker pull`。其命令格式为: ```bash docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签] ``` 具体的选项可以通过 `docker pull --help` 命令看到 ```bash docker pull --platform linux/arm64 ubuntu:20.04 ``` `--platform` 后面可以指定: 操作系统/架构,例如: * `linux/amd64` → x86\_64 架构 * `linux/arm64` → ARM 64 位架构 * `linux/arm/v7` → ARM 32 位架构 `ubuntu:20.04` → 要拉取的镜像 ```bash docker manifest inspect ubuntu:20.04 ``` 会列出镜像支持的 **所有平台和架构** **Docker 镜像仓库地址**:地址的格式一般是 `<域名/IP>[:端口号]`。默认地址是 Docker Hub `docker.io `。 **仓库名**:如之前所说,这里的仓库名是两段式名称,即 `<用户名>/<软件名>`。如果不给出用户名,则默认为 `library`,也就是官方镜像。 ```bash $ docker pull mysql:8.0 8.0: Pulling from library/mysql a803e7c4b030: Pull complete c3c8d5a7b6da: Pull complete 2b9f5f4d0fdb: Pull complete 5b93ad8d3a0e: Pull complete a03e6b85c0e6: Pull complete f07fa83e6c38: Pull complete e6f8c35cb7d9: Pull complete e6691f90c1d9: Pull complete 4e5bdf37d774: Pull complete a1a0e7c2f2c1: Pull complete 8b0f6a7dae38: Pull complete Digest: sha256:eb9c7e6fae0e7c90e3f66a7b2b4d1e2f3b4b5d6c7a8b9a0c1d2e3f4a5b6c7d8 Status: Downloaded newer image for mysql:8.0 docker.io/library/mysql:8.0 ``` **命令解析** * `docker pull mysql:8.0`:拉取官方 `mysql` 镜像的 `8.0` 标签版本。 * **仓库地址**:未指定地址,默认从 **Docker Hub** 下载。 * **完整镜像名**:输出最后一行显示为 `docker.io/library/mysql:8.0`,这验证了它来自 Docker Hub 的官方 `library` 仓库。 **核心特性:分层存储的直观体现** * 输出中显示了多行 `Pull complete`,每一行代表镜像的一个**层**被成功下载。 * MySQL 镜像比 Ubuntu 镜像更复杂,因此层数也更多(本例中有11层)。这印证了镜像是由多个只读层联合组成的。 **安全与一致性:数字摘要** * `Digest: sha256:eb9c7****c7d8` * 这是镜像内容的**唯一、安全的哈希值**。只要镜像内容不变,这个摘要就不会变。它可以用来精确验证你拉取的镜像是否与官方发布的镜像完全一致,防止镜像被篡改。 **镜像的维护与更新** * 如果你在不同时间拉取同一个标签(如 `mysql:8.0`)的镜像,看到的层ID和摘要**可能会不同**。 * 这是因为维护者会更新镜像(例如,打上安全补丁),然后用相同的标签重新发布。这确保了用户总能获取到该版本最新、最安全的镜像。 通过 `docker pull mysql:8.0` 的命令输出,我们清晰地看到了 **Docker 镜像从默认的 Docker Hub 仓库被分层下载的过程,并通过唯一的数字摘要来保证内容的一致性和安全性**,这是镜像工作机制的完美演示。 ### 运行镜像 ```bash docker run -d --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -p 3306:3306 mysql:8.0 # c710212735c9d64b6d18bd0b26f6bfeae78db0f52df22d8a3764b46d2d6c6f33 ``` **命令解析:** * **docker run**:核心命令,用于创建并启动一个新容器。 * **-d**:以**分离模式** 运行容器,即让容器在后台运行。这是运行服务器类应用(如数据库、Web服务器)的标准方式。 * **--name some-mysql**:为容器指定一个自定义名称 `some-mysql`,便于后续管理(如启动、停止、查看日志),否则Docker会随机生成一个名字。 * **-e MYSQL\_ROOT\_PASSWORD=my-secret-pw**:**-e 参数用于设置环境变量**。这是 MySQL 镜像的强制要求,通过它来设置 `root` 用户的密码。这是配置容器化应用的核心方式。 * **-p 3306:3306**:**端口映射**。将宿主机的 `3306` 端口映射到容器内部的 `3306` 端口。这样,你才能通过宿主机IP和端口访问容器内运行的MySQL服务。 * **mysql:8.0**:指定使用的镜像名称和标签。 * 长串哈希值:命令执行后返回的是容器的唯一ID,表示容器已成功在后台启动。 *** ```bash docker run -it --rm ubuntu:18.04 bash ``` * **-it**:这是两个参数的组合,用于获取一个**交互式终端**。 * `-i`:保持标准输入打开,允许你与容器交互。 * `-t`:分配一个伪终端,让你像在真实服务器上一样操作。 * **--rm**:一个非常实用的参数。表示**容器退出后自动删除**。这适合临时性的测试任务,避免留下大量停止的容器占用磁盘空间。 * **ubuntu:18.04**:指定用于创建容器的**基础镜像**。 * **bash**:在容器启动后要执行的**命令**,这里表示启动一个 Bash shell。 **进入容器**:命令执行后,会切换到容器内部的 Shell 提示符(如 `root@e7009c6ce357:/#`)。 **执行命令**:在容器内可以执行任何命令,例如 `cat /etc/os-release`,确认了容器内运行的是 Ubuntu 18.04 系统。 **退出容器**:使用 `exit` 命令退出 Bash shell,容器随之停止。由于使用了 `--rm` 参数,该容器也会被自动删除。 `docker run -it --rm` 是一个经典的测试命令组合,它能让你快速进入一个临时容器进行操作,退出后容器自动清理,非常方便快捷。 | 命令 | 作用 | 是否创建新容器 | 使用场景 | | ----------------- | ------------------ | -------------- | ---------------------------------------------- | | `docker run -it` | 启动容器并进入交互 | 是 | 想从镜像新建并进入一个容器时 | | `docker exec -it` | 在容器里执行命令 | 否 | 容器已存在并在运行时,想进去看看、调试、跑命令 | ### 列出镜像 ```bash docker image ls # docker images(快捷方式) ``` ```bash REPOSITORY TAG IMAGE ID CREATED SIZE ``` 列表包含了 `仓库名`、`标签`、`镜像 ID`、`创建时间` 以及 `所占用的空间`。 **Docker Hub 上的大小**:显示的是 **压缩后的镜像体积**(网络传输大小),用于下载或上传时参考。 **`docker image ls` 显示的大小**:显示 **本地展开后的镜像各层占用的总空间**,更准确地反映磁盘占用。 **镜像实际占用空间**: Docker 镜像是 **多层结构(layered)**,可以复用相同的基础层。 多个镜像共享相同层时,每层只保存一次。 因此 **不同镜像大小之和 ≠ 实际硬盘占用**,实际占用通常更小。 查看实际空间占用,使用命令: ```bash docker system df ``` **Images**:所有镜像占用空间 **Containers**:所有容器占用空间 **Local Volumes**:卷占用空间 **Build Cache**:构建缓存占用空间 **Reclaimable**:可回收空间(未使用的镜像层、卷等) ```shell root@armbian:~# docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 11 7 1.383GB 687.4MB (49%) Containers 7 7 122.4MB 0B (0%) Local Volumes 3 1 4.559MB 0B (0%) Build Cache 0 0 0B 0B ``` 还可以根据仓库名列出镜像,比如显示所有 **ubuntu** 仓库的镜像: ```bash docker image ls ubuntu ``` 仓库名 + 标签列出特定镜像 ```bash docker image ls ubuntu:18.04 ``` 使用过滤器 `--filter` / `-f` **按时间过滤** * 列出 **在某个镜像之后创建的镜像**: ```bash docker image ls -f since=mongo:3.2 ``` * 列出 **在某个镜像之前创建的镜像**: ```bash docker image ls -f before=mongo:3.2 ``` **按标签过滤** 如果镜像构建时定义了 **LABEL**,可以用标签筛选: ```bash docker image ls -f label=com.example.version=0.1 ``` `docker image ls` 默认会输出一个完整的表格,适合直接查看整体情况。 `docker image ls -q` 只输出 **镜像 ID**,一行一,用途:常与其他命令配合,如删除镜像: ```bash docker rmi $(docker image ls -q -f dangling=true ``` 可以通过过滤条件筛选需要的镜像,例如: ```bash docker image ls -f dangling=true -q ``` 只输出 **虚悬镜像(dangling image)** 的 ID。 常见过滤条件:`dangling=true/false`、`before=`、`reference=` 等。 自定义输出通过 `--format` 参数 + Go 模板语法,适合更灵活的场景,比如: * **自定义列内容** ```bash docker image ls --format "{{.ID}}: {{.Repository}}" ``` 只显示 **ID 和仓库名**。 自定义表格 ```bash docker image ls --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}" ``` 效果与默认类似,但可以指定自己想要的列,且带标题行。 常见可用的字段有:`.ID`,`.Repository`,`.Tag`,`.Digest`,`.Size`,`.CreatedSince`,`.CreatedAt` **总结要点** * **`-q`** → 精简模式,常用于脚本和批处理操作。 * **`--filter/-f`** → 精确筛选目标对象(镜像、容器、网络等都支持)。 * **`--format`** → 使用 Go 模板自定义输出,方便人读或者机器解析。 ### 删除镜像 如果要删除本地的镜像,可以使用 `docker image rm` 命令,其格式为: ```bash docker image rm [选项] <镜像> # docker image rm redis:latest nginx:latest mongo:3.2 # docker image rm 5f515359c7f8 05a60462f8ba fe9198c04d62 ``` 删除所有悬空镜像(无标签镜像) ```bash docker image rm $(docker images -f "dangling=true" -q) ``` 删除所有仓库名为 `redis` 的镜像: ```bash docker image rm $(docker image ls -q redis) ``` 或者删除所有在 `mongo:3.2` 之前的镜像: ```bash docker image rm $(docker image ls -q -f before=mongo:3.2) ``` `<镜像>` 可以是 `镜像短 ID`、`镜像长 ID`、`镜像名` 或者 `镜像摘要`。 **镜像摘要**(Digest)是基于镜像内容生成的全局唯一哈希,用于保证内容一致性和安全验证。 * **镜像 ID** 和 **摘要 Digest** 都是基于 SHA256 的哈希 * **ID** 更多用于本地管理和层标识 * **Digest** 用于内容唯一性验证和远程镜像一致性检查 查看长ID: ```bash docker image ls --no-trunc ``` ```bash docker image inspect ubuntu:18.04 ``` 查询单个镜像的详细信息,输出中也会有长ID **Untagged 和 Deleted** **镜像的唯一标识** * 镜像由 **ID** 和 **摘要** 唯一确定 * **一个镜像可以有多个标签**(Repository:Tag) **删除标签 → Untagged** * 使用 `docker image rm <镜像>` 删除的是 **标签** * 如果镜像还有其它标签指向它,命令只会 **取消指定标签(Untagged)** * 镜像本身仍然存在,直到所有标签都被取消 **删除镜像 → Deleted** * 当镜像 **没有任何标签** 且 **没有被其它镜像依赖**,才会触发真正的删除(Deleted) * Docker 会从上层向基础层依次判断哪些层可以删除 **镜像层依赖** * 镜像是多层结构(UnionFS) * 如果某层被其它镜像或容器依赖,该层不会被删除 * 直到 **没有任何镜像或容器依赖该层**,才会真正释放存储 **容器依赖** * 如果有容器基于该镜像(即使未运行),镜像也不能删除 * 删除镜像前应确保相关容器已经删除 ### 悬空镜像 ```bash 00285df0df87 2 days ago 532 MB ``` **悬空镜像**就是 **没有名字/标签的镜像**,常见于镜像更新、构建未打 tag 或手动删除 tag 后遗留的旧镜像。它占用空间但无法直接通过名字访问,可以安全清理。 ::: details 悬停镜像的产生 假设你第一次拉取 `ubuntu:18.04`: ```bash docker pull ubuntu:18.04 ``` 本地生成一个镜像 A tag 指向 A: ```bash ubuntu:18.04 -> 镜像 A (ID: abc123) ``` 此时没有悬空镜像。 官方推送了新版 `ubuntu:18.04`,你再拉取: ```bash docker pull ubuntu:18.04 ``` Docker 下载新版镜像 B(ID: def456) tag `ubuntu:18.04` **现在指向 B** 镜像 A **失去 tag**,变成 `:` **结果**: ```bash : -> 镜像 A (旧版本) ubuntu:18.04 -> 镜像 B (新版本) ``` 镜像 A 仍在本地,只是没有名字了 → **悬空镜像**。 ```bash docker build . ``` 如果在构建镜像的时候没有使用 `-t` 给镜像打名字,构建完成的镜像也会是 `:`。 这种情况和旧镜像失去 tag 是一样的结果。 ::: 悬空镜像本身没有名字和直接用途,**大多数情况下已经没有价值**,清理它可以节省磁盘空间,只有你明确想保留的,才需要重新打 tag 保存。 ```bash docker image prune ``` `docker image prune` 的作用是 **清理本地未使用的 Docker 镜像**,特别是那些 **悬空镜像(dangling images)**,释放磁盘空间。 默认只删除 **dangling 镜像**(即 `:` 的镜像),删除前会有提示,需要确认 `-f` 或 `--force` 不再提示,直接删除 `-a` 或 `--all` 删除 **所有未被容器使用的镜像**,不仅仅是悬空镜像,**注意**:如果某个镜像没有 tag 或未被容器使用,会被删除。 还可以按 **过滤条件**删除,比如,删除 **24 小时前未使用的镜像**: ```bash docker image prune -a --filter "until=24h" ``` ### 中间层镜像 Docker 构建镜像时会生成 **中间层镜像**,用于 **加速构建和重复利用资源**。 每一层对应 Dockerfile 的一个命令(`RUN` / `COPY` / `ADD` 等),上层镜像会依赖下层中间层。 `docker image ls` 只显示 **顶层镜像**(有 tag 的镜像)。 如果想显示 **包括中间层在内的所有镜像** `docker image ls -a` 你会看到很多 **无标签的镜像(``)**。 这些无标签镜像大多是 **中间层**,是其他镜像的依赖。 **管理注意事项** * **不要删除这些中间层镜像**,否则依赖它们的上层镜像可能会出错。 * 这些镜像 **不会重复占用磁盘空间**,因为 Docker 会复用相同层。 * 当删除顶层镜像后,依赖的中间层如果没有其他引用,才会被连带清理。 **总结**:中间层镜像是 Docker 的内部实现细节,用于构建和复用,不必手动管理。只要保留顶层镜像,Docker 会自动管理中间层的存储和复用。 ### 镜像快照 `docker commit` 用于 **将容器当前状态保存为一个新的镜像**,相当于把容器“拍快照”变成可复用的镜像。 **把容器改动保存成镜像** * 当你在容器里安装了软件、修改了配置、添加了文件,想保留这些改动 * 使用 `docker commit` 就可以生成一个新的镜像,后续可以用它启动新容器 **生成自定义镜像** * 可以基于官方镜像快速做定制,而不必每次都写 Dockerfile ```bash docker commit [OPTIONS] <容器ID或名称> <新镜像名>:<标签> ``` `-a`:作者信息 `-m`:提交说明(commit message) `--change`:修改 Dockerfile 指令(如 ENV、CMD、EXPOSE 等) 当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 `docker commit` 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原有容器最后的文件变化。 :::note **慎用 `docker commit`** **镜像容易臃肿** * `docker commit` 会把容器当前状态 * 直接打包成新镜像。 * 容器里除了你想修改的文件外,还有许多临时文件、缓存、日志、安装包残留等,都会被打包进去。 * 每次 commit 都会增加一层,这些无关内容会长期存在于镜像中,无法清理。 **黑箱操作,不可追踪** * commit 是对容器文件系统的快照,不记录具体的命令和操作步骤。 * 除了制作人,别人几乎无法复现镜像的构建过程。 * 即使是制作人,过一段时间后也很难记起具体操作,维护成本高。 **分层机制导致问题积累** * Docker 镜像的分层是不可变的,每一层都是只读的。 * commit 只会在当前层增加修改记录,不会删除上一层的内容。 * 结果是镜像越来越大,即使你删除了容器里的文件,底层层依然占空间。 **维护困难** * 黑箱镜像不利于团队协作。 * 后期修改或调试时,很难知道镜像是如何生成的。 ::: ### 容器历史记录 `docker history` 是 Docker 提供的一个命令,用来 **查看镜像(image)每一层的历史信息**。它可以帮助你了解镜像是如何构建的,每一层的大小,以及每一层对应的命令。 ```bash docker history [OPTIONS] IMAGE ``` | 选项 | 说明 | | ------------- | -------------------------------- | | `-H, --human` | 以可读格式显示大小(默认是字节) | | `--no-trunc` | 不截断输出,显示完整信息 | | `-q, --quiet` | 只显示层ID,不显示其他信息 | ```bash docker history nginx:latest IMAGE CREATED CREATED BY SIZE COMMENT cfc29b0b0b1d 3 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemo… 0B 3 weeks ago /bin/sh -c #(nop) EXPOSE 80 0B 3 weeks ago /bin/sh -c #(nop) STOPSIGNAL SIGTERM 0B 3 weeks ago /bin/sh -c apt-get update && apt-get install… 21.6MB 3 weeks ago /bin/sh -c #(nop) LABEL maintainer=NGINX D… 0B ``` * **IMAGE**:这一层的镜像ID。 * **CREATED**:这一层创建的时间。 * **CREATED BY**:执行创建这一层的命令。 * **SIZE**:这一层的大小。 * **COMMENT**:可选备注信息。 **主要用于**: **分析镜像大小**,可以找出哪些层占用空间大,从而优化镜像。 **追踪镜像构建历史**,了解镜像是如何一步步构建出来的,特别是从别人提供的镜像中学习 Dockerfile 的写法。 **排查问题**,如果某个镜像层有问题,可以通过 `docker history` 确认具体命令和层信息。 *** 当你执行 `docker commit` 时,Docker 会在镜像上 **增加一层**(类似新的“快照”层)。 `docker history` 可以显示这个镜像的 **所有层历史**,包括用 `docker commit` 创建的层。 所以,如果你对某个镜像做过 commit,然后查看 `docker history`,就会看到对应的那一层。 `docker history` 只能显示 **每层的创建命令、时间和大小**,它不会直接告诉你是哪个容器做的 commit,但你可以通过 **commit 的时候加标签或注释** 来标记: ```bash docker commit -m "修改配置文件" mycontainer myimage:latest ``` 然后在 `docker history myimage:latest` 中,你就能看到 `COMMENT` 列显示 `"修改配置文件"`,知道这层是你 commit 的。 ### 更改容器 ```bash docker run --name webserver -d -p 80:80 nginx ``` ```bash docker exec -it webserver bash root@3729b97e8226:/# echo '

Hello, Docker!

' > /usr/share/nginx/html/index.html root@3729b97e8226:/# exit exit ``` 修改了容器的文件,也就是改动了容器的存储层。我们可以通过 `docker diff` 命令看到具体的改动。 ### 查看容器差异 ```bash docker diff [容器] ``` `docker diff` 就是用来查看容器相对于镜像的 **文件系统改动**,它会输出三种类型的变化标记: **A (Added)**:新增了一个文件或目录 **C (Changed)**:修改了一个已有的文件或目录(内容变了,或者元数据变了,比如权限、时间戳) **D (Deleted)**:删除了一个文件或目录 ## Dockerfile [Dockerfile相关](/posts/vhajxjfz/) ## 操作容器 [操作容器相关](/posts/u98f21md/) ## 导入和导出 Docker 还提供了 `docker save` 和 `docker load` 命令,用以将镜像保存为一个文件,然后传输到另一个位置上,再加载进来。这是在没有 Docker Registry 时的做法,现在已经不推荐,镜像迁移应该直接使用 Docker Registry,无论是直接使用 Docker Hub 还是使用内网私有 Registry 都可以。 ```bash docker save -o <保存文件名>.tar <镜像名>:<标签> docker load -i <保存文件名>.tar ``` 文件名可以自定义为任意名称甚至任意后缀名,但文件的本质都是归档文件,但是推荐使用 `.tar` 后缀名以示区别。 **注意:如果同名则会覆盖(没有警告)** 若使用 `gzip` 压缩: ```bash docker save <镜像名>:<标签> | gzip > <保存文件名>.tar.gz ``` 然后我们将 `<保存文件名>.tar.gz` 文件复制到了到了另一个机器上,可以用下面这个命令加载镜像: ```bash docker load -i <保存文件名>.tar.gz ``` ## 镜像的实现原理 **分层结构** * 每个镜像由很多只读层组成(每个层对应 Dockerfile 里的一条指令)。 * 层与层之间叠加,最终形成完整镜像。 **联合文件系统(UnionFS / OverlayFS)** * 技术上通过一种叫 UnionFS(Docker 常用 OverlayFS)的文件系统,把多层目录“合并”为一个目录。 * 上层可以覆盖下层的同名文件,看起来就像一个整体。 **可写层** * 容器启动时,镜像层是只读的,Docker 会在顶层加一个**可写层**,所有的修改都写在这一层。 * 镜像本身不变,因此能保证复用和一致性。 **好处** * 节省空间:多个镜像可以共享相同的底层层。 * 快速构建:只修改或新增的部分会形成新的一层。 * 可维护:删除容器不会影响镜像。 **Docker 镜像是由多层只读层叠加组成的,运行时再加一个可写层,借助 OverlayFS 看起来像一个完整文件系统,从而实现增量构建、复用和轻量化。** ## Docker Hub 可以通过执行 `docker login` 命令交互式的输入用户名及密码来完成在命令行界面登录 Docker Hub。 可以通过 `docker logout` 退出登录。 你可以通过 `docker search` 命令来查找官方仓库中的镜像,并利用 `docker pull` 命令来将它下载到本地。 在查找的时候通过 `--filter=stars=N` 参数可以指定仅显示收藏数量为 `N` 以上的镜像。 用户也可以在登录后通过 `docker push` 命令来将自己的镜像推送到 Docker Hub。 ```bash docker tag ubuntu:18.04 username/ubuntu:18.04 docker push username/ubuntu:18.04 ``` ## 数据管理 ### 数据卷 `数据卷` 是一个可供一个或多个容器使用的特殊目录,它绕过 UnionFS,可以提供很多有用的特性: **Docker 卷(Volume)** 是挂载在容器内的 **宿主机目录**(或者由 Docker 管理的卷路径)。 容器对卷的读写操作,实际上是在操作宿主机上的真实文件系统。 因此,无论在容器内还是宿主机上修改卷里的文件,**都会立即生效**,不需要重启容器。 即使容器删除,卷里的数据依然保留在宿主机上,下次启动新容器时可以继续使用,但是要注意挂载路径必须一致。 ### 创建数据卷 ```bash docker volume create [选项] <卷名> ``` `选项` 常用的有: * `-d, --driver`:指定卷的驱动(默认是 `local`) * `--label`:添加标签 * `--opt`:传递驱动特定选项 `卷名`:自定义卷的名字,如果不指定,Docker 会生成一个随机名字 ### 查看数据卷 查看所有的 `数据卷` ```bash docker volume ls ``` 查看指定 `数据卷` 的信息 ```bash docker volume inspect <卷名> ``` ### 挂载数据卷 在用 `docker run` 命令的时候,使用 `--mount` 标记来将 `数据卷` 挂载到容器里。在一次 `docker run` 中可以挂载多个 `数据卷`。 下面创建一个名为 `web` 的容器,并加载一个 `数据卷` 到容器的 `/usr/share/nginx/html` 目录。 ```bash docker run -d -P \ --name web \ # -v <卷名>:/usr/share/nginx/html \ --mount source=<卷名>,target=/usr/share/nginx/html \ nginx:alpine ``` ### 删除数据卷 ```bash docker volume rm <卷名> ``` `数据卷` 是被设计用来持久化数据的,它的生命周期独立于容器,Docker 不会在容器被删除后自动删除 `数据卷`,并且也不存在垃圾回收这样的机制来处理没有任何容器引用的 `数据卷`。 如果需要在删除容器的同时移除数据卷。可以在删除容器的时候使用 `docker rm -v` 这个命令。 无主的数据卷可能会占据很多空间,要清理请使用以下命令 ```bash docker volume prune ``` ## 挂载目录 挂载一个主机目录作为数据卷,使用 `--mount` 标记可以指定挂载一个本地主机的目录到容器中去。 ```bash docker run -d -P \ --name web \ # -v /src/webapp:/usr/share/nginx/html \ --mount type=bind,source=/src/webapp,target=/usr/share/nginx/html \ nginx:alpine ``` 上面的命令加载主机的 `/src/webapp` 目录到容器的 `/usr/share/nginx/html`目录。 这个功能在进行测试的时候十分方便,比如用户可以放置一些程序到本地目录中,来查看容器是否正常工作。 本地目录的路径必须是绝对路径,并且使用 `--mount` 参数时如果本地目录不存在,Docker 会报错。 Docker 挂载主机目录的默认权限是 `读写`,用户也可以通过增加 `readonly` 指定为 `只读`。 ```bash docker run -d -P \ --name web \ # -v /src/webapp:/usr/share/nginx/html:ro \ --mount type=bind,source=/src/webapp,target=/usr/share/nginx/html,readonly \ nginx:alpine ``` 加了 `readonly` 之后,就挂载为 `只读` 了。如果你在容器内 `/usr/share/nginx/html` 目录新建文件,会显示如下错误 ```bash /usr/share/nginx/html # touch new.txt touch: new.txt: Read-only file system ``` --- --- url: /docker/ch7eyil2/index.md --- ## Node(nest) Dockerfile `dockerfile` ```dockerfile # =========================== # 构建阶段 # =========================== FROM node:20-alpine AS builder WORKDIR /app # 复制依赖文件 COPY package*.json ./ # 安装依赖(包含 devDependencies) RUN npm ci # 复制源代码 COPY . . # 构建 Nest 应用 RUN npm run build # =========================== # 运行阶段 # =========================== FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # 复制 package.json 以安装生产依赖 COPY package*.json ./ # 仅安装生产依赖 RUN npm ci --omit=dev && npm cache clean --force # 从构建阶段复制编译结果 COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/main.js"] ``` `.dockerignore` ```dockerfile node_modules dist .git .gitignore .vscode test Dockerfile docker-compose* *.md .env ``` ## GHCR 推送工作流模板 ```yaml name: Build and Push to GHCR on: push: branches: - main - master - release/* workflow_dispatch: inputs: image_name: description: 'Docker image name (e.g., nestapp)' required: false default: yumengjh/nestapp tag: description: 'Docker tag (e.g., 1.0.0)' required: false default: 1.0.0 platforms: description: 'Build platforms (comma separated)' required: false default: 'linux/amd64,linux/arm64' build_args: description: 'Build arguments (key=value,key=value)' required: false context: description: 'Build context path' required: false default: '.' dockerfile: description: 'Dockerfile path' required: false default: './Dockerfile' env: DEFAULT_REGISTRY_URL: ghcr.io DEFAULT_REGISTRY_USERNAME: ${{ github.actor }} DEFAULT_REGISTRY_PASSWORD: ${{ secrets.GH_TOKEN }} jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Prepare variables id: vars run: | # 设置默认值 IMAGE_NAME="${{ github.event.inputs.image_name || 'yumengjh/nestapp' }}" TAG="${{ github.event.inputs.tag || '1.0.0' }}" PLATFORMS="${{ github.event.inputs.platforms || 'linux/amd64,linux/arm64' }}" CONTEXT="${{ github.event.inputs.context || '.' }}" DOCKERFILE="${{ github.event.inputs.dockerfile || './Dockerfile' }}" BUILD_ARGS="${{ github.event.inputs.build_args }}" echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV echo "TAG=$TAG" >> $GITHUB_ENV echo "PLATFORMS=$PLATFORMS" >> $GITHUB_ENV echo "CONTEXT=$CONTEXT" >> $GITHUB_ENV echo "DOCKERFILE=$DOCKERFILE" >> $GITHUB_ENV echo "BUILD_ARGS=$BUILD_ARGS" >> $GITHUB_ENV # Registry info echo "REGISTRY_URL=${{ env.DEFAULT_REGISTRY_URL }}" >> $GITHUB_ENV echo "REGISTRY_USERNAME=${{ env.DEFAULT_REGISTRY_USERNAME }}" >> $GITHUB_ENV echo "REGISTRY_PASSWORD=${{ env.DEFAULT_REGISTRY_PASSWORD }}" >> $GITHUB_ENV - name: Show build configuration run: | echo "-------------------------------------------" echo "Registry URL: $REGISTRY_URL" echo "Image: $IMAGE_NAME" echo "Tag: $TAG" echo "Platforms: $PLATFORMS" echo "Context: $CONTEXT" echo "Dockerfile: $DOCKERFILE" echo "Build Args: $BUILD_ARGS" echo "-------------------------------------------" - name: Login to GHCR run: | echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USERNAME" --password-stdin - name: Build and push multi-arch image uses: docker/build-push-action@v6 with: context: ${{ env.CONTEXT }} file: ${{ env.DOCKERFILE }} push: true platforms: ${{ env.PLATFORMS }} tags: | ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:latest build-args: ${{ env.BUILD_ARGS }} - name: Build summary if: success() run: | echo "-------------------------------------------" echo "Build and push completed successfully." echo "Registry URL: $REGISTRY_URL" echo "Image: $IMAGE_NAME" echo "Tag: $TAG" echo "Platforms: $PLATFORMS" echo "-------------------------------------------" ``` ## ACR 推送工作流模板 ```yaml name: Build & Push to ACR on: push: branches: - main - master - release/* workflow_dispatch: inputs: image_name: description: 'Docker image name' required: false default: nestapp tag: description: 'Docker tag' required: false default: 1.0.0 platforms: description: 'Build platforms (comma separated)' required: false default: linux/amd64,linux/arm64 env: ACR_URL: ${{ secrets.ACR_REGISTRY }} ACR_USERNAME: ${{ secrets.ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} jobs: build-and-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - name: Login to ACR run: | echo "$ACR_PASSWORD" | docker login $ACR_URL -u $ACR_USERNAME --password-stdin - name: Build and push multi-arch image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true platforms: ${{ github.event.inputs.platforms }} tags: | $ACR_URL/${{ github.event.inputs.image_name }}:${{ github.event.inputs.tag }} $ACR_URL/${{ github.event.inputs.image_name }}:latest ``` ## manifest GHCR(GitHub Container Registry)在多架构镜像的处理逻辑上和 ACR 或 Docker Hub 没有本质区别,它也是基于 **OCI 镜像规范** 来管理镜像和 manifest 的 当你使用 `docker buildx` 构建 multi-platform 镜像时,会生成一个 **顶层 manifest list**,指向不同平台的具体镜像。 ```bash cr.io/your-org/your-image:latest ├─ linux/amd64 → sha256:aaa... ├─ linux/arm64 → sha256:bbb... ├─ linux/arm/v7 → sha256:ccc... ``` 顶层 manifest (`application/vnd.oci.image.index.v1+json`) 是 **一个索引**,只显示一条记录在界面上,但它包含了每个平台镜像的 digest。 在 GHCR Web 界面上,你看到的也通常只有一个条目(最新版本或 tag),不会直接显示每个平台的 digest。 实际上多平台的镜像已经上传完毕,Docker 客户端拉取镜像时,会自动选择当前主机的架构: ```bash # 在 amd64 主机上 docker pull ghcr.io/your-org/your-image:latest # 自动拉取 linux/amd64 镜像 # 在 arm64 主机上 docker pull ghcr.io/your-org/your-image:latest # 自动拉取 linux/arm64 镜像 ``` 你可以使用 `docker buildx imagetools inspect` 查看 GHCR 上的所有平台信息: ```bash docker buildx imagetools inspect ghcr.io/your-org/your-image:latest ``` 输出示例: ```bash Name: ghcr.io/your-org/your-image:latest MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:... Manifests: Name: ghcr.io/your-org/your-image:latest@sha256:aaa... MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/amd64 Name: ghcr.io/your-org/your-image:latest@sha256:bbb... MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 ``` **attestation manifest** 如果你在 workflow 中开启了 `attest`(如 buildx 支持的 `--attest type=provenance`),会生成一些 `unknown/unknown` 的 attestation manifests。 功能: * 用于安全审计和镜像来源验证 * 不影响正常的拉取和运行 * 在 Web 界面上通常不会显示,但通过 `docker buildx imagetools inspect` 可以看到 > \[!important] > > GHCR、ACR、Docker Hub 都支持 multi-platform 镜像。 > > Web 界面通常只显示一条 tag,但实际每个平台的镜像都存在。 > > 拉取镜像时,Docker 根据本地平台自动选择正确架构。 > > 可以使用 `docker buildx imagetools inspect <镜像>` 来查看每个平台的 digest。 > > attestation manifest 是安全信息,不影响使用。 **Web 界面看到的“版本号”和 Digest** * **版本号(tag)**:比如 `latest` 或 `1.0.0`,这是你给镜像打的标签。 * **Digest**:Web 界面显示的 Digest,指的是 **顶层 manifest list 的 digest**,也就是 multi-arch 镜像的索引本身的 SHA256,而不是某个平台的实际镜像 SHA。 **换句话说**,这个 digest 是 “索引文件”的 checksum,不是 amd64 或 arm64 镜像的 digest。 **manifest list 与具体平台镜像** 当你构建多架构镜像并推送时,`docker buildx` 会生成: **顶层 manifest list**(索引文件) * 指向每个平台的镜像 * Web 界面显示的 digest 就是这个 **各个平台的实际镜像**(amd64、arm64、arm/v7 等) * 每个平台镜像也有自己的 digest * 拉取镜像时,Docker 会根据本机架构自动选择对应平台镜像 可以通过命令查看全部平台: ```bash docker buildx imagetools inspect : ``` --- --- url: /docker/ez70eegi/index.md --- # Docker Buildx ## 简介 ### 什么是 Docker Buildx? **Docker Buildx** 是 Docker 官方提供的一个 CLI 插件,它扩展了 `docker build` 命令的功能。其核心是建立在 **Moby BuildKit** 之上的下一代镜像构建工具。 你可以把它理解为 **`docker build` 命令的增强版**。它保留了用户熟悉的 `docker build` 使用体验,但同时增加了许多强大的新特性,其中最核心的就是**跨平台构建**。 ### 为什么需要 Buildx? 1. **跨平台构建困难**:在 x86\_64 的机器上,很难直接构建出适用于 ARM(例如树莓派)、PowerPC 等架构的镜像。传统方法需要复杂的交叉编译环境或在对应架构的机器上进行构建。 2. **构建性能瓶颈**:传统的 Docker 构建器在某些方面效率不高。 3. **功能单一**:传统的 `docker build` 缺乏对复杂构建场景(如多镜像并发构建、高级缓存管理)的原生支持。 ### Buildx 的核心特性 #### 跨平台构建 这是 Buildx 最引人注目的功能。它允许你在一台机器上(例如,你的 Intel Mac 或 AMD Linux 电脑)为多种不同的 CPU 架构构建 Docker 镜像。 **支持的平台包括:** * `linux/amd64` (常规的 Intel/AMD 服务器和桌面) * `linux/arm64` (苹果 M1/M2 Mac,AWS Graviton 处理器,现代树莓派) * `linux/arm/v7` (旧版树莓派等) * `linux/ppc64le` * `linux/s390x` * `windows/amd64` **实现方式:** Buildx 通过 QEMU 仿真和 BuildKit 的能力,在后台自动模拟目标平台的环境,使得构建过程就像在目标平台上进行一样。 #### 高效的构建缓存 Buildx 提供了比传统构建更强大的缓存机制,支持将缓存信息存储在本地、注册表内或特定的缓存卷中。这在与 CI/CD 流水线集成时特别有用,可以大幅加速后续构建。 #### 并发构建 可以一次性为多个平台构建镜像,而不是逐个平台地执行 `docker build` 命令。这极大地提升了效率。 #### 可扩展的构建器后端 Buildx 支持不同的“构建器实例”。你可以创建使用不同配置的构建器,例如: * `docker-container` 驱动:这是功能最强大的驱动,支持跨平台构建等所有高级功能。 * `docker` 驱动:使用与旧版 `docker build` 相同的守护进程,功能有限。 * `kubernetes` 驱动:在 Kubernetes Pod 中运行构建任务。 #### 创建清单列表 当你为多个平台构建镜像后,Buildx 可以自动创建一个 **Docker 清单列表**。这个清单指向所有不同架构的镜像。当用户使用 `docker pull` 或 `docker run` 时,Docker 会自动根据其系统架构选择正确的镜像。 ### 如何使用 Docker Buildx #### 前提条件 * Docker Engine 版本 19.03 或更高。 * 对于 Linux,需要额外安装 QEMU 静态二进制文件以支持跨平台构建(通常通过 `binfmt-support` 包)。 #### 基本使用步骤 1. **启用实验性功能**(如果尚未启用):编辑 `~/.docker/config.json` 文件,确保包含: ```json { "experimental": "enabled" } ``` 然后重启 Docker 守护进程。 2. **创建并使用一个 Buildx 构建器**:默认的构建器可能功能有限,建议创建一个新的。 ```bash # 创建一个新的构建器实例,并使用 'docker-container' 驱动 docker buildx create --name mybuilder --driver docker-container --bootstrap # 使用这个构建器 docker buildx use mybuilder ``` 3. **检查构建器状态**: ```bash docker buildx inspect mybuilder ``` 4. **开始构建**: **单平台构建**(与 `docker build` 类似): ```bash docker buildx build -t your-username/your-app:latest . ``` **多平台构建**(核心功能): ```bash docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t your-username/your-app:latest --push . ``` `--platform`:指定目标平台列表。 `--push`:构建完成后,自动将镜像和清单列表推送到 Docker Hub 或其他容器注册中心。**如果不加此参数,构建结果只会存在于本地缓存中,无法被 `docker images` 看到或使用。** 5. **查看支持的平台**: ```bash docker buildx ls ``` ### 实战示例 假设你有一个 Go 语言应用,想要为 Intel 和 Apple Silicon Mac 以及树莓派构建镜像。 ##### 准备 Dockerfile: ```dockerfile # 使用多阶段构建 FROM --platform=$BUILDPLATFORM golang:1.19 AS builder ARG TARGETARCH WORKDIR /app COPY . . RUN GOOS=linux GOARCH=$TARGETARCH go build -o myapp . FROM alpine:latest WORKDIR /root/ COPY --from=builder /app/myapp . CMD ["./myapp"] ``` ##### 执行 Buildx 多平台构建并推送: ```bash docker buildx build \ --platform linux/amd64,linux/arm64,linux/arm/v7 \ -t your-dockerhub-username/multi-arch-example:latest \ --push \ . ``` ##### 验证: 构建并推送成功后,你可以在 Docker Hub 上看到该标签下关联了多个架构的镜像。 任何人使用 `docker pull your-dockerhub-username/multi-arch-example:latest` 时,Docker 都会自动为他们分配合适的架构版本。 ### 总结 | 特性 | 传统 `docker build` | Docker Buildx | | ------------ | ------------------- | -------------------------------------- | | **核心功能** | 单平台镜像构建 | **跨平台构建**,支持一次构建多架构镜像 | | **构建后端** | 旧版构建器 | **BuildKit**(性能更好,功能更强) | | **输出** | 单一镜像 | 支持创建**多架构清单列表** | | **缓存** | 基础缓存 | 高级、可配置的缓存后端(如注册表缓存) | | **并发性** | 无 | 支持**并发多平台构建** | **总而言之,Docker Buildx 是现代 Docker 镜像构建的推荐工具。** 尤其是当你需要支持多种硬件架构(如云服务器、个人电脑、物联网设备)时,它几乎是不可或缺的,能极大地简化和加速你的 CI/CD 和工作流程。 ## 使用 BuildKit 构建镜像 **BuildKit** 是下一代的镜像构建组件,在 https://github.com/moby/buildkit 开源。 **注意:如果您的镜像构建使用的是云服务商提供的镜像构建服务(腾讯云容器服务、阿里云容器服务等),由于上述服务提供商的 Docker 版本低于 18.09,BuildKit 无法使用,将造成镜像构建失败。建议使用 BuildKit 构建镜像时使用一个新的 Dockerfile 文件(例如 Dockerfile.buildkit)** 目前,Docker Hub 自动构建已经支持 buildkit,具体请参考 https://github.com/docker-practice/docker-hub-buildx ### Dockerfile 新增指令详解 启用 `BuildKit` 之后,我们可以使用下面几个新的 `Dockerfile` 指令来加快镜像构建。 #### RUN --mount=type=cache 目前,几乎所有的程序都会使用依赖管理工具,例如 `Go` 中的 `go mod`、`Node.js` 中的 `npm` 等等,当我们构建一个镜像时,往往会重复的从互联网中获取依赖包,难以缓存,大大降低了镜像的构建效率。 例如一个前端工程需要用到 `npm`: ```dockerfile FROM node:alpine as builder WORKDIR /app COPY package.json /app/ RUN npm i --registry=https://registry.npm.taobao.org \ && rm -rf ~/.npm COPY src /app/src RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /app/dist ``` 使用多阶段构建,构建的镜像中只包含了目标文件夹 `dist`,但仍然存在一些问题,当 `package.json` 文件变动时,`RUN npm i && rm -rf ~/.npm` 这一层会重新执行,变更多次后,生成了大量的中间层镜像。 为解决这个问题,进一步的我们可以设想一个类似 **数据卷** 的功能,在镜像构建时把 `node_modules` 文件夹挂载上去,在构建完成后,这个 `node_modules` 文件夹会自动卸载,实际的镜像中并不包含 `node_modules` 这个文件夹,这样我们就省去了每次获取依赖的时间,大大增加了镜像构建效率,同时也避免了生成了大量的中间层镜像。 `BuildKit` 提供了 `RUN --mount=type=cache` 指令,可以实现上边的设想。 ```dockerfile # syntax = docker/dockerfile:experimental FROM node:alpine as builder WORKDIR /app COPY package.json /app/ RUN --mount=type=cache,target=/app/node_modules,id=my_app_npm_module,sharing=locked \ --mount=type=cache,target=/root/.npm,id=npm_cache \ npm i --registry=https://registry.npm.taobao.org COPY src /app/src RUN --mount=type=cache,target=/app/node_modules,id=my_app_npm_module,sharing=locked \ # --mount=type=cache,target=/app/dist,id=my_app_dist,sharing=locked \ npm run build FROM nginx:alpine # COPY --from=builder /app/dist /app/dist # 为了更直观的说明 from 和 source 指令,这里使用 RUN 指令 RUN --mount=type=cache,target=/tmp/dist,from=builder,source=/app/dist \ # --mount=type=cache,target/tmp/dist,from=my_app_dist,sharing=locked \ mkdir -p /app/dist && cp -r /tmp/dist/* /app/dist ``` **由于 `BuildKit` 为实验特性,每个 `Dockerfile` 文件开头都必须加上如下指令** ```dockerfile # syntax = docker/dockerfile:experimental ``` 第一个 `RUN` 指令执行后,`id` 为 `my_app_npm_module` 的缓存文件夹挂载到了 `/app/node_modules` 文件夹中。多次执行也不会产生多个中间层镜像。 第二个 `RUN` 指令执行时需要用到 `node_modules` 文件夹,`node_modules` 已经挂载,命令也可以正确执行。 第三个 `RUN` 指令将上一阶段产生的文件复制到指定位置,`from` 指明缓存的来源,这里 `builder` 表示缓存来源于构建的第一阶段,`source` 指明缓存来源的文件夹。 上面的 `Dockerfile` 中 `--mount=type=cache,...` 中指令作用如下: | Option | Description | | ----------------- | ------------------------------------------------------------ | | `id` | `id` 设置一个标志,以便区分缓存。 | | `target` (必填项) | 缓存的挂载目标文件夹。 | | `ro`,`readonly` | 只读,缓存文件夹不能被写入。 | | `sharing` | 有 `shared` `private` `locked` 值可供选择。`sharing` 设置当一个缓存被多次使用时的表现,由于 `BuildKit` 支持并行构建,当多个步骤使用同一缓存时(同一 `id`)会发生冲突。`shared` 表示多个步骤可以同时读写,`private` 表示当多个步骤使用同一缓存时,每个步骤使用不同的缓存,`locked` 表示当一个步骤完成释放缓存后,后一个步骤才能继续使用该缓存。 | | `from` | 缓存来源(构建阶段),不填写时为空文件夹。 | | `source` | 来源的文件夹路径。 | #### RUN --mount=type=bind 该指令可以将一个镜像(或上一构建阶段)的文件挂载到指定位置。 ```dockerfile # syntax = docker/dockerfile:experimental RUN --mount=type=bind,from=php:alpine,source=/usr/local/bin/docker-php-entrypoint,target=/docker-php-entrypoint \ cat /docker-php-entrypoint ``` #### RUN --mount=type=tmpfs 该指令可以将一个 `tmpfs` 文件系统挂载到指定位置。 ```dockerfile # syntax = docker/dockerfile:experimental RUN --mount=type=tmpfs,target=/temp \ mount | grep /temp ``` #### RUN --mount=type=secret 该指令可以将一个文件(例如密钥)挂载到指定位置。 ```docker # syntax = docker/dockerfile:experimental RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \ cat /root/.aws/credentials ``` ```bash docker build -t test --secret id=aws,src=$HOME/.aws/credentials . ``` #### RUN --mount=type=ssh 该指令可以挂载 `ssh` 密钥。 ```dockerfile # syntax = docker/dockerfile:experimental FROM alpine RUN apk add --no-cache openssh-client RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan gitlab.com >> ~/.ssh/known_hosts RUN --mount=type=ssh ssh git@gitlab.com | tee /hello ``` ```bash eval $(ssh-agent) ssh-add ~/.ssh/id_rsa (Input your passphrase here) docker build -t test --ssh default=$SSH_AUTH_SOCK . ``` ### docker-compose build 使用 Buildkit 设置 `COMPOSE_DOCKER_CLI_BUILD=1` 环境变量即可使用。 ## 使用 Buildx 构建镜像 你可以直接使用 `docker buildx build` 命令构建镜像。 ```bash docker buildx build . [+] Building 8.4s (23/32) => ... ``` Buildx 使用 BuildKit 引擎 进行构建,并且支持许多新的功能 ## 使用 buildx 构建多种系统架构支持的 Docker 镜像 在之前的版本中构建多种系统架构支持的 Docker 镜像,要想使用统一的名字必须使用 `docker manifest` 命令。 在 Docker 19.03+ 版本中可以使用 `docker buildx build` 命令使用 `BuildKit` 构建镜像。该命令支持 `--platform` 参数可以同时构建支持多种系统架构的 Docker 镜像,大大简化了构建步骤。 ### 新建 builder 实例 Docker for Linux 不支持构建 `arm` 架构镜像,我们可以运行一个新的容器让其支持该特性,Docker 桌面版无需进行此项设置。 ```bash docker run --rm --privileged tonistiigi/binfmt:latest --install all ``` 由于 Docker 默认的 `builder` 实例不支持同时指定多个 `--platform`,我们必须首先创建一个新的 `builder` 实例。 同时由于国内拉取镜像较缓慢,我们可以使用配置了 [镜像加速地址 ](https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md)的 [`dockerpracticesig/buildkit:master`](https://github.com/docker-practice/buildx)镜像替换官方镜像。 如果你有私有的镜像加速器,可以基于 https://github.com/docker-practice/buildx 构建自己的 buildkit 镜像并使用它。 ```bash # 适用于国内环境 docker buildx create --use --name=mybuilder-cn --driver docker-container --driver-opt image=dockerpracticesig/buildkit:master # 适用于腾讯云环境(腾讯云主机、coding.net 持续集成) docker buildx create --use --name=mybuilder-cn --driver docker-container --driver-opt image=dockerpracticesig/buildkit:master-tencent # $ docker buildx create --name mybuilder --driver docker-container docker buildx use mybuilder ``` ### 构建镜像 新建 Dockerfile 文件。 ```docker FROM --platform=$TARGETPLATFORM alpine RUN uname -a > /os.txt CMD cat /os.txt ``` 使用 `docker buildx build` 命令构建镜像,注意将 `myusername` 替换为自己的 Docker Hub 用户名。 `--push` 参数表示将构建好的镜像推送到 Docker 仓库。 ```bash docker buildx build --platform linux/arm,linux/arm64,linux/amd64 -t myusername/hello . --push # 查看镜像信息 docker buildx imagetools inspect myusername/hello ``` 在不同架构运行该镜像,可以得到该架构的信息。 ```bash # arm docker run -it --rm myusername/hello Linux buildkitsandbox 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 armv7l Linux # arm64 docker run -it --rm myusername/hello Linux buildkitsandbox 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 aarch64 Linux # amd64 docker run -it --rm myusername/hello Linux buildkitsandbox 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64 Linux ``` ### 架构相关变量 `Dockerfile` 支持如下架构相关的变量 **TARGETPLATFORM** 构建镜像的目标平台,例如 `linux/amd64`, `linux/arm/v7`, `windows/amd64`。 **TARGETOS** `TARGETPLATFORM` 的 OS 类型,例如 `linux`, `windows` **TARGETARCH** `TARGETPLATFORM` 的架构类型,例如 `amd64`, `arm` **TARGETVARIANT** `TARGETPLATFORM` 的变种,该变量可能为空,例如 `v7` **BUILDPLATFORM** 构建镜像主机平台,例如 `linux/amd64` **BUILDOS** `BUILDPLATFORM` 的 OS 类型,例如 \`linux **BUILDARCH** `BUILDPLATFORM` 的架构类型,例如 `amd64` **BUILDVARIANT** `BUILDPLATFORM` 的变种,该变量可能为空,例如 `v7` #### 使用举例 例如我们要构建支持 `linux/arm/v7` 和 `linux/amd64` 两种架构的镜像。假设已经生成了两个平台对应的二进制文件: * `bin/dist-linux-arm` * `bin/dist-linux-amd64` 那么 `Dockerfile` 可以这样书写: ```dockerfile FROM scratch # 使用变量必须申明 ARG TARGETOS ARG TARGETARCH COPY bin/dist-${TARGETOS}-${TARGETARCH} /dist ENTRYPOINT ["dist"] ``` --- --- url: /docker/i55dyy53/index.md --- ## 端口映射 容器中可以运行一些网络应用,要让外部也可以访问这些应用,可以通过 `-P` 或 `-p` 参数来指定端口映射。 当使用 `-P` 标记时,Docker 会随机映射一个端口到内部容器开放的网络端口。 `-p` 则可以指定要映射的端口,并且,在一个指定端口上只可以绑定一个容器,支持的格式有 `ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort`。 ```bash 419bb2720cc1 nginx:alpine "/docker-entrypoint.…" 34 hours ago Up 34 hours 0.0.0.0:50->80/tcp ``` 本地主机的 50 被映射到了容器的 80 端口。此时访问本机的 50 端口即可访问容器内 NGINX 默认页面。 同样的,可以通过 `docker logs` 命令来查看访问记录。 ### 映射所有接口地址 使用 `hostPort:containerPort` 格式本地的 80 端口映射到容器的 80 端口,可以执行 ```bash docker run -d -p 80:80 nginx:alpine ``` 此时默认会绑定本地所有接口上的所有地址。 ### 映射到指定地址的指定端口 可以使用 `ip:hostPort:containerPort` 格式指定映射使用一个特定地址,比如 localhost 地址 127.0.0.1 ```bash docker run -d -p 127.0.0.1:80:80 nginx:alpine ``` ### 映射到指定地址的任意端口 使用 `ip::containerPort` 绑定 localhost 的任意端口到容器的 80 端口,本地主机会自动分配一个端口。 ```bash docker run -d -p 127.0.0.1::80 nginx:alpine ``` 还可以使用 `udp` 标记来指定 `udp` 端口 ```bash docker run -d -p 127.0.0.1:80:80/udp nginx:alpine ``` ### 查看映射端口配置 使用 `docker port` 来查看当前映射的端口配置,也可以查看到绑定的地址 ```bash docker port nginx:alpine 80 # 0.0.0.0:50 ``` 注意: * 容器有自己的内部网络和 ip 地址(使用 `docker inspect` 查看,Docker 还可以有一个可变的网络配置。) * `-p` 标记可以多次使用来绑定多个端口 ## 容器互联 ### 新建网络 创建一个新的 Docker 网络。 ```bash docker network create -d bridge my-net ``` `-d` 参数指定 Docker 网络类型,有 `bridge` `overlay`。 ### 连接容器 运行一个容器并连接到新建的 `my-net` 网络 ```bash docker run -it --rm --name busybox1 --network my-net busybox sh ``` 打开新的终端,再运行一个容器并加入到 `my-net` 网络 ```bash docker run -it --rm --name busybox2 --network my-net busybox sh ``` 再打开一个新的终端查看容器信息 ```bash docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES b47060aca56b busybox "sh" 11 minutes ago Up 11 minutes busybox2 8720575823ec busybox "sh" 16 minutes ago Up 16 minutes busybox1 ``` 下面通过 `ping` 来证明 `busybox1` 容器和 `busybox2` 容器建立了互联关系。 在 `busybox1` 容器输入以下命令 ```bash ping busybox2 PING busybox2 (172.19.0.3): 56 data bytes 64 bytes from 172.19.0.3: seq=0 ttl=64 time=0.072 ms 64 bytes from 172.19.0.3: seq=1 ttl=64 time=0.118 ms ``` 用 ping 来测试连接 `busybox2` 容器,它会解析成 `172.19.0.3`。 同理在 `busybox2` 容器执行 `ping busybox1`,也会成功连接到。 ```bash / # ping busybox1 PING busybox1 (172.19.0.2): 56 data bytes 64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.064 ms 64 bytes from 172.19.0.2: seq=1 ttl=64 time=0.143 ms ``` 这样,`busybox1` 容器和 `busybox2` 容器建立了互联关系。 如果你有多个容器之间需要互相连接,推荐使用 Docker Compose。 ## 配置 DNS Docker 通过挂载虚拟文件来管理容器的网络配置文件: * `/etc/hostname`:容器主机名 * `/etc/hosts`:主机名与 IP 的映射 * `/etc/resolv.conf`:DNS 服务器配置 这些文件与宿主机关联,因此宿主机的 DNS 更新可自动同步到容器中。 **全局配置(影响所有容器)** 在宿主机的 `/etc/docker/daemon.json` 文件中添加: ```json { "dns": ["114.114.114.114", "8.8.8.8"] } ``` 重启 Docker 服务后,所有新启动的容器都会自动使用该 DNS。 验证命令: ```bash docker run -it --rm ubuntu:18.04 cat /etc/resolv.conf ``` **单独配置(影响某个容器)** 在 `docker run` 时指定参数: | 参数 | 作用 | | ----------------------------- | ------------------------------------------------------------ | | `-h` 或 `--hostname=HOSTNAME` | 设置容器主机名(写入 `/etc/hostname` 和 `/etc/hosts`) | | `--dns=IP_ADDRESS` | 指定容器使用的 DNS 服务器(写入 `/etc/resolv.conf`) | | `--dns-search=DOMAIN` | 设置搜索域(如 `--dns-search=example.com` 会解析 `host` 为 `host.example.com`) | 如果不指定 `--dns` 或 `--dns-search`,Docker 默认会继承宿主机的 `/etc/resolv.conf`。 **总结**:Docker 通过挂载虚拟文件 `/etc/hostname`、`/etc/hosts`、`/etc/resolv.conf` 实现容器主机名和 DNS 配置。 可通过 **全局配置文件** 设置所有容器的 DNS,也可在 **docker run** 时为单个容器手动指定。 ## 高级网络配置 Docker 安装后,会自动创建几个默认网络: | 网络名 | 类型 | 说明 | | ---------------- | -------------- | -------------------------- | | `bridge` | 桥接网络 | 默认网络,容器间用 IP 通信 | | `host` | 主机网络 | 直接使用宿主机网络,无隔离 | | `none` | 无网络 | 完全断网模式 | | (用户自定义的) | 桥接或其他类型 | 最推荐的实践方式 | 默认 `bridge` 网络虽然方便,但容器名无法直接互相解析(要用 IP),而 **自定义 bridge 网络** 则支持容器名自动 DNS 解析,非常方便。 ::: details 什么是网桥 想象一下一个办公室里有多台电脑: * 每台电脑都有自己的网线(网卡); * 它们都插在同一个交换机上; * 交换机会在电脑之间转发数据,让它们能互相通信。 在网络世界里,这个 **交换机** 的角色,就是“**网桥(bridge)**”。 网桥的作用:**连接多个网络接口,让它们像在同一个局域网里一样通信**。 在传统 Linux 网络中: * **物理网卡(eth0)** 负责与外界通信; * **虚拟网桥(bridge)** 是一个虚拟交换机; * **虚拟网卡(veth pair)** 是桥与容器之间的“网线”。 可以这样理解结构: ```bash [容器A eth0]──┐ │ [veth pair]──[br0]──[宿主机 eth0]──Internet │ [容器B eth0]──┘ ``` `br0`(或 Docker 默认的 `docker0`)就是 Linux 网桥设备,它让所有挂上去的容器像在同一个交换机里一样互通。 当你安装 Docker 时,它默认会创建一个虚拟网桥: ```bash docker0: 172.17.0.1/16 ``` 然后,每当你运行一个容器: * Docker 会创建一对虚拟网卡(`veth`); * 一端放进容器(当作容器的 `eth0`); * 另一端连接到 `docker0` 网桥。 于是容器获得一个虚拟 IP,比如: ```bash 172.17.0.2 ``` 多个容器都挂到同一个 `docker0` 上,于是它们能互相通信,而且通过 NAT(iptables)规则,也能访问外网。 查看宿主机的网桥: ```bash brctl show ``` 输出示例: ```bash bridge name bridge id STP enabled interfaces docker0 8000.0242ac110002 no veth12345 ``` 说明: `bridge name` 是虚拟网桥; `interfaces` 是接入桥的虚拟网卡(即连接的容器)。 **网桥(bridge)** 是一种让多个虚拟或物理网卡组成同一局域网的网络设备。在 Docker 中,它就像一个虚拟交换机,负责让容器之间互相通信。 ::: | 类型 | 特点 | 通信路径 | | ------------------------------- | ---------------------------------------- | ----------------------------------------------- | | `bridge`(默认) | 所有容器连接到宿主机的虚拟网桥 `docker0` | 容器 → docker0 → 宿主机网卡 → 公网 | | 自定义 `bridge` | 创建自定义子网(如 172.18.0.0/16) | 容器 → my-bridge → docker0 → 宿主机网卡 | | `overlay`(Swarm 或跨主机网络) | 在多个主机之间创建虚拟子网 | 容器 → overlay 子网 → VXLAN 隧道 → 另一主机网桥 | | `macvlan` | 容器直接“拥有”宿主机的物理网卡 MAC 地址 | 容器 → 物理网络(绕过网桥) | 也就是说,容器可能: * **直接连接默认网桥(docker0)**; * **连接到一个自定义 bridge 子网(桥连在桥上)**; * **甚至跨宿主机通过 overlay 网络连接**。 但无论哪种情况,最终访问外网时,都会经由宿主机的物理网络接口(eth0、ens33 等)转发出去 **总结**:容器的所有网络流量最终都会经过宿主机的虚拟网桥(bridge)与物理网卡转发出站。即使存在自定义子网或多层网络,它们本质上仍通过宿主机网络访问外部世界。 **Overlay 网络** 是 Docker 提供的一种虚拟网络技术,用于 **在多个宿主机之间创建一个逻辑上的虚拟局域网(VLAN 类似,但更灵活)**。 可以把它理解为:比子网更高级的虚拟局域网,可以跨宿主机,把多个 Docker 主机上的容器看作同一个网络段里的机器。 **原理** Overlay 网络会在宿主机之间创建 **隧道(VXLAN)**: * 容器的流量先从容器 veth 进入本机的 overlay 网桥; * 再封装成 VXLAN 包,通过宿主机物理网络发送到其他宿主机; * 到达目标宿主机解封装,再进入目标容器。 容器间可以直接通过容器名互相访问,就像在同一个局域网里一样。 **与自定义子网(bridge)对比** | 特性 | 自定义 bridge | Overlay | | -------------- | ----------------------------------- | ---------------------------------------------- | | **作用范围** | 单宿主机 | 多宿主机 / 集群 | | **连接方式** | 容器 → 网桥 → 宿主机 → NAT / 物理网 | 容器 → overlay 虚拟网 → VXLAN → 另一宿主机容器 | | **容器名解析** | 内建 DNS(单宿主机) | 内建 DNS(跨主机集群) | | **出站 NAT** | 容器访问外网需要 NAT | 容器访问外网通常由宿主机 NAT | | **应用场景** | 单机开发、测试 | Swarm/Kubernetes 集群、跨主机服务通信 | | **复杂度** | 简单 | 高,需要集群控制平面支持 | 可以理解为:**bridge 是局部局域网,overlay 是跨宿主机的“虚拟局域网”**。 **Overlay 的实际用途** **Docker Swarm / Kubernetes 集群** * 让不同宿主机上的服务容器互联,就像在同一个局域网; * 支持容器名解析(service name)和自动负载均衡。 **微服务架构** * 不管容器在集群哪个节点,都可以通过逻辑网络访问服务; * 解决跨主机通信问题。 **跨数据中心 / 私有云** * 可以在物理网络不互通的情况下,通过 overlay 隧道互联容器。 **Overlay 与自定义子网结合使用** 在 overlay 网络内部,每个服务或容器依然会有自己的子网(比如 10.0.0.0/24、10.0.1.0/24); 你可以理解成: * **Overlay** = “跨宿主机的虚拟交换机” * **子网** = “交换机内部划分的小网络段” **总结**:Overlay 网络 = 更高级的虚拟局域网,用于跨宿主机容器通信,通常用于集群环境,而自定义子网(bridge)只限于单宿主机。 ::: note **单宿主机自定义子网(Custom Bridge)** 特点: * **范围**:仅限当前宿主机。 * **容器名解析**:支持。容器 A 可以通过容器名访问容器 B。 * **通信机制**: ```bash 容器 → veth → 自定义 bridge → 宿主机 → 容器 ``` * **外网访问**:通过宿主机 NAT。 * **用途**:单机开发、测试、轻量部署。 换句话说,它是一个 **局域网**,容器间通信完全没问题,但只局限在一台宿主机上。 ::: ::: note **Overlay 网络(跨宿主机自定义子网)** 特点: * **范围**:可以跨多台宿主机。 * **容器名解析**:支持。无论容器在哪台宿主机,都能通过服务名访问。 * **通信机制**: ``` 容器 → veth → overlay 网桥 → VXLAN 隧道 → 另一宿主机 overlay → 目标容器 ``` * **外网访问**:依然通过宿主机 NAT。 * **用途**:Swarm / Kubernetes / 集群微服务。 Overlay 网络可以把不同宿主机上的容器逻辑上放到同一个局域网里,所以才适合集群环境。 ::: **为什么单宿主机自定义子网不能直接“集群化”** * 单宿主机的 bridge 网络,只能管理当前主机的 veth 对接和 IP 分配。 * 如果你把 bridge 网络跨宿主机直接用,没有 VXLAN 或类似隧道,宿主机之间根本不知道对方网络存在。 * 容器名解析和路由只在本地主机生效,跨宿主机无法自动解析。 所以 **桥接子网可以在单机里实现容器名通信,但要跨机器就必须 overlay 或类似的隧道技术**。 **自定义子网是单宿主机级别的“虚拟局域网”,Overlay 是跨宿主机的“虚拟局域网+隧道”,前者方便本地通信,后者适合集群环境。** *** 当 Docker 启动时,会自动在主机上创建一个 `docker0` 虚拟网桥,实际上是 Linux 的一个 bridge,可以理解为一个软件交换机。它会在挂载到它的网口之间进行转发。 同时,Docker 随机分配一个本地未占用的私有网段中的一个地址给 `docker0` 接口。比如典型的 `172.17.42.1`,掩码为 `255.255.0.0`。此后启动的容器内的网口也会自动分配一个同一网段(`172.17.0.0/16`)的地址。 当创建一个 Docker 容器的时候,同时会创建了一对 `veth pair` 接口(当数据包发送到一个接口时,另外一个接口也可以收到相同的数据包)。 这对接口一端在容器内,即 `eth0`;另一端在本地并被挂载到 `docker0` 网桥,名称以 `veth` 开头(例如 `vethAQI2QT`)。 通过这种方式,主机可以跟容器通信,容器之间也可以相互通信。Docker 就创建了在主机和所有容器之间一个虚拟共享网络。 ![](https://img.yumgjs.com/2025/10/05/68e21a47ee797.png) ### 相关命令 **其中有些命令选项只有在 Docker 服务启动的时候才能配置,而且不能马上生效。** * `-b BRIDGE` 或 `--bridge=BRIDGE` 指定 Docker 容器挂载的默认网桥,例如 docker0 或自定义桥接网络。所有新建容器默认连接到这个网桥。 * `--bip=CIDR` 设置 docker0 网桥的 IP 地址和子网掩码。例如 `--bip=172.18.0.1/16`,用于容器分配 IP。 * `-H SOCKET...` 或 `--host=SOCKET...` 指定 Docker 服务端监听的通道,可以是 Unix Socket 或 TCP Socket,用于接受 Docker CLI 或 API 命令。 * `--icc=true|false` 是否支持容器之间进行通信 * `--ip-forward=true|false` 允许你的宿主操作系统内核转发网络流量,如果没有开启 IP 转发,容器将无法“走出去”(访问互联网)或“走进来”(通过端口映射被访问)。Docker 通常会默认启用它。 * `--iptables=true|false` 是否允许 Docker 添加 iptables 规则 * `--mtu=BYTES` 设置容器网络接口的最大传输单元(MTU),用于优化网络性能或兼容隧道。 **下面 2 个命令选项既可以在启动服务时指定,也可以在启动容器时指定。在 Docker 服务启动的时候指定则会成为默认值,后面执行 `docker run` 时可以覆盖设置的默认值。** * `--dns=IP_ADDRESS...` 指定容器使用的 DNS 服务器 IP,可在 daemon 设置默认,也可在单容器启动时覆盖。 * `--dns-search=DOMAIN...` 设置容器的 DNS 搜索域,例如 `example.com`,用于解析短域名。 **最后这些选项只有在 `docker run` 执行时使用,因为它是针对容器的特性内容。** * `-h HOSTNAME` 或 `--hostname=HOSTNAME` 设置容器内部的主机名,写入容器的 `/etc/hostname` 和 `/etc/hosts`。 * `--link=CONTAINER_NAME:ALIAS` 将当前容器与另一个容器建立连接,使容器可以通过别名互相访问(已被自定义网络和 overlay 网络替代)。 * `--net=bridge|none|container:NAME_or_ID|host` 配置容器的桥接模式 * `-p SPEC` 或 `--publish=SPEC` 映射容器端口到宿主机端口,例如 `-p 8080:80` 表示宿主机 8080 映射到容器 80 端口。 * `-P or --publish-all=true|false` 映射容器所有端口到宿主主机 ### 容器访问控制 在 Linux 容器(如 Docker、Podman)中,容器的网络访问控制主要依赖 **Linux 内核的防火墙机制**,通常是 **`iptables`** 或现代系统上的 **`nftables`**。具体说明: **iptables 是默认防火墙** * Linux 内核自带 `iptables`,大部分发行版默认安装。 * 它通过 **规则链(chains)和表(tables)** 控制网络流量,可以过滤入站、出站、转发的数据包。 * 容器启动时,Docker 等容器引擎会自动在 `iptables` 上创建规则,实现 **NAT 转发、端口映射、网络隔离**。 **容器访问控制的实现方式** * **NAT(网络地址转换)**:通过 `iptables` 的 `nat` 表,将容器内部端口映射到宿主机端口。 * **网络隔离**:容器默认使用独立的网络命名空间,配合 `iptables` 规则限制容器间或容器与外部的通信。 * **端口/流量控制**:可以通过自定义 `iptables` 规则限制哪些 IP 或端口可以访问容器。 **现代替代方案** * 新版本 Linux 偏向使用 **`nftables`**,Docker 也在逐步支持。 * 上层工具如 **`firewalld`** 或 **`ufw`** 也能管理容器访问规则,但容器引擎通常直接操作底层防火墙。 容器要想访问外部网络,需要本地系统的转发支持。在Linux 系统中,检查转发是否打开。 **简而言之**:容器的访问控制依赖 Linux 内核提供的防火墙功能,`iptables` 是最常用的默认工具,容器引擎会在其上自动配置规则来实现端口映射和网络隔离。 ### 容器访问外部网络的条件 **网络命名空间隔离** * 容器通常运行在独立的网络命名空间中,拥有自己的 IP 地址和网络栈。 * 默认情况下,这个网络命名空间和宿主机外部网络是隔离的。 **需要宿主机进行 IP 转发** * 容器访问外部网络(如互联网)时,宿主机需要将容器的流量 **路由转发** 到外部网络。 * Linux 内核通过 **IP 转发功能** 控制这一行为。 **检查或开启 IP 转发** * 查看当前状态: ```bash sysctl net.ipv4.ip_forward # net.ipv4.ip_forward = 1 ``` 返回 `1` 表示开启,`0` 表示关闭。 * 临时开启: ```bash sysctl -w net.ipv4.ip_forward=1 ``` 永久开启(重启后仍生效):编辑 `/etc/sysctl.conf`,添加或修改: ```bash net.ipv4.ip_forward = 1 ``` 然后执行: ```bash sysctl -p ``` **结合防火墙实现 NAT** * 容器引擎(如 Docker)通常会在宿主机 `iptables` 上添加 **MASQUERADE/NAT 规则**,把容器流量映射到宿主机的公网 IP。 * 有了 IP 转发和 NAT,容器就可以访问外部网络,同时保持容器内网地址独立。 **总结一句话**:容器访问外部网络必须依赖宿主机的 **IP 转发**,可以通过 `sysctl net.ipv4.ip_forward` 检查和开启;结合防火墙 NAT 规则,实现容器网络与外部网络的通信。 一般情况下 **官方的 Docker 安装脚本** 或者通过官方包安装 Docker 时,会自动开启 IP 转发(`net.ipv4.ip_forward=1`),以确保容器可以访问外部网络。 通常你不需要手动去开启这个参数,除非你做了自定义内核网络设置或者禁用了 Docker 的默认配置。 ### 容器之间访问 容器要互相访问(比如容器 A 访问容器 B 的服务),需要满足两个条件: 1. **网络拓扑允许通信** 2. **宿主机防火墙允许通信** **容器网络拓扑(Topology)** 什么是网络拓扑? * 拓扑就是**网络中设备之间的连接关系**。 * 对容器来说,拓扑决定了容器是否在同一个网络里,以及它们之间能否直接通信。 **Docker 的默认网络拓扑** Docker 安装后,会创建一个默认网桥 `docker0`: * 这是宿主机上的一个虚拟网桥(类似交换机)。 * 默认情况下,所有新建的容器都会连接到 `docker0`。 * 容器会分配一个私有 IP(比如 172.17.0.x),在同一网桥内,容器之间可以互相访问。 **拓扑的类型** Docker 支持几种网络模式,每种模式的拓扑不同: | 模式 | 描述 | 容器互访情况 | | -------------- | -------------------------------------- | ---------------------------------------------------- | | bridge(默认) | 容器连接到宿主机上的虚拟网桥 `docker0` | 同一桥内可互访,不同桥默认隔离 | | host | 容器共享宿主机网络栈 | 容器可以直接访问宿主机网络,也可访问宿主机的其他服务 | | overlay | 跨主机容器互联 | 用于 Swarm 集群,跨物理机通信 | | none | 容器没有网络接口 | 容器之间无法通信 | 所以,默认情况下,容器在同一个 `docker0` 网桥里,拓扑上是互联的。 **防火墙的影响(iptables)** 即使网络拓扑允许,宿主机防火墙也可能阻止容器间通信。 Docker 启动时,会在宿主机的 `iptables` 中添加规则: * 允许同一桥接网络的容器之间互相访问。 * 实现端口映射和 NAT。 如果你手动配置了防火墙或禁用了 Docker 的 iptables 规则,容器之间的互访可能会被阻止。 检查方法: ```bash # 查看 docker0 网桥 ip addr show docker0 # 查看容器之间的防火墙规则 sudo iptables -L -v -n ``` **容器互访示意流程** 1. 容器 A 发起访问 → 目标 IP 是容器 B 的私有 IP。 2. 数据包通过 docker0 网桥转发到容器 B。 3. iptables 规则检查是否允许 → 允许则通过,阻止则丢弃。 4. 数据到达容器 B,通信完成。 **总结** * **拓扑**决定容器能否在逻辑上互联,默认 `docker0` 网桥允许同桥容器互访。 * **防火墙**决定数据包能否被宿主机允许通过。 * Docker 默认配置下,容器可以互访,不需要额外操作;自定义网络或严格防火墙时,需要注意规则配置。 ### 访问指定端口 在 Docker 中,即使关闭了容器之间的默认网络访问(通过 `-icc=false`),仍然可以通过 **`--link`** 选项访问指定容器的开放端口。 启动 Docker 服务时,可以使用参数: ```bash dockerd --icc=false --iptables=true ``` `icc=false`:禁止容器间默认相互访问 `--iptables=true`:允许 Docker 修改宿主机的 iptables 规则 此时,宿主机默认的 iptables 规则可能类似: ```bash Chain FORWARD (policy ACCEPT) target prot opt source destination DROP all -- 0.0.0.0/0 0.0.0.0/0 ``` 默认拒绝容器之间的通信。 使用 `--link` 实现访问,启动容器时使用: ```bash docker run --link CONTAINER_NAME:ALIAS ... ``` `CONTAINER_NAME`:目标容器的名字(Docker 自动分配或通过 `--name` 指定) `ALIAS`:在当前容器内使用的别名 Docker 会在 iptables 中自动添加规则,只允许两个容器之间的指定端口互访(通常基于 Dockerfile 中的 `EXPOSE` 指令),例如: ```bash Chain FORWARD (policy ACCEPT) target prot opt source destination ACCEPT tcp -- 172.17.0.2 172.17.0.3 tcp spt:80 ACCEPT tcp -- 172.17.0.3 172.17.0.2 tcp dpt:80 DROP all -- 0.0.0.0/0 0.0.0.0/0 ``` 这样,即使默认禁止容器间通信,使用 `--link` 的容器仍可以通过指定端口互相访问。 **注意** * `--link` 中的 **CONTAINER\_NAME** 必须是 Docker 分配的容器名,或者通过 `--name` 指定的名字。 * 容器的主机名不会被识别。 * `--link` 是一种显式允许访问的方式,适合精确控制容器间通信,而不依赖默认桥接网络。 **总结**: * 默认情况下,`-icc=false` 禁止容器互访。 * 使用 `--link=CONTAINER_NAME:ALIAS` 可以在 iptables 上为两个容器添加规则,只允许指定端口互访。 * 这种方式结合了安全隔离与可控访问,适用于需要严格网络策略的场景。 这个机制的核心就是 **严格控制容器之间的访问**: * 默认情况下,Docker 容器在同一个桥接网络上可以互相访问。 * 使用 **`-icc=false`** 可以关闭这种默认互访,让容器之间不能随意通信。 * 之后,如果某些容器需要通信,可以用 **`--link=CONTAINER_NAME:ALIAS`** 显式允许,只开放指定端口。 **换句话说**:这是为了实现“最小权限”原则:默认禁止容器之间互访,只允许明确授权的端口通信,从而提升安全性,防止容器之间随意访问或泄露服务。 这种方式特别适合 **多租户环境** 或 **敏感服务部署**,每个容器的网络访问都被严格控制。 ### 映射容器端口到宿主主机的实现 默认情况下,容器可以主动访问到外部网络的连接,但是外部网络无法访问到容器。 #### 容器访问外部实现 容器所有到外部网络的连接,源地址都会被 NAT 成本地系统的 IP 地址。这是使用 `iptables` 的源地址伪装操作实现的。 查看主机的 NAT 规则。 ```bash sudo iptables -t nat -nL ... Chain POSTROUTING (policy ACCEPT) target prot opt source destination MASQUERADE all -- 172.17.0.0/16 !172.17.0.0/16 ... ``` 其中,上述规则将所有源地址在 `172.17.0.0/16` 网段,目标地址为其他网段(外部网络)的流量动态伪装为从系统网卡发出。MASQUERADE 跟传统 SNAT 的好处是它能动态从网卡获取地址。 #### 外部访问容器实现 容器允许外部访问,可以在 `docker run` 时候通过 `-p` 或 `-P` 参数来启用。 不管用那种办法,其实也是在本地的 `iptable` 的 nat 表中添加相应的规则。 使用 `-P` 时: ```bash iptables -t nat -nL ... Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:49153 to:172.17.0.2:80 ``` 使用 `-p 80:80` 时: ```bash iptables -t nat -nL Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80 to:172.17.0.2:80 ``` 注意: * 这里的规则映射了 `0.0.0.0`,意味着将接受主机来自所有接口的流量。用户可以通过 `-p IP:host_port:container_port` 或 `-p IP::port` 来指定允许访问容器的主机上的 IP、接口等,以制定更严格的规则。 * 如果希望永久绑定到某个固定的 IP 地址,可以在 Docker 配置文件 `/etc/docker/daemon.json` 中添加如下内容。 ```bash { "ip": "0.0.0.0" } ``` 在 Linux 的 `iptables` 中,**`nat` 表**并不是单个规则,而是一个独立的表(table),里面可以有很多规则和链(chain),所以 NAT 可以有多个,而不是只能一个。具体说明如下: #### iptables NAT 的概念 **NAT(Network Address Translation)**:网络地址转换,用来修改数据包的源地址或目标地址,实现容器/私网访问外网或端口映射。 **iptables 中的 NAT 表**: * 专门用于处理数据包地址转换的表 * 内置三个主要链(chains): 1. **PREROUTING**:数据包进入前处理,常用于 DNAT(目标地址转换) 2. **POSTROUTING**:数据包离开前处理,常用于 SNAT/MASQUERADE(源地址转换) 3. **OUTPUT**:本机发出的包处理 NAT #### 多个 NAT 规则 * 在 **同一个 NAT 表**里,可以添加多条规则。 * 例如,你可以同时有: ``` - 容器 A 的 80 端口映射到宿主机 8080 - 容器 B 的 443 端口映射到宿主机 8443 ``` * 这些都是 NAT 表里的不同规则。 * 每条规则针对不同源/目标 IP 或端口,可以共存。 #### 表和链的关系 ``` nat 表 ├─ PREROUTING (DNAT) ├─ POSTROUTING (SNAT/MASQUERADE) └─ OUTPUT ``` * 表 = 一个容器或系统中处理 NAT 的整体逻辑 * 链 = NAT 表内不同处理阶段 * 规则 = 链内的每条具体操作 * 所以 NAT 可以是 **多个规则**,统一在 **nat 表** 内管理 #### 规则解析 ```bash Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80 to:172.17.0.2:80 ``` **Chain DOCKER**:Docker 在 `nat` 表中创建的自定义链,用来管理容器端口映射规则 **DNAT**:目标地址转换(Destination NAT) * 把目标 IP/端口从宿主机地址转换到容器内部 IP/端口 **tcp dpt:80 to:172.17.0.2:80**: * 任何访问宿主机 **80 端口** 的 TCP 流量 * 都会被转发到容器 IP `172.17.0.2` 的 **80 端口** #### 为什么容器可以被外部访问 * 外部访问宿主机的 80 端口 → iptables 检测到 DNAT 规则 → 转发到容器 80 端口 → 容器处理请求 → 返回流量通过宿主机 NAT 返回客户端 * 这个机制也叫 **端口映射(port mapping)**,Docker 启动容器时使用 `-p` 或 `--publish` 就会自动创建这样的规则。 #### 为什么容器可以访问外部 * 除了 DNAT 规则外,Docker 会在 `POSTROUTING` 链添加 **MASQUERADE/SNAT** 规则,把容器流量的源地址改成宿主机的 IP * 这样返回的数据包可以正确回到容器,实现 **容器访问外网** #### 总结 总结你看到的 `DNAT` 规则就是 **宿主机端口映射到容器端口的秘密**,让容器对外可访问。 配合 `MASQUERADE/SNAT` 规则,又实现了容器主动访问外网。 这就是 Docker 网络的核心工作机制:**NAT + IP 转发 + iptables 规则**。 #### 容器转发性能损耗 理论上,容器的端口映射和 NAT 会引入 **轻微的性能损耗**,但实际影响取决于场景和流量大小。 ##### 原因 **额外的网络栈处理** * 当容器访问外网,或者外部访问容器时,数据包必须经过宿主机的 **iptables NAT 处理**(DNAT/SNAT)。 * 这个过程需要 Linux 内核修改数据包头部,并做路由判断,相比直接服务监听在宿主机端口上多了一步处理。 **桥接网络** * 默认 Docker 使用 **docker0 网桥**,容器的虚拟网卡(veth)通过网桥转发数据包。 * 数据包从容器 → veth → docker0 → iptables → 外网,需要经过几次内核层面转发,比直接在宿主机端口监听多了几个环节。 **网络命名空间隔离** * 容器在独立网络命名空间内,需要额外做 NAT 和路由转换。 * 这种隔离提供了安全性,但也增加了内核处理开销。 ##### 实际损耗 * **轻量服务**(HTTP 请求量不高,单机访问): * 性能损耗可以忽略不计,通常 <1%~5%。 * **高性能或大流量服务**(如 CDN、视频流、大量 TCP 连接): * NAT + 桥接网络可能成为瓶颈,需要优化。 ##### 性能优化方式 **Host 网络模式** * 启动容器时使用 `--network=host`,容器直接使用宿主机网络栈 * 避免 NAT/Docker 网桥,性能几乎和直接在宿主机部署服务相同 **合理的端口映射策略** * 尽量减少不必要的端口转发 * 高性能服务可直接在宿主机监听端口,或者使用 host 模式 **容器网络插件优化** * 使用 CNI 插件或 macvlan,让容器直接拥有物理网络 IP * 减少内核 NAT 和桥接开销 ##### 总结 | 部署方式 | 性能 | 说明 | | --------------------- | ---------- | -------------------------------------- | | 直接在宿主机部署 | 最高 | 数据包直接处理,没有 NAT 或桥接开销 | | 容器 + 默认桥接 + NAT | 略低 | 多一层 NAT + veth 桥接,轻微性能损耗 | | 容器 + host 网络模式 | 接近宿主机 | 容器直接用宿主机网络栈,几乎无额外开销 | **损耗来源** * **NAT 处理**:容器端口映射(DNAT/SNAT/MASQUERADE)需要内核修改数据包头部,增加少量 CPU 开销。 * **桥接网络**:数据包从容器虚拟网卡 → docker0 网桥 → iptables → 外网,多了几个内核层处理环节。 * **网络命名空间隔离**:容器独立的网络栈增加转发和路由开销。 **实际影响** * **轻量服务**(普通 Web 或后台服务):性能损耗微乎其微,通常 <5%,可忽略。 * **高吞吐量或高并发服务**(视频流、大量 TCP 连接):NAT 与桥接可能成为瓶颈,需要优化。 **结论**: * 对一般 Web 服务或后台服务,性能差异可以忽略 * 对高吞吐量、高并发场景,容器 NAT 和桥接网络可能成为瓶颈,需要 host 模式或其他网络优化方案 * 容器端口转发会带来轻微性能损耗,但对于大多数应用可以忽略;只有在高并发、大流量场景下,才需要通过 host 网络或其他网络优化方案来降低开销。 容器 NAT/桥接网络确实带来轻微性能损耗,但换来了 **灵活的网络管理、服务隔离和部署便利**。 对大多数应用而言,这种损耗完全可以接受,而收益非常显著。 ### 自定义网桥 除了默认的 `docker0` 网桥,用户也可以创建 **自定义网桥**,让容器连接到指定的网络环境中。 #### 指定 Docker 使用的网桥 在启动 Docker 服务时,可以通过参数指定网桥: ```bash dockerd -b BRIDGE # 或 dockerd --bridge=BRIDGE ``` 如果 Docker 服务已经运行,需要先停止服务并删除旧的默认网桥。 #### 停止 Docker 并删除默认网桥 ```bash sudo systemctl stop docker # 停止 Docker 服务 sudo ip link set dev docker0 down # 将 docker0 接口关闭 sudo brctl delbr docker0 # 删除 docker0 网桥 ``` **注意**:删除网桥会断开所有使用该网桥的容器,请确保容器已经停止或迁移。 #### 创建自定义网桥 创建网桥接口 `bridge0`: ```bash sudo brctl addbr bridge0 ``` 给网桥分配 IP 地址: ```bash sudo ip addr add 192.168.5.1/24 dev bridge0 ``` 启动网桥: ```bash sudo ip link set dev bridge0 up ``` 查看网桥状态: ```bash ip addr show bridge0 ``` 示例输出: ```bash 4: bridge0: mtu 1500 qdisc noop state UP group default link/ether 66:38:d0:0d:76:18 brd ff:ff:ff:ff:ff:ff inet 192.168.5.1/24 scope global bridge0 valid_lft forever preferred_lft forever ``` #### 配置 Docker 使用自定义网桥 编辑 Docker 配置文件 `/etc/docker/daemon.json`: ``` { "bridge": "bridge0" } ``` 保存后,启动 Docker: ``` sudo systemctl start docker ``` #### 验证容器是否连接到自定义网桥 新建一个容器: ``` docker run -it --rm alpine sh ``` 容器内查看 IP 地址和路由: ``` ip addr ip route ``` 宿主机查看网桥信息: ``` brctl show ``` 可以看到新建容器已经连接到 `bridge0`,分配了该网段的 IP。 #### 自定义网桥的作用 ##### 网络隔离与拓扑管理 * **隔离容器网络**:通过自定义网桥,你可以把一组容器放在独立的网络里,互相之间或与默认网桥的容器隔离。 * **自定义拓扑**:可以为不同应用或服务创建不同网段,便于管理和安全策略的实施。 * **避免冲突**:默认 `docker0` 使用 172.17.0.0/16 网段,自定义网桥可以使用其他私有网段,防止与宿主机或其他网络冲突。 ##### 自定义 IP 和路由 * **指定 IP 段**:创建自定义网桥时可以分配特定的网段(如 192.168.5.0/24),容器获得的 IP 在该网段内。 * **灵活路由**:可以结合宿主机路由策略,控制容器间或容器对外访问的路径。 * **便于网络规划**:在多服务或多租户环境下,IP 管理更清晰,可避免地址冲突和混乱。 ##### 安全控制 * **控制互访**:不同网桥的容器默认不能直接互相访问,可以配合 `-icc=false` 或防火墙策略严格控制通信。 * **精细权限**:只允许特定网桥内的容器互相通信,实现更精细的访问控制。 ##### 提高管理便利性 * **多环境部署**:可为不同应用环境(如开发、测试、生产)创建独立网桥,避免互相干扰。 * **便于监控**:每个网桥可以单独监控流量、连接和端口使用情况。 * **方便迁移和扩容**:容器可以快速挂载到指定网桥,不影响其他服务网络。 ##### 总结 自定义网桥的核心作用是**隔离网络、灵活管理 IP 和路由、提高安全性和便捷性**,它让 Docker 容器网络更可控、可规划,并且适用于多租户、复杂服务和生产环境。 #### 多个自定义网桥 在 Docker 中,你可以创建 **多个自定义网桥**,并让不同容器连接到不同的网桥,从而实现容器间的 **网络隔离**。 ##### 多网桥支持 * Docker 不只支持默认 `docker0`,还可以通过 `brctl` 或 Docker 网络命令创建多个网桥。 * 每个网桥都是一个独立的 L2 网络,拥有自己的 IP 网段。 例如: ```bash # 创建两个自定义网桥 sudo brctl addbr bridgeA sudo ip addr add 192.168.10.1/24 dev bridgeA sudo ip link set dev bridgeA up sudo brctl addbr bridgeB sudo ip addr add 192.168.20.1/24 dev bridgeB sudo ip link set dev bridgeB up ``` ##### 容器连接不同网桥 在启动容器时,指定使用哪一个网桥: ```bash # 容器1连接 bridgeA docker run -dit --name container1 --network=bridgeA alpine # 容器2连接 bridgeB docker run -dit --name container2 --network=bridgeB alpine ``` 结果: * 容器1只在 bridgeA 网络内通信 * 容器2只在 bridgeB 网络内通信 * 默认情况下,桥间互不访问,实现了 **网络隔离** ##### 网络隔离的效果 不同网桥的容器: * IP 网段不同 * 默认不可以互相 ping 或访问端口 可以通过防火墙、`--link` 或自定义路由策略实现**必要的跨网桥访问**。 ##### 总结 Docker 支持多个网桥,每个网桥独立管理 IP 和流量。将容器分配到不同网桥,可以实现 **网络隔离**,提高安全性和网络管理可控性。 同时,你可以灵活配置 NAT、端口映射或防火墙规则,实现精细化访问控制。 #### 跨网桥通信 [更多](https://www.google.com/search?q=Docker%E8%B7%A8%E7%BD%91%E6%A1%A5%E9%80%9A%E4%BF%A1) ### Docker 容器点到点(P2P)网络连接 在默认情况下,Docker 会将所有容器连接到一个虚拟子网中(通常是 `docker0` 网桥)。 这意味着容器之间的通信都需要经过主机上的桥接设备转发。 然而,有时用户希望让两个容器**直接通信**,而不是通过 `docker0` 转发。 例如: * 构建高性能容器间通信通道; * 进行网络实验或自定义拓扑; * 测试自定义协议或非 IP 网络环境。 此时,就可以通过 **Linux veth(虚拟以太网对)** 来实现“点到点”连接(point-to-point link)。 **实现思路** veth pair(虚拟以太网对)是一对成对存在的虚拟网络接口: * 一端发送的数据会从另一端接收; * 它们可以被放入不同的网络命名空间(netns),从而实现两个容器之间的直连。 Docker 容器的网络环境本质上也是一个独立的 network namespace, 所以我们可以: 1. 启动两个容器(不使用默认网桥); 2. 创建一对 veth 接口; 3. 将这对接口分别放入两个容器的网络命名空间; 4. 配置 IP 地址与路由。 **操作步骤** **启动两个不带网络的容器** ```bash docker run -it --rm --net=none base /bin/bash root@1f1f4c1f931a:/# docker run -it --rm --net=none base /bin/bash root@12e343489d2f:/# ``` `--net=none` 表示容器启动后没有任何网络接口(除了 `lo`),方便我们手动配置。 **获取容器的进程号(PID)** ```bash docker inspect -f '{{.State.Pid}}' 1f1f4c1f931a # 输出:2989 docker inspect -f '{{.State.Pid}}' 12e343489d2f # 输出:3004 ``` 每个容器都有独立的网络命名空间,可以通过 `/proc//ns/net` 访问。 **将容器的网络命名空间挂载到系统可见位置** ```bash sudo mkdir -p /var/run/netns sudo ln -s /proc/2989/ns/net /var/run/netns/2989 sudo ln -s /proc/3004/ns/net /var/run/netns/3004 ``` 这样我们就可以使用 `ip netns exec` 命令直接操作容器网络。 **创建一对 veth 虚拟接口** ```bash sudo ip link add A type veth peer name B ``` 此时系统中出现了两张网络卡片: * `A` * `B` 它们是连通的,一端发包另一端就能收到。 **将接口放入容器网络命名空间并配置 IP** ```bash # 连接到容器 1 sudo ip link set A netns 2989 sudo ip netns exec 2989 ip addr add 10.1.1.1/32 dev A sudo ip netns exec 2989 ip link set A up sudo ip netns exec 2989 ip route add 10.1.1.2/32 dev A # 连接到容器 2 sudo ip link set B netns 3004 sudo ip netns exec 3004 ip addr add 10.1.1.2/32 dev B sudo ip netns exec 3004 ip link set B up sudo ip netns exec 3004 ip route add 10.1.1.1/32 dev B ``` **验证连接** 在任意一方容器中执行 ```bash ping 10.1.1.2 ``` 若可以连通,即表示点到点网络成功建立。 **拓展:保留原有网络 + 直连通道** 如果在启动容器时不加 `--net=none`, 例如: ```bash docker run -it --rm base /bin/bash ``` 则容器除了默认连接到 `docker0` 网桥外,还可以额外通过上述 veth 对建立点到点链路,实现双网络接口的效果:**既能访问外网,又能和特定容器直连通信** **安全提示:使用 `--icc=false` 的替代方案** 如果只是为了**限制容器之间的互访**,无需创建复杂链路,可以直接在 Docker 启动参数中设置: ```bash --icc=false --iptables=true ``` 这样容器间默认禁止互访,仅允许显式授权的端口访问,这是比手动隔离更简洁的安全方案。 --- --- url: /docker/no1d0nql/index.md --- # Docker 常用命令 ## 清理无用镜像、卷、网络 ```bash docker system prune -af --volumes ``` --- --- url: /docker/nz1bmds4/index.md --- # Armbian Docker 安装 **卸载旧的 Docker 版本(如果有)** ```bash sudo apt-get remove -y docker docker-engine docker.io containerd runc sudo apt-get update ``` **安装依赖工具** ```bash sudo apt-get install -y \ ca-certificates \ curl \ gnupg \ lsb-release ``` **添加 Docker 官方 GPG 密钥** ```bash sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg ``` **添加 Docker 官方 Debian 源(自动识别架构)** ```bash echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null ``` `dpkg --print-architecture` 会自动填入 `arm64` 或 `armhf`,根据你的 Armbian 架构自动选择。 **更新软件包索引** ```bash sudo apt-get update ``` 确认没有 404 错误,如果有错误提示,说明源配置错了 **安装 Docker Engine 和相关组件** ```bash sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` **验证安装** ```bash sudo docker version sudo docker run hello-world ``` 看到 `Hello from Docker!` 就说明 Docker 安装成功。 **可选:设置普通用户运行 Docker(免 sudo)** ```bash sudo usermod -aG docker $USER ``` 然后 **注销再登录**,即可直接用 `docker` 命令。 --- --- url: /docker/nz1bmdsd/index.md --- ## 导出 Docker 镜像为文件 ```bash docker images # 输出示例 # REPOSITORY TAG IMAGE ID # yumengjh/mynestapp 1.0.0 a458d16cc32a ``` 使用 `docker save` ```bash docker save -o mynestapp_1.0.0.tar yumengjh/mynestapp:1.0.0 ``` * `-o` 指定输出文件 * 文件会生成在当前目录,大小就是镜像大小(通常几十 MB 到几百 MB) * `.tar` 文件可以直接存储到硬盘、移动盘、OneDrive、NAS 等 ## 压缩文件(可选) Docker 镜像通常比较大,可以用 gzip 压缩: ```bash docker save yumengjh/mynestapp:1.0.0 | gzip > mynestapp_1.0.0.tar.gz ``` * 减少存储空间 * 传输更方便 ## 导入镜像文件 当你在另一台机器或者同一台机器恢复镜像时: ```bash # 如果是 tar docker load -i mynestapp_1.0.0.tar # 如果是压缩包 gunzip -c mynestapp_1.0.0.tar.gz | docker load ``` 导入完成后,使用 `docker images` 就能看到镜像 可以像普通镜像一样 `docker run` 使用导出的镜像 ```bash docker run -d -p 3000:3000 yumengjh/mynestapp:1.0.0 ``` 镜像不依赖仓库 可以随时在离线环境部署 ## 注意事项 1. **镜像大小** * 大镜像(几百 MB 或 GB)导出和导入都要时间 * 可以多阶段构建减小镜像大小 2. **版本管理** * 文件名最好带上版本号,方便管理 * 例如:`mynestapp_1.0.0.tar`、`mynestapp_1.0.1.tar` 3. **安全性** * 导出的镜像文件中包含完整镜像内容,确保不要随意外泄 ## 镜像一键部署脚本 检测系统是否安装 Docker,没有就安装(兼容 Debian/Ubuntu) 自动加载同目录下的镜像 `.tar` 文件 创建并运行容器,绑定端口,配置重启策略 人性化中文提示、容错处理、避免重复操作 文件名:`run_mydocker.sh` ```sh #!/bin/bash # ========================================= # 0基础友好Docker镜像运行脚本 # 使用方法: 把镜像tar文件和本脚本放在同一目录, chmod +x run_mydocker.sh, ./run_mydocker.sh # ========================================= IMAGE_FILE="mynestapp_1.0.0.tar" # 镜像文件名,可修改 CONTAINER_NAME="mynestapp" HOST_PORT=3000 # 映射宿主机端口,可修改 CONTAINER_PORT=3000 # 容器内部端口 RESTART_POLICY="always" # 重启策略 # ---------- 中文提示 ---------- echo "======================================" echo "欢迎使用 Docker 镜像快速部署脚本" echo "本脚本会自动检测Docker、加载镜像并运行容器" echo "======================================" # ---------- 检测 Docker ---------- if ! command -v docker &>/dev/null; then echo "检测到系统未安装 Docker,正在安装..." # Debian/Ubuntu安装流程 apt-get update apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release curl -fsSL https://get.docker.com | sh if [ $? -ne 0 ]; then echo "Docker 安装失败,请检查网络或权限!" exit 1 fi echo "Docker 安装完成!" else echo "检测到 Docker 已安装,跳过安装" fi # ---------- 检查镜像文件 ---------- if [ ! -f "$IMAGE_FILE" ]; then echo "未找到镜像文件 $IMAGE_FILE,请确保脚本与镜像在同一目录" exit 1 fi # ---------- 加载镜像 ---------- echo "正在加载镜像 $IMAGE_FILE ..." docker load -i "$IMAGE_FILE" if [ $? -ne 0 ]; then echo "镜像加载失败!请检查文件是否完整" exit 1 fi echo "镜像加载完成!" # ---------- 检查容器是否已存在 ---------- if [ "$(docker ps -aq -f name=$CONTAINER_NAME)" ]; then echo "检测到容器 $CONTAINER_NAME 已存在,尝试删除..." docker stop $CONTAINER_NAME &>/dev/null docker rm $CONTAINER_NAME &>/dev/null echo "旧容器已删除" fi # ---------- 运行容器 ---------- echo "正在启动容器 $CONTAINER_NAME ..." docker run -d \ --name $CONTAINER_NAME \ -p $HOST_PORT:$CONTAINER_PORT \ --restart=$RESTART_POLICY \ $IMAGE_FILE if [ $? -ne 0 ]; then echo "容器启动失败,请检查端口是否被占用" exit 1 fi echo "容器 $CONTAINER_NAME 启动成功!" echo "访问方式: http://<服务器IP>:$HOST_PORT" echo "脚本执行完毕" ``` 把 `run_mydocker.sh` 和导出的镜像 `mynestapp_1.0.0.tar` 放在同一目录 给脚本执行权限: ```bash chmod +x run_mydocker.sh ``` 执行脚本: ```bash sudo ./run_mydocker.sh ``` 脚本会自动安装 Docker(如果未安装)、加载镜像、运行容器,并设置 **开机自启** --- --- url: /docker/t7wt64b8/index.md --- # Docker 安全 ## 评估 Docker 安全性 理解 Docker 安全性的这三个核心方面,对于安全地部署和管理 Docker 容器至关重要。 #### 由内核的命名空间和控制组机制提供的容器内在安全 这是 Docker 安全的基础,它利用了 Linux 内核本身的功能来提供隔离和资源限制。 ##### 内核命名空间 命名空间的作用是 **隔离**。它让容器认为自己拥有一个独立的系统环境,而不知道其他容器或宿主机也在运行。Docker 主要使用了以下命名空间: * **PID 命名空间:** * **功能:** 隔离进程 ID。每个容器都有自己的 PID 1 进程(通常是 `init` 系统或应用进程),它看不到宿主机或其他容器中的进程。 * **安全意义:** 阻止了容器内进程通过 PID 向宿主机或其他容器发送信号(如 `kill -9`),提供了进程间的隔离。 * **Network 命名空间:** * **功能:** 为每个容器提供独立的网络栈,包括网络接口、IP 地址、路由表、端口号等。 * **安全意义:** 容器默认拥有私有的 IP 地址,需要通过端口映射才能被外部访问。这防止了容器随意监听宿主机网络或干扰其他容器的网络流量。 * **Mount 命名空间:** * **功能:** 隔离文件系统挂载点。每个容器只能看到自己的文件系统视图,无法访问其他容器或宿主机的未挂载目录。 * **安全意义:** 这是文件系统隔离的关键。防止容器访问或篡改宿主机的敏感数据(如 `/etc`, `/proc`)。 * **UTS 命名空间:** * **功能:** 允许容器拥有自己的主机名和域名。 * **安全意义:** 次要的安全隔离,防止容器修改宿主机的主机名。 * **IPC 命名空间:** * **功能:** 隔离进程间通信资源,如信号量、消息队列和共享内存。 * **安全意义:** 阻止容器通过 IPC 机制与宿主机或其他无关容器进行通信。 * **User 命名空间:** * **功能:** 这是 **最关键也是最强大的** 安全命名空间。它允许将容器内的用户 ID 和组 ID 映射到宿主机上不同的(通常是非特权的)用户 ID 和组 ID。 * **安全意义:** 容器内的 `root` 用户(UID 0)可以被映射到宿主机上的一个非特权的高位用户 ID(如 UID 100000)。这意味着,即使攻击者在容器内获得了 `root` 权限,它在宿主机上执行的操作也受到其映射到的非特权用户的权限限制,极大地减少了“容器逃逸”带来的影响。**默认情况下,Docker 不启用 User Namespace,需要手动配置。** ##### 控制组 控制组的作用是 **限制和审计**。 * **功能:** Cgroups 用于限制容器可以使用的资源上限,包括: * **CPU:** 设置 CPU 份额或绑定到特定 CPU 核心。 * **内存:** 设置硬性和软性内存限制。 * **块设备 I/O:** 限制磁盘的读写带宽。 * **网络 I/O:** 虽然主要由网络命名空间处理,但也可以通过其他工具配合 cgroups 进行限制。 * **安全意义:** * **防止拒绝服务攻击:** 确保单个行为异常或恶意的容器不会耗尽宿主机的所有资源(如内存、CPU),从而导致系统瘫痪或其他容器被“饿死”。 * **确保服务质量:** 为关键容器保障必要的资源。 * **提供审计:** 统计容器的资源使用情况。 **小结:** 命名空间和 Cgroups 共同构成了容器的“沙箱”,提供了基础的隔离和资源控制。但 **它们并非无懈可击**,历史上曾出现过利用内核漏洞突破命名空间隔离的案例。 #### Docker 程序(特别是服务端)本身的抗攻击性 Docker 采用客户端-服务器架构。守护进程 `dockerd` 以 root 权限运行,这是整个体系中最主要的安全攻击面。 ##### Docker 守护进程的攻击面 * **REST API:** Docker 守护进程监听一个 Socket(通常是 Unix 套接字 `/var/run/docker.sock`,有时也可能是 TCP 端口)来接收来自 `docker` 客户端的命令。 * **安全风险:** * 任何能访问到 Docker Socket 的用户或进程,本质上就获得了在宿主机上执行任意命令的权限(因为 `dockerd` 以 root 运行)。例如,他们可以通过 `docker run -v /:/host ...` 将宿主机根目录挂载到容器中,从而完全控制宿主机。 * 如果错误地将 Docker API 暴露在 TCP 端口且没有适当的认证和加密,那么网络上的任何人均可控制 Docker 守护进程。 ##### 安全最佳实践 * **严格保护 Docker Socket:** 永远不要将 `docker.sock` 暴露给不可信的容器。在容器内挂载 `docker.sock` 是一种常见的 CI/CD 模式,但这等同于将 root 权限交给了该容器,需要极其谨慎。 * **使用非 root 用户运行守护进程:** 较新版本的 Docker 支持以非 root 用户运行守护进程,这可以减轻攻击影响。 * **启用 TLS 认证:** 如果必须使用 TCP 连接,务必配置 TLS 双向认证,以确保只有授权的客户端才能连接。 * **定期更新:** Docker 本身也可能存在漏洞,需要保持最新版本,以获取安全补丁。 **小结:** Docker 守护进程是一个高权限、高价值的目标。其安全性很大程度上依赖于配置的合理性和对访问权限的严格控制。 #### 内核安全性的加强机制对容器安全性的影响 由于命名空间和 Cgroups 并非绝对安全,我们需要额外的安全层来“加固”内核和容器,这就是“纵深防御”策略。 ##### Capabilities * **概念:** Linux Capabilities 将 root 用户的超级权限分解为一系列独立的权限单元。 * **Docker 的应用:** 默认情况下,Docker 容器在启动时只被授予一个 **受限的** 能力集合,即使容器内以 root 运行。例如,默认被移除的能力包括: * `CAP_SYS_MODULE`: 禁止加载内核模块。 * `CAP_SYS_ADMIN`: 禁止执行大量系统管理命令。 * `CAP_NET_RAW`: 禁止创建原始套接字(防止 `ping` 等)。 * **安全实践:** * **原则:** 使用 `--cap-drop` 移除所有能力,然后仅通过 `--cap-add` 添加应用运行所必需的最小能力集。 * **示例:** `docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...` ##### Seccomp * **概念:** Seccomp 是一种内核安全机制,用于限制进程可以执行的系统调用。 * **Docker 的应用:** Docker 提供了一个默认的 Seccomp 配置文件,它白名单了大约 300 个系统调用,并禁用了大约 40 个被认为危险或不必要的系统调用(如 `swapon`, `swapoff`, `reboot`)。 * **安全实践:** 可以使用自定义的 Seccomp 配置文件,进一步收紧系统调用的访问,为特定应用量身定制最严格的策略。 ##### AppArmor 和 SELinux * **概念:** 它们是强制访问控制系统,可以为进程定义其可以访问的文件、网络端口等资源的精细策略。 * **Docker 的应用:** * Docker 默认会为容器生成并加载一个 AppArmor 配置文件(如果宿主机支持)。 * 这个默认策略阻止了容器进行许多危险操作,例如写入 `/proc` 和 `/sys` 下的某些文件。 * **安全实践:** 可以编写自定义的 AppArmor 或 SELinux 策略,为容器提供更强的强制访问控制。 ##### 其他加固技术 * **无特权容器:** 使用 User Namespace,如前所述,是最大的安全改进之一。 * **只读根文件系统:** 使用 `--read-only` 标志启动容器,防止恶意代码写入文件系统。 * **安全计算模式:** 使用 `--security-opt seccomp=...` 应用自定义 Seccomp 策略。 #### 总结 评估 Docker 安全性是一个多层次的过程: 1. **基础隔离层:** 依赖 Linux 内核的 **命名空间和控制组**。这是容器技术的基石,提供了基本的沙箱环境,但存在被内核漏洞攻破的风险。 2. **程序攻击面:** **Docker 守护进程** 本身是一个关键风险点,必须通过严格的访问控制和网络加密来保护。 3. **纵深防御层:** 利用 **Capabilities、Seccomp、AppArmor/SELinux** 等内核安全机制,遵循“最小权限原则”,极大地提升容器的安全性,即使基础隔离被突破,也能有效遏制攻击。 一个安全的 Docker 部署,必然是这三个方面协同作用的结果,缺一不可。 ## 内核命名空间 您可以把 Docker 容器想象成一个 **装修好的酒店房间**。 * **命名空间 = 房间的墙和门锁** * 它提供了最基本的 **隔离**。你在你的房间里活动、睡觉,不会被走廊或其他房间的客人看到或打扰(进程隔离)。 * 每个房间都有独立的 **门牌号和钥匙**(网络栈)。你无法直接进入另一个房间,除非酒店(宿主机)在前台为你们建立了联系(比如通过端口映射或 Links)。 * **网络通信 = 酒店的内部电话系统** * 所有房间(容器)都通过酒店的总机(**主机的网桥接口**)相互联系。要打电话给另一个房间,你需要知道对方的房间号,并通过总机转接。这就像物理机器通过一台共享的交换机来通信。 **那么,这套“建房间”的技术可靠吗?非常可靠。** * **历史悠久**:支撑 Docker 容器的核心技术(Linux 命名空间)在 **2008 年** 就已经被加入到主流 Linux 内核中了。这意味着它已经经历了超过 15 年的实践检验和持续改进。 * **久经考验**:全球无数大型互联网公司(如 Google, Netflix)每天都在数百万台服务器上运行着容器,其稳定性和安全性已经得到了充分的证明。 * **源远流长**:这个想法本身甚至更早,源于一个叫 **OpenVZ** 的成熟容器项目(2005 年)。Docker 使用的是经过长期打磨和验证的内核基础。 **总结** 1. **隔离可靠**:Docker 使用 **命名空间** 为每个容器创建了独立的“小房间”,实现了有效的进程和网络隔离。 2. **通信可控**:容器之间通过 **主机网桥** 通信,就像酒店总机转接电话,既方便又可控。 3. **技术成熟**:这套底层技术 **并非新技术**,它已经发展了近二十年,非常稳定可靠,足以支撑当今最重要的商业应用。 ## 控制组 控制组,可以理解为我们为容器设定的 **“资源管理员”**。 想象一下,在一台物理服务器上运行着多个容器,就像在一栋合租房里住了好几个室友。如果没有管理,有人可能会疯狂下载,占满所有网络;有人可能冬天把暖气开到最大,让其他人受冻。 控制组的作用就是 **给每个室友(容器)设定规矩**,确保大家能公平、和谐地共用房子(主机)的资源: 1. **资源限制(设定上限)**: * **比如内存**:可以明确规定“你的容器最多只能用 2GB 内存”,防止某个程序内存泄漏,吃光所有内存,导致整个主机瘫痪。 * **比如 CPU**:可以规定“你的容器最多只能使用 30% 的 CPU”,防止一个容器跑满 CPU,让其他容器和主机系统都卡死。 2. **资源保障(保证下限)**: * 可以承诺“至少保证 10% 的 CPU 分配给你的容器”,这样即使系统很忙,你的关键服务也能获得最基本的资源,确保性能稳定。 3. **资源审计(计算账单)**: * 控制组会记录每个容器到底使用了多少 CPU、内存、网络流量等。这对于成本核算、性能监控和故障排查非常有用。 **为什么这很重要?** 它的核心安全意义在于:**确保一个“坏掉”或“恶意”的容器不会拖垮整个主机**。这就像给合租房的每个房间都安装了独立的水电闸,一个房间漏水短路,不会淹了整栋楼。这在云服务平台上是必不可少的功能,因为它能有效防止因单个用户应用异常而导致的“拒绝服务”攻击。 **总结** * **它是什么**:控制组是 Linux 内核用于 **限制、记录和隔离进程资源使用** 的机制。 * **核心作用**:确保容器 **公平分享** 主机资源(CPU、内存、磁盘 IO 等),并且 **当一个容器出问题时,不会连累整个主机系统**。 * **关键价值**:它是实现多租户环境下稳定性的基石,能有效防止因资源争夺导致的系统瘫痪。 ## 服务端防护 运行一个容器或应用程序的核心是通过 Docker 服务端。Docker 服务的运行目前需要 root 权限,因此其安全性十分关键。 首先,确保只有可信的用户才可以访问 Docker 服务。Docker 允许用户在主机和容器间共享文件夹,同时不需要限制容器的访问权限,这就容易让容器突破资源限制。例如,恶意用户启动容器的时候将主机的根目录 `/` 映射到容器的 `/host` 目录中,那么容器理论上就可以对主机的文件系统进行任意修改了。这听起来很疯狂?但是事实上几乎所有虚拟化系统都允许类似的资源共享,而没法禁止用户共享主机根文件系统到虚拟机系统。 这将会造成很严重的安全后果。因此,当提供容器创建服务时(例如通过一个 web 服务器),要更加注意进行参数的安全检查,防止恶意的用户用特定参数来创建一些破坏性的容器。 为了加强对服务端的保护,Docker 的 REST API(客户端用来跟服务端通信)在 0.5.2 之后使用本地的 Unix 套接字机制替代了原先绑定在 127.0.0.1 上的 TCP 套接字,因为后者容易遭受跨站脚本攻击。现在用户使用 Unix 权限检查来加强套接字的访问安全。 用户仍可以利用 HTTP 提供 REST API 访问。建议使用安全机制,确保只有可信的网络或 VPN,或证书保护机制(例如受保护的 stunnel 和 ssl 认证)下的访问可以进行。此外,还可以使用 [HTTPS 和证书](https://docs.docker.com/engine/security/https/) 来加强保护。 最近改进的 Linux 命名空间机制将可以实现使用非 root 用户来运行全功能的容器。这将从根本上解决了容器和主机之间共享文件系统而引起的安全问题。 终极目标是改进 2 个重要的安全特性: * 将容器的 root 用户 [映射到本地主机上的非 root 用户 ](https://docs.docker.com/engine/security/userns-remap/),减轻容器和主机之间因权限提升而引起的安全问题; * 允许 Docker 服务端在 [非 root 权限(rootless 模式)](https://docs.docker.com/engine/security/rootless/) 下运行,利用安全可靠的子进程来代理执行需要特权权限的操作。这些子进程将只允许在限定范围内进行操作,例如仅仅负责虚拟网络设定或文件系统管理、配置操作等。 最后,建议采用专用的服务器来运行 Docker 和相关的管理服务(例如管理服务比如 ssh 监控和进程监控、管理工具 nrpe、collectd 等)。其它的业务服务都放到容器中去运行。 ## 内核能力机制 [能力机制(Capability)](https://man7.org/linux/man-pages/man7/capabilities.7.html) 是 Linux 内核一个强大的特性,可以提供细粒度的权限访问控制。 Linux 内核自 2.2 版本起就支持能力机制,它将权限划分为更加细粒度的操作能力,既可以作用在进程上,也可以作用在文件上。 例如,一个 Web 服务进程只需要绑定一个低于 1024 的端口的权限,并不需要 root 权限。那么它只需要被授权 `net_bind_service` 能力即可。此外,还有很多其他的类似能力来避免进程获取 root 权限。 默认情况下,Docker 启动的容器被严格限制只允许使用内核的一部分能力。 使用能力机制对加强 Docker 容器的安全有很多好处。通常,在服务器上会运行一堆需要特权权限的进程,包括有 ssh、cron、syslogd、硬件管理工具模块(例如负载模块)、网络配置工具等等。容器跟这些进程是不同的,因为几乎所有的特权进程都由容器以外的支持系统来进行管理。 * ssh 访问被主机上 ssh 服务来管理; * cron 通常应该作为用户进程执行,权限交给使用它服务的应用来处理; * 日志系统可由 Docker 或第三方服务管理; * 硬件管理无关紧要,容器中也就无需执行 udevd 以及类似服务; * 网络管理也都在主机上设置,除非特殊需求,容器不需要对网络进行配置。 从上面的例子可以看出,大部分情况下,容器并不需要“真正的” root 权限,容器只需要少数的能力即可。为了加强安全,容器可以禁用一些没必要的权限。 * 完全禁止任何 mount 操作; * 禁止直接访问本地主机的套接字; * 禁止访问一些文件系统的操作,比如创建新的设备、修改文件属性等; * 禁止模块加载。 这样,就算攻击者在容器中取得了 root 权限,也不能获得本地主机的较高权限,能进行的破坏也有限。 默认情况下,Docker 采用 [白名单](https://github.com/moby/moby/blob/master/oci/caps/defaults.go) 机制,禁用必需功能之外的其它权限。 当然,用户也可以根据自身需求来为 Docker 容器启用额外的权限。 ### 什么是能力机制? 你可以把传统的 **root 用户** 想象成一把 **“万能钥匙”** ,拥有整个系统的所有权限。一旦把这把钥匙交给某个程序,它就可以在系统里为所欲为,这非常危险。 而 **Linux 能力机制** 则像是将这把“万能钥匙”拆分成了一系列 **“专用门卡”** ,比如: * `net_bind_service`:是一张“网络端口门卡”,允许持卡程序绑定特定的网络端口。 * … 以及其他几十种不同的“门卡”,分别控制着加载内核模块、修改系统时间等特定权限。 这样一来,一个程序(比如 Web 服务器)无需拿到“万能钥匙”(root 权限),只需要领取它工作所必需的那张“门卡”(特定能力),就能完成它的任务。这大大减少了程序被滥用时的破坏范围。 ### Docker 如何利用能力机制加强安全? Docker 深谙“最小权限原则”,默认情况下,它启动容器时只会授予一个**非常小的白名单能力集**,许多高风险的能力在默认情况下就被禁用了。 **为什么这样做很安全且合理?** 因为在容器这个“小房间”里,很多需要 root 权限的系统管理任务,其实都由外面的“酒店管理方”(宿主机系统)承担了: | 系统任务 | 在传统服务器中 | 在 Docker 环境中的情况 | | ------------ | ------------------------------------------- | ---------------------------------- | | **SSH 连接** | 由 `sshd` 进程(root权限)处理 | 由宿主机管理,容器内不需要 | | **定时任务** | 由 `cron` 进程(root权限)处理 | 可作为用户进程运行,或由外部管理 | | **硬件管理** | 由 `udevd` 进程(root权限)处理 | 与容器无关,无需此权限 | | **网络配置** | 由 root 权限工具(如 `ip`, `ifconfig`)处理 | 主要由宿主机负责,容器只需使用网络 | **最终的安全效果是:** 即使攻击者成功在容器内取得了 root 身份,由于 Docker 已经收回了大部分关键的“专用门卡”(能力),他能造成的破坏也极其有限。他无法挂载新设备、无法加载恶意内核模块、也无法直接操作宿主机的网络设备,从而无法从容器这个“小房间”逃逸到“酒店主体结构”(宿主机)中。 ### 总结 1. **核心思想**:能力机制将 root 的超级权限拆分为几十种细粒度的权限,实现了从“万能钥匙”到“专用门卡”的进化。 2. **Docker的实践**:Docker 默认采用**白名单机制**,只赋予容器所必需的最小能力集合,遵循了“最小权限原则”。 3. **安全价值**:这极大地增强了容器安全性。即使容器被攻破,攻击者由于缺乏关键能力,也无法对宿主机造成严重影响,实现了有效的安全隔离。 简单来说,Docker 通过能力机制,既保证了容器能正常运行,又牢牢锁住了它通往宿主机的“后门”。 ## 总结 总体而言,在合理配置下,Docker容器是相当安全的。其安全性在以下两种情况下会得到极大提升: 1. **最佳实践**:在容器内不使用root用户来运行应用进程。 2. **纵深防御**:使用现有的安全工具为容器增加额外的防护层。 这些安全工具就像是给容器穿上不同类型的“护甲”,共同构成纵深防御体系: | 安全机制 | 通俗理解 | 在Docker中的作用 | | -------------------- | ---------------------- | ------------------------------------------------------------ | | **Seccomp** | **“系统调用过滤器”** | 限制容器内的进程只能使用一部分必要的系统调用(比如禁止调用“重启系统”的指令),大幅减少攻击面。 | | **AppArmor/SELinux** | **“行为访问控制列表”** | 为容器定义一个严格的“行为守则”,明确规定它能够读、写或执行哪些文件、端口等,防止越权行为。 | | **GRSEC** | **“增强型内核补丁”** | 通过对整个Linux内核打上安全补丁,提供一系列增强防护,从更底层保护主机和所有容器。 | **总结** 1. **基础安全**:Docker本身通过命名空间和控制组提供了良好的默认隔离。 2. **关键提升**:**在容器内以非root用户运行程序**,是提升安全性的最简单、最有效的方法之一。 3. **高级加固**:通过**Seccomp、AppArmor或SELinux**等工具,可以实施“最小权限”原则,为容器定制严格的安全策略,从而构建起坚固的纵深防御,即使应用存在漏洞,也能有效控制破坏范围。 因此,您可以相信,只要遵循基本的安全实践并善用现有工具,Docker容器环境完全可以满足生产级的安全要求。 **只要严格遵循 Docker 安全最佳实践,并采用“纵深防御”策略,就可以在生产环境中构建起风险可控、足够安全的应用运行平台。** 您可以对 Docker 的安全性抱有高度的信心。它提供了坚实的安全基础和一整套成熟的可用于加固的工具。**只要您主动地、有意识地去配置和管理它,它就能成为一个用于生产环境的安全可靠的平台。** 它的安全性不是自动获得的,而是正确实践的结果。 --- --- url: /docker/u98f21md/index.md --- 容器是 Docker 的一个核心概念。 简单来说,容器是一个独立运行的应用或一组应用,以及它们所需的运行环境。相比之下,虚拟机则相当于模拟出一整套操作系统(包含完整的运行环境和系统资源),然后在其上运行应用程序。 ## 管理容器 下面的命令输出一个 “Hello World”,之后终止容器。 ```bash docker run ubuntu:18.04 /bin/echo 'Hello world' # Hello world ``` 启动一个 bash 终端,允许用户进行交互。 ```bash docker run -t -i ubuntu:18.04 /bin/bash ``` 其中,`-t` 选项让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上, `-i` 则让容器的标准输入保持打开。 当利用 `docker run` 来创建容器时,Docker 在后台运行的标准操作包括: * 检查本地是否存在指定的镜像,不存在就从 Registry 下载 * 利用镜像创建并启动一个容器 * 分配一个文件系统,并在只读的镜像层外面挂载一层可读写层 * 从宿主主机配置的网桥接口中桥接一个虚拟接口到容器中去 * 从地址池配置一个 ip 地址给容器 * 执行用户指定的应用程序 * 执行完毕后容器被终止 \*\[Registry]:指任何存储和分发 Docker 镜像的服务 **容器核心特点**:容器只包含运行所需的应用程序及其资源,不会额外占用其他系统资源。通过 `ps` 或 `top` 可以看到容器内仅运行指定的进程(如 bash)。 ```bash root@f58ec56d66bc:/# top PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 18 root 20 0 8644 4220 2344 R 0.3 0.1 0:00.03 top 1 root 20 0 4296 3552 3020 S 0.0 0.1 0:00.13 bash ``` ```bash root@f58ec56d66bc:/# ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 19 pts/0 00:00:00 ps ``` **轻量化优势**:由于只运行必要的应用和进程,Docker 容器资源利用率极高,是典型的轻量级虚拟化方案。 ### 容器创建与启动 * `docker run [OPTIONS] IMAGE [COMMAND] [ARG...]` 创建并启动一个新容器。 * `docker create [OPTIONS] IMAGE [COMMAND] [ARG...]` 只创建容器,不启动。 * `docker start CONTAINER` 启动已停止的容器。 * `docker restart CONTAINER` 重启容器。 ### 容器停止与暂停 * `docker stop CONTAINER` 停止运行中的容器。 * `docker kill CONTAINER` 强制终止容器。 * `docker pause CONTAINER` 暂停容器内的所有进程。 * `docker unpause CONTAINER` 取消暂停,继续运行。 ### 容器删除 * `docker rm CONTAINER` 删除一个或多个已停止的容器。 * `docker rm -f CONTAINER` 强制删除运行中的容器。 * `docker container prune` 删除所有已停止的容器,释放空间。 ### 容器查看与管理 * `docker ps` 查看正在运行的容器。 * `docker ps -a` 查看所有容器(包括停止的)。 * `docker inspect CONTAINER` 查看容器详细信息(配置、网络、挂载等)。 * `docker logs CONTAINER` 查看容器日志输出。 * `docker top CONTAINER` 查看容器内运行的进程。 * `docker stats [CONTAINER...]` 实时查看容器资源使用情况(CPU、内存、网络等)。 ### 容器命令执行 * `docker exec -it CONTAINER COMMAND` 在运行的容器中执行命令(常用于进入容器交互式 shell)。 * `docker attach CONTAINER` 连接到正在运行的容器的主进程终端。 ### 其他常用操作 * `docker rename CONTAINER NEW_NAME` 重命名容器。 * `docker commit CONTAINER NEW_IMAGE` 将容器当前状态保存为新的镜像。 * `docker export CONTAINER > file.tar` 导出容器文件系统为 tar 文件。 * `docker cp CONTAINER:SRC_PATH DEST_PATH` 从容器拷贝文件到宿主机(或反向拷贝)。 ## 后台运行 大多时候,我们希望容器在后台运行,而不是占用当前终端。可以使用 `-d` 选项让容器在后台运行。 否则容器会把主进程的标准输出和标准错误输出绑定到当前终端,直到容器终止或用户按 `Ctrl+C` 停止容器。 使用 `-d` 选项(即后台模式)运行容器时,主进程的标准输出(stdout)和标准错误输出(stderr)会被重定向到容器的日志系统中,可通过 `docker logs` 命令查看。 后台模式运行会返回容器 ID。 ```bash docker run -d nginx:latest # e2f1c3b4d5f6a7b8c9 docker logs e2f1c3b4d5f6a7b8c9 # [日志输出] # ... ``` ## 进入容器 有些时候需要进入正在运行的容器内进行操作,比如进入一个mysql容器执行SQL语句,或者进入一个web应用容器查看日志文件等。 可以使用 `docker attach` 命令或 `docker exec` 命令,但是更推荐使用 `docker exec`,因为 `docker attach` 会连接到容器的主进程终端,可能会影响容器的正常运行。 当你使用 ```bash docker attach e2f1c3b4d5f6a7b8c9 ``` 连接到容器后,你其实是**直接附着到容器主进程的标准输入输出**上(通常是 PID 1,比如 `/bin/bash` 或应用进程)。 因此: * 如果你在 attach 会话中输入 `exit`,就等价于让主进程退出。 * 主进程一退出,整个容器就会**随之停止**。 通常的做法是: ```bash docker exec -it e2f1c3b4d5f6a7b8c9 /bin/bash ``` 这样打开的是一个**新的子进程 shell**,即使你退出 (`exit`),主进程也不会受影响,容器依然保持运行。 | 命令 | 行为 | 退出容器后效果 | | ----------------- | -------------- | ------------------------- | | `docker attach` | 直接连接主进程 | 主进程退出 → 容器停止 | | `docker exec -it` | 启动新子进程 | 子进程退出 → 容器继续运行 | ## 导出和导入容器 ### 导出容器 如果要导出本地某个容器,可以使用 `docker export` 命令。 ```bash docker export [CONTAINER_ID/CONTAINER_NAME] > [FILE_NAME].tar ``` 这样将导出容器快照到本地文件。 ### 导入容器快照 可以使用 `docker import` 从容器快照文件中再导入为镜像,例如 ```bash cat [FILE_NAME].tar | docker import - [IMAGE_NAME]:[TAG] ``` 此外,也可以通过指定 URL 或者某个目录来导入,例如 ```bash docker import https://example.com/someimage.tar docker import /path/to/someimage.tar ``` * **`docker load`**:用于导入由 `docker save` 导出的**镜像存储文件**,保留完整的镜像层、历史记录和元数据。 * **`docker import`**:用于导入由 `docker export` 导出的**容器快照文件**,只保留文件系统内容,丢失历史记录和元数据,但可重新指定标签等信息。 * 因此,`load` 更完整但体积大,`import` 更轻便但信息少。 --- --- url: /docker/vhajxjfz/index.md --- ## Dockerfile 镜像的定制实际上就是定制每一层所添加的配置、文件。 如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么**无法重复的问题、镜像构建透明性的问题、体积的问题**就都会解决。这个脚本就是 Dockerfile。 Dockerfile 是一个文本文件,其内包含了一条条的 **指令(Instruction)**,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。 ::: details 一个 node 项目的Dockerfile ```dockerfile # =============================== # Step 1: 使用官方 Node.js 镜像作为基础镜像 # 这里用的是 Node 20 LTS,带有 npm # =============================== FROM node:20 # =============================== # Step 2: 设置工作目录 # 所有后续命令都在 /app 目录下执行 # =============================== WORKDIR /app # =============================== # Step 3: 复制 package.json 和 package-lock.json # 先复制依赖文件,可以利用 Docker 缓存加速构建 # =============================== COPY package*.json ./ # =============================== # Step 4: 安装依赖 # npm install 会安装所有依赖包 # =============================== RUN npm install # =============================== # Step 5: 复制整个项目文件 # 把源代码复制到容器内 # =============================== COPY . . # =============================== # Step 6: 编译 TypeScript # NestJS 默认使用 TypeScript # =============================== RUN npm run build # =============================== # Step 7: 暴露应用端口 # NestJS 默认端口 3000 # =============================== EXPOSE 3000 # =============================== # Step 8: 设置容器启动命令 # 使用编译后的 JS 文件启动应用 # =============================== CMD ["node", "dist/main.js"] ``` ```bash docker build -t [镜像名称/版本号] . ``` ::: ### FROM 构建一个镜像的第一步,就是选择一个**基础镜像**,这个基础镜像就是我们要定制的镜像的**起点**。 在这个基础镜像上,我们可以**添加文件、安装软件、配置环境**,从而一步步构建出我们想要的镜像。 而选择基础镜像的指令,就是 `FROM`,一个 Dockerfile 必须以 `FROM` 开头。 比如构建一个 Java 应用的镜像,我们可以选择一个带有 JDK 的官方镜像作为基础,比如 `openjdk:17-jdk-slim`,然后在这个基础镜像上安装我们应用需要的其他软件、复制应用文件、配置环境变量等。 再或者,我们要构建一个 Node.js 应用的镜像,可以选择 `node:20` 作为基础镜像,Python 应用可以选择 `python:3.11-slim`,Ruby 应用可以选择 `ruby:3.2`,Go 应用可以选择 `golang:1.21`,等等。 甚至可以选择一些更广阔的基础镜像,比如 `ubuntu:20.04`、`debian:bookworm-slim`、`alpine:3.18`,然后在这些基础镜像上手动安装 JDK、Node.js、Python 等运行环境。 Docker 有一个特殊的镜像 `scratch`,它是一个**空镜像**,没有任何文件系统。它通常用于构建**极简的基础镜像**,比如只包含一个静态编译的二进制文件,没有任何额外的依赖。 ### COPY 格式: * `COPY [--chown=:] <源路径>... <目标路径>` * `COPY [--chown=:] ["<源路径1>",... "<目标路径>"]` 一种类似于命令行,一种类似于函数调用。 `COPY` 指令将从构建上下文目录中 `<源路径>` 的文件/目录复制到新的一层的镜像内的 `<目标路径>` 位置。比如: ```dockerfile COPY package.json /usr/src/app/ ``` `<源路径>` 可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 [`filepath.Match` ](https://golang.org/pkg/path/filepath/#Match)规则,如: ```dockerfile COPY hom* /mydir/ COPY hom?.txt /mydir/ ``` `<目标路径>` 可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(**工作目录可以用 `WORKDIR` 指令来指定**)。 目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。 此外,还需要注意一点,使用 `COPY` 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。 在使用该指令的时候还可以加上 `--chown=:` 选项来改变文件的所属用户及所属组。 语法示例: ```dockerfile COPY --chown=user:group src/ /dest/ COPY --chown=uid:gid src/ /dest/ ``` ```docker COPY --chown=55:mygroup files* /mydir/ # 用户是 UID 55 所属组是 mygroup COPY --chown=bin files* /mydir/ # 用户是 bin 所属组 默认使用 bin 用户的主组 COPY --chown=1 files* /mydir/ # 用户是 UID 1 所属组 默认使用 UID 1 的主组 COPY --chown=10:11 files* /mydir/ # 用户是 UID 10 所属组是 GID 11 ``` **注意**: **用户/组必须存在** * 使用用户名或组名时,容器里必须有这个用户或组,否则构建会报错 * 使用 UID/GID 可以避免依赖存在的用户名,但 GID 也最好是容器里已存在或你自己创建的 **主组默认行为** * 如果只写 `--chown=user`,所属组会默认用这个用户的主组 **如果源路径为文件夹,复制的时候不是直接复制该文件夹,而是将文件夹中的内容复制到目标路径。** ### ADD `ADD` 指令和 `COPY` 的格式和性质基本一致。但是在 `COPY` 基础上增加了一些功能。 比如 `<源路径>` 可以是一个 `URL`,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 `<目标路径>` 去。 对于 `ADD` 指令下载的远程文件,Docker 不会自动修改权限,默认权限通常为 644,如果需要其他权限(例如 600),需要额外增加一条 `RUN chmod` 指令进行调整。此外,如果下载的是压缩包,Docker 不会自动解压,需要额外增加 `RUN` 指令手动解压。 所以不如直接使用 `RUN` 指令,然后使用 `wget` 或者 `curl` 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。 `ADD` 虽然能下载文件,但 **权限不会自动设置**,**远程压缩包不会自动解压** 每次 `ADD` 或 `RUN` 都会增加一层镜像,如果你先 ADD 再 RUN chmod,再 RUN 解压,就多了好几层,镜像会臃肿 使用 `RUN` + `wget/curl && chmod && 解压 && rm`,**一条 RUN 链接所有操作**,只增加一层,下载、权限、解压、清理都在同一条指令里,并且清理临时文件不会留在中间层 ```dockerfile RUN wget -O /tmp/app.tar.gz https://example.com/app.tar.gz && \ tar -xzf /tmp/app.tar.gz -C /app && \ chmod 600 /app/config.yml && \ rm /tmp/app.tar.gz ``` 一条 RUN 完成:下载,解压,修改权限,删除临时文件,并且**只产生一层镜像**,比多条 RUN 更轻量 **总结**: 在 Dockerfile 中,`ADD` 可以自动解压**本地**的 tar、tar.gz、tar.bz2、tar.xz 压缩文件到目标路径,并尽量保留目录结构和元数据; 但对于远程 URL 下载的压缩包,`ADD` 不会自动解压,也不会修改权限,默认权限通常为 644,如果需要其他权限或解压操作,需要额外增加 `RUN` 指令手动处理。因此,对于远程文件或压缩包,更推荐使用 `RUN` + `wget/curl && chmod && 解压 && 清理` 的方式,一条命令完成所有操作,更可控且镜像更轻量。 在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像 `ubuntu` 中: ```docker FROM scratch ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz / ... ``` 从 scratch + ADD rootfs 与直接 FROM ubuntu 的差别在于控制权和精简程度:前者可以完全自定义每一层内容,更轻量或者用于教学/实验,后者方便、快速、官方维护,但可能包含额外不必要的层和包。 在 Dockerfile 中,**COPY 和 ADD 都可以复制文件到镜像**,但官方最佳实践推荐: **尽量使用 COPY** * 语义明确,只复制文件或目录 * 行为简单,可预测 * 不会意外触发自动解压或下载 **仅在需要自动解压本地 tar/gzip/bzip2/xz 文件时使用 ADD** * ADD 会自动解压本地压缩包 * 对远程 URL 下载的文件不会自动解压,但可能增加镜像层,影响构建缓存 **权限与用户设置** * COPY 和 ADD 可配合 `--chown=:` 设置文件所属用户和组 * `` 和 `` 可以是名称或 UID/GID **注意构建缓存** * ADD 的自动解压或远程下载功能可能会使缓存失效,导致构建变慢 ### CMD `CMD` 指令的格式和 `RUN` 相似,也是两种格式: * `shell` 格式:`CMD <命令>` * `exec` 格式:`CMD ["可执行文件", "参数1", "参数2"...]` * 参数列表格式:`CMD ["参数1", "参数2"...]`。在指定了 `ENTRYPOINT` 指令后,用 `CMD` 指定具体的参数。 **容器不是虚拟机,而是进程** * 容器就是一个运行在宿主机上的进程,它依赖镜像提供文件系统和运行环境 * 容器启动时必须指定主进程及其参数,这就是容器存在的核心 **RUN 指令** * **用途**:在镜像构建阶段执行命令 * **作用**:安装软件、编译代码、处理文件等 * **执行时机**:镜像构建阶段 * **注意事项**:每条 RUN 会生成一层镜像,可写多条 ```dockerfile RUN apt-get update && apt-get install -y curl RUN npm install ``` **CMD 指令** * **用途**:指定容器启动时默认执行的主进程命令 * **执行时机**:容器运行阶段 * **特性**: * 可以被 `docker run ` 替换 * 镜像中只能有一条 CMD,多条会覆盖前面的 * **格式**: **exec(JSON)格式**:推荐使用,信号转发正确 ```dockerfile CMD ["npm", "start"] CMD ["nginx", "-g", "daemon off;"] ``` **shell 格式**:会被包装为 `sh -c "..."`,支持环境变量和管道 ```dockerfile CMD echo $HOME # 实际执行为 CMD ["sh", "-c", "echo $HOME"] ``` 前台 和 后台进程 容器没有后台服务的概念,主进程就是容器的核心 **主进程退出,容器就退出** 避免用 `service nginx start` 或 systemd 启动后台服务 * 因为主进程变成了 `sh`,后台服务结束后容器立即退出 正确做法:直接执行可执行程序并以前台形式运行 ```dockerfile CMD ["nginx", "-g", "daemon off;"] ``` ::: details * 容器的本质上**不是虚拟机**,没有完整的 init/systemd/systemctl 管理后台服务 * 容器就是一个运行在宿主上的进程空间,**主进程就是容器的核心** * 主进程退出,容器退出 **不需要用系统服务去启动**,比如 `service nginx start` 或 systemd **直接用 CMD 或 ENTRYPOINT 指定项目的启动命令** * 项目本身就是容器的主进程 * 必须以前台形式运行,否则主进程退出,容器随之退出 ```dockerfile # 正确示例:Node.js 应用 CMD ["npm", "start"] # 正确示例:Nginx CMD ["nginx", "-g", "daemon off;"] ``` **原因总结** * 容器的生命周期依赖主进程 * 后台服务模式(像 VM 里那样)在容器里会导致主进程退出后容器立即停止 * 所以在容器里 **每个服务都应以主进程形式启动** **总结**:容器运行项目时不依赖系统去调起服务,直接 CMD 指定启动命令即可,项目本身就是容器的主进程,以前台形式运行。 *** 容器的主进程以前台形式运行时,它的 **标准输出 (stdout)** 和 **标准错误 (stderr)** 会直接挂在容器上 这就是为什么每次使用 `docker run -it` 或 `docker logs ` 都能看到应用的实时日志 ```bash docker run -it my-node-app # 终端会直接显示 npm start 的输出 ``` 如果主进程不是前台运行(比如后台启动服务),日志不会直接输出到容器 stdout,甚至容器会立即退出 **这是前台运行的直接体现。** ::: **RUN 与 CMD 区别总结** | 指令 | 执行阶段 | 作用 | 注意事项 | | ---- | -------- | ---------------------------- | -------------------------------------------- | | RUN | 构建镜像 | 安装软件、构建项目、修改文件 | 每条生成镜像层,可多条 | | CMD | 容器启动 | 默认启动命令(主进程) | 只能有一条,多条会覆盖;可被 docker run 覆盖 | **RUN 用于镜像构建阶段执行命令,CMD 用于容器启动阶段指定默认主进程命令,容器以主进程为核心,必须以前台形式运行;CMD 优先使用 exec 格式确保信号转发正确。** ### ENTRYPOINT `ENTRYPOINT` 的格式和 `RUN` 指令格式一样,分为 `exec` 格式和 `shell` 格式。 `ENTRYPOINT` 的目的和 `CMD` 一样,都是在指定容器启动程序及参数。`ENTRYPOINT` 在运行时也可以替代,不过比 `CMD` 要略显繁琐,需要通过 `docker run` 的参数 `--entrypoint` 来指定。 当指定了 `ENTRYPOINT` 后,`CMD` 的含义就发生了改变,不再是直接的运行其命令,而是将 `CMD` 的内容作为参数传给 `ENTRYPOINT` 指令,换句话说实际执行时,将变为: ```bash "" ``` **ENTRYPOINT 与 CMD 的组合:让镜像像命令一样使用** **单独使用 CMD 的局限性** ```dockerfile FROM ubuntu:18.04 RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* CMD ["curl", "-s", "http://myip.ipip.net"] ``` 镜像默认执行 `curl -s http://myip.ipip.net` 运行时如果直接追加参数,如 `docker run myip -i` * Docker 会用 `-i` **替换原 CMD** * 因为 `-i` 不是可执行命令,会报错:`executable file not found` ::: note 跟在镜像名后面的是 `command`,运行时会替换 `CMD` 的默认值。 ::: 解决方式只能完整重写命令: ```bash docker run myip curl -s http://myip.ipip.net -i ``` 不够方便 **使用 ENTRYPOINT + CMD 组合** ```dockerfile FROM ubuntu:18.04 RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* ENTRYPOINT ["curl", "-s", "http://myip.ipip.net"] ``` ENTRYPOINT 固定主命令为 `curl -s http://myip.ipip.net` 运行时追加参数(CMD 或 `docker run `)会作为参数传给 ENTRYPOINT ```bash docker run myip # 输出默认 IP docker run myip -i # 输出带 HTTP 头信息 ``` 这样就可以让镜像 **像命令一样使用**,而不必每次手动完整输入命令 **总结原则** * **ENTRYPOINT**:固定主命令,确保核心行为不被覆盖 * **CMD**:提供默认参数,可在运行时追加或覆盖参数 * **效果**:运行时可以灵活传递参数,同时保持主命令不变,使镜像像可执行命令一样使用 **通过 ENTRYPOINT + CMD 组合,可以让镜像像命令一样使用,ENTRYPOINT 固定主命令,CMD 提供默认参数,运行时追加参数会传给主命令,而不会覆盖它。** **ENTRYPOINT + CMD 可以把镜像变成一个“命令行工具”,让容器不仅是环境,更像一个可执行的 CLI 应用。** ### ENV 格式有两种: * `ENV ` * `ENV = =...` 这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如 `RUN`,还是运行时的应用,都可以直接使用这里定义的环境变量。 ```bash ENV VERSION=1.0 DEBUG=on \ NAME="Happy Feet" ``` 这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。 定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 `node` 镜像 `Dockerfile` 中,就有类似这样的代码: ```dockerfile ENV NODE_VERSION 7.2.0 RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \ && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \ && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ && ln -s /usr/local/bin/node /usr/local/bin/nodejs ``` 在这里先定义了环境变量 `NODE_VERSION`,其后的 `RUN` 这层里,多次使用 `$NODE_VERSION` 来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新 `7.2.0` 即可,`Dockerfile` 构建维护变得更轻松了。 下列指令可以支持环境变量展开(将环境变量的值替换到指令中使用的地方): `ADD`、`COPY`、`ENV`、`EXPOSE`、`FROM`、`LABEL`、`USER`、`WORKDIR`、`VOLUME`、`STOPSIGNAL`、`ONBUILD`、`RUN`。 可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份 `Dockerfile` 制作更多的镜像,只需使用不同的环境变量即可。 ### ARG 格式:`ARG <参数名>[=<默认值>]` 构建参数和 `ENV` 的效果一样,都是设置环境变量。所不同的是,`ARG` 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 `ARG` 保存密码之类的信息,因为 `docker history` 还是可以看到所有值的。 **换句话说,`ARG` 就像是编译时的变量,而 `ENV` 是运行时的变量**。 `Dockerfile` 中的 `ARG` 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 `docker build` 中用 `--build-arg <参数名>=<值>` 来覆盖。 灵活的使用 `ARG` 指令,能够在不修改 Dockerfile 的情况下,构建出不同的镜像。 ARG 指令有生效范围,如果在 `FROM` 指令之前指定,那么只能用于 `FROM` 指令中。 ```dockerfile ARG DOCKER_USERNAME=library FROM ${DOCKER_USERNAME}/alpine RUN set -x ; echo ${DOCKER_USERNAME} ``` 使用上述 Dockerfile 会发现无法输出 `${DOCKER_USERNAME}` 变量的值,要想正常输出,你必须在 `FROM` 之后再次指定 `ARG` ```dockerfile # 只在 FROM 中生效 ARG DOCKER_USERNAME=library FROM ${DOCKER_USERNAME}/alpine # 要想在 FROM 之后使用,必须再次指定 ARG DOCKER_USERNAME=library RUN set -x ; echo ${DOCKER_USERNAME} ``` 对于多阶段构建,尤其要注意这个问题 ```dockerfile # 这个变量在每个 FROM 中都生效 ARG DOCKER_USERNAME=library FROM ${DOCKER_USERNAME}/alpine RUN set -x ; echo 1 FROM ${DOCKER_USERNAME}/alpine RUN set -x ; echo 2 ``` 对于上述 Dockerfile 两个 `FROM` 指令都可以使用 `${DOCKER_USERNAME}`,对于在各个阶段中使用的变量都必须在每个阶段分别指定: ```dockerfile ARG DOCKER_USERNAME=library FROM ${DOCKER_USERNAME}/alpine # 在FROM 之后使用变量,必须在每个阶段分别指定 ARG DOCKER_USERNAME=library RUN set -x ; echo ${DOCKER_USERNAME} FROM ${DOCKER_USERNAME}/alpine # 在FROM 之后使用变量,必须在每个阶段分别指定 ARG DOCKER_USERNAME=library RUN set -x ; echo ${DOCKER_USERNAME} ``` **与 ENV 的区别** | 特性 | ARG | ENV | | ---------------------------------------------- | ------------------------------ | ------------------------------ | | 生效阶段 | 构建时(build) | 运行时(run)和 构建时(build) | | 默认可见范围 | 当前 Dockerfile 构建过程 | 最终镜像与运行时容器 | | 是否出现在 `docker inspect` / `docker run env` | ❌ 不会 | ✅ 会 | | 是否能在 `docker build` 时覆盖 | ✅ 使用 `--build-arg` | ❌ 不能(只能在容器启动时覆盖) | | 是否能被 `docker run -e` 覆盖 | ❌ 不存在 | ✅ 可以 | | 是否会被写入镜像层 | ✅(会出现在 `docker history`) | ✅(也会) | | 是否适合保存敏感信息 | ❌(仍能在 history 看到) | ❌(同样能在 inspect/env 看到) | * `ENV` 设置的环境变量在 **Docker 构建阶段** 和 **容器运行阶段** 都有效。 * `ARG` 仅在 **构建阶段** 有效,且不会出现在最终镜像或运行时环境中。 无论是 `ARG` 还是 `ENV`: **都不安全存储密码、Token 等敏感数据。** 原因: * `ARG` 的值可以通过 `docker history` 查看; * `ENV` 的值可以通过 `docker inspect` 或 `docker exec env` 查看。 推荐安全方式: * 使用 Docker Secret; * 或通过 CI/CD 在运行时注入临时环境变量。 **最佳实践**: **`ARG` 用于“构建配置”,`ENV` 用于“运行配置”。** 如果变量只影响构建过程,用 `ARG`; 如果变量要在运行时仍然可用,用 `ENV`; 如果两者都要,用 `ARG` + `ENV` 搭配。 **使用 ARG 的场景(构建时变量)** `ARG` 主要用于构建阶段的逻辑控制,比如: | 典型用途 | 示例 | 原因 | | ---------------------------- | ------------------------------------------------------------ | ------------------------------ | | 指定基础镜像或依赖版本 | `ARG NODE_VERSION=20` `FROM node:${NODE_VERSION}-alpine` | 在构建时确定版本,运行时不需要 | | 传入构建上下文信息 | `ARG TARGETARCH`、`ARG BUILDPLATFORM` | 多架构构建(buildx)场景 | | 控制构建行为 | `ARG INSTALL_DEV=false` `RUN if [ "$INSTALL_DEV" = "true" ]; then npm i -D; fi` | 仅影响构建行为 | | 指定构建时用户名、镜像仓库等 | `ARG DOCKER_USERNAME` | 不需要留到容器中 | **特征:** * 构建时有效; * 不会出现在运行容器的环境中; * 可用 `--build-arg` 临时覆盖; * 不适合存放密码、token。 **使用 ENV 的场景(运行时变量)** `ENV` 用于容器**运行时**仍需存在的配置,例如: | 典型用途 | 示例 | 原因 | | -------------------------- | ----------------------------------------- | ---------------------- | | 程序运行环境 | `ENV NODE_ENV=production` | 运行时需要识别环境模式 | | 应用内部路径 | `ENV APP_HOME=/usr/src/app` | 构建时和运行时都需要 | | 默认端口、语言、时区 | `ENV LANG=C.UTF-8` `ENV TZ=Asia/Shanghai` | 容器长期需要 | | 与 CMD/ENTRYPOINT 配合使用 | `ENV CONFIG_PATH=/etc/app/config.json` | 程序读取配置路径 | **特征:** * 构建时和运行时都能访问; * 可被 `docker run -e` 或 `.env` 文件覆盖; * 会写入镜像层; * 不建议保存敏感信息(可通过 Secret 注入)。 **ARG + ENV 联合使用(双阶段变量传递)** 当你希望构建时通过 `--build-arg` 动态传入变量,并让它在运行时继续存在时,可以这样: ```dockerfile FROM node:20-alpine # 构建时传入 ARG APP_VERSION=1.0.0 # 运行时也能用 ENV APP_VERSION=${APP_VERSION} RUN echo "Building version: $APP_VERSION" CMD ["node", "app.js"] ``` 构建: ```bash docker build --build-arg APP_VERSION=2.1.0 -t myapp:2.1.0 . ``` 运行: ```bash docker run --rm myapp:2.1.0 ``` 容器中依然能访问: ```bash echo $APP_VERSION # 输出:2.1.0 ``` 当变量既影响构建行为,又影响程序运行时,用 **`ARG` 定义输入 → `ENV` 保存输出**。 **常见错误与反例** | 错误 | 问题 | | ------------------------------------- | ----------------------------------------------- | | 把密码写进 ARG 或 ENV | 这两者都会被 `docker history` 或 `inspect` 看到 | | 想在 FROM 之后用 FROM 前的 ARG | 必须重新声明,否则为空 | | 想在运行时访问 ARG | 不行,ARG 在镜像构建完就消失了 | | 想在构建时访问 ENV(但写在 ENV 之前) | 无效,ENV 在定义之后才生效 | ### VOLUME 格式为: * `VOLUME ["<路径1>", "<路径2>"...]` * `VOLUME <路径>` 容器在运行时产生的数据(如日志、数据库文件)如果直接写入容器文件系统,会造成: * 容器存储层膨胀; * 删除容器后数据丢失; * 镜像更新时无法继承旧数据。 因此,**动态数据必须与容器生命周期解耦**,解决方案:将数据保存到 **卷(Volume)** 中。 ```dockerfile VOLUME /data ``` 意思是:“在容器中声明一个挂载点 `/data`,告诉 Docker 这个目录应该被当作一个\*\*数据卷(Volume)\*\*来处理。” **注意**:它**不会自动映射到宿主机的 `/data` 目录**。 实际情况上,如果你只是写了: ```bash docker run myapp ``` Docker 会自动为 `/data` 创建一个**匿名卷**(anonymous volume),它位于宿主机的: ```bash /var/lib/docker/volumes/<随机ID>/_data ``` 也就是说,宿主机的路径是随机的,而不是 `/data`。 如果你想让容器的 `/data` 映射到宿主机的 `/data`,必须显式写: ```bash docker run -v /data:/data myapp ``` 这样宿主机的 `/data` 和容器的 `/data` 才是一一对应的。 并且**在 Dockerfile 里声明 `VOLUME /data`**:告诉别人这个目录有持久化意义。 然后**在 `docker run` 或 Compose 中显式挂载路径**: ``` volumes: - ./data:/data ``` 这样可以控制宿主机的存储位置,不依赖匿名卷。 **三种情况对比表** | 类型 | 示例命令 | 宿主机路径 | 管理方式 | 特征 | | ---------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------- | ------------------------ | | **命名卷(named volume)** | `-v mydata:/data` | `/var/lib/docker/volumes/mydata/_data` | Docker 管理,可复用 | 左边是一个**单词名称** | | **匿名卷(anonymous volume)** | Dockerfile: `VOLUME /data` 或 `-v /data` | `/var/lib/docker/volumes/<随机ID>/_data` | Docker 自动创建,不可复用 | 名称自动生成(哈希) | | **绑定挂载(挂载卷)(bind mount)** | `-v /home/user/data:/data` | `/home/user/data`(宿主机真实路径) | 用户自行管理 | 左边是**路径(带 `/`)** | **挂载卷就是用户手动指定宿主机目录挂载到容器目录,相当于完全控制存储路径。** 示例: ```bash # 使用匿名卷(Dockerfile 中的 VOLUME /data) docker run -d myimage # 使用命名卷,替代匿名卷 docker run -d -v mydata:/data myimage ``` 命名卷会覆盖掉 Dockerfile 中定义的匿名卷挂载。 可以用以下命令区分卷类型: ``` docker inspect <容器ID> | grep Source ``` 命名卷或匿名卷会在 `/var/lib/docker/volumes/` 下, 绑定挂载会显示宿主机的真实路径(如 `/home/user/data`)。 **注意**: 匿名卷**生命周期与容器一致**,当容器被删除时,如果你没加 `--volumes`,匿名卷会残留在宿主机上,占用空间,可以用 `docker volume prune` 清理。 匿名卷一般用于**临时数据**或**默认数据目录**,而生产环境通常会**显式使用命名卷**。 **最佳实践**: **应用的动态数据目录(如数据库、日志、缓存等)应使用 `VOLUME` 声明**; **在运行容器时,推荐使用命名卷或绑定挂载**,例如: ``` docker run -d -v mydata:/var/lib/mysql mysql ``` **镜像应保持无状态**,只包含程序和依赖; **卷负责持久化与共享数据**,与容器生命周期解耦。 **命名卷管理命令** **显式创建命名卷** ```bash docker volume create mydata ``` 可选参数: ```bash docker volume create --driver local --label project=demo mydata ``` `--driver`:指定卷驱动,一般用 `local` `--label`:给卷打标签,方便管理 **查看卷列表** ```bash docker volume ls ``` 示例输出: ```bash DRIVER VOLUME NAME local mydata local otherdata ``` **查看卷详情** ```bash docker volume inspect mydata ``` 输出包括: * 卷的宿主机路径 (`Mountpoint`) * 驱动信息 * 使用的容器(如果有) **删除卷** ```bash # 删除未被使用的卷 docker volume rm mydata # 删除所有未使用卷 docker volume prune ``` 正在被容器使用的卷无法删除; `prune` 会清理所有未使用的卷,谨慎执行。 **使用卷挂载容器** ```bash docker run -d -v mydata:/data myapp ``` 如果卷不存在,Docker 会自动创建; 生产环境推荐**提前显式创建卷**,便于管理和监控。 ### EXPOSE 格式为 `EXPOSE <端口1> [<端口2>...]` **声明端口**:告诉镜像使用者容器内哪个端口会提供服务。 ```dockerfile EXPOSE 80 EXPOSE 443/tcp EXPOSE 53/udp ``` **不启动服务**:EXPOSE 只是声明端口,容器内部服务必须自己启动,EXPOSE 本身不会监听端口 **与端口映射结合**: * `docker run -P`:自动将所有 EXPOSE 声明的端口随机映射到宿主机端口。 * `docker run -p <宿主端口>:<容器端口>`:手动指定端口映射,公开容器端口给宿主或外界访问。 `-p` / `-P` 的区别 | 指令 | 功能 | 是否映射宿主端口 | 用途 | | ------------------------ | -------------------------- | ---------------- | ------------------------------------------------------------ | | EXPOSE | 声明容器内部服务端口 | ❌ 否 | 文档化端口,帮助使用者了解容器提供的服务端口;配合 `-P` 自动随机映射 | | -p <宿主端口>:<容器端口> | 映射宿主端口到容器端口 | ✅ 是 | 手动暴露服务端口,允许外部访问 | | -P | 自动映射所有 EXPOSE 的端口 | ✅ 是 | 随机分配宿主端口,方便快速启动和测试 | **最佳实践** **镜像中声明 EXPOSE**:帮助使用者了解容器服务端口,方便文档化和团队协作。 **运行容器时使用 `-p` 或 `-P`**: * 开发或测试环境可用 `-P` 随机映射端口; * 生产环境应使用 `-p <宿主端口>:<容器端口>`,保证端口可控。 **端口协议明确**:TCP/UDP 需在 EXPOSE 中标明,否则默认 TCP。 EXPOSE 也可以声明多个端口: ```bash EXPOSE 80 443 ``` EXPOSE 只是镜像元数据的一部分,在 `docker inspect` 时可以看到: ```bash docker inspect <镜像ID> | grep ExposedPorts -A 3 ``` ### WORKDIR 格式为 `WORKDIR <工作目录路径>` **指定工作目录**:设置 Dockerfile 后续所有 `RUN`、`CMD`、`ENTRYPOINT`、`COPY`、`ADD` 等命令的默认工作目录。 **自动创建目录**:如果指定的目录不存在,Docker 会帮你创建。 ```dockerfile WORKDIR /app RUN echo "hello" > world.txt ``` 上述示例中,`world.txt` 会生成在 `/app` 下。 **常见错误示例** 很多初学者会把 Dockerfile 当作普通 Shell 脚本写,例如: ```dockerfile RUN cd /app RUN echo "hello" > world.txt ``` * **问题原因**:每个 `RUN` 命令都是独立容器执行的临时环境,前一个命令的内存状态(如当前工作目录)不会影响下一条 `RUN`。 * **结果**:`world.txt` 不会出现在 `/app` 下,而是生成在默认工作目录 `/` 下。 **相对路径用法** * WORKDIR 支持相对路径,相对路径是基于 **上一条 WORKDIR**: ```dockerfile WORKDIR /a WORKDIR b WORKDIR c RUN pwd ``` * 输出路径为 `/a/b/c`。 * 多个 WORKDIR 可以连续叠加,相当于在前一个目录的基础上切换。 **最佳实践**: **尽量在 Dockerfile 开头设置 WORKDIR** * 可以保证后续所有命令在正确目录执行,避免错误。 **相对路径小心使用** * 明确理解它是基于前一个 WORKDIR 的路径,不要写模糊路径。 **与 COPY/ADD 配合** ``` WORKDIR /app COPY . . RUN npm install ``` 复制操作和安装命令都会基于 `/app` 目录执行,更直观。 **不要用 RUN cd 切换目录** * 每个 RUN 都是新容器,`cd` 的效果不会保留。 ### USER 格式:`USER <用户名>[:<用户组>]` `USER` 用于**切换容器内的执行用户**。 影响后续层的所有指令,包括 `RUN`、`CMD`、`ENTRYPOINT` 等。 **注意**:切换的是**容器内用户**,与宿主机用户无关。 ```dockerfile RUN groupadd -r redis && useradd -r -g redis redis USER redis RUN echo "Hello" > /tmp/hello.txt ``` 上例中,后续 `RUN` 命令都会以 `redis` 用户身份执行,而不是 root。 **常见误区** **USER 不是 sudo/su**: * `USER` 是 Docker 构建镜像阶段或容器启动阶段的默认用户切换。 * 不依赖 TTY,不会报错(不像直接在容器内用 `su`/`sudo` 切换用户,可能因无终端或缺少密码而失败)。 **切换用户启动服务的意义**: 1. **安全性**:容器内的服务通常不建议以 root 用户运行,降低被攻破后的权限风险。 2. **权限隔离**:保证服务只能访问自己用户的文件和目录,防止误操作或安全漏洞。 3. **符合最佳实践**:许多官方镜像都会提供非 root 用户运行服务(如 `nginx`、`redis`、`mysql`)。 **结合 gosu** * 有时需要在容器启动阶段用 root 用户做准备工作,但执行服务时使用非 root 用户。 * `gosu` 可以方便切换用户执行命令: ```dockerfile RUN groupadd -r redis && useradd -r -g redis redis RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.12/gosu-amd64" \ && chmod +x /usr/local/bin/gosu \ && gosu nobody true CMD ["exec", "gosu", "redis", "redis-server"] ``` * 作用:在容器启动时,以 `redis` 用户启动服务,而构建时仍可用 root 完成安装、权限配置等操作。 ::: details **容器内运行 root 的风险** 如果容器内的服务以 **root 用户** 运行: * 攻击者一旦突破容器,**他们在容器里的权限就是 root**。 * 可能发生的事情: 1. **修改容器内文件**:包括程序、配置、日志等。 2. **读取敏感信息**:比如挂载卷内的数据(如果卷挂载了宿主机目录,root 有权限访问)。 3. **逃逸风险增加**:在某些情况下,攻击者可能利用内核漏洞或挂载卷逃逸到宿主机,获取宿主 root 权限。 ::: ::: details **非 root 用户的安全优势** * 如果容器内服务以 **普通用户** 运行: 1. 攻击者权限被限制在该用户能访问的范围内。 2. 对容器内其他目录或挂载卷(需要更高权限的目录)无法随意修改。 3. 减少了潜在的容器逃逸风险。 **注意**:这并不能完全保证安全,仍需结合**最小权限原则、只挂载必要卷、限制能力(capabilities)、使用只读文件系统等措施**。 ::: 核心理念:**容器不是完全面的隔离**,但以非 root 用户运行服务可以显著降低攻击面和潜在风险。 ### HEALTHCHECK 用于 **检测容器运行状态**,判断服务是否健康。 Docker 1.12 之前只能通过主进程是否退出判断容器状态,无法发现死锁或服务卡死等情况。 HEALTHCHECK 指令允许容器 **主动自检**,状态分为: * `starting`:健康检查刚开始,尚未判断。 * `healthy`:健康检查通过。 * `unhealthy`:连续失败超过重试次数。 **语法** ```dockerfile HEALTHCHECK [选项] CMD <命令> # 设置健康检查命令 HEALTHCHECK NONE # 屏蔽基础镜像已有的健康检查 ``` **可选参数**: | 选项 | 说明 | 默认值 | | ------------------- | -------------------------------- | ------ | | `--interval=<间隔>` | 两次健康检查之间的时间间隔 | 30s | | `--timeout=<时长>` | 健康检查命令运行超时时间 | 30s | | `--retries=<次数>` | 连续失败多少次判定为 `unhealthy` | 3 | **命令返回值约定**: * `0` → 健康 * `1` → 不健康 * `2` → 保留,不要使用 **命令格式**: * Shell 格式:`CMD curl -fs http://localhost/ || exit 1` * Exec 格式:`CMD ["curl", "-fs", "http://localhost/"]` 和 CMD、ENTRYPOINT 一样,HEALTHCHECK 只能出现一次,后续会覆盖前面的。 **示例** 构建带健康检查的 nginx 镜像: ```dockerfile FROM nginx RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5s --timeout=3s \ CMD curl -fs http://localhost/ || exit 1 ``` 运行容器: ```bash docker build -t myweb:v1 . docker run -d --name web -p 80:80 myweb:v1 ``` 查看状态: ```bash docker container ls # 初始状态:health: starting # 几秒后:health: healthy ``` 如果连续失败超过 `--retries`,状态会变为 `unhealthy`。 查看健康检查日志: ```bash docker inspect --format '{{json .State.Health}}' web | python -m json.tool ``` **使用场景与建议** **场景**: * Web 服务、API 服务、数据库等进程可能“卡死”或“死循环”。 * 需要和 Docker 编排工具(如 Docker Swarm、Kubernetes)配合,实现自动重启或调度。 **建议**: * 健康检查命令尽量轻量,避免对容器本身造成负载。 * 间隔和超时可根据实际业务调整,不必太频繁。 * 健康检查可以结合 `CMD` 或 `ENTRYPOINT` 启动的主进程一起设计,确保检查覆盖关键功能。 **补充** HEALTHCHECK 只是 **容器自身状态检测**,不会自动修复或重启容器。 在编排工具中,可结合容器状态做自动重启或流量切换: * Docker Swarm:服务健康检查失败可触发重启。 * Kubernetes:LivenessProbe / ReadinessProbe 原理类似。 使用 `HEALTHCHECK NONE` 可以覆盖基础镜像自带的健康检查。 **总结**:HEALTHCHECK 是 Docker 提供的容器健康状态自检机制,通过执行指定命令定期判断服务状态,为自动化调度和高可用提供基础。 ### LABEL **作用**:给镜像添加**元数据(metadata)**,以键值对的形式存储。元数据可以用来描述镜像信息、作者、版本、文档地址等,方便管理、搜索和自动化工具使用。 **格式**: ```dockerfile LABEL = = ... ``` **注意**:多个键值对可以写在同一行,也可以分多行写。 **示例** ```dockerfile # 作者信息 LABEL maintainer="yumengjianghu " # 镜像相关信息 LABEL org.opencontainers.image.authors="yeasy" LABEL org.opencontainers.image.version="1.0" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.source="https://github.com/yeasy/example" LABEL org.opencontainers.image.documentation="https://yeasy.gitbooks.io" LABEL org.opencontainers.image.title="My Docker Image" LABEL org.opencontainers.image.description="这是一个示例 Docker 镜像" ``` **常用标签分类** | 类别 | 键示例 | 说明 | | -------- | ------------------------------------------------------------ | -------------------- | | 作者信息 | `maintainer`, `org.opencontainers.image.authors` | 指明镜像的作者 | | 文档 | `org.opencontainers.image.documentation` | 镜像文档或说明网址 | | 源码 | `org.opencontainers.image.source` | 镜像对应的源码仓库 | | 版本 | `org.opencontainers.image.version` | 镜像版本号 | | 许可证 | `org.opencontainers.image.licenses` | 镜像使用的开源许可证 | | 描述 | `org.opencontainers.image.title`, `org.opencontainers.image.description` | 镜像名称和描述 | **补充说明** 1. **元数据不会影响镜像运行** * LABEL 只是存储信息,不会改变容器行为或占用额外资源。 2. **方便搜索和自动化** * Docker Hub、私有 Registry、CI/CD 工具都可以根据标签筛选、查找和管理镜像。 3. **多行写法**(可读性更好): ```dockerfile LABEL org.opencontainers.image.authors="yeasy" \ org.opencontainers.image.version="1.0" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.documentation="https://yeasy.gitbooks.io" ``` **总结**:LABEL 指令是镜像的“名片”,存储作者、版本、文档等信息,便于管理、搜索和自动化流程,是 **构建规范化镜像的最佳实践**。 ### SHELL **作用**:指定 **RUN、CMD、ENTRYPOINT** 执行命令时使用的默认 shell。 * Linux 默认 shell 是 `["/bin/sh", "-c"]` * Windows 默认 shell 是 `["cmd", "/S", "/C"]` **格式**: ```dockerfile SHELL ["executable", "parameters"] ``` 注意:只有 exec 形式(JSON 数组形式)才可靠,推荐使用数组形式而非字符串形式。 **示例** ```dockerfile # 默认 shell SHELL ["/bin/sh", "-c"] RUN echo "Hello"; ls # 启用错误退出和命令打印 SHELL ["/bin/sh", "-cex"] RUN echo "Hello"; ls ``` * `-c`:执行命令字符串 * `-e`:遇到错误时退出 * `-x`:打印每条命令及参数(调试友好) **与 ENTRYPOINT / CMD 的关系** 当 ENTRYPOINT 或 CMD 使用 **shell 格式**(字符串形式)时,SHELL 指定的 shell 会成为这两个指令的执行 shell: ```dockerfile SHELL ["/bin/sh", "-cex"] # CMD 或 ENTRYPOINT 使用 shell 格式时 CMD nginx ENTRYPOINT nginx ``` 等价于执行: ```dockerfile /bin/sh -cex -c "nginx" ``` 如果 CMD / ENTRYPOINT 使用 **exec 形式**(数组形式),SHELL 不会生效,直接执行指定的可执行文件。 **使用场景和最佳实践** **调试 RUN 命令** * 在构建镜像时,`-x` 参数方便查看每条命令执行情况 * `-e` 参数可以避免错误被忽略,防止构建成功但镜像不可用 **确保 ENTRYPOINT/CMD 的一致性** * 当 ENTRYPOINT / CMD 使用 shell 格式时,可以通过 SHELL 指令指定执行行为(如开启 `-e` 防止启动失败被忽略) **安全和可移植性** * exec 形式的 CMD / ENTRYPOINT 更安全,SHELL 不会影响 * 对于复杂命令,建议在 RUN 时用 SHELL 调试,但生产 CMD / ENTRYPOINT 尽量用 exec 形式 **示例:调试 RUN 命令** ```dockerfile SHELL ["/bin/sh", "-cex"] RUN apt-get update && apt-get install -y curl \ && curl -fs http://example.com || exit 1 ``` * 遇到任何失败都会立即停止构建 * 输出每条命令和执行结果,方便排查问题 ### ONBUILD **作用**:在 Dockerfile 中设置**触发指令**,这些指令不会在当前镜像构建时执行,而是在**基于该镜像的下游镜像**构建时执行。 也就是说,ONBUILD 是一种 **“延迟执行”的机制**,把某些操作留给子镜像处理。 **格式** ```dockerfile ONBUILD <指令> ``` * `<指令>` 可以是 RUN、COPY、ADD 等绝大多数 Dockerfile 指令 * 当你用这个镜像做 **FROM** 时,ONBUILD 指令才会触发 **示例** ```dockerfile # 父镜像 Dockerfile FROM ubuntu:20.04 ONBUILD COPY . /app ONBUILD RUN make /app ``` 然后我们写一个子镜像: ```dockerfile # 子镜像 Dockerfile FROM myparentimage RUN echo "Hello World" ``` 构建子镜像时,父镜像的 **ONBUILD 指令会被触发**,等价于: ```dockerfile COPY . /app RUN make /app ``` **使用场景** **构建模板镜像(Base image)** * 父镜像定义一些**通用操作**,如复制应用目录、安装依赖 * 子镜像只需要 FROM 父镜像,就自动继承这些操作 **为下游镜像“约定行为”** * 比如一个语言运行环境镜像,可以约定下游镜像必须复制源代码并编译 **注意事项** **ONBUILD 指令只触发在下游镜像构建时** * 构建父镜像时不会执行 **不要滥用 ONBUILD** * 容易造成“魔法行为”,让下游镜像构建变得不透明 * 不适合普通应用镜像,更多用于**库镜像或模板镜像** **常用组合** ```dockerfile ONBUILD COPY . /app # 子镜像会自动复制当前目录 ONBUILD RUN pip install -r /app/requirements.txt # 安装依赖 ``` **调试技巧** * 父镜像中可以写注释提醒下游开发者 ONBUILD 的作用 * 构建子镜像时,如果不想执行 ONBUILD,可以选择不继承父镜像,或者手动处理 **ONBUILD 指令最佳实践** **ONBUILD** 虽然提供了延迟执行的机制,但在实际使用中需要谨慎。最佳实践如下: **适合模板镜像(Base Image)** * 用于为下游镜像约定通用操作,例如:复制源代码、安装依赖、执行初始化操作。 * 不适合普通应用镜像,否则容易造成“魔法行为”,下游构建难以理解。 **尽量避免复杂逻辑** * ONBUILD 指令最好保持简单和明确 * 复杂的条件、参数化操作不适合使用 ONBUILD **替代方案**:使用参数 + 条件 RUN * 通过 `ARG` 和环境变量控制构建逻辑,更灵活、易维护 * 示例: ```dockerfile # 父镜像 Dockerfile ARG INSTALL_DEPS=true RUN if [ "$INSTALL_DEPS" = "true" ]; then \ apt-get update && apt-get install -y curl; \ fi ``` 下游镜像可以传入不同参数决定是否安装依赖: ```dockerfile docker build --build-arg INSTALL_DEPS=false -t mychildimage . ``` **可维护性考虑** * ONBUILD 是隐式触发,难以调试 * 参数 + 条件逻辑明确,构建流程可控 ONBUILD 适合**简单的模板镜像**和固定操作的延迟执行场景; 更灵活、可维护的方案是**使用 ARG / ENV + 条件 RUN**,通过传参控制镜像构建逻辑,避免魔法行为,同时方便调试和扩展。 ### RUN `RUN` 指令是用来执行命令行命令的。由于命令行的强大能力,`RUN` 指令在定制镜像时是最常用的指令之一。其格式有两种: * shell 格式:`RUN <命令>`,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 `RUN` 指令就是这种格式。 ```bash RUN echo '

Hello, Docker!

' > /usr/share/nginx/html/index.html ``` * exec 格式:`RUN ["可执行文件", "参数1", "参数2"]`,这更像是函数调用中的格式。 虽然 `RUN` 可以像 Shell 脚本一样可以执行命令,但是 Dockerfile 中每一个指令都会建立一层,`RUN` 也不例外。 如果你有很多的命令需要执行,可以使用 `&&` 把它们连接成一行,这样就只会创建一层,如果太长,可以使用反斜杠 `\` 换行,甚至使用 `#` 注释来增加可读性。 并且注意在执行命令后清理不必要的文件,减少镜像体积,必须在同层中完成清理,否则清理操作会在新层中执行,前一层的文件依然存在,因为每一层都是只读的。 **同层删除** → 文件在同一层被创建后又删除 → 从未进入最终镜像层 → **真正减小体积** **跨层删除** → 删除操作在新的一层发生 → 上一层的文件依然存在,只是被标记为不可见 → **占用空间不减** ### 构建镜像 为了构建一个镜像,需要在 Dockerfile 所在目录下运行 `docker build` 命令。 ```bash docker build [选项] <上下文路径/URL/-> docker build -t <镜像名称:标签> . ``` `.` 表示当前目录,而 `Dockerfile` 就在当前目录,这个参数用于指定构建上下文,而不是 Dockerfile 所在目录。 即 Docker 引擎在构建镜像时可以访问的文件和目录,Docker 会把上下文目录下的所有文件打包并发送给 Docker 引擎,用于镜像构建。 **COPY / ADD 指令** 的源路径都是相对于上下文目录的。 超出上下文的路径无法被 Docker 引擎访问,例如: ```bash COPY ../package.json /app # 无法工作 ``` 如果文件不在上下文内,需要先移动或复制到上下文目录中。 上下文越大,`docker build` 上传给 Docker 引擎的包就越大,构建越慢,避免将整个硬盘或无关目录作为上下文,可以使用 `.dockerignore` 文件排除不需要的文件(语法类似 `.gitignore`)。 默认情况下:Dockerfile 名称为 `Dockerfile`,位于上下文目录中,可以用 `-f` 指定其他路径或名称的 Dockerfile: ```bash docker build -t myimage -f ../Dockerfile.custom . ``` `docker build` 还支持从 URL 构建,比如可以直接从 Git repo 中构建: ```bash docker build -t hello-world https://github.com/docker-library/hello-world.git#master:amd64/hello-world ``` 这行命令指定了构建所需的 Git repo,并且指定分支为 `master`,构建目录为 `/amd64/hello-world/`,然后 Docker 就会自己去 `git clone` 这个项目、切换到指定分支、并进入到指定目录后开始构建。 ```bash docker build http://server/context.tar.gz ``` Docker 会下载指定的 tar.gz 包,自动解压并将解压后的内容作为 **构建上下文**,默认会在上下文根目录寻找 `Dockerfile` **用途**:远程构建非 Git 仓库内容 ```bash docker build - < context.tar.gz ``` 如果标准输入是 tar 压缩包(gzip / bzip2 / xz),Docker 会解压后将内容作为 **构建上下文**,可正常使用 `COPY` / `ADD` 指令 **用途**:通过管道或脚本提供上下文,而不依赖本地目录 ```bash docker build - < Dockerfile # 或 cat Dockerfile | docker build - ``` 标准输入直接提供 Dockerfile 内容,**没有上下文** ,不能使用 `COPY` / `ADD` 拷贝本地文件,适合临时或简短 Dockerfile 构建。 **最佳实践**: ```bash docker build \ -f \ # 指定 Dockerfile 文件,默认是 ./Dockerfile -t <镜像名称>:<标签> \ # 镜像名称和标签,例如 myapp:latest --build-arg <参数名>=<值> \ # 可选,传入构建参数 ARG --no-cache \ # 可选,不使用缓存重新构建 --pull \ # 可选,总是尝试拉取最新基础镜像 --progress=plain \ # 可选,构建输出显示方式 <上下文路径> # 构建上下文,一般是当前目录 . ``` **示例** ```bash docker build \ -f Dockerfile \ -t myapp:1.0.0 \ --build-arg APP_ENV=production \ --pull \ --progress=plain \ . ``` 解释: * `-f Dockerfile`:使用当前目录下的 Dockerfile * `-t myapp:1.0.0`:给镜像打标签,方便管理和推送仓库 * `--build-arg APP_ENV=production`:传入构建参数 * `--pull`:拉取最新基础镜像,确保镜像更新 * `--progress=plain`:显示完整日志,方便调试 * `.`:上下文路径为当前目录 *** 总是给镜像打标签 (`-t :`) 大上下文目录可以使用 `.dockerignore` 排除不必要文件 尽量把不常变化的指令放在前面利用缓存,加快构建速度 对于敏感信息,不要直接写在 Dockerfile,使用 `--build-arg` 或构建时注入环境变量 ### 多阶段构建 **全部放入一个 Dockerfile**:将所有的构建过程编包含在一个 `Dockerfile` 中,包括项目及其依赖库的编译、测试、打包等流程,这里可能会带来的一些问题,比如: * 镜像层次多,镜像体积较大,部署时间变长 * 源代码存在泄露的风险 **分散到多个 Dockerfile** 事先在一个 `Dockerfile` 将项目及其依赖库编译测试打包好后,再将其拷贝到运行环境中,这种方式需要我们编写两个 `Dockerfile` 和一些编译脚本才能将其两个阶段自动整合起来,这种方式虽然可以很好地规避第一种方式存在的风险,但明显部署过程较复杂。 **使用多阶段构建** 为解决以上问题,Docker v17.05 开始支持多阶段构建 (`multistage builds`)。 使用多阶段构建我们就可以很容易解决前面提到的问题,并且只需要编写一个 `Dockerfile`: ```dockerfile FROM golang:alpine as builder RUN apk --no-cache add git WORKDIR /go/src/github.com/go/helloworld/ RUN go get -d -v github.com/go-sql-driver/mysql COPY app.go . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM alpine:latest as prod RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=0 /go/src/github.com/go/helloworld/app . CMD ["./app"] ``` 我们还可以使用 `as` 来为某一阶段命名,例如 ```docker FROM golang:alpine as builder ``` 例如当我们只想构建 `builder` 阶段的镜像时,增加 `--target=builder` 参数即可 ```bash docker build --target builder -t username/imagename:tag . ``` **构建时从其他镜像复制文件** 上面例子中我们使用 `COPY --from=0 /go/src/github.com/go/helloworld/app .` 从上一阶段的镜像中复制文件,我们也可以复制任意镜像中的文件。 ```docker COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf ``` 多阶段构建**基本语法**: ```bash FROM <基础镜像> AS <阶段名> ... COPY --from=<阶段名或序号> <源路径> <目标路径> ``` `AS <阶段名>`:给阶段命名,便于后续 COPY 或单独构建 `--from`:从指定阶段或任意镜像复制文件 **核心概念** 多阶段构建就是在 **同一个 Dockerfile 中定义多个构建阶段**,每个阶段都是一个独立的镜像构建环境。最终镜像只保留运行所需的文件,而构建时产生的临时文件、源码、编译工具等不会进入最终镜像。 通俗理解: 就像“先在厨房里做好所有材料(builder 阶段),然后把最终成品搬到餐盘上(production 阶段)”,最终盘子里只放菜,不放调料、厨具或废料。 多阶段构建的好处: * **减小镜像体积**:不把编译工具和源码带入最终镜像 * **提高安全性**:源码、临时文件和秘钥不会暴露在最终镜像中 * **方便管理**:一个 Dockerfile 完成构建和打包,不需要额外脚本 * **可选择性构建**:可通过 `--target` 构建指定阶段 ```bash # 第一阶段:builder FROM golang:alpine AS builder WORKDIR /app COPY . . RUN go build -o myapp . # 第二阶段:生产运行 FROM alpine:latest AS prod WORKDIR /app COPY --from=builder /app/myapp . CMD ["./myapp"] ``` **关键点**: * `AS <阶段名>`:给阶段命名,便于后续引用 * `COPY --from=<阶段名或序号>`:从指定阶段复制文件 * 每个阶段都是独立的镜像环境,二次构建阶段不会自动继承前一阶段的环境,**只能拿文件** **二次构建阶段细节** **环境独立**: * 第二阶段的基础镜像可以与第一阶段完全不同 * 第二阶段无法访问第一阶段安装的包或命令(除非复制) **复制文件粒度灵活**: * 可以复制单个文件、目录,甚至从任意镜像中复制文件 * 例:`COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf` **优化缓存和效率**: * 依赖不常改动的指令放前面,减少重复构建 * 源码、配置文件变动只影响最后几层 **总结与最佳实践** * **保持最终镜像干净、轻量、只包含运行时依赖** * **给阶段命名清晰**,比用数字序号更易维护 * **编译依赖、源码、测试文件只留在 builder 阶段** * **生产阶段只复制必要文件** * **适合编译型语言或前端打包**(Go、C/C++、Java、Node) **通俗总结**:多阶段构建就是“先在一个干净的厨房里把所有东西做出来,然后只把菜搬到盘子上”,这样最终镜像既小又干净,构建过程也可以灵活控制。 ### 多架构 在 Docker 中,镜像和宿主机架构必须一致,例如: * x86\_64 Linux 宿主机只能运行 x86\_64 Linux 镜像 * ARM64 Linux 宿主机只能运行 ARM64 Linux 镜像 如果直接在树莓派(ARM)上拉取 x86\_64 镜像,容器会拉不到或无法运行。 传统做法是为不同架构构建不同镜像,例如: * `username/test:x86_64` * `username/test:arm64v8` 但这种方式不够方便,用户需要自己选择架构镜像。 Docker 支持 **Manifest 列表(manifest list)**,也称为 **多架构镜像列表**。 一个 manifest 列表包含同一镜像的不同架构版本 Docker 客户端根据宿主机架构自动选择合适的镜像 官方镜像(如 `golang:alpine`)就是这种方式 查看 manifest 列表: ```bash docker manifest inspect golang:alpine ``` 输出中每个 `manifest` 对应一个特定架构: ```json "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" } ``` **创建多架构镜像的流程** **(1) 构建不同架构的镜像并推送** ```bash # x86_64 构建 & 推送 docker build -t username/x8664-test . docker push username/x8664-test # arm64v8 构建 & 推送 docker buildx build --platform linux/arm64 -t username/arm64v8-test . docker push username/arm64v8-test ``` `buildx` 支持交叉架构构建,尤其适合在 x86\_64 主机上构建 ARM 镜像。 **(2) 创建 manifest 列表** ```bash docker manifest create username/test \ username/x8664-test \ username/arm64v8-test ``` `username/test` 将成为多架构镜像名 可使用 `--amend` 修改已有列表 **(3) 标注 manifest 信息** ```bash docker manifest annotate username/test username/x8664-test \ --os linux --arch amd64 docker manifest annotate username/test username/arm64v8-test \ --os linux --arch arm64 --variant v8 ``` `--os`、`--arch`、`--variant` 用于指明每个镜像的架构信息 **(4) 查看 manifest 列表** ```bash docker manifest inspect username/test ``` 输出中会显示不同架构的 digest 信息。 **(5) 推送 manifest 列表** ```bash docker manifest push username/test ``` 用户直接拉取 `username/test`,Docker 会根据宿主机架构自动选择正确镜像。 **(6)测试** 在不同架构主机上执行: ``` docker run -it --rm username/test ``` * Docker 会自动选择 x86\_64 或 ARM64 镜像 * 用户无需手动选择架构镜像 **调试方法** * `docker manifest inspect <镜像>` 查看支持架构 * `docker run --platform <架构>` 可手动指定 --- --- url: /friends/index.md --- # 友情链接 ::: flex center ## 欢迎交换友链{#make-friends} ::: --- --- url: /js/index.md --- # JavaScript 小册子 ## JavaScript 简介 JavaScript(简称 JS)是一种广泛应用于 Web 前端开发的**脚本语言**,也是浏览器端最核心的编程语言。它最初由 Netscape 公司于 1995 年推出,目的是让网页具备动态交互能力。如今,JavaScript 已经不仅仅局限于浏览器端,还可以用于服务端(如 Node.js)、桌面应用、移动端开发等多个领域。 ### 主要特点 * **解释型、动态类型**:无需编译,运行时解释执行,变量类型灵活。 * **基于原型的面向对象**:通过原型链实现继承和对象扩展。 * **事件驱动、异步编程**:支持事件监听、回调、Promise、async/await 等异步机制。 * **跨平台**:只要有 JS 引擎(如浏览器、Node.js),就能运行。 ### 应用场景 * **网页交互**:实现动态效果、表单校验、数据交互等。 * **前端框架**:如 React、Vue、Angular 等,都是基于 JS 构建。 * **服务端开发**:Node.js 让 JS 能写后端 API、命令行工具等。 * **全栈开发**:前后端统一语言,提升开发效率。 * **小程序、桌面应用**:如 Electron、Taro 等。 ### 生态与发展 JavaScript 拥有庞大的生态系统(NPM 包管理器、各类框架库),并且每年都会通过 ECMAScript 标准持续进化。现代 JS 已经支持模块化、类、解构赋值、箭头函数等高级特性。 > \[!important] > **学习建议**:建议先掌握 JS 的基础语法、数据类型、函数、作用域、闭包、原型链、异步编程等核心知识,再逐步深入框架和工程化内容。 --- --- url: /js/10b2f96o/index.md --- # 标准库 ## 闭包 如果在函数外访问函数内的局部变量,正常情况下这无法读取,但是可以通过变通的方法。在函数的内部再定义一个函数。 ```js function f1() { var n = 999; function f2() { console.log(n); } return f2; } var result = f1(); result(); // 999 ``` 在JavaScript中只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成一个**定义在一个函数内部的函数**。 闭包最大的特点就是:它能够**记住**诞生的环境,比如`f2`记住了它的诞生环境是`f1`,所以从`f2`可以得到`f1`的内部变量。 本质上是,**闭包就是将函数内部和函数外部连接起来的一座桥梁。** 其最大的两个用处是:一个是可以读取外层函数内部的变量,另一个是这些变量始终都保持在内存中,即闭包可以使得**它诞生的环境一直存在**。 下面的例子中,闭包使得内部变量记住了上一次调用的运行结果 ```js function createIncrementor(start) { return function () { return start++; }; } var inc = createIncrementor(5); inc() // 5 inc() // 6 inc() // 7 ``` 上面代码中,`start`是函数`createIncrementor`的内部变量。通过闭包,`start`的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包`inc`使得函数`createIncrementor`的内部环境,**一直存在**。所以,闭包可以看作是函数内部作用域的一个接口。 为什么闭包能够返回外层函数的内部变量?原因是闭包(上例的`inc`)用到了外层变量(`start`),导致外层函数(`createIncrementor`)不能从内存释放。 只要闭包没有被垃圾回收机制清除,外层函数提供的运行环境也不会被清除,它的内部变量就始**终保存着当前值**,供闭包读取。 闭包还可以用于封装对象的**私有属性**和**私有方法** ```js function Person(name) { var _age; function setAge(n) { _age = n; } function getAge() { return _age; } return { name: name, getAge: getAge, setAge: setAge }; } var p1 = Person('张三'); p1.setAge(25); p1.getAge() // 25 ``` > \[!tip] > > 注意,外层函数每次运行,都会生成一个**新的闭包**,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。 ## Object `instanceof`运算符用来验证,一个对象是否为指定的构造函数的实例。 **静态方法**指的是`Object`对象自身的方法。 `Object.keys`方法的参数是一个对象,返回一个数组。该数组的成员都是该对象自身的(而不是继承的)所有属性名,**只返回可枚举的属性** `Object.getOwnPropertyNames`方法也是接受一个对象作为参数,返回一个数组,包含了该对象自身的所有属性名,**还返回不可枚举的属性名。** ```js Object.keys([]) // [] Object.getOwnPropertyNames([]) // ["length"] ``` 其中数组的`length`属性是不可枚举的 可以利用这两个方法做一个计算对象属性个数的方法: ```js var obj = { p1: 123, p2: 456 }; Object.keys(obj).length // 2 Object.getOwnPropertyNames(obj).length // 2 ``` *** `Object.prototype.valueOf()`方法的作用是返回一个对象的“值”,默认情况下返回对象本身。 `Object.prototype.hasOwnProperty()`方法接受一个字符串作为参数,返回一个布尔值,表示该实例对象**自身**是否具有该属性,不包括继承的 **属性描述对象**用于描述对象的属性,控制它的行为,比如该属性是否可写,可遍历等 每个属性都有自己对应的属性描述对象,保存该属性的一些**元信息**。 ```js { value: 123, writable: false, enumerable: true, configurable: false, get: undefined, set: undefined } ``` `value`是该属性的属性值,默认为`undefined`。 `writable`是一个布尔值,表示属性值(value)是否可改变(即是否可写),默认为`true`。 `enumerable`是一个布尔值,表示该属性是否可遍历,默认为`true`。如果设为`false`,会使得某些操作(比如`for...in`循环、`Object.keys()`)跳过该属性。 `configurable`是一个布尔值,表示属性的可配置性,默认为`true`。如果设为`false`,将阻止某些操作改写属性描述对象,比如无法删除该属性,也不得改变各种元属性(`value`属性除外)。也就是说,`configurable`属性控制了属性描述对象的可写性。 `get`是一个函数,表示该属性的取值函数(getter),默认为`undefined`。 `set`是一个函数,表示该属性的存值函数(setter),默认为`undefined`。 `Object.getOwnPropertyDescriptor()`方法可以获取属性描述对象。它的第一个参数是目标对象,第二个参数是一个字符串,对应目标对象的某个属性名。\*\*同样只能只能用于对象自身的属性,不能用于继承的属性。\*\*比如`toString` **enumerable: false**可以控制`Object.keys`的行为,只返回可遍历的属性,`Object.getOwnPropertyNames`则不行 `Object.defineProperty()`方法允许通过属性描述对象,**定义**或**修改**一个属性,然后返回修改后的对象,它的用法如下。 ```js Object.defineProperty(object, propertyName, attributesObject) ``` * object:属性所在的对象 * propertyName:字符串,表示属性名 * attributesObject:属性描述对象 ```js obj.p // 可以写成 var obj = Object.defineProperty({}, 'p', { value: 123, writable: false, enumerable: true, configurable: false }); ``` 如果一次性定义或修改多个属性,可以使用`Object.defineProperties()`方法。 ```js var obj = Object.defineProperties({}, { p1: { value: 123, enumerable: true }, p2: { value: 'abc', enumerable: true }, p3: { get: function () { return this.p1 + this.p2 }, enumerable:true, configurable:true } }); obj.p1 // 123 obj.p2 // "abc" obj.p3 // "123abc" ``` `Object.prototype.propertyIsEnumerable()`用来判断某个属性是否可遍历,**对于继承的属性一律返回false** ### 元属性 `value`属性是目标属性的值。 `writable`属性是一个布尔值,决定了目标属性的\*\*值(value)\*\*是否可以被改变。 **注意:**如果原型对象的某个属性的`writable`为`false`,那么**子对象**将无法自定义这个属性。 > \[!tip] > > 有一个规避方法,就是通**过覆盖属性描述对象**,绕过这个限制。原因是这种情况下,原型链会被完全忽视。 ```js var proto = Object.defineProperty({}, 'foo', { value: 'a', writable: false }); var obj = Object.create(proto); Object.defineProperty(obj, 'foo', { value: 'b' }); obj.foo // "b" ``` `enumerable`表示目标属性是否可遍历。 > 在早期JavaScript中,`for...in`循环是基于`in`运行符的,而`in`运算符不管某个属性是自身的还是继承的都会返回`true` > > ```js > var obj = {}; > 'toString' in obj // true > ``` > > `toString`不是`obj`对象自身的属性,但是in返会了true,导致了`toString`属性也会被for in 循环遍历 > > 再之后,引入了一个 **可遍历性** 这个概念,只有可遍历的属性才能被`for in` 循环遍历, > > 并且规定了像`toString`这一类实例对象继承的原生属性,都是不可遍历的,这样就保证了`for...in`循环的可用性。 > > 所以:如果一个属性的`enumerable`为`false`,以下操作不会读取到该属性 > > * `for..in`循环 > * `Object.keys`方法 > * `JSON.stringify`方法 `configurable`属性决定了是否可以修改属性描述对象,还决定了目标属性是否可以被删除(delete) 注意,`writable`属性只有在`false`改为`true`时会报错,`true`改为`false`是允许的。 `value`属性的情况比较特殊。只要`writable`和`configurable`有一个为`true`,就允许改动`value`。 ### 存取器 ```js // 写法一 var obj = Object.defineProperty({}, 'p', { get: function () { return 'getter'; }, set: function (value) { console.log('setter: ' + value); } }); // 写法二 var obj = { get p() { return 'getter'; }, set p(value) { console.log('setter: ' + value); } }; ``` 第一种写法,属性`p`的`configurable`和`enumerable`都为`false`,从而导致属性`p`是不可遍历的; 第二种写法,属性`p`的`configurable`和`enumerable`都为`true`,因此属性`p`是可遍历的。 存取器往往用于,**属性的值依赖对象内部数据**的场合。 ```js var obj ={ $n : 5, get next() { return this.$n++ }, set next(n) { if (n >= this.$n) this.$n = n; else throw new Error('新的值必须大于当前值'); } }; obj.next // 5 obj.next = 10; obj.next // 10 obj.next = 5; // Uncaught Error: 新的值必须大于当前值 ``` ### 对象的拷贝 将一个对象的所有属性拷贝到另一个对象 ```js var extend = function (to, from) { for (var property in from) { to[property] = from[property]; } return to; } extend({}, { a: 1 }) // {a: 1} ``` 上述方法的缺点是遇到**存取器只会拷贝值** ```js extend({}, { get a() { return 1 } }) // {a: 1} ``` 解决办法是通过`Object.defineProperty`方法来拷贝属性 ```js var extend = function (to, from) { for (var property in from) { if (!from.hasOwnProperty(property)) continue; Object.defineProperty( to, property, Object.getOwnPropertyDescriptor(from, property) ); } return to; } extend({}, { get a(){ return 1 } }) // { get a(){ return 1 } }) ``` `hasOwnProperty`那一行用来过滤掉继承的属性,否则可能会报错,因为`Object.getOwnPropertyDescriptor`读不到继承属性的属性描述对象。 ### 控制对象状态 `Object.preventExtensions`方法可以使得一个对象无法**再添加**新的属性。 `Object.isExtensible`方法用于检查一个对象是否使用了`Object.preventExtensions`方法。 `Object.seal`方法使得一个对象既无法添加新属性,也无法删除旧属性。 > `Object.seal`实质是把属性描述对象的`configurable`属性设为`false`,因此属性描述对象不再能改变了。`Object.seal`只是禁止新增或删除属性,并不影响修改某个属性的值。 `Object.isSealed`方法用于检查一个对象是否使用了`Object.seal`方法。 `Object.freeze`方法可以使得一个对象无法添加新属性、无法删除旧属性、也无法改变属性的值,使得这个对象实际上变成了常量。 `Object.isFrozen`方法用于检查一个对象是否使用了`Object.freeze`方法。 > `Object.isFrozen`的一个用途是,确认某个对象没有被冻结后,再对它的属性赋值。 > \[!tip] > > 上述方法中,如果操作了被限制的属性,在严格模式下,大部分会报错,否则只是静默失败 ### 局限性 上面的三个方法锁定对象的可写性有一个漏洞:可以通过**改变原型对象**,来为对象增加属性。 ```js var obj = new Object(); Object.preventExtensions(obj); // 限制对象 var proto = Object.getPrototypeOf(obj); // 在原型对象操作 proto.t = 'hello'; obj.t // 依然可以读取到 // hello ``` 解决方案是,把`obj`的原型也冻结住 ```js var obj = new Object(); Object.preventExtensions(obj); var proto = Object.getPrototypeOf(obj); Object.preventExtensions(proto); proto.t = 'hello'; obj.t // undefined ``` 还有一个局限性是,如果属性值是对象,上述的方法只能冻结属性**指向的对象**,而不能冻结对象的本身的内容。 ```js var obj = { foo: 1, bar: ['a', 'b'] }; Object.freeze(obj); obj.bar.push('c'); obj.bar // ["a", "b", "c"] ``` 上述代码中,将`obj`对象冻结后,**其指向将无法改变,即无法指向其他值**,但是其中的数组是可变的。类似于说,**只能冻结一个对象的顶层属性**,嵌套属性则不行 ## Array `new Array`用于生成新的数组,可以传入数字参数,表示生成指定个数成员的数组,每个位置都是空的。 `Array.isArray`方法返回一个布尔值,表示参数是否为数组。它可以弥补`typeof`运算符的不足,`typeof`运算符只能显示数组的类型是`Object` `valueOf`方法是一个所有对象都拥有的方法,表示对该对象求值。不同对象的`valueOf`方法不尽一致,数组的`valueOf`方法返回数组本身。 `toString`方法返回数组的字符串形式。 `push`方法用于在数组的末端添加一个或多个元素,并返回添加新元素后的数组长度。注意,该方法会改变原数组。 `pop`方法用于删除数组的最后一个元素,并返回该元素。注意,该方法会改变原数组,对空数组使用`pop`方法,不会报错,而是返回`undefined`。 > \[!tip] > > `push`和`pop`结合使用,就构成了“后进先出”的栈结构(stack)。 `shift()`方法用于删除数组的第一个元素,并返回该元素。注意,该方法会改变原数组。 > ```js > var list = [1, 2, 3, 4]; > var item; > > while (item = list.shift()) { > console.log(item); > } > > list // [] > ``` > > 遍历输出并清空一个数组,局限性:数组元素不能是`0`或任何布尔值等于`false`的元素 `unshift()`方法用于在数组的第一个位置添加元素,并返回添加新元素后的数组长度。注意,该方法会改变原数组,可以接受多个参数 `join()`方法以指定参数作为分隔符,将所有数组成员连接为一个字符串返回。如果不提供参数,默认用逗号分隔。 ```js var a = [1, 2, 3, 4]; a.join(' ') // '1 2 3 4' a.join(' | ') // "1 | 2 | 3 | 4" a.join() // "1,2,3,4" ``` > 通过`call`方法,这个方法也可以用于字符串或类似数组的对象。 > > ```js > Array.prototype.join.call('hello', '-') > // "h-e-l-l-o" > > var obj = { 0: 'a', 1: 'b', length: 2 }; > Array.prototype.join.call(obj, '-') > // 'a-b' > ``` `concat`方法用于多个数组的合并。它将新数组的成员,添加到原数组成员的后部,然后返回一个新数组,原数组不变,其参数可以是数组,数字,字符串.... > 如果数组成员包括对象,`concat`方法返回当前数组的一个浅拷贝。所谓“浅拷贝”,指的是新数组拷贝的是对象的引用。 > > ``` > var obj = { a: 1 }; > var oldArray = [obj]; > > var newArray = oldArray.concat(); > > obj.a = 2; > newArray[0].a // 2 > ``` > > 上面代码中,原数组包含一个对象,`concat`方法生成的新数组包含这个对象的引用。所以,改变原对象以后,新数组跟着改变。 `reverse`方法用于颠倒排列数组元素,返回改变后的数组。注意,该方法将改变原数组。 `slice()`方法用于提取目标数组的一部分,返回一个新数组,原数组不变。 ```js arr.slice(start, end); ``` 它的第一个参数为起始位置(从0开始,会包括在返回的新数组之中),第二个参数为终止位置(但该位置的元素本身不包括在内)。如果省略第二个参数,则一直返回到原数组的最后一个成员。 如果`slice()`方法的参数是负数,则表示倒数计算的位置。 > \[!tip] > > `slice()`方法的一个重要应用,是将类似数组的对象转为真正的数组。 > > ```js > Array.prototype.slice.call({ 0: 'a', 1: 'b', length: 2 }) > // ['a', 'b'] > > Array.prototype.slice.call(document.querySelectorAll("div")); > Array.prototype.slice.call(arguments); > ``` > > 上面代码的参数都不是数组,但是通过`call`方法,在它们上面调用`slice()`方法,就可以把它们转为真正的数组。 `splice()`方法用于删除原数组的一部分成员,并可以在删除的位置添加新的数组成员,返回值是被删除的元素。注意,该方法会改变原数组。 ```js arr.splice(start, count, addElement1, addElement2, ...); ``` `splice`的第一个参数是删除的起始位置(从0开始),第二个参数是被删除的元素个数。如果后面还有更多的参数,则表示这些就是要被插入数组的新元素。 还可以单独插入元素而不删除元素,把`splice`方法的第二个参数设置为0。如果只提供一个参数,等同于将原数组在指定位置拆分成两个数组 `sort`方法对数组成员进行排序,默认是按照**字典顺序**排序。排序后,原数组将被改变。 如果想让`sort`方法按照自定义的方式排序,可以传入一个函数作为参数。函数接受两个参数,表示进行比较的两个数组成员,如果函数的返回值大于0,表示第一个成员排在第二个成员后面,其他情况,都是第一个元素排在第二个元素前面。 > \[!tip] > > 注意,自定义的排序函数**应该返回数值**,**而不是布尔值或者其他**,否则不同的浏览器可能有不同的实现,不能保证结果都一致。 `map()`方法将数组的所有成员依次传入参数函数,然后把每一次的执行结果组成一个新数组返回。 `map()`方法接受一个函数作为参数。该函数调用时,`map()`方法向它(函数)传入三个参数:**当前成员、当前位置和数组本身。** `map()`方法还可以接受第二个参数,用来绑定回调函数内部的`this`变量 ```js {3} var arr = ['a', 'b', 'c']; [1, 2].map(function (e) { return this[e]; }, arr) // ['b', 'c'] ``` 上述代码中函数内的`this`指向`arr`数组。 > \[!tip] > > `map()`方法的回调函数**不会在空位执行函数**(`['a','b', ,'d']`),但是不会跳过`undefined`和`null` `forEach()`方法与`map()`方法很相似,也是对数组的所有成员依次执行参数函数。但是,`forEach()`方法**不返回值**,只用来操作数据(比如:为了在屏幕输出内容)。 `forEach()`的用法与`map()`方法一致,参数是一个函数,该函数同样接受三个参数:当前值、当前位置、整个数组。 `forEach()`方法也可以接受第二个参数,绑定参数函数的`this`变量。 注意,`forEach()`方法无法中断执行,总是会将所有成员遍历完,`forEach()`方法不会跳过`undefined`和`null`,但会跳过空位。 `filter()`方法用于过滤数组成员,满足条件的成员组成一个新数组返回。 它的参数是一个函数,所有数组成员依次执行该函数,**返回结果为**`true`**的成员组成一个新数组返回**。该方法不会改变原数组。 `filter()`方法的参数函数可以接受三个参数:当前成员,当前位置和整个数组。 `filter()`方法还可以接受第二个参数,用来绑定参数函数内部的`this`变量。 `some`方法是只要**一个成员执行函数的返回值是**`true`,则整个`some`方法的返回值就是`true`,否则返回`false`。 `every`方法是所有成员的返回值都是`true`,整个`every`方法才返回`true`,否则返回`false`。 `some`和`every`方法还可以接受第二个参数,用来绑定参数函数内部的`this`变量。 `reduce()`方法和`reduceRight()`方法依次处理数组的每个成员,最终累计为一个值。它们的差别是,`reduce()`是从左到右处理(从第一个成员到最后一个成员),`reduceRight()`则是从右到左(从最后一个成员到第一个成员),其他完全一样。 ```js [1, 2, 3, 4, 5].reduce(function (a, b) { console.log(a, b); return a + b; }) // 1 2 // 3 3 // 6 4 // 10 5 //最后结果:15 ``` 上面代码中,`reduce()`方法用来求出数组所有成员的和。`reduce()`的参数是一个函数,数组每个成员都会依次执行这个函数。如果数组有 n 个成员,这个参数函数就会执行 n - 1 次。 * 第一次执行:`a`是数组的第一个成员`1`,`b`是数组的第二个成员`2`。 * 第二次执行:`a`为上一轮的返回值`3`,`b`为第三个成员`3`。 * 第三次执行:`a`为上一轮的返回值`6`,`b`为第四个成员`4`。 * 第四次执行:`a`为上一轮返回值`10`,`b`为第五个成员`5`。至此所有成员遍历完成,整个方法的返回值就是最后一轮的返回值`15`。 `reduce()`方法和`reduceRight()`方法的第一个参数都是一个函数。该函数接受以下四个参数。 1. 累积变量。第一次执行时,默认为数组的第一个成员;以后每次执行时,都是上一轮的返回值。 2. 当前变量。第一次执行时,默认为数组的第二个成员;以后每次执行时,都是下一个成员。 3. 当前位置。一个整数,表示第二个参数(当前变量)的位置,默认为`1`。 4. 原数组。 这四个参数之中,只有前两个是必须的,后两个则是可选的。 如果要对累积变量**指定初值**,可以把它放在`reduce()`方法和`reduceRight()`方法的第二个参数。 `indexOf`方法返回给定元素在数组中第一次出现的位置,如果没有出现则返回`-1`。 `indexOf`方法还可以接受第二个参数,表示搜索的开始位置。 `lastIndexOf`方法返回给定元素在数组中最后一次出现的位置,如果没有出现则返回`-1`。 > \[!tip] > > 注意,这两个方法不能用来搜索`NaN`的位置,即它们无法确定数组成员是否包含`NaN`。 ## 包装对象 在JavaScript中,原始类型的值:数字,字符串,布尔值,在一定条件下会自动转换为对象,也就是**原始类型的包装对象** 所谓“包装对象”,指的是与数值、字符串、布尔值分别相对应的`Number`、`String`、`Boolean`三个原生对象。这三个原生对象可以把**原始类型的值变成(包装成)对象**。 ```js var v1 = new Number(123); typeof v1 // "object" v1 === 123 // false ``` 包装对象的目的是:让整门语言有一个通用的数据模型,**其次是使得原始类型的值也有办法调用自己的方法。** ### 实例方法 三种包装对象各自提供了许多实例方法,其中两种它们共同具有、从`Object`对象继承的方法:`valueOf()`和`toString()`。 `valueOf()`方法返回包装对象实例对应的原始类型的值。 ```js new Number(123).valueOf() // 123 ``` `toString()`方法返回对应的字符串形式。 ```js new Number(123).toString() // "123" ``` ### 自动转换 某些场合,原始类型的值会自动当作包装对象调用,即调用包装对象的属性和方法。这时,JavaScript 引擎会自动将原始类型的值转为包装对象实例,并在使用后**立刻销毁实例**。 ```js 'abc'.length // 3 ``` 上述代码中,`abc`本身只是一个**普通的字符串**,不是一个对象,所以不能调用`length`属性 此时,JavaScript 引擎自动将其转为**包装对象**,在这个对象上调用`length`属性。 调用结束后,这个临时对象就会被销毁。这就叫原始类型与实例对象的自动转换。 ```js let str = 'abc'; str.length // 3 ``` 实际上执行的是: ```js let strObj = new String(str) // String { // 0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc" // } strObj.length // 3 ``` 上面代码中,字符串`abc`的包装对象提供了多个属性,`length`只是其中之一。 自动转换生成的包装对象是只读的,无法修改。所以,字符串无法添加新属性。 > \[!tip] > > 当调用结束后,包装对象实例会自动销毁。这意味着,下一次调用字符串的属性时,实际是调用一个新生成的对象,而不是上一次调用时生成的那个对象,所以取不到赋值在上一个对象的属性。如果要为字符串添加属性,只有在它的原型对象`String.prototype`上定义 ### 自定义方法 除了原生的实例方法,包装对象还可以自定义方法和属性,**供原始类型的值直接调用** 比如,我们可以新增一个`double`方法,使得字符串和数字翻倍。 ```js String.prototype.double = function () { return this.valueOf() + this.valueOf(); }; 'abc'.double() // abcabc Number.prototype.double = function () { return this.valueOf() + this.valueOf(); }; (123).double() // 246 ``` 上面代码在`String`和`Number`这两个对象的原型上面,分别自定义了一个方法,从而可以在所有实例对象上调用。注意,最后一行的`123`外面必须要加上圆括号,否则后面的点运算符(`.`)会被解释成小数点。 ## Boolean 对象 `Boolean`对象是 JavaScript 的三个包装对象之一。作为构造函数,它主要用于生成布尔值的包装对象实例。 使用双重的否运算符(`!`)也可以将任意值转为对应的布尔值。 ## Number 对象 `Number`对象是数值对应的包装对象,可以作为构造函数使用,也可以作为工具函数使用。 ### 静态属性 > \[!tip] > > **静态属性**指的是直接定义在`Number`对象上的属性,而不是定义在实例上的属性 | 静态属性 | 含义 | | -------------------------- | ------------------------------------------------------------ | | `Number.POSITIVE_INFINITY` | 正的无限,指向`Infinity` | | `Number.NEGATIVE_INFINITY` | 负的无限,指向`-Infinity` | | `Number.NaN` | 表示非数值,指向`NaN` | | `Number.MIN_VALUE` | 表示最小的正数(即最接近0的正数,在64位浮点数体系中为`5e-324`),相应的,最接近0的负数为`-Number.MIN_VALUE` | | `Number.MAX_SAFE_INTEGER` | 表示能够精确表示的最大整数,即`9007199254740991` | | `Number.MIN_SAFE_INTEGER` | 表示能够精确表示的最小整数,即`-9007199254740991` | ### 实例方法 #### toString() `Number.prototype.toString()`用来将一个数值转为字符串形式。 `toString`方法可以接受一个参数,表示输出的进制。如果省略这个参数,默认将数值先转为十进制,再输出字符串;否则,就根据参数指定的进制,将一个数字转化成某个进制的字符串。 ```js (10).toString(2) // "1010" ``` > \[!tip] > > 上面代码中,`10`一定要放在括号里,这样表明后面的点表示调用对象属性。如果不加括号,这个点会被 JavaScript 引擎解释成小数点,从而报错。 > > 当然,只要让JavaScript 引擎不混淆小数点和对象的点运算符,各种写法都能用。 > > 还可以在`10`后面加两个点,JavaScript 会把第一个点理解成小数点(即`10.0`),把第二个点理解成调用对象属性,从而得到正确结果。 ```js 10..toString(2) // "1010" // 其他方法还包括 10 .toString(2) // "1010" 10.0.toString(2) // "1010" ``` 通过方括号运算符也可以调用`toString`方法。 ```js 10['toString'](2) // "1010" ``` `toString`方法只能将十进制的数,转为其他进制的字符串。如果要将其他进制的数,转回十进制,需要使用`parseInt`方法。 #### toFixed() `Number.prototype.toFixed()`方法先将一个数转为指定位数的小数,然后返回这个小数对应的字符串。 #### toExponential `Number.prototype.toExponential()`方法用于将一个数转为科学计数法形式,`toExponential`方法的参数是小数点后有效数字的位数,范围为0到100 #### toPrecision() `Number.prototype.toPrecision()`方法用于将一个数转为指定位数的有效数字,该方法的参数为有效数字的位数,范围是1到100 #### toLocaleString `Number.prototype.toLocaleString()`方法接受一个地区码作为参数,返回一个字符串,表示当前数字在该地区的当地书写形式。 ### 自定义方法 与其他对象一样,`Number.prototype`对象上面可以自定义方法,被`Number`的实例继承。 ```js Number.prototype.add = function (x) { return this + x; }; 8['add'](2) // 10 ``` ```js Number.prototype.iterate = function () { var result = []; for (var i = 0; i <= this; i++) { result.push(i); } return result; }; (8).iterate() // [0, 1, 2, 3, 4, 5, 6, 7, 8] ``` 注意,数值的自定义方法,只能定义在它的原型对象`Number.prototype`上面,数值本身是无法自定义属性的。 ```js var n = 1; n.x = 1; n.x // undefined ``` 上面代码中,`n`是一个原始类型的数值。直接在它上面新增一个属性`x`,不会报错,但毫无作用,总是返回`undefined`。这是因为一旦被调用属性,`n`就自动转为`Number`的实例对象,调用结束后,该对象自动销毁。所以,下一次调用`n`的属性时,实际取到的是另一个对象,属性`x`当然就读不出来。 ## String 对象 `String`对象是 JavaScript 原生提供的三个包装对象之一,用来生成字符串对象。 字符串对象是一个类似数组的对象(很像数组,但不是数组) ```js new String('abc') // String {0: "a", 1: "b", 2: "c", length: 3} (new String('abc'))[1] // "b" ``` ### 静态方法 ... ### 实例方法 `String.prototype.charAt()`方法返回指定位置的字符,参数是从`0`开始编号的位置 `String.prototype.concat()`方法用于连接两个字符串,返回一个新字符串,不改变原字符串。 `String.prototype.slice()`方法用于从原字符串取出子字符串并返回,不改变原字符串。它的第一个参数是子字符串的开始位置,第二个参数是子字符串的结束位置(不含该位置)。 `String.prototype.substring()`方法用于从原字符串取出子字符串并返回,不改变原字符串,跟`slice`方法很相像。它的第一个参数表示子字符串的开始位置,第二个位置表示结束位置(返回结果不含该位置)。 如果省略第二个参数,则表示子字符串一直到原字符串的结束。 如果第一个参数大于第二个参数,`substring`方法会自动更换两个参数的位置。 如果参数是负数,`substring`方法会自动将负数转为0。 `String.prototype.indexOf()`方法用于确定一个字符串在另一个字符串中第一次出现的位置,返回结果是匹配开始的位置。如果返回`-1`,就表示不匹配,该方法还可以接受第二个参数,表示从该位置开始向后匹配。 `String.prototype.lastIndexOf()` 方法的用法跟`indexOf`方法一致,主要的区别是`lastIndexOf`从尾部开始匹配,`indexOf`则是从头部开始匹配。 `String.prototype.trim()`方法用于去除字符串两端的空格,返回一个新字符串,不改变原字符串。 `String.prototype.toLowerCase()`方法用于将一个字符串全部转为小写,`toUpperCase`则是全部转为大写。它们都返回一个新字符串,不改变原字符串。 `String.prototype.match()`方法用于确定原字符串是否匹配某个子字符串,返回一个数组,成员为匹配的第一个字符串。如果没有找到匹配,则返回`null`。支持正则表达式作为参数 返回的数组还有`index`属性和`input`属性,分别表示匹配字符串开始的位置和原始字符串。 `String.prototype.search()`方法的用法基本等同于`match`,但是返回值为匹配的第一个位置。如果没有找到匹配,则返回`-1`。 `String.prototype.replace()` 方法用于替换匹配的子字符串,一般情况下只替换第一个匹配(除非使用带有`g`修饰符的正则表达式)。 `String.prototype.split()` 方法按照给定规则分割字符串,返回一个由分割出来的子字符串组成的数组。 ```js 'a|b|c'.split('|') // ["a", "b", "c"] ``` `split`方法的第二个参数,决定了返回数组的成员数,支持正则表达式。 ## Math 对象 `Math`是 JavaScript 的原生对象,提供各种数学功能。**该对象不是构造函数**,不能生成实例,所有的属性和方法都必须在`Math`对象上调用。 ### Math.abs() `Math.abs`方法返回参数值的绝对值。 ### Math.max() `Math.max`方法返回参数之中最大的那个值 ### Math.min() `Math.min`返回最小的那个值 ### Math.floor() `Math.floor`方法返回**小于或等于**参数值的最大**整数**(地板值) ```js Math.floor(3.2) // 3 Math.floor(-3.2) // -4 ``` ### Math.ceil() `Math.ceil`方法返回大于或等于参数值的最小整数(天花板值) ```js Math.ceil(3.2) // 4 Math.ceil(-3.2) // -3 ``` ### Math.round() `Math.round`方法用于四舍五入。 ### Math.random() `Math.random()`返回0到1之间的一个伪随机数,可能等于0,但是一定小于1。 任意范围的**随机数**生成函数如下。 ```js function getRandomArbitrary(min, max) { return Math.random() * (max - min) + min; } getRandomArbitrary(1.5, 6.5) // 2.4942810038223864 ``` 任意范围的**随机整数**生成函数如下。 ```js function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } getRandomInt(1, 6) // 5 ``` 返回**随机字符**的例子如下。 ```js function random_str(length) { var ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; ALPHABET += 'abcdefghijklmnopqrstuvwxyz'; ALPHABET += '0123456789-_'; var str = ''; for (var i = 0; i < length; ++i) { var rand = Math.floor(Math.random() * ALPHABET.length); str += ALPHABET.substring(rand, rand + 1); } return str; } random_str(6) // "NdQKOr" ``` 上面代码中,`random_str`函数接受一个整数作为参数,返回变量`ALPHABET`内的随机字符所组成的指定长度的字符串。 ## Date 对象 ... ## RegExp 对象 新建正则表达式有两种方法。 一种是使用**字面量**,以斜杠表示开始和结束。 ```js var regex = /xyz/; ``` 另一种是使用`RegExp`构造函数。 ```js var regex = new RegExp('xyz'); ``` `RegExp`构造函数还可以接受第二个参数,表示修饰符 ### 实例方法 #### test() `RegExp.prototype.` 正则实例对象的`test`方法返回一个布尔值,表示当前模式是否能匹配参数字符串。 ```js /cat/.test('cats and dogs') // true ``` 如果正则表达式带有`g`修饰符,则每一次`test`方法都从**上一次结束**的位置开始向后匹配。 > \[!tip] > > `RegExp.prototype.lastIndex`:返回一个整数,表示下一次开始搜索的位置。该属性可读写,但是只在进行连续搜索时有意义,详细介绍请看后文。 ```js var r = /x/g; var s = '_x_x'; r.lastIndex // 0 r.test(s) // true r.lastIndex // 2 r.test(s) // true r.lastIndex // 4 r.test(s) // false ``` `g`修饰符,表示是全局搜索,可以通过正则对象的`lastIndex`属性指定开始搜索的位置。 #### exec() `RegExp.prototype.exec()`用来返回匹配结果。如果发现匹配,就返回一个数组,成员是匹配成功的子字符串,否则返回`null`。 如果正则表达式包含圆括号(即含有“**组匹配**”),则返回的数组会包括多个成员。第一个成员是整个匹配成功的结果,后面的成员就是圆括号对应的匹配成功的组。 也就是说,第二个成员对应第一个括号,第三个成员对应第二个括号,以此类推。整个数组的`length`属性等于组匹配的数量再加1。 ```js var s = '_x_x'; var r = /_(x)/; r.exec(s) // ["_x", "x"] ``` `exec()`方法的返回数组还包含以下两个属性: * `input`:整个原字符串。 * `index`:模式匹配成功的开始位置(从0开始计数)。 如果正则表达式加上`g`修饰符,则可以使用多次`exec()`方法,下一次搜索的位置从上一次匹配成功结束的位置开始。 利用`g`修饰符允许多次匹配的特点,可以用一个循环完成全部匹配。 ```js var reg = /a/g; var str = 'abc_abc_abc' while(true) { var match = reg.exec(str); if (!match) break; console.log('#' + match.index + ':' + match[0]); } // #0:a // #4:a // #8:a ``` #### match() `String.prototype.match()`对**字符串**进行正则匹配,返回匹配结果 ```js var s = '_x_x'; var r1 = /x/; var r2 = /y/; s.match(r1) // ["x"] s.match(r2) // null ``` 如果正则表达式带有`g`修饰符,则该方法与正则对象的`exec`方法行为不同,**会一次性返回所有匹配成功的结果。** 设置正则表达式的`lastIndex`属性,对`match`方法无效,匹配总是从字符串的第一个字符开始。 #### search() `String.prototype.search()`返回**第一个满足条件**的匹配结果在整个字符串中的位置。如果没有任何匹配,则返回`-1`。 ```js '_x_x'.search(/x/) // 1 ``` #### replace() `String.prototype.replace()`方法可以替换匹配的值。它接受两个参数,第一个是正则表达式,表示搜索模式,第二个是替换的内容。 正则表达式如果不加`g`修饰符,就替换第一个匹配成功的值,否则替换所有匹配成功的值。 `replace`方法的一个应用,就是消除字符串首尾两端的空格。 ```js var str = ' #id div.class '; str.replace(/^\s+|\s+$/g, '') // "#id div.class" ``` `replace`方法的第二个参数可以使用美元符号`$`,用来指代所替换的内容。 * `$&`:匹配的子字符串。 * \`$\`\`:匹配结果前面的文本。 * `$'`:匹配结果后面的文本。 * `$n`:匹配成功的第`n`组内容,`n`是从1开始的自然数。 * `$$`:指代美元符号`$`。 ```js 'hello world'.replace(/(\w+)\s(\w+)/, '$2 $1') // "world hello" ``` `replace`方法的第二个参数还可以是一个函数,将每一个匹配内容替换为函数返回值。 ```js '3 and 5'.replace(/[0-9]+/g, function (match) { return 2 * match; }) // "6 and 10" var a = 'The quick brown fox jumped over the lazy dog.'; var pattern = /quick|brown|lazy/ig; a.replace(pattern, function replacer(match) { return match.toUpperCase(); }); // The QUICK BROWN fox jumped over the LAZY dog. ``` 函数,可以接受多个参数。 其中,第一个参数是**捕捉到的内容**,第二个参数是捕捉到的**组匹配**(**有多少个组匹配,就有多少个对应的参数**)。 此外,最后还可以添加两个参数,倒数第二个参数是捕捉到的内容在整个字符串中的**位置**(比如从第五个位置开始),最后一个参数是**原字符串**。下面是一个网页模板替换的例子。 #### split() `String.prototype.split()`方法按照正则规则分割字符串,返回一个由分割后的各个部分组成的数组。 ```js str.split(separator, [limit]) ``` 该方法接受两个参数,第一个参数是正则表达式,表示分隔规则,第二个参数是返回数组的最大成员数。 ## 匹配规则 ### 字面量字符 大部分字符在正则表达式中,就是字面的含义,比如`/a/`匹配`a`,`/b/`匹配`b`。 ```js /dog/.test('old dog') // true ``` 如果在正则表达式之中,某个字符只表示它字面的含义(就像前面的`a`和`b`),那么它们就叫做“**字面量字符**”(literal characters)。 ### 元字符 还有一部分字符有特殊含义,不代表字面的意思。它们叫做“**元字符**”(metacharacters),主要有以下几个。 #### 点字符(.) 点字符(`.`)匹配除回车(`\r`)、换行(`\n`) 、行分隔符(`\u2028`)和段分隔符(`\u2029`)以外的所有字符。注意,对于码点大于`0xFFFF`字符,点字符不能正确匹配,会认为这是两个字符。 ``` /c.t/ ``` 上面代码中,`c.t`匹配`c`和`t`之间包含任意一个字符的情况,只要这三个字符在同一行,比如`cat`、`c2t`、`c-t`等等,但是不匹配`coot`。 #### 位置字符 位置字符用来提示字符所处的位置,主要有两个字符。 * `^` 表示字符串的开始位置 * `$` 表示字符串的结束位置 ```js // test必须出现在开始位置 /^test/.test('test123') // true // test必须出现在结束位置 /test$/.test('new test') // true // 从开始位置到结束位置只有test /^test$/.test('test') // true /^test$/.test('test test') // false ``` #### 选择符(|) 竖线符号(`|`)在正则表达式中表示“或关系”(OR),即`cat|dog`表示匹配`cat`或`dog`。 **选择符会包括它前后的多个字符**,比如`/ab|cd/`指的是匹配`ab`或者`cd`,而不是指匹配`b`或者`c`。如果想修改这个行为,可以使用圆括号。 ```js /a( |\t)b/.test('a\tb') // true ``` 上面代码指的是,`a`和`b`之间有一个空格或者一个制表符。 ### 转义符 正则表达式中那些有特殊含义的元字符,如果要匹配它们本身,就需要在它们前面要加上反斜杠。比如要匹配`+`,就要写成`\+`。 ```js /1+1/.test('1+1') // false /1\+1/.test('1+1') // true ``` 正则表达式中,需要反斜杠转义的,一共有12个字符:`^`、`.`、`[`、`$`、`(`、`)`、`|`、`*`、`+`、`?`、`{`和`\`。 需要特别注意的是,如果使用`RegExp`方法生成正则对象,转义需要使用两个斜杠,因为字符串内部会先转义一次。 > \[!tip] > > `RegExp`作为构造函数,参数是一个字符串。但是,在字符串内部,反斜杠也是转义字符,所以它会先被反斜杠转义一次,然后再被正则表达式转义一次,因此需要两个反斜杠转义。 ### 特殊字符 正则表达式对一些不能打印的特殊字符,提供了表达方法。 * `\cX` 表示`Ctrl-[X]`,其中的`X`是A-Z之中任一个英文字母,用来匹配控制字符。 * `[\b]` 匹配退格键(U+0008),不要与`\b`混淆。 * `\n` 匹配换行键。 * `\r` 匹配回车键。 * `\t` 匹配制表符 tab(U+0009)。 * `\v` 匹配垂直制表符(U+000B)。 * `\f` 匹配换页符(U+000C)。 * `\0` 匹配`null`字符(U+0000)。 * `\xhh` 匹配一个以两位十六进制数(`\x00`-`\xFF`)表示的字符。 * `\uhhhh` 匹配一个以四位十六进制数(`\u0000`-`\uFFFF`)表示的 Unicode 字符。 ### 字符类 字符类(class)表示有一系列字符可供选择,只要匹配其中一个就可以了。所有可供选择的字符都放在方括号内,比如`[xyz]` 表示`x`、`y`、`z`之中任选一个匹配。 ```js /[abc]/.test('hello world') // false /[abc]/.test('apple') // true ``` **有两个字符在字符类中有特殊含义。** **脱字符(^)** 如果方括号内的第一个字符是`[^]`,则表示除了字符类之中的字符,其他字符都可以匹配。比如,`[^xyz]`表示除了`x`、`y`、`z`之外都可以匹配。 ```js /[^abc]/.test('bbc news') // true /[^abc]/.test('bbc') // false ``` 上面代码中,字符串`bbc news`包含`a`、`b`、`c`以外的其他字符,所以返回`true`;字符串`bbc`不包含`a`、`b`、`c`以外的其他字符,所以返回`false`。 > \[!tip] > > 如果方括号内没有其他字符,即只有`[^]`,就表示匹配一切字符,其中包括换行符。相比之下,点号作为元字符(`.`)是不包括换行符的。 **连字符(-)** 某些情况下,对于连续序列的字符,连字符(`-`)用来提供简写形式,表示字符的连续范围。比如,`[abc]`可以写成`[a-c]`,`[0123456789]`可以写成`[0-9]`,同理`[A-Z]`表示26个大写字母。 **当连字号(dash)不出现在方括号之中,就不具备简写的作用,只代表字面的含义** > \[!caution] > > 不要过分使用连字符,设定一个很大的范围,否则很可能选中意料之外的字符。最典型的例子就是`[A-z]`,表面上它是选中从大写的`A`到小写的`z`之间52个字母,但是由于在 ASCII 编码之中,大写字母与小写字母之间还有其他字符,结果就会出现意料之外的结果。 > > ```js > /[A-z]/.test('\\') // true > ``` > > 上面代码中,由于反斜杠(`\`)的ASCII码在大写字母与小写字母之间,结果会被选中。 ### 预定义模式 预定义模式指的是某些常见模式的简写方式。 * `\d` 匹配0-9之间的任一数字,相当于`[0-9]`。 * `\D` 匹配所有0-9以外的字符,相当于`[^0-9]`。 * `\w` 匹配任意的字母、数字和下划线,相当于`[A-Za-z0-9_]`。 * `\W` 除所有字母、数字和下划线以外的字符,相当于`[^A-Za-z0-9_]`。 * `\s` 匹配空格(包括换行符、制表符、空格符等),相等于`[ \t\r\n\v\f]`。 * `\S` 匹配非空格的字符,相当于`[^ \t\r\n\v\f]`。 * `\b` 匹配词的边界。 * `\B` 匹配非词边界,即在词的内部。 ```js // \s 的例子 /\s\w*/.exec('hello world') // [" world"] // \B 的例子 /\Bworld/.test('hello-world') // false /\Bworld/.test('helloworld') // true /\bworld/.test('hello world') // true /\bworld/.test('hello-world') // true /\bworld/.test('helloworld') // false // \B 的例子 /\Bworld/.test('hello-world') // false /\Bworld/.test('helloworld') // true ``` `\b`表示词的边界,所以`world`的词首必须独立(词尾是否独立未指定),才会匹配。同理,`\B`表示非词的边界,只有`world`的词首不独立,才会匹配。 > \[!TIP] > > 通常情况下,正则表达式遇到换行符`\n`就会停止匹配。 > > 可以使用`\s`字符类即可匹配包含换行符 ### 重复类 用于描述一个字符出现次数,使用大括号`{}`表示,`{n}`表示只能出现`n`次,`{n,}`表示最少出现`n`次,`{n,m}`表示最少`n`次,最多`m`次 ```js /lo{2}k/.test('look') // true /lo{2,5}k/.test('looook') // true ``` ### 量词符 量词符用来设定某个模式出现的次数。 * `?` 问号表示某个模式出现0次或1次,等同于`{0, 1}`。 * `*` 星号表示某个模式出现0次或多次,等同于`{0,}`。 * `+` 加号表示某个模式出现1次或多次,等同于`{1,}`。 ```js // t 出现0次或1次 /t?est/.test('test') // true /t?est/.test('est') // true // t 出现1次或多次 /t+est/.test('test') // true /t+est/.test('ttest') // true /t+est/.test('est') // false // t 出现0次或多次 /t*est/.test('test') // true /t*est/.test('ttest') // true /t*est/.test('tttest') // true /t*est/.test('est') // true ``` ### 贪婪模式 刚才讲述的量词符,默认情况下都是最大可能得匹配,即匹配到下一个字符不满足匹配规则为止。这种被称为贪婪模式。 ```js let a = 'aaa'; s.match(/a+/) // ["aaa"] ``` 上面的代码中,模式是`a+`,表示匹配一个`a`或者多个`a`,但是其默认是贪婪模式,会一直匹配到字符`a`不出现为止,所以匹配结果是三个字符`a`。 量词符默认是**贪婪模式**,当然也可以改成非贪婪模式,即最小可能匹配。**只要一发现匹配,就返回结果**,不要继续往下检查了。 **把贪婪模式改为非贪婪模式的方式是在量词符的后面添加一个问号。** ```js var s = 'aaa'; s.match(/a+?/) // ["a"] ``` 上面例子中,模式结尾添加了一个问号`/a+?/`,这时就改为非贪婪模式,一旦条件满足,就不再往下匹配,`+?`表示只要发现一个`a`,就不再往下匹配了。 * `+?`:表示某个模式出现1次或多次,匹配时采用非贪婪模式。 * `*?`:表示某个模式出现0次或多次,匹配时采用非贪婪模式。 * `??`:表格某个模式出现0次或1次,匹配时采用非贪婪模式。 ### 修饰符 修饰符(modifier)表示模式的附加规则,放在正则模式的最尾部,修饰符可以单个使用,也可以多个一起使用。 ```js // 单个修饰符 var regex = /test/i; // 多个修饰符 var regex = /test/ig; ``` #### g 修饰符 默认情况下,第一次匹配成功后,正则对象就停止向下匹配了。`g`修饰符表示全局匹配(global),加上它以后,正则对象将匹配全部符合条件的结果,主要用于搜索和替换。 #### i 修饰符 默认情况下,正则对象区分字母的大小写,加上`i`修饰符以后表示忽略大小写(ignoreCase)。 #### m 修饰符 `m`修饰符表示多行模式(multiline),会修改`^`和`$`的行为。 默认情况下(即不加`m`修饰符时),`^`和`$`匹配字符串的开始处和结尾处,加上`m`修饰符以后,`^`和`$`还会匹配行首和行尾,**即`^`和`$`会识别换行符(`\n`)**。 ```js /world$/.test('hello world\n') // false 不匹配换行符 /world$/m.test('hello world\n') // true ``` ```js /^b/m.test('a\nb') // true ``` 上面代码要求匹配行首的`b`,如果不加`m`修饰符,就相当于`b`只能处在字符串的开始处。加上`m`修饰符以后,**换行符`\n`也会被认为是一行的开始。** ### 组匹配 **概述**:正则表达式的括号表示分**组匹配**,括号中的模式可以用来匹配**分组的内容**。 ```js /fred+/.test('fredd') // true /(fred)+/.test('fredfred') // true ``` 上面代码中,第一个模式没有括号,结果`+`只表示**重复字母**`d`,第二个模式有括号,结果`+`就表示匹配`fred`**这个词**。 ```js var m = 'abcabc'.match(/(.)b(.)/); m // ['abc', 'a', 'c'] 第一个代表整个正则的匹配结果,后面是各组匹配的结果 ``` 上面代码中,正则表达式`/(.)b(.)/`一共使用两个括号,第一个括号捕获`a`,第二个括号捕获`c`。 > \[!TIP] > > 注意,使用组匹配时,不宜同时使用`g`修饰符,否则`match`方法不会捕获分组的内容。 ```js var m = 'abcabc'.match(/(.)b(.)/g); m // ['abc', 'abc'] ``` 上面代码使用带`g`修饰符的正则表达式,结果`match`方法只捕获了匹配整个表达式的部分。这时必须使用正则表达式的`exec`方法,配合循环,才能读到每一轮匹配的组捕获。 ```js var str = 'abcabc'; var reg = /(.)b(.)/g; while (true) { var result = reg.exec(str); if (!result) break; console.log(result); } // ["abc", "a", "c"] // ["abc", "a", "c"] ``` **正则表达式内部,还可以用`\n`引用括号匹配的内容**,`n`是从1开始的自然数,表示对应顺序的括号。 ```js /(.)b(.)\1b\2/.test("abcabc") // true ``` 上面的代码中,`\1`表示第一个括号匹配的内容(即`a`),`\2`表示第二个括号匹配的内容(即`c`)。 ```js /y(..)(.)\2\1/.test('yabccab') // true ``` **括号还可以嵌套**。 ```js /y((..)\2)\1/.test('yabababab') // true ``` 上面代码中,`\1`指向外层括号,`\2`指向内层括号。 一个匹配网页标签的例子 ```js var tagName = /<([^>]+)>[^<]*<\/\1>/; tagName.exec("bold")[1] // 'b' ``` 上面代码中,圆括号匹配尖括号之中的标签,而`\1`就表示对应的闭合标签。 上面代码略加修改,就能捕获带有属性的标签。 ```js var html = 'Helloworld'; var tag = /<(\w+)([^>]*)>(.*?)<\/\1>/g; var match = tag.exec(html); match[1] // "b" match[2] // " class="hello"" match[3] // "Hello" match = tag.exec(html); match[1] // "i" match[2] // "" match[3] // "world" ``` **非捕获组** `(?:x)`称为非捕获组(Non-capturing group),表示不返回该组匹配的内容,即匹配的结果中不计入这个括号。 非捕获组的作用请考虑这样一个场景,假定需要匹配`foo`或者`foofoo`,正则表达式就应该写成`/(foo){1, 2}/`,但是这样会占用一个**组匹配**。这时,就可以使用非捕获组,将正则表达式改为`/(?:foo){1, 2}/`,它的作用与前一个正则是一样的,但是不会单独输出括号内部的内容。 ```js var m = 'abc'.match(/(?:.)b(.)/); m // ["abc", "c"] ``` 上面代码中的模式,一共使用了两个括号。其中第一个括号是非捕获组,所以最后返回的结果中没有第一个括号,只有第二个括号匹配的内容。 分解网址的正则表达式。 ```js // 正常匹配 var url = /(http|ftp):\/\/([^/\r\n]+)(\/[^\r\n]*)?/; url.exec('http://google.com/'); // ["http://google.com/", "http", "google.com", "/"] // 非捕获组匹配 var url = /(?:http|ftp):\/\/([^/\r\n]+)(\/[^\r\n]*)?/; url.exec('http://google.com/'); // ["http://google.com/", "google.com", "/"] ``` 上面的代码中,前一个正则表达式是正常匹配,第一个括号返回网络协议;后一个正则表达式是非捕获匹配,返回结果中不包括网络协议。 **先行断言** `x(?=y)`称为先行断言(Positive look-ahead),`x`只有在`y`前面才匹配,`y`不会被计入返回结果。比如,要匹配后面跟着百分号的数字,可以写成`/\d+(?=%)/`。 “先行断言”中,括号里的部分是不会返回的。 ```js var m = 'abc'.match(/b(?=c)/); m // ["b"] ``` 上面的代码使用了先行断言,`b`在`c`前面所以被匹配,但是括号对应的`c`不会被返回。 **先行否定断言** `x(?!y)`称为先行否定断言(Negative look-ahead),`x`只有不在`y`前面才匹配,`y`不会被计入返回结果。比如,要匹配后面跟的不是百分号的数字,就要写成`/\d+(?!%)/`。 ```js /\d+(?!\.)/.exec('3.14') // ["14"] ``` 上面代码中,正则表达式指定,只有不在小数点前面的数字才会被匹配,因此返回的结果就是`14`。 “先行否定断言”中,括号里的部分是不会返回的。 ```js var m = 'abd'.match(/b(?!c)/); m // ['b'] ``` 上面的代码使用了先行否定断言,`b`不在`c`前面所以被匹配,而且括号对应的`d`不会被返回。 --- --- url: /js/4bpyazfy/index.md --- # Promise ## 状态传递 `resolve` 函数的参数除了正常的值以外,还可能是另一个 Promise 实例 ```js const p1 = new Promise(function (resolve, reject) { // ... }); const p2 = new Promise(function (resolve, reject) { // ... resolve(p1); }) ``` 上面代码中,`p1` 和 `p2` 都是 Promise 的实例,但是 `p2` 的 `resolve` 方法将 `p1` 作为参数,**即一个异步操作的结果是返回另一个异步操作。** 注意,这时 `p1` 的状态就会传递给 `p2`,也就是说,`p1` 的状态决定了 `p2` 的状态。 如果 `p1` 的状态是 `pending`,那么 `p2` 的回调函数就会等待 `p1` 的状态改变;如果 `p1` 的状态已经是 `resolved` 或者 `rejected`,那么 `p2` 的回调函数将会立刻执行。 ```js const p1 = new Promise(function (resolve, reject) { setTimeout(() => reject(new Error('fail')), 3000) }) const p2 = new Promise(function (resolve, reject) { setTimeout(() => resolve(p1), 1000) }) p2 .then(result => console.log(result)) .catch(error => console.log(error)) // Error: fail ``` 上面的代码,`p1` 和 `p2` 同时开始执行(并发),1 秒后 `p2` 执行 `resolve`,再过 2 秒,`p1` 的 `reject` 执行 `resolve` 方法返回的是 `p1`。由于 `p2` 返回的是另一个 Promise,**导致 `p2` 自己的状态无效了,由 `p1` 的状态决定 `p2` 的状态。** 所以,后面的 `then` 语句都变成针对后者(`p1`),只会执行 `catch` 捕获错误。 **注意**,调用 `resolve` 或 `reject` 并不会终结 Promise 的参数函数的执行,也就是,在 `resolve` 或 `reject` 后的代码还会继续执行。 ```js new Promise((resolve, reject) => { resolve(1); console.log(2); }).then(r => { console.log(r); }); // 2 // 1 ``` 上面代码中,调用 `resolve(1)` 以后,后面的 `console.log(2)` 还是会执行,并且会首先打印出来。**这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行**,总是晚于本轮循环的同步任务。 一般来说,调用 `resolve` 或 `reject` 以后,Promise 的使命就完成了,后继操作应该放到 `then` 方法里面,而不应该直接写在 `resolve` 或 `reject` 的后面。所以,最好在它们前面加上 `return` 语句,这样就不会有意外。 ## 实例方法 ### then() 它的作用是为 Promise 实例添加状态改变时的回调函数,`then` 方法的第一个参数是 `resolved` 状态的回调函数,第二个参数是 `rejected` 状态的回调函数,它们都是可选的。 `then` 方法返回的是一个新的 `Promise` 实例(注意,不是原来那个 `Promise` 实例)。因此可以采用链式写法,即 `then` 方法后面再调用另一个 `then` 方法。 ```js {1,3} getJSON("/posts.json").then(function(json) { return json.post; }).then(function(post) { // ... }); ``` 上面的代码使用`then`方法,依次指定了两个回调函数。**第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。** 采用链式的`then`,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个`Promise`对象(即有异步操作),这时后一个回调函数,就会等待该`Promise`对象的状态发生变化,才会被调用。 ```js getJSON("/post/1.json").then( post => getJSON(post.commentURL) ).then( comments => console.log("resolved: ", comments), err => console.log("rejected: ", err) ); ``` 上面代码中,第一个`then`方法指定的回调函数,返回的是另一个`Promise`对象。这时,第二个`then`方法指定的回调函数,就会等待这个新的`Promise`对象状态发生变化。如果变为`resolved`,就调用第一个回调函数,如果状态变为`rejected`,就调用第二个回调函数。 **绕点**: then() 本身就返回一个 Promise: 这是 Promise 链式调用的关键。 无论你在 then() 中做什么,then() 方法都会返回一个新的 Promise。 **then()** **的返回的 Promise 的值,取决于** **then()** **中的** **return** **语句:** * **如果 then() 中 return 一个值 (非 Promise),** 那么 then() 返回的 Promise 会变为 fulfilled 状态,并且这个值会成为下一个 then() 的输入。 * **如果 then() 中 return 一个 Promise,** 那么 then() 返回的 Promise 会“等待” 这个被 return 的 Promise 变为 fulfilled 或 rejected 状态。 最终,then() 返回的 Promise 的状态和值,与被 return 的 Promise 的状态和值保持一致。 ### catch() `Promise.prototype.catch()`方法是`.then(null, rejection)`或`.then(undefined, rejection)`的别名,用于指定发生错误时的回调函数。 Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个`catch`语句捕获。 ```js getJSON('/post/1.json').then(function(post) { return getJSON(post.commentURL); }).then(function(comments) { // some code }).catch(function(error) { // 处理前面三个Promise产生的错误 }); ``` 上面代码中,一共有三个 Promise 对象:一个由`getJSON()`产生,两个由`then()`产生。它们之中任何一个抛出的错误,都会被最后一个`catch()`捕获。 一般来说,不要在`then()`方法里面定义 Reject 状态的回调函数(即`then`的第二个参数),总是使用`catch`方法。 ```js // bad promise .then(function(data) { // success }, function(err) { // error }); // good promise .then(function(data) { //cb // success }) .catch(function(err) { // error }); ``` 上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面`then`方法执行中的错误,也更接近同步的写法(`try/catch`)。因此,建议总是使用`catch()`方法,而不使用`then()`方法的第二个参数。 ### finally() `finally()`方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。 ### all() `Promise.all()`方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。 ```js const p = Promise.all([p1, p2, p3]); ``` 上面代码中,`Promise.all()`方法接受一个数组作为参数,`p1`、`p2`、`p3`都是 Promise 实例,如果不是,就会先调用`Promise.resolve`方法,将参数转为 Promise 实例,再进一步处理。另外,`Promise.all()`方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例。 `p`的状态由`p1`、`p2`、`p3`决定,分成两种情况。 (1)只有`p1`、`p2`、`p3`的状态都变成`fulfilled`,`p`的状态才会变成`fulfilled`,此时`p1`、`p2`、`p3`的返回值组成一个数组,传递给`p`的回调函数。 (2)只要`p1`、`p2`、`p3`之中有一个被`rejected`,`p`的状态就变成`rejected`,此时第一个被`reject`的实例的返回值,会传递给`p`的回调函数。 ```js // 生成一个Promise对象的数组 const promises = [2, 3, 5, 7, 11, 13].map(function (id) { return getJSON('/post/' + id + ".json"); }); Promise.all(promises).then(function (posts) { // ... }).catch(function(reason){ // ... }); ``` ### race() `Promise.race()`方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。 ```js const p = Promise.race([p1, p2, p3]); ``` 上面代码中,只要`p1`、`p2`、`p3`之中有一个实例率先改变状态,`p`的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给`p`的回调函数。 ### allSettled() 有时候,我们希望等到一组异步操作都结束了,不管每一个操作是成功还是失败,再进行下一步操作。但是,现有的 Promise 方法很难实现这个要求。 `Promise.all()`方法只适合所有异步操作都成功的情况,如果有一个操作失败,就无法满足要求。 `Promise.allSettled()`方法,用来确定一组异步操作是否都结束了(不管成功或失败)。 `Promise.allSettled()`方法接受一个数组作为参数,数组的每个成员都是一个 Promise 对象,并返回一个新的 Promise 对象。只有等到参数数组的所有 Promise 对象都发生状态变更(不管是`fulfilled`还是`rejected`),返回的 Promise 对象才会发生状态变更。 该方法返回的新的 Promise 实例,一旦发生状态变更,状态总是`fulfilled`,不会变成`rejected`。状态变成`fulfilled`后,它的回调函数会接收到一个数组作为参数,该数组的每个成员对应前面数组的每个 Promise 对象。 ```js const resolved = Promise.resolve(42); const rejected = Promise.reject(-1); const allSettledPromise = Promise.allSettled([resolved, rejected]); allSettledPromise.then(function (results) { console.log(results); }); // [ // { status: 'fulfilled', value: 42 }, // { status: 'rejected', reason: -1 } // ] ``` ### any() 该方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例返回。 只要参数实例有一个变成`fulfilled`状态,包装实例就会变成`fulfilled`状态;如果所有参数实例都变成`rejected`状态,包装实例就会变成`rejected`状态。 `Promise.any()`跟`Promise.race()`方法很像,只有一点不同,就是`Promise.any()`不会因为某个 Promise 变成`rejected`状态而结束,必须等到所有参数 Promise 变成`rejected`状态才会结束。 ### resolve() 有时需要将现有对象转为 Promise 对象,`Promise.resolve()`方法就起到这个作用。 `Promise.resolve()`等价于下面的写法。 ```js Promise.resolve('foo') // 等价于 new Promise(resolve => resolve('foo')) ``` `Promise.resolve()`方法的参数分成四种情况。 **(1)参数是一个 Promise 实例** 如果参数是 Promise 实例,那么`Promise.resolve`将不做任何修改、原封不动地返回这个实例。 **(2)参数是一个`thenable`对象** `thenable`对象指的是具有`then`方法的对象,比如下面这个对象。 ```js let thenable = { then: function(resolve, reject) { resolve(42); } }; ``` `Promise.resolve()`方法会将这个对象转为 Promise 对象,然后就立即执行`thenable`对象的`then()`方法。 ```js let thenable = { then: function(resolve, reject) { resolve(42); } }; let p1 = Promise.resolve(thenable); p1.then(function (value) { console.log(value); // 42 }); ``` 上面代码中,`thenable`对象的`then()`方法执行后,对象`p1`的状态就变为`resolved`,从而立即执行最后那个`then()`方法指定的回调函数,输出42。 **(3)参数不是具有`then()`方法的对象,或根本就不是对象** 如果参数是一个原始值,或者是一个不具有`then()`方法的对象,则`Promise.resolve()`方法返回一个新的 Promise 对象,状态为`resolved`。 ```js const p = Promise.resolve('Hello'); p.then(function (s) { console.log(s) }); // Hello ``` 上面代码生成一个新的 Promise 对象的实例`p`。由于字符串`Hello`不属于异步操作(判断方法是字符串对象不具有 then 方法),返回 Promise 实例的状态从一生成就是`resolved`,所以回调函数会立即执行。`Promise.resolve()`方法的参数,会同时传给回调函数。 **(4)不带有任何参数** `Promise.resolve()`方法允许调用时不带参数,直接返回一个`resolved`状态的 Promise 对象。 **需要注意的是,立即`resolve()`的 Promise 对象,是在本轮“事件循环”(event loop)的结束时执行,而不是在下一轮“事件循环”的开始时。** ```js setTimeout(function () { console.log('three'); }, 0); Promise.resolve().then(function () { console.log('two'); }); console.log('one'); // one // two // three ``` 上面代码中,`setTimeout(fn, 0)`在下一轮“事件循环”开始时执行,`Promise.resolve()`在本轮“事件循环”结束时执行,`console.log('one')`则是立即执行,因此最先输出。 ### reject() `Promise.reject(reason)`方法也会返回一个新的 Promise 实例,该实例的状态为`rejected`。 `Promise.reject()`方法的参数,会原封不动地作为`reject`的理由,变成后续方法的参数。 --- --- url: /js/512p5cp5/index.md --- # 模块化 ## 概述 ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入输出的变量。 CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。 ```js // CommonJS模块 let { stat, exists, readfile } = require('fs'); // 等同于 let _fs = require('fs'); let stat = _fs.stat; let exists = _fs.exists; let readfile = _fs.readfile; ``` 上面代码的实质是整体加载`fs`模块,即加载`fs`的所有方法,生成一个对象`_fs`,然后再从这个对象上面读取方法。 这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。 ES6 模块不是对象,而是通过`export`命令**显式**指定输出的代码,再通过`import`命令输入。 ```js // ES6模块 import { stat, exists, readFile } from 'fs'; ``` 上面代码的实质是从`fs`模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。 ## 严格模式 ES6 的模块自动采用严格模式,不管你有没有在模块头部加上`"use strict";`。 ::: details 严格模式主要有以下限制: * 变量必须声明后再使用 * 函数的参数不能有同名属性,否则报错 * 不能使用`with`语句 * 不能对只读属性赋值,否则报错 * 不能使用前缀 0 表示八进制数,否则报错 * 不能删除不可删除的属性,否则报错 * 不能删除变量`delete prop`,会报错,只能删除属性`delete global[prop]` * `eval`不会在它的外层作用域引入变量 * `eval`和`arguments`不能被重新赋值 * `arguments`不会自动反映函数参数的变化 * 不能使用`arguments.callee` * 不能使用`arguments.caller` * 禁止`this`指向全局对象 * 不能使用`fn.caller`和`fn.arguments`获取函数调用的堆栈 * 增加了保留字(比如`protected`、`static`和`interface`) ::: 其中,尤其需要注意`this`的限制。ES6 模块之中,顶层的`this`指向`undefined`,即不应该在顶层代码使用`this`。 ## export 命令 模块功能主要由两个命令构成:`export`和`import`。`export`命令用于规定模块的对外接口,`import`命令用于输入其他模块提供的功能。 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用`export`关键字输出该变量。下面是一个 JS 文件,里面使用`export`命令输出变量。 ```javascript // profile.js export var firstName = 'Michael'; export var lastName = 'Jackson'; export var year = 1958; ``` 上面代码是`profile.js`文件,保存了用户信息。ES6 将其视为一个模块,里面用`export`命令对外部输出了三个变量。 `export`的写法,除了像上面这样,还有另外一种。 ```javascript // profile.js var firstName = 'Michael'; var lastName = 'Jackson'; var year = 1958; export { firstName, lastName, year }; ``` 上面代码在`export`命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在`var`语句前)是等价的,**但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。** `export`命令除了输出变量,还可以输出函数或类(class)。 ```javascript export function multiply(x, y) { return x * y; }; ``` 通常情况下,`export`输出的变量就是本来的名字,但是可以使用`as`关键字重命名。 ```javascript function v1() { ... } function v2() { ... } export { v1 as streamV1, v2 as streamV2, v2 as streamLatestVersion }; ``` `export`命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。 ```javascript // 报错 export 1; // 报错 var m = 1; export m; ``` 上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量`m`,还是直接输出 1。`1`只是一个值,不是接口。正确的写法是下面这样。 ```javascript // 写法一 export var m = 1; // 写法二 var m = 1; export {m}; // 写法三 var n = 1; export {n as m}; ``` `function`和`class`的输出,也必须遵守这样的写法。 ```javascript // 报错 function f() {} export f; // 正确 export function f() {}; // 正确 function f() {} export {f}; ``` 目前,export 命令能够对外输出的就是三种接口:函数(Functions), 类(Classes),var、let、const 声明的变量(Variables)。 另外,`export`语句输出的接口,**与其对应的值是动态绑定关系**,即通过该接口,可以取到模块内部**实时的值**。 ```javascript export var foo = 'bar'; setTimeout(() => foo = 'baz', 500); ``` 上面代码输出变量`foo`,值为`bar`,500 毫秒之后变成`baz`。 这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。 最后,`export`命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错。 ## import 命令 使用`export`命令定义了模块的对外接口以后,其他 JS 文件就可以通过`import`命令加载这个模块。 ```javascript // main.js import { firstName, lastName, year } from './profile.js'; function setName(element) { element.textContent = firstName + ' ' + lastName; } ``` 上面代码的`import`命令,用于加载`profile.js`文件,并从中输入变量。`import`命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(`profile.js`)对外接口的名称相同。 如果想为输入的变量重新取一个名字,`import`命令要使用`as`关键字,将输入的变量重命名。 ```javascript import { lastName as surname } from './profile.js'; ``` `import`命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。 ```javascript import {a} from './xxx.js' a = {}; // Syntax Error : 'a' is read-only; ``` 上面代码中,脚本加载了变量`a`,对其重新赋值就会报错,因为`a`是一个只读的接口。但是,如果`a`是一个对象,改写`a`的属性是允许的。 ```javascript import {a} from './xxx.js' a.foo = 'hello'; // 合法操作 ``` 上面代码中,`a`的属性可以成功改写,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,**建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。** `import`后面的`from`指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。 ```javascript import { myMethod } from 'util'; ``` 注意,`import`命令具有提升效果,**会提升到整个模块的头部,首先执行**。 ```javascript foo(); import { foo } from 'my_module'; ``` 上面的代码不会报错,因为`import`的执行早于`foo`的调用。这种行为的本质是,`import`命令是编译阶段执行的,在代码运行之前。 由于`import`是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。 `import`语句会执行所加载的模块,因此可以有下面的写法。 ```js import 'lodash'; ``` 上面代码仅仅执行`lodash`模块,但是不输入任何值。 ## 模块的整体加载 除了指定加载某个输出值,还可以使用整体加载,即用星号(`*`)指定一个对象,所有输出值都加载在这个对象上面。 ```js // circle.js export function area(radius) { return Math.PI * radius * radius; } export function circumference(radius) { return 2 * Math.PI * radius; } ``` 加载这个模块。 ```js // main.js import { area, circumference } from './circle'; console.log('圆面积:' + area(4)); console.log('圆周长:' + circumference(14)); ``` 上面写法是逐一指定要加载的方法,整体加载的写法如下。 ```js import * as circle from './circle'; console.log('圆面积:' + circle.area(4)); console.log('圆周长:' + circle.circumference(14)); ``` 注意,模块整体加载所在的那个对象(上例是`circle`),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。 ```js import * as circle from './circle'; // 下面两行都是不允许的 circle.foo = 'hello'; circle.area = function () {}; ``` ## export default 命令 从前面的例子可以看出,使用`import`命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。 但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。 为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到`export default`命令,为模块指定默认输出。 ```js // export-default.js export default function () { console.log('foo'); } ``` 上面代码是一个模块文件`export-default.js`,它的默认输出是一个函数。 其他模块加载该模块时,`import`**命令可以为该匿名函数指定任意名字。** ```js // import-default.js import customName from './export-default'; customName(); // 'foo' ``` 上面代码的`import`命令,可以用任意名称指向`export-default.js`输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时`import`命令后面,**不使用大括号。** `export default`命令用在非匿名函数前,也是可以的。 ```js // export-default.js export default function foo() { console.log('foo'); } // 或者写成 function foo() { console.log('foo'); } export default foo; ``` 上面代码中,`foo`函数的函数名`foo`,在模块外部是无效的。**加载的时候,视同匿名函数加载。** **默认输出和正常输出比较:** ```js // 第一组 export default function crc32() { // 输出 // ... } import crc32 from 'crc32'; // 输入 // 第二组 export function crc32() { // 输出 // ... }; import {crc32} from 'crc32'; // 输入 ``` `export default`命令用于指定模块的默认输出。显然,**一个模块只能有一个默认输出**,因此`export default`命令只能使用一次。 所以,import命令后面才不用加大括号,因为只可能唯一对应`export default`命令。 本质上,`export default`就是输出一个叫做`default`的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。 ```js // modules.js function add(x, y) { return x * y; } export {add as default}; // 等同于 // export default add; // app.js import { default as foo } from 'modules'; // 等同于 // import foo from 'modules'; ``` 正是因为`export default`命令其实只是输出一个叫做`default`的变量,所以它后面不能跟变量声明语句。 ```js // 正确 export var a = 1; // 正确 var a = 1; export default a; // 错误 export default var a = 1; ``` 上面代码中,`export default a`的含义是将变量`a`的值赋给变量`default`。所以,最后一种写法会报错。 同样地,因为`export default`命令的本质是将后面的值,赋给`default`变量,所以可以直接将一个值写在`export default`之后。 ```js // 正确 export default 42; // 报错 export 42; ``` 如果想在一条`import`语句中,同时输入默认方法和其他接口,可以写成下面这样。 ```js import _, { each, forEach } from 'lodash'; ``` ```js export default function (obj) { // ··· } export function each(obj, iterator, context) { // ··· } export { each as forEach }; ``` `export default`也可以用来输出类。 ## export 与 import 的复合写法 如果在一个模块之中,**先输入后输出同一个模块**,`import`语句可以与`export`语句写在一起。 ```js export { foo, bar } from 'my_module'; // 可以简单理解为 import { foo, bar } from 'my_module'; export { foo, bar }; ``` 上面代码中,`export`和`import`语句可以结合在一起,写成一行。 但需要注意的是,写成一行以后,`foo`和`bar`实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,**导致当前模块不能直接使用**`foo`和`bar`。 模块的接口改名和整体输出,也可以采用这种写法。 ```js // 接口改名 export { foo as myFoo } from 'my_module'; // 整体输出 export * from 'my_module'; ``` 默认接口的写法 ```js export { default } from 'foo'; ``` 具名接口改为默认接口的写法 ```js export { es6 as default } from './someModule'; // 等同于 import { es6 } from './someModule'; export default es6; ``` 同样地,默认接口也可以改名为具名接口。 ```js export { default as es6 } from './someModule'; ``` ## 模块的继承 模块之间也可以继承。 假设有一个`circleplus`模块,继承了`circle`模块。 ```js // circleplus.js export * from 'circle'; export var e = 2.71828182846; export default function(x) { return Math.exp(x); } ``` 上面代码中的`export *`,表示再输出`circle`模块的所有属性和方法。**注意**,`export *`命令会忽略`circle`模块的`default`方法。然后,上面代码又输出了自定义的`e`变量和默认方法。 ```js export { area as circleArea } from 'circle'; ``` 上面代码表示,只输出`circle`模块的`area`方法,且将其改名为`circleArea`。 加载上面模块的写法如下。 ```js // main.js import * as math from 'circleplus'; import exp from 'circleplus'; console.log(exp(math.e)); ``` 上面代码中的`import exp`表示,将`circleplus`模块的默认方法加载为`exp`方法。 ## 跨模块常量 `const`声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。 ```js // constants.js 模块 export const A = 1; export const B = 3; export const C = 4; // test1.js 模块 import * as constants from './constants'; console.log(constants.A); // 1 console.log(constants.B); // 3 // test2.js 模块 import {A, B} from './constants'; console.log(A); // 1 console.log(B); // 3 ``` ## import() `import`命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行。所以,下面的代码会报错。 ```js // 报错 if (x === 2) { import MyModual from './myModual'; } ``` 上面代码中,引擎处理`import`语句是在编译时,这时不会去分析或执行`if`语句,所以`import`语句放在`if`代码块之中毫无意义,因此会报句法错误,而不是执行时错误。 也就是说,`import`和`export`命令只能在模块的顶层,不能在代码块之中(比如,在`if`代码块之中,或在函数之中)。 这样的设计,固然有利于编译器提高效率,**但也导致无法在运行时加载模块**。 在语法上,条件加载就不可能实现。如果`import`命令要取代 Node 的`require`方法,这就形成了一个障碍。因为`require`是运行时加载模块,`import`命令无法取代`require`的**动态加载**功能。 ```js const path = './' + fileName; const myModual = require(path); ``` 上面的语句就是动态加载,`require`到底加载哪一个模块,只有运行时才知道。`import`命令做不到这一点。 [ES2020提案](https://github.com/tc39/proposal-dynamic-import) 引入`import()`函数,支持动态加载模块。 ```js import(specifier) ``` 上面代码中,`import`函数的参数`specifier`,指定所要加载的模块的位置。`import`命令能够接受什么参数,`import()`函数就能接受什么参数,两者区别主要是后者为动态加载。 `import()`返回一个 Promise 对象 ```js const main = document.querySelector('main'); import(`./section-modules/${someVariable}.js`) .then(module => { module.loadPageInto(main); }) .catch(err => { main.textContent = err.message; }); ``` `import()`函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。 它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,`import()`函数与所加载的模块没有静态连接关系,这点也是与`import`语句不相同。 `import()`类似于 Node.js 的`require()`方法,区别主要是前者是异步加载,后者是同步加载。 由于`import()`返回 Promise 对象,所以需要使用`then()`方法指定处理函数。考虑到代码的清晰,更推荐使用`await`命令。 ```js async function renderWidget() { const container = document.getElementById('widget'); if (container !== null) { // 等同于 // import("./widget").then(widget => { // widget.render(container); // }); const widget = await import('./widget.js'); widget.render(container); } } renderWidget(); ``` ## 适用场合 下面是`import()`的一些适用场合。 (1)按需加载。 `import()`可以在需要的时候,再加载某个模块。 ```js button.addEventListener('click', event => { import('./dialogBox.js') .then(dialogBox => { dialogBox.open(); }) .catch(error => { /* Error handling */ }) }); ``` 上面代码中,`import()`方法放在`click`事件的监听函数之中,只有用户点击了按钮,才会加载这个模块。 (2)条件加载 `import()`可以放在`if`代码块,根据不同的情况,加载不同的模块。 ```js if (condition) { import('moduleA').then(...); } else { import('moduleB').then(...); } ``` (3)动态的模块路径 `import()`允许模块路径动态生成。 ```js import(f()) .then(...); ``` ## 注意点 `import()`加载模块成功以后,这个模块会作为一个对象,当作`then`方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。 ```js import('./myModule.js') .then(({export1, export2}) => { // ...· }); ``` 上面代码中,`export1`和`export2`都是`myModule.js`的输出接口,可以解构获得。 如果模块有`default`输出接口,可以用参数直接获得。 ```js import('./myModule.js') .then(myModule => { console.log(myModule.default); }); ``` 如果想同时加载多个模块,可以采用下面的写法。 ```js Promise.all([ import('./module1.js'), import('./module2.js'), import('./module3.js'), ]) .then(([module1, module2, module3]) => { ··· }); ``` ## import.met 开发者使用一个模块时,有时需要知道模板本身的一些信息(比如模块的路径)。[ES2020](https://github.com/tc39/proposal-import-meta) 为 import 命令添加了一个元属性`import.meta`,返回当前模块的元信息。 `import.meta`只能在模块内部使用,如果在模块外部使用会报错。 这个属性返回一个对象,该对象的各种属性就是当前运行的脚本的元信息。具体包含哪些属性,标准没有规定,由各个运行环境自行决定。一般来说,`import.meta`至少会有下面两个属性。 **(1)import.meta.url** `import.meta.url`返回当前模块的 URL 路径。举例来说,当前模块主文件的路径是`https://foo.com/main.js`,`import.meta.url`就返回这个路径。如果模块里面还有一个数据文件`data.txt`,那么就可以用下面的代码,获取这个数据文件的路径。 ```js new URL('data.txt', import.meta.url) ``` 注意,Node.js 环境中,`import.meta.url`返回的总是本地路径,即`file:URL`协议的字符串,比如`file:///home/user/foo.js`。 **(2)import.meta.scriptElement** `import.meta.scriptElement`是浏览器特有的元属性,返回加载模块的那个` // my-module.js 内部执行下面的代码 import.meta.scriptElement.dataset.foo // "abc" ``` **(3)其他** Deno 现在还支持`import.meta.filename`和`import.meta.dirname`属性,对应 CommonJS 模块系统的`__filename`和`__dirname`属性。 * `import.meta.filename`:当前模块文件的绝对路径。 * `import.meta.dirname`:当前模块文件的目录的绝对路径。 这两个属性都提供当前平台的正确的路径分隔符,比如 Linux 系统返回`/dev/my_module.ts`,Windows 系统返回`C:\dev\my_module.ts`。 本地模块可以使用这两个属性,远程模块也可以使用。 ## 浏览器加载 传统方法是通过` ``` 浏览器对于带有`type="module"`的` ``` 对于外部的模块脚本,有几点需要注意: * 代码是在**模块作用域之中运行**,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。 * 模块脚本自动采用严格模式,不管有没有声明`use strict`。 * 模块之中,可以使用`import`命令加载其他模块(`.js`后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用`export`命令输出对外接口。 * 模块之中,顶层的`this`关键字返回`undefined`,而不是指向`window`。也就是说,在模块顶层使用`this`关键字,是无意义的。 * 同一个模块如果加载多次,将只执行一次。 利用顶层的`this`等于`undefined`这个语法点,可以侦测当前代码是否在 ES6 模块之中。 ```js const isNotModuleScript = this !== undefined; ``` ## ES6 模块与 CommonJS 模块的差异 ES5模块和CommonJs模块有三个重大的差异: * CommonJs模块输出的是一个值的拷贝,ES6输出的则是值的引用。 * CommonJs是运行时加载,ES6是编译时输出接口 * CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。 CJS 加载的是一个对象,即`module.exports`属性,该对象只有在脚本运行完成后才会生成,而ES6不是对象,它的对外接口是一种静态定义,在代码静态解析阶段就会生成。 CJS输出的是一个拷贝的值的,即模块内部的变化不会影响这个值。 例子: ```js // lib.js var counter = 3; function incCounter() { counter++; } module.exports = { counter: counter, incCounter: incCounter, }; ``` ```js // main.js var mod = require('./lib'); console.log(mod.counter); // 3 mod.incCounter(); console.log(mod.counter); // 3 ``` 上面的代码可以看出,模块内部的变化不会影响输出的值,因为`mod.counter`是一个原始类型的值,会被缓存,解决方法就是写成一个函数,才能得到内部变动后的值。 ```js // lib.js var counter = 3; function incCounter() { counter++; } module.exports = { get counter() { return counter }, incCounter: incCounter, }; ``` 在ES6模块中,引擎在对代码进行静态解析的时候,遇到模块加载命令`import`,就会生成一个只读的引用。等到真正执行的时候,再根据这个只读引用,到被加载的模块中去取值。 ES6是动态绑定的,即模块内部的变化会影响输出的值。 ```js // lib.js export let counter = 3; export function incCounter() { counter++; } // main.js import { counter, incCounter } from './lib'; console.log(counter); // 3 incCounter(); console.log(counter); // 4 ``` ## Node.js 的模块加载方法 CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。语法上面,两者最明显的差异是,CommonJS 模块使用`require()`和`module.exports`,ES6 模块使用`import`和`export`。 在nodejs后来的版本中,开放了ESM模块的支持,使用`import`和`export`语法。 Node.js 要求ES6模块必须采用`.mjs`后缀,也就是说,只要脚本文件中使用了`import`或`export`语句,就必须使用`.mjs`后缀,并且`node.js`中的ESM模块默认是严格模式。 有一个简单的办法就是在 package.json 中添加一个`type`字段,值为`module`,这样就可以使用`.js`后缀。 ```json { "type": "module" } ``` 一旦设置了以后,该项目的 JS 脚本,就被解释成 ES6 模块。 如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。 总结为一句话:`.mjs`文件总是以 ES6 模块加载,`.cjs`文件总是以 CommonJS 模块加载,`.js`文件的加载取决于`package.json`里面`type`字段的设置。 注意:两种模块的加载命令不能混用,CommonJS 模块只能使用`require()`和`module.exports`,ES6 模块只能使用`import`和`export`。 ## ackage.json 的 main 字段 `package.json`文件有两个字段可以指定模块的入口文件:`main`和`exports`。比较简单的模块,可以只使用`main`字段,指定模块加载的入口文件。 ```js // ./node_modules/es-module-package/package.json { "type": "module", "main": "./src/index.js" } ``` 上面代码指定项目的入口脚本为`./src/index.js`,它的格式为 ES6 模块。如果没有`type`字段,`index.js`就会被解释为 CommonJS 模块。 然后,`import`命令就可以加载这个模块。 ```js // ./my-app.mjs import { something } from 'es-module-package'; // 实际加载的是 ./node_modules/es-module-package/src/index.js ``` 上面代码中,运行该脚本以后,Node.js 就会到`./node_modules`目录下面,寻找`es-module-package`模块,然后根据该模块`package.json`的`main`字段去执行入口文件。 ## package.json 的 exports 字段 `exports`字段的优先级高于`main`字段。它有多种用法。 **(1)子目录别名** `package.json`文件的`exports`字段可以指定脚本或子目录的别名。 ```js // ./node_modules/es-module-package/package.json { "exports": { "./submodule": "./src/submodule.js" } } ``` 上面的代码指定`src/submodule.js`别名为`submodule`,然后就可以从别名加载这个文件。 ```js import submodule from 'es-module-package/submodule'; // 加载 ./node_modules/es-module-package/src/submodule.js ``` 例子: ```js // ./node_modules/es-module-package/package.json { "exports": { "./features/": "./src/features/" } } import feature from 'es-module-package/features/x.js'; // 加载 ./node_modules/es-module-package/src/features/x.js ``` 如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。 **(2)main 的别名** `exports`字段的别名如果是`.`,就代表模块的主入口,优先级高于`main`字段,并且可以直接简写成`exports`字段的值。 ```js { "exports": { ".": "./main.js" } } // 等同于 { "exports": "./main.js" } ``` 由于`exports`字段只有支持 ES6 的 Node.js 才认识,所以可以搭配`main`字段,来兼容旧版本的 Node.js。 ```js { "main": "./main-legacy.cjs", "exports": { ".": "./main-modern.cjs" } } ``` 上面代码中,老版本的 Node.js (不支持 ES6 模块)的入口文件是`main-legacy.cjs`,新版本的 Node.js 的入口文件是`main-modern.cjs`。 **(3)条件加载** 利用`.`这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。 ```js { "type": "module", "exports": { ".": { "require": "./main.cjs", "default": "./main.js" } } } ``` 上面代码中,别名`.`的`require`条件指定`require()`命令的入口文件(即 CommonJS 的入口),`default`条件指定其他情况的入口(即 ES6 的入口)。 上面的写法可以简写如下。 ```js { "exports": { "require": "./main.cjs", "default": "./main.js" } } ``` 注意,如果同时还有其他别名,就不能采用简写,否则会报错。 ```js { // 报错 "exports": { "./feature": "./lib/feature.js", "require": "./main.cjs", "default": "./main.js" } } ``` ## CommonJS 模块加载 ES6 模块 CommonJS 的`require()`命令不能加载 ES6 模块,会报错,只能使用`import()`这个方法加载。 ## ES6 模块加载 CommonJS 模块 ES6 模块的`import`命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。 ```js // 正确 import packageMain from 'commonjs-package'; // 报错 import { method } from 'commonjs-package'; ``` 这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是`module.exports`,是一个对象,无法被静态分析,所以只能整体加载。 加载单一的输出项,可以写成下面这样。 ```js import packageMain from 'commonjs-package'; const { method } = packageMain; ``` 还有一种变通的加载方法,就是使用 Node.js 内置的`module.createRequire()`方法。 ```js // cjs.cjs module.exports = 'cjs'; // esm.mjs import { createRequire } from 'module'; const require = createRequire(import.meta.url); const cjs = require('./cjs.cjs'); cjs === 'cjs'; // true ``` 上面代码中,ES6 模块通过`module.createRequire()`方法可以加载 CommonJS 模块。但是,这种写法等于将 ES6 和 CommonJS 混在一起了,所以不建议使用。 --- --- url: /js/8mdfw70d/index.md --- # Set 和 Map ## Set ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。 `Set`本身是一个构造函数,用来生成 Set 数据结构。 ```js const s = new Set(); [2, 3, 5, 4, 5, 2, 2].forEach(x => s.add(x)); for (let i of s) { console.log(i); } // 2 3 5 4 ``` `Set()`函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。 ```js const set = new Set(document.querySelectorAll('div')); set.size // 56 ``` **去除数组重复成员的方法**: ```js // 去除数组的重复成员 [...new Set(array)] ``` **去除字符串重复成员的方法:** ```js [...new Set('ababbc')].join('') // "abc" ``` 向 Set 加入值的时候,不会发生类型转换,所以`5`和`"5"`是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符(`===`),主要的区别是向 Set 加入值时认为`NaN`等于自身,而精确相等运算符认为`NaN`不等于自身,另外,两个对象总是不相等的。 `Array.from()`方法可以将 Set 结构转为数组。 **去除数组重复成员的方法**: ```js function dedupe(array) { return Array.from(new Set(array)); } dedupe([1, 1, 2, 3]) // [1, 2, 3] ``` **实例属性&方法** Set 结构的实例有以下属性。 * `Set.prototype.constructor`:构造函数,默认就是`Set`函数。 * `Set.prototype.size`:返回`Set`实例的成员总数。 Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。下面先介绍四个操作方法。 * `Set.prototype.add(value)`:添加某个值,返回 Set 结构本身。 * `Set.prototype.delete(value)`:删除某个值,返回一个布尔值,表示删除是否成功。 * `Set.prototype.has(value)`:返回一个布尔值,表示该值是否为`Set`的成员。 * `Set.prototype.clear()`:清除所有成员,没有返回值。 Set 结构的实例有四个遍历方法,可以用于遍历成员。 * `Set.prototype.keys()`:返回键名的遍历器 * `Set.prototype.values()`:返回键值的遍历器 * `Set.prototype.entries()`:返回键值对的遍历器 * `Set.prototype.forEach()`:使用回调函数遍历每个成员 需要特别指出的是,`Set`的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。 ```js for (let item of set.values()) { console.log(item); } // red // green // blue ``` Set 结构的实例默认可遍历,它的默认遍历器生成函数就是它的`values`方法。 ```js Set.prototype[Symbol.iterator] === Set.prototype.values // true ``` 这意味着,可以省略`values`方法,直接用`for...of`循环遍历 Set。 ```js let set = new Set(['red', 'green', 'blue']); for (let x of set) { console.log(x); } // red // green // blue ``` ## WeakSet WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。 首先,WeakSet 的成员只能是对象和 Symbol 值,而不能是其他类型的值。 其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。 这是因为垃圾回收机制根据对象的可达性(reachability)来判断回收,如果对象还能被访问到,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。 WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。 由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。 WeakSet 是一个构造函数,可以使用`new`命令,创建 WeakSet 数据结构。 ```js const ws = new WeakSet(); ``` 作为构造函数,WeakSet 可以接受一个数组或类似数组的对象作为参数。(实际上,任何具有 Iterable 接口的对象,都可以作为 WeakSet 的参数。)**该数组的所有成员,都会自动成为 WeakSet 实例对象的成员。** WeakSet 结构有以下三个方法。 * **WeakSet.prototype.add(value)**:向 WeakSet 实例添加一个新成员,返回 WeakSet 结构本身。 * **WeakSet.prototype.delete(value)**:清除 WeakSet 实例的指定成员,清除成功返回`true`,如果在 WeakSet 中找不到该成员或该成员不是对象,返回`false`。 * **WeakSet.prototype.has(value)**:返回一个布尔值,表示某个值是否在 WeakSet 实例之中。 WeakSet 没有`size`属性,没有办法遍历它的成员。 WeakSet 不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。 WeakSet 的一个用处,是储存 DOM 节点,而不用担心这些节点从文档移除时,会引发内存泄漏。 ```js const foos = new WeakSet() class Foo { constructor() { foos.add(this) } method () { if (!foos.has(this)) { throw new TypeError('Foo.prototype.method 只能在Foo的实例上调用!'); } } } ``` 上面代码保证了`Foo`的实例方法,只能在`Foo`的实例上调用。这里使用 WeakSet 的好处是,`foos`对实例的引用,不会被计入内存回收机制,所以删除实例的时候,不用考虑`foos`,也不会出现内存泄漏。 ## Map JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。 ```js const data = {}; const element = document.getElementById('myDiv'); data[element] = 'metadata'; data['[object HTMLDivElement]'] // "metadata" ``` 上面代码原意是将一个 DOM 节点作为对象`data`的键,但是由于对象只接受字符串作为键名,所以`element`被自动转为字符串`[object HTMLDivElement]`。 为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。 也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适 ```javascript const m = new Map(); const o = {p: 'Hello World'}; m.set(o, 'content') m.get(o) // "content" m.has(o) // true m.delete(o) // true m.has(o) // false ``` 作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。 ```js const map = new Map([ ['name', '张三'], ['title', 'Author'] ]); map.size // 2 map.has('name') // true map.get('name') // "张三" map.has('title') // true map.get('title') // "Author" ``` `Map`构造函数接受数组作为参数,实际上执行的是下面的算法。 ```js const items = [ ['name', '张三'], ['title', 'Author'] ]; const map = new Map(); items.forEach( ([key, value]) => map.set(key, value) ); ``` 事实上,不仅仅是数组,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构(详见《Iterator》一章)都可以当作`Map`构造函数的参数。这就是说,`Set`和`Map`都可以用来生成新的 Map。 ```js const set = new Set([ ['foo', 1], ['bar', 2] ]); const m1 = new Map(set); m1.get('foo') // 1 const m2 = new Map([['baz', 3]]); const m3 = new Map(m2); m3.get('baz') // 3 ``` **注意,只有对同一个对象的引用,Map 结构才将其视为同一个键。这一点要非常小心。** ```js const map = new Map(); map.set(['a'], 555); map.get(['a']) // undefined ``` 上面代码的`set`和`get`方法,表面是针对同一个键,**但实际上这是两个不同的数组实例**,\*\*内存地址是不一样的,\*\*因此`get`方法无法读取该键,返回`undefined`。 同理,同样的值的两个实例,在 Map 结构中被视为两个键。 ```js const map = new Map(); const k1 = ['a']; const k2 = ['a']; map .set(k1, 111) .set(k2, 222); map.get(k1) // 111 map.get(k2) // 222 ``` **由上可知,Map 的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键。** 这就解决了同名属性碰撞(clash)的问题,我们扩展别人的库的时候,如果使用对象作为键名,就不用担心自己的属性与原作者的属性同名。 如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,比如`0`和`-0`就是一个键,布尔值`true`和字符串`true`则是两个不同的键。另外,`undefined`和`null`也是两个不同的键。虽然`NaN`不严格相等于自身,但 Map 将其视为同一个键。 **实例属性&方法**: **(1)size 属性** `size`属性返回 Map 结构的成员总数。 **(2)Map.prototype.set(key, value)** `set`方法设置键名`key`对应的键值为`value`,然后返回整个 Map 结构。如果`key`已经有值,则键值会被更新,否则就新生成该键。 `set`方法返回的是当前的`Map`对象,因此可以采用链式写法。 ```js let map = new Map() .set(1, 'a') .set(2, 'b') .set(3, 'c'); ``` **(3)Map.prototype.get(key)** `get`方法读取`key`对应的键值,如果找不到`key`,返回`undefined`。 ```js const m = new Map(); const hello = function() {console.log('hello');}; m.set(hello, 'Hello ES6!') // 键是函数 m.get(hello) // Hello ES6! ``` **(4)Map.prototype.has(key)** `has`方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。 **(5)Map.prototype.delete(key)** `delete()`方法删除某个键,返回`true`。如果删除失败,返回`false`。 **(6)Map.prototype.clear()** `clear()`方法清除所有成员,没有返回值。 **遍历方法** Map 结构原生提供三个遍历器生成函数和一个遍历方法。 * `Map.prototype.keys()`:返回键名的遍历器。 * `Map.prototype.values()`:返回键值的遍历器。 * `Map.prototype.entries()`:返回所有成员的遍历器。 * `Map.prototype.forEach()`:遍历 Map 的所有成员。 ```js for (let value of map.values()) { console.log(value); } // "no" // "yes" for (let item of map.entries()) { console.log(item[0], item[1]); } // "F" "no" // "T" "yes" // 或者 for (let [key, value] of map.entries()) { console.log(key, value); } // "F" "no" // "T" "yes" // 等同于使用map.entries() for (let [key, value] of map) { console.log(key, value); } // "F" "no" // "T" "yes" ``` ```js map[Symbol.iterator] === map.entries // true ``` Map 结构转为数组结构,比较快速的方法是使用扩展运算符(`...`)。 ```js [...map.keys()] // [1, 2, 3] [...map.values()] // ['one', 'two', 'three'] ``` 结合数组的`map`方法、`filter`方法,可以实现 Map 的遍历和过滤(Map 本身没有`map`和`filter`方法)。 ```js const map0 = new Map() .set(1, 'a') .set(2, 'b') .set(3, 'c'); const map1 = new Map( [...map0].filter(([k, v]) => k < 3) ); // 产生 Map 结构 {1 => 'a', 2 => 'b'} const map2 = new Map( [...map0].map(([k, v]) => [k * 2, '_' + v]) ); // 产生 Map 结构 {2 => '_a', 4 => '_b', 6 => '_c'} ``` Map 还有一个`forEach`方法,与数组的`forEach`方法类似,也可以实现遍历。 `forEach`方法还可以接受第二个参数,用来绑定`this`。 **与其他数据结构的互相转换** : **(1)Map 转为数组** 前面已经提过,Map 转为数组最方便的方法,就是使用扩展运算符(`...`)。 **(2)数组 转为 Map** 将数组传入 Map 构造函数,就可以转为 Map。 **(3)Map 转为对象** 如果所有 Map 的键都是字符串,它可以无损地转为对象。 ```js function strMapToObj(strMap) { let obj = Object.create(null); for (let [k,v] of strMap) { obj[k] = v; } return obj; } const myMap = new Map() .set('yes', true) .set('no', false); strMapToObj(myMap) // { yes: true, no: false } ``` 如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。 **(4)对象转为 Map** 对象转为 Map 可以通过`Object.entries()`。 ```js let obj = {"a":1, "b":2}; let map = new Map(Object.entries(obj)); ``` **(5)Map 转为 JSON** Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择转为对象 JSON。 ```js function strMapToJson(strMap) { return JSON.stringify(strMapToObj(strMap)); } let myMap = new Map().set('yes', true).set('no', false); strMapToJson(myMap) // '{"yes":true,"no":false}' ``` 另一种情况是,Map 的键名有非字符串,这时可以选择转为**数组 JSON。** ```js function mapToArrayJson(map) { return JSON.stringify([...map]); } let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']); mapToArrayJson(myMap) // '[[true,7],[{"foo":3},["abc"]]]' ``` **(6)JSON 转为 Map** JSON 转为 Map,正常情况下,所有键名都是字符串。 ```js function jsonToStrMap(jsonStr) { return objToStrMap(JSON.parse(jsonStr)); } jsonToStrMap('{"yes": true, "no": false}') // Map {'yes' => true, 'no' => false} ``` 但是,有一种特殊情况,整个 JSON 就是一个数组,且每个数组成员本身,又是一个有两个成员的数组。这时,它可以一一对应地转为 Map。这往往是 Map 转为数组 JSON 的逆操作。 ```js function jsonToMap(jsonStr) { return new Map(JSON.parse(jsonStr)); } jsonToMap('[[true,7],[{"foo":3},["abc"]]]') // Map {true => 7, Object {foo: 3} => ['abc']} ``` ## WeakMap `WeakMap`结构与`Map`结构类似,也是用于生成键值对的集合。 `WeakMap`与`Map`的区别有两点。 首先,`WeakMap`只接受对象(`null`除外)和 [Symbol 值](https://github.com/tc39/proposal-symbols-as-weakmap-keys)作为键名,不接受其他类型的值作为键名。 其次,`WeakMap`的键名所指向的对象,不计入垃圾回收机制。 `WeakMap`的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。 ```js const e1 = document.getElementById('foo'); const e2 = document.getElementById('bar'); const arr = [ [e1, 'foo 元素'], [e2, 'bar 元素'], ]; ``` 上面代码中,`e1`和`e2`是两个对象,我们通过`arr`数组对这两个对象添加一些文字说明。这就形成了`arr`对`e1`和`e2`的引用。 一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放`e1`和`e2`占用的内存。 ```js // 不需要 e1 和 e2 的时候 // 必须手动删除引用 arr [0] = null; arr [1] = null; ``` 上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。 WeakMap 就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。 因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。 基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用`WeakMap`结构。当该 DOM 元素被清除,其所对应的`WeakMap`记录就会自动被移除。 ```js const wm = new WeakMap(); const element = document.getElementById('example'); wm.set(element, 'some information'); wm.get(element) // "some information" ``` 上面代码中,先新建一个 WeakMap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对`element`的引用就是弱引用,不会被计入垃圾回收机制。 也就是说,上面的 DOM 节点对象除了 WeakMap 的弱引用外,其他位置对该对象的引用一旦消除,该对象占用的内存就会被垃圾回收机制释放。WeakMap 保存的这个键值对,也会自动消失。 总之,`WeakMap`的专用场合就是,它的键所对应的对象,可能会在将来消失。`WeakMap`结构有助于防止内存泄漏。 注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。 ```js const wm = new WeakMap(); let key = {}; let obj = {foo: 1}; wm.set(key, obj); obj = null; wm.get(key) // Object {foo: 1} ``` 上面代码中,键值`obj`是正常引用。所以,即使在 WeakMap 外部消除了`obj`的引用,WeakMap 内部的引用依然存在。 **语法**: WeakMap 与 Map 在 API 上的区别主要是两个,一是没有遍历操作(即没有`keys()`、`values()`和`entries()`方法),也没有`size`属性。 因为没有办法列出所有键名,某个键名是否存在完全不可预测,跟垃圾回收机制是否运行相关。 这一刻可以取到键名,下一刻垃圾回收机制突然运行了,这个键名就没了,为了防止出现不确定性,就统一规定不能取到键名。二是无法清空,即不支持`clear`方法。 因此,`WeakMap`只有四个方法可用:`get()`、`set()`、`has()`、`delete()`。 **示例**: WeakMap 的例子很难演示,因为无法观察它里面的引用会自动消失。此时,其他引用都解除了,已经没有引用指向 WeakMap 的键名了,导致无法证实那个键名是不是存在。 **用途**: ```js let myWeakmap = new WeakMap(); myWeakmap.set( document.getElementById('logo'), {timesClicked: 0}) ; document.getElementById('logo').addEventListener('click', function() { let logoData = myWeakmap.get(document.getElementById('logo')); logoData.timesClicked++; }, false); ``` 上面代码中,`document.getElementById('logo')`是一个 DOM 节点,每当发生`click`事件,就更新一下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是这个节点对象。**一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。** WeakMap 的另一个用处是部署私有属性。 ```js const _counter = new WeakMap(); const _action = new WeakMap(); class Countdown { constructor(counter, action) { _counter.set(this, counter); _action.set(this, action); } dec() { let counter = _counter.get(this); if (counter < 1) return; counter--; _counter.set(this, counter); if (counter === 0) { _action.get(this)(); } } } const c = new Countdown(2, () => console.log('DONE')); c.dec() c.dec() // DONE ``` 上面代码中,`Countdown`类的两个内部属性`_counter`和`_action`,是实例的弱引用,所以如果删除实例,它们也就随之消失,不会造成内存泄漏。 ## WeakRef WeakSet 和 WeakMap 是基于弱引用的数据结构,[ES2021](https://github.com/tc39/proposal-weakrefs) 更进一步,提供了 WeakRef 对象,用于直接创建对象的弱引用。 ```js let target = {}; let wr = new WeakRef(target); ``` 上面示例中,`target`是原始对象,构造函数`WeakRef()`创建了一个基于`target`的新对象`wr`。这里,`wr`就是一个 WeakRef 的实例,属于对`target`的弱引用,垃圾回收机制不会计入这个引用,也就是说,`wr`的引用不会妨碍原始对象`target`被垃圾回收机制清除。 WeakRef 实例对象有一个`deref()`方法,如果原始对象存在,该方法返回原始对象;如果原始对象已经被垃圾回收机制清除,该方法返回`undefined`。 ```js let target = {}; let wr = new WeakRef(target); let obj = wr.deref(); if (obj) { // target 未被垃圾回收机制清除 // ... } ``` 弱引用对象的一大用处,就是作为缓存,未被清除时可以从缓存取值,一旦清除缓存就自动失效。 ```js function makeWeakCached(f) { const cache = new Map(); return key => { const ref = cache.get(key); if (ref) { const cached = ref.deref(); if (cached !== undefined) return cached; } const fresh = f(key); cache.set(key, new WeakRef(fresh)); return fresh; }; } const getImageCached = makeWeakCached(getImage); ``` 上面示例中,`makeWeakCached()`用于建立一个缓存,缓存里面保存对原始文件的弱引用。 注意,标准规定,一旦使用`WeakRef()`创建了原始对象的弱引用,那么在本轮事件循环(event loop),原始对象肯定不会被清除,只会在后面的事件循环才会被清除。 ## FinalizationRegistry [ES2021](https://github.com/tc39/proposal-weakrefs#finalizers) 引入了清理器注册表功能 FinalizationRegistry,用来指定目标对象被垃圾回收机制清除以后,所要执行的回调函数。 首先,新建一个注册表实例。 ```js const registry = new FinalizationRegistry(heldValue => { // .... }); ``` 上面代码中,`FinalizationRegistry()`是系统提供的构造函数,返回一个清理器注册表实例,里面登记了所要执行的回调函数。回调函数作为`FinalizationRegistry()`的参数传入,它本身有一个参数`heldValue`。 然后,注册表实例的`register()`方法,用来注册所要观察的目标对象。 ```js registry.register(theObject, "some value"); ``` 上面示例中,`theObject`就是所要观察的目标对象,一旦该对象被垃圾回收机制清除,注册表就会在清除完成后,调用早前注册的回调函数,并将`some value`作为参数(前面的`heldValue`)传入回调函数。 注意,注册表不对目标对象`theObject`构成强引用,属于弱引用。因为强引用的话,原始对象就不会被垃圾回收机制清除,这就失去使用注册表的意义了。 回调函数的参数`heldValue`可以是任意类型的值,字符串、数值、布尔值、对象,甚至可以是`undefined`。 最后,如果以后还想取消已经注册的回调函数,则要向`register()`传入第三个参数,作为标记值。这个标记值必须是对象,一般都用原始对象。接着,再使用注册表实例对象的`unregister()`方法取消注册。 ```js registry.register(theObject, "some value", theObject); // ...其他操作... registry.unregister(theObject); ``` 上面代码中,`register()`方法的第三个参数就是标记值`theObject`。取消回调函数时,要使用`unregister()`方法,并将标记值作为该方法的参数。这里`register()`方法对第三个参数的引用,也属于弱引用。如果没有这个参数,则回调函数无法取消。 由于回调函数被调用以后,就不再存在于注册表之中了,所以执行`unregister()`应该是在回调函数还没被调用之前。 下面使用`FinalizationRegistry`,对前一节的缓存函数进行增强。 ```js function makeWeakCached(f) { const cache = new Map(); const cleanup = new FinalizationRegistry(key => { const ref = cache.get(key); if (ref && !ref.deref()) cache.delete(key); }); return key => { const ref = cache.get(key); if (ref) { const cached = ref.deref(); if (cached !== undefined) return cached; } const fresh = f(key); cache.set(key, new WeakRef(fresh)); cleanup.register(fresh, key); return fresh; }; } const getImageCached = makeWeakCached(getImage); ``` 上面示例与前一节的例子相比,就是增加一个清理器注册表,一旦缓存的原始对象被垃圾回收机制清除,会自动执行一个回调函数。该回调函数会清除缓存里面已经失效的键。 下面是另一个例子。 ```js class Thingy { #file; #cleanup = file => { console.error( `The \`release\` method was never called for the \`Thingy\` for the file "${file.name}"` ); }; #registry = new FinalizationRegistry(this.#cleanup); constructor(filename) { this.#file = File.open(filename); this.#registry.register(this, this.#file, this.#file); } release() { if (this.#file) { this.#registry.unregister(this.#file); File.close(this.#file); this.#file = null; } } } ``` 上面示例中,如果由于某种原因,`Thingy`类的实例对象没有调用`release()`方法,就被垃圾回收机制清除了,那么清理器就会调用回调函数`#cleanup()`,输出一条错误信息。 由于无法知道清理器何时会执行,所以最好避免使用它。另外,如果浏览器窗口关闭或者进程意外退出,清理器则不会运行。 --- --- url: /js/9fqwqn0w/index.md --- # 闭包 JavaScript 的\*\*闭包(Closure)\*\*是一个既强大又容易让人迷惑的概念。掌握闭包不仅能帮助你写出更灵活、高效的代码,还能让你更好地理解现代框架(比如 React)背后的工作机制。今天,我们就从理论和实践两方面,结合 React 的实际场景,一步步带你快速精通闭包。 ## 什么是闭包? 简单来说,**闭包是函数和声明该函数的词法环境的组合**。 * **词法环境(Lexical Environment)**:指的是函数创建时所处的作用域,包含函数外部的变量和参数。 * **闭包的意义**:函数可以访问定义时的作用域中的变量,即使函数在定义作用域外执行。 举个最简单的例子: ```js function outer() { let count = 0; function inner() { count++; console.log(count); } return inner; } const fn = outer(); fn(); // 输出 1 fn(); // 输出 2 fn(); // 输出 3 ``` 这里,`inner` 函数形成了一个闭包,保留了对 `outer` 函数中 `count` 变量的引用。即使 `outer` 已经执行完毕,`inner` 仍然可以访问和修改 `count`。 因为`count`有引用所以它不会被 GC 回收,即使`outer`函数执行完毕,`count`也不会被回收,一直存在内存中。 GC 的机制就是:当一个对象没有被引用时,它会在适当的时候会被回收。 ## 闭包的实际意义和用途 闭包能带来强大的功能,例如: * **数据私有化** 变量不会暴露在全局,避免冲突和污染。 * **函数工厂** 动态创建带有特定环境的函数。 * **实现模块化** 利用闭包封装状态和方法。 * **异步编程中的状态保持** 保证回调函数能访问定义时的环境变量。 ## 闭包在 React 中的重要性 React 是基于函数式组件和 Hooks 构建的,闭包是其核心机制之一。 ### React 状态与闭包 React 组件中的状态(`useState`)和事件处理函数依赖闭包捕获的状态快照。举个例子: ```jsx function Counter() { const [count, setCount] = React.useState(0); function handleClick() { setCount(count + 1); console.log("事件处理函数里的 count:", count); } console.log("组件渲染时 count:", count); return ; } ``` 每次渲染,`handleClick` 函数都会捕获当前的 `count` 变量(闭包),即“当前渲染快照”。 事件处理函数内访问到的 `count` 是旧状态,直到下一次渲染才更新。 这就是闭包和 React 渲染机制结合的经典表现。 ### 异步操作中的闭包陷阱 ```jsx function AsyncCounter() { const [count, setCount] = React.useState(0); function handleClick() { setTimeout(() => { console.log("异步回调里的 count:", count); }, 1000); setCount(count + 1); } console.log("组件渲染时 count:", count); return ; } ``` 异步回调函数中的 `count` 变量是定义时的旧值,和当前状态可能不同。 这是闭包导致的状态“快照”问题,需要用函数式更新或者 `useEffect` 解决。 ## 闭包的内容泄露 **闭包会让函数持有对外部变量的引用**,即使外部函数已经执行完毕,这些变量也不会被垃圾回收(GC)——因为闭包“拦截”了它们。 如果闭包持有大量数据或者长时间不释放,可能造成内存泄露,尤其在 SPA(单页应用)中长时间运行时更明显。 把闭包里引用的变量设为 `null`,**可以帮助垃圾回收器释放内存**,因为引用断开了。 ```js function example() { let bigData = new Array(1000000).fill('x'); return function() { console.log(bigData.length); // 用完后可以清空,帮助释放内存 bigData = null; } } ``` 但你必须确保之后闭包不会再访问这个变量,否则会报错。 在 React 组件卸载时如何避免闭包泄露? **清理副作用**:利用 `useEffect` 的返回函数清除定时器、取消订阅、取消网络请求。 ```jsx useEffect(() => { const timer = setTimeout(() => { // 一些逻辑 }, 1000); return () => clearTimeout(timer); // 组件卸载时清理 }, []); ``` * **避免在闭包里持有大量数据或 DOM 引用**,改用 refs 或状态。 * **函数式更新**,减少闭包捕获的外部变量数量。 创建闭包时,最后在闭包里添加一个方法,用于手动释放闭包里引用的变量,这样就可以避免闭包的内容泄露。 ```js function outer() { let largeArray = new Array(1000000).fill('data'); function inner() { console.log(largeArray.length); } // 使用完断开引用 inner.clear = function() { largeArray = null; } return inner; } const fn = outer(); fn.clear(); // 手动释放 largeArray 引用 ``` ## 实战 ```jsx // 经典面试题(问:输出什么) function testClosure() { for (var i = 0; i < 3; i++) { setTimeout(() => { console.log(i); }, 100); } } testClosure(); // 打印三次 3 // 解决方案:用 let 创建块级作用域变量 function testClosureFixed() { for (let i = 0; i < 3; i++) { setTimeout(() => { console.log(i); }, 100); } } testClosureFixed(); // 依次打印 0,1,2 ``` ## 总结 * 闭包是 JavaScript 中函数访问其外部作用域变量的一种机制。 * 理解闭包可以帮你写出更安全、优雅的代码。 * 在 React 中,闭包和状态管理、事件处理息息相关,理解它能帮助你避免状态“过期”等常见问题。 * 多练习结合实战是掌握闭包的关键。 --- --- url: /js/9nrldj3c/index.md --- # 动态深层代理 普通 Proxy 对嵌套对象的拦截 “天生失效”,而 “懒代理(动态深层代理)” 正是解决这个问题的关键方案,本质就是 “访问到嵌套子对象时,才为其创建代理”。 ### 普通 Proxy 拦截不到嵌套对象 Proxy 的工作机制是 “代理目标对象本身”,而不是递归代理其属性值。对于嵌套对象,它的属性值是 “另一个对象的引用”,普通 Proxy 只会拦截 “获取这个引用” 的操作(比如 `obj.nested`),但不会拦截 “通过这个引用访问子属性” 的操作(比如 `obj.nested.key`)。 ```js // const target = { // name: "顶层属性", // nested: { // 嵌套对象 // age: 18 // } // }; // 本质上等价于: const nested = { age: 18 }; const target = { name: "顶层属性", nested // 属性简写写法,等价于 nested: nested }; // 普通 Proxy:只代理 target 本身 const proxy = new Proxy(target, { get(target, key) { console.log(`拦截到:访问顶层属性 ${key}`); return target[key]; // 直接返回嵌套对象的引用,没有对其代理 }, set(target, key, value) { console.log(`拦截到:修改顶层属性 ${key} = ${value}`); target[key] = value; return true; } }); // 测试: proxy.name; // 触发拦截 → 正确 proxy.nested; // 触发拦截(获取 nested 引用)→ 正确 proxy.nested.age; // 不触发拦截!→ 因为访问的是“nested 引用指向的对象”的 age 属性 proxy.nested.age = 20; // 不触发拦截!→ 同理 ``` 嵌套对象的 `age` 属性读写完全没被拦截,这就是普通 Proxy 的 “局限性”。 ### 懒代理(动态深层代理) “懒代理” 的核心思想是**延迟代理**:不为嵌套对象提前创建代理(避免浪费性能),而是在 “首次访问该嵌套对象” 时,动态为其创建 Proxy 并替换原引用,后续对子对象的所有操作,就都能被新 Proxy 拦截了。 **关键步骤拆解**: 1. 代理顶层对象时,在 `get` 拦截器中判断:如果访问的属性值是 “对象 / 数组”(且未被代理过),就为其创建子代理; 2. 将子代理对象替换原属性值(比如 `target.nested = 子代理`),确保后续再访问 `obj.nested` 时,拿到的是代理后的对象; 3. 子代理的拦截逻辑可以和顶层代理一致(也可自定义),形成 “递归代理” 效果,但触发时机是 “访问时”(懒加载)。 ### 实现一个简单的懒代理 改造普通 Proxy,实现懒代理: ```js // 工具函数:判断是否为需要代理的对象(排除 null、基础类型) function isObject(value) { return typeof value === "object" && value !== null; } // 懒代理工厂函数:递归创建动态深层代理 function createLazyDeepProxy(target, handler = {}) { // 存储“已代理的子对象”(避免重复代理同一对象) const proxyCache = new WeakMap(); // 核心:创建代理的函数(可递归调用) function createProxy(target) { // 如果已经代理过,直接返回缓存的代理对象 if (proxyCache.has(target)) { return proxyCache.get(target); } // 自定义默认拦截器(可被外部 handler 覆盖) const defaultHandler = { get(target, key) { const value = Reflect.get(target, key); // 用 Reflect 确保行为和原生一致 console.log(`拦截到:访问 ${key}(${isObject(value) ? "嵌套对象" : "普通属性"})`); // 关键:如果访问的是对象/数组,且未代理过,动态创建子代理 if (isObject(value)) { const subProxy = createProxy(value); // 替换原属性值为子代理(下次访问直接拿到代理,无需重复创建) Reflect.set(target, key, subProxy); return subProxy; } return value; }, set(target, key, value) { console.log(`拦截到:修改 ${key} = ${value}`); // 如果新值是对象,先为其创建代理再赋值 if (isObject(value)) { value = createProxy(value); } return Reflect.set(target, key, value); } }; // 合并外部传入的 handler 和默认 handler(外部优先级更高) const proxyHandler = { ...defaultHandler, ...handler }; const proxy = new Proxy(target, proxyHandler); // 缓存代理对象,避免重复代理 proxyCache.set(target, proxy); return proxy; } return createProxy(target); } ``` **测试懒代理效果**: ```js const target = { name: "顶层属性", nested: { age: 18 }, // 嵌套对象 list: [1, 2, 3] // 嵌套数组(也能被代理) }; const lazyProxy = createLazyDeepProxy(target); // 测试访问: lazyProxy.name; // 拦截到:访问 name(普通属性)→ 顶层属性 lazyProxy.nested; // 拦截到:访问 nested(嵌套对象)→ 动态创建子代理 lazyProxy.nested.age; // 拦截到:访问 age(普通属性)→ 子代理生效 lazyProxy.list[0]; // 拦截到:访问 0(普通属性)→ 数组元素也能拦截 // 测试修改: lazyProxy.nested.age = 20; // 拦截到:修改 age = 20 → 子代理拦截 lazyProxy.list.push(4); // 拦截到:访问 push(嵌套对象)→ 数组的方法也能拦截 ``` ### 懒代理的核心特点 **懒加载**:嵌套对象只有在 “首次被访问” 时才会创建代理,而不是初始化时就递归代理所有层级,避免了对大型嵌套对象的性能浪费(比如嵌套 10 层,但只访问前 3 层,后 7 层不会被代理)。 **动态代理**:访问子对象时才 “动态生成代理”,并替换原引用,后续所有操作都基于代理对象。 **递归生效**:不仅支持对象嵌套,还支持数组、甚至对象里嵌套数组的复杂结构(上面的示例已验证)。 **缓存避免重复代理**:用 `WeakMap` 缓存已代理的对象,防止同一对象被多次代理(比如 `obj.a = obj.b` 这种循 ### 补充 **预代理**:初始化时就递归遍历所有嵌套层级,为每个对象都创建代理,优点是简单,缺点是性能差(大型对象初始化慢)、浪费内存(未访问的子对象也被代理)。 **懒代理**:访问时才代理,优点是性能优(按需代理)、内存占用低,缺点是需要处理 “代理缓存” 和 “引用替换”,实现稍复杂。 --- --- url: /js/btymcmg0/index.md --- # JSON json格式是一种数据交换的文本格式,目的就是取代繁琐笨重的XML格式。 每个json对象的就是一个值,可能是一个数组或者对象,也可能是一个原始类型的值。总是,**只能是一个值,不能是两个值。** ### 类型和格式 json对类型和格式有严格要求: 复合类型的值**只能是数组或者对象**,不能是函数,正则表达式对象,日期对象 原始类型的值只有四种:字符串,数值(十进制),布尔值和`null`,不能使用`NaN`, `Infinity`, `-Infinity`和`undefined` 字符串必须使用双引号表示,不能使用单引号。 对象的键名必须放在**双引号**里面 数组或对象最后一个成员的后面,不能加逗号。 ### JSON 对象 `JSON`对象是 JavaScript 的原生对象,用来处理 JSON 格式数据。它有两个静态方法:`JSON.stringify()`和`JSON.parse()`。 ### JSON.stringify() #### 基本用法 `JSON.stringify()`方法用于将一个值转为 JSON 字符串。该字符串符合 JSON 格式,并且可以被`JSON.parse()`方法还原。 **注意**,对于原始类型的字符串,转换结果会带双引号。 如果对象的属性是`undefined`、函数或 XML 对象,该属性会被`JSON.stringify()`过滤。 ```js var obj = { a: undefined, b: function () {} }; JSON.stringify(obj) // "{}" ``` 如果数组的成员是`undefined`、函数或 XML 对象,则这些值被转成`null`。 正则对象会被转成空对象。 `JSON.stringify()`方法会忽略对象的**不可遍历的属性**。 #### 第二个参数 `JSON.stringify()`方法还可以接受一个数组,作为第二个参数,指定参数对象的哪些属性需要转成字符串。 ```js var obj = { 'prop1': 'value1', 'prop2': 'value2', 'prop3': 'value3' }; var selectedProperties = ['prop1', 'prop2']; JSON.stringify(obj, selectedProperties) // "{"prop1":"value1","prop2":"value2"}" ``` 这个类似**白名单**的数组,**只对对象的属性有效,对数组无效**。 第二个参数还可以是一个函数,用来更改`JSON.stringify()`的返回值。 ```js function f(key, value) { if (typeof value === "number") { value = 2 * value; } return value; } JSON.stringify({ a: 1, b: 2 }, f) // '{"a": 2,"b": 4}' ``` 上面代码中的`f`函数,接受两个参数,分别是被转换的对象的键名和键值。如果键值是数值,就将它乘以`2`,否则就原样返回。 **注意**,这个处理函数是递归处理所有的键。 #### 第三个参数 `JSON.stringify()`还可以接受第三个参数,用于增加返回的 JSON 字符串的可读性。 默认返回的是单行字符串,对于大型的 JSON 对象,可读性非常差。第三个参数使得每个属性单独占据一行,并且将每个属性前面添加指定的前缀(不超过10个字符)。 ```js // 默认输出 JSON.stringify({ p1: 1, p2: 2 }) // JSON.stringify({ p1: 1, p2: 2 }) // 分行输出 JSON.stringify({ p1: 1, p2: 2 }, null, '\t') // { // "p1": 1, // "p2": 2 // } ``` 上面例子中,第三个属性`\t`在每个属性前面添加一个制表符,然后分行显示。 第三个属性如果是一个数字,则表示每个属性前面添加的空格(最多不超过10个)。 ```js JSON.stringify({ p1: 1, p2: 2 }, null, 2); /* "{ "p1": 1, "p2": 2 }" */ ``` ### toJSON() 如果参数对象有自定义的`toJSON()`方法,那么`JSON.stringify()`会使用这个方法的返回值作为参数,而忽略原对象的其他属性。 ::: code-group ```js [有toJSON] var user = { firstName: '三', lastName: '张', get fullName(){ return this.lastName + this.firstName; } }; JSON.stringify(user) // "{"firstName":"三","lastName":"张","fullName":"张三"}" ``` ```js [无toJSON] var user = { firstName: '三', lastName: '张', get fullName(){ return this.lastName + this.firstName; }, toJSON: function () { return { name: this.lastName + this.firstName }; } }; JSON.stringify(user) // "{"name":"张三"}" ``` ::: `Date`对象就有一个自己的`toJSON()`方法。 ```js var date = new Date('2015-01-01'); date.toJSON() // "2015-01-01T00:00:00.000Z" JSON.stringify(date) // ""2015-01-01T00:00:00.000Z"" ``` `toJSON()`方法的一个应用是,将正则对象自动转为字符串。因为`JSON.stringify()`默认不能转换正则对象,但是设置了`toJSON()`方法以后,就可以转换正则对象了。 ```js var obj = { reg: /foo/ }; // 不设置 toJSON 方法时 JSON.stringify(obj) // "{"reg":{}}" // 设置 toJSON 方法时 RegExp.prototype.toJSON = RegExp.prototype.toString; JSON.stringify(/foo/) // ""/foo/"" ``` ### JSON.parse() `JSON.parse()`方法用于将 JSON 字符串转换成对应的值。 ```js var o = JSON.parse('{"name": "张三"}'); o.name // 张三 ``` 如果传入的字符串不是有效的 JSON 格式,`JSON.parse()`方法将报错。 `JSON.parse()`方法可以接受一个处理函数,作为第二个参数,用法与`JSON.stringify()`方法类似。 ```js function f(key, value) { if (key === 'a') { return value + 10; } return value; } JSON.parse('{"a": 1, "b": 2}', f) // {a: 11, b: 2} ``` ### 深拷贝 `JSON.parse()`和`JSON.stringify()`可以结合使用,像下面这样写,实现对象的深拷贝。 ```js JSON.parse(JSON.stringify(obj)) ``` 上面这种写法,可以深度克隆一个对象,但是对象内部不能有 JSON 不允许的数据类型,比如函数、正则对象、日期对象等。 --- --- url: /js/c1una9pm/index.md --- # BOM 下 ## Location 对象 `Location`对象是浏览器提供的原生对象,提供 URL 相关的信息和操作方法。通过`window.location`和`document.location`属性,可以拿到这个对象。 ### 属性 `Location`对象提供以下属性。 * `Location.href`:整个 URL。 * `Location.protocol`:当前 URL 的协议,包括冒号(`:`)。 * `Location.host`:主机。如果端口不是协议默认的`80`和`433`,则还会包括冒号(`:`)和端口。 * `Location.hostname`:主机名,不包括端口。 * `Location.port`:端口号。 * `Location.pathname`:URL 的路径部分,从根路径`/`开始。 * `Location.search`:查询字符串部分,从问号`?`开始。 * `Location.hash`:片段字符串部分,从`#`开始。 * `Location.username`:域名前面的用户名。 * `Location.password`:域名前面的密码。 * `Location.origin`:URL 的协议、主机名和端口。 这些属性里面,只有`origin`属性是只读的,其他属性都可写。 注意,如果对`Location.href`写入新的 URL 地址,浏览器会立刻跳转到这个新地址。 ```js // 跳转到新网址 document.location.href = 'http://www.example.com'; ``` 这个特性常常用于让网页自动滚动到新的锚点。 ```js document.location.href = '#top'; // 等同于 document.location.hash = '#top'; ``` 直接改写`location`,相当于写入`href`属性。 ```js document.location = 'http://www.example.com'; // 等同于 document.location.href = 'http://www.example.com'; ``` 另外,`Location.href`属性是浏览器唯一允许跨域写入的属性,即非同源的窗口可以改写另一个窗口(比如子窗口与父窗口)的`Location.href`属性,导致后者的网址跳转。`Location`的其他属性都不允许跨域写入。 ### 方法 **(1)Location.assign()** `assign`方法接受一个 URL 字符串作为参数,使得浏览器立刻跳转到新的 URL。如果参数不是有效的 URL 字符串,则会报错。 ```js // 跳转到新的网址 document.location.assign('http://www.example.com') ``` **(2)Location.replace()** `replace`方法接受一个 URL 字符串作为参数,使得浏览器立刻跳转到新的 URL。如果参数不是有效的 URL 字符串,则会报错。 它与`assign`方法的差异在于,`replace`会在浏览器的浏览历史`History`里面删除当前网址,也就是说,一旦使用了该方法,后退按钮就无法回到当前网页了,相当于在浏览历史里面,使用新的 URL 替换了老的 URL。它的一个应用是,当脚本发现当前是移动设备时,就立刻跳转到移动版网页。 **(3)Location.reload()** `reload`方法使得浏览器重新加载当前网址,相当于按下浏览器的刷新按钮。 它接受一个布尔值作为参数。如果参数为`true`,浏览器将向服务器重新请求这个网页,并且重新加载后,网页将滚动到头部(即`scrollTop === 0`)。如果参数是`false`或为空,浏览器将从本地缓存重新加载该网页,并且重新加载后,网页的视口位置是重新加载前的位置。 **(4)Location.toString()** `toString`方法返回整个 URL 字符串,相当于读取`Location.href`属性。 ## URL 的编码和解码 网页的 URL 只能包含合法的字符。合法字符分成两类。 * URL 元字符:分号(`;`),逗号(`,`),斜杠(`/`),问号(`?`),冒号(`:`),at(`@`),`&`,等号(`=`),加号(`+`),美元符号(`$`),井号(`#`) * 语义字符:`a-z`,`A-Z`,`0-9`,连词号(`-`),下划线(`_`),点(`.`),感叹号(`!`),波浪线(`~`),星号(`*`),单引号(`'`),圆括号(`()`) 除了以上字符,其他字符出现在 URL 之中都必须转义,规则是根据操作系统的默认编码,将每个字节转为百分号(`%`)加上两个大写的十六进制字母。 比如,UTF-8 的操作系统上,`http://www.example.com/q=春节`这个 URL 之中,汉字“春节”不是 URL 的合法字符,所以被浏览器自动转成`http://www.example.com/q=%E6%98%A5%E8%8A%82`。其中,“春”转成了`%E6%98%A5`,“节”转成了`%E8%8A%82`。这是因为“春”和“节”的 UTF-8 编码分别是`E6 98 A5`和`E8 8A 82`,将每个字节前面加上百分号,就构成了 URL 编码。 JavaScript 提供四个 URL 的编码/解码方法。 * `encodeURI()` * `encodeURIComponent()` * `decodeURI()` * `decodeURIComponent()` ### encodeURI() `encodeURI()`方法用于转码整个 URL。它的参数是一个字符串,代表整个 URL。它会将元字符和语义字符之外的字符,都进行转义。 ```js encodeURI('http://www.example.com/q=春节') // "http://www.example.com/q=%E6%98%A5%E8%8A%82" ``` ### encodeURIComponent() `encodeURIComponent()`方法用于转码 URL 的组成部分,会转码除了语义字符之外的所有字符,即元字符也会被转码。所以,它不能用于转码整个 URL。它接受一个参数,就是 URL 的片段。 ```js encodeURIComponent('春节') // "%E6%98%A5%E8%8A%82" encodeURIComponent('http://www.example.com/q=春节') // "http%3A%2F%2Fwww.example.com%2Fq%3D%E6%98%A5%E8%8A%82" // [!code warning] ``` ### decodeURI() `decodeURI()`方法用于整个 URL 的解码。它是`encodeURI()`方法的逆运算。它接受一个参数,就是转码后的 URL。 ```js decodeURI('http://www.example.com/q=%E6%98%A5%E8%8A%82') // "http://www.example.com/q=春节" ``` ### decodeURIComponent() `decodeURIComponent()`用于URL 片段的解码。它是`encodeURIComponent()`方法的逆运算。它接受一个参数,就是转码后的 URL 片段。 ```js decodeURIComponent('%E6%98%A5%E8%8A%82') // "春节" ``` ## URL 对象 浏览器原生提供`URL()`接口,它是一个构造函数,用来构造、解析和编码 URL。一般情况下,通过`window.URL`可以拿到这个构造函数。 ### 构造函数 `URL()`作为构造函数,可以生成 URL 实例。它接受一个表示 URL 的字符串作为参数。如果参数不是合法的 URL,会报错。 ```js var url = new URL('http://www.example.com/index.html'); url.href // "http://www.example.com/index.html" ``` 除了字符串,`URL()`的参数也可以是另一个 URL 实例。这时,`URL()`会自动读取该实例的`href`属性,作为实际参数。 如果 URL 字符串是一个相对路径,那么需要表示绝对路径的第二个参数,作为计算基准。 ```js var url1 = new URL('index.html', 'http://example.com'); url1.href // "http://example.com/index.html" var url2 = new URL('page2.html', 'http://example.com/page1.html'); url2.href // "http://example.com/page2.html" var url3 = new URL('..', 'http://example.com/a/b.html') url3.href // "http://example.com/" ``` 第一个参数是`..`,表示上层路径。 ### 实例属性 URL 实例的属性与`Location`对象的属性基本一致,返回当前 URL 的信息。 * URL.href:返回整个 URL * URL.protocol:返回协议,以冒号`:`结尾 * URL.hostname:返回域名 * URL.host:返回域名与端口,包含`:`号,默认的80和443端口会省略 * URL.port:返回端口 * URL.origin:返回协议、域名和端口 * URL.pathname:返回路径,以斜杠`/`开头 * URL.search:返回查询字符串,以问号`?`开头 * URL.searchParams:返回一个`URLSearchParams`实例,该属性是`Location`对象没有的 * URL.hash:返回片段识别符,以井号`#`开头 * URL.password:返回域名前面的密码 * URL.username:返回域名前面的用户名 这些属性里面,只有`origin`属性是只读的,其他属性都可写,并且会立即生效。 ### 静态方法 **(1)URL.createObjectURL()** `URL.createObjectURL()`方法用来为上传/下载的文件、流媒体文件生成一个 URL 字符串。这个字符串代表了`File`对象或`Blob`对象的 URL。 **(2)URL.revokeObjectURL()** `URL.revokeObjectURL()`方法用来释放`URL.createObjectURL()`方法生成的 URL 实例。它的参数就是`URL.createObjectURL()`方法返回的 URL 字符串。 ## URLSearchParams 对象 ### 概述 `URLSearchParams`对象是浏览器的原生对象,用来构造、解析和处理 URL 的查询字符串(即 URL 问号后面的部分)。 它本身也是一个构造函数,可以生成实例。参数可以为查询字符串,起首的问号`?`有没有都行,也可以是对应查询字符串的数组或对象。 ```js // 方法一:传入字符串 var params = new URLSearchParams('?foo=1&bar=2'); // 等同于 var params = new URLSearchParams(document.location.search); // 方法二:传入数组 var params = new URLSearchParams([['foo', 1], ['bar', 2]]); // 方法三:传入对象 var params = new URLSearchParams({'foo' : 1 , 'bar' : 2}); ``` `URLSearchParams`会对查询字符串自动编码。 ```js var params = new URLSearchParams({'foo': '你好'}); params.toString() // "foo=%E4%BD%A0%E5%A5%BD" ``` 浏览器向服务器发送表单数据时,可以直接使用`URLSearchParams`实例作为表单数据。 ```js const params = new URLSearchParams({foo: 1, bar: 2}); fetch('https://example.com/api', { method: 'POST', body: params }).then(...) ``` `URLSearchParams`可以与`URL()`接口结合使用。 ```js var url = new URL(window.location); var foo = url.searchParams.get('foo') || 'somedefault'; ``` URL 实例的`searchParams`属性就是一个`URLSearchParams`实例,所以可以使用`URLSearchParams`接口的`get`方法。 ```js new URLSearchParams(document.location.search).get('enc') ``` ### URLSearchParams.toString() `toString`方法返回实例的字符串形式。 ### URLSearchParams.append() `append()`方法用来追加一个查询参数。它接受两个参数,第一个为键名,第二个为键值,没有返回值。 `append()`方法不会识别是否键名已经存在。 ```js var params = new URLSearchParams({'foo': 1 , 'bar': 2}); params.append('foo', 3); params.toString() // "foo=1&bar=2&foo=3" ``` ### URLSearchParams.delete() `delete()`方法用来删除指定的查询参数。它接受键名作为参数。 ```js params.delete('bar'); ``` ### URLSearchParams.has() `has()`方法返回一个布尔值,表示查询字符串是否包含指定的键名。 ```js params.has('bar') // true ``` ### URLSearchParams.set() `set()`方法用来设置查询字符串的键值。 它接受两个参数,第一个是键名,第二个是键值。如果是已经存在的键,键值会被改写,否则会被追加。 ```js var params = new URLSearchParams('?foo=1'); params.set('foo', 2); params.toString() // "foo=2" params.set('bar', 3); params.toString() // "foo=2&bar=3" ``` 如果有多个的同名键,`set`会移除现存所有的键。 ```js var params = new URLSearchParams('?foo=1&foo=2'); params.set('foo', 3); params.toString() // "foo=3" ``` ### URLSearchParams.get() `get()`方法用来读取查询字符串里面的指定键。它接受键名作为参数。 ```js params.get('foo') ``` 如果有多个的同名键,`get`返回位置最前面的那个键值。 ### URLSearchParams.getAll() `getAll()`方法返回一个数组,成员是指定键的所有键值。它接受键名作为参数。 ### URLSearchParams.sort() `sort()`方法对查询字符串里面的键进行排序,规则是按照 Unicode 码点从小到大排列。 ### URLSearchParams.keys() ### URLSearchParams.values() ### URLSearchParams.entries() 这三个方法都返回一个遍历器对象,供`for...of`循环遍历。它们的区别在于,`keys`方法返回的是键名的遍历器,`values`方法返回的是键值的遍历器,`entries`返回的是键值对的遍历器。 ```js var params = new URLSearchParams('a=1&b=2'); for(var p of params.keys()) { console.log(p); } // a // b for(var p of params.values()) { console.log(p); } // 1 // 2 for(var p of params.entries()) { console.log(p); } // ["a", "1"] // ["b", "2"] ``` 如果直接对`URLSearchParams`进行遍历,其实内部调用的就是`entries`接口。 ```js for (var p of params) {} // 等同于 for (var p of params.entries()) {} ``` ## ArrayBuffer 对象 ArrayBuffer 对象表示一段二进制数据,用来模拟内存里面的数据。通过这个对象,JavaScript 可以读写二进制数据。这个对象可以看作内存数据的表达。 浏览器原生提供`ArrayBuffer()`构造函数,用来生成实例。它接受一个整数作为参数,表示这段二进制数据占用多少个字节。 ```js var buffer = new ArrayBuffer(8); ``` 上面代码中,实例对象`buffer`占用8个字节。 ArrayBuffer 对象有实例属性`byteLength`,表示当前实例占用的内存长度(单位字节)。 ArrayBuffer 对象有实例方法`slice()`,用来复制一部分内存。它接受两个整数参数,分别表示复制的开始位置(从0开始)和结束位置(复制时不包括结束位置),如果省略第二个参数,则表示一直复制到结束。 ## Blob 对象 ### 简介 Blob 对象表示一个二进制文件的数据内容,比如一个图片文件的内容就可以通过 Blob 对象读写。 它通常用来读写文件,它的名字是 Binary Large Object (二进制大型对象)的缩写。它与 ArrayBuffer 的区别在于,它用于操作二进制文件,而 ArrayBuffer 用于操作内存。 浏览器原生提供`Blob()`构造函数,用来生成实例对象。 ```js new Blob(array [, options]) ``` `Blob`构造函数接受两个参数。 第一个参数是数组,成员是字符串或二进制对象,表示新生成的`Blob`实例对象的内容; 第二个参数是可选的,是一个配置对象,目前只有一个属性`type`,它的值是一个字符串,表示数据的 MIME 类型,默认是空字符串。 ```js var htmlFragment = ['hey!']; var myBlob = new Blob(htmlFragment, {type : 'text/html'}); var obj = { hello: 'world' }; var blob = new Blob([ JSON.stringify(obj) ], {type : 'application/json'}); ``` ### 实例属性 `Blob`具有两个实例属性`size`和`type`,分别返回数据的大小和类型。 ```js myBlob.size // 32 myBlob.type // "text/html" ``` ### 实例方法 `Blob`具有一个实例方法`slice`,用来拷贝原来的数据,返回的也是一个`Blob`实例。 ```js myBlob.slice(start, end, contentType) ``` `slice`方法有三个参数,都是可选的。它们依次是起始的字节位置(默认为0)、结束的字节位置(默认为`size`属性的值,该位置本身将不包含在拷贝的数据之中)、新实例的数据类型(默认为空字符串)。 ### 获取文件信息 文件选择器``用来让用户选取文件。 出于安全考虑,浏览器不允许脚本自行设置这个控件的`value`属性,即文件必须是用户手动选取的,不能是脚本指定的。一旦用户选好了文件,脚本就可以读取这个文件。 文件选择器返回一个 FileList 对象,该对象是一个类似数组的成员,每个成员都是一个 File 实例对象。File 实例对象是一个特殊的 Blob 实例,增加了`name`和`lastModifiedDate`属性。 ```js // HTML 代码如下 // function fileinfo(files) { for (var i = 0; i < files.length; i++) { var f = files[i]; console.log( f.name, // 文件名,不含路径 f.size, // 文件大小,Blob 实例属性 f.type, // 文件类型,Blob 实例属性 f.lastModifiedDate // 文件的最后修改时间 ); } } ``` ### 下载文件 AJAX 请求时,如果指定`responseType`属性为`blob`,下载下来的就是一个 Blob 对象。 ```js function getBlob(url, callback) { var xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.responseType = 'blob'; xhr.onload = function () { callback(xhr.response); } xhr.send(null); } ``` ### 生成 URL 浏览器允许使用`URL.createObjectURL()`方法,针对 Blob 对象生成一个临时 URL,以便于某些 API 使用。 这个 URL 以`blob://`开头,表明对应一个 Blob 对象,协议头后面是一个识别符,用来唯一对应内存里面的 Blob 对象。 这一点与`data://URL`(URL 包含实际数据)和`file://URL`(本地文件系统里面的文件)都不一样。 ```js var droptarget = document.getElementById('droptarget'); droptarget.ondrop = function (e) { var files = e.dataTransfer.files; for (var i = 0; i < files.length; i++) { var type = files[i].type; if (type.substring(0,6) !== 'image/') continue; var img = document.createElement('img'); img.src = URL.createObjectURL(files[i]); img.onload = function () { this.width = 100; document.body.appendChild(this); URL.revokeObjectURL(this.src); } } } ``` 上面代码通过为拖放的图片文件生成一个 URL,产生它们的缩略图,从而使得用户可以预览选择的文件。 浏览器处理 Blob URL 就跟普通的 URL 一样,如果 Blob 对象不存在,返回404状态码;如果跨域请求,返回403状态码。Blob URL 只对 GET 请求有效,如果请求成功,返回200状态码。由于 Blob URL 就是普通 URL,因此可以下载。 ### 读取文件 取得 Blob 对象以后,可以通过`FileReader`对象,读取 Blob 对象的内容,即文件内容。 FileReader 对象提供四个方法,处理 Blob 对象。Blob 对象作为参数传入这些方法,然后以指定的格式返回。 * `FileReader.readAsText()`:返回文本,需要指定文本编码,默认为 UTF-8。 * `FileReader.readAsArrayBuffer()`:返回 ArrayBuffer 对象。 * `FileReader.readAsDataURL()`:返回 Data URL。 * `FileReader.readAsBinaryString()`:返回原始的二进制字符串。 下面是`FileReader.readAsText()`方法的例子,用来读取文本文件。 ```js // HTML 代码如下 // //

function readfile(f) {
  var reader = new FileReader();
  reader.readAsText(f);
  reader.onload = function () {
    var text = reader.result;
    var out = document.getElementById('output');
    out.innerHTML = '';
    out.appendChild(document.createTextNode(text));
  }
  reader.onerror = function(e) {
    console.log('Error', e);
  };
}
```

上面代码中,通过指定 FileReader 实例对象的`onload`监听函数,在实例的`result`属性上拿到文件内容。

下面是`FileReader.readAsArrayBuffer()`方法的例子,用于读取二进制文件。

```js
// HTML 代码如下
// 
function typefile(file) {
  // 文件开头的四个字节,生成一个 Blob 对象
  var slice = file.slice(0, 4);
  var reader = new FileReader();
  // 读取这四个字节
  reader.readAsArrayBuffer(slice);
  reader.onload = function (e) {
    var buffer = reader.result;
    // 将这四个字节的内容,视作一个32位整数
    var view = new DataView(buffer);
    var magic = view.getUint32(0, false);
    // 根据文件的前四个字节,判断它的类型
    switch(magic) {
      case 0x89504E47: file.verified_type = 'image/png'; break;
      case 0x47494638: file.verified_type = 'image/gif'; break;
      case 0x25504446: file.verified_type = 'application/pdf'; break;
      case 0x504b0304: file.verified_type = 'application/zip'; break;
    }
    console.log(file.name, file.verified_type);
  };
}
```

## File 对象

File 对象代表一个文件,用来读写文件信息。它继承了 Blob 对象,或者说是一种特殊的 Blob 对象,所有可以使用 Blob 对象的场合都可以使用它。

最常见的使用场合是表单的文件上传控件(``),用户选中文件以后,浏览器就会生成一个数组,里面是每一个用户选中的文件,它们都是 File 实例对象。

```js
// HTML 代码如下
// 
var file = document.getElementById('fileItem').files[0];
file instanceof File // true
```

上面代码中,`file`是用户选中的第一个文件,它是 File 的实例。

### 构造函数

浏览器原生提供一个`File()`构造函数,用来生成 File 实例对象。

```js
new File(array, name [, options])
```

`File()`构造函数接受三个参数。

* array:一个数组,成员可以是二进制对象或字符串,表示文件的内容。
* name:字符串,表示文件名或文件路径。
* options:配置对象,设置实例的属性。该参数可选。

第三个参数配置对象,可以设置两个属性。

* type:字符串,表示实例对象的 MIME 类型,默认值为空字符串。
* lastModified:时间戳,表示上次修改的时间,默认为`Date.now()`。

```js
var file = new File(
  ['foo'],
  'foo.txt',
  {
    type: 'text/plain',
  }
);
```

### 实例属性

File 对象有以下实例属性。

* File.lastModified:最后修改时间
* File.name:文件名或文件路径
* File.size:文件大小(单位字节)
* File.type:文件的 MIME 类型

```js
var myFile = new File([], 'file.bin', {
  lastModified: new Date(2018, 1, 1),
});
myFile.lastModified // 1517414400000
myFile.name // "file.bin"
myFile.size // 0
myFile.type // ""
```

### 实例方法

File 对象没有自己的实例方法,由于继承了 Blob 对象,因此可以使用 Blob 的实例方法`slice()`。

## FileList 对象

`FileList`对象是一个类似数组的对象,代表一组选中的文件,每个成员都是一个 File 实例。它主要出现在两个场合。

* 文件控件节点(``)的`files`属性,返回一个 FileList 实例。
* 拖拉一组文件时,目标区的`DataTransfer.files`属性,返回一个 FileList 实例。

```js
// HTML 代码如下
// 
var files = document.getElementById('fileItem').files;
files instanceof FileList // true
```

上面代码中,文件控件的`files`属性是一个 FileList 实例。

FileList 的实例属性主要是`length`,表示包含多少个文件。

FileList 的实例方法主要是`item()`,用来返回指定位置的实例。

它接受一个整数作为参数,表示位置的序号(从零开始)。但是,由于 FileList 的实例是一个类似数组的对象,可以直接用方括号运算符,即`myFileList[0]`等同于`myFileList.item(0)`,所以一般用不到`item()`方法。

## FileReader 对象

FileReader 对象用于读取 File 对象或 Blob 对象所包含的文件内容。

浏览器原生提供一个`FileReader`构造函数,用来生成 FileReader 实例。

```js
var reader = new FileReader();
```

FileReader 有以下的实例属性。

* FileReader.error:读取文件时产生的错误对象
* FileReader.readyState:整数,表示读取文件时的当前状态。一共有三种可能的状态,`0`表示尚未加载任何数据,`1`表示数据正在加载,`2`表示加载完成。
* FileReader.result:读取完成后的文件内容,有可能是字符串,也可能是一个 ArrayBuffer 实例。
* FileReader.onabort:`abort`事件(用户终止读取操作)的监听函数。
* FileReader.onerror:`error`事件(读取错误)的监听函数。
* FileReader.onload:`load`事件(读取操作完成)的监听函数,通常在这个函数里面使用`result`属性,拿到文件内容。
* FileReader.onloadstart:`loadstart`事件(读取操作开始)的监听函数。
* FileReader.onloadend:`loadend`事件(读取操作结束)的监听函数。
* FileReader.onprogress:`progress`事件(读取操作进行中)的监听函数。

FileReader 有以下实例方法。

* FileReader.abort():终止读取操作,`readyState`属性将变成`2`。
* FileReader.readAsArrayBuffer():以 ArrayBuffer 的格式读取文件,读取完成后`result`属性将返回一个 ArrayBuffer 实例。
* FileReader.readAsBinaryString():读取完成后,`result`属性将返回原始的二进制字符串。
* FileReader.readAsDataURL():读取完成后,`result`属性将返回一个 Data URL 格式(Base64 编码)的字符串,代表文件内容。对于图片文件,这个字符串可以用于``元素的`src`属性。注意,这个字符串不能直接进行 Base64 解码,必须把前缀`data:*/*;base64,`从字符串里删除以后,再进行解码。
* FileReader.readAsText():读取完成后,`result`属性将返回文件内容的文本字符串。该方法的第一个参数是代表文件的 Blob 实例,第二个参数是可选的,表示文本编码,默认为 UTF-8。

## FormData 对象

### 概述

表单数据以键值对的形式向服务器发送,这个过程是浏览器自动完成的。但是有时候,我们希望通过脚本完成这个过程,构造或编辑表单的键值对,然后通过脚本发送给服务器。浏览器原生提供了 FormData 对象来完成这项工作。

`FormData()`首先是一个构造函数,用来生成表单的实例。

```js
var formdata = new FormData(form);
```

`FormData()`构造函数的参数是一个 DOM 的表单元素,构造函数会自动处理表单的键值对。这个参数是可选的,如果省略该参数,就表示一个空的表单。

```html
``` ### 实例方法 FormData 提供以下实例方法。 * `FormData.get(key)`:获取指定键名对应的键值,参数为键名。如果有多个同名的键值对,则返回第一个键值对的键值。 * `FormData.getAll(key)`:返回一个数组,表示指定键名对应的所有键值。如果有多个同名的键值对,数组会包含所有的键值。 * `FormData.set(key, value)`:设置指定键名的键值,参数为键名。如果键名不存在,会添加这个键值对,否则会更新指定键名的键值。如果第二个参数是文件,还可以使用第三个参数,表示文件名。 * `FormData.delete(key)`:删除一个键值对,参数为键名。 * `FormData.append(key, value)`:添加一个键值对。如果键名重复,则会生成两个相同键名的键值对。如果第二个参数是文件,还可以使用第三个参数,表示文件名。 * `FormData.has(key)`:返回一个布尔值,表示是否具有该键名的键值对。 * `FormData.keys()`:返回一个遍历器对象,用于`for...of`循环遍历所有的键名。 * `FormData.values()`:返回一个遍历器对象,用于`for...of`循环遍历所有的键值。 * `FormData.entries()`:返回一个遍历器对象,用于`for...of`循环遍历所有的键值对。如果直接用`for...of`循环遍历 FormData 实例,默认就会调用这个方法。 ### 内置验证 #### 自动校验 表单提交的时候,浏览器允许开发者指定一些条件,它会自动验证各个表单控件的值是否符合条件。 ```js ``` 如果一个控件通过验证,它就会匹配`:valid`的 CSS 伪类,浏览器会继续进行表单提交的流程。如果没有通过验证,该控件就会匹配`:invalid`的 CSS 伪类,浏览器会终止表单提交,并显示一个错误信息。 ```css input:invalid { border-color: red; } input, input:valid { border-color: #ccc; } ``` #### checkValidity() 除了提交表单的时候,浏览器自动校验表单,还可以手动触发表单的校验。表单元素和表单控件都有`checkValidity()`方法,用于手动触发校验。 ```js // 触发整个表单的校验 form.checkValidity() // 触发单个表单控件的校验 formControl.checkValidity() ``` `checkValidity()`方法返回一个布尔值,`true`表示通过校验,`false`表示没有通过校验。 #### willValidate 属性 控件元素的`willValidate`属性是一个布尔值,表示该控件是否会在提交时进行校验。 #### validationMessage 属性 控件元素的`validationMessage`属性返回一个字符串,表示控件不满足校验条件时,浏览器显示的提示文本。以下两种情况,该属性返回空字符串。 * 该控件不会在提交时自动校验 * 该控件满足校验条件 #### setCustomValidity() 控件元素的`setCustomValidity()`方法用来定制校验失败时的报错信息。它接受一个字符串作为参数,该字符串就是定制的报错信息。如果参数为空字符串,则上次设置的报错信息被清除。 #### validity 属性 控件元素的属性`validity`属性返回一个`ValidityState`对象,包含当前校验状态的信息。 该对象有以下属性,全部为只读属性。 * `ValidityState.badInput`:布尔值,表示浏览器是否不能将用户的输入转换成正确的类型,比如用户在数值框里面输入字符串。 * `ValidityState.customError`:布尔值,表示是否已经调用`setCustomValidity()`方法,将校验信息设置为一个非空字符串。 * `ValidityState.patternMismatch`:布尔值,表示用户输入的值是否不满足模式的要求。 * `ValidityState.rangeOverflow`:布尔值,表示用户输入的值是否大于最大范围。 * `ValidityState.rangeUnderflow`:布尔值,表示用户输入的值是否小于最小范围。 * `ValidityState.stepMismatch`:布尔值,表示用户输入的值不符合步长的设置(即不能被步长值整除)。 * `ValidityState.tooLong`:布尔值,表示用户输入的字数超出了最长字数。 * `ValidityState.tooShort`:布尔值,表示用户输入的字符少于最短字数。 * `ValidityState.typeMismatch`:布尔值,表示用户填入的值不符合类型要求(主要是类型为 Email 或 URL 的情况)。 * `ValidityState.valid`:布尔值,表示用户是否满足所有校验条件。 * `ValidityState.valueMissing`:布尔值,表示用户没有填入必填的值。 下面是一个例子。 ```js var input = document.getElementById('myinput'); if (input.validity.valid) { console.log('通过校验'); } else { console.log('校验失败'); } ``` 下面是另外一个例子。 ```js var txt = ''; if (document.getElementById('myInput').validity.rangeOverflow) { txt = '数值超过上限'; } document.getElementById('prompt').innerHTML = txt; ``` 如果想禁止浏览器弹出表单验证的报错信息,可以监听`invalid`事件。 ```js var input = document.getElementById('username'); var form = document.getElementById('form'); var elem = document.createElement('div'); elem.id = 'notify'; elem.style.display = 'none'; form.appendChild(elem); input.addEventListener('invalid', function (event) { event.preventDefault(); if (!event.target.validity.valid) { elem.textContent = '用户名必须是小写字母'; elem.className = 'error'; elem.style.display = 'block'; input.className = 'invalid animated shake'; } }); input.addEventListener('input', function(event){ if ( 'block' === elem.style.display ) { input.className = ''; elem.style.display = 'none'; } }); ``` 上面代码中,一旦发生`invalid`事件(表单验证失败),`event.preventDefault()`用来禁止浏览器弹出默认的验证失败提示,然后设置定制的报错提示框。 #### novalidate 属性 表单元素的 HTML 属性`novalidate`,可以关闭浏览器的自动校验。 ```html
``` 这个属性也可以在脚本里设置。 ```js form.noValidate = true; ``` ### enctype 属性 表单能够用四种编码,向服务器发送数据。编码格式由表单的`enctype`属性决定。 **(1)GET 方法** 如果表单使用`GET`方法发送数据,`enctype`属性无效。 **(2)application/x-www-form-urlencoded** 如果表单用`POST`方法发送数据,并省略`enctype`属性,那么数据以`application/x-www-form-urlencoded`格式发送(因为这是默认值)。 ```http Content-Type: application/x-www-form-urlencoded ``` **(3)text/plain** 如果表单使用`POST`方法发送数据,`enctype`属性为`text/plain`,那么数据将以纯文本格式发送。 ```http Content-Type: text/plain ``` **(4)multipart/form-data** 如果表单使用`POST`方法,`enctype`属性为`multipart/form-data`,那么数据将以混合的格式发送。 这种格式也是文件上传的格式。 ### 文件上传 用户上传文件,也是通过表单。具体来说,就是通过文件输入框选择本地文件,提交表单的时候,浏览器就会把这个文件发送到服务器。 ```html ``` 此外,还需要将表单`
`元素的`method`属性设为`POST`,`enctype`属性设为`multipart/form-data`。 其中,`enctype`属性决定了 HTTP 头信息的`Content-Type`字段的值,默认情况下这个字段的值是`application/x-www-form-urlencoded`,但是文件上传的时候要改成`multipart/form-data`。 ```html
``` 上面的 HTML 代码中,file 控件的`multiple`属性,指定可以一次选择多个文件;如果没有这个属性,则一次只能选择一个文件。 ```js var fileSelect = document.getElementById('file'); var files = fileSelect.files; ``` 然后,新建一个 FormData 实例对象,模拟发送到服务器的表单数据,把选中的文件添加到这个对象上面。 ```js var formData = new FormData(); for (var i = 0; i < files.length; i++) { var file = files[i]; // 只上传图片文件 if (!file.type.match('image.*')) { continue; } formData.append('photos[]', file, file.name); } ``` 最后,使用 Ajax 向服务器上传文件。 ```js var xhr = new XMLHttpRequest(); xhr.open('POST', 'handler.php', true); xhr.onload = function () { if (xhr.status !== 200) { console.log('An error occurred!'); } }; xhr.send(formData); ``` 除了发送 FormData 实例,也可以直接 AJAX 发送文件。 ```js var file = document.getElementById('test-input').files[0]; var xhr = new XMLHttpRequest(); xhr.open('POST', 'myserver/uploads'); xhr.setRequestHeader('Content-Type', file.type); xhr.send(file); ``` ## IndexedDB API ### 概述 随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据。 现有的浏览器数据储存方案,都不适合储存大量数据:Cookie 的大小不超过 4KB,且每次请求都会发送回服务器;LocalStorage 在 2.5MB 到 10MB 之间(各家浏览器不同),而且不提供搜索功能,不能建立自定义的索引。所以,需要一种新的解决方案,这就是 IndexedDB 诞生的背景。 通俗地说,IndexedDB 就是浏览器提供的本地数据库,它可以被网页脚本创建和操作。IndexedDB 允许储存大量数据,提供查找接口,还能建立索引。这些都是 LocalStorage 所不具备的。就数据库类型而言,IndexedDB 不属于关系型数据库(不支持 SQL 查询语句),更接近 NoSQL 数据库。 IndexedDB 具有以下特点。 **(1)键值对储存。** IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以“键值对”的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。 **(2)异步。** IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。 **(3)支持事务。** IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。 **(4)同源限制。** IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。 **(5)储存空间大。** IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。 **(6)支持二进制储存。** IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。 ### 基本概念 IndexedDB 是一个比较复杂的 API,涉及不少概念。它把不同的实体,抽象成一个个对象接口。学习这个 API,就是学习它的各种对象接口。 * 数据库:IDBDatabase 对象 * 对象仓库:IDBObjectStore 对象 * 索引: IDBIndex 对象 * 事务: IDBTransaction 对象 * 操作请求:IDBRequest 对象 * 指针: IDBCursor 对象 * 主键集合:IDBKeyRange 对象 下面是一些主要的概念。 **(1)数据库** 数据库是一系列相关数据的容器。每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库。 IndexedDB 数据库有版本的概念。同一个时刻,只能有一个版本的数据库存在。如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据库版本完成。 **(2)对象仓库** 每个数据库包含若干个对象仓库(object store)。它类似于关系型数据库的表格。 **(3)数据记录** 对象仓库保存的是数据记录。每条记录类似于关系型数据库的行,但是只有主键和数据体两部分。主键用来建立默认的索引,必须是不同的,否则会报错。主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号。 ```javascript { id: 1, text: 'foo' } ``` 上面的对象中,`id`属性可以当作主键。 数据体可以是任意数据类型,不限于对象。 **(4)索引** 为了加速数据的检索,可以在对象仓库里面,为不同的属性建立索引。 **(5)事务** 数据记录的读写和删改,都要通过事务完成。事务对象提供`error`、`abort`和`complete`三个事件,用来监听操作结果。 ### 操作流程 IndexedDB 数据库的各种操作,一般是按照下面的流程进行的。这个部分只给出简单的代码示例,用于快速上手,详细的各个对象的 API 放在后文介绍。 #### 打开数据库 使用 IndexedDB 的第一步是打开数据库,使用`indexedDB.open()`方法。 ```js var request = window.indexedDB.open(databaseName, version); ``` 这个方法接受两个参数,第一个参数是字符串,表示数据库的名字。如果指定的数据库不存在,就会新建数据库。第二个参数是整数,表示数据库的版本。如果省略,打开已有数据库时,默认为当前版本;新建数据库时,默认为`1`。 `indexedDB.open()`方法返回一个 IDBRequest 对象。这个对象通过三种事件`error`、`success`、`upgradeneeded`,处理打开数据库的操作结果。 **(1)error 事件** `error`事件表示打开数据库失败。 ```js request.onerror = function (event) { console.log('数据库打开报错'); }; ``` **(2)success 事件** `success`事件表示成功打开数据库。 ```js var db; request.onsuccess = function (event) { db = request.result; console.log('数据库打开成功'); }; ``` 这时,通过`request`对象的`result`属性拿到数据库对象。 **(3)upgradeneeded 事件** 如果指定的版本号,大于数据库的实际版本号,就会发生数据库升级事件`upgradeneeded`。 ```js var db; request.onupgradeneeded = function (event) { db = event.target.result; } ``` 这时通过事件对象的`target.result`属性,拿到数据库实例。 #### 新建数据库 新建数据库与打开数据库是同一个操作。如果指定的数据库不存在,就会新建。不同之处在于,后续的操作主要在`upgradeneeded`事件的监听函数里面完成,因为这时版本从无到有,所以会触发这个事件。 通常,新建数据库以后,第一件事是新建对象仓库(即新建表)。 ```js request.onupgradeneeded = function(event) { db = event.target.result; var objectStore = db.createObjectStore('person', { keyPath: 'id' }); } ``` 上面代码中,数据库新建成功以后,新增一张叫做`person`的表格,主键是`id`。 更好的写法是先判断一下,这张表格是否存在,如果不存在再新建。 ```js request.onupgradeneeded = function (event) { db = event.target.result; var objectStore; if (!db.objectStoreNames.contains('person')) { objectStore = db.createObjectStore('person', { keyPath: 'id' }); } } ``` 主键(key)是默认建立索引的属性。比如,数据记录是`{ id: 1, name: '张三' }`,那么`id`属性可以作为主键。主键也可以指定为下一层对象的属性,比如`{ foo: { bar: 'baz' } }`的`foo.bar`也可以指定为主键。 如果数据记录里面没有合适作为主键的属性,那么可以让 IndexedDB 自动生成主键。 ```js var objectStore = db.createObjectStore( 'person', { autoIncrement: true } ); ``` 上面代码中,指定主键为一个递增的整数。 新建对象仓库以后,下一步可以新建索引。 ```js request.onupgradeneeded = function(event) { db = event.target.result; var objectStore = db.createObjectStore('person', { keyPath: 'id' }); objectStore.createIndex('name', 'name', { unique: false }); objectStore.createIndex('email', 'email', { unique: true }); } ``` 上面代码中,`IDBObject.createIndex()`的三个参数分别为索引名称、索引所在的属性、配置对象(说明该属性是否包含重复的值)。 #### 新增数据 新增数据指的是向对象仓库写入数据记录。这需要通过事务完成。 ```js function add() { var request = db.transaction(['person'], 'readwrite') .objectStore('person') .add({ id: 1, name: '张三', age: 24, email: 'zhangsan@example.com' }); request.onsuccess = function (event) { console.log('数据写入成功'); }; request.onerror = function (event) { console.log('数据写入失败'); } } add(); ``` 上面代码中,写入数据需要新建一个事务。新建时必须指定表格名称和操作模式(“只读”或“读写”)。 新建事务以后,通过`IDBTransaction.objectStore(name)`方法,拿到 IDBObjectStore 对象,再通过表格对象的`add()`方法,向表格写入一条记录。 写入操作是一个异步操作,通过监听连接对象的`success`事件和`error`事件,了解是否写入成功。 #### 读取数据 读取数据也是通过事务完成。 ```js function read() { var transaction = db.transaction(['person']); var objectStore = transaction.objectStore('person'); var request = objectStore.get(1); request.onerror = function(event) { console.log('事务失败'); }; request.onsuccess = function(event) { if (request.result) { console.log('Name: ' + request.result.name); console.log('Age: ' + request.result.age); console.log('Email: ' + request.result.email); } else { console.log('未获得数据记录'); } }; } read(); ``` 上面代码中,`objectStore.get()`方法用于读取数据,参数是主键的值。 #### 遍历数据 遍历数据表格的所有记录,要使用指针对象 IDBCursor。 ```js function readAll() { var objectStore = db.transaction('person').objectStore('person'); objectStore.openCursor().onsuccess = function (event) { var cursor = event.target.result; if (cursor) { console.log('Id: ' + cursor.key); console.log('Name: ' + cursor.value.name); console.log('Age: ' + cursor.value.age); console.log('Email: ' + cursor.value.email); cursor.continue(); } else { console.log('没有更多数据了!'); } }; } readAll(); ``` 上面代码中,新建指针对象的`openCursor()`方法是一个异步操作,所以要监听`success`事件。 #### 更新数据 更新数据要使用`IDBObject.put()`方法。 ```js function update() { var request = db.transaction(['person'], 'readwrite') .objectStore('person') .put({ id: 1, name: '李四', age: 35, email: 'lisi@example.com' }); request.onsuccess = function (event) { console.log('数据更新成功'); }; request.onerror = function (event) { console.log('数据更新失败'); } } update(); ``` 上面代码中,`put()`方法自动更新了主键为`1`的记录。 #### 删除数据 `IDBObjectStore.delete()`方法用于删除记录。 ```js function remove() { var request = db.transaction(['person'], 'readwrite') .objectStore('person') .delete(1); request.onsuccess = function (event) { console.log('数据删除成功'); }; } remove(); ``` #### 使用索引 索引的意义在于,可以让你搜索任意字段,也就是说从任意字段拿到数据记录。如果不建立索引,默认只能搜索主键(即从主键取值)。 假定新建表格的时候,对`name`字段建立了索引。 ```js objectStore.createIndex('name', 'name', { unique: false }); ``` 现在,就可以从`name`找到对应的数据记录了。 ```js var transaction = db.transaction(['person'], 'readonly'); var store = transaction.objectStore('person'); var index = store.index('name'); var request = index.get('李四'); request.onsuccess = function (e) { var result = e.target.result; if (result) { // ... } else { // ... } } ``` ### indexedDB 对象 浏览器原生提供`indexedDB`对象,作为开发者的操作接口。 #### indexedDB.open() `indexedDB.open()`方法用于打开数据库。这是一个异步操作,但是会立刻返回一个 IDBOpenDBRequest 对象。 ```js var openRequest = window.indexedDB.open('test', 1); ``` 上面代码表示,打开一个名为`test`、版本为`1`的数据库。如果该数据库不存在,则会新建该数据库。 `open()`方法的第一个参数是数据库名称,格式为字符串,不可省略;第二个参数是数据库版本,是一个大于`0`的正整数(`0`将报错),如果该参数大于当前版本,会触发数据库升级。第二个参数可省略,如果数据库已存在,将打开当前版本的数据库;如果数据库不存在,将创建该版本的数据库,默认版本为`1`。 打开数据库是异步操作,通过各种事件通知客户端。下面是有可能触发的4种事件。 * **success**:打开成功。 * **error**:打开失败。 * **upgradeneeded**:第一次打开该数据库,或者数据库版本发生变化。 * **blocked**:上一次的数据库连接还未关闭。 第一次打开数据库时,会先触发`upgradeneeded`事件,然后触发`success`事件。 #### indexedDB.deleteDatabase() `indexedDB.deleteDatabase()`方法用于删除一个数据库,参数为数据库的名字。它会立刻返回一个`IDBOpenDBRequest`对象,然后对数据库执行异步删除。删除操作的结果会通过事件通知,`IDBOpenDBRequest`对象可以监听以下事件。 * `success`:删除成功 * `error`:删除报错 调用`deleteDatabase()`方法以后,当前数据库的其他已经打开的连接都会接收到`versionchange`事件。 注意,删除不存在的数据库并不会报错。 #### indexedDB.cmp() `indexedDB.cmp()`方法比较两个值是否为 indexedDB 的相同的主键。它返回一个整数,表示比较的结果:`0`表示相同,`1`表示第一个主键大于第二个主键,`-1`表示第一个主键小于第二个主键。 ```js window.indexedDB.cmp(1, 2) // -1 ``` ### IDBRequest 对象 IDBRequest 对象表示打开的数据库连接,`indexedDB.open()`方法和`indexedDB.deleteDatabase()`方法会返回这个对象。数据库的操作都是通过这个对象完成的。 这个对象的所有操作都是异步操作,要通过`readyState`属性判断是否完成,如果为`pending`就表示操作正在进行,如果为`done`就表示操作完成,可能成功也可能失败。 操作完成以后,触发`success`事件或`error`事件,这时可以通过`result`属性和`error`属性拿到操作结果。如果在`pending`阶段,就去读取这两个属性,是会报错的。 IDBRequest 对象有以下属性。 * `IDBRequest.readyState`:等于`pending`表示操作正在进行,等于`done`表示操作正在完成。 * `IDBRequest.result`:返回请求的结果。如果请求失败、结果不可用,读取该属性会报错。 * `IDBRequest.error`:请求失败时,返回错误对象。 * `IDBRequest.source`:返回请求的来源(比如索引对象或 ObjectStore)。 * `IDBRequest.transaction`:返回当前请求正在进行的事务,如果不包含事务,返回`null`。 * `IDBRequest.onsuccess`:指定`success`事件的监听函数。 * `IDBRequest.onerror`:指定`error`事件的监听函数。 IDBOpenDBRequest 对象继承了 IDBRequest 对象,提供了两个额外的事件监听属性。 * `IDBOpenDBRequest.onblocked`:指定`blocked`事件(`upgradeneeded`事件触发时,数据库仍然在使用)的监听函数。 * `IDBOpenDBRequest.onupgradeneeded`:`upgradeneeded`事件的监听函数。 ### IDBDatabase 对象 打开数据成功以后,可以从`IDBOpenDBRequest`对象的`result`属性上面,拿到一个`IDBDatabase`对象,它表示连接的数据库。后面对数据库的操作,都通过这个对象完成。 #### 属性 IDBDatabase 对象有以下属性。 * `IDBDatabase.name`:字符串,数据库名称。 * `IDBDatabase.version`:整数,数据库版本。数据库第一次创建时,该属性为空字符串。 * `IDBDatabase.objectStoreNames`:DOMStringList 对象(字符串的集合),包含当前数据的所有 object store 的名字。 * `IDBDatabase.onabort`:指定 abort 事件(事务中止)的监听函数。 * `IDBDatabase.onclose`:指定 close 事件(数据库意外关闭)的监听函数。 * `IDBDatabase.onerror`:指定 error 事件(访问数据库失败)的监听函数。 * `IDBDatabase.onversionchange`:数据库版本变化时触发(发生`upgradeneeded`事件,或调用`indexedDB.deleteDatabase()`)。 下面是`objectStoreNames`属性的例子。该属性返回一个 DOMStringList 对象,包含了当前数据库所有对象仓库的名称(即表名),可以使用 DOMStringList 对象的`contains`方法,检查数据库是否包含某个对象仓库。 ```js if (!db.objectStoreNames.contains('firstOS')) { db.createObjectStore('firstOS'); } ``` 上面代码先判断某个对象仓库是否存在,如果不存在就创建该对象仓库。 #### 方法 IDBDatabase 对象有以下方法。 * `IDBDatabase.close()`:关闭数据库连接,实际会等所有事务完成后再关闭。 * `IDBDatabase.createObjectStore()`:创建存放数据的对象仓库,类似于传统关系型数据库的表格,返回一个 IDBObjectStore 对象。该方法只能在`versionchange`事件监听函数中调用。 * `IDBDatabase.deleteObjectStore()`:删除指定的对象仓库。该方法只能在`versionchange`事件监听函数中调用。 * `IDBDatabase.transaction()`:返回一个 IDBTransaction 事务对象。 `createObjectStore()`方法还可以接受第二个对象参数,用来设置对象仓库的属性。 ```js db.createObjectStore('test', { keyPath: 'email' }); db.createObjectStore('test2', { autoIncrement: true }); ``` 上面代码中,`keyPath`属性表示主键(由于主键的值不能重复,所以上例存入之前,必须保证数据的`email`属性值都是不一样的),默认值为`null`;`autoIncrement`属性表示,是否使用自动递增的整数作为主键(第一个数据记录为1,第二个数据记录为2,以此类推),默认为`false`。一般来说,`keyPath`和`autoIncrement`属性只要使用一个就够了,如果两个同时使用,表示主键为递增的整数,且对象不得缺少`keyPath`指定的属性。 下面是`transaction()`方法的例子,该方法用于创建一个数据库事务,返回一个 IDBTransaction 对象。向数据库添加数据之前,必须先创建数据库事务。 ```js var t = db.transaction(['items'], 'readwrite'); ``` `transaction()`方法接受两个参数:第一个参数是一个数组,里面是所涉及的对象仓库,通常是只有一个;第二个参数是一个表示操作类型的字符串。目前,操作类型只有两种:`readonly`(只读)和`readwrite`(读写)。添加数据使用`readwrite`,读取数据使用`readonly`。第二个参数是可选的,省略时默认为`readonly`模式。 ### IDBObjectStore 对象 IDBObjectStore 对象对应一个对象仓库(object store)。`IDBDatabase.createObjectStore()`方法返回的就是一个 IDBObjectStore 对象。 IDBDatabase 对象的`transaction()`返回一个事务对象,该对象的`objectStore()`方法返回 IDBObjectStore 对象,因此可以采用下面的链式写法。 ```js db.transaction(['test'], 'readonly') .objectStore('test') .get(X) .onsuccess = function (e) {} ``` #### 属性 IDBObjectStore 对象有以下属性。 * `IDBObjectStore.indexNames`:返回一个类似数组的对象(DOMStringList),包含了当前对象仓库的所有索引。 * `IDBObjectStore.keyPath`:返回当前对象仓库的主键。 * `IDBObjectStore.name`:返回当前对象仓库的名称。 * `IDBObjectStore.transaction`:返回当前对象仓库所属的事务对象。 * `IDBObjectStore.autoIncrement`:布尔值,表示主键是否会自动递增。 #### 方法 IDBObjectStore 对象有以下方法。 **(1)IDBObjectStore.add()** `IDBObjectStore.add()`用于向对象仓库添加数据,返回一个 IDBRequest 对象。该方法只用于添加数据,如果主键相同会报错,因此更新数据必须使用`put()`方法。 ```js objectStore.add(value, key) ``` 该方法接受两个参数,第一个参数是键值,第二个参数是主键,该参数可选,如果省略默认为`null`。 创建事务以后,就可以获取对象仓库,然后使用`add()`方法往里面添加数据了。 ```js var db; var DBOpenRequest = window.indexedDB.open('demo', 1); DBOpenRequest.onsuccess = function (event) { db = DBOpenRequest.result; var transaction = db.transaction(['items'], 'readwrite'); transaction.oncomplete = function (event) { console.log('transaction success'); }; transaction.onerror = function (event) { console.log('transaction error: ' + transaction.error); }; var objectStore = transaction.objectStore('items'); var objectStoreRequest = objectStore.add({ foo: 1 }); objectStoreRequest.onsuccess = function (event) { console.log('add data success'); }; }; ``` **(2)IDBObjectStore.put()** `IDBObjectStore.put()`方法用于更新某个主键对应的数据记录,如果对应的键值不存在,则插入一条新的记录。该方法返回一个 IDBRequest 对象。 ```js objectStore.put(item, key) ``` 该方法接受两个参数,第一个参数为新数据,第二个参数为主键,该参数可选,且只在自动递增时才有必要提供,因为那时主键不包含在数据值里面。 **(3)IDBObjectStore.clear()** `IDBObjectStore.clear()`删除当前对象仓库的所有记录。该方法返回一个 IDBRequest 对象。 ```js objectStore.clear() ``` 该方法不需要参数。 **(4)IDBObjectStore.delete()** `IDBObjectStore.delete()`方法用于删除指定主键的记录。该方法返回一个 IDBRequest 对象。 ```js objectStore.delete(Key) ``` 该方法的参数为主键的值。 **(5)IDBObjectStore.count()** `IDBObjectStore.count()`方法用于计算记录的数量。该方法返回一个 IDBRequest 对象。 ```js IDBObjectStore.count(key) ``` 不带参数时,该方法返回当前对象仓库的所有记录数量。如果主键或 IDBKeyRange 对象作为参数,则返回对应的记录数量。 **(6)IDBObjectStore.getKey()** `IDBObjectStore.getKey()`用于获取主键。该方法返回一个 IDBRequest 对象。 ```js objectStore.getKey(key) ``` 该方法的参数可以是主键值或 IDBKeyRange 对象。 **(7)IDBObjectStore.get()** `IDBObjectStore.get()`用于获取主键对应的数据记录。该方法返回一个 IDBRequest 对象。 ```js objectStore.get(key) ``` **(8)IDBObjectStore.getAll()** `DBObjectStore.getAll()`用于获取对象仓库的记录。该方法返回一个 IDBRequest 对象。 ```js // 获取所有记录 objectStore.getAll() // 获取所有符合指定主键或 IDBKeyRange 的记录 objectStore.getAll(query) // 指定获取记录的数量 objectStore.getAll(query, count) ``` **(9)IDBObjectStore.getAllKeys()** `IDBObjectStore.getAllKeys()`用于获取所有符合条件的主键。该方法返回一个 IDBRequest 对象。 ```js // 获取所有记录的主键 objectStore.getAllKeys() // 获取所有符合条件的主键 objectStore.getAllKeys(query) // 指定获取主键的数量 objectStore.getAllKeys(query, count) ``` **(10)IDBObjectStore.index()** `IDBObjectStore.index()`方法返回指定名称的索引对象 IDBIndex。 ```js objectStore.index(name) ``` 有了索引以后,就可以针对索引所在的属性读取数据。 ```js var t = db.transaction(['people'], 'readonly'); var store = t.objectStore('people'); var index = store.index('name'); var request = index.get('foo'); ``` 上面代码打开对象仓库以后,先用`index()`方法指定获取`name`属性的索引,然后用`get()`方法读取某个`name`属性(`foo`)对应的数据。如果`name`属性不是对应唯一值,这时`get()`方法有可能取回多个数据对象。另外,`get()`是异步方法,读取成功以后,只能在`success`事件的监听函数中处理数据。 **(11)IDBObjectStore.createIndex()** `IDBObjectStore.createIndex()`方法用于新建当前数据库的一个索引。该方法只能在`VersionChange`监听函数里面调用。 ```js objectStore.createIndex(indexName, keyPath, objectParameters) ``` 该方法可以接受三个参数。 * indexName:索引名 * keyPath:主键 * objectParameters:配置对象(可选) 第三个参数可以配置以下属性。 * unique:如果设为`true`,将不允许重复的值 * multiEntry:如果设为`true`,对于有多个值的主键数组,每个值将在索引里面新建一个条目,否则主键数组对应一个条目。 假定对象仓库中的数据记录都是如下的`person`类型。 ```js var person = { name: name, email: email, created: new Date() }; ``` 可以指定这个对象的某个属性来建立索引。 ```js var store = db.createObjectStore('people', { autoIncrement: true }); store.createIndex('name', 'name', { unique: false }); store.createIndex('email', 'email', { unique: true }); ``` 上面代码告诉索引对象,`name`属性不是唯一值,`email`属性是唯一值。 **(12)IDBObjectStore.deleteIndex()** `IDBObjectStore.deleteIndex()`方法用于删除指定的索引。该方法只能在`VersionChange`监听函数里面调用。 ```js objectStore.deleteIndex(indexName) ``` **(13)IDBObjectStore.openCursor()** `IDBObjectStore.openCursor()`用于获取一个指针对象。 ```js IDBObjectStore.openCursor() ``` 指针对象可以用来遍历数据。该对象也是异步的,有自己的`success`和`error`事件,可以对它们指定监听函数。 ```js var t = db.transaction(['test'], 'readonly'); var store = t.objectStore('test'); var cursor = store.openCursor(); cursor.onsuccess = function (event) { var res = event.target.result; if (res) { console.log('Key', res.key); console.dir('Data', res.value); res.continue(); } } ``` 监听函数接受一个事件对象作为参数,该对象的`target.result`属性指向当前数据记录。该记录的`key`和`value`分别返回主键和键值(即实际存入的数据)。`continue()`方法将光标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向`null`。 `openCursor()`方法的第一个参数是主键值,或者一个 IDBKeyRange 对象。如果指定该参数,将只处理包含指定主键的记录;如果省略,将处理所有的记录。该方法还可以接受第二个参数,表示遍历方向,默认值为`next`,其他可能的值为`prev`、`nextunique`和`prevunique`。后两个值表示如果遇到重复值,会自动跳过。 **(14)IDBObjectStore.openKeyCursor()** `IDBObjectStore.openKeyCursor()`用于获取一个主键指针对象。 ```js IDBObjectStore.openKeyCursor() ``` ### IDBTransaction 对象 IDBTransaction 对象用来异步操作数据库事务,所有的读写操作都要通过这个对象进行。 `IDBDatabase.transaction()`方法返回的就是一个 IDBTransaction 对象。 ```js var db; var DBOpenRequest = window.indexedDB.open('demo', 1); DBOpenRequest.onsuccess = function(event) { db = DBOpenRequest.result; var transaction = db.transaction(['demo'], 'readwrite'); transaction.oncomplete = function (event) { console.log('transaction success'); }; transaction.onerror = function (event) { console.log('transaction error: ' + transaction.error); }; var objectStore = transaction.objectStore('demo'); var objectStoreRequest = objectStore.add({ foo: 1 }); objectStoreRequest.onsuccess = function (event) { console.log('add data success'); }; }; ``` 事务的执行顺序是按照**创建的顺序**,而不是发出请求的顺序。 ```js var trans1 = db.transaction('foo', 'readwrite'); var trans2 = db.transaction('foo', 'readwrite'); var objectStore2 = trans2.objectStore('foo') var objectStore1 = trans1.objectStore('foo') objectStore2.put('2', 'key'); objectStore1.put('1', 'key'); ``` 上面代码中,`key`对应的键值最终是`2`,而不是`1`。因为事务`trans1`先于`trans2`创建,所以首先执行。 注意,事务有可能失败,只有监听到事务的`complete`事件,才能保证事务操作成功。 IDBTransaction 对象有以下属性。 * `IDBTransaction.db`:返回当前事务所在的数据库对象 IDBDatabase。 * `IDBTransaction.error`:返回当前事务的错误。如果事务没有结束,或者事务成功结束,或者被手动终止,该方法返回`null`。 * `IDBTransaction.mode`:返回当前事务的模式,默认是`readonly`(只读),另一个值是`readwrite`。 * `IDBTransaction.objectStoreNames`:返回一个类似数组的对象 DOMStringList,成员是当前事务涉及的对象仓库的名字。 * `IDBTransaction.onabort`:指定`abort`事件(事务中断)的监听函数。 * `IDBTransaction.oncomplete`:指定`complete`事件(事务成功)的监听函数。 * `IDBTransaction.onerror`:指定`error`事件(事务失败)的监听函数。 IDBTransaction 对象有以下方法。 * `IDBTransaction.abort()`:终止当前事务,回滚所有已经进行的变更。 * `IDBTransaction.objectStore(name)`:返回指定名称的对象仓库 IDBObjectStore。 ### IDBIndex 对象 IDBIndex 对象代表数据库的索引,通过这个对象可以获取数据库里面的记录。 数据记录的主键默认就是带有索引,IDBIndex 对象主要用于通过除主键以外的其他键,建立索引获取对象。 IDBIndex 是持久性的键值对存储。只要插入、更新或删除数据记录,引用的对象库中的记录,索引就会自动更新。 `IDBObjectStore.index()`方法可以获取 IDBIndex 对象。 ```js var transaction = db.transaction(['contactsList'], 'readonly'); var objectStore = transaction.objectStore('contactsList'); var myIndex = objectStore.index('lName'); myIndex.openCursor().onsuccess = function (event) { var cursor = event.target.result; if (cursor) { var tableRow = document.createElement('tr'); tableRow.innerHTML = '' + cursor.value.id + '' + '' + cursor.value.lName + '' + '' + cursor.value.fName + '' + '' + cursor.value.jTitle + '' + '' + cursor.value.company + '' + '' + cursor.value.eMail + '' + '' + cursor.value.phone + '' + '' + cursor.value.age + ''; tableEntry.appendChild(tableRow); cursor.continue(); } else { console.log('Entries all displayed.'); } }; ``` IDBIndex 对象有以下属性。 * `IDBIndex.name`:字符串,索引的名称。 * `IDBIndex.objectStore`:索引所在的对象仓库。 * `IDBIndex.keyPath`:索引的主键。 * `IDBIndex.multiEntry`:布尔值,针对`keyPath`为数组的情况,如果设为`true`,创建数组时,每个数组成员都会有一个条目,否则每个数组都只有一个条目。 * `IDBIndex.unique`:布尔值,表示创建索引时是否允许相同的主键。 IDBIndex 对象有以下方法,它们都是异步的,立即返回的都是一个 IDBRequest 对象。 * `IDBIndex.count()`:用来获取记录的数量。它可以接受主键或 IDBKeyRange 对象作为参数,这时只返回符合主键的记录数量,否则返回所有记录的数量。 * `IDBIndex.get(key)`:用来获取符合指定主键的数据记录。 * `IDBIndex.getKey(key)`:用来获取指定的主键。 * `IDBIndex.getAll()`:用来获取所有的数据记录。它可以接受两个参数,都是可选的,第一个参数用来指定主键,第二个参数用来指定返回记录的数量。如果省略这两个参数,则返回所有记录。由于获取成功时,浏览器必须生成所有对象,所以对性能有影响。如果数据集比较大,建议使用 IDBCursor 对象。 * `IDBIndex.getAllKeys()`:该方法与`IDBIndex.getAll()`方法相似,区别是获取所有主键。 * `IDBIndex.openCursor()`:用来获取一个 IDBCursor 对象,用来遍历索引里面的所有条目。 * `IDBIndex.openKeyCursor()`:该方法与`IDBIndex.openCursor()`方法相似,区别是遍历所有条目的主键。 ### IDBCursor 对象 IDBCursor 对象代表指针对象,用来遍历数据仓库(IDBObjectStore)或索引(IDBIndex)的记录。 IDBCursor 对象一般通过`IDBObjectStore.openCursor()`方法获得。 ```js var transaction = db.transaction(['rushAlbumList'], 'readonly'); var objectStore = transaction.objectStore('rushAlbumList'); objectStore.openCursor(null, 'next').onsuccess = function(event) { var cursor = event.target.result; if (cursor) { var listItem = document.createElement('li'); listItem.innerHTML = cursor.value.albumTitle + ', ' + cursor.value.year; list.appendChild(listItem); console.log(cursor.source); cursor.continue(); } else { console.log('Entries all displayed.'); } }; ``` IDBCursor 对象的属性。 * `IDBCursor.source`:返回正在遍历的对象仓库或索引。 * `IDBCursor.direction`:字符串,表示指针遍历的方向。共有四个可能的值:next(从头开始向后遍历)、nextunique(从头开始向后遍历,重复的值只遍历一次)、prev(从尾部开始向前遍历)、prevunique(从尾部开始向前遍历,重复的值只遍历一次)。该属性通过`IDBObjectStore.openCursor()`方法的第二个参数指定,一旦指定就不能改变了。 * `IDBCursor.key`:返回当前记录的主键。 * `IDBCursor.value`:返回当前记录的数据值。 * `IDBCursor.primaryKey`:返回当前记录的主键。对于数据仓库(objectStore)来说,这个属性等同于 IDBCursor.key;对于索引,IDBCursor.key 返回索引的位置值,该属性返回数据记录的主键。 IDBCursor 对象有如下方法。 * `IDBCursor.advance(n)`:指针向前移动 n 个位置。 * `IDBCursor.continue()`:指针向前移动一个位置。它可以接受一个主键作为参数,这时会跳转到这个主键。 * `IDBCursor.continuePrimaryKey()`:该方法需要两个参数,第一个是`key`,第二个是`primaryKey`,将指针移到符合这两个参数的位置。 * `IDBCursor.delete()`:用来删除当前位置的记录,返回一个 IDBRequest 对象。该方法不会改变指针的位置。 * `IDBCursor.update()`:用来更新当前位置的记录,返回一个 IDBRequest 对象。它的参数是要写入数据库的新的值。 ### IDBKeyRange 对象 IDBKeyRange 对象代表数据仓库(object store)里面的一组主键。根据这组主键,可以获取数据仓库或索引里面的一组记录。 IDBKeyRange 可以只包含一个值,也可以指定上限和下限。它有四个静态方法,用来指定主键的范围。 * `IDBKeyRange.lowerBound()`:指定下限。 * `IDBKeyRange.upperBound()`:指定上限。 * `IDBKeyRange.bound()`:同时指定上下限。 * `IDBKeyRange.only()`:指定只包含一个值。 下面是一些代码实例。 ```js // All keys ≤ x var r1 = IDBKeyRange.upperBound(x); // All keys < x var r2 = IDBKeyRange.upperBound(x, true); // All keys ≥ y var r3 = IDBKeyRange.lowerBound(y); // All keys > y var r4 = IDBKeyRange.lowerBound(y, true); // All keys ≥ x && ≤ y var r5 = IDBKeyRange.bound(x, y); // All keys > x &&< y var r6 = IDBKeyRange.bound(x, y, true, true); // All keys > x && ≤ y var r7 = IDBKeyRange.bound(x, y, true, false); // All keys ≥ x &&< y var r8 = IDBKeyRange.bound(x, y, false, true); // The key = z var r9 = IDBKeyRange.only(z); ``` `IDBKeyRange.lowerBound()`、`IDBKeyRange.upperBound()`、`IDBKeyRange.bound()`这三个方法默认包括端点值,可以传入一个布尔值,修改这个属性。 与之对应,IDBKeyRange 对象有四个只读属性。 * `IDBKeyRange.lower`:返回下限 * `IDBKeyRange.lowerOpen`:布尔值,表示下限是否为开区间(即下限是否排除在范围之外) * `IDBKeyRange.upper`:返回上限 * `IDBKeyRange.upperOpen`:布尔值,表示上限是否为开区间(即上限是否排除在范围之外) IDBKeyRange 实例对象生成以后,将它作为参数输入 IDBObjectStore 或 IDBIndex 对象的`openCursor()`方法,就可以在所设定的范围内读取数据。 ```js var t = db.transaction(['people'], 'readonly'); var store = t.objectStore('people'); var index = store.index('name'); var range = IDBKeyRange.bound('B', 'D'); index.openCursor(range).onsuccess = function (e) { var cursor = e.target.result; if (cursor) { console.log(cursor.key + ':'); for (var field in cursor.value) { console.log(cursor.value[field]); } cursor.continue(); } } ``` IDBKeyRange 有一个实例方法`includes(key)`,返回一个布尔值,表示某个主键是否包含在当前这个主键组之内。 ```js var keyRangeValue = IDBKeyRange.bound('A', 'K', false, false); keyRangeValue.includes('F') // true keyRangeValue.includes('W') // false ``` ## Web Worker ### 概述 JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。 前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。 Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。 这样的好处是,一些计算密集型或高延迟的任务可以交由 Worker 线程执行,主线程(通常负责 UI 交互)能够保持流畅,不会被阻塞或拖慢。 Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。 Web Worker 的注意点: (1)**同源限制** 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。 (2)**DOM 限制** Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用`document`、`window`、`parent`这些对象。但是,Worker 线程可以使用`navigator`对象和`location`对象。 (3)**全局对象限制** Worker 的全局对象`WorkerGlobalScope`,不同于网页的全局对象`Window`,很多接口拿不到。比如,理论上 Worker 线程不能使用`console.log`,因为标准里面没有提到 Worker 的全局对象存在`console`接口,只定义了`Navigator`接口和`Location`接口。不过,浏览器实际上支持 Worker 线程使用`console.log`,保险的做法还是不使用这个方法。 (4)**通信联系** Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。 (5)**脚本限制** Worker 线程不能执行`alert()`方法和`confirm()`方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。 (6)**文件限制** Worker 线程无法读取本地文件,即不能打开本机的文件系统(`file://`),它所加载的脚本,必须来自网络。 ### 基本用法 #### 主线程 主线程采用`new`命令,调用`Worker()`构造函数,新建一个 Worker 线程。 ```js var worker = new Worker('work.js'); ``` `Worker()`构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。 然后,主线程调用`worker.postMessage()`方法,向 Worker 发消息。 ```js worker.postMessage('Hello World'); worker.postMessage({method: 'echo', args: ['Work']}); ``` `worker.postMessage()`方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。 接着,主线程通过`worker.onmessage`指定监听函数,接收子线程发回来的消息。 ```js worker.onmessage = function (event) { doSomething(event.data); } function doSomething() { // 执行任务 worker.postMessage('Work done!'); } ``` 上面代码中,事件对象的`data`属性可以获取 Worker 发来的数据。 Worker 完成任务以后,主线程就可以把它关掉。 ```js worker.terminate(); ``` #### Worker 线程 Worker 线程内部需要有一个监听函数,监听`message`事件。 ```js self.addEventListener('message', function (e) { self.postMessage('You said: ' + e.data); }, false); ``` 上面代码中,`self`代表子线程自身,即子线程的全局对象。因此,等同于下面两种写法。 ```js // 写法一 this.addEventListener('message', function (e) { this.postMessage('You said: ' + e.data); }, false); // 写法二 addEventListener('message', function (e) { postMessage('You said: ' + e.data); }, false); ``` 除了使用`self.addEventListener()`指定监听函数,也可以使用`self.onmessage`指定。 监听函数的参数是一个事件对象,它的`data`属性包含主线程发来的数据。`self.postMessage()`方法用来向主线程发送消息。 根据主线程发来的数据,Worker 线程可以调用不同的方法,下面是一个例子。 ```js self.addEventListener('message', function (e) { var data = e.data; switch (data.cmd) { case 'start': self.postMessage('WORKER STARTED: ' + data.msg); break; case 'stop': self.postMessage('WORKER STOPPED: ' + data.msg); self.close(); // Terminates the worker. break; default: self.postMessage('Unknown command: ' + data.msg); }; }, false); ``` 上面代码中,`self.close()`用于在 Worker 内部关闭自身。 #### Worker 加载脚本 Worker 内部如果要加载其他脚本,有一个专门的方法`importScripts()`。 ```js importScripts('script1.js'); ``` 该方法可以同时加载多个脚本。 ```js importScripts('script1.js', 'script2.js'); ``` #### 错误处理 主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的`error`事件。 ```js worker.onerror = function (event) { console.log( 'ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message ); }; // 或者 worker.addEventListener('error', function (event) { // ... }); ``` Worker 内部也可以监听`error`事件。 #### 关闭 Worker 使用完毕,为了节省系统资源,必须关闭 Worker。 ```js // 主线程 worker.terminate(); // Worker 线程 self.close(); ``` ### 数据通信 前面说过,主线程与 Worker 之间的通信内容,可以是文本,也可以是对象。需要注意的是,这种通信是拷贝关系,即是传值而不是传址,Worker 对通信内容的修改,不会影响到主线程。 事实上,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。 主线程与 Worker 之间也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等类型,也可以在线程之间发送。下面是一个例子。 ```js // 主线程 var uInt8Array = new Uint8Array(new ArrayBuffer(10)); for (var i = 0; i < uInt8Array.length; ++i) { uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...] } worker.postMessage(uInt8Array); // Worker 线程 self.onmessage = function (e) { var uInt8Array = e.data; postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString()); postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength); }; ``` 但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。 这种转移数据的方法,叫做[Transferable Objects](http://www.w3.org/html/wg/drafts/html/master/infrastructure.html#transferable-objects)。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。 如果要直接转移数据的控制权,就要使用下面的写法。 ```js // Transferable Objects 格式 worker.postMessage(arrayBuffer, [arrayBuffer]); // 例子 var ab = new ArrayBuffer(1); worker.postMessage(ab, [ab]); ``` ### 同页面的 Web Worker 通常情况下,Worker 载入的是一个单独的 JavaScript 脚本文件,但是也可以载入与主线程在同一个网页的代码。 ```html ``` 上面是一段嵌入网页的脚本,注意必须指定` ``` 上面的代码,浏览器不会执行,也不会显示它的内容,因为不认识它的`type`属性。 但是,这个` ... ``` 上面代码执行时会报错,因为此时`document.body`元素还未生成。 一种解决方法是设定`DOMContentLoaded`事件的回调函数。 ```js ``` 上面代码中,指定`DOMContentLoaded`事件发生后,才开始执行相关代码。`DOMContentLoaded`事件只有在 DOM 结构生成之后才会触发。 当` ``` 如果将脚本放在页面底部,就可以完全按照正常的方式写。 如果有多个`script`标签,比如下面这样。 ```js ``` 浏览器会同时并行下载`a.js`和`b.js`,但是,执行时会保证先执行`a.js`,然后再执行`b.js`,即使后者先下载完成,也是如此。 也就是说,脚本的执行顺序由它们在页面中的出现顺序决定,这是为了保证脚本之间的依赖关系不受到破坏。 当然,加载这两个脚本都会产生“**阻塞效应**”,必须等到它们都加载完成,浏览器才会继续页面渲染。 解析和执行 CSS,也会产生阻塞。Firefox 浏览器会等到脚本前面的所有样式表,都下载并解析完,再执行脚本;Webkit则是一旦发现脚本引用了样式,就会暂停执行脚本,等到样式表下载并解析完,再恢复执行。 此外,对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般有限制,同时最多下载6~20个资源,即最多同时打开的 TCP 连接有限制,这是为了防止对服务器造成太大压力。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。 ### defer 属性 为了解决脚本文件下载阻塞网页渲染的问题,一个方法是对` ``` 如果要采用 HTTPS 协议下载,必需写明。 但是有时我们会希望,根据页面本身的协议来决定加载协议,这时可以采用下面的写法。 ```js ``` ## 浏览器的组成 浏览器的核心是两部分:**渲染引擎**和 **JavaScript 解释器**(又称 JavaScript 引擎)。 ### 渲染引擎 渲染引擎的主要作用是,将网页代码渲染为用户视觉可以感知的平面文档。 渲染引擎处理网页,通常分成四个阶段。 1. 解析代码:HTML 代码解析为 DOM,CSS 代码解析为 CSSOM(CSS Object Model)。 2. 对象合成:将 DOM 和 CSSOM 合成一棵渲染树(render tree)。 3. 布局:计算出渲染树的布局(layout)。 4. 绘制:将渲染树绘制到屏幕。 以上四步并非严格按顺序执行,往往第一步还没完成,第二步和第三步就已经开始了。所以,会看到这种情况:网页的 HTML 代码还没下载完,但浏览器已经显示出内容了。 ### 重流和重绘 渲染树转换为网页布局,称为“布局流”(flow)。 布局显示到页面的这个过程,称为“绘制”(paint)。它们都具有阻塞效应,并且会耗费很多时间和计算资源。 页面生成以后,脚本操作和样式表操作,都会触发“重流”(reflow)和“重绘”(repaint)。 用户的互动也会触发重流和重绘,比如设置了鼠标悬停(`a:hover`)效果、页面滚动、在输入框中输入文本、改变窗口大小等等。 重流和重绘并不一定一起发生,重流必然导致重绘,重绘不一定需要重流。比如改变元素颜色,只会导致重绘,而不会导致重流;改变元素的布局,则会导致重绘和重流。 大多数情况下,浏览器会智能判断,将重流和重绘只限制到相关的子树上面,最小化所耗费的代价,而不会全局重新生成网页。 作为开发者,应该尽量设法降低重绘的次数和成本。 比如,尽量不要变动高层的 DOM 元素,而以底层 DOM 元素的变动代替;再比如,重绘`table`布局和`flex`布局,开销都会比较大。 ```js var foo = document.getElementById('foobar'); foo.style.color = 'blue'; foo.style.marginTop = '30px'; ``` 上面的代码只会导致一次重绘,因为浏览器会累积 DOM 变动,然后一次性执行。 下面是一些优化技巧。 * 读取 DOM 或者写入 DOM,尽量写在一起,不要混杂。不要读取一个 DOM 节点,然后立刻写入,接着再读取一个 DOM 节点。 * 缓存 DOM 信息。 * 不要一项一项地改变样式,而是使用 CSS class 一次性改变样式。 * 使用`documentFragment`操作 DOM * 动画使用`absolute`定位或`fixed`定位,这样可以减少对其他元素的影响。 * 只在必要时才显示隐藏元素。 * 使用`window.requestAnimationFrame()`,因为它可以把代码推迟到下一次重绘之前执行,而不是立即要求页面重绘。 * 使用虚拟 DOM(virtual DOM)库。 ### JavaScript 引擎 JavaScript 引擎的主要作用是,读取网页中的 JavaScript 代码,对其处理后运行。 JavaScript 是一种解释型语言,也就是说,它不需要编译,由解释器实时运行。 这样的好处是运行和修改都比较方便,刷新页面就可以重新解释;缺点是每次运行都要调用解释器,系统开销较大,运行速度慢于编译型语言。 为了提高运行速度,目前的浏览器都将 JavaScript 进行一定程度的编译,生成类似字节码(bytecode)的中间代码,以提高运行速度。 早期,浏览器内部对 JavaScript 的处理过程如下: 1. 读取代码,进行词法分析(Lexical analysis),将代码分解成词元(token)。 2. 对词元进行语法分析(parsing),将代码整理成“语法树”(syntax tree)。 3. 使用“翻译器”(translator),将代码转为字节码(bytecode)。 4. 使用“字节码解释器”(bytecode interpreter),将字节码转为机器码。 逐行解释将字节码转为机器码,是很低效的。为了提高运行速度,现代浏览器改为采用“即时编译”(Just In Time compiler,缩写 JIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升。 字节码不能直接运行,而是运行在一个虚拟机(Virtual Machine)之上,一般也把虚拟机称为 JavaScript 引擎。并非所有的 JavaScript 虚拟机运行时都有字节码,有的 JavaScript 虚拟机基于源码,即只要有可能,就通过 JIT(just in time)编译器直接把源码编译成机器码运行,省略字节码步骤。这一点与其他采用虚拟机(比如 Java)的语言不尽相同。这样做的目的,是为了尽可能地优化代码、提高性能。下面是目前最常见的一些 JavaScript 虚拟机: ## window 对象 ### window.closed 这个属性一般用来检查,使用脚本打开的新窗口是否关闭。 ```js var popup = window.open(); if ((popup !== null) && !popup.closed) { // 窗口仍然打开着 } ``` ### window.opener `window.opener`属性表示打开当前窗口的父窗口。如果当前窗口没有父窗口(即直接在地址栏输入打开),则返回`null`。 ```js window.open().opener === window // true ``` ```js var newWin = window.open('example.html', 'newWindow', 'height=400,width=400'); newWin.opener = null; ``` 上面代码中,子窗口的`opener`属性设为`null`,两个窗口之间就没办法再联系了。 通过`opener`属性,可以获得父窗口的全局属性和方法,但只限于两个窗口同源的情况,且其中一个窗口由另一个打开。``元素添加`rel="noopener"`属性,可以防止新打开的窗口获取父窗口,减轻被恶意网站修改父窗口 URL 的风险。 ### 位置大小属性 #### **(1)window.screenX,window.screenY** `window.screenX`和`window.screenY`属性,返回浏览器窗口左上角相对于当前屏幕左上角的水平距离和垂直距离(单位像素)。这两个属性只读。 #### **(2) window.innerHeight,window.innerWidth** `window.innerHeight`和`window.innerWidth`属性,返回网页在当前窗口中可见部分的高度和宽度,即“视口”(viewport)的大小(单位像素)。这两个属性只读。 用户放大网页的时候(比如将网页从100%的大小放大为200%),这两个属性会变小。因为这时网页的像素大小不变(比如宽度还是960像素),只是每个像素占据的屏幕空间变大了,因此可见部分(视口)就变小了。 注意,这两个属性值包括滚动条的高度和宽度。 #### **(3)window.outerHeight,window.outerWidth** `window.outerHeight`和`window.outerWidth`属性返回浏览器窗口的高度和宽度,包括浏览器菜单和边框(单位像素)。这两个属性只读。 #### **(4)window.scrollX,window.scrollY** `window.scrollX`属性返回页面的水平滚动距离,`window.scrollY`属性返回页面的垂直滚动距离,单位都为像素。这两个属性只读。 注意,这两个属性的返回值不是整数,而是双精度浮点数。如果页面没有滚动,它们的值就是`0`。 举例来说,如果用户向下拉动了垂直滚动条75像素,那么`window.scrollY`就是75左右。用户水平向右拉动水平滚动条200像素,`window.scrollX`就是200左右。 ```js if (window.scrollY < 75) { window.scroll(0, 75); } ``` 上面代码中,如果页面向下滚动的距离小于75像素,那么页面向下滚动75像素。 #### **(5)window.pageXOffset,window.pageYOffset** `window.pageXOffset`属性和`window.pageYOffset`属性,是`window.scrollX`和`window.scrollY`别名。 ### 事件 `window`对象可以接收以下事件。 #### load 事件和 onload 属性 `load`事件发生在文档在浏览器窗口加载完毕时。`window.onload`属性可以指定这个事件的回调函数。 ```js window.onload = function() {...} ``` #### error 事件和 onerror 属性 浏览器脚本发生错误时,会触发`window`对象的`error`事件。我们可以通过`window.onerror`属性对该事件指定回调函数。 ### 方法 `window.alert()`、`window.prompt()`、`window.confirm()`都是浏览器与用户互动的全局方法。它们会弹出不同的对话框,要求用户做出回应。注意,这三个方法弹出的对话框,都是浏览器统一规定的式样,无法定制。 这三个方法都具有堵塞效应,一旦弹出对话框,整个页面就是暂停执行,等待用户做出反应。 #### window.alert() `window.alert()`方法弹出的对话框,只有一个“确定”按钮,往往用来通知用户某些信息。 可以用`\n`指定换行。 #### window.prompt() `window.prompt()`方法弹出的对话框,提示文字的下方,还有一个输入框,要求用户输入信息,并有“确定”和“取消”两个按钮。它往往用来获取用户输入的数据。 ```js var result = prompt('您的年龄?', 25) ``` 上面代码会跳出一个对话框,文字提示为“您的年龄?”,要求用户在对话框中输入自己的年龄(默认显示25)。用户填入的值,会作为返回值存入变量`result`。 `window.prompt()`方法的第二个参数是可选的,但是最好总是提供第二个参数,作为输入框的默认值。 #### window.confirm() `window.confirm()`方法弹出的对话框,除了提示信息之外,只有“确定”和“取消”两个按钮,往往用来征询用户是否同意。 `confirm`方法返回一个布尔值,如果用户点击“确定”,返回`true`;如果用户点击“取消”,则返回`false`。 #### window.open() `window.open`方法用于新建另一个浏览器窗口,类似于浏览器菜单的新建窗口选项。它会返回新窗口的引用,如果无法新建窗口,则返回`null`。 ```js var popup = window.open('somefile.html'); ``` 上面代码会让浏览器弹出一个新建窗口,网址是当前域名下的`somefile.html`。 `open`方法一共可以接受三个参数。 ```js window.open(url, windowName, [windowFeatures]) ``` * `url`:字符串,表示新窗口的网址。如果省略,默认网址就是`about:blank`。 * `windowName`:字符串,表示新窗口的名字。如果该名字的窗口已经存在,则占用该窗口,不再新建窗口。如果省略,就默认使用`_blank`,表示新建一个没有名字的窗口。另外还有几个预设值,`_self`表示当前窗口,`_top`表示顶层窗口,`_parent`表示上一层窗口。 * `windowFeatures`:字符串,内容为逗号分隔的键值对(详见下文),表示新窗口的参数,比如有没有提示栏、工具条等等。如果省略,则默认打开一个完整 UI 的新窗口。如果新建的是一个已经存在的窗口,则该参数不起作用,浏览器沿用以前窗口的参数。 ```js var popup = window.open( 'somepage.html', 'DefinitionsWindows', 'height=200,width=200,location=no,status=yes,resizable=yes,scrollbars=yes' ); ``` 上面代码表示,打开的新窗口高度和宽度都为200像素,没有地址栏,但有状态栏和滚动条,允许用户调整大小。 第三个参数可以设定如下属性。 * left:新窗口距离屏幕最左边的距离(单位像素)。注意,新窗口必须是可见的,不能设置在屏幕以外的位置。 * top:新窗口距离屏幕最顶部的距离(单位像素)。 * height:新窗口内容区域的高度(单位像素),不得小于100。 * width:新窗口内容区域的宽度(单位像素),不得小于100。 * outerHeight:整个浏览器窗口的高度(单位像素),不得小于100。 * outerWidth:整个浏览器窗口的宽度(单位像素),不得小于100。 * menubar:是否显示菜单栏。 * toolbar:是否显示工具栏。 * location:是否显示地址栏。 * personalbar:是否显示用户自己安装的工具栏。 * status:是否显示状态栏。 * dependent:是否依赖父窗口。如果依赖,那么父窗口最小化,该窗口也最小化;父窗口关闭,该窗口也关闭。 * minimizable:是否有最小化按钮,前提是`dialog=yes`。 * noopener:新窗口将与父窗口切断联系,即新窗口的`window.opener`属性返回`null`,父窗口的`window.open()`方法也返回`null`。 * resizable:新窗口是否可以调节大小。 * scrollbars:是否允许新窗口出现滚动条。 * dialog:新窗口标题栏是否出现最大化、最小化、恢复原始大小的控件。 * titlebar:新窗口是否显示标题栏。 * alwaysRaised:是否显示在所有窗口的顶部。 * alwaysLowered:是否显示在父窗口的底下。 * close:新窗口是否显示关闭按钮。 对于那些可以打开和关闭的属性,设为`yes`或`1`或不设任何值就表示打开,比如`status=yes`、`status=1`、`status`都会得到同样的结果。如果想设为关闭,不用写`no`,而是直接省略这个属性即可。也就是说,如果在第三个参数中设置了一部分属性,其他没有被设置的`yes/no`属性都会被设成`no`,只有`titlebar`和关闭按钮除外(它们的值默认为`yes`)。 上面这些属性,属性名与属性值之间用等号连接,属性与属性之间用逗号分隔。 ```js 'height=200,width=200,location=no,status=yes,resizable=yes,scrollbars=yes' ``` 另外,`open()`方法的第二个参数虽然可以指定已经存在的窗口,但是不等于可以任意控制其他窗口。为了防止被不相干的窗口控制,浏览器只有在两个窗口同源,或者目标窗口被当前网页打开的情况下,才允许`open`方法指向该窗口。 `window.open`方法返回新窗口的引用。 ```js var windowB = window.open('windowB.html', 'WindowB'); windowB.window.name // "WindowB" ``` 注意,如果新窗口和父窗口不是同源的(即不在同一个域),它们彼此不能获取对方窗口对象的内部属性。 ```js var w = window.open(); console.log('已经打开新窗口'); w.location = 'http://example.com'; ``` 上面代码先打开一个新窗口,然后在该窗口弹出一个对话框,再将网址导向`example.com`。 由于`open`这个方法很容易被滥用,**许多浏览器默认都不允许脚本自动新建窗口**。 只允许在用户点击链接或按钮时,脚本做出反应,弹出新窗口。因此,有必要检查一下打开新窗口是否成功。 ```js var popup = window.open(); if (popup === null) { // 新建窗口失败 } ``` #### window.close() `window.close`方法用于关闭当前窗口,一般只用来关闭`window.open`方法新建的窗口。 ```js popup.close() ``` 该方法只对顶层窗口有效,`iframe`框架之中的窗口使用该方法无效。 #### window.stop() `window.stop()`方法完全等同于单击浏览器的停止按钮,会停止加载图像、视频等正在或等待加载的对象。 #### window.moveTo() `window.moveTo()`方法用于移动浏览器窗口到指定位置。它接受两个参数,分别是窗口左上角距离屏幕左上角的水平距离和垂直距离,单位为像素。 ```js window.moveTo(100, 200) ``` 上面代码将窗口移动到屏幕`(100, 200)`的位置。 #### window.moveBy() `window.moveBy()`方法将窗口移动到一个相对位置。它接受两个参数,分别是窗口左上角向右移动的水平距离和向下移动的垂直距离,单位为像素。 ```js window.moveBy(25, 50) ``` 上面代码将窗口向右移动25像素、向下移动50像素。 为了防止有人滥用这两个方法,随意移动用户的窗口,目前只有一种情况,浏览器允许用脚本移动窗口:该窗口是用`window.open()`方法新建的,并且窗口里只有它一个 Tab 页。除此以外的情况,使用上面两个方法都是无效的。 #### window.resizeTo() `window.resizeTo()`方法用于缩放窗口到指定大小。 #### window.resizeBy() `window.resizeBy()`方法用于缩放窗口。它与`window.resizeTo()`的区别是,它按照相对的量缩放,`window.resizeTo()`需要给出缩放后的绝对大小。 #### window.scrollTo() `window.scrollTo`方法用于将文档滚动到指定位置。它接受两个参数,表示滚动后位于窗口左上角的页面坐标。 ```js window.scrollTo(x-coord, y-coord) ``` 它也可以接受一个配置对象作为参数。 ```js window.scrollTo(options) ``` 配置对象`options`有三个属性。 * `top`:滚动后页面左上角的垂直坐标,即 y 坐标。 * `left`:滚动后页面左上角的水平坐标,即 x 坐标。 * `behavior`:字符串,表示滚动的方式,有三个可能值(`smooth`、`instant`、`auto`),默认值为`auto`。 ```js window.scrollTo({ top: 1000, behavior: 'smooth' }); ``` #### window.scroll() `window.scroll()`方法是`window.scrollTo()`方法的别名。 #### window.scrollBy() `window.scrollBy()`方法用于将网页滚动指定距离(单位像素)。它接受两个参数:水平向右滚动的像素,垂直向下滚动的像素。 ```js window.scrollBy(0, window.innerHeight) ``` 上面代码用于将网页向下滚动一屏。 如果不是要滚动整个文档,而是要滚动某个元素,可以使用下面三个属性和方法。 * Element.scrollTop * Element.scrollLeft * Element.scrollIntoView() #### window.print() `window.print`方法会跳出打印对话框,与用户点击菜单里面的“打印”命令效果相同。 #### window.focus() `window.focus()`方法会激活窗口,使其获得焦点,出现在其他窗口的前面。 #### window.blur() `window.blur()`方法将焦点从窗口移除。 #### window.getSelection() `window.getSelection`方法返回一个`Selection`对象,表示用户现在选中的文本。 使用`Selection`对象的`toString`方法可以得到选中的文本。 ```js window.getSelection().toString() ``` #### window.getComputedStyle() `window.getComputedStyle()`方法接受一个元素节点作为参数,返回一个包含该元素的最终样式信息的对象 #### window.matchMedia() `window.matchMedia()`方法用来检查 CSS 的`mediaQuery`语句 #### window.requestAnimationFrame() `window.requestAnimationFrame()`方法跟`setTimeout`类似,都是推迟某个函数的执行。 不同之处在于,`setTimeout`必须指定推迟的时间,`window.requestAnimationFrame()`则是推迟到浏览器下一次重流时执行,执行完才会进行下一次重绘。 重绘通常是 16ms 执行一次,不过浏览器会自动调节这个速率,比如网页切换到后台 Tab 页时,`requestAnimationFrame()`会暂停执行。 如果某个函数会改变网页的布局,一般就放在`window.requestAnimationFrame()`里面执行,这样可以节省系统资源,使得网页效果更加平滑。 因为慢速设备会用较慢的速率重流和重绘,而速度更快的设备会有更快的速率。 该方法接受一个回调函数作为参数。 ```js window.requestAnimationFrame(callback) ``` 上面代码中,`callback`是一个回调函数。`callback`执行时,它的参数就是系统传入的一个高精度时间戳(`performance.now()`的返回值),单位是毫秒,表示距离网页加载的时间。 `window.requestAnimationFrame()`的返回值是一个整数,这个整数可以传入`window.cancelAnimationFrame()`,用来取消回调函数的执行。 下面是一个`window.requestAnimationFrame()`执行网页动画的例子。 ```js var element = document.getElementById('animate'); element.style.position = 'absolute'; var start = null; function step(timestamp) { if (!start) start = timestamp; var progress = timestamp - start; // 元素不断向右移,最大不超过200像素 element.style.left = Math.min(progress / 10, 200) + 'px'; // 如果距离第一次执行不超过 2000 毫秒, // 就继续执行动画 if (progress < 2000) { window.requestAnimationFrame(step); } } window.requestAnimationFrame(step); ``` 上面代码定义了一个网页动画,持续时间是2秒,会让元素向右移动。 #### window.requestIdleCallback() `window.requestIdleCallback()`跟`setTimeout`类似,也是将某个函数推迟执行,但是它保证将回调函数推迟到系统资源空闲时执行。 也就是说,如果某个任务不是很关键,就可以使用`window.requestIdleCallback()`将其推迟执行,以保证网页性能。 它跟`window.requestAnimationFrame()`的区别在于,后者指定回调函数在下一次浏览器重排时执行,问题在于下一次重排时,系统资源未必空闲,不一定能保证在16毫秒之内完成;`window.requestIdleCallback()`可以保证回调函数在系统资源空闲时执行。 该方法接受一个回调函数和一个配置对象作为参数。配置对象可以指定一个推迟执行的最长时间,如果过了这个时间,回调函数不管系统资源有无空闲,都会执行。 ```js window.requestIdleCallback(callback[, options]) ``` `callback`参数是一个回调函数。该回调函数执行时,系统会传入一个`IdleDeadline`对象作为参数。`IdleDeadline`对象有一个`didTimeout`属性(布尔值,表示是否为超时调用)和一个`timeRemaining()`方法(返回该空闲时段剩余的毫秒数)。 `options`参数是一个配置对象,目前只有`timeout`一个属性,用来指定回调函数推迟执行的最大毫秒数。该参数可选。 `window.requestIdleCallback()`方法返回一个整数。该整数可以传入`window.cancelIdleCallback()`取消回调函数。 ## Navigator 对象 `window.navigator`属性指向一个包含浏览器和系统信息的 Navigator 对象。脚本通过这个属性了解用户的环境信息。 ### 属性 #### Navigator.userAgent `navigator.userAgent`属性返回浏览器的 User Agent 字符串,表示用户设备信息,包含了浏览器的厂商、版本、操作系统等信息。 ```js navigator.userAgent // 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' ``` **识别手机浏览器**: ```js var ua = navigator.userAgent.toLowerCase(); if (/mobi/.test(ua)) { // 手机浏览器 } else { // 非手机浏览器 } ``` 如果想要识别所有移动设备的浏览器,可以测试更多的特征字符串。 ```js /mobi|android|touch|mini/.test(ua) ``` #### Navigator.plugins `Navigator.plugins`属性返回一个类似数组的对象,成员是 Plugin 实例对象,表示浏览器安装的插件,比如 Flash、ActiveX 等。 #### Navigator.platform `Navigator.platform`属性返回用户的操作系统信息,比如`MacIntel`、`Win32`、`Linux x86_64`等 。 #### Navigator.onLine `navigator.onLine`属性返回一个布尔值,表示用户当前在线还是离线(浏览器断线)。 有时,浏览器可以连接局域网,但是局域网不能连通外网。这时,有的浏览器的`onLine`属性会返回`true`,所以不能假定只要是`true`,用户就一定能访问互联网。不过,如果是`false`,可以断定用户一定离线。 用户变成在线会触发`online`事件,变成离线会触发`offline`事件,可以通过`window.ononline`和`window.onoffline`指定这两个事件的回调函数。 ```js window.addEventListener('offline', function(e) { console.log('offline'); }); window.addEventListener('online', function(e) { console.log('online'); }); ``` #### Navigator.language `Navigator.language`属性返回一个字符串,表示浏览器的首选语言。该属性只读。 #### Navigator.languages `Navigator.languages`属性返回一个数组,表示用户可以接受的语言。 `Navigator.language`总是这个数组的第一个成员。HTTP 请求头信息的`Accept-Language`字段,就来自这个数组。 如果这个属性发生变化,就会在`window`对象上触发`languagechange`事件。 #### Navigator.geolocation `Navigator.geolocation`属性返回一个 Geolocation 对象,包含用户地理位置的信息。注意,该 API 只有在 HTTPS 协议下可用,否则调用下面方法时会报错。 Geolocation 对象提供下面三个方法。 * Geolocation.getCurrentPosition():得到用户的当前位置 * Geolocation.watchPosition():监听用户位置变化 * Geolocation.clearWatch():取消`watchPosition()`方法指定的监听函数 注意,调用这三个方法时,浏览器会跳出一个对话框,要求用户给予授权。 #### Navigator.cookieEnabled `navigator.cookieEnabled`属性返回一个布尔值,表示浏览器的 Cookie 功能是否打开。 ### 方法 #### Navigator.javaEnabled() `navigator.javaEnabled()`方法返回一个布尔值,表示浏览器是否能运行 Java Applet 小程序。 #### Navigator.sendBeacon() `Navigator.sendBeacon()`方法用于向服务器异步发送数据。 ### 实验性属性 Navigator 对象有一些实验性属性,在部分浏览器可用。 #### Navigator.deviceMemory `navigator.deviceMemory`属性返回当前计算机的内存数量(单位为 GB)。该属性只读,只在 HTTPS 环境下可用。 它的返回值是一个近似值,四舍五入到最接近的2的幂,通常是 0.25、0.5、1、2、4、8。实际内存超过 8GB,也返回`8`。 ```js if (navigator.deviceMemory > 1) { await import('./costly-module.js'); } ``` 上面示例中,只有当前内存大于 1GB,才加载大型的脚本。 #### Navigator.hardwareConcurrency `navigator.hardwareConcurrency`属性返回用户计算机上可用的逻辑处理器的数量。该属性只读。 现代计算机的 CPU 有多个物理核心,每个物理核心有时支持一次运行多个线程。因此,四核 CPU 可以提供八个逻辑处理器核心。 可用于判断是否加载大型脚本。 该属性通过用于创建 Web Worker,每个可用的逻辑处理器都创建一个 Worker。 ```js let workerList = []; for (let i = 0; i < window.navigator.hardwareConcurrency; i++) { let newWorker = { worker: new Worker('cpuworker.js'), inUse: false }; workerList.push(newWorker); } ``` 上面示例中,有多少个可用的逻辑处理器,就创建多少个 Web Worker。 #### Navigator.connection `navigator.connection`属性返回一个对象,包含当前网络连接的相关信息。 * downlink:有效带宽估计值(单位:兆比特/秒,Mbps),四舍五入到每秒 25KB 的最接近倍数。 * downlinkMax:当前连接的最大下行链路速度(单位:兆比特每秒,Mbps)。 * effectiveType:返回连接的等效类型,可能的值为`slow-2g`、`2g`、`3g`、`4g`。 * rtt:当前连接的估计有效往返时间,四舍五入到最接近的25毫秒的倍数。 * saveData:用户是否设置了浏览器的减少数据使用量选项(比如不加载图片),返回`true`或者`false`。 * type:当前连接的介质类型,可能的值为`bluetooth`、`cellular`、`ethernet`、`none`、`wifi`、`wimax`、`other`、`unknown`。 ## Screen 对象 Screen 对象表示当前窗口所在的屏幕,提供显示设备的信息。`window.screen`属性指向这个对象。 该对象有下面的属性。 * `Screen.height`:浏览器窗口所在的屏幕的高度(单位像素)。除非调整显示器的分辨率,否则这个值可以看作常量,不会发生变化。显示器的分辨率与浏览器设置无关,缩放网页并不会改变分辨率。 * `Screen.width`:浏览器窗口所在的屏幕的宽度(单位像素)。 * `Screen.availHeight`:浏览器窗口可用的屏幕高度(单位像素)。因为部分空间可能不可用,比如系统的任务栏或者 Mac 系统屏幕底部的 Dock 区,这个属性等于`height`减去那些被系统组件的高度。 * `Screen.availWidth`:浏览器窗口可用的屏幕宽度(单位像素)。 * `Screen.pixelDepth`:整数,表示屏幕的色彩位数,比如`24`表示屏幕提供24位色彩。 * `Screen.colorDepth`:`Screen.pixelDepth`的别名。严格地说,colorDepth 表示应用程序的颜色深度,pixelDepth 表示屏幕的颜色深度,绝大多数情况下,它们都是同一件事。 * `Screen.orientation`:返回一个对象,表示屏幕的方向。该对象的`type`属性是一个字符串,表示屏幕的具体方向,`landscape-primary`表示横放,`landscape-secondary`表示颠倒的横放,`portrait-primary`表示竖放,`portrait-secondary`表示颠倒的竖放。 ```js if (window.screen.width >= 1024 && window.screen.height >= 768) { // 分辨率不低于 1024x768 } ``` 根据屏幕的宽度,将用户导向不同网页的代码。 ```js if ((screen.width <= 800) && (screen.height <= 600)) { window.location.replace('small.html'); } else { window.location.replace('wide.html'); } ``` ## Cookie ### 概述 Cookie 是服务器保存在浏览器的一小段文本信息,一般大小不能超过4KB。浏览器每次向服务器发出请求,就会自动附上这段信息。 HTTP 协议不带有状态,有些请求需要区分状态,就通过 Cookie 附带字符串,让服务器返回不一样的回应。举例来说,用户登录以后,服务器往往会在网站上留下一个 Cookie,记录用户编号(比如`id=1234`),以后每次浏览器向服务器请求数据,就会带上这个字符串,服务器从而知道是谁在请求,应该回应什么内容。 Cookie 的目的就是区分用户,以及放置状态信息,它的使用场景主要如下。 * 对话(session)管理:保存登录状态、购物车等需要记录的信息。 * 个性化信息:保存用户的偏好,比如网页的字体大小、背景色等等。 * 追踪用户:记录和分析用户行为。 Cookie 不是一种理想的客户端存储机制。它的容量很小(4KB),缺乏数据操作接口,而且会影响性能。客户端存储建议使用 Web storage API 和 IndexedDB。**只有那些每次请求都需要让服务器知道的信息,才应该放在 Cookie 里面。** 每个 Cookie 都有以下几方面的元数据。 * Cookie 的名字 * Cookie 的值(真正的数据写在这里面) * 到期时间(超过这个时间会失效) * 所属域名(默认为当前域名) * 生效的路径(默认为当前网址) 举例来说,用户访问网址`www.example.com`,服务器在浏览器写入一个 Cookie。这个 Cookie 的所属域名为`www.example.com`,生效路径为根路径`/`。 如果 Cookie 的生效路径设为`/forums`,那么这个 Cookie 只有在访问`www.example.com/forums`及其子路径时才有效。 以后,浏览器访问某个路径之前,就会找出对该域名和路径有效,并且还没有到期的 Cookie,一起发送给服务器。 用户可以设置浏览器不接受 Cookie,也可以设置不向服务器发送 Cookie。 `window.navigator.cookieEnabled`属性返回一个布尔值,表示浏览器是否打开 Cookie 功能。 `document.cookie`属性返回当前网页的 Cookie。 ```js window.navigator.cookieEnabled // true document.cookie // "id=foo;key=bar" ``` 不同浏览器对 Cookie 数量和大小的限制,是不一样的。一般来说,单个域名设置的 Cookie 不应超过30个,每个 Cookie 的大小不能超过 4KB。超过限制以后,Cookie 将被忽略,不会被设置。 Cookie 是按照域名区分的,`foo.com`只能读取自己放置的 Cookie,无法读取其他网站(比如`bar.com`)放置的 Cookie。 一般情况下,一级域名也不能读取二级域名留下的 Cookie,比如`mydomain.com`不能读取`subdomain.mydomain.com`设置的 Cookie。 但是有一个例外,设置 Cookie 的时候(不管是一级域名设置的,还是二级域名设置的),明确将`domain`属性设为一级域名,则这个域名下面的各级域名可以共享这个 Cookie。 ```http Set-Cookie: name=value; domain=mydomain.com ``` 上面示例中,设置 Cookie 时,`domain`属性设为`mydomain.com`,那么各级的子域名和一级域名都可以读取这个 Cookie。 注意,区分 Cookie 时不考虑协议和端口。也就是说,`http://example.com`设置的 Cookie,可以被`https://example.com`或`http://example.com:8080`读取。 ### Cookie & HTTP Cookie 由 HTTP 协议生成,也主要是供 HTTP 协议使用。 #### 回应 & 生成 服务器如果希望在浏览器保存 Cookie,就要在 HTTP 回应的头信息里面,放置一个`Set-Cookie`字段。 ```http Set-Cookie:foo=bar ``` 上面代码会在浏览器保存一个名为`foo`的 Cookie,它的值为`bar`。 HTTP 回应可以包含多个`Set-Cookie`字段,即在浏览器生成多个 Cookie。下面是一个例子。 ```http HTTP/1.0 200 OK Content-type: text/html Set-Cookie: yummy_cookie=choco Set-Cookie: tasty_cookie=strawberry [page content] ``` 除了 Cookie 的值,`Set-Cookie`字段还可以附加 Cookie 的属性。 ```http Set-Cookie: =; Expires= Set-Cookie: =; Max-Age= Set-Cookie: =; Domain= Set-Cookie: =; Path= Set-Cookie: =; Secure Set-Cookie: =; HttpOnly ``` 一个`Set-Cookie`字段里面,可以同时包括多个属性,没有次序的要求。 ```http Set-Cookie: =; Domain=; Secure; HttpOnly ``` **例子**: ```http Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly ``` 如果服务器想改变一个早先设置的 Cookie,必须同时满足四个条件:Cookie 的`key`、`domain`、`path`和`secure`都匹配。 举例来说,如果原始的 Cookie 是用如下的`Set-Cookie`设置的。 ```http Set-Cookie: key1=value1; domain=example.com; path=/blog ``` 改变上面这个 Cookie 的值,就必须使用同样的`Set-Cookie`。 ```http Set-Cookie: key1=value2; domain=example.com; path=/blog ``` 只要有一个属性不同,就会生成一个全新的 Cookie,而不是替换掉原来那个 Cookie。 ```http Set-Cookie: key1=value2; domain=example.com; path=/ ``` 上面的命令设置了一个全新的同名 Cookie,但是`path`属性不一样。下一次访问`example.com/blog`的时候,浏览器将向服务器发送两个同名的 Cookie。 #### 请求 & 发送 浏览器向服务器发送 HTTP 请求时,每个请求都会带上相应的 Cookie。 也就是说,把服务器早前保存在浏览器的这段信息,再发回服务器。这时要使用 HTTP 头信息的`Cookie`字段。 ```http Cookie: foo=bar ``` 上面代码会向服务器发送名为`foo`的 Cookie,值为`bar`。 `Cookie`字段可以包含多个 Cookie,使用分号(`;`)分隔。 ```http Cookie: name=value; name2=value2; name3=value3 ``` 下面是一个例子。 ```http GET /sample_page.html HTTP/1.1 Host: www.example.org Cookie: yummy_cookie=choco; tasty_cookie=strawberry ``` 服务器收到浏览器发来的 Cookie 时,有两点是无法知道的。 * Cookie 的各种属性,比如何时过期。 * 哪个域名设置的 Cookie,到底是一级域名设的,还是某一个二级域名设的。 ### Cookie 的属性 #### Expires `Expires`属性指定一个具体的到期时间,到了指定时间以后,浏览器就不再保留这个 Cookie。它的值是 UTC 格式,可以使用`Date.prototype.toUTCString()`进行格式转换。 如果不设置该属性,或者设为`null`,Cookie 只在当前会话(session)有效,浏览器窗口一旦关闭,当前 Session 结束,该 Cookie 就会被删除。 #### Max-Age `Max-Age`属性指定从现在开始 Cookie 存在的秒数,比如`60 * 60 * 24 * 365`(即一年)。过了这个时间以后,浏览器就不再保留这个 Cookie。 如果同时指定了`Expires`和`Max-Age`,那么`Max-Age`的值将优先生效。 #### Domain `Domain`属性指定 Cookie 属于哪个域名,以后浏览器向服务器发送 HTTP 请求时,通过这个属性判断是否要附带某个 Cookie。 #### Path `Path`属性指定浏览器发出 HTTP 请求时,哪些路径要附带这个 Cookie。 #### Secure `Secure`属性指定浏览器只有在加密协议 HTTPS 下,才能将这个 Cookie 发送到服务器。 #### HttpOnly `HttpOnly`属性指定该 Cookie 无法通过 JavaScript 脚本拿到,主要是`document.cookie`属性、`XMLHttpRequest`对象和 Request API 都拿不到该属性。这样就防止了该 Cookie 被脚本读到,只有浏览器发出 HTTP 请求时,才会带上该 Cookie。 #### SameSite Chrome 51 开始,浏览器的 Cookie 新增加了一个`SameSite`属性,用来防止 CSRF 攻击和用户追踪。 Cookie 往往用来存储用户的身份信息,恶意网站可以设法伪造带有正确 Cookie 的 HTTP 请求,这就是 CSRF 攻击。 ### document.cookie `document.cookie`属性用于读写当前网页的 Cookie。 读取的时候,它会返回当前网页的所有 Cookie,前提是该 Cookie 不能有`HTTPOnly`属性。 --- --- url: /js/c1una9pp/index.md --- # BOM 中 ## XMLHttpRequest 对象 浏览器与服务器之间,采用 HTTP 协议通信。用户在浏览器地址栏键入一个网址,或者通过网页表单向服务器提交内容,这时浏览器就会向服务器发出 HTTP 请求。 JavaScript 脚本向服务器发起 HTTP 请求。AJAX 这个词第一次正式提出,它是 Asynchronous JavaScript and XML 的缩写,指的是通过 JavaScript 的异步通信,从服务器获取 XML 文档从中提取数据,再更新当前网页的对应部分,而不用刷新整个网页。 后来,AJAX 这个词就成为 JavaScript 脚本发起 HTTP 通信的代名词,也就是说,只要用脚本发起通信,就可以叫做 AJAX 通信。 概括起来,就是一句话,AJAX 通过原生的`XMLHttpRequest`对象发出 HTTP 请求,得到服务器返回的数据后,再进行处理。 现在,服务器返回的都是 JSON 格式的数据,XML 格式已经过时了,但是 AJAX 这个名字已经成了一个通用名词,字面含义已经消失了。 `XMLHttpRequest`对象是 AJAX 的主要接口,用于浏览器与服务器之间的通信。尽管名字里面有`XML`和`Http`,它实际上可以使用多种协议(比如`file`或`ftp`),发送任何格式的数据(包括字符串和二进制)。 `XMLHttpRequest`本身是一个构造函数,可以使用`new`命令生成实例。它没有任何参数。 ```js var xhr = new XMLHttpRequest(); ``` 一旦新建实例,就可以使用`open()`方法指定建立 HTTP 连接的一些细节。 ```js xhr.open('GET', 'http://www.example.com/page.php', true); ``` 上面代码指定使用 GET 方法,跟指定的服务器网址建立连接。第三个参数`true`,表示请求是异步的。 然后,指定回调函数,监听通信状态(`readyState`属性)的变化。 ```js xhr.onreadystatechange = handleStateChange; function handleStateChange() { // ... } ``` 上面代码中,一旦`XMLHttpRequest`实例的状态发生变化,就会调用监听函数`handleStateChange` 最后使用`send()`方法,实际发出请求。 ```js xhr.send(null); ``` 上面代码中,`send()`的参数为`null`,表示发送请求的时候,不带有数据体。如果发送的是 POST 请求,这里就需要指定数据体。 一旦拿到服务器返回的数据,AJAX 不会刷新整个网页,而是只更新网页里面的相关部分,从而不打断用户正在做的事情。 **注意**,AJAX 只能向**同源网址**(协议、域名、端口都相同)发出 HTTP 请求,如果发出跨域请求,就会报错。 下面是`XMLHttpRequest`对象简单用法的完整例子: ```js var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function(){ // 通信成功时,状态值为4 if (xhr.readyState === 4){ if (xhr.status === 200){ console.log(xhr.responseText); } else { console.error(xhr.statusText); } } }; xhr.onerror = function (e) { console.error(xhr.statusText); }; xhr.open('GET', '/endpoint', true); xhr.send(null); ``` ### 实例属性 #### XMLHttpRequest.readyState `XMLHttpRequest.readyState`返回一个整数,表示实例对象的当前状态。该属性只读。它可能返回以下值。 * 0,表示 XMLHttpRequest 实例已经生成,但是实例的`open()`方法还没有被调用。 * 1,表示`open()`方法已经调用,但是实例的`send()`方法还没有调用,仍然可以使用实例的`setRequestHeader()`方法,设定 HTTP 请求的头信息。 * 2,表示实例的`send()`方法已经调用,并且服务器返回的头信息和状态码已经收到。 * 3,表示正在接收服务器传来的数据体(body 部分)。这时,如果实例的`responseType`属性等于`text`或者空字符串,`responseText`属性就会包含已经收到的部分信息。 * 4,表示服务器返回的数据已经完全接收,或者本次接收已经失败。 通信过程中,每当实例对象发生状态变化,它的`readyState`属性的值就会改变。这个值每一次变化,都会触发`readyStateChange`事件。 ```js if (xhr.readyState === 4) { // ... } else { // ... } ``` #### XMLHttpRequest.onreadystatechange `XMLHttpRequest.onreadystatechange`属性指向一个监听函数。`readystatechange`事件发生时(实例的`readyState`属性变化),就会执行这个属性。 另外,如果使用实例的`abort()`方法,终止 XMLHttpRequest 请求,也会造成`readyState`属性变化,导致调用`XMLHttpRequest.onreadystatechange`属性。 ```js xhr.onreadystatechange = function () { if (xhr.readyState !== 4 || xhr.status !== 200) { return; } ``` #### XMLHttpRequest.response `XMLHttpRequest.response`属性表示服务器返回的数据体(即 HTTP 回应的 body 部分)。 它可能是任何数据类型,比如字符串、对象、二进制对象等等,具体的类型由`XMLHttpRequest.responseType`属性决定。该属性只读。 如果本次请求没有成功或者数据不完整,该属性等于`null`。但是,如果`responseType`属性等于`text`或空字符串,在请求没有结束之前(`readyState`等于3的阶段),`response`属性包含服务器已经返回的部分数据。 #### XMLHttpRequest.responseType `XMLHttpRequest.responseType`属性是一个字符串,表示服务器返回数据的类型。 这个属性是可写的,可以在调用`open()`方法之后、调用`send()`方法之前,设置这个属性的值,告诉浏览器如何解读返回的数据。如果`responseType`设为空字符串,就等同于默认值`text`。 `XMLHttpRequest.responseType`属性可以等于以下值。 * ""(空字符串):等同于`text`,表示服务器返回文本数据。 * "arraybuffer":ArrayBuffer 对象,表示服务器返回二进制数组。 * "blob":Blob 对象,表示服务器返回二进制对象。 * "document":Document 对象,表示服务器返回一个文档对象。 * "json":JSON 对象。 * "text":字符串。 #### XMLHttpRequest.responseText `XMLHttpRequest.responseText`属性返回从服务器接收到的字符串,该属性为只读。只有 HTTP 请求完成接收以后,该属性才会包含完整的数据。 #### XMLHttpRequest.responseXML `XMLHttpRequest.responseXML`属性返回从服务器接收到的 HTML 或 XML 文档对象,该属性为只读。如果本次请求没有成功,或者收到的数据不能被解析为 XML 或 HTML,该属性等于`null`。 #### XMLHttpRequest.responseURL `XMLHttpRequest.responseURL`属性是字符串,表示发送数据的服务器的网址。 注意,这个属性的值与`open()`方法指定的请求网址不一定相同。如果服务器端发生跳转,这个属性返回最后实际返回数据的网址。另外,如果原始 URL 包括锚点(fragment),该属性会把锚点剥离。 #### XMLHttpRequest.status `XMLHttpRequest.status`属性返回一个整数,表示服务器回应的 HTTP 状态码。 一般来说,如果通信成功的话,这个状态码是200;如果服务器没有返回状态码,那么这个属性默认是200。请求发出之前,该属性为`0`。该属性只读。 ##### HTTP状态码 | HTTP 状态码 | 状态描述 | 含义 | | ----------- | --------------------- | -------------- | | 200 | OK | 访问正常 | | 301 | Moved Permanently | 永久移动 | | 302 | Moved temporarily | 暂时移动 | | 304 | Not Modified | 未修改 | | 307 | Temporary Redirect | 暂时重定向 | | 401 | Unauthorized | 未授权 | | 403 | Forbidden | 禁止访问 | | 404 | Not Found | 未发现指定网址 | | 500 | Internal Server Error | 服务器发生错误 | 基本上,只有2xx和304的状态码,表示服务器返回是正常状态。 ```js if (xhr.readyState === 4) { if ( (xhr.status >= 200 && xhr.status < 300) || (xhr.status === 304) ) { // 处理服务器的返回数据 } else { // 出错 } } ``` #### XMLHttpRequest.statusText `XMLHttpRequest.statusText`属性返回一个字符串,表示服务器发送的状态提示。不同于`status`属性,该属性包含整个状态信息,比如“OK”和“Not Found”。 #### XMLHttpRequest.timeout `XMLHttpRequest.timeout`属性返回一个整数,表示多少毫秒后,如果请求仍然没有得到结果,就会自动终止。如果该属性等于0,就表示没有时间限制。 #### XMLHttpRequestEventTarget.ontimeout `XMLHttpRequestEventTarget.ontimeout`属性用于设置一个监听函数,如果发生 timeout 事件,就会执行这个监听函数。 ```js xhr.timeout = 10 * 1000; // 指定 10 秒钟超时 xhr.ontimeout = function () { console.error('The request timed out.'); }; ``` ### 事件监听 XMLHttpRequest 对象可以对以下事件指定监听函数。 * XMLHttpRequest.onloadstart:loadstart 事件(HTTP 请求发出)的监听函数 * XMLHttpRequest.onprogress:progress事件(正在发送和加载数据)的监听函数 * XMLHttpRequest.onabort:abort 事件(请求中止,比如用户调用了`abort()`方法)的监听函数 * XMLHttpRequest.onerror:error 事件(请求失败)的监听函数 * XMLHttpRequest.onload:load 事件(请求成功完成)的监听函数 * XMLHttpRequest.ontimeout:timeout 事件(用户指定的时限超过了,请求还未完成)的监听函数 * XMLHttpRequest.onloadend:loadend 事件(请求完成,不管成功或失败)的监听函数 ```js xhr.onprogress = function (event) { console.log(event.loaded); console.log(event.total); }; ``` `progress`事件的监听函数有一个事件对象参数,该对象有三个属性:`loaded`属性返回已经传输的数据量,`total`属性返回总的数据量,`lengthComputable`属性返回一个布尔值,表示加载的进度是否可以计算。 所有这些监听函数里面,只有`progress`事件的监听函数有参数,其他函数都没有参数。 注意,如果发生网络错误(比如服务器无法连通),`onerror`事件无法获取报错信息。也就是说,可能没有错误对象,所以这样只能显示报错的提示。 ### XMLHttpRequest.withCredentials `XMLHttpRequest.withCredentials`属性是一个布尔值,表示跨域请求时,用户信息(比如 Cookie 和认证的 HTTP 头信息)是否会包含在请求之中,默认为`false`,即向`example.com`发出跨域请求时,不会发送`example.com`设置在本机上的 Cookie(如果有的话)。 如果需要跨域 AJAX 请求发送 Cookie,需要`withCredentials`属性设为`true`。注意,同源的请求不需要设置这个属性。 ### XMLHttpRequest.upload XMLHttpRequest 不仅可以发送请求,还可以发送文件,这就是 AJAX 文件上传。 发送文件以后,通过`XMLHttpRequest.upload`属性可以得到一个对象,通过观察这个对象,可以得知上传的进展。 主要方法就是监听这个对象的各种事件:loadstart、loadend、load、abort、error、progress、timeout。 文件上传时,对`upload`属性指定`progress`事件的监听函数,即可获得上传的进度。 ```js // 0% complete function upload(blobOrFile) { var xhr = new XMLHttpRequest(); xhr.open('POST', '/server', true); xhr.onload = function (e) {}; var progressBar = document.querySelector('progress'); xhr.upload.onprogress = function (e) { if (e.lengthComputable) { progressBar.value = (e.loaded / e.total) * 100; // 兼容不支持 元素的老式浏览器 progressBar.textContent = progressBar.value; } }; xhr.send(blobOrFile); } upload(new Blob(['hello world'], {type: 'text/plain'})); ``` ### 实例方法 #### XMLHttpRequest.open() `XMLHttpRequest.open()`方法用于指定 HTTP 请求的参数,或者说初始化 XMLHttpRequest 实例对象。它一共可以接受五个参数。 ```js void open( string method, string url, optional boolean async, optional string user, optional string password ); ``` * `method`:表示 HTTP 动词方法,比如`GET`、`POST`、`PUT`、`DELETE`、`HEAD`等。 * `url`: 表示请求发送目标 URL。 * `async`: 布尔值,表示请求是否为异步,默认为`true`。如果设为`false`,则`send()`方法只有等到收到服务器返回了结果,才会进行下一步操作。该参数可选。由于同步 AJAX 请求会造成浏览器失去响应,许多浏览器已经禁止在主线程使用,只允许 Worker 里面使用。所以,这个参数轻易不应该设为`false`。 * `user`:表示用于认证的用户名,默认为空字符串。该参数可选。 * `password`:表示用于认证的密码,默认为空字符串。该参数可选。 注意,如果对使用过`open()`方法的 AJAX 请求,再次使用这个方法,等同于调用`abort()`,即终止请求。 #### XMLHttpRequest.send() `XMLHttpRequest.send()`方法用于实际发出 HTTP 请求。它的参数是可选的,如果不带参数,就表示 HTTP 请求只有一个 URL,没有数据体,典型例子就是 GET 请求;如果带有参数,就表示除了头信息,还带有包含具体数据的信息体,典型例子就是 POST 请求。 ```js var xhr = new XMLHttpRequest(); var data = 'email=' + encodeURIComponent(email) + '&password=' + encodeURIComponent(password); xhr.open('POST', 'http://www.example.com', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send(data); ``` 注意,所有 XMLHttpRequest 的监听事件,都必须在`send()`方法调用之前设定。 `send`方法的参数就是发送的数据。多种格式的数据,都可以作为它的参数。 ```js var formData = new FormData(); formData.append('username', '张三'); formData.append('email', 'zhangsan@example.com'); formData.append('birthDate', 1940); var xhr = new XMLHttpRequest(); xhr.open('POST', '/register'); xhr.send(formData); ``` `FormData`对象可以用于构造表单数据。它的效果与发送下面的表单数据是一样的。 ```html
``` #### XMLHttpRequest.setRequestHeader() `XMLHttpRequest.setRequestHeader()`方法用于设置浏览器发送的 HTTP 请求的头信息。 该方法必须在`open()`之后、`send()`之前调用。如果该方法多次调用,设定同一个字段,则每一次调用的值会被合并成一个单一的值发送。 该方法接受两个参数。第一个参数是字符串,表示头信息的字段名,第二个参数是字段值。 ```js xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Length', JSON.stringify(data).length); xhr.send(JSON.stringify(data)); ``` 上面代码首先设置头信息`Content-Type`,表示发送 JSON 格式的数据;然后设置`Content-Length`,表示数据长度;最后发送 JSON 数据 #### XMLHttpRequest.overrideMimeType() `XMLHttpRequest.overrideMimeType()`方法用来指定 MIME 类型,覆盖服务器返回的真正的 MIME 类型,从而让浏览器进行不一样的处理。 举例来说,服务器返回的数据类型是`text/xml`,由于种种原因浏览器解析不成功报错,这时就拿不到数据了。为了拿到原始数据,我们可以把 MIME 类型改成`text/plain`,这样浏览器就不会去自动解析,从而我们就可以拿到原始文本了。 ```js xhr.overrideMimeType('text/plain') ``` 注意,该方法必须在`send()`方法之前调用。 修改服务器返回的数据类型,不是正常情况下应该采取的方法。如果希望服务器返回指定的数据类型,可以用`responseType`属性告诉服务器,就像下面的例子。只有在服务器无法返回某种数据类型时,才使用`overrideMimeType()`方法。 ```js var xhr = new XMLHttpRequest(); xhr.onload = function(e) { var arraybuffer = xhr.response; // ... } xhr.open('GET', url); xhr.responseType = 'arraybuffer'; xhr.send(); ``` #### XMLHttpRequest.getResponseHeader() `XMLHttpRequest.getResponseHeader()`方法返回 HTTP 头信息指定字段的值,如果还没有收到服务器回应或者指定字段不存在,返回`null`。该方法的参数不区分大小写。 ```js function getHeaderTime() { console.log(this.getResponseHeader("Last-Modified")); } var xhr = new XMLHttpRequest(); xhr.open('HEAD', 'yourpage.html'); xhr.onload = getHeaderTime; xhr.send(); ``` 如果有多个字段同名,它们的值会被连接为一个字符串,每个字段之间使用“逗号+空格”分隔。 #### XMLHttpRequest.getAllResponseHeaders() `XMLHttpRequest.getAllResponseHeaders()`方法返回一个字符串,表示服务器发来的所有 HTTP 头信息。 格式为字符串,每个头信息之间使用`CRLF`分隔(回车+换行),如果没有收到服务器回应,该属性为`null`。如果发生网络错误,该属性为空字符串。 ```js xhr.onreadystatechange = function () { if (this.readyState === 4) { var headers = xhr.getAllResponseHeaders(); } } ``` ```http date: Fri, 08 Dec 2017 21:04:30 GMT\r\n content-encoding: gzip\r\n x-content-type-options: nosniff\r\n server: meinheld/0.6.1\r\n x-frame-options: DENY\r\n content-type: text/html; charset=utf-8\r\n connection: keep-alive\r\n strict-transport-security: max-age=63072000\r\n vary: Cookie, Accept-Encoding\r\n content-length: 6502\r\n x-xss-protection: 1; mode=block\r\n ``` #### XMLHttpRequest.abort() `XMLHttpRequest.abort()`方法用来终止已经发出的 HTTP 请求。调用这个方法以后,`readyState`属性变为`4`,`status`属性变为`0`。 ### 实例事件 #### readyStateChange 事件 `readyState`属性的值发生改变,就会触发 readyStateChange 事件。 我们可以通过`onReadyStateChange`属性,指定这个事件的监听函数,对不同状态进行不同处理。尤其是当状态变为`4`的时候,表示通信成功,这时回调函数就可以处理服务器传送回来的数据。 #### progress 事件 上传文件时,XMLHttpRequest 实例对象本身和实例的`upload`属性,都有一个`progress`事件,会不断返回上传的进度。 ```js var xhr = new XMLHttpRequest(); function updateProgress (oEvent) { if (oEvent.lengthComputable) { var percentComplete = oEvent.loaded / oEvent.total; } else { console.log('无法计算进展'); } } xhr.addEventListener('progress', updateProgress); xhr.open(); ``` #### load 事件 #### error 事件 #### abort 事件 load 事件表示服务器传来的数据接收完毕,error 事件表示请求出错,abort 事件表示请求被中断(比如用户取消请求)。 #### loadend 事件 `abort`、`load`和`error`这三个事件,会伴随一个`loadend`事件,表示请求结束,但不知道其是否成功。 ```js xhr.addEventListener('loadend', loadEnd); function loadEnd(e) { console.log('请求结束,状态未知'); } ``` #### timeout 事件 服务器超过指定时间还没有返回结果,就会触发 timeout 事件。 ### Navigator.sendBeacon() 用户卸载网页的时候,有时需要向服务器发一些数据。很自然的做法是在`unload`事件或`beforeunload`事件的监听函数里面,使用`XMLHttpRequest`对象发送数据。 但是,这样做不是很可靠,因为`XMLHttpRequest`对象是异步发送,很可能在它即将发送的时候,页面已经卸载了,从而导致发送取消或者发送失败。 解决方法就是`unload`事件里面,加一些很耗时的同步操作。这样就能留出足够的时间,保证异步 AJAX 能够发送成功。 浏览器引入了`Navigator.sendBeacon()`方法。这个方法还是异步发出请求,但是请求与当前页面线程脱钩,作为浏览器进程的任务,因此可以保证会把数据发出去,不拖延卸载流程。 ```js window.addEventListener('unload', logData, false); function logData() { navigator.sendBeacon('/log', JSON.stringify({ some: "data" })); } ``` `Navigator.sendBeacon()`方法接受两个参数,第一个参数是目标服务器的 URL,第二个参数是所要发送的数据(可选),可以是任意类型(字符串、表单对象、二进制对象等等)。 ```js navigator.sendBeacon(url, data) ``` 这个方法的返回值是一个布尔值,成功发送数据为`true`,否则为`false`。 该方法发送数据的 HTTP 方法是 POST,可以跨域,类似于表单提交数据。它不能指定回调函数。 ## 同源限制 浏览器安全的基石是“同源政策”([same-origin policy](https://en.wikipedia.org/wiki/Same-origin_policy))。很多开发者都知道这一点,但了解得不全面。 ### 概述 #### 含义 最初,它的含义是指,A 网页设置的 Cookie,B 网页不能打开,除非这两个网页“同源”。所谓“同源”指的是“三个相同”。 * 协议相同 * 域名相同 * 端口相同(这点可以忽略) 举例来说,`http://www.example.com/dir/page.html`这个网址,协议是`http://`,域名是`www.example.com`,端口是`80`(默认端口可以省略),它的同源情况如下。 * `http://www.example.com/dir2/other.html`:同源 * `http://example.com/dir/other.html`:不同源(域名不同) * `http://v2.www.example.com/dir/other.html`:不同源(域名不同) * `http://www.example.com:81/dir/other.html`:不同源(端口不同) * `https://www.example.com/dir/page.html`:不同源(协议不同) 注意,标准规定端口不同的网址不是同源(比如8000端口和8001端口不是同源),但是浏览器没有遵守这条规定。实际上,同一个网域的不同端口,是可以互相读取 Cookie 的。 #### 目的 同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。 设想这样一种情况:A 网站是一家银行,用户登录以后,A 网站在用户的机器上设置了一个 Cookie,包含了一些隐私信息。用户离开 A 网站以后,又去访问 B 网站,如果没有同源限制,B 网站可以读取 A 网站的 Cookie(通过JavaScript直接读取A网站的Cookie),那么隐私就泄漏了。 更可怕的是,Cookie 往往用来保存用户的登录状态(身份凭证),如果用户没有退出登录,其他网站就可以冒充用户(恶意网站获取受害者的Cookie后,将这些Cookie附加到**自己向目标网站发起的请求**中,目标网站看到合法Cookie后,会认为这是真实用户的请求),为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。 由此可见,同源政策是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。 #### 限制范围 随着互联网的发展,同源政策越来越严格。目前,如果非同源,共有三种行为受到限制。 (1) 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB。 (2) 无法接触非同源网页的 DOM。 (3) 无法向非同源地址发送 AJAX 请求(可以发送,但浏览器会拒绝接受响应)。 另外,通过 JavaScript 脚本可以拿到其他窗口的`window`对象。如果是非同源的网页,目前允许一个窗口可以接触其他网页的`window`对象的**九个属性和四个方法。** ::: details 允许的属性和方法 * window.closed * window.frames * window.length * window.location * window.opener * window.parent * window.self * window.top * window.window * window.blur() * window.close() * window.focus() * window.postMessage() ::: 上面的九个属性之中,只有`window.location`是可读写的,其他八个全部都是只读。而且,即使是`location`对象,非同源的情况下,也只允许调用`location.replace()`方法和写入`location.href`属性。 虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。下面介绍如何规避上面的限制。 ### Cookie Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。 如果两个网页一级域名相同,只是次级域名不同,浏览器允许通过设置`document.domain`共享 Cookie。 举例来说,A 网页的网址是`http://w1.example.com/a.html`,B 网页的网址是`http://w2.example.com/b.html`,那么只要设置相同的`document.domain`,两个网页就可以共享 Cookie。因为浏览器通过`document.domain`属性来检查是否同源。 ```js // 两个网页都需要设置 document.domain = 'example.com'; ``` 注意,A 和 B 两个网页都需要设置`document.domain`属性,才能达到同源的目的。因为设置`document.domain`的同时,会把端口重置为`null`,因此如果只设置一个网页的`document.domain`,会导致两个网址的端口不同,还是达不到同源的目的。 现在,A 网页通过脚本设置一个 Cookie。 ```js document.cookie = "test1=hello"; ``` B 网页就可以读到这个 Cookie。 ```js var allCookie = document.cookie; ``` 注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexedDB 无法通过这种方法,规避同源政策。 另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,比如`example.com`。 ```http Set-Cookie: key=value; domain=example.com; path=/ ``` 这样的话,二级域名和三级域名不用做任何设置,都可以读取这个 Cookie。 ### iframe 和多窗口通信 `iframe`元素可以在当前网页之中,嵌入其他网页。每个`iframe`元素形成自己的窗口,即有自己的`window`对象。`iframe`窗口之中的脚本,可以获得父窗口和子窗口。但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨域,就无法拿到对方的 DOM。 比如,父窗口运行下面的命令,如果`iframe`窗口不是同源,就会报错。 ```js document .getElementById("myIFrame") .contentWindow .document // Uncaught DOMException: Blocked a frame from accessing a cross-origin frame. ``` 上面命令中,父窗口想获取子窗口的 DOM,因为跨域导致报错。 反之亦然,子窗口获取主窗口的 DOM 也会报错。 ```js window.parent.document.body // 报错 ``` 这种情况不仅适用于`iframe`窗口,还适用于`window.open`方法打开的窗口,只要跨域,父窗口与子窗口之间就无法通信。 如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的`document.domain`属性,就可以规避同源政策,拿到 DOM。 对于完全不同源的网站,目前有两种方法,可以解决跨域窗口的通信问题。 * 片段识别符(fragment identifier) * 跨文档通信API(Cross-document messaging) #### 片段识别符 片段标识符(fragment identifier)指的是,URL 的`#`号后面的部分,比如`http://example.com/x.html#fragment`的`#fragment`。如果只是改变片段标识符,页面不会重新刷新。 父窗口可以把信息,写入子窗口的片段标识符。 ```js var src = originURL + '#' + data; document.getElementById('myIFrame').src = src; ``` 上面代码中,父窗口把所要传递的信息,写入 iframe 窗口的片段标识符。 子窗口通过监听`hashchange`事件得到通知。 ```js window.onhashchange = checkMessage; function checkMessage() { var message = window.location.hash; // ... } ``` 同样的,子窗口也可以改变父窗口的片段标识符。 ```js parent.location.href = target + '#' + hash; ``` #### window.postMessage() 上面的这种方法属于破解,HTML5 为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。 这个 API 为`window`对象新增了一个`window.postMessage`方法,允许跨窗口通信,**不论这两个窗口是否同源**。举例来说,父窗口`aaa.com`向子窗口`bbb.com`发消息,调用`postMessage`方法就可以了。 ```js // 父窗口打开一个子窗口 var popup = window.open('http://bbb.com', 'title'); // 父窗口向子窗口发消息 popup.postMessage('Hello World!', 'http://bbb.com'); ``` `postMessage`方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即“协议 + 域名 + 端口”。也可以设为`*`,表示不限制域名,向所有窗口发送。 子窗口向父窗口发送消息的写法类似。 ```js // 子窗口向父窗口发消息 window.opener.postMessage('Nice to see you', 'http://aaa.com'); ``` 父窗口和子窗口都可以通过`message`事件,监听对方的消息。 ```js // 父窗口和子窗口都可以用下面的代码, // 监听 message 消息 window.addEventListener('message', function (e) { console.log(e.data); },false); ``` `message`事件的参数是事件对象`event`,提供以下三个属性。 * `event.source`:发送消息的窗口 * `event.origin`: 消息发送者的源(origin),即协议、域名、端口。 * `event.data`: 消息内容 下面的例子是,子窗口通过`event.source`属性引用父窗口,然后发送消息(收到它(父窗口)的信息后回复它)。 ```js window.addEventListener('message', receiveMessage); function receiveMessage(event) { event.source.postMessage('Nice to see you!', '*'); } ``` 上面代码有几个地方需要注意。首先,`receiveMessage`函数里面没有过滤信息的来源,任意网址发来的信息都会被处理。其次,`postMessage`方法中指定的目标窗口的网址是一个星号,表示该信息可以向任意网址发送。通常来说,这两种做法是不推荐的,因为不够安全,可能会被恶意利用。 `event.origin`属性可以过滤非许可地址发来的消息。 ```js window.addEventListener('message', receiveMessage); function receiveMessage(event) { if (event.origin !== 'http://aaa.com') return; if (event.data === 'Hello World') { event.source.postMessage('Hello', event.origin); } else { console.log(event.data); } } ``` #### LocalStorage 通过`window.postMessage`,读写其他窗口的 LocalStorage 也成为了可能。 下面是一个例子,主窗口写入 iframe 子窗口的`localStorage`。 ```js window.onmessage = function(e) { if (e.origin !== 'http://bbb.com') { return; } var payload = JSON.parse(e.data); localStorage.setItem(payload.key, JSON.stringify(payload.data)); }; ``` 上面代码中,子窗口将父窗口发来的消息,写入自己的 LocalStorage。 父窗口发送消息的代码如下。 ```js var win = document.getElementsByTagName('iframe')[0].contentWindow; var obj = { name: 'Jack' }; win.postMessage( JSON.stringify({key: 'storage', data: obj}), 'http://bbb.com' ); ``` **加强版的子窗口接收消息的代码如下。** ```js window.onmessage = function(e) { if (e.origin !== 'http://bbb.com') return; var payload = JSON.parse(e.data); switch (payload.method) { case 'set': localStorage.setItem(payload.key, JSON.stringify(payload.data)); break; case 'get': var parent = window.parent; var data = localStorage.getItem(payload.key); parent.postMessage(data, 'http://aaa.com'); break; case 'remove': localStorage.removeItem(payload.key); break; } }; ``` 加强版的父窗口发送消息代码如下。 ```js var win = document.getElementsByTagName('iframe')[0].contentWindow; var obj = { name: 'Jack' }; // 存入对象 win.postMessage( JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com' ); // 读取对象 win.postMessage( JSON.stringify({key: 'storage', method: "get"}), "*" ); window.onmessage = function(e) { if (e.origin != 'http://aaa.com') return; console.log(JSON.parse(e.data).name); }; ``` ### AJAX 同源政策规定,AJAX 请求只能发给同源的网址,否则就报错。 除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。 * JSONP * WebSocket * CORS #### JSONP JSONP 是服务器与客户端跨源通信的常用方法。最大特点就是简单易用,没有兼容性问题,老式浏览器全部支持,服务端改造非常小。 它的做法如下。 第一步,网页添加一个` ``` 注意,请求的脚本网址有一个`callback`参数(`?callback=bar`),用来告诉服务器,客户端的回调函数名称(`bar`)。 第二步,服务器收到请求后,拼接一个字符串,将 JSON 数据放在函数名里面,作为字符串返回(`bar({...})`)。 第三步,客户端会将服务器返回的字符串,作为代码解析,因为浏览器认为,这是` ``` ## ES6 模块化规范 ES6 模块化规范是一个官方标准**的规范,它是在语言标准的层面上实现了模块化功能,是目前**最流行的模块化规范,且浏览器与服务端均支持该规范。 ### demo **创建** **school.js** ```js // 导出name export let name = {str:'尚硅谷'} // 导出slogan export const slogan = '让天下没有难学的技术!' // 导出name export function getTel (){ return '010-56253825' } function getCities(){ return ['北京','上海','深圳','成都','武汉','西安'] } ``` **创建** **student.js** ```js // 导出name export const name = '张三' // 导出motto export const motto = '相信明天会更好!' // 导出getTel export function getTel (){ return '13877889900' } function getHobby(){ return ['抽烟','喝酒','烫头'] } ``` **创建** **index.js** ```js // 引入school模块暴露的所有内容 import * as school from './school.js' // 引入student模块暴露的所有内容 import * as student from './student.js' ``` **页面中引入** **index.js** ```js ``` ### Node 中运行 ES6 模块 `Node.js`中运行`ES6`模块代码有两种方式: * **方式一**:将`JavaScript`文件后缀从`.js` 改为`.mjs`,Node 则会自动识别 ES6 模块。 * **方式二**:在`package.json`中设置`type`属性值为`module`。 ### 导出数据 ES6 模块化提供 3 种导出方式: * 分别导出 * 同一导出 * 默认导出 #### **分别导出** 备注:在上方【demo】环节,我们使用的导出方式就是【分别导出】 ```js // 导出name export let name = {str:'尚硅谷'} // 导出slogan export const slogan = '让天下没有难学的技术!' // 导出getTel export function getTel (){ return '010-56253825' } ``` #### **统一导出** ```js const name = {str:'尚硅谷'} const slogan = '让天下没有难学的技术!' function getTel (){ return '010-56253825' } function getCities(){ return ['北京','上海','深圳','成都','武汉','西安'] } // 统一导出了:name,slogan,getTel export {name,slogan,getTel} ``` #### **默认导出** ```js const name = '张三' const motto = '走自己的路,让别人五路可走!' function getTel (){ return '13877889900' } function getHobby(){ return ['抽烟','喝酒','烫头'] } //默认导出:name,motto,getTel export default {name,motto,getTel} ``` **备注 :「上述多种导出方式,可以同时使用」** ```js // 导出name ———— 分别导出 export const name = {str:'尚硅谷'} const slogan = '让天下没有难学的技术!' function getTel (){ return '010-56253825' } function getCities(){ return ['北京','上海','深圳','成都','武汉','西安'] } // 导出slogan ———— 统一导出 export {slogan} // 导出getTel ———— 默认导出 export default getTel ``` ### 导入数据 对于 ES6 模块化来说,使用何种导入方式,要根据导出方式决定。 #### **导入全部** **通用**方法 可以将模块中的所有导出内容整合到一个对象中。 ```js import * as school from './school.js' ``` #### **命名导入** **对应导出方式:分别导出、统一导出** 导出数据的模块 ```js //分别导出 export const name = {str:'尚硅谷'} //分别导出 export const slogan = '让天下没有难学的技术!' function getTel (){ return '010-56253825' } function getCities(){ return ['北京','上海','深圳','成都','武汉','西安'] } //统一导出 export { getTel } ``` 命名导入: ```js import { name,slogan,getTel } from './school.js' ``` 通过`as`重命名: ```javascript import { name as myName,slogan,getTel } from './school.js' ``` #### **默认导入** **对应导出方式:默认导出** 导出数据的模块 ```js const name = '张三' const motto = '走自己的路,让别人五路可走!' function getTel (){ return '13877889900' } function getHobby(){ return ['抽烟','喝酒','烫头'] } //使用默认导出的方式,导出一个对象,对象中包含着数据 export default { name,motto,getTel } ``` 默认导入: ```js import student from './student.js' //默认导出的名字可以修改,不是必须为student ``` #### 混合使用 **命名导入** **与** **默认导入**可以混合使用 导出数据的模块 ```js //分别导出 export const name = {str:'尚硅谷'} //分别导出 export const slogan = '让天下没有难学的技术!' function getTel (){ return '010-56253825' } function getCities(){ return ['北京','上海','深圳','成都','武汉','西安'] } //统一导出 export default getTel ``` > \[!IMPORTANT] > > 「命名导入」与「默认导入」混合使用,且默认导入的内容必须放在前方: ```js import getTel,{name,slogan} from './school.js' ``` #### **动态导入** **通用** 允许在运行时按需加载模块,返回值是一个 Promise。 ```js const school = await import('./school.js'); console.log(school) ``` #### 运行导入 **import 可以不接收任何数据** 例如只是让 mock.js 参与运行 ```js import './mock.js' ``` > \[!NOTE] > > 此时,我们感受到模块化确实解决了: > > * 全局污染问题 > * 依赖混乱问题 > * 数据安全问题 > 来自尚硅谷-[随课笔记](https://www.yuque.com/tianyu-coder/openshare/hycdb5tispao428h) ### 数据引用问题 以下代码的输出结果? ```js function count (){ let sum = 1 function increment(){ sum += 1 } return {sum,increment} } const {sum,increment} = count() console.log(sum) increment() increment() console.log(sum) ``` 使用 CommonJS 规范,编写如下代码,输出结果? ```js let sum = 1 function increment (){ sum += 1 } module.exports = {sum,increment} ``` ```js const {sum,increment} = require('./count.js') console.log(sum) increment() increment() console.log(sum) ``` 使用 ES6 模块化规范,编写如下代码,输出结果? ```js let sum = 1 function increment(){ sum += 1 } export {sum,increment} ``` ```js import {sum,increment} from './count.js' console.log(sum) //1 increment() increment() console.log(sum) //3 ``` > \[!TIP] > > 使用原则:导出的常量,务必用`const`定义 ## AMD ... ## CMD ... --- --- url: /js/e2251035/index.md --- # 事件 上 ### EventTarget 接口 事件的本质是程序各个组成部分之间的一种通信方式,也是异步编程的一种实现。 DOM 支持大量的事件,本章开始介绍 DOM 的事件编程。 介绍具体的事件之前,先来看看如何让 DOM 节点监听事件。 #### 概述 DOM 节点的事件操作(监听和触发),都定义在`EventTarget`接口。所有节点对象都部署了这个接口,其他一些需要事件通信的浏览器内置对象(比如,`XMLHttpRequest`、`AudioNode`、`AudioContext`)也部署了这个接口。 #### EventTarget.addEventListener() `EventTarget.addEventListener()`用于在当前节点或对象上(即部署了 EventTarget 接口的对象),定义一个特定事件的监听函数。 一旦这个事件发生,就会执行监听函数。该方法没有返回值。 ```js target.addEventListener(type, listener[, useCapture]); ``` 该方法接受三个参数。 * `type`:事件名称,大小写敏感。 * `listener`:监听函数。事件发生时,会调用该监听函数。 * `useCapture`:布尔值,如果设为`true`,表示监听函数将在捕获阶段(capture)触发。该参数可选,默认值为`false`(监听函数只在冒泡阶段被触发)。 ```js function hello() { console.log('Hello world'); } var button = document.getElementById('btn'); button.addEventListener('click', hello, false); ``` 上面代码中,`button`节点的`addEventListener()`方法绑定`click`事件的监听函数`hello()`,该函数只在冒泡阶段触发。 关于参数,有两个地方需要注意。 首先,第二个参数除了监听函数,还可以是一个具有`handleEvent`方法的对象,效果与监听函数一样。 ```js buttonElement.addEventListener('click', { handleEvent: function (event) { console.log('click'); } }); ``` 第三个参数除了布尔值`useCapture`,还可以是一个监听器配置对象,定制事件监听行为。该对象有以下属性。 * `capture`:布尔值,如果设为`true`,表示监听函数在捕获阶段触发,默认为`false`,在冒泡阶段触发。 * `once`:布尔值,如果设为`true`,表示监听函数执行一次就会自动移除,后面将不再监听该事件。该属性默认值为`false`。 * `passive`:布尔值,设为`true`时,表示禁止监听函数调用`preventDefault()`方法。如果调用了,浏览器将忽略这个要求,并在控制台输出一条警告。该属性默认值为`false`。 * `signal`:该属性的值为一个 AbortSignal 对象,为监听器设置了一个信号通道,用来在需要时发出信号,移除监听函数。 `addEventListener()`方法可以为针对当前对象的同一个事件,添加多个不同的监听函数。这些函数按照添加顺序触发,即先添加先触发。如果为同一个事件多次添加同一个监听函数,该函数只会执行一次,多余的添加将自动被去除(不必使用`removeEventListener()`方法手动去除)。 如果希望向监听函数传递参数,可以用匿名函数包装一下监听函数。 ```js function print(x) { console.log(x); } var el = document.getElementById('div1'); el.addEventListener('click', function () { print('Hello'); }, false); ``` 上面代码通过匿名函数,向监听函数`print`传递了一个参数。 监听函数内部的`this`,指向当前事件所在的那个对象。 ```js // HTML 代码如下 //

Hello

var para = document.getElementById('para'); para.addEventListener('click', function (e) { console.log(this.nodeName); // "P" }, false); ``` #### EventTarget.removeEventListener() `EventTarget.removeEventListener()`方法用来移除`addEventListener()`方法添加的事件监听函数。该方法没有返回值。 `removeEventListener()`方法的参数,与`addEventListener()`方法完全一致。它的第一个参数“事件类型”,大小写敏感。 注意,**`removeEventListener()`方法移除的监听函数,必须是`addEventListener()`方法添加的那个监听函数,而且必须在同一个元素节点,否则无效。** ```js div.addEventListener('click', function (e) {}, false); div.removeEventListener('click', function (e) {}, false); ``` 上面代码中,`removeEventListener()`方法无效,因为监听函数不是同一个匿名函数。 ```js element.addEventListener('mousedown', handleMouseDown, true); element.removeEventListener("mousedown", handleMouseDown, false); ``` 上面代码中,`removeEventListener()`方法也是无效的,因为第三个参数不一样。 #### EventTarget.dispatchEvent() `EventTarget.dispatchEvent()`方法在当前节点上触发指定事件,从而触发监听函数的执行。该方法返回一个布尔值,只要有一个监听函数调用了`Event.preventDefault()`,则返回值为`false`,否则为`true`。 ```js target.dispatchEvent(event) ``` `dispatchEvent()`方法的参数是一个`Event`对象的实例。 ```js para.addEventListener('click', hello, false); var event = new Event('click'); para.dispatchEvent(event); ``` 上面代码在当前节点触发了`click`事件。 如果`dispatchEvent()`方法的参数为空,或者不是一个有效的事件对象,将报错。 下面代码根据`dispatchEvent()`方法的返回值,判断事件是否被取消了。 ```js var canceled = !cb.dispatchEvent(event); if (canceled) { console.log('事件取消'); } else { console.log('事件未取消'); } ``` ### 事件模型 #### 监听函数 浏览器的事件模型,就是通过监听函数(listener)对事件做出反应。事件发生后,浏览器监听到了这个事件,就会执行对应的监听函数。这是事件驱动编程模式(event-driven)的主要编程方式。 JavaScript 有三种方法,可以为事件绑定监听函数。 ##### HTML 的 on- 属性 HTML 语言允许在元素的属性中,直接定义某些事件的监听代码。 ```js
``` 上面代码为`body`节点的`load`事件、`div`节点的`click`事件,指定了监听代码。一旦事件发生,就会执行这段代码。 元素的事件监听属性,都是`on`加上事件名,比如`onload`就是`on + load`,表示`load`事件的监听代码。 注意,这些属性的值是将会执行的代码,而不是一个函数。 ```js ``` 一旦指定的事件发生,`on-`属性的值是原样传入 JavaScript 引擎执行。因此如果要执行函数,不要忘记加上一对圆括号。 使用这个方法指定的监听代码,只会在冒泡阶段触发。 ```html
``` 上面代码中,` ``` 执行上面代码,点击后会输出`btn`。 其他两种监听函数的写法,`this`的指向也是如此。 #### 事件的传播 一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段。 * **第一阶段**:从`window`对象传导到目标节点(上层传到底层),称为“捕获阶段”(capture phase)。 * **第二阶段**:在目标节点上触发,称为“目标阶段”(target phase)。 * **第三阶段**:从目标节点传导回`window`对象(从底层传回上层),称为“冒泡阶段”(bubbling phase)。 这种三阶段的传播模型,使得同一个事件会在多个节点上触发。 ```html

点击

``` 上面代码中,`
`节点之中有一个`

`节点。 如果对这两个节点,都设置`click`事件的监听函数(每个节点的捕获阶段和冒泡阶段,各设置一个监听函数),共计设置四个监听函数。然后,对`

`点击,`click`事件会触发四次。 ```js var phases = { 1: 'capture', 2: 'target', 3: 'bubble' }; var div = document.querySelector('div'); var p = document.querySelector('p'); div.addEventListener('click', callback, true); p.addEventListener('click', callback, true); div.addEventListener('click', callback, false); p.addEventListener('click', callback, false); function callback(event) { var tag = event.currentTarget.tagName; var phase = phases[event.eventPhase]; console.log("Tag: '" + tag + "'. EventPhase: '" + phase + "'"); } // 点击以后的结果 // Tag: 'DIV'. EventPhase: 'capture' // Tag: 'P'. EventPhase: 'target' // Tag: 'P'. EventPhase: 'target' // Tag: 'DIV'. EventPhase: 'bubble' ``` 上面代码表示,`click`事件被触发了四次:`

`节点的捕获阶段和冒泡阶段各1次,`

`节点的目标阶段触发了2次。 1. 捕获阶段:事件从`

`向`

`传播时,触发`

`的`click`事件; 2. 目标阶段:事件从`
`到达`

`时,触发`

`的`click`事件; 3. 冒泡阶段:事件从`

`传回`

`时,再次触发`
`的`click`事件。 其中,`

`节点有两个监听函数(`addEventListener`方法第三个参数的不同,会导致绑定两个监听函数),因此它们都会因为`click`事件触发一次。所以,`

`会在`target`阶段有两次输出。 注意,浏览器总是假定`click`事件的目标节点,就是点击位置嵌套最深的那个节点(本例是`

`节点里面的`

`节点)。所以,`

`节点的捕获阶段和冒泡阶段,都会显示为`target`阶段。 事件传播的最上层对象是`window`,接着依次是`document`,`html`(`document.documentElement`)和`body`(`document.body`)。也就是说,上例的事件传播顺序,在捕获阶段依次为`window`、`document`、`html`、`body`、`div`、`p`,在冒泡阶段依次为`p`、`div`、`body`、`html`、`document`、`window`。 #### 事件的代理 由于事件会在冒泡阶段向上传播到父节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。这种方法叫做事件的代理(delegation)。 ```js var ul = document.querySelector('ul'); ul.addEventListener('click', function (event) { if (event.target.tagName.toLowerCase() === 'li') { // some code } }); ``` 上面代码中,`click`事件的监听函数定义在`