作者:muwoo
原文:https://github.com/muwoo/rose
Rose
活動(dòng)頁構(gòu)建利器,用于快速搭建活動(dòng)頁
- 示例DEMO (逐步完善中...)
- node構(gòu)建服務(wù)
- template 模板
- 文本組件示例
前言
如果你經(jīng)常接觸一些公司的活動(dòng)頁,可能會(huì)經(jīng)常頭疼以下問題:這些項(xiàng)目周期短,需求頻繁,迭代快,技術(shù)要求不高,成長空間也小。但是我們還是馬不停蹄的趕著產(chǎn)品提來的一個(gè)個(gè)需求,隨著公司規(guī)模的增加,我們不可能無限制的增加人手不斷地重復(fù)著這些活動(dòng)。這里我就不具體介紹一些有的沒的的一些概念了,因?yàn)橐榻B的概念實(shí)在太多了,作為一個(gè)前端的我們,直接上代碼擼就好了?。。?!
目標(biāo)
我們的目標(biāo)是實(shí)現(xiàn)一個(gè)頁面制作后臺,在后臺中我們可以對頁面進(jìn)行 組件選擇 --> 布局樣式調(diào)整 --> 發(fā)布上線 --> 編輯修改
這樣的流程操作。
架構(gòu)設(shè)計(jì)
首先是要能提供組件給用戶進(jìn)行選擇,那么我們需要一個(gè)組件庫
,然后需要對選擇的組件進(jìn)行布局樣式調(diào)整,所以我們需要一個(gè)頁面編輯后臺
接著我們需要將編輯產(chǎn)出的數(shù)據(jù)渲染成真實(shí)的頁面,所以我們需要一個(gè)node服務(wù)
和用于填充的template 模板
。發(fā)布上線,這個(gè)直接對接各個(gè)公司內(nèi)部的發(fā)布系統(tǒng)就好了,這里我們不做過多闡述。最后的編輯修改功能也就是針對配置的修改,所以我們需要一個(gè)數(shù)據(jù)庫,這里我選擇的是用了mysql 。當(dāng)然你也可以順便做做權(quán)限管理,頁面管理....等等之類的活。
啰嗦了這么長,我們來畫個(gè)圖,了解下大概的流程:
開擼
組件的實(shí)現(xiàn)
首先我們來實(shí)現(xiàn)組件這一部分,因?yàn)榻M件關(guān)聯(lián)著后臺編輯的預(yù)覽和最后發(fā)布的使用。組件設(shè)計(jì)我們應(yīng)該盡量保持組件的對外一致性,這樣在進(jìn)行渲染的時(shí)候,我們可以提供一個(gè)統(tǒng)一的對外數(shù)據(jù)接口。這里我們的技術(shù)選型是基于 Vue 的,所以下面的代碼部分也主要是基于 Vue 的,但是萬變不離其宗,其他語言也類似。
根據(jù)上圖,我們的組件是會(huì)被一個(gè)個(gè)拆分單獨(dú)發(fā)布到 npm
倉庫的,為什么這么設(shè)計(jì)呢?其實(shí)之前也考慮過設(shè)計(jì)成一個(gè)組件庫,所有組件都包含在一個(gè)組件庫內(nèi),這樣只需要發(fā)布一個(gè)組件庫包,用的時(shí)候按需加載就好了。后來在實(shí)踐的過程中發(fā)現(xiàn)這樣并不合適協(xié)同開發(fā),其他前端如果想貢獻(xiàn)組件,接入的改造成本也很大。舉個(gè)例子:小明在業(yè)務(wù)中寫了個(gè)Button
組件,這個(gè)組件經(jīng)常會(huì)被其他項(xiàng)目復(fù)用,他想把這個(gè)組件貢獻(xiàn)到我們的系統(tǒng)中,被模板使用,如果是一個(gè)組件庫的話,他首先得拉取我們組件庫的代碼,然后按照組件庫的規(guī)范格式進(jìn)行提交。這樣一來,偷懶的小明可能就不太愿意這么干,最爽的方法當(dāng)然是在本地構(gòu)建一個(gè)npm庫,開發(fā)選用的是用TypeScript
還是其他的我們不關(guān)心,選用的 Css 預(yù)處理器我們也不關(guān)心,甚至編碼規(guī)范的ESLint
我們也不關(guān)心。最后只需通過編譯后的文件即可。這樣就避免了一個(gè)組件庫的約束。依托于NPM完善的發(fā)布/拉取,以及版本控制機(jī)制,可以讓我們少做一些額外的工作,也可以快速的把平臺搭建起來。
說了這么多,代碼呢?,我們以一個(gè)Button
為例,我們對外提供這樣的形式組件:
<template>
<div :style="data.style.container" class="w_button_container">
<button :style="data.style.btn"> {{data.context}}</button>
</div>
</template>
<script>
export default {
name: 'WButton',
props: {
data: {
type: Object,
default: () => {}
}
}
}
</script>
可以看到我們只對外暴露了一個(gè)props
,這樣做法的好處是可以統(tǒng)一組件對外暴露的數(shù)據(jù),組件內(nèi)部愛怎么玩怎么玩。注意,這里我們也可以引入一些第三方組件庫,比如mint-ui
之類的。
后臺編輯的實(shí)現(xiàn)
在寫代碼前,我們先考慮一下需要實(shí)現(xiàn)哪些功能:
- 一個(gè)屬性編輯區(qū),提供給使用者編輯組件內(nèi)部
props
的功能 - 一個(gè)組件選擇區(qū),提供使用者選擇需要的組件
- 一個(gè)組件預(yù)覽區(qū),提供使用者拖拽排序頁面預(yù)覽的功能
編輯區(qū)的實(shí)現(xiàn)
按照順序,我們先來實(shí)現(xiàn)組件的屬性編輯功能。我們要考慮,一個(gè)組件暴露出哪些可配置的信息。這些可配置的信息如何同步到后臺編輯區(qū),讓使用者進(jìn)行編輯,一個(gè)按鈕的可配置信息可能是這樣:
如果把這些配置全部寫在后臺庫里面,根據(jù)當(dāng)前選擇的組件加載不同的配置,維護(hù)起來會(huì)相當(dāng)麻煩,而且隨著組件數(shù)量的增加,也會(huì)變得臃腫,所以我們可以將這些配置存儲(chǔ)在服務(wù)端,后臺只需要根據(jù)存儲(chǔ)的規(guī)則進(jìn)行解析便可,舉個(gè)例子,我們其實(shí)可以存儲(chǔ)這樣的編輯配置:
[
{
"blockName": "按鈕布局設(shè)置",
"settings": {
"src": {
"type": "input",
"require": true,
"label": "按鈕文案"
}
}
}
]
我們在編輯后臺,通過接口請求到這些配置,便可以進(jìn)行規(guī)則渲染:
/**
* 根據(jù)類型,選擇創(chuàng)建對應(yīng)的組件
* @param {VNode} vm
* @returns {any}
*/
createEditorElement (vm: VNode) {
let dom = null
switch (vm.config.type) {
case 'align':
dom = this.createAlignElement(vm)
break;
case 'select':
dom = this.createSelectElement(vm)
break;
case 'actions':
dom = this.createActionElement(vm)
break;
case 'vue-editor':
dom = this.createVueEditor(vm)
break;
default:
dom = this.createBasicElement(vm)
}
return dom
}
組件選擇功能
首先我們需要考慮的是,組件怎么進(jìn)行注冊?因?yàn)榻M件被用戶選用的時(shí)候,我們是需要渲染該組件的,所以我們可以提供一段 node 腳本來遍歷所需組件,進(jìn)行組件的安裝注冊:
// 定義渲染模板和路徑
var OUTPUT_PATH = path.join(__dirname, '../packages/index.js');
console.log(chalk.yellow('正在生成包引用文件...'))
var INSTALL_COMPONENT_TEMPLATE = ' {{name}}';
var IMPORT_TEMPLATE = 'import {{componentName}} from \\\\'{{name}}\\\\'';
var MAIN_TEMPLATE = `/* Automatic generated by './compiler/build-entry.js' */
{{include}}
const components = [
{{install}}
]
const install = function(Vue) {
components.map((component) => {
Vue.component(component.name, component)
})
}
/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
export {
install,
{{list}}
}
`;
// 渲染引用文件
var template = render(MAIN_TEMPLATE, {
include: includeComponentTemplate.join(endOfLine),
install: installTemplate.join(`,${endOfLine}`),
version: process.env.VERSION || require('../package.json').version,
list: listTemplate.join(`,${endOfLine}`)
});
// 寫入引用
fs.writeFileSync(OUTPUT_PATH, template);
最后渲染出來的文件大概是這樣:
import WButton from 'w-button'
const components = [
WButton
]
const install = function(Vue) {
components.map((component) => {
Vue.component(component.name, component)
})
}
/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
export {
install,
WButton
}
這個(gè)也是組件庫的通用寫法,所以這里的思想就是把發(fā)布到npm
上的組件,進(jìn)行聚合,聚合成一個(gè)組件包引用,我們在后臺編輯的時(shí)候,是需要全量引入的:
import * as W_UI from '../../packages'
Vue.use(W_UI)
這樣,我們組件便注冊完了,組件選擇區(qū),主要是提供組件的可選項(xiàng),我們可以遍歷組件,提供一個(gè)個(gè) List 讓用戶選擇,當(dāng)然如果我們每個(gè)組件如果只提供一個(gè)組件名,用戶可能并不知道組件長什么樣,所以我們最好可以提供一下組件長什么樣的縮略圖。這里我們可以在組件發(fā)布的時(shí)候,也通過 node 腳本進(jìn)行。這里要實(shí)現(xiàn)的代碼比較多,我就大致說一下過程,因?yàn)橐膊皇呛诵倪壿嫞捎锌蔁o,只能說有了體驗(yàn)上會(huì)好一點(diǎn):
- 用戶啟用 dev-server 進(jìn)行代碼編寫測試
- server 腳本使用 Chrome 工具
puppeteer
,調(diào)整頁面到手機(jī)端模式, 進(jìn)行當(dāng)前 dev-server 截圖。 - 生成截圖文件,上傳到node服務(wù),關(guān)聯(lián)組件
這樣,就可以在加載組件選擇區(qū)的時(shí)候,為組件附上縮略圖。
組件預(yù)覽區(qū)
當(dāng)用戶在選擇區(qū)選擇了組件,我們需要展示在預(yù)覽區(qū)域,那么我們怎么知道用戶選擇了哪些組件呢?總不能提前全部把組件寫入渲染區(qū)域,通過 v-if
來判斷選擇吧?當(dāng)然沒有這么蠢,Vue 已經(jīng)提供了動(dòng)態(tài)組件的功能了:
<div
:class="[index===currentEditor ? 'active' : '']"
:is="select.name"
:data="select.data">
</div>
為什么我們不用縮略圖代替真實(shí)組件?一方面生成的縮略圖尺寸存在問題,另一方面,我們需要編輯的聯(lián)動(dòng)性,就是編輯區(qū)的編輯需要及時(shí)的反饋給用戶。
額外的問題
說了這么多,貌似一切都很順利,但是這樣在實(shí)踐的時(shí)候,發(fā)現(xiàn)了存在一個(gè)明顯的問題就是:我們中間的預(yù)覽區(qū)域其實(shí)就是為了盡可能模擬移動(dòng)端頁面效果。但是如果我們加入了一些包含類似 position: fixed
樣式的組件,會(huì)發(fā)現(xiàn)樣式上就出現(xiàn)了明顯的問題。典型的比如Dialog Loading
等。
所以我們參考了 m-ui
組件庫的設(shè)計(jì),將中間預(yù)覽操作容器展示為一個(gè)iframe
。將iframe
大小調(diào)整為375 * 667
,模擬 iPhone 6 的手機(jī)端。這樣就不會(huì)存在樣式問題了??墒沁@樣又出現(xiàn)了另一個(gè)難點(diǎn),那就是左側(cè)的編輯數(shù)據(jù)如何及時(shí)的反應(yīng)到iframe
中?沒錯(cuò),就是postMessgae
,大致思路如下:
利用 vuex
做數(shù)據(jù)存儲(chǔ)池,所有的變化,通過 postMessgae
進(jìn)行同步,這樣我們只用確保數(shù)據(jù)池中的數(shù)據(jù)變化,便可以映射到渲染層的變化。比如,我們在預(yù)覽區(qū)進(jìn)行了組件選擇和拖拽排序,那么我們只需通過vuex
出發(fā)同步信息便可:
// action.ts
const action = {
setCurrentPage ({commit, state}, page: number) {
// 更新當(dāng)前store
commit('setCurrentPage',page)
// 對應(yīng)postMessage
helper.postMsgToChild({type: 'syncState', value: state})
},
// ...
}
Template 模板的實(shí)現(xiàn)
模板的設(shè)計(jì)實(shí)現(xiàn),我參考了 Vue-cli 2.x
版本的思想,把這里的模板,存在了對應(yīng)的 git
倉庫中。當(dāng)用戶需要進(jìn)行頁面構(gòu)建的時(shí)候,直接從 git 倉庫中拉取對應(yīng)的模板即可。當(dāng)然拉取完,也會(huì)緩存一份在本地,以后渲染,直接從本地緩存中讀取即可。我們現(xiàn)在把中心放在模板的格式和規(guī)范上。模板我們采用什么樣的語法無所謂,這里我才用了和 Vue-cli
一樣的Handlerbars
引擎。這里直接上我們模板的設(shè)計(jì):
<template>
<div class="pg-index" :style="{backgroundColor: '{{bgColor}}'}">
<div class="main-container" :style="{
backgroundColor: '{{bgColor}}',
backgroundImage: '{{bgImage}}' ? 'url({{bgImage}})' : null,
backgroundSize: '{{bgSize}}',
backgroundRepeat: 'no-repeat'
}">
{{#components}}
<div class="cp-module-editor {{className}} {{data.className}}">
<{{name}} class="temp-component" :data="{{tostring data}}" data-type="{{upcasefirst name}}"></{{name}}>
</div>
{{/components}}
</div>
</div>
</template>
<script>
{{#noRepeatCpsName}}
import {{upcasefirst this}} from '{{this}}'
{{/noRepeatCpsName}}
export default {
name: '{{upcasefirst repoName}}',
components: {
{{#noRepeatCpsName}}
{{upcasefirst this}},
{{/noRepeatCpsName}}
}
}
</script>
為了簡化邏輯,我們把模板都設(shè)計(jì)成流式布局,所有組件一個(gè)個(gè)堆疊往下順序排列。這個(gè)文件便是我們vue-webpack-simple
的模板中的App.vue
。我們對其進(jìn)行了改寫。這樣在數(shù)據(jù)填充萬,便可以渲染出一個(gè) Vue 單文件。這里我只舉著一個(gè)例子,我們還可以實(shí)現(xiàn)多頁模板等等復(fù)雜的模板,根據(jù)需求拉取不同的模板即可。
Node 渲染服務(wù)
當(dāng)后臺提交渲染請求的時(shí)候,我們的 node 服務(wù)所做的工作主要是:
- 拉取對應(yīng)模板
- 渲染數(shù)據(jù)
- 編譯
拉取也就是去指定倉庫中通過download-git-repo
插件進(jìn)行拉取模板。編譯其實(shí)也就是通過metalsmith
靜態(tài)模板生成器把模板作為輸入,數(shù)據(jù)作為填充,按照handlebars
的語法進(jìn)行規(guī)則渲染。最后產(chǎn)出build
構(gòu)建好的目錄。在這一步,我們之前所需的組件,會(huì)被渲染進(jìn)package.json
文件。我們來看一下核心代碼:
// 這里就像一個(gè)管道,以數(shù)據(jù)入口為生成源,通過renderTemplateFiles編譯產(chǎn)出到目標(biāo)目錄
function build(data, temp_dest, source, dest, cb) {
let metalsmith = Metalsmith(temp_dest)
.use(renderTemplateFiles(data))
.source(source)
.destination(dest)
.clean(false)
return metalsmith.build((error, files) => {
if (error) console.log(error);
let f = Object.keys(files)
.filter(o => fs.existsSync(path.join(dest, o)))
.map(o => path.join(dest, o))
cb(error, f)
})
}
function renderTemplateFiles(data) {
return function (files) {
Object.keys(files).forEach((fileName) => {
let file = files[fileName]
// 渲染方法
file.contents = Handlebars.compile(file.contents.toString())(data)
})
}
}
最后我們得到的是一個(gè) Vue 項(xiàng)目,此時(shí)還不能直接跑在瀏覽器端,這里就涉及到當(dāng)前發(fā)布系統(tǒng)所支持的形式了。怎么說?如果你的公司發(fā)布系統(tǒng)需要在線編譯,那么你可以把源文件直接上傳到 git 倉庫,觸發(fā)倉庫的 WebHook 讓發(fā)布系統(tǒng)替你發(fā)掉這個(gè)項(xiàng)目即可。如果你們的發(fā)布系統(tǒng)是需要你編譯后提交編譯文件進(jìn)行發(fā)布的,那么你可以通過 node 命令,進(jìn)行本地構(gòu)建,產(chǎn)出 HTML ,CSS ,JS 。直接提交給發(fā)布系統(tǒng)即可。 到這里,我們的任務(wù)就差不多了~具體的核心實(shí)心大多已經(jīng)闡述清楚,如果實(shí)現(xiàn)當(dāng)中有什么問題和不妥,也歡迎一起探討交流!!
題外話
實(shí)現(xiàn)這樣一套頁面構(gòu)建系統(tǒng),其實(shí)我這里簡化了很多東西,旨在給大家提供一種思路。另外,其實(shí)我們的頁面全部在服務(wù)端構(gòu)建的時(shí)候產(chǎn)出,我們可以再服務(wù)端這一層做很多工作,比如頁面的性能優(yōu)化,因?yàn)轫撁鏀?shù)據(jù)我們?nèi)慷加?,我們也可以做頁面的預(yù)渲染,骨架屏,ssr ,編譯時(shí)優(yōu)化等等。而且我們也可以對產(chǎn)出的活動(dòng)頁做數(shù)據(jù)分析~有很多想象的空間。