自动化构建

581 阅读40分钟

一、自动化构建介绍

1.自动化构建简介

说明

自动化构建是前端工程化当中非常重要的组成部分;

解释

自动化 => 通过机器去代替手工完成一些工作;
构建 => 转换, 就是把一个东西转换成另外的一些东西;
开发行业中的自动化构建: 把我们开发阶段的写出来的源代码自动化的去转换为生产环境中可以运行的代码或者是程序;
我们一般会把这个转换过程称为自动化构建工作流, 它的作用就是让我们尽可能去脱离运行环境兼容带来的问题, 去在阶段使用一些提高效率的语法、规范和标准;
最典型的引用场景就是: 在开发网页应用时可以使用ECMAScript 最新标准去提高编码效率和质量;利用 Sass 去增强 css 的可编程性; 在借助模板引擎去抽象页面当中重复的 html; 然而这些用法在浏览器中是没有办法直接被支持的, 在这种情况下, 自动化构建工具就提供了很大的作用, 我们通过自动化构建的方式将不被支持的代码特性转化为能被支持的代码, 这样就可以提高在开发过程的效率;

2.自动化构建的初体验

需求

通过 sass 去增强 css 的编程性

步骤:

在开发的工程中添加构建的环节, 这样我们就可以在开发工程中通过 sass 书写样式, 再通过工具去将 sass 代码构建为 css;

  1. 在根目录下新建 sass 文件(main.scss), 编写该 sass 文件
    $body-bg: #f8f9fb;
    $body-color: #333;     
    body{
       margin: 0 auto; padding: 20px; color: $body-color;
       max-width: 800px; background-color: $body-bg;
    }
    
  2. 因为Sass并不能在浏览器环境中直接去使用, 在开发阶段通过工具转化为 css: 这里使用的 Sass 官方提供的 sass 模块;
    yarn add sass --dev;
    
  3. 安装完成后, 在node_modules 中会出现 .bin 的目录, 在 里面有个 sass 的命令文件, 可以通过命令行找到这个路径;
    .\node_modules\.bin\sass
    
  4. 执行上条命令会打印出帮助信息: 查看帮助信息, 找到用法, 执行转换命令;
    .\node_modules\.bin\sass scss/main.scss css/style.css
    
  5. 这样操作, 会重复的输入复杂的命令, 而且在别人接手你的项目, 也不知道该如何去运行构建任务
    => NPM Scripts 就是来解决这个问题的: 你可以在NPM Scripts 中去定义一些与项目开发姑婆成有关的脚本命令, 这样就可以将这些命令跟着项目一起维护, 便于我们在后期开发过程中的使用;
    => 这里最好的方式就是通过 NPM Scripts 包装我们的构建命令: 在 package.json 中添加 scripts 字段, 值是一个对象{ 名称: 构建的命令 }
    => 值得注意的是: scripts 可以自动去发现 node_modules 里面的命令, 这里就可以不用写完整的路径, 直接使用命令的名称就行;
    "scripts": {
      "build": "sass main.css css/style.css"
    }
    
  6. 完成之后: 通过 npm 或 yarn 去执行构建命令;
    //npm 
    npm run build
    //yarn 
    yarn build
    
  • NPM Scripts:
    • NPM Scripts: 是实现自动化构建工作流最简单的方式;
    • 如何通过 NPM Scripts
      1. 为项目安装 browser-sync 模块: 用于启动一个测试服务器去运行我们的项目;

        yarn add browser-sync --dev
        
      2. 在 package.json 的 scripts 中添加 serve 命令:

        "serve": "browser-sync ."
        
      3. 运行 serve 命令: 将项目运行起来

        yarn serve
        
      4. 如果在我们运行服务器之前, 我们并没有去生成样式文件, 那我们 browser-sync 运行的时候, 页面都没有样式文件:
        => 我们要在启动 serve 命令之前让 build 任务去工作: 借助NPM Scripts 的钩子机制
        => preserve:会在 serve 命令之前去执行

        "preserve": "yarn build"
        
      5. 光有这些还不够: 我们可以给 sass 命令添加 --watch 参数: sass 在工作时就会监听文件的变化, 一旦 sass 文件发生变化, 就会自动被编译;

        "build": "sass main.scss css/style.css --watch"
        
      6. 重新运行 serve 命令, 发现 sass 命令阻塞去等待文件的变化, 这样就导致后面的 browser-sync 不能工作
        => 这种情况下, 需要同时去执行多个任务: 通过 npm-run-all 模块去实现:

        yarn add npm-run-all --dev
        

        => 安装完成后, 在 scripts 中 去掉 preserve 命令, 添加 start 命令: 在start 中通过 run-p 的命令同时去执行build 和 serve 命令

        "start": "run-p build serve"
        
      7. 在 serve 命令之后添加: --files \"css/*.csss"\": browser-sync 启动后, 监听后面跟的文件的变化, 如果发生变化, 就自动同步到浏览器, 从而更新浏览器界面
        =>这样避免了修改文件后手动刷新浏览器

        "serve": "browser-sync . --files \"css/*.css"\""
        
      8. 运行 start 命令: yarn start

3.常见的自动化构建工具

说明:

NPM Scripts 虽然可以解决一部分的自动构建任务, 但对于复杂的构建过程, NPM Scripts 就显得非常吃力, 这时就需要更为专业的构建工具, 目前市面上使用最多的开发者工具:
=> Grunt: 最早的前端构建系统, 插件生态非常完善, 因为工作过程是基于临时文件去实现的, 所有构构建速度相对较慢, 例如使用它去完成项目中sass 文件的构建, 一般先对 sass 文件做编译操作,再去自动添加一些私有属性的前缀, 最后再去压缩代码, 这样一个过程当中, Grunt 每一步都会有磁盘读写操作, 比如在这个过程中, 将 sass 编辑完成之后, Grunt 就会将结果写入一个临时的文件, 然后下一个插件再去读取这个临时文件, 进行下一步, 这样, 处理的环节越多, 文件的读写的次数就越多, 对于超大型的项目, 项目文件会非常多, 构建速度就会特别的慢;
=>Gulp: 很好的解决了 Grunt 中构建速度非常慢的问题, 它是基于内存去实现的, 他对文件的处理都是在内存中去完成的, 相对于磁盘读写, 速度就快的很多; 另外他支持同时去执行多个任务, 效率大大提高, 而且执行方式相对于 Grunt 更加直观易懂, 插件生态也非常完善;
=>FIS: 是有百度的前端团队推出的一款构建系统, 最早只是在团队的内部使用, 后来开源之后, 在国内迅速流行, 相对于前两个构建系统, 这种微内核的特点, FIS更像一种捆绑套餐, 它把项目中一些典型的需求竟可能的集成在内部, 例如在 FIS 就可以很轻松的去处理资源加载、模块化开发、代码部署甚至是性能优化, 正是这种大而全, 所以在国内很对项目都使用 FIS
这些工具都可以解决重复而无聊的工作, 实现自动化, 用法上大体相同: 都是先通过简单的代码去组织一些插件的使用, 然后使用这些工具执行各种各样重复的工作;
=> 整体来说: 如果你是初学者, 可能 FIS 更适合你, 如果要求灵活多变, Gulp、Grunt 应该是你更好的选择;新手是需要规则,老手渴望自由;

二、Grunt 构建工具

2-1.Grunt 的基本使用

  1. 首先初始化 package.json
    yarn init --yes
    
  2. 有了 package.json 之后, 添加 Grunt 模块:
    yarn add grunt
    
  3. 安装完成后, 在根目录下添加 gruntfile.js 的文件, 做为 Grunt 的入口文件, 用于去定义 Grunt 自动自行的任务;
  4. 编写 gruntfile.js
    // Grunt 的入口文件
    // 用于定义 Grunt 自动执行的任务
    // 需要导出一个函数, 这个函数接收一个 grunt 的形式参数, grunt 是一个对象, 对象里面是 grunt提供的一些 API, 借助这些API, 可以快速创建一些构建任务;
    module.exports = grunt => {
      /* grunt.registerTask() 方法: 注册任务
      	第一个参数: 指定任务的名称
      	第二个参数: 指定任务函数, 函数里面是任务执行时, 所执行的代码
      */
      grunt.registerTask('foo', () => {
        console.log('hello grunt~');
      });
      //可以注册多个任务
      // registerTask() 方法中的第二个参数如果是一个字符串, 该字符串会作为注册任务的默认描述, 这个描述会出现在 Grunt 的帮助信息中, 任务函数参数就往后移;
      //可以通过命令行 yarn grunt --hele, 得到帮助信息: 帮助信息中的 Available tasks, 会有刚刚自定义的任务描述
      grunt.registerTask('bar', 'bar任务描述', () => {
        console.log('othter task~');
      });
      //也可以通过 yarn grunt bar 运行 bar 任务
      //在注册任务时: 如果任务的名称为 default, 这个任务将会成为 Grunt 的默认任务, 在运行这个任务时不需要写任务的名称, Grunt 会自动的调用该任务名称: yarn grunt
      /* grunt.registerTask('default', () => {
      	console.log('default task~');
      });*/
      //一般 default 用作去映射其他的任务: registerTask()方法的第二个参数传入一个数组,数组的成员就是任务的名称, 这个时候执行 default 就会依次执行数组里面的方法, 这样就把两个方法串联到一起
      grunt.registerTask('default', ['foo', 'bar']);
      /* Grunt 中对异步任务的支持: 
      	Grunt的代码默认支持同步模式, 需要异步操作, 必须使用 this 的 async() 方法得到一个回调函数, 在异步操作完成之后, 调用该回调函数, 标识异步任务的完成; 值得注意的是: 在 registerTask() 方法的执行函数中, 需要使用 this, 执行函数就不能使用箭头函数
      */
      grunt.registerTask('async-task', function () {
        //通过 this.async() 方法得到回调函数 done, 当 Grunt 执行到 this.async 时, 就知道该任务为异步任务, 他会等待 done 的执行, 直到 done 被执行, Grunt才会结束这个任务的执行;
        const done = this.async();
        //setTimeout 模拟异步函数
       	setTimeout(() => {
          console.log('async task working~');
          //在执行函数完成之后, 需要调用回调函数 done, 标识异步任务完成
          done();
        }, 1000);
      });
    };
    
  5. 运行定义的方法名称: 例如 foo
    yarn grunt foo
    

2-2.Grunt 标记任务失败

2-2-1.说明

​ 如果在构建任务的逻辑代码中发生错误, 例如需要的文件找不到, 此时可以将该任务标记为失败任务;具体的实现方式可以在函数体中 return false来实现;

2-2-2.实现

编写gruntfile.js

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

命令行执行 bad 任务: yarn grunt bad; //显示执行失败 => Waining: Task “bad” failed. Use --force to continue;

2-2-3注意
2-2-3-1.如果失败任务是在任务列表中, 失败任务会导致其后的所有任务不在被执行;
//gruntfile.js
modules.exports = grunt => {
  grunt.registerTask('foo', () => {
    console.log('foo task~');
  });
  grunt.registerTask('bad', () => {
    console.log('bad task~');
    return false;
  });
  grunt.registerTask('bar', () => {
    console.log('bar task~');
  });
  grunt.registerTask('default', ['foo', 'bad', 'bar']);
}

当去运行 default 任务时, 当 bad 任务运行失败后, bar 将不会被运行;

在命令上加入 -- force 的参数, 会采用一种强制方式去执行所有的任务, 失败任务后续的任务也会被执行;

yarn grunt --force
2-2-3-2.异步任务中无法通过 return false 标记任务失败, 需要给异步任务的回调函数指定 false 的实参标记该异步任务的失败;
module.exports = grunt => {
  grunt.registerTask('async-bad', function () {
    const done = this.async();
    setTimeout(() => {
      console.log('bad async~');
      done(false);
    });
  });
};

运行 yarn grunt async-bad //显示执行失败 => Waining: Task “bad” failed. Use --force to continue;

2-3.Grunt 的配置方法

Grunt 除了 registerTask() 方法之外, Grunt 还提供了用于添加配置选项的 API => initconfig, 例如用 Grunt 压缩文件时, 就可以通过该 API 配置压缩的文件路径;

//gruntfile.js
module.exports = grunt => {
  //grunt.initConfig() 方法: 接受对象形式的参数, 对象的属性名(键名)一般和任务名称保持一致, 属性的值可以是任意类型的数据;
  grunt.initConfig({
    foo: 'bar',
    expert: {
      expertName: 'use'
    }
  });
  //在任务中使用该配置属性: 在任务的执行函数中可以通过 grunt.config() 方法获取配置属性;
  //grunt.config() 方法: 接受一个字符串参数, 该字符串参数就是 initConfig 中指定的属性名; 
  grunt.registerTask('foo', () => {
    console.log(grunt.config('foo'));
  });
  //如果 initConfig() 方法中的属性值是一个对象, grunt.config() 方法还支持一种高级的用法, 一般我们不使用这种方式, grunt.config() 方法可以拿到对象的值, 在对象上通过.的方式去使用其中的属性;
  grunt.registerTask('expert', () => {
    console.log(grunt.config('expert.expertName'));
  });
};

2-4.Grunt 多目标任务

除了普通的任务形式外, Grunt 还支持多目标模式的任务, 可以理解为子任务, 多目标模式任务通过各种任务时非常有用;

//gruntfile.js
module.export = grunt => {
  //多目标模式的任务, 可以让任务根据配置形成多个子任务, 通过 grunt.registerMultiTask() 方法来定义
  //设置多目标模式任务时需要为多目标任务配置不同的目标,配置的属性值必须是对象;
  grunt.initConfig({
    build: {
      //注意: 在配置对象中的每一个属性的键都会成为一个目标, 除了 options 以外, 在 options 中配置的信息会作为任务的配置选项存在, 在任务函数中可以通过 this.options 拿到该任务的配置项;
      options: {
        foo: bar
      },
      //对象中每一个属性的名字就是目标名称, 比如 build css
      css: {
        //处理任务可以配置 options 外, 目标也可以配置options
        options: {
          foo: 'baz'
        }
      },
      //build Js
      js: '2'
    }
  });
  //grunt.registerMultiTask() 方法: 接受两个参数, 第一个参数为任务的名字, 第二个参数时为一个函数, 函数里面时任务执行时所执行的内容;
  grunt.registerMultiTask('build', function () {
    console.log('build task~');
    //通过 this.options() 可以得到该任务的配置项;
    console.log(this.options());
    //在任务函数中可以通过 this.target 拿到当前执行的目标名称, 可以通过 this.data 拿到 目标任务所配置的数据;
    console.log(`target: ${this.target}, data: ${this.data}`);
  });
};

运行多目标任务: yarn grunt build; //会运行两个子任务, 实际上是运行了两个目标任务, 也就是 build 的 css 目标和 build 的 Js 目标

  • 要运行指定的目标: 通过 build: 目标名称来实现 => yarn grunt build:css

2-5.Grunt 插件的使用

插件机制是 Grunt 的核心, 存在原因: 很多构建任务都是通用的 => 社区中存在很多插件, 插件内部都封装了一些通用的构建任务;
=> 一般的构建过程都是由通用的构建任务组成的;
=> 最大情况下 Grunt 的插件命名规范都是 grunt-contrib-taskName(任务名称)

2-5-1.使用插件的步骤: 以 grunt-contrib-clean => 用来自动清除在项目开发过程中产生的临时文件
2-5-1-1.先通过 NPM 安装这个插件
yarn add grunt-contrib-clean
2-5-1-2.到 gruntfile.js 中载入插件, 根据插件的文档去配置相应的配置选项

在大型项目中, 引入的插件有很多, 用 grunt.loadNpmTasks() 方法载入插件的操作会很多, 代码会很多, 这种情况, 可以使用社区提供的 load-grunt-tasks 模块: 有了这个模块以后, 就不用去重复载入插件了, 该插件可以自动去读取加载所有的 grunt 插件;
=> 使用方法如下:

  1. 通过命令行安装 load-grunt-tasks 模块:

    yarn add load-grunt-tasks --dev
    
  2. 在 gruntfile.js 中导入 load-grunt-tasks 模块:

    //gruntfile.js
    const loadGruntTasks = require('loadGruntTasks');
    
  3. 通过 loadGruntTasks() 方法载入所有插件:

    loadGruntTasks(grunt);//自动加载所有安装的 grunt 插件中的任务, 这里需要传入 grunt 实参
    
//gruntfile.js
module.exports = grunt => {
  //grunt-contrib-clean 插件提供的 clean 任务为多目标任务,需要配置目标
  grunt.initConfig({
    clean: {
      //除了指定文件外, 还可以通过通配符的方式指定一类的文件
      temp: 'temp/app.js'
    }
  });
  //通过 grunt.loadNpmTasks() 方法载入插件所提供的任务
  grunt.loadNpmTasks('grunt-contrib-clean');
}

2-6.Grunt 的常用插件及总结

2-6-1.grunt-sass

grunt 的官方也提供的 sass 模块, 该模块需要本机安装 sass 环境, 使用起来非常不方便; grunt-sass 是一个 NPM 模块, 在该模块的内部会通过 NPM 的形式去依赖 sass, 使用起来对本地的机器没有环境要求;
=> grunt-sass:

2-6-1-1.需要有 sass 模块支持, 同时安装两个模块到开发依赖中;
yarn add grunt-sass sass --dev
2-6-1-2.在 gruntfile.js 中 通过 loadNpmTasks() 方法引入 grunt-sass 模块所提供里任务, 因为 grunt-sass 为多目标任务, 所以还需为 grunt-sass 提供配置项;
//gruntfile.js
const sass = require('sass');
module.exports = grunt => {
  grunt.initConfig({
    sass: {
      options: {
        sourceMap: true,
        implementation: sass
      },
      main: {//需要去指定 sass 的输入文件以及最终输出文件的路径, 具体的指定的方式通过files 对象指定
        files: {//对象里面的键是输出文件的路径, 值为输入文件的路径
          'dist/css/main.css': 'src/scss/mian.scss'
        }
      }
    }
  });
  grunt.loadNpmTasks('grunt-sass');
}

grunt-sass 还有一些配置, 可以去官方文档上查阅;

2-6-1-3.命令行运行 sass 任务:
yarn grunt sass
2-6-2.grunt-babel

编译 ES6+ 的语法, grunt-babel 也需要依赖 babel 的核心模块: @bebel/core 以及 babel 的预设: @babel/preset-env

2-6-2-1.安装 grunt-babel、bebel 的核心模块、babel 的预设:
yarn add grunt-babel @babel/core @babel/preset-env --dev
2-6-2-2.通过 grunt.loadNpmTasks() 方法载入插件(在 gruntfile.js 中载入了两个插件, 这里使用 load-grunt-tasks 模块):
//gruntfile.js
const loadGruntTasks = require('load-grunt-tasks');
module.exports = grunt => {
  loadGruntTasks(grunt);
}
2-6-2-3.为 babel 提供配置项
//gruntfile.js
module.exports = grunt => {
  grunt.initConfig({
    babel: {
      options: {
        sourceMap: true,
        //这里需要配置 babel 在转换的时候的需要转换的特性: 默认配置为最新的特性
        presets: ['@babel/preset-env]
      },
      main: {
        files: {
          'dist/js/app.js': 'src/js/app.js'
        }
      }
    }
  });
}
2-6-2-4.通过命令行执行 babel 任务:
yarn grunt babel
2-6-3.grunt-contrib-watch

在项目中经常遇到这样的需求: 当文件修改完以后, 文件会被自动编译; 这种情况下, 需要使用插件 grunt-contrib-watch

2-6-3-1.通过命令行安装 grunt-contrib-watch 插件
yarn add grunt-contrib-watch --dev
2-6-3-2. loadGruntTasks() 方法也会自动将 grunt-contrib-watch 插件载入进来
2-6-3-3. 为 grunt-contrib-watch 添加配置项
//gruntfile.js
const loadGruntTasks = require('load-grunt-tasks');
module.exports = grunt => {
  grunt.initConfig({
    watch: {
      js: {
        files: ['src/js/app.js'],//也可以使用通配符通配一类文件
      	tasks: ['babel']//当文件发生变化时你需要执行什么样的任务
      },
      css: {
        files: ['src/scss/*.scss],
        tasks: ['sass']
      }
    }
  });
  loadGruntTasks(grunt);
};
2-6-3-4.watch 任务启动之后, 不会先去编译再去执行, 这样可能会照成项目错误, 一般会给 watch 映射
//gruntfile.js
module.exports = grunt => {
  //.............这里省略很多 sass 和 babel 的代码
  grunt.registerTask('default', ['sass', 'babel', 'watch']);
}
2-6-3-5.通过命令行运行 watch 命令
yarn grunt watch

三、Gulp 构建工具

作为当下最流行的前端构建系统, 其核心特点就是高效、易用;因为使用 gulp 的过程非常简单,大体的过程就是:在项目中安装 Gulp 的开发依赖,在项目的更目录中添加 gulpfile.js 的文件,用于去编写 Gulp 自动执行的构建任务, 完成之后我们就可以在命令行使用 Gulp 模块所提供的 CLI 运行构建任务;

3.1、Gulp 的基本使用

3.1.1、新建一个文件夹, 初始化 package.json
mkdir gulp-basic-use
cd gulp-basic-use
yarn init --yes
3.1.2、通过命令行开发依赖安装 Gulp 模块,在安装 Gulp 的时候,会自动同时安装 gulp-cli 的模块

安装完成后,在 node_module 下面会有 gulp 的命令, 有了这个命令以后,后续可以通过 gulp 运行构建任务

yarn add gulp --dev
3.1.3、在更目录下创建 gulpfile.js 文件,作为 gulp 的入口文件
3.1.4、编写 gulpfile.js

gulpfile.js 是运行在 nodeJs 环境中,我们可以在 gulpfile.js 中使用 commonJs 的规范;
=> 文件中定义构建任务的方式:通过导出函数成员的方式去定义

//gulp 的入口文件
// 定义构建任务的方式: 通过带出函数成员的方式去定义
exports.foo = () => {
  console.log('foo task working');
}
//这个时候 gulpfile.js 中就定义了foo 的任务
3.1.5、通过命令行运行 foo 命令

这个时候命令行会爆出错误:foo 任务没有执行完成,是否忘记该任务的结束; 原因是在最新的 gulp 中取消了同步代码的模式, 约定每个任务都必须是异步任务, 当任务结束后, 需要调用回调函数或者其他的方式去标记该任务已经完成;

// gulpfile.js
exports.foo = done => {
  console.log('foo task working~');
  done();
}
yarn gulp foo
3.1.6、注意事项
3.1.6.1、如果任务名称为 default, 该任务会作为 gulp 的默认任务出现;
// gulpfile.js
export.default = done => {
  console.log('default task working~');
  done()
}

调用 default 任务时不需要写任务名:

yarn gulp
3.1.6.2、在 Gulp 4.0以前, 去注册 Gulp 的任务是需要通过 Gulp 模块里面的 task() 方法来实现;
//gulpfile.js Gulp 4.0 以前
const gulp = require('gulp');
gulp.task('bar', done => {
  console.log('bar working~');
  done();
});

3.2、Gulp 创建组合任务

除了创建普通任务以外, Gulp 还提供了 series 和 parallel 这两个用来创建组合任务的 API, 有了这两个 API 之后, 就很容易创建并行任务和串行任务;

3.2.1、series() 方法: 创建串行任务

series(): 接收任意个数的参数, 每个参数都是一个任务, series 会按照参数的命令依次执行这些任务;

//gulpfile.js
const { series } = require('gulp');
const task1 = done => {
  setTimeout(() => {
    console.log('task1 working~');
    done();
  }, 1000);
}
const task2 = done => {
  setTimeout(() => {
    console.log('task2 working~');
    done();
  }, 1000);
};
const task3 = done => {
  setTimeout(()=> {
    console.log('task3 working~');
    done();
  }, 1000);
}
exports.foo = series(task1, task2, task3);

通过命令行执行 foo 任务:

yarn gulp foo //会依次执行 task1, task2, task3
3.2.2、parallet() 方法: 创建并行任务
const { parallel } = require('gulp');
const task1 = done => {
  setTimeout(() => {
    console.log('task1 working~');
    done();
  }, 1000);
}
const task2 = done => {
  setTimeout(() => {
    console.log('task2 working~');
    done();
  }, 1000);
};
const task3 = done => {
  setTimeout(()=> {
    console.log('task3 working~');
    done();
  }, 1000);
}
exports.bar = parallel(task1, task2, task3);

通过命令行执行 bar 任务

yarn gulp bar //会同时执行 task1, task2, task3 任务
3.2.3、总结

创建并行任务和创建串行任务在实际构建工作流是非常有用, 例如在编译 css 和编辑 JS 的任务时, 两个任务互不干扰, 可以使用 parallel 创建并行任务; 在部署任务时, 需要先执行编译任务, 再执行部署任务, 可以使用 series 创建串行任务;

3.3、Gulp 的异步任务

Gulp 中的任务都是异步任务, 也就是 JS 中的异步函数, 我们知道, 去调用异步函数的时候, 是没有办法直接去明确这个调用是否完成的, 都是在函数内部通过回调或者事件的方式去通知外部这个函数执行完成, 那我们在 Gulp 中的异步任务也存在如果通知 Gulp 该任务完成情况的问题;
=> 针对于这个问题, Gulp 有很多解决方法, 接下来了解最常用的四种方式:

3.3.1、通过回调的方式去解决

在任务函数中去接受一个回调函数形参, 任务完成以后调用一下该回调函数, 从而通知 Gulp 该任务执行完成了;

//gulpfile.js
exports.foo = done => {
  console.log('foo task working~');
  done();
}

这个回调函数和 node 里面的回调函数是同样的标准, 都是一种错误优先的回调函数, 也就是说当我们想在执行过程中去报出一个错误去阻止剩下任务完成的时候, 可以给回调函数的第一个参数指定一个错误对象就可以了;

//gulpfile.js
exports.foo_err = done => {
  console.log('foo err task working~');
  done(new Error('task failed!'));
}
//命令行执行时会报出 task failed 错误, 而且如果是多个任务同时执行的话, 后续的任务也就不会工作了;
3.3.2、通过 ES6 的 Promise 来解决

Promise 是一个相对于回调函数比较好的方案, 因为 Promise 避免了回调嵌套过深的问题;
=> 在 Gulp 中也支持Promise 的方式, 具体的使用: 在任务的执行函数中 return 一个 Promise 对象
=>这里返回成功的 Promise(return Promise.resolve()), 一旦返回的 Promise 对象 resolve 了, 也就意味着该任务结束了;
=> 需要注意的是: 返回的 Promise.resolve() 不需要返回任务的参数值, 因为 Gulp 当中会忽略掉这个值;

//gulpfile.js
exports.barPromise = () => {
  console.log('bar Promise task working ~');
  return Promise.resolve();
}

使用 Promise 也会有任务失败的情况, 一旦返回的是 Promise.reject(), Gulp 就会认为这是一个失败的任务, 同样结束后面任务的执行;

exports.BarPromnise_error = () => {
  console.log('bar Promise error task working ~');
  return Promise.reject(new Error('task failed~'));
}
3.3.3、通过 ES7 中的 Async/Await 语法糖来解决

Async/Await 实际上是 Promise 的语法糖, 他可以让使用 Promise 的代码更加容易理解, 但是需要 node 环境在 8 以上的版本
=> 具体的用法是: 将任务函数定义为异步函数, 在函数中去 Await 一个异步的任务(其实 Await 的就是一个 Promise 对象) ;

//gulpfile.js
const timeoutTask = time => {//将任务函数定义为异步函数
  return new Promise(resolve => {
    setTimeout(resolve, time);
  });
}
//有了异步函数之后, 就可以在 Async 中  Await 这个异步函数, 这样的话, 在执行 async() 函数时就会等待 timeoutTask 的 resolve, 一旦 resolve 完成之后才会执行后续代码
exports.asyncTask = async() => {
  await timeoutTask(1000);
  console.log('async task working ~');
} 
3.3.4、通过 stream 的方式

构建系统大都是在处理文件, 所以这种方式也是最常用到的方式;
=> 具体的话使用方式: 在任务函数中需要返回一个 stream 对象

//fulpfile.js
const fs = require('fs');
exports.stream = () => {
  //fs.createReadStream() 方法: 创建一个读取文件的文件流
  const readStream = fs.createReadStream('package.json');
  //这里的 readStream 就是一个文件流对象;
  //fs.createWriteStream() 方法: 创建一个写入文件的文件流
  const writeStream = fs.createWriteStream('temp.txt');
  readStream.pipe(writeStream);//将 readStream 通过 pipe 的方式导到 writeStream 中(文件复制的作用)
  return readStream;
};

在命令行中运行 stream 任务, 可以正常开始, 正常结束的, 结束的时机就是 readStream end 的时候,
=> stream 对象中都有一个事件: end 事件, 一旦读出文档流读取完成后, 就会触发 end 事件, 从而 Gulp 就直到该事件已经完成;

可以通过以下代码去模拟 Gulp 所执行的事情:
=> Gulp 中接收到 readStream 后, 为 readStream 注册了 end 事件, 在 end 事件中结束了任务的执行;

//gulpfile.js
const fs = require('fs');
exports.stream = done => {
  const readStream = fs.createReadStream('package.json');
  const writeStream = fs.createWriteStream('temp.txt');
  readStream.pipe(writeStream);
  readStream.on('end', () => {
    done();
  });
}

3.4、Gulp 构建过程核心工作原理

构建过程大多数情况下都是讲文件读出来, 然后进行一些转换, 最后写入到另外一个位置, 在没有构建系统的情况下, 人工也是按照这样的过程去做的: 例如压缩 css 文件, 人工需要将开发代码复制到css 压缩工具里面,进行压缩, 压缩完后存储到另外的位置, 作为生产代码;
=> 通过构建任务解决也是类似的:

转换工具工作流程.png

//gulpfile.js
const fs = require('fs');
const { Transform } = rquire('stream');
exports.default = () => {
  //文件读取流
  const readStream = fs.createReadStream('normalize.css');
  //文件转换流
  //Transform 类: 接收一个核心转换过程对象, 对象里面 transform 为核心转换过程, 需要三个参数: chunk => 得到文件读取流中读取的内容(Buffer)这里的内容是字节数组, 听过 toString() 方法转化为字符串; 转换后的数据通过 callback 返回出去;
  const transformStream = new Transform({
    transform: (chunk, encoding, callback) => {
      //核心转化过程实现
      // chunk => 读取流中读取到的内容(Buffer)
      const inputFileStr = chunk.toString();
      const outputFileStr = inputFileStr.replace(/\s+/g, '').replace(/\/\*.+?\*\//g, '');
      //callback 是错误优先的回调函数, 第一个参数应该传入错误对象, 没有发生错误传入 null
      callback(null, outputFileStr);
    }
  });
  //文件写入流
  const writeStream = fs.createWriteStream('normalize.min.css');
  //打读取出来的文件流导入写入文件流
  readStream
    .pipe(transformStream)
    .pipe(writeStream);
  return readStream;
}

Gulp 的官方定义: The Streaming build system => 基于流的构建系统, 至于在 Gulp 中构建过程为什么选用文件流的方式, 这是因为 Gulp 希望实现一个构建管道的概念, 这样在后续做一些扩建插件时, 就可以有一个很统一的方式;

3.5、Gulp 文件操作 API

Gulp 中提供了专门去创建读取流和写入流的 API , 相对于 node 的 API, Gulp 提供的API 更强大, 也更容易使用; 至于负责文件加工的转化流, 绝大多数情况下都是通过独立的插件来提供, 在实际中通过 Gulp 创建构建任务时的流程: 先通过 src 方法创建读取流 -> 借助于插件提供的转换流实现文件加工 -> 最后通过 Gulp 提供的 dest方法创建写入流, 从而写入到目标文件;

//gulpfile.js
const { src, dest } = require('gulp');
// 压缩 css 文件,使用 gulp-clean-css : 通过 yarn add gulp-clean-css --dev 安装
const cleanCss = require('gulp-clean-css');
// 通过 gulp-rename 插件, 重命名压缩后的文件: 通过 yarn add gulp-rename --dev 安装
const rename = require('gulp-rename');
exports.default = () => {
  //通过 src 创建一个文件的读取流, 
  //需要通过 return 的方式 return 出去, 这样 Gulp 就可以控制该任务完成
  //通过 pipe 的方式导出到 dest 创建的写入流中
  // return src('src/normalize.css').pipe(dest('dist'))
  //在 src 可以使用通配符去匹配一类文件
  return src('src/*.css')
    .pipe(cleanCss())//压缩 css 文件
  	.pipe(rename({ extname: '.min.css' }))//重命名 css 文件: extname 规定重命名的后缀
    .pipe(dest('dist'))
}

3.6、Gulp 自动化构建案例

演示如果使用 Gulp 完成一个网页应用的自动化构建工作流
=>准备一个构建的网页应用:通过命令行克隆 GitHub 上的已经准备好的项目: git clone github.com/zce/zce-gul…
=>通过 vscode 打开该应用: 通过 dev 安装 gulp , 在根目录下创建 gulpfile.js , 编辑 gulpfile.js

//gulpfile.js
const { src, dest, parallel, series } = require('gulp');
const del = require('del');

const gulpSass = require('gulp-sass');
const babel = require('gulp-babel');
cosnt swig = require('gulp-swig');
const imagemin = require('gulp-imagemin');
const data = {
  menus: [
    {
      name: 'Home',
      icon: 'aperture',
      link: 'index.html'
    },
    {
      name: 'Features',
      link: 'features.html'
    },
    {
      name: 'About',
      link: 'about.html'
    },
    {
      name: 'Contact',
      link: '#',
      children: [
        {
          name: 'Twitter',
          link: 'https://twitter.com/w_zce'
        },
        {
          name: 'About',
          link: 'https://weibo.com/zceme'
        },
        {
          name: 'divider'
        },
        {
          name: 'About',
          link: 'https://github.com/zce'
        }
      ]
    }
  ],
  pkg: require('./package.json'),
  date: new Date()
}
//首先构建 style
const buildStyle = () => {
  //src() 方法: 在第二个参数是一个对象, 为 src 添加配置:
  return src('src/assets/styles/*.sass', { 
    base: 'src',// 设置在转换的时候的基准路径: 把 build 的路径下 src 后面的目录结构保存下来
  })
  /* 安装 gulp-sass 插件用于转换 sass 文件: yarn add gulp-sass --dev */
  // gulp-sass: 在转换的时候, 原始路径下的 _xxx.sass 会被认为是主文件依赖的文件, 会被忽略掉, 不会被转换; 安装 gulp-sass 模块时,默认安装 node-sass
  	.pipe(gulpSass({ outputStyle: 'expanded',//指定转换后的样式文件为完成展开的 }))
  	.pipe(dest('dist'))
};
//构建 es6 js
const buildScript = () => {
  return src('src/assets/scripts/*.js', { base: 'src' })
  	/* 安装 gulp-babel 插件用于转换 es6 文件: yarn add gulp-babel --dev */
  	// gulp-babel: 只是安装启动 babel, 没有主动安装 bebel 主程序, 需要自己手动安装: yarn add @babel/core @babel/preset-env --dev
  	//presets: 指定 babel 转换时会转换 ES几 的标准, 如果没有传 presets 属性, 就会出现转换没有效果, 如果你需要之转换特定的特性, 你只需安装特定的转换器就可以了;
  	.pipe(babel({ presets: ['@babel/preset-env'] }))
  	.pipe(dest('dist'))
}
//构建 html 文件
//在该项目中为了能够让页面中重用的地方被抽象出来, 使用了模板引擎, 这里使用的模板引擎为 swig
// 转换 swig 引擎文件, 安装 gulp-swig 模块: yarn add gulp-swig --dev
const buildPage = () => {
  //注意: 如果项目的html 文件不够集中, 需要使用通配符的方式去完成所有 html 文件的编译
  // src('src/**/*.html) 代表 src 目录下任意子目录下的 html 文件
  return src('src/*.html', { base: 'src' })
  	//因为在原来的模板文件里面使用了数据标记, 这些数据标记就是将网页开发过程中有可能会发生变化的地方, 例如: 网站的名字, 网站的数据信息, 提起为数据, 这些数据在转换时候需要通过数据的方式去指定; 也可以单独写一个 json 文件, 然后将 json 文件载入进页面(这种方式更加合理);
  	.pipe(swig({ data }))
  	.pipe(dest('dist'))
}
//构建图片: 需要安装插件 gulp-imagemin: yarn add gulp-imagemin --dev
const buildImg = () => {
  return src('src/assets/images/**', { base: 'src' })
  	.pipe(imagemin())
  	.pipe(dest('dist'))
}
//构建字体文件: 因为字体文件里面可能会有 svg, 所以也是用imagemin 来转换字体文件, imagemin 转换字体文件时, 不认识的不会转换
const buildFont = () => {
  return src('src/assets/fonts/**', { base: 'src' })
  	.pipe(imagemin())
  	.pipe(dest('dist'))
};
//编译其他文件: 只需拷贝到dist 目录
const copyExtra = () => {
  return src('public/**', { base: 'public' })
  	.pipe(dest('dist'))
}
//自动清理 dist 目录下的文件: 使用 del 模块: yarn add del --dev
//gulp 的构建过程不一定是 src 读取文件流, dest 写入文件流, 也可以自己写代码完成构建任务, 
//del 方法可以删除指定的文件, 是一个 Promise 方法
const clean = () => {
  return del(['dist'])
}
//创建一个组合任务, 因为三个任务互不干扰, 使用 parallel
const compile = parallel(buildStyle, buildScript, buildPage, buildImg, buildFont);
const build = series(clean, parallel(compile, copyExtra))
module.exports = { build }

=>通过命令行运行编译任务: yarn gulp build

3.6.1、Gulp 自动加载插件

随着构建任务越来越复杂, 使用的插件也越来越多, 如果都是使用手动的方式加载插件, require 的操作会很多, 不利于后期回顾代码, 这里可以使用一个插件来解决这样的问题:
=> 插件: gulp-load-plugins -> cnpm i gulp-load-plugins --save-dev

//gulpfile.js
const loadPlugins = require('gulp-load-plugins')
const plugins = loadPlugins();
//修改以前引入的模块: 例如 babel 修改为 plugins.bable

该插件只适合于 gulp 插件

3.6.2、Gulp 开发服务器

除了对文件的构建以外, 应用还需要一个开发服务器, 用于在开发阶段调试应用, 我们可以通过 Gulp 去启动并管理这个服务器, 后续可以配合其他的构建任务实现代码修改以后自动去编译, 并自动刷新浏览器页面, 这样就大大提高开发的效率, 因为他会减少在开发阶段的重复操作;
=> 通过 browser-sync 模块: cnpm i browser-sync --save-dev

//gulpfile.js
//该模块会为我们提供开发服务器, 相对于 express 创建的服务器来说, browser-sync 有更前大的功能, 支持在代码修改后自动热更新到浏览器中, 让我们可以及时看到最新的页面效果;
const browserSync = require('browser-sync');
//browserSync 模块提供来 create() 方法: 用于创建一个开发服务器
const bs = browserSync.create();
//我们将这个开发服务器单独定义到一个任务当中去启动;
const serve = () => {
  //通过 bs.init()方法: 初始化开发服务器的相关配置
  bs.init({
    notify: false,//将启动后的弹出提示隐藏
    port: 2080,//服务器启动的端口
    open: false,//是否自动打开浏览器
    files: 'dist/**',//指定一个字符串, 该字符串用于指定服务器启动后监听的路径的通配符(那些文件发生变化后, 服务器热更新到浏览器)
    server: {
      baseDir: 'dist', //指定网站的根目录     
      routes: { //routes 会优先于 baseDir, 服务器启动之后,会先走 routes 里面的配置
        '/node_modules': 'node_modules'//对于 '/node_moduls' 开头的网页请求,都直到同一个目录
      }
    }
  });
}
module.exports = {build, serve};

Gulp API=> watch: 会自动监视一个文件路径的通配符, 根据这些文件的变化, 决定是否要重新去执行某一个任务;

//gulpfils.js
//修改上文件内容
const { src, dest, series, parallel, watch } = require('gulp');
const buildStyle = () => {
  return src('src/assets/styles/*.sacc', { base: 'src' })
  	.pipe(plugins.sass({ putputStyle: 'expanded' }))
  	.pipe(dest('dist'))
  	//这里通过 bs.reload 的方式将修改后的 style 文件热更新到浏览器, js、html 文件的监听也可以这样修改
  	pipe(bs.reload({ stream: true//以流的方式推送 style 文件 }))
}
const serve = () => {
  //监听 css 文件 
  watch('src/assets/styles/*.scss', buildStyle);
  //监听 js 文件
  watch('src/assets/scripts/*.js', buildScript);
  //监听 html 文件
  watch('src/**/*.html', buildPage);
  //在开发阶段, 图片、文字以及额外文件的编译是没有太大意义的, 因为图片、字体只是提供来压缩, 并不影响在页面中的呈现; 在开发阶段监视更多的文件, 会有更多的花销, 所以可以去掉对图片、文字以及额外文件的监听, 将这三类文件的不做编译, 放在baseDir 中;
  /* //监听 image 文件
  watch('src/assets/images/**', buildImg);
  //监听 font 文件
  watch('src/assets/fonts/**', buildFont);
  //监听其他文件
  watch('public/**', copyExtra); */
  //在开发阶段, 图片文件、字体文件以及额外文件发生变化一样需要更新到浏览器
  //watch() 方法的第一个参数可以是一个数组, 数组里面的每一个成员就是监听的路径
  //browserSync.reload 方法: 当监听目录里面的文件发生变化, 更热更新到浏览器
  watch(['src/assets/images/**', 'src/assets/fonts/**', 'public/**'], bs.reload)
  bs.init({
    notify: false,
    port: 2080,
    //files: 'dist/**', 在热更新到服务器的时候可以使用 bs.reload 来代替
    server: {
      //baseDir 支持数组, 当请求过来之后, 先去数组第一个目录去找, 如果找不到的话, 就会继续往后找
      //将 图片、文字及额外文件放在baseDir 中, 在开发过程中不参与编译, 只在上线时编译一次就好;
      baseDir: ['dist', 'src', 'public'],
      routes: {
        '/node_modules': 'node_modules'
      }
    }
  });
}
const compile = parallel(buildStyle, buildScript, buildPage);
//上线之前执行的任务 build
const build = series(clean, parallel(compile, buildImg, buildFont, copyExtra));
//开发阶段执行的任务 develop
const develop = series(compile, serve);

在开发过程中遇到的找不到 ‘/node_modules’ 的路径, 处理方式是: 通过 bs.server.routes 指定路径; 但是在上线时不能这么操作, 通常的解决方式为:
=> 借助于 gulp-useref: cnpm i gulp-useref --save-dev
=> gulp-useref 插件会自动处理 html 中的构建注释( 对资源的引入 )
=> 对资源的引入包括 css 引入以及 js 引入: xxxx(assets/styles/vendoe.css) 说的是会将引入的资源打包到某个路径下, 如果你引入来多个文件, 该插件会把多个文件打包到一个文件

//gulpfile.js
const useref = () => {
  rerurn src('dist/*.html', {base: 'dist'})
  	//useref() 方法: 创建一个转换流, 自动转化构建注释的文件, searchPath 设置去哪里找构建注释的文件
  	.pipe(plugins.useref({ searchPath: ['dist', '.'] }))
  	.pipe(dest('dist'))
}

有了 useref 之后, useref 就把找不到路径的引入文件打包进一个文件, 这里还需要对打包的文件进行压缩;
=>这里需要压缩的文件有三种: html、css 以及 js

//gulpfile.js
const useref = () => {
  return src('dist/*.html'. { base: 'dist' })
  	.pipe(gulgins.useref({ searchPath: ['dist', '.'] }))
		/* 这里通过 userref 打包后有三种文件: html、css 以及 js
			这里需要对这三种文件打包, 需要安装三种文件的压缩插件: 
			html: gulp-htmlmin, js: gulp-uglify, css: gulp-clean-css
			安装: cnpm i gulp-htmlmin gulp-uglify gulp-clean-css --save-dev
			安装完成后, 在 useref 打包完以后这里有三种文件, 需要对这三种文件做不同的操作, 需要安装插件: gulp-if => cnpm i gulp-if --save-dev
		*/
		.pipe(plugins.if(/\.js$/, plugins.uglify()))
		.pipe(plugins.if(/\.css$/, plugins.cleanCss()))
		//htmlmin 插件默认只删除 html 文件的多余空格, 不会去删除换行符, 这里需要给 htmlmin() 方法指定一个参数: collapseWhitespace: true
    .pipe(plugins.if(/\.html/, plugins.htmlmin({ 
      collapseWhitespace: true,//去除 html 文件中的 html 语法的压缩 
      minifyCSS: true, //指定去除 html 文件中的 css 语法的压缩
      minifyJS: true //指定去除 html 文件中的 js 语法的压缩
    })))
		// 在这里, 读取 dist 目录下的文件, 然后打包压缩又写入到 dist 目录下, 就可能产生文件读写的冲突, 造成文件写入不进去的情况, 这里需要做一个额外的操作: 将打包压缩后的文件写入到其他文件目录下
		//.pipe(dest('dist'))
		.pipe(dest('release'))
}

启动 useref 任务之前必须要先执行 compile 任务: yarn gulp compile => yarn gulp useref

为了避免发生读写冲突的问题, 我们将打包压缩的文件放到了 release 文件夹下, 但是我们要上线的文件夹为 dist , 这就打破了构建的目录结构;
=> 在 useref 之前编译(compile)后的文件其实算作中间产物, 直接将编译(compile)后的文件放到 dist 下面是不合理的, 需要将编译(compile)后的文件放到一个临时的文件夹(temp)下, 然后 userf 去取临时文件夹(temp)里面的文件进行打包压缩, 这样才是合理的

//gulpfile.js
//修改下面的代码
const clean = () => {
	return del(['dist', 'temp']);
}
const buildStyle = () => {
  //.pipe(dest('dist')) => pipe(dest('temp'))
}
const buildScript = () => {
  //.pipe(dest('dist')) => pipe(dest('temp'))
}
const buildPage = () => {
  //.pipe(dest('dist')) => pipe(dest('temp'))
}
const serve = () => {
  //baseDir: ['dist', 'src', 'public'] => baseDir: ['temp', 'src', 'public']
}
const useref = () => {
  //return src('dist/*.html', { base: 'dist' }) => return src('temp/*.html', { base: temp })
}
const build = series(clean, parallel(series(compile, useref), buildImg, buildFont, copyExtra))

这里要解决两个小问题:

1、在构建文件 gulpfile.js 开发完成后, 你需要整理导出的任务
=> 一般导出的任务为 clean, develop, build
=> 也可以将这三个任务放到 package.json 中, 定义到 package.json 的 scripts 中

//package.json
"scripts": {
  "clean": "gulp clean",
  "develop": "gulp develop",
  "build": "gulp build"
}

命令行使用: yarn clean, yarn develop, yarn build 运行命令

2、在 .gitignore 中需要去忽略这些生成的目录

//在最后添加 
dist 

temp

3、在开发中创建的自动化工作流, 只要在相同类型的项目中都会被重复使用到该工作流; 在下一个模块来解决这个问题: 如果提取多个项目的自动化构建任务

3.7、封装自动化构建工作流

如果要开发多个同类型的项目, 那这些项目的自动化构建流应该是一样的, 这时就涉及到在多个项目中重复去使用这些构建任务, 这些构建任务大多数情况下都是相同, 所以说就面临着复用 gulpfile.js 的问题:
=> 针对于这个问题, 我们可以使用代码段的方式, 将 gulpfile.js 作为一个代码段保存起来, 然后在不同的项目中去使用, 这种方式也有一个弊端: gulpfile.js 散落在各个项目当中, 一但 gulpfile.js 有问题需要修复或者升级时, 这时就需要对每个项目做相同的操作, 这样不利于整体的维护,
=>所以需要重点来看怎么提取一个可复用的自动化工作流, 解决的方法也很简单: 就是通过创建一个新的模块, 包装 Gulp, 然后将自动化构建工作流包装进去, 具体来说就是: Gulp 只是一个构建自动化构建工作流的平台, 他不负责构建任何的构建任务, 你的构建任务需要通过 gulpfile.js 去定义, 现在我们有了 gulpfile 也有了 Gulp, 我们将这二者通过一个模块结合到一起, 结合完以后, 再以后的同类型的项目中就是用该模块去实现自动化构建工作流就好了;

具体的做法就是: 先去创建一个模块 => 然后将这个模块发布到 npm 的仓库上, => 最后在项目中去使用这个模块就可以了;

3.7.1、先去 GitHub 创建一个仓库: 这样可以将我们新创建的模块托管到 GitHub 上

创建完成以后, 复制仓库地址;

3.7.2、在本地空目录下创建模块:

可以使用传统方式 yarn init --yes 的方式创建模块, 也可以使用之前学习的脚手架来创建模块, 这里使用 zce-li 脚手架来创建模块:

安装脚手架工具:

cnpn i zce-cli 

通过 zce-cli 创建项目:

zce init nm lcy-pages
//project description(项目描述)

进入项目文件夹

CD lcy-pages

初始化仓库

git init
git remote add origin GitHub仓库地址
//查看仓库的初始状态
git status
//监听没有监听的文件: 对于 CRLF 的警告 => 在 windows 上默认的换行符是 \r\n 然而所有的源代码托管仓库都是以 \n 的方式去存储代码;
git add .
//初次提交: 
git commit -m "feat: initial commit" 
//将代码往上推到原地仓库: 
git push -u origin master

脚手架 zce-cli 安装的结构解析

项目根目录下的文件就是一些特定工具的配置文件;
=> CHANGELOG.md -> 变更日志
=> package.json 项目的配置文件
=> lib 目录下的 index.js -> 这个模块的入口文件, 后续需要在该文件里面写实现代码, 将刚刚创建自动化工作流的实现以及 Gulp 结合到一起,形成新的模块, 在后续开发同类型的项目时, 就可以使用该模块提高效率;

3.7.3、提取 gulpfile.js 到模块

将 gulp-build-demo 中创建的自动化构建流提取到 lcy-pages 里面, 然后封装好该工作流, 然后解决好该模块里面的问题, 这样就可以在其他项目中去使用该模块了;

3.7.3.1、将 gulp-build-demo 中的 gulpfile.js 内容复制到 lcy-pages 的 lib目录下的 index.js 中
3.7.3.2、将 gulp-build-demo 中的 package.json 的 devDependencies 的内容拷贝到 lcy-pages 的package.json 中的 dependencies 中

gulp-build-demo 项目中的生产依赖(dependencies)是不需要的, 因为不同的项目生产依赖是不一样的, 但是自动化构建的开发依赖是必须的;
=> 去安装一个模块, 会自动去安装 dependencies 中的依赖, 不会去安装 devDependencies 中的依赖, 所以这里复制到 dependencies 中;

3.7.3.3、通过命令行去安装 dependencies 里面的依赖
yarn 或者 npm install
3.7.3.4、本地调试

3.7.3.4.1、将 lcy-pages 这个模块 link 到全局

cd lcy-pages
yarn link

3.7.3.4.2、在 gulp-build-demo 中 link lcy-pages

//删除 gulp-build-demo 中的 node-modules, 删除 gulp-build-demo package.json 中的 devDependentcies 中的所有字段
yarn link "lcy-pages"

3.7.3.4.3、通过命令行初始化依赖包

yarn 或者  npm install

3.7.3.4.4、编辑 gulpfile.js

module.exports = require('lcy-pages');

3.7.3.4.5、运行 yarn build

报错: 在项目中没有找到 gulp , 所以这里要先安装

yarn add gulp --dev

再次运行 yarn build : 报错 connot find module ‘./package.json’

3.7.3.5、解决模块中的问题

因为前面 yarn build 报找不到 package.json 的错误, 所以需要将那些不应该被提取的东西全部抽出来;

3.7.3.5.1、在 lcy-pages 的index.js 中提取出来的 data, 因为需要在同类型的很多项目中去使用, 这里将data 写死肯定是不对的; 这里的解决方法: 通过约定大于配置的方式, 在项目根目录下(gulp-build-demo)去创建一个配置文件, 在模块中去读取项目中的配置文件;

这个配置文件名称约定为 pages.config.js

3.7.3.5.2、编辑 pages.config.js

module.exports = {
  data: //将 lcy-pages\lib\index.js 中的 data 拷贝过来
}

3.7.3.5.3、编辑 lcy-pages\lib\index.js

//修改
/* - */
const data = {xxxx}
/* + */
// cwd() 方法: 返回当前命令行的工作目录
const cwd = process.cwd();
let config = {} //这里为什么用let: 待一会去读取配置文件, 如果这个项目没有配置文件, 你的代码可以不报错, 应该有默认的配置
try {
  const loadConfig = require(`${cwd}/pages.config.js`)
  config = Object.assign({}, config, loadConfig)
} catch (e) {
  //default config
}
//将后面代码中的 data 换为 config.data, 对象里面的 data 换为 data: config.data

再次运行 yarn build : 报错 connot find module ‘@babel/preset-env’: 原因是: preset 字符串的时候会去你当前文件的目录下去找 babel/preset-env , 找不到就报错;

解决这个问题: 再次修改 index.js

//修改
const buildScript = () => {
  return src('src/assets/scripts/*.js', { base: 'src' })
  	//require 的方式去载入: 先到当前文件所在目录下去找, 找不到就依次往上找, 在根目录下的 node_modules 里面有
  	.pipe(plugins.babel({ preset: [requre('@babel/preset-env')] }))
  	xxxxx......
}
3.7.3.6、抽象路径配置

这个自动化构建流的模块就算完成了, 但是还有些地方可以做近一步的包装, 具体来看就是: 对于代码中写死的路径, 这些路径,在使用的项目中就可以看做是一个约定; 约定固然好, 但是提供可配置的能力也很重要(因为在项目中要求项目的源代码目录不是叫 src, 必须叫其他的, 这时你就可以通过配置项去覆盖), 这样操作可能更加灵活些;

3.7.3.6.1、修改 lcy-pages\lib\index.js

//修改
let config = {
  /* + */
  build: {
    src: 'src',
    dist: 'dist',
    temp: 'temp',
    public: 'public',
    paths: {
      styles: 'assets/styles/*.scss',
      scripts: 'assets/scripts/*.js',
      pages: '*.html',
      images: 'assets/images/**',
      fonts: 'assets/fonts/**'
    }
  }
}
const clean = () => {
  /* - */
  return del(['dist', 'temp'])
  /* + */
  return del([config.build.dist, config.build.temp])
}
const buildStyle = () => {
  /*amend - */
  return src('src/assets/styles/*.scss', { base: 'src' })
  /*amend + */
  return src(config.build.paths.styles, {
    base: config.build.src,
    cwd: config.build.src//配置找第一个参数的路径从哪一个路径去找; 默认是当前项目的路径
  })
  /*amend end */
  	.pipe(plugins.sass({ outputStyle: 'expended' }))
  /*amend - */
  	.pipe(dest('temp'))
  /*amend + */
  	.pipe(dest(config.build.temp))
  /*amend end */
  	.pipe(bs.reload({ stream: true }))
}
//按照 buildStyle 的修改方式修改所有的编译任务
//这里注意的是: watch()方法: cwd 路径为第二个参数: watch(config.build.paths.styles, { cwd: config.build.src }, style)

3.7.3.6.2、修改 gulp-build-demo\pages.config.js

module.exports = {
  /*amend + */
  build: {
    src: 'src',
    dist: 'release',
    temp: '.tmp',
    public: 'public',
    paths: {
      styles: 'assets/styles/*.scss',
      scripts: 'assets/scripts/*.js',
      pages: '*.html',
      images: 'assets/images/**',
      fonts: 'assets/fonts/**'
    }
  },
  /*amend end */
  data: xxxx....
}

这样路径就可以按照项目的配置来了, 不配置的话, 就会按照工作流模板默认配置来;

对于开发者来讲, 一开始需要技能, 然后需要你的想法, 想法建立在技能基础只想, 你的技能能够满足你的想法时, 你的想法越多越好, 你的想法越多, 你尝试的可能性也就越多, 你的收获也就越多;

3.7.3.7、包装 Gulp CLI

以上自动化构建工作流的模块就算是完成了, 但是我们还可以做更多得操作, 让使用者在使用的时候更加简单、方便;

在使用的时候, 如果想要使用 lcy-pages 提供的自动化构建工作流的话, 那么现需要将工作流安装到项目中, 然后在项目中添加配置文件(必要的), 在然后在项目的根目录下添加 gulpfile.js 将 lcy-pages 里面提供的任务导出出去, 然后才可以通过 Gulp 去运行自动化构建流;
=> 其实 gulpfile.js 对于项目来讲, 存在的价值就是将 lcy-pages 模块提供的成员导出出去, 这样做就显得有点冗余, 每次都要做重复的操作, 没有太大的意义; 我们就希望在项目的根目录下没有 gulpfile.js 也可以正常工作; 在项目中要怎么操作勒?

3.7.3.7.1、删除项目根目录下的 gulpfile.js, 然后通过命令行去运行 gulp 命令

yarn gulp //报错: No gulpfile found(找不到 gulpfile 文件)

3.7.3.7.2、gulp 提供了一个命令行参数, 可以让我们去指定 gulpfile 的路径, 这里需要使用的 gulpfile.js 在 node_modules\lcy-pages\lib\index.js 就是项目需要的 gulpfile.js

yarn gulp build --gulpfile ./node_modules/lcy-pages/lin/index.js

这个时候就可以正常工作, 但是有一个小小的问题: 工作目录已经改变为 node.modules\lcy-pages\lib , 因为你的 gulpfile.js 在 lib 目录, 工作流会认为你的工作目录也在 lib 目录, 这时就不会将你项目的根目录作为工作目录;

3.7.3.7.3、指定当前项目的根目录为 工作目录

yarn gulp build --gulpfile ./node_modules/lcy-pages/lib/index.js --cwd .

此时你的工作目录就是项目的根目录, 现在就可以正常的使用工作流; 只不过在项目中需要使用工作流模块所需要的命令行就比较复杂了;

这里就会有一个想法: 在 lcy-pages 中也提供一个 CLI , 自动的去传递命令行里的参数, 然后在内部去调用 gulp-CLI 所提供的可执行程序, 这样的话, 在外界去使用就不需要去使用 Gulp 了, 就相当于将 Gulp 完全包装到 lcy-pages 模块中;

具体的操作方法如下:

3.7.3.7.4、在 lcy-pages 下面添加 CLI 的程序, 在 lcy-pages 根目录下添加 bin\lcy-pages.js

一般来讲, 项目的模块代码放在 lib 下, 项目的 CLI 的代码放在 bin 目录; lcy-pages.js 会作为 CLI 的执行入口; CLI 的执行入口必须是 package.json 中的 bin字段

//lcy-pages/package.json
/*amend + */
"bin": "bin/lcy-pages.js"

入口文件的命名其实无所谓, 一般来讲, 会将其命名为跟 CLI 命令的名字, 如果你想指定 CLI 命令的话, 可以将 package.json 的 bin 字段配置为一个对象, 对象的键就是 CLI 的命令, 对象的值就是 CLI 命令对应的入口文件;

这时 lcy-pages.js 就会作为 CLI 命令的入口文件;

3.7.3.7.5、编辑 lcy-pages.js

lcy-pages.js: 这里需要将 Gulp-CLI 的调用以及刚刚使用工作流模块所传递的参数放在当前文件中
=> 首先:查看 Gilp-CLI 是怎么工作的: 在 node_moduls.bin\gulp.cmd, Gulp 在window 上应该执行的是 CMD 文件;

第一句: if 的语句 exist是否存在 %~dp0 指定的是当前 CMD 文件所在的目录(.bin) => 判断 .bin 目录下是否存在 node.exe, 如果有的话就走上面括号里面, 没有就走 ELSE 括号里面的
=> .bin 目录下肯定是没有的, 然后就走下面的: ELSE 里面的语句: 前两句 -> 配置环境变量, 就是让我们可执行文件的名字加了一个 .js 的扩展名;
第三句: 通过 node 去执行了当前目录(.bin)上一级(node_modules)下的gulp下的bin下的 gulp.js

@IF EXIST "%~dp0\node.ext" (
	"%~dp0\node.exe" "%~dp0\..\gulp\bin\gulp.js" %*
) ELSE (
	@SETLOCAL
	@SET PATHEXT=%PATHEXT:;/JS;=;%
	node ""%~dp0\..\gulp\bin\gulp.js" %*
)

node_modules\gulp\bin\gulp.js

这个 js 文件实际上是 require 了一下 gulp-cli 导出出来的方法, 然后调用了一下这个方法;

require('gulp-cli')()

从上面的分析来看, 要 Gulp-CLI 去运行 gulp, 其实很简单, 只需要执行一下 gulp 就好了(gulp 模块下 bin 目录下的 gulp )

#!/usr/bin/env node
require('gulp/bin/gulp)

在 lcy-pages 命令行执行

lcy-pages

报错: No gulpfile found => 说明已经让 Gulp-CLI 工作了, 剩下的就是怎么去指定 gulpfile.js 的路径以及 cwd 的路径

在命令行传递的参数可以通过 process.argv 去拿到

//lcy-pages.js
console.log(process.argv)

命令行执行命令: lcy-pages --csData data => 打印出数组: [node.exe 的路径, 当前文件的路径, ‘--csData’, ‘data’], 也就是说在 Gulp-CLI 中实际上是通过 peocess.argv 去拿数据的
=> 所以在代码运行之前, 先往 process.argv 中去 push 刚刚需要传递的参数

cwd: 当前命令行所在的目录
gulpfile 所在路径: 当前项目下的 lib 目录下的 index.js, require.resolve() 方法: 找到模块所对应的路径, 里面传递的字符串参数是相同的, 都是通过相对路径去传(找到项目的根目录下先去 package.json 中找 main 字段对应的路径)

#!/usr/bin/env node
process.argv.push('--cwd')
process.argv.push(process.cwd())
process.argv.push('--gulpfile')
//process.argv.puch(require.resolve('../lib/index.js'))
process.argv.push(require.resolve('..'))
require('gulp/bin/gulp')

3.7.3.7.6、进入 lcy-pages , 重新 link 一下

3.7.3.7.7、在 项目下通过命令行执行命令

lcy-pages build

此时工作流模块就会自动找到 lcy-pages\lib\index.js 做为 gulpfile, 工作目录就是当前的目录;
=> 现在就不需要你项目中有 gulpfle.js 了, 如果你将工作流作为全局安装, 甚至在项目的本地可以不需要安装 gulp 依赖;

3.7.3.8、发布并使用模块

将工作流模块发布到 npm 中, 并在新项目中使用该模块;
=> 在发布之前 lcy-pages 还有小小的问题: 当使用 npm publish 的时候默认会把项目根目录下的文件和在 package.json 中 files 字段配置的对应的目录发布到 npm 中, lay-pages 创建的时候只有 lib 目录, 所以要先将 bin 目录添加到 files 字段中;

3.7.3.8.1、先提交到 git 仓库

git add .
git commit -m "feat: update package"
git push

3.7.3.8.2、发布到 npm

yarn publish

在国内, 一般都是配置了淘宝镜像源的, 这里 publish 是不成功的, 这里需要修改配置文件才能成功, 如果修改配置文件, 可以指定 registry 的参数

yarn publish --registry https://registry.yarnpkg.com

发布成功后就可以在新项目去使用该模块了;

3.7.3.8.3、新创建一个项目

因为是同类型的项目, 所以项目的目录结构要和 gulp-build-demo 项目相同, 所以将 gulp-build-demo 的 publish 目录、src 目录以及pages.config.js 文件复制到新项目中

3.7.3.8.4、初始化 package.json

yarn init --yes

3.7.3.8.5、安装 lcy-pages 模块

yarn add lcy-pages --dev

这里可能会产生的一个问题: 我们去 add lcy-pages, 这个 lcy-pages 是我们刚刚发布上去的, 对于国内开发者来讲, 可能会面临一个问题, 你发布时发布到官方镜像, 你安装 是从淘宝镜像去安装, 这时就会出现官方镜像往淘宝镜像同步的时间差问题; 如果你在这里安装的时候时间间隔特别短, 很有可能你安装的是老版本或者说找不到该模块
=> 解决这个问题的方法: 去淘宝镜像的地址: npm.taobao.org 去 search lcy-pages 然后在 banner 的右边, 点击 SYNC 一下

3.7.3.8.6、安装完成后通过 lcy-pages 执行命令行命令

yarn lcy-pages build
3.7.3.9、封装自动化构建工作流总结

在新项目如果想要去使用刚刚创建的模块提供的工作流, 直接通过刚刚创建模块提供的 CLI 去运行对应的命令就可以了;
=>那刚刚创建模块提供的 CLI , 是 node_modulw.bin\lcy-pages.cmd 文件, 在这个文件中, 根据 cmd 的执行规则, 他是通过 node 去执行了 lcy-pages\bin\lcy-pages.js 文件
=>lcy-pages.js 文件内部先是通过 process.argv push 了几个参数, 实际上就相当于替换了手动输入这几个参数的操作, 在这个代码里面, 实际上做的事情就是: 告诉命令行 通过 gulp 工作流去工作的工作目录(当前命令行所在目录 process.cwd())以及 gulpfile.js 的文件路径(通过 require.resolve(‘..’) 找到 lcy-pages 的根目录, 这个目录下并没有 js 文件, 他就去找 package.json 中 main 字段对应的 js 文件 ), 然后调用了 gulp 模块下的 gulp 命令, 会自动去执行 gulp-CLI;

四、FIS 构建工具

4.1、FIS 的基本使用

FIS 属于另外一套构建系统, 相比较于 Gulp 和 Grunt , FIS 的核心特点是高度集成, 因为他把前端日常开发的常见的构建任务还有调试任务都集成到了内部, 这样开发者就可以通过简单的配置文件的方式去配置构建过程需要完成的工作, 也就是说, 在FIS 中不需要像 Gulp 和 Grunt 一样去定义一些任务, FIS 里面有一些内置的任务, 这些内置的任务会根据开发者的配置自动完成整个构建过程, 除此之外, FIS 中还内置了一款用于调试的 Web Server , 可以很方便的去调试我们的构建结果; 像这些内置的东西, 在 Gulp 和 Grunt 中都是需要通过自己通过一些插件实现的;

4.1.1、首先在新项目中安装 FIS 模块
yarn add fis3 --dev
4.1.2、进入项目中编辑原始代码

在这里注意的是: 在 html 中是直接引入的 sass 文件, 而且这里引入的 JS 文件也是使用了 ES6 的一些方式, 那 sass 和 ES6 在实际的生产环节肯定是需要转换的, 这些转换的过程我们都可以借助于 FIS 内置的构建任务去完成;

4.1.3、进入项目: fis-demo, 通过命令行执行 fis3 release

release 任务: 就是 FIS 内置的构建任务, 这个任务会自动将我们项目所有需要被构建的文件自动构建到临时的目录中, 这个目录可以在用户文件夹里面找到;

fis3 release

如果你需要指定输出的目录在你的根目录下, 可以添加参数: --d 文件名, 例如 -d output

fis3 release -d output

执行完成后, 在根目录下多了 output 文件夹, 在文件夹里是构建的文件, 但是我们发现 FIS 在这个过程中并没有对这些需要编译的文件做任务的转换, 而是直接输出到文件夹中, 整个过程只会将代码中那些对资源文件的引用的相对路径转换为绝对路径, 从而实现资源的定位;
=>资源定位是 FIS 中核心特性, 他的作用将我们开发阶段的路径彻底的与部署路径之间的关系分开, 如果你从事过前后端统一部署的项目, 你肯定遇到过前端输出的目录结构他并不是后端项目所需要的, 在上线之前还需要去手动修改这些路径; 而 资源定位就解决了这个问题;
=> 在项目的根目录下添加 fis-conf.js, 并编辑 fis-conf.js

//FIS 中有个特定的全局对象 fis
// fis.match() 方法: 在构建该过程中匹配到的一些文件添加指定的配置, 这里将这些文件 release 后的结果放在 '/assets/$0' 中, 这里的 $0 指的是当前文件的原始目录结构, 这样一来输出的资源文件都会出现在 assets 这个目录下
fis.match('*.{js,scss,png}', {
	release: '/assets/$0'          
})

我们可以通过 FIS 的资源定位的特性, 就可以大大提高代码的可移植性; 因为不管部署在哪个后端项目中, 他只需告诉你生成的结构是什么, 然后你根据生成的结构去配置 fis-conf.js 就可以了;

4.1.4、对文件做编译的处理

如果需要在构建的过程对文件进行编译的处理, 同样需要通过配置文件去配置如何处理文件的编译;

FIS 的配置文件的书写方式: 官方的设计思路是 -> 把它形成一种类似于 css 一样的声明式的方式去做配置, 具体的就是通过 match 的第一个参数去指定一个选择器, 这个选择器选择到构建过程中哪些匹配的文件, 后面的参数就是对这一类文件做的配置;

4.1.4.1、对 sass 进行编译

编辑 fis-conf.js, 对 sass 进行编译, 需要借助于插件: yarn add fis-parser-node-sass

yarn add fis-parser-node-sass --dev
/*amend + */
fis.match('**/*.sass', {
  rExt: '.css', //修改扩展名
  //这里添加转换器, fis.glugin() 方法: 自动载入插件, 插件的前缀是不需要的
  parser: fis.plugin('node-sass')
})

通过命令行执行命令: fis3 release -d output, 编译完成后, sass 文件就被转换了, html 中也替换了相应的编译后的文件;

以此类推, js 的转换也可以通过相应的插件与配置来完成;

4.1.4.2、对 js 进行编译

需要插件: fis-parser-babel-6.x

yarn add fis-parser-babel-6.x --dev
/*amend + */
fis.match('**/.*.js', {
  parser: fis.plugin('babel-6.0x')
})
4.1.5、对文件做压缩处理

压缩的插件是 FIS 内置的;

//修改
fis.match('**/*.scss', {
  rExt: '.css',
  parser: fis.plugin('node-sass'),
  /*amend + */
  optimizer: fis.plugin('clean-css')
  /*amend end */ 
})
fis.match('**/*.js', {
  parser: fis.plugin('bable-6.x'),
  /*amend + */
  optimizer: fis.plugin('uglify-js')
  /*amend end */
})
4.1.6、可以通过命令行: fis3 inspect 查看在转换过程中会转换哪些文件, 以 ~ 开头的就是匹配到的文件, 转换的配置都在 ~ 下有呈现;