Vue动态组件的实践与原理探究

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

ps.本文项目使用 Vue CLI创建,所用的 Vue版本为 2.6.11webpack版本为 4.46.0
创建项目 首先使用Vue CLI创建一个项目,在src目录下新建一个widgets目录用来存放小部件:
Vue动态组件的实践与原理探究
文章图片

一个小部件由一个Vue单文件和一个js文件组成:
Vue动态组件的实践与原理探究
文章图片

测试组件index.vue的内容如下:
.countBox { display: flex; flex-direction: column; align-items: center; .count { color: red; } }

一个十分简单的计数器。
【Vue动态组件的实践与原理探究】index.js用来导出组件:
import Widget from './index.vue'export default Widgetconst config = { color: 'red' }export { config }

除了导出组件,也支持导出配置。
项目的App.vue组件我们用来作为小部件的开发预览和测试,效果如下:
Vue动态组件的实践与原理探究
文章图片

小部件的配置会影响包裹小部件容器的边框颜色。
打包小部件 假设我们的小部件已经开发完成了,那么接下来我们需要进行打包,把Vue单文件编译成js文件,打包使用的是webpack,首先创建一个webpack配置文件:
Vue动态组件的实践与原理探究
文章图片

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的配置就写到这里,接下来写打包的脚本文件:
Vue动态组件的实践与原理探究
文章图片

我们通过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" } }

运行完后可以看到打包结果已经有了:
Vue动态组件的实践与原理探究
文章图片

使用小部件 我们的需求是线上动态的请求小部件的文件,然后将小部件渲染出来。请求使用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) } }

正常来说我们能获取到导出的模块,可是居然报错了!
Vue动态组件的实践与原理探究
文章图片

说实话,笔者看不懂这是啥错,百度了一下也无果,但是经过一番尝试,发现把项目的babel.config.js里的预设由@vue/cli-plugin-babel/preset修改为@babel/preset-env后可以了,具体是为啥呢,反正我也不知道,当然,只使用@babel/preset-env可能是不够的,这就需要你根据实际情况再调整了。
不过后来笔者阅读Vue CLI官方文档时看到了下面这段话:
Vue动态组件的实践与原理探究
文章图片

直觉告诉我,肯定就是这个问题导致的了,于是把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('打包完成'); });

几行代码解放双手。现在来看看我们最后获取到的小部件导出数据:
Vue动态组件的实践与原理探究
文章图片

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

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 = https://www.it610.com/article/res.default this.widgetConfig = res.config } catch (error) { console.error(error) } } } }

效果如下:
Vue动态组件的实践与原理探究
文章图片

是不是很简单。
深入component组件 最后让我们从源码的角度来看看component组件是如何工作的,先来看看对于component组件最后生成的渲染函数长啥样:
Vue动态组件的实践与原理探究
文章图片

_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动态组件的实践与原理探究
文章图片

这个方法实际上就是以Vue为父类创建了一个子类:
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
Vue动态组件的实践与原理探究
文章图片

createElement方法最后会返回创建的这个VNode,渲染函数执行完生成了VNode树,下一步会将虚拟DOM树转换成真实的DOM,这一阶段没有必要再去看,因为到这里我们已经能发现在编译后,也就是将模板编译成渲染函数这个阶段,component组件就已经被处理完了,得到了下面这个创建VNode的方法:
_c(_vm.widgetData,{tag:"component"})

如果我们传给componentis属性是一个组件的名称,那么在createElement方法里就会走下图的第一个if分支:
Vue动态组件的实践与原理探究
文章图片

也就是我们普通注册的组件会走的分支,如果我们传给is的是选项对象,相对于普通组件,其实就是少了一个根据组件名称查找选项对象的过程,其他和普通组件没有任何区别,至于模板编译阶段对它的处理也十分简单:
Vue动态组件的实践与原理探究
文章图片

直接取出is属性的值保存到component属性上,最后在生成渲染函数的阶段:
Vue动态组件的实践与原理探究
文章图片

Vue动态组件的实践与原理探究
文章图片

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

    推荐阅读