神族九帝's blog 神族九帝's blog
首页
  • 神卡套餐 (opens new window)
  • 神族九帝 (opens new window)
  • 网盘资源 (opens new window)
  • 今日热点 (opens new window)
  • 在线PS (opens new window)
  • IT工具 (opens new window)
  • FC游戏 (opens new window)
  • 在线壁纸 (opens new window)
  • 面试突击
  • 复习指导
  • HTML
  • CSS
  • JavaScript
  • 设计模式
  • 浏览器
  • 手写系列
  • Vue
  • Webpack
  • Http
  • 前端优化
  • 项目
  • 面试真题
  • 算法
  • 精选文章
  • 八股文
  • 前端工程化
  • 工作笔记
  • 前端基础建设与架构 30 讲
  • vue2源码学习
  • 剖析vuejs内部运行机制
  • TypeScript 入门实战笔记
  • vue3源码学习
  • 2周刷完100道前端优质面试真题
  • 思维导图
  • npm发包
  • 重学node
  • 前端性能优化方法与实战
  • webpack原理与实战
  • webGl
  • 前端优化
  • Web3
  • React
  • 更多
  • 未来要做的事
  • Stirling-PDF
  • ComfyUI
  • 宝塔面板+青龙面板
  • 安卓手机当服务器使用
  • 京东自动评价代码
  • 搭建x-ui免流服务器(已失效)
  • 海外联盟
  • 好玩的docker
  • 收藏夹
  • 更多
GitHub (opens new window)

神族九帝,永不言弃

首页
  • 神卡套餐 (opens new window)
  • 神族九帝 (opens new window)
  • 网盘资源 (opens new window)
  • 今日热点 (opens new window)
  • 在线PS (opens new window)
  • IT工具 (opens new window)
  • FC游戏 (opens new window)
  • 在线壁纸 (opens new window)
  • 面试突击
  • 复习指导
  • HTML
  • CSS
  • JavaScript
  • 设计模式
  • 浏览器
  • 手写系列
  • Vue
  • Webpack
  • Http
  • 前端优化
  • 项目
  • 面试真题
  • 算法
  • 精选文章
  • 八股文
  • 前端工程化
  • 工作笔记
  • 前端基础建设与架构 30 讲
  • vue2源码学习
  • 剖析vuejs内部运行机制
  • TypeScript 入门实战笔记
  • vue3源码学习
  • 2周刷完100道前端优质面试真题
  • 思维导图
  • npm发包
  • 重学node
  • 前端性能优化方法与实战
  • webpack原理与实战
  • webGl
  • 前端优化
  • Web3
  • React
  • 更多
  • 未来要做的事
  • Stirling-PDF
  • ComfyUI
  • 宝塔面板+青龙面板
  • 安卓手机当服务器使用
  • 京东自动评价代码
  • 搭建x-ui免流服务器(已失效)
  • 海外联盟
  • 好玩的docker
  • 收藏夹
  • 更多
GitHub (opens new window)
  • 工作笔记

  • 前端基础建设与架构 30 讲

    • 开篇词 像架构师一样思考,突破技术成长瓶颈
    • npm 安装机制及企业级部署私服原理
    • Yarn 的安装理念及如何破解依赖管理困境
    • CI 环境上的 npm 优化及更多工程化问题解析
    • 横向对比主流构建工具,了解构建工具的设计考量
    • Vite 实现:从源码分析出发,构建 bundlele 开发工程
    • core-j 及垫片理念:设计一个“最完美”的 Polyfill 方案
    • 梳理混乱的 Babel,不再被编译报错困扰
    • 探索前端工具链生态,制定一个统一标准化 babel-preet
    • 从实战出发,从 0 到 1 构建一个符合标准的公共库
    • 代码拆分和按需加载:缩减 bundle ize,把性能做到极致
    • Tree Shaking:移除 JavaScript 上下文中的未引用代码
    • 如何理解 AST 实现和编译原理?
    • 工程化思维处理方案:如何实现应用主题切换功能?
    • 解析 Webpack 源码,实现自己的构建工具
    • 从编译到运行,跨端解析小程序多端方案
    • 原生跨平台技术:移动端跨平台到 Flutter 的技术变革
    • 学习 axio:封装一个结构清晰的 Fetch 库
    • 对比 Koa 和 Redux:分析前端中的中间件理念
    • 如何理解软件开发灵活性和高定制性?
    • 如何理解前端中面向对象的思想?
    • 如何利用 JavaScript 实现经典数据结构?
    • 剖析前端中的数据结构应用场景
    • npm cript:打造一体化的构建和部署流程
    • 自动化代码检查:剖析 Lint 工具和工程化接入&优化方案
    • 如何设计一个前端 + 移动端离线包方案?
    • 如何设计一个“万能”项目脚手架?
      • 同构渲染架构:实现一个 SSR 应用
      • 设计性能守卫系统:完善 CICD 流程
      • 实践打造网关:改造企业 BFF 方案
      • 实现高可用:使用 Puppeteer 生成性能最优的海报系统
      • 结束语 再谈项目的基建和架构,个人的价值和方向
    • vue2源码学习

    • 剖析vuejs内部运行机制

    • TypeScript 入门实战笔记

    • vue3源码学习

    • 2周刷完100道前端优质面试真题

    • 思维导图

    • npm发包

    • 重学node

    • 前端性能优化方法与实战

    • webpack原理与实战

    • webGl

    • 前端优化

    • Web3

    • React

    • 更多

    • 笔记
    • 前端基础建设与架构 30 讲
    wu529778790
    2024-04-07

    如何设计一个“万能”项目脚手架?

    脚手架是工程化中不可缺少的一环。究竟什么是脚手架呢?广义上来说,脚手架就是为了保证各施工过程顺利进行而搭设的工作平台。

    编程领域的脚手架主要为了完成新项目的启动和搭建,能够帮助开发者提升效率和开发体验。对于前端来说,从零开始建立一个项目是复杂的,因此也就存在了较多类型的脚手架:

    • Vue/React 框架类脚手架

    • Webpack 等构建配置类脚手架

    • 混合脚手架,比如大家熟悉的 Vue-cli 或者 create-react-app

    这一讲我们就深入这些脚手架的原理进行讲解。

    命令行工具原理和实现

    现代脚手架离不开命令行工具,命令行工具即 Command-line interfaces(CLIs) ,是编程领域的重要概念,也是我们开发中经常接触到的工具之一。

    比如 Webpack、Babel、npm、Yarn 等都是典型的命令行。此外,流畅的命令行能够迅速启动一个脚手架,实现自动化和智能化流程。这一部分,我们就使用 Node.js 来开发一个命令行。

    我们先来看几个开发命令行工具的关键依赖。

    • inquirer、enquirer、prompts:可以处理复杂的用户输入,完成命令行输入交互。

    • chalk、kleur:使终端可以输出彩色信息文案。

    • ora:可以让命令行出现好看的 Spinners。

    • boxen:可以在命令行中画出 Boxes 区块。

    • listr:可以在命令行中画出进度列表。

    • meow、arg:可以进行基础的命令行参数解析。

    • commander、yargs:可以进行更加复杂的命令行参数解析。

    我们的目标是支持以下面这种启动方式,建立我们的项目,如下代码:

    npm init @lucas/project
    

    npm 6.1 及以上版本,我们都可以使用npm init或yarn create来启动我们的项目,比如下面两个命令就是等价的:

    # 使用 Node.js
    npm init @lucas/project
    # 使用 Yarn
    yarn create @lucas/project
    

    启动命令行项目

    下面开始进入开发,首先我们创建项目:

    mkdir create-project && cd create-project
    npm init --yes
    

    接着进入create-project文件中,创建src目录及src/cli.js文件,cli.js文件内容如下:

    export function cli(args) {
     console.log(args);
    }
    

    接下来,为了使我们的命令行可以在终端执行,我们新建bin/目录,并在其下创建一个create-project文件,代码为:

    #!/usr/bin/env node
    require = require('esm')(module /*, options*/);
    require('../src/cli').cli(process.argv);
    

    上述代码中,我们使用了esm 模块,这样就可以在其他文件中使用import关键字,即 ESM 模块规范了。我们在该入口文件中,引入cli.js并将命令行参数process.argv传给cli函数执行。

    当然,为了能够正常使用esm 模块,我们需要先安装,执行npm install esm。

    此时 package.json 内容如下:

    {
     "name": "@lucas/create-project",
     "version": "1.0.0",
     "description": "A CLI to bootstrap my new projects",
     "main": "src/index.js",
     "bin": {
       "@lucas/create-project": "bin/create-project",
       "create-project": "bin/create-project"
     },
     "publishConfig": {
       "access": "public"
     },
     "scripts": {
       "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [
       "cli",
       "create-project"
     ],
     "author": "YOUR_AUTHOR",
     "license": "MIT",
     "dependencies": {
       "esm": "^3.2.18"
     }
    }
    

    这里需要注意的是 bin字段,我们注册了两个可用命令:一个是带有 npm 命名 scope 的,一个是常规的create-project命令。

    为了调试方便,我们使用npm link命令进行调试,在终端中项目目录下执行:

    npm link
    

    上述命令可以在全局范围内添加一个软链到当前项目中。我们执行:

    create-project --yes
    

    就会得到下面这样的输出:

    [ '/usr/local/Cellar/node/11.6.0/bin/node',
      '/Users/dkundel/dev/create-project/bin/create-project',
      '--yes' ]
    

    该输出,就对应了代码中的process.argv。

    解析处理命令行输入

    在解析处理命令行输入之前,我们需要设计命令行支持的几个选项,如下。

    • [template]:支持默认的几种模板类型,用户可以通过 select 进行选择。

    • --git:等同于git init去创建一个新的 Git 项目。

    • --install:支持自动下载项目依赖。

    • --yes:跳过命令行交互,直接使用默认配置。

    我们利用inquirer使得命令行支持用户交互,同时使用arg来解析命令行参数,安装相关依赖命令:

    npm install inquirer arg
    

    接下来编写命令行参数解析逻辑,在cli.js中添加:

    import arg from 'arg';
    // 解析命令行参数为 options
    function parseArgumentsIntoOptions(rawArgs) {
     // 使用 arg 进行解析
     const args = arg(
       {
         '--git': Boolean,
         '--yes': Boolean,
         '--install': Boolean,
         '-g': '--git',
         '-y': '--yes',
         '-i': '--install',
       },
       {
         argv: rawArgs.slice(2),
       }
     );
     return {
       skipPrompts: args['--yes'] || false,
       git: args['--git'] || false,
       template: args._[0],
       runInstall: args['--install'] || false,
     }
    }
    export function cli(args) {
     // 获取命令行配置
     let options = parseArgumentsIntoOptions(args);
     console.log(options);
    }
    

    上述代码很好理解,我已经加入了相关注释。接下来,我们实现使用默认配置和交互式配置选择逻辑,如下代码:

    import arg from 'arg';
    import inquirer from 'inquirer';
    function parseArgumentsIntoOptions(rawArgs) {
     // ...
    }
    async function promptForMissingOptions(options) {
     // 默认使用名为 JavaScript 的模板
     const defaultTemplate = 'JavaScript';
     // 使用默认模板则直接返回
     if (options.skipPrompts) {
       return {
         ...options,
         template: options.template || defaultTemplate,
       };
     }
     // 准备交互式问题 
     const questions = [];
     if (!options.template) {
       questions.push({
         type: 'list',
         name: 'template',
         message: 'Please choose which project template to use',
         choices: ['JavaScript', 'TypeScript'],
         default: defaultTemplate,
       });
     }
     if (!options.git) {
       questions.push({
         type: 'confirm',
         name: 'git',
         message: 'Initialize a git repository?',
         default: false,
       });
     }
     // 使用 inquirer 进行交互式查询,并获取用户答案选项
     const answers = await inquirer.prompt(questions);
     return {
       ...options,
       template: options.template || answers.template,
       git: options.git || answers.git,
     };
    }
    export async function cli(args) {
     let options = parseArgumentsIntoOptions(args);
     options = await promptForMissingOptions(options);
     console.log(options);
    }
    

    这样一来,我们就可以获取到类似:

    {
     skipPrompts: false,
        git: false,
        template: 'JavaScript',
        runInstall: false
    }
    

    相关的配置了。

    下面我们需要完成下载模板到本地的逻辑,我们事先准备好两种名为typescript和javascript的模板,并将相关的模板存储在项目的根目录中。当然你在实际开发应用中,可以内置更多的模板。

    我们使用ncp包实现跨平台递归拷贝文件,使用chalk做个性化输出。安装相关依赖如下:

    npm install ncp chalk
    

    在src/目录下,创建新的文件main.js,代码如下:

    import chalk from 'chalk';
    import fs from 'fs';
    import ncp from 'ncp';
    import path from 'path';
    import { promisify } from 'util';
    const access = promisify(fs.access);
    const copy = promisify(ncp);
    // 递归拷贝文件
    async function copyTemplateFiles(options) {
     return copy(options.templateDirectory, options.targetDirectory, {
       clobber: false,
     });
    }
    // 创建项目
    export async function createProject(options) {
     options = {
       ...options,
       targetDirectory: options.targetDirectory || process.cwd(),
     };
     const currentFileUrl = import.meta.url;
     const templateDir = path.resolve(
       new URL(currentFileUrl).pathname,
       '../../templates',
       options.template.toLowerCase()
     );
     options.templateDirectory = templateDir;
     try {
       // 判断模板是否存在
       await access(templateDir, fs.constants.R_OK);
     } catch (err) {
       // 模板不存在 
       console.error('%s Invalid template name', chalk.red.bold('ERROR'));
       process.exit(1);
     }
     // 拷贝模板
     await copyTemplateFiles(options);
     console.log('%s Project ready', chalk.green.bold('DONE'));
     return true;
    }
    

    上述代码我们通过import.meta.url来获取当前模块的 URL 路径,并通过fs.constants.R_OK判断对应模板是否存在。此时cli.js关键内容为:

    import arg from 'arg';
    import inquirer from 'inquirer';
    import { createProject } from './main';
    function parseArgumentsIntoOptions(rawArgs) {
    // ...
    }
    async function promptForMissingOptions(options) {
    // ...
    }
    export async function cli(args) {
     let options = parseArgumentsIntoOptions(args);
     options = await promptForMissingOptions(options);
     await createProject(options);
    }
    

    接下来,我们需要完成git的初始化以及依赖安装工作,这时候需要用到以下内容。

    • execa:允许开发中使用类似git的外部命令。

    • pkg-install:使用yarn install或npm install安装依赖。

    • listr:给出当前进度 progress。

    执行安装依赖:

    npm install execa pkg-install listr
    

    更新main.js为:

    import chalk from 'chalk';
    import fs from 'fs';
    import ncp from 'ncp';
    import path from 'path';
    import { promisify } from 'util';
    import execa from 'execa';
    import Listr from 'listr';
    import { projectInstall } from 'pkg-install';
    const access = promisify(fs.access);
    const copy = promisify(ncp);
    // 拷贝模板
    async function copyTemplateFiles(options) {
     return copy(options.templateDirectory, options.targetDirectory, {
       clobber: false,
     });
    }
    // 初始化 git
    async function initGit(options) {
     // 执行 git init
     const result = await execa('git', ['init'], {
       cwd: options.targetDirectory,
     });
     if (result.failed) {
       return Promise.reject(new Error('Failed to initialize git'));
     }
     return;
    }
    // 创建项目
    export async function createProject(options) {
     options = {
       ...options,
       targetDirectory: options.targetDirectory || process.cwd()
     };
     const templateDir = path.resolve(
       new URL(import.meta.url).pathname,
       '../../templates',
       options.template
     );
     options.templateDirectory = templateDir;
     try {
       // 判断模板是否存在
       await access(templateDir, fs.constants.R_OK);
     } catch (err) {
       console.error('%s Invalid template name', chalk.red.bold('ERROR'));
       process.exit(1);
     }
     // 声明 tasks
     const tasks = new Listr([
       {
         title: 'Copy project files',
         task: () => copyTemplateFiles(options),
       },
       {
         title: 'Initialize git',
         task: () => initGit(options),
         enabled: () => options.git,
       },
       {
         title: 'Install dependencies',
         task: () =>
           projectInstall({
             cwd: options.targetDirectory,
           }),
         skip: () =>
           !options.runInstall
             ? 'Pass --install to automatically install dependencies'
             : undefined,
       },
     ]);
     // 并行执行 tasks
     await tasks.run();
     console.log('%s Project ready', chalk.green.bold('DONE'));
     return true;
    }
    

    这样一来,我们的命令行就大功告成了。

    接下来我们主要谈谈模板维护问题,上述实现中,模板维护在了本地。为了更大范围的合作,模板可以共享到 GitHub 中。我们可以在 package.json 文件中声明 files 字段,如下所示:

    },
     "files": [
       "bin/",
       "src/",
       "templates/"
     ]
    }
    

    以此来声明哪些文件可以被npm publish出去。

    另外一种做法是将模板单独维护到一个 GitHub 仓库当中。在创建一个项目时,我们使用 download-git-repo来下载模板。

    从命令行到万能脚手架

    前面我们分析了一个命令行的实现和开发原理,这些内容并不复杂。但如何从一个命令行升级到一个万能脚手架呢?我们继续探讨。

    使用命令行启动并创建一个基于模板的项目只能说是一个脚手架的雏形。对比大家熟悉的vue-cli、create-react-app、@tarojs/cli、umi等,我们还需要从可伸缩性、用户友好性方面考虑:

    • 如何使模板支持版本管理

    • 模板如何进行扩展

    • 如何进行版本检查和更新

    • 如何自定义构建

    下面我们分别来讨论。

    模板支持版本管理可以使用 npm 维护模板,这样借助 npm 的版本管理,我们可以天然地支持不同版本的模板。当然在脚手架的设计中,要加入对版本的选择和处理。

    如前文所说,模板扩展可以借助中心化手段,集成开发者力量,提供模板市场。这里需要注意的是,针对不同模板或功能区块的可插拔性是非常重要的。下面我们会具体展开。

    版本检查可以使用 npm view @lucas/create-project version\ 来进行版本检查,并根据环境版本,提示用户更新。

    构建是一个老大难问题,不同项目的构建需求是不同的。参照第 23 讲“npm scripts:打造一体化的构建和部署流程”所讲,不同构建脚本可以考虑单独抽象,提供可插拔式封装。比如jslib-base这个库的设计,这也是一个“万能脚手架”。

    我们具体来看,使用脚手架初始化一个项目的过程,本质是根据输入信息进行模板填充。比如,如果开发者选择使用 TypeScript 以及英语环境构建项目,并使用 rollup 进行构建。那么核心流程中在初始化 rollup.config.js 文件时,我们读取 rollup.js.tmpl,并将相关信息(比如对 TypeScript 的编译)填写到模板中。

    类似的情况还有初始化 .eslintrc.ts.json、package.json、CHANGELOG.en.md、README.en.md,以及 doc.en.md 等。

    所有这些文件的生成过程都需要可插拔,更理想的是,这些插件是一个独立的运行时。因此我们可以将每一个脚手架文件(即模板文件)的初始化视作一个独立的应用,由命令行统一指挥调度。

    比如 jslib-base 这个库对于 rollup 构建的处理,支持开发者传入 option,由命令行处理函数,结合不同的配置版本进行自定义分配。具体代码如下:

    const path = require('path');
    const util = require('@js-lib/util');
    function init(cmdPath, name, option) {
        // type 为 js 和 ts 两种
        const type = option.type;
      // module 分为:umd/esm/commonjs
        const module = option.module = option.module.reduce((prev, name) => (prev[name] = name, prev), ({}));
      // rollup 基本配置
        util.copyTmpl(
            path.resolve(__dirname, `./template/${type}/rollup.js.tmpl`),
            path.resolve(cmdPath, name, 'config/rollup.js'),
            option,
        );
      // umd 模式
        if (module.umd) {
            util.copyFile(
                path.resolve(__dirname, `./template/${type}/rollup.config.aio.js`),
                path.resolve(cmdPath, name, 'config/rollup.config.aio.js')
            );
        }
        // esm 模式
        if (module.esm) {
            util.copyFile(
                path.resolve(__dirname, `./template/${type}/rollup.config.esm.js`),
                path.resolve(cmdPath, name, 'config/rollup.config.esm.js')
            );
        }
        // commonjs 模式
        if (module.commonjs) {
            util.copyFile(
                path.resolve(__dirname, `./template/${type}/rollup.config.js`),
                path.resolve(cmdPath, name, 'config/rollup.config.js')
            );
        }
    
        util.mergeTmpl2JSON(
            path.resolve(__dirname, `./template/${type}/package.json.tmpl`),
            path.resolve(cmdPath, name, 'package.json'),
            option,
        );
        if (type === 'js') {
            util.copyFile(
                path.resolve(__dirname, `./template/js/.babelrc`),
                path.resolve(cmdPath, name, '.babelrc')
            );
        } else if (type === 'ts') {
            util.copyFile(
                path.resolve(__dirname, `./template/ts/tsconfig.json`),
                path.resolve(cmdPath, name, 'tsconfig.json')
            );
        }
    }
    module.exports = {
        init: init,
    }
    

    如上代码,根据用户输入,使用了不同版本的 rollup 构建内容。

    相信你了解了这些内容,对于实现一个自己的 create-react-app、vue-cli 会更有心得和启发。

    总结

    这一讲我们从开发一个命令行入手,分析了实现一个脚手架的方方面面。实现一个企业级脚手架需要不断打磨和优化,不断增强用户体验和可操作性,比如处理边界情况、终端提示等。更重要的是,对构建逻辑的抽象和封装,根据业务需求,不断扩展命令和模板。

    本讲内容总结如下:

    前端基建 金句.png

    从 0 到 1 简单,但是从 1 开始出发,就需要开发者不断思考和总结。下一讲我们将开启 Node.js 的学习,来实现一个 SSR 应用。我们会直入正题,不再过多学习 Node.js 的基础内容,也请你提前做好准备。


    # 精选评论

    # *哲

    执行 npm link 时出错:npm ERR! path /usr/local/lib/node_modules/create-project/bin/create-projectnpm ERR! errno -2npm ERR! enoent ENOENT: no such file or directory, chmod '/usr/local/lib/node_modules/create-project/bin/create-project'

    #     讲师回复

        从报错来看,是找不到该路径对应的文件,先确认一下改路径下是否有文件,再看一下是否有创建文件的权限

    # *斌

    有 github repo 吗?

    #     编辑回复

        注意看超链接的地方,相关地址都在哈~

    编辑 (opens new window)
    上次更新: 2025/03/17, 12:21:00
    如何设计一个前端 + 移动端离线包方案?
    同构渲染架构:实现一个 SSR 应用

    ← 如何设计一个前端 + 移动端离线包方案? 同构渲染架构:实现一个 SSR 应用→

    最近更新
    01
    Code Review
    10-14
    02
    ComfyUI
    10-11
    03
    vscode插件开发
    08-24
    更多文章>
    Power by vuepress | Copyright © 2015-2025 神族九帝
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式
    ×