如何系统化地搭建一个功能完备的前端应用脚手架

813 阅读3分钟

本文中展示的代码均为项目中截取并处理后的代码片段,可能无法直接运行。完整项目请参考 Intt

现在市面上已经存在很多成熟的脚手架,如 vue-clicreate-react-app 等,但在日常工作或学习中我们常常会有需要选择不同的构建工具,不同的框架以及其它所需的依赖快速创建一个项目的需求,那么能否搭建一个可自定义配置的通用脚手架来满足这些场景的需求呢?

ezgif.com-gif-maker.gif

先实现一个雏形

在开始系统地搭建项目之前,我们先以最基础的要求实现一个简单的雏形,以便我们了解这个脚手架的工作流程。

这个脚手架会以命令行的方式执行,接收一个命令行参数作为项目名称,并询问用户选择 react 或是 vue 作为主框架。当然一开始我们不需要真的生成一个完整的项目,只是需要象征性地写入一行 Hello React 或是 Hello Vue 到示例文件中。

首先需要安装两个工具:commander 用于命令行指令解析;prompts 用于向用户发起询问并取得答案。其次编写一段最基本的逻辑使程序得以执行,并取得我们想要的参数。

import { Command } from 'commander'
import prompts from 'prompts'

const program = new Command()

program
  .argument('[app]') // 接收命令行参数作为项目名称
  .description('create a new project')
  .action(async name => {
    // 询问用户需要的框架
    prompts([
      {
        type: 'select',
        name: 'framework',
        message: 'Select a framework: ',
        choices: [
          { title: 'React', value: 'react' },
          { title: 'Vue', value: 'vue' }
        ]
      }
    ]).then(({ framework }) => {
      console.log({ name, framework }) // { name: 'my-app', framework: 'react' }
    })
  })

program.parse(process.argv)

运行这段代码

$ node index.js my-app

至此我们便能够得到项目的名称 name 及选择的框架 framework。接下来就可以通过这两项参数去生成目标文件。这里只需要简单地实现即可。

const root = path.resolve(process.cwd(), name)

fs.mkdirSync(root)

const files = {
  'index.js': feature === 'react' ? "console.log('Hello React!')" : "console.log('Hello Vue!')",
}

for (const key in files) {
  const path = path.resolve(root, key)
  fs.writeFileSync(path, "console.log('Hello Vue!')")
}

再次运行,不出所料的话一个新的项目文件夹已经被创建,并在其中生成了一个示例文件 index.js

总结一下,项目大体的执行流程为:接收用户输入参数 -> 生成文件结构 -> 写入目标路径。了解了流程之后,就可以进行进一步的工作了。

重新规划流程

在上文中,我们在执行生成文件内容的逻辑中,只是简单地使用条件判断去做了处理。但当项目中提供的可选配置项逐渐增多,例如我们想要提供不同的构建工具 (Webpack, Parcel, Snowpack)、不同的主框架(React, Vue, Svelte)以及各种额外的 UI 库测试库等等,如果仍然选择去新增一连串的条件分支,甚至不同的条件分支还会互相产生影响,最终可能会导致项目的逻辑变得愈发混乱。

那么我们需要重新规划一下整个项目的流程:

  1. 将各个功能选项分装至单独的插件中,插件会描述每个功能所需的配置及依赖等内容,如 React 插件需要安装依赖 reactreact-dom,以及需要配置 bable 的预设 '@babel/preset-react'

  2. 为基础的配置文件分别定义 generator 函数,接收插件返回的配置,并生成对应的文件内容,如 package.json 等;

  3. 将所有生成的文件内容整合到一个单一的文件结构对象中,再根据结构将文件全部写入到目标路径。

Intt.png

从插件开始

目前已经知晓是,本次脚手架的各个功能模块都以插件的形式分装,那么首先就需要明确插件是如何定义的。我们希望各个插件间的关系是平行的,避免因为插件间互相依赖出现的耦合关系。

这里首先定义插件的格式:插件为一个默认导出的函数,其参数接受命令行在用户交互时产生的选项结果 (answers),用来在插件内部按需引用应对不同的选项场景;其返回值为一个配置对象,其结构如下:

export type PluginConfig = {
  condition?: boolean
  files?: Files
  package?: PackageConfig
  webpack?: WebpackConfig
  babel?: BabelConfig
}

这是项目中定义的插件配置的格式,下面来逐一介绍这些选项的含义:

  • condition:插件的启用条件。因为我们需要根据用户的选项不同启用不同的插件,这部分的判断交给插件自行处理。

  • files:需要直接向项目路径中写入的文件结构。例如当用户选择了 jest 插件,我们往往需要生成一个名为 jest.config.js 的配置文件,这时可以在这个选项中定义。这个一个偏开放的配置项,可以根据需要灵活使用。

  • package: package.json 的配置内容。这里选择只暴露部分选项,以满足基本的需求为宜。配置结构如下:

    export type PackageConfig = {
      scripts?: { [x: string]: string }
      dependencies?: string[]
      devDependencies?: string[]
    }
    

    值得注意的是,所需的依赖列表只接收的依赖的 name,因为依赖的版本会在后续的配置生成中自动获取最新的结果。

  • webpack: Webpack 的配置内容。这里同样选择只暴露部分常用的选项,以保证配置的简洁性。配置结构如下:

    export type WebpackConfig = {
      prefix?: string[]
      entry?: string
      extensions?: string[]
      rules?: any[]
      plugins?: string[]
    }
    
  • babel: babel 的配置内容。因为配置项比较简单,所以直接返回一个符合 Json 格式的对象即可。

到这里如果对上文的配置项尚有疑问,也可以不必深究,我们只需要对插件的定义形式有个大致的了解即可。此外也可以按喜好自行定义插件的格式。

定义一个插件

为了满足项目的基本流程,我们可以按照上文的格式先定义几个简单的插件。这里我们以 React 插件为例:

const react: Plugin = ({ framework }) => {
  return {
    condition: framework === 'react', // 当框架选项为 React 时启用此插件
    files: {
      src: {
        // 定义入口文件 index 及 App 组件内容
        'App.jsx': reactApp(),
        'index.jsx': reactIndex()
    	}
    },
    package: {
      // 配置所需依赖
      dependencies: ['react', 'react-dom'],
      devDependencies: ['@babel/preset-react']
    },
    babel: {
      // 配置 babel react 预设
      presets: ['@babel/preset-react']
    },
    webpack: {
      // 配置入口文件位置
      entry: './src/index.jsx',
      // 添加支持后缀 .jsx
      extensions: ['.jsx']
    }
  }
}

这样,一个符合格式的插件就定义好了。

当我们的项目中已经定义了数个插件,我们要做的便是引入这些插件,通过它们来生成对应的文件内容,并写入到目标路径中,以完成项目的创建。那么第一步,我们需要将这些被分装的零散的配置组合起来。

首先,依次调用这些插件,获取到它们的返回值,即插件定义的配置内容,其次以它们的 condition 字段做一次筛选,只保留当前需要启用的插件。最后将结果依次深度合并并累加,最终得到一个单一的配置对象。

const plugins = [webpack, react, vue, babel, ...]

const config = plugins
    .map(plugin => plugin(options)) // 调用插件,返回配置结果
    .filter(({ condition }) => condition) // 筛选需启用的插件
    .reduce<PluginConfig>((acc, elem) => mergeDeep(acc, elem), {}) // 将所有配置合并为最终的单一配置对象

用插件配置生成文件内容

合并的结果类型与每个插件返回的类型一致,我们便可以应用每一项的配置内容生成对应配置文件的文件内容。这里先定义一个文件路径结构:

const files: Files = {
  ...config.files,
  'package.json': await generatePackage(name, config.package),
  'webpack.config.js': generateWebpack(config.webpack)
}

这里的 generate 便是通过插件配置生成文件内容的函数。generateWebpack 函数的参数类型为上文定义的 WebpackConfig,它的作用时通过参数生成一个 webpack.config.js 文件内容的字符串,用于最终写入到文件中。

export const generateWebpack = ({
  prefix = [],
  entry = './src/index.js',
  extensions = [],
  rules = [],
  plugins = []
}: WebpackConfig = {}) => {
  const config = {
    entry,
    output: {
      // CODE: 开头的字符串将会以源代码模式处理
      path: `CODE:path.resolve(__dirname, 'dist')`,
      filename: 'index.js'
    },
    devServer: {
      contentBase: './dist'
    },
    resolve: {
      // 去除 extensions 数组重复项
      extensions: uniq(['.js', ...extensions])
    },
    module: {
      // 去除 rules 数组中 loader 相同的配置项
      rules: uniqBy(({ loader }) => loader, rules.reverse())
    },
    plugins
  }

  return `
    const path = require('path')
    ${prefix.join('\n')}

    const config = ${/* jsStringify 的作用是将 js 的值转化为源代码字符串 */jsStringify(config)}

    module.exports = config
	`
}

这里在返回结果中引用了一个 jsStringify 函数,它的作用是将 js 中的值转化为源码字符串,调用规则与 JSON.stringify 类似,例如:

jsStringify({ a: 1 }) // => '{ a: 1 }' // 使用工具库 javascript-stringify 实现

回到上文中的函数,这里值得注意的是这一行:

{ path: `CODE:path.resolve(__dirname, 'dist')` }

我们需要最终生成的配置内容为:

{ path: path.resolve(__dirname, 'dist') }

我们希望将这行代码作为源代码直接添加到文件中,因此我们需要定义一条转换规则,即以 CODE: 开头的字符串将会被识别为源代码,在 stringify 函数中去统一处理,具体逻辑如下。

() => jsStringify(
  value,
  (value, _, stringify) => {
    if (typeof value === 'string' && value.startsWith('CODE:')) {
      return value.replace(/"/g, '\\"').replace(/^CODE:/, '')
    }

    return stringify(value)
  },
  2
)

将文件结构写入目标路径

经过各项 generator 函数的执行后,我们可以得到类似这样的一个文件结构对象:

const files: Files = {
  src: {
    'App.jsx': '...',
    'index.js': '...'
  },
  'package.json': '{ ... }',
  'webpack.config.js': 'module.exports = { ... }'
}

接下来的操作就很明显了,我们去遍历这个对象,将每一项的内容分别写入到目标路径对应的文件位置中。这里同样实现一个函数去递归地完成创建:

export const writeFiles = (files: Files, dir: string) => {
  foreach((value, key) => {
    if (typeof value === 'string') {
      fs.writeFileSync(path.resolve(dir, key), value)
    }

    if (typeof value === 'object') {
      writeFiles(value, path.resolve(dir, key))
    }
  }, files)
}

最终,在目标路径中便可以看到生成的文件结构了

image-20210817235723644.png

至此,我们便完成了这个脚手架基础设施的搭建。当想要去拓展这个脚手架的功能时,我们只需要定义包含对应配置的插件,并在命令选项中提供给用户即可。而如果引入的功能是作为一个基础依赖存在,那么就需要额外新增一个 generator 来生成对应的文件内容。

总结

如果想要获取完整的项目代码,请参考 Intt,项目中更多的功能特性还在持续完善中。

最后,感谢您的阅读。