一套自生成元件系統的構想與實踐

子物發表於2018-09-15

專案需求

一套功能類似於有贊商城後臺管理系統中店鋪-微頁面的系統,該系統實現使用者可以選擇所需要的元件,拖動調整元件的順序,以及編輯元件的內容並且在移動端展示等相關功能,如下圖所示。

wx20180915-153555

開始前的思考

系統主要功能

仔細想了一想,該系統需要實現下面三大功能

  • 元件系統
  • 移動端元件生成系統
  • 後臺管理系統元件編輯功能

基於什麼開發

服務端渲染?還是用原生js封裝元件?使用框架選 react 還是 vue?(其實我不會react,但請容許我裝個b []~( ̄▽ ̄)~*)

因為之前且嘗試開發過element ui庫的元件,詳情點這,對於element ui的架構有一定的瞭解,於是打算把基本的功能照搬element ui的架構,先基於vue開發一套元件系統,其他的功能再自行定製。

構建時的思考

構建工具的搭建

構建工具我選擇了webpack,大版本為4.0,試著吃吃螃蟹。先思考下需要webpack的哪些功能:

功能 相關外掛
es6-es5 babel-loader
sass-css sass-loader css-loader style-loader
css檔案抽離 mini-css-extract-plugin
html模版 html-loader 以及 html-webpack-plugin
vue檔案解析 vue-loader
圖片檔案解析 file-loader
markdown轉換vue vue-markdown-loader
刪除檔案 clean-webpack-plugin 或者指令碼
熱更新 HotModuleReplacementPlugin
webpack配置合併 webpack-merge

基本上就是以上的loader及外掛了。

因為元件庫涉及到多個功能的打包,比如元件庫,預覽時的配置,以及後面會提到的和其他專案的整合,所以對於webpack的配置,可以學習vue-cli中的配置,將公共的配置抽離,特殊的配置通過webpack-merge外掛完成合並。

wx20180915-161834

這裡將不同的需求及功能抽離成了如上圖所示的幾個webpack配置, 其中webpack.base.js為公共的配置,如下圖所示,分別處理了vue檔案,圖片以及js檔案

wx20180915-161846

這篇文章的目的是主要提供一個思路,所以這裡不詳細講解webpack的相關配置。

除了構建工具,還能不能更加高效的完成開發?

其實對於開發來說,提高效率的主要方式就是將相同的事物封裝起來,就好比一個函式的封裝,這裡因為元件檔案的結構是相似的,所以我學習element ui的做法,將元件的建立過程封裝成指令碼,執行命令就能夠直接生成好檔案,以及新增配置檔案。程式碼如下

const path = require('path')
const fileSave = require('file-save')
const getUnifiedName = require('../utils').getUnifiedName
const uppercamelcase = require('uppercamelcase')
const config = require('../config')

const component_name = process.argv[2]                                  //元件檔名 橫槓分隔
const ComponentName = uppercamelcase(component_name)                    //元件帕斯卡拼寫法命名
const componentCnName = process.argv[3] || ComponentName                //元件中文名
const componentFnName = '$' + getUnifiedName(component_name)            //元件函式名


/** 以下模版字串不能縮排,否則建立的檔案內容排版會混亂  **/

const createFiles = [
    {
        path: path.join(config.packagesPath, component_name, 'index.js'),
        content: `import Vue from 'vue'
import ${ComponentName} from './src/main.vue'

const Component = Vue.extend(${ComponentName})
${ComponentName}.install = function(Vue) {
    Vue.component(${ComponentName}.name, ${ComponentName})
    Vue.prototype.${componentFnName} = function() {
        const instance = new Component()
        instance.$mount()
        return instance
    }
}

export default ${ComponentName}`
    }, {
        path: path.join(config.packagesPath, component_name, 'src', 'main.scss'),
        content: `@import '~@/style/common/variable.scss';
@import '~@/style/common/mixins.scss';
@import '~@/style/common/functions.scss';`
    }, {
        path: path.join(config.packagesPath, component_name, 'src', 'main.vue'),
        content: `<template>
    
</template>

<script>
export default {
    name: '${getUnifiedName(component_name)}'
}
</script>

<style lang="scss" scoped>
@import './main.scss';
</style>`
    }, {
        path: path.join(config.examplesPath, 'src', 'doc', `${component_name}.md`),
        content: `## ${ComponentName} ${componentCnName}

<div class="example-conainer">
    <div class="phone-container">
        <div class="phone-screen">
            <div class="title"></div>
            <div class="webview-container">
                <sg-${component_name}></sg-${component_name}>
            </div>
        </div>
    </div>
    <div class="edit-container">
        <edit-component>
    </div>
</div>
    
<script>
    import editComponent from '../components/edit-components/${component_name}'
    export default {
        data() {
            return {

            }
        },
        components: {
            editComponent
        }
    }
</script>
        `
    }, {
        path: path.join(config.examplesPath, 'src/components/edit-components', `${component_name}.vue`),
        content: ``
    }
]





const componentsJson = require(path.join(config.srcPath, 'components.json'))
const docNavConfig = require(path.join(config.examplesPath, 'src', 'router', 'nav.config.json'))

if(docNavConfig[component_name]) {
    console.log(`${component_name} 已經存在,請檢查目錄或者components.json檔案`)
    process.exit(0)
}

if(componentsJson[component_name]) {
    console.log(`${component_name} 已經存在,請檢查目錄或者nav.config.json檔案`)
    process.exit(0)
}

createFiles.forEach(file => {
    fileSave(file.path)
    .write(file.content, 'utf8')
    .end('\n');
})


componentsJson[component_name] = {}
componentsJson[component_name].path =  `./packages/${component_name}/index.js`
componentsJson[component_name].cnName = componentCnName
componentsJson[component_name].fnName = componentFnName
componentsJson[component_name].propsData = {}


docNavConfig[component_name] = {}
docNavConfig[component_name].path =  `./src/doc/${component_name}.md`
docNavConfig[component_name].cnName = componentCnName
docNavConfig[component_name].vueRouterHref = '/' + component_name
docNavConfig[component_name].fnName = componentFnName

fileSave(path.join(config.srcPath, 'components.json'))
    .write(JSON.stringify(componentsJson, null, '  '), 'utf8')
    .end('\n');

fileSave(path.join(config.examplesPath, 'src', 'router', 'nav.config.json'))
    .write(JSON.stringify(docNavConfig, null, '  '), 'utf8')
    .end('\n');

console.log('元件建立完成')
複製程式碼

以及刪除元件

const path = require('path')
const fsdo = require('fs-extra')
const fileSave = require('file-save')
const config = require('../config')

const component_name = process.argv[2]

const files = [{
    path: path.join(config.packagesPath, component_name),
    type: 'dir'
}, {
    path: path.join(config.examplesPath, 'src', 'doc', `${component_name}.md`),
    type: 'file'
}, {
    path: path.join(config.srcPath, 'components.json'),
    type: 'json',
    key: component_name
}, {
    path: path.join(config.examplesPath, 'src', 'router', 'nav.config.json'),
    type: 'json',
    key: component_name
}]

files.forEach(file => {
    switch(file.type) {
        case 'dir':
        case 'file':
            removeFiles(file.path)
            break;
        case 'json':
            deleteJsonItem(file.path, file.key);
            break;
        default:
            console.log('unknow file type')
            process.exit(0);
            break;
    }
})

function removeFiles(path) {
    fsdo.removeSync(path)
}

function deleteJsonItem(path, key) {
    const targetJson = require(path)

    if(targetJson[key]) {
        delete targetJson[key]
    }
    
    fileSave(path)
        .write(JSON.stringify(targetJson, null, '  '), 'utf8')
        .end('\n');
}

console.log('元件刪除完成')
複製程式碼

如何開發vue元件

用過vue的同學應該知道vue開發元件有兩種方式,一種是 vue.component()的方式,另一種是vue.extend()方式,可以在上面的建立檔案程式碼中看見,這兩種方式我都用到了。原因是,對於配置元件的頁面,需要用到動態元件,對於移動端渲染,動態元件肯定是不行的,所以需要用到函式形式的元件。

如何打包vue元件

打包vue元件,當然不能將其他無用的功能打包進庫中,所以再來一套單獨的webpack配置

const path = require('path')
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base')
const miniCssExtractPlugin = require('mini-css-extract-plugin')
const config = require('./config')
const ENV = process.argv.NODE_ENV

module.exports = merge(webpackBaseConfig, {
    output: {
        filename: 'senguo.m.ui.js',
        path: path.resolve(config.basePath, './dist/ui'),
        publicPath: '/dist/ui',
        libraryTarget: 'umd'
    },
    externals: {
        vue: {
            root: 'Vue',
            commonjs: 'vue',
            commonjs2: 'vue',
            amd: 'vue'
        }
    },
    module: {
        rules: [
            {
                test: /\.(sc|c)ss$/,
                use: [miniCssExtractPlugin.loader, 
                {loader: 'css-loader'}, 
                {loader: 'sass-loader'}]
            }
        ]
    },

    plugins: [
        new miniCssExtractPlugin({
            filename: "sg-m-ui.css"
        })
    ]
})
複製程式碼

先看看元件的入口檔案,這是通過配置檔案自動生成的,所以不必操心什麼,本文的最後會奉上精簡版的vue元件開發webpack腳手架,可以直接拿去用哦。


//檔案從 build/bin/build-entry.js生成
import SgAlert from './packages/alert/index.js' 
import SgSwipe from './packages/swipe/index.js' 
import SgGoodsList from './packages/goods-list/index.js' 


const components = [SgAlert,SgSwipe,SgGoodsList]

const install = function(Vue) {
    components.forEach(component => {
        component.install(Vue)
    })
}

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue);
}

// module.exports = {install}
// module.exports.default = module.exports
export default {install}
複製程式碼

是不是很簡單啊。

該如何看見我開發的元件?

因為開發元件時肯定是需要一套webpack的配置用於啟動web服務和熱更新,所以在build資料夾中,編寫了另外一套webpack配置用於開發時預覽元件


<--webpack.dev.js-->

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base')
const webpackCleanPlugin = require('clean-webpack-plugin')
const config = require('./config')


module.exports = merge(webpackBaseConfig, {
    module: {
        rules: [{
            test: /\.(sc|c)ss$/,
            use: [{
                loader: 'style-loader'
            }, {
                loader: 'vue-style-loader',
            }, {
                loader: 'css-loader'
            }, {
                loader: 'sass-loader'
            }]
        }]
    },
    devServer: {
        host: '0.0.0.0',
        publicPath: '/',
        hot: true,
    },

    plugins: [
        new webpackCleanPlugin(
            ['../dist'], {
                root: config.basePath,
                allowExternal: true
            }
        ),
        new webpack.HotModuleReplacementPlugin()
    ]
})
複製程式碼

<--webpack.demo.js-->

const path = require('path')
const merge = require('webpack-merge')
const webpackDevConfig = require('./webpack.dev')
const config = require('./config')
const htmlWebpackPlugin = require('html-webpack-plugin')


const webpackDemoConfig= merge(webpackDevConfig, {
    entry: path.resolve(config.examplesPath, 'index.js'),

    output: {
        filename: 'index.js',
        path: path.resolve(config.basePath, './dist'),
        publicPath: '/'
    },

    module: {
        rules: [{
            test: /\.md$/,
            use: [
            {
                loader: 'vue-loader'
            }, 
            {
                loader: 'vue-markdown-loader/lib/markdown-compiler',
                options: {
                    raw: true
                }
            }]
        },  {
            test: /\.html$/,
            use: [{loader: 'html-loader'}]
        }, ]
    }, 
    
    plugins: [
        new htmlWebpackPlugin({
            template: path.join(config.examplesPath, 'index.html'),
            inject: 'body'
        })
    ]
})


module.exports = webpackDemoConfig
複製程式碼

在其中可以看見使用了md檔案,使用md檔案的目的是:

  1. 可以在開發時直接預覽元件
  2. 可以很方便的編寫說明文件

通過vue-markdown-loader就可以將md檔案解析成vue檔案了,這個庫是element ui 的官方人員開發的,其實原理很簡單,就是將md文件先解析成html文件,再將html文件放入vue文件的template標籤內,script 和 style標籤單獨抽離並排放置,就是一個vue的文件了,解析完後交給vue-loader處理就可以將md文件內容渲染到頁面了。

那麼預覽頁面的路由該如何處理呢?

就像上面建立檔案那樣,通過配置檔案以及指令碼動態生成路由檔案,執行之前,先建立路由js檔案即可

配置檔案一覽

{
  "main": {
    "path": "./src/pages/main.vue",
    "cnName": "首頁",
    "vueRouterHref": "/main"
  },
  "alert": {
    "path": "./src/doc/alert.md",
    "cnName": "警告",
    "vueRouterHref": "/alert"
  },
  "swipe": {
    "path": "./src/doc/swipe.md",
    "cnName": "輪播",
    "vueRouterHref": "/swipe"
  },
  "goods-list": {
    "path":"./src/doc/goods-list.md",
    "cnName": "商品列表",
    "vueRouterHref": "/goods-list"
  }
}

複製程式碼

構建完成的路由檔案

//檔案從 build/bin/build-route.js生成
import Vue from 'vue'
import Router from 'vue-router'
const navConfig = require('./nav.config.json')
import SgMain from '../pages/main.vue' 
import SgAlert from '../doc/alert.md' 
import SgSwipe from '../doc/swipe.md' 
import SgGoodsList from '../doc/goods-list.md' 


Vue.use(Router)

const modules = [SgMain,SgAlert,SgSwipe,SgGoodsList]
const routes = []

Object.keys(navConfig).map((value, index) => {
    let obj = {}
    obj.path = value.vueRouterHref,
    obj.component = modules[index]
    routes.push(obj)
})

export default new Router({
    mode: 'hash',
    routes
})
複製程式碼

就這樣,從元件的建立到專案的執行都是自動的啦。

介個編輯拖拽的功能要咋弄呢?

wx20180915-185651

當然是用的外掛啦,簡單粗暴,看這裡,它是基於Sortable.js封裝的,有贊貌似用的也是這個庫。

但看到右邊的那個編輯框,我不禁陷入了沉思,怎麼樣才能做到只開發一次,這個配置頁面就不用管理了?

編輯元件的元件???

由於中文的博大精深,姑且將下面的關鍵字分為兩種:

  1. 用於移動端展示的元件------ 功能元件
  2. 用於編輯功能元件的元件---- 選項元件

wx20180915-174853

分析需求可以發現,功能元件的內容都是可以由選項元件編輯的,最初我的想法是,選項元件的內容也根據配置檔案生成,比如元件的props資料,這樣就不用開發選項元件了,仔細一想還是太年輕了,配置項不可能滿足設計稿以及不同的需求。

只能開發另一套選擇元件咯,於是乎將選項元件的內容追加到自動生成檔案的列表,這樣微微先省點事。

元件間的通訊咋辦?

功能元件與選項元件間的通訊可不是一件簡單的事,首先要所有的元件實現同一種通訊方式,其次也不能因為引數的丟失而導致報錯,更重要的是,功能元件在移動端渲染後需要將選項元件配置的選項還原。

嗯,用那些方式好呢?

vuex? 需要對每一個元件都新增狀態管理,麻煩

eventBus? 我怕我記不住事件名

props?是個好辦法,但是選項元件要怎麼樣高效的把配置的資料傳遞出來呢?v-model就是一個很優雅的方式

首先功能元件的props與選項元件的v-model繫結同一個model,這樣就能實現高效的通訊,就像這樣:


<--swipe.md-->

## Swipe 輪播

<div class="example-conainer">
    <div class="phone-container">
        <div class="phone-screen">
            <div class="title"></div>
            <div class="webview-container" ref="phoneScreen">
                <sg-swipe :data="data"></sg-swipe>
            </div>
        </div>
    </div>
    <div class="edit-container">
        <edit-component v-model="data">
    </div>
</div>

<script>
import editComponent from '../components/edit-components/swipe'
export default {
    data() {
        return {
            data: {
                imagesList: ['https://aecpm.alicdn.com/simba/img/TB183NQapLM8KJjSZFBSutJHVXa.jpg']
            }
        }
    },

    components: {
        editComponent
    }
}
</script>
複製程式碼

就這樣,完美解決元件間通訊,但是這是靜態的元件,別忘了還有一個難點,那就是動態元件該如何進行引數傳遞,以及知道傳遞什麼引數而不會導致報錯。

拖拽系統的構建

先看個示例圖

wx20180915-181819

其中左側手機裡的內容是用v-for渲染的動態元件,右側選項元件也是動態元件,這樣就實現了上面所想的,功能元件和選項元件只需開發完成,配置頁面就會自動新增對應的元件,而不用管理,如下圖所示

wx20180915-182841

但這樣就會有一個問題,每個元件內部的資料不一致,得知道選中的元件是什麼,以及知道該如何傳遞正確的資料,還記得之前的配置檔案嗎?其實這些元件也是讀取的配置檔案渲染的,配置檔案如下:

{
    "alert": {          // 元件名
        "path": "./packages/alert/index.js",
        "cnName": "警告",
        "fnName": "$SgAlert",
        "propsData": {} //props需要傳遞的資料
    },
    "swipe": {
        "path": "./packages/swipe/index.js",
        "cnName": "輪播",
        "fnName": "$SgSwipe",
        "propsData": {
            "imagesList": ["https://aecpm.alicdn.com/simba/img/TB183NQapLM8KJjSZFBSutJHVXa.jpg"]
        }
    },
    "goods-list": {
        "path": "./packages/goods-list/index.js",
        "cnName": "商品列表",
        "fnName": "$SgGoodsList",
        "propsData": {

        }
    }
}
複製程式碼

每一個元件的配置都新增了propsData,裡面的元素和元件props資料以及選項元件v-model關聯,這樣就不用擔心缺失欄位而報錯了,但是這樣的做法給開發新增了麻煩。

元件編寫的過程中還得將資料手動新增到配置檔案,看能不能直接讀取vue檔案的props解決這個問題

到了這一步,元件以及元件的編輯拖拽功能均已完成,要考慮的是,如何把編輯拖拽功能頁面整合到現有的後臺系統中去,因為拖拽編輯元件的功能是給客戶用的,這裡為了效率和元件系統一同開發了。

如何與現有商戶後臺系統整合

vue路由的配置,每一個路由都對應一個元件,那麼這個系統也可以這樣做,只需要把中間那部分拖拽配置元件的頁面打包後引入到父工程(商戶後臺管理系統)中去就好了,那麼該如何處理呢?其實很簡單,將webpack打包入口設定成相對應的vue檔案就行,就像這樣。

const path = require('path')
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base')
const config = require('./config')
const miniCssExtractPlugin = require('mini-css-extract-plugin')
const ENV = process.argv.NODE_ENV

module.exports = merge(webpackBaseConfig, {
    entry: path.resolve(config.examplesPath, 'src/manage-system-app.vue'),
    output: {
        filename: 'components-manage.js',
        path: path.resolve(config.basePath, './dist/components-manage'),
        publicPath: '/dist/components-manage',
        libraryTarget: 'umd'
    },
    externals: {
        vue: {
            root: 'Vue',
            commonjs: 'vue',
            commonjs2: 'vue',
            amd: 'vue'
        }
    },
    module: {
        rules: [
            {
                test: /\.(sc|c)ss$/,
                use: [ 
                    miniCssExtractPlugin.loader, 
                    {loader: 'css-loader'}, 
                    {loader: 'sass-loader'}
                ]
            }
        ]
    },

    plugins: [
        new miniCssExtractPlugin({
            filename: "components-manage.css"
        })
    ]
})
複製程式碼

然後在父工程引入元件庫以及樣式檔案,再將路由對應的元件配置成這個打包後的js檔案就行。

import EditPage from '@/pages/EditPage.js'

new Router({
    routes: [{
        path: '/edit-page',
        components: EditPage
    }]
})
複製程式碼

wx20180915-201107

元件渲染系統

這還不簡單麼,看程式碼就懂了。

class InsertModule {
    constructor(element, componentsData, thatVue) {
        if(element instanceof String) {
            const el = document.getElementById(element)
            this.element = el ? el : document.body
        } else if(element instanceof HTMLElement) {
            this.element = element
        } else {
            return console.error('傳入的元素不是一個dom元素id或者dom元素')
        }

        if(JSON.stringify(componentsData) == '[]') {
            return console.error('傳入的元件列表為空')
        }

        this.componentsData = componentsData
        this.vueInstance = thatVue
        this.insertToElement()
    }

    insertToElement() {
        this.componentsData.forEach((component, index) => {
            const componentInstance = (this.vueInstance[component.fnName] 
                                    && 
                                    this.vueInstance[component.fnName] instanceof Function
                                    &&
                                    this.vueInstance[component.fnName]({propsData: component.propsData})
                                    ||
                                    {}
                                )
            
            if (componentInstance.$el) {
                componentInstance.$el.setAttribute('component-index', index)
                componentInstance.$el.setAttribute('isComponent', "true")
                componentInstance.$el.setAttribute('component-name', component.fnName)
                this.element.appendChild(
                    componentInstance.$el
                )
            } else {
                console.error(`元件 ${component.fnName} 不存在`)
            }
        }) 
    }
}

const install = function(Vue) {
    Vue.prototype.$insertModule = function(element, componentsData) {
        const self = this;
        return new InsertModule(element, componentsData, self)
    }
}

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue);
}


export default {install}
複製程式碼

這裡將 元件的props資料傳入至元件完成相關配置,這也是之前為什麼選擇prosp通訊的原因

this.vueInstance[component.fnName]({propsData: component.propsData})

<-- swipe.js -->
import Vue from 'vue'
import Swipe from './src/main.vue'

const Component = Vue.extend(Swipe)
Swipe.install = function(Vue) {
    Vue.component(Swipe.name, Swipe)
    Vue.prototype.$SgSwipe = function(options) {
        const instance = new Component({
            data: options.data || {},
            propsData: {data: options.propsData || {}}      //這裡接收了資料
        })
        instance.$mount()
        return instance
    }
}

export default Swipe

複製程式碼

wx20180915-201846

就係介樣,渲染完成,200元一條的8g記憶體的夢啥時候能夠實現?

結束語

最後,奉上此係統精簡版的webpack配置,除了沒拖拽系統以及元件渲染系統,其他的基本都支援,可以在此配置上定製自己的功能,編寫自己的元件系統,但是強烈建議閱讀element ui的腳手架配置,嘗試從0-1定製自己的腳手架哦。

github.com/Richard-Cho…

相關文章