0%

前端脚手架搭建

前言

由于公司标准化产品逐渐成熟,项目越来越多,需要一款脚手架通过业务标准化模板自动生成项目,可以让开发人员专注于业务开发,降低心智成本,提升团队效率。

参考了常用的脚手架,create-react-app、vue-cli、egg-init 的实现,搭建出了一套符合团队实际情况的脚手架工具。

介绍

脚手架就是在启动的时候询问一些简单的问题,并且通过用户回答的结果去渲染对应的模板文件。
基本工作流程如下:

  1. 通过命令行交互询问用户问题
  2. 拉取远端标准化模板
  3. 根据用户回答的结果生成文件
  4. 自动下载依赖

热门脚手架工具库

116d0e8220abc84e44593738478cbee7

搭建

1. 判断当前环境是否符合要求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env node
'use strict'
const currentNodeVersion = process.versions.node
const semver = currentNodeVersion.split('.')
const major = semver[0]
if (major < 14) {
console.error(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'Create React App requires Node 14 or higher. \n' +
'Please update your version of Node.'
)
process.exit(1)
}

2. 创建脚手架启动命令(使用 commander)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const program = new commander.Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action((name) => {
projectName = name
})
.parse(process.argv)
if (typeof projectName === 'undefined') {
console.error('Please specify the project directory:')
console.log(
`  ${chalk.cyan(program.name())} ${chalk.green('<project-directory>')}`
)
console.log()
console.log('For example:')
console.log(`  ${chalk.cyan(program.name())} ${chalk.green('my-app')}`)
process.exit(1)
}

3. 判断当前脚手架版本是否是最新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 获取最新的version
const checkForLatestVersion = (url) => {
return new Promise((resolve, reject) => {
http
.get(url, (res) => {
if (res.statusCode === 200) {
let body = ''
res.on('data', (data) => (body += data))
res.on('end', () => {
resolve(JSON.parse(body).version)
})
} else {
reject()
}
})
.on('error', () => {
reject()
})
})
}

checkForLatestVersion(`${host}create-my-app/latest`)
.catch(() => {
try {
return execSync('npm view create-my-app version').toString().trim()
} catch (e) {
return null
}
})
.then((latest) => {
if (latest && semver.lt(packageJson.version, latest)) {
console.log()
console.error(
chalk.yellow(
`You are running \`create-my-app\` ${packageJson.version}, which is behind the latest release (${latest}).\n\n` +
'We recommend always using the latest version of create-my-app if possible.'
)
)
console.log()
console.log(
'The latest instructions for creating a new app can be found here:\n' +
`${host}-/web/detail/create-my-app`
)
console.log()
} else {
createApp()
}
})

4. 询问用户问题获取创建所需信息(使用 inquirer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const getAnswers = async () => {
  return await inquirer.prompt([
    {
      type: 'input',
      name: 'projectZHName',
      message: '请输入项目中文名',
      default: '',
    },
...,
    {
      type: 'input',
      name: 'version',
      message: '请输入版本',
      default: '1.0.0',
    },
    {
      type: 'input',
      name: 'author',
      message: '请输入创建人(拼音全拼)',
      default: '',
    },
  ])
}

5. 下载远程模板(使用 hyperquest 或 download-git-repo)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
const latest = await checkForLatestVersion(
`${host}my-frontend-template/latest`
).catch(() => {
try {
return execSync('npm view my-frontend-template version').toString().trim()
} catch (e) {
return null
}
})
const { tmpdir, cleanup } = await getPackageInfo(
`${host}my-frontend-template/-/my-frontend-template-${latest}.tgz`
)

const extractStream = (stream, dest) => {
return new Promise((resolve, reject) => {
stream.pipe(
unpack(dest, (err) => {
if (err) {
reject(err)
} else {
resolve(dest)
}
})
)
})
}
const getTemporaryDirectory = () => {
return new Promise((resolve, reject) => {
// Unsafe cleanup lets us recursively delete the directory if it contains
// contents; by default it only allows removal if it's empty
tmp.dir({ unsafeCleanup: true }, (err, tmpdir, callback) => {
if (err) {
reject(err)
} else {
resolve({
tmpdir: tmpdir,
cleanup: () => {
try {
callback()
} catch (ignored) {
// Callback might throw and fail, since it's a temp directory the
// OS will clean it up eventually...
}
},
})
}
})
})
}

const getPackageInfo = (installPackage) => {
if (installPackage.match(/^.+\.(tgz|tar\.gz)$/)) {
return getTemporaryDirectory().then((obj) => {
let stream
if (/^http/.test(installPackage)) {
stream = hyperquest(installPackage)
} else {
stream = fsExtra.createReadStream(installPackage)
}
return extractStream(stream, obj.tmpdir).then(() => obj)
})
}
return Promise.resolve({ name: installPackage })
}

6. 根据需要读取修改文件和复制文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { tmpdir, cleanup } = await getPackageInfo(
    `${host}my-frontend-template/-/my-frontend-template-${latest}.tgz`
)

const root = path.resolve(projectName)
fsExtra.mkdirSync(root)
process.chdir(root)

const templates = [
   ...,
    'src/common',
...
  ]
  for (const item of templates) {
    fsExtra.copy(templatePath(tmpdir, item), item)
  }

7. 自动下载依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const child = spawn('pnpm', ['install'], { stdio: 'inherit' })
child.on('close', (code) => {
if (code !== 0) {
return
}
console.log()
console.log(
`${chalk.cyan(projectName)} is created ${chalk.green('successfully')}`
)
console.log()
console.log('Get Started with the following commands:')
console.log()
console.log(
`${chalk.gray('$')} ${chalk.green('cd')} ${chalk.green(projectName)}`
)
console.log(`${chalk.gray('$')} ${chalk.green('pnpm run serve')}`)
})

8. 发布和使用

1
2
3
4
5
6
7
8
// package.json
{
...
"bin": {
"create-my-app": "./index.js"
},
...
}

将脚手架发布到公司私库内,在使用中使用一行命令即可完成生成工作。

1
npx create-my-app <project-directory>

小结

文章中贴出来了关键部分的脱敏代码,整体主要参考了create-react-app的实现,对其代码逻辑之严谨深受启发,其几乎对每一个环节可能出现的问题都做了第二种甚至第三种容错处理,在自己实现过程中,学习到了很多,对日后的开发大有帮助。