Skip to content

写在前面:这是一篇译文,是原作者系列文章的第一篇,第二篇主题是 CSS , 第三篇是 HTML。

作者:Peter Jang
原文名:Modern JavaScript Explained For Dinosaurs
原文于 2017 年 10 月 19 日发表于 medium

写给恐龙的现代 JavaScript 指南 ​

Images from Dinosaur Comics by Ryan North

如果没有一直关注的话,学习现代 JavaScript 并非易事。它的语言生态发展和变化太快,甚至让人难以理解不同工具期望解决的问题。我从 1998 年就开始编程了,但直到 2014 年才开始认真学习 JavaScript。我还记得当时偶然看到Browserify,它的标语是

"Browserify 负责打包所有依赖,实现在浏览器中引入各种模块"

我完全不懂这句话什么意思,还在拼命想这东西对开发者有什么用。

本文会介绍 JavaScript 发展的历史背景,说明 JavaScript 工具是怎样发展到 2017 年现在这种状态的。我们会从头开始,只用 HTML 和 JavaScript 开发一个示例网站。然后逐步引入各种不同工具,逐个说明每个工具试图解决的问题。当了解历史轨迹之后,你会更好的学习并适应未来不断变化的 JavaScript 生态。开始吧!

传统 JavaScript 方式 ​

我们先从只含 HTML 和 JavaScript 的传统网站开始,它需要手动下载和链接文件。下面是一个链接到 JS 文件的简单的index.html文件:

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>JavaScript Example</title>
    <script src="index.js"></script>
  </head>
  <body>
    <h1>Hello from HTML!</h1>
  </body>
</html>

<script src="index.js"></script>这一行引用了同文件夹内的另一个 JavaScript 文件,名为index.js。

javascript
// index.js
console.log('Hello from JavaScript!');

这就是一个网站了!现在假设你想导入别人写的库,比如说moment.js(它把时间格式化为可读形式)。 假设你可以像下面这样使用moment:

javascript
moment()
  .startOf('day')
  .fromNow(); // 20 hours ago

但你首先得在网站里引入 moment.js。在 moment.js 首页上能看到以下说明:

右侧Install这一栏里有很多内容。暂时先忽略,我们可以下载moment.min.js文件到网站文件夹里,并在index.html里引用它。

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Example</title>
    <link rel="stylesheet" href="index.css" />
    <script src="moment.min.js"></script>
    <script src="index.js"></script>
  </head>
  <body>
    <h1>Hello from HTML!</h1>
  </body>
</html>

注意moment.min.js比index.js先加载,这样你就可以在index.js里使用moment函数:

javascript
// index.js
console.log('Hello from JavaScript!');
console.log(
  moment()
    .startOf('day')
    .fromNow()
);

这就是使用 JS 库开发网站的方式!好处是非常容易理解。不足在于每次库更新都要下载它的新版本,这很繁琐。

使用 JavaScript 包管理器(npm) ​

2010 年开始,一批互相竞争的 JavaScript 包管理器出现,通过从中心仓库下载和更新库实现了这个过程的自动化。Bower可以说是 2013 年最受欢迎的包管理器,但最终在 2015 年左右被npm取代。(2016 年下半年,yarn作为 npm 的替代品吸引了大量关注,但本质上它仍然使用 npm 包管理技术)
说明一下,npm 起初只是node.js的专用包管理器。node 运行在服务端,不是前端。前端的包管理器选择运行在服务器端的库,有点不正常啊。

注:使用包管理器需要掌握命令行,过去的前端开发中并没有这个要求。如果你从来没用过,可以从这个入门指南开始。总而言之,掌握命令行是现代 JavaScript 中一项重要内容(命令行也让其它领域的开发成为可能)。

看一下如何使用 npm 自动安装 moment.js。如果你已经安装了 node.js,你就有了 npm。你就可以通过命令行访问index.html的文件夹并输入:

Command
$ npm init

之后会弹出一系列问题(直接选默认就行,一直点回车),并生成package.json文件。这是 npm 用来保存项目信息的配置文件。默认选项的package.json的内容如下:

json
{
  "name": "your-project-name",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

我们现在可以按照 moment.js 主页的说明安装了,命令行输入下面这条命令:

command
$ npm install moment --save

这条命令做了 2 件事情:一,它下载 moment.js 的所有代码,并保存在node_modules文件夹里。二,它自动修改package.json文件,把 moment.js 作为项目依赖。

json
{
  "name": "modern-javascript-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "moment": "^2.22.2"
  }
}

这在后面与他人共享项目时很有用——不再共享node_modules文件夹(这个文件夹可能很大),只需要共享package.json文件,其他人就能通过npm install自动安装需要的包。

我们再也不用手动下载 moment.js 了,可以使用 npm 自动下载和更新。打开node_modules文件夹,可以看到moment.min.js文件出现在node_modules/moment/min文件夹。这样就可以在index.html中链接下载好的moment.min.js版本:

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>JavaScript Example</title>
    <script src="node_modules/moment/min/moment.min.js"></script>
    <script src="index.js"></script>
  </head>
  <body>
    <h1>Hello from HTML!</h1>
  </body>
</html>

优点是我们现在可以通过命令行使用 npm 下载和更新包。不足是现在得从node_modules里找到每个包的路径并手动添加到 HTML 中。太麻烦了,下面会谈到怎么让这个过程也实现自动化。

使用 JavaScript 模块打包工具(webpack) ​

大多数编程语言支持将一个文件的代码导入到另一个文件。JavaScript 开始并没有这个功能,因为 JavaScript 开始只在浏览器运行,没有访问客户端电脑文件系统的权限(因为安全原因)。所以一直以来,组织多文件的 JavaScript 代码需要将各文件变量作为全局导入。

我们在上面 moment.js 例子里也是这么做的——HTML 加载整个moment.min.js文件,定义了一个全局变量moment,这样后加载的文件都可以访问它(不管他们是否需要访问这个变量)

2009 年,名为 CommonJS 的项目启动了。它旨在规范浏览器端以外的 JavaScript 生态。CommonJS 的大部分内容是关于模块的标准,它让 JavaScript 像其他编程语言一样,最终实现了跨文件导入和导出代码的功能,结束了依赖全局变量的时代。CommonJS 标准最广为人知的实现是 node.js 。

之前提过,node.js 是在服务端运行的 JavaScript。之前的例子用 node.js 模块表示的话,是这样的。HTML 不再通过 script 标签加载moment.min.js,而是直接在 JavaScript 文件中导入:

javascript
// index.js
var moment = require('moment');

console.log('Hello from JavaScript!');
console.log(
  moment()
    .startOf('day')
    .fromNow()
);

这是 node.js 里加载模块的方式。它在 node.js 中一切正常,因为 node 是服务端语言,能够访问计算机文件系统。node.js 也知道每个 npm 模块的路径,因此只需要使用require('moment')语句,而不再需要require('./node_modules/moment/min/moment.min.js)(译注:即指明模块具体路径)——非常好。

但这也只在 node.js 里行的通了,如果你在浏览器里使用这种方式,得到的只是“require未定义”的错误。浏览器没有访问文件系统的权限,因此通过这种方式加载模块非常困难——文件加载只能动态完成,要么是同步的(这会降低执行速度),要么是异步的(会出现时间不一致的问题)。

于是模块打包工具应运而生。JavaScript 模块打包工具通过构建过程(过程中有文件访问权限)绕开了这个问题,它输出一个兼容浏览器的最终文件(即不需要访问文件系统)。这样就需要打包工具找到所有浏览器不兼容的require语句,将其替换成依赖文件的真实内容。最终输出是单个打包好的 JavaScript 文件(并且没有 require 语句)!

曾经最流行的打包工具是Browserify,它在 2011 年发布,首先在前端支持 node.js 格式的 require 语句(这最终成就了 npm,让它成了前端包管理器)。2015 年左右,webpack后来居上,成为最广泛使用的打包工具(前端框架 React 的普及推动了这一过程,因为 React 充分利用了 webpack 的各种特性)。

看看 webpack 怎么让前面require('moment')这个例子实现兼容浏览器的。首先需要在项目中安装 webpack。webpack 自身就是一个 npm 包,可以通过命令行安装:

$ npm install webpack webpack-cli --save-dev

注意安装的是 2 个包——webpack 和 webpack-cli(后者支持通过命令行使用 webpack)。再看 --save-dev参数——这个参数表示将安装包作为开发依赖保存,意思是安装包在开发环境中使用,但不会部署在生产服务器上。可以在package.json文件中看到,文件会自动更新:

json
{
  "name": "modern-javascript-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "moment": "^2.19.1"
  },
  "devDependencies": {
    "webpack": "^4.17.1",
    "webpack-cli": "^3.1.0"
  }
}

现在node_modules文件夹里安装好了 webpack 和 webpack-cli 包。你可以在命令行里使用 webpack-cli:

$ ./node_modules/.bin/webpack index.js --mode=development

这条命令将会运行安装好的 webpack,从index.js文件开始,搜索全部require语句,将它替换为合适的代码,最终创建单个输出文件(默认路径是dist/main.js)。--mode=development参数让 JavaScript 代码保持可读性,而--mode=production参数则会输出(不可读的)最小化的文件。

得到 webpack 的dist/main.js输出文件后,我们就可以在浏览器中用它取代index.js,因为后者还包含非法的 require 语句。这个过程也能在index.html文件中体现:

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>JavaScript Example</title>
    <script src="dist/main.js"></script>
  </head>
  <body>
    <h1>Hello from HTML!</h1>
  </body>
</html>

刷新浏览器就能发现现在和之前一样了!

注意每次更改index.js,我们都需要运行 webpack 命令。这样很繁琐,并且在使用 webpack 高级功能时会变得更糟(比如从转换后的代码中生成source map辅助调试初始代码)。webpack 可以从项目根目录的配置文件中读取配置,配置文件名为webpack.config.js,在本文的例子是这样的:

javascript
// webpack.config.js
module.exports = {
  mode: 'development',
  entry: './index.js',
  output: {
    filename: 'main.js',
    publicPath: 'dist',
  },
};

现在每次修改index.js时,可以哦那个过下面这条命令运行 webpack:

$ ./node_modules/.bin/webpack

我们不再需要指定index.js和--mode=development设置了,因为 webpack 会从webpack.config.js加载这些配置。现在好些了,但仍显得繁琐,每次修改代码还是需要输入这串命令——下面会让这个过程更简单一些。

总之,上面这些步骤看起来并不多,但这种流程有巨大的优势。我们不再需要以全局变量的形式加载外部文件了。不必在 HTML 文件里增加<script>标签了,所有 JavaScript 的新库都能通过 JavaScript 的require语句加载。单一的 JS 打包文件也意味着更好的性能。加入构建过程之后,还可以在工作流中加入其他的强大功能。

转换代码以支持新语言特性(babel) ​

代码转换是指将一种语言的代码转换为另一种相似语言的代码。这是前端开发的一个重要部分——因为浏览器对新特性的支持进展缓慢,所以新语言的实验性功能得转译为兼容浏览器的语言。

对 CSS 来说,现在有Sass,Less和Stylus,等等。对 JavaScript 来说,曾经有红极一时的CoffeScript(大约发布于 2010 年),而现在人们用得最多的则是babel和TypeScript。CoffeeScript 这门语言,通过显著改变 JavaScript 来实现优化后者的目的——可选括号,有意义的留白,等等。babel 并不是一个新语言,而是一个转换器,它将所有浏览器尚未支持的新一代 JavaScript 特性(ES2015及之后的版本)转换为更兼容的 ES5 JavaScript 版本。TypeScript 本质上与新一代 JavaScript 相同,但还支持可选的静态类型。很多人选择使用 babel,因为它与原生 JavaScript 最相似。

看看如何在 webpack 构建过程中使用 babel。首先需要在项目中通过命令行安装 babel(它是个 npm 包):

$ npm install @babel/core @babel/preset-env babel-loader --save-dev

注意,作为开发依赖,我们安装了 3 个独立的包——@babel/core是 babel 的主题,@babel/preset-env是预设,决定转换 JavaScript 的哪些新特性,而babel-loader是让 babel 能与 webpack 协同的包。可以编辑webpack.config.js来设置 webpack 使用babel-loader,方法如下:

javascript
// webpack.config.js
module.exports = {
  mode: 'development',
  entry: './index.js',
  output: {
    filename: 'main.js',
    publicPath: 'dist',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
};

语法很不清晰(幸好我们不必经常修改)。基本上就是告诉 webpack 查找每个.js 文件(node_modules文件夹里的除外)并使用babel-loader通过@babel/preset-env的预设进行转换。这里有更多关于 webpack 配置的内容。

配置好了所有东西,现在可以用 ES2015 的语法写 JavaScript 了!下面是在index.html中使用 ES2015 模板字符串(template string)语法的例子:

javascript
// index.js
var moment = require('moment');
console.log('Hello from JavaScript!');
console.log(
  moment()
    .startOf('day')
    .fromNow()
);
var name = 'Bob',
  time = 'today';
console.log(`Hello ${name}, how are you ${time}?`);

还可以使用ES2015 import语句代替require加载模块,这在目前的代码库中很常见。

javascript
// index.js
import moment from 'moment';

console.log('Hello from JavaScript!');
console.log(
  moment()
    .startOf('day')
    .fromNow()
);
var name = 'Bob',
  time = 'today';
console.log(`Hello ${name}, how are you ${time}?`);

上例中,import语法和require没什么区别,但import在复杂情况下更灵活。修改index.js之后,我们需要通过命令行再运行一次 webpack。

$ ./node_modules/.bin/webpack

现在浏览器里刷新index.html。在我写这篇文章的时候,大多数现代浏览器已支持 ES2015 的全部特性,所以证实 babel 是否起作用有点难。你可以尝试用老浏览器比如 IE9,或者在bundle.js里搜索转换后的代码:

javascript
// bundle.js
// ...
console.log('Hello ' + name + ', how are you ' + time + '?');
// ...

这段代码可以看到,为了兼容浏览器, babel 把 ES2015 的模板字符串转换为了普通 JavaScript 字符串拼接。可能这个例子没什么大不了的,但代码转换这个功能真的非常强大。比如async/await这种让人激动的 JavaScript 新功能,能够让你写出更好的代码。尽管代码转换有时显得既繁琐又痛苦,但在过去几年中它确实大大改善了 JavaScript 这门语言,因为它让开发者能够在当下测试未来的语言特性。

还有一些没提到的内容,马上就完了。如果关注性能,我们应该压缩(minifying)打包后的文件。考虑到已经引入了构建过程,实现这个目标应该不难。我们每次修改代码后,也必须重新运行 webpack 命令行。下一步,我们将关注有哪些工具能够帮助我们。

使用任务管理工具(npm scripts) ​

我们已经使用构建过程来处理 JavaScript 模块了,自然也该使用任务管理工具。它能够自动化构建过程中的各个步骤。对前端开发来说,任务包括压缩代码,优化图片,执行测试等等。

2013 年,grunt 是最普及的任务管理工具,随后出现了 gulp 。二者都依赖内置了其他命令行工具的插件。现在最受欢迎的工具似乎是 npm 包管理器内置的脚本,它不需要插件,直接调用其他命令行工具。

写几个 npm 脚本,方便我们使用 webpack。只需要简单修改package.json文件:

json
{
  "name": "modern-javascript-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --progress --mode=production",
    "watch": "webpack --progress --watch"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "moment": "^2.22.2"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "@babel/preset-env": "^7.0.0",
    "babel-loader": "^8.0.2",
    "webpack": "^4.17.1",
    "webpack-cli": "^3.1.0"
  }
}

我们增加了 2 个新脚本,build和watch。执行 build 脚本,可以在命令行输入:

$ npm run build

这条命令会运行 webpack(使用wepack.config.js的配置),其中--progress参数会显示进度百分比,而--mode=production参数会为生产环境压缩代码。要执行watch脚本:

$ npm run watch

这条命令使用--watch参数,每次有 JavaScript 文件修改时,就自动重新运行 webpack。开发的福音。

注意在package.json中的脚本执行时,不必指定完整路径,因为 node.js 知道每个 npm 模块的路径。太方便了!我们还可以安装 webpack-dev-server 让事情变得更方便,它是一个独立工具,能够创建一个实时加载的 web 服务器。把它作为开发依赖安装,输入以下命令:

$ npm install webpack-dev-server --save-dev

随后在package.json中添加一条 npm 脚本:

json
{
  "name": "modern-javascript-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --progress -p",
    "watch": "webpack --progress --watch",
    "server": "webpack-dev-server --open"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "moment": "^2.19.1"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "@babel/preset-env": "^7.0.0",
    "babel-loader": "^8.0.2",
    "webpack": "^3.7.1",
    "webpack-dev-server": "^3.1.6"
  }
}

现在可以通过命令来启动开发服务器了:

$ npm run server

这条命令默认通过localhost:8080,自动在浏览器打开index.html网站。每次在index.js文件中修改代码,wepack-dev-server 都会重新构建打包文件,并自动刷新浏览器。它能大大节约我们的时间,因为这让我们只关注代码,而不必在浏览器效果与代码之间不断切换。

上面的内容只是一点皮毛,关于 webpack 和 webpack-dev-server 还有很多设置(更多内容看这里)。你当然也可以使用 npm 脚本完成其他任务,比如把 Sass 转换为 CSS,压缩图片,执行测试——所有可以通过命令行完成的任务。npm 脚本自身还有一些高级设置和技巧——Kate Hudson这个演讲很不错:

结语 ​

总而言之,这就是现代 JavaScript。我们从只用 HTML 和 JS,到了使用包管理器自动下载第三方包,到使用模块打包工具创建单一脚本文件,再到使用转换器使用未来 JavaScript 特性,最后到使用任务管理工具自动化构建过程的各个步骤。内容很多,对新手尤甚。web 开发曾经是一个对新手友好的领域,主要是因为它易于上手;但现在却让人望而却步,尤其是各种快速变化的工具。

但它并没有看起来那么可怕。生态正在稳定,特别是在前端拥抱 node 生态之后。npm 包管理器、node 模块中的 require 和 import 语句、还有任务管理工具 npm 脚本,他们都容易上手并且很少出差错。现在的工作流相比一两年之前已经大大简化了。

现在的框架对新手和老手开发者一视同仁,通常自带初始配置工具。Ember 有ember-cli,它对 Angular 的angular-cli产生了极大影响,还有 React 的create-react-app,Vue 的vue-cli等等。这些工具会配置好一个项目的所有依赖——你可以直接开始写代码。但是,这些工具并非奇迹,它们也只是按照一定规则和可用性配置好了全部东西——你可能经常遇到需要额外配置 webpack,babel 的情况。所以理解每个工具的作用仍然至关重要,这正是本文所介绍的内容。

现代 JavaScript 时常令人沮丧,因为它还在不停变化和升级。尽管看起来像是在不停的“造轮子”,但 JavaScript 的快速升级确实推动了一系列创新,比如 hot-reloading、real-time linting 和 time-travel debugging 等。开发是一项令人兴奋的工作,希望本文的内容能够帮助你在开发道路上走得更远。