低代码平台实现思路探索:Sparrow项目源码分析(上)

1,234 阅读4分钟

之前的文章中,我们介绍了低代码开发平台iVX的实现思路,今天我们来继续探索一款社区开源产品——sparrow,项目源码见github.com/sparrow-js/…

当前,对于前端开发,有纯代码开发、低代码开发、无代码开发、自驱式开发四种形态。

图引自:2020中国低代码平台指数测评报告

Sparrow是个场景化低代码搭建工作台,它基于Vue.js和element-ui开发,能实时输出可二次开发的源代码(即代码可读性好的源代码,非编译后的代码),支持基于原子、区块粒度的搭建。之所以特别强调场景化,是因为一般性通用组件搭建平台的提效效果比较一般,通过场景化的粗粒度封装可以进一步达到提效的目的。笔者任务,作者将场景化这样一个特点重视起来,是非常明智的。实际上,像宜搭、简道云、活字格等市面上的低代码产品,也都是在切场景。而像iVX那样的产品,上手门槛还是太高,非专业人员很难大面积地使用起来。要想让非专业人员大面积用起来,场景化似乎是一条必由之路。

下面是演示效果:

大体上,Sparrow的实现方案如下图所示:

该项目的代码目录结构如下:

看起来像是采用了monorepo的方式来管理项目,但是在项目中并未看到关于lerna之类的monorepo管理工具的配置。

本文主要分析plugin-demo、plugins、sparrow-cli、sparrow-utils这四个文件夹。

一、plugin-demo

该目录下给出了两个插件(实际上就是组件)样例:sparrow-test-box和sparrow-test-component。

其中,sparrow-test-box的目录结构为:

sparrow-test-component的目录结构为:

两个插件的目录结构基本上类似,都是package.json、tsconfig.json、sparrow.json以及一个src目录和一个dist目录。dist目录是src目录的内容打包后的结果;tsconfig.json是常规的TypeScript打包配置,不必解释。

1、sparrow.json

{
  "name": "sparrow-test-box",
  "description": "Test sparrow box",
  "thumb": "https://unpkg.com/@sparrow-vue/images@1.0.26/assets/box.png"
}

放置的是插件的名称、描述、缩略图等基本声明信息。

2、package.json

在其package.json中,都用到了如下两个库:

这是一个适用于Node.js和浏览器的微型(大约230B)、快速的UUID(V4)生成器。

cheerio是jQuery核心功能的一个快速、灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的场景。

它们都依赖了 @sparrow-vue/sparrow-utils 这个库。也就是 packages/sparrow-utils 下的这个package。下面会详细讲到。

3、src/config.ts

export default {
  model: {
    attr: {
      direction: "",
      'content-position': '',
    },
    custom: {
      label: '输入文本',
    },
  },
  schema: {
    fields: [
      {
        type: 'object',
        label: '',
        model: 'attr',
        schema: {
          fields: [
            {
              type: "select",
              label: "direction",
              model: "direction",
              multi: true,
              values: ["horizontal", "vertical", ""]
            },
            {
              type: "select",
              label: "content-position",
              model: "content-position",
              multi: true,
              values: ["left", "right", "center", ""]
            },
          ]
        }
      },
      {
        type: 'object',
        label: '',
        model: 'custom',
        schema: {
          fields: [
            {
              type: "input",
              inputType: "text",
              label: "label",
              model: "label"
            }
          ]
        }
      }
    ]
  },
}

这应该是插件的属性配置声明文件,用于声明可视化编辑器的属性配置面板中,应该渲染出来哪些配置项用的。

4、sparrow-test-box/src/index.ts

该插件继承了@sparrow-vue/sparrow-utils中导出的Box类。

其中,customAttrHandler对样式进行了处理,比较好理解,可以自行看如下代码:

public customAttrHandler () {
  const custom = _.get(this.config, 'model.custom');
  const styleKeys = [
    'display',
    'flex-direction',
    'justify-content',
    'align-items',
    'flex-wrap',
    'style',
  ];

  const styleArr = [];

  styleKeys.forEach(key => {
    if (key === 'style') {
      styleArr.push(custom[key]);
      return;
    }
    if (custom[key]) {
      styleArr.push(`${key}: ${custom[key]}`);
    }
  });
  if (styleArr.length > 0) {
    this.styleStr = `style="${styleArr.join(';')}"`
  }    
}

5、sparrow-test-component/src/index.ts

它继承了@sparrow-vue/sparrow-utils中抛出的Component类。

二、plugins

目前该目录下还没有实质性的内容,当前应该只是占位。

三、sparrow-utils

通过查看其index.ts,我们发现该package主要包含了四个类:Events、Box、Component和VuePress:

export { default as events } from './events; // 因为Events实际上是个类,所以应该首字母为大写更合理些
export { default as Component } from './Component';
export { default as Box } from './Box';
export { default as VueParse} from './VueParse';
  • Events类 是个事件管理类,包含了on(注册监听)、off(取消监听)、emit(触发)、destroy(清空全部监听)这四个public方法。

  • Box类 应该是容器类的基类。其中有getFragment、addComponent、renderComp等比较重要的方法以及一些其它方法。

  • Component类 应该是组件类的基类。其中有getFragment、renderFragment等比较重要的方法以及一些其它方法。

  • VueParse类 这是一个非常重要的类,它定义了如何对Vue文件进行解析和处理。其中包含了getData、setData、getFormatData、getMethods、getComponents、getImport、getCreated等public方法,此外还有一个init私有方法。

在init私有方法中,分别用了如下三个正则表达式将Vue.js SFC文件的template、script、style三个部分的内容分别提取出来:

/<template>([\s\S])*<\/template>/g

/(?<=<style[\s\S]*>)[\s\S]*(?=<\/style>)/g

/(?<=<script>)[\s\S]*(?=<\/script>)/g

然后,通过@babel/parser对script部分解析成抽象语法树(注意这些都是运行在Node.js上的,而非浏览器上):

this.scriptAst = parser.parse(this.vueScript, {
  sourceType: 'module',
  plugins: [
    "jsx",
  ]
});

接着在通过@babel/traverse来遍历和更新抽象语法树中的节点:

public getMethods () {
  let methods:any = [];
  traverse(this.scriptAst, {
    ObjectProperty: (path: any) => {
      const {node} = path;
      if (node.key.name === 'methods') {
        methods = node.value.properties;
      }
    }
  });
  return methods;
}

PS:@babel/traverse允许我们在语法树中定位特定的节点类型,如上面代码片段中传给traverse方法的第二个参数。

四、sparrow-cli

这是一个命令行package,它就是官方文档里npm install -g sparrow-code中所指的那个sparrow-code包。执行npm install -g sparrow-code之后,再执行sparrow就可以在本地启动起来一个可视化编辑器的前后台项目。

它的主要功能就是从本地加载sparrow-plugins、sparrow-view、sparrow-server这三个包的源文件压缩包并将他们解压、安装依赖。然后启动sparrow-view和sparrow-server中代码所对应的前后台服务。效果如下图所示:

其中:

  • 本地加载时先需要获取源文件压缩包路径,实现采用了request-promise-native库,不过这个库已经不推荐使用了,因为它是扩展自现在已经不推荐使用的request这个package。具体实现如下:
const request = require('request-promise-native');
const semver = require('semver');

module.exports = async function getNpmTarball(npm, version, registry) {
  const url = `${registry}/${npm}`;
  const body = await request({
    url,
    json: true,
  });

  if (!semver.valid(version)) {
    version = body['dist-tags'].latest;
  }

  if (
    semver.valid(version) &&
    body.versions &&
    body.versions[version] &&
    body.versions[version].dist
  ) {
    const tarball = body.versions[version].dist.tarball;
    return tarball;
  }

  throw new Error(`${name}@${version} 尚未发布`);
};
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const request = require('request');
const progress = require('request-progress'); // 可以得到百分比、下载速度和剩余时间
const zlib = require('zlib');
const tar = require('tar');

/**
 * Download tarbar content to the specified directory
 *
 * @param {string} tarballURL tarball url
 * @param {string} destDir target directory
 */
module.exports = function extractTarball({
  tarballURL,
  destDir,
  progressFunc = () => {},
  formatFilename,
}) {
  return new Promise((resolve, reject) => {
    const allFiles = [];
    const allWriteStream = [];
    const directoryCollector = [];
    progress(
      request({                     // 加载
        url: tarballURL,
        timeout: 100000,
      })
    )
      .on('progress', (state) => {
        progressFunc(state);
      })
      .on('error', (error = {}) => {
        error.name = 'download-tarball-error';
        error.data = {
          url: tarballURL,
        };
        reject(error);
      })
      .pipe(zlib.Unzip())          // 解压
      .on('error', (error) => {
        reject(error);
      })
      .pipe(tar.Parse())
      .on('entry', (entry) => {
        const realPath = entry.path.replace(/^package\//, '');

        let filename = path.basename(realPath);
        filename = formatFilename ? formatFilename(filename) : filename;

        const destPath = path.join(destDir, path.dirname(realPath), filename);

        const needCreateDir = path.dirname(destPath);
        if (!directoryCollector.includes(needCreateDir)) {
          directoryCollector.push(needCreateDir);
          mkdirp.sync(path.dirname(destPath));
        }

        allFiles.push(destPath);
        const writeStream = new Promise((streamResolve) => {
          entry
            .pipe(fs.createWriteStream(destPath))
            .on('finish', () => streamResolve());
        });
        allWriteStream.push(writeStream);
      })
      .on('end', () => {
        progressFunc({
          percent: 1,
        });
        Promise.all(allWriteStream)
          .then(() => resolve(allFiles))
          .catch((error) => {
            reject(error);
          });
      });
  });
};
  • 安装依赖是基于cross-spawn实现的,它是node中的spawn和spawnSync的跨平台解决方案。这个比较常见了。具体实现如下:
const spawn = require('cross-spawn');

module.exports = (cwd, registry)=> {
  return new Promise((resolve, reject) => {
    const child = spawn('npm', ['install', '--loglevel', 'silly', '--registry', registry], {
      stdio: ['pipe'],
      cwd,
    });

    child.stdout.on('data', data => {
      console.log(data.toString());
    });

    child.stderr.on('data', data => {
      console.log(data.toString());
    });

    child.on('error', error => {
      reject(error);
    });

    child.on('close', (code) => {
      if (code === 0) {
        console.log('>>> install completed');
        resolve();
      } else {
        reject(new Error('install deps error'));
      }
    });
  });
}
  • 对于要下载到的目标目录,其中使用了user-home来获取系统的user home directory,放在其中的.sparrow目录下。

你可以执行sparrow命令之后,在你的用户目录下的.sparrow文件夹下找到如下图所示文件/文件夹: