一文了解前端自动化构建工具

866 阅读34分钟

自动化构建简介

自动化构建是前端工程化当中一个非常重要的组成部分,那在具体学习之前我们先来解读一下这一个开发行业当中经常提起的名词。

自动化实际上就是指的是我们通过机器去代替手工完成一些工作,构建呢可以理解为转换,就是把一个东西换转换成另外的一些东西。

那总的来说开发行业的自动化构建就是把我们开发阶段写出来的源代码自动化的去转换成生产环境当中可以运行的代码或者程序。

那一般我们会把这样一个转换的过程称之为自动化构建工作流,他的作用就是让我们尽可能去脱离运行环境的种种问题,去在开发阶段去使用一些提高效率的语法、规范和标准。

最典型的应用场景呢就是我们去开发网页应用时我们就可以使用ECMAScript最新标准去提高编码效率和质量。

利用Sass去增强css的可编程性,然后借助模板引擎去抽象页面当中重复的html。

那这些用法在浏览器当中是没有办法直接被支持的,那这种情况下自动化构建工具就可以派上用场了,我们通过自动化构建的方式将这些不被支持的代码特性转换成能够直接运行的代码。

这样我们就可以尽情在我们开发过程当中通过这些方式去提高我们编码效率了。

自动化构建初体验

接下来我们通过一个小案例来体会一下自动化工具的便捷之处,在这个案例中我们一开始使用的是直接使用css的方式去编写网站的样式。但是我们通过saas去增强css的编程性。

具体的实现方式呢就是在开发时我们添加一个构建的环节,这样让我们在开发环节就可以通过sass编写样式再通过工具将sass去构建为css。

我们看下如何操作,我们新建一个scss文件,在这个文件中我们可以按照sass的语法去编写我们的网页样式,相对于css,sass的编程能力肯定要更强一些。

$body-bg: #f8f9fb;
$body-color: #333;

body {
    margin: 0 auto;
    padding: 20px;
    background-color: $body-bg;
    color: $body-color;
}

但是呢sass并不能在我们的浏览器环境中直接去使用,所以我们需要在开发阶段通过一个工具去把他转换成css。

这里我们要使用的就是sass官方提供的一个sass模块,我们需要安装这个模块。

yarn add sass --dev

安装完成过后我们node_modules中会出现一个.bin的目录,这个目录下就会有一个sass的命令文件,我们执行这个命令会打印出一些帮助信息。在这个帮助信息的一开始就给出了这个命令的具体用法。

./node_modules/.bin/sass

具体的就是我们要指定一个sass的输入路径和css的输出路径,输入路径我们是main.scss, 输出路径是style.css。再次执行他就可以把我们的sass文件转换成css了。

./node_modules/.bin/sass main.scss style.css

不仅如此,他还帮我们添加了对应的source-map文件,这样的话我们在调试阶段就可以定位到我们代码中源代码的位置了。

但是这样也有一个比较麻烦的地方,那就是我们每次都要重复的去输出这些复杂的命令,而且在别人接收你的项目过后他也不知道如何去运行这些构件的任务。

所以说我们需要去做一些额外的事情,去解决这些在项目开发阶段重复去执行的命令。

npm scripts

那npm scripts主要就是用来解决这个问题的,你可以在npm的script当中去定义一些与这个项目开发过程有关的一些脚本命令,那这样一来呢你就可以让这些命令跟着项目一起去维护,便于我们在后期开发过程的使用。

所以我们这里最好的方式就是通过npm scripts方式去帮助包装你的构建命令。具体的实现方式就是在我们的package.json当中去添加一个scripts字段,这个字段是一个对象,键就是script的名称,值呢是我们需要去执行的命令。

这里需要注意的是script可以自动取发现node_modules里面的命令,所以我们不需要写完整的路径,直接使用命令的名称就可以了。

"script": {
    "build": "sass main.scss style.css"
}

完成过后我们就可以通过npm或者yarn去启动这个script,npm当中需要运行npm run build, yarn当中可以省略这个run,-> yarn build。

另外,npm scripts也是实现自动化构建最简单的方式,接着我们来看一下如何通过他来实现自动化构建。

我们这里安装一个browser-sync的模块,用于去启动一个测试服务器, 去运行我们的项目。

yarn add browser-sync --dev

然后我们在package.json的scripts当中添加一个serve的命令。在这个命令当中通过browser-sync把当前这个目录运行起来。

"script": {
    "build": "sass main.scss style.css",
    "serve": "browser-sync"
}

回到命令行运行一下npm run serve,此时会启动一个服务,并打开浏览器运行我们目录下的index.html文件。

但是如果说在browser-sync之前我们并没有去生成我们的样式,那此时browser-sync工作时我们的页面就没有样式文件,我们需要在启动serve命令之前去让build任务去工作,所以我们这里可以借助npm scripts的钩子机制去定义一个preserve,他会自动在serve命令执行之前去执行。

"script": {
    "build": "sass main.scss style.css",
    "preserve": "npm run build",
    "serve": "browser-sync"
}

这时我们再去执行npm run serve就会自动化的先去执行build命令, build完成过后再去执行对应的serve,那此时我们就可以完全在启动web服务之前自动去构建我们的sass文件。

光有这些还不够我们还可以为sass文件去添加一个--watch的参数,有了这个参数过后,sass在工作时就会监听文件的变化,一旦我们代码当中的sass文件发生改变,他就会自动被编译。

"script": {
    "build": "sass main.scss style.css --watch",
    "preserve": "npm run build",
    "serve": "browser-sync"
}

我们运行serve命令,你会发现sass文件在工作时命令行会阻塞,这样就导致了我们后面的browser-sync他并没有办法直接去工作,这种情况下我们就需要同时去执行多个任务,我们这里可以借助于npm-run-all的这个模块去实现, 我们需要先安装这个模块。

yarn add npm-run-all --dev

有了这个模块过后我们就可以在scripts当中再去添加一个新的命令,这个命令我们叫做start,在这个命令当中我们通过npm-run-all里面的run-p的命令同时去执行build和serve命令。

"script": {
    "build": "sass main.scss style.css --watch",
    "serve": "browser-sync",
    "start": "run-p build serve"
}

我们再运行npm run start的命令,那这个时候就会发现build命令和browser-sync命令同时被执行了。我们可以尝试修改main.scss文件当中的内容,这个时候你会发现css文件也会跟着一起变化,也就证明我们的watch已经生效了。

我们还可以给browser-sync这个命令去添加--files的参数,这个参数可以让browser-sync在启动过后监听项目下的一些文件的变化。一旦当文件发生变化过后,browser-sync会将这些文件的内容自动同步到浏览器,从而更新浏览器当中的界面,让我们可以及时查看到最新监听的效果。

那这样就避免了我们修改完代码过后再去手动刷新浏览器这样的一个重复的工作。

那这样我们就借助于npm scripts完成一个简单的自动化构建的一个工作流,那他具体的工作流程呢就是在启动任务过后,同时去运行了build和serve这两个命令,其中build去自动监听sass文件的变化去编译sass,browser-sync他启动一个web服务,当文件发生变化过后去刷新浏览器。

"script": {
    "build": "sass main.scss style.css --watch",
    "serve": "browser-sync --files \"*.css\"",
    "start": "run-p build serve"
}

常用的自动化构建工具

npm scripts确实能解决一部分的自动化构建任务,但是对于相对复杂的构建过程,npm scripts就显得有些吃力,这时我们就需要更为专业的构建工具。

这里我们先对市面上几个比较常见的构建工具去做一个大致的介绍。让大家先有一个整体的认识,后面我们再去做具体的深入探究。

那目前市面上开发者使用最多的一些开发工具主要就是gulp,grunt和fis,可能有人会问,webpack去哪了,严格来说webpack他实际上是一个模块打包工具所以不在我们这次的谈论范围之内。

那这些工具呢他都可以帮你解决那些重复而且无聊的工作从而实现自动化,用法上他们也都大体相同,都是先通过一些简单的代码去组织一些插件的使用,然后你就可以使用这些工具去帮你执行各种重复的工作了。

grunt他算是最早的前端构建系统了,他的插件生态非常的完善,用官方的一句话来说就是grunt他的插件几乎可以帮你自动化的完成任何你想要做的事情。

但是呢,由于他的工作过程是基于临时文件去实现的,所以说他的构建速度相对较慢,例如我们使用它去完成我们项目当中sass文件的构建,那我们一般会先对sass文件去做编译操作,再去自动添加一些私有属性的前缀,最后再去压缩代码。

那这样一个过程当中,grunt每一步都会有磁盘读写操作,比如像sass文件在编译完成过后他就会将结果写入到一个临时的文件,然后下一个插件他再去读取这个临时文件进行下一步。

那这样一来,处理的环节越多,文件读写的次数也就越多,那对于超大型项目当中,我们项目文件会非常多,你的构建速度就会特别的慢。

gulp是个人使用非常多的一个构建系统,他很好的解决了grunt当中构建速度非常慢的这样一个问题,因为他是基于内存去实现的,也就是说他对文件的处理环节都是在内存当中完成的。相对于磁盘读写速度自然就快了很多。

另外他默认支持同时去执行多个任务,效率自然大大提高,而且他的使用方式相对于grunt更加直观易懂,插件生态也同样非常完善,所以说他后来居上,更受欢迎,应该是目前市面上最流行的前端构建系统了。

fis是百度的前端团队推出的一款构建系统,最早只是在他们团队内部去使用,后来开源过后在国内快速流行,那相对于前面两个构建系统这种微内核的特点,fis更像是一种捆绑套餐。

他把我们项目当中一些典型的需求尽可能都集成在内部了,例如我们在fis内部就可以很轻松的去处理资源加载,模块化开发,代码部署,甚至是性能优化。

正是因为这种大而全,所以在国内很多项目中就流行开了,那总体来说如果你是初学者的话,可能fis更适合你,但是如果你的要求灵活多变的话,gulp和grunt应该是你更好的选择。

还是那句话,新手是需要规则的,而老手呢一般都会渴望自由,也正是因为这个原因,现在这些小而美的框架或者工具。

grunt 的基本使用

我们在项目中想要使用grunt的话,首先需要安装他。

yarn add grunt --dev

安装完成过后我们需要在项目跟目录添加一个gruntfile.js文件,那这个文件是grunt的入口文件,用于去定义一些需要grunt自动执行的任务。

我们在这个文件中需要去导出一个函数,这个函数接收一个叫做grunt的形式参数,这个grunt是一个对象,对象中就是grunt提供的一些api。

我们可以借助api去快速创建一些构建任务,具体来做就是我们通过module.export去导出这样一个函数,这个函数中我们借助grunt的registerTask方法去注册一个任务。

这个方法第一个参数指定我们任务的名字,第二个参数可以去指定一个任务函数,也就是当这个任务发生时自动执行的这个函数。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
}

运行yarn grunt foo, foo就是我们刚刚注册这个任务的名字,这样的话grunt就是自动的帮我们去执行foo这个任务,当然你可以添加更多的任务。

如果添加任务的时候第二个参数指定一个字符串,这个字符串就是这个任务的描述,他会出现在grunt的帮助信息当中。

我们可以在命令行中通过grunt --help去得到grunt的帮助信息,在这个帮助信息当中有一个avaible tasks,在这个tasks当中任务描述就是我们自定义的一个任务描述了。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
    grunt.registerTask('bar', '任务描述', () => {
        console.log('other task');
    })
}

如果你在注册任务的时候任务名称叫做default的话,那这个任务将会成为grunt的默认任务,在运行任务的时候就不需要指定任务的名称。grunt将自动调用default。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
    grunt.registerTask('bar', '任务描述', () => {
        console.log('other task');
    })
    grunt.registerTask('default', () => {
        console.log('default task');
    })
}

一般我们会用default去映射一些其他的任务,一般的做法就是在registerTask函数第二个参数传入一个数组,这个数组当中我们可以去指定一些任务的名字。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
    grunt.registerTask('bar', '任务描述', () => {
        console.log('other task');
    })
    grunt.registerTask('default', ['foo', 'bar']);
}

这个时候我们去执行default的时候grunt就会依次执行我们数组当中这些任务。

yarn grunt

最后我们再介绍一下grunt当中对异步任务的支持,我们在这个任务通过setTimeout去模拟一下异步操作,在异步任务完成过后我们打印一个消息到控制台当中。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
    grunt.registerTask('bar', '任务描述', () => {
        console.log('other task');
    })
    grunt.registerTask('default', ['foo', 'bar']);

    grunt.registerTask('async-task', () => {
        setTimeout(() => {
            console.log('async task');
        }, 1000)
    })
}
yarn grunt async-task

通过执行我们发现,console.log并没有直接执行,那这个是grunt当中一个特点,grunt代码默认支持同步模式。

如果说你需要异步操作的话,你必须要使用this的async方法得到一个回调函数,在你的异步操作完成过后去调用这个回调函数,标识一下这个任务已经被完成。

如果我们要在函数当中使用this的话,那我们函数就不能是剪头函数了,我们就要使用普通的话函数,在这个函数当中我们使用this.async得到一个回调函数。

那这次我们再setTimeout完成之后除了打印消息以外还要去调用一下done回调函数,标识一下我们的任务已经完成了。

module.exports = grunt => {
    grunt.registerTask('foo', () => {
        console.log('hello grunt');
    })
    grunt.registerTask('bar', '任务描述', () => {
        console.log('other task');
    })
    grunt.registerTask('default', ['foo', 'bar']);

    grunt.registerTask('async-task', function () {
        const done = this.async();
        setTimeout(() => {
            console.log('async task');
            done();
        }, 1000)
    })
}

此时grunt就知道这是一个异步任务,他会等待done的执行,直到done被执行grunt才会结束这个任务的执行。

Grunt 标记任务失败

如果你在构建任务的逻辑代码当中发生错误,例如我们需要的文件找不到了,那此时我们就可以将这个任务标记为一个失败的任务。

具体实现我们可以通过在函数体当中去return false来实现,例如在我们这个任务当中,我们在最下面return false。

module.exports = grunt => {
    grunt.registerTask('bad', () => {
        console.log('bad working~')
        return false
    })
}

那此时我们运行任务就会被提示执行失败了,那如果说这个任务是在一个任务列表当中的话那这个任务的失败会导致后续任务不在被执行。

例如我们这里有多个任务,我们通过default把它们连在一起,正常情况他们会依次执行,但是当bad失败的时候bar也就不执行了。

module.exports = grunt => {
    grunt.registerTask('bad', () => {
        console.log('bad working~')
        return false
    })
    grunt.registerTask('foo', () => {
        console.log('foo task~')
        return false
    })
    grunt.registerTask('bar', () => {
        console.log('bar task~')
        return false
    })
    grunt.registerTask('default', ['foo', 'bad', 'bar']);
}

我们可以运行的时候使用--force强制执行所有任务。

yarn grunt default --force

使用--force之后我们即使bad任务执行失败了也是会正常去执行bar,那这就是我们如何去标记一个任务失败,以及标记失败过后的影响。

但是如果说你的任务是一个异步任务的话,那么一步任务当中我们就没有办法直接通过return false去标记这个任务失败,那此时我们需要给异步的回调函数指一个first实参就可以。标记这个任务失败了。

module.exports = grunt => {
    grunt.registerTask('bad-async', function() {
        const done = this.async();
        setTimeout(() => {
            console.log('bad async');
            done(false)
        }, 1000)
    })
}

那此时这个异步任务就被标记为一个失败的任务,这就是我们在grunt的任务中如何去标记此任务为一个失败的任务。以及标记为失败任务过后的一个影响。

Grunt 的配置方法

除了registerTask的方法之外,Grunt还提供了一个用于去添加任务选项的API叫做initConfig,例如我么在使用Grunt去为我们压缩文件时,我们就可以通过这种方式去配置我们压缩的文件路径。

这个方法接收一个对象形式的参数,对象的属性名一般与我们的任务名称保持一致,那我们这里叫做foo,属性的值他可以是任意类型的数据,我们这里用bar字符串。

有了这个配置属性过后我们就可以在任务当中去使用这个配置属性, 这里我们注册一个叫做foo的任务,在这个foo的任务中可以通过grunt提供的config方法去获取这个配置,那这个config方法接收一个字符串参数,这个参数就是在initConfig当中指定的字符串名字。

module.exports = grunt => {
    grunt.initConfig({
        foo: 'bar'
    })

    grunt.registerTask('foo', () => {
        const result = grunt.config('foo');
        console.log(result);
    })
}

这个时候在执行任务的时候就会获取到initConfig的内容。如果我们的foo是一个对象的话,在config可以通过.的方式获取到属性值。

module.exports = grunt => {
    grunt.initConfig({
        foo: {
            bar: 123
        }
    })

    grunt.registerTask('foo', () => {
        const result = grunt.config('foo.bar');
        console.log(result);
    })
}

grunt build

当然一般我们很少会使用到这种方式。一般可以通过foo拿到整个对象,再去单独获取。

Grunt 多目标任务

除了普通的任务形式以外Grunt中还支持一种叫做多目标形式的任务,你可以理解为子任务的概念,那这种形式的任务在我们后续具体去通过Grunt去实现各种构建任务时非常有用。

多目标任务需要通过grunt当中的registerMultiTask方法去定义,这个方法同样接收两个参数,第一个是任务名字,第二个是函数。

module.exports = grunt => {
    grunt.registerMultiTask('build', function() {
        console.log('task');
    })
}

grunt build

使用这种多任务需要配置任务目标,配置方式就是通过initConfig方法去配置。在这里我们需要去指定一个与我们任务名称同名的属性,也就是build,并且属性值必须要是一个对象,对象当中每一个属性的名字就是我们的目标名称。

这个时候就相当于为我们的build任务添加了两个目标,一个是css一个是js。此时在运行的时候就会执行两个任务,也就是两个目标,也就是我们的build任务有两个目标一个js一个css,运行build命令的时候会同时执行这两个目标。

module.exports = grunt => {
    grund.initConfig({
        build: {
            css: '1',
            js: '2'
        }
    })

    grunt.registerMultiTask('build', function() {
        console.log('task');
    })
}

grunt build

如果需要执行指定目标的时候可以通过build:css,这个时候只会执行css目标。

grunt build:css

在我们这个任务函数当中我们可以通过this.target去拿到当前执行的这个目标的名字,还可以通过this当中data去拿到这个target所对应的数据。

module.exports = grunt => {
    grund.initConfig({
        build: {
            css: '1',
            js: '2'
        }
    })

    grunt.registerMultiTask('build', function() {
        console.log(`${this.target} ${this.data}`);
    })
}

需要注意的是我们在build当中指定的每一个属性的键都会成为一个目标,除了指定的options以外,在options当中指定的信息会作为任务的配置选项出现。我们也可以通过this.options()拿到他的配置选项。注意这里的options是一个方法。

module.exports = grunt => {
    grund.initConfig({
        build: {
            options: {
                foo: 'bar'
            },
            css: '1',
            js: '2'
        }
    })

    grunt.registerMultiTask('build', function() {
        console.log(`${this.target} ${this.data} ${this.options()}`);
    })
}

除了可以在任务当中去加这个选项,我们还可以在目标当中添加,如果说目标也是一个对象的话,那么在这个属性当中也可以去添加一个options,添加之后会覆盖掉对象当中的options。

module.exports = grunt => {
    grund.initConfig({
        build: {
            options: {
                foo: 'bar'
            },
            css: {
                options: {
                    foo: 'baz'
                }
            },
            js: '2'
        }
    })

    grunt.registerMultiTask('build', function() {
        console.log(`${this.target} ${this.data} ${this.options()}`);
    })
}

Grunt 插件的使用

插件机制是Grunt的核心,它存在的云因也非常简单,因为很多构建任务都是通用的,例如你在你的项目当中需要压缩代码,别人的项目当中同样也会需要,所以说社区当中就出现了很多预设插件,那这些插件内部都封装了很多通用的构建任务。一般情况下我们的构建过程都是由这些通用的构建任务组成的。接下来我们就来看一下如何去使用插件的构建任务。

使用插件的过程非常简单,大体就是先通过npm去安装这个插件,再到gruntfile当中去载入这个插件提供的一些任务,最后我们根据这些插件的文档去完成配置选项。

我们回到项目当中,这里我们通过一个非常常见的插件去尝试一下。这个插件叫做grunt-contrib-clean,用来自动清除我们在项目开发过程中产生的一些临时文件。首先我们需要安装这个插件。

yarn add grunt-contrib-clean

然后在Grunt中通过grunt.loadNpmTasks的方式去加载这个插件当中提供的一些任务。绝大多数情况下grunt的命名规范都是grunt-contrib-taskname, 所以我们这里的clean插件提供的任务名称应该就叫clean。

clean是一种多目标任务,我们需要使用config的方式去配置不同的目标。我们使用initConfig配置一个任务。

module.exports = grunt => {
    grunt.initConfig({
        clean: {
            temp: 'temp/app.js'
        }
    })

    grunt.loadNpmTask('grunt-contrib-clean');
}

这事我们再运行clean命令的时候就会删除掉temp/app.js文件。除了具体文件我们还可以使用通配符的方式去统配一些文件类型。例如我们可以删除所有的txt文件。我们也可以使用**删除temp下所有的文件。

module.exports = grunt => {
    grunt.initConfig({
        clean: {
            temp: 'temp/*.txt'
        }
    })

    grunt.loadNpmTask('grunt-contrib-clean');
}

使用grunt插件第一件事就是找到这个插件然后把他安装到npm模块当中,第二件事就是通过loadNpmTask方法把这个插件中提供的一些方法加载进来,第三件事就是在initConfig当中为这个任务添加一些配置选项,这样我们的插件就可以正常工作了。

Grunt 常用插件及总结

接下来我们再来看几个在grunt当中常用的插件。

首先第一个就是grunt-sass, 需要注意的是grunt官方也提供了一个sass模块,但是那个模块需要本机安装sass环境,使用起来很不方便。我们这里使用的grunt-sass是一个npm模块,他在内部会通过npm的形式去依赖sass。这在使用起来不需要对机器有任何的环境要求。

使用的方式也是需要先去安装他。grunt-sass需要有一个sass模块支持,我们这里使用的是sass官方提供的npm模块, 我们把这两个模块安装到开发依赖当中。

yarn add grunt-sass sass --dev

安装之后我们就可以通过grunt.loadNpmTasks去载入grunt-sass当中提供的任务。我们还要在grunt.initConfig中配置多任务目标, main中需要指定sass中输入文件。以及最终输出的css文件的路径。可以通过files属性,键是输出文件路径,值就是原路径也就是sass文件的路径。

这里还需要添加一个options的implementation模块,指定依赖的模块。

const sass = require('sass');

module.exports = grunt = > {
    grunt.initConfig({
        sass: {
            options: {
                implementation: sass
            },
            main: {
                files: {
                    'dist/css/main.css': 'src/scss/main.scss'
                }
            }
        }
    })

    grunt.loadNumTasks('grunt-sass')
}

这就是sass文件在grunt中的一个基本使用,当然他还有更多的选项比如我们可以添加source-map。

当然在开发中我们常用的一个插件还有babel,我们一般通过他把ES6的语法转换为浏览器可识别的ES5, 在grunt中如果想要使用babel的话,也可以使用一个叫做grunt-babel的插件。

同样我们首选需要安装这个插件。当然他也需要一个babel模块,这里我们安装@babel/core以及babel的预设@babel/preset-env。

yarn add grunt-babel @babel/core @babel/preset-env --dev

有了这三个模块我们就可以在我们的项目中使用babel的任务了。首先我们也是需要使用loadNpmTasks加载grunt-babel的任务。

随着我们的任务越来越多loadNpmTasks也会越来越多,这个时候社区有一个模块可以减少loadNpmTasks的使用。我们可以先安装一个loadGruntTasks的模块。

yarn add load-grunt-tasks --dev

安装完成之后我们可以先导入这个模块, 这样我们就不需要每次重复的导入这个插件了,我们可以直接通过loadGruntTasks调用。那这个时候loadGruntTasks会自动加载grunt的所有插件。

const sass = require('sass');
const loadGruntTasks = require('load-grunt-tasks');

module.exports = grunt = > {
    grunt.initConfig({
        sass: {
            options: {
                implementation: sass
            },
            main: {
                files: {
                    'dist/css/main.css': 'src/scss/main.scss'
                }
            }
        }
    })

    loadGruntTasks(grunt);
    // grunt.loadNumTasks('grunt-sass')
}

这个时候我们就可以为grunt-babel提供一些任务配置,首先也是配置输入和输出路径。这里也需要为babel设置一个options,这里设置的是babel在转换时候的preset。他会根据最新的ES特性去做转换。

这里我们也可以为js文件生成source-map

const sass = require('sass');
const loadGruntTasks = require('load-grunt-tasks');

module.exports = grunt = > {
    grunt.initConfig({
        sass: {
            options: {
                implementation: sass
            },
            main: {
                files: {
                    'dist/css/main.css': 'src/scss/main.scss'
                }
            }
        },
        babel: {
            options: {
                sourceMap: true,
                presets: ['@babel/preset-env']
            },
            main: {
                files: {
                    'dist/css/app.js': 'src/scss/app.js'
                }
            }
        }
    })

    loadGruntTasks(grunt);
    // grunt.loadNumTasks('grunt-sass')
}

还有一个特性就是当文件修改之后需要自动的去编译,那这个时候在grunt当中我们需要另外一个插件。grunt-contrib-watch

yarn add grunt-contrib-watch --dev

安装之后loadGruntTasks会自动把这个任务加载进来,所以我们可以直接配置使用,watch可以配置不同的目标,我们这里配置一个js目标。这里的files是一个数组,因为他不需要输出目标,只需要设置监听目标就可以了。

这里我们还需要设置一个tasks,也就是当文件发生改变要执行什么任务, 这里我们写上babel。

const sass = require('sass');
const loadGruntTasks = require('load-grunt-tasks');

module.exports = grunt = > {
    grunt.initConfig({
        watch: {
            js: {
                files: ['src/js/*.js'],
                tasks: ['babel']
            }
        }
    })

    loadGruntTasks(grunt);
    // grunt.loadNumTasks('grunt-sass')
}

这个时候我们再运行就要运行watch任务,watch任务并不会直接去执行babel,一旦文件发生变化,就会执行babel任务。

一般我们会给watch做一个映射,是为了确保我们再启动的时候先执行一下sass和babel任务,最后在执行watch任务。这样会更合理一些。

const sass = require('sass');
const loadGruntTasks = require('load-grunt-tasks');

module.exports = grunt = > {
    grunt.initConfig({
        watch: {
            js: {
                files: ['src/js/*.js'],
                tasks: ['babel']
            }
        }
    })

    loadGruntTasks(grunt);
    
    grunt.registerTask('default', ['sass', 'babel', 'watch']);
}

这就是我们介绍的3个小插件,这三个小插件实际上是我们使用grunt这个工具最常用的几个插件,除此之外还有很多其他的插件,这里不过多介绍了。

其实grunt已经退出历史舞台了,这里介绍他是因为他算是鼻祖,所以我们先介绍一下它,另外介绍他还有一个最主要的点就是为后面做铺垫,有了它来对比后面的gulp,就会有更好的体验。

至于其他的一些插件,我们可以自己去查询文档来了解。这些内容已经足够我们去无压力的分析别人的grunt项目了。

Gulp 的基本使用

gulp作为当下最流行的前端构建系统,其核心特点就是高效易用,因为使用gulp的过程非常简单,大体过程就是现在项目当中安装一个gulp依赖, 然后创建一个gulpfile文件来配置gulp。

npm init
npm install gulp --save

gulpfile.js

exports.foo = () => {
    console.log('foo');
}

这样我们就创建了一个foo的gulp任务。我们可以运行一下gulp foo。

npx gulp foo

gulp中所有的任务都是异步任务,我们需要标记任务完成,可以在函数中接收一个函数,调用这个函数的时候就是完成了。

gulpfile.js

exports.foo = (done) => {
    console.log('foo');
    done(); // 结束标识
}

如果导出的是一个default任务会作为gulp的默认任务出现,运行gulp的时候可以不需要指定任务名称。

在gulp4.x以前注册gulp任务需要使用task方法注册,不过这种方式已经不推荐了。

const gulp = require('gulp');
gulp.task('foo', done => {
    console.log('任务')
    done();
})

Gulp 的组合任务

可以通过gulp模块提供的series和parallel来组合任务。

series是一个函数,可以接收任意个数的参数,每个参数就是一个任务,series会自动的按顺序依次执行这些任务。

const { series } = require('gulp');

const t1 = done => {
    console.log('任务1')
    done();
}
const t2 = done => {
    console.log('任务2')
    done();
}
const t3 = done => {
    console.log('任务3')
    done();
}

exports.foo = series(t1, t2, t3);
npx gulp foo

series组合的是串行的任务,也就是按顺序执行的,如果我们想要组合并行也就是同时执行的任务可以使用parallel,用法和series相同。

任务组合还是非常有用的,比如说我们编译js和css,可以通过并行的方式分别编译,因为他们是互不干扰的。

Gulp 的异步任务

我们之前说过gulp当中的任务都是异步任务,那我们在调用一个异步任务的时候是无法明确这个任务是否执行完毕的,一般都是通过回调函数来解决。

在glup中我们同样可以通过回调函数来解决异步任务,也就是我们前面用的done函数。我们可以给done函数中传入一个错误信息来阻止后面的任务执行。表示任务出错。

exports.foo = (done) => {
    console.log('foo');
    done(new Error('失败了'); // 结束标识
}

gulp也支持Promise的方式,就是在任务中返回一个Promise,可以返回resolve或者reject。

exports.foo = () => {
    console.log('foo');
    return Promise.resolve()
}

当然async和await也是可以的。任务定义为async类型的函数,就可以使用await了。

exports.foo = async () => {
    // await ....
    console.log('foo');
    return Promise.resolve()
}

如果读取文件可以使用stream的方式, 返回stream就可以了。

exports.foo = () => {
    console.log('foo');
    const readStream = fs.createReadStream('a.json');
    const writeStream = fs.createWriteStream('b.json');
    readStream.pipe(writeStream);
    return readStream; // 相当于注册end事件调用done
}

Gulp 构建过程核心原理

构建过程基本是将文件读取出来经过转换再写入到对应的位置。在以前这都是手动去做的,有了gulp以后就可以通过代码去自动化执行。我们通过node的api来模拟一下这个过程。

const fs = require('fs');
const { Transform } = require('stream');
exports.default = () => {
    // 创建文件读取流
    const read = fs.createReadStream('a.css');
    // 文件写入流
    const write = fs.createWriteStream('b.css');
    // 文件转换
    const transform = new Transform({
        transform: (chunk, encoding, callback) => {
            // chunk是读取到的内容,也就是流
            // 使用正则删除空格删除注释
            const input = chunk.toString();
            const output = input.replace(/\s+/g, '').replace(/\/\*.+?\*\//g, '');
            callback(output);
        }
    })
    // 读取到的文件写入到对应文件
    read.pipe(transform).pipe(write);
    return read;
}

gulp官方的定义就是基于流的构建系统,gulp实现的是一种管道的概念。

Gulp 文件操作

gulp有自己的读取流和写入流,相比较node来说更好用一些。

const { src, dest } = require('gulp');
const cleanCss = require('gulp-clean-css');
const rename = require('gulp-rename');

export.default = () => {
    return src('src/a.css').pipe(cleanCss()).pipe(rename({ extname: '.min.css'})).pipe(dest('dist'));
}

这样会将src/a.css文件写入到dist文件夹下。这里支持通配符。

const { src, dest } = require('gulp');
const cleanCss = require('gulp-clean-css')

export.default = () => {
    return src('src/*.css').pipe(cleanCss()).pipe(rename({ extname: '.min.css'})).pipe(dest('dist'));
}

Gulp 样式编译

const { src, dest } = require('gulp');
const sass = require('gulp-sass');

const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(sass({outputStyle: 'expanded'})).pipe(dest('dist'));
}

module.exports = {
    style
}

Gulp 脚本编译

const { src, dest, parallel, series } = require('gulp');

const del = require('del');
const sass = require('gulp-sass');
const babel = require('gulp-babel');
const swig = require('gulp-swig');
const imagemin = require('gulp-imagemin');

// 删除dist
const clean = () => {
    return del(['dist']);
}
// 样式
const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(sass({outputStyle: 'expanded'})).pipe(dest('dist'));
}
// js
const script = () => {
    return src('src/*.js', { base: 'src'}).pipe(babel({presets: ['@babel/preset-env']})).pipe(dest('dist'));
}
// html swig中可以使用传入的参数
const page = () => {
    return src('src/*.html', { base: 'src'}).pipe(swig({data: { date: new Date()})).pipe(dest('dist'));
}
// image
const image = () => {
    return src('src/images/**', { base: 'src'}).pipe(imagemin({})).pipe(dest('dist'));
}
// font
const font = () => {
    return src('src/fonts/**', { base: 'src'}).pipe(imagemin({})).pipe(dest('dist'));
}
// extra copy public file
const extra = () => {
    return src('public/**', { base: 'public'}).pipe(dest('dist'));
}

const compile = parallel(style, script, page, image, font);

const build = series(clean, parallel(compile, extra));

module.exports = {
   build
}

Gulp 自动加载插件

手动方式载入插件,require会越来越多不利于后期维护,可以通过gulp-load-plugin提供的插件自动载入使用的插件。

// 自动载入插件
const loadPlugins = require('gulp-load-plugins');
const plugins = loadPlugins();

他的用法也很简单,会自动把gulp-删除,后面的名称变为驼峰命名,比如gulp-sass可以写成plugins.sass。

const { src, dest, parallel, series } = require('gulp');

const del = require('del');

// 自动载入插件
const loadPlugins = require('gulp-load-plugins');
const plugins = loadPlugins();

// 删除dist
const clean = () => {
    return del(['dist']);
}
// 样式
const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(plugins.sass({outputStyle: 'expanded'})).pipe(dest('dist'));
}
// js
const script = () => {
    return src('src/*.js', { base: 'src'}).pipe(plugins.babel({presets: ['@babel/preset-env']})).pipe(dest('dist'));
}
// html swig中可以使用传入的参数
const page = () => {
    return src('src/*.html', { base: 'src'}).pipe(plugins.swig({data: { date: new Date()})).pipe(dest('dist'));
}
// image
const image = () => {
    return src('src/images/**', { base: 'src'}).pipe(plugins.imagemin({})).pipe(dest('dist'));
}
// font
const font = () => {
    return src('src/fonts/**', { base: 'src'}).pipe(plugins.imagemin({})).pipe(dest('dist'));
}
// extra copy public file
const extra = () => {
    return src('public/**', { base: 'public'}).pipe(dest('dist'));
}

const compile = parallel(style, script, page, image, font);

const build = series(clean, parallel(compile, extra));

module.exports = {
   build
}

Gulp 开发服务器

开发服务器可以配合构建任务在代码修改过后实现自动更新,提高开发阶段的效率。

他依赖的是一个browser-sync的模块,需要提前安装。他支持代码热更新的功能。他并不是glup的插件,我们只是引用并且管理它而已。

const browserSync = require('browser-sync');
const bs = browserSync.create();

const serve = () => {
    bs.init({ // 初始化
        port: 8080,
        open: false,
        files: 'dist/**',
        server: {
            baseDir: 'dist',
            routes: {
                '/node_modules': 'node_modules',
            }
        }
    });
}

module.exports = {
    serve
}

routes是将请求转发到对应位置,优先于baseDir,如果不存在才会走baseDir。

init可以配置很多参数,port, notify, open, files等。files是监听的通配符,指定哪些文件更细就会发生重新渲染。

Gulp 监视变化构建优化

要实现src目录下的文件更改重新编译,需要借助glup提供的一个叫做watch的功能。他会监视一个路径的通配符,然后根据文件变化执行一个任务。我们只需要监视就是有构建任务的路径就可以。

const { src, dest, parallel, series, watch } = require('gulp');

// 自动载入插件
const loadPlugins = require('gulp-load-plugins');
const plugins = loadPlugins();

// 样式
const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(plugins.sass({outputStyle: 'expanded'})).pipe(dest('dist'));
}

const serve = () => {
    watch('src/*.css', style);
}

通过watch我们可以出发源代码修改过后自动编译到dist,dist变更在重新渲染浏览器。

一般情况下我们再开发阶段对于图片压缩文件压缩基本不需要做,他们只需要在上线前做一次就可以了,所以images,public这类的文件我们直接指定路径就可以了,不要打包。

const { src, dest, parallel, series, watch } = require('gulp');

// 自动载入插件
const loadPlugins = require('gulp-load-plugins');
const plugins = loadPlugins();

const browserSync = require('browser-sync');
const bs = browserSync.create();

// 样式
const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(plugins.sass({outputStyle: 'expanded'})).pipe(dest('dist'));
}

const serve = () => {
    watch('src/*.css', style); // 构建

    watch('src/images/**', 'public/**', bs.reload); // 变化更新

    bs.init({ // 初始化
        port: 8080,
        open: false,
        files: 'dist/**',
        server: {
            baseDir: ['dist', 'src', 'public'],
            routes: {
                '/node_modules': 'node_modules',
            }
        }
    });
}

module.exports = {
    serve
}

我们需要监听文件更新的时候刷新浏览器,毕竟这里是减少了构建次数,不是刷新次数。

Gulp useref文件引用

之前我们基本已经完成了glup的配置任务,但是还存在一些小问题,假设我们html中引入了node_modules文件夹中的资源,开发环境是没有问题的,但是build的正式环境是有问题的。我们在开发环境配置了routes来映射node_moudles,正式环境也需要一些配置。

这里使用useref,他会自动处理html中的构建注释也就是,他可以把我们下面注释中的文件打包到指定的位置,如果多个标识了同一个位置,就会合并。

<!-- build:css dist/a.css -->
<link ref="stylesheet" href="/node_modules/bootstrap/dist/bootstrap.css" />
<!-- endbuild -->

这种方式会更加简单,压缩合并都可以完成。这里使用gulp-useref插件,他监听的是打包过后的文件。


const useref = () => {
    return src('dist/*.html', {base: 'dist'}).pipe(plugins.useref({searchPath: ['dist', '.']})).pipe(dest('dist'));
}

module.exports = {
    useref
}

这样他会将构建注释去掉,将构建注释中的内容进行合并,替换html中的引用。

Gulp 文件压缩

我们需要压缩的文件有3种,html,js,css。他们都是useref创建出来的。所以我们的useref管道中会有三种文件类型,需要分别去做不同的压缩工作。

gulp-uglify是压缩js的,gulp-clean-css是压缩css的,gulp-htmlnin是压缩html的。

const useref = () => {
    return src('dist/*.html', {base: 'dist'})
    .pipe(plugins.useref({searchPath: ['dist', '.']}))
    .pipe(plugins.if(/\.js$/, plugins.uglify()))
    .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
    .pipe(plugins.if(/\.html$/, plugins.htmlmin({
        collapseWhitespace: true,
        minifyCss: true,
        minifyJs: true
    })))
    .pipe(dest('release'));
}

module.exports = {
    useref
}

Gulp 重新规划构建过程

前面我们打包的时候通过useref进行处理,src打包到build目录之后useref将build转换到release目录中。在这里build就是一个中间的媒介也就是一个临时目录。我们这里整理下,也就是替换文件夹名称。将build改成temp,将release改成build。

const { src, dest, parallel, series, watch } = require('gulp');

// 自动载入插件
const loadPlugins = require('gulp-load-plugins');
const plugins = loadPlugins();

const browserSync = require('browser-sync');
const bs = browserSync.create();

// 样式
const style = () => {
    return src('src/*.css', { base: 'src'}).pipe(plugins.sass({outputStyle: 'expanded'})).pipe(dest('temp'));
}

const serve = () => {
    watch('src/*.css', style); // 构建

    watch('src/images/**', 'public/**', bs.reload); // 变化更新

    bs.init({ // 初始化
        port: 8080,
        open: false,
        files: 'temp/**',
        server: {
            baseDir: ['temp', 'src', 'public'],
            routes: {
                '/node_modules': 'node_modules',
            }
        }
    });
}
const useref = () => {
    return src('temp/*.html', {base: 'temp'})
    .pipe(plugins.useref({searchPath: ['temp', '.']}))
    .pipe(plugins.if(/\.js$/, plugins.uglify()))
    .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
    .pipe(plugins.if(/\.html$/, plugins.htmlmin({
        collapseWhitespace: true,
        minifyCss: true,
        minifyJs: true
    })))
    .pipe(dest('build'));
}

module.exports = {
    useref,
    serve
}

封装工作流

通过gulpfile + gulp 封装一个构建工作流,gulp是一个构建工具,他只是一个工具不做任何任务,所以我们需要依赖gulpfile文件来定义我们的构建任务。

我们可以创建一个模块,然后将这个模块发布到npm仓库中,之后在我们的项目中引用就可以了。其实也就是开发一款脚手架工具。

fis

fis是百度前端团队推出的一款构建系统,只是现在用的人比较少。官方也很久没有更新fis了。

相比于gulp和grunt,fis的核心特点是高度继承,他将前端日常开发的任务和构建任务都继承到了内部,这样开发者就可以用过简单的配置去配置需要的功能。

fis中提供了一款webserver,可以方便的调试构建结果,这一系列的在glup和grunt中都需要我们手动配置。

使用fis首先需要安装他。安装之后我们就可以使用他了。

npm install fis3

fis3 release

release命令会将我们构建任务临时构建到一个文件夹中,一般在用户目录下,如果需要指定目录可以通过-d, 会构建到项目根目录的output中。

fis3 release -d output

fis首要解决的是资源定位问题,开发的过程中不需要关心正式环境的资源定位问题,fis构建的时候会自动帮我们找到。

fis也支持config文件来配置,fis-conf.js。

fs.match('*.{js,css,ping}', {
    release: '/assets/$0'
})

除了资源定位还可以编译和压缩等。fis的配置文件相当于声明式的配置方式,通过match方法指定一个选择器,选择构建过程中的文件,后面的选项就是对于选中文件的配置。

比如我们对sass文件进行处理。

fis.match('**/*.scss', {
    parser: fis.plugin('node-sass'),
    optimizer: fis.plugin('clean-css'),
    rExt: '.css' // 修改扩展名
})

针对js的转换也类似

fis.match('**/*.js', {
    optimizer: fis.plugin('uglify-js'),
    parser: fis.plugin('babel-6.x')
})

fis.baidu.com/fis3/index.…