版本比较

标识

  • 该行被添加。
  • 该行被删除。
  • 格式已经改变。

...

Markdown
allowHtmltrue
# 插件平台技术开发文档


> 下面的所有技术点都以签到插件`SignIn`为例说明。

- [技术要求](#技术要求)
- [架构设计](#架构设计)
- [代码包结构](#代码包结构)
- [代码包版本](#代码包版本)
- [插件怎么实现](#插件怎么实现)
- [插件怎么接入](#插件怎么接入)
- [插件怎么调接口](#插件怎么调接口)
- [开发指南](#开发指南)

## 技术要求

- `框架技术无关`
  插件方可以使用任意前端框架技术开发,例如`React` `Vue` `jQuery`等。

- `开发语言无关`
  插件方可以使用任意开发语言,但是最终需要打包成`ES5`规范的代码,不能包含`ES6+`代码,否则可能会运行时报错。

- `打包工具无关`
  插件方可以使用任意打包工具,但最终的代码包必须符合平台方要求,要求详见下面的`代码包结构`章节。

- `插件后端技术无关`
  插件方可以使用任意服务端技术,部署在自己的服务器上,插件方前端通过自己的域名与插件后端接口进行交互,**但需要允许兔展域名`*.rabbitpre.com`发来的跨域请求**。

- `插件必须是动态模块`
  插件方必须按照动态模块的方式开发,平台方动态异步加载插件模块代码,插件动态渲染在平台方传递进来的`DOM`容器内,**不能是静态页面的开发方式,我们不部署 html 文档**。

- `必须暴露与插件英文名同名的类名`
  插件必须暴露和插件英文名同名的类名,挂载在`window.PLUGIN`的特定全局命名空间下,插件英文名是唯一的,可以保证不与其他供应商的插件发生冲突。

- `css作用域安全`
  关于`css`作用域安全问题,推荐使用`css modules`、`css scoped`、`css in js`等技术方案,或者使用命名空间这种低成本解决方案。

- `不要覆盖或重写全局样式`
  不管是页面级还是平台系统的样式,插件方都不要去覆盖和改写,降低莫名其妙 bug 的隐患。

## 架构设计

总体架构设计方案见图所示:

![前端架构图](http://test-cdn1.rabbitpre.com/04ff9f80-e030-4c84-9829-6f658640aff8)

## 代码包结构

插件方开发完毕的插件,打包好并压缩成`zip`、`7z`等常用压缩包,通过兔展插件管理后台上传到插件代码库。

代码包内不允许包含任意可执行文件,例如`html`文件、`bash`脚本等,代码包上传接口会自动剔除任意可执行文件。

代码包内必须包含`main.js`和`main.css`两个入口文件,文件名不能附加`md5`版本号,其他资源随意。

下面是个示例:

```txt
├─ SignIn
│  ├─ assets
│  │  ├─ logo.12abcde4.png
│  │  ├─ music.12abcde4.mp3
│  │  └─ font.12abcde4.ttf
│  ├─ main.js
│  └─ main.css
```

## 代码包版本

每次通过插件平台上传代码包,都会自动生成一个新的版本号,版本号规范如`v1` `v2`等。

平台方将会以此版本号创建代码库目录,然后把代码包解压部署在此版本号目录下,平台方动态加载和执行此版本下的入口代码。

**注意:插件版本即代码包版本,自带版本管理和代码缓存控制。**

## 插件怎么实现

插件必须以类的方式给平台方暴露插件入口,平台在加载到插件之后,会立刻`new`一个插件实例,并传入一些必要参数。可以参考[插件入口类文档](http://docs.tuzhanai.com/pages/viewpage.action?pageId=1934703)

插件入口类的实现签名如下所示(以 TypeScript 声明):

```ts
/**
 * 插件配置参数,由平台方在`new`插件实例的时候传入
 */
interface PluginConfig {
  /**
   * 插件容器,
   * 插件必须将插件内容动态渲染在此容器里,
   * 不能渲染在此容器外面,也不能随意渲染在平台方的文档任意地方
   */
  container: HTMLDivElement;

  /**
   * 插件授权token,
   * 由插件平台前端向插件平台后端申请,然后传入插件前端,
   * 插件前端不管是向插件自己业务后端还是插件平台后端发起接口请求,都需要带上此token,
   */
  token: string | null;

  /**
   * 插件资源前缀,
   * 也就是插件代码部署在平台方资源服务器上的绝对目录,
   * 插件可以使用这个参数去加载其他资源
   */
  assetPrefix: string;

  /**
   * 插件实例数据
   */
  pluginInfo: PluginInfo;

  /**
   * 平台方在微信授权之后,将微信用户信息传入,
   * 当拒绝授权或者授权失败的时候,此参数为`null`或`undefined`,
   * 插件的实现需要考虑这种情况的容错处理,例如显示占位图
   */
  userInfo: WechatUserInfo | null;

  /**
   * 更新微信分享配置,
   * 可以用在需要自定义微信分享的场景下,
   * 将会覆盖兔展平台默认的微信分享配置
   */
  updateWechatShareInfo: (patch: Partial<WechatShareData>) => void;

  /**
   * 追加微信分享链接参数,
   * 不覆盖图片平台默认的微信分享配置,但需要追加一些自定义链接参数,
   * 返回追加参数之后的完整分享链接
   */
  appendShareLinkParams: (link: string, params: Record<string, any>) => string;
}

/**
 * 插件数据
 */
interface PluginInfo {
  /**
   * 插件id,
   * 注意:不是插件组件id
   */
  id: string;

  /**
   * 插件组件实例id
   */
  componentId: string;

  /**
   * 插件唯一英文名称
   */
  name: string;

  /**
   * 插件版本号
   */
  version: string;

  /**
   * 插件活动的相关配置属性,
   * 这些属性就是在编辑器配置的属性数据,
   * 这个参数有可能会传入`null`或`undefined`,请注意容错处理,
   */
  props?: PluginProp[] | null;
}

/**
 * 插件配置属性数据
 */
interface PluginProp {
  /**
   * 属性id
   */
  id: string;

  /**
   * 属性唯一英文名称,用来编程使用
   */
  name: string;

  /**
   * 属性标签
   */
  title: string;

  /**
   * 属性值,在编辑器制作时存储的值
   */
  value: string;

  /**
   * 属性类型
   */
  type: string;

  /**
   * 模块名字
   */
  moduleName: string;

  /**
   * 是否组件 1是,0否
   */
  componentFlag: number;

  /**
   * 是否系统组件 1是,0否
   */
  systemComponentFlag: number;

  /**
   * 组件值,如果是系统组件,根据系统组件定义的格式存储,如果是普通属性组件只存属性名
   */
  componentValue: any;

  /**
   * 预留支持未来任意扩展字段
   */
  [x: string]: any;
}

/**
 * 微信用户信息
 */
interface WechatUserInfo {
  /**
   * 用户所在城市
   */
  city: string;

  /**
   * 用户所在国家
   */
  country: string;

  /**
   * 用户头像
   */
  headimgurl: string;

  /**
   * 用户的语言,简体中文为zh_CN
   */
  language: string;

  /**
   * 用户的昵称
   */
  nickname: string;

  /**
   * 用户的标识,对当前公众号唯一
   */
  openid: string;

  /**
   * 用户所在省份
   */
  province: string;

  /**
   * 用户的性别,值为1时是男性,值为2时是女性,值为0时是未知
   */
  sex: number;

  /**
   * UnionID
   */
  unionid: string;
}

/**
 * 微信分享配置数据
 */
interface WechatShareData {
  /**
   * 分享标题
   */
  title: string;

  /**
   * 分享描述
   */
  desc: string;

  /**
   * 分享链接
   */
  link: string;

  /**
   * 分享封面图
   */
  imgUrl: string;

  /**
   * 分享成功后的回调函数,
   * 回调函数参数是表示分享到哪里
   * - `timeline` 微信朋友圈
   * - `friend` 微信好友
   * - `workwechat` 企业微信
   * - `qq` QQ好友
   * - `qzone` QQ空间
   */
  onShare: (type: string) => void;

  /**
   * 分享失败的回调函数
   */
  onCancel: (type: string) => void;
}

/**
 * 示例插件:签到插件
 */
class SignIn {
  /**
   * 插件构造器
   * @param {PluginConfig} config 插件配置
   */
  constructor(config: PluginConfig) {
    // 插件方实现业务代码
    // 可以在这里实现插件的视图渲染
  }

  /**
   * 同步更新插件,
   * 当在编辑器修改了插件的属性配置,可能需要同步插件的UI界面,那么就需要实现这个方法,
   * 如果插件没有实现这个方法,那么平台方则忽略插件属性配置的同步操作,
   * @param {PluginProp[]} props 插件组件的属性配置
   */
  update?(props: PluginProp[]) {
    // 插件方实现同步更新的代码
  }

  /**
   * 插件被卸载后的清尾逻辑,
   * 例如清除事件绑定等逻辑
   */
  destroy?() {
    // 在插件被卸载后的清尾代码
  }
}

// 暴露插件入口类
window.PLUGIN.SignIn = SignIn;
```

这个插件入口类请通过`window.PLUGIN`全局命名空间暴露出来,`window.PLUGIN`全局命名空间由平台方创建。

## 插件怎么接入

插件会被兔展编辑器和兔展移动端渲染引擎按照统一的规范主动加载和调用。**这里由平台方负责!!!**。

插件平台前端会在合适的时机,通过插件加载器来动态异步加载插件的`main.js`和`main.css`两个入口文件,在成功加载插件之后,则开始创建插件。

示例代码如下所示:

```js
try {
  // `loadPlugin`是平台方实现的插件加载器中的加载函数
  // 它的入参是插件配置,包括插件id、插件名、插件版本号
  // 它的返回值是插件类,这里示例就是`SignIn`类
  const SignIn = await loadPlugin(plugin);
  if (SignIn) {
    const signIn = new SignIn({
      container: container,
      token: token,
      assetPrefix: getPluginAssetPrefix(plugin),
      pluginInfo: {
        id: plugin.id,
        componentId: plugin.componentId,
        name: plugin.name,
        version: plugin.version,
        props: pluginProps,
      },
      userInfo: userInfo,
    });
  }
} catch (e) {
  console.log("创建插件出错", e);
}
```

**注意:这段代码由平台方实现,插件方不用管,这里只是为了方便开发者看明白插件是如何被平台方加载进来的。**

## 插件怎么调接口

插件方不管是调用插件平台的开放接口还是插件后端的业务接口,都需要把`token`参数传递过去,`token`由平台方生成并传给插件。

插件平台开放接口需要`token`鉴权,插件后端业务接口需要`token`到平台方校验和换回用户信息(与用户有关的业务)。

传递`token`的方式:指定`HTTP`协议认证首部`Authorization`。

示例代码如下所示:

```ts
// 假设请求的接口是`https://plugin.rabbitpre.com/api/plugin/abc`
const api = "https://plugin.rabbitpre.com/api/plugin/abc";
const result = await ajax.get(api, {
  headers: {
    Authorization: token,
  },
});
```

如果`token`非法或过期,则接口请求失败,具体接口协议请见插件平台开放接口相关文档。

## 开发指南

以签到插件`SignIn`为例说明。以签到插件`SignIn`为例说明,还可以下载[插件H5示例代码](http://docs.tuzhanai.com/pages/viewpage.action?pageId=1934854)来参考。

- [技术选型](#技术选型)
- [创建工程](#创建工程)
- [实现入口类](#实现入口类)
- [实现插件业务](#实现插件业务)
- [本地开发调试](#本地开发调试)
- [打包代码包](#打包代码包)
- [部署代码包](#部署代码包)
- [提交审核](#提交审核)
- [使用插件](#使用插件)

### 技术选型

考虑到插件开发与平台技术无关,可以自由灵活选型,开发者选择合适自己的技术即可,但建议开发者多考虑下移动端的复杂环境。

签到插件的技术选型如下所示:

- `ES6+` 使用现代 JavaScript 语言规范
- `TypeScript` 使用 TypeScript 开发语言
- `React` 前端基础框架
- `Less` 样式预处理器
- `Webpack` 构建和打包工具

### 创建工程

新建文件夹,命名为`signin`,创建必要的代码文件,结构图如下所示:

```txt
├─ signin
│  ├─ src                     // 源代码目录
│  │  ├─ components           // 插件组件目录
│  │  │  ├─ app.less          // 插件应用组件样式文件
│  │  │  ├─ app.tsx           // 插件应用组件实现文件
│  │  │  └─ index.ts          // 导出插件应用组件
│  │  ├─ main.tsx             // 签到插件入口类实现文件
│  │  └─ demo.html            // 签到插件本地开发调试的静态页面(不是用来发布的)
│  ├─ package.json            // NPM相关配置文件
│  ├─ tsconfig.json           // TypeScript配置文件
│  └─ webpack.config.js       // Webpack配置文件,入口模块配置为`src/main.tsx`
```

以上工程结构仅供参考。

重点说明一下:

- `demo.html`是开发者在本地开发调试时的静态页面,不是用来发布到插件平台插件库的,**注意:插件平台不允许发布`html`页面,代码包里的`html`文件会被自动过滤掉**。
- `main.tsx`才是插件的入口模块,通过`Webpack`构建和打包之后,输出`main.js`和`main.css`两个入口文件,还有其他资源文件,统一打包成`zip`等压缩包,再发布到插件代码库。
- `webpack.config.js`里的入口模块务必配置为`src/main.tsx`入口类文件,再配置输出`main.js`和`main.css`两个入口文件。

### 实现入口类

按照[入口类规范](#插件怎么实现)实现`main.tsx`,这个入口类是必须要实现和暴露给插件平台方的,不能缺失或错误实现。可以参考[插件入口类文档](http://docs.tuzhanai.com/pages/viewpage.action?pageId=1934703)

核心代码如下所示:

```tsx
import React from "react";
import ReactDOM from "react-dom";

class SignIn {
  // 关于`PluginConfig`的ts声明,详见本文档的`插件怎么实现`小节
  constructor(config: PluginConfig) {
    const { container, ...props } = config;
    if (!container) {
      throw new Error("插件容器不能为空");
    }

    // 考虑到资源加载方案,这里使用了`webpack`的自由变量特性
    // 如果开发者有自己的其他解决方案,可以删掉这里的代码
    if (config.assetPrefix) {
      setWebpackFreeVariable(config.assetPrefix);
    }

    // 延迟require App模块,确保webpack自由变量已经生效
    // @see setWebpackFreeVariable
    const App = require("components/app").default;
    // 渲染插件到指定容器
    ReactDOM.render(<App {...appProps} />, container);
  }

  update(props: PluginProp[] | null) {
    // 当插件活动配置数据发生变更时,执行同步渲染逻辑
    // 代码省略
  }

  destroy() {
    // 卸载插件时,执行清尾逻辑
    // 代码省略
  }
}

// 将插件平台传入的`assetPrefix`属性设置webpack自由变量,
// 确保webpack加载图片、字体等资源模块的时候,`publicPath`是正确的,
// 因为代码包最终是发布到插件平台的代码库中,由插件平台来加载,
// 所以资源前缀是插件平台控制的,插件本身只需要使用相对路径`require`图片资源即可
// 关于webpack自由变量特性,请参考以下官方文档描述
// @see https://webpack.js.org/configuration/output/#outputpublicpath
function setWebpackFreeVariable(assetPrefix: string) {
  let publicPath = assetPrefix;
  if (!/\/$/.test(publicPath)) publicPath += "/";
  // webpack支持的自由变量,这是webpack的特性
  __webpack_public_path__ = publicPath;
}

if (!window.PLUGIN) window.PLUGIN = {};
// 挂载插件入口类
window.PLUGIN.SignIn = SignIn;
```

### 实现插件业务

我们在`components/app.tsx`应用组件里实现签到插件的业务应用,包括各个部分组件的调用和聚合。

在这个示例中,我们把已签到状态存储在本地的`LocalStorage`中,并没有实现插件业务接口,仅供参考。

核心代码如下所示:

```tsx
import React, { Component } from "react";
import "./app.less";

interface AppProps extends Omit<PluginConfig, "container"> {}

interface AppState {
  /**
   * 是否已签到
   */
  isLogin: boolean;
}

class App extends Component<AppProps, AppState> {
  constructor(props: AppProps) {
    super(props);
    const { componentId } = props.pluginInfo;
    // 上次是否已经签到了?
    const cacheValue = window.localStorage.getItem(componentId);
    this.state = {
      isLogin: cacheValue === "1",
    };
  }

  handleLogin = () => {
    this.setState({ isLogin: true });
    if (!window.localStorage) {
      console.log("并不支持localStorage");
    } else {
      const { componentId } = this.props.pluginInfo;
      window.localStorage.setItem(componentId, "1");
    }
  };

  renderButton() {
    // 省略签到按钮组件
  }

  renderUserInfo() {
    // 省略已签到的展示组件
  }

  render() {
    const { isLogin } = this.state;
    return (
      <div className="signin-app">
        {isLogin ? this.renderUserInfo() : this.renderButton()}
      </div>
    );
  }
}

export default App;
```

### 本地开发调试

我们通过一个`demo.html`页面来承载和渲染签到插件,方便在本地开发和调试。

在此页面中创建签到插件,核心代码如下所示:

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <!-- 省略main.css link,由webpack插件注入 -->
  </head>
  <body class="root">
    <div class="container" id="container"></div>
    <!-- 省略main.js script,由webpack插件注入 -->
  </body>
  <script>
    if (window.PLUGIN && window.PLUGIN.SignIn) {
      // 创建签到插件实例,传入相关的必要参数
      new window.PLUGIN.SignIn({
        container: document.getElementById("container"),
        token: "xxxxxxxxxxxxxxxxxxxxx",
        assetPrefix: "/",
        pluginInfo: {
          id: "123456789",
          componentId: "123456789",
          name: "SignIn",
          version: "v1",
          props: [
            {
              id: "123456789",
              name: "tips",
              title: "提示",
              value: "签到成功",
              type: "TEXT",
            },
          ],
        },
      });
    }
  </script>
</html>
```

### 打包代码包

再一次强调,代码包根目录下必须包含`main.js`和`main.css`两个入口文件,目录结构示例见[代码包结构](#代码包结构)。

我们通过`Webpack`工具构建和打包输出代码包,核心配置如下所示:

```js
const path = require("path");
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  mode: "production",
  entry: {
    main: path.resolve(__dirname, "src/main.tsx"),
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    publicPath: "/",
	// 输出`main.js`主模块
    filename: "[name].js",
	// 输出按需加载的异步动态模块
	chunkFilename: "[name].[chunkhash:10].js",
	// 考虑到插件平台也是使用`webpack`打包工具,为了避免模块加载器冲突,
	// 插件开发者需要配置`jsonpFunction`,自定义`webpack`模块加载器函数名
	jsonpFunction: "webpackJsonpPluginSignIn",
  },
  module: {
    // 省略所有loader配置
    rules: [],
  },
  // 省略其他plugin配置
  plugins: [
    new MiniCssExtractPlugin({
	  // 输出`main.css`主模块
      filename: "[name].css",
    }),
  ],
};
```

一切准备就绪之后,执行如下命令,打包代码:

```shell
# 可以在npm scripts中配置`build`脚本,然后执行`npm run build`
NODE_ENV=production webpack -p --progress --profile
```

在`dist`目录检查打包的代码,如果文件无缺失且目录结构正确,则可以压缩`dist`目录为`SignIn.zip`压缩包。

### 部署代码包

准备好了`SignIn.zip`压缩包之后,则可以通过插件管理后台上传此代码包了。上传成功之后,平台方将会把此代码包部署在代码库中。

但要注意的是,如果插件并未审核通过,则此代码包不会生效。

### 提交审核

代码包部署完毕之后,开发者就可以在插件管理后台提交插件审核,请耐心等待平台方运营审核结果。

### 使用插件

当插件审核通过之后,就可以在兔展编辑器看到该插件了,可以开始创建插件到你的活动作品中。

...