Skip to content

优雅的使用native jsapi

什么是混合开发

顾名思义,混合开发就是多种技术融为一体,共同构建一个产物。那么客户端的hyrid混合开发指的是什么呢?指的就是通过 指通过 Web 技术(如 HTML、CSS、JavaScript) 开发移动应用,并通过一个原生容器(Native Container)将 Web 页面嵌入到移动应用中运行。Hybrid 应用结合了 Web 应用的跨平台性和原生应用的功能性。具有开发效率高(一次开发,多平台运行);无需发版即可实现热更新的特点。 这里特意强调下微信,微信极大的推动了这种技术的普及,比如图文时代的公众号,以及现在小程序都是混合开发的产物,一处开发,运营两端。

alt text

问题在哪

从工作一来,一共呆过三家公司,都有混合开发影子,特别是第二家公司,但是不约而同的都出现过如下的问题。

  • 方法散落各处,管理麻烦,缺乏版本控制

  • 方法调用层级过深,在非客户端内报错问题

  • 方法缺少说明和调试页面,无法实现前后分离

    alt text

如何解决以上两个问题

  • 封装nativejsapi的仓库,所以api统一调度,针对每个方法做版本控制
  • 在仓库中做ua头判定,对非目标环境下,直接return false
  • 开发调试页面和文档

技术方案

文件目录:

获取App信息

ts
const isClient = typeof window !== "undefined";
const userAgent = isClient ? window.navigator.userAgent : "";
const IsProd = isClient ? location.host.indexOf(".com") > 0 : true;
const UA = window.navigator.userAgent;
let appType = "NATIVE_APP";
const regex = new RegExp(`${appType}\/\\d{1}\\.\\d{1,2}\\.\\d{1,2}`, "ig");
const matchResult = (UA.match(regex)?.[0] as unknown as string)?.split("/");
const isAndroid = UA.indexOf("Android") > -1 || UA.indexOf("Adr") > -1;
const lowerUserAgent = userAgent.toLowerCase();
const appUas: [string?] = [];

const appVersionRegExp = new RegExp(`(${appUas.join("|")})\\/([^\s]*)\s?`, "i");
const appVersionResult = lowerUserAgent.match(appVersionRegExp);

export const appInfo = {
  appName: matchResult?.[0] || "NATIVE_APP",
  isXXXAPP: userAgent.includes("NATIVE_APP"),
  AppVersion: appVersionResult ? appVersionResult[2] : "",
  IOS: !!userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/),
  Andriod: userAgent.includes("Android") || userAgent.includes("Adr"),
  version: matchResult?.[1] || "1.0.0",
  system: isAndroid ? "android" : "ios",
  IsProd: IsProd,
  isAppWebview: !!matchResult?.length,
};

export default appInfo;

事件监听

ts
const events: {
  [key: string]: Array<any>;
} = {};

/**
 * 绑定事件
 * @param name
 * @param self
 * @param callback
 */
function on(name: string, self: any = null, callback: Function) {
  const tuple = [self, callback];
  const callbacks = events[name];
  if (Array.isArray(callbacks)) {
    callbacks.push(tuple);
  } else {
    events[name] = [tuple];
  }
}

function _remove(name: string, self: any = null) {
  const callbacks = events[name];
  if (Array.isArray(callbacks)) {
    if (!self) {
      events[name] = [];
    } else {
      events[name] = callbacks.filter((tuple) => tuple[0] !== self);
    }
  }
}

/**
 * 移除事件
 * @param name
 * @param self
 */
function remove(name: string, self: any = null) {
  if (!name) {
    Object.keys(events).forEach((_name) => {
      _remove(_name, self);
    });
  } else {
    _remove(name, self);
  }
}

/**
 * 触发事件
 * @param name
 * @param data
 */
function emit(name: string, data = {}) {
  const callbacks = events[name];
  if (Array.isArray(callbacks)) {
    callbacks.map((tuple) => {
      const self = tuple[0];
      const callback = tuple[1];
      callback.call(self, name, data);
    });
  }
}

const BACKED_SHOW = "onBackedShow"; // webview离开
const LEAVE = "onLeave"; // webview返回
const KEYBOARD_FRAME_CHANGED = "onKeyboardChangeFrame"; // 键盘变化
const STATUS_BAR_TAPPED = "onStatusBarTapped"; // 点击状态栏,目前只有ios, deprecated,直接使用 js方法 setScrollsToTop
const PULL_TO_REFRESH = "onPullToRefresh"; // 下拉刷新

export default {
  on,
  remove,
  emit,
  types: {
    BACKED_SHOW,
    LEAVE,
    KEYBOARD_FRAME_CHANGED,
    STATUS_BAR_TAPPED,
    PULL_TO_REFRESH,
  },
};

方法汇总

ts
import EventBus from "./event-bus";
import {
  osProxy,
  iosFactoryFn,
  createCallBack,
  encodeData,
  isClient,
} from "./utils";

const VOIDFN = (res: any) => {}; // 默认空函数
const VOID_EVENT_FN = (event: string, data: any) => {}; // 事件空函数
const ANDROIDPREFIX: any = isClient ? (window as any).toolbox || {} : {};
const customWindow: any = isClient ? (window as any) : {};
interface CommOptions {
  [propName: string]: any;
}
/**
 * 分享类型
 * wechat_friend-微信好友
 * wechat_moment-朋友圈
 * qq-qq好友
 * qqzone-qq空间
 * sinaweibo-新浪微博
 */
type ShareType =
  | "wechat_friend"
  | "wechat_moment"
  | "qq"
  | "qqzone"
  | "sinaweibo";
type SingleImg = {
  base64?: string; // 安卓base64
  method?: string; // ios base64取方法名称
};
interface ShareOptions extends CommOptions {
  copy_writing?: string; // 原生底部的title
  share_string?: string; // 一级标题
  share_desc?: string; // 二级标题
  share_url?: string; // 分享地址
  share_img_url?: string; // 分享图片
  share_type?: ShareType; // 分享类型
  single_img?: SingleImg; // base64图片
}
class NativeApi {
  /**
   * 分享
   * @param shareOptions
   * @param callback
   * @description 单图分享只需要传 single_img | SingleImg
   */
  doShare(shareOptions: ShareOptions, callback = VOIDFN) {
    const callBackName = createCallBack(callback);
    osProxy({
      android: () => {
        if (shareOptions.single_img) {
          delete shareOptions.single_img.method;
        }
        ANDROIDPREFIX.notifyAndroid_doShare(
          JSON.stringify(shareOptions),
          callBackName,
        );
      },
      ios: () => {
        if (shareOptions.single_img) {
          delete shareOptions.single_img.base64;
        }
        iosFactoryFn(
          `share&param=${encodeData(shareOptions)}&callback=${callBackName}`,
        );
      },
    });
  }
  /**
   * 绑定事件
   * @param event 事件名
   * @param func 回调
   * @param target 事件目标,默认为当前 nativeApi 实例
   */
  addEventListener(event: string, func = VOID_EVENT_FN, target: any = this) {
    let sFunc = func;
    EventBus.on(event, target, sFunc);
  }

  /**
   * 移除事件
   * @param event 事件名
   * @param target 事件目标,默认为当前 nativeApi 实例
   */
  removeEventListener(event: string, target: any = this) {
    EventBus.remove(event, target);
  }

  /**
   * 触发事件
   * @param event 事件名
   * @param data 事件参数
   */
  raiseEvent(event: string, data: any = {}) {
    EventBus.emit(event, data);
  }
}
const instance = new NativeApi();

// 提供类供app调用
customWindow.NativeApi = function () {
  return instance;
};
customWindow.NativeApi = instance;
export const Events = EventBus.types;
export default instance;

公共方法

ts
import appInfo from "./appInfo";
export const isClient = typeof window !== "undefined";
export const IOSPREFIX: string = "http://callclient?method=";
const ANDROIDPREFIX: any = isClient ? (window as any).toolbox : {};
interface ProxyF {
  android: Function;
  ios: Function;
}

interface CallbackOption {
  count: number; // 回调次数
}

export interface CallbackFunction extends Function {
  (res?: any, option?: CallbackOption): void;
}

export function versionComparison(appVersion: string) {
  if (appInfo.AppVersion) {
    const v1 = appInfo.AppVersion;
    const v2 = appVersion;
    const arr1 = v1.split(".");
    const arr2 = v2.split(".");
    const len = Math.max(arr1.length, arr2.length);
    while (arr1.length < len) {
      arr1.push("0");
    }
    while (arr2.length < len) {
      arr2.push("0");
    }
    for (let i = 0; i < len; i++) {
      const num1 = parseInt(arr1[i]);
      const num2 = parseInt(arr2[i]);
      if (num1 < num2) {
        return false;
      } else if (num1 > num2) {
        return true;
      }
    }
    return true;
  } else {
    return false;
  }
}

/**
 * 代理,根据不同设备执行不同方法
 * @param f
 */
export function osProxy(
  f: ProxyF,
  version: string = "1.0.0",
  callback?: Function,
) {
  let appVersion = "";
  let versionMatch = false;
  if (appInfo.AppVersion) {
    versionMatch = versionComparison(appVersion);
  }
  if (versionMatch) {
    if (appInfo.Andriod && f.android) {
      return f.android();
    }
    if (appInfo.IOS && f.ios) {
      return f.ios();
    }
  } else {
    callback &&
      callback({
        success: false,
        data: {
          message: "暂不支持",
        },
      });
    return;
  }
}

/**
 * ios 工厂函数
 * @param url
 */
export function iosFactoryFn(url: string): void {
  const _window: any = window;
  const messageHandlers =
    _window.webkit && _window.webkit.messageHandlers
      ? _window.webkit.messageHandlers
      : {};
  const nativeApi = messageHandlers.nativeApi || null;
  if (nativeApi && nativeApi.postMessage) {
    // 新版通过 postMessage 通信,优化性能
    const arr = url.split("&");
    const method: string = arr.shift() as string;
    let params = {};
    let callback = "";
    for (const item of arr) {
      const arr1 = item.split("=");
      const key = arr1[0];
      const value = arr1[1] || "";
      if (key === "callback") {
        callback = value;
      } else if (key === "param") {
        const val = decodeURIComponent(value);
        params = val[0] === "{" ? JSON.parse(val) : val;
      }
    }
    nativeApi.postMessage({
      method,
      params,
      callback,
    });
  } else {
    const iframeDom: HTMLIFrameElement = document.createElement("iframe");
    iframeDom.src = `${IOSPREFIX}${url}`;
    iframeDom.style.display = "none";
    document.documentElement.appendChild(iframeDom);
    setTimeout(() => {
      document.documentElement.removeChild(iframeDom);
    }, 0);
  }
}

/**
 * 发送消息到ios
 * @param method
 * @param paramStr
 * @param callback
 */
export function postMessageToIOS(
  method: string,
  paramStr: string = "",
  callback: string = "",
) {
  iosFactoryFn(
    `${method}&param=${encodeURIComponent(paramStr)}&callback=${callback}`,
  );
}

/**
 * 发送消息到ios
 * @param method
 * @param paramStr
 * @param callback
 */
export function postMessageToAndroid(
  method: string,
  paramStr: string = "",
  callback: string = "",
) {
  ANDROIDPREFIX[`notifyAndroid_${method}`](paramStr, callback);
}

/**
 * 本地回调函数
 * @param res
 */
export function formatNativeParam(res: any) {
  const data = osProxy({
    android: () => {
      if (typeof res === "object") {
        return res;
      }
      if (!res) {
        return { success: true };
      }
      if (res.indexOf("{") === 0) {
        try {
          return JSON.parse(res);
        } catch (err) {
          return { success: true, data: res };
        }
      }
      return { success: true, data: res };
    },
    ios: () => {
      if (res && res.hasOwnProperty("success")) {
        if (res.success === "1") {
          res.success = true;
        } else if (res.success === "0") {
          res.success = false;
        } else {
          res.success = !!res.success;
        }
      }
      return res;
    },
  });
  return data;
}

/**
 * 生成随机回调函数
 * @param fn 回调函数,第二个参数为系统参数 count:当前是第几次回调
 * @param callbackNum 最大回调次数,某些特定场景可以指定多次回调
 */
export function createCallBack(
  fn: CallbackFunction,
  maxCallbackNum: number = 1,
): string {
  const mathRandom = Math.ceil(Math.random() * 1000);
  const callBackName = `nativeCallBack${new Date().getTime()}${mathRandom}`;
  const _window: any = window;
  if (!_window.nativeCallback) {
    _window.nativeCallback = {};
  }
  _window.nativeCallback[callBackName] = {
    max: maxCallbackNum,
    current: 0,
  };
  _window[callBackName] = (res: any) => {
    _window.nativeCallback[callBackName].current++;
    fn(formatNativeParam(res), {
      count: _window.nativeCallback[callBackName].current,
    });
    if (
      _window.nativeCallback[callBackName].current >=
      _window.nativeCallback[callBackName].max
    ) {
      removeCallback(callBackName);
    }
  };
  return callBackName;
}

/**
 * 清除回调函数
 * @param callBackName
 */
export function removeCallback(callBackName: string) {
  const _window: any = window;
  if (_window.nativeCallback) {
    delete _window.nativeCallback[callBackName];
  }
  delete _window[callBackName];
}

/**
 * encodeURIComponent 数据
 * @param data
 */
export function encodeData(data: object): string {
  return encodeURIComponent(JSON.stringify(data));
}

/**
 * 判定数组所在值
 * @param {val} number
 * @param {arr} Array
 */
export function inArray(val: number, arr: any[]): number {
  for (let x = 0, len = arr.length; x < len; x++) {
    if (arr[x] == val) {
      return x;
    }
  }
  return -1;
}

这样就可以规避掉以上的所存在的问题了。

原本想封装一个NPM包,但适用性并不高,仍然还是需要手动改造,因此建了一个模板,放在了git上,有需要的可以自取。 传送门

相关文章

微信小程序API
微信JSSDK

上次更新于: