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

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属性上,最后在生成渲染函数的阶段:

这样就得到了最后生成的渲染函数。

Vue动态组件的实践与原理探究的更多相关文章

  1. Vue动态组件

    前面的话 让多个组件使用同一个挂载点,并动态切换,这就是动态组件.本文将详细介绍Vue动态组件 概述 通过使用保留的 <component> 元素,动态地绑定到它的 is 特性,可以实现动 ...

  2. vue动态组件切换(选项卡)

    vue的动态组件 <template :is='变量'></template> 可以通过改变变量,来改变template的替换内容.达到选项卡的功能 如果想要切换保持不重新创建 ...

  3. vue 动态组件、父子组件传参

    1.vue中的自定义属性并获得属性的值 自定义属性::data-id语法为 :data-属性  获取属性的值:ev.target.dataset.id 2.vue父子组件传值 3.动态组件使用

  4. Vue 动态组件、动画、插件

    1 动态组件 ①简单来说: 就是几个组件放在一个挂载点下,然后根据父组件的某个变量来决定显示哪个,或者都不显示. ②动态切换: 在挂载点使用component标签,然后使用v-bind:is=”组件名 ...

  5. Vue动态组件&异步组件

    在动态组件上使用keep-alive 我们之前曾经在一个多标签的界面中使用is特性来切换不同的组件: Vue.js的动态组件模板 <component v-bind:is="curre ...

  6. vue动态组件-根据数据展示特定组件

    vue中有个内置组件component,利用它可以实现动态组件,在某些业务场景下可以替换路由 假设有以下三个组件: com1.com2.com3 有一个外层路/coms中代码如下 <templa ...

  7. vue 动态组件的传值

    vue项目开发中会用到大量的父子组件传值,也会用到动态组件的传值,常规子组件获取父组件的传值时,第一次是获取不到的,这时候有两种解决方案 第一种: 父组件向子组件传的是一个json对象,ES6的方法O ...

  8. Vue - 动态组件 & 异步组件

    动态组件 <div id="app"> <components :is="com[2]"></components> < ...

  9. Vue 动态组件和异步组件

    基础案例 动态组件切换类比"bilibili-个人中心"的横向菜单切换不同的标签页的功能. 在Vue中可以使用 component 标签,并加一个特殊的属性(attribute) ...

随机推荐

  1. Angular项目构建指南 - 不再为angular构建而犹豫不决(转)

    如果你不知道什么是Angular或者根本没听说过,那么我接下来所说的对你来说毫无益处,不过如果你打算以后会接触Angular或者干脆要涨涨姿势~读下去还是有点用的. Angular和它之前所出现的其余 ...

  2. Python 交互式解释器的二三事

    学 Python 不知道何时起成了一种风尚.这里,我也随便聊聊跟Python 的交互式解释器的几个有意思的小问题. 如何进入 Python 交互解释器? 当你安装好 Python 后,如何进入 Pyt ...

  3. 论文解读(GROC)《Towards Robust Graph Contrastive Learning》

    论文信息 论文标题:Towards Robust Graph Contrastive Learning论文作者:Nikola Jovanović, Zhao Meng, Lukas Faber, Ro ...

  4. 如何用C/C++实现去除字符串头和尾指定的字符

    编程时我们经常需要对字符串进行操作,其中有一项操作就是去除字符串的头(尾)指定的字符,比如空格.通常我们会使用封装好的库函数或者类函数的Trim方法来实现,如果自己动手写一个TrimHead和Trim ...

  5. 安装与基本配置DHCP服务器

    一,安装DHCP服务器角色 1,打开[开始]→[管理工具]→[服务器管理器]→"仪表板"选项的[添加角色和功能],持续单机[下一步]按钮, 直至出现下图所示的"选择服务器 ...

  6. C++基础-1-内存管理(全局区、堆区、栈区)

    1. 内存管理 1.1 全局区 1 #include<iostream> 2 using namespace std; 3 4 // 全局变量 5 int g_a = 10; 6 int ...

  7. 建设Kubernetes生产环境的16条建议

    点击上方"开源Linux",选择"设为星标" 回复"学习"获取独家整理的学习资料! Kubernetes是用于构建高度可扩展系统的强大工具. ...

  8. 超详干货!Linux 环境变量配置全攻略

    点击上方"开源Linux",选择"设为星标" 回复"学习"获取独家整理的学习资料! Linux环境变量配置 在自定义安装软件的时候,经常需要 ...

  9. 以rem为单位,数值较小,border-radius:50%部分浏览器渲染不圆方法

    元素使用rem做单位且较小时,对于border-radius:50%在部分浏览器不圆解决方法: 1.将原来宽高扩大至两倍(.1rem --> .2rem),再使用transform:scale( ...

  10. 单源最短路问题:OJ5——低德地图

    本题就是一道单源最短路问题.由于是稀疏图,我们采用Dijkstra算法. Dijkstra算法原理 Dijkstra算法的步骤 我们把所有的节点分为两个集合:被选中的(visited==1) 和 未被 ...