Skip to content

09从实战出发,从0到1构建一个符合标准的公共库

上一讲我们从 Babel 编译预设的角度理清了前端生态中的公共库和应用的丝缕关联,这一讲我们就从实战出发,动手剖析一个公共库从设计到完成的过程。

(源码出处:Creating a simple npm library to use in and out of the browser)

实战打造一个公共库

下面我们从实战出发,从 0 到 1 构建一个符合标准的公共库。我们的目标是,借助 Public APIs,通过网络请求获取 dogs/cats/goats 三种动物的随机图像,并进行展示。更重要的是,将整个逻辑过程抽象成可以在浏览器端和 Node.js 端复用的 npm 包,编译构建使用 Webpack 和 Babel。

首先创建文件:

java
$ mkdir animal-api
$ cd animal-api
$ npm init

并通过 npm init 初始化一个 package.json 文件:

java
{
  "name": "animal-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

编写index.js代码逻辑非常简单,如下:

java
import axios from 'axios';
const getCat = () => {
    // 发送请求
    return axios.get('https://aws.random.cat/meow').then((response) => {
        const imageSrc = response.data.file
        const text = 'CAT'
        return {imageSrc, text}
    })
}
const getDog = () => {
    return axios.get('https://random.dog/woof.json').then((response) => {
        const imageSrc = response.data.url
        const text = 'DOG'
        return {imageSrc, text}
    })
}
const getGoat = () => {
    const imageSrc = 'http://placegoat.com/200'
    const text = 'GOAT'
    return Promise.resolve({imageSrc, text})
}
export default {
    getDog,
    getCat,
    getGoat
}

我们通过三个接口:

封装了三个获取图片地址的函数:

  • getDog()

  • getCat()

  • getGoat()

源码通过 ESM 的方式提供对外接口,请你注意这里的模块化方式,这是一个公共库设计的关键点之一,后文会更详细解析。

对公共库来说,质量保证至关重要。我们使用 Jest 来进行 animal-api 这个公共库的单元测试。Jest 作为 devDependecies 被安装,代码如下:

java
npm install --save-dev jest

编写测试脚本animal-api/spec/index.spec.js

java
import AnimalApi from '../index'
describe('animal-api', () => {
    it('gets dogs', () => {
        return AnimalApi.getDog()
            .then((animal) => {
                expect(animal.imageSrc).not.toBeUndefined()
                expect(animal.text).toEqual('DOG')
            })
   })
})

改写 package.json 中 test script 为 "test": "jest",我们通过运行npm run test来测试。

这时候会得到报错:SyntaxError: Unexpected identifier,如下图所示:

不要慌,这是因为 Jest 并不"认识"import 这样的关键字。Jest 运行在 Node.js 环境中,大部分 Node.js 版本(v10 以下)运行时并不支持 ESM,为了可以使用 ESM 方式编写测试脚本,我们需要安装 babel-jest 和 Babel 相关依赖到开发环境中

java
npm install --save-dev babel-jest @babel/core @babel/preset-env

同时创建babel.config.js,内容如下:

java
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
};

注意上述代码,我们将 @babel/preset-env 的 targets.node 属性设置为当前环境 current。再次执行npm run test,得到报错如下:Cannot find module 'axios' from 'index.js'。

原因看报错信息即可得到,我们需要安装 axios。注意:axios 应该作为生产依赖被安装:

java
npm install --save axios

现在,我们的测试脚本就可以正常运行了。如下图:

当然,这只是给公共库接入测试,"万里长征"才开始第一步。接下来我们按照各种场景进行更多探索。

打造公共库,支持 script 标签引入

在大部分不支持 import 语法特性的浏览器中,为了让我们的脚本直接在浏览器中使用 script 标签引入代码,首先我们需要将已有公共库脚本编译为 UMD 方式。类似上面使用 babel-jest 将测试脚本编译降级为当前 Node.js 版本支持的代码,我们还是需要 Babel 进行降级。

注意这次不同之处在于:这里的降级需要输出代码内容到一个 output 目录中,浏览器即可直接引入该 output 目录中的编译后资源 。我们使用@babel/plugin-transform-modules-umd来完成对代码的降级编译:

java
$ npm install --save-dev @babel/plugin-transform-modules-umd @babel/core @babel/cli

同时在 package.json 中加入相关 script 内容:"build": "babel index.js -d lib",执行npm run build,得到产出(如下图):

我们在浏览器中验证产出:

java
<script src="./lib/index.js"></script>
<script>
    AnimalApi.getDog().then(function(animal) {
        document.querySelector('#imageSrc').textContent = animal.imageSrc
        document.querySelector('#text').textContent = animal.text
    })
</script>

结果出现了报错:

java
index.html:11 Uncaught ReferenceError: AnimalApi is not defined
    at index.html:11

并没有找到 AnimalApi 这个对象,重新翻看编译产出源码:

java
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = void 0;
// 引入 axios
var _axios = _interopRequireDefault(require("axios"));
//  兼容 default 导出
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// 原 getCat 方法
const getCat = () => {
  return _axios.default.get('https://aws.random.cat/meow').then(response => {
    const imageSrc = response.data.file;
    const text = 'CAT';
    return {
      imageSrc,
      text
    };
  });
};
// 原 getDog 方法
const getDog = () => {
  return _axios.default.get('https://random.dog/woof.json').then(response => {
    const imageSrc = response.data.url;
    const text = 'DOG';
    return {
      imageSrc,
      text
    };
  });
};
// 原 getGoat 方法
const getGoat = () => {
  const imageSrc = 'http://placegoat.com/200';
  const text = 'GOAT';
  return Promise.resolve({
    imageSrc,
    text
  });
};
// 默认导出对象
var _default = {
  getDog,
  getCat,
  getGoat
};
exports.default = _default;

发现出现报错是因为 Babel 的编译产出如果要支持全局命名(AnimalApi)空间,需要添加以下配置:

java
  plugins: [
      ["@babel/plugin-transform-modules-umd", {
      exactGlobals: true,
      globals: {
        index: 'AnimalApi'
      }
    }]
  ],

调整后再运行编译,得到源码:

java
// umd 导出格式
(function (global, factory) {
  // 兼容 amd 方式
  if (typeof define === "function" && define.amd) {
    define(["exports", "axios"], factory);
  } else if (typeof exports !== "undefined") {
    factory(exports, require("axios"));
  } else {
    var mod = {
      exports: {}
    };
    factory(mod.exports, global.axios);
    // 挂载 AnimalApi 对象
    global.AnimalApi = mod.exports;
  }
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (_exports, _axios) {
  "use strict";
  Object.defineProperty(_exports, "__esModule", {
    value: true
  });
  _exports.default = void 0;
  _axios = _interopRequireDefault(_axios);
  // 兼容 default 导出
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
  const getCat = () => {
    return _axios.default.get('https://aws.random.cat/meow').then(response => {
      const imageSrc = response.data.file;
      const text = 'CAT';
      return {
        imageSrc,
        text
      };
    });
  };
  const getDog = () => {
    // ... 省略
  };
  const getGoat = () => {
    // ... 省略
  };
  var _default = {
    getDog,
    getCat,
    getGoat
  };
  _exports.default = _default;
});

这时,编译源码产出内容改为了由一个 IIFE 形式实现的命名空间。同时观察源码:

java
global.AnimalApi = mod.exports;
...
var _default = {
    getDog,
    getCat,
    getGoat
  };
  _exports.default = _default;

为了兼容 ESM 特性,导出内容全部挂在了 default 属性中(可以通过 libraryExport 属性来切换),我们的引用方式需要改为:

java
AnimalApi.default.getDog().then(function(animal) {
    ...
})

解决了以上所有问题,看似大功告成了,但是工程的设计没有这么简单。事实上,在源码中,我们没有使用引入并编译 index.js 所需要的依赖 ,比如 axios 并没有被引入处理。正确的方式应该是把公共库需要的依赖,一并按照依赖关系进行打包和引入

为了解决上面这个问题,此时需要引入 Webpack:

java
npm install --save-dev webpack webpack-cli

同时添加webpack.config.js,内容为:

java
const path = require('path');
module.exports = {
  entry: './index.js',
  output: {
    path: path.resolve(__dirname, 'lib'),
    filename: 'animal-api.js',
    library: 'AnimalApi',
    libraryTarget: 'var'
  },
};

我们设置入口为./index.js,构建产出为./lib/animal-api.js,同时通过设置 library 和 libraryTarget 将 AnimalApi 作为公共库对外暴露的命名空间。修改package.json中的 build script 为"build": "webpack",执行npm run build,得到产出,如下图:

至此,我们终于构造出了能够在浏览器中通过 script 标签引入的公共库。当然,一个现代化的公共库还需要支持更多场景,请继续阅读。

打造公共库,支持 Node.js 环境

现在已经完成了公共库的浏览器端支持,下面我们要集中精力适配一下 Node.js 环境了。

首先编写一个node.test.js文件,进行 Node.js 环境的验证:

java
const AnimalApi = require('./index.js')
AnimalApi.getCat().then(animal => {
    console.log(animal)
})

这个文件的意义在于测试公共库是否能在 Node.js 环境下使用 。执行node node-test.js,不出意料得到报错,如下图:

这个错误我们并不陌生,在 Node.js 环境中,我们不能通过 require 来引入一个通过 ESM 编写的模块化文件。上面的操作中,我们通过 Webpack 编译出来了符合 UMD 规范的代码,尝试修改node.test.js文件为:

java
const AnimalApi = require('./lib/index').default
AnimalApi.getCat().then((animal) => {
    console.log(animal)
})

如上代码,我们按照require('./lib/index').default的方式引用,就可以愉快地在 Node.js 环境中运行了。

事实上,依赖上一步的构建产出,我们只需要按照正确的引用路径,就可以轻松地支持 Node.js 环境了。是不是有些恍恍惚惚:"基本什么都没做,这就搞定了",下面,我们从代码原理上阐述说明。

符合 UMD 规范的代码,形如:

java
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['b'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node.
        module.exports = factory(require('b'));
    } else {
        // Browser globals (root is window)
        root.returnExports = factory(root.b);
    }
}(typeof self !== 'undefined' ? self : this, function (b) {
    // Use b in some fashion.
    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

如上结构,通过 if...else 判断是否根据环境加载代码。我们的编译产出类似上面 UMD 格式,因此是天然支持浏览器和 Node.js 环境的。

但是这样的设计将 Node.js 和浏览器环境融合在了一个 bundle 当中,并不优雅,也不利于使用方优化。另外一个常见的做法是将公共库按环境区分,分别产出两个 bundle,分别支持 Node.js 和浏览器环境。如下图架构:

公共库支持浏览器/Node.js 环境方式示意图

当然,如果编译和产出为两种不同环境的资源,还得需要设置 package.json 中的相关字段。事实上,如果一个 npm 需要在不同环境下加载 npm 包不同的入口文件,就会牵扯到 main字段module以及 browser字段。简单来说:

  • main定义了npm包的入口文件,Browser 环境和 Node 环境均可使用;

  • module定义npm包的 ESM 规范的入口文件,Browser 环境和 Node 环境均可使用;

  • browser定义npm包在 Browser 环境下的入口文件。

而这三个字段也需要区分优先级,打包工具对于不同环境适配不同入口的字段在选择上还是要以实际情况为准。经我测试后,在目前状态,Webpack 在 Web 浏览器环境配置下,优先选择:browser > module > main,在 Node.js 环境下 module > main

从开源库总结生态设计

最后一部分,我们针对一个真正的公共库,来总结一下编译适配不同环境的"公共库最佳实践"。simple-date-format 可以将 Date 类型转换为标准定义格式的字符串类型,它支持了多种环境:

java
import SimpleDateFormat from "@riversun/simple-date-format";
const SimpleDateFormat = require('@riversun/simple-date-format');
<script src="https://cdn.jsdelivr.net/npm/@riversun/simple-date-format@1.1.2/lib/simple-date-format.js"></script>

使用方式也很简单:

java
const date = new Date('2018/07/17 12:08:56');
const sdf = new SimpleDateFormat();
console.log(sdf.formatWith("yyyy-MM-dd'T'HH:mm:ssXXX", date));//to be "2018-07-17T12:08:56+09:00"

我们看这个公共库的相关设计,源码如下:

java
// 入口配置
entry: {
  'simple-date-format': ['./src/simple-date-format.js'],
},
// 产出配置
output: {
  path: path.join(__dirname, 'lib'),
  publicPath: '/',
  // 根据环境产出不同的文件名
  filename: argv.mode === 'production' ? `[name].js` : `[name].js`,  //`[name].min.js`
  library: 'SimpleDateFormat',
  libraryExport: 'default',
  // umd 模块化方式
  libraryTarget: 'umd',
  globalObject: 'this',//for both browser and node.js
  umdNamedDefine: true,
  // 在和 output.library 和 output.libraryTarget 一起使用时,auxiliaryComment 选项允许用户向导出文件中插入注释
  auxiliaryComment: {
    root: 'for Root',
    commonjs: 'for CommonJS environment',
    commonjs2: 'for CommonJS2 environment',
    amd: 'for AMD environment'
  }
},

设计方式与前文类似,因为这个库的目标就是:作为一个函数 helper 库,同时支持浏览器和 Node.js 环境。它采取了比较"偷懒"的方式,使用了 UMD 规范来输出代码。

我们再看另一个例子,在 Lodash 的构建脚本中,分为了:

java
"build": "npm run build:main && npm run build:fp",
"build:fp": "node lib/fp/build-dist.js",
"build:fp-modules": "node lib/fp/build-modules.js",
"build:main": "node lib/main/build-dist.js",
"build:main-modules": "node lib/main/build-modules.js",

其中主命令为 build,同时按照编译所需,提供:ES 版本、FP 版本等(build:fp/build:fp-modules/build:main/build:main-modules)。官方甚至提供了 lodash-cli 支持开发者自定义构建,更多相关内容可以参考 Custom Builds

我们在构建环节"颇费笔墨",目的是让你理解前端生态天生"混乱",不统一的运行环境使得公共库的架构,尤其是相关的构建设计更加复杂。更多构建相关内容,我们会在后续章节继续讨论,这里先到此为止。

总结

这两节课我们从公共库的设计和使用方接入两个方面进行了梳理。当前前端生态多种规范并行、多类环境共存,因此使得"启用或设计一个公共库"并不简单,单纯的 'npm install' 后,才是一系列工程化问题的开始。

与此同时,开发者经常疲于业务开发,对于编译和构建,以及公共库设计和前端生态的理解往往得过且过,但这些内容正是前端基础设施道路上的重要一环,也是开发者通往前端架构师的必经之路。建议你将本节知识融入自己手上的真实项目中,刨根问底,相信你一定会有更多收获!

最后,如果本节内容你难以一步到位地理解消化,请不要灰心,我们会在后续章节中不断巩固梳理。我们下一讲再见!