关于javascript:Vue动态组件的实践与原理探究

5次阅读

共计 6246 个字符,预计需要花费 16 分钟才能阅读完成。

我司有一个工作台搭建产品,容许通过拖拽小部件的形式来搭建一个工作台页面,平台内置了一些罕用小部件,另外也容许自行开发小部件上传应用,本文会从实际的角度来介绍其实现原理。

ps. 本文我的项目应用 Vue CLI 创立,所用的 Vue 版本为 2.6.11webpack 版本为4.46.0

创立我的项目

首先应用 Vue CLI 创立一个我的项目,在 src 目录下新建一个 widgets 目录用来寄存小部件:

一个小部件由一个 Vue 单文件和一个 js 文件组成:

测试组件 index.vue 的内容如下:

<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>

一个非常简略的计数器。

index.js用来导出组件:

import Widget from './index.vue'

export default Widget

const config = {color: 'red'}

export {config}

除了导出组件,也反对导出配置。

我的项目的 App.vue 组件咱们用来作为小部件的开发预览和测试,成果如下:

小部件的配置会影响包裹小部件容器的边框色彩。

打包小部件

假如咱们的小部件曾经开发实现了,那么接下来咱们须要进行打包,把 Vue 单文件编译成 js 文件,打包应用的是 webpack,首先创立一个webpack 配置文件:

webpack的罕用配置项为:entryoutputmoduleplugins,咱们一一来看。

1.entry入口

入口显然就是各个小部件目录下的 index.js 文件,因为小部件数量是不定的,可能会越来越多,所以入口不能写死,须要动静生成:

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

const getEntry = () => {let res = {}
    let files = fs.readdirSync(__dirname)
    files.forEach((filename) => {
        // 是否是目录
        let dir = path.join(__dirname, filename)
        let isDir = fs.statSync(dir).isDirectory
        // 入口文件是否存在
        let entryFile = path.join(dir, 'index.js')
        let entryExist = fs.existsSync(entryFile)
        if (isDir && entryExist) {res[filename] = entryFile
        }
    })
    return res
}

module.exports = {entry: getEntry()
}

2.output输入

因为咱们开发完后还要进行测试,所以便于申请打包后的文件,咱们把小部件的打包后果间接输入到 public 目录下:

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

3.module模块

这里咱们要配置的是 loader 规定:

  • 解决 Vue 单文件咱们须要vue-loader
  • 编译 js 最新语法须要babel-loader
  • 解决 less 须要less-loader

因为 vue-loaderbabel-loader相干的包 Vue 我的项目自身就曾经装置了,所以不须要咱们手动再装置,装一下解决 less 文件的 loader 即可:

npm i less less-loader -D

不同版本的 less-loaderwebpack的版本也有要求,如果装置出错了能够指定装置反对以后 webpack 版本的 less-loader 版本。

批改配置文件如下:

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

4.plugins 插件

插件咱们就应用两个,一个是 vue-loader 指定的,另一个是用来清空输入目录的:

npm i clean-webpack-plugin -D

批改配置文件如下:

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

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

webpack的配置就写到这里,接下来写打包的脚本文件:

咱们通过 api 的形式来应用webpack

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

webpack(config, (err, stats) => {if (err || stats.hasErrors()) {
        // 在这里处理错误
        console.error(err);
    }
    // 解决实现
    console.log('打包实现');
});

当初咱们就能够在命令行输出 node src/widgets/build.js 进行打包了,嫌麻烦的话也能够配置到 package.json 文件里:

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

运行完后能够看到打包后果曾经有了:

应用小部件

咱们的需要是线上动静的申请小部件的文件,而后将小部件渲染进去。申请应用 ajax 获取小部件的 js 文件内容,渲染咱们的第一想法是应用 Vue.component() 办法进行注册,然而这样是不行的,因为全局注册组件必须在根 Vue 实例创立之前产生。

所以这里咱们应用的是 component 组件,Vuecomponent 组件能够承受以注册组件的名字或一个组件的选项对象,刚好咱们能够提供小部件的选项对象。

申请 js 资源咱们应用 axios,获取到的是js 字符串,而后应用 new Function 动静进行执行获取导出的选项对象:

// 点击加载按钮后调用该办法
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)
    }
}

失常来说咱们能获取到导出的模块,可是竟然报错了!

说实话,笔者看不懂这是啥错,百度了一下也无果,然而通过一番尝试,发现把我的项目的 babel.config.js 里的预设由 @vue/cli-plugin-babel/preset 批改为 @babel/preset-env 后能够了,具体是为啥呢,反正我也不晓得,当然,只应用 @babel/preset-env 可能是不够的,这就须要你依据理论状况再调整了。

不过起初笔者浏览 Vue CLI 官网文档时看到了上面这段话:

直觉通知我,必定就是这个问题导致的了,于是把 vue.config.js 批改为如下:

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

而后打包,果然一切正常(多看文档准没错),然而每次打包都要手动批改 babel.config.js 文件总不是一件优雅的事件,咱们能够通过脚本在打包前批改,打包完后复原,批改 build.js 文件:

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

// babel.config.js 文件门路
const babelConfigPath = path.join(__dirname, '../../babel.config.js')
// 缓存本来的配置
let originBabelConfig = ''

// 批改配置
const changeBabelConfig = () => {
    // 保留本来的配置
    originBabelConfig = fs.readFileSync(babelConfigPath, {encoding: 'utf-8'})
    // 写入新配置
    fs.writeFileSync(babelConfigPath, `
        module.exports = {
            presets: [
                ['@vue/cli-plugin-babel/preset', {useBuiltIns: false}]
            ]
        }
    `)
}

// 复原为本来的配置
const resetBabelConfig = () => {fs.writeFileSync(babelConfigPath, originBabelConfig)
}

// 打包前批改
changeBabelConfig()
webpack(config, (err, stats) => {
    // 打包后复原
    resetBabelConfig()
    if (err || stats.hasErrors()) {console.error(err);
    }
    console.log('打包实现');
});

几行代码解放双手。当初来看看咱们最初获取到的小部件导出数据:

小部件的选项对象有了,接下来把它扔给 component 组件即可:

<div class="widgetWrap" v-if="widgetData" :style="{borderColor: widgetConfig.color}">
    <component :is="widgetData"></component>
</div>
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)
            }
        }
    }
}

成果如下:

是不是很简略。

深刻 component 组件

最初让咱们从源码的角度来看看 component 组件是如何工作的,先来看看对于 component 组件最初生成的渲染函数长啥样:

_ccreateElement 办法:

vm._c = function (a, b, c, d) {return createElement(vm, a, b, c, d, false); };
function createElement (
  context,// 上下文,即父组件实例,即 App 组件实例
  tag,// 咱们的动静组件 Count 的选项对象
  data,// {tag: 'component'}
  children,
  normalizationType,
  alwaysNormalize
) {
  // ...
  return _createElement(context, tag, data, children, normalizationType)
}

疏忽了一些没有进入的分支,间接进入 _createElement 办法:

function _createElement (
 context,
 tag,
 data,
 children,
 normalizationType
) {
    // ...
    var vnode, ns;
    if (typeof tag === 'string') {// ...} else {
        // 组件选项对象或构造函数
        vnode = createComponent(tag, data, context, children);
    }
    // ...
}

tag是个对象,所以会进入 else 分支,即执行 createComponent 办法:

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

    // 选项对象: 转换成构造函数
    if (isObject(Ctor)) {Ctor = baseCtor.extend(Ctor);
    }
    // ...
}

baseCtorVue 构造函数,CtorCount 组件的选项对象,所以理论执行了 Vue.extend() 办法:

这个办法实际上就是以 Vue 为父类创立了一个子类:

持续看 createComponent 办法:

// ...
// 返回一个占位符节点
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

最初创立了一个占位VNode

createElement办法最初会返回创立的这个 VNode,渲染函数执行完生成了VNode 树,下一步会将虚构 DOM 树转换成实在的 DOM,这一阶段没有必要再去看,因为到这里咱们曾经能发现在编译后,也就是将模板编译成渲染函数这个阶段,component 组件就曾经被解决完了,失去了上面这个创立 VNode 的办法:

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

如果咱们传给 componentis属性是一个组件的名称,那么在 createElement 办法里就会走下图的第一个 if 分支:

也就是咱们一般注册的组件会走的分支,如果咱们传给 is 的是选项对象,绝对于一般组件,其实就是少了一个依据组件名称查找选项对象的过程,其余和一般组件没有任何区别,至于模板编译阶段对它的解决也非常简略:

间接取出 is 属性的值保留到 component 属性上,最初在生成渲染函数的阶段:

这样就失去了最初生成的渲染函数。

正文完
 0