With plug-ins, developers can easily solve their own problems or extend scenario-specific functionality. System users can use more features. System owners can build a product ecosystem and reduce maintenance costs. Clearly this is a win-win-win scenario.

Plug-ins are a good design pattern. This article introduces the use scenario of plug-in, how to design plug-in system, how to develop plug-in system, how to ensure the security of plug-in system and so on four aspects to introduce plug-in, let you understand and master this increasingly widely used powerful thought mode.

Do you really need plug-ins?

Getting your system to support plug-ins and rest easy is not easy. To support plug-ins, our system would have to introduce some functionally irrelevant code and logic, adding complexity to the system. In addition, security of plug-ins is the biggest challenge, ensuring that plug-ins do not interfere with each other and that plug-ins do not affect the main system. In a Web environment, it is easy to modify object prototypes and manipulate the DOM.

So, don’t introduce a plug-in system unless you can make sure it’s secure and controllable.

Atom’s demise was blamed on plug-ins, which slowed down the performance of the entire editor and introduced security concerns.

Whether plug-ins are supported or not is a tradeoff. If the following scenarios are met, a plug-in system can be given priority. The benefit is far greater than the cost (the main cost is to ensure security).

A plug-in can be thought of as an idea that extends new functionality to an existing system (component) without concern for the implementation of that functionality.

  1. Ensure core layer stability: Microkernel Architecture, also known as plug-in Architecture. The core system provides common capabilities, with plug-ins to implement business functions. Such as Web PPT
  2. Open up the ability for users to solve scenario-specific problems themselves. For example: Webpack, Figma.
  3. The goal is to build a product platform for third-party developers to develop new features themselves.
  4. Adapt to changing business scenarios. When a module varies with a business scenario, it can be abstracted as a plug-in.

Design Principles

A good plug-in system is first and foremost a good software system.

The optimal solution is the simplest solution

An excellent software system, I think it must be simple (clear), whether it is the design scheme, implementation ideas, or system interaction, should be simple and clear.

Nicholas Zakas, senior JavaScript programmer, wrote On Designing Great Systems:

A good framework or a good architecture makes it hard to do the wrong thing. A lot of people will always go for the easiest way to get something done. So as a systems designer, your job is to make sure that the easiest thing to do is the right thing. And once you get that, the whole system gets more maintainable.

Yes, the best solution is always the simplest, and when your design or solution is complex, you might want to look again and see if there is a better solution.

System designers should design systems that make functionality easier to implement, modify, and extend.

Abstract change and invariance

Good Book recommendation: The Way to Organize yourself

The core principle of system architecture is to abstract and isolate the variable and invariant parts of the system. For the invariant parts, we need to keep them stable. For the parts that change, encapsulate and isolate them to make them easy to expand. The original intention of the plug-in system is the practice of the principle of change. The easy to change part is provided in the form of plug-in, and the unchanged part is taken as the core of the system, so that the change of plug-in will not affect the core layer of the system.

SOLID design principles

When it comes to concrete modules (components, classes, functions), we need to follow SOLID design principles.

  1. SRP: Single responsibility principle. Every software module has one and only one reason to be changed.
  2. OCP: Open and close principle. Open to extension, closed to modification. Allow new code to extend rather than modify existing code. Here the specific design often needs to combine SRP and DIP principles.
  3. LSP: Indicates the Richter replacement principle. The polymorphic idea relies on an interface that is interchangeable between concrete classes that implement that interface.
  4. ISP: interface isolation principle. Only rely on what is needed, not affected by other implementation changes. Lodash, for example, is introduced on demand.
  5. DIP: dependency inversion principle. Interface – oriented programming, more use of stable abstract interface, less dependent on changeable concrete implementation.

When designing and developing a system, there are three layers:

High level: architectural design.

Middle level: design principles, design patterns.

Bottom line: Code cleanliness.

How to design a plug-in system

Objective: To design a simple, flexible and secure plug-in system.

The core concept

Any design needs to be established in the specific needs above, no scheme is universally applicable. Plug-in systems come in many patterns and forms, and the following basic designs can help you envision and design a system for a particular scenario:

Hook

The system needs to provide hooks that allow the plug-in to decide when it is called to execute. The Webpack plug-in, for example, defines many hooks.

compiler.hooks.beforeCompile.tapAsync('MyPlugin'.(params, callback) = > {
  params['MyPlugin - data'] = 'important stuff my plugin will use later';
  callback();
});
Copy the code

Plugin Interface

Interface oriented programming. Defines the interface that the plug-in needs to implement. To develop a plug-in, you must implement the system-defined plug-in interface. This is a specification that plug-ins must follow, based on which the system can verify, register, mount, unregister, and so on.

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('Hello World Plugin'.(
      stats /* stats is passed as an argument when done hook is tapped. */
    ) = > {
      console.log('Hello World! '); }); }}module.exports = HelloWorldPlugin;
Copy the code

Plugin Loader

Plug-in loader, the main function is to register plug-ins. There are two main models:

Plug-in driver: Plug-ins know how to access programs and register themselves. The system provides a way for plug-ins to register themselves with a program, such as vue.use.

System driver: The system knows how to find plug-ins and load the plug-ins it finds. For example, loading files under plugins, pulling a list of plugins from a custom interface, and so on. For example, Webpack pulls the plug-in from the configuration file and loads it.

Develop plug-in system

Above we understand the core concept of plug-in system and prototype design, next we will simply implement a plug-in system. To calculator extension plugin system, support developers to extend functions.

Step 1: Make a simple calculator.

class Calculator {

  currentValue: number = 0;

  setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }
  
  plus(addend: number): void {
    this.setValue(this.currentValue + addend);
  }
  
  minus(subtrahend: number): void {
    this.setValue(this.currentValue - subtrahend); }}const calculator = new Calculator();
calculator.setValue(3); / / = > 3
calculator.plus(3);     / / = > 6
calculator.minus(2);    / / = > 4
Copy the code

This is a Web application without a plug-in system, all operations (addition, subtraction) need to be developed in the main system, such a system is not easy to maintain and expand. Next, based on this simple calculator, let’s design it as a plug-in system.

Step 2: Make your calculator support plug-ins.

type IExec = (currentValue: number, operand? : number) = > number;

interface IPlugin {
  name: string;
  exec: IExec;
}

class Calculator {

  constructor(private currentValue: number = 0) {}

  private plugins: { [name: string]: IExec } = {};

  private setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }
  
  public register(plugin: IPlugin) {
    const { name, exec } = plugin;
    this.plugins[name] = exec;
  }

  public press(operation: string, operand? : number) {
    const exec = this.plugins[operation];
    if (typeofexec ! = ='function') {
      throw Error(`${operation}operation no support yet! `);
    }
    this.setValue(exec(this.currentValue, operand)); }}const calculator = new Calculator(3);
calculator.press('plus'.2); // Uncaught Error: plus operation no support yet!
Copy the code

We separate all operations into a plug-in, and the core function of the calculator only provides the registration and invocation logic of the plug-in. Next, we develop plug-ins for each operator.

Step 3: Implement custom plug-ins

// plus plugin
const plus: IPlugin = {
  name: 'plus'.exec: (currentValue, addend) = > {
    if(! addend) {throw Error('addend is required! ');
    }
    returncurrentValue + addend; }}// minus plugin
const minus: IPlugin = {
  name: 'minus'.exec: (currentValue, subtrahend) = > {
    if(! subtrahend) {throw Error('subtrahend is required! ');
    }
    returncurrentValue - subtrahend; }}const calculator = new Calculator(3);
calculator.register(plus);
calculator.press('plus'.2); / / = > 5
Copy the code

At this point, we have completed a simple plug-in system.

We’ve abstracted the calculator into two parts, control and behavior. All behaviors, such as addition, squaring, etc., are provided through plug-ins, ensuring the core layer is stable and easy to expand. (Architecture Layer design)

We design variables in the system as private variables to avoid external tampering, and only provide the only method to modify data press and plug-in registration method register. The plug-in interface is defined, following the DIP principle. Plug-ins are isolated, and all plug-in code is a pure function, which not only facilitates testing, but also reduces plug-in coupling to the system and reduces plug-in attack risk to the system. (Design of module layer)

However, on the Web, is this isolation of plug-ins safe?

If someone adds the following code to a plug-in:

console.log = function() {
  alert('You're under attack.');
}
Copy the code

In JS, you have direct access to many global methods and objects, and if one object is tampered with, it affects everything. In addition, CSS styles are global, and there can be style conflicts and contamination between plug-ins, or between plug-ins and systems.

Our goal is to design a secure plug-in system, especially when our plug-in is developed to the third party developers, plug-in security is a problem that must be paid attention to.

Plug-in security

A popular solution for ensuring plugin security is Sandboxing. Plug-ins are typically code from third-party developers, and if you can execute the plug-in code in an environment (scope) that is isolated from the system, the plug-in will not have side effects on the system. Node has a VM module that allows code to be executed in a separate environment, but the browser doesn’t and we need to implement it ourselves.

Next, we will take a look at several popular sandbox solutions, including JS sandbox and CSS sandbox.

JS sandbox

Iframe

A natural sandbox is also the best isolation, iframe should be the first choice of sandbox solution. Such as CodePen.

When we want to set iframe to sandbox, it is best to set the sandbox property.

This property imposes more restrictions on iframe content:

See Iframe for more information

  1. Script The script cannot be executed.
  2. Ajax requests cannot be sent.
  3. Cannot use localStorage, i.e. localStorage, cookies, etc.
  4. Cannot create new popovers and Windows.
  5. The form cannot be sent.
  6. Can’t load extra plug-ins like Flash etc.

If the sandbox is set to a null character value, all restrictions (the strictest) apply. Specific restrictions can be lifted by setting specific values for the Sandbox.

<! -- All restrictions in effect -->
<iframe src="xxx" sandbox=""></iframe>
<! Allow script execution, allow form submission, allow local requests
<iframe src="xxx" sandbox="allow-scripts,allow-forms,allow-same-origin"></iframe>
Copy the code

However, in practice, iframe also encounters some other problems:

  1. PostMessage messages can only be pure strings, and serialization of data can take a lot of time if the plug-in interacts with the system with a large amount of data.
  2. In certain scenarios, it is not easy to assemble into the system because plug-ins can only run in a separate IFrame.

Run on the main thread

This is dangerous when plug-in code is executed on the main thread of the system, mainly because it can arbitrarily access and call the browser’s global API. So we want to hide global variables.

We can set the Window and Document objects to NULL, but it is difficult to eliminate all global variables due to the JS prototype chain pattern. For example, you can retrieve an Object from ({}).constructor and modify the prototype chain methods and properties of all objects.

So we need to build a sandbox where we can’t access global variables (or only global variables that we’ve processed).

Standalone JS interpreter

This is a solution Figma tried, they wanted to write their own JS interpreter, but it was too expensive, Finally use the [Duktape] (https://github.com/svaarala/duktape) (a lightweight JavaScript interpreter written in c + +), then compiled into WebAssembly. Duktape does not support any browser apis, runs in WebAssembly and has no access to the browser apis, which seems to be a successful solution.

However, it still has some problems, mainly because the Duktape interpreter is too backward to debug, and the script execution performance is not as good as the browser’S JS engine.

In the end, Fimga did not adopt this solution, they adopted a better solution.

Realms

A new proposal for Stage 2, the Realms proposal provides a new mechanism for implementing JavaScript code in the context of new global objects and a set of JavaScript built-in objects.

const red = new Realm();
globalThis.someValue = 1;
red.evaluate('globalThis.someValue = 2'); // Affects only the Realm's global
console.assert(globalThis.someValue === 1);
Copy the code

One of the best practices for this proposal is Sandboxing, which is currently a Stage 2 proposal and cannot be used in production.

However, the idea is that you can implement this technique using existing JavaScript functionality, and the main idea is to create a separate code execution environment context. The core implementation is as follows:

function simplifiedEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode)
  }
}
Copy the code

The with statement extends the scope chain of a statement by adding the given expression to the nearest scope chain where the statement is executed.

with (Math) {
  const r = 2;
  a = PI * r * r
  x = r * cos(PI)
  y = r * sin(PI)
  console.log(x,  y)
}
Copy the code

If a variable in the execution statement is not found in the current block scope, it is found up the scope chain, and the expression after with is the nearest scope.

So, we can use with + Proxy to implement the sandbox. In the above example, eval(userCode) global variables in userCode are first looked up in scopeProxy. If we set get and set to scopeProxy, global variables accessed and modified in userCode will be intercepted.

Implement a simple sandbox Eval using with + Proxy + Whitelist.

const whitelist = {
  window: undefined.document: undefined
};
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  },
  set(target, name, value){
    if(Object.keys(whitelist).includes(name)){
      whitelist[name] = value;
      return true;
    }
    return false; }})function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode)
  }
}
const code = ` console.log(window); // undefined window.aa = 123; // Cannot set property 'aa' of undefined `;
sandBoxingEval(scopeProxy, code);
Copy the code

Above, we hid global variables like window and document using a whitelist mechanism. However, some global variables can still be accessed through expressions like ({}).constructor. In addition, the sandbox does require access to certain global variables, such as Object, which is often present in legitimate JavaScript code, such as object.keys.

At this point, iframe comes into play again. Inside the iframe is a contentWindow that holds copies of all global variables, such as Object.prototype. In the same origin case, we can get the contentWindow on the main thread.

const iframe = document.createElement('iframe', { url:'about:blank' });

document.body.appendChild(iframe);

const sandboxGlobal = iframe.contentWindow;

console.log(sandboxGlobal); // Window {Window: Window, self: Window, document: document, name: "", location: location,... }Let's modify the above implementation slightly to treat the contentWindow as a whitelist.const iframe = document.createElement('iframe', { url:'about:blank' });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
const scopeProxy = new Proxy(sandboxGlobal, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  },
  set(target, name, value){
    if(Object.keys(target).includes(name)){
      target[name] = value;
      return true;
    }
    return false; }})function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode)
  }
}
const code = ` window.aa = 123; ({}).constructor.prototype.aa = 'aa'; `;
sandBoxingEval(scopeProxy, code);

// down the main thread
window.aa; // undefined
({}).aa; // undefined
Copy the code

So far, we’ve achieved a well-isolated sandbox, but that’s not the end of the story.

The plug-in code can be sandboxed independently, but in the implementation scenario, we need to provide capabilities to the plug-in. We might expose some utility methods to the plug-in, but this would be extremely dangerous because the plugin could use this method internally to follow the prototype chain to our main thread and access and modify the global variables of the main thread.

Therefore, providing capabilities for plug-ins is also a matter of extreme care, which can be wasted. Learn more about Figma’s implementation ideas: How to build a plugin system on the Web and also sleep well at night

The main idea here is to change the reference of the prototype chain by sandboxing a layer of function package passed in.

const iframe = document.createElement('iframe', { url:'about:blank' });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
sandboxGlobal.log = v= > v + v;
const scopeProxy = new Proxy(sandboxGlobal, {})

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(` const safeFactory = fun => (... args) => fun(... args); log = safeFactory(log); `)
    eval(userCode)
  }
}
const code = ` log.constructor.prototype.__proto__.aa = 'aa'; console.log(log(1)) // 2 `;
sandBoxingEval(scopeProxy, code);
({}).aa // undefined
Copy the code

CSS sandbox

The namespace

This requires following certain naming conventions, such as defining a unique class prefix. Such as Antd Design, Iview. This is the simplest and most direct way to handle it, and the lowest cost. It requires artificial constraints, which can easily cause style conflicts and pollution.

Here we use engineering tools, or write a converter, plug-in code processing, add a unique namespace.

Scope CSS

In Vue, we can use scope to control the scope of CSS. Scope CSS is slightly different from CSS Module in that it uses property selectors to narrow the Scope of the CSS.

<style scope lang="less"></style>
Copy the code

Here is an idea, we can learn from this idea, in the process of plug-in code, develop an intermediate processor, add unique attributes to the plug-in HTML nodes, and then replace them all with attribute selectors in CSS (vue-loader). You can do it yourself with @vue/ component-Compiler-utils and postCSS.

Scope CSS implementation in Vue

.box[data-v-992092a6] {
  width: 200px;
  height: 200px;
  background: #aff;
}
Copy the code
<div data-v-992092a6>scoped css</div>
Copy the code

Shadow DOM

Shadow DOM is an important part of the new Web Components technology.

The core of Web Components technology is encapsulation. Shadow DOM allows us to create an independent Shadow space, where the styles inside do not affect the styles inside, and the styles outside do not affect the styles inside. It is truly independent.

This solution is a trend in the future, but it’s not compatible yet, so if you only use chrome, it’s the first one.

Create a Shadow space with element.attachShadow () and then add a separate DOM to it. Note that not all tags can be called attachShadow to generate space.

const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'closed' });
const pluginDom = getPluginDom();
shadowRoot.appendChild(pluginDom);
Copy the code

This image comes from: mp.weixin.qq.com/s/pIRFNpAo8…

conclusion

This article starts with a plug-in usage scenario where we need to weigh whether supporting plug-ins is really necessary. A good plug-in system, the premise is a good software system, so before design, we need to understand the basic design principles and specifications. Plug-in system can be regarded as a best practice combining design principles with actual scenarios. This paper gives the basic design model and core concepts, which can be combined with the actual business scenarios to design our own plug-in system. This article also led you to a simple implementation of a plug-in system, from which we see the importance of plug-in system security. How to ensure the security of plug-ins, we can start from the JS sandbox and CSS sandbox two aspects, a simple introduction of several mainstream solutions for your reference.

I hope you can benefit from it.

The resources

  1. How to build a plugin system on the web and also sleep well at night
  2. Designing a JavaScript Plugin System | CSS-Tricks
  3. How to Design Software — Plugin Systems
  4. tc39/proposal-realms
  5. Scope CSS implementation in Vue