bookmark_border[原创]Unity面试经验分享

面试的这一个月时间,拿了几家大厂的offer(包含自己最心仪的公司),即将去新的工作岗位,所以总结一下这一个月的面经,希望对读到这篇文章的你,有些许帮助

letcode 肯定要刷一刷,这属于送分题。你不刷,到时候问你就只能挠头,刷完以后,你再临场发挥就有思路了。哪怕面试官问的题,你没刷到,你也不至于犯憷。最起码,如果是数组那么左右指针试试?

刷题也有技巧,不是照着 letcode 来一遍,力扣探索页 就有很多大厂高频算题,你可以把微软、腾讯、字节、还有初级中级的题都刷刷;30分钟以内想不出来,建议直接找答案(我是用 js 刷的,vscode + js 简直不要太爽)

下面就是一些我被问到,我觉得可以跟大家分享的问题,具体是哪个厂的面试官问题,就不方便说了(以下答案,是我自己的理解,如有错误还请告知,以免误人)

问:A*算法了解吗?大概说说

A*是比较经典的寻路算法,它的重点就是启发函数:总代价 = 当前点到起点的代价+ 当前点到终点的代价(代价可以是距离或者权重等)。算法的核心是两个列表,开启列表和关闭列表,未走过的节点放入开启列表,已走过的节点放入关闭列表。每个节点的核心元素:当前点的总代价,当前点的父节点

问:C# 字典的具体实现讲讲?跟哈希表有什么区别?

字典的实现,主要是两个数组;桶数组以及对象数组。对象是个结构体(hashcode,同哈希值的下一个对象的下标索引,key,value);如果 new 字典的时候传入了字典大小,那么会初始化,否则要等到添加对象的时候初始化

ADD:有个 freeCount和freeList来标记,当前对象数组,是否有空位置。如果有,优先放入空位置。然后判断哈希值,如果对应的桶内已经有值,那么头插法;桶的索引值变成当前新添加对象所在对象数组的索引,而它的next就是桶原来的索引值

REMOVE:最巧妙的地方,删除以后,该对象的next值等于freeList,然后新的freeList等于这个被删的对象的索引,这样的话,根据freeList就能找到所有被删以后的空位置

扩展:注意,字典没有容量因子的说法,一定要对象数组满了才会扩容。而且它扩容是当前长度*2,然后选中大于这个值的素数,之所以用素数,是减少哈希冲突。当哈希冲突次数超过100以后,才会执行重哈希

问:谈谈 Unity 的 GC

标记阶段:收集器从根对象开始进行遍历,对从根对象 ( 正在运行的局部变量、静态变量、重写 Finalize 方法的变量、正在调用的函数传递的参数 ) 可以访问到的对象的同步索引块标标记为可达

清除阶段:收集器会对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记则将其回收(回收之前,会先执行它的 Finalize() 方法,如果重写了话)

缺点:容易造成内存碎片化

2019.3增加了增量GC优点:避免出现GC时CPU峰值,导致掉帧或者卡顿 ; 缺点:1. 因为是分帧执行遍历,所以得采用写屏障(可能导致bug),2. GC总时常变长 

CG触发:1.向托管堆申请连续内存块,但是托管堆中的连续内存块大小不足2.GC 自动触发3.手动调用GC

CG优化: Cache变量,使用对象池,闭包,匿名函数和协程会有托管堆内存分配,配置表拆分,字符串,手动调用GC

问: Editor 和真机环境下,profiler 看内存是否有差异?为什么?

有差异,一般有以下原因:

  • 一些引擎API在Editor和真机环境下,实现原理不一致。比如加载ab.LoadFromFile, Editor下会加载整个AB,而真机只加载AB头部;GetComponent在Editor会造成GC Alloc, 真机不会
  • 我们自己写的或者第三方插件代码实现不一致
  • 编辑器可能缓存了某些资源(比如你选中某个纹理,该纹理会一直在内存中)

问:谈谈资源卸载?

非托管资源,手动卸载;托管资源,清空引用

  • 1. 文件,网络套接字,数据库连接等采用Dispose + Finalize() 释放 ;
  • 2. 资源(纹理,网格, ab中LoadAsset() 等) 用Resources.UnloadAsset(obj) 来卸载
  • 3. AssetBundle内存 (非托管内存 ),如果有指向该资源的变量( 假设名为:ab),则可使用  ab.Unload ( false/true ) 来卸载
  • 4. 最后调用Resources.UnloadUnusedAssets()来释放(此方法非常耗时)

问为什么说 MOBA 要用帧同步? 帧同步用什么网络协议?以及为什么?

moba类游戏小兵多玩家少且固定;对实时性、流畅性 、公平性要求较高。用帧同步的优势是回放制作方便,流量消耗少,制作离线战斗方便 ( 新手战斗 ),而难度则如下:

  • 1. 保证一致性
  • 2. 开发难度比较高,经常会有以主角为主类的想法。也有可能会直接拿玩家的位置等属性作为参数等的情况 ( 一定要注意逻辑代码和表现分离 )
  • 3. 流畅性(性能,网络)

一般来说,都会采用UDP协议,因为帧同步方案,会有高频次的上报和下发逻辑帧信息,所以对网络要求很高 而它的优点协议简单,包头小。缺点是无序,不可靠,所以需要额外信息来保证传输数据的可靠性。因此,我们需要在传输的数据上增加一个包头。用于确保数据的可靠、有序主要使用两种策略来决定是否需要重传数据包 : 超时重传、快速重传

超时重传:发送数据包在一定的时间内没有收到相应的ACK,等待一定的时间,就会重新发送。这个等待时间被称为RTO,即重传超时时间

快速重传:明确丢了哪个包,那么不用等超时,直接快速重传

问:帧同步有遇到过不同步的问题吗?怎么解决?

原则就是:就是保证客户端的输入一致,计算一致

  1. 主要的点就是随机数(服务器发随机种子)和浮点数运算(采用定点数)
  2. 战斗逻辑不要使用一些排序不固定的容器,比如:Dictionary
  3. 逻辑层不要使用协程(协程是根据Unity的Update去驱动,而不是我们自己的帧逻辑)
  4. 第三方插件的某些实现导致不同步(比如:Update不受帧控制,导致某些计算两个客户端不是在同一个逻辑帧,就会导致异常)
  5. 逻辑层和表现层一定不能交叉调用,表现层根据Update去轮询逻辑层的帧

问:说说深度缓冲(z-buffer)与w缓冲(w-buffer)

z-buffer 保存的是经过投影变换后的 z 坐标,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小。所以投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错

w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体

问:什么是法线贴图,缺点是什么?为什么法线贴图一般都是偏蓝色?

法线贴图是简单的视觉欺骗,一旦凹凸太明显的模型,使用了法线贴图,太靠近的时候,就穿帮了。法线贴图主要适用于凹凸不太明显,细节很多,需要表现实时光照效果,不会太靠近观察的物体的情况

生成法线贴图,一般都是采取纹理的灰度图。根据两个像素间的灰度差,形成U,V两个向量,然后两个向量的叉积就是法线的方向。在切线坐标系里,定义顺序是Tangent、Binormal、Normal。也就是说,Normal处于z这个方向。而对于一个三角形而言,绝大多数时候,法线值都是垂直于这个面的。显而易见,法线贴图的法线值大多数时候是接近于(0,0,1)的,当然是接近于蓝色了

问:为什么 png,jpg 不能直接用,而要采用etc,astc格式?

jpg , png 是针对硬盘的压缩格式,使用的时候需要CPU解压。最大的问题他们都是基于整张图片的压缩,像素与像素之间在解码过程中存在依赖关系,无法实现单个像素级别的解析;而且,png,jpg 解码以后都是 RGBA 的纹理格式,无法减少显存的占用率。而 etc 和 astc 这种是针对图形接口而设计的压缩格式,不需要 CPU 解压而 GPU 可以直接采样

问:AOP , IOC 懂吗?简单说说

 AOP:与传统OOP对比,面向切面,传统的OOP开发中代码逻辑是自上而下的,在自上而下的过程中会产生一些横切性的问题,这些横切性的问题和我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,AOP的思想就是把业务逻辑和横切的问题进行分离,从而达到解耦的目的,是代码的重用性和开发效率提高

IOC :主要是针对接口,抽象编程,而不是具体实现。要做到绝对的依赖注入,就要配置文件,使用反射,针对接口,外部dll实现功能(这一块,可以去B站找找相关的视频,那个老师废话很多,但是得耐心看下去)

问:UGUI  优化

Canvas负责将其子节点的UI元素的网格合并,并生成相应的渲染命令发送到Unity的图形管道,当UI发生了变化,它就要执行一次Batch给GPU进行渲染。Canvas只影响其子节点,但是不会影响子Canvas

重建:

  1. 重新计算一个Layout组件子节点的适当位置,或者可能的大小
  2. Graphic发生了变化,比如:大小,旋转,文字的变化,图片的修改等,都会引起Rebuild

优化:

  1. 动静分离
  2. 少对Canvas增加、删除、显示、隐藏;少对Canvas进行颜色,材质,纹理等的改变
  3. 预加载UI模块
  4. 不直接关闭,设置UI界面为其他的layer
  5. 不需要进行事件接收的组件,取消勾选Raycaster Target
  6. 不适用富文本的Text,取消勾选Rich Text,不使用Best Fit
  7. 用TextMashPro 代替OutLine,shadow组件

问:讲讲UGUI的Draw Call

UGUI 主要是根据 depth 来判断 Draw Call 数量(还有纹理,材质,shader等)

  1. 按照 Hierarchy 节点顺序,从上往下进行深度分析(深度优先)
  2. 且没有任何其他渲染元素与它相交深度 = 0
  3. 有其他元素跟它相交,则找到相交渲染元素的最大深度的渲染元素,判断是否能够与它合批,如果可以,则它等于最大深度,否则深度= 最大深度+ 1
  4. 按照 Hierarchy 顺序来的对深度进行排序,然后根据材质的 Instance ID,纹理的 Instance ID 排序

优化点:

  1. 一个Canvas组建下的元素才会合批,不同Canvas不会合批
  2. 有时候会为了合并层级,我们需要给Text垫高层级
  3. 一个节点的RectTransform的值不会影响合批,Image和rawImage如果引用了相同的texture,可以合批

问: Lua GC 的垃圾回收机制?

lua使用的是经典的标记清扫算法;Lua所有类型的对象都统一为Tvalue;所有动态分配的对象串连成一个链表(或多个);Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构

Lua的垃圾回收周期共分为四个阶段:标记、整理、清扫、收尾
标记阶段:Lua会首先将根集合中的对象标记为活跃,然后将可以通过根节点访问到的对象也标记为活跃
整理阶段:Lua会遍历所有的userdata,找出未被标记且有__gc元方法的userdata,将它们标记为活跃,并放入单独的列表中。再根据所有的弱引用table,删除那些未被标记为活跃的key或者value
清扫阶段:Lua遍历所有对象,如果当前对象未被标记,就收集它,否则清除它的标记
收尾阶段:根据上面生成的userdata列表来调用终结函数(类似C#的析构函数)

问:Lua 如何实现协程?

线程:抢占式多任务机制,是一个相对独立的、可调度的执行单元,是系统独立调度和分配CPU的基本单位。它由操作系统来决定执行哪个任务,在运行过程中需要调度,休眠挂起,上下文切换等系统开销,而且最关键还要使用同步机制保证多线程的运行结果正确

协程:协作式多任务机制,协程之间通过函数调用来完成一个既定的任务。它由程序自己决定执行哪个任务,只涉及到控制权的交换(通过resume-yield),同一时刻只有一个协程在运行,而且无法外部停止。通俗来说,协程就是可以用同步的方式,写出异步的代码

协程(Coroutine)拥有4种状态:

  • 运行(running)如果在协程的函数中调用status,传入协程自身的句柄,那么执行到这里的时候才会返回运行状态
  • 挂起(suspended)调用了yeild或还没开始运行,那么就是挂起状态
  • 正常(normal)如果协程A重启协程B时,协程A处于的状态为正常状态
  • 停止(dead)如果一个协程发生错误结束,或正常终止。那么就处于死亡状态(不可以再重启)

Lua的协程是一种非对称式协程,又或叫半协程,因为它提供了两种传递程序控制权的操作:1. 重启调用协程,通过coroutine.resume实现;2. 挂起协程并将程序控制权返回给协程的调用者,即通过coroutine.yield实现。对称式协程,只有一种传递程序控制权的操作,即将控制权直接传递给指定的协程

协程(Coroutine)具有两个非常重要的特性:1. 私有数据在协程间断式运行期间一直有效;2. 协程每次yield后让出控制权,下次被resume后从停止点开始继续执行

问:lua中的闭包懂吗?说说看

闭包主要由2个元素组成;1. 函数原型:一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令 2.上下文环境:在Lua里主要是Upvalues和env

  1. Upvalues是在函数闭包生成的时候(运行到function时)绑定的
  2. Upvalues在闭包还没关闭前(即函数返回前),是对栈的引用,这样做的目的是可以在函数里修改对应的值从而修改Upvalues的值
  3. 闭包关闭后(即函数退出后),Upvalues不再是指针,而是值

问:类(class)和结构(struct)的区别是什么?它们对性能有影响吗?在自定义类型时,您如何选择是类还是结构?

Class是引用类型,Struct是值类型。Struct不可以被继承,但是可以Override它基类的方法;也可以实现接口。比如:如果想用Struct当作字典的key,但是又想避免有装箱和拆箱等操作,则可以实现接口IEquatable的Equals方法,则可以避免装箱和拆箱操作,以下是一些详细的区别

  1. 值类型对象未装箱和已装箱两种形式,而引用类型总是处于已装箱形式
  2. 值类型从System.ValueType派生,重写了Equals和GetHashCode,由于默认的实现存在性能问题,所以在自定义值类型的时候,如果有用到,应重写这两个方法
  3. 自定义的值类型不应该有虚方法
  4. 引用类型的变量的值是堆上的一个对象的地址,值类型包含的是值
  5. 引用类型赋值是复制内存地址,而值类型赋值则是逐一字段复制
  6. 未装箱的值类型,不在堆上分配内存。而引用类型在堆上分配内存
  7. 因为未装箱的值类型,没有同步索引块,所以不支持多线程同步
  8. 虽然未装箱的值类型,没有类型对象指针,但是仍可以调用由类型继承或重写的虚方法(比如:Equals,GetHashCode或ToString),但是请注意:调用这些方法可能会产生装箱。具体如下:
  9. 如果结构体重写了虚方法,并且虚方法内没有内部调用父类的方法,那么结构体实例调用这个重写的虚方法就不会装箱
  10. 如果调用父类的非虚方法,那么一定会产生装箱,一定得需要一个类型对象指针,以定位父类类型的方法表才能执行该方法

以下情况适合值类型:

  1. 类型具有基元类型的行为,意思就是结构十分简单,属性没有可变类型
  2. 类型不会派生子类,也不从其他类继承
  3. 类型的实例较小 ( 因为值作为参数传递或者作为方法返回值的时候,会拷贝整个结构,对性能有损害,所以类型的实例大小是个重要考虑指标 )

关于值类型的装箱问题,发生装箱操作时,在内部发生的事情如下:

  1. 在托管堆中分配好内存,分配的内存量是值类型各个字段需要的内存量加上两个额外(类型对象指针和同步索引块)需要的内存量
  2. 将值类型的字段复制到新分配的内存中
  3. 返回对象的内存地址,现在值对象变成了引用类型

问:泛型的作用是什么?它有什么优势?它对性能有影响吗?

面向对象的好处是代码重用,对一个类来说,可以继承基类的所有能力,同时可以重写虚方法,或者添加一些新方法,就可以定制该类的行为。而泛型,则是支持另外一种形式的代码重用,即”算法重用”

泛型具有如下优势:1. 源代码保护  2. 类型安全(在编译期就能过滤不符合的类型数据)3. 更佳的性能 ( CLR不再需要执行装箱操作;也不需要类型转换,这对于提高代码运行速度很有帮助 )

问:抽象类和接口有什么区别?使用时有什么需要注意的吗?如何选择是定义一个抽象类,还是接口?什么是接口的“显式实现”?

接口:描述的是Can-do,是一类行为,而不是针对具体某个类。比如: IDispose,IEnumerable等都是描述一类行为

抽象类:为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那就没有任何意义。抽象类不可用来实例化

拿个具体的事例,比如说动物Animal ,这是一个高度抽象的概念。说到动物,有些人想到的是狗,有些人想到的是猫。所以Animal这个类没办法具体实例化出来,应该设计为抽象类,不继承它就没有意义。那有些动物会飞,有些动物会游,这表示有些动物具有某些行为,应该设计成接口,比如:Ifly, Iswim等

在考虑是使用抽象类还是接口的时候,还要考虑易用性和后期版本控制。因为如果要修改接口(比如,添加新的方法),那么实现了该接口的所有类都必须实现这个新方法。所以改动很大 接口的显示实现,是指继承的接口的方法 与类本身的方法重名而采取的措施,就是将定义方法的那个接口的名称作为方法名的前缀(例如:IDispose.Dispose)。注意,在C#中定义一个显示接口方法时,不可以指定可访问性(比如:public或privtae),也不能标记为virtual,所以它不能被重写。这是因为显示实现的方法并非是该类类型的对象模型的一部分,它是将一个接口连接到一个类型上

问:谈谈 Unity 中的协程

协程其实是分帧执行的类似语法糖。协程方法,在编译以后,会生成一个对应名字的类,继承了IEnumerator<object>, IEnumerator, Idisposable 这三个接口。最主要实现了 MoveNext() 方法,Current 属性。而实现原理则是在MonoBehaviour 内的Update和LateUpdate方法之间,根据Current的值是否调用该类的MoveNext()方法

当MonoBehaviour被摧毁以后,协程也会被终止。但是enabled =false,协程会继续执行

协程的使用规范:需要的时候使用,用完就销毁。不要用对象池保存,因为协程会引用MonoBehaviour类(包括变量,方法等),所以如果不销毁,这些引用会一直存在,从而导致无法被GC。有点类似闭包,以及委托

问:说说关于 Unity 内存,显存,CPU 的优化

这一部分太庞大了,如有有需要可以邮件我,我会把整理过的文档发给你们。邮箱地址:lingzelove2008@163.com

bookmark_border[原创]C#中Dispose和 Finalize

要讨论这两个方法,首先要知道C#的 GC,因为有了 GC,所以开发者才能专注于应用程序的功能;然而 GC 也有局限性,它只能释放托管内存中的对象;像文件句柄,网络套接字,数据库链接这种非托管资源,GC 就无法帮你释放了。如果不释放这些资源,就会导致应用程序一直引用它们,而其他应用程序就无法对它们进行操作(比如:当你打开Word 文件,然后删除它的时候就会弹出:操作无法完成,因为文件已经在 Microsoft Office Word中打开)

C# 内的 Dispose() 和 Finalize() 方法,就是释放对象中非托管资源的方法。如果你想在自定义的类里面实现 Dispose() 方法,那么就得引用 IDisposable 接口;你可能见过使用 using 的代码,它其实就是try/finally格式的语法糖,在finally内会主动调用 Dispose() 方法,所以当你遇到非托管资源的操作,using是个很安全的做法。当然,你也可以在使用完该对象以后,主动调用它的 Dispose() 方法

using(FileStream file = new FileStream("path", FileMode.Open, FileAccess.Read))   
{  
    //Do something with file   
}  

finailze() 是 object 类的内置方法;注意:你查看 object 类源码的时候,并不能查找到 finalize 方法,它是析构方法” ~Object() “编译产生的。如下图,析构方法 ~FinalizeTest() 编译成IL代码以后,就变成了 Finalize():

源码和IL代码

那么问题来了,既然有 Dispose() 方法,为什么还需要 Finalize() 方法呢? 因为有时候持有非托管资源的变量可能被用在很多地方,我们无法显示判断在何时调用 Dispose() 方法释放该资源。只有在 GC 的时候,我们判断引用该非托管资源的变量已经不被任何其他对象引用,这时候才是合适的释放时机。然而GC过程是内部实现的,我们无法在 GC 过程中手动调用 Dispose() 方法,所以 Finalize() 方法的用处就来了,GC 会自动调用 Finalize() 方法,我们只要把释放非托管内存的代码写在 Finalize() 方法中,就能释放非托管内存了

那么 Finalize() 是怎么被调用的呢? 当程序运行以后,GC会收集实现了 Finalize() 方法的对象,并把他们放入一个队列。当某个对象可以释放内存的时候:GC会把它所持有的非托管内存释放,并从队列中移除;但请注意,该对象在托管堆中并未释放。只有在下一次 GC 的时候,才会从托管堆中释放该对象

至此,我们已经了解了 Dispose() 和 Finalize() 方法,那是不是可以让他们两完美组合呢?既可以让用户手动调用,也可以做个托底,让GC自动调用。肯定可以的,代码如下:

using System;
public class FinailzeTest : IDisposable
{
    //GC自动调用
    ~FinailzeTest() { Dispose(false); }

    //用户手动调用
    public void Dispose() { Dispose(true); }

    protected virtual void Dispose(bool disposing)
    {
        //释放托管资源 
        if (disposing) { managedObject = null; }
        //释放非托管资源
        unManagedObjectDispose();
        //告诉GC, 把该类对象从列表中移除, 到时候不再执行Finalize()方法
        if (disposing)
        {
            GC.SuppressFinalize(this);
        }
    }
}

如果用户没有主动调用 Dispose() ,未能及时释放托管和非托管资源,那么在垃圾回收时,会执行Finalize(),释放非托管资源(注意:非托管资源并未被释放)
;如果用户主动调用了 Dispose(),就能及时释放了托管和非托管资源,而且垃圾回收的时候不会执行 Finalize() 方法,提高了性能

好了,关于 Dispose() 和 Finalize() 方法,就写到这,以后有新发现,再更新!

bookmark_border[原创]何为利率市场化?

何为利率

说到利率,首先就要清楚两个概念:货币的时间价值和利息。现实生活中,一般来说现在的100块钱比一年后的100块钱更有价值,因为可以把现在拥有的100块钱存进银行,一年后从银行取出来的货币总额将大于100块钱。两者的差额就是通常所说的利息。对于这种现象,金融理论用”货币的时间价值”进行概括。所谓货币的时间价值(Time Value of Money),就是指同等金额的货币其现在的价值要大于其未来的价值,利息就是货币时间价值的体现

为什么货币具有时间价值? 西方经济学的解释:人类本性中,存在着不耐心等待而现在就想要消费的欲望。如果货币的所有者将其持有的货币进行投资(存银行也是一种投资)或者借给他人,他就必须牺牲当前的消费,对此,他会要求对其当前消费的推迟给予一定的补偿,补偿金额的多少与当前消费推迟的时间长短同向变动。因此货币的时间价值来源于对当前消费推迟的时间补偿

我们知道利息是借贷关系中资金借入方支付给资金贷出方的报酬。由于本金数量会对利息总额产生重要影响,这使得我们无法通过比较利息额来衡量货币时间价值的高低,因此,我们要引入一个新的概念来具体衡量货币时间价值——利率。它是指借贷期满的利息总额与贷出本金总额的比例。按照大白话来理解利率:就是借钱贵不贵,利率高,借钱的代价就高,利率低,借钱的代价就低

知道了利率我们就可以开始讨论各种名头的利率了,最重要的一个当属国之重器:基准利率(Benchmark Interest Rate),是在多种利率并存的条件下起决定作用的利率,其他利率会随其变动而发生相应的变化。我国的基准利率是:央行对商业银行及其他金融机构的存贷款利率,又称法定利率。可能你还听过上海银行间同业拆借利率(SHIBOR),它就是我国货币市场的基准利率,后面会多次谈到它

利率管制

为什么要搞利率市场化? 要回答这个问题,得先搞清楚两个问题:1. 什么是利率市场化? 2. 利率以前为什么没有市场化?

利率市场化是指金融机构在货币市场经营融资的利率由市场供求来决定。它包括利率决定、利率传导、利率结构和利率管理的市场化。即是货币当局将利率的决定权交给市场,由市场主体自主决定利率,货币当局则通过运用货币政策工具,间接影响和决定市场利率,以达到货币政策目标

利率之前为什么没有市场化呢?建国初期,出于对当时国际国内政治、经济等因素的全面考虑,我国领导人高瞻远瞩的选择了优先发展重工业的”赶超战略“。而重工业作为资本密集型产业,具有三个主要的特征:建设周期长、投资规模大、大部分设备需要从国外引进。而当时我国资金短缺、外汇短缺、资金动员能力弱。所以如果依靠市场机制来配置资源,是不可能把资金引入重工业部门的。为了解决这一问题,就不得不做出一些政策来降低市场机制的作用,而利率管制,就是这些政策里面重要的一个

当时的做法是中央银行进行利率管制,制定较低的存款利率和贷款利率。居民以较低的存款利率在银行存钱,银行以较低的利率优先向重工业相关的企业提供资金,助其快速工业化。实践证明,当时这种做法效果是不错的,在短时间内按照国家意志建立了重工业体系,为后续的经济腾飞打下了坚实的工业基础。但是随着我国经济的发展,这样做的弊端也逐渐显现

第一,因为银行的贷款完全是以政策为导向,不是按照科学的信贷风控来决策的,所以不良贷款率会较高;其次就是很多银行未训练出真正意义的经营管理能力,尤其是风险控制、客户服务,以及最重要的定价能力; 第二,因为产业部门市场化程度提高,银行再向其提供低利率资金,就会助长过度投资、过度杠杆; 第三,过低利率不能覆盖借款人风险,所以银行不愿意把信贷投放给中小企业(后两个问题以及这两个问题衍生出的问题,至今仍困扰着我国)

利率双轨

在中国经济市场化过程中,产品市场上同一产品存在计划垄断性定价和市场定价两种价格,称为”价格双轨制”。与之相仿,在金融市场中,也存在受管制的存贷款利率和完全由市场供求决定的市场化利率,这便是“利率双轨制

  随着市场化进程的加快,产品市场的价格已完成并轨,实现了由市场供求决定价格。金融市场的改革则相对滞后,所以利率双轨制问题仍然存在。银行根据央行公布的存贷款基准利率去和客户定价;央行通过政策工具调节货币市场利率。贷款利率与货币市场利率各行其是

在利率两轨制下,不管央行怎么调节货币市场利率,都不会明显影响存贷款利率。比如,政策当局想降低企业融资成本时,央行通过货币政策工具引导货币市场利率下行,但贷款利率却基本不动。因此,最理想的办法是实现利率两轨并一轨,实现从货币市场利率向存贷款利率的有效传导

利率体系

要明白利率的有效传导,就要了解我国目前的利率体系。我国的利率体系可分为:央行利率、金融市场利率和商业银行存贷款利率三个层次(如下图)。目前利率的双轨制问题是,央行的利率体系能影响金融市场利率。但目前我国社会融资的主要模式仍然是“间接融资”(也就是商业银行直接给企业放贷),而商业银行的贷款利率则是由被管制的”存贷款利率”决定,所以央行的利率体系并不能影响到商业银行的存贷款利率

为了应对双轨制利率,央行得采用两套不同的对策来应对银行存贷款市场与货币债券资金市场的问题

利率传导

针对所以央行的利率体系并不能影响到商业银行的存贷款利率问题,2019年8月人民银行宣布改革完善LPR形成机制推出了贷款市场报价利率(Loan Prime Rate, LPR),LPR的报价是在MLF利率基础上加点形成的。共有18家银行每月根据MLF等市场利率报出LPR,去掉最高值和最低之后形成的价格就是每月公布的LPR利率。央行可以调整MLF利率来影响LPR利率,而商业银行的贷款利率则是在LPR基础上加点,因此新的传导利率则打通。通过LPR利率,我们可以预知,MLF利率以后可能成为新的基准利率,至少它有这个潜力

货币市场有SHIBOR作为基准利率,而商业银行利率体系有LPR作为贷款基准利率。因此央行调节金融市场的可用货币政策工具越来越多,也越来越灵活。其实理想化的利率传导应该如下图:

① 首先,央行制定政策利率,即投放基础货币的各种工具(再贷款、逆回购、MLF、SLF等)的利率

② 然后,政策利率影响货币市场利率。比如SLF作为利率走廊上限,能够稳定市场利率

③ 货币市场利率会影响银行存款利率,因为理想模型中,如果市场利率足够低,银行就没必要更高成本去拉存款

④ 银行综合了各种负债成本之后,再考虑其他因素,与客户确定贷款利率

我们发现上述整个传导过程中,我们用不到存贷款基准利率,也用不到LPR……换言之,这两个基准利率似乎并不是未来成熟模式下的必需品(只是最后签合同时可以选它们为锚,也可以不选它们)

一二点,央行制定策略,没啥问题。利率传导卡在第三点,银行可以从货币市场获取同业负债,也可以从存款市场获取存款,理论上两种获取货币的代价应该一致,如果差别太大,比如货币市场利率过低,而存款利率过高,那么就没必要继续高息揽存。但现实中,即使货币市场利率再低,高息揽存现象依然屡禁不绝,背后是什么原因?因为我们实施了很多专门针对存款的考核,比如存贷比、同业负债不得超1/3等之类的,这就导致了银行无论如何也必须拉存款

第四点,银行内部只有建立了完善的FTP系统,才能实现负债成本向贷款利率的有效传导。否则,货币市场利率、存款利率变动之后,贷款也不会跟着及时变动。FTP是利率传导的真正最后一公里

第三点不可能一下子要求放开所有要求,而第四点才是最关键的,银行内部的FTP才是利率传导的真正最后一公里,也是利率并轨的最大阻碍,因此,正如央行自己说的,利率并轨工作非一日之功,需稳妥推进。

总结

目前我国利率市场化,已到了最后一公里。但这一公里才是整个利率市场化最难啃的骨头,一些经济学家给出的建议是:

一、建立一套完整的中央银行利率调整框架。一是优化货币政策的目标,突出价格稳定目标;而是进一步完善货币政策决策机能;三是健全货币政策操作系统;四是建立利率走廊机制

二、强化金融市场基准利率培育,健全市场基准利率体系。一方面进一步优化Shibor报价生成机制。另一方面,加速研究完善LPR报价质量考核评估指标体系

三、完善金融机构的定价能力,提高微观经济主体的利率敏感性

四、加速推进配套改革

bookmark_border[译文]Unity性能优化—图形渲染最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

本文我们将学习Unity在渲染每一帧幕后所发生的事情,以及有哪些问题会导致渲染卡顿,以及怎么解决这些问题

阅读本文之前,首先要明白对于提升渲染性能,没有一招鲜的办法。影响渲染性能的因素太多:包括游戏类型、设备硬件、操作系统等。重要的是我们通过观察,实践得到数据然后分析数据从而有针对性的解决问题。本文包含一些常见的渲染性能问题和解决办法以及一些扩展链接供你更深层次的了解其原理;当然,很有可能你们游戏中出现的性能问题,这篇文章并没有指出;不过,这篇文章你还是值得看看的,理解事物的本质对解决问题很有帮助

渲染简介

开始之前我们快速的过一遍,看看Unity渲染每一帧时发生了什么,理解整个流程以及每个过程发生的时机会对我们解决性能问题有帮助。渲染流程如下:

  • CPU(central processing unit)计算哪些物体需要渲染以及为这些物体设置渲染状态
  • CPU 发送图形渲染指令给 GPU(graphics processing unit)
  • GPU 渲染物体

“渲染管线(rendering pipeline)”通常用来描述渲染的过程;这十分贴切,高效的渲染就像车间的流水线,无停顿,高效率。渲染每一帧的过程中,CPU都会做如下工作:

  • 检测场景中的每一个物体是否需要渲染;一个物体被渲染需要符合一定的条件。比如:物体必须处于摄像机的可视范围内(注意:哪怕只有一部分处于可视范围,仍然需要渲染整个物体),不在范围内的则剔除,如果想了解更多关于物体剔除方面的内容,请查阅:Understanding the View Frustum
  • CPU收集和计算将要被渲染的物体的相关信息并发送命令给GPU(包括将网格和纹理等传送到显存,以及设置渲染状态,调用图形API),这个过程称之为:Draw Calls
  • CPU 给每一个 Draw Call 创建的数据包,称之为:Batch(批次),批次有时候会包含一些 Draw Call 以外的数据,但这些数据对性能没有什么影响,因此本文将不会讨论这些数据

每一个批次至少包含一个 Draw Call,CPU 针对批次会做如下事情:

  • CPU会发出更改指令,让 GPU 更改渲染状态,这个指令称之为:SetPass Call,SetPass Call 告诉 GPU 使用哪些设置去渲染网格。SetPass Call指令只有在渲染下一个网格的设置和渲染上一个网格的设置不一样时,才会发出
  • CPU通过 Draw Call 命令告诉 GPU,就按照上一次 SetPass Call 的渲染设置去渲染指定的网格
  • 有些情况下,一个批次可能需要不止一个pass,pass是一段shader代码,并且新的 pass 需要更改渲染状态,对于批次中的每个 pass ,CPU必须发送新的 SetPass Call 指令,然后再次发送 Draw Call 通知 GPU 按照新设置的渲染状态去渲染网格

同时,GPU也会做如下事情:

  • GPU 按照 CPU 发送到 Command buffer 内的指令顺序处理
  • 如果当前指令是 SetPass Call,那么GPU更新渲染状态
  • 如果当前指令是 Draw Call,那么GPU根据上一次设置的渲染状态来渲染网格。渲染网格的过程有很多阶段,这里就不一一阐述,不过你可以了解一下:顶点着色器(Vertex Shader)是用来处理网格顶点的,而片元着色器(Fagment Shader)是用来处理每一个像素的
  • 这个过程会重复执行,直到Command buffer内的指令都被执行完毕

大概了解了Unity渲染的简要流程,让我们来考虑渲染的过程中可能会出现的问题

渲染问题

关于渲染,最重要的一点是:每一帧之内,CPU和GPU都要按时完成自己的任务。他们中任何一个任务超时的话,那就会造成渲染问题。渲染问题一般有两个基本原因:1. CPU 约束,渲染过程中,CPU 为每一帧渲染准备数据花费的时间太长,导致渲染瓶颈 2. GPU 约束,渲染数量过于膨大,导致 GPU 渲染一帧需要花费的时间过长

有果必有因,在性能出问题的时候,找出导致性能问题的原因才是首要任务。针对不同的问题,我们才能给出不同的解决方案。修复性能问题,其实也是一项平衡性的工作,比如:牺牲内存用于提高 CPU 性能,牺牲游戏画质解决渲染瓶颈。我们会使用Unity自带的两个工具来定位问题:ProfilerFrame Debugger

CPU瓶颈

基本上,渲染每一帧的过程中,CPU 就干三件事儿:1. 决定渲染哪些物体 2. 为渲染准备好数据以及设置渲染状态 3.发送图形渲染API 。 这三类工作包含很多独立任务,这些任务可能是通过多线程完成的;当这些任务被分配到不同的线程执行时,我们称之为:多线程渲染

Unity的渲染过程中,有三类线程参与:主线程(main thread)、渲染线程(render thread)、辅助线程(worker threads)。主线程用于我们游戏中主要的 CPU 任务(也包括一些渲染任务),渲染线程主要是用来给 GPU 发送指令的,而辅助线程则用来处理单独的任务,比如:物体剔除和网格蒙皮计算。哪些任务执行在哪个线程,取决于我们游戏运行设备的硬件以及游戏的设置。比如:设备的CPU核心数量越多,我们就会线程越多的辅助线程。正是由于这个原因,在目标设备上进行性能分析十分有必要,在不同的设备上,我们的游戏表现可能有天壤之别

由于多线程渲染非常复杂而且非常依赖硬件条件,所以在尝试提升性能的时候,我们要理解是那些任务导致的CPU瓶颈。如果游戏卡顿是因为剔除物体是否在摄像机是窗内的线程超时,那么我们减少 Draw Call 对提升游戏体验,并没有什么卵用

注意:不是所有平台都支持多线程渲染,WebGL就不支持。在不支持多线程的平台,所有的任务都是在同一个线程中执行。如果在这种平台碰到CPU瓶颈问题,那么就试着去优化所有可能对CPU性能有帮助的点

Graphics Jobs

Project Settings ->Player->Other Setting->Rendering-> Graphics jobs 选项可以让Unity将那些本该由主线程处理的渲染任务分配到辅助线程中。在可以使用该功能的平台上,它将带来显著的性能提升(可以开启和关闭这个功能来做性能对比,该功能目前仍是预览版)

发送指令到GPU

发送命令到GPU这个过程,一般是CPU瓶颈的最常见原因。大多数平台这个任务是由渲染线程完成的,个别平台是在辅助线程(比如:PS4)

而最耗时的操作是 SetPass Call 指令,如果C PU 性能问题是因为发送指令到 GPU 引起的,那么减少 SetPass Calls 的数量通常是最有效的改善性能的办法。我们可以通过 Profiler 查看到 SetPass Call 和 批次的数量。多少 SetPass Call 会造成性能问题,这个和游戏运行设备的硬件有很大关系,在高端PC上可以发送的 SetPass Call 数量远大于移动设备

SetPass Call 数量以及对应批次数量取决很多因素,稍后会详细介绍。然而一般来说:

  • 减少批次数量 或者 让更多物体共用相同的渲染状态,通常会减少 SetPass Call 数量
  • 减少 SetPass Call 数量,通常能提升 CPU 性能

减少批次能提升 CPU 性能,即便减少批次并没有减少 SetPass Call 的数量。因为CPU 能够更有效的处理单个批次。一般来说有以下方式,能减少批次和 SetPass Call 的数量:

  • 减少需要渲染的物体数量,能减少批次和SetPass Call 的数量
  • 减少每个物体被渲染的次数,能减少 SetPass Call 的数量
  • 合并需要渲染的物体,可以减少渲染批次

减少需要渲染的物体数量

这是减少批次和SetPass Call最简单的方法了,有以下方法可以降低渲染物体的数量:

  • 直接减少场景中的可见物体数量。比如:场景中有很多人物需要渲染,我们可以选择性的只渲染一部分人
  • 使用摄像机的 Far Clipping Planes 属性来降低摄像机的绘制范围。这个属性表示距离摄像机多远的物体不再被渲染
  • 通过 Occlusion culling 技术来关闭被其他物体完全遮挡的物体,这样就不用渲染被遮挡的物体了。请注意:这个功能不适用于所有场景,但是在某些场景它确实带来很可观的性能提升。另外,我们可以通过手动关闭物体来实现自己的遮挡剔除。比如:如果我们场景中包含一些过场切换才会出现的物体,那么在过场播放之前或者结束以后,就应该手动隐藏他们以减少需要渲染的物体。手动剔除往往比Unity 提供的动态剔除更高效

减少每个物体渲染的次数

实时光,阴影,反射等效果可以极大的提高游戏的真实感;但其实这些效果都非常消耗性能,因为这些效果会导致物体被渲染多次

我们对游戏的渲染路径的设置,对这些功能的性能消耗也有实质性的影响。渲染路径:表示绘制场景的时候,渲染计算的执行顺序;不同的渲染路径最主要的区别是他们怎么处理实时光,阴影和反射。通常来说,如果我们的游戏运行在比较高端的设备上,并且运用了实时光,阴影和反射,那么延迟渲染(Deferred Rendering)是比较好的选择。向前渲染(Forward Rendering)适用于不使用以上功能的低端设备。实时光,阴影和反射是个比较复杂的功能,最好能研究相关主题充分了解实现原理,可参考:灯光和渲染-2019.3

不管选择哪种渲染路径,实时光,阴影,反射都会极大的影响性能,所以优化他们十分有必要:

  • 动态光照是个非常复杂的主题,它超出了本文的讨论范畴,你可以参考这篇文章去深入了解:Shadow troubleshooting
  • 动态光照非常消耗性能,当我们场景中包含很多静态物体时,我们可以利用烘焙技术去预先计算场景的光照,生成光照贴图,详细可以点击:Lighting
  • 如果我们想使用实时阴影,你可以通过这篇文章来提交性能:Shadow Cascades。文章介绍了阴影的设置,以及这些设置怎么影响性能
  • 反射探头创建真实的反射,但是会影响合批。因此我们最好最小化的使用它,反射探头的优化请参考:Reflection Probe performance

合批物体

一个批次可以包含多个物体的数据,为了能合并批次,物体必须满足如下条件:

  • 共享相同的材质
  • 一样的渲染状态(比如:纹理,shader等)

合并物体确实可以提高性能;同时我们也要分析,合并物体带来的性能提高会不会反而造成其他方面更大的性能消耗。关于合批可以参考我上一篇文章:静态批处理、动态批处理、GPU Instancing

纹理图集(Texture Atlasing),是把大量的小尺寸的纹理合并成一张大的纹理。它通常在2D游戏以及UI系统中使用,Unity内置了图集工具:Sprite Packer

我们也可以手动合并共享材质和纹理的网格(可通过编辑器直接操作,也可以在运行时调用API操作),但是我们必须意识到,有可能我们手动合并以后,本来会被剔除而无需渲染的物体,因为共享相同的网格而必须渲染,而这些物体可能还有光照,阴影等更加消耗性能的操作,所以要权衡利弊

在脚本中,我们必须小心使用Renderer.material,这个接口会拷贝材质,并返回拷贝后的引用。这样做也会破坏合批,因为它和其他物体不再有相同的材质引用了。如果我们一定要访问合批物体的材质,应该使用Renderer.shareMaterial

蒙皮网格

SkinnedMeshRenderers通常用在网格动画中,渲染蒙皮的任务通常在主线程或者单独的辅助线程(取决于游戏的设置以及目标设备硬件)。渲染蒙皮是个很消耗性能的动作,如果在Profiler中看到是渲染蒙皮对CPU造成性能瓶颈,这里有几个方法我们可以改进性能

  • 考虑是否真的有必要使用蒙皮网格渲染组件,如果物体并不需要运动,尽可能使用普通的网格渲染组件(MeshRenderer)
  • 如果我们只在某些时刻运动物体,我们应该用细节较少的网格,(SkinnedMeshRenderers组件有一个函数BakeMesh,可以用匹配的动作创建一个网格)
  • 关于蒙皮网格的优化,可以参考:Skinned Mesh Renderer。(蒙皮网格消耗是在每个顶点上,因此,使用顶点较少的模型以及减少骨骼数量也可以提高性能)
  • 在某些平台,蒙皮可以被GPU快速处理。如果目标设备GPU比较强,则可以对当前平台开启 GPU Skinning

GPU瓶颈

如果游戏是GPU性能限制,那么首先就得找到GPU瓶颈原因。一般GPU性能最常见的问题是填充率限制,尤其是移动平台。当然显存带宽和顶点处理也可能影响

填充率

填充率是指 GPU 在屏幕上每秒可以渲染的像素数量;如果我们游戏是因为填充率导致的GPU性能问题,那么意味着我们游戏每帧尝试绘制的像素数量超过了 GPU 的处理能力,检查是否填充率引起的GPU性能问题其实很简单:

  • 打开Profiler,注意GPU时间
  • 重新设置渲染分辨率 Screen.SetResolution(width,height,true)
  • 重新打开Profiler,如果GPU性能提升,那么大概率就是填充率的原因了

如果是填充率问题,那么我们有如下几个方法解决这个问题

  • 片元着色器是告诉 GPU 怎么去绘制每一个像素的shader代码,如果这段代码效率低,那么就容易发生性能问题,复杂的片元着色器是很常见的引起填充率问题的原因
  • 如果我们游戏使用了Unity内置的Shader,那么应该使用针对移动平台的 the mobile shaders
  • 如果游戏使用的是Unity的Standard Shader,那么要理解 Unity 编译这些shader是基于当前材质设置,只有那些当前使用的功能才会被编译。这意味着,移除 detail maps可以减少片元着色器的复杂度
  • 如果使用的是定制的shader,那么应该尽量优化它。优化shader是个很大的话题,可以参考:Optimizations
  • Overdraw 是指相同位置的像素被绘制了多次。一般发生在某个物体在其他物体之上。为了理解 Overdraw,我们必须理解 Unity 在场景中绘制物体的顺序。物体的 shader 决定了物体的绘制顺序,通常由 render queue 属性决定。最常见引起 Overdraw 的因素是透明材质,未优化的粒子以及重叠的UI元素。关于Overdraw优化,可以参考:Optimizing Unity UI

显存带宽(Memory bandwidth)

显存带宽是指GPU读写专用内存的速度,如果我们的游戏受限于显存带宽,通常意味着我们使用的纹理太大了,我们可以用如下方法检测是否是显存带宽问题:

  • 打开Profiler,并关注GPU各项数据
  • Project Settings->Quality->Texture Quality,设置纹理质量,降低当前平台的纹理质量
  • 重新打开Profiler,重新查看GPU各项数据。如果性能改善,那么就是显存带宽问题

如果是显存带宽问题,那么我们需要降低纹理的内存占用,针对不同游戏通常有不同的解决方案,这里我们提供几个优化纹理的方法

  • 纹理压缩技术可以同时极大的降低纹理在内存中的占用。Texture Import Setting讲述了纹理压缩格式和各种设置的详细信息
  • Mipmaps,多级渐远纹理是 Unity 对远处物体使用低分辨率纹理。Unity场景视图中的 The Mipmaps Draw Mode 允许我们查看哪些物体适用多级渐远纹理。其实,Mipmap最主要的目的是为了提高质量,如果没有mipmap 纹理很大,采样频率却很小的情况下,模型看去来质量会很差(相邻的两个屏幕像素采样的纹素差的很远,此时会大大降低缓存命中率)

顶点处理

顶点处理是指 GPU 处理网格中的每一个顶点,顶点处理的消耗主要受两个因素的影响:顶点数量以及操作每个顶点的复杂度。有一些方法可以优化这个:

  • 降低网格复杂度
  • 使用法线贴图模拟更高几何复杂度的网格,关于法线贴图可以参考:Normal map
  • 如果游戏未使用法线贴图,在网格导入的设置中,可以关闭顶点的切线,这可以降低每个顶点的数据量
  • LOD,当物体远离摄像机的时候,降低物体网格的复杂度的技术,可以有效的降低 GPU 需要渲染的顶点数量,并且不会影响视觉表现,具体细节请参考:LODGroup
  • 顶点着色器,是一段shder代码,降低它的复杂度,可以提升性能

bookmark_border[原创]静态批处理、动态批处理、GPU Instancing

这几个东西的原理是什么? 问过几个经验丰富的大佬,不知是不是这些问题太低端,他们要么不屑于回答,要么直接给个链接。反正就是别问,问就是只可意会,不可言传

没办法,只能去茫茫互联网寻觅答案;当疑惑被解开的时候,那感觉就像星期五下午,明知这个礼拜没得休息,而且晚上要加班。老板突然在群里说一句:最近大家幸苦了,项目进度不错,这周双休,今晚不加班!我特么,我好想反手给老板一………张过去的CD,纪念那时我们的爱情

要回答这个问题,首先得了解何为渲染流水线?渲染流水线的工作任务就是由一个三维场景出发,生成(或者说渲染)一张二维图像,而这个工作通常是由CPU和GPU共同完成的,本文主要讨论的就是渲染流水线中关于CPU的部分。在渲染物体之前,CPU需要为渲染这个物体做准备工作,主要包括如下三个步骤:

Data Transfer (把数据加载到显存中)

在程序开始运行之前所有渲染所需的数据都是保存在磁盘(HDD)上的。程序开始运行,为了CPU能够快速访问数据,需要先把数据载入到内存(RAM)。GPU和CPU一样,也有自己的可以快速访问的类内存结构,叫做显存(VRAM)。当需要渲染该物体时,网格和纹理等数据又被加载到显存中

Set Render States (设置渲染状态)

渲染状态(Render State),通俗的解释就是:这些状态定义了场景中的网格是怎么被渲染的。渲染状态包括 使用哪个顶点着色器/片段着色器、光源,材质,纹理等。在我们调用图形渲染API函数进行绘制之前我们需要设置这些状态值,这些状态值指导 GPU 如何渲染我们传递到显存的模型和纹理数据

Draw Call(调用图形渲染API)

在模型和纹理数据传输到显存,并且设置了正确的渲染状态之后,CPU需要调用渲染API函数通知GPU去执行绘制操作。这个步骤就是传说中的” Draw Call “。因为渲染状态已经通过其他API函数设置好了,所以 Draw Call 仅仅是发送命令给GPU,告诉GPU绘制哪些模型数据

CPU和GPU是并行工作的,他们之间通过命令缓冲区(Command Buffer)这个队列沟通。大概如下:

明白了 Draw Call,我们得了解另外一个概念:批次(Batch), 调用一次渲染API的绘制接口来向GPU提交使用相同渲染状态来渲染物体的行为称为一个渲染批次。从渲染API调用的角度来看,Batch和Draw call是一样的,但是在游戏引擎中他们的实际意义是不一样的,Batch一般指经过打包之后的 Draw call 。如下2种缓冲区命令,你就能明白他们的区别了

渲染状态A > 渲染A >渲染状态B > 渲染B > 渲染状态C > 渲染C Draw Call:3 Batch:3

渲染状态A > 渲染A > 渲染B > 渲染C Draw Call : 3 Batch:1

实际证明,从CPU到GPU最消耗性能的地方是切换渲染状态,而不是 Draw Call 这个操作。也因此,Unity引擎越来越淡化Draw Call,转而用Batchs作为Graphics的性能指标,同时这也是Unity进行批处理的理论依据

Static Batching(静态批处理)

静态批处理的工作原理是将静态游戏对象转换为世界空间,并为它们建立一个共享的顶点和索引缓冲区。 在后续的绘制过程中,根据Unity引擎渲染排序函数,将符合静态合批的对象,一次性提交整个合并模型的顶点数据,然后设置一次渲染状态,调用多次Draw call分别绘制每一个子模型 , 而多次 Draw call 调用之间并没有渲染状态的切换

优点:针对相同材质的静态GameObject,Unity使用静态合批能将相同渲染状态的对象同一批次添加到命令缓冲区供GPU去渲染,从而加速了渲染效率

缺点:会加大运行时的内存以及增加包体大小

为什么会增加包体呢? 答: 因为在Build项目的时候,Unity会把符合静态合批的对象生成一个大的合并网格,这个多出来的网格会导致包体增加

为什么会增加运行时内存呢? 答: 渲染一个对象,CPU会从硬盘拿到渲染这个物体的数据(网格,材质,贴图等)加载到内存中,然后传送到显存中,渲染完成后,自动删除这部分内存。渲染合批的对象时,因为是直接加载Build的时候生成的合并后的大的Mesh,所以运行时的内存就增加了。合并后的网格越大,则运行时增加的内存就越大

Unity还提供了一种灵活度很高的运行时静态合批方法我们可以在运行时调用StaticBatchingUtility.Combine实现将一些模型合并成一个完整模型。使用这种方法我们可以避免最终打包的应用体积增大,但是由于在运行时通过CPU做模型的合并,会到来一次性的运行时内存和CPU开销

Dynamic Batching (动态批处理)

动态批处理 专门为优化场景中共享同一材质的动态GameObject的渲染设计的。为了合并渲染批次, 动态批处理 每一帧都会有一些CPU性能消耗,如果我们开启了 动态批处理 ,Unity会自动地将所有符合条件的共享同一材质的动态GameObject在一个Draw call内绘制。动态批处理 的原理也很简单,在进行场景绘制之前将所有的共享同一材质的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型,达到合批的目的

优点:弥补了静态批处理不能处理相同材质的动态对象的不足

缺点:1. 会带来CPU性能消耗。它与静态批处理带来性能优化不一样,动态批处理是将多个对象通过一个Draw Call来渲染。那么就需要在渲染之前,将这些小模型的顶点从本地坐标系转换成世界坐标系,模型顶点变换的操作是由CPU完成的,所以这会带来CPU的性能消耗。计算的模型顶点数量不宜太多,否则CPU串行计算耗费的时间太长会造成场景渲染卡顿。2. 动态批处理有很多严格的限制条件,具体可以参考官网,这里就不一一列举

GPU Instancing

静态批处理和动态批处理,都有自己的优势和劣势。但这些都是在CPU端做的优化,实际上GPU的运行速度是很高的,一次性渲染20个对象和一次性渲染200个对象,几乎是一样的。以上两种提升渲染能力的方法都是针对Command buffer,那我们能不能另辟蹊径,针对GPU来做个优化。比如:一个Draw Call,绘制一个对象;那能不能告诉GPU,一个Draw Call ,给我绘制一百个对象

静态批处理的优先级要比 GPU Instancing 的优先级高,如果一个GameObject被标记为 static 物体并且在Build阶段成功地执行了静态合批,那么如果这个物体还要使用 Instancing Shader 渲染的话,Instancing会失效

动态批处理的优先级要低于 GPU Instancing ,如果一个GameObject使用Instancing渲染的话,那么对于它的Dynamic batching会失效

好了,希望本文也能为你解惑,重拾爱情

Unity3D性能优化——渲染篇

图形渲染及优化—Unity合批技术实践

Unity Shader 入门精要

bookmark_border[译文]Unity性能优化—脚本最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

当游戏在移动设备上运行的时候,游戏的每一帧可能需要CPU执行上百万个指令;为了保持平稳的帧率,CPU必须在规定时间内执行完相应的指令,一旦CPU无法及时执行所有指令,我们游戏就会变的卡顿

很多问题会导致 CPU 需要执行密集指令,比如:为渲染做准备工作、进行超复杂的物理运算或者太多的动画回调;本文主要探讨的是:我们编写的脚本导致的 CPU 性能问题。我们将学习脚本是如何转换成 CPU 指令,哪些代码会让 CPU 执行过多的指令,以及如何修复这些代码

诊断代码中的问题

CPU 超负荷运行的直接现象就是游戏不够流畅(帧频不稳)。然而其他性能问题也可能导致类似的现象。如果我们游戏有类似症状,我们首先想到的是要利用Unity 的 Profiler 来分析是不是因为 CPU 超负荷运行引起的;如果是那么进一步分析到底因为代码问题引起的;还是因为物理计算或者动画太多引起的(如何使用Profiler分析性能问题,请移步到:Diagnosing Performance Problems tutorial.

Unity构建和运行游戏

     为什么我们的代码可能造成性能问题呢 ? 我们首先需要知道当构建游戏的时候发生了什么事情,了解幕后发生的事情有助于我们提高游戏性能。构建游戏的时候,Unity 会把游戏运行需要的所有东西打包到对应设备能运行的程序内。众所周知,CPU 只能执行机器代码(原生代码:native code),不能执行一些高级语言编写的代码(比如: C#)。第一步:Unity 会将我们编写的高级语言转换成其他语言;这个转换过程我们称之为 编译( compiling ) , Unity 会把我们写的 C# 代码编译成CIL(译者注:这一步是通过 Mono 实现的),CIL 是一种很容易编译成不同原生代码的通用中间语言。第二步: CIL 然后被编译为目标设备对应的原生代码; 它发生在我们构建游戏时(静态编译,AOT )或代码运行之前(即时编译,JIT )

源代码与编译代码之间的关系

代码在未编译之前称为”源代码”,源代码直接决定了编译后的原生代码的结构和内容,结构良好且高效的源代码将生成结构良好且高效的已编译代码

有些 CPU 指令的执行时间比其他指令长。比如: 计算平方根比计算两个数相乘耗时更长。实际上 CPU 执行这两个指令的时间都非常短暂,但是它们之间相对来说还是有快慢之分

一些在源代码中看起来非常简单的操作,编译之后的原生代码会非常复杂。比如将一个元素插入列表,执行此操作所需要的指令要比按索引从数组中访问元素多得多

理解了以上两点,即便你对一些原生代码底层如果工作不是很熟悉,但是可以通过源代码和编译以后的代码之间的联系编写高性能的源代码

Unity引擎代码与脚本代码在运行时通讯

用 C# 编写的脚本代码与Unity引擎核心代码的运行方式略有不同。Unity 引擎的内核代码是用C++编写的,并且已经被编译成原生代码。这个编译后的引擎代码包含在我们安装的Unity程序中

源代码被编译成 CIL 以后,称为托管代码。当托管代码被编译成原生代码时,它与托管运行时集成。托管运行时负责内存管理和安全检查,以确保代码中的错误只是会导致异常,而不会造成设备崩溃

当数据从托管代码传递回引擎代码时,CPU 可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式,这种转换称为编组(marshlling)。托管代码和引擎代码之间的任何单个调用的开销都不是很大,但是我们必须知道这里是有开销的

代码性能不佳的原因

现在我们已经了解了 Unity 构建和运行游戏时代码会发生什么,我们也知道,我们的代码之所以性能很低,那是因为它们导致了 CPU 执行了密集指令

第一种可能是我们的代码很浪费或者结构很差。常见的例子可能是:当一个函数只需要被调用一次时,它却被重复调无数次

第二种可能是我们的代码看起来结构良好,但是对其他代码进行不必要的大量调用。这方面的一个例子可能是导致托管代码和引擎代码之间不必要的重复通讯从而造成 CPU 开销

第三种可能是我们的代码的没有任何问题,但是在不需要调用它的时候它依然被调用(一个例子是:怪物自动寻敌代码,当主角离怪物足够远的时候,这段代码应该停止执行)

最后一种可能是我们的代码需求太高了。这个我们只能重新设计游戏以降低它对性能的要求。实现这种优化超出了本文的范围,因为它非常依赖于游戏本身,但是阅读这篇文章并考虑如何使我们的游戏尽可能地拥有高性能仍然会对我们有好处

提高代码性能

一旦确定我们游戏中的性能问题是由于代码造成的,我们就应该仔细想想怎么解决这些问题。优化臃肿的函数似乎是个好的开始,当然也有可能这个函数为了实现这个功能已经是最优写法了,再去改动需要花费的代价太大(有可能导致功能异常或者带来新的bug)。我们可以在一个被数百个游戏对象所引用的脚本中创造一个小的效率节约,那么这就会给我来很可观的性能提升。此外,可能为了提高 CPU 的性能,会导致更多的内存消耗或者将工作量分担给了 GPU

尽量将代码移出循环

循环是个老生常谈的低效率代码之一,嵌套循环更甚。如果它们是被包含在Update 这类每一帧都会被调用的函数中,那么效率更低。在下面的示例中,无论条件是否满足,for 循环每一帧都被执行,这是非常糟糕的代码

void Update()
{
	for(int i = 0; i < myArray.Length; i++)
	{
		if(exampleBool)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

一个小小的改变,代码只在满足条件的情况下才会循环,情况就会好很多

void Update()
{
	if(exampleBool)
	{
		for(int i = 0; i < myArray.Length; i++)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

这是一个简单的例子,我们应该认真检查代码中的循环结构,尤其是被频繁调用的循环。Update() 是每一帧都会被调用的函数,将代码移出 Update() 在需要时才运行,这是提高性能的一种好办法,而且也是最容易实现的办法之一

只有情况发生改变时才调用代码

让我们来看一个非常简单的例子(只在状态发生变化时才调用它)。在下面的代码中,在 Update() 中调用 DisplayScore() 。事实上分数的值不可能每一帧都发生变化,这意味着,我们可以将它移出 Update()

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
}

void Update()
{
	DisplayScore(score);
}

通过一个简单的更改,我们现在确保只有在 Score 的值发生改变时才调用DisplayScore()

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
	DisplayScore(score);
}

每隔X帧执行代码

如果代码需要频繁运行并且不能被事件触发,那其实也并不意味着每一帧都需要调用,可以每隔X帧调用一次

void Update()
{
	ExampleExpensiveFunction();
}

我们每3帧调用一次这段代码就行了,在下面的代码中,我们使用模运算来确保该函数每隔3帧才会调用一次

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
}

这种技术的另一个好处是很容易在不同的帧之间分散开销很大的代码,从而避免峰值。在下面的示例中,两个性能消耗很大的代码就可以错帧执行,非常好的方法

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
	else if(Time.frameCount % 1 == 1)
	{
		AnotherExampleExpensiveFunction();
	}
}

缓存变量

我们通常调用 GetComponent() 来访问组件。在下面的示例中,我们在Update() 中调用GetComponent() 来获取 Renderer 组件,并将它传给ExampleFunction() 使用。这段代码可以工作,但是它的效率很低

void Update()
{
	Renderer myRenderer = GetComponent<Renderer>();
	ExampleFunction(myRenderer);
}

下面的代码只调用 GetComponent() 一次,因为结果被变量myRenderer缓存。变量可以在 Update() 中重用,而无需调用 GetComponent() 重新获取

private Renderer myRenderer;

void Start()
{
	myRenderer = GetComponent<Renderer>();
}

void Update()
{
	ExampleFunction(myRenderer);
}


对于频繁调用返回结果的函数的情况,我们应该检查代码,最好通过使用缓存来降低这些调用

使用正确的数据结构

为了正确地决定使用哪种数据结构,我们需要了解不同数据结构的优缺点,并仔细考虑我们希望代码做什么。我们可能有数千个元素需要每一帧中迭代一次,或者我们可能有少量的元素需要频繁地添加和删除。这些不同的问题最好由不同的数据结构来解决。 如果数据结构对你来说是一个新的知识领域,可以参考:数据结构

尽量减少垃圾回收的影响

垃圾回收是 Unity 管理内存的一部分。我们的代码使用内存的方式决定了垃圾收集的频率和 CPU 成本,因此理解垃圾回收是如何工作的非常重要。在下一篇文章中,我们将深入讨论垃圾回收,并提供几种不同的策略来最小化垃圾收集的影响

使用对象池

实例化和销毁一个对象通常比停用和重新激活一个对象花费更多。如果对象包含启动代码,例如在 Awake() 或 Start() 函数中调用GetComponent(),则更是如此。如果我们需要生成和处理多个相同对象,比如射击游戏中的子弹,那么使用对象池就非常有必要

对象池是一种技术,它不是创建和销毁对象的实例,而是临时停用对象,然后根据需要回收和重新激活。尽管对象池作为一种管理内存使用的技术广为人知,但作为一种减少过度使用CPU的技术,它也很有用。Unity教程上有关于在Unity中实现对象池的应用的内容

避免昂贵的Unity API的调用

有时,我们的代码对其他函数或 api 的调用可能会出乎意料性能消耗。因为有些看起来像变量的东西可能包含额外的代码、触发事件或从托管代码调用引擎代码的访问器(accessor)

在这一节中,我们将看一些 Unity API 调用的例子,这些调用的代价比看起来的要高。这些例子展示了成本的不同潜在原因,建议的解决方案可以应用于其他类似的情况

很遗憾,我无法列出哪些 Unity API 你绝对禁止调用。因为每个 API 有时候你必须使用,而且合理使用的时候开销并不会很大。在任何情况下,我们都必须仔细分析我们的游戏,找出导致开销昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。

SendMessage()

SendMessage() 和 BroadcastMessage() 是非常灵活的函数,它们几乎不需要了解项目的结构,而且实现起来非常快。因此,这些函数对于原型或初学者来说非常有用。然而,它们是非常耗费性能的 API。这是因为这些函数使用了反射

建议仅将 SendMessage() 和 BroadcastMessage()用于快速出Demo的时期,并尽可能使用其他函数代替。例如,如果我们知道要在哪个组件上调用某个函数,我们应该直接引用该组件并直接调用该函数;如果我们不知道在哪个组件上调用该函数,那么可以考虑使用事件或委托

Find()

Find() 和相关的函数功能强大,但是开销也很大。这些函数需要 Unity 遍历内存中的每个 GameObject 和组件。这意味着在小项目中不需要特别关注效率,但是随着项目复杂性增加,它们的使用成本会越来越高

Transform

设置 transform 位置或旋转将导致内部 OnTransformChanged 事件传播到该 transform 的所有子节点。这意味着设置一个 transform 位置和旋转值相对比较昂贵,特别是在有许多子元素的转换中

为了限制这些内部事件的数量,我们应该避免设置这些属性的值。例如,我们不应该单独设置 transform 的 x,y,z的值。而是将 x,y,z的值放入 vector3中,然后将 vector3的值赋给 transform,这只会导致一个 OnTransformChanged 事件

如果代码经常使用Transform.position,应该用Transform.localPosition替换。这将导致更少的CPU指令,并可能最终提高性能。如果我们经常使用Transform.position,我们应该在可能的地方缓存它

Update()

Update(),LateUpdate() 和其他事件函数看起来像简单的函数,但是它们有隐藏的开销。每次调用这些函数,都需要在引擎代码和托管代码之间进行通信。除此之外,Unity在调用这些函数之前还进行了一些安全检查。安全检查确保GameObject处于有效状态,没有被销毁等等

因此,空 Update() 调用可能特别浪费。你可能猜想,因为函数是空的,并且我们的代码不包含对它的直接调用,所以空函数不会运行。事实并非如此:在幕后,即使 Update() 函数的主体为空,这些安全检查和本机调用仍然会发生。为了避免浪费CPU时间,我们应该确保项目中不包含空的Update()调用(译者注:可以写个脚本,在打包的时候执行该脚本,检测到了空Update()函数,自动删除它)

关于Update(),这篇文章带来了深入的探讨:10000 Update() calls

Vector2 和 Vector3

我们知道一些操作会导致比其他操作更多的CPU指令。向量数学操作就是一个例子:它们比浮点或整数操作更复杂。尽管两次这样的计算所花费的时间实际差异很小,但是在足够大的范围内,这样的操作可能影响性能

在数学运算中使用 Unity 的 Vector2 和 Vector3 结构是很常见和方便的,特别是在处理 transform 时。如果我们在 Update() 中的嵌套循环对大量游戏对象执行这些操作,我们很可能会给 CPU 带来不必要的工作。在这些情况下,我们可以通过执行int或float计算来节省性能

在文章的前面,我们了解到执行平方根计算所需的 CPU 指令比简单乘法所需的CPU指令要多的多。Vector2.Magnitude 和Vector3.Magnitude 就是一个例子,因为它们都涉及平方根计算。如果我们的游戏广泛而频繁地使用 magnitude 或 Distance,那么我们就有可能使用Vector2.sqrMagnitude和Vector3.sqrMagnitude代替来避免相对昂贵的平方根计算

Camera.main

Camera.main是一个方便的 Unity API ,它返回对第一个被标记为“Main Camera” 的已激活相机的引用。这是另一个类似于变量但实际上是访问器(accessor)的例子。在这种情况下,访问器在幕后调用类似Find()的函数。因此Camera.main 面临着与 Find() 相同的问题:它搜索内存中的所有游戏对象和组件,并且使用起来非常耗费性能。为了避免这种潜在的昂贵调用,我们应该缓存Camera.main的结果,或者避免使用它,并手动管理对相机的引用

其他的Unity API调用和进一步优化

我们已经考虑了一些 Unity API 调用的常见例子,它们可能会带来意想不到的代价,并且了解这种代价背后的不同原因。然而,这并不是提高Unity API调用效率的全部方法。这里有一篇关于Unity性能优化的文章,它包含了许多我们可能会发现有用的其他Unity API优化

只在需要运行时运行代码

鲁迅说过:”最快的代码是没有运行的代码”。通常,解决性能问题的最有效方法不是使用高级技术而是删除不需要的代码

裁剪

Unity 包含代码来检查对象是否在摄像机视锥体内。如果它们不在摄像机的视锥体内,则渲染这些对象相关的代码不会运行。这里的术语是视锥体剔除

我们可以对脚本中的代码采取类似的方法。如果我们有与对象的可视状态相关的代码,当玩家看不到该对象时,我们可能不需要执行此代码。在有许多对象的复杂场景中,这可以节省大量的性能

在下面的简化示例代码中,我们有一个巡逻敌人的示例。每次调用Update()时,控制这个敌人的脚本都会调用两个示例函数:一个与移动敌人有关,一个与它的可视状态有关

void Update()
{
	UpdateTransformPosition();
	UpdateAnimations();
}

在下面的代码中,我们现在检查敌人的渲染器是否在任何摄像机的视锥体内。与敌人的可视状态相关的代码仅在敌人可见时才运行

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}

在玩家看不到的情况下禁用代码有几种方法。如果我们知道我们的场景中的某些对象在游戏中的某个特定点是不可见的,我们可以手动禁用它们。当我们不确定并且需要计算可见性时,我们可以使用粗略的计算(例如,检查玩家背后的对象)、OnBecameInvisible() 和 OnBecameVisible() 等函数,或者更详细的raycast。最好的实现在很大程度上取决于我们的游戏,而实验和分析是必不可少的。

LOD

LOD 是另一种常见的渲染优化技术。最接近玩家的物体使用更详细的网格和纹理以完全保真的方式渲染。远处的物体使用较少的细节网格和纹理。我们的代码也可以使用类似的方法。例如,我们可能有一个敌人的AI脚本决定了它的行为。这种行为的一部分可能涉及昂贵的操作。以确定它可以看到和听到什么,以及它应该如何对输入作出反应。我们可以使用一个详细级别的系统来启用和禁用这些昂贵的操作

总结

我们已经了解了在Unity游戏构建和运行时我们编写的代码会发生什么变化,为什么我们的代码会导致性能问题,以及如何最小化昂贵代码对游戏的影响。我们已经了解了代码中导致性能问题的一些常见原因,并考虑了一些不同的解决方案。使用这些知识和分析工具,我们现在应该能够诊断、理解和修复与游戏中的代码相关的性能问题

bookmark_border[译文] JavaScript 之执行上下文,函数调用栈,事件循环

原文地址:The JavaScript Execution Context, Call-stack & Event Loop

给你一段 JavaScript 代码,你可能会很快知道这段代码运行后的结果。但若我问你,JavaScript 引擎是如何执行代码并给出结果的? 或者问你 click handler, AJAX call 等回调函数是如何被触发的?你是不是一时语塞了

理解 执行上下文( Execution Context ) 、调用栈( Call-stack )和 事件循环( Event Loop )这三个概念并知道他们是如何协作的,你就能回答以上两个问题

执行上下文 ( Execution Context )

var a = 4;
function foo(num) {
    return num * num;
}
var b = foo(a);

很简单直白的一段代码,我们将数字 42 赋值给变量 a , 声明 foo() 方法,把变量 a 当作参数传给 foo() 方法并调用,最后把函数执行的结果赋值给变量 b 。如果我问 b 的值是多少?你会马上说出答案。那么 JavaScript 是如何得到最终结果的呢? 让我们来一起探讨这个问题吧

针对以上代码,JS 引擎要做的第一件事情就是生成运行这段代码的 执行上下文。引擎在执行代码的时候有着精确的步骤,在生成执行上下文的时候,分两个阶段,分别是:生成阶段(  creational phase )和执行阶段(  execution phase )

首先,在代码执行之前,一个全局的执行上下文( Global Execution Context )就被生成了。在生成阶段,引擎会做以下一些事情:

  • 创建一个全局实例对象,在浏览器中叫做 window , 在Node中叫做 global
  • 创建一个this对象 , this指向上一步创建的全局实例对象
  • 设置用于存储变量和函数引用的内存堆
  • 将函数声明存储在上一个的内存堆中,并将执行上下文中的每个变量赋值为undefined

在生成阶段,引擎会保存变量 a ,b 以及函数 foo 。 变量a 和 b 会被赋值为 undefined

当生成阶段结束以后,引擎会跳到执行阶段。在执行阶段,代码会从上往逐行执行。在这个阶段,变量会被赋予它真正的值,并且函数也会被调用

此时如果你的代码内没有函数被调用,那么存储到这一步就结束了。然而,每一个你调用的函数,引擎都会创建一个 函数执行上下文,这个上下文和上面我们介绍全局执行上下文基本一致,但是此时 global object的对象不是 window ,而是 arguments , 它指向外部传入到该函数的全部参数的引用

回到我们上文的代码示例,在执行阶段,引擎首先去拿已声明变量 a ,然后将42赋值给它,然后回执行到给变量 b 赋值,注意,这一行有一个方法调用,所以引擎会创建一个新的函数运行上下文,然后重复上面的步骤(不过,此时有个argumenrts 对象被创建)

但引擎是如何跟踪所有这些执行上下文呢 ? 特别是在有多个嵌套函数调用的情况下 ? 它如何知道哪一个是活动的,或者哪一个已经完全执行了? 这就要用到我们即将介绍的下一个概念:函数调用栈

函数调用栈(Call-Stack)

调用堆栈是一种数据结构,用于跟踪和管理 JavaScript 的函数执行。 它的工作是存储在代码执行过程中创建的所有执行上下文,并记录我们实际处于哪个执行上下文以及那些仍然留在堆栈上的执行上下文 。 当您调用一个函数时,引擎将该函数推到堆栈的顶部,然后创建一个执行上下文。从上面对执行上下文的研究中,我们知道这个上下文要么是全局上下文,要么是函数执行上下文

每当一个函数执行完后,函数调用栈会弹出这个函数,然后移动到下一个函数,直到栈上没有函数为止,这种顺序称为后进先出LIFO(Last in First Out)

当调用一个函数时,将创建一个堆栈帧。这是内存中存储参数和变量的位置(还记得我们在上面讨论的内存堆吗?)当函数返回(隐式或显式)时,该内存将被清除,整个上下文将从调用堆栈中弹出

执行上下文从栈中弹出一个接一个,他们与每一个创建一个完整的执行堆栈帧当我们抛出错误时,我们得到了所谓的堆栈跟踪,这就是听起来——跟踪所有的执行上下文从错误到我们已通过的所有上下文。

如果拥有的帧数超过了堆栈的设计容纳量,也可能会破坏调用堆栈。这可能发生在递归调用一个函数时,没有某种退出条件,或者我确信我们都在某个时间点做过——当运行一个无限的for循环时。

请看如下代码块:

function thirdFunc() {
    console.log("Greetings from thirdFunc()");
  }
  
  function secondFunc() {
    thirdFunc();
    console.log("Greetings from secondFunc()");
  }
  
  function firstFunc() {
    secondFunc();
    console.log("Greetings from firstFunc()");
  }
  
  firstFunc();

灵魂拷问,我们是怎么知道最后输出结果的呢?

当我们运行这段代码的时候,引擎首先启用函数调用栈,将main() 和 global() 函数压入函数调用栈 , 这就是执行JS代码的主线程,我们上文讲到的执行上下文会先进入 创建上下文阶段,然后再进入 执行上下文 阶段。 我们在前一节中描述的执行上下文将首先进入创建阶段,然后调用执行阶段。在此阶段,当引擎到达对firstFunc()的调用时,调用堆栈将再次被引用,firstFunc()的函数执行上下文将推入main()顶部的调用堆栈(下面的步骤2)

现在,引擎开始执行 firstFunc() 因为它在函数调用栈里最上面。引擎会创建一个私有执行上下文用和私有内存用来存储 变量,参数,函数声明等(作用域与这个息息相关)

firstFunc() 函数的第一行就是调用 secondFunc() ,此时,引擎会重新引用函数调用栈把 函数 secondFunc() 压入到最顶端,然后执行上面的过程。 在 secondFunc() 函数的第一行,又是调用 thirdFunc(),那么就重复上面的过程

当引擎开始执行 thirdFunc() 以后,这个函数内没有方法调用了,我们仅仅打印了一个字符串 ” Greetings from thirdFunc() “(注意:console.log()其实也是一个方法,但是我们为了简化模型,暂时把它只当作一行执行语句)。 因为函数中没有更多的语句,执行完毕以后它会隐式返回(译者注:其实所有函数都有返回,如果你没有明确它返回的值,引擎会默认返回 undefined ) ,此时,调用栈会弹出thirdFunc() (步骤4),弹出以后,secondFunc() 处于栈顶,引擎会执行 secondFunc()内剩余的语句,打印出” Greetings from secondFunc() ” , 同样执行完以后,调用栈弹出secondFunc(),如此反复,一旦调用栈为空,程序就停止运行

调用堆栈的性质反映了这样一个事实:JavaScript本质上是单线程的,一次只能运行一个执行上下文。这意味着在执行一个函数时,引擎不能同时运行另一个上下文。它还意味着,每次将一个函数推入调用堆栈时,它都会成为活动的执行上下文,并从调用它的任何函数中获取控制流,直到显式(使用return语句)或隐式(当所有指令都已执行时)返回。

如果故事到这里就结束,那么JavaScript就不可能被用于各种应用,更别提有用户输入,资源请求,API调用这些异步操作了。因为它是单线程呀,那么意味着,当我在加载一张图片的时候,是不是我就不能输入用户名和密码?当然不是,那么JavaScript是如何实现异步的呢?那就得介绍我们鼎鼎大名的事件循环机制了(event loop)

事件循环机制(The Event Loop)

从上文可知,JavaScript是单线程模式, 它从我们代码的顶部开始,向下工作,根据需要创建新的执行上下文,并将它们推入和弹出调用堆栈。 如果有一个阻塞函数需要很长时间才能执行,那么在该函数位于调用堆栈顶部期间,浏览器将无法执行任何操作。不能发生新的执行上下文或代码执行。这意味着即使是像滚动和按钮单击事件这样的用户输入也不能工作。

相反,当我们有一个可能需要很长时间才能完成的函数时,我们通常会提供一个回调函数。这个函数封装了我们希望在稍后的阻塞操作(e。网络呼叫)已经被解决。这允许我们将控制权返回给JS引擎,并将其余的执行推迟到调用堆栈被清除之后。这就是JavaScript中的异步概念。

调整一下代码,看看异步是怎么执行的:

function thirdFunc() {
  setTimeout(function() {
    console.log("Greetings from thirdFunc()");
  }, 5000);
}

function secondFunc() {
  thirdFunc();
  console.log("Greetings from secondFunc()");
}

function firstFunc() {
  secondFunc();
  console.log("Greetings from firstFunc()");
}

firstFunc();

在上面的代码中,执行与前面的示例一样开始。但是,当引擎到达第三个函数时,它不是立即将消息记录到控制台,而是调用setTimeout(),这是浏览器环境提供给我们的一个API。这个函数接受一个“回调”函数,该函数将存储在一个我们尚未讨论的结构中,称为回调队列。然后thirdFunc()将完成它的执行,依次将控制权返回给secondFunc()和firstFunc()。最后,在至少5秒之后(后面会详细介绍),来自thirdFunc()的消息将被记录到控制台。

在JavaScript中,我们实现代码异步执行的机制是通过环境api (Node和浏览器都提供某些api,向我们公开底层特性)、回调队列和事件循环来实现的。 并发性(或它的假象)是通过这些额外的机制实现的。

正如我们所说的,调用堆栈用于跟踪当前执行的函数上下文,回调队列跟踪需要在以后运行的任何执行上下文。例如传递给setTimeout函数或节点异步任务的回调。在调用代码时,事件循环会定期检查调用堆栈是否为空。一旦调用堆栈在我们的代码中运行了所有的执行上下文,事件循环将获取进入回调队列的第一个函数,并将其放在要执行的调用堆栈上。然后再次重复该过程,不断地检查调用堆栈和回调队列,并在调用堆栈为空时将函数从回调队列传递到调用堆栈。

还记得我们说过setTimeout回调将从调用setTimeout开始“至少”运行5秒吗?这是因为setTimeout并不只是在超时完成时将其代码插入调用堆栈,它必须将其传递给回调队列,然后等待事件循环在调用堆栈为空时将其放入调用堆栈。只要调用堆栈中还有项,就不会运行setTimeout回调。让我们仔细看看这个

我们的代码按上面的方式运行,直到到达第三个函数为止,此时setTimeout被调用,从调用堆栈中取出并开始倒计时。我们的代码继续到secondFunc和firstFunc和console。依次记录他们的消息。与此同时,setTimeout几乎立即(在0秒内)完成了它的倒计时,但是没有办法让它的回调直接进入调用堆栈。相反,当它完成倒计时时,它将回调传递给回调队列。事件循环不断检查调用堆栈,但在此期间secondFunc和firstFunc占用了调用堆栈上的空间。直到这两个函数完成执行并清空调用堆栈后,事件循环才接受传递给setTimeout的回调函数,并将其放在要执行的调用堆栈上

这就是为什么有时您会发现使用0调用setTimeout的模式是一种延迟执行传递给它的回调中的代码的方法。我们只是想确保所有其他同步代码在setTimeout回调中的代码之前运行。

还需要注意的是,“回调”是由另一个函数调用的函数,但是我们在上面讨论的回调,例如传递给setTimeout的回调是“异步回调”。区别在于,异步回调被传递到回调队列,等待被(通过事件循环)放到调用堆栈上,以便稍后执行

至此,我们已经介绍了JavaScript代码执行的主要概念,以及JavaScript引擎如何处理异步代码。我们已经看到JS引擎是单线程的,只能同步执行代码。我们还看到了在不阻塞执行线程的情况下实现异步代码的机制。我们还可以更好地理解函数执行的顺序和围绕这个过程的规则。

这些概念很容易理解,但是值得花时间真正掌握它们,因为它们是深入了解JavaScript的基础。不仅仅是var a = 2语法,而是一个完整的视图,关于JavaScript采用这种语法并运行时到底发生了什么。这些概念还可以作为更好地理解其他概念(如作用域和闭包)的基础。

bookmark_border[译文] JavaScript 之闭包

原文地址:A Simple Explanation of JavaScript Closures

回调 ( callbacks ),事件句柄 ( event handlers ),高优先级函数 ( higher-order function ① ) 等这些之所以可以访问其作用域外的变量,就是因为闭包;闭包是面向函数范式编程里一个非常重要的概念,同时也是 JavaScript 面试时经常被问到的一个知识点

① 在函数内操作的其他函数(接受它们作为参数或返回它们)称为高优先级函数

虽然闭包随处可见,但其实真正理解并不简单;如果你对理解闭包还差那么一层窗户纸,那么这篇文章就是帮你捅破那层窗户纸

首先,我会给你们介绍两个基本术语:作用域和词法作用域( scope and lexical scope ),当你了解这些基础以后,你离理解闭包也就只有一步之遥了。文章的最后,我会通过一个真实世界的例子来试图解释闭包

开始之前,我强烈建议你不要跳过作用域和词法作用域这两个小节,这两个概念对理解闭包非常重要

1. 作用域( The scope )

当你定义一个变量,你总是希望能在一定的范围内用它。比如:你定义一个变量 result ,看起来这个变量就应该在名为 calculate() 的函数内,用它来保存这个函数的计算结果并返回 。在 calculate() 函数外部,这个变量并没什么用

作用域就是管理变量的可访问性的,你可以在定义该变量的作用域内访问该变量,但是在这个变量的作用域外,你就禁止访问该变量。 在 JavaScript 种,作用域一般是由函数或者代码块产生

让我们来看看变量 count 是怎么被作用域影响的, count 属于函数 foo() 创造的一个作用域内的变量 :

function foo() {
  // The function scope
  let count = 0;
  console.log(count); // logs 0
}

foo();
console.log(count); // ReferenceError: count is not defined

变量 count 在函数 foo() 内可被自由访问。但是在函数 foo() 外,count 变量就被禁止访问,如果你试图访问,就会抛异常 ReferenceError: count is not defined

“ 作用域的作用是规定变量是否可以被访问 ”

因为作用域隔绝了变量,所以在不同的作用域内,你可以取相同的变量名。比如不同方法内使用一些很通用的变量名( i,count,index,current,value 等等),示例如下:

function foo() {
    // "foo" function scope
    let count = 0;
    console.log(count); // logs 0
}

function bar() {
    // "bar" function scope
    let count = 1;
    console.log(count); // logs 1
}

foo();
bar();

变量 count 在函数 foo(),bar() 内都被定义了,而代码也良好的运行,并没有产生冲突

2. 作用域嵌套(Scopes nesting)

作用域很简单,对不对?那我们就进阶点难度,在一个作用域内嵌套另一个作用域会怎么样呢? 方法 innerFunc() 是嵌套在 outerFunc() 方法内:

这两个方法的作用域是怎么相互影响的呢?在 innerFunc() 函数的作用域内是否可以访问变量 outerVar 呢?我们用个例子来说明一下:

function outerFunc() {
    // the outer scope
    let outerVar = 'I am outside!';
    function innerFunc() {
        // the inner scope
        console.log(outerVar); // => logs "I am outside!"
    }
    innerFunc();
}
outerFunc();

从打印结果来看,函数 innerFunc() 作用域内可以访问变量 outerVar ,从这点可以推断以下2个结论:

  • 作用域可以嵌套
  • 外层作用域的变量可以被内层作用域访问

3. 词法作用域(The lexical scope)

JavaScript 是如何将函数 outerFunc() 作用域的变量 outerVar 映射到 innerFunc() 函数作用域内的呢?

因为 JavaScript 实现了一个作用域机制,名叫 词法作用域 (或者静态作用域) 。 词法作用域确定了变量的可访问性由变量所在源代码中的位置所决定 。简单一句话就是,词法域规定:在嵌套作用域的环境中,内部作用域可以访问外部作用域的变量

它被称为词法(或静态),因为引擎(在词法分析时)仅通过查看 JavaScript 源代码来确定作用域的嵌套,而不执行它 。下面是 JavaScript 引擎理解上面示例代码的过程:

  • 1. 我发现你定义了一个函数 outFunc(),该函数内包含一个变量 outerVar
  • 2. 在outFunc() 内部,我发现你定义了一个函数 innderFunc()
  • 3. 在innerFunc()内部,我发现一个变量 outerVar ,但是在该函数内部并未定义这个变量。因为使用词法作用域,那么我就认为,函数innderFunc()内部的变量 outerVar 其实就是 函数 outerFunc() 内的变量 outerVar

所以,词法作用域可概括为:

词法作用域由静态确定的外部作用域组成

示例如下:

const myGlobal = 0;

function func() {
    const myVar = 1;
    console.log(myGlobal); // logs "0"
    function innerOfFunc() {
        const myInnerVar = 2;
        console.log(myVar, myGlobal); // logs "1 0"
        function innerOfInnerOfFunc() {
            console.log(myInnerVar, myVar, myGlobal); // logs "2 1 0"
        }
        innerOfInnerOfFunc();
    }
    innerOfFunc();
}

func();

函数 innerOfInnerOfFunc() 的词法作用域由 函数innerOfFunc()的作用域,函数 func() 的作用域,以及全局作用域三部分组成。在函数 innerOfInnerOfFunc() 内你可以通过词法作用域访问 变量 myVar 和 myGlobal

函数 func() 的词法作用域 仅仅是由全局作用域组成,所以在函数 func() 内你可以访问全局变量 myGlobal ,但是并不能访问变量 myInnerVar

4. 闭包(The closure)

通过上面的知识,我们知道词法作用域允许静态地访问外部作用域的变量 ,这离理解闭包又进了一步

让我们回头再来看包含函数 outerFunc() 和 innderFunc() 的代码片段:

function outerFunc() {
  let outerVar = 'I am outside!';
  function innerFunc() {
    console.log(outerVar); // => logs "I am outside!"
  }
  innerFunc();
}
outerFunc();

我们知道,在函数 innerFunc() 作用域内,通过词法作用域可以访问外部变量 outerInner ,请仔细注意,函数 innerFunc() 的调用是在其词法作用域内(函数outerFunc() 的作用域)

我们来稍微改动一下,让 函数 innerFunc() 的执行在词法作用域范围之外,这样的话,函数 innderFunc() 是否依然能访问 变量 outerVar 呢? 代码片段如下:

function outerFunc() {
  let outerVar = 'I am outside!';
  function innerFunc() {
    console.log(outerVar); // => logs "I am outside!"
  }
  return innerFunc;
}
const myInnerFunc = outerFunc();
myInnerFunc();

现在,函数 innerFunc() 被执行的时候,是在定义函数 innerFunc() 的词法作用域之外了,重点来了:

函数 innerFunc() 依然能正常访问它词法作用域内的变量,即便它是在定义它的词法作用域外被执行

一句话,函数 innerFunc() 能通过词法作用域映射( 也可以理解成捕获,记住等) 变量 outerVar . 也就是说,innerFunc() 是个闭包函数,因为它通过词法作用域映射了变量 outerVar

所以呢,闭包就是一个函数访问其词法作用域内的变量,哪怕这个函数是在词法作用域外被执行

更简单点说,闭包就是一个函数记住了它定义处词法域内的变量以及变量的值,不管这个函数以后在哪儿被执行。所以确定闭包很简单:如果在函数中看到一个外来变量(未在函数中定义),那么该函数很可能是一个闭包,因为它捕获了外来变量

在上文的代码片段中, 变量 outerVar 对于闭包函数 InnerFunc() 来说,是个外来变量,它来自函数 outerFunc() 的作用域

让我们用更多例子来说明闭包吧

5. 闭包的例子(Closure examples)

5.1 事件句柄 (Event Handler)

让我们来显示一个按钮被点击了多少次:

let countClicked = 0;

myButton.addEventListener('click', function handleClick() {
  countClicked++;
  myText.innerText = `You clicked ${countClicked} times`;
});

打开示例 然后点击按钮,文本会更新你点击以后的次数。当按钮被点击以后,函数 handleClick() 会在DOM 代码中执行,这时已经远离定义它的位置了,但是因为闭包的原因,函数 handleClick() 通过词法域捕获了变量 countClicked 并刷新它的值。 当前, myText 其实也是通过词法作用域捕获的

5.2 回调(callbacks)

通过词法作用域捕获变量在回调中经常被用到,比如 setTimeout() 的回调方法

const message = 'Hello, World!';

setTimeout(function callback() {
  console.log(message); // logs "Hello, World!"
}, 1000)

函数 callback() 就是一个闭包,因为它捕获了变量 message . 又比如 forEach()的迭代器函数:

let countEven = 0;
const items = [1, 5, 100, 10];
items.forEach(function iterator(number) {
  if (number % 2 === 0) {
    countEven++;
  }
});
countEven; // => 2

函数 iterator 就是一个闭包,因为它捕获了 变量 countEvent

5.3 函数范式编程 (Functional programming)

柯里化函数就是调用一个函数会返回另一个函数 ,直到参数被完全提供。例子如下:

function multiply(a) {
  return function executeMultiply(b) {
    return a * b;
  }
}

const double = multiply(2);
double(3); // => 6
double(5); // => 10

const triple = multiply(3);
triple(4); // => 12

multiply 就是一个柯里化函数。对于函数范式编程来说,柯里化是个很重要的感念,而之所以能实现柯里化函数,还得感谢闭包。 excuteMultiply就是一个闭包函数,因为它从词法作用域捕获了变量 a , 当这个闭包函数被调用的时候,被捕获的变量 a 就和传入的参数 b 一起执行计算 a * b

6. 一个真实世界的闭包例子

我知道闭包很难掌握,但是一旦你get到它的点,你就永远掌握了它(就好比游泳,骑自行车),你可以根据如下的方式去理解闭包

想象一下你有一根神笔,如果你用它在纸上画现实世界的某些物品,那么这幅画就是跟这些物品相关联的窗口,通过这个窗口,无论你在世界的哪个角落,你都可以通过这幅画伸手进去移动这些物品,而真实世界里的这些物品也会被相应的移动。( 哇,这不就是一个任意门吗?好神奇 )

这幅神奇的画就是 闭包,而画里面的物品就是 词法作用域

7. 总结

作用域是 JavaScript 中变量是否可被访问的规则,可以是一个函数或一个范围块;词法作用域允许函数作用域从外部作用域静态地访问变量

bookmark_border[译文] JavaScript 之 Temporal Dead Zone

原文地址:Don’t Use JavaScript Variables Without Knowing Temporal Dead Zone

让我问你一个小问题,以下2个代码片段,你觉得哪一个会报错?

片段1:在定义类之前实例化一个类:

new Car('red');
class Car {
    constructor (color) {
        this.color = color;
    }
}

片段2:在定义方法之前调用该方法:

greet('World');
function greet(who) {
    console.log("Hello " + who);
}

正确答案是:片段1,报得错误是:ReferenceError: Car is not defined ;片段2能正确执行

如果你的答案不是片段1,或者你只是猜测而不是真的知道原因,那么你就得好好理解 Temporal Dead Zone( TDZ )

在 JavaScript 中,对于关键字 let ,const 和 class 来说,TDZ是个非常重要的东西

1. 什么是 Temporal Dead Zone?

让我们从一个最基础的变量定义开始,如果你先定义然后初始化这个变量,然后再访问这个变量,那么一切都会正常执行

const white = '#FFFFFF';
console.log(white); // 输出:#FFFFFF 

如果我们先访问这个变量,再定义的话

console.log(white); //抛异常:ReferenceError
const white = '#FFFFFF';

在表达式 const white=’#FFFFFF’ 之前的行, 就是变量 white的Temporal Dead Zone(禁制访问区域),在TDA访问 ‘white’会抛异常:ReferenceError: Cannot access ‘white’ before initialization

“Temporal Dead Zone”寓意定义变量之前,禁止访问这个变量。它给我们传递的信号是:未定义,勿访问

2. TDZ对表达式的影响

2.1 const 变量

如下例所示,const变量也受TDZ影响,在定义之前访问的话,会报错

pi; // throws `ReferenceError`
const pi = 3.14;

如果你在定义之后访问,则能正常执行

const pi = 3.14;
pi;

2.2 let 变量

let 变量受TDZ影响和cost变量一样

count; // throws `ReferenceError`
let count =10;

而在定义之后再访问,则正常

let count;
count; // => undefined
count = 10;
count; // => 10

2.3 class 类

如文章最开始的时候,在定义类之前,不能使用它

const myNissan = new Car('red'); // throws `ReferenceError`

class Car {
    constructor (color) {
        this.color = color;
    }
}

如果你想正确使用,那么记得在定义之后再访问

class Car {
    constructor (color) {
        this.color = color;
    }
}

const myNissan = new Car('red');
myNissan.color; // => 'red'

2.4 constructor()内的super()

如果你是继承了一个父类,那么在构造函数中,调用super()方法之前 ,this 就处于TDZ,请看如下代码:

class MuscleCar extends Car {
    constructor (color, power) {
        this.power = power;
        super(color);
    }
}

const myCar = new MuscleCar('blue', '300HP'); // `ReferenceError`

在构造函数内,在调用super()之前,this是不能使用的,TDZ 机制建议调用父类的构造函数来初始化实例,然后在子类的构造函数内对实例化的对象进行你想要的操作

2.5 函数参数

函数的默认参数处于一个全局作用域和函数作用域之间的 “中间作用域”( intermidiate scope ),这个中间区域的参数,依然要遵循TDZ的规则约束:

const a = 2;
function square(a = a) {
    return a * a;
}
square(); // throws `ReferenceError`

” function square(a = a) ” ,是想表达a = a 与 const a= 2 是处于不同的作用域中,所以可以变量名一致。而 a = a 这个表达式相当于如下代码:

a;
let a = ?; 

其中 a = a,可以分成两部分,第一部分是先执行右边,访问 a,拿到a的值,然后再执行左边 let a=?这个赋值操作。所以会出现定义之前访问 a 的问题。所以为了解决这个问题,可以把代码改成如下:

const init = 2;
function square(a = init) {
  return a * a;
}
square(); // => 4

3. TDZ对var,function,import 的影响

一句话,TDZ对这三个关键字定义的对象不起任何作用,因为他们会自动提升到当前作用域最前面。你在var变量定义之前访问它,不会报错,会得到undefined

value; // => undefined
var value;

同时,你也可以在方法被定义之前,直接调用该方法

greet('World'); // => 'Hello, World!'
function greet(who) {
  return `Hello, ${who}!`;
}
greet('Earth'); // => 'Hello, Earth!'

通常,我们对函数的实现是不太感兴趣的,这也是为什么有时候在函数定义之前调用它是有意义的。所以,import也能自动提升就是很有必要的

myFunction();
import { myFunction } from './myModule';

以上代码,能正常执行。但实际上,我们不建议这么做,import放在文件最开头是个好的编程习惯

4. 在TDZ获取变量类型

typeof 操作通用被我们用来判断当前作用域变量的类型,比如,一个名叫notDefined的变量,我们直接用typeof 去获取它类型的时候,不会报错:

console.log(typeof notDefined); //=> undefined

因为这个变量未定义,所以 typeof notDefined相当于undefined. 但是当你在TDZ区域使用 typeof 的时候,就会报引用错误,代码如下:

typeof variable; // throws `ReferenceError`
let variable;

所以,总体来说,在定义变量之前,任何访问该变量的操作都是不允许,会抛引用异常的

5. 不同作用域内的TDZ

TDZ只影响当前变量所在的作用域:

让我们来看一个详细例子:

function doSomething(someVal) {
  // 函数作用域
  typeof variable; // => undefined
  if (someVal) {
    // if内部块作用域
    typeof variable; // throws `ReferenceError`
    let variable;
  }
}

doSomething(true);

通过这个示例我们能很明确的知道,一个变量的TDZ只对它当前所在的作用域有效果

6. 总结

TDZ是影响 const、let 和 class 语句可用性的重要概念,它不允许在声明之前使用变量。相反,如果想在声明之前就可以使用变量,那么你可以继续使用var来兼容。不过你应该避免那样做。因为在我看来,TDZ是语言规范中良好的编码实践之一

bookmark_border[译文] JavaScript 之 函数

“ 函数是 JavaScript 的精髓 , 同时也是这门语言的魅力所在 ” —— Douglas Crockford

原文地址:The power of JavaScript Functions

函数( Functions )可以说是 JavaScript 中最强大的对象,其他编程语言中很多不同的特性功能,比如:程序( procedures ),方法( methods ),构造函数( constructors ) 甚至是 类( classes )和 模块( modules ) 等 ;所有这些,在 javaScript 中都可以用函数实现

在最新版的 Javascript 中,引入了类( Class ) 、方法( method )和构造函数( constructor )等概念,其实这些只不过是用函数包装的语法糖

一旦你理解了函数的细节微妙,你其实就掌握了相当一部分 JavaScript ;当然,你还得学习在不同的作用域有效的使用函数,才能更上一层楼

本文的目的就是为你揭开函数的神秘面纱,让你不仅看到它的表象,更能洞彻它的精髓

函数对象(Functions as objects)

Javascript 的一个重要的特性:函数是一种特殊类型的对象。声明一个函数和声明一个对象其实没什么两样,因为声明一个函数就是产生一个函数对象

函数对象是第一类对象( first classed object ) : 它可以当作参数来传递,可以是其他函数返回的对象,可以把它赋值给一个变量,也可以存储在对象或者数组中。它继承自Function.prototype ,所以函数还可以直接调用 bind() , apply() 和 call() 方法

将函数看作是一个特殊类型的对象,对有其他编程语言经验的人员来说,可能是个不小的挑战;但是一旦你接受了这个设定,则对于理解JavaScript非常有帮助

函数结构(Function structure)

声明一个函数有如下两种方式:

var sum = function(a, b) { //函数表达式
     return a + b; 
}

function sum(a, b) {  //函数声明
    return a + b;
}

第 1 种方式,函数名是可选的(上面的示例就没有函数名);第 2 种方式,必须要有函数名

另一个值得注意的是,JavaScript没有”函数签名(function signature)”的概念,你可以传入零个或者多个参数去调用函数,而不用担心会报错;如果函数需要的参数你没传入,那么参数值默认就是undefined 。如果你想知道传入的参数是什么?你可以通过 arguments 对象来查看。示例如下:

function sum(a, b) {
    for (let i = 0; i < arguments.length; i++) {
        console.log("argument: " + arguments[i]);
    }
    console.log("sum: " + (a + b));
}

sum();
sum(1, 2, 3);

输出结果:

sum: NaN
argument: 1
argument: 2
argument: 3
sum: 3

函数没有明确要求一定得返回什么,如果一个函数没有明确返回的对象,那么默认返回 undefined. 谨记:

  • 函数签名概念在js中不存在 —— 函数可以被传入任意参数调用
  • 函数永远会返回一个值 —— 如果没明确提供返回的值,那么返回undefined(构造函数除外,它一定会返回新对象)

变量作用域(Variable scope)

变量作用域决定了变量的可访问性(可见性) , 在JavaScript中,有两种作用域—全局(global)和本地(local 主要是只函数内的 )

当一个函数被调用,那么一个作用域就形成了,本地变量只能在本地作用域内被访问

当使用var声明一个变量时,它会自动添加到最直接的可用范围中。在函数中,最直接的可用范围是函数的上下文环境。由于局部变量只能在函数内部识别,所以同名的变量可以用于不同的函数中

Javascript的一个特殊性是它没有块级范围。块级范围意味着如果一个变量在一个块内声明(for、while、if等),那么它只能在块内访问,不能在块外访问。例如,你可能期望这段代码能正常工作:

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
function print_matrix(matrix, n) {
    for (var i = 0; i < n; ++i) {
        row = matrix[i];
        for (var i = 0; i < n; ++i) {
            console.log(row[i]);
        }
    }
}

print_matrix(matrix, 3);

输出结果:1 2 3 问题出在哪儿呢? 因为第一个 for 的变量 i 每次都会在第二个for中重新赋值,这个函数将在第一行之后退出。 为了解决这个问题,在最新版本的Javascript (ECMAScript 6)中引入了关键字let。在块内部使用let声明的变量将具有块级范围。所以简单地改变var,上面的例子就能按照你的期望执行

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
function print_matrix(matrix, n) {
    for (let i = 0; i < n; ++i) {
        row = matrix[i];
        for (let i = 0; i < n; ++i) {
            console.log(row[i]);
        }
    }
}

print_matrix(matrix, 3);

变量提升(Variable hoisting)

提升是Javascript中的一种默认行为,它表示在代码执行之前将所有变量的声明移到其作用域的顶部。所以在函数中声明一个变量并不重要,变量从执行上下文开始就属于那个范围。但是,提升只针对变量声明,而不是变量赋值。所以在到达变量被显式赋值的那一行之前,变量的值是undefined

function myFunction() {
    console.log(myVar); // undefined
    var myVar = 3;
    console.log(myVar); // 3
}

myFunction();

因为变量提升,所以上面的代码相当于下面这段代码:

function myFunction() {
    var myVar;          // 变量定义会被提升到最顶端
    console.log(myVar); // 此时变量并未赋值,所以输出undefined
    myVar = 3;          // 变量赋值不会提升
    console.log(myVar); // 3
}

myFunction();

这适用于任何变量和函数表达式,但是,函数表达式和函数声明之间有一些细微的区别。提升会把声明移动到当前作用域最顶端,但是不包括赋值(初始化),在赋值(初始化)之前,变量的值是undefined

函数表达式 vs 函数声明

在继续之前,我有一件事要坦白。文章开头引人注目的引语是不完整的。我漏掉了一部分。让我们看看完整版:

“ 函数是 JavaScript 的精髓 , 同时也是这门语言的魅力所在。当然,像JavaScript中的其他东西一样,它很难确切的掌握 ” —— Douglas Crockford

就比如函数,就有两种声明函数的方法,它们之间还有细微的差别,这就是“不太确切”的一个例子

区别在于浏览器如何将它们加载到执行上下文中。函数表达式的加载方式与其他变量完全相同。当编译器到达这行代码时,它们被赋值(在本例中是函数对象)

但是,函数声明在执行任何代码之前加载。因此,即使函数在调用它的代码行下面声明,它也能工作。谨记:

  • 当你调用一个函数表达式,而它的声明代码在你调用的代码后面时,会报错
  • 当你调用一个函数,哪怕这个函数在你调用的代码后面声明,它依然能正确执行(因为它在编译之前会被提升到当前作用域前面)

为什么函数有这两种定义?

问的好,函数声明是在ECMAScript 3中引入的,它让我们记住Javascript是浏览器的语言,而用户很少更新浏览器。很多时候,浏览器的新版本只有在用户更新操作系统时才会接触到他们。所以为了考虑兼容性,就有了这两种定义方式

如前所述,从数据类型的角度来看,每个函数都是一个对象。函数对象有三个非常重要的属性来表示函数的身份: this 、arguments 和 prototype

this 是啥?

关于JavaScript的 this ,可以写一篇很长的文章,此处不打算详细介绍。只要记住this 是指向调用它的对象, 所以 this 的值在函数调用时分配

当从全局作用域简单地调用一个函数时,this 显然会指向全局作用域本身。这是造成混乱的一个重要原因,这就是为什么在严格模式下运行时,值是未定义的。通常,您不会对这样的函数使用 this , This 对于用作方法的函数非常有用。这样,该方法就可以访问对象属性

var myObj = {
    name: "John",
    sayName: function (name) {
        console.log("Hi, " + this.name); // 'this' 指向 myObj 
    }
};

myObj.sayName();

“This”在函数用作构造函数时也有重要作用。构造函数没有任何特殊的结构。当使用运算符new调用它时,它是一个构造函数。该操作符在底层创建一个新对象,并通过’ This ‘将其传递给函数

function Car(name) {
   this.name = name;
}

var myCar = new Car("Subaru");
console.log(myCar.name);

在上面的例子中,函数Car被用作一个函数。用大写字母声明它是一种常见的做法,目的很明显,但它只是一个简单的函数

执行new关键字,其实执行了如下步骤:

  • 创建一个空对象
  • this指向新创建的对象,给新对象增加name属性并赋值
  • 将Car函数的prototype赋值给新对象的prototype
  • 返回创建的对象

每个函数都可以访问从 Array.prototype 继承的.apply()、.bind()和call()。它们允许使用显式设置的’ this ‘的任何值来调用函数。一般来说有以下四种方式调用一个函数:

  • function form (‘this’ will be the global object or undefined in strict mode)
  • method form (‘this’ will reference the object that made the call)
  • constructor form (‘this’ will be the new object)
  • apply/bind/call form (‘this’ is explicitly set)

函数参数(Function arguments)

如前所述,声明中提供的参数不是可靠的信息来源。可以使用不同数量的参数调用该函数。如果它们太多,则忽略额外的参数。如果它们太少,丢失的值将是未定义的。

幸运的是,函数的arguments属性在这里可以帮助我们。它是一个类似数组的对象,包含调用时提供的参数。注意,它并不是一个真的数组,因为它不是从数组继承的,它唯一能调用的就是获取数组的长度,参数的存储方式与数组完全一样,索引从0开始

虽然arguments对象不是只读的,但是强烈建议不要修改它。更改arguments对象也会影响已命名的参数,这很容易引起混淆。

它的一个常见用法是创建具有未定义数量的参数的函数。例如,一个函数返回它的参数和:

function sum() {
    var i;
    var total = 0;
    var n = arguments.length;
    for (i = 0; i < n; i++) {
        total += arguments[i];
    }
    return total;
}
console.log(sum(1, 2, 3, 4)); // 10 

记住:参数对象是关于函数调用时提供的信息的惟一可靠来源,要将参数视为只读结构

函数原型(Function Prototype)

函数的原型对象只有在函数用作构造函数时才有意义。它在 Javascript 中创建继承时起着关键作用。通过它,使用相同构造函数创建的对象可以共享相同的属性和行为

原型继承是一个庞大的主题,超出了本文的范围,以后有时间再详谈