经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » JS/JS库/框架 » JavaScript » 查看文章
Vue动态组件的实践与原理探究
来源:cnblogs  作者:街角小林  时间:2021/12/31 8:58:00  对本文有异议

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

ps.本文项目使用Vue CLI创建,所用的Vue版本为2.6.11webpack版本为4.46.0

创建项目

首先使用Vue CLI创建一个项目,在src目录下新建一个widgets目录用来存放小部件:

image-20211228135808675.png

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

image-20211228135933206.png

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

  1. <template>
  2. <div class="countBox">
  3. <div class="count">{{ count }}</div>
  4. <div class="btn">
  5. <button @click="add">+1</button>
  6. <button @click="sub">-1</button>
  7. </div>
  8. </div>
  9. </template>
  10. <script>
  11. export default {
  12. name: 'count',
  13. data() {
  14. return {
  15. count: 0,
  16. }
  17. },
  18. methods: {
  19. add() {
  20. this.count++
  21. },
  22. sub() {
  23. this.count--
  24. },
  25. },
  26. }
  27. </script>
  28. <style lang="less" scoped>
  29. .countBox {
  30. display: flex;
  31. flex-direction: column;
  32. align-items: center;
  33. .count {
  34. color: red;
  35. }
  36. }
  37. </style>

一个十分简单的计数器。

index.js用来导出组件:

  1. import Widget from './index.vue'
  2. export default Widget
  3. const config = {
  4. color: 'red'
  5. }
  6. export {
  7. config
  8. }

除了导出组件,也支持导出配置。

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

image-20211228141656015.png

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

打包小部件

假设我们的小部件已经开发完成了,那么接下来我们需要进行打包,把Vue单文件编译成js文件,打包使用的是webpack,首先创建一个webpack配置文件:

image-20211228145100423.png

webpack的常用配置项为:entryoutputmoduleplugins,我们一一来看。

1.entry入口

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

  1. const path = require('path')
  2. const fs = require('fs')
  3. const getEntry = () => {
  4. let res = {}
  5. let files = fs.readdirSync(__dirname)
  6. files.forEach((filename) => {
  7. // 是否是目录
  8. let dir = path.join(__dirname, filename)
  9. let isDir = fs.statSync(dir).isDirectory
  10. // 入口文件是否存在
  11. let entryFile = path.join(dir, 'index.js')
  12. let entryExist = fs.existsSync(entryFile)
  13. if (isDir && entryExist) {
  14. res[filename] = entryFile
  15. }
  16. })
  17. return res
  18. }
  19. module.exports = {
  20. entry: getEntry()
  21. }

2.output输出

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

  1. module.exports = {
  2. // ...
  3. output: {
  4. path: path.join(__dirname, '../../public/widgets'),
  5. filename: '[name].js'
  6. }
  7. }

3.module模块

这里我们要配置的是loader规则:

  • 处理Vue单文件我们需要vue-loader

  • 编译js最新语法需要babel-loader

  • 处理less需要less-loader

因为vue-loaderbabel-loader相关的包Vue项目本身就已经安装了,所以不需要我们手动再安装,装一下处理less文件的loader即可:

  1. npm i less less-loader -D

不同版本的less-loaderwebpack的版本也有要求,如果安装出错了可以指定安装支持当前webpack版本的less-loader版本。

修改配置文件如下:

  1. module.exports = {
  2. // ...
  3. module: {
  4. rules: [
  5. {
  6. test: /\.vue$/,
  7. loader: 'vue-loader'
  8. },
  9. {
  10. test: /\.js$/,
  11. loader: 'babel-loader'
  12. },
  13. {
  14. test: /\.less$/,
  15. loader: [
  16. 'vue-style-loader',
  17. 'css-loader',
  18. 'less-loader'
  19. ]
  20. }
  21. ]
  22. }
  23. }

4.plugins 插件

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

  1. npm i clean-webpack-plugin -D

修改配置文件如下:

  1. const { VueLoaderPlugin } = require('vue-loader')
  2. const { CleanWebpackPlugin } = require('clean-webpack-plugin')
  3. module.exports = {
  4. // ...
  5. plugins: [
  6. new VueLoaderPlugin(),
  7. new CleanWebpackPlugin()
  8. ]
  9. }

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

image-20211228152656241.png

我们通过api的方式来使用webpack

  1. const webpack = require('webpack')
  2. const config = require('./webpack.config')
  3. webpack(config, (err, stats) => {
  4. if (err || stats.hasErrors()) {
  5. // 在这里处理错误
  6. console.error(err);
  7. }
  8. // 处理完成
  9. console.log('打包完成');
  10. });

现在我们就可以在命令行输入node src/widgets/build.js进行打包了,嫌麻烦的话也可以配置到package.json文件里:

  1. {
  2. "scripts": {
  3. "build-widgets": "node src/widgets/build.js"
  4. }
  5. }

运行完后可以看到打包结果已经有了:

image-20211228153736948.png

使用小部件

我们的需求是线上动态的请求小部件的文件,然后将小部件渲染出来。请求使用ajax获取小部件的js文件内容,渲染我们的第一想法是使用Vue.component()方法进行注册,但是这样是不行的,因为全局注册组件必须在根Vue实例创建之前发生。

所以这里我们使用的是component组件,Vuecomponent组件可以接受以注册组件的名字或一个组件的选项对象,刚好我们可以提供小部件的选项对象。

请求js资源我们使用axios,获取到的是js字符串,然后使用new Function动态进行执行获取导出的选项对象:

  1. // 点击加载按钮后调用该方法
  2. async load() {
  3. try {
  4. let { data } = await axios.get('/widgets/Count.js')
  5. let run = new Function(`return ${data}`)
  6. let res = run()
  7. console.log(res)
  8. } catch (error) {
  9. console.error(error)
  10. }
  11. }

正常来说我们能获取到导出的模块,可是居然报错了!

image-20211228181924164.png

说实话,笔者看不懂这是啥错,百度了一下也无果,但是经过一番尝试,发现把项目的babel.config.js里的预设由@vue/cli-plugin-babel/preset修改为@babel/preset-env后可以了,具体是为啥呢,反正我也不知道,当然,只使用@babel/preset-env可能是不够的,这就需要你根据实际情况再调整了。

不过后来笔者阅读Vue CLI官方文档时看到了下面这段话:

image-20211228192729057.png

直觉告诉我,肯定就是这个问题导致的了,于是把vue.config.js修改为如下:

  1. module.exports = {
  2. presets: [
  3. ['@vue/cli-plugin-babel/preset', {
  4. useBuiltIns: false
  5. }]
  6. ]
  7. }

然后打包,果然一切正常(多看文档准没错),但是每次打包都要手动修改babel.config.js文件总不是一件优雅的事情,我们可以通过脚本在打包前修改,打包完后恢复,修改build.js文件:

  1. const path = require('path')
  2. const fs = require('fs')
  3. // babel.config.js文件路径
  4. const babelConfigPath = path.join(__dirname, '../../babel.config.js')
  5. // 缓存原本的配置
  6. let originBabelConfig = ''
  7. // 修改配置
  8. const changeBabelConfig = () => {
  9. // 保存原本的配置
  10. originBabelConfig = fs.readFileSync(babelConfigPath, {
  11. encoding: 'utf-8'
  12. })
  13. // 写入新配置
  14. fs.writeFileSync(babelConfigPath, `
  15. module.exports = {
  16. presets: [
  17. ['@vue/cli-plugin-babel/preset', {
  18. useBuiltIns: false
  19. }]
  20. ]
  21. }
  22. `)
  23. }
  24. // 恢复为原本的配置
  25. const resetBabelConfig = () => {
  26. fs.writeFileSync(babelConfigPath, originBabelConfig)
  27. }
  28. // 打包前修改
  29. changeBabelConfig()
  30. webpack(config, (err, stats) => {
  31. // 打包后恢复
  32. resetBabelConfig()
  33. if (err || stats.hasErrors()) {
  34. console.error(err);
  35. }
  36. console.log('打包完成');
  37. });

几行代码解放双手。现在来看看我们最后获取到的小部件导出数据:

image-20211228182743099.png

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

  1. <div class="widgetWrap" v-if="widgetData" :style="{ borderColor: widgetConfig.color }">
  2. <component :is="widgetData"></component>
  3. </div>
  1. export default {
  2. data() {
  3. return {
  4. widgetData: null,
  5. widgetConfig: null
  6. }
  7. },
  8. methods: {
  9. async load() {
  10. try {
  11. let { data } = await axios.get('/widgets/Count.js')
  12. let run = new Function(`return ${data}`)
  13. let res = run()
  14. this.widgetData = res.default
  15. this.widgetConfig = res.config
  16. } catch (error) {
  17. console.error(error)
  18. }
  19. }
  20. }
  21. }

效果如下:

2021-12-28-18-45-52.gif

是不是很简单。

深入component组件

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

image-20211229135411191.png

_ccreateElement方法:

  1. vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
  1. function createElement (
  2. context,// 上下文,即父组件实例,即App组件实例
  3. tag,// 我们的动态组件Count的选项对象
  4. data,// {tag: 'component'}
  5. children,
  6. normalizationType,
  7. alwaysNormalize
  8. ) {
  9. // ...
  10. return _createElement(context, tag, data, children, normalizationType)
  11. }

忽略了一些没有进入的分支,直接进入_createElement方法:

  1. function _createElement (
  2. context,
  3. tag,
  4. data,
  5. children,
  6. normalizationType
  7. ) {
  8. // ...
  9. var vnode, ns;
  10. if (typeof tag === 'string') {
  11. // ...
  12. } else {
  13. // 组件选项对象或构造函数
  14. vnode = createComponent(tag, data, context, children);
  15. }
  16. // ...
  17. }

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

  1. function createComponent (
  2. Ctor,
  3. data,
  4. context,
  5. children,
  6. tag
  7. ) {
  8. // ...
  9. var baseCtor = context.$options._base;
  10. // 选项对象: 转换成构造函数
  11. if (isObject(Ctor)) {
  12. Ctor = baseCtor.extend(Ctor);
  13. }
  14. // ...
  15. }

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

image-20211229142121211.png

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

image-20211229142346598.png

继续看createComponent方法:

  1. // ...
  2. // 返回一个占位符节点
  3. var name = Ctor.options.name || tag;
  4. var vnode = new VNode(
  5. ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
  6. data, undefined, undefined, undefined, context,
  7. { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
  8. asyncFactory
  9. );
  10. return vnode

最后创建了一个占位VNode

image-20211229142925592.png

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

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

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

image-20211229143615082.png

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

image-20211229150134553.png

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

image-20211229150410598.png

image-20211229150453947.png

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

原文链接:http://www.cnblogs.com/wanglinmantan/p/15747075.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号