Vue動態組件的實踐與原理探究

我司有一個工作臺搭建產品,允許通過拖拽小部件的方式來搭建一個工作臺頁面,平臺內置了一些常用小部件,另外也允許自行開發小部件上傳使用,本文會從實踐的角度來介紹其實現原理。

ps.本文項目使用Vue CLI創建,所用的Vue版本為2.6.11webpack版本為4.46.0

創建項目

首先使用Vue CLI創建一個項目,在src目錄下新建一個widgets目錄用來存放小部件:

image-20211228135808675.png

一個小部件由一個Vue單文件和一個js文件組成:

image-20211228135933206.png

測試組件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組件我們用來作為小部件的開發預覽和測試,效果如下:

image-20211228141656015.png

小部件的配置會影響包裹小部件容器的邊框顏色。

打包小部件

假設我們的小部件已經開發完成了,那么接下來我們需要進行打包,把Vue單文件編譯成js文件,打包使用的是webpack,首先創建一個webpack配置文件:

image-20211228145100423.png

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的配置就寫到這里,接下來寫打包的腳本文件:

image-20211228152656241.png

我們通過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"
    }
}

運行完后可以看到打包結果已經有了:

image-20211228153736948.png

使用小部件

我們的需求是線上動態的請求小部件的文件,然后將小部件渲染出來。請求使用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)
    }
}

正常來說我們能獲取到導出的模塊,可是居然報錯了!

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修改為如下:

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('打包完成');
});

幾行代碼解放雙手。現在來看看我們最后獲取到的小部件導出數據:

image-20211228182743099.png

小部件的選項對象有了,接下來把它扔給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)
            }
        }
    }
}

效果如下:

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

是不是很簡單。

深入component組件

最后讓我們從源碼的角度來看看component組件是如何工作的,先來看看對于component組件最后生成的渲染函數長啥樣:

image-20211229135411191.png

_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()方法:

image-20211229142121211.png

這個方法實際上就是以Vue為父類創建了一個子類:

image-20211229142346598.png

繼續看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

image-20211229142925592.png

createElement方法最后會返回創建的這個VNode,渲染函數執行完生成了VNode樹,下一步會將虛擬DOM樹轉換成真實的DOM,這一階段沒有必要再去看,因為到這里我們已經能發現在編譯后,也就是將模板編譯成渲染函數這個階段,component組件就已經被處理完了,得到了下面這個創建VNode的方法:

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

如果我們傳給componentis屬性是一個組件的名稱,那么在createElement方法里就會走下圖的第一個if分支:

image-20211229143615082.png

也就是我們普通注冊的組件會走的分支,如果我們傳給is的是選項對象,相對于普通組件,其實就是少了一個根據組件名稱查找選項對象的過程,其他和普通組件沒有任何區別,至于模板編譯階段對它的處理也十分簡單:

image-20211229150134553.png

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

image-20211229150410598.png
image-20211229150453947.png

這樣就得到了最后生成的渲染函數。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容

  • 前? Vue是?套?于構建?戶界?的漸進式框架,?前有越來越多的開發者在學習和使?。?組件庫能幫我們節省開發精?,...
    天明丶胡金斌閱讀 838評論 0 2
  • 前言 前一段時間利用空閑時間學習了一下vue組件的封裝,也在工作中進行了實踐,將公司常用的一個api抽象成了vue...
    朱小維閱讀 13,832評論 1 30
  • 前言 本文篇幅有點長,希望看完后能給你帶去一些收獲;主要針對react組件化原理、與vue開發感知上的對比以及一些...
    辣筆小明閱讀 2,726評論 0 2
  • 一、什么是懶加載 懶加載也叫延遲加載,即在需要的時候進行加載,隨用隨載。 二、為什么需要懶加載 在單頁...
    菲菲菲菲妞閱讀 22,063評論 0 10
  • Vue組件 vue組件:封裝前端vue特效代碼,便于引用 全局組件全局組件通過Vue.component在scri...
    3e0693dcfb2f閱讀 326評論 0 0