Skip to content

自己制作和发布npm包

本文以自己开发一个fet-tools前端函数库为例,讲解rollup怎么打造一个类库项目并发布。

创建项目

本地创建一个文件夹,或者远程git直接创建一个项目后再克隆下来。我们选择远程github创建后克隆下来。

克隆后项目结构:

接下来,我们补充项目npm项目基本文件。例如:

  • package.json。 这个我们通过npm init -y 命令来完成。并且修改文件里的name和version字段为 fet-tools和1.0.0.
  • .gitignore。这个我们创建后,写个node_modules,以避免提交上依赖包。
  • LICENSE。这个我们直接用git上生成的那个。
  • README.md。这个我们直接用git上生成的那个。后续会在里面编写内容。
  • 再创建一个src目录,待会用于放置我们的源码。

先完成rollup基本构建的配置

  • 先在package.json里面写一个script脚本:
js
  "scripts": {
    "build": "rollup -c",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

即,执行 npm run build的时候,则调用rollup --config来进行rollup构建(默认就是用项目根目录的rollup.config.js文件)。于是我们需要创建一个该文件。

然后编写里面的内容(具体可以参考学习rollup官方文档哈):

js
export default {
    input: 'src/main.js',
    output: {
        dir: 'dist',
        format: 'umd',
        name: 'fetTools',
    }
}

我们这个最简单的配置,完成了输入 src下main文件,然后构建后把打包后的产物输出到dist目录下。由于我们类库只有一个main入口,不是所谓的“多页面入口应用”,因此我们只需要把input配置成一个入口字符串即可。

至于输出格式我们选择用umd,他既可以支持在预编译环境下的一些项目使用(此时会调用umd中的commonjs导出),又可以支持在浏览器amd环境或window变量下使用。

至于name这个字段,在umd下是必须的,因为你往window对象上挂载变量,就必须告知rollup一个名字。

然后,我们在 src 目录下新建 main.js,并编写我的类库代码:

js
console.log('i am main.js')

export default a = 1

接下来,命令行执行 npm run build, 得到dist下的main.js产物如下:

js
(function (global, factory) {
	typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
	typeof define === 'function' && define.amd ? define(factory) :
	(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.fetTools = factory());
})(this, (function () { 'use strict';

	console.log('i am main.js');

	var main = a = 1;

	return main;

}));

可以看到主模块入口的内容已经打包进去,并且全部被包裹在一个umd封装函数里面。

关于类库src下的组织方式

我们类库一般是暴露一些模块函数给别人用,因此我们一般会按照模块划分,例如:url相关、ajax请求相关、cookie相关、js语言类型相关、js语言辅助函数相关。

于是我们要这样组织src下代码结构:

js
- url.js
- ajax.js
- cookie.js
- main.js

其中main.js负责导入其他模块,并作为本npm包的对外暴露的入口,到时候把对外入口main.js写到package.json里面。

接下来,我们在main.js里面把其他模块引入并一起导出:

js
// main.js

export * as ajax  from './ajax'
export * as cookie from './cookie'
export * as type from './type.js'
export * as url from './url.js'

有些同学可能会这样编写导出语句:

js
export * from './ajax'
export * from './cookie'

我不太建议这样哈,因为这样导出后,你在外部调用时候,等同于直接调用各个模块中暴露的相关功能,例如调用cookie方法的时候,需要这样:window.fetTools.cookie.getCookie() 。而我们一般是希望别人调用的时候,也能通过 fetTools.cookie.getCookie() 类似这样通过先访问内部模块,再访问模块上的函数。所以我建议用我的写法。

另外,为了某些不明所以的同学,只知道使用默认导入,咱们还可以通过默认导出再导出一次。所以综合来看,我发明了如下这样导出类库的方式:

js
import * as ajax  from './ajax'
import * as cookie from './cookie'
import * as type from './type.js'
import * as url from './url'

export default {
    ajax,
    cookie,
    type,
    url
}

export { ajax, cookie, type, url }

关于调用node_modules下别的依赖

例如我 fet-tools 里面要实现一个cookie操作库,虽然这个很简单,但是我确实没有必要自己实现,因为社区里已经有挺成熟的js-cookie库了。于是我fet-tools里面想直接借用别人的实现来实现我的cookie相关函数。于是我需要 import 另外一个npm包。

经过检查js-cookie这个npm包的package.json和他的dist产物。发现对于构建/node环境来说,他其实就打包了umd和mjs(即esm)版本。另外他把所有功能函数都挂载到了一个对象上。然后commonjs直接module.exports对外暴露这个对象,esm则export default暴露这个对象。

于是我创建并编写我的cookie.js代码:

js
import jsCookie from 'js-cookie'

export function getCookie(name) {
    return jsCookie.getCookie(name)
}

export function setCookie(name, value, options) {
    return jsCookie.setCookie(name, value, options)
}

不过我想了一下,其实直接把他导入的对象直接暴露就可以了。只要最终在咱们npm包对外暴露的那里再起个名字用具名方式暴露就行了。操作如下:

js
// main.js
import * as ajax  from './ajax'
import cookie from './cookie'
import * as type from './type.js'
import * as url from './url'

const defaultResult = {
    ajax,
    cookie,
    type,
    url
}

export default defaultResult
export { ajax, cookie, type, url }

// cookie.js
export { default } from 'js-cookie'

然后当你构建的时候,会提示你找不到js-cookie这个包。

::: detail (!) Unresolved dependencies https://rollupjs.org/troubleshooting/#warning-treating-module-as-external-dependency js-cookie (imported by "src/cookie.js") :::

这是因为默认情况下,rollup根本无法加载node_modules下的包。他默认不会按照nodejs的惯例去node_modules下寻找包。于是我们需要配置一个resolve插件:

js
import nodeResolve from "@rollup/plugin-node-resolve"
const buildEnv = process.env.BUILD_ENV
const distDir = buildEnv === 'examples' ? 'examples/cdn-style/' : 'dist'
export default {
    input: 'src/main.js',
    output: {
        dir: distDir,
        format: 'umd',
        name: 'fetTools',
        exports: 'named'
    },
    plugins: [
        nodeResolve()
    ]
}

注意上面exports: 'named'这一句,这样防止rollup给我们告警。因为rollup发现我们main.js里面既有具名导出又有默认导出。这会导致调用者他在commonjs环境下只能访问default这个字段才能访问到对应的default导出,这会让调用者困惑。然而这是我特意为之的,所以我加上这一句避免他告警。

接下来,我们构建后,在浏览器就可以这样使用了:

js
window.fetTools.cookie.get('_ga')

当然也可以强行在这种特殊环境下,强行去找到他的default导出:

js
window.fetTools.default.cookie.get('_ga')

不过呢,幸好我们所用的js-cookie他是给import调用方暴露了esm产物文件。那假如我们依赖的npm包是暴露的早期commonjs模块化方式的文件呢。此时加载就报错了。为了让rollup能处理commonjs模块化产物,我们需要配置一个commonjs插件:

js
// rollup.config.js
    plugins: [
        nodeResolve(),
        commonjsResolve()
    ]

继续在浏览器测试,成功:

关于external

那么,假如fet-tools里面要提供一个vue的所有api。但是我不可能把vue给打包进来,我仅仅就是希望告诉调用方你可以通过我来引入vue,但是我自己内部没有vue,我需要你调用方主动提供好vue。 另外一种情形就是,我fet-tools里面提供的某个函数功能(例如函数名叫vueEnhance),也是要依赖vue的。然而我这个函数功能就是给主的vue项目才能用的,也就意味着主项目他必然已经引入了vue才能用我这个函数,这时候我就也完全没必要自己内部还重复打包一份vue。于是此时external功能就派上用场了。

external是指的不把某个模块打包到rollup产物里面。

  • 注意,配置成externals的模块,在我们构建产物里面就变成了require或import语法直接引用(对amd来说就变成了define时候多加一个依赖数组内容,对global来说就是要把globalThis.xxxName传给产物factory函数),该依赖包并不会打包到产物。因此要特别注意globalThis上有没有这个导出,以及node_modules下面有没有对应import/require时候的对应模块化方式的文件和内容。
  • 由于UMD产物里面有globalThis的封装格式,因此external的时候你得说清楚到底globalThis上是用啥变量。

于是rollup配置写法应该是这个样子(多了globals):

js
  output: {
    dir: 'dist',
    format: 'umd',
    name: 'fetTools', // 这个是我自己类库往window挂的时候叫啥名字。
    globals: {
      'fet-block-dep3': 'fetBlockDep3', // 这个是我依赖的东西,在window上叫啥名字。
      'fet-block-dep2': 'fetBlockDep2',
      'vue': 'Vue' // 因为vue在window上是用大写的Vue暴露的,因此必须首字母大写。
    }
  },

globals的作用就是告诉rollup要用全局的什么变量名来作为你依赖的模块名,这个主要是生成umd产物中的纯window那个场景下作为依赖注入使用。

umd编译结果如下:

带vue的编译结果如下:

若产物格式配成esm,则编译结果如下(此处示例删除了vue):

js
import dep3, { c } from 'fet-block-dep3';
import { b } from 'fet-block-dep2';

// import { foo, xtt } from './foo'
// import { abc, def } from './one.cjs'
// import { hij, klm } from './two.cjs'


console.log(dep3());
console.log(c());

console.log('fet2', b);

commonjs编译结果如下(此示例删除了vue):

js
'use strict';

var dep3 = require('fet-block-dep3');
var fetBlockDep2 = require('fet-block-dep2');

// import { foo, xtt } from './foo'
// import { abc, def } from './one.cjs'
// import { hij, klm } from './two.cjs'


console.log(dep3());
console.log(dep3.c());

console.log('fet2', fetBlockDep2.b);

对于esm和commonjs这种产物来说,有个很重要的点是:你dist产物里的产物文件你用nodejs不一定能够真正运行起来。因为你require和import的目标依赖,他真的不一定有对应的模块化版本。 例如cjs产物里面,需要 require(fet-block-dep2),那么假设fet-block-dep2他里面只有个esm的文件(例如其main或exports字段也是指向这个文件),那么对于nodejs环境的运行时来说,cjs产物里面用require语法去引用一个esm的文件,是语法错误的(因为cjs引用esm在nodejs里必须通过import动态函数)。

反过来esm产物也是,如同上文,你产物里有个 import { b } from 'fet-block-dep2'。万一 fet-block-dep2是个commonjs的包,他又没有通过 exports.b=xx 这种语法进行具名导出。那就意味着你的esm产物执行时候会报错:

external的用途是声明 peerDependency。因此我们开发类库时候如果是需要依赖宿主环境下必须有某些东西的话,则可以用external,并且咱们把这个依赖声明成peerDependencies。

然后我们在浏览器中测试vue这个外部依赖是否有效:

html
// 测试页html要这么写
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>example</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="./main.js"></script>
</head>
<body>
    load the fet-tools and try one function.
</body>
</html>

浏览器内访问window.fetTools.vue效果:

关于babel

我们fet-tools里面依赖了一个js-cookie库,而且打包环境下他必然是加载的esm版本的那个js-cookie内的文件。我仔细看了下js-cookie的这个文件,发现他确实提前做了一下es5转换(不过肯定是没有加polyfill了)。

于是我尝试在里面写了这么一个箭头函数,且还用了一个es6的Promise api, 我们看一下构建结果:

果然没有做任何语法转换,更别提polyfill了。

另外,我在主项目的ajax.js里也写了这么个函数:

js
export function sendAjax() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('done');
        }, 1000);
    });
}

结果我的编译结果中,依然啥语法都没有处理。

咋办呢。赶紧引入babel吧!

安装上:

bash
npm i @rollup/plugin-babel @babel/preset-env @babel/core --save-dev

然后.babelrc.json配置(实际上要用babel.config.json,具体可以看下文):

json
{
    "presets": ["@babel/preset-env"]
}

上面主要作用就是把es6+的代码转译成es5,他会自动根据target选择要转译的feature。

例如我们在 .browserslistrc 配置: chrome >= 90 他就不会转译箭头函数;而如果配置chrome大于30,他就会转箭头函数。

当我们这样配置完,再去编译,就会发现我们主项目内写的代码,箭头函数这种语法层面已经能实现转译了。但是js-cookie里面的箭头函数依旧没转译,解决方案请看下一节。

关于node_modules下面的包如何参与语法编译

有时候你依赖的包啊,他可能就只给你暴露了源码版本。或者说人家也rollup构建了产物,但是人家构建的时候,肯定是随便写的browserslist,不一定他是针对es5或者你的目标浏览器编译的umd/cjs/esm产物呀。

所以咱们暂且不谈polyfill,咱只看箭头函数这种语言代码层面的,你用他的产物但又不经过你项目的babel转换,有可能就出问题咯。

所以,感觉最佳实践应该是:

  • 对于箭头函数这种语法。现在的包都会给我们宿主暴露一个esm或cjs版本,我感觉他们这种版本里肯定没有按照我的browserslist要求进行babel转译(他顶多是设置成编译成es5,确实也基本能满足咱们要求)。未了严谨,我感觉我们对引入的第三方包全tmd加入到babel的转译中来(即使构建速度慢一点。。)。
  • 对于polyfill。要求我们也把第三方包加入到babel转译中来。因为我他妈的怎么知道他有没有自己做polyfill?除非他文档写了他自己polyfill完了---即告诉宿主开发者说不需要编译我了。但谁有空看他的文档啊,万一看不明白或者他没写清楚咋整。 为了保险,还是给他也走一遍babel得了。

babel要想让node_modules的某个包参加转译,又有个坑,那就是不要用baberc.json这种配置,必须用babel.config.json这种配置

然而一旦你用了babel.config.json这种配置,他就默认把node_modules全部加入编译。万一你想按需参与编译,你最好使用函数式的include(原因下面讲)。

因此我搞了一个 babel.config.js,include用正则这么写,大家可以抄一下:

js

export default {
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": false, // 这里填写false就是不做polyfill,如果希望做polyfiill,就写usage字符串。
            "corejs": "3",
        }]
    ],
    // include语法参考:https://babeljs.io/docs/options#include
    "include": function(filename, ctx) {
        console.log('要不要编译', filename, ctx)
        // 凡是命中我正则的,都让他参与babel编译。
        const a =  [/fet-block-dep3/].some(item => {
            return item.test(filename)
        });
        console.log('结果', a)
        return a;
    }
}

效果如下:

主要还是因为把babel配置文件改成 babel.config.js, 他才能默认去编译node_modules下的东西。基于此,咱们才可以在 include函数里再针对性处理下希望编译的目标库。

有个小坑点: 当你按照下文描述打开了polyfill之后,你可能希望要对某个node_module下的库也自动检测加polyfill,但后来发现:

  • 报错1:根本就不会对node_modules下的库进行polyfill。这个就通过上面说的改成babel.config.js这种配置文件名来解决。
  • 报错2:爆出循环依赖了。

原因应该是我们babel编译依赖库的时候,babel发现要给模块文件顶部加core-js引用,于是在顶部加上import corejs。然后去corejs里面编译corejs又发现corejs也需要引用自己解决polyfill问题。。

解决方案就是babel转译要把corejs排除,于是只能靠上面的babel的include函数(咱们只能用函数,因为如果用字符串和正则,这里很坑,他必须是写清文件名),务必让他只编译咱们想编译的那些库. 完整配置如下:

js
import path from 'path'

export default {
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "usage",
            "corejs": "3"
        }]
    ],
    include: function(f) {
        console.log('检测', f);
        // 这里的正则你就按照你的需求来写。
        const res = [/fet-block-dep3/, /src\/\w+\.js/].some(r => r.test(f.replace(new RegExp(`\\${path.sep}`, 'g'), '\/')))
        console.log(res);
        return res;
    }
}
  • 报错3:symlink的库,babel编译他的时候,忽然找不到要给他注入的polyfill了(即找不到core-js包了)。原因是我的依赖库是npm link搞过来的,他babel执行我的依赖库的时候,工作目录忽然漂移到工作项目之外了。

解决方案:rollup配置里有个属性,直接给他打开。最终配置如下:

js
import commonjsPlugin from '@rollup/plugin-commonjs'
import nodeResolve from "@rollup/plugin-node-resolve"
import babelPlugin from '@rollup/plugin-babel'

export default {
  input: 'src/main.js',
  preserveSymlinks: true,
  output: {
    dir: 'dist',
    format: 'umd',
    name: 'fetBlock'
  },
  external: [],
  plugins: [nodeResolve({
    browser: false
  }), commonjsPlugin(), babelPlugin({
    babelHelpers: 'bundled' // 这个bundle后面在讲
  })]
}

.browserslistrc配置,为了能兼容低一点浏览器,则可以把版本都设置低一点,例如chrome就这样:

js
// .browserslistrc 文件
chrome >= 50

使用browserslist的时候,注意随时升级我们的caniuse-lite库(caniuse是browserslist库的依赖,而browserslist又是core-js所依赖的库)。有时候你的caniuse库过期了,就会爆出如下错误:

bash
browserslist的本地数据库升级:
Browserslist: caniuse-lite is outdated. Please run:
  npx update-browserslist-db@latest
  Why you should do it regularly: https://github.com/browserslist/update-db#readme

此时按照他的要求执行命令更新即可。

fet-tools 配置总结:

js
// rollup.config.js配置:
import nodeResolve from "@rollup/plugin-node-resolve"
import commonjsResolve from "@rollup/plugin-commonjs"
import babelTransform from "@rollup/plugin-babel"

const buildEnv = process.env.BUILD_ENV

const distDir = buildEnv === 'examples' ? 'examples/cdn-style/' : 'dist'

export default {
    input: 'src/main.js',
    output: {
        dir: distDir,
        format: 'umd',
        name: 'fetTools',
        exports: 'named',
        globals: {
            vue: 'Vue'
        },
    },
    external: ['vue'],
    plugins: [
        nodeResolve(),
        commonjsResolve(),
        babelTransform({
            // exclude: 'node_modules/**',
            babelHelpers: 'bundled'
        })
    ],
    preserveSymlinks: true,
}

// babel.config.js配置
import path from 'path'

export default {
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": false,
            "corejs": "3",
        }]
    ],
    // include语法参考:https://babeljs.io/docs/options#include
    "include": function(filename, ctx) {
        // 凡是命中我正则的,都让他参与babel编译。
        const res = [/js-cookie/, /src\/\w+\.[cm]{0,1}js/].some(r => r.test(filename.replace(new RegExp(`\\${path.sep}`, 'g'), '\/')))
        return res;
    }
}

再次构建后,发现语法层面起码已经编译了:

但是产物js文件行数 只有100多行。很显然polyfill没有构建,这是符合预期的,因为我们上面的配置主动把 useBultIns配置成了false。即不需要polyfill。

关于转译代码后需要的helpers,我们需要:

helpers概念:例如实现class的一些辅助函数。

  • 若你是面向node或预构建环境的lib库项目,对于你的esm和cjs产物,由于他们必然是在nodejs环境下运行。因此你应该把helpers让他变成每个源码模块文件顶部的import('@babel/runtime/xxx')这样的引入,从而让nodejs运行的时候自动调用对应的helper。对于前端预构建场景,我们应该让rollup打包的时候都能实现自动复用各个模块顶部引入的helper。要实现这一点也很简单,只需要给我们rollup的output.format是esm或cjs,然后plugin-babel插件设置babelHepers为runtime就行,然后务必external里配置上 external: ['/@babel\/runtime/], (否则他会把所有babel/runtime本次用到的内容都打进产物,就变成bundled配置的效果了)。 参考:https://github.com/rollup/plugins/tree/master/packages/babel#babelhelpers 参考:https://juejin.cn/post/7363607004348923943
  • 若你是面向浏览器的lib库项目,对于umd的产物。则不能像上面一样了。你得让helper打进产物里,且按需打。所以此时你不要设置external(一旦设置了,那么umd产物里的globalThis那个地方会从window上找babel/runtime对应的helper,那肯定找不到(毕竟没有人在浏览器还提前放置这个东西))。 然后同时你也要像上面一样把plugin-babel插件的babelHelper配置项设置成runtiem,此时每个模块顶部的helper引用语句就会自动被rollup打到rollup产物里面,并且多个模块之间会复用同一份。
  • 若你是web应用,则你肯定是要把所有helper打到产物给浏览器用了。那么此时你肯定希望按照上面这一条方式来做。不过经过我测试,如果把plugin-babel插件的babelHelpers配置成bundled,其效果也是一样的。既然文档中说用bundle那么我们就用bundle吧。

所以总结来看,要么你就打进去(面向直接可能扔给浏览器使用的那种场景,例如umd);要么你就别打进去(例如ejs/cjs面向node或者前端预构建项目的时候)。于是我们就说下打进去的这种bundle的配置吧:

第一种方式,即配置babelHelpers为bun为runtime,此时需要你安装如下运行时:

js
npm i @babel/runtime

并且配置如下babel插件:

js
{
    "presets": ["@babel/preset-env"],
    "plugins": [
        ["@babel/plugin-transform-runtime"]
    ]
}

然后@rollup/plugin-babel插件配置那边,不要写external。

第二种方式比较简单,就是只需要配置plugin-babel的插件,将babelHelper配置成bundled。

我建议我们作为类库开发者例如fet-tools这种库,我们肯定要构建出多份产物,其中:

  • esm/commonjs的产物里面可以给预构建环境使用(可能是纯node也可能是预编译情况下的前端web项目)。此时咱们最好别把babelHelper配置成bundled,而是配置成runtime+external。同时明确告知调用方要把咱们加入到构建中--------这样可以极大的复用主项目已有的helper,避免重复打包helper。 同样,polyfill我也是建议如此。 然而我们又非常担心主项目作者忘记把我们加入构建,这就会造成问题,所以到底打不打polyfill和helper,这是个值得考量和权衡的话题。 在此,为了保证我fet-tool的安全,我打算即使esm/commonjs产物,也打进去。。。
  • umd的产物,我们必然要把polyfill和helper全打进去。因为umd必然会可能在浏览器中直接使用,一般没有人在宿主环境下引入什么全局polyfill,所以你必须确保helper和polyfill自己满足。

关于polyfill

开发web项目,那你polyfill肯定得借助babel自己打上。 如果是类库,你可以在文档里给调用方说明你需要打什么polyfill。当然了既然现在可以按需usage打,感觉也不是不能自己打。

配置方法,由于preset-env现在要求指明corejs版本号,免得未来发生变化,所以按照命令行报错提示,你得:

bash
npm install --save core-js@3

然后:

js
{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "usage",
            "corejs": "3"
        }]
    ]
}

接下来,编译构建。

结果发现他引入的东西也太多了。。。例如我代码里只写一个 [].includes函数,但凡chrome配置大于等于53,他就不做任何polyfill。然后只要低于53,立刻产物变成1630行,难道为了polyfill一个include函数还真的要写1000多行代码。

我看了下这1000多行确实好像也是按需的,因为里面没有promise。 当我源码里再写一句new Promise,他产物就直接变成3000行了。 哎,这polyfill确实体积有点大(这就体现出最新vue3的vite项目要弄个legacy产物和module产物的价值了。。)。

另外一个能证明他确实是按需polyfill的证据是,当我没有安装core-js的时候,编译会报错。他会提示他到底在给哪个模块文件顶部添加corejs引用:

可以看到它确实是只给我的sendAjax以及js-cookie里面添加promise的polyfill。

然而我的编译结果这次已经从当初的5k变成了166kb了。这polyfill可真占体积。看到这个体积变化,又让我不得不思考咱们的库本身是不是就“不要打polyfill了”, 包括helper也不要搞了。即:除了umd版本,其他commonjs/esm版本都保持纯精简,不要打任何helper和polyfill,甚至不要转译到es5,这些转译步骤都交给主项目就好了。(这里有一个作者的观点值得看一下:https://github.com/Jarweb/thinking-in-deep/issues/5)

关于类库项目的output导出

其实umd就已经满足了浏览器环境全局window挂载+浏览器amd环境+commonjs环境。 按这个道理,其实我们除了umd之外,再打包一个esm的版本,给nodejs场景下直接用或者前端nodejs预编译场景来用就行了。

之所以有了umd的情况下,很多类库作者依然要打一个commonjs版本,可能有2个原因(在上一节末尾其实也阐述过了):

  1. 它可能确实有给纯nodejs环境运行使用的纯node版本,这种肯定不需要打入任何polyfill或helper。因此这种产物polyfill直接不打,而helper则配成runtime形式(当然纯node环境我就不建议加任何helper了,反正直接装个最新nodejs就行了)。
  2. 即使面向浏览器。它可能也希望umd里是打上所有helper和polyfill。 但作者可能依然希望commonjs/esm给预构建项目使用,且尽可能让主项目加入构建,从而让他复用我们的helper和polyfill。 (参考:https://github.com/Jarweb/thinking-in-deep/issues/5)

即假设我们源码是用esm模块化方式编写的,则打包时候我们可以输出如下类型的产物:

  • 浏览器空环境:就打一个umd产物。因为umd里面的globalThis模式检测可以让该产物在浏览器下运行。app.bundle.umd.js
  • 浏览器amd环境:umd里面的define模式检测能解决其在浏览器下运行的问题。app.bundle.umd.js
  • 浏览器端esm现代化模块环境: 打包一个esm模块式产物来解决。 app.bundle.esm.js。 注意,这里咱就建议不要加polyfill和helper。
  • Nodejs commonjs环境下直接运行:umd里面的commonjs模式检测就能解决,即app.bundle.umd.js(是否还有必要单独打一个app.bundle.cjs.js? 感觉也有必要,必要性的原因在上文已经阐述)
  • Node.js esm现代化环境(例如v13以后),且开发者用esm写法来写代码的话: 直接引用源码里的index.js不就完了?因为本来就是esm的源码。
  • 前端预构建环境下(即前端通过rollup等工具对业务代码打包情况,例如vue项目): 此时我们要把vue项目的rollup的browser配置成true,以便于加载axios这种库的时候能找到真正的浏览器版本;而对于其他正常库来说,感觉我们库作者就直接用 umdcjsesm 产物都可以吧?甚至由于rollup能支持引入任意类型模块,那我们直接把入口指定成我们库的esm源码入口index.js不是也行吗,这种反而更现代化?

所以,基于上述六个点,此时问题来了:

  1. node.js的commonjs环境下,是否有必要为他专门打一个 app.bundle.cjs.js。

正常情况下,其实umd里就包含了commonjs导出了,理论上能给cjs宿主环境用。

然而,就像babel那些helper,可能我们有时候想弄一个干净点的cjs产物-----例如里面的babelHelper让他只是变成import('@babel-runtime/xxxhelper'),而不是打到产物里面,那么这个时候感觉确实可以弄一个专门的cjs产物来用,毕竟万一你的使用方他是用你来打包成新的项目产物来用的话(无论是node运行用还是前端浏览器环境用), 他一旦二次打包,那么其实最好就是引用你的纯净cjs版本,甚至是引用你的源码版本。这样才可以让那些helper变成可复用的模块,而避免产物里出现重复。

所以我感觉cjs产物的适用场景,就是使用方要用到nodejs运行,或者前端浏览器环境用;其中node运行稍微冗余了helper的话也问题不是特别大,但浏览器场景则尽量不能冗余(毕竟占用体积)。

  1. 非浏览器环境下(例如nodejs环境下),是否有必要还给单独打一个 app.bundle.esm.js

其实原因也同上面的cjs。之所以除了umd还要打esm,就是为了能有个干净点的esm产物。以避免helper被重复打包。然而其实无论cjs还是esm,最干净的其实应该是直接以源码形式暴露。这样调用者他无论nodejs执行还是构建打包给浏览器,都能用上最干净的内容,也能最大可能复用那些共用的node_modules。

所以,我反而建议:

  • 对nodejs包来说。如果你打算就只暴露esm版本,不管那些老旧的调用者。咱们就直接暴露esm源码,让调用者直接用咱们的源码入口。反正nodejs运行肯定没有兼容问题,也不需要加什么babel构建啥的。
  • 如果是nodejs包,你还想兼容那些老旧的cjs调用者。那你相当于要暴露2种模块化的代码。如果写2份代码也不现实;而如果用cjs写再用一个单独的esm文件包一下cjs结果,虽然方案可行,但是这会让咱们用不上最新的esm语法。所以这种场景,我们迫不得已得走构建--------把esm源码构建成cjs作为一个入口来暴露。 只需构建出一个dist/xx.cjs而已;esm还是用源码暴露。我觉得ok。

不过呢,虽然esm直接走源码就行,然而咱们正常一个类库工作流总会有一个构建过程。所以esm不如也保持统一,但是尽量不要对esm打包过程做任何转译,因为没有啥价值。

总结:node环境下,有必要打一个app.bundle.cjs.js。 对于esm来说,我感觉没必要打,我感觉直接暴露esm源码即可,当然从工作统一性角度单纯打一个合并版的esm也可以。

  1. 前端构建环境下,是否还有必要单独打一个 app.bundle.esm.js

答:我认为构建环境也需要打一个这个。因为umd虽然能用,但是umd天然要把helpers那些辅助函数直接打到库结果里面。这会导致项目使用方二次构建的时候的helpers和polyfill冗余,无法复用node_modules。

另一方面,就是前端构建他不同于nodejs,最大的区别就是必须要求使用方务必记得要把所有代码打成es5,然而咱们光靠文档很难说确保使用者一定知道把咱们的包加入构建。因此索性不如咱们自己就把自己先提前babel转译了呗。只是说既然对方处于rollup等构建环境下,咱们尽可能复用他的node_modules,所以咱们优先让他用app.bundle.esm.js这种半干净的版本,而不是umd那种已经怼进去一堆helper和polyfill的产物。 对了,这个场景最重要的一点就是:咱们要复用宿主的polyfill,所以咱们自己作为库的作者,还是尽量别打polyfill了,因为打多了太浪费体积了(毕竟宿主他肯定会打polyfill)。

然而经过本文一系列的思考,我认为前端构建环境下,esm和cjs这种期望参与主项目构建的产物,我觉得咱们还是尽量也别转译更别polyfill,都交给主项目得了。参考:https://github.com/Jarweb/thinking-in-deep/issues/5

treeshaking

由于es6模块化的众多特性(例如import的模块是不可变的,即他是一个常量不能改),这就让他esm他的模块之间关系是稳定的,因此可以在编译期间就直接自作主张的扔掉那些没用到的函数。这就是treeshaking。因为这种基于显式的 import 和 export 语句的方式,它远比「在编译后的输出代码中,简单地运行自动 minifier 检测未使用的变量」更有效.

在rollup中,代码中未使用的代码会被自动清除,因为Rollup默认开启tree-shaking。

关于开发包时候的合理入口声明

由于当前历史原因:

  • nodejs v13之后才支持cjs和esm共存。
  • nodejs v12及之前,只支持cjs。

因此,我们的包必须同时支持被esmodule语法引用,也要支持给commonjs的调用方语法引用。

原理,最新的暴露方式,我们建议直接用nodejs最新推出的package.json里面写exports字段:

js
"exports":{ 
    "require": "./index.js"
    "import": "./esm/wrapper.js" 
}

然后我们package.json中尽量不要写type:module或type:commonjs。这样你的cjs和mjs的扩展名都可以用js,从而让webpack和rollup进行智能判断。

然而我认为咱们为了适配未来,且为了明确语义,每个类库开发者应该遵循声明 type:module的这种实践才好。这样可以更明确。即:

js
"type": "module",
"exports":{ 
    "require": "./dist/index.common.cjs"
    "import": "./dist/index.esm.js" 
}

其中require就表示调用方用require方式引用,咱们此时最好给他暴露一个commonjs的打包产物; 当import调用的时候它可能时nodejs原生环境也可能时打包web环境,反正它都是可以去import别的包,因此咱们就给他暴露 esm 版本即可(而且只需要暴露一个干净的没有helper和polyfill的版本即可)。

这样看下来,umd其实都没有任何必要在package.json里面暴露,它可能就是为cdn外链使用方式而生的。

技术实施:如何一次性打出多种产物

可以考虑按顺序依次执行 npm run build:umd && npm run build:esm 这样,也可以通过写一个build.js脚本,通过api方式调用rollup解决。

而我采用的是依然是使用rollup命令,但是package.json中创建多个子命令的方式。首先package.json这样:

js
  "scripts": {
    "build": "npm run build:umd && npm run build:esm && npm run build:cjs",
    "build:umd": "cross-env BUILD_FORMAT=umd rollup -c",
    "build:esm": "cross-env BUILD_FORMAT=esm rollup -c",
    "build:cjs": "cross-env BUILD_FORMAT=cjs rollup -c",
  },

然后,我们rollup.config.js里面则针对每次的 BUILD_FORMAT环境变量做处理:

js
import nodeResolve from "@rollup/plugin-node-resolve"
import commonjsResolve from "@rollup/plugin-commonjs"
import babelTransform from "@rollup/plugin-babel"
import { getFormatFileName } from "./build/config.js"

const buildDir = process.env.BUILD_DIR
const buildFormat = process.env.BUILD_FORMAT

const distDir = 'dist'

export default {
    input: 'src/main.js',
    output: {
        dir: distDir,
        format: buildFormat,
        name: 'fetTools',
        exports: 'named',
        globals: {
            vue: 'Vue'
        },
        entryFileNames: getFormatFileName(buildFormat)
    },
    external: ['vue'],
    plugins: [
        nodeResolve(),
        commonjsResolve(),
        babelTransform({
            // exclude: 'node_modules/**',
            babelHelpers: 'bundled'
        })
    ],
    preserveSymlinks: true,
}

后来我发现,其实本来就可以直接在一个rollup配置和构建过程中,自动打出多个output。只需要我们把output配置成数组就行就可以了。而且为了实现压缩和非压缩,我们还可以把rollup.config.js对外导出的时候也导出一个数组。这样就等于把一个rollup.config.js分成了2份配置。

然后在构建产物的命名方面,我们期望这样设计:

  • umd产物,由于浏览器cdn可能会引用。因此需要有个压缩版和非压缩版。即 fettools.umd.js和fettools.umd.min.js
  • esm产物, 虽然它一般是给nodejs和预编译环境用的。但由于我们可以面向现代浏览器提供以后个cdn版本的esm版本产物来用。所以也得弄一个压缩版和非压缩版。即fettools.esm.js和fettools.esm.min.js
  • commonjs产物。这个一般肯定就是nodejs环境用了,所以不需要压缩版。就直接fettools.common.cjs。(注意这里无比要使用cjs扩展名,因为我们打算在fet-tools的包内声明type:module,一旦声明了,当你在nodejs下引用的时候,就必须用cjs扩展名才能当作commonjs来解析)。

然后我们需要了解rollup的output里面的以下几个字段配置的作用:

js
      // 以下三个配置项都可以使用这些占位符:
      // 1. [name]: 文件名称,不包括".后缀名"
      // 2. [hash]: 根据文件名和文件内容生成的 hash 值
      // 3. [format]: 产物模块格式,如 es、cjs
      // 4. [extname]: 产物后缀名(带`.`)
      entryFileNames: '[name].js', // 入口文件
      // 拆分的块。例如使用动态导入,就会被单独打包成一个文件
      chunkFileNames: 'chunk-[name].js',	
      // 静态资源
      assetFileNames: 'assets/[name]-[hash][extname]',

接下来配置开搞:

js
// rollup.config.js
import nodeResolve from "@rollup/plugin-node-resolve"
import commonjsResolve from "@rollup/plugin-commonjs"
import babelTransform from "@rollup/plugin-babel"
import { getOutputConfigs } from "./build/config.js"
import { terser as terserPlugin } from 'rollup-plugin-terser'

const unCompressedConfig = {
    input: 'src/main.js',
    output: getOutputConfigs(['umd', 'cjs', 'esm'], false),
    external: ['vue'],
    plugins: [
        nodeResolve(),
        commonjsResolve(),
        babelTransform({
            // exclude: 'node_modules/**',
            babelHelpers: 'bundled'
        })
    ],
    preserveSymlinks: true,
}

const compressedConfig = {
    input: 'src/main.js',
    output: getOutputConfigs(['umd', 'esm'], true),
    external: ['vue'],
    plugins: [
        nodeResolve(),
        commonjsResolve(),
        babelTransform({
            // exclude: 'node_modules/**',
            babelHelpers: 'bundled'
        }),
        terserPlugin()
    ],
    preserveSymlinks: true,
}
console.log('看最终配置', unCompressedConfig, compressedConfig)
// 导出压缩版和非压缩版的配置
export default [unCompressedConfig, compressedConfig]

// script/config.js
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const pkgName = packageJson.name.replace(/-/g, '');

const buildDir = process.env.BUILD_DIR
const distDir = buildDir === 'examples' ? 'examples/cdn-style/' : 'dist'

export const getOutputConfigs = function (formatNames = [], isCompress) {
    return formatNames.map(formatName => {
        const suffix = isCompress ? '.min' : ''
        const extName = formatName === 'cjs' ? 'cjs' : 'js'
        const distFileFormatName = formatName === 'cjs' ? 'common' : formatName
        return {
            dir: distDir,
            format: formatName,
            name: pkgName,
            exports: 'named',
            globals: {
                vue: 'Vue'
            },
            entryFileNames: `${pkgName}.[hash].${distFileFormatName}${suffix}.${extName}`
        }
    })
}

然后 package.json里面:

json
{
  "type": "module",
  "scripts": {
    "build": "rollup -c"
  }
}

为了能准确把需要的入口暴露给外部,我们参考vue作者的最佳实践:

json
  "exports": {
    ".": {
      "import": {
        "node": "./dist/fettools.esm.js",
        "browser": "./dist/fettools.esm.js",
        "default": "./dist/fettools.esm.js"
      },
      "require": "./dist/fettools.common.cjs"
    }
  },

其实我顺便做了一下优化。因为vue它import.node这里用的是vue.mjs,而default(也就是构建打包场景)它用的是一个vue.esm.js。它那个所谓mjs文件里面就直接引用的commonjs版本产物,它其实是想让node环境就统一用nodejs那一份产物。但我认为既然现代nodejs已经支持esm了,那为啥不直接用esm呢。所以我就直接把esm.js作为node环境默认入口了。

ts接入

支持cdn方式引入 引入时有代码提示,有ts声明文件

本地开发可模拟安装调试

先说cdn版本的调试,这需要我们做一个watch命令,还要做一个cdn版本的测试页面。

js
    "watch": "rollup -c -w",
    "examples:cdn": "serve ./examples/cdn-style && cross-env BUILD_DIR=examples npm run watch",

如果你像上面直接写两个命令:``。他第一个命令运行后就卡住了,第二个命令无法运行。因为他们都是非退出式的命令。为了能让2个npm命令同时跑起来,我们得用npm-run-all插件。或者其他插件,参考:https://stackoverflow.com/questions/30950032/how-can-i-run-multiple-npm-scripts-in-parallel

于是最终的脚本如下:

json
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w",
    "serve": "serve ./examples/cdn-style",
    "examples:cdn": "cross-env BUILD_DIR=examples run-p watch serve",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

这样当我执行npm run examples:cdn的时候,他会同时运行watch和serve命令。然后我们打开serve命令给我们启动的服务器地址,就可以看到我提前examples下面准备好的index.html内容了,从而可以直接修改fettools代码实时查看到库内容改动的效果。

注意rollup配置中,我们尽量增加一个watch字段,只监听src目录下的代码变动。

另外本地构建和调试还可以配合:

  • delete插件。打包项目到目标路径时,大多数情况下,我们都需要先将目标目录进行清空。要实现这个功能,就需要用到rollup-plugin-delete插件。
  • 开发服务器插件。rollup-plugin-serve rollup-plugin-livereloa。 具体使用就参考:https://juejin.cn/post/7212235580103786557。 这里我暂且先不使用了。

同构怎么解决

例如一个包,我里面既能跑在浏览器环境下,又能跑在node环境下。

首先我们知道肯定是用 exports字段让他映射到不同入口上。

那问题来了:这俩入口是怎么编译出来的,你总不能给两边各自都写一套代码吧?

他肯定是 80% 的公共代码,唯独跟平台相关的那部分是稍微区别对待一下,可能是通过一个构建时的环境变量来区分的。

那么构建打包过程种,有什么技术能做到按条件调用不同的js模块来打包?

关于min压缩版代码

发布npm包

其他发布npm包实践

js
  "engines": {
    "node": ">=8.0.0"
  },
  "files": [
    "dist"
  ]

cdn方式使用

"unpkg": "dist/js.cookie.min.js", "jsdelivr": "dist/js.cookie.min.js",

https://www.unpkg.com/browse/fet-block@1.0.0/dist/fetBlockLogic@1.0.0.umd.js

漂亮的readme

这玩意叫做 github readme badage https://dev.to/kumareth/5-must-have-badges-to-add-in-your-readme-14c3

https://github.com/dwyl/repo-badges/blob/main/README.md

参考学习

https://juejin.cn/post/7331070736491642917https://juejin.cn/post/7363607004348923943https://juejin.cn/post/6844903495481425934 从 package.json 来聊聊如何管理一款优秀的 Npm 包 https://cloud.tencent.com/developer/article/2112048https://es6.ruanyifeng.com/#docs/module-loader#ES6-模块与-CommonJS-模块的差异