开发脚手架与自动化构建工作流封装
去年6月24号开始工作,到今天刚好一周年了,纪念一下,分享最近学习的前端工程化笔记。
一、前端工程化
前端工程化是指遵循一定的标准和规范,通过工具去提高效率、降低成本的一种手段。
1. 前端开发中遇到的问题
- 想要使用ES6+新特性,但是兼容有问题
- 想要使用Less/Sass/PostCSS增强CSS的编程性,但是运行环境不能直接支持
- 想要使用模块化的方式提高项目的可维护性,但运行环境不能直接支持
- 部署上线前需要手动压缩代码及资源文件,部署过程需要手动上传代码到服务器
- 多人协同开发,无法硬性统一大家的代码风格,从仓库中pull回来的代码质量无法保证
2. 主要解决的问题
- 传统语言或语法的弊端
- 无法使用模块化/组件化
- 重复的机械式工作
- 代码风格统一、质量保证
- 依赖后端服务接口支持
- 整体依赖后端项目
3. 工程化表现
- 创建项目
- 创建项目结构
- 创建特定类型文件
- 编码
- 格式化代码
- 校验代码风格
- 编译/构建/打包
- 预览/测试
+ Web Server / Mock- Live Reloading / HMR
- Source Map
- 提交
- Git Hooks
- Lint-staged
- 持续集成
- 部署
- CI / CD
- 自动发布
4. 工程化不等于某个具体工具
工具并不是工程化的核心,工程化的核心是对项目的整体规划或架构,工具只是落地和实现工程化的一个手段
一些成熟的工程化集成:
- create-react-app
- vue-cli
- angular-cli
- gatsby-cli
上面的几个是某个项目的官方提供的集成化方案
5. 工程化与Node.js
工程化工具都是Node.js开发的
二、脚手架工具
脚手架的本质作用就是创建项目基础结构、提供项目规范和约定。
1. 脚手架工具的作用
因为在前端工程中,可能会有:
- 相同的组织结构
- 相同的开发范式
- 相同的模块依赖
- 相同的工具配置
- 相同的基础代码
脚手架就是解决上面问题的工具,通过创建项目骨架自动的执行工作。IDE创建项目的过程就是一个脚手架的工作流程。
由于前端技术选型比较多样,又没有一个统一的标准,所以前端脚手架不会集成在某一个IDE中,一般都是以一个独立的工具存在,相对会复杂一些。
2. 常用的脚手架工具
-
第一类脚手架是根据信息创建对应的项目基础结构,适用于自身所服务的框架的那个项目。
- create-react-app
- vue-cli
- Angular-cli
-
第二类是像Yeoman为代表的通用型脚手架工具,会根据模板生成通用的项目结构,这种脚手架工具很灵活,很容易扩展。
-
第三类以Plop为代表的脚手架工具,是在项目开发过程中,创建一些特定类型的组件,例如创建一个组件/模块所需要的文件,这些文件一般都是由特定结构组成的,有相同的结构。
3. 通用脚手架工具剖析
(1)Yeoman + Generator
Yeoman是最老牌、最强大、最通用的脚手架工具,是创建现代化应用的脚手架工具,不同于vue-cli,Yeoman更像是脚手架运行平台,我们可以通过Yeoman搭配不同的Generator去创建任何类型的项目,我们可以创建我们自己的Generator,从而去创建我们自己的前端脚手架。缺点是,在框架开发的项目中,Yeoman过于通用不够专注。
如果使用Yeoman:
- 在电脑上全局安装Yeoman:
yarn global add yo
- Yeoman要搭配相应的Generator创建任务,所以要安装Generator。例如是创建node项目,则安装generator-node:
yarn global add generator-node
- 创建一个空文件夹:
mkdir my-module
, 然后进入文件夹:cd my-module
- 通过Yeoman的yo命令安装刚才的生成器(去掉生成器名字前的generator-):
yo node
- 交互模式填写一些项目信息,会生成项目基础结构,并且生成一些项目文件,然后自动运行
npm install
安装一些项目依赖。
(2)SubGenerator
有时候我们可能不需要创建一个完成的项目结构,而是在已有项目的基础上,创建一些项目文件,如README.md,或者是创建一些特定类型的文件,如ESLint、Babel配置文件
- 运行SubGenerator的方式就是在原有Generator基础上加上:SubGenerator的名字,如:
yo node:cli
- 在使用SubGenerator前,要先去查看一下Generator之下有哪些SubGenerator
(3)Plop
Plop是一个小而美的脚手架工具,通常用于创建项目中特定类型文件的小工具,一般是把Plop集成到项目中,用来自动化创建同类型的项目文件。
如何使用Plop创建文件:
- 将plop模块作为项目开发依赖安装
- 在项目根目录下创建一个plopfile.js文件
- 在plopfile.js文件中定义脚手架任务
- 编写用于生成特定类型文件的模板
- 通过Plop提供的cli运行脚手架任务
4. 脚手架工作原理
脚手架的工作原理就是在启动脚手架之后,回自动地去询问一些预设问题,通过回答的结果结合一些模板文件,生成项目的结构。
使用NodeJS开发一个小型的脚手架工具:
-
用
yarn init
初始化一个空文件夹:sample-scaffolding
-
在
package.json
中添加bin
属性指定脚手架的命令入口文件为cli.js
{
"name": "sample-scaffolding",
"version": "1.0.0",
"main": "index.js",
"bin": "cli.js",
"license": "MIT",
"dependencies": {
"ejs": "^3.1.3",
"inquirer": "^7.1.0"
}
}
- 编写
cli.js
#!/usr/bin/env node
// Node CLI 应用入口文件必须要有这样的文件头
// 如果Linux 或者 Mac 系统下,还需要修改此文件权限为755: chmod 755 cli.js
// 脚手架工作过程:
// 1. 通过命令行交互询问用户问题
// 2. 根据用户回答的结果生成文件
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer') // 发起命令行交互询问
const ejs = require('ejs') // 模板引擎
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project name?'
}
]).then(answer => {
console.log(answer)
// 模板目录
const tempDir = path.join(__dirname, 'templates')
// 目标目录
const destDir = process.cwd()
// 将模板下的文件全部转换到目标目录
fs.readdir(tempDir, (err, files) => {
if (err) throw err
files.forEach(file => {
// 通过模板引擎渲染文件
ejs.renderFile(path.join(tempDir, file), answer, (err, result) => {
if(err) throw err
// 将结果写入到目标目录
fs.writeFileSync(path.join(destDir, file), result)
})
})
})
})
- 将该cli程序link到全局:
yarn link
- 然后再其他文件夹中执行:
sample-scaffolding
命令,就可以根据模板自动化创建文件了。
5. 自定义Generator开发脚手架
|-- generators/ ······生成器目录
| |-- app/ ······默认生成器目录
| |–templates ······模板文件夹
| |–foo.txx ······模板文件
| |–index.js ······默认生成器实现
| |–component/ ······其他生成器目录
| |–index.js ······其他生成器实现
|–package.json ······模块包配置文件
注意:Yeoman的生成器名称必须是
generator-<name>
,安装生成器的时候,就执行yo <name>
创建Generator生成器的步骤:
-
mkdir generator-sample
-
cd generator-sample
-
yarn init
-
yarn add yeoman-generator
-
创建文件:generators/app/index.jsx
// 此文件作为Generator的核心入口 // 需要导出一个集成字Yeoman Generator的类型 // Yeoman Generator在工作时会自动调用我们在此类型中定义的一些生命周期方法 // 我们在这些方法中可以通过调用父类提供的一些工具方法实现一些功能,比如文件写入 const Generator = require('yeoman-generator') module.exports = class extends Generator { prompting () { // Yeoman 在询问用户环节会自动调用次方法 // 在此方法中可以调用父类的prompt()方法发出对用户命令行询问 return this.prompt([ { type: 'input', name: 'name', message: 'Your project name', default: this.appname // appname为项目生成目录 } ]).then( answers => { // answers => {name: 'user input value'} this.answers = answers }) } writing () { // Yeoman 自动在生成文件阶段调用次方法 // 我们这里尝试往项目目录中写入文件 // this.fs.write( // this.destinationPath('temp.txt'), // Math.random().toString() // ) // 通过模板方法导入文件到目标目录 // 模板文件路径 const tmpl = this.templatePath('foo.txt') // 输出目标路径 const output = this.destinationPath('foo.txt') // 模板数组上下文 const context = {title: 'Hello', success: false} // const context = this.answers // 从命令行获取的参数 this.fs.copyTpl(tmpl, output, context) } }
-
templates/foo.txt作为模板文件
这是一个模板文件 内部可以使用EJS模板标记输出数据 例如:<%= title %> 其他的EJS语法也支持 <%if (success) {%> hello world <%}%>
-
执行
yarn link
, 此时这个模块就会作为全局模块被link到全局,别的项目可以直接使用它。 -
创建一个别的文件夹my-proj, 在这个文件夹中执行:
yo sample
-
发布到npmjs网站上:
yarn publish --registry=https://registry.yarnpkg.com
5. Plop
yarn add plop
plopfile.js
// Plop 入口文件,需要导入一个函数
// 此函数接受一个plop对象,用户创建生成器任务
module.exports = plop => {
plop.setGenerator('component', {
description: 'create a component',
prompts: [
{
type: 'input',
name: 'name',
message: 'component name',
default: 'MyComponent'
}
],
actions: [
{
type: 'add', // 代表添加文件
path: 'src/components/{{name}}/{{name}}.js',
templateFile: 'plop-templates/component.hbs'
},
{
type: 'add', // 代表添加文件
path: 'src/components/{{name}}/{{name}}.css',
templateFile: 'plop-templates/component.css.hbs'
},
]
})
}
编写模板:
component.hbs:
import React from 'react';
export default () => (
<div className="{{name}}">
<h1>{{name}} Component</h1>
</div>
)
Component.css.hbs:
import React from 'react';
import ReactDOM from 'react-dom';
import {{name}} from './{{name}}';
it('renders without crashing', () => {
const div = documents.createElement('div');
ReactDOM.render(<{{name}}/>, div);
ReactDOM.unmountComponentAtNode(div)
})
执行命令:yarn plop component
三、自动化构建
源代码自动化构建成生产代码,也称为自动化构建工作流。
使用提高效率的语法、规范和标准,如:ECMAScript Next、Sass、模板引擎,这些用法大都不被浏览器直接支持,自动化工具就是解决这些问题的,构建转换那些不被支持的特性。
1. NPM Scripts
在package.json中增加一个scripts对象,如:
{
"scripts": {
"build": "sass scss/main.scss css/style.css"
}
}
scripts可以自动发现node_modules里面的命令,所以不需要写完整的路径,直接写命令的名称就可以。然后可以通过npm或yarn运行scripts下面的命令名称,npm用run启动,yarn可以省略run,如:npm run build
或yarn build
NPM Scripts是实现自动化构建工作流的最简方式。
{
"scripts": {
"build": "sass scss/main.scss css/style.css",
"preserve": "yarn build",
"serve": "browser-sync ."
}
}
preserve是一个钩子,保证在执行serve之前,会先执行build,使样式先处理,然后再执行serve。
通过--watch
可以监听sass文件的变化自动编译,但是此时sass命令在工作时,命令行会阻塞,去等待文件的变化,导致了后面的serve无法去工作,此时就需要同时去执行多个任务,要安装npm-run-all
这个模块
{
"scripts": {
"build": "sass scss/main.scss css/style.css --watch",
"serve": "browser-sync .",
"start": "run-p build serve"
}
}
运行npm run start命令,build和serve就会被同时执行。
2. Grunt
Grunt是最早的前端构建系统,它的插件生态非常完善,它的插件可以帮你完成任何你想做的事情。由于Grunt工作过程是基于临时文件去实现的,所以会比较慢。
如何使用Grunt:
- 安装grunt:
yarn add grunt
,编写gruntfile.js文件,下面举例grunt任务的几种用法:
// Grunt的入口文件
// 用于定义一些需要Grunt自动执行的任务
// 需要导出一个函数
// 此函数接受一个grunt的形参,内部提供一些创建任务时可以用到的API
module.exports = grunt => {
grunt.registerTask('foo', () => {// 第一个参数是任务名字,第二个参数接受一个回调函数,是指定任务的执行内容,执行命令是yarn grunt foo
console.log('hello grunt ~')
})
grunt.registerTask('bar', '任务描述', () => { // 如果第二个参数是字符串,则是任务描述,执行命令是yarn grunt bar
console.log('other task~')
})
grunt.registerTask('default', () => { // 如果任务名称是'default',则为默认任务,grunt在运行时不需要执行任务名称,自动执行默认任务,执行命令是yarn grunt
console.log('default task')
})
grunt.registerTask('default', ['foo', 'bad', 'bar']) // 一般用default映射其他任务,第二个参数传入一个数组,数组中指定任务的名字,grunt执行默认任务,则会依次执行数组中的任务,执行命令是yarn grunt
// grunt.registerTask('async-task', () => {
// setTimeout(() => {
// console.log('async task working')
// }, 1000);
// })
// 异步任务,done()表示结束
grunt.registerTask('async-task', function () { // grunt代码默认支持同步模式,如果需要异步操作,则需要通过this.async()得到一个回调函数,在你的异步操作完成过后,去调用这个回调函数,标记这个任务已经被完成。知道done()被执行,grunt才会结束这个任务的执行。执行命令是yarn grunt async-task
const done = this.async()
setTimeout(() => {
console.log('async task working..')
done()
}, 1000);
})
// 失败任务
grunt.registerTask('bad', () => { // 通过return false标志这个任务执行失败,执行命令是yarn grunt bad。如果是在任务列表中,这个任务的失败会导致后序所有任务不再被执行,执行命令是yarn grunt。可以通过--force参数强制执行所有的任务,,执行命令是yarn grunt default --force
console.log('bad working...')
return false
})
// 异步失败任务,done(false)表示任务失败,执行命令是yarn grunt bad-async-task
grunt.registerTask('bad-async-task', function () {
const done = this.async()
setTimeout(() => {
console.log('bad async task working..')
done(false)
}, 1000);
})
}
-
grunt配置选项
module.exports = grunt => { grunt.initConfig({ // 对象的属性名一般与任务名保持一致。 // foo: 'bar' foo: { bar: 123 } }) grunt.registerTask('foo', () => { // console.log(grunt.config('foo')) // bar console.log(grunt.config('foo.bar')) // 123.grunt的config支持通过foo.bar的形式获取属性值,也可以通过获取foo对象,然后取属性 }) }
-
多目标任务(相当于子任务)
module.exports = grunt => { grunt.initConfig({ // 与任务名称同名 build: { options: { // 是配置选项,不会作为任务 foo: 'bar' }, // 每一个对象属性都是一个任务 css: { options: { // 会覆盖上层的options foo: 'baz' } }, // 每一个对象属性都是一个任务 js: '2' } }) // 多目标任务,可以让任务根据配置形成多个子任务,registerMultiTask方法,第一个参数是任务名,第二个参数是任务的回调函数 grunt.registerMultiTask('build', function () { console.log(this.options()) console.log(`build task: ${this.target}, data: ${this.data}`) }) }
执行命令:
yarn grunt build
, 输出结果:Running "build:css" (build) task { foo: 'baz' } build task: css, data: [object Object] Running "build:js" (build) task { foo: 'bar' } build task: js, data: 2
-
grunt插件使用
插件机制是grunt的核心,因为很多构建任务都是通用的,社区当中也就出现了很多通用的插件,这些插件中封装了很多通用的任务,一般情况下我们的构建过程都是由通用的构建任务组成的。先去npm中安装 需要的插件,再去gruntfile中使用grunt.loadNpmTasks方法载入这个插件,最后根据插件的文档完成相关的配置选项。
例如使用clean插件,安装
yarn add grunt-contrib-clean
,用来清除临时文件。module.exports = grunt => { // 多目标任务需要通过initConfig配置目标 grunt.initConfig({ clean: { temp: 'temp/**' // ** 表示temp下的子目录以及子目录下的文件 } }) grunt.loadNpmTasks('grunt-contrib-clean') }
执行:
yarn grunt clean
,就会删除temp文件夹 -
Grunt常用插件总结:
- grunt-sass
- grunt-babel
- grunt-watch
const sass = require('sass') const loadGruntTasks = require('load-grunt-tasks') module.exports = grunt => { grunt.initConfig({ sass: { options: { sourceMap: true, implementation: sass, // implementation指定在grunt-sass中使用哪个模块对sass进行编译,我们使用npm中的sass }, main: { files: { 'dist/css/main.css': 'src/scss/main.scss' } } }, babel: { options: { presets: ['@babel/preset-env'], sourceMap: true }, main: { files: { 'dist/js/app.js': 'src/js/app.js' } } }, watch: { js: { files: ['src/js/*.js'], tasks: ['babel'] }, css: { files: ['src/scss/*.scss'], tasks: ['sass'] } } }) // grunt.loadNpmTasks('grunt-sass') loadGruntTasks(grunt) // 自动加载所有的grunt插件中的任务 grunt.registerTask('default', ['sass', 'babel', 'watch']) }
3. Gulp
Gulp是目前世界上最流行的前端构建系统,其核心特点就是高效、易用。它很好的解决了Grunt中读写磁盘慢的问题,Gulp是基于内存操作的。Gulp支持同时执行多个任务,效率自然大大提高,而且它的使用方式相对于Grunt更加易懂,而且Gulp的生态也非常完善,所以后来居上,更受欢迎。
- gulp的使用
安装gulp:yarn add gulp,然后编写gulpfile.js,通过导出函数成员的方式定义gulp任务
// gulp的入口文件
exports.foo = done => {
console.log('foo task working...')
done() // 使用done()标识任务完成
}
exports.default = done => {
console.log('default task working...')
done()
}
执行命令:yarn gulp foo执行foo任务, 或者yarn gulp执行默认任务default
gulp4.0之前的任务写法:
const gulp = require('gulp')
gulp.task('bar', done => {
console.log('bar working...')
done()
})
执行命令yarn gulp bar可以运行bar任务,gulp4.0之后也保留了这个API,但是不推荐使用了
-
gulp创建组合任务:series串行、parallel并行
const {series, parallel} = require('gulp') // gulp的入口文件 exports.foo = done => { console.log('foo task working...') done() // 标识任务完成 } exports.default = done => { console.log('default task working...') done() } 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); } // series 串行执行 // exports.bar = series(task1, task2, task3) // parallel 并行执行 exports.bar = parallel(task1, task2, task3)
-
Gulp的异步任务:
const fs = require('fs') exports.callback = done => { console.log('callback task...') done() // 通过使用done()标志异步任务执行结束 } exports.callback_error = done => { console.log('callback task...') done(new Error('task failed!')) // done函数也是错误优先回调函数。如果这个任务失败了,后序任务也不会工作了 } exports.promise = () => { console.log('promise task...') return Promise.resolve() // resolve执行的时候,表示异步任务执行结束了。resolve不需要参数,因为gulp会忽略它的参数 } exports.promise_error = () => { console.log('promise task...') return Promise.reject(new Error('task failed')) // reject标志这是一个失败的任务,后序的任务也会不再执行 } const timeout = time => { return new Promise(resolve => { setTimeout(resolve, time); }) } exports.async = async() => { await timeout(1000) // 在node8以上可以使用async和await,await的就是一个Promise对象 console.log('async task...') } exports.stream = (done) => { // 最常用的就是基于stream的异步任务 const readStream = fs.createReadStream('package.json') const writeSteam = fs.createWriteStream('temp.txt') readStream.pipe(writeSteam) return readStream // 相当于下面的写法 // readStream.on('end', () => { // done() // }) }
-
Gulp构建过程,例子:压缩CSS
const fs = require('fs') const {Transform} = require('stream') exports.default = () => { const read = fs.createReadStream('normalize.css') const write = fs.createWriteStream('normalize.min.css') // 文件转化流 const transform = new Transform({ transform: (chunk, encoding, callback) => { // 核心转化过程 // chunk => 读取流中读取的内容(Buffer ) const input = chunk.toString() // 转化空白符和注释 const output = input.replace(/\s+/g, '').replace(/\/\*.+?\*\//g, '') callback(null, output) } }) read .pipe(transform) // 先转化 .pipe(write) return read }
-
Gulp文件api
const {src, dest} = require('gulp') const cleanCss = require('gulp-clean-css') const rename = require('gulp-rename') exports.default = () => { return src('src/*.css') .pipe(cleanCss()) .pipe(rename({ extname: '.min.css' })) .pipe(dest('dist')) }
-
Gulp构建
// 实现这个项目的构建任务 const {src, dest, parallel, series, watch} = require('gulp') const del = require('del') const browserSync = require('browser-sync') const bs = browserSync.create() const loadPlugins = require('gulp-load-plugins') const plugins = loadPlugins() const {sass, babel, swig, imagemin} = plugins 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() } const clean = () => { return del(['dist', 'temp']) } const style = () => { return src('src/assets/styles/*.scss', { base: 'src' }) .pipe(sass({ outputStyle: 'expanded' })) .pipe(dest('temp')) .pipe(bs.reload({stream: true})) } const script = () => { return src('src/assets/scripts/*.js', { base: 'src' }) .pipe(babel({ presets: ['@babel/preset-env'] })) .pipe(dest('temp')) .pipe(bs.reload({stream: true})) } const page = () => { return src('src/**/*.html', {base: 'src'}) .pipe(swig(data)) .pipe(dest('temp')) .pipe(bs.reload({stream: true})) } const image = () => { return src('src/assets/images/**', {base: 'src'}) .pipe(imagemin()) .pipe(dest('dist')) } const font = () => { return src('src/assets/fonts/**', {base: 'src'}) .pipe(imagemin()) .pipe(dest('dist')) } const extra = () => { return src('public/**', {base: 'public'}) .pipe(dest('dist')) } const serve = () => { watch('src/assets/styles/*.scss', style) watch('src/assets/scripts/*.js', script) watch('src/*.html', page) watch([ 'src/assets/images/**', 'src/assets/fonts/**', 'public/**' ], bs.reload) bs.init({ notify: false, port: 2080, 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('dist')) } // const compile = parallel(style, script, page, image, font) const compile = parallel(style, script, page) // 上线之前执行的任务 const build = series( clean, parallel( series(compile, useref), image, font, extra ) ) // 开发阶段 const develop = series(compile, serve) module.exports = { clean, compile, build, develop, }
其中依赖文件如下:
"devDependencies": { "@babel/core": "^7.10.2", "@babel/preset-env": "^7.10.2", "browser-sync": "^2.26.7", "del": "^5.1.0", "gulp": "^4.0.2", "gulp-babel": "^8.0.0", "gulp-clean-css": "^4.3.0", "gulp-htmlmin": "^5.0.1", "gulp-if": "^3.0.0", "gulp-imagemin": "^7.1.0", "gulp-load-plugins": "^2.0.3", "gulp-sass": "^4.1.0", "gulp-swig": "^0.9.1", "gulp-uglify": "^3.0.2", "gulp-useref": "^4.0.1" },
-
Gulp补充
4. FIS
FIS是百度的前端团队推出的构建系统,FIS相对于前两种微内核的特点,它更像是一种捆绑套餐,它把我们的需求都尽可能的集成在内部了,例如资源加载、模块化开发、代码部署、甚至是性能优化。正式因为FIS的大而全,所以在国内流行。FIS适合初学者。
全局安装:yarn global add fis3
执行fis3 release