preface

This article will teach you how to design a flexible and scalable front-end engineering solution by using practical experience (trampled pit) in designing a front-end engineering solution. In order to give you a clearer understanding of the causes and effects of such a design, I will explain the design ideas and process step by step from the very beginning with a LuoLiBaSuo attitude.

Beginning 🌟

Our team used the template generated by create-React-app for the background project at the beginning of development.

For example, if you need to configure babel-plugin-import to use ant Design, you can only override the create-react-app configuration. Create-react-app does not provide a way to override the default configuration (choosing eject is not a good option because the template cannot be upgraded), so we can only use React-app-rewired for our purposes.

However, with the development of business requirements/technical requirements, we want to integrate more engineering facilities, and react-app-Rewired is not enough at this time. Moreover, we hope that each project should share a set of engineering facilities, instead of each project having to be configured separately after being newly built. Such a design is not good for the unification of technical selection specifications of the team.

In the end, we chose to develop our own scaffolding kit for our team.

The original proposal 😉

Ok, now we need two features:

  • Based on our team’s technical selection and specifications, a set of templates that integrates the default configuration, engineering facilities, and workflow are generated when a new project is created.
  • The template should be updatable and accept external customizations as well.

In order to achieve this, I referred to the creation-React-App implementation 😝 and wrote our own set of scaffolding, which is essentially a set of WebPack workflow templates packaged as an NPM package plus a template generator. The difference is that, This template works by merging the custom configuration of byted.config.js in the project directory and its own default configuration.

It’s relatively simple, but seems to fulfill our technical needs perfectly.

Defect 😵

After working happily for some time with the simple, knockoff version of the Create-React-app, we realized that it still didn’t solve some of our problems. There are two main ones:

  • Various functions can not be disassembled to use, release
    • Many projects do not need all the functions provided by scaffolding, but the various facilities provided by scaffolding itself cannot be dismantled and used. For example, some old projects only want to integrate the functions of I18N, but to use scaffolding, they need to package and compile their own components and replace them together.
    • Since our teams are located in different urban areas, each team has its own technical output and can add different functions to the scaffold. But you can’t have everyone working on a scaffolding warehouse, it’s not appropriate.
  • Just providing templates does not solve all problems
    • Since it is a command line tool that everyone installs globally (not too many tools for you to install globally, you need to integrate as many functions as possible into one), we hope that this tool will help you to simplify more issues such as triggering CI builds, submitting code for review, testing/releasing/launching, etc. Hopefully it will cover all aspects of the project from launch to rollout.

Refactoring 😈

After some reflection, I was embarrassed to find that the existing design did not solve the two problems mentioned above.

Since the current design only generates a template that I configured, the only way to solve the first problem is to break the template into more templates, em… 🤔️, this is not a good idea because there is no way to control the specification and loading of the templates, let alone putting them together. The second problem is even harder to solve, because the global installation is now just a template generator and can’t do anything else.

Finally, we chose to reconstruct the scaffolding. Using the latest scaffolding designs available in the community (Vue-cli, Angular-cli, UMI), we designed a flexible and extensible engineering solution based on plug-ins:

  • Each plug-in is a Class that exposes life cycle methods such as Apply and afterInstall beforeUninstall. It is published as an NPM package to the NPM Registry and installed as a dependency within the project when used. Some plug-ins can also be installed globally

  • Globally installed command line tools provide only one mechanism for starting and coordinating plug-ins

  • Plug-ins are executed as entry via apply or lifecycle methods

    At first, we only designed a apply method as the entry point for plug-in execution. Later, we found that some scenarios cannot meet this requirement. For example, the environment needs to be initialized when installing the plug-in, and some configurations need to be removed when uninstalling the plug-in.

  • When a plug-in executes, it passes in the Context object of the entire command line. The plug-in can mount methods to the Context and listen for/fire events to communicate with other plug-ins

// Part of the code that constructs the Context object
export class BaseContext extends Hook {
  private _api: Api = {};
  public api: Api;

  constructor() {
    super(a);this.api = new Proxy(this._api, {
      get: this._apiGet,
      set: this._apiSet,
    });
  }

  // ...

  private _apiSet(target, key, value, receiver) {
    console.log(chalk.bgRed(`please use mountApi('${key}',func) !!! `));
    return true;
  }

  private _apiGet(target, key, receiver) {
    if (target[key]) {
      return target[key];
    } else {
      console.log(chalk.bgRed(`there have not api.${key}`));
      return new Function(a); } } mountApi(apiName: string, func) {if (!this._api[apiName]) {
      this._api[apiName] = func;
      return this._api[apiName];
    }
    return false; }}Copy the code
  • Plug-in execution can be combined with the capabilities assigned to the context to perform various functions
  • The cli tool automatically collects plug-ins and global plug-ins that are installed in projects. You can use a configuration file to configure the plug-in execution sequence and plug-in parameters

The following figure shows the refactoring operation process:

As you can see, the scaffold was just a plug-in to generate a new project. In fact, we did the same, converging the template generation logic into a generate plug-in.

The first problem can be solved by distributing functions to plug-ins. The functions provided by the solution can be disassembled and used. If a function is needed, only the plug-in of the function can be installed, which is convenient for the maintenance and release of plug-ins.

Different plug-ins are installed in different projects. Executing the light command can support different functions, for example:

Bytedance directory is only installed some basic plug-ins, command line prompt only a few simple operation of plug-ins and materials instructions

The i18n Lint larklet plugin is installed in the larkSuite directory

Plugins have the ability to share Context to facilitate collaboration between different functions (for example, the i18n plugin needs to call the WebPack plugin to add a WebPack plugin). For example, basePlugin mounts a lot of code material and command-line apis to the Context for other plug-ins to use. For example, add a new WebPack entry by calling the setEntry method provided by webpackPlugin:

this.ctx.api.setEntry(entries);
Copy the code

Improving the lifecycle mechanism for plug-ins and providing global plug-ins is to solve our second problem (such as that many plug-ins can initialize the required environment at the time of installation). Some common development tools can be installed as global plug-ins and used in conjunction with engineering plug-ins.

Here is an example of how to use a plug-in:

class MyPlugin implements Plugin {
  // The member variable CTX is used to hold the CTX object obtained by constructor
  ctx: Cli;

  constructor(ctx: Cli, option) {
    // New passes the LightBlue context and user-defined option into the constructor
    this.ctx = ctx;
  }

  /** * Life cycle function afterInstall * The afterInstall function is executed immediately after lightBlue Add installs the plug-in * you can initialize the working environment for the plug-in here, For example, lint-plugin generates the.eslintrc file * */
  afterInstall(ctx: Cli) {
    // A lightBlue API is used to copy templates to the initialization workspace
    this.ctx.api.copyTemplate('template path'.'workpath');
  }

  This is where you can register commands, register various apis, listen for events, etc., * For example, webpack-plugin provides build/serve commands and getEntry API * */
  apply(ctx: Cli) {
    // Register a command with the registerCommand method
    this.ctx.registerCommand({
      cmd: 'hello'.desc: 'say hello in terminal'.builder: (argv) = >
        argv.option('name', {
          alias: 'n'.default: 'bytedancer'.type: 'string'.desc: 'name to say hello'
        }),
      handler: (argu) = > {
        let { name } = argu;
        // Print the message using lightBlue's built-in log method
        this.ctx.api.logSuccess('hello '+ name); }});// Mount an API with mountApi
    this.ctx.mountApi('hello', (name) => {
      this.ctx.api.logSuccess('hello ' + name);
    });

    // Other plug-ins can use this API in this way
    this.ctx.api.hello('bytedancer');

    // Fires an event emitAsync
    this.ctx.emit('hello');

    // Other plugins can listen for this event
    this.ctx.on('hello'.async () => {});
  }
}

export default MyPlugin;
Copy the code

Optimize 💪

Our solution has finally taken shape and is being used in some projects, but the revolution is not yet successful, and comrades still need to work hard. After using it for a while and collecting your comments and suggestions, we made some optimizations:

Problem: There is no logging mechanism, so execution records and exceptions cannot be viewed when a problem occurs.

Optimization: Winston encapsulates a logging API on Context for use by other plug-ins.

ctx.mountApi('log', Logger.getInstance().log);
ctx.mountApi('logError', Logger.getInstance().logErr);
ctx.mountApi('logWarn', Logger.getInstance().logWarn);
ctx.mountApi('logSuccess', Logger.getInstance().logSuccess);
Copy the code

The problem: Although a plug-in mechanism is provided, the tools associated with writing plug-ins are not provided, resulting in fewer people willing to write plug-ins.

Optimizations: Use TypeScript for the refactoring, complete the interfaces, code plug-ins directly from TS prompts, and provide a plug-in that generates a plug-in development environment for automatic building.

Problem: After installation, many people are unwilling to update, resulting in a small number of new feature users.

Optimization solution: After each execution, check the version information and compare it with the latest version on the NPM. If you need to update the version, print the update prompt.

conclusion

We started with a simple scaffolding tool and worked our way through concepts like plugins and life cycles to a front-end engineering framework. It was a bumpy but inevitable process. The design of technical solutions needs to meet the change of business requirements, and the design of engineering solutions also needs to meet the change of technical requirements. When designing the plan, we should consider the possible changes in the future, but we should not overdesign the plan. We should follow the principle of satisfying the requirements first. When changing the plan, we should first discuss the feasibility and direction of the design, and then proceed with optimization/reconstruction.


Author: Hu Yue

BDEEFE is recruiting excellent front-end engineers all over the country for a long time.