Follow me on my blog shymean.com

The front-end code also needs to be designed, but even if you read a lot of design pattern books, you can’t really apply it. It turned out that you needed to start with real business scenarios and think about how to write cleaner, more maintainable code in the face of complex and changing requirements. From this point of view, this paper sorted out some design patterns I encountered in front-end business development.

reference

  • JavaScript Design Patterns and Development Practices: An Exploration by Zeng
  • The Wisdom of programming

This article will not introduce the relevant concepts, nor will it give UML diagrams according to the conventional design mode, because my level is limited, but also halfway born to write code, without special CS education, if there is something wrong in the article, I hope you correct.

Singleton mode: global pop-ups

Pop-ups are a common requirement in front-end development. A simple MessageBox is defined below to instantiate various pop-ups

class MessageBox {
    show() {
        console.log("show");
    }
    hide(){}}let box1 = new MessageBox();
let box2 = new MessageBox();
console.log(box1 === box2); // false
Copy the code

In the normal case, where there is usually only one global popover at a time, we can implement the singleton pattern to ensure that the same method is actually returned each time we instantiate it

class MessageBox {
    show() {
        console.log("show");
    }
    hide() {}

    static getInstance() {
        if(! MessageBox.instance) { MessageBox.instance =new MessageBox();
        }
        returnMessageBox.instance; }}let box3 = MessageBox.getInstance();
let box4 = MessageBox.getInstance();

console.log(box3 === box4); // true
Copy the code

The above is a more common singleton pattern implementation, which has some disadvantages

  • The caller needs to be aware of the passMessage.getInstanceTo get a singleton
  • If the requirements change, you can do it by having a secondary popover, you need to change a lot of things becauseMessageBoxIn addition to implementing the regular popover logic, you are responsible for maintaining the logic of the singleton

Therefore, the logic for initializing a singleton can be maintained separately. In other words, we need to implement a generic method that returns a singleton corresponding to a class, which can be easily solved with closures

function getSingleton(ClassName) {
    let instance;
    return () = > {
        if(! instance) { instance =new ClassName();
        }
        return instance;
    };
}

const createMessageBox = getSingleton(MessageBox);
let box5 = createMessageBox();
let box6 = createMessageBox();
console.log(box5 === box6);
Copy the code

This way, the same instance is always returned through the createMessageBox.

If you need to generate additional instances in certain scenarios, you can either regenerate a createMessageBox method or call new MessageBox() without any impact on the previous logic.

Policy mode: Form judgment

The main function of the policy pattern is to encapsulate the same level of logic into a policy method that can be combined and replaced, reducing if… Else code to facilitate extension of subsequent functions.

Speaking of if in front-end code… Else code, probably the most common is form validation

function onFormSubmit(params) {
    if(! params.nickname) {return showError("Please fill in your nickname");
    }
    if (params.nickname.length > 6) {
        return showError("Nickname up to 6 characters");
    }
    if (!/^1\d{10}$/.test(params.phone))
        return showError("Please fill in the correct phone number.");
    }
    // ...
    sendSubmit(params)
}
Copy the code

About the if.. Else code transgressions are familiar, but there are some additional problems with this

  • Stack the verification rules of all fields together. If you want to view the verification rules of a field, you need to read all the judgments (to avoid a colleague putting two judgments of the same field in different places).
  • When an error is encountered, a return is used to skip the following judgment. If the product wants to show errors in each field directly, the amount of change can be small.

However, in the days of ANTD, ELementUI and other frameworks, you don’t see much of this code in Form components anymore, thanks to async-Validator.

Let’s implement a suggested Validator

class Schema {
    constructor(descriptor) {
        this.descriptor = descriptor;
    }

    validate(data) {
        return new Promise((resolve, reject) = > {
            let keys = Object.keys(data);
            let errors = [];
            for (let key of keys) {
                const config = this.descriptor[key];
                if(! config)continue;

                const { validator } = config;
                try {
                    validator(data[key]);
                } catch(e) { errors.push(e.toString()); }}if (errors.length) {
                reject(errors);
            } else{ resolve(); }}); }}Copy the code

Then declare the validation rules for each field,

// First declare the validation rule for each field
const descriptor = {
    nickname: {
        validator(val) {
            if(! val) {throw "Please fill in your nickname";
            }
            if (val.length < 6) {
                throw "Nickname up to 6 characters"; }}},phone: {
        validator(val) {
            if(! val) {throw "Please fill in your telephone number.";
            }
            if (!/^1\d{10}$/.test(val)) {
                throw "Please fill in the correct phone number."; ,}}}};Copy the code

Finally verify data source

// Start verification
const validator = new Schema(descriptor);
const params = { nickname: "".phone: "123000" };
validator
    .validate(params)
    .then(() = > {
        console.log("success");
    })
    .catch((e) = > {
        console.log(e);
    });
Copy the code

As you can see, Schema mainly exposes the construct parameter and validate interface, which is a general utility class, while params is the data source for form submission, so the main validation logic is actually declared in descriptor.

In the above implementation, we implemented a Validator method for each field based on its dimension, which handles the logic needed to validate that field.

In fact, we can split more general rules such as required, len, min/ Max, and so on, so that they can be reused as much as possible when multiple fields have some similar validation logic.

Modify descriptor to change the verification rule type of each field to list. The key of each element in the list indicates the name of the rule. The Validator is used as a custom rule

const descriptor = {
    nickname: [{key: "required".message: "Please fill in your nickname" },
        { key: "max".params: 6.message: "Nickname up to 6 characters"},].phone: [{key: "required".message: "Please fill in your telephone number." },
        {
            key: "validator".params(val) {
                return !/^1\d{10}$/.test(val);
            },
            message: "Please fill in the correct telephone number",}]};Copy the code

Then modify the Schema implementation to add the handleRule method

class Schema {
    constructor(descriptor) {
        this.descriptor = descriptor;
    }

    handleRule(val, rule) {
        const { key, params, message } = rule;
        let ruleMap = {
            required() {
                return! val; },max() {
                return val > params;
            },
            validator() {
                returnparams(val); }};let handler = ruleMap[key];
        if (handler && handler()) {
            throwmessage; }}validate(data) {
        return new Promise((resolve, reject) = > {
            let keys = Object.keys(data);
            let errors = [];
            for (let key of keys) {
                const ruleList = this.descriptor[key];
                if (!Array.isArray(ruleList) || ! ruleList.length)continue;

                const val = data[key];
                for (let rule of ruleList) {
                    try {
                        this.handleRule(val, rule);
                    } catch(e) { errors.push(e.toString()); }}}if (errors.length) {
                reject(errors);
            } else{ resolve(); }}); }}Copy the code

In this way, common validation rules can be placed in ruleMap and exposed to the user to combine the various validation rules, rather than the previous variety of reusable if.. Else judgments are easier to maintain and iterate over.

Agent mode: Erdua mobile debug panel

Proxy mode is mainly to encapsulate the access to some objects, the most typical application in the back end is AOP in Spring, for the front end, more familiar with the response principle of Vue3 core implementation of Pxory, in addition to such as network proxy, caching proxy and other proxy terms. The following is a scenario for using the proxy pattern in a front-end business.

In the mobile end page debugging of front-end development, since there is no corresponding developer panel on the mobile end, in addition to using Chrome ://inspect/# Devices and Safari development tools, You can also use vConole or Erdua for debugging purposes such as browsing the page structure and viewing the console.

Take Eruda for example, for console information in code

The first is the information printed in the Erdua debug panel

It will also print the actual message in the browser console, and you can see that the code source is from erdua.js, not where the console code was written

The whole debug panel is also easy to understand, with the erdua proxy for the real console and the original print displayed on the Erdua panel

const browserConsole = window.console;

function printInConsolePanel(type, msg) {
    const dom = document.querySelector("J_proxy_console_panel");
    dom.innerHTML = type + ":" + msg;
}

const proxyConsole = {
    browserConsole,
    log(msg) {
        // Print on the real panel
        printInConsolePanel("log", msg);
        // The original browser log
        this.browserConsole.log(msg); }};window.console = { ... browserConsole, ... proxyConsole, };Copy the code

This way, on mobile devices where the console is not easily accessible, the real window.console can be accessed through the proxyConsole agent, and then the user can directly browse the console information interface.

Factory mode: Encapsulates storage

The factory pattern provides a way to create objects, hides their implementation details from users, and uses a common interface to create objects.

Front-end localStorage is currently the most common solution is to use localStorage, in order to avoid scattered in the business code of various getItem and setItem, we can do the simplest encapsulation

let themeModel = {
    name: "local_theme".get() {
        let val = localStorage.getItem(this.name);
        return val && JSON.parse(val);
    },
    set(val) {
        localStorage.setItem(this.name, JSON.stringify(val));
    },
    remove() {
        localStorage.removeItem(this.name); }}; themeModel.get(); themeModel.set({darkMode: true });
Copy the code

Thus, with the get and SET interfaces exposed by themeModel, we no longer need to maintain local_theme; However, there are some visible problems in the above encapsulation. If 10 new names are added, the above template code needs to be rewritten 10 times.

To solve this problem, we can encapsulate the logic that creates the Model object

const storageMap = new Map(a)function createStorageModel(key, storage = localStorage) {
    // Returns a singleton for the same key
    if (storageMap.has(key)) {
        return storageMap.get(key);
    }

    const model = {
        key,
        set(val) {
            storage.setItem(this.key, JSON.stringify(val);) ; },get() {
            let val = storage.getItem(this.key);
            return val && JSON.parse(val);
        },
        remove() {
            storage.removeItem(this.key); }}; storageMap.set(key, model);return model;
}

const themeModel =  createStorageModel('local_theme'.localStorage)
const utmSourceModel = createStorageModel('utm_source', sessionStorage)
Copy the code

In this way, we can create various local storage interface objects through createStorageModel without having to worry about the details of creating the object.

Iterative mode: Platform judgment

In the front-end development, you will come into contact with a variety of arrays or array-like objects. In jQuery, you can traverse a variety of lists through $. Each and other interfaces. Of course, JS also has a variety of built-in methods to traverse a number group, such as forEach and reduce.

Array loops are pretty familiar, but in practice, you can optimize your code with loops.

A common development scenario is: through ua to determine the running platform of the current page, convenient to execute different business logic, the most basic writing of course is if… Else a spindle

const PAGE_TYPE = {
    app: "app".// app
    wx: "wx"./ / WeChat
    tiktok: "tiktok"./ / trill
    bili: "bili"./ / B station
    kwai: "kwai"./ / well quickly
};
function getPageType() {
    const ua = navigator.userAgent;
    let pageType;
    // wechat browser on mobile and desktop
    if (/xxx_app/i.test(ua)) {
        pageType = app;
    } else if (/MicroMessenger/i.test(ua)) {
        pageType = wx;
    } else if (/aweme/i.test(ua)) {
        pageType = tiktok;
    } else if (/BiliApp/i.test(ua)) {
        pageType = bili;
    } else if (/Kwai/i.test(ua)) {
        pageType = kwai;
    } else {
        // ...
    }

    return pageType;
}
Copy the code

The logic of the judgment is simple. We iterate through the current list of platforms to be judged and return the first matching platform type. As you can see, when we need to add a platform judgment, we need to make two changes

  • Modify thePAGE_TYPETo add a new platform name
  • Modify thegetPageTypeAdd an implementation inelse ifbranch

Similarly, removing a platform judgment requires modification of these two areas.

With reference to the strategy pattern, we can reduce the occurrence of branching judgments and break down the judgments of each platform into separate strategies

function isApp(ua) {
    return /xxx_app/i.test(ua);
}

function isWx(ua) {
    return /MicroMessenger/i.test(ua);
}

function isTiktok(ua) {
    return /aweme/i.test(ua);
}

function isBili(ua) {
    return /BiliApp/i.test(ua);
}

function isKwai(ua) {
    return /Kwai/i.test(ua);
}
Copy the code

Each policy defines the same function signature: it accepts a UA string and returns a Boolean value, true for a match. Then re-implement getPageType

let platformList = [
    { name: "app".validator: isApp },
    { name: "wx".validator: isWx },
    { name: "tiktok".validator: isTiktok },
    { name: "bili".validator: isBili },
    { name: "kwai".validator: isKwai },
];
function getPageType() {
    // The name and detection method of each platform
    const ua = navigator.userAgent;
    / / traverse
    for (let { name, validator } in platformList) {
        if (validator(ua)) {
            returnname; }}}Copy the code

Thus, the entire getPageType method becomes very neat: iterate through the platformList in order, returning the first matching platform name as pageType.

So even if you need to add or remove platform judgments later, the only place you need to change is the platformList. To count headlines as TikTok, for example, isTiktok would need to be modified

function isTiktok(ua) {
    return /aweme|NewsArticle/i.test(ua);
}
Copy the code

There are many other criteria, such as finding a suitable XHR version object for compatibility with older browsers, and using a different client interface based on the app version number. As long as the code changes are small enough, the chances of bugs are small.

In the example above, we are more likely to be using the policy pattern or chain of responsibility pattern, but we are unknowingly using the iterator pattern. Most modern languages have iterators built in, so there is no need to implement prev, next, isDone, etc. Instead, we should learn to use iterators flexibly to simplify code logic.

Publish-subscribe mode: eventBus event communication

Publish and subscribe model is probably one of the most familiar design patterns of front-end students, common

  • addEventListener, basic event listening, and various property methodsonload,onchangeEtc.
  • $.on,$.emitJQuery version event listener
  • Vue responsive data and component communication
  • redux,eventBusEtc.

(It seems that publish-subscribe can be understood as “events” on the front end…

In addition to the related methods provided by the framework, we can also use this pattern to decouple the previous dependencies of various modules.

Suppose we now have a page, and once we get to the page we need to do two things

  • Report to a specific burial site
  • Check whether it is during the activity period. If it is during the activity period, the coupon popup window will pop up

I’m just going to write it the conventional way

const activity = {
    showCouponDialog() {
        console.log("Congratulations on getting the coupon."); }};const logger = {
    pageView(page) {
        reportLog("page_view", page); }};// Write the logic in the page
const indexPage = {
    mounted() {
        console.log("mounted");

        logger.pageView("index"); activity.showCouponDialog(); }}; indexPage.mounted();Copy the code

Written this way, it seems logical and meets current requirements, but there are some maintenance issues

  • When a new requirement is also to be processed after entering the page, you need to find the indexPage to insert the relevant logic
  • When no longer neededshowCouponDialogYou need to find the indexPage and remove the related logic
const indexPage = {
    mounted() {
        console.log("mounted");

        logger.pageView("index");
        // Cancel the display coupon
        // activity.showCouponDialog()
        // Display membership expiration prompt windowvip.showExpireTipDialog(); }};Copy the code

Future requirements are unpredictable in terms of changes, but the logic of indexPage itself should be stable, so the code above can be modified to

/ /... Ignore the implementation of eventBus
const eventBus = {
    on() {},
    emit(){}};const activity = {
    init() {
        eventBus.on("enterIndexPage".() = > {
            this.showCouponDialog();
        });
    },
    showCouponDialog() {
        console.log("Congratulations on getting the coupon."); }};const logger = {
    init() {
        eventBus.on("enterIndexPage".() = > {
            this.pageView("index");
        });
    },
    pageView(page) {
        reportLog("page_view", page); }};const indexPage = {
    mounted() {
        console.log("mounted");
        eventBus.emit("enterIndexPage"); }};// Each module listens for events as needed
activity.init();
logger.init();

// Trigger time
indexPage.mounted();
Copy the code

We now decouple indexPage from the individual business modules, and there is no need to change indexPage-related code when we encounter the previous requirements change scenario. Of course, this approach introduces a new problem: apart from looking at the entire project, there is no way to know which modules are subscribed to the indexPage, which makes parts of the process difficult to track and understand.

Template approach: Reuse logic and isolate changes

At some point, we might write code snippet that looks repetitive but is not easy to find reuse points, as in the scenario above for determining device platforms

// Check if it is app environment
if (isApp) {
    a();
} else {
    b();
}

c();

if (isApp) {
    d();
} else {
    e();
}
Copy the code

If above (isApp)… Else appears twice, so we can simplify them a little bit

const appHanlder = () = > {
    a();
    c();
    d();
};
const defaultHandler = () = > {
    b();
    c();
    e();
};

if (isApp) {
    appHanlder();
} else {
    defaultHandler();
}
Copy the code

It looks like the code logic is clearer, and you can also use the template approach to optimize code segments that can be reused but have separate logic in other parts.

Simply look at the template method pattern, can be understood as a process of some common methods to public the parent class, and then subclass implementation of specific methods alone make tea and coffee (the most classic example:) and, of course, in this article in order to close to the JavaScript, we don’t discuss the parent class or subclass, instead of everyone is familiar with the UI components

There are now two promotional details pages

  • Their common logic includes an interface to query the details of an item, detect whether a user has already made a purchase, and place an order based on the item information
  • The logic of their differences includes different UI displays, item A’s SKU list will pop up when the button is clicked, and Item B will buy directly when the buy button is clicked

We can encapsulate common logic into a base component, encapsulate UI differences for each commodity separately, and pass them into the base component to display them using render props(React) or slot(Vue). Here is the crude code to display them using Vue

const sellPage = {
    template: ` 
      
`
.methods: { fetchDetail() { // Get product details }, confirmPay(item) { // Pull payment according to goods,}}};const A = { components: { sellPage, }, template: ` <div> <sellPage ref="sellPage"> <! <button @click=" skuList "> </button> </sellPage> <skuList V-show ="skuVisible" @confirm="confirmBuy"/> </div> `.data() { return { skuVisible: false}; },methods: { showSkuList() { this.skuVisible = true; }, confirmBuy(item) { this.$refs.sellPage(item); ,}}};const B = { components: { sellPage, }, template: ` <div> <sellPage> <! -- Commodity B details --> </sellPage> </div> '.methods: { confirmBuy(item) { this.$refs.sellPage(item); ,}}};Copy the code

In this case, our common logic is encapsulated in the sellPage component and the default Slot interface is defined. Components A and B then implement the corresponding UI and specific logic themselves.

In addition, another form of template method in the front end, namely the hook method, is used to expose some data and information in the encapsulated module or component, such as life cycle functions, Function prop, etc.

Another way of looking at this approach is that the subclass gives up control of its own implementation, and is only responsible for implementing the logic. The parent class then calls the relevant methods when appropriate. For example, we only need to implement the logic in Mounted, and then give the VUE framework to call it when appropriate.

Chain of responsibility mode: Handles membership levels

Reference: mp.weixin.qq.com/s/oP3GOPbjg…

Error handling is an issue that all developers have to deal with. In catch, if the current code can handle the corresponding error, it handles it; If not, the exception needs to be thrown upwards rather than silently eaten, which is a typical chain of responsibility pattern application scenario

function a() {
    throw "error b";
}

// When B can handle an exception, it is no longer thrown up
function b() {
    try {
        a();
    } catch (e) {
        if (e === "error b") {
            console.log("Handled by B");
        } else {
            throwe; }}}function main() {
    try {
        b();
    } catch (e) {
        console.log("The top catch"); }}Copy the code

The main idea of the chain of responsibility mode is to define each check as a Handler, and each Handler will expose an interface to add the next Handler. When the current error cannot be handled, the next Handler will be automatically called internally, and so on, which greatly increases the flexibility of processing tasks. When the process changes, there is no need to change the underlying implementation, just the order of the responsibility chain.

Below is a code that returns membership levels based on user points

function getLevel(score) {
    if (score < 10) {
        / /... Level 1 membership logic
    } else if (score < 100) {
        / /... Level 2 members
    } else if (score < 1000) {
        / /... Level 3 members
    }
    // ...
}
Copy the code

That’s right, it’s time for our favorite if… The else part. Since the subsequent logic of each level of membership is not the same, we separate the logic of each member by level and then use the chain of responsibility model

function createRule(handler) {
    return {
        next: null.handle(args) {
            let ans = handler(args);
            if (ans) {
                return ans;
            } else if (this.next) {
                this.next.handle(args); }},setNext(rule) {
            this.next = rule; }}; }const rule1 = createRule((score) = > {
    if (score >= 10) return;
    / /... Member 1
    return true;
});
const rule2 = createRule((score) = > {
    if (score >= 100) return;
    / /... Member 2
    return true;
});
const rule3 = createRule((score) = > {
    if (score >= 1000) return;
    / /... Member 3
    return true;
});

rule1.setNext(rule2);
rule2.setNext(rule3);

rule1.handle(80);
Copy the code

Rule3.setnext (rule4) when the membership rules are adjusted, or new membership levels are inserted, simply change the order of the chain without adding additional if… The else.

Adapter: Compatible with history projects

My current project was changed from Python service to Spring Cloud microservice after a background reconstruction recently. In addition to the changes in business logic and interface fields, there is another significant difference: Python’s field style is underlined and Java’s is humped ~, which results in most interface field names being changed from underlined to humped.

As part of the historical component is directly using the interface fields, if direct migrated to the new interface, you need to go deep into the each component found in the use of field, scope of change is very big, because these components have been relatively stable, in order to avoid fighting, finally the solution is returned to the fields of the docking port adapter, field mapping for the underline the hump.

The approximate implementation is as follows

function api() {
    return Promise.resolve({
        code: 0.data: {
            userName: 123,}}); }function adapterApi() {
    return api().then((res) = > {
        // Add adaptation
        return {
            code: res.code,
            data: {
                user_name: res.data.userName,
            },
        };
    });
}

function a() {
    api().then((res) = > {
        console.log(res.data.user_name); // undefined
    });

    adapterApi().then((res) = > {
        console.log(res.data.user_name); / / 123
    });
}
Copy the code

The adapter pattern brings with it some implicit problems: if used to avoid changing existing code, the overall project becomes increasingly difficult to maintain over time, so it is best to use it only for compatibility with older systems or third-party services.

summary

This article mainly summarizes some ways to use design patterns to optimize code in daily development. In addition to the above mentioned design patterns, there are other more common design patterns such as decorators, mediation patterns, etc. Due to the lack of space, it is not listed at present.

Design patterns are like “read a lot of great things, but still have a bad life”. I feel like I need to write more code and less literal words. After all, the most effective way to improve your programming is to modify your bad code.