我司有一個工作臺搭建產品,允許通過拖拽小部件的方式來搭建一個工作臺頁面,平臺內置了一些常用小部件,另外也允許自行開發小部件上傳使用,本文會從實踐的角度來介紹其實現原理。
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
屬性上,最后在生成渲染函數的階段:
這樣就得到了最后生成的渲染函數。