各种包管理器到ESLint,从CommonJS到AMD,再从ES6模块到Babel和Webpack,好多工具啊!
1. 好累……
是的,今天我觉得很疲劳。我不禁想,我本应该继续我的销售职业,不应该抄近路做前端开发。但我意识到,前端开发是给勇敢者准备的,而勇敢者绝不会放弃,他们才是人生赢家。
所以我决定做人生赢家,我要写点东西给前端开发和工具链疲劳的受害者们看。我要写一下我是怎样将初学者级别的代码变成令人赞叹的产品级代码的,以及这个过程中我用到的工具。
现在开始吧!
2. 我们在做的东西
其实没什么令人激动的东西。我们做了个Web应用,从某个API读取一些随机的用户,然后显示在前端上。它没有路由的能力。本文的最终目标是让你熟悉前端的工具链。
我们的AngularJS代码中没有使用样板代码,所以我们不会被CLI的那些黑科技搞得晕头转向。注意我用的是AngularJS,不是Angular。使用AngularJS的原因是我找不到任何关于AngularJS工具链和打包的文章。
首先在根目录下建一个index文件。
Random User!
Random User!
中规中矩的老式代码。这段代码从CDN上加载AngularJS文件和一个最小化的CSS框架,然后开始一点点编写JavaScript代码并添加到index中。
但随着应用程序的增长,我们有必要跟踪所有的依赖(这里的依赖就是Angular)。
3. 使用包管理器
许多人使用包管理器跟踪项目所需的依赖的版本。包管理器的最大卖点就是,它会访问依赖的GitHub主页,下载到你的文件夹里,并且跟踪下载的版本。这样可以保证,移动代码位置或者下载其他版本的依赖不会造成代码无法工作。
代码管理器有过duojs、jspm、bower、npm,现在还有Yarn。现在去装一个yarn,我们稍后会用到。
向应用程序里添加依赖时,yarn会下载所需的文件,并保存到node_modules文件夹中。之后,需要用到依赖时,可以在index文件里引入:
装好这个后,我们再往根目录下添加app.js、userController.js和userFactory.js文件,然后全都链接到index文件里。
/**
* /app.js
*/
var app = angular.module("RandomApp", []);
// /userFactory.js
app.factory("UserF", function($http) {
var UserF = {};
UserF.getUsers = function(){
return $http({
method: 'GET',
url: 'https://www.reqres.in/api/users',
})
};
return UserF;
});
// /userController.js
app.controller("userController", function($scope, UserF){
$scope.users = [];
UserF.getUsers()
.then(function(res) {
$scope.users = res.data.data;
})
});
Random User!
Random User!
{{user.first_name}} {{user.last_name}}
index越来越大了。他遇到了他自己的问题。他的手心开始出汗,膝盖发软,胳膊也越来越沉重……
4. 这种方式的问题
所有的script标签必须按照固定的顺序。app.js生成app,然后附加到全局的window对象上。这个app变量会被其他脚本文件用到。这种情况通常被称为“全局命名空间污染”。
如果你还在用这种方式,就趁早改了吧。它的问题是,不管我们什么时候打开哪个文件,都无法得知app变量的值究竟是什么。
这段代码的另一个语义上的问题是,它使用了匿名函数。匿名函数是JavaScript的天使,也是魔鬼。
永远不要忘记给匿名函数起名字。这样以后调试代码会变得容易很多。
那么,要是有个JS警察负责找出这些问题,岂不是很好?
5. ESLint
ESLint是个清理器。就像个严格的结对编程的伙伴一样。清理器会在你运行应用程序之前就帮你调试代码,并且强迫你和你的团队遵循整洁代码的实践。谁说这样的老师不存在的?
6. 配置ESLint
我们使用Airbnb的样式配置,在我们编写代码时进行检查,并指出不当的地方。上面的命令会将配置安装到node_modules目录下,但我们得告诉ESLint怎么用这个配置。建立一个名为.eslintrc.json的文件,内容如下:
// .eslintrc.json
{
"extends": [
"airbnb/legacy"
],
"env": {
"browser": true
}
}
extends列表告诉ESLint在它自己的默认规则之上使用Airbnb的规则。env变量告诉ESLint不要抱怨没有初始化的window变量等。要清理所有文件,可以使用通配符 * 。
现在运行下ESLint看看有什么结果。
这些都是Airbnb样式指南定义的规则。我留给你自己去改正这些文件。一开始就有个清理器是最理想的。当然,你可以关闭某个特定的规则。
比如,如果你喜欢不用分号,或者使用双引号而不是单引号,你可以关闭相应的规则。ESLint很灵活,允许你这么做。
7. 模块
现在来讨论下模块。在创建大规模应用时,我们要求代码有良好的结构,以便于以后的扩展。
我们把代码放到不同的模块中,以实现代码分割的目的。JavaScript直到ES6才支持模块。但模块化的概念早在ES6之前就出现了。
8. CommonJS
在ES6之前,人们使用CommonJS标准。你可以写一段代码,然后告诉环境导出这段代码。之后就能使用像RequireJS之类的库导入模块了。
// util.js
module.export = {
noop: function(){},
validateUrl: function(s){
return s.matches(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/)
}
};
// postController.js
var validateUrl = require('./util').validateUrl;
var handleSubmit = function handleSubmit(e) {
if(!validateUrl(e.target.value)) {
return;
}
submitUrl(e.target.value);
}
如果你玩过Node,你会觉得这段代码看起来很眼熟。不过这个标准有个缺陷——它是同步的。
也就是说,只有在validateUrl被require了之后,postController的第3行的handleSubmit才会被执行。在这之前代码会暂停。
这种体系在Node.js中没什么问题。Node可以在服务器启动之前加载所有依赖,比如配置日志文件、连接云端的数据库、配置秘钥等。但在前端,这种方法并不是太理想。
9. 异步模块定义(Asynchronous Module Definition,AMD)顾名思义,这种方式会异步加载模块,比CommonJS的方式好一些。下面是使用AMD的代码(我加了几个函数)。看着眼熟吗?
define(['validateSpam', 'blockUser', function(validateSpam, blockUser){
return {
noop: function(){},
validateUrl: function(s) {
return s.matches(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/)
},
validateSpammyComment: function validateSpammyComment(comment, userID) {
if(validateSpam(comment)) {
blockUser(userID);
return false;
}
return true;
}
}])
第1行看起来就像AngularJS中的依赖注入一样。
10. ES6模块
TC39委员会看到开发者们使用外部库之后,他们深切感受到JavaScript需要支持模块。因此他们在ES6中引入了模块功能。现在使用ES6吧!
function noop(){};
function validateUrl(s) {
return s.matches(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/)
}
export {
noop,
validateUrl
}
import { validateUrl } from './util';
var handleSubmit = function handleSubmit(e) {
if(!validateUrl(e.target.value)) {
return;
}
submitUrl(e.target.value);
}
不需要再调用外部库。import和export都是原生支持的。但依然有些版本的浏览器并不能完全支持ES6的所有功能。
但这种浏览器之间的不一致并不能阻止程序员编写下一代的JavaScript。像babel等工具可以扫描所有JavaScript,并将它们转译成能兼容所有浏览器的代码。因此,你的代码甚至可以支持IE之类的老浏览器。
11. Babel和ES6好了,现在我们把旧的JavaScript转换成新的JavaScript。先做一点改动,以便添加模块功能。现在我们先不管清理器……让它去抱怨吧。
// /userFactory.js
let angular = window.angular;
let app = angular.module('RandomApp');
/**
* A User factory which gets the user list
* @param $http
*/
let userFactory = $http => {
let UserF = {};
UserF.getUsers = () => $http({
method: 'GET',
url: 'https://www.reqres.in/api/users'
});
return UserF;
};
app.factory('UserF', userFactory);
// /userController.js
let angular = window.angular;
let app = angular.module('RandomApp');
/**
* Controls the user
* @param $scope
* @param UserF
*/
let userController = ($scope, UserF) => {
$scope.users = [];
UserF.getUsers().then(res => $scope.users = res.data.data);
};
userController.$inject = ['$scope', 'UserFactory'];
app.controller('userController', userController);
12. 这段代码的问题
这段代码无法工作。因为ES6的let关键字创建的变量是代码块上下文内的变量,而在同一个上下文内无法重复定义代码块级别的变量。
别忘了:我们还在全局上下文里呢。现在来改正这个问题。
我希望重构代码的原因是,我想引入babel,这样可以亲眼看看babel的魔法。现在可以在终端里安装babel:yarn add babel-cli babel-preset-env
这行命令会安装babel-cli和babel-preset-env。
13. babel插件和预设
JavaScript代码会通过一系列转换器,而我们可以选择需要什么转换器。你可以让它把箭头函数转换成匿名函数,转换扩展运算符(spread),转换for...of循环,等等。这些转换器叫做插件。
你可以选择任何你想要的插件。成组的插件叫做预设。babel-preset-env会给babel一个灵活的目标。
它并不是指定某个特定版本的JavaScript,而是告诉babel自动跟踪最新的n个版本浏览器。
现在来设置babel配置文件:.babelrc,把它放到根目录下。
{
"presets": [
["env", {
"targets": {
"browsers": "last 2 versions"
}
}]
]
}
现在从终端运行如下命令,babel就能正常工作。输入以下命令:
node_modules/.bin/babel *.js
很方便对吧?babel会在转换之前预览以下。
现在喘口气,考虑下我们学过的东西。我们将一个JavaScript文件分解成了许多个文件。我们添加了清理器,防止写出不合规范的代码。
我们使用未来的JavaScript语法,并将其转换成特定版本的浏览器能理解的东西。我们污染了全局命名空间,但暂时还能接受,我们稍后会解决这个问题。
如果有个工具能自动完成这一切就好了。我们给它所有代码,自动运行清理器找出所有错误,然后转译成浏览器兼容的代码。没错,确实有这么个工具。现在把这些东西都自动化吧。
14. 用Webpack进行构建首先,把所有JS文件都移动到一个目录下。我们使用标准的命名方式,将文件夹命名为build。同时,我们重构下JavaScript文件,这样所有文件都能被构建到同一个文件下。
// /build/userController.js
/**
* Controls the user
* @param $scope
* @param UserF
*/
let userController = ($scope, UserF) => {
$scope.users = [];
UserF.getUsers().then(res => $scope.users = res.data.data);
};
userController.$inject = ['$scope', 'userFactory'];
export default userController;
// /build/userFactory.js
/**
* A User factory which gets the user list
* @param $http
*/
let userFactory = $http => {
let UserF = {};
UserF.getUsers = () => $http({
method: 'GET',
url: 'https://www.reqres.in/api/users'
});
return UserF;
};
userFactory.$inject = ['$http'];
export default userFactory;
/// /build/app.js
import angular from 'angular';
import userController from './userController';
import userFactory from './userFactory';
angular.module('RandomApp', [])
.factory('userFactory', userFactory)
.controller('userController', userController);
现在创建webpack.config.js文件:
var path = require('path');
module.exports = {
mode: 'development', // tells webpack that this is a development build. the 'production' switch will minify the code among other things
devtool: 'cheap-eval-source-map', // generate source maps for better debugging and dont take much time.
context: __dirname, // since this runs in a node environment, webpack will need the current directory name
entry: './build/app.js', // take this file and add to the bundled file whatever this file imports
output: {
path: path.join(__dirname, 'dist'), // output this in a dist folder
filename: 'bundle.js' // and name it bundle.js
},
// read medium post to know what's module and devServer because I dont have much room for comments
module: {
rules: [{
enforce: 'pre',
loader: 'eslint-loader',
test: /\.js$/
}, {
loader: 'babel-loader',
test: /\.js$/
}]
},
devServer: {
publicPath: '/dist/',
filename: 'bundle.js',
historyApiFallback: true,
overlay: true
}
};
如果现在运行webpack,就会看到所有文件都被打包成一个文件,放到dist目录下:
15. webpack配置揭秘
祝贺你,给自己点奖励吧。现在我们把所有文件都打包到一起,它几乎可以用于生产环境了。
现在来讨论下这个配置。我会逐一介绍每个配置项的意思。更详细的资料可以参考手册(https://webpack.js.org/)。
大多数配置项我都给出了注释。这里说说没加注释的那些。
16. Webpack加载器(module对象)
这个可以想像成一条流水线上的一串代码加载单元。最后一个加载器(这里是babel-loader)会最先被Webpack用来加载代码。
我们要求webpack遍历所有代码,然后用babel-loader将代码转译成ES5。
加载器对象还需要一个test设置项。它用这个设置项来匹配所有它应该负责的文件(本例中用一段正则表达式匹配了所有扩展名为.js的文件)。
转译之后,就执行下一个加载器(这里是eslint-loader)。最后,把所有改变从内存中写到文件中,然后输出到output对象指定的文件里。
但这并不是我们的配置文件的行为。我们在ESLint加载器上加了个enforce: pre,因为我们希望先运行清理器。
这是因为输出的结果只有一个文件,而且如果使用最小化和混淆功能的话,这个文件通常会变成人类无法阅读的格式(生产环境中经常会如此)。
清理器看这个文件就会疯了。这不是我们想要的,所以我们希望webpack先运行清理器,再进行转译。
除此之外,你还可以使用好几个加载器,可以加载样式表文件、SVG图像,以及字体。有个我总会用到的加载器就是html-loader。
17. HTML加载器
在Angular下,我们通常会在directive/component中包含模板文件,因此可以在Webpack中使用html-loader进行加载。
templateUrl: './users/partial/user.tpl.html' // 把这种写法改成:templateUrl: require('./users/partial/user.tpl.html')
Webpack由一个超大规模的社区支持,他们写了很多优秀的加载器,以及很完善的文档。不管你有什么需求,很可能已经有现成的加载器了。
18. Webpack开发服务器(devServer)
Webpack开发服务器是个独立于Webpack的模块。它会启动自己的服务器,然后监视任何文件的改动。如果文件发生变化,Webpack开发服务器就会重新打包并自动刷新页面。
如果发生错误,它会在屏幕上显示一个覆盖层(通过overlay配置项设置),并直接在浏览器中显示错误信息。而且它速度非常快,因为一切都在内存中完成,不会访问硬盘。
当然,为了运行webpack开发服务器,你首先得有一个基础的构建好的文件(即,至少要运行webpack一次以生成构建好的文件)。
一旦生成之后,就可以运行该命令。该命令会启动服务器并提供静态文件,打开浏览器(默认是8080端口),并持续监视任何变动。
搞定了!
不过这并不是结局。还有许多你能做的事情。在工作中,我们使用Flow在编码时进行静态类型检查。
静态类型检查器可以检查代码,并在发生错误时发出警告,比如调用函数时提供了错误类型的参数等。Flow也可以集成到Webpack中。
我们还使用Prettier实现编码时的自动格式化。它能让代码更可读。
傻瓜都能写计算机能看懂的代码。
好的程序员写人类能看懂的代码。
—— Martin Fowler。
我要把这句话贴在我的桌子上。祝贺你!你成功了!
如果你读完了这篇超长的文章,我要跟你击掌向你道喜,你是人生赢家。JavaScript对我而言并不容易。
我很希望我在第一个项目中编写UI时能懂得这些东西。但我估计这就是前端开发对我的意义。持续学习,持续进步。