Our company has a workbench building product, which allows to build a workbench page by dragging and dropping widgets. The platform has some common widgets built in, and also allows self-developed widgets to be uploaded for use. This paper will introduce its implementation principle from the perspective of practice.

Ps. This project was created using Vue CLI, using Vue version 2.6.11 and WebPack version 4.46.0.

Create a project

Start by using the Vue CLI to create a project and create a new widgets directory under the SRC directory to hold the widgets:

A widget consists of a Vue single file and a JS file:

The contents of the test component index.vue are as follows:

<template>
  <div class="countBox">
    <div class="count">{{ count }}</div>
    <div class="btn">
      <button @click="add">+ 1</button>
      <button @click="sub">- 1</button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'count'.data() {
    return {
      count: 0,}},methods: {
    add() {
      this.count++
    },
    sub() {
      this.count--
    },
  },
}
</script>

<style lang="less" scoped>
.countBox {
  display: flex;
  flex-direction: column;
  align-items: center;

  .count {
    color: red; }}</style>
Copy the code

A very simple counter.

Index.js is used to export components:

import Widget from './index.vue'

export default Widget

const config = {
    color: 'red'
}

export {
    config
}
Copy the code

In addition to exporting components, export configurations are also supported.

The app.vue component of the project we used as a development preview and test for the widget looks like this:

The configuration of the widget affects the color of the border around the widget container.

Package widget

Assuming that our widget is already developed, we need to package it, compile the Vue single file into A JS file, and package it using WebPack. First create a WebPack configuration file:

The common configuration items for Webpack are entry, Output, Module, and plugins.

1. Entry portal

The entry is obviously an index.js file in each widget directory. Since the number of widgets is variable and may increase, the entry cannot be written to death and needs to be generated dynamically:

const path = require('path')
const fs = require('fs')

const getEntry = () = > {
    let res = {}
    let files = fs.readdirSync(__dirname)
    files.forEach((filename) = > {
        // Is a directory
        let dir = path.join(__dirname, filename)
        let isDir = fs.statSync(dir).isDirectory
        // Check whether the entry file exists
        let entryFile = path.join(dir, 'index.js')
        let entryExist = fs.existsSync(entryFile)
        if (isDir && entryExist) {
            res[filename] = entryFile
        }
    })
    return res
}

module.exports = {
    entry: getEntry()
}
Copy the code

2. The output output

Since we need to test after development, so it’s easy to request the packaged file, we print the package results of the widget directly to the public directory:

module.exports = {
    // ...
    output: {
        path: path.join(__dirname, '.. /.. /public/widgets'),
        filename: '[name].js'}}Copy the code

3. The module module

Here we need to configure loader rule:

  • We need vuE-loader to process Vue single files

  • Compiling the latest JS syntax requires babel-loader

  • Less-loader is required to process less

Vue loader and babel-loader are already installed, so we don’t need to install them manually. Install loader to handle less files.

npm i less less-loader -D
Copy the code

Different versions of less-loader also have requirements on the WebPack version. If the installation fails, you can specify a less-loader version that supports the current WebPack version.

Modify the configuration file as follows:

module.exports = {
    // ...
    module: {
        rules: [{test: /\.vue$/,
                loader: 'vue-loader'
            },
            {
                test: /\.js$/,
                loader: 'babel-loader'
            },
            {
                test: /\.less$/,
                loader: [
                    'vue-style-loader'.'css-loader'.'less-loader'}]}}Copy the code

4. Plugins plugins

We use two plugins, one for vue-loader and the other for clearing the output directory:

npm i clean-webpack-plugin -D
Copy the code

Modify the configuration file as follows:

const { VueLoaderPlugin } = require('vue-loader')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
    // ...
    plugins: [
        new VueLoaderPlugin(),
        new CleanWebpackPlugin()
    ]
}
Copy the code

This is where the webPack configuration is done. Next, write the packaged script file:

We use Webpack via the API:

const webpack = require('webpack')
const config = require('./webpack.config')

webpack(config, (err, stats) = > {
    if (err || stats.hasErrors()) {
        // Handle the error here
        console.error(err);
    }
    // The processing is complete
    console.log('Packed and done');
});
Copy the code

Nodesrc /widgets/build.js on the command line, or in a package.json file if you don’t mind:

{
    "scripts": {
        "build-widgets": "node src/widgets/build.js"}}Copy the code

After running it, you can see that the package result is already there:

Using widgets

Our requirement is to dynamically request the widget file online, and then render the widget. Requesting ajax access to the widget’s JS file contents, rendering our first thought was to use the Vue.component() method to register, but this is not possible because the global registration component must occur before the root Vue instance is created.

So here we’re using component. Vue’s Component can accept the name of the component or an option object for a component, just as we can provide the option object for the widget.

We use axios to get the js string, and then use new Function to dynamically get the exported option object:

// Call this method after clicking the load button
async load() {
    try {
        let { data } = await axios.get('/widgets/Count.js')
        let run = new Function(`return ${data}`)
        let res = run()
        console.log(res)
    } catch (error) {
        console.error(error)
    }
}
Copy the code

Normally we can get the exported module, but unexpectedly error!

To be honest, I don’t know what is wrong and even search for preset results, but after trying, I find that the preset in babel.config.js of the project is changed from @vue/cli-plugin-babel/ to @babel/preset-env, and it is ok. I don’t know anyway, of course, just using @babel/preset-env might not be enough, which is up to you to tune in to the situation.

However, when I read the official Vue CLI documentation, I came across the following:

My gut tells me that this must be the problem, so I changed vue.config.js to the following:

module.exports = {
  presets: [['@vue/cli-plugin-babel/preset', {
      useBuiltIns: false}}]]Copy the code

It’s not elegant to manually modify the babel.config.js file every time you pack it. You can modify the build.js file by scripting it before you pack it and then resuming it after you pack it:

const path = require('path')
const fs = require('fs')

// babel.config.js file path
const babelConfigPath = path.join(__dirname, '.. /.. /babel.config.js')
// Cache the original configuration
let originBabelConfig = ' '

// Modify the configuration
const changeBabelConfig = () = > {
    // Save the original configuration
    originBabelConfig = fs.readFileSync(babelConfigPath, {
        encoding: 'utf-8'
    })
    // Write the new configuration
    fs.writeFileSync(babelConfigPath, ` module.exports = { presets: [ ['@vue/cli-plugin-babel/preset', { useBuiltIns: false }] ] } `)}// Restore the original configuration
const resetBabelConfig = () = > {
    fs.writeFileSync(babelConfigPath, originBabelConfig)
}

// Modify before packaging
changeBabelConfig()
webpack(config, (err, stats) = > {
    // Restore the package
    resetBabelConfig()
    if (err || stats.hasErrors()) {
        console.error(err);
    }
    console.log('Packed and done');
});
Copy the code

A few lines of code free your hands. Now look at the widget export data we finally retrieved:

Now that we have the widget’s option object, we can just throw it to the Component:

<div class="widgetWrap" v-if="widgetData" :style="{ borderColor: widgetConfig.color }">
    <component :is="widgetData"></component>
</div>
Copy the code
export default {
    data() {
        return {
            widgetData: null.widgetConfig: null}},methods: {
        async load() {
            try {
                let { data } = await axios.get('/widgets/Count.js')
                let run = new Function(`return ${data}`)
                let res = run()
                this.widgetData = res.default
                this.widgetConfig = res.config
            } catch (error) {
                console.error(error)
            }
        }
    }
}
Copy the code

The effect is as follows:

Isn’t that easy?

Digging into Component components

Finally, let’s take a look at how component works from a source point of view. Let’s first look at what the resulting render function for Component looks like:

_c is createElement method:

vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
Copy the code
function createElement (
  context,// Context, i.e. parent component instance, i.e. App component instance
  tag,// The option object for our dynamic component Count
  data,// {tag: 'component'}
  children,
  normalizationType,
  alwaysNormalize
) {
  // ...
  return _createElement(context, tag, data, children, normalizationType)
}
Copy the code

Ignore some branches that are not entered and go directly to the _createElement method:

function _createElement (context, tag, data, children, normalizationType) {
    // ...
    var vnode, ns;
    if (typeof tag === 'string') {
        // ...
    } else {
        // Component option object or constructor
        vnode = createComponent(tag, data, context, children);
    }
    // ...
}
Copy the code

Tag is an object, so it goes to the else branch, which executes the createComponent method:

function createComponent (Ctor, data, context, children, tag) {
    // ...
    var baseCtor = context.$options._base;

    // Option object: convert to constructor
    if (isObject(Ctor)) {
        Ctor = baseCtor.extend(Ctor);
    }
    // ...
}
Copy the code

BaseCtor is the Vue constructor, and Ctor is the option object of the Count component, so the vue.extend () method is actually executed:

This method essentially creates a subclass with Vue as its parent:

Moving on to the createComponent method:

// ...
// Returns a placeholder node
var name = Ctor.options.name || tag;
var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : ' ')),
    data, undefined.undefined.undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
);

return vnode
Copy the code

Finally, a placeholder VNode is created:

The createElement method returns the created VNode, the render function completes the creation of the VNode tree, and the next step is to convert the virtual DOM tree into the real DOM. There is no need to look at this stage, because we can already see that after the compilation, the template is compiled into the render function. The component has been processed and we have the following method to create a VNode:

_c(_vm.widgetData,{tag:"component"})
Copy the code

If the is property we pass to component is the name of a component, the createElement method will follow the first if branch as shown below:

If we pass is an option object, compared with a common component, there is no process of finding the option object according to the component name. There is no difference between other components and ordinary components. As for the processing of it in the template compilation stage, it is also very simple:

Directly fetch the value of the IS property and save it to the Component property. Finally, in the render function generation phase:

This gives you the resulting render function.