自研开源CMS可视化编辑器

10,095

一、前言

helo大家好,我是peryl。今天给大家分享自研的页面可视化编辑器。一说到页面可视化,大家可能首先想到的是“低代码平台”,“可视化拖拽生成表单”等等。不过今天分享的这款工具并不是低代码表单,如本文标题所示,这个工具是用来做页面的内容管理的,更多的场景是用于设计店铺首页、电商网站首页,活动页面等等,支持自适应屏幕宽度显示以及服务端渲染,小程序嵌入及其页面跳转。

最近gitee pages服务打开比较慢,同学们在看一些示例的时候得耐心等待一下。。。

二、在线示例

  • 编辑器的样子跟一般的低代码平台或者编辑器很像,目前有Vue3以及React两个版本。
  • React版本是基于小编自研的React组件库plain-design以及plain-design-composition实现的,线上demo地址:react-cms-visual-editor1.png
  • Vue3版本是基于小编自研的Vue3组件库plain-ui以及plain-ui-composition实现的,线上demo地址:vue-cms-visual-editor,与React版本不同的是,默认主色调为绿色,方便与React版本区分开来。 2.png
  • 后面还有Nuxt3(Vue3)以及Next(React)渲染的示例说明,都是使用react版本的可视化编辑器里的代码渲染的(react版本的已经将代码发包到npm),是的你没有看错。就是同样的代码既能跑在Next React中,也能够跑在Nuxt Vue3中,不是web component。就是单纯地引入代码使用。比如轮播组件就是Nuxt以及Next使用同样的代码渲染。所以后续指的可视化编辑器,都是指react版本的。vue版本跟下面的内容就没啥关系了。
  • 老规矩,仓库地址在文章末尾;

编辑预览效果

小屏(手机端,0~719px)

竖屏

small1.png

横屏(其实这时候宽度变化已经变成pad的展示内容了)

small2.png

中屏(pad端,720~1439px)

竖屏

medium1.png

横屏

medium2.png

大屏(pc端,1440~max)

这里设置了容器节点最大宽度1440px,这个是可以在编辑的时候配置的,目前这个宽度是简单模仿的掘金的文章列表的设计,屏幕比较大的时候,某些内容宽度比较长反而会比较难看。

preview_1.png

在小程序中webview嵌入预览

开发者工具iphone

111.png

开发者工具ipad

222.png

移动端扫码预览

qrcode.png

三、功能特点

结构

结构.png

组件

  • 别看左侧面板里边有这么多组件,其实就只有第一个分类“可用组件”里边的组件是有效的,其他分类的组件都是不能用,暂时没有实现的,而且目前也没有打算实现剩下的组件。没别的意思,就是组件太少显得空旷。
  • 目前支持的组件有:
    • 图片
    • 视频
    • 按钮
    • 文本
    • 多行容器
    • 多列容器
    • 轮播容器
    • 固定容器(用来自适应高度)
    • 正方形容器(宽度自适应,高度与宽度一致)
  • 组合组件:
    • 整个页面渲染的数据,是一个树形结构的数据。
    • 当某个节点hover(鼠标放在节点上)或者focus(点击节点)的时候,节点右上角会出现几个特别小的按钮,其中按钮“组合”点击了之后,会将这个节点抽离为一个新的组件在左侧菜单组件列表中供操作;

操作节点

  • 新增节点:从左侧菜单拖拽节点的时候,可以将节点放置在显示“+”的节点中,放置即在目标位置插入一个新的节点;
  • 复制节点:点击节点右上角的“复制”图标小按钮,可以在目标位置复制这个节点;
  • 移动节点:将节点从当前位置,移动到目标位置;
  • 清空节点:清空节点的内容。如果这个节点是页面根节点,那么这个节点会自动被删除。某些容器,比如多列容器、多行容器等等,容器内的某个节点清空内容的时候,会保留位置,仅仅是内容为空,显示“+”待放置节点;

操作页面

默认情况下,只有一个页面。页面是用来实现在不同分辨率下显示不同布局内容的方式。,比如在PC端(屏幕宽度>1440px)的情况下,展示四列商品,在pad端(720px<屏幕宽度<1440px)的情况下,展示三列,移动端的情况下不分列。这种情况下,就需要创建三个页面,然后在设置面板中,【页面设置】中设置最小宽度以及最大宽度;

  • 新建页面:在操作栏中点击新建页面按钮;
  • 复制页面:复制一份当前页面;
  • 删除页面:删除当前页面;
  • 清空页面:清空当前页面所有内容;
  • 切换页面:操作栏右侧会显示已经创建的页面,点击可以切换到对应的页面显示;

设置面板

  • 设置面板中,可以设置节点或者页面的信息;当没有选中节点(无focus)的时候,设置面板会显示编辑页面信息。选中节点后会显示编辑节点信息;
  • 设置面板中编辑完毕之后,点击编辑内容区域可以自动应用编辑设置,也可以点击设置面板右上角的“应用”按钮应用设置。点击重置可以将编辑的变更撤回原始的信息。
  • 所有节点都会有“基本样式”,“背景设置”以及“动作设置”三个配置;

页面结构

  • 在左侧菜单面板中,点击页面结构页签,可以显示当前页面内容结构。比如要选中某个节点的父节点的时候,一种方式是点击节点右上方的“父级”按钮可以选中父节点,另一种方式就是在这个“页面结构”页签中点击对应的节点选中。

操作历史

  • 所有对节点以及页面的增删改操作都会记录到操作历史中,可以点击某个时间点的历史,将数据回退到具体的版本;

四、预览

点击操作栏的“保存并且预览”按钮之后,会打开弹框,输入备注之后,有四种方式可以预览:

  • 新窗口预览:会打开当前工程的preview.html页面预览数据,也就是说,如果当前工程是react-cms-visual-editor工程,那么就是用vite react代码渲染内容。如果是vue-cms-visual-editor工程,那就是用vite vue3渲染内容。
  • 二维码预览:跟“新窗口预览”一样,只是提供二维码供移动端扫码预览。点击“保存预览”的时候,会将当前数据保存到后端,接着返回一个数据的id。二维码的链接地址的参数就是这个id。所以移动端能够预览数据。
  • 在NuxtVue3服务端渲染中预览:顾名思义,不过注意的是,无论是react-cms-visual-editor还是vue-cms-visual-editor,NuxtVue3用的都是react-cms-visual-editor的代码渲染内容;与新窗口预览不同的是,这个预览的内容底部会多出来Nuxt欢迎相关的内容;
  • 在NextReact服务端渲染中预览:顾名思义,不过注意的是,无论是react-cms-visual-editor还是vue-cms-visual-editor,NuxtVue3用的都是react-cms-visual-editor的代码渲染内容;与新窗口预览不同的是,这个预览的内容底部会多出来Next欢迎相关的内容; 预览窗口打开的时候,当前url的查询参数code会变成新的id。所以刷新页面的时候,当前页面编辑的数据来源取决于url中的code参数。

五、关于渲染内容

在Nuxt中渲染数据

nuxt中通过引入react-cms-visual-editor中的部分代码实现渲染数据的功能,只有以下简单的几行代码:

import {computed, designComponent, inject, onBeforeUnmount, onMounted, provide, reactive, watch} from 'plain-ui-composition'
import {iVisualData} from 'react-cms-visual-editor/src/packages/utils/types.base'
import {createCmsPreview} from "react-cms-visual-editor";
import 'react-cms-visual-editor/src/libs/icon/iconfont.css'
import {PropType} from "vue";
import {createReactivityApi} from "react-cms-visual-editor/src/packages/utils/createReactivityApi";
import {processActionRender} from "./processActionRender";

export const CmsPreviewApp = designComponent({
    props: {
        data: {type: Object as PropType<iVisualData>}
    },
    setup({props}) {
        const reactivityApi = createReactivityApi({reactive, computed, type: 'vue', designComponent, onMounted, onBeforeUnmount, watch, provide, inject})
        const CmsPreview = createCmsPreview({api: reactivityApi, processActionRender})
        return () => (
            !!props.data && <>
                <CmsPreview data={props.data}/>
            </>
        )
    },
})

pages/index.vue中查询数据,并且将数据传递给这个CmsPreview组件

<template>
  <div>
    <CmsPreviewApp :data="state.data"/>
    <nuxt-welcome/>
  </div>
</template>

<script setup>
import {CmsPreviewApp} from "../cms/Preview";

const route = useRoute()
const id = route.query.code
const state = reactive({
  data: null
})
if (!!id) {
  const {data} = await useAsyncData(id, async () => {
    const {result} = await $fetch('http://xxx.xxx.xxx/cms/item', {method: 'post', body: {id}})
    return result
  })

  state.data = JSON.parse(data.value.json)
}
</script>

<style lang="scss">
html, body {
  margin: 0;
  padding: 0;
}
</style>

在Next中渲染数据

next中通过引入react-cms-visual-editor中的部分代码实现渲染数据的功能,只有以下简单的几行代码:

import {computed, designComponent, inject, onBeforeUnmount, onMounted, PropType, provide, reactive, watch} from 'plain-design-composition'
import {iVisualData} from 'react-cms-visual-editor/src/packages/utils/types.base'
import {createCmsPreview} from "react-cms-visual-editor";
import 'react-cms-visual-editor/src/libs/icon/iconfont.css'
import {createReactivityApi} from "react-cms-visual-editor/src/packages/utils/createReactivityApi";
import {processActionRender} from "./processActionRender";

// export const CmsPreviewApp = 'CmsPreviewApp'
export const CmsPreviewApp = designComponent({
    props: {
        data: {type: Object as PropType<iVisualData>}
    },
    setup({props}) {
        const reactivityApi = createReactivityApi({reactive, computed, type: 'react', designComponent, onMounted, onBeforeUnmount, watch, provide, inject})
        const CmsPreview = createCmsPreview({api: reactivityApi, processActionRender})
        return () => (
            !!props.data && <>
                <CmsPreview data={props.data}/>
            </>
        )
    },
})

pages/index.tsx中查询数据,并且将数据传递给组件渲染

import {Welcome} from "../cms/Welcome";
import {GetServerSideProps, NextPage} from "next";
import {CmsPreviewApp} from "../cms/Preview";

const Page: NextPage = ({data}: any) => {
    return (
        <div>
            {!!data && <CmsPreviewApp data={data}/>}
            <Welcome/>
        </div>
    )
}

export default Page

export const getServerSideProps: GetServerSideProps = async (ctx) => {

    const id = ctx.query.code
    let data = null
    if (!!id) {
        const resp = await fetch('http://1.116.13.72:7001/cms/item', {
            method: 'post',
            body: JSON.stringify({id}),
            headers: {
                'content-type': 'application/json'
            },
        })
        data = JSON.parse((await resp.json()).result.json)
    }

    return {
        props: {
            data,
        },
    }
}

在小程序中渲染内容

  • 在小程序中也一样能够像nuxt或者next呢样手动渲染数据,不过某些组件还得用小程序的方式再写一遍。swiper实现的轮播组件自然是没法跑在小程序里边的。
  • 这里用的比较省事的办法是,在小程序中通过嵌入webview的方式渲染这个内容。比如小程序中使用webview嵌入已经部署好的nuxt或者next的地址。nuxt或者next在实现路由跳转的时候,判断一下当前环境是否是微信小程序。如果是则调用微信的jssdk跳转,以next工程中示例所示;

首先客户端渲染的时候动态加载微信的jssdk;

const state = reactive({
   isWeapp: false
})
onMounted(() => {
   const el = document.createElement('script') as HTMLScriptElement
   el.src = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js'
   el.onload = () => {
       const {__wxjs_environment} = window as any
       state.isWeapp = __wxjs_environment === 'miniprogram'
   }
   document.body.appendChild(el)
})

在next渲染Link跳转的时候,判断一下当前是否为小程序环境,是的话就调用jssdk跳转小程序页面,不是就按照原来的方式跳转;

<Link href={`/pdp/${data.data.id}`} key={attrs.key}>
    <a {...attrs} onClick={e => {
        e.stopPropagation()
        e.preventDefault()
        const Win = window as any
        const {__wxjs_environment, wx} = Win

        if (__wxjs_environment === 'miniprogram') {
            /*当前是小程序,走小程序跳转*/
            wx.miniProgram.navigateTo({url: `/pages/cms/cms-detail?id=${data.data.id}`})
        } else {
            /*当前不是小程序,走next单页面路由*/
            router.push(`/pdp/${data.data.id}`)
        }
    }}>
        {children}
    </a>
</Link>

六、一些遗留的问题

  • 轮播组件可以配置是否显示切换按钮,在不显示切换按钮的情况下,得通过拖动轮播组件来实现切换。不过swiper把这个mousedown的事件给拦截了,导致编辑器中不显示切换按钮的时候,没法拖动“复制”,“移动”按钮来操作节点。在React中试了onMousedownCapture也不管用,这个问题留到以后在解决。
  • nuxt工程中,在渲染nuxt-link的时候,自定义处理onClick的时候如果检测到是小程序环境,调用jssdk跳转小程序页面。但是发现这个e.stopPropagation以及e.preventDefault都没有办法阻止这个nuxt跳转。后面可能考虑统一在路由拦截器里边处理,拦截路由跳转的时候,如果当前路由支持跳转小程序,并且当前环境也是小程序环境,那么就中止路由跳转,调用小程序jssdk的方式跳转。
  • 服务端渲染的时候,由于无法知道客户端的屏幕分辨率大小。所以返回的内容是固定的大屏的内容。这个导致网络比较慢的时候,移动端打开页面一开始展示的是大屏的内容,等全部资源加载完毕之后才会显示正常的移动端内容。如果有同学有比较好的方案解决这种也可以评论区请留言,万分感谢哈!目前能想到的就只有是,一开始的时候所有内容透明度为0隐藏,等客户端初始化之后再设置显示。

七、结语

  • 到目前为止这个只能说是跟大家分享了一个方案,目前并没有计划将这个工具完善到适应比较多的场景。这个是因为,这个编辑器需要用到组件库,真正在使用的时候需要选择商品、选择活动、上传图片视频之类的。如果是具体的公司或者产品需要用上这么一套编辑器,那么肯定第一步就是要把里边的组件替换成公司或者产品正在使用的组件库,所以目前没有什么方案把这个做成一个受众面比较广的一个工具。
  • 小编在设计这套方案的初衷是,在实现这类内容管理的功能的时候,尽量少地开发组件,所以示例中“组合”组件这个功能是开发这个工具的灵魂,虽然这个相比较于其他的功能没有那么复杂,确是最有意义的一个功能。
  • 在react-cms-visual-editor中把组件写好了之后,渲染数据的工程(next或者nuxt)是不需要实现具体组件的代码的,同时也不会有额外的依赖。比如编辑器中使用了体积比较大的组件库来编辑数据,在渲染数据的工程中并不需要用到这些组件,自然体积会比较小。
  • 以示例中的轮播组件为例,在react-cms-visual-editor中,会调用 createCmsCarousel 函数创建一个Carousel组件,创建的过程中判断当前是vue还是react,如果是vue,会使用plain-ui-composition暴露的designComponent渲染组件,如果是react,会使用plain-design-compositiondesignComponent渲染组件。当然还有一些其他的代码需要切换。比如传递给div节点的样式是class还是className;然后使用swiper处理轮播逻辑。最终的效果就是,在nuxt以及next中渲染数据的时候,不需要实现具体的组件就可以将数据渲染出来。但是跳转的动作是需要处理的。比如nuxt中是用nuxt-link跳转的,next中是用next/link中的Link跳转的。processActionRender就是处理这个逻辑。

八、相关资源

说明地址
在线视频讲解地址bilibili:痞老板很pi
react版本编辑器仓库地址react-cms-visual-editor > gitee
react版本编辑器预览地址react-cms-visual-edirot > gitee pages
vue版本编辑器仓库地址vue-cms-visual-editor > gitee
vue版本编辑器预览地址vue-cms-visual-editor > gitee pages
nuxt3服务端渲染预览地址nuxt + react-cms-viusal-editor
nuxt3仓库地址nuxt-vue3_react-cms-visual-editor
next服务端渲染预览地址next + react-cms-visual-editor
next仓库地址next-react_react-cms-visual-editor