Search code examples
typescriptwebpackvuejs3quasar-frameworkquasar

vue/quasar - dynamically injecting components


I'm trying to figure out a way to dynamically load vue components into my Layout without actually needing to specifically import them. Take this structure

// Directory structure
src/
  layouts/
    MainLayout.vue
  modules/
    client/
      inject.ts
      components/
        AddButton.vue
    property/
      inject.ts
      components/
        AddButton.vue
  boot/
    injectors.ts

// modules/client/inject.ts (client/)
import {defineAsyncComponent} from "vue";

export default [
  {
    component: defineAsyncComponent(() => import('./components/AddButton.vue')),
    id: 'client-add-button',
    to: 'action-bar',
  }
]

// modules/property/inject.ts
import {defineAsyncComponent} from "vue";

export default [
  {
    component: defineAsyncComponent(() => import('./components/AddButton.vue')),
    id: 'property-add-button',
    to: 'action-bar',
  }
]

// boot/injectors.ts

export default boot(() => {
  
  // Somehow loop through all folders in src/modules/ and find inject files
  // 
  const injections = // concat the exported arrays into a single array
  app.provide('$injections', injections);
});

// src/layouts/MainLayout.vue

<template>

  <div>
    <template v-for="comp in injected" :key="comp.id">
      <component :is="comp.component" />
    </template>
  </div>

</template>

<script lang="ts">
import {
  computed, defineComponent, ref,
} from 'vue';

export default defineComponent({
  name: 'MainLayout',
  setup() {
    const injectables = computed( () => {
      return $injections.filter( injection => injection.to === 'action-bar');
    });
  }
});

</script>

The idea is that "modules" don't pollute the global view/layout space, but instead are given the ability to be injected into it.

The part I have no idea about is making this dynamic. I don't want to have to constantly add files to the "boot" file. I want to create modules with inject.ts files have it work

I have a feeling to make this dynamic it has to be done in Webpack rather than in the "boot" module, but I have NFI where to start here


Solution

  • OK. this is going to be long, but I figured this out. I needed to create an extension - I called it InjectMe.

    // injectme/index.js
    
    /**
     * Quasar App Extension index/runner script
     * (runs on each dev/build)
     *
     * Docs: https://quasar.dev/app-extensions/development-guide/index-api
     * API: https://github.com/quasarframework/quasar/blob/master/app/lib/app-extension/IndexAPI.js
     */
    
    path = require('path');
    fs = require('fs');
    
    const extendConf = async function (conf) {
    
       console.log('    Starting InjectMe installation');
       // make sure boot & component files transpile
        conf.boot.push('~quasar-app-extension-injectme/src/boot/setup.ts');
        conf.build.transpileDependencies.push(/quasar-app-extension-injectme[\\/]src/);
    
        const baseDir = 'src/modules/';
    
        try {
           const modules = await fs.promises.readdir(baseDir);
    
           for (const file of modules) {
            const filePath = path.join(baseDir, file);
            // eslint-disable-next-line no-await-in-loop
            const stat = await fs.promises.stat(filePath);
    
             if (stat.isDirectory()) {
                const moduleFiles = await fs.promises.readdir(filePath);
    
                for (const moduleFile of moduleFiles) {
                   const moduleFilePath =  path.join(filePath, moduleFile);
                   if (moduleFile.includes('inject.ts')) {
                       conf.boot.push('../../' + moduleFilePath);
                       conf.build.transpileDependencies.push(moduleFilePath);
                       console.log(`    : InjectMe Installed ${moduleFilePath}`);
                   }
               }
             }
          } // End for...of
        } catch (e) {
          console.error(e);
        }
    
        conf.boot.push('~quasar-app-extension-injectme/src/boot/provide.ts')
    }
    
    /**
     * Quasar App Extension index/runner script
     * (runs on each dev/build)
     *
     * Docs: https://quasar.dev/app-extensions/development-guide/index-api
     * API: https://github.com/quasarframework/quasar/blob/master/app/lib/app-extension/IndexAPI.js
     */
    
    module.exports = function (api) {
        // extend quasar.conf
        api.extendQuasarConf(extendConf)
    }
    
    
    // injectme/src/boot/setup.ts
    import { boot } from 'quasar/wrappers';
    
    export default boot( ({app}) => {
         app.config.globalProperties.$injectme = [];
    });
    
    // injectme/src/boot/provide.ts
    import { boot } from 'quasar/wrappers';
    
    export default boot( ({app}) => {
       app.provide('injectme', app.config.globalProperties.$injectme);
    });
    

    I place this extension a couple of directories above my quasar app (so I can install locally without having it on an NPM repo just yet).

    // Directory structure looks like this
    extensions/
      injectme/
        src/
          /boot
    
    web/ # quasar app
      src/
        modules/
          client/  
    
    

    Now inside my quasar app, I added the extension to the package.json file:

        "quasar-app-extension-injectme": "file:../extensions/injectme", 
    

    Next I invoke the extension into the app:

    quasar ext invoke injectme
    

    Now to load a dynamic module, I need to create an inject.ts file inside a module/ folder

    # web/src/modules/client/inject.ts
    
    
    import { boot } from 'quasar/wrappers';
    import { defineAsyncComponent } from 'vue';
    import { routes } from './routes';
    
    export default boot(({ app }) => {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
      app.config.globalProperties.$injectme.push({
        id: 'client-add-new-button',
        component: defineAsyncComponent(() => import('./components/AddButton.vue')),
        // Anything
        to: 'action-bar',
      });
    
     );
    
    

    Now, reload the app using "quasar dev", and you should see the injectme pick up the inject file.

    It will now have added the component to the global $injectme, which is then provided back to Vue as an "injectable".

    So, head to a new file in a completely different part of the application, and test it out.

    // web/src/layout/MainLayout.vue
    
    <template>
      <div>
        <template v-for="comp in components.filter(c => c.to === 'action-bar')" :key="comp.id">
          <component :is="comp.component" />
        </template>
      </div>
    </template>
    
    <script lang="ts">
    
    ...
    setup() {
    
      const components = inject('injectme');
      return { components };
    
    }
    
    </script>
    

    And there you go. I have done no memory testing just yet, or any performance tests, so this is truly use at your own peril.

    It's really meant for adding buttons here and there, not for massive components with many elements etc.