Vue动态组件的实践与原理探究
Posted 街角前端铺
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue动态组件的实践与原理探究相关的知识,希望对你有一定的参考价值。
我司有一个工作台搭建产品,允许通过拖拽小部件的方式来搭建一个工作台页面,平台内置了一些常用小部件,另外也允许自行开发小部件上传使用,本文会从实践的角度来介绍其实现原理。
ps.本文项目使用
Vue CLI
创建,所用的Vue
版本为2.6.11
,webpack
版本为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
的常用配置项为:entry
、output
、module
、plugins
,我们一一来看。
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-loader
和babel-loader
相关的包Vue
项目本身就已经安装了,所以不需要我们手动再安装,装一下处理less
文件的loader
即可:
npm i less less-loader -D
不同版本的less-loader
对webpack
的版本也有要求,如果安装出错了可以指定安装支持当前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
组件,Vue
的component
组件可以接受以注册组件的名字或一个组件的选项对象,刚好我们可以提供小部件的选项对象。
请求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
组件最后生成的渲染函数长啥样:
_c
即createElement
方法:
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);
// ...
baseCtor
为Vue
构造函数,Ctor
即Count
组件的选项对象,所以实际执行了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")
如果我们传给component
的is
属性是一个组件的名称,那么在createElement
方法里就会走下图的第一个if
分支:
也就是我们普通注册的组件会走的分支,如果我们传给is
的是选项对象,相对于普通组件,其实就是少了一个根据组件名称查找选项对象的过程,其他和普通组件没有任何区别,至于模板编译阶段对它的处理也十分简单:
直接取出is
属性的值保存到component
属性上,最后在生成渲染函数的阶段:
这样就得到了最后生成的渲染函数。
以上是关于Vue动态组件的实践与原理探究的主要内容,如果未能解决你的问题,请参考以下文章