Design patterns used to improve extensibility in excellent framework source code

Why to improve code extensibility

The code we write serves certain requirements, but these requirements are not immutable. When the requirements change, if our code has good expansibility, we may simply add or delete modules. If the expansibility is not good, all the codes need to be rewritten, which is a disaster. Therefore, to improve the expansibility of the code is to It is imperative. How to have good expansibility? Good scalability should have the following characteristics:

  1. When requirements change, the code does not need to be rewritten.
  2. Local code changes do not cause large-scale changes. Sometimes we refactor a small piece of code, but we find that it is mixed with other codes. There are various kinds of coupling in it. One thing is done in several places. To change this small piece, we have to change many other codes. That means that the coupling of these codes is too high and the scalability is not strong.
  3. It is very convenient to introduce new functions and new modules.

How to improve code extensibility?

Of course, I learned from excellent code. This article will go into Axios, Node.js , Vue and other excellent frameworks, summarize several design patterns from their source code, and then use these design patterns to try to solve the problems encountered in the work. This article will mainly talk about responsibility chain mode, observer mode, adapter mode and decorator mode. Let's take a look:

Responsibility chain mode

As the name implies, the responsibility chain mode is a chain, in which a lot of responsibilities are connected. An event can be handled by the responsibilities in the chain in turn. His advantage is that each responsibility in the chain only needs to care about his own affairs. He doesn't need to know what his previous step is, what his next step is, and the responsibility to keep up with him is uncoupled. So when the upper and lower responsibilities change, he will not be affected. It's very convenient to add or reduce responsibilities to the chain.

Example: Axios interceptor

Friends who have used Axios should know that Axios interceptors include request interceptors and response interceptors. The order of execution is request interceptors - > initiate requests - > response interceptors. In fact, there are three responsibilities in a chain. Let's see how this chain can be realized:

// Starting with usage, we usually add interceptors as follows 
// instance.interceptors.request.use(fulfilled, rejected)
// According to this usage, we first write an Axios class.
function Axios() {
  // There is an interceptors object on the instance, which has two properties: request and response
  // Both properties are instances of interceptor Manager
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

// Then implement the InterceptorManager class
function InterceptorManager() {
  // There is an array on the instance to store the interceptor method
  this.handlers = [];
}

// Interceptor manager has an instance method use
InterceptorManager.prototype.use = function(fulfilled, rejected) {
  // This method is very simple. Just put the incoming callback into handlers
  this.handlers.push({
    fulfilled,
    rejected
  })
}
Copy code

The above code actually completes the logic of interceptor creation and use, which is not complicated. When are these interceptor methods executed? Of course, we call instance.request When, call instance.request The real execution is request interceptor - > request initiation - > response interceptor chain, so we also need to implement the following Axios.prototype.request :

Axios.prototype.request = function(config) {
  // Chain contains the method chain we want to implement
  // dispatchRequest is a method to initiate network requests. This paper mainly talks about the design pattern. This method will not be implemented
  // First put the method of initiating network request in the chain. Its position should be in the middle of the chain
  const chain = [dispatchRequest, undefined];
  
  // In front of the chain is the method of request interceptor, from request.handlers Take it out and put it in
  this.interceptors.request.handlers.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });
  
  // After the chain is the response interceptor's method, from response.handlers Take it out and put it in
  this.interceptors.response.handlers.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });
  
  // After the above code organization, the chain is as follows:
  // [request.fulfilled, request.rejected, dispatchRequest, undefined, response.fulfilled,  
  // response.rejected]
  // In fact, this has been arranged in the order of request interceptor - > request initiation - > response interceptor. Just execute it
  
  let promise = Promise.resolve(config);   // Let's start with an empty promise to open then
  while (chain.length) {
    // use promise.then Make a chain call
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
}
Copy code

The above code is from Axios source code It can be seen that he cleverly uses the responsibility chain mode to organize the tasks to be done into a chain. The tasks in the chain do not affect each other, the interceptors are optional, and there can be multiple, with strong compatibility.

Example: responsibility chain organization form validation

After looking at the application of the excellent framework to the responsibility chain mode, let's see how to use this mode in our daily work. Now suppose there is a requirement for form validation, which requires the front end to verify the format and other contents, and then the API is sent to the back end for legitimacy verification. Let's first analyze this requirement. The front-end verification is synchronous, the back-end verification is asynchronous, and the whole process is synchronous and asynchronous interleaved. In order to be compatible with this situation, the return value of each verification method needs to be wrapped as promise

// Write a method first for front end verification
function frontEndValidator(inputValue) {
  return Promise.resolve(inputValue);      // Note that the return value is promise
}

// Back end verification also writes a method
function backEndValidator(inputValue) {
  return Promise.resolve(inputValue);      
}

// Write a verifier
function validator(inputValue) {
  // Follow the example of Axios and put each step into an array
  const validators = [frontEndValidator, backEndValidator];
  
  // Previously, Axios was a circular call promise.then To execute the responsibility chain, let's use async to execute it
  async function runValidate() {
    let result = inputValue;
    while(validators.length) {
      result = await validators.shift()(result);
    }
    
    return result;
  }
  
  // Execute runValidate, note that the return value is also a promise
  runValidate().then((res) => {console.log(res)});
}

// The above code can be executed, but we have no specific verification logic, and the input value will be returned intact
validator(123);     // Output: 123
Copy code

In the above code, we use the responsibility chain mode to organize multiple verification logics. There is no dependency between these verifications. If you need to reduce a verification in the future, you only need to delete it from the validators array. If you want to add it, you need to add it to this array. The coupling between these calibrators is greatly reduced, and they encapsulate promise, which can also be used in other modules. Other modules can organize their own responsibility chain as needed.

Observer mode

The observer mode is also called publish and subscribe mode, which is well-known in the world of JS. Everyone has used it more or less. The most common one is event binding. Some interviews also require interviewers to write an event center, which is actually an observer mode. The advantage of observer mode is that it can make the event producer and consumer not know each other, only need to generate and consume the corresponding event, especially suitable for the situation that the event producer and consumer are not convenient to call directly, such as asynchronous. Let's write an observer pattern:

class PubSub {
  constructor() {
    // One object holds all message subscriptions
    // Each message corresponds to an array. The array structure is as follows
    // {
    //   "event1": [cb1, cb2]
    // }
    this.events = {}
  }

  subscribe(event, callback) {
    if(this.events[event]) {
      // If someone has subscribed and this key already exists, just add it to it
      this.events[event].push(callback);
    } else {
      // No one subscribes to it. Just build an array and put the callback in it
      this.events[event] = [callback]
    }
  }

  publish(event, ...args) {
    // Fetch callback execution for all subscribers
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      subscribedEvents.forEach(callback => {
        callback.call(this, ...args);
      });
    }
  }

  unsubscribe(event, callback) {
    // Delete one subscription and keep others
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }
}

// When using
const pubSub = new PubSub();
pubSub.subscribe('event1', () => {});    // Register events
pubSub.publish('event1');                // Publishing events
Copy code

example: Node.js EventEmitter for

A typical application of the observer model is Node.js I have another article Reading from the mode of publish and subscribe Node.js EventEmitter source code of From the point of view of asynchronous application, the principle and Node.js I won't write again here. The above handwritten code is also from this article.

Example: circle drawing

Similarly, after reading the source code of the excellent framework, we should try to use it ourselves. The example here is the circle lottery. I think many friends have drawn prizes on the Internet. A rotary table contains various prizes. Click the lottery, then the pointer starts to rotate, and finally it will stop at a prize. Our example is to implement such a Demo, but another requirement is to speed up every revolution. Let's analyze the following requirements:

  1. To draw a lottery, we must draw the turntable first.
  2. There must be a result in the draw, whether there is a prize or not. What is the prize? Generally, the result is returned by the API. Many implementation schemes are to click the draw and launch an API request to get the result. The loop animation is only an effect.
  3. Let's write a little code to make the turntable move. We need a motion effect
  4. We need to speed up every turn, so we also need to control the speed of movement

Through the above analysis, we found a problem: it takes some time for the turntable to move. When it is finished, it needs to tell the module that controls the turntable to speed up the next round of movement, so the motion module and the control module need an asynchronous communication, which needs our observer mode to solve. The final effect is as follows. Since it's just a DEMO, I use several DIV blocks to replace the turntable:

Here is the code:

// First, take the previous publish and subscribe mode
class PubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback) {
    if(this.events[event]) {
      this.events[event].push(callback);
    } else {
      this.events[event] = [callback]
    }
  }

  publish(event, ...args) {
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      subscribedEvents.forEach(callback => {
        callback.call(this, ...args);
      });
    }
  }

  unsubscribe(event, callback) {
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }
}

// Instantiate an event center
const pubSub = new PubSub();

// There are four modules in total: initialization page - > get final result - > motion effect - > motion control
// Initialize page
const domArr = [];
function initHTML(target) {
  // 10 prizes in total, that is, 10 divs
  for(let i = 0; i < 10; i++) {
    let div = document.createElement('div');
    div.innerHTML = i;
    div.setAttribute('class', 'item');
    target.appendChild(div);
    domArr.push(div);
  }
}

// To get the final result, that is to say, we need to rotate several times in total. We use a random number plus 40 (4 turns)
function getFinal() {
  let _num = Math.random() * 10 + 40;

  return Math.floor(_num, 0);
}

// Motion module, specific motion method
function move(moveConfig) {
  // moveConfig = {
  //   times: 10, / / times of this circle
  //   speed: 50 / / speed of this circle
  // }
  let current = 0; // current location
  let lastIndex = 9;   // Previous position

  const timer = setInterval(() => {
    // Each time you move, add a border to the current element and remove the previous border
    if(current !== 0) {
      lastIndex = current - 1;
    }

    domArr[lastIndex].setAttribute('class', 'item');
    domArr[current].setAttribute('class', 'item item-on');

    current++;

    if(current === moveConfig.times) {
      clearInterval(timer);

      // After a round of broadcasting events
      if(moveConfig.times === 10) {
        pubSub.publish('finish');
      }
    }
  }, moveConfig.speed);
}

// The motion control module controls the parameters of each turn
function moveController() {
  let allTimes = getFinal();
  let circles = Math.floor(allTimes / 10, 0);
  let stopNum = allTimes % circles;
  let speed = 250;  
  let ranCircle = 0;

  move({
    times: 10,
    speed
  });    // Turn on the first rotation manually

  // Listen to events, turn on the next rotation automatically after each rotation
  pubSub.subscribe('finish', () => {
    let time = 0;
    speed -= 50;
    ranCircle++;

    if(ranCircle <= circles) {
      time = 10;
    } else {
      time = stopNum;
    }

    move({
      times: time,
      speed,
    })
  });
}

// Draw page, start turning
initHTML(document.getElementById('root'));
moveController();
Copy code

The difficulty of the above code is that the motion of the motion module is asynchronous, and the motion control module needs to be informed of the next rotation after each circle of motion. The observer mode solves this problem well. The complete code of this example has been uploaded to my GitHub, so I can take it down and run it for fun.

Decorator mode

The decorator mode is aimed at the situation that I have some old codes, but these old codes have insufficient functions and need to add functions, but I can't change the old codes. For example, Vue 2.x needs to monitor the array changes and add the response type to it, but it can't directly modify them Array.prototype . In this case, it is particularly suitable to use the decorator mode, to redecorate the old method and turn it into a new method.

Basic structure

The structure of decorator pattern is also very simple, that is to call the original method first, and then add more operations, that is to decorate.

var a = {
  b: function() {}
}

function myB() {
  // Call the previous method first
  a.b();
  
  // Add your own new operation
  console.log('New operation');
}
Copy code

Example: listening to Vue array

Anyone familiar with Vue's responsive principle knows( Unfamiliar friends can see here ), the response of the Vue 2.x object is through Object.defineProperty Implemented, but this method can't monitor array changes. How can array monitor? Generally, array operations are push and shift. These methods are array native methods. Of course, we can't change them. That's the decorator mode. We can extend their functions on the basis of maintaining their previous functions:

var arrayProto = Array.prototype;    // Get the prototype of the native array first
var arrObj = Object.create(arrayProto);     // Create a new object with the prototype of the native array to avoid polluting the native array
var methods = ['push', 'shift'];    // There are only two methods that need to be extended. But there are more than two

// Loop methods array, extend them
methods.forEach(function(method) {
  // Replacing methods on arrObj with extended methods
  arrObj[method] = function() {
    var result = arrayProto[method].apply(this, arguments);    // Execute the old method first
    dep.notify();     // This is Vue's method, which is used for response
    return result;
  }
});

// For user-defined arrays, manually point its prototype to the extended arrObj
var a = [1, 2, 3];
a.__proto__ = arrObj;
Copy code

The above code is from Vue source code is simplified In fact, it is a typical example of using decorators to extend the functions of the original methods, because Vue only extends the array methods. If you do not use these methods, but directly operate the array through subscripts, the response type will not work.

Instance: extend existing event binding

According to the old rules, after learning the code of others, we will try it ourselves. The requirement of this example is that we need to add some operations to the existing DOM click events.

//Our previous click events only need to print 1
dom.onclick = function() {
  console.log(1);
}
Copy code

However, our current requirements require that we output another 2. Of course, we can return the original code to change it, but we can also use the decorator mode to add functions to it:

var oldFunc = dom.onclick;  // Take out the old method first
dom.onclick = function() {   // Rebind events
  oldFunc.apply(this, arguments);  // Start with the old method
  
  // Then add a new method
  console.log(2);
}
Copy code

The above code extends the DOM click event, but if there are many DOM elements that need to be modified, we need to rebind the event one by one, and there will be a large number of similar codes. One of the purposes of learning the design pattern is to avoid duplicate codes, so we can extract the common binding operations as a decorator:

var decorator = function(dom, fn) {
  var oldFunc = dom.onclick;
  
  if(typeof oldFunc === 'function'){
    dom.onclick = function() {
      oldFunc.apply(this, arguments);
      fn();
    }
  }
}

// Call the decorator, and the input parameters can be extended
decorator(document.getElementById('test'), function() {
  console.log(2);
})
Copy code

This method is especially suitable for the third-party UI components we introduced. Some UI components encapsulate many functions themselves, but they do not expose the interface. If we want to add functions, we can not directly modify their source code. The best way is to use the decorator pattern to expand, and with the decoration factory, we can also quickly batch modify.

Adapter mode

The adapter must have been used by everyone. The old video card in my family only has HDMI interface, but the monitor is DP interface. These two can't be plugged in. What should I do? The answer is to buy an adapter and convert the DP interface to HDMI. The principle of the adapter pattern here is similar. When we are faced with the situation that the interface is not universal and the interface parameters do not match, we can package another method outside of it. This method receives our current name and parameters, which calls the old method to pass in the previous parameter form.

Basic structure

The basic structure of the adapter pattern is as follows. Suppose that the function we want to use to log is mylog, but we want to call out of the box methods window.console.log Implementation, then we can pack a layer for him.

var mylog = (function(){
  return window.console.log;
})()
Copy code

If you think the above structure is too simple and still don't know how to use it, let's take another example.

Example: Framework changed

If one of the problems we are facing is the A framework that the company has been using before, but now we have decided to change it to jQuery. Most of the interfaces of the two frameworks are compatible, but some of them are not suitable. We need to solve this problem.

//An interface to modify css
 $. css(); / / jQuery is called css
 A. Style(); / / a frame is called style

//An interface for binding events
 $. On(); / / jQuery is on
 A. Bind(); / / frame a is called bind
 Copy code

Of course, it's OK for us to change where we use the global search, but if we use the adapter to change it, it may be more elegant:

// Replace A with A$
window.A = $;

// Fit A.style
A.style = function() {
  return $.css.apply(this, arguments);    // Keep this the same
}

// Fit A.bind
A.bind = function() {
  return $.on.apply(this, arguments);
}
Copy code

The adapter is so simple. The interface is different. Just change the package layer to the same.

Example: parameter adaptation

The adapter pattern can be used not only to adapt the interface inconsistency as above, but also to adapt the diversity of parameters. If one of our methods needs to receive a very complex object parameter, such as the configuration of webpack, there may be many options, but the user may only use part of it, or the user may pass in unsupported configuration, then we need a process to adapt the configuration passed in by the user to the standard configuration, which is also very simple to do

// func method receives a very complex config
function func(config) {
  var defaultConfig = {
    name: 'hong',
    color: 'red',
    // ......
  };
  
  // In order to adapt the user's configuration to the standard configuration, we directly loop defaultConfig
  // If the user has passed in the configuration, the user 's will be used; if not, the default will be used
  for(var item in defaultConfig) {
    defaultConfig[item] = config[item] || defaultConfig[item];
  }
}
Copy code

summary

  1. The core of high scalability is actually high cohesion and low coupling. Each module focuses on its own functions to minimize direct dependence on the external.
  2. Responsibility chain mode and observer mode are mainly used to reduce the coupling between modules. When the coupling is low, it is convenient to organize them and extend their functions. Adapter mode and decorator mode are mainly used to expand without affecting the original code.
  3. If we need to perform a series of operations on an object, which can be organized into a chain, then we can consider using the responsibility chain mode. The specific tasks in the chain do not need to know the existence of other tasks, only focus on their own work, and the chain is responsible for the transmission of messages. Using the responsibility chain mode, the tasks in the chain can be easily added, deleted or reorganized into a new chain, just like a pipeline.
  4. If we have two objects that need asynchronous communication at uncertain time points, we can consider using the observer mode. The user does not need to pay attention to other specific objects all the time. He only needs to register a message in the message center. When the message appears, the message center will be responsible for informing him.
  5. If we have got some old code, but the old code can't meet our needs, and we can't change it at will, we can consider using decorator mode to enhance its functions.
  6. For old code transformation or new module introduction, we may face the situation that the interface is not universal. At this time, we can consider writing an adapter to adapt them. The adapter mode is also suitable for parameter adaptation.
  7. In that sentence, the design pattern pays more attention to the idea, and does not need to copy the code template mechanically. Don't apply design patterns everywhere, but use them when you really need them to increase the extensibility of our code.

This is the third article of design pattern, which focuses on the design pattern of improving the expansibility. The first two articles are:

(480 likes!) Don't know how to package code? Look at these design patterns!

Don't know how to improve code reusability? Take a look at these design patterns

Later, there is a design pattern to improve the code quality.

At the end of the article, thank you for your precious time reading this article. If this article gives you a little help or inspiration, please don't be stingy with your praise and little GitHub star. Your support is the driving force of the author's continuous creation.

The material of this article comes from Netease senior front-end development engineer Mr. Tang Lei's design mode course.

Author's blog GitHub project address: github.com/dennis-jian...

Summary of the author's gold digging articles: juejin.im/post/5e3ffc...

Tags: axios Vue github JQuery

Posted on Sat, 30 May 2020 00:35:53 -0400 by hadingrh