前言
我们知道,Node.js
是基于CommonJS
规范进行模块化管理的,模块化是面对复杂的业务场景不可或缺的工具,或许你经常使用它,但却从没有系统的了解过,所以今天我们来聊一聊Node.js
模块化你所需要知道的一些事儿,一探Node.js
模块化的面貌。
在Node.js
中,内置了两个模块来进行模块化管理,这两个模块也是两个我们非常熟悉的关键字:require
和module
。内置意味着我们可以在全局范围内使用这两个模块,而无需像其他模块一样,需要先引用再使用。
无需 require('require') or require('module')
在Node.js
中引用一个模块并不是什么难事儿,很简单:
const config = require('/path/to/file')
但实际上,这句简单的代码执行了一共五个步骤:
了解这五个步骤有助于我们了解Node.js
模块化的基本原理,也能让我们甄别一些陷阱,让我们简单概括下这五个步骤都做了什么:
.json
文件、.js
文件或者.node
文件。有些同学看完这五个步骤可能已经心知肚明,对这些原理轻车熟路,有些同学心中可能产生了更多疑惑,无论如何,接下来的内容会详细解析上述的执行步骤,希望能帮助大家答疑解惑 or 巩固知识、查缺补漏。
By the way,如果有需要,可以和我一样,构建一个实验目录,跟着Demo进行实验。
想要了解模块化,需要先直观地看看模块是什么。
我们知道在Node.js
中,文件即模块,刚刚提到了模块可以是.js
、.json
或者.node
文件,通过引用它们,可以获取工具函数、变量、配置等等,但是它的具体结构是怎样呢?在命令行中简单执行下面的命令就可以看到模块,也就是module
对象的结构:
~/learn-node $ node
> module
Module {
id: '<repl>',
exports: {},
parent: undefined,
filename: null,
loaded: false,
children: [],
paths: [ ... ] }
可以看到模块也就是一个普通对象,只不过结构中有几个特殊的属性值,需要我们一一去理解,有些属性,例如id
、parent
、filename
、children
甚至都无需解释,通过字面意思就可以理解。
后续的内容会帮助大家理解这些字段的意义和作用。
大致了解了什么是模块后,我们从第一个步骤Resolving
开始,了解模块化原理,也就是Node.js
如何寻找目标模块,并生成目标模块的绝对路径。
那么什么我们刚刚要先打印module
对象,先让大家了解module
的结构呢?因为这里有两个字段值id
、paths
和Resolving
这个步骤息息相关。一起来看看吧。
id
属性:每个module
都有id
属性,通常这个属性值是模块的完整路径,通过这个值Node.js
可以标识和定位模块的所在位置。但是在这儿并没有具体的模块,我们只是在命令行中输出了module
的结构,所以为默认的<repl>
值(repl
表示交互式解释器)。
paths
属性:这个paths
属性有什么作用呢?Node.js
允许我们用多种方式来引用模块,比如相对路径、绝对路径、预置路径(马上会解释),假设我们需要引用一个叫做find-me
的模块,require
如何帮助我们找到这个模块呢?
require('find-me')
我们先打印看看paths
中是什么内容:
~/learn-node $ node
> module.paths
[ '/Users/samer/learn-node/repl/node_modules',
'/Users/samer/learn-node/node_modules',
'/Users/samer/node_modules',
'/Users/node_modules',
'/node_modules',
'/Users/samer/.node_modules',
'/Users/samer/.node_libraries',
'/usr/local/Cellar/node/7.7.1/lib/node' ]
ok,其实就是一堆系统绝对路径,这些路径表示了所有目标模块可能出现的位置,并且它们是有序的,这意味着Node.js
会按序查找paths
中列出的所有路径,如果找到这个模块,就输出该模块的绝对路径供后续使用。
现在我们知道Node.js
会在这一堆目录中查找module
,尝试执行require('find-me')
来查找find-me
模块,由于我们并没有在任何目录放置find-me
模块,所以Node.js
在遍历所有目录之后并不能找到目标模块,因此报错Cannot find module 'find-me'
,这个错误大家也许经常看到:
~/learn-node $ node
> require('find-me')
Error: Cannot find module 'find-me'
at Function.Module._resolveFilename (module.js:470:15)
at Function.Module._load (module.js:418:25)
at Module.require (module.js:498:17)
at require (internal/module.js:20:19)
at repl:1:1
at ContextifyScript.Script.runInThisContext (vm.js:23:33)
at REPLServer.defaultEval (repl.js:336:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:533:10)
现在,可以尝试把需要引用的find-me
模块放在上述的任意一个目录下,在这里我们创建一个node_modules
目录,并创建find-me.js
文件,让Node.js
能够找到它:
~/learn-node $ mkdir node_modules
~/learn-node $ echo "console.log('I am not lost');" > node_modules/find-me.js
~/learn-node $ node
> require('find-me');
I am not lost
{}
>
手动创建了find-me.js
文件后,Node.js
果然找到了目标模块。当然,当Node.js
本地的node_modules
目录中找到了find-me
模块,就不会再去后续的目录中继续寻找了。
有Node.js
开发经验的同学会发现在引用模块时,不一定非得指定到准确的文件,也可以通过引用目录来完成对目标模块的引用,例如:
~/learn-node $ mkdir -p node_modules/find-me
~/learn-node $ echo "console.log('Found again.');" > node_modules/find-me/index.js
~/learn-node $ node
> require('find-me');
Found again.
{}
>
find-me
目录下的index.js
文件会被自动引入。
当然,这是有规则限制的,Node.js
之所以能够找到find-me
目录下的index.js
文件,是因为默认的模块引入规则是当具体的文件名缺失时寻找index.js
文件。我们也可以更改引入规则(通过修改package.json
),比如把index \-> main
:
~/learn-node $ echo "console.log('I rule');" > node_modules/find-me/main.js
~/learn-node $ echo '{ "name": "find-me-folder", "main": "main.js" }' > node_modules/find-me/package.json
~/learn-node $ node
> require('find-me');
I rule
{}
>
如果你只想要在项目中引入某个模块,而不想立即执行它,可以使用require.resolve
方法,它和require
方法功能相似,只是并不会执行被引入的模块方法:
> require.resolve('find-me');
'/Users/samer/learn-node/node_modules/find-me/start.js'
> require.resolve('not-there');
Error: Cannot find module 'not-there'
at Function.Module._resolveFilename (module.js:470:15)
at Function.resolve (internal/module.js:27:19)
at repl:1:9
at ContextifyScript.Script.runInThisContext (vm.js:23:33)
at REPLServer.defaultEval (repl.js:336:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:533:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:191:7)
>
可以看到,如果该模块被找到了,Node.js
会打印模块的完整路径,如果未找到,就报错。
了解了Node.js
是如何寻找模块之后,来看看Node.js
是如何加载模块的。
我们把模块间引用关系,表示为父子依赖关系。
简单创建一个lib/util.js
文件,添加一行console.log
语句,标识这是一个被引用的子模块。
~/learn-node $ mkdir lib
~/learn-node $ echo "console.log('In util');" > lib/util.js
在index.js
也输入一行console.log
语句,标识这是一个父模块,并引用刚刚创建的lib/util.js
作为子模块。
~/learn-node $ echo "require('./lib/util'); console.log('In index, parent', module);" > index.js
执行index.js
,看看它们间的依赖关系:
~/learn-node $ node index.js
In util
In index <ref *1> Module {
id: '.',
path: '/Users/samer/',
exports: {},
parent: null,
filename: '/Users/samer/index.js',
loaded: false,
children: [
Module {
id: '/Users/samer/lib/util.js',
path: '/Users/samer/lib',
exports: {},
parent: [Circular *1],
filename: '/Users/samer/lib/util.js',
loaded: true,
children: [],
paths: [Array]
}
],
paths: [...]
}
在这里我们关注与依赖关系相关的两个属性:children
和parent
。
在打印的结果中,children
字段包含了被引入的util.js
模块,这表明了util.js
是index.js
所依赖的子模块。
但仔细观察util.js
模块的parent
属性,发现这里出现了Circular
这个值,原因是当我们打印模块信息时,产生了循环的依赖关系,在子模块信息中打印父模块信息,又要在父模块信息中打印子模块信息,所以Node.js
简单地将它处理标记为Circular
。
为什么需要了解父子依赖关系呢?因为这关系到Node.js
是如何处理循环依赖关系的,后续会详细描述。
在看循环依赖关系的处理问题之前,我们需要先了解两个关键的概念:exports
和module.exports
。
exports
:exports
是一个特殊的对象,它在Node.js
中可以无需声明,作为全局变量直接使用。它实际上是module.exports
的引用,通过修改exports
可以达到修改module.exports
的目的。
exports
也是刚刚打印的module
结构中的一个属性值,但是刚刚打印出来的值都是空对象,因为我们并没有在文件中对它进行操作,现在我们可以尝试简单地为它赋值:
// 在lib/util.js的开头新增一行
exports.id = 'lib/util';
// 在index.js的开头新增一行
exports.id = 'index';
执行index.js
:
~/learn-node $ node index.js
In index Module {
id: '.',
exports: { id: 'index' },
loaded: false,
... }
In util Module {
id: '/Users/samer/learn-node/lib/util.js',
exports: { id: 'lib/util' },
parent:
Module {
id: '.',
exports: { id: 'index' },
loaded: false,
... },
loaded: false,
... }
可以看到刚刚添加的两个id
属性被成功添加到exports
对象中。我们也可以添加除id
以外的任意属性,就像操作普通对象一样,当然也可以把exports
变成一个function
,例如:
exports = function() {}
module.exports
:module.exports
对象其实就是我们最终通过require
所得到的东西。我们在编写一个模块时,最终给module.exports
赋什么值,其他人引用该模块时就能得到什么值。例如,结合刚刚对lib/util
的操作:
const util = require('./lib/util');
console.log('UTIL:', util);
// 输出结果
UTIL: { id: 'lib/util' }
由于我们刚刚通过exports
对象为module.exports
赋值{id: 'lib/util'}
,因此require
的结果就相应地发生了变化。
现在我们大致了解了exports
和module.exports
都是什么,但是有一个小细节需要注意,那就是Node.js
的模块加载是个同步的过程。
我们回过头来看看module
结构中的loaded
属性,这个属性标识这个模块是否被加载完成,通过这个属性就能简单验证Node.js
模块加载的同步性。
当模块被加载完成后,loaded
值应该为true
。但到目前为止每次我们打印module
时,它的状态都是false
,这其实正是因为在Node.js
中,模块的加载是同步的,当我们还未完成加载的动作(加载的动作包括对module
进行标记,包括标记loaded
属性),因此打印出的结果就是默认的loaded: false
。
我们用setImmediate
来帮助我们验证这个信息:
// In index.js
setImmediate(() => {
console.log('The index.js module object is now loaded!', module)
});
The index.js module object is now loaded! Module {
id: '.',
exports: [Function],
parent: null,
filename: '/Users/samer/learn-node/index.js',
loaded: true,
children:
[ Module {
id: '/Users/samer/learn-node/lib/util.js',
exports: [Object],
parent: [Circular],
filename: '/Users/samer/learn-node/lib/util.js',
loaded: true,
children: [],
paths: [Object] } ],
paths:
[ '/Users/samer/learn-node/node_modules',
'/Users/samer/node_modules',
'/Users/node_modules',
'/node_modules' ] }
ok,由于console.log
被后置到加载完成(打完标记)之后,因此现在加载状态变成了loaded: true
。这充分验证了Node.js
模块加载是一个同步过程。
了解了exports
、module.exports
以及模块加载的同步性后,来看看Node.js
是如何处理模块的循环依赖关系。
在上述内容中,我们了解到了模块之间是存在父子依赖关系的,那如果模块之间产生了循环的依赖关系,Node.js
会怎么处理呢?假设有两个模块,分别为module1.js
和modole2.js
,并且它们互相引用了对方,如下:
// lib/module1.js
exports.a = 1;
require('./module2'); // 在这儿引用
exports.b = 2;
exports.c = 3;
// lib/module2.js
const Module1 = require('./module1');
console.log('Module1 is partially loaded here', Module1); // 引用module1并打印它
尝试运行module1.js
,可以看到输出结果:
~/learn-node $ node lib/module1.js
Module1 is partially loaded here { a: 1 }
结果中只输出了{a: 1}
,而{b: 2, c: 3}
却不见了。仔细观察module1.js
,发现我们在module1.js
的中间位置添加了对module2.js
的引用,也就是exports.b = 2
和exports.c = 3
还未执行之前的位置。如果我们把这个位置称作发生循环依赖的位置,那么我们得到的结果就是在循环依赖发生前被导出的属性,这也是基于我们上述验证过的Node.js
的模块加载是同步过程的结论。
Node.js
就是这样简单地处理循环依赖。在加载模块的过程中,会逐步构建exports
对象,为exports
赋值。如果我们在模块被完全加载前就引用这个模块,那么我们只能得到部分的exports
对象属性。
.json
和.node
在Node.js
中,我们不仅能用require
来引用JavaScript
文件,还能用于引用JSON
或C++
插件(.json
和.node
文件)。我们甚至都不需要显式地声明对应的文件后缀。
在命令行中也可以看到require
所支持的文件类型:
~ % node
> require.extensions
[Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
}
当我们用require
引用一个模块,首先Node.js
会去匹配是否有.js
文件,如果没有找到,再去匹配.json
文件,如果还没找到,最后再尝试匹配.node
文件。但是通常情况下,为了避免混淆和引用意图不明,可以遵循在引用.json
或.node
文件时显式地指定后缀,引用.js
时省略后缀(可选,或都加上后缀)。
.json
文件:引用.json
文件很常用,例如一些项目中的静态配置,使用.json
文件来存储更便于管理,例如:
{
"host": "localhost",
"port": 8080
}
引用它或使用它都很简单:
const { host, port } = require('./config');
console.log(`Server will run at http://${host}:${port}`)
输出如下:
Server will run at http://localhost:8080
.node
文件:.node
文件是由C++
文件转化而来,官网提供了一个简单的由C++
实现的 hello插件 ,它暴露了一个hello()
方法,输出字符串world
。有需要的话,可以跳转链接做更多了解并进行实验。
我们可以通过node-gyp
来将.cc
文件编译和构建成.node
文件,过程也非常简单,只需要配置一个binding.gyp
文件即可。这里不详细阐述,只需要知道生成.node
文件后,就可以正常地引用该文件,并使用其中的方法。
例如,将hello()
转化生成addon.node
文件后,引用并使用它:
const addon = require('./addon');
console.log(addon.hello());
其实在上述内容中,我们阐述了在Node.js
中引用一个模块的前两个步骤Resolving
和Loading
,它们分别解决了模块的路径和加载的问题。接下来看看Wrapping
都做了什么。
Wrapping
就是包装,包装的对象就是所有我们在模块中写的代码。也就是我们引用模块时,其实经历了一层『透明』的包装。
要了解这个包装过程,首先要理解exports
和module.exports
之间的区别。
exports
是对module.exports
的引用,我们可以在模块中使用exports
来导出属性,但是不能直接替换它。例如:
exports.id = 42; // ok,此时exports指向module.exports,相当于修改了module.exports.
exports = { id: 42 }; // 无用,只是将它指向了{ id: 42 }对象而已,对module.exports不会产生实际改变.
module.exports = { id: 42 }; // ok,直接操作module.exports.
大家也许会有疑惑,为什么这个exports
对象似乎对每个模块来说都是一个全局对象,但是它又能够区分导出的对象是来自于哪个模块,这是怎么做到的。
在了解包装(Wrapping
)过程之前,来看一个小例子:
// In a.js
var value = 'global'
// In b.js
console.log(value) // 输出:global
// In c.js
console.log(value) // 输出:global
// In index.html
...
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>
当我们在a.js
脚本中定义一个值value
,这个值是全局可见的,后续引入的b.js
和c.js
都是可以访问该value
值。但是在Node.js
模块中却并不是这样,在一个模块中定义的变量具有私有作用域,在其它模块中无法直接访问。这个私有作用域如何产生的?
答案很简单,是因为在编译模块之前,Node.js
将模块中的内容包装在了一个function
中,通过函数作用域实现了私有作用域。
通过require('module').wrapper
可以打印出wrapper
属性:
~ $ node
> require('module').wrapper
[ '(function (exports, require, module, __filename, __dirname) { ',
'\n});' ]
>
Node.js
不会直接执行文件中的任何代码,但它会通过这个包装后的function
来执行代码,这让我们的每个模块都有了私有作用域,不会互相影响。
这个包装函数有五个参数:exports, require, module, __filename, __dirname
。我们可以通过arguments
参数直接访问和打印这些参数:
~/learn-node $ echo "console.log(arguments)" > index.js
~/learn-node $ node index.js
{ '0': {},
'1':
{ [Function: require]
resolve: [Function: resolve],
main:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/samer/index.js',
loaded: false,
children: [],
paths: [Object] },
extensions: { ... },
cache: { '/Users/samer/index.js': [Object] } },
'2':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/samer/index.js',
loaded: false,
children: [],
paths: [ ... ] },
'3': '/Users/samer/index.js',
'4': '/Users/samer' }
简单了解一下这几个参数,第一个参数exports
初始时为空(未赋值),第二、三个参数require
和module
是和我们引用的模块相关的实例,它们俩不是全局的。第四、五个参数__filename
和__dirname
分别表示了文件路径和目录。
整个包装后的函数所做的事儿约等于:
function (require, module, __filename, __dirname) {
let exports = module.exports;
// Your Code...
return module.exports;
}
总而言之,wrapping
就是将我们的模块作用域私有化,以module.exports
作为返回值将变量或方法暴露出来,以供使用。
缓存很容易理解,通过一个案例来看看吧:
echo 'console.log(`log something.`)' > index.js
// In node repl
> require('./index.js')
log something.
{}
> require('./index.js')
{}
>
可以看到,两次引用同一个模块,只打印了一次信息,这是因为第二次引用时取的是缓存,无需重新加载模块。
打印require.cache
可以看到当前的缓存信息:
> require.cache
[Object: null prototype] {
'/Users/samer/index.js': Module {
id: '/Users/samer/index.js',
path: '/Users/samer/',
exports: {},
parent: Module {
id: '<repl>',
path: '.',
exports: {},
parent: undefined,
filename: null,
loaded: false,
children: [Array],
paths: [Array]
},
filename: '/Users/samer/index.js',
loaded: true,
children: [],
paths: [
'/Users/samer/learn-node/repl/node_modules',
'/Users/samer/learn-node/node_modules',
'/Users/samer/node_modules',
'/Users/node_modules',
'/node_modules',
'/Users/samer/.node_modules',
'/Users/samer/.node_libraries',
'/usr/local/Cellar/node/7.7.1/lib/node'
]
}
}
可以看到刚刚引用的index.js
文件处于缓存当中,因此不会重新加载模块。当然我们也可以通过删除require.cache
来清空缓存内容,达到重新加载的目的,这里不再演示。
本文概述了使用Node.js
模块化时需要了解到的一些基本原理和常识,希望帮助大家对Node.js
模块化有更清晰的认识。但更深入的细节并未在本文中阐述,例如wrapper
函数内部的处理逻辑,CommonJS
的同步加载的问题、与ES
模块的区别等等。这些未提到的内容大家可以在本文以外做更多探索。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/ovLl8jw1xBa1TwPSVwJQ_Q
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。