前端工程化-脚手架篇

23,710 阅读3分钟

工程化是软件工程的一种。性能,稳定性,可用性,可维护性,效率等都是工程化需要解决的。

前端的真正的发展,也就这么10年时间,工程化发展还不够完善。

而我们日常的开发,首先要解决创建项目问题。为此vue社区出现了vue-cli, react社区出现了create-react-app,然而这些脚手架只是些通用解决方案,他们不关注上层的设计,比如:统一ajax封装,library external,docker的构建,模板工程约束,固化团队风格的代码规范约束,公共依赖的统一控制,服务的监控报警,拨测,强缓存控制,CDN接入等等。

如果我们有多服务创建需求(庞大应用拆分,多项目),上述的问题,需要手动一个个接入,效率低下。

此时,我们需要一个脚手架来解决这些问题。

本文将以react为例,实现简单的脚手架, 完整的脚手架可以私我~

一. lerna

lerna是一个多包管理工具,他主要解决多个package相互依赖和管理的问题。

一般来说,我们的脚手架cli和模板工程,webpack scripts,以及utils都是 分包管理的,而这些包之间又有相互依赖关系。

更多信息,请参考: github.com/lerna/lerna

初始化项目

lerna init

通过lerna init 初始化一个多包项目,lerna自动为我们创建的lerna.json以及pakcages。这个pakcages就是我们将开发的各个包目录。

创建cli工程

# 创建cli项目
lerna create gw-cli 

# 创建webpack scripts
lerna create gw-scripts

# 创建模板工程
lerna create gw-web-template

# 创建eslint, prettier模块
lerna create eslint-config-gw

其他命令:

初始化各个packages依赖

lerna bootstrap

为包相互依赖创建软连接

lerna link

包开发完成后,多包依赖自动发布

发布

lerna publish

二. gw-scripts

cli bin

进入gw-scripts文件夹,创建bin文件夹,bin文件下创建index.js,此时,需要注意的是,package.json中需要添加如下配置:

"bin": {
  "gw-scripts": "./bin/index.js"
}

这意味着,当我们执行gw-scripts命令时,将执行bin/index.js内容:

(./bin/index.js):

#!/usr/bin/env node

const spawn = require('cross-spawn');
const chalk = require("chalk");
const args = process.argv.slice(2);

const scriptIndex = args.findIndex(
  x => x === 'prod' || x === 'develop'
);
const script = scriptIndex === -1 ? args[0] : args[scriptIndex];
const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : [];

if(['develop', 'prod'].includes(script)) {
  /**
   * @returns result 
   * {
   *    status: 0,
   *    singal: null,
   *    output: [],
   *    pid: xx,
   *    error: '',
   *    ...
   * }
   */
  const result = spawn.sync(process.execPath,
    nodeArgs
      .concat(require.resolve('../scripts/' + script))
      .concat(args.slice(scriptIndex + 1)),
    { stdio: 'inherit' }
  );

  if(result.signal) {
    console.log(`build error : signal = ${result.signal}`);
    console.log(`result = ${JSON.stringify(result)}`);
    process.exit(1);
  }
  process.exit(result.status);
}else {
  console.log()
  console.log( chalk.red(`not exist script : ${script}`) );
  console.log();
  console.log( chalk.cyan("your can run develop or prod") );
}

安装依赖

yarn add @babel/core@7.12.3 
babel-loader@8.1.0 
babel-plugin-named-asset-import@0.3.7 
babel-preset-react-app@10.0.0 
mini-css-extract-plugin@0.6 
optimize-css-assets-webpack-plugin@6.0.1 
progress-bar-webpack-plugin@2.1.0 
sass-loader@10.0.5 
style-loader@1.3.0 
url-loader@4.1.1

配置react webpack dev

仅供参考

const HtmlWebpackPlugin = require("html-webpack-plugin");
const ProgressBarPlugin = require("progress-bar-webpack-plugin");
const paths = require('./paths');

process.env.NODE_ENV = 'development';

const hasJsxRuntime = (() => {
  try {
    require.resolve('react/jsx-runtime');
    return true;
  } catch (e) {
    return false;
  }
})();

const config = {
  entry: paths.appIndexJs,
  output: {
    filename: 'bundle.js',
    path: paths.appBuild,
    publicPath: "/",
    library: paths.appName,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${paths.appName}`,
  },
  resolve: {
    alias: {
      "@": paths.appSrc
    },
    extensions: [".js", ".json", ".jsx", ".css", ".scss", ".tsx"]
  },
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    "antd": "antd"
  },
  module: {
    strictExportPresence: true,
    rules: [
      {
        parser: {
          // require.ensure 不是标准,不允许使用
          requireEnsure: false
        }
      },
      {
        oneOf: [
          {
            test: /\.(js|jsx|ts|tsx)$/,
            exclude: /node_modules/,
            loader: require.resolve('babel-loader'),
            options: {
              babelrc: false,
              configFile: false,
              presets: [
                [
                  require.resolve('babel-preset-react-app'),
                  {
                    runtime: hasJsxRuntime ? 'automatic' : 'classic',
                  },
                ],
              ],
              // webpack 未来的提案,将缓存编译的结果在: ./node_modules/.cache/babel-loader/
              cacheDirectory: true,
              cacheCompression: false,
              compact: false
            }
          },
          // css module
          {
            test: /\.module\.css$/,
            use: [
              'style-loader',
              'css-loader'
            ]
          },
          // // scss
          {
            test: /\.(scss|sass)$/,
            use: [
              'style-loader',
              'css-loader',
              'sass-loader'
            ]
          },
          // // sass module
          {
            test: /\.module\.(scss|sass)$/,
            use: [
              'style-loader',
              'css-loader',
              'sass-loader'
            ]
          }
        ]
      }
    ]
  },

  plugins: [
    new HtmlWebpackPlugin({
      inject: true,
      filename: "index.html",
      template: "public/index.html"
    }),
    new ProgressBarPlugin()
  ],

  node: {
    module: 'empty',
    dgram: 'empty',
    dns: 'mock',
    fs: 'empty',
    http2: 'empty',
    net: 'empty',
    tls: 'empty',
    child_process: 'empty',
  },
  performance: false,

  mode: 'development'
}

module.exports = config;

react webpack prod

仅供参考


const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ProgressBarPlugin = require("progress-bar-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const paths = require('./paths');

process.env.NODE_ENV = 'production';
process.env.BABEL_ENV = 'production';

module.exports = {
  mode: "production",
  bail: true,
  devtool: false,
  entry: paths.appIndexJs,
  output: {
    path: paths.appBuild,
    filename: "static/js/[name].[chunkhash:8].js",
    chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
    publicPath: '',
    library: paths.appName,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${paths.appName}`,

    devtoolModuleFilenameTemplate: info => path.resolve(paths.appSrc, info.absoluteResourcePath).replace(/\\/g, "/")
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          parse: {
            ecma: 8
          },
          compress: {
            ecma: 5,
            warnings: false,
            comparisons: false
          },
          mangle: {
            safari10: true
          },
          output: {
            ecma: 5,
            comments: false,
            ascii_only: true
          }
        },
        parallel: true,
        cache: true,
        sourceMap: false
      }),
      new OptimizeCSSAssetsPlugin({
        // 启用 cssnano safe模式
        cssProcessorOptions: {
          safe: true
        }
      })
    ],
    splitChunks: {
      chunks: "all",
      name: true,
      maxInitialRequests: Infinity
    },
    runtimeChunk: true
  },
  resolve: {
    alias: {
      "@": paths.appSrc
    },
    extensions: [".js", ".json", ".jsx", ".css", ".scss", ".tsx"]
  },
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    "antd": "antd"
  },
  module: {
    strictExportPresence: true,
    rules: [
      {
        parser: {
          // require.ensure 不是标准,不允许使用
          requireEnsure: false
        }
      },
      {
        oneOf: [
          {
            test: /\.(js|jsx|ts|tsx)$/,
            exclude: /node_modules/,
            loader: require.resolve('babel-loader'),
            options: {
              babelrc: false,
              configFile: false,
              presets: [
                [
                  require.resolve('babel-preset-react-app'),
                  {
                    runtime: 'automatic',
                  },
                ],
              ],
              // webpack 未来的提案,将缓存编译的结果在: ./node_modules/.cache/babel-loader/
              cacheDirectory: true,
              cacheCompression: false,
              compact: false
            }
          },
          // css module
          {
            test: /\.module\.css$/,
            use: [
              'style-loader',
              'css-loader'
            ]
          },
          // scss
          {
            test: /\.(scss|sass)$/,
            use: [
              'style-loader',
              'css-loader',
              'sass-loader'
            ]
          },
          // sass module
          {
            test: /\.module\.(scss|sass)$/,
            use: [
              'style-loader',
              'css-loader',
              'sass-loader'
            ]
          }
        ]
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      inject: true,
      filename: "index.html",
      template: "public/index.html"
    }),
    new ProgressBarPlugin(),
    new MiniCssExtractPlugin({
      filename: "static/css/[name].[contenthash:8].css",
      chunkFilename: "static/css/[name].[contenthash:8].chunk.css"
    }),
    new ManifestPlugin({
      fileName: "asset-manifest.json"
    }),
  ],
  node: {
    dgram: "empty",
    fs: "empty",
    net: "empty",
    tls: "empty",
    child_process: "empty"
  },
  performance: false
}

校验开发环境端口使用情况

const chalk = require('chalk');
const detect = require('detect-port-alt');

function choosePort(host, defaultPort) {
  return detect(defaultPort, host).then(
    port => {
      return new Promise(resolve => {
        if (port === defaultPort) {
          return resolve(port);
        }else {
          console.log(chalk.red(`Something is already running on port ${defaultPort}`))
          return resolve(null);
        }
      })
    },
    err => {
      throw new Error(
        chalk.red(`error at ${chalk.bold(host)}.`) +
          '\n' +
          ('Network error message: ' + err.message || err) +
          '\n'
      );
    }
  )
}

merge config

自定义webpack配置,可以放置根目录,如新建 gw.config.js,使用webpack-merge 合并配置

创建dev compiler 对象

function createCompiler(webpack, config) {
  let compiler;
  try {
    compiler = webpack(config);
  } catch (err) {
    console.log(chalk.red('Failed to compile.'));
    console.log();
    console.log(err.message || err);
    console.log();
    process.exit(1);
  }

  compiler.plugin('invalid', () => {
    console.log('Compiling...');
  });

  compiler.plugin('done', stats => {
    const messages = stats.toJson({}, true)
    const isSuccessful = !messages.errors.length && !messages.warnings.length;
    // 成功
    if (isSuccessful) {
      console.log(chalk.green('Compiled successfully!'));
    }

    // error
    if (messages.errors.length) {
      console.log(chalk.red('Failed to compile.\n'));
      console.log(messages.errors.join('\n\n'));
      return;
    }

    // 警告
    if (messages.warnings.length) {
      console.log(chalk.yellow('Compiled with warnings.\n'));
      console.log(messages.warnings.join('\n\n'));
    }
  });

  return compiler;
}

配置dev server

module.exports = function (proxy, allowedHost) {
  return {
    disableHostCheck: true,
    compress: true,
    clientLogLevel: 'none',
    contentBase: paths.appPublic,
    watchContentBase: true,
    hot: true,
    publicPath: config.output.publicPath,
    quiet: true,
    watchOptions: {
      ignored: '/node_modules/',
      poll: true
    },
    host: host,
    overlay: true, // 编译出现错误时,将错误直接显示在页面上
    historyApiFallback: {
      disableDotRule: true,
    },
    stats: "normal",
    public: allowedHost,
    proxy,
    before(app) {
      // console.log('before',app)
    },
    after(app) {
      // console.log('after')
    },
  };
};

创建dev server实例

const devServer = new WebpackDevServer(compiler, serverConfig);

  devServer.listen(port, HOST, err => {
    if(err) {
      console.log(err);
      return;
    }
    ["SIGINT", "SIGTERM"].forEach(function(sig) {
      process.on(sig, function() {
        devServer.close();
        process.exit();
      });
    });
  });

三. eslint-config-gw

关于eslint重要性,这里不再赘述。 eslint的所有规则 不一定都要使用,而是使用 适合团队的 常用规范即可。

eslint规则,可以封装成单独模块,可以使用在web端, react native等,利用eslint extend ,我们就可以轻松的使用 公共基础规则。

基础rules

{
    // 使用驼峰
    "camelcase": 1,
    // 禁用为声明的变量
    "no-undef": 2,
    // 禁止扩展原生类型,比如添加Object的原型
    "no-extend-native": 2,
    // 禁止在return语句中,使用赋值
    "no-return-assign": 2,
    // 自动调整import顺序
    "import/order": 0,
    // 禁止导入未在package.json中声明的依赖,可能是父级中有声明
    "import/no-extraneous-dependencies": 0,
    // commonjs的require与esm不同,可以提供运行时的动态解析,虽然有的场景需要使用,但建议还是静态化
    "import/no-dynamic-require": 0,
    // 某些文件解析算法运行省略引用文件扩展名,此处不需要强干预
    "import/extensions": 0,
    // 导入模块是文本文件系统的模块,此处关闭,避免eslint不认识webpack alias
    "import/no-unresolved": 0,
    // 当模块只有1个导出的时候,更喜欢使用export default,而不是命名导出
    "import/prefer-default-export": 0,
    // 禁止出现未使用的变量
    "no-unused-vars": [2, { "vars": "all", "args": "none" }],
    // 强制generate函数中的 * 周围使用一致的空格
    "generator-star-spacing": 0,
    // 禁止使一元操作符,此处不需要禁止
    "no-plusplus": 0,
    // 要求使用命名的function表达式,此处不需要
    "func-names": 0,
    // 禁止使用console.log,某些场景可能需要console打印日志
    "no-console": 0,
    // 强制在parseInt中使用基数参数,此处可以不需要
    "radix": 0,
    // 禁止正则表达式中使用控制语句,比如:/\x1f/
    "no-control-regex": 2,
    // 禁止使用continue,有些情况可能需要
    "no-continue": 0,
    // 禁止使用debugger,生产环境是不允许的,开发环境可以支持debugger
    "no-debugger": process.env.NODE_ENV === 'production' ? 2 : 0,
    // 禁止对function参数进行赋值,此处不强求,会警告
    "no-param-reassign": 1,
    // 禁止标识符出现 '_', 有时候lodash会出现,此处不强求,会警告
    "no-underscore-dangle": 1,
    // 要求require写在代码最上方,可能会先需要动态require的情况,此处给出警告
    "global-require": 1,
    // 定义变量,要求使用let, const,而不是 var
    "no-var": 2,
    // 要求所有的var,必须在作用域的顶部
    "vars-on-top": 2,
    // 优先使用对象和数组解构
    "prefer-destructuring": 1,
    // 禁止不必要的字符串字面量或模板字面量链接
    "no-useless-concat": 1,
    // 禁止声明与外层作用域同名的变量
    "no-shadow": 2,
    // 要求for in中,必须出现一个if语句,而不去过滤结果,可能出现bug,此处不需要
    "guard-for-in": 0,
    // 禁止使用特定语法,可以参考:https://cn.eslint.org/docs/rules/no-restricted-syntax
    "no-restricted-syntax": 1,
    // 强制方法必须有返回值,实际上是不一定
    "consistent-return": 0,
    // 判断相等,必须使用 === ,而不是 ==
    "eqeqeq": 2,
    // 禁止出现未使用过的表达式
    "no-unused-expressions": 1,
    // 在块级作用域外访问块内的变量,是否提示
    "block-scoped-var": 1,
    // 禁止多次声明,同一个变量
    "no-redeclare": 2,
    // 强制回调函数使用箭头函数
    "prefer-arrow-callback": 1,
    // 强制数组的回调方法中,需要return
    "array-callback-return": 1,
    // 要求switch语句,需要有default分支
    "default-case": 2,
    // 禁止在循环中,出现function声明和表达式
    "no-loop-func": 2,
    // 禁止case语句落空
    "no-fallthrough": 2,
    // 禁止连接赋值,let a = b = c = 1;
    "no-multi-assign": 2,
    // 禁止 if单独出现在 else语句中,else if会更加清晰
    "no-lonely-if": 2,
    // 禁止在字符串中不规范的空格,注释除外
    "no-irregular-whitespace": 2,
    // 要求使用const声明不再修改的变量
    "prefer-const": 2,
    // 禁止在定义变量之前使用变量
    "no-use-before-define": 2,
    // 禁止不必要的转义字符
    "no-useless-escape": 2,
    // 禁用array构造函数,可以参考:https://eslint.org/docs/rules/no-array-constructor#disallow-array-constructors-no-array-constructor
    "no-array-constructor": 0,
    // 要求或禁止字面量中的方法属性简写
    "object-shorthand": 0,
    // 禁止直接调用Object.prototype内置属性
    "no-prototype-builtins": 2,
    // 禁止多层嵌套三元表达式
    "no-nested-ternary": 2,
    // 禁止对String, Number, Boolean使用new操作符,没有任何理由将这些基本类型包装成构造函数
    "no-new-wrappers": 2,
    // promise reject的原因,需要通过Error对象包装
    "prefer-promise-reject-errors": 1,
    // 禁止使用label语句
    "no-labels": 2,
    // 格式化
    "prettier/prettier": 2
}

vscode集成

  • 首先安装vscode eslint插件,安装perttier插件。
  • 配置vscode -> 文件 -> 首选项 -> setting.json,设置如下规则:
{
  "eslint.validate": [
    {
      "language": "vue",
      "autoFix": true
    },
    {
      "language": "html",
      "autoFix": true
    },
    {
      "language": "javascript",
      "autoFix": true
    },
    {
      "language": "javascriptreact",
      "autoFix": true
    }
  ],
  "eslint.autoFixOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "window.zoomLevel": 0,
  "editor.fontSize": 16
}

四. gw-web-template

react web项目,需要一个模板工程。 控制每个项目的 react, react-router, react-dom, antd,以及库的版本号很重要。基础库的建设,组件库的建设,以及后期接入更友好的接入微前端,都十分重要。

模板工程

一个模板工程,应该内置这些(不局限于):

  • react, react-dom, react-router, antd, reset.css 等基础库,并CDN引入
  • 静态资源的构建path,以及上层的ingress的强缓存控制
  • 合理的code split,以及hash chunk控制
  • 统一css模块化方案,这里采用 css module
  • 前端容器化部署,构建私有镜像库,内置符合业务的dockerfile。以及合理的镜像资源优化(如:镜像分层)
  • 内置eslint
  • 内置prettier
  • 集成单元测试
  • 常见的gitigore
  • 内置ts,以及合理的tsconfig设置
  • 统一ajax库引入,需要统一封装。可以是直接调用,也可以是hooks方式调用
  • 集成redux,react-router,路由权限控制
  • 自定义hooks库
  • 自定义组件库
  • 构建脚本,集成运维CI,CD
  • 接入上层的服务拨测程序,或k8s的心跳
  • 接入自定义异常监控服务
  • 约束制定统一的规范组件,友好的接入前端自动化测试
  • 合理的模块化方案,统一assert Path前缀,以便在上层的前端ingress转发和收集信息
  • ...

以上都是模板工程必须做的,同时对于脚手架使用者都是 屏蔽的。 即脚手架使用者不关注这些,开发人员只需投入业务开发即可。

工程结构

工程结构并非一成不变,这只是团队的约束规范,大家统一遵守。

以下仅供参照

|--build            打包的结果文件夹
|--public           公共文件夹
|--src              源码文件夹
    |-- api           接口定义
    |-- assets        iconfont等
    |-- config        配置类文件夹,如axios配置
    |-- hooks         项目级hooks公共库
    |-- components    项目级组件库
    |-- model         数据存储,如 redux
    |-- page          视图层文件夹
    |-- router        路由定义文件夹
    |-- util          工具类文件夹
    |-- index.tsx     主入口
|--types            typescript 类型定义文件夹
|--.editorconfig
|--.eslintignore
|--.eslintrc.js
|--.gitattributes
|--.gitignore
|--.npmignore
|--.prettierignore
|--build.sh         构建脚本
|--Dockerfile         
|--package.json
|--README.md
|--run.sh           容器镜像构建时需要执行的shell
|--tsconfig.json    ts规则配置

五. cli

以上模块准备结束后,我们需要做一个npm模块,让用户安装。 自定义交互命令。这就是cli的主要作用。

理论上,命令行交互模块只做 交互的事件, scripts和模板工程以及其他模块,应该单独拆分,便于复用。

命令行交互设计,也取决于各个公司的实际情况。

大致可以分个大类:

  • react web
  • react native
  • 文档库
  • 组件库
  • 微前端基座
  • 微前端子服务
  • hooks库
  • ...

注意: 二级交互,不需要设置 eslint,ts等是否可选, 对于整个团队来说,最好统一规范,统一使用。 不必借鉴vue-cli, cra去做。 因为大家面对的 使用者 不一样,最终的目的也不一样。

设置全局命令

package.json中添加:

"bin": {
  "gw": "./index.js"
},

校验node版本

const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = semver[0];

if (major < 10) {
  console.error(
    'You are running Node ' +
      currentNodeVersion +
      '.\n' +
      'gw cli requires Node 10 or higher. \n' +
      'we recommand 10.18.0'
  );
  process.exit(1);
}

commander

使用commander获取用户输入命令。

比如,我们希望

gw create <project-name>

代表创建一个项目。

我们也可以集成运行时,借鉴Ruby on Rails的交互,可以自动创建文件。

我们希望,通过如下命令,自动创建页面所需的index.tsx, index.module.css, dto.ts, index.test.js等等。

gw page <page-path>

等等...

我们可以自由发挥,但目的只有一个: 让重复性的工作交给机器

设置交互

交互模块使用 inquirer,可以做如下封装:

const inquirer = require("inquirer");

module.exports = function(callback) {
  const prompt = inquirer.createPromptModule();
  prompt([
    {
      name: "type",
      type: 'list',
      message: "'Which basic framework do you want to choose!",
      choices: [
        { name: "react web", value: "web" },
        { name: "文档库", value: "doc" },
        { name: "组件库", value: 'component' },
        { name: "react native app", value: "react-native" },
        { name: "hooks库", value: "hooks" },
        { name: "微前端基座", value: "micro-pedestal" },
        { name: "微前端子服务", value: "micro-service" }
      ]
    }
  ]).then(answer => {
    callback(answer.type);
  });
}

create project

当我们选择创建react-web时,我们如何获取模板工程?

大致有三种方式:

  • 远程clone模板工程

  • npm安装模板工程,再cp -r *

  • 模板工程和cli放在一起,直接cp -r *

这里,我们可以选择 npm 安装, 个人建议这种。

安装代码,可参考:

/*
* @param {String}          root             安装目录
* @param {Array<string>}   dependencies     依赖数组
* @param {Boolean}         verbose
*/
function install(root, dependencies, verbose) => {
    return new Promise((resolve, reject) => {
      let command = 'yarnpkg';
  
      let args = ['add', '--exact'];
  
      [].push.apply(args, dependencies);
  
      args.push('--cwd');
      args.push(root);
  
      if (verbose) {
        args.push('--verbose');
      }
  
      const child = spawn(command, args, { stdio: 'inherit' });
      child.on('close', code => {
        if (code !== 0) {
          reject({
            command: `${command} ${args.join(' ')}`,
          });
          return;
        }
        resolve();
      });
    });
  }

效果展示

image.png

image.png

六. 总结

前端的工程化是每位高工必须专研的方向,工程化 !== webpack,而整个大前端工程化都 基于 nodejs。 nodejs可以说是前端工程师的必修课啦~

码字不易,多多点赞关注~