Appearance
前面我们学习过 nodejs 的cjs/esm互操作和main入口读取规律。这里我们看看打包工具的。
之前nodejs的入口加载规律
- 先看是否有exports,有则以exports为准(然后从exports字段里从上往下看看,看看是否能匹配上当前的环境或加载语法,匹配则使用)
- 没有exports则以main字段为准。
- nodejs里没有打包的概念,因此它只有“寻找入口文件是谁”、“按esm还是cjs解析某个文件”、“解析后导出的是什么(按照esm和cjs互操作规则)”
打包工具的入口加载问题
打包工具,它相当于自己实现了一套模块加载机制。基于此,它其实要比nodejs原生的更灵活,当然也会更复杂。因为打包工具它其实是要把任何模块化类型的都最终和转换成一个最终的模块化方式:
- 例如你产物目标是esm,那么它就会把所有模块都转换成esm,然后导出esm。
- 例如你产物目标是cjs,那么它就会把所有模块都转换成cjs,然后导出cjs。
无论你各个源码模块到底是啥格式,反正都是要经过rollup之类的先处理(而且是从代码转换层面,并不是从运行时层面)成其他模块化方式,那么它就能做到一些nodejs原生互操作和入口加载完全做不到的事情,
例如入口加载方面:
- 可以让dep依赖包支持配置 browser和node字段。从而根据你rollup打包目标环境自动选择入口文件。这对于那种编写“同构包”的场景是万分有用的,例如axios。而Nodejs原生则只能根据import还是require区分一下加载esm场景和cjs场景而已(当然node后来也可以区分node和node-addon环境)。
- 无论你依赖包里的main/module/exports配置的是啥扩展名(因为当前步骤仅仅是为了找到目标入口文件而已)。rollup都不care,他自己会匹配到入口文件后,直接看入口里的代码到底是用啥方式导出的从而确定是esm还是cjs,并按需转译到产物里。(注意:这里也有特例:例如你package.json里面type=commonjs,那么意味着只要不是js扩展名的rollup就会当成esm,此时如果你app.xxjs这样的文件里写一个esm导出就没问题,但是写module.exports就会报错。所以假如你这种情况下,你要写cjs语法就要把app.xxjs扩展名写清楚写成app.cjs。因此总体上还是建议按照node的type=module这种官方用法来使用扩展名哈)
而在esm和cjs互操作方面:
- 他能解决调原生nodejs互操作的时候,cjs根本无法同步加载一个esm模块的问题(因为只能用import函数语法加载)。 而rollup他直接把esm打包转换成产物里的模块化方式,在产物内就会放上依赖包的代码,然后适当处理后被调用方直接同步引用。(不过rollup下cjs调用esm也会有问题,例如esm有默认导出和具名导出,但调用方require后是把具名导出和默认导出全放到一个对象上了。有时候默认导出是个函数的话,倒是可以让require的结果直接是那个函数,但如果是数值的话rollup就没法搞了,只能把{x:1, default: 3} 一起返回给调用方)。
关于rollup里面的browser
回顾nodejs:
- package.json里面他只识别main字段。
- 后来出现exports字段后,他支持exports里面声明 node、import、require、default。而且要记得这个顺序很重要,一旦匹配就使用。
再看rollup(即https://www.npmjs.com/package/@rollup/plugin-node-resolve插件):
- 依旧跟node一样,优先看exports字段,没有则看package.json第一层的字段。
- exports字段里面默认是支持 [default, module, import] (即插件的exportsConditons字段)。 所谓module应该其实就是import(即调用方用ems模块加载方式)。
- 如果开启了brower: true。 则exportsConditions这里就会变成 [browser, default, module, import]。 然后会去依赖包里的exports字段上从上往下依次找看看谁能匹配,例如忽然在前面发现一个browser的且那个文件存在,则立刻就命中该文件。
- 如果package.json里面没有exports字段。则看第一层的main/module等字段。默认插件的mainFilelds支持 [main, module]。 即各个打包工具经典的esm和cjs配置入口。当调用方用esm引用的时候,那么就会找到module入口。否则找main入口。
- 如果rollup配置中开启browser:true(即当前打包环境是web),则依赖包的package.json第一层若出现browser配置,则优先会读browser的入口。
总结:还是建议用更先进的exports字段吧。至于webpack等细节,参考:
打包工具的互操作规律
打包工具其实也遵循之前学习的nodejs的加载规律。即他们也会去识别main,另外他们打包工具还自己研究出来了module/browser这俩新的字段。分别代表当打包工具配置成“某个目标环境”的时候,则去找对应环境的入口。
对于rollup来说,要想加载node_modules下面的东西,需要用plugin-rosolve插件。安装后rollup的 plugin-rosolve插件里面就有个 browser字段,默认不配置的时候,它就是按照module/main方式去找。如果配置成true,他就去读依赖包里面package.json里面browser字段指定的那个入口(这对于同构包做node和前端区分非常有用)。
由于最新标准都建议用package.json里面的exports字段了,那我就用exports字段来讲解:
js
"exports": {
".": {
"node": {
"import": "./final/nodeesm/app.js",
"require": "./final/nodecjs/app.js"
},
"browser": {
"import": "./final/browseresm/app.js",
"require": "./final/browsercjs/app.js"
},
"default": {
"import": "./final/nodeesm/app.js",
"require": "./final/nodecjs/app.js"
}
}
},
"type": "module"
理论上,你的type是module的情况下,应该让cjs那种文件扩展名写成 cjs
。 不过还好,rollup并不care这个(即type=module仅仅是对nodejs程序有用,对打包没啥意义),他rollup是这样操作的:
- 先看看调用方rollup配置的brower字段是否true。true的话就去找依赖包里的browser字段。
- 再看看你调用方是用import还是用require来调用依赖包,若是import那就找browser字段下面的import字段所指向的文件。
- 找到这个最终 final/browseresm/app.js,之后,他也不管你文件是啥扩展名。他就去你文件里面看文件内容分析这个文件是esm还是cjs,然后按需转成调用方需要的代码。假设这个final/browsersesm/app.js依赖文件是cjs写的,也没关系,那么你此时rollup配置里面就得加上 plugin-commonjs插件,他就能识别出这是个cjs模块。否则他构建时会报错:无法找到导出内容。
- 假设你的调用方项目是用cjs语法写的,那么他去依赖包里面寻找时,就是找 browser下 require字段下的 Final/browsercjs/app.js。这种cjs调用cjs/esm的情况,无论怎样你都得搞个 plugin-commonjs插件,否则他构建产物里就根本不会把依赖包打进去(就只会在产物中放一个require('dep')写法,将他作为一个external算是)。
- 假设依赖包的package.json根本没有上述exports字段。那么他就会先看看调用方用的啥语法,如果esm导入,则就找module。但是可能module字段根本没有写,那么他就转而去找main字段。若main字段也没写,他就找默认文件index.js。
由于我们使用rollup一般都是esm调用依赖包,所以我们主要关注上述第1、2、3步。
同构模块
例如axios这样的同构模块,你会发现他们是在dist里面构建了cjs版本和浏览器的cjs版本。然后开始利用上文提到的exports字段往外暴露。
我们可以参考腾讯云这个:https://cloud.tencent.com/developer/article/2112048
利用browser寻找模块文件来给不同编译环境直接换掉其文件,从而达到同构但产物构建过程中某些文件又能分别差异化处理的效果。
js
{
"name": "qingfeng",
"version": "1.0.0",
"description": "",
"main": "index.js",
"browser": {
"./src/server.js": "./src/client.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
建议的exports用法:
json
{
"exports": {
".": {
"import": "./index.esm.js",
"import": "./index.cjs.js"
},
"./adapter.js": {
"browser": "./adapter-browser.js",
"default": "./adapter-node.js"
}
}
}
其中adapter你需要实现2版本。
对外暴露的时候,你深圳可以暴露调试版本(这要求你构建的时候,额外构建一份xxx.debug.js版本,或者说就直接暴露min.js和非min.js,其中非min的就当作调试版本。):
"development"- 可用于定义仅开发环境入口点,例如提供额外的调试上下文。 "production"- 可用于定义生产环境入口点。必须始终与 互斥"development"。
关于webpack
其规律也是类似的:
js
const path = require('path');
module.exports = {
entry: './src/index.js',
devtool: false,
target: 'node',
mode: 'development',
resolve: {
// mainFields 在 Npm 包中存在 exports 的情况下完全无用
mainFields: ['main'],
// 即使设置了 target: node 环境,但是由于设置了 conditionNames: ['browser', 'import']
// 仍然会去 browser 的 import 中去寻找对应的入口文件
conditionNames: ['browser', 'import'],
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
},
};
即target就类似于rollup的browser是true还是false之类的,仅仅用于决定mainFields默认值,以及conditionNames默认值。
webpack的默认值规律(官方文档有写):
- 如果target没有制定,或者设置的web/webworker。则mainFields是 [browser, module, main]。估计conditionNames也一样是多了browser,即变成 [browser, module, node, require, import]。