二、建立一个表情图创建器

正如本章名称所示,我们将在本章中构建一个有趣的应用——一个Meme Creator。 每个人都喜欢表情图! 但这并不是我们创建 Meme Creator 的唯一原因。 我们将探索一些新事物,它们将改变你构建 web 应用的方式。 让我们看看有什么准备:

  • CSS3 flexbox简介。 一种在网络上创建响应式布局的新方法。
  • 使用Webpack模块绑定器将所有依赖项和代码转换为静态资产。
  • 使用HTML5 canvas使用 JavaScript 动态绘制图形。
  • 创建一个经过充分优化、缩小和版本化的稳定的生产构建。

之前,您在学习 JavaScript 的 ES6 新特性的同时,成功地构建了一个 ToDo List 应用。 在本章的最后,你学习了如何使用 Node 和 npm 进行 web 开发。 我们只涉及了基本的东西。 我们还没有意识到在我们的项目中使用 npm 的全部潜力。 这就是为什么,在这个项目中,我们将试验一个强大的模块绑定器 Webpack。 在我们开始构建一个完全自动化的开发环境的实验之前,让我们先进行一些设置。

初始项目设置

为 Meme Creator 应用创建一个新文件夹。 在 VSCode 或任何其他用于此项目的文本编辑器中打开该文件夹。 在您的终端中导航到该文件夹并运行npm init。 正如我们在前一章所做的那样,填写所有细节要求终端,然后点击进入在 Windows 或返回在 Mac,你会有你的package.json文件在项目的根。

从为本书下载的代码文件中,打开第 2 章的 starter files 文件夹。 您将看到一个index.html文件。 复制并粘贴到您的新项目文件夹中。 这就是本章提供的所有入门文件,因为没有默认的 CSS 文件。 我们将从头开始构建 UI !

创建本章中将要使用的文件和文件夹。 文件夹结构如下所示:

.
├── index.html
├── package.json
└── src
    ├── css
       └── styles.css
    └── js
         ├── general.js
         └── memes.js

现在,让 JS 文件为空。 我们要处理styles.css文件。 在你的浏览器中打开index.html(尝试使用我们在上一章中全局安装的http-server包)。 你应该会看到一个看起来很尴尬的页面,其中使用 Bootstrap 的类应用了一些默认的 Bootstrap 样式。 我们将把这个页面变成一个 Meme Creator 应用,如下所示:

这个 web 应用也会有响应。 所以,在你的移动设备上,它应该如下所示:

这个空白框将是我们的画布,它将预览这个应用创建的表情包。现在你已经对这个应用的外观有了一个概念,我们将开始处理我们的styles.css文件。

响应式设计与 flexbox

如果你查看我们上一章的index.html文件,你会看到有类,比如col-md-2col-xs-2col-lg-2col-sm-2等等。 它们是 Bootstrap 的网格类。 上一章的布局是使用 Bootstrap 网格系统设计的。 系统将页面分为行和 12 列,并根据屏幕大小为每行分配特定的列数div

有四种不同的屏幕尺寸:

  • 桌面(md)
  • 平板电脑(sm)
  • 电话(x)
  • 大型桌面(lg)

然而,我们不打算在本章中使用 Bootstrap 网格。 我们将使用 CSS3 中引入的一个新的布局模式,叫做 flexbox。 Flexbox 或灵活盒,就像它的名字一样,提供了一个用于创建布局的盒模型。

Flexbox is a new layout system, which is actively being implemented by the browser vendors. Support is almost complete; it's time to adopt this standard in projects. A few problems still exist, such as IE 11 only having partial flexbox support and older versions of IE do not support flexbox. Visit https://caniuse.com/ to check details on browser support for flexbox.

Flexbox -一个快速的介绍

在 flexbox 布局系统中,你用一个 CSS 属性display: flex声明一个父元素div,它允许你控制你想要如何定位它的子元素。

一旦你声明了display: flexdiv元素就变成了一个带有两个轴的 flexbox。 将主轴随内容物放置于横轴,与主轴垂直。 你可以在父 flex 中使用以下 CSS 属性来改变子元素(flex 项目)的位置:

  • :水平(行)或垂直(列)创建主轴
  • :指定伸缩项目如何放置在主轴上
  • 对齐项目:指定如何将伸缩项目放置在十字轴上
  • flex-wrap:指定如何处理伸缩项目时,没有足够的空间显示单行

你也可以应用一些伸缩属性到伸缩项目,比如:

  • align-self:指定如何将特定的伸缩项目放置在十字轴上
  • flex:伸缩项目相对于其他伸缩项目的相对大小(如果您有两个分别带有flex: 2flex: 1的项目,那么第一个项目的大小将是第二个项目的两倍)

所有这些听起来都令人困惑,但理解 flexbox 最简单的方法是使用在线 flexbox 游乐场。 谷歌一些 flexbox 操场可在线体验如何不同的性质的 flexbox 工作。 你可以在http://flexboxplayground.catchmyfame.com/上找到这样一个平台。

要学习 flexbox,请参考以下页面:

At the time of writing this book, the latest version of Safari browser 10.1 is having problems with the flex-wrap property, which is fixed in nightly builds. If you are using the same or an earlier version of the Safari browser, I'd recommend using Chrome for this chapter.

设计表情图创建器

在我们的index.html文件中,我们的<body>元素被分为导航条和div,其中包含了网站的内容。 div.body又分为div.canvas-areadiv.input-area

导航栏

文档主体的第一部分是导航栏<nav>。 导航栏通常包含用于网站导航的主要链接集。 因为在本章中我们只构建了一个页面,所以我们可以在导航栏上只留下我们的页面标题。

导航栏使用 Bootstrap 样式。 类.navbar将各自的元素样式设置为页面的主导航栏。 .navbar-inverse类为导航栏添加一个深色,.navbar-fixed-top类将导航栏固定在屏幕顶部。 导航栏的内容包裹在 Bootstrap 容器中(div.container)。 页面标题写在div.navbar-header中,作为一个锚标记,带有类.navbar-brand,它告诉 Bootstrap 这是应用的品牌名称/标题。

The Bootstrap navigation bar is highly customizable. To learn more about this topic, refer to W3Schools' Bootstrap tutorial: https://www.w3schools.com/bootstrap/ or Bootstrap's official documentation: http://getbootstrap.com/getting-started/.

内容区域

导航栏位于屏幕上方的固定位置。 因此,它将与页面内容重叠。 打开styles.css,添加以下代码:

body {
  padding-top: 65px;
}

这将添加填充到整个主体部分,使导航条不会与我们的内容重叠。 现在,我们需要将我们的主要内容区div.body转换为 flexbox:

.body {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: space-around;
}

这将把我们的div.body元素转换为一个 flexbox,它将其内容组织为一行(flex-direction),如果空间不能用于整个行(flex-wrap),则将内容包装到新行。 此外,内容将被水平等边距包围(justify-content)。

你猜怎么着? 我们是做! 我们的主要布局已经完成! 切换到 Chrome,硬加载,并看到内容现在水平对齐。 开拓响应式设计模式; 对于移动设备,您将看到该行自动分成两行以显示内容。 如果没有 flexbox,这将需要三倍的代码量来实现相同的布局。 Flexbox 大大简化了布局过程。

现在我们的主布局已经完成了,让我们为单个元素添加一些样式,例如:

  • .canvas-area制作为.input-area的两倍
  • 向 canvas 元素添加黑色边框
  • 在画布和表单输入的各自区域中居中
  • 此外,我们需要为.canvas-area.input-area添加边距,以便在行换行时它们之间有空格

为了实现这些样式,添加以下 CSS 到你的styles.css文件:

.canvas-area {
   flex: 2;
   display: flex;
   align-items: center;
   justify-content: center;
   margin: 10px;
}
.img-canvas {
   border: 1px solid #000000;
}
.input-area {
   flex: 1;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
   margin: 10px;
}

canvas 区域仍然非常小,但我们将通过 JavaScript 代码来处理它的大小。 现在,我们不需要担心画布的大小。

我们几乎完成了我们的样式,除了表单输入现在是不同的大小。 这是因为 Bootstrap 的.form-input样式告诉各自的div占用其父div的整个宽度。 然而,当我们在样式中添加align-items: center时,我们是在告诉父div分配有限的宽度,以便内容不重叠,然后在 flexbox 中居中。 因此,每个元素的宽度现在根据其内容而不同。

为了克服这个问题,我们只需要为.form-input类指定一个固定的宽度。 同时,让我们为下载按钮添加一些额外的顶部 margin。 在你的styles.css文件的末尾添加以下行:

.form-group {
  width: 90%;
}
.download-button {
  margin-top: 10px;
}

现在我们已经完成了使用 flexbox 为 Meme Creator 创建 UI 的工作。 是时候转到本章最重要的话题了。

Due to its ease of use and a huge amount of features, the flexbox layout system is also being adopted in mobile application development. React Native uses flexbox to create a UI for Android and iOS apps. Facebook has also released open source libraries, such as yoga and litho, to use flexbox in native Android and iOS apps.

Webpack 模块打包机

现在终于到了设置全功能开发环境的时候了。 你可能想知道 Webpack 是什么,它和开发环境有什么关系。 或者,您熟悉 gulp 或 grunt 等工具,想知道 Webpack 与它们有何不同。

如果您以前使用过 gulp 或 grunt,那么它们是任务运行器。 它们执行一组特定的任务来编译、转换和最小化代码。 还有一个叫做Browserify的工具,它可以让你在浏览器中使用require()。 通常,使用 gulp/grunt 的开发环境涉及使用不同的工具集(如 Babel、Browserify 等)执行各种命令,以特定的顺序生成所需的输出代码。 但 Webpack 不同。 与任务运行器不同,Webpack 不运行一组命令来构建代码。 相反,它充当一个模块绑定器。

Webpack 检查你的 JavaScript 代码,寻找importrequire等等,以找到依赖于它的文件。 然后,它将文件加载到依赖关系图中,然后查找这些文件和依赖关系。 这个过程会一直进行下去,直到不再存在依赖关系为止。 最后,它使用自己构建的依赖关系图将依赖关系文件与初始文件捆绑到一个文件中。 这个功能在现代 JavaScript 开发中非常有用,因为所有内容都是作为模块编写的:

Webpack is being adopted as the bundler of popular modern frameworks, such as React, Angular, and Vue. It is also a good skill to have on your resume.

在 JavaScript 模块

还记得我们在前一章构建的待办事项列表应用吗? 我们使用 npm 安装 Babel,将 ES6 代码转换为 ES5。 导航到ToDo List文件夹,打开node_modules文件夹。 你会发现一个包含各种包的文件夹的巨大列表! 即使你只安装了四个包,npm 也会跟踪所需包的所有依赖关系,并将它们与实际包一起安装。

我们只使用那些包作为开发依赖来编译我们的代码。 所以,我们不知道这些包是如何构建的。 这些包被构建为模块。 模块是一段独立的可重用代码,它返回一个值。 取值为 object、function、stringint等。 模块被广泛用于构建大型应用。 Node.js 支持导出和导入 JavaScript 模块,这些模块目前在浏览器中不可用。

让我们看看如何用 JavaScript 创建一个简单的模块:

function sum (a, b) {
  return a+b;
}

考虑前面提到的返回两个数字和的函数。 我们将把这个函数转换成一个模块。 创建一个新文件sum.js,并编写如下函数:

export function sum (a, b) {
  return a+b;
}

这是所有! 你只需要在你想要导出的变量或对象之前添加一个export键盘,它将成为一个模块,可以在不同的文件中使用。 假设您有一个名为add.js的文件,您需要找到两个数字的和。 导入sum模块的方法如下:

// In file add.js at the same directory as sum.js
import { sum } from './sum.js';

let a = 5, b = 6, total;
total = sum(a, b);

你可以忽略扩展.js,如果你正在导入一个 JavaScript 文件,并使用import { sum } from './sum'。 你也可以使用以下方法:

let sum = (a, b) => return a+b;
module.exports = { sum };

然后导入,如下所示:

const sum = require('./sum');

module.exportsrequire关键字在 ES6 引入之前就已经被 Node.js 用于导入和导出 JavaScript 模块。 然而,ES6 有一个使用关键字importexport的新的模块语法。 Webpack 支持所有类型的导入和导出。 对于我们的项目,我们将坚持使用 ES6 模块。

考虑以下文件sides.js,其中包含多个模块中的几何图形的边数:

export default TRIANGLE = 3;
export const SQUARE = 4;
export const PENTAGON = 5;
export const HEXAGON = 6;

要将它们全部导入到我们的文件中,你可以使用以下方法:

import * as sides from './sides.js';

现在,从sides.js文件导出的所有变量/对象都可以在sides对象中访问。 要得到TRIANGLE的值,只需使用sides.LINE。 另外,请注意,TRIANGLE被标记为 default。 当同一个文件中有多个模块时,一个default导出是有用的。 输入以下内容:

import side from './sides.js';

现在,side将包含默认导出TRIANGLE的值。 现在,side = 3。 要在导入默认模块的同时导入其他模块,可以使用以下方法:

import TRIANGLE, { SQUARE, PENTAGON, HEXAGON } from './sides.js';

现在,如果你想导入一个在node_modules文件夹内的模块,你可以完全忽略相对文件路径(./部分),只输入import jquery from 'jquery';。 Node.js 或 Webpack 将自动从文件的父目录中找到最近的node_modules文件夹,并自动搜索所需的包。 只需确保您已经使用npm install安装了包。

这几乎涵盖了在 JavaScript 中使用模块的基础知识。 现在是时候了解 Webpack 在我们项目中的角色了。

在 Webpack 中绑定模块

要开始使用 Webpack,让我们先写一些 JavaScript 代码。 打开memes.js文件和general.js文件。 在这两个文件中编写以下代码,它只是在控制台中打印各自的文件名:

// inside memes.js file
console.log('Memes JS file');
// inside general.js file
console.log('General JS File');

通常,在构建包含大量 HTML 文件的多页 web 应用时,通常会使用一个 JavaScript 文件,其中的代码需要在所有 HTML 文件上运行。 为此,我们将使用general.js文件。 虽然我们的 Meme Creator 只有一个 HTML 文件,但我们将使用general.js文件包含一些通用代码,并将 Meme Creator 的代码包含在memes.js文件中。

为什么我们不试着在我们的memes.js文件中导入general.js文件? 由于general.js不导出任何模块,只需在您的memes.js文件中输入以下代码:

import './general';

在你的index.html文件的<body>元素的末尾包含一个参考memes.js文件的script标签,并在 Chrome 中看到结果。 如果一切顺利,你应该会在 Chrome 控制台看到一个错误,说:意外令牌导入。 这意味着有些事情对 Chrome 来说并不顺利。 是的! Chrome 不知道如何使用import关键字。 为了使用import,我们需要 Webpack 将general.jsmeme.js文件捆绑在一起,并将其作为一个单独的文件提供给 Chrome。

让我们安装 Webpack 作为我们项目的开发依赖项。 在终端中执行如下命令:

npm install -D webpack

Webpack 现在作为开发依赖安装到我们的项目中。 Webpack 也是一个类似于 Babel 的命令行工具。 要运行 Webpack,我们需要使用npm脚本。 在您的package.json文件中,在测试脚本下面,创建以下脚本:

"webpack": "webpack src/js/memes.js --output-filename dist/memes.js",

现在在你的终端上运行以下命令:

npm run webpack

将在dist/js/目录下创建一个新的memes.js文件。 该文件包含捆绑在一起的general.jsmemes.js文件。 在 VSCode 中打开新的 JavaScript 代码; 您应该会看到大量代码。 在这个阶段没有必要恐慌; 这是 Webpack 用来管理绑定文件的作用域和属性的代码。 这是我们现在不需要担心的事情。 如果您滚动到文件的末尾,您将看到我们在两个原始文件中编写的console.log语句。 编辑你的脚本标签在index.html,包括新的文件,如下所示:

<script src="./dist/memes.js"></script>

现在,在 Chrome 中重新加载页面,你应该看到控制台语句从两个文件都在memes.js文件中执行。 我们已经成功地在代码中导入了一个 JavaScript 文件。 在我们之前的项目中,我们设置了开发环境,以便每当源文件中发生更改时,代码将被自动编译并提供服务。 要完成 ES6 到 ES5 的编译和其他任务,我们需要安装很多包,必须给 Webpack 很多指令。 为此,在你的项目根目录中创建webpack.config.js,并编写以下代码:

const webpack = require('webpack');

module.exports = {
  context: __dirname,
  entry: {
    general: './src/js/general.js',
    memes: './src/js/memes.js',
  },
  output: {
    path: __dirname + "/dist",
    filename: '[name].js',
  },
}

删除package.json中传递给 Webpack 的所有选项。 现在,你的脚本里面package.json应该如下所示:

"webpack": "webpack"

由于我们没有向 Webpack 传递任何参数,它将在执行它的目录中查找webpack.config.js文件。 现在它将从我们刚刚创建的文件中读取配置。 配置文件中的第一行是使用require('webpack')导入 Webpack。 我们仍然使用 Node.js 来执行我们的代码,所以我们应该在 Webpack 配置文件中使用require。 我们只需要将该文件中的配置导出为 JSON 对象。 在module.exports对象中,每个属性的用途如下:

  • context:指定入口段中需要解析文件路径的绝对路径。 这里,__dirname是一个常量,它将自动包含当前目录的绝对路径。
  • entry:指定 Webpack 需要捆绑的所有文件。 它接受字符串、数组和 JSON 对象。 如果你需要 Webpack 打包单个入口文件,只需将文件的路径指定为字符串。 否则,使用数组或对象。
    • 在本例中,我们将输入文件指定为[name]: [path_of_the_file]格式的对象。
    • 这个[name]将用于命名每个文件的输出包。
  • output:在输出中,我们需要指定输出目录的绝对路径,在我们的例子中是dist,文件名是[name],我们在入口节中指定,然后是文件扩展名[name].js

在终端上运行npm run webpack。 您应该看到在dist目录中创建了两个新文件:general.jsmemes.js,它们包含来自各自源文件的捆绑代码。 memes.js文件将包含来自general.js文件的代码,所以在 HTML 中只包含memes.js文件就足够了。

现在我们已经编写了用于绑定代码的配置,我们将使用这个配置文件将 ES6 语法转换为 ES5。 在 Webpack 中,导入文件时应用转换。 要应用转换,我们需要使用加载器。

加载器在 Webpack

加载器用于在导入和绑定文件之前对文件应用转换。 在 Webpack 中,使用不同的第三方加载器,我们可以转换任何文件并将其导入到我们的代码中。 这适用于用其他语言编写的文件,如 TypeScript、Dart 等。 我们甚至可以导入 CSS 和图像到我们的 JS 代码。 首先,我们将使用加载器将 ES6 转换为 ES5。

memes.js文件中添加以下代码:

class Memes {
  constructor() {
    console.log('Inside Memes class');
  }
}

new Memes();

这是一个使用 ES6 的简单类,在构造函数中有一个console.log语句。 我们将使用 Webpack 和babel-loader将这些 ES6 代码转换为 ES5 形式。 为此,请安装以下软件包:

npm install -D babel-core babel-loader babel-preset-env babel-preset-es2015

在你的webpack.config.js文件中,在输出属性下面添加以下代码:

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /(node_modules)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['env', 'es2015'],
        }
      }
    }
  ],
},

这就是我们在 Webpack 中添加加载器的方法。 我们需要在模块部分中创建一个规则数组。 该规则包含加载器的配置对象数组。 在我们的配置中,它将测试文件,看看它是否匹配正则表达式.js$,也就是说,检查文件是否是使用其扩展名的 JavaScript 文件。 我们已经排除了node_modules目录,因此只有我们的代码将被评估转换。

如果导入的文件是 JavaScript 文件,Webpack 将使用babel-loader和提供的选项。 在这里,在options中,我们指导巴别塔使用enves2015预设。 预设的es2015会将 ES6 代码转换为 ES5 格式。

env预设更特别。 它用于将任何 ES 版本的 javascript 编译为特定环境支持的版本(例如特定版本的 Chrome 和 Firefox)。 如果没有提供配置,就像我们前面提到的代码一样,那么它将使 JavaScript 代码(甚至是 ES8)在几乎所有环境中工作。 更多信息请登录https://github.com/babel/babel-preset-env

Since we are only going to use ES6 in this book, the es2015 preset is enough for all the projects. However, if you want to learn ES7 and beyond in the future, do learn the working of the env preset.

同样,让我们使用 Webpack 打包 CSS 代码。 将 CSS 代码与 Webpack 绑定有很多好处。 其中一些建议如下:

  • 只使用所需的 CSS 代码为每个网页导入它在各自的 JavaScript 文件。 这将导致更容易和更好的依赖管理,并减少每个页面的文件大小。
  • 缩小 CSS 文件。
  • 自动添加特定于供应商的前缀轻松使用 autoprefixer。
  • 轻松地将使用 Sass、Less、Stylus 等编写的样式表编译为普通的 CSS。

至于为什么你需要使用 Webpack 捆绑你的 CSS 代码,还有更多的好处。 所以,让我们开始捆绑我们的styles.css文件,然后 Bootstrap 的文件。 安装以下依赖项来实现我们的 CSS 加载器:

npm install -D css-loader style-loader

在 Webpack 配置中,将以下对象添加到 rules 数组中:

{
  test: /\.css$/,
  use: [ 'style-loader', 'css-loader' ]
},

我们正在安装两个加载器来捆绑 CSS 文件:

  1. 第一个是css-loader。 它使用 Webpack 解析所有的导入和url()。 然后返回完整的 CSS 文件。
  2. style-loader将添加 CSS 到页面,以便样式在页面上是活动的。
  3. 首先运行css-loader,然后运行style-loaderstyle-loader使用css-loader返回的输出。 为此,我们写了以下内容:
    • 对于 CSS 文件:test: /\.css$/
    • 使用以下加载器:use: ['style-loader', 'css-loader']。 Webpack 按照最后一个到第一个顺序执行加载器。 因此,首先,将执行css-loader,并将其输出传递给style-loader
  4. 打开你的general.js文件,并在文件的开头添加以下行:
import '../css/styles.css';

另外,删除用于在您的index.html页面中包含 CSS 文件的<link>属性。 这里的技巧:CSS 文件将被导入到general.js文件中,而general.js文件又将被导入到memes.js文件中,这是你在index.html中唯一需要包含的文件。

We are going to create a large webpack.config.js file. If you face any problems, refer to the final webpack.config.js file we are creating at either: https://goo.gl/Q8P4ta or the book's code files under the chapter02\webpack-dev-server directory.

现在是时候看看我们的应用了。 在你的终端执行npm run webpack,并打开网站,只有一个单一的memes.js文件包括在 Chrome。 您应该看到没有任何更改的准确页面。 所有依赖项都被捆绑到一个文件中——除了 Bootstrap!

Webpack 中捆绑 Bootstrap

是时候将我们最后的依赖捆绑到 Webpack 中了。 引导由三部分组成。 首先是 Bootstrap 的 CSS 文件,其次是 jQuery 和 Bootstrap 的 JavaScript 文件,后者依赖于 jQuery。 最后两个文件在本章的index.html文件中被忽略,因为我们没有使用它们。 但是,既然我们将依赖与 Webpack 绑定在一起,就让我们把它们都放在一起吧。 第一步,安装我们的依赖项(这些不是开发依赖项; 因此,用-S代替-D:

npm install -S jquery bootstrap@3

Bootstrap 使用较少而不是 CSS。 Less是一个 CSS 预处理器,扩展 CSS 的更多特性,如变量,mixin,和函数。 为了使用 Webpack 导入 Bootstrap 的 less 文件,我们需要另一个加载器:

npm install -D less less-loader

这将在我们的node_modules中安装较少的编译器和加载器。 现在,在我们的规则中,将 CSS 规则修改为:

{
  test: /\.(less|css)$/,
  use: [ 'style-loader', 'css-loader', 'less-loader' ]
},

这将添加less-loader作为加载器的第一个选项,当 Webpack 检测到 CSS 或更小的文件时。 现在,试试npm run webpack。 这一次,对于 Bootstrap 使用的字体,您将在终端中得到一个错误,提示“您可能需要一个合适的加载器来处理这个文件类型”。 由于 Bootstrap 依赖于很多字体,我们需要创建一个单独的加载器来将它们包含在我们的包中。 为此目的,安装以下设备:

npm install -D file-loader url-loader

然后在你的规则数组中包含以下对象:

{
  test: /\.(svg|eot|ttf|woff|woff2)$/,
  loader: 'url-loader',
  options: {
    limit: 10000,
    name: 'fonts/[name].[ext]'
  }
},

这将告诉 Webpack 文件大小是否小于 10kb。 然后,只需将文件作为数据 URL 内联到 JavaScript 中。 否则,将该文件移动到 fonts 文件夹中,并在 JavaScript 中创建一个引用。 如果文件小于 10 KB,这对于减少网络开销非常有用。 url-loader要求file-loader作为依赖项安装。 再次,执行npm run webpack,这一次,您的无引导文件将被成功捆绑,您将能够在浏览器中查看您的网站。

This may look like a lot of work for a few CSS and JS files. But, when you are working on large-scale applications, these configurations can save hours of development work. The biggest advantage of Webpack is that you can write the configuration for one project and use it for other projects. So, most of the work we do here will be done only once. We'll simply copy and use our webpack.config.js file in other projects.

正如我前面提到的,我们在应用中没有使用 Bootstrap 的 JS 文件。 然而,我们可能需要在将来的应用中使用它们。 Bootstrap 要求 jQuery 在全局范围内可用,以便它的 JavaScript 文件可以被执行。 然而,Webpack 不会公开它绑定的 JavaScript 变量,除非明确指定要公开它们。

为了让 jQuery 在整个 web 应用的全局范围内可用,我们需要使用 Webpack 插件。 插件不同于加载器。 我们稍后会看到更多关于插件的内容。 现在,在 Webpack 的 module 属性后面添加如下代码:

module: {
  rules: [...],
},
plugins: [
  new webpack.ProvidePlugin({
    jQuery: 'jquery',
    $: 'jquery',
    jquery: 'jquery'
  }),
],

在我们的general.js文件中,包括以下一行来导入所有的引导 JavaScript 文件到我们的 web 应用:

import 'bootstrap';

这一行将从node_modules文件夹中导入 Bootstrap 的 JavaScript 文件。 现在你已经成功地使用 Webpack 捆绑了 Bootstrap。 还有一个加载器是常用的- img-loader。 有些情况下,我们在 CSS 和 JavaScript 中包含图像。 使用 Webpack,我们可以自动打包图像,同时压缩较大图像的尺寸。

要捆绑图像,我们需要将img-loaderurl-loader一起使用。 一、安装img-loader:

npm install -D img-loader

将以下对象添加到规则列表中:

{
  test: /\.(png|jpg|gif)$/,
  loaders: [
    {
      loader: 'url-loader',
      options: {
        limit: 10000,
        name: 'img/[name].[ext]'
      }
    },
  'img-loader'
  ],
},

现在,执行npm run webpack,再次打开网站。 您已经将所有依赖项捆绑在一个 JavaScript 文件memes.js中,然后就可以开始了。

Sometimes, the img-loader binaries might fail during building depending on your operating system. In the latest version of Ubuntu, this is due to a missing package that can be downloaded and installed from: https://packages.debian.org/jessie/amd64/libpng12-0/download. In other operating systems, you have to manually find out why the build failed. If you cannot resolve the img-loader issue, do try to use a different loader or simply remove img-loader and only use url-loader for images.

插件在 Webpack

与加载器不同,插件是用来定制 Webpack 构建过程的。 Webpack 中内置了很多插件。 可以通过webpack.[plugin-name]访问。 我们也可以编写自己的插件函数。

For more information on webpack's plugin system, refer to https://webpack.js.org/configuration/plugins/.

Webpack 开发服务器

到目前为止,我们已经创建了 Webpack 配置来编译我们的代码,但如果我们可以像使用http-server那样提供代码,那么将会更容易。 webpack-dev-server是一个使用 Node.js 和 Express 编写的小服务器,用于服务 Webpack 包。 要使用webpack-dev-server,我们需要安装它的依赖项并更新我们的 npm 脚本:

npm install -D webpack-dev-server

在 npm 脚本中添加以下代码:

 "watch": "webpack-dev-server"

使用npm run watch,我们现在可以在本地主机上的服务器上提供文件。 webpack-dev-server不将绑定的文件写入磁盘。 相反,它会根据记忆自动服务。 webpack-dev-server的一个伟大特性是它能够做HotModuleReplacement,这将取代部分已更改的代码,甚至无需重新加载页面。 要使用HotModuleReplacement,请在 Webpack 配置文件中添加以下配置:

entry: {...},
output: {...},
devServer: {
  compress: true,
  port: 8080,
  hot: true,
},
module: {..},
plugins: [
  ...,
  new webpack.HotModuleReplacementPlugin(),
],

目前,webpack-dev-server从根目录提供文件。 但是我们需要从dist目录提供文件。 为此,我们需要在输出配置中设置publicPath:

output: {
  ...,
  publicPath: '/dist/',
},

删除dist文件夹,运行npm run watch命令。 您的 web 应用现在将在控制台中打印一些额外的消息。 这些文件来自webpack-dev-server,它正在侦听任何文件更改。 尝试更改 CSS 文件中的几行。 您的更改将立即反映出来,而无需重新加载页面! 这对于在代码保存后立即看到样式变化非常有用。 HotModuleReplacement在现代 JavaScript 框架中被广泛使用,如 React、Angular 等。

我们的调试代码中仍然缺少source-maps。 为了启用source-maps,Webpack 提供了一个简单的配置选项:

devtool: 'source-map',

Webpack 可以生成不同类型的源地图,这取决于生成它们所花费的时间和质量。 https://webpack.js.org/configuration/devtool/

这只会将源映射添加到 JS 文件中。 要添加source-maps到 CSS 文件中,它也包含 Bootstrap 的 less 文件,更改 CSS 规则如下:

{
  test: /\.(less|css)$/,
  use: [
    {
      loader: "style-loader"
    },
    {
      loader: "css-loader",
      options: {
        sourceMap: true
      }
    },
    {
      loader: "less-loader",
      options: {
        sourceMap: true
      }
    }
  ]
},

该规则将告诉less-loadersource-maps添加到它编译的文件中,并将其传递给css-loadercss-loader也将源映射传递给style-loader。 现在,你的 JS 和 CSS 文件都将有源地图,使它容易调试的应用在 Chrome。

如果你一直遵循下面的方法,你的 Webpack 配置文件现在应该看起来像以下 URL 中的代码:https://goo.gl/Q8P4ta。 你的package.json文件应该是:https://goo.gl/m4Ib97。 这些文件也包含在书的chapter02\webpack-dev-server目录的代码中。

We have used a lot of different loaders with Webpack, each of them having their own configuration options, many of which we did not discuss here. Do visit those packages, npm or GitHub pages to learn more about their configuration and customize them as per your requirements.

下一节是可选的。 如果您想要构建 Meme Creator 应用,可以跳过下一节,从开发开始。 你现在拥有的 Webpack 配置将完全正常。 然而,下一节对于学习更多关于 Webpack 的知识和在生产中使用它是很重要的,所以请稍后回来阅读它!

针对不同环境优化 Webpack 构建

在大型应用上工作时,通常会为应用的运行创建不同类型的环境,如开发、测试、登台、生产等。 每个环境对应用都有不同的配置,对于团队中不同组的人进行开发和测试都很有用。

例如,假设你的应用中有一个用于支付的 API。在开发过程中,你会有沙箱凭证,在测试过程中,你会有不同的凭证,最后,在生产过程中,你会有支付网关所需的实际凭证。 因此,应用需要为三种不同的环境使用三种不同的凭证。 不要向版本控制系统提交敏感信息也很重要。

那么,我们如何将凭据传递给应用而不把它们写进代码中呢? 这就是环境变量的概念。 操作系统将在编译期间提供这些值,以便可以使用来自不同环境中的不同环境变量的值生成构建。

每个操作系统创建环境变量的过程是不同的,为每个项目维护这些环境变量是一项乏味的任务。 因此,让我们使用一个npm包来从项目根目录的.env文件加载环境变量,从而简化这个过程。 在 Node.js 中,你可以访问process.env对象中的环境变量。 下面是如何从.env文件中读取变量:

  1. 第一步是安装以下包:
npm install -D dotenv
  1. 完成后,在你的项目根目录下用以下几行创建一个.env文件:
NODE_ENV=production
CONSTANT_VALUE=1234567
  1. 这个.env文件包含三个环境变量及其值。 如果你使用的是 Git,你应该将.env文件添加到.gitignore文件中,或者将其包含在版本控制系统的忽略列表中。 创建.env.example文件也是一个很好的实践,它告诉其他开发人员应用需要什么样的环境变量。 您可以将.env.example文件提交到您的版本控制系统。 我们的.env.example文件应该如下所示:
NODE_ENV=
CONSTANT_VALUE=

Node.js 可以读取这些环境变量,但是我们的 JavaScript 代码无法读取它们。 因此,我们需要 Webpack 读取这些变量,并将它们作为全局变量提供给 JavaScript 代码。 建议使用大写字母表示环境变量名,这样便于识别。

我们将使用NODE_ENV来检测环境类型,并告诉 Webpack 为该环境生成一个适当的构建,我们需要在 JS 代码中使用其他两个环境变量。 在你的webpack.config.js文件,在第一行,包括以下代码:

require('dotenv').config()

这将使用我们刚刚安装的dotenv包,并从项目根目录的.env文件中加载环境变量。 现在,可以在 Webpack 配置文件的process.env对象中访问环境变量。 首先,让我们设置一个标志来检查当前环境是否为生产环境。 在require('webpack')行后面包含以下代码:

const isProduction = (process.env.NODE_ENV === 'production');

现在,当NODE_ENV设置为 production 时,isProduction将被设置为 true。 为了在 JavaScript 代码中包含其他两个变量,我们需要在 Webpack 中使用DefinePlugin。 在 plugins 数组中,添加以下配置对象:

new webpack.DefinePlugin({
  ENVIRONMENT: JSON.stringify(process.env.NODE_ENV),
  CONSTANT_VALUE: JSON.stringify(process.env.CONSTANT_VALUE),
}),

DefinePlugin将在编译时定义常量,所以你可以根据你的环境改变你的环境变量,它将反映在代码中。 确保对传递给DefinePlugin的任何值进行 stringify。 更多关于这个插件的信息可以在:https://webpack.js.org/plugins/define-plugin/上找到。

现在,在你的memes.js文件的构造函数中,尝试console.log(ENVIRONMENT, CONSTANT_VALUE);并重新加载 Chrome。 您应该看到它们的值在控制台中打印出来。

因为我们使用isProduction变量设置了一个标志,所以只有当环境是生产环境时,我们才能使用这个变量对构建进行各种优化。 以下是一些在生产版本中用于优化的常用插件。

在 Windows 中创建.env 文件

Windows 不允许你直接从 Windows 资源管理器创建一个.env文件,因为它不允许文件名以点开始。 但是,您可以轻松地从 VSCode 创建它。 首先,在 VSCode 中使用菜单选项 File | open folder… [Ctrl+K Ctrl+O]如下截图所示:

打开文件夹后,单击 VSCode 左上角的 Explorer 图标(或按Ctrl+Shift+E),打开 Explorer 面板。 在资源管理器面板中,点击新建文件按钮,如下图所示:

然后简单地输入新的文件名.env,如下图所示:

点击,输入,创建.env文件并开始编辑。

.env files are read only when the Webpack-dev-server starts. So, if you make any changes to the .env files, you will have to kill the running Webpack-dev-server instance in the Terminal and restart it so that it will read the new values in .env files.

UglifyJsPlugin

这是一个用于压缩和缩小 JavaScript 文件的插件。 这大大减少了 JavaScript 代码的大小,提高了最终用户的加载速度。 然而,在开发期间使用这个插件会导致 Webpack 变慢,因为它在构建过程中增加了一个额外的步骤(昂贵的任务)。 因此,UglifyJsPlugin通常只在生产环境中使用。 要做到这一点,在你的 Webpack 配置的末尾添加以下几行:

if(isProduction) {
  module.exports.plugins.push(
    new webpack.optimize.UglifyJsPlugin({sourceMap: true})
  );
}

如果环境设置为生产环境,这将把UglifyJSPlugin推到插件数组。 更多关于UglifyJsPlugin的信息可在:https://webpack.js.org/plugins/uglifyjs-webpack-plugin/

PurifyCSSPlugin

在构建 web 应用时,会有很多样式是在 CSS 中定义的,但从未在 HTML 中使用。 PurifyCSSPlugin将通过所有的 HTML 文件,并删除任何不必要的 CSS 样式,我们已经定义了捆绑之前的代码。 要使用PurifyCSSPlugin,需要安装purifycss-webpack包:

npm install -D purifycss-webpack

之后,将插件导入到你的 Webpack 配置文件中,并按照以下代码中指定的方式使用它:

const PurifyCSSPlugin = require('purifycss-webpack');
constglob = require('glob');

module.exports = {
  ...
  plugins: [
    ...
    new PurifyCSSPlugin({
      paths: glob.sync(__dirname + '/*.html'),
      minimize: true,
    }),
  ],
}

glob是 Node.js 中的内置模块。 我们使用glob.sync指定 HTML 的路径,它将正则表达式解析为指定目录中的所有 HTML 文件。 PurifyCSSPlugin现在将使用这些 HTML 文件来净化我们的样式。 minimize选项将缩小 CSS 和净化。 更多关于PurifyCSSPlugin的信息可登录:https://github.com/webpack-contrib/purifycss-webpack

PurifyCSSplugin is useful but it might cause problems with Bootstrap animations and some other plugins. Make sure you test it well before using it.

ExtractTextPlugin

在生产中,建议将所有 CSS 代码提取到一个单独的文件中。 这是因为需要在页面的开头包含 CSS 文件,以便在加载时将页面样式应用于 HTML。 然而,由于我们将 CSS 与 JavaScript 绑定在一起,所以我们将它包含在页面的末尾。 当页面加载时,它看起来就像一个普通的文档,直到 CSS 文件被加载。

是用来克服这个问题的。 它将把所有 JS 代码中的 CSS 文件提取到与它捆绑在一起的 JS 文件同名的单独文件中。 我们现在可以在 HTML 文件的顶部包含 CSS 文件,这使得样式首先被加载。 和往常一样,第一步是安装包:

npm install -D extract-text-webpack-plugin

在此之后,我们需要创建一个新的ExtractTextPlugin实例,我们将使用我们的 CSS 文件。 由于我们也使用较少的引导,我们的配置文件应该如下所示:

...
const extractLess = new ExtractTextPlugin({
  filename: "[name].css",
});

module.exports = {
  ...
  module: {
    rules: [
      ...
      {
        test: /\.(less|css)$/,
        use: extractLess.extract({
          use: [
            {
              loader: 'css-loader',
              options: {
                sourceMap: true
              }
            },
            {
              loader: 'less-loader',
              options: {
                sourceMap: true
              }
            }
          ],
          fallback: 'style-loader',
        })
     },
    ]
  },
  ...
  plugins: [
    ...
    extractLess,
    new PurifyCSSPlugin({
      paths: glob.sync(__dirname + '/*.html'),
      minimize: true,
    }),
    ...
  ]
}

我们将ExtractTextPlugin的实例创建为extractLess。 因为我们使用的是PurifyCSSPlugin,所以在我们在插件数组中创建实例PurifyCSSPlugin之前,请确保您包含了extractLess对象。

More information regarding PurifyCSSPlugin can be found at: https://github.com/webpack-contrib/purifycss-webpack.

一旦你添加了ExtractTextPlugin,如果 JavaScript 文件导入 CSS, Webpack 将为每个 JavaScript 文件生成两个文件。 您必须在 HTML 中单独包含 CSS 文件。 在本例中,对于memes.js,它将在dist目录中生成memes.jsmemes.css,需要分别包含在 HTML 文件中。

ExtractTextPlugin will not work properly with Webpack HotModuleReplacement for CSS files. Hence, it's best to include ExtractTextPlugin only in production.

缓存的地沟油

要在 Webpack 生成的静态资源中使用缓存,最好在静态资源的文件名后面加上哈希值。 [chunkhash]将生成一个内容相关的哈希,该哈希应该被附加到作为缓存破坏者的文件名。 只要文件的内容发生变化,散列就会发生变化,这将导致新的文件名,从而重新生成缓存。

只有生产版本需要缓存破坏逻辑。 开发构建不需要这些配置。 因此,我们只需要在生产中生成散列文件名。 此外,我们必须生成一个manifest.json文件,其中包含生成的资源的新文件名,这些资源必须内联到 HTML 文件中。 cache busting 的配置如下:

const fileNamePrefix = isProduction? '[chunkhash].' : '';

module.exports = {
  ...
  output: {
    ...
    filename: fileNamePrefix + '[name].js',
    ...
  }
}

这将向生产环境中的文件名添加散列前缀。 然而,webpack.HotModuleReplacementPlugin()[chunkhash]不能很好地工作,所以HotModuleReplacementPlugin不应该在我们的生产环境中使用。 要生成manifest.json文件,将以下函数作为元素添加到 plugins 数组中:

function() {
  this.plugin("done", function(status) {
    require("fs").writeFileSync(
      __dirname + "/dist/manifest.json",
      JSON.stringify(status.toJson().assetsByChunkName)
    );
  });
}

或者最好将它添加到UglifyJSPlugin旁边,它只在生产中执行。 该函数将使用 Node.js 中的fs模块将生成的文件写成 JSON 文件。 更多信息请参考:https://webpack.js.org/guides/caching/

在生成新版本之前清理 dist 文件夹

由于我们使用不同的散列文件名生成许多构建,所以在运行每个构建之前删除dist目录是一个很好的做法。 clean-webpack-plugin就是那样做的。 在捆绑新文件之前,它会清理dist目录。 如果要使用clean-webpack-plugin,请在项目根目录下执行以下命令安装插件::

npm install -D clean-webpack-plugin

然后,在你的 Webpack 配置文件中添加以下变量:

const CleanWebpackPlugin = require('clean-webpack-plugin');
const pathsToClean = [
 'dist'
];
const cleanOptions = {
 root: __dirname,
 verbose: true,
 dry: false,
 exclude: [],
};

最后,将new CleanWebpackPlugin(pathsToClean, cleanOptions)添加到您的生产插件中。 现在,每次生产构建生成时,旧的dist文件夹将被删除,一个新的文件夹将使用最新的绑定文件创建。 更多关于这个插件的信息可以在:https://github.com/johnagan/clean-webpack-plugin上找到。

生产中的源图

源映射为调试编译后的代码提供了一种简单的方法。 浏览器在开发工具打开之前不会加载源代码映射。 因此,性能上的源映射不会造成任何伤害。 但是,如果您需要保护原始源代码,那么删除源代码映射是一个好主意。 你也可以通过在每个捆绑文件的末尾设置sourceMappingURL到一个受限制的 URL 来使用私有的源映射(例如,源映射只能被公司域内的开发人员访问):

//# sourceMappingURL: http://protected.domain/dist/general.js.map

包含前面提到的所有优化的完整 Webpack 配置文件看起来是:https://goo.gl/UDuUBu。 该配置中使用的依赖项可以在:https://goo.gl/PcHpZf中找到。 这些文件也包含在本书的Chapter02\webpack production optimized目录下的代码文件中。

We have just tried of lot of community created plugins and loaders for Webpack. Remember that there is more than one way to perform these tasks. So, be sure to check out a lot of new plugins/loaders created over time. This repository contains a curated list of Webpack resources: https://github.com/webpack-contrib/awesome-webpack. Since we are using flexbox in the Meme Creator, some old browsers support flexbox with vendor-prefixes. Try adding vendor prefixes to your CSS using postcss/autoprefixer: https://github.com/postcss/autoprefixer.

构建表情图创建器

我们只是用 Webpack 构建了一个不错的小开发环境。 是时候付诸行动了。 如果您已经进行了生产优化,请确保您已经在项目根文件夹中创建了.env文件,并且该文件中的NODE_ENV环境变量不是production。 当我们处理应用时,只需设置NODE_ENV=dev的值。 我们现在要创建 Meme Creator 了。 确保您已经包含了index.html文件中的dist目录中的memes.jsmemes.css文件(如果您使用了ExtractTextPlugin)。

在文本编辑器中打开memes.js文件,并保持webpack-dev-server(npm run watch)运行。 第一步是创建对类变量中所有必需的 DOM 元素的引用。 然后,我们可以在以后从类内部使用这些引用来修改元素。 另外,每当我们创建 DOM 元素的引用时,变量名最好以$开头。 这样,我们就很容易知道哪些变量包含值,哪些变量包含对 DOM 元素的引用。

webpack-dev-server will print the URL in the console which you should open using Chrome to see your application. The URL will be http://localhost:8080/

还记得在前一章中,我们如何使用document.getElementById()来搜索 DOM 元素吗? JavaScript 还有一个更好的方法可以简化 DOM 元素的查询:document.querySelector()方法。 前者允许我们仅使用Id搜索文档,而querySelector允许我们使用id、类甚至元素名查询文档。 例如,如果需要选择:

<input id="target" class="target-input" type="text"/>

你可以使用以下方法之一:

document.querySelector('#target');
document.querySelector('.target-input');
document.querySelector('input#target.target-input');

所有这些都将返回与查询条件匹配的第一个元素。 如果你想选择多个元素,你可以使用document.querySelectorAll(),它返回一个指向所有匹配 DOM 元素的引用数组。 在构造函数中,编写以下代码:

this.$canvas = document.querySelector('#imgCanvas');
this.$topTextInput = document.querySelector('#topText');
this.$bottomTextInput = document.querySelector('#bottomText');
this.$imageInput = document.querySelector('#image');
this.$downloadButton = document.querySelector('#downloadMeme');

现在我们有了对类中所有必需的 DOM 元素的引用。 目前,我们的画布很小; 我们没有使用 CSS 指定它的大小,因为我们需要页面响应。 如果用户通过移动设备访问页面,我们不希望显示水平滚动条,因为画布的大小已经超出了屏幕。 因此,我们将使用 JavaScript 根据屏幕大小创建画布的高度和宽度。 我们需要先计算设备宽度。 在Memes类上面添加以下代码(不在类内部):

const deviceWidth = window.innerWidth;

这将计算设备的宽度,并将其存储在常量deviceWidth中。 在类内部,创建以下函数:

createCanvas() {
  let canvasHeight = Math.min(480, deviceWidth-30);
  let canvasWidth = Math.min(640, deviceWidth-30);
  this.$canvas.height = canvasHeight;
  this.$canvas.width = canvasWidth;
}

References to DOM elements contain the entire target element as a JavaScript object. It can be used in the same way we handle normal class objects. Modifications to references will be reflected in the DOM.

如果设备的屏幕足够大,这将创建一个高度为480、宽度为640的矩形画布。 否则,它将创建一个宽度为deviceWidth-30的正方形画布。 参考你之前看到的 Meme 创建者的图片。 画布将是矩形的,用于桌面,并将成为一个正方形区域,为移动设备留有余地。

Math.min(x, y)将返回两个数字xy中最小的一个。 我们减少了30的宽度,因为我们需要为边缘留出空间。 在构造函数中添加this.createCanvas(),并在 Chrome 中查看页面(Webpack 将为你重新加载页面)。 尝试响应式设计模式,查看画布如何在移动设备上显示。 高度和宽度仅在页面第一次加载时应用; 因此,当您检查不同的设备时,请刷新页面。

我们的画布区域准备好了; 让我们看看 HTML 中新的<canvas>元素的一些事情。 Canvas 是图形的容器。 我们可以使用 JavaScript 在 canvas 元素上编写图形。 Canvas 有几种绘图方法,如路径、形状、文本和图像。 此外,在 canvas 中渲染图形比使用 DOM 元素更快。 canvas 的另一个优点是,我们可以将画布内容转换为图像。 在具有服务器端 api 的实际应用中,可以使用服务器为 meme 呈现图像和文本。 但是,由于我们不打算在本章中使用服务器端,canvas 是我们的最佳选择。

Visit the Mozilla Developer Network (MDN) page: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial for more information regarding the canvas element.

以下是 Meme 创作者的策略:

  • canvas 元素只是在得到指示时将图形渲染到它的位图。 我们无法检测到之前在上面绘制的任何图形。 这让我们别无选择,只能在每次新的文本或图像进入 Meme Creator 时清除画布,并重新渲染整个画布。
  • 当用户在 Top text 或 Bottom text 输入框中输入时,我们需要事件监听器向表情图添加文本。
  • 底部文本是必填项。 除非 meme 被填满,否则用户无法下载。
  • 用户可以选择任何大小的图像。 如果他选择了一个巨大的图像,它不应该破坏我们的页面布局。
  • 下载按钮应该像下载按钮一样工作!

事件处理

我们现在有了一个创建 Meme Creator 的想法。 我们的第一步是创建一个函数,将表情图渲染到画布上。 在Memes类中,创建一个函数createMeme(),它将包含我们的主画布渲染器。 现在,给函数留一个简单的控制台语句:

createMeme() {
  console.log('rendered');
}

记住,每次发生更改时,我们都需要渲染整个画布。 因此,我们需要将事件监听器附加到所有输入元素。 你也可以使用 HTML 事件属性,比如我们在之前的 ToDo List 应用中使用的onchange。但是事件监听器让我们为一个元素处理多个事件。 因此,它们广受欢迎。 此外,由于我们使用 Webpack 打包代码,我们不能直接访问 HTML 中的 JavaScript 变量或对象! 这需要对 Webpack 的配置做一点更改,可能根本就不需要。 我们将在下一章详细讨论这个话题。

首先,当文本输入TopTextInputBottomTextInput区域时,我们需要调用createMeme。 因此,我们需要在这些输入框上附加一个事件监听器来监听keyup事件。 创建事件监听器函数:

addEventListeners() {
  this.$topTextInput.addEventListener('keyup', this.createMeme);
  this.$bottomTextInput.addEventListener('keyup', this.createMeme);
}

打开 Chrome 浏览器,尝试在文本框中输入,同时保持控制台打开。 每次键入一个单词时,您应该会看到在控制台中打印呈现。 如果您想将同一个事件监听器附加到多个元素,实际上有一种更好的方法来附加事件监听器。 只需使用以下方法:

addEventListeners() {
  let inputNodes = [this.$topTextInput, this.$bottomTextInput, this.$imageInput];

  inputNodes.forEach(element => element.addEventListener('keyup', this.createMeme));
}

这段代码的作用如下:

  • 它为所有目标输入元素创建一个引用对象数组(inputNodes)
  • 使用forEach()方法循环遍历数组中的每个元素,并为其附加一个事件监听器
  • 通过使用 ES6 的胖箭头,我们在一行中就实现了它,而不必担心将this对象绑定到回调函数

我们还在inputNodes中加入了$imageInput。 这个元素不会受到keyup事件的太大影响,但是当用户上传新图像时,我们需要监视这个元素。 此外,如果用户在没有按任何键盘按钮的情况下将文本复制并粘贴到文本输入中,我们需要处理更改。 这两种情况都可以使用change事件来处理。 在addEventListeners()函数中添加以下代码:

inputNodes.forEach(element => element.addEventListener('change', this.createMeme));

当用户输入一些文本或上传一个新图像时,this.createMeme()方法将被自动调用。

在画布中渲染图像

将内容渲染到画布上的第一步是使用CanvasRenderingContext2D界面获取目标<canvas>元素的 2D 渲染上下文。 在我们的createMeme()函数中,为 canvas 元素创建一个上下文:

let context = this.$canvas.getContext('2d');

context变量现在将保存CanvasRenderingContext2D接口的对象。 为了使渲染更有效,我们将添加一个条件,只在用户选择了图像时才渲染。 我们可以通过检查对图像输入的引用中是否有任何文件来做到这一点。 只有在输入中选择了一个文件时,我们才应该启动渲染过程。 为此,请检查 input 元素是否包含任何文件对象:

if (this.$imageInput.files && this.$imageInput.files[0]) {
  console.log('rendering');
}

现在,试着在输入框中输入一些文本。 你应该在控制台中得到一个错误:Cannot read property 'getContext' of undefined。

此时此刻,你应该问以下问题:

  • 我们不是在构造函数中定义了this.$canvas来保存对 canvas 元素的引用吗?
  • 我们从 canvas 引用this.$canvas中获取 context 对象。 但是this.$canvas怎么可能是未定义的呢?
  • 我们做的不是都对吗?

为了找到答案,我们需要使用 Chrome DevTools 来找出在我们的代码中出现了什么问题。 在导致错误的行(定义上下文变量的行)之前添加debugger;关键字。 现在,重新加载 Chrome 并开始输入。 Chrome 的调试器现在将暂停页面的执行,源代码选项卡将突出显示的行,其中 Chrome 调试器暂停了执行:

代码的执行现在暂停。 这意味着在执行期间,所有变量都将包含它们的值。 将光标悬停在debugger;旁边的this关键字上。 令人惊讶的是,把光标放在这个对象上面会突出显示你网站上的 Input text 字段。 此外,弹出的信息还将显示该对象包含对input#topText.form-control的引用。 问题是:this对象不再有对类的引用,而是有对 DOM 元素的引用。 我们在类内部定义了$canvas变量; 因此,this.$canvas现在是未定义的。 在之前的项目中,我们在绑定this对象时遇到了类似的问题。 你能猜出我们哪里出错了吗?

在这一行中,我们将事件监听器附加到addEventListeners()函数的输入元素。 由于我们在这里使用 ES6 胖箭头,您可能想知道为什么this没有自动地从父节点继承它的值。 这是因为,这一次,我们将把this.createMeme作为参数发送给目标元素的addEventListener()方法。 因此,该输入元素成为继承this对象的新父元素。 为了克服这个问题,将this.createMeme改为this.createMeme.bind(this),或者为了更清晰的语法,添加以下代码作为addEventListeners()函数的第一行:

this.createMeme = this.createMeme.bind(this);

现在,this.createMeme可以在addEventListeners()函数的任何地方正常使用。 试着在输入框中输入一些文本。 这一次,不应该有任何错误。 现在,从源图像输入中选择一个图像。 试着打些文字。 这一次,您应该看到控制台打印出呈现文本。 我们将在这个if条件中编写渲染代码,使表情图仅在图像被选中时才渲染。

一件事! 如果单击图像输入,它将显示磁盘中的所有文件。 我们只需要用户选择图像文件。 在本例中,将accept属性添加到index.html的输入元素中,并使用允许用户选择的扩展名。 新的输入元素应该如下所示:

<input type="file" id="image" class="form-control" accept=".png,.jpg,.jpeg">

使用 JavaScript 读取文件

要读取选定的图像,我们将使用FileReader,它允许 JavaScript异步读取文件的内容(从文件或原始数据)。 注意术语异步; 这意味着 JavaScript 不会等待FileReader代码完成执行。 JavaScript 将开始执行下一行,而FileReader仍在读取文件。 这是因为 JavaScript 是一种单线程语言。 这意味着所有操作、事件监听器、函数等等都在一个线程中执行。 如果 JS 必须等待FileReader的完成,那么整个 JavaScript 代码将暂停(就像调试器暂停脚本的执行),因为一切都在一个线程中运行。

为了避免这种情况发生,JavaScript 不是简单地等待事件完成,而是在执行下一行代码的同时同时运行事件。 我们可以用不同的方式来处理异步事件。 通常,异步事件会被赋予一个回调函数(一些需要在事件完成后执行的代码行),或者异步代码会在执行完成时触发一个事件,我们可以编写一个函数在事件被触发时执行。 ES6 有一种处理异步事件的新方法,称为 promise。

我们将在下一章中看到更多关于使用 promise 的内容。 FileReader将触发一个load事件,当它完成读取文件。 FileReader还附带了onload事件处理程序来处理load事件。 在if语句中,创建一个新的FileReader对象,并使用FileReader()构造函数将其赋值给变量 reader。 下面是我们如何处理异步FileReader逻辑:在if语句中编写以下代码(删除之前的console.log语句):

let reader = new FileReader();

reader.onload = () => {
  console.log('file completly read');
};

reader.readAsDataURL(this.$imageInput.files[0]);
console.log('This will get printed first!');

现在,尝试在 Chrome 中选择一个图像。 您应该看到控制台中打印了两条语句。 这是我们在前面的代码中所做的:

  • 我们在 reader 变量中创建了一个新的FileReader实例
  • 然后我们指定读取器在onload事件处理程序中应该做什么
  • 然后,我们将所选图像的文件对象传递给 reader 对象

正如您可能已经猜到的,JavaScript 将首先执行reader.readAsDataURL,并发现它是一个异步事件。 因此,当FileReader运行时,它将执行下一个console.log()语句。

一旦FileReader完成读取文件,它将触发load事件,该事件将调用相应的reader.onload事件处理程序。 现在,将执行reader.onload方法中的console.log()语句。 reader.result现在将包含图像数据。

我们需要使用FileReader的结果创建一个Image对象。 使用Image()构造函数创建一个新的图像实例(我们现在应该在reader.onload方法中编写代码):

reader.onload = () => {
  let image = new Image();

  image.onload = () => {

  };

  image.src = reader.result;
}

正如你所看到的,动态加载图像源也是一个异步事件,我们需要使用Image对象提供的onload事件处理程序。

一旦我们加载了图像,我们需要将画布的大小调整为图像的大小。 为此,在image.onload方法中编写以下代码:

image.onload = () => {
  this.$canvas.height = image.height;
  this.$canvas.width = image.width;
}

这将调整画布的大小为图像的大小。 一旦我们调整了画布的大小,我们的第一步是擦除画布。 canvas 对象具有clearRect()方法,可用于清除画布中的矩形区域。 在我们的例子中,矩形区域就是整个画布。 为了清除整个画布,我们需要使用 canvas 的上下文对象clearRect(),即我们之前创建的context变量。 之后,我们需要将图像加载到画布中。 在分配画布尺寸后,在image.onload方法中编写以下代码:

context.clearRect(0, 0, this.$canvas.height, this.$canvas.width);
context.drawImage(image,0,0);

现在,尝试选择一个图像。 图像应该显示在画布中。 这是前面的代码所做的:

  • 清楚画布的矩形区域(0,0)从左上角的坐标,即前两个参数clearRect()的方法,然后创建一个矩形的高度和宽度等于帆布的,也就是说,clearRect()的最后两个参数的方法。 这有效地清除了整个画布。
  • 使用存储在image对象中的图像从坐标(0,0)开始在画布上绘制图像。 由于画布与图像具有相同的尺寸,所以图像将覆盖整个画布。

在画布上渲染文本

我们现在有了一个图像,但我们仍然缺少顶部文本和底部文本。 以下是 text 属性需要的一些东西:

  • 字体大小应该响应图像的大小
  • 文本应该居中对齐
  • 文字应该在图像的顶部和底部有空白
  • 文本应该有一个黑色的笔触,以便它可以清楚地看到的图像

第一步,我们需要字体大小响应。 如果用户选择了一个大图像或一个小图像,我们需要有一个相对的字体大小。 因为我们有画布的高度和宽度,我们可以使用它来获得一个字体大小为图像高度和宽度平均值的4%。 我们可以使用textAlign属性居中对齐文本。

此外,我们需要使用textBaseline属性指定基线。 它用于将文本定位到指定的位置。 首先,画布在我们为文本指定的位置创建一个基线。 然后,它将根据提供给textBaseline的值在基线上方、下方或上方书写文本。 在image.onload方法中编写以下代码:

 let fontSize = ((this.$canvas.width+this.$canvas.height)/2)*4/100;
 context.font = `${fontSize}pt sans-serif`;
 context.textAlign = 'center';
 context.textBaseline = 'top';

我们已经指定字体为画布平均高度和宽度的4%,并设置字体样式为sans-serif。 此外,通过设置textBaselinetop,基线将在文本的上方,也就是说,文本将呈现在基线以下。

画布没有一个选项来应用笔触的文字。 因此,要创建带有黑色笔触的白色文本,我们需要创建两个不同的文本,一个黑色笔触文本和一个白色填充文本,描边文本的线宽略大于填充文本,并将它们放在另一个上面。 这听起来可能是一个复杂的任务,但实际上很简单。

这是描边文本的样子:

这是填充文本的外观(在灰色背景中):

为描边文本和填充文本创建样式:

// for stroke text
context.lineWidth = fontSize/5;
context.strokeStyle = 'black';

// for fill text
context.fillStyle = 'white';

从输入字段获取顶部文本和底部文本的值:

const topText = this.$topTextInput.value.toUpperCase();
const bottomText = this.$bottomTextInput.value.toUpperCase();

这将从输入字段获取值,并自动将文本转换为大写字母。 最后,为了在画布的顶部和底部渲染文本,我们需要做以下工作:

// Top Text
context.strokeText(topText, this.$canvas.width/2, this.$canvas.height*(5/100));
context.fillText(topText, this.$canvas.width/2, this.$canvas.height*(5/100));

// Bottom Text
context.strokeText(bottomText, this.$canvas.width/2, this.$canvas.height*(90/100));
context.fillText(bottomText, this.$canvas.width/2, this.$canvas.height*(90/100));

context.strokeText()为例。 这是文本的渲染方式:

  • strokeText方法的第一个参数topText包含要呈现的文本。
  • 第二个和第三个参数包含文本应该开始呈现的位置。 沿着x轴,文本应该从画布的中间(this.$canvas.width/2)开始渲染。 文本将以y轴居中对齐,高度为5%,距离画布顶部(this.$canvas.height*(5/100))。 文本将被渲染。

这正是我们需要米姆的标题的地方。 对于底部文本,从顶部增加高度到90%。 带有黑色笔触的笔触文本将在填充文本的下方。 有时,“M”会在文本上有额外的笔画。 这是因为两条线相交的地方不是适当的圆角。 为此,在指定fillStyle的行后面添加以下行:

context.lineJoin = 'round';

现在,快速切换到 Chrome 浏览器,选择一个图像,并键入一些文本! 你有了自己的 Meme 创作者! 作为参考,它应该像这样工作:

现在,要下载 meme,我们需要将画布转换为图像,并将图像作为属性附加到下载按钮。 在Memes类中创建一个新的downloadMeme()函数。 在addEventListeners()函数中添加以下行:

this.$downloadButton.addEventListener('click', this.downloadMeme.bind(this));

现在,在downloadMeme()函数中,添加以下代码:

const imageSource = this.$canvas.toDataURL('img/png');
let att = document.createAttribute('href');
att.value = imageSource.replace(/^data:image\/[^;]/, 'data:application/octet-stream');
this.$downloadButton.setAttributeNode(att);

现在,点击下载按钮将画布转换为图像,并让浏览器下载它。 前面的代码是这样工作的:

  • 首先,使用toDataURL('img/png')方法将画布转换为 64 位编码的 png URL,并存储在imageSource常量中。
  • 创建另一个包含 HTML'href'属性对象的常量att
  • 现在,将att对象的值更改为存储在imageSource中的图像 URL,同时将 mime 类型从data:image更改为data:application/octet-stream。 这个步骤是必要的,因为大多数浏览器直接显示图像而不是下载它们。 通过将 mime 类型更改为octet-stream(用于二进制文件),我们可以欺骗浏览器,使其认为该文件不是一个图像,从而下载该文件,而不是查看它。
  • 最后,将att对象指定为$downloadButton属性,这是一个带有download属性的锚标记。 download属性的值将是下载的图像的默认名称。

In the imageSource.replace() method, a regular expression is used for changing the mime type of the image. We will discuss more on using regular expressions in the next chapter. To know more about regular expressions, visit the following MDN page: https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions.

在从 meme Creator 下载 meme 之前,我们需要验证表单,以便必须选中一张图片,至少要填满底部的文本框才能下载 meme。 我们需要在代码上面的downloadMeme()函数中添加表单验证代码来下载文件:

if(!this.$imageInput.files[0]) {
  this.$imageInput.parentElement.classList.add('has-error');
  return;
}
if(this.$bottomTextInput.value === '') {
  this.$imageInput.parentElement.classList.remove('has-error');
  this.$bottomTextInput.parentElement.classList.add('has-error');
  return;
}
this.$imageInput.parentElement.classList.remove('has-error');
this.$bottomTextInput.parentElement.classList.remove('has-error');

前面的代码将在底部文本输入框中检查图像和文本,并使用return关键字停止downloadMeme()的继续执行。 一旦找到了一个空字段,它将把.has-error类添加到输入的父类div中,根据 Bootstrap,它会用红色边框突出显示输入(我们以前在 ToDo 列表应用中使用过它)。

你可能得不到突出显示,因为我们在 Webpack 中使用了PurifyCSSPlugin,它通过引用index.html过滤掉了所有不需要的样式。 由于.has-error类最初并没有出现在index.html中,所以它的样式定义也从捆绑的 CSS 中删除了。 为了克服这个问题,添加所有你想动态添加到页面中隐藏的div元素的类。 添加以下一行到我们的index.html文件,就在<script>标签上方:

<div class="has-error" style="display: none;"></div>

现在,.has-error的样式定义将包含在包中,表单验证将为空字段添加一个红色边框。

使画布响应显示大图像

如果用户选择了一个大的图像(例如,屏幕大小的图像),它将导致布局中断。 为了防止这种情况发生,我们需要在选择大图像时缩小画布。 我们可以通过 CSS 控制 canvas 元素的高度和宽度来放大或缩小它。 在Memes类中,创建以下函数:

resizeCanvas(canvasHeight, canvasWidth) {
  let height = canvasHeight;
  let width = canvasWidth;
  this.$canvas.style.height = `${height}px`;
  this.$canvas.style.width = `${width}px`;
  while(height > Math.min(1000, deviceWidth-30) && width > Math.min(1000, deviceWidth-30)) {
    height /= 2;
    width /= 2;
    this.$canvas.style.height = `${height}px`;
    this.$canvas.style.width = `${width}px`;
  }
}

以下是resizeCanvas()的工作原理:

  • 这个函数最初将在 CSS 中应用画布的高度和宽度到它的实际高度和宽度(以便前一个图像的缩放级别不会被记住)。
  • 然后,它将检查高度和宽度是否大于 1000px 的最小值或deviceWidth-30(我们已经定义了deviceWidth常量)。
  • 如果画布的大小大于给定条件,我们将高度和宽度减半,然后将新值分配给画布的 CSS(这将缩小画布)。
  • 由于这是一个 while 循环,操作将一直重复,直到画布大小低于条件,从而有效地缩小画布并保留页面布局。

简单地在代码后的image.onload方法中调用this.resizeCanvas(this.$canvas.height, this.$canvas.width)来在画布中渲染文本。

height /= 2 is a shorthand used for height = height / 2. This is applicable for other arithmetic operators, such as +, -, *, and %.

总结

干得好! 你已经建立了一个 Meme Creator,现在可以将你的图像转换为 Meme。 更重要的是,您有一个很棒的开发环境,它将使使用 JavaScript 进行应用开发更加容易。 让我们回顾一下你在本章中学到的东西:

  • 简单介绍 CSS 中的 flexbox 布局系统
  • JavaScript 模块简介
  • 与 Webpack 绑定模块
  • 生产优化,以提高用户的性能
  • 使用 HTML5 canvas 和 JavaScript 在网站上绘制图形

我们在这一章学到了很多东西。 特别是关于 Webpack。 这可能看起来有点让人不知所措,但从长远来看是非常有用的。 在下一章中,我们将看到如何编写模块化代码并在应用中重用它,这现在由于 Webpack 而成为可能。