前言
为什么要写这篇文章呢?是因为最近一直在搞Strve.js生态,在自己捣鼓框架的同时也学到了很多东西。所以就本篇文章给大家介绍一种更加方便灵活的命令行脚手架工具,以及如何发布到NPM上。
之前,我也写过类似的开发命令行工具的文章,但是核心思想都是通过代码远程拉取Git仓库中的项目模板代码。有时候会因为网速的原因导致拉取失败,进而会初始化项目失败。
那么,有没有比这个更好的方案呢?那么本篇就来了。
最近,使用Vite工具开发了很多项目。不得不佩服尤老师惊人的代码能力,创建了这么好的开发工具,开发体验非常丝滑。尤其是你刚初始化项目时,只需要执行一行命令,也不用全局安装什么工具。然后,自定义选择需要的模板进行初始化项目,就大功告成了!这种操作着实把我惊到了!我在想,如果我把create-vite的这种思路应用到我自己的脚手架工具中是不是很Nice!
实战
所以,二话不说,就抓紧打开ViteGitHub地址。
https://github.com/vitejs
找了大半天,终于找到了命令行工具核心代码。
https://github.com/vitejs/vite/tree/main/packages/create-vite
映入眼帘的是很多以template-开头的文件夹,打开几个都看了一下,都是框架项目模板。那么,可以先放在一边。
下一步,我们就打开index.js文件看下什么内容。我列下代码,大家可以简单看一下,不用深究。
#!/usr/bin/env node
// @ts-check
const fs = require('fs')
const path = require('path')
// Avoids autoconversion to number of the project name by defining that the args
// non associated with an option ( _ ) needs to be parsed as a string. See #4606
const argv = require('minimist')(process.argv.slice(2), { string: ['_'] })
// eslint-disable-next-line node/no-restricted-require
const prompts = require('prompts')
const {
yellow,
green,
cyan,
blue,
magenta,
lightRed,
red
} = require('kolorist')
const cwd = process.cwd()
const FRAMEWORKS = [
{
name: 'vanilla',
color: yellow,
variants: [
{
name: 'vanilla',
display: 'JavaScript',
color: yellow
},
{
name: 'vanilla-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'vue',
color: green,
variants: [
{
name: 'vue',
display: 'JavaScript',
color: yellow
},
{
name: 'vue-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'react',
color: cyan,
variants: [
{
name: 'react',
display: 'JavaScript',
color: yellow
},
{
name: 'react-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'preact',
color: magenta,
variants: [
{
name: 'preact',
display: 'JavaScript',
color: yellow
},
{
name: 'preact-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'lit',
color: lightRed,
variants: [
{
name: 'lit',
display: 'JavaScript',
color: yellow
},
{
name: 'lit-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'svelte',
color: red,
variants: [
{
name: 'svelte',
display: 'JavaScript',
color: yellow
},
{
name: 'svelte-ts',
display: 'TypeScript',
color: blue
}
]
}
]
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
const renameFiles = {
_gitignore: '.gitignore'
}
async function init() {
let targetDir = argv._[0]
let template = argv.template || argv.t
const defaultProjectName = !targetDir ? 'vite-project' : targetDir
let result = {}
try {
result = await prompts(
[
{
type: targetDir ? null : 'text',
name: 'projectName',
message: 'Project name:',
initial: defaultProjectName,
onState: (state) =>
(targetDir = state.value.trim() || defaultProjectName)
},
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}"`) +
` is not empty. Remove existing files and continue?`
},
{
type: (_, { overwrite } = {}) => {
if (overwrite === false) {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
},
name: 'overwriteChecker'
},
{
type: () => (isValidPackageName(targetDir) ? null : 'text'),
name: 'packageName',
message: 'Package name:',
initial: () => toValidPackageName(targetDir),
validate: (dir) =>
isValidPackageName(dir) || 'Invalid package.json name'
},
{
type: template && TEMPLATES.includes(template) ? null : 'select',
name: 'framework',
message:
typeof template === 'string' && !TEMPLATES.includes(template)
? `"${template}" isn't a valid template. Please choose from below: `
: 'Select a framework:',
initial: 0,
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color
return {
title: frameworkColor(framework.name),
value: framework
}
})
},
{
type: (framework) =>
framework && framework.variants ? 'select' : null,
name: 'variant',
message: 'Select a variant:',
// @ts-ignore
choices: (framework) =>
framework.variants.map((variant) => {
const variantColor = variant.color
return {
title: variantColor(variant.name),
value: variant.name
}
})
}
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
return
}
// user choice associated with prompts
const { framework, overwrite, packageName, variant } = result
const root = path.join(cwd, targetDir)
if (overwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
// determine template
template = variant || framework || template
console.log(`\nScaffolding project in ${root}...`)
const templateDir = path.join(__dirname, `template-${template}`)
const write = (file, content) => {
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file)
if (content) {
fs.writeFileSync(targetPath, content)
} else {
copy(path.join(templateDir, file), targetPath)
}
}
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
const pkg = require(path.join(templateDir, `package.json`))
pkg.name = packageName || targetDir
write('package.json', JSON.stringify(pkg, null, 2))
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`)
}
switch (pkgManager) {
case 'yarn':
console.log(' yarn')
console.log(' yarn dev')
break
default:
console.log(` ${pkgManager} install`)
console.log(` ${pkgManager} run dev`)
break
}
console.log()
}
function copy(src, dest) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
function isValidPackageName(projectName) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
projectName
)
}
function toValidPackageName(projectName) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-')
}
function copyDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true })
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
function isEmpty(path) {
return fs.readdirSync(path).length === 0
}
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
const abs = path.resolve(dir, file)
// baseline is Node 12 so can't use rmSync :(
if (fs.lstatSync(abs).isDirectory()) {
emptyDir(abs)
fs.rmdirSync(abs)
} else {
fs.unlinkSync(abs)
}
}
}
/**
* @param {string | undefined} userAgent process.env.npm_config_user_agent
* @returns object | undefined
*/
function pkgFromUserAgent(userAgent) {
if (!userAgent) return undefined
const pkgSpec = userAgent.split(' ')[0]
const pkgSpecArr = pkgSpec.split('/')
return {
name: pkgSpecArr[0],
version: pkgSpecArr[1]
}
}
init().catch((e) => {
console.error(e)
})
- 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.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
- 108.
- 109.
- 110.
- 111.
- 112.
- 113.
- 114.
- 115.
- 116.
- 117.
- 118.
- 119.
- 120.
- 121.
- 122.
- 123.
- 124.
- 125.
- 126.
- 127.
- 128.
- 129.
- 130.
- 131.
- 132.
- 133.
- 134.
- 135.
- 136.
- 137.
- 138.
- 139.
- 140.
- 141.
- 142.
- 143.
- 144.
- 145.
- 146.
- 147.
- 148.
- 149.
- 150.
- 151.
- 152.
- 153.
- 154.
- 155.
- 156.
- 157.
- 158.
- 159.
- 160.
- 161.
- 162.
- 163.
- 164.
- 165.
- 166.
- 167.
- 168.
- 169.
- 170.
- 171.
- 172.
- 173.
- 174.
- 175.
- 176.
- 177.
- 178.
- 179.
- 180.
- 181.
- 182.
- 183.
- 184.
- 185.
- 186.
- 187.
- 188.
- 189.
- 190.
- 191.
- 192.
- 193.
- 194.
- 195.
- 196.
- 197.
- 198.
- 199.
- 200.
- 201.
- 202.
- 203.
- 204.
- 205.
- 206.
- 207.
- 208.
- 209.
- 210.
- 211.
- 212.
- 213.
- 214.
- 215.
- 216.
- 217.
- 218.
- 219.
- 220.
- 221.
- 222.
- 223.
- 224.
- 225.
- 226.
- 227.
- 228.
- 229.
- 230.
- 231.
- 232.
- 233.
- 234.
- 235.
- 236.
- 237.
- 238.
- 239.
- 240.
- 241.
- 242.
- 243.
- 244.
- 245.
- 246.
- 247.
- 248.
- 249.
- 250.
- 251.
- 252.
- 253.
- 254.
- 255.
- 256.
- 257.
- 258.
- 259.
- 260.
- 261.
- 262.
- 263.
- 264.
- 265.
- 266.
- 267.
- 268.
- 269.
- 270.
- 271.
- 272.
- 273.
- 274.
- 275.
- 276.
- 277.
- 278.
- 279.
- 280.
- 281.
- 282.
- 283.
- 284.
- 285.
- 286.
- 287.
- 288.
- 289.
- 290.
- 291.
- 292.
- 293.
- 294.
- 295.
- 296.
- 297.
- 298.
- 299.
- 300.
- 301.
- 302.
- 303.
- 304.
- 305.
- 306.
- 307.
- 308.
- 309.
- 310.
- 311.
- 312.
- 313.
- 314.
- 315.
- 316.
- 317.
- 318.
- 319.
- 320.
- 321.
- 322.
- 323.
- 324.
- 325.
- 326.
- 327.
- 328.
- 329.
- 330.
- 331.
- 332.
- 333.
- 334.
- 335.
- 336.
- 337.
- 338.
- 339.
- 340.
- 341.
- 342.
- 343.
- 344.
- 345.
- 346.
- 347.
- 348.
看到上面这么多代码是不是不想继续阅读下去了?不要慌!我们其实就用到里面几个地方,可以放心的继续阅读下去。
这些代码算是Create Vite核心代码了,我们会看到常量FRAMEWORKS定义了一个数组对象,另外数组对象中都是一些我们初始化项目时需要选择安装的框架。所以,我们可以先ViteGithub项目Clone下来,试试效果。
然后,将项目Clone下来之后,我们找到/packages/create-vite这个文件夹,我们现在就只关注这个文件夹。
我用的Yarn依赖管理工具,所以我首先使用命令初始化依赖。
yarn
- 1.
然后,我们可以先打开根目录下的package.json文件,会发现有如下命令。
{
"bin": {
"create-vite": "index.js",
"cva": "index.js"
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
我们可以在这里起一个自己模板的名字,比如我们就叫demo,
{
"bin": {
"create-demo": "index.js",
"cvd": "index.js"
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
然后,我们先在这里使用yarn link命令来将此命令在本地可以运行。
然后再运行create-demo命令·。
会显示一些交互文本,会发现非常熟悉,这正是我们创建Vite项目时所看到的。我们在前面说到我们想实现一个属于自己的项目模板,现在我们也找到了核心。所以就开始干起来吧!
我们会看到在根目录下有很多template-开头的文件夹,我们打开一个看一下。比如template-vue。
原来模板都在这!但是这些模板文件都是以template-开头,是不是有什么约定?所以,我们打算回头再去看下index.js文件。
// determine template
template = variant || framework || template
console.log(`\nScaffolding project in ${root}...`)
const templateDir = path.join(__dirname, `template-${template}`)
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
果真,所以模板都必须以template-开头。
那么,我们就在根目录下面建一个template-demo文件夹,里面再放一个index.js文件,作为示例模板。
我们在执行初始化项目时发现,需要选择对应的模板,那么这些选项是从哪里来的呢?我们决定再回去看下根目录下的index.js文件。
会发现有这么一个数组,里面正是我们要选择的框架模板。
const FRAMEWORKS = [
{
name: 'vanilla',
color: yellow,
variants: [
{
name: 'vanilla',
display: 'JavaScript',
color: yellow
},
{
name: 'vanilla-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'vue',
color: green,
variants: [
{
name: 'vue',
display: 'JavaScript',
color: yellow
},
{
name: 'vue-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'react',
color: cyan,
variants: [
{
name: 'react',
display: 'JavaScript',
color: yellow
},
{
name: 'react-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'preact',
color: magenta,
variants: [
{
name: 'preact',
display: 'JavaScript',
color: yellow
},
{
name: 'preact-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'lit',
color: lightRed,
variants: [
{
name: 'lit',
display: 'JavaScript',
color: yellow
},
{
name: 'lit-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'svelte',
color: red,
variants: [
{
name: 'svelte',
display: 'JavaScript',
color: yellow
},
{
name: 'svelte-ts',
display: 'TypeScript',
color: blue
}
]
}
]
- 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.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
所以,可以在后面数组后面再添加一个对象。
{
name: 'demo',
color: red,
variants: [
{
name: 'demo',
display: 'JavaScript',
color: yellow
}
]
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
好,你会发现我这里会有个color属性,并且有类似颜色值的属性值,这是依赖kolorist导出的常量。kolorist是一个将颜色放入标准输入/标准输出的小库。我们在之前那些模板交互文本会看到它们显示不同颜色,这正是它的功劳。
const {
yellow,
green,
cyan,
blue,
magenta,
lightRed,
red
} = require('kolorist')
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
我们,也将模板对象添加到数组里了,那么下一步我们执行命令看下效果。
会发现多了一个demo模板,这正是我们想要的。
我们继续执行下去。
我们会看到根目录下已经成功创建了demo1文件夹,并且里面正是我们想要的demo模板。
上图显示的Error,是因为我没有在demo模板上创建package.json文件,所以这里可以忽略。你可以在自己的模板里创建一个package.json文件。
虽然,我们成功在本地创建了自己的一个模板,但是,我们只能本地创建。也就是说你换台电脑,就没有办法执行这个创建模板的命令。
所以,我们要想办法去发布到云端,这里我们发布到NPM上。
首先,我们重新新建一个项目目录,将其他模板删除,只保留我们自己的模板。另外,将数组中的其他模板对象删除,保留一个自己的模板。
我以自己的模板create-strve-app为例。
然后,我们打开package.json文件,需要修改一些信息。
以create-strve-app为例:
{
"name": "create-strve-app",
"version": "1.3.3",
"license": "MIT",
"author": "maomincoding",
"bin": {
"create-strve-app": "index.js",
"cs-app": "index.js"
},
"files": [
"index.js",
"template-*"
],
"main": "index.js",
"private": false,
"keywords": ["strve","strvejs","dom","mvvm","virtual dom","html","template","string","create-strve","create-strve-app"],
"engines": {
"node": ">=12.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/maomincoding/create-strve-app.git"
},
"bugs": {
"url": "https://github.com/maomincoding/create-strve-app/issues"
},
"homepage": "https://github.com/maomincoding/create-strve-app#readme",
"dependencies": {
"kolorist": "^1.5.0",
"minimist": "^1.2.5",
"prompts": "^2.4.2"
}
}
- 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.
注意,每次发布前,version字段必须与之前不同,否则发布失败。
最后,我们依次运行如下命令。
切换到npm源
npm config set registry=https://registry.npmjs.org
- 1.
登录NPM(如果已登录,可忽略此步)
npm login
- 1.
发布NPM
npm publish
- 1.
我们可以登录到NPM(https://www.npmjs.com/)
查看已经发布成功!
以后,我们就可以直接运行命令下载自定义模板。这在我们重复使用模板时非常有用,不仅可以提升效率,而且还可以避免犯很多不必要的错误。
结语
另外,此篇举例的 Create Strve App 是一套快速搭建Strve.js项目的命令行工具。如果你对此感兴趣,可以访问以下地址查看源码:
https://github.com/maomincoding/create-strve-app
熬夜奋战二个多月,Strve.js生态初步已经建成,以下是Strve.js 最新文档地址,欢迎浏览。
https://maomincoding.github.io/strvejs-doc/