封装一个js业务sdk

6,346 阅读8分钟

github: github.com/ahao430/sdk…

npm仓库:www.npmjs.com/package/@ah…

1. 需求

开发一个sdk,将业务接口都放进去,活动项目和小程序通过npm安装,实现快速调用。

2. 分析

  • sdk是纯js,一般采用rollup打包,体积更小。
  • 要同时适配h5和小程序,公用部分提出来,剩下不同的部分分开写。
  • 通过sdk.模块名.接口名方式来调用
  • 导出ts注释,通过npm引入,可以在vscode看到代码提示

3. 开发

3.1 目录结构

初始化项目,src目录存放源码,里面index.js作为入口,根目录新建rollup.config.js文件。

然后考虑到我们的sdk主要目的是存放api接口。在src新建一个modules目录,里面存放各个接口模块,再在每个模块目录编写一个个接口文件。

再考虑我们的项目要同时兼顾h5段和小程序端,应该有两个入口文件,分别打包各自的sdk。两个环境各发起请求的方式不同,需要分开写兼容代码,新建一个platform目录,新建h5和zfb分别存放各自平台的适配代码。在rollup中也需要有两个入口文件。

暂时设计目录结构如下:

.
├── dist
├── src
│   ├── core // 核心代码
│   ├── modules // 模块
│   │   ├── a // 单个模块,里面写接口
│   │   ├── b
│   │   └── index.js
│   ├── platform // 平台
│   │   ├── h5 // h5
│   │   └── zfb // 支付宝小程序
│   ├── index.ts  // h5入口
│   └── index.zfb.ts // 支付宝小程序入口
├── package.json
├── README.md
└── rollup.config.js // rollup配置

3.2 rollup配置

下面直接贴出最终配置,根据npm script的环境变量,取不同的入口文件。其中run dev时,把dist生成的打包文件又复制到demo/public目录下,同时在demo/index.html启动服务方便开发调试。

import json from 'rollup-plugin-json'
import typescript from 'rollup-plugin-typescript2'
import sourceMaps from 'rollup-plugin-sourcemaps'
import commonjs from '@rollup/plugin-commonjs'
import nodeResolve from '@rollup/plugin-node-resolve'
import builtins from 'rollup-plugin-node-builtins'
import babel from 'rollup-plugin-babel'
import livereload from 'rollup-plugin-livereload'
import { terser } from 'rollup-plugin-terser'
import { eslint } from 'rollup-plugin-eslint'
import replace from '@rollup/plugin-replace'
// import del from 'rollup-plugin-delete'
import copy from 'rollup-plugin-copy'
import dts from 'rollup-plugin-dts'
import { defineConfig } from 'rollup'

const libraryName = 'sdk'
const production = process.env.NODE_ENV === 'production'
const runEnv = process.env.RUN_ENV
const release = process.env.RELEASE

function serve () {
  let server

  function toExit () {
    if (server) server.kill(0)
  }

  return {
    writeBundle () {
      if (server) return
      server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
        stdio: ['ignore', 'inherit', 'inherit'],
        shell: true
      })

      process.on('SIGTERM', toExit)
      process.on('exit', toExit)
    }
  }
}

/**
 * 配置不同运行环境入口文件
 */
const entry = runEnv === 'h5' ? 'src/index.ts' : 'src/index.zfb.ts'
/**
 * 配置不同打包方式输出目录
 */
const outputDir = production ? 'dist' : 'demo/public'

const outPutName = runEnv === 'h5' ? 'index' : 'zfb'

export default defineConfig([{
  input: entry,
  output: [
    { format: 'es', file: `${outputDir}/${outPutName}.js` },
    { format: 'umd', file: `${outputDir}/${outPutName}.umd.js`, name: libraryName, sourcemap: true }
  ],
  plugins: [
    nodeResolve({
      browser: true,
      preferBuiltins: true,
      mainFields: ['browser']
    }),
    builtins(),
    eslint({
      fix: true,
      throwOnError: true
    }),
    commonjs(),
    // del({ targets: `${outputDir}/*` }),
    replace({
      preventAssignment: true,
      'process.browser': true,
      /** 注入环境变量 */
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      'process.env.RUN_ENV': JSON.stringify(process.env.RUN_ENV)
    }),
    typescript({
      exclude: 'node_modules/**',
      typescript: require('typescript'),
      useTsconfigDeclarationDir: true
    }),
    json(),
    babel({
      exclude: 'node_modules/**',
      runtimeHelpers: true // 配置runtime,不设置会报错
    }),
    sourceMaps(),

    /** 开发时启动demo页面服务 */
    !production && serve(),
    !production && livereload('demo'),

    /** 打包时对js进行压缩 */
    production && terser(),

    /** 打包时对js进行压缩 */
    release && copy({
      targets: [
        { src: 'dist/*', dest: 'releases' }
      ]
    })
  ],
  watch: {
    clearScreen: false
  }
},
/* 单独生成声明文件 */
{
 input: entry,
 plugins: [dts()],
 output: {
   format: 'esm',
   file: `${outputDir}/${outPutName}.d.ts`
 }
}
])

核心代码编写

我们希望入口文件拿到sdk,然后可以调用sdk.模块.接口。同时,sdk上挂载一些属性和方法。这里sdk用一个class来处理会比较好,然后在入口文件new这个class的时候,可以将platform作为参数传入。

另外,编写模块的时候,这个请求的方法显然要从platform获取,但是我们挂在实例上,可以通过this获取。

先来编写模块:

/**
* getList, 查询信息接口, /a/get-list, yapi链接:xxx
* @params {object} params
* @params {string} params.authCode 支付宝校验码或者微信校验码(非加密模式){string}
* @params {string} params.appId 应用id(非加密模式){string}
* @returns res 返回代码 {number}
* @returns code 返回代码 {number}
* @returns message 返回处理消息 {string}
* @returns data
*/
export default function getList (params: any = {}, options: Object = {}) {
  return this.get('/a/get-list', params, options)
}
import getList from './getList'
import getDetail from './getDetail'

export default {
  getList,
  getDetail,
}
import a from './a/index'
import b from './b/index'

const modules = {
  a,
  b,
}

export default modules

export type TModules = typeof modules

如上,我们可以通过modules.a.getList拿到方法,后面要考虑一下this指向问题。

import './utils/polyfill'
import SDK from './core/sdk'
import h5 from './platform/h5'

const sdk = new SDK(h5)

export default sdk
import modules from '../modules/index'
import store, { setStore } from './store'

/** SDK工具类 */
class SDK {
  /** 平台对象 */
  private _engine: any
  public _modules: any
  public store: any
  public request: Function
  public get: Function
  public post: Function
  public setStore: Function

  constructor (engine: any) {
    this._engine = engine
    this.store = store
    this.store.platform = engine.platform
    this.request = engine.request
    this.get = engine.request.get
    this.post = engine.request.post
    this._modules = modules
    this.setStore = setStore
  }
}

export type TSdk = typeof SDK

export default SDK

这里,我们可以通过sdk._modules.a.getList调用。下面来编写传入的参数部分。其中store是一个公用的存储对象。

const store = {
  uid: '',
  appId: '',
  config: null,
  env: 'prod',
  debug: false,
}
export default store

适配代码编写

暂时需要做的适配就是请求。在h5发送ajax请求,我们选用axios;在支付宝小程序,使用my.request。这里,我们要对两边做一些处理,保证使用的格式一样。

axios的文档,使用格式是这样:axios.get(url, option), axios.post(url, body, option)。其中option包含headers, params等等。所以get请求和post请求的一般格式是:axios.get(url, {params: data}), axios.post(url, data)。

而my.request(option),接收一个参数对象,其中的data字段在get请求时是query参数,在post请求时是body参数;post请求如果还想带query参数,就只能在url上拼了。也没有baseURL属性,域名要拼在url上。并且不支持promise。下面对二者做一些封装。

import request from './request'

export default {
  platform: 'h5',
  request
}
import axios from 'axios'

const instance = axios.create({
  timeout: 10000
})

const get = (url, data, options = {}) => instance.get(url, {
  ...options,
  method: 'GET',
  params: data
})
const post = (url, data, options = {}) => instance.post(url, data, {
  ...options,
  method: 'POST',
})

export default {
  get,
  post
}
// 小程序的data,
const ajax = async (url, data, options) => 
    const response = await new Promise((resolve, reject) => {
      let formatUrl = config.baseURL + url
      if (config.method === 'GET') {
        my.request({
          ...config,
          data: config.params,
          url: formatUrl,
          success: resolve,
          fail: reject
        })
      } else {
        if (config.params) {
          const arr = []
          for (const key in config.params) {
            arr.push(key + '=' + config.params[key])
          }
          if (arr.length) {
            formatUrl += '?' + arr.join('&')
          }
        }
        my.request({
          ...config,
          data: config.data,
          url: formatUrl,
          success: resolve,
          fail: reject
        })
      }
    })

    return response
}

const get = (url, data, options) => ajax(url, {}, {
  ...options,
  params: data,
  method: 'GET'
})
const post = (url, data, options) => ajax(url, data, {
  ...options,
  method: 'POST'
})

export default {
  get,
  post
}

通过以上的封装,我们调用方法统一入参格式get(url, data, option), post(url, data, option)。其中get第二个参数是query参数,post第二个参数是body参数,最后都返回一个promise。

除此之外,我们在不同的环境,要对参数做不同的处理。在小程序环境,每个请求要带上authcode,可以通过my.getAuthCode获取。这里我们在两个request文件分别封装拦截器功能,在小程序的request加上authcode逻辑,再在公用代码编写其他通用的拦截器业务逻辑。

现在可以在sdk调用方法,但是调用的链比我们期望的多写一步,再就是this的问题需要解决。

proxy处理

上面的两个问题,都可以通过proxy解决。

当get访问sdk.xxx时,如果key是modules的key,直接代理到sdk._modules[key],去掉了._modules看起来就舒服多了。对module继续代理,对module的api接口,正常返回,其他的属性返回sdk的属性。这样get,post请求就代理到了sdk上。同时,对sdk本身的set,return false,防止修改sdk本身。至于modules,加个Object.freeze。而sdk.store本身不能修改,其内部的属性是可以修改的。

import './utils/polyfill'
import SDK from './core/sdk'
import getProxy from './core/proxy'
import h5 from './platform/h5'

const sdk = new SDK(h5)

export default getProxy(sdk)
import TSdk from './sdk'
import TModules from '../modules'

type TProxy = typeof TModules & TSdk

/**
 * sdk实例
 * 调用sdk[模块名][方法名]
 */
const getProxy = (sdk: any) => {
  const proxy: TProxy = new Proxy((sdk), {
    get (target, prop, receiver) {
      const modules = sdk._modules

      if (typeof prop === 'string' && prop.indexOf('_') === 0) {
        return undefined
      } else {
        for (const key in modules) {
          if (prop === key) {
            const module = modules[prop]
            const moduleProxy = new Proxy(module, {
              get (target, prop, receiver) {
                if (typeof target[prop] === 'function') {
                  return target[prop]
                } else {
                  return sdk[prop]
                }
              },
              set (target, prop, value) {
                return false
              }
            })
            return moduleProxy
          }
        }
      }

      return target[prop]
    },
    set (target, prop, value) {
      return false
    }
  })

  return proxy
}

export default getProxy

4. 发布和使用

现在我们可以发布和使用了。先注册账号,然后执行npm login登录,再执行npm publish发布到npm。就可以在其他项目安装依赖并使用了。

要注意的是:

  • nrm use npm,切换回npm源
  • 先commit所有改动
  • package.name用@用户名/项目名格式的,后面要加参数npm publish --access public
  • 先登录再发布

然后我们新建一个项目,像其他npm包一样安装和调用。

// h5使用,main.js引用
import sdk from '@ahao430/sdk-demo/dist/index.js'

console.log(sdk)

sdk.a.getList(params, options)

5. ts导出提示

现在已经可以正常使用sdk.模块.接口来发起请求了。但是通过proxy之后,我们导出的ts上实际是没有这个信息的。在vscode中,我们把鼠标放到方法上面,什么提示也看不到。而我们前面写方法的时候,是有写详细的注释的,我们的需求本身就是开发一个sdk供组内同事调用,代码提示肯定是越详细越好。

这里其实很简单,ts中有个typeof方法,可以获取对象的type。我们把modules的type导出,再挂到proxy上就可以了。

import TSdk from './sdk'
import TModules from '../modules'

type TProxy = typeof TModules & TSdk

/**
 * sdk实例
 * 调用sdk[模块名][方法名]
 */
const getProxy = (sdk: any) => {
  const proxy: TProxy = new Proxy((sdk), {
    ...

再次发布,然后在项目中引用,就可以看到vscode提示信息了。

6 其他

在此基础上,又加了一些优化,比如加了一个debug属性,当设置sdk.debug = true,再次调用sdk方法,会在控制台打印很多日志来帮助。还有加入了环境变量,来适配接口的域名,以及通过域名在拦截器精细处理。

完整的代码可以参见上面github仓库,移除了公司业务代码,放了一个demo在这里。