tldr;

Implement Inversion of Control (IoC) in the code construction phase through Vite. This is a simple but powerful way to split code modules.

Resolution of the module

It is a natural idea to break up the code in a front-end project into modules to manage. For example, we split a project into three modules:

The code in demo-app is then continuously pulled away from demo-plugin1 and demo-plugin2. For example, pull out some purely visual components that don’t contain business logic.

What are the problems?

There are three common problems

  1. If demo-plugin1 is not allowed to rely on demo-app or demo-plugin2, much of the business logic cannot be written because no code can be referenced. However, if demo-plugin1 is allowed to refer to demo-plugin2, it is easy to create circular references
  2. A lot of code is left in demo-app. Because there’s nothing compelling about writing new code into the Demo-app, right
  3. If a new version of demo-plugin1 is published, all references to it are updated with the version number specified in the dependency. In the case of native development, it is not possible to update demo-plugin1 every time to see the effect of the plugin1 in demo-app. Of course, there is a simpler way to update a bunch of modules at the same time

Monorepo by PNPM

Problem 3 is best solved. Use YARN or PNPM to provide an out-of-the-box solution.

  • Define the file pnpm-workshop. yaml in the root directory and add packages/* to the same workspace
  • The package.json package under workspace is referenced with a special version number workspace:*

This allows you to make changes to demo-plugin1 while developing locally and immediately see the effects in the Demo-app

Resolve loop dependencies through the Inversion of Control (IoC)

The solution to problem 1 is to introduce a demo-motherboard package under demo-plugin1 and demo-plugin2. So they don’t have to depend on each other anymore.

For example, we define a component in Demo-motherboard

export function SomePage(props: {
    Comp1: () => any,
    Comp2: () => any
}) {
    const { Comp1, Comp2 } = props;
    return <div><Comp1/><Comp2/></div>
}
Copy the code

Comp1 and Comp2 here are two abstract interface definitions. You don’t need to know about Comp1 and Comp2 implementations in Demo-motherboard. Put the concrete implementation together when referencing SomePage in demo-app:

import { ComponentProvidedByPlugin1 } from 'demo-plugin1';
import { ComponentProvidedByPlugin2 } from 'demo-plugin2';

<SomePage Comp1={ComponentProvidedByPlugin1} Comp1={ComponentProvidedByPlugin2} />
Copy the code

In this way, multiple modules of code are assembled together through function call relationships in runtime memory when the code is first executed. This assembly can be even more flexible, such as demo-app dynamically downloading demo-plugin1 code at runtime, which is the so-called micro-frontend solution.

Disadvantages of hand-written assembly code

The previous solution works, but is a bit verbose.

  • As more and more modules are broken down, the Demo – App constantly needs to hand-write such module assembly code. Is there a way to reduce the amount of code in Demo-App and automate it?
  • Looking back at the three problems mentioned earlier, problem 2 was that the amount of code in the Demo-App was growing and there was no way to stop writing new code into the Demo-App

This paper introduces an Inversion of Control method based on Vite in construction phase.

Motherboard declares abstract interfaces @plugin1 and @plugin2

We’ll look at demo-motherboard, demo-plugin1, demo-plugin2 and then demo-app.

The first step is to define the interface plugin1.abstract. Ts in Demo-motherboard.

import { defineComponent } from "vue";

// interface declaration
export const ComponentProvidedByPlugin1 = defineComponent({
    props: {
        msg: {
            type: String,
            required: true
        }
    },
    data() {
        return {
            hello: ''
        }
    },
    methods: {
        onClick() {
        }
    }
})

export function spiExportedByPlugin1ForOtherPlugins(): string {
    throw new Error('abstract');
}
Copy the code

Similarly, plugin2.abstract. Ts. The files labeled *.abstract.ts are not special.d.ts files, they are normal typescript source files. The *.abstract.ts is added to make it easier for us to distinguish when we read the code, knowing that the code is just a declaration and doesn’t actually contain an implementation.

We can then reference the two components in somePage.tsx that do not contain the implementation code.

import { ComponentProvidedByPlugin1 } from '@plugin1'; import { ComponentProvidedByPlugin2 } from '@plugin2'; import * as vue from 'vue'; export const SomePage = vue.defineComponent({ render() { return <div> === <ComponentProvidedByPlugin1 msg="hello" /> ===  <ComponentProvidedByPlugin2 position="blah" /> </div> } })Copy the code

It is worth noting that instead of using./plugin1.abstract. Ts, @plugin1 is used. So where does @plugin1 come from? We can find the answer by opening tsconfig.json

{
  "compilerOptions": {
    "paths": {
      "@plugin1": ["../demo-motherboard/src/plugin1.abstract.ts"],
      "@plugin2": ["../demo-motherboard/src/plugin2.abstract.ts"]
    }
  }
}
Copy the code

Implement the interface declared by @plugin1 in demo-plugin1

We will now introduce @plugin1 in demo-plugin1. As with demo-motherboard, modify tsconfig.json.

Then define ComponentProvidedByPlugin1 implementation

import * as vue from 'vue'; import * as plugin1 from '@plugin1'; // demo-motherboard does not depend on demo-plugin1 // demo-plugin2 does not depend on demo-plugin1 // even if we export  this function, they can not import it export function secretHiddenByPlugin1() { return 'is secret' } // implement the abstract declaration of @plugin1 // if the implementation does not match declaration, typescript will complain type incompatible export const ComponentProvidedByPlugin1: typeof plugin1.ComponentProvidedByPlugin1 = vue.defineComponent({ props: { msg: { type: String, required: true } }, data() { return { hello: 'world' } }, methods: { onClick(): void { secretHiddenByPlugin1(); } }, render() { return <div>ComponentProvidedByPlugin1</div> } });Copy the code

Pay special attention to the type definition const ComponentProvidedByPlugin1: typeof ponentProvidedByPlugin1 at plugin1.Com. This ensures that the interface declaration and implementation are type compatible. If they are incompatible, we can find them immediately by opening the demo-plugin1 code.

Similarly, realize spiExportedByPlugin1ForOtherPlugins. Finally, export all the implementation code in index.ts. This completes the implementation definition of @plugin1.

Implement the interface declared by @plugin2 in demo-plugin2

We will now introduce @plugin2 in demo-plugin2. As with demo-motherboard, modify tsconfig.json.

Then define ComponentProvidedByPlugin2 implementation

import * as vue from 'vue'; import * as plugin2 from '@plugin2'; import * as plugin1 from '@plugin1'; // demo-motherboard does not depend on demo-plugin2 // demo-plugin1 does not depend on demo-plugin2 // even if we export  this function, they can not import it export function secretHiddenByPlugin2() { return 'is secret' } // implement the abstract declaration of @plugin1 // if the implementation does not match declaration, typescript will complain type incompatible export const ComponentProvidedByPlugin2: typeof plugin2.ComponentProvidedByPlugin2 = vue.defineComponent({ props: { position: { type: String, required: true } }, data() { return { left: 100, right: 200 } }, methods: { move(): void { secretHiddenByPlugin2(); // demo-plugin2 does not depend on demo-plugin1 in compile time // however, in runtime, demo-plugin2 can call demo-plugin1 // as long as the interface has been declared by demo-motherboard plugin1.spiExportedByPlugin1ForOtherPlugins(); } }, render() { return <div>ComponentProvidedByPlugin2</div> } });Copy the code

. It is important to note that here plugin1 spiExportedByPlugin1ForOtherPlugins () implements plugin2 invokes the plugin1 code. However, not all demo-plugin1 implementations are free to refer to. Such as plugin1. SecretHiddenByPlugin1 () will be an error. Since @plugin1 is declared in demo-motherboard, the implementation code contains secretHiddenByPlugin1, but the declaration does not.

The demo app assembly

And finally how to put it together. Reference SomePage defined by Demo-motherboard in app.vue:

<script setup lang="ts">
import { SomePage } from 'demo-motherboard';
</script>
<template>
    <SomePage />
</template>
Copy the code

If vite is run without any configuration, we can see the following error:

error when starting dev server:
Error: The following dependencies are imported but could not be resolved:

  @plugin1 (imported by /home/taowen/vite-ioc-demo/packages/demo-motherboard/src/SomePage.tsx)
  @plugin2 (imported by /home/taowen/vite-ioc-demo/packages/demo-motherboard/src/SomePage.tsx)
Copy the code

To fix this missing problem, we modify vite.config.ts

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue(), { // we can inject different implementation, // as long as @plugin1 interface has been implemented name: 'inject @plugin1', resolveId(id) { if (id === '@plugin1') { return 'demo-plugin1';  } } }, { // we can inject different implementation, // as long as @plugin2 interface has been implemented name: 'inject @plugin2', resolveId(id) { if (id === '@plugin2') { return 'demo-plugin2'; } } }], base: '', })Copy the code

So we plugged in @plugin1 to demo-plugin1, and @plugin2 to demo-plugin2.

The advantage of this is that no matter how many components are added to the assembly, the viet.config. ts does not need to be modified. The number of lines of code in Demo-app can theoretically be fixed. New requirements can be written in demo-plugin1 or demo-plugin2 if they are local, and demo-motherboard needs to be modified if the global scope needs to be logically correlated. This makes it easier to spot potential problems in the code organization by focusing on the demo-app changes and the Demo-Motherboard changes.

If you just want to enforce modularity, you don’t need a heavy-duty runtime scheme like micro-frontend. Typescript + Vite takes care of that during code construction.

If you think PNPM and Vite are too modern, in NPM-IOC-demo, we switched to the simplest technology stack to achieve the same effect.

tldr;

In the Viet-IOC-demo, we show how to use typescript+ PNPM + Vite to implement an Inversion of Control in the construction phase. In this repository, we use typescript+ NPM to achieve the same effect.

Build monorepo by hand

Instead of using PNPM’s off-the-shelf solution, we use manual soft links.

#! /usr/bin/env bash pushd packages/demo-app rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard ln -s .. /.. /demo-plugin1 node_modules/demo-plugin1 ln -s .. /.. /demo-plugin2 node_modules/demo-plugin2 popd pushd packages/demo-motherboard rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin popd pushd packages/demo-plugin1 rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard popd pushd packages/demo-plugin2 rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard popdCopy the code

This establishes the dependencies of the four packages under packages/*. The dependencies between them are not defined in package.json, but are maintained in linkmonorepo.sh.

What about third-party packages? These dependencies are not defined in packages/*, but in package.json of the top-level module.

Typescript declares and imports @plugin1 and @plugin2

The declaration is in demo-motherboard

  • plugin1.abstract.ts
  • plugin2.abstract.ts

The three modules each import declarations with their own tsconfig.json

  • demo-motherboard/tsconfig.json
  • demo-plugin1/tsconfig.json
  • demo-plugin2/tsconfig.json

These declarations make vscode prompt work properly

Compile TS, TSX to JS

Instead of relying on Bundler like Vite or WebPack, we build in typescript.

cd packages/demo-app
tsc -b --watch
Copy the code

This command will do

  • Build demo-app with the associated Demo-motherboard, demo-plugin1, and demo-plugin2
  • If a file is modified in a project like demo-plugin1, it will be rebuilt immediately because of –watch
  • Builds are incremental –watch to file changes to recompile is pretty fast

This is defined in tsconfig.json depending on demo-app:

{
  "compilerOptions": {
    "composite": true,
    "outDir": "lib"
  },
  "references": [
    { "path": "../demo-motherboard" },
    { "path": "../demo-plugin1" },
    { "path": "../demo-plugin2" }
  ]
}
Copy the code

As a result, ts and TSX become JS files in the lib/ SRC directory of each module. Typescript’s new build model of managing multiple modules in a single command works well.

Webpack the scattered JS files into one

If you are executing with Node.js, go to the previous step. If you want to do this in the browser, you need to do some bundle packaging in Webpack. Execute the command

webpack serve
Copy the code

Start dev Server for Webpack. Notice that the previous TSC-b –watch process still exists. These two processes are responsible for different things:

  • TSC is responsible for putting TS, TSX => js
  • Webpack is responsible for packaging TSC built JS into a single package and provides http://localhost:3000 for the browser to open

So webpack configuration is simple, no ts-loader and no babel-loader:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: "development",
    entry: './lib/src/index.js',
    output: {
        filename: 'index.js',
        path: path.resolve(__dirname, 'dist'),
    },
    plugins: [new HtmlWebpackPlugin({
        template: './index.html'
    })],
};
Copy the code

Once implemented, we’ll see that WebPack is having trouble bundling.

Module not found: Error: Can't resolve '@plugin1' in '/home/taowen/npm-ioc-demo/packages/demo-motherboard/lib/src'
resolve '@plugin1' in '/home/taowen/npm-ioc-demo/packages/demo-motherboard/lib/src'
Copy the code

Typescript compilation does not link @plugin1 to demo-plugin1. Js is still import from @plugin1. To avoid configuring webpack, we link directly through node_modules. Modify linkMonorepo. Sh:

#! /usr/bin/env bash pushd packages/demo-app rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard ln -s .. /.. /demo-plugin1 node_modules/demo-plugin1 ln -s .. /.. /demo-plugin2 node_modules/demo-plugin2 popd pushd packages/demo-motherboard rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-plugin1 node_modules/@plugin1 ln -s .. /.. /demo-plugin2 node_modules/@plugin2 popd pushd packages/demo-plugin1 rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard ln -s .. /.. /demo-plugin1 node_modules/@plugin1 ln -s .. /.. /demo-plugin2 node_modules/@plugin2 popd pushd packages/demo-plugin2 rm -rf node_modules mkdir node_modules ln -s .. /.. /.. /node_modules/.bin node_modules/.bin ln -s .. /.. /demo-motherboard node_modules/demo-motherboard ln -s .. /.. /demo-plugin1 node_modules/@plugin1 ln -s .. /.. /demo-plugin2 node_modules/@plugin2 popdCopy the code

Then WebPack will work properly. As you can see linkmonorepo. sh is very low tech, but very simple to rely on. It’s easier than flipping through various vite. Config. js or webpack.config.js.