「Create-?」每個(gè)前端開發(fā)者都可以擁有屬于自己的命令行腳手架
前言
為什么要寫這篇文章呢?是因?yàn)樽罱恢痹诟鉙trve.js生態(tài),在自己搗鼓框架的同時(shí)也學(xué)到了很多東西。所以就本篇文章給大家介紹一種更加方便靈活的命令行腳手架工具,以及如何發(fā)布到NPM上。
之前,我也寫過類似的開發(fā)命令行工具的文章,但是核心思想都是通過代碼遠(yuǎn)程拉取Git倉庫中的項(xiàng)目模板代碼。有時(shí)候會因?yàn)榫W(wǎng)速的原因?qū)е吕∈。M(jìn)而會初始化項(xiàng)目失敗。
那么,有沒有比這個(gè)更好的方案呢?那么本篇就來了。
最近,使用Vite工具開發(fā)了很多項(xiàng)目。不得不佩服尤老師驚人的代碼能力,創(chuàng)建了這么好的開發(fā)工具,開發(fā)體驗(yàn)非常絲滑。尤其是你剛初始化項(xiàng)目時(shí),只需要執(zhí)行一行命令,也不用全局安裝什么工具。然后,自定義選擇需要的模板進(jìn)行初始化項(xiàng)目,就大功告成了!這種操作著實(shí)把我驚到了!我在想,如果我把create-vite的這種思路應(yīng)用到我自己的腳手架工具中是不是很Nice!
實(shí)戰(zhàn)
所以,二話不說,就抓緊打開ViteGitHub地址。
https://github.com/vitejs
找了大半天,終于找到了命令行工具核心代碼。
https://github.com/vitejs/vite/tree/main/packages/create-vite
映入眼簾的是很多以template-開頭的文件夾,打開幾個(gè)都看了一下,都是框架項(xiàng)目模板。那么,可以先放在一邊。
下一步,我們就打開index.js文件看下什么內(nèi)容。我列下代碼,大家可以簡單看一下,不用深究。
- #!/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)
- })
看到上面這么多代碼是不是不想繼續(xù)閱讀下去了?不要慌!我們其實(shí)就用到里面幾個(gè)地方,可以放心的繼續(xù)閱讀下去。
這些代碼算是Create Vite核心代碼了,我們會看到常量FRAMEWORKS定義了一個(gè)數(shù)組對象,另外數(shù)組對象中都是一些我們初始化項(xiàng)目時(shí)需要選擇安裝的框架。所以,我們可以先ViteGithub項(xiàng)目Clone下來,試試效果。
然后,將項(xiàng)目Clone下來之后,我們找到/packages/create-vite這個(gè)文件夾,我們現(xiàn)在就只關(guān)注這個(gè)文件夾。
我用的Yarn依賴管理工具,所以我首先使用命令初始化依賴。
- yarn
然后,我們可以先打開根目錄下的package.json文件,會發(fā)現(xiàn)有如下命令。
- {
- "bin": {
- "create-vite": "index.js",
- "cva": "index.js"
- }
- }
我們可以在這里起一個(gè)自己模板的名字,比如我們就叫demo,
- {
- "bin": {
- "create-demo": "index.js",
- "cvd": "index.js"
- }
- }
然后,我們先在這里使用yarn link命令來將此命令在本地可以運(yùn)行。
然后再運(yùn)行create-demo命令·。
會顯示一些交互文本,會發(fā)現(xiàn)非常熟悉,這正是我們創(chuàng)建Vite項(xiàng)目時(shí)所看到的。我們在前面說到我們想實(shí)現(xiàn)一個(gè)屬于自己的項(xiàng)目模板,現(xiàn)在我們也找到了核心。所以就開始干起來吧!
我們會看到在根目錄下有很多template-開頭的文件夾,我們打開一個(gè)看一下。比如template-vue。
原來模板都在這!但是這些模板文件都是以template-開頭,是不是有什么約定?所以,我們打算回頭再去看下index.js文件。
- // determine template
- template = variant || framework || template
- console.log(`\nScaffolding project in ${root}...`)
- const templateDir = path.join(__dirname, `template-${template}`)
果真,所以模板都必須以template-開頭。
那么,我們就在根目錄下面建一個(gè)template-demo文件夾,里面再放一個(gè)index.js文件,作為示例模板。
我們在執(zhí)行初始化項(xiàng)目時(shí)發(fā)現(xiàn),需要選擇對應(yīng)的模板,那么這些選項(xiàng)是從哪里來的呢?我們決定再回去看下根目錄下的index.js文件。
會發(fā)現(xiàn)有這么一個(gè)數(shù)組,里面正是我們要選擇的框架模板。
- 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
- }
- ]
- }
- ]
所以,可以在后面數(shù)組后面再添加一個(gè)對象。
- {
- name: 'demo',
- color: red,
- variants: [
- {
- name: 'demo',
- display: 'JavaScript',
- color: yellow
- }
- ]
- }
好,你會發(fā)現(xiàn)我這里會有個(gè)color屬性,并且有類似顏色值的屬性值,這是依賴kolorist導(dǎo)出的常量。kolorist是一個(gè)將顏色放入標(biāo)準(zhǔn)輸入/標(biāo)準(zhǔn)輸出的小庫。我們在之前那些模板交互文本會看到它們顯示不同顏色,這正是它的功勞。
- const {
- yellow,
- green,
- cyan,
- blue,
- magenta,
- lightRed,
- red
- } = require('kolorist')
我們,也將模板對象添加到數(shù)組里了,那么下一步我們執(zhí)行命令看下效果。
會發(fā)現(xiàn)多了一個(gè)demo模板,這正是我們想要的。
我們繼續(xù)執(zhí)行下去。
我們會看到根目錄下已經(jīng)成功創(chuàng)建了demo1文件夾,并且里面正是我們想要的demo模板。
上圖顯示的Error,是因?yàn)槲覜]有在demo模板上創(chuàng)建package.json文件,所以這里可以忽略。你可以在自己的模板里創(chuàng)建一個(gè)package.json文件。
雖然,我們成功在本地創(chuàng)建了自己的一個(gè)模板,但是,我們只能本地創(chuàng)建。也就是說你換臺電腦,就沒有辦法執(zhí)行這個(gè)創(chuàng)建模板的命令。
所以,我們要想辦法去發(fā)布到云端,這里我們發(fā)布到NPM上。
首先,我們重新新建一個(gè)項(xiàng)目目錄,將其他模板刪除,只保留我們自己的模板。另外,將數(shù)組中的其他模板對象刪除,保留一個(gè)自己的模板。
我以自己的模板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"
- }
- }
注意,每次發(fā)布前,version字段必須與之前不同,否則發(fā)布失敗。
最后,我們依次運(yùn)行如下命令。
切換到npm源
- npm config set registry=https://registry.npmjs.org
登錄NPM(如果已登錄,可忽略此步)
- npm login
發(fā)布NPM
- npm publish
我們可以登錄到NPM(https://www.npmjs.com/)
查看已經(jīng)發(fā)布成功!
以后,我們就可以直接運(yùn)行命令下載自定義模板。這在我們重復(fù)使用模板時(shí)非常有用,不僅可以提升效率,而且還可以避免犯很多不必要的錯(cuò)誤。
結(jié)語
另外,此篇舉例的 Create Strve App 是一套快速搭建Strve.js項(xiàng)目的命令行工具。如果你對此感興趣,可以訪問以下地址查看源碼:
https://github.com/maomincoding/create-strve-app
熬夜奮戰(zhàn)二個(gè)多月,Strve.js生態(tài)初步已經(jīng)建成,以下是Strve.js 最新文檔地址,歡迎瀏覽。
https://maomincoding.github.io/strvejs-doc/