【前端架构】你值得拥有属于自己的脚手架👊🏻👊🏻👊🏻

845 阅读11分钟

以最简单的方式实现一个webpack+vue3脚手架

知识点

  • Webpack 是一个现代 JavaScript 应用程序的静态模块打包工具。
  • Vue3 流行的 JavaScript 前端框架。
  • Commander 它可以帮助开发者快速构建复杂的命令行工具,并提供命令解析、帮助信息生成等功能。
  • download-git-repo 用于从 Git 仓库下载代码。它可以帮助开发者在项目初始化或构建过程中从远程仓库下载代码,并将其集成到项目中。
  • Handlebars 是一个 JavaScript 模板引擎,用于生成 HTML 页面。它采用简单的模板语法,可以通过填充数据来生成动态内容,适用于前端和后端渲染。
  • Inquirer 用于创建命令行交互式界面。它可以帮助开发者在命令行中询问用户问题,并根据用户的输入执行相应的操作,例如收集配置信息、生成文件等。

思路分析

  1. 根据自己喜好,使用Webpack + Vue3搭建一个项目,该项目应该具备简单的开发和打包功能,作为提供给用户使用的模板,并上传到github。
  2. 新建一个脚手架项目。
  3. Commander构建脚手架的命令行功能。
  4. Inquirer提供命令行选项,实现动态配置。
  5. download-git-repo下载第一步上传的项目模板。
  6. Handlebars模板引擎,根据用户选择进行相应配置文件的修改。

具体实现

搭建模板项目

因为不是文章重点,这里简单介绍一下我搭建的一个项目模板

目录结构

.
├── package-lock.json
├── package.json
├── src
│   ├── App.vue
│   ├── assets
│   ├── main.js
│   └── pages
└── webpack.config.js

webpack配置文件结构(仅供参考)

const path = require("path");
const { VueLoaderPlugin } = require("vue-loader");
const { DefinePlugin } = require("webpack");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

module.exports = {
    mode: "development",
    entry: "./src/main.js",
    output: {
        filename: "main.js",
        path: path.resolve(__dirname, "dist"),
    },
    devtool: "cheap-source-map",
    optimization: {
        usedExports: true, // 启用Tree Shaking
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: "vue-loader",
            },
            {
                test: /\.css$/,
                use: ["style-loader", "css-loader"],
            },
            {
                test: /\.scss$/,
                use: ["style-loader", "css-loader", "sass-loade"],
            },
            {
                test: /\.(png|svg|jpg|jpeg|gif|webp)$/i,
                type: "asset/resource",
                generator: {
                    filename: "static/[hash][ext][query]",
                },
            },
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/i,
                type: "asset/resource",
            },
            {
                test: /\.m?js$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: "babel-loader",
                    options: {
                        presets: ["@babel/preset-env"],
                        plugins: ["@babel/plugin-transform-runtime"],
                    },
                },
            },
        ],
    },
    plugins: [
        new DefinePlugin({
            // 在这里定义Vue的特性标志
            __VUE_OPTIONS_API__: true, // 如果使用Composition API,请设置为true
            __VUE_PROD_DEVTOOLS__: false, // 如果不需要Vue开发者工具,请设置为false
            __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // 如果不需要关于水合不匹配的详细信息,请设置为false
        }),
        new VueLoaderPlugin(),
        new BundleAnalyzerPlugin({
            generateStatsFile: true,
            openAnalyzer: false,
        }),
    ],
    resolve: {
        alias: {
            "@": path.resolve(__dirname, "src"),
        },
    },
    devServer: {
        static: "./dist",
        compress: true,
        port: 9000,
        hot: true,
        open: true,
        historyApiFallback: true,
    },
};

main.js

import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.mount('#app');

搭建脚手架项目

以下是我的目录结构,后续都会以这种形式进行展示,+代表新增文件,-代表删除文件

    .
 +  ├── bin
 +  │   └── po-vue-webpack.js
 +  ├── lib
 +  ├── package-lock.json
 +  ├── package.json
 +  └── templates

在这个示例中:

  • bin/: 存放CLI工具的执行脚本。
  • lib/: 存放CLI工具的功能模块。
  • templates/: 存放脚手架生成项目或组件时使用的模板文件。
  • package.json: 项目的配置文件,通常包含项目依赖、脚本等信息。

npm link

node.js中,要执行po-vue-webpack.js的代码,应该在终端输入node po-vue-webpack.js,但是这显得非常麻烦,而且貌似别人的脚手架也不是这么玩的。因此需要在po-vue-webpack.js中写第一行代码

// po-vue-webpack.js
#!/usr/bin/env node
console.log('测试测试')

#!/usr/bin/env node 是一个称为"shebang"的特殊注释,它告诉操作系统如何执行当前的脚本文件。在这个例子中,它指示操作系统使用 /usr/bin/env 程序来运行当前文件中指定的解释器,即 Node.js。这样做的好处是,它允许你在不同的系统上执行脚本文件,而无需硬编码Node.js的路径。

接着在package.json中新增命令

{
  "bin": {
    "po-webpack-cli": "bin/po-vue-webpack.js"
  },
}

再通过npm link命令,将本地开发中的一个 npm 包链接到全局安装的 npm 包中,就可以在命令行中直接使用po-webpack-cli命令了。

image.png

Commander

Commandernode.js快速构建命令的工具,根据npm文档描述,在po-vue-webpack.js文件下编写第一个命令。

import { program } from 'commander';

program
  .command('create <projectName>')
  .action((projectName) => {
    
  });
program.parse();

通过program.command定义了一个create的指令,并且他接收一个必填参数projectName,脑补一下也就是用户在命令行输入po-webpack-cli create myProject时,就会执行该命令并且action回调函数中projectName接收到的应该就是myProject字符串。

接下来,新建一个文件处理create命令。

    .
    ├── bin
  + │   ├── po-vue-webpack-create.js // 处理create命令
    │   └── po-vue-webpack.js
    ├── lib
    ├── package-lock.json
    ├── package.json
    └── templates
// po-vue-webpack.js
#!/usr/bin/env node

import { program } from 'commander';
import { createProject } from './po-vue-webpack-create.js';

program
  .command('create <projectName>')
  .action((projectName) => {
    createProject(projectName);
  });
program.parse();
// po-vue-webpack-create.js
export async function createProject(projectName) {
    
}

Inquirer

定义好脚手架的命令后,根据以往使用脚手架的经验,不难发现当用户输入po-webpack-cli create myProject后,应该会有一系列的选项给用户选择,例如:是否使用vue-router、是否使用eslint、是否使用pinia等等...

这时候就需要借助Inquirer来定义脚手架的所有选项,修改// po-vue-webpack-create.js代码。

import inquirer from "inquirer";
export async function createProject(projectName) {
    const answer = await inquirer.prompt([
        {
            type: "confirm",
            name: "confirmCreate",
            message: `你是否要创建名为${projectName}的vue3应用?`,
            default: true,
        },
        {
            type: "confirm",
            name: "poAxiosCreate",
            message: "是否使用po-axios?",
            default: true,
        },
        {
            type: "list",
            name: "elementPlusCreate",
            message: "是否引入Element Plus",
            choices: ["否", "全局引入", "按需引入"],
        },
    ]);
    console.log(answer)
    if (answer.confirmCreate) {
        
    } else {
        console.log("Aborted creating new project.");
    }
}

这些第三方包使用都非常简单,就直接贴代码讲重点。利用inquirer.prompt方法定义了三个问题,分别是创建项目、po-axois点击跳转(小弟自己二次封装的axios,)、element-plus。

打印看看answer变量拿到的是什么

image.png

上图所示,answer拿到用户选择的所有选项,事情就变得非常简单了,接下来暂时忽略用户选项,先将模板项目下载下来看看怎么个事。

为了代码逻辑条理清晰,修改下代码

    .
    ├── bin
    │   ├── po-vue-webpack-create.js
    │   └── po-vue-webpack.js
    ├── lib
 +  │   ├── commands
 +  │   │   └── createApp.js // 创建APP的方法
    ├── package-lock.json
    ├── package.json
    └── templates
// po-vue-webpack-create.js
export async function createProject(projectName) {
    ...代码省略
    if (answer.confirmCreate) {
        cteateApp(projectName, answer);
    } else {
        console.log("Aborted creating new project.");
    }
}

// cteateApp.js
export default async function cteateApp(projectName, answer) {
   
}

download-git-repo

根据download-git-repo文档,使用download方法将远程项目拷贝下来

    .
    ├── bin
    │   ├── po-vue-webpack-create.js
    │   └── po-vue-webpack.js
    ├── env
    │   └── index.js
    ├── lib
    │   ├── commands
    │   │   └── createApp.js
 +  │   └── utils
 +  │       ├── loadTemplate.js // 用于加载模板的方法
    ├── package-lock.json
    ├── package.json
    └── templates
 +       └── repo.js // 记录所有模板仓库信息
// repo.js
const repo = {
    'vue-webpack-template': {
        url: 'https://github.com/Inatuation/vue2-webpack-template.git',
        downloadUrl: 'https://github.com:Inatuation/vue2-webpack-template#main',
        description: 'vue2 + webpack'
    }
}

export default repo;

新建一个repo.js用于存储所有模板的github仓库地址,方便后续拓展,downloadUrl字段解释一下github.com:(项目所有者名称)/(项目名称)#(下载的…

// loadTepmlate.js
import download from "download-git-repo";
import repo from "../../templates/repo.js";
export default function loadTemplate(projectPath) {
    return new Promise((resolve, reject) => {
        // download-git-repo下载项目的方法,本地测试可以改为node复制本地项目
        download(
            repo["vue-webpack-template"].downloadUrl, // 目前只有一个模板,暂时写死
            projectPath,
            function (error) {
                if (error) {
                    console.log(error);
                    reject();
                    return;
                }
                resolve();
            }
        );
    });
}

// createApp.js
import path from "path";
import { mkdirSync } from "fs";
import download from "download-git-repo";
export default async function cteateApp(projectName, answer) {
    // 在当前目录创建新项目文件夹
    // projectName就是用户输入的项目名称
    const projectPath = path.join(process.cwd(), projectName);
    // 新建文件夹的方法
    mkdirSync(projectPath);
    // 加载项目模板到新建的文件夹内
    await loadTemplate(projectPath);
}

执行下命令po-webpack-cli create myProject,选项还没有进行处理随便选择,就可以在目录上得到我们的模板项目

image.png

Handlebars(重点)

现在基本功能已经实现了,需要对之前定义的Inquirer进行处理,又到了脑补环节。

来看下vue官方cli的选项

image.png

用户选择了引入Vue RouterPinia后,脚手架就应该在项目的package.json中引入相应的包,并且在项目入口文件中main.js中进行相应的配置、创建router目录、创建store目录……等一系列操作。

现在想来,我们的模板是固定写死的,不管用户选择什么,我们都只是通过download-git-repo去下载模板,难道我们要新建很多个模板?当用户选择不同的选项使对应去下载不同的模板提供给用户?

虽然这种做法可以实现,但是模板的数量可能选项的N次方,需要很多很多模板。

既然我们在node.js环境中,可以肆意操控文件,就用这个打开一条通道,只要定义好每个选项执行的修改哪些文件,插入哪些代码就可以了,因此我们要借助一个新的第三方包Handlebars

Handlebars.js 是 Chris Wanstrath 创建的 Mustache 模板语言的扩展,Mustache又是什么呢?它是模板语法的鼻祖,Vue的模板语法也是借助了Mustache的思想做的,来看看Handlebars的官方实例。

var source = "<p>Hello, my name is {{name}}. I am from {{hometown}}. I have " + "{{kids.length}} kids:</p>" + "<ul>{{#kids}}<li>{{name}} is {{age}}</li>{{/kids}}</ul>";

var template = Handlebars.compile(source);

var data = { "name": "Alan", "hometown": "Somewhere, TX", "kids": [{"name": "Jimmy", "age": "12"}, {"name": "Sally", "age": "4"}]};
var result = template(data);

// Would render:
// <p>Hello, my name is Alan. I am from Somewhere, TX. I have 2 kids:</p>
// <ul>
//   <li>Jimmy is 12</li>
//   <li>Sally is 4</li>
// </ul>

是不是非常熟悉,其实跟Vue的模板语法是一样的,利用模板语法,对模板项目进行改造。

再来看个例子:

var source = "hello {{name}}"
var template = Handlebars.compile(source);
var data  = { name: "破写代码的" }
var result = template(data);
console.log(result)
// 输出
// hello 破写代码的

如果数据为空的话会发生什么?

var source = "hello {{name}}"
var template = Handlebars.compile(source);
var data  = {}
var result = template(data);
console.log(result)
// 输出
// hello

虽然没有数据,但是模板语法{{name}}已经被替换为空字符串,请记住这个特性。利用这个特性,我们只需要数据不存在模板字符串的变量就可以做到不把代码注入到文件中。

举个例子: 当用户选择element plus按需引入时,Element plus对应模板字符串的变量分别是autoElementImport按需引入和importElementPlus完整引入,我们只需要把importElementPlus变为空或者在data数据中不存在即可。

改造模板项目

明确改造目标:脚手架提供了什么选项,需要对项目哪个文件进行动态修改。

回顾一下模板项目的目录结构:

.
├── package-lock.json
├── package.json
├── src
│   ├── App.vue
│   ├── assets
│   ├── main.js
│   └── pages
└── webpack.config.js

目前脚手架提供了

  1. 是否使用po-axios?
  2. 是否引入Element Plus

引入Element Plus

分析: Element Plus官方提供了完整引入和按需引入两种方式。

  • 完整引入

image.png

  • 按需引入

image.png

把对应要修改的文件定义成.hbs文件,并且利用模板语法动态赋值

    .
    ├── package-lock.json
    ├── package.json
    ├── src
    │   ├── App.vue
    │   ├── assets
 -  │   ├── main.js
 +  │   ├── main.hbs
    │   └── pages
 -  └── webpack.config.js
 +  └── webpack.config.mbs
// main.hbs
import { createApp } from 'vue';
import App from './App.vue';
{{#ifNotEmpty autoElementImport}}
{{{autoElementImport}}}
{{/ifNotEmpty}}
{{#ifNotEmpty importAxiosPlugin}}
{{{importAxiosPlugin}}}
{{/ifNotEmpty}}
{{#ifNotEmpty importElementPlus}}
{{{importElementPlus}}}
{{/ifNotEmpty}}

const app = createApp(App);
{{#ifNotEmpty useAxiosPlugin}}
{{useAxiosPlugin}}
{{/ifNotEmpty}}
{{#ifNotEmpty useElementPlus}}
{{useElementPlus}}
{{/ifNotEmpty}}

app.mount('#app');
// webpack.config.js
...代码省略
{{#ifNotEmpty autoElementPlusConfigPlugins}}
{{{autoElementPlusConfig}}}
{{/ifNotEmpty}}

module.exports = {
    plugins: [
        ...代码省略
        {{#ifNotEmpty autoElementPlusConfigPlugins}}
        {{autoElementPlusConfigPlugins}}
        {{/ifNotEmpty}}
    ],
};

image.png

图中作了颜色区分,将原本固定的代码变为模板语法,在脚手架中动态给这些变量赋值

引入po-axios

由于是个人项目,直接上图对比下怎么用,后续可以用官方的axios代替

image.png

改造脚手架项目

Element Plus逻辑

    .
    ├── bin
    │   ├── po-vue-webpack-create.js
    │   └── po-vue-webpack.js
    ├── lib
    │   ├── commands
    │   │   └── createApp.js
    │   └── utils
 +  │       ├── inject.js
    │       ├── loadTemplate.js
 +  │       └── util.js
    ├── package-lock.json
    ├── package.json
    └── templates
        └── repo.js
// createApp.js
...代码省略
import inject from '../utils/inject.js';

export default async function cteateApp(projectName, answer) {
    ...代码省略
    inject(projectPath, answer);
}
// inject.js
// 当全局引入elment时main.js的数据
const mainElementData = {
    importElementPlus: `import ElementPlus from 'element-plus';\nimport 'element-plus/dist/index.css';`,
    useElementPlus: `app.use(ElementPlus);`,
};
// 当按需引入element时main.js的数据
const mainAutoElementData = {
    autoElementImport: `import 'element-plus/dist/index.css';`,
};
// 当按需引入element时,webpack.config.js的数据
const webpackAutoElementData = {
    autoElementPlusConfig: `const AutoImport = require("unplugin-auto-import/webpack").default;\nconst Components = require("unplugin-vue-components/webpack").default;\nconst { ElementPlusResolver } = require("unplugin-vue-components/resolvers");`,
    autoElementPlusConfigPlugins: `AutoImport({
            resolvers: [ElementPlusResolver()],
        }),
            Components({
            resolvers: [ElementPlusResolver()],
        }),`,
};

let mainData = {};

let webpackConfigData = {};

export default function inject(url, answer) {
    handleData(answer);
    // 定义 Handlebars 辅助方法
    Handlebars.registerHelper("ifNotEmpty", function (value, options) {
        return value ? options.fn(this) : "";
    });
    mainInject(url); // 修改main.js
    webpackInject(url); // 修改webpack.config.js
    packageInjext(url, answer); // 修改package.json
}

// 处理数据
function handleData(answer) {
    const mData = {};
    const wData = {};
    if (answer.elementPlusCreate === "全局引入") {
        // 如果是全局引入,将全局引入的Element数据赋值给mainData
        Object.assign(mData, mainElementData);
    }
    if (answer.elementPlusCreate === "按需引入") {
        // 如果是按需引入,将按需引入的Element数据赋值给mainData
        Object.assign(mData, mainAutoElementData);
        // 将按需引入的Element数据赋值给webpackConfigData
        Object.assign(wData, webpackAutoElementData);
    }
    mainData = mData;
    webpackConfigData = wData;
}

为了确保讲述的更清晰,再来一张对比图。

  • 完整引入 image.png
  • 按需引入

image.png

现在利用Handlebars替换掉目录中的模板语法文件,并输出为.js文件。

// createApp.js
...代码省略
function mainInject(url) {
    // 修改main.js
    // 获取到main.js文件
    const mainFileString = findFile(url, "src/main.hbs");
    // 利用Handlebars.compile编译main.js文件
    const mainTemplate = Handlebars.compile(mainFileString);
    // mainTemplate输入mainData的数据,如果mainData有对应的变量,就会被替换。
    const mainResult = mainTemplate(mainData);
    // 输出用户需要的.js文件
    outputFile(url, "src/main.js", mainResult);
    // 删除不需要的.hbs文件
    deleteHbsFile(url, "src/main.hbs");
}
function webpackInject(url) {
    // 修改webpack.config.js
    const webpackFileString = findFile(url, "webpack.config.hbs");
    const webpackTemplate = Handlebars.compile(webpackFileString);
    const webpackResult = webpackTemplate(webpackConfigData);
    outputFile(url, "webpack.config.js", webpackResult);
    deleteHbsFile(url, "webpack.config.hbs");
}

function packageInjext(url, answer) {
    // 修改package.json
    const packageFileString = findFile(url, "package.json");
    const packageFile = JSON.parse(packageFileString);
    // 根据用户选择,注入package.json依赖
    if (
        answer.elementPlusCreate === "全局引入" ||
        answer.elementPlusCreate === "按需引入"
    ) {
        packageFile.dependencies["element-plus"] = "^2.6.3";
    }

    if (answer.elementPlusCreate === "按需引入") {
        packageFile.devDependencies["unplugin-auto-import"] = "^0.17.5";
        packageFile.devDependencies["unplugin-vue-components"] = "^0.26.0";
    }
    outputFile(url, "package.json", JSON.stringify(packageFile, null, 2));
}

工具函数

// utils.js
import { readFileSync, writeFileSync, unlink } from "fs";
import path from "path";
export function findFile(baseUrl, targetUrl) {
    return readFileSync(path.join(baseUrl, targetUrl), "utf-8");
}

export function outputFile(baseUrl, targetUrl, content) {
    try {
        writeFileSync(path.join(baseUrl, targetUrl), content, "utf-8");
    } catch (error) {
        console.error(error);
    }
}

export function deleteHbsFile(baseUrl, targetUrl) {
    try {
        unlink(path.join(baseUrl, targetUrl), (err) => {
            if (err) {
                console.error("Error deleting file:", err);
                return;
            }
        });
    } catch (error) {
        console.error(error);
    }
}

po-axios逻辑

// inject.js
function handleData(answer) {
    const mData = {};
    const wData = {};
    if (answer.poAxiosCreate) {
        Object.assign(mData, mainAxiosData);
    }
    ...代码省略
    mainData = mData;
    webpackConfigData = wData;
}

最后

把脚手架项目发布到npm上,然后通过npm install po-webpack-cli -g全局安装,就可以在任意位置通过po-webpack-cli create XXX来构建项目了。

demo地址:

cli项目

模板项目: