「完全理解」SDK 实现解析

674 阅读11分钟

一、背景


技术 Leader 发现最近一段时间,大家的工作热情着实不高,眉头微皱,长叹一口气。心想,是时候来提高大家的工作热情了... ​

根据调研,同学们工作热情不高的主要原因是:每次使用浏览器控制台调试页面,看到满屏幕的输出,就特别烦躁,导致无心工作。 ​

于是,Leader 找到了你,希望开发一个能提高大家工作热情的 SDK... ​

开发 SDK 要顾及的东西实在太多了!于是我使用了自研的 「SDK 脚手架工具」,生成了最标准的 SDK 模板。并且高效的完成了开发!

咳咳,还是先弄清楚开发 SDK 到底需要哪些步骤再说工具的事吧

二、SDK 初成


1. project-x

SDK 是给项目用的。所以先观察我们的项目「project-x」

project-x
├── README.md
├── index.html
├── package.json
├── src
|  └── index.js
└── webpack.config.js
// src/index.js

console.log("Hello World!");

一个基于 webpack 的普通项目,主要逻辑是输出 "Hello World!"

定义:对于不再有调用方的项目。我们称之为「应用

2. hummer-master

怎么才能看到满屏输出还不心烦呢? ​

心烦是因为输出的内容都是 debug。所以我们加入一些其他内容,比如幽默的小故事。这样开发者每次调试,都会先看到段子,笑过之后再排查问题,效率立马就提高了! ​

image.png

我们给 SDK 取名叫 hummer-master,并创建对应的文件

hummer-master
├── package.json
└── src
   ├── constant.js
   └── index.js
// hummer-master/package.json

{
  "name": "@baidu/hummer-master",
  "version": "1.0.0",
  // 保证 project-x 能正确找到项目入口
  "main": "src/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "license": "ISC"
}
//  hummer-master/src/index.js

import {HUMMER_STORY} from './constant'

function HummerMaster() {
    this.storys = HUMMER_STORY
}

HummerMaster.prototype.getStory = function () {
    const story = this.storys[
        Math.floor(
            Math.random() * this.storys.length
        )
    ]
    return story
}

export { HummerMaster }

定义:对于会被「应用」或者其他任意代码库引入的项目。我们称之为「类库」或者「SDK」 ​

调用 SDK 的一方称为「调用方

3. 使用

  1. 在 hummer-master 中执行 npm link 生成一个软链接
  2. 在 project-x 中使用 npm link hummer-master 引入写好的 SDK
  3. 在代码中使用,并执行 npm run build 编译。查看效果
  // project-x/src/index.js
 
+ import {HummerMaster} from '@baidu/hummer-master'
+ const hummerMaster = new HummerMaster()
+ console.log(hummerMaster.getStory());

  console.log("Hello World!");

image.png

功能完全正常。因为 webpack 在编译过程中,会处理所有的导入、导出。因此 SDK 不用做任何编译也可以被正常调用 ​

完美达成了 Leader 的需求! ​

三、满足不同模块化规范


使用了 hummer-master 之后,前端同学们都笑着 debug,开发效率提高了 200% ​

image.png

  • 服务端的同学表示「我们主要基于 Node.js 开发,能不能也引入这个 SDK」
  • 其他组的前端同学表示「我们的项目太老了,压根没用 webpack,能不能通过 script 直接使用」

这两种声音代表着模块化的不同规范。我们先大致了解下当前的主流规范

1. 规范简介

典型的模块规范有如下几种:

  • CommonJS
  • ES6 Modules
  • UMD

1.1 CommonJS

image.png

CommonJS 是一套以在浏览器环境之外(服务器、桌面),构建 JavaScript 生态系统为目标而产生的规范 ​

服务器端的 Node.js 是 CommonJS 规范的一个实现

 cjs 只有一种导入、导出方式

// 导出方式
module.exports = {
  a: 1,
  b: 2
}
// 和上面等价,算一种
exports.a = 1;
exports.b = 2;
// 导入方式
const lib = require('./lib');
console.log('a:',lib.a);
console.log('b:',lib.b);

对 Node.js 来说,模块存放在本地硬盘,同步加载,等待时间就是硬盘的读取时间,这个时间非常短

1.2 ES6 Modules

ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。它的目标,是使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言

ES6 在语言标准的层面上,实现了模块功能

esm 支持三种导入方式和两种导出方式,如下所示

// 导出方式
export default 'hello world'; // default export

export const name = 'hello world'; // named export
export {name:'hello world'} // 上一种的简便写法
// 导入方式
import lib from './lib'; // default import
import * as lib from './lib';
import { method1, method2 } from './lib';

1.3 UMD

希望提供一个前后端跨平台的解决方案(支持 AMD 与 CommonJS 模块方式) ​

  1. 先判断是否支持Node.js模块格式(exports 是否存在),存在则使用 Node.js 模块格式
  2. 再判断是否支持AMD(define 是否存在),存在则使用 AMD 方式加载模块
  3. 前两个都不存在,则将模块公开到全局(window 或 global)

目前输出 UMD 最主要的作用,是让 SDK 支持类似 <script src="xxx/sdk.js"> 形式的引用。(也就是挂载到 window 或 global 上)

2. 让 SDK 满足规范

显然,我们不可能在源码里把每个规范都实现一遍。所以引入 rollup 来编译 SDK 源码, 输出满足三种规范的代码 ​

Rollup 是基于 ES6 实现的代码模块化工具

安装 rollup

npm install rollup -D

新增配置文件

  hummer-master
  ├── package.json
+ ├── build
+	|	 └── rollup.config.js
  └── src
     ├── constant.js
     └── index.js
// src/rollup.config.js

import { resolve } from 'path'
import { name } from '../package.json'

const FORMAT = {
    'ES': 'es',
    'CJS': 'cjs',
    'UMD': 'umd'
}

const base = {
    input: resolve(__dirname, '../src/index.js'),
};

const output = function (format) {
    return {
        name,
        dir: resolve(__dirname, `../dist/${format}`),
      	// format 参数,决定输出需要满足哪一种模块化规范
        format,
    }
}

export default [
    {
        ...base,
        output: output(FORMAT.ES),
    },
    {
        ...base,
        output: output(FORMAT.CJS),
    },
    {
        ...base,
        output: output(FORMAT.UMD),
    }
]

修改 package.json

{
  "name": "@baidu/hummer-master",
  "version": "1.0.0",
- "main": "src/index.js",
+ "main": "dist/cjs/index.js",
+ "module": "dist/es/index.js",
+ "files": ["dist"],
  "scripts": {
+   "build":"rm -rf ./dist && rollup -c build/rollup.config.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "rollup": "^2.55.1"
  }
}

3. 测试输出

3.1 es

使用方式不变

3.2 cjs

mock 一个 nodejs 项目来测试 cjs 规范

// index.js
const {HummerMaster} = require('@baidu/hummer-master')
const hummerMaster = new HummerMaster()
console.log(hummerMaster.getStory());

console.log("Hello World!");

image.png

3.3 umd

创建一个 .html 文件直接访问,验证 umd

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
</head>

<body>
    <script src="./node_modules/@baidu/hummer-master/dist/umd/index.js"></script>
    <script>
        const { HummerMaster } = window['@baidu/hummer-master']
        const hummerMaster = new HummerMaster()
        console.log(hummerMaster.getStory());

        console.log("Hello World!");
    </script>
</body>

</html>

四、SDK 中的 SDK


有同学表示,hummer-master 输出的信息不太明显。希望能和上下内容分隔开 ​

假设我们刚好有两个现成的,设置分割线的库 ​

  • divider-top
  • divider-bottom

目录结构都一样:

divider-top, divider-bottom
├── index.js
└── package.json

唯一区别在于遵守的模块化规范不同。一个是 esm、一个是 cjs

// divider-top/index.js
export const dividerTop = '↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓\n'
// divider-bottom/index.js
module.exports =  {
    dividerBottom:'\n↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑'
}

1. 引入依赖

生成软链接,并在 hummer-master 中安装这两个依赖

npm link divider-top divider-bottom
// package.json

{
  "name": "@baidu/hummer-master",
  "version": "1.0.0",
	...
  "license": "ISC",
+ "dependencies": {
+   "@baidu/divider-bottom": "^1.0.0",
+   "@baidu/divider-top": "^1.0.0"
+ }
}

修改源码,增加分割线

  //  src/index.js

  import {HUMMER_STORY} from './constant'
+ import {dividerTop} from '@baidu/divider-top'
+ import {dividerBottom} from '@baidu/divider-bottom'

  function HummerMaster() {
      this.storys = HUMMER_STORY
  }

  HummerMaster.prototype.getStory = function () {
      const story = this.storys[
          Math.floor(
              Math.random() * this.storys.length
          )
      ]
+     return dividerTop + story + dividerBottom
  }

  export { HummerMaster }

执行 build 命令,编译代码。此时控制台会提示 warn

(!) Unresolved dependencies
https://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency
@baidu/divider-top (imported by src/index.js)
@baidu/divider-Bottom (imported by src/index.js)

需要修改 rollup 的配置,告知其不用处理 dependencies 相关的 import

// src/rollup.config.js

  import { resolve } from 'path'
+ import { name, dependencies } from '../package.json'

  const FORMAT = {
      'ES': 'es',
      'CJS': 'cjs',
      'UMD': 'umd'
  }

  const base = {
      input: resolve(__dirname, '../src/index.js'),
+     external: Object.keys(dependencies)
  };

  // ... 省略

2. 依赖处理

观察 dist 里输出的最终代码。rollup 在处理依赖时,并没有跟处理源码一样,把代码引入。 ​

输出的代码和源码完全没区别,在 cjs 规范下,唯一变动也只是把 import 处理成了 require

// hummer-master/dist/es/index.js

import { dividerTop } from '@baidu/divider-top';
import { dividerBottom } from '@baidu/divider-bottom';

// ... 中间省略

export { HummerMaster };
// hummer-master/dist/cjs/index.js

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

var dividerTop = require('@baidu/divider-top');
var dividerBottom = require('@baidu/divider-bottom');

// ... 中间省略

exports.HummerMaster = HummerMaster;

这是因为对于「调用方」来说,在安装 hummer-master 的同时。也会安装其 dependencies 中的所有 SDK ​

  • 如果没有同名且冲突的依赖,会直接安装到 projext-x 的 node_modules 下,和其他依赖同级
  • 如果有同名且冲突的依赖,会直接安装到 hummer-master/node_modules

更多情况详见文章:「完全理解」各种 dependencies

project-x
├── README.md
├── index.html
├── package.json
├── src
|  └── index.js
|
├── node_modules
|  ├── @baidu
|  |	├── divider-top
|  |	├── divider-bottom
|  |	└── hummer-master
|  ... 省略	
|
└── webpack.config.js

es

因此在 projext-x 使用 webpack 编译时, 处理依赖中的依赖,和处理源码中的依赖并没什么区别 ​

build 项目。项目运行正常,输出符合预期 image.png 这主要是得益于 webpack 的强大(可以同时处理 cjs、esm 规范的源码和依赖) ​

cjs

而在 node 环境中,由于无法识别 dividerTop 中的 esm 规范,调用时则会报错(这也提醒我们,node 项目虽然本身支持 cjs,也还是配合 webpack 开发比较靠谱,毕竟你无从得知依赖里是否存在一个 esm 模块会导致项目报错) image.png

umd

script 调用的情境就更不用说了。压根不认识 import 和 require(不考虑最新的 type="module" 特性) ​

所以我们需要想办法,把 divider-top 和 divider-bottom 的代码也打包到输出中去。好满足 node 和 script 情况下的使用

3. 引入插件处理依赖

rollup 官方已经考虑到了这种情况,所以引入两个插件即可解决问题

  • @rollup/plugin-node-resolve - 把依赖当成普通文件一样处理
  • @rollup/plugin-commonjs - rollup 默认只支持 esm 规范,这个插件引入后可以同时支持 cjs 规范
npm install @rollup/plugin-node-resolve @rollup/plugin-commonjs -D

修改 rollup

  // src/rollup.config.js

  import { resolve } from 'path'
  import { name, dependencies } from '../package.json'
+ import nodeResolve from '@rollup/plugin-node-resolve';
+ import commonjs from '@rollup/plugin-commonjs';

  const FORMAT = {
      'ES': 'es',
      'CJS': 'cjs',
      'UMD': 'umd'
  }

  const base = {
      input: resolve(__dirname, '../src/index.js'),
      external: Object.keys(dependencies),
+     plugins: [
+         nodeResolve(),
+         commonjs()
+     ]
  };

// ... 省略

  export default [
      {
          ...base,
          output: output(FORMAT.ES),
      },
      {
          ...base,
          output: output(FORMAT.CJS),
      },
      {
          ...base,
          output: output(FORMAT.UMD),
+         external: null,
      }
  ]

这里只修改了 umd 中的 external 为 null。这是因为对于 esm 和 cjs,可以设想如下情境: ​

假设 hummer-master 依赖 A,project-x 或者及其依赖同样依赖 A。那么 project-x 经过编译,会同时存在两份 A 的代码。导致代码 size 增大 ​

所以只让 umd 规范的输出把所有依赖都编译到最终产物 ​

这样不依赖编译工具的 node 项目,以及 script 引入都能得到满足

  // index.js
- const {HummerMaster} = require('@baidu/hummer-master')
+ const {HummerMaster} = require('@baidu/hummer-master/dist/umd')
	const hummerMaster = new HummerMaster()
  console.log(hummerMaster.getStory());

  console.log("Hello World!");

image.png

当然你也可以让 es、cjs 的 external 为 null。这样的话,可以把所有的依赖都作为 devDependencies

五、 引入 babel

image.png 经过长时间的迭代,hummer-master 增加了新功能。可以用 promise 的形式,在等待指定时间后返回段子

  //  src/index.js

  import {HUMMER_STORY} from './constant'
  import {dividerTop} from '@baidu/divider-top'
  import {dividerBottom} from '@baidu/divider-bottom'

  function HummerMaster() {
      this.storys = HUMMER_STORY
  }

  HummerMaster.prototype.getStory = function () {
      const story = this.storys[
          Math.floor(
              Math.random() * this.storys.length
          )
      ]
      return dividerTop + story + dividerBottom
  }
 
+ HummerMaster.prototype.proGetStory = function (time) {
+     return new Promise(res=>{
+         setTimeout(() => {
+             res(this.getStory())
+         }, time);
+     })
+ }

  export { HummerMaster }

调用方式改变

  // src/index.js
  
  import {HummerMaster} from '@baidu/hummer-master'
  const hummerMaster = new HummerMaster()
- console.log(hummerMaster.getStory());
+ hummerMaster.proGetStory(1000).then(res=>{
+   console.log(hummerMaster.getStory(res));
+ })

  console.log("Hello World!");

那么像箭头函数、Promise 等新特性。低版本的浏览器显然无法支持,我们需要想办法处理成能兼容的代码 ​

本文重点不是 babel,对于相关配置只做简单的介绍。babel 相关可以阅读我的另一篇文章:「完全理解」如何配置项目中的 Babel

1. api 和 syntax

js 兼容问题一般分为两部分:api(接口)和 syntax(句法)。 有个最直观的区分方法

不修改源码,只添加 polyfill(垫片)就可以兼容的是 api

// 挂载一个全局 Promise 类即可兼容

const pro = new Promise()

需要修改源码才能兼容的是 syntax

// 需要改写源码
const fn = ()=>{}

// 编译后
var fn = function fn() {};

2. syntax

先解决 syntax

安装 babel 相关的依赖

npm install @rollup/plugin-babel @babel/preset-env @babel/core -D

根目录新增 babal 配置文件

// hummer-master/babel.config.js

module.exports = {
    'presets': [
        [
            '@babel/preset-env', {
                useBuiltIns: false, // 不处理 api,只处理 syntax
            },
        ]
    ],
};

在 rollup.config.js 中引入对应插件

  // src/rollup.config.js

  import { resolve } from 'path'
  import { name, dependencies } from '../package.json'
  import nodeResolve from '@rollup/plugin-node-resolve';
  import commonjs from '@rollup/plugin-commonjs';
+ import { babel } from '@rollup/plugin-babel';


  const FORMAT = {
      'ES': 'es',
      'CJS': 'cjs',
      'UMD': 'umd'
  }

  const base = {
      input: resolve(__dirname, '../src/index.js'),
      external: Object.keys(dependencies),
      plugins: [
          nodeResolve(),
          commonjs(),
+         babel({ babelHelpers: 'bundled' })
      ]
  };

// ... 省略

babelHelpers:github.com/rollup/plug…

执行 npm run build 之后,我们可以看到编译完的代码。已经处理好了箭头函数和 const

// hummer-master/dist/es/index.js


//... 省略

function HummerMaster() {
  this.storys = HUMMER_STORY;
}

HummerMaster.prototype.getStory = function () {
  var story = this.storys[Math.floor(Math.random() * this.storys.length)];
  return dividerTop + story + dividerBottom;
};

HummerMaster.prototype.proGetStory = function (time) {
  var _this = this;   // const -> var

  return new Promise(function (res) {
    setTimeout(function () { // ()=> -> function(){}
      res(_this.getStory());
    }, time);
  });
};

export { HummerMaster };

3. api

对于 api 的处理,常规方法有三种 ​

3.1 引入所有 polyfill(替换、修改原生方法)

只推荐 project-x 这种最上级项目用,可以保证自身源码,和依赖里所有的 api 都能被覆盖到 ​

缺点是代码量大,而且会造成 api 全局污染

// hummer-master/dist/es/index.js

import 'core-js/modules/es.symbol.js';
import 'core-js/modules/es.symbol.description.js';
import 'core-js/modules/es.symbol.async-iterator.js';
import 'core-js/modules/es.symbol.has-instance.js';
// ...
// ... 省略上百行
// ...

HummerMaster.prototype.proGetStory = function (time) {
  var _this = this;

  return new Promise(function (res) {
    _setTimeout(function () {
      res(_this.getStory());
    }, time);
  });
};

export { HummerMaster };

3.2 只引入当前源码既有 api 的 polyfill

通过 babel 配置 useBuiltIns: 'usage' 实现

// hummer-master/dist/es/index.js

import 'core-js/modules/es.object.to-string.js';
import 'core-js/modules/es.promise.js';
import 'core-js/modules/web.timers.js';
// ... 省略

HummerMaster.prototype.proGetStory = function (time) {
  var _this = this;

  return new Promise(function (res) {
    _setTimeout(function () {
      res(_this.getStory());
    }, time);
  });
};

export { HummerMaster };

3.3 只引入当前源码既有 api 的 runtime(不影响原生方法)

通过 babel 插件, @babel/plugin-transform-runtim 实现

// hummer-master/dist/es/index.js

import _Promise from '@babel/runtime-corejs3/core-js-stable/promise';
import _setTimeout from '@babel/runtime-corejs3/core-js-stable/set-timeout';
// ... 省略

HummerMaster.prototype.proGetStory = function (time) {
  var _this = this;

  return new _Promise(function (res) {
    _setTimeout(function () {
      res(_this.getStory());
    }, time);
  });
};

export { HummerMaster };

处理了 api 的同时,还不污染全局环境。 ​

可以通过把 @babel/runtime-corejs3 设置为 peerDependencies,尝试减小调用方的引用成本

3.4 不处理 api

其实还有第四种方案。在 3.1 的方案我们可以知道, project-x 这种最上级项目可能会按浏览器版本配置的 polyfill。也就意味着会覆盖到所以需要兼容的 api

如果能确认 SDK 的所有调用方都使用的该方法,完全可以不处理 api,减小最终代码的体积 ​

六、其他

经过上面一系列的配置,我们的 SDK 终于有了一个最小粒度、功能完备的框架。可以放心的提供给任何调用方了 ​

而对于开发环境来说,其实还有很多需要优化的地方: ​

  • 引入 typescript
  • 引入 jest
  • 引入 eslint
  • 引入 husky
  • ...

SDK 脚手架

image (3).png

针对这一系列乱七八糟麻烦的配置,我开发了一个 SDK 脚手架工具。用于生成一个包含以上所有特性的 SDK 基础模板,方便后续的开发! ​

欢迎有 SDK 开发需求的同学使用 @draftbook/cli

draftbookJs/cli: Draftbook cli,For quickly creating a customized sdk (github.com)