前端缓存解决方案

前言

缓存对页面性能的提升是众多优化手段中见效最明显的,也是面试中经常会问到的高频题,缓存再实际项目中如何应用的? 大型项目中又是如何使用缓存的?

本文将讨论的缓存分为两种,一种是跟后端交互通过http字段头配置的缓存(强制缓存,协商缓存)这种能加快资源再次防问的速度,另一种是能加快资源首次防问速度的缓存(CDN,DNS),并对此给出一套解决方案。

协商缓存

协商两字指的是每次请求这个资源时,都问一下服务器(协商一下)有没有更新过,如果有更新那么就取服务器最新的资源,否则就返回一个304状态码告诉浏览资源没有更新过表示取本地缓存中的资源即可。

可见这种方式是由服务器决定缓存内容是否失效。

实现细节

前后端交互中协商缓存使用两组字段进行控制Last-Modified & If-Modified-Since,Etag & If-None-Match,后者的优先级高于前者。

Last-Modified & If-Modified-Since

  1. 请求一个新资源时,服务器会往响应头写入Last-Modified字段,格式是时间。

  2. 浏览器将资源和Last-Modified字段保存在本地。

  3. 下次请求这个资源时往请求头中添加If-Modified-Since字段,字段值是上次保存的Last-Modified

  4. 服务器收到If-Modified-Since这个字段跟资源的最后修改时间进行对比。

  5. 如一致则返回304表示没修改过取本地资源即可,否则返回200并将最新的资源反回给浏览器。

Etag & If-None-Match

Last-Modified & If-Modified-Since 这种方式有个缺陷,因为它们是通过时间来对比文件是否更新的,如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒,而Etag & If-None-Match是通过文件内容计算出来的hash值确定文件是否更新的。

两种方式只是对比文件更新方式上有所不同,其它逻辑一致,Etag对应的是Last-ModifiedIf-None-Match对应的是If-Modified-Since

强制缓存

所谓强制缓存就是当请求到一个静态资源,我们给它设置一个过期时间,下次再请求这个资源时进行对比,如果当前时间小于设置的过期时间,那么就使用缓存,这种方式相对协商缓存,直接减少了http请求。

实现细节

前后端交互中强制缓存使用的是ExpireCache-Control两个这段进行控制,前者是http1.0的标准,后者http1.1的标准,前者已逐渐被后者代替。

Expire

Expires: Thu, 11 Nov 2020 00:00:00 GMT

浏览器请求一个新资源时,服务给响应头设置这么一段,表示2020年11月11号之前请求这个文件都使用缓存中的,这个字段有个缺点,因为它是在服务器设置的,如果客户端的时间跟服务器的时间不一致那么缓存生效时间就不精准,因为它设置的是绝对时间。

Cache-Control

这个属性具有多个可选参数,参数不区分在小写,多个参数以逗号分隔,以下是常用的几个参数:

  • public 表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)

  • private 表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它),私有缓存可以缓存响应内容,比如:对应用户的本地浏览器。

  • no-cache 在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证(协商缓存验证)。

  • no-store 缓存不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存。

  • max-age 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。

以上属性从MDN复制来的,要查看更多属性点这里!

我们来看下一个最重要的属性 max-age 它与 Expire 一样都是设置过期时间,但前者设置的是相对时间,就不会有客户端跟服务器时间不一致而产生的bug了:

Cache-Control:public, max-age=31536000

实际项目中怎么用?

直观上可以看到强制缓存比协商缓存性能要更好,为什么? 因为协商缓存每次都要发送一个http请求询问服务器,而强制缓存一但缓存了就再也不向服务器请求这个资源了。

等等 … 再也不请求了 ???

那服务器的资源要是更新了怎么办 ?

举个例子,如果服务器的API更新了一个版本,前端资源也进行了相应的更新,但是使用了强制缓存就没办法了,服务器更新了资源前端还是取本地缓存的,那缓存文件里用的还是旧版本的API岂不是炸了? (当然这只是个例子,实际开发中服务器API肯定会向后前兼容的,比如说发布了新版本地址是 v2 /xxx 老版本地址还是 v1/xxx)。

 

想像一下之所以每次都会使用本地缓存中的资源是不是因为浏览器根据这个资源名,在本地缓存中找到了对应名字的资源了?

那我把资源的名字改掉它不就在缓存中找不到了吗? 没错如果服务的资源更新了就重新换个名字,那我们前端就不会再到缓存中去取了。

通过上图可以看到名字变了就不会取缓存中的了,但是a.html不能使用强制缓存,因为a.html是入口文件,入口文件的文件名变了别人就防问不到你的页面了。

我们可以总结出: 入口文件使用协商缓存,页面内的资源文件使用强制缓存。

webpack

上文说到了资源文件更新后每次都换一个新名字,这个名字是根据文件内容的摘要信息计算出来的一个hash值,文件内容变了名字就变,前端看到名字变了就请求服务器,没变就取缓存,这样挺好的.

但你有没有想过这样一个场景 A.CSS 依赖了 B.CSS , 而B.CSS又依赖了 C.CSS,当
C.CSS文件内容发生改变C.CSS文件名就变了,那么B引用了C,B的内容变了B的文件名也要变,A同理。

这种操作不可能手工去改,所以就引出了我们的神器 webpack ,使用webpack可以自动帮我们生成文件hash处理依赖关系。

CDN

前面介绍的缓存策略只能加快资源的再次防问速度,使用CDN可以加快我们首次防问资源的速度,CDN全称Content delivery networks(内容分发网络),其目的是通过在源服务器和用户之间增加一层新的网络架构,将网站的内容分发到最接近用户的网络“边缘”,使用户可以就近取得所需的内容,提高用户访问网站的响应速度。

说的是通过在各地部署很多缓存服务器,然后将你的请求指派到最近的缓存服务器上,从而提高防问速度,类似于你取快递时的快递代收点,你总是会去离你最近的代收点去取快递。

下图说明使用CDN前跟没使用CDN前的区别:

可以看到有CDN的情况下北京的用户直接将请求发给了离自己最近的武汉服务器,而没有CDN则发给了香港的源服务器。

我们实际开发中会把静态资源放到CDN服务器(这里指的不是将静态资源上传上去,而是将静态资源的地址改成cdn服务器的地址,让cdn去找到源服务器取这个文件,取到了就缓存下来),这些静态资源包括js、css、img等等… 但是页面的入口文件url不能改成cdn服务器的, 因为如果入口文件被缓存了,那么页面就更新不了了。

总结

实际工作中 ,我们将协商缓存强制缓存结合使用,将静态资源使用强制缓存,页面入口文件使用协商缓存,强制缓存通过文件摘要生成hash当文件更新文件名也更新解决服务器更新本地仍使用旧资源的情况,使用webpack为我们做这部分工作,在些基础上还可以部署cdn服务,将静态资源使用一个单独域名指向dns服务器,dns再指向你的源服务器,通过dns来缓存提高首次防问速度。

接入Https(实践篇)

前言

上篇文章介绍了https的工作原理,这篇文章介给如何让自己的网站支持https,相比理论知识实践起来简单的多,只需申请安装证书再配置下服务器即可。

环境

  • Centos7.6(腾讯云服务器)
  • Nginx

申请证书

SSL证书分为三种认证级别:

  • 域名认证(Domain Validation):最低级别认证,可以确认申请人拥有这个域名。对于这种证书,浏览器会在地址栏显示一把锁。

  • 公司认证(Company Validation):确认域名所有人是哪一家公司,证书里面会包含公司信息。

  • 扩展认证(Extended Validation):最高级别的认证,浏览器地址栏会显示公司名。

因为我的域名云服务器都是在腾讯云买的所以也在腾讯云申请SSL证书,打开腾讯云SSL证书控制台申请免费的DV域名认证证书:

域名这一栏填你网站的域名,一张证书只能对应一个域名,如果你的API接口(api.xxxxx.xxx)也要接入HTTPS的话还需要再申请一张证书。

安装证书

将下载来的证书压缩文件解压:

我使用的http服务器是Nginx,所以将nginx文件夹中的两个文件通过FTP工具复制到服务器的任意位置(记住路径)。

接下来找到你的nginx配置文件,我的nginx配置文件在 /etc/nginx/nginx.conf 你的路径可能根我不一样但都是 nginx.conf 这个文件。

执行vim nginx.conf 修改这个文件, 在http块中添加这一段:

   server {
       listen       443 ssl http2 default_server;
       listen       [::]:443 ssl http2 default_server;
       server_name  *.liaowei.info;

       ssl_certificate "/xxxx/xxxxx/1_liaowei.info_bundle.crt";
       ssl_certificate_key "/xxxx/xxxxx/2_liaowei.info.key";
       ssl_session_cache shared:SSL:1m;
       ssl_session_timeout  10m;
       ssl_ciphers HIGH:!aNULL:!MD5;
       ssl_prefer_server_ciphers on;

       location / {
           proxy_pass http://127.0.0.1:5252;
       }
   }
  • 第2行监听了 443 端口, https默认监听的是443端口,http默认监听的是80端口,比如在浏览地址栏输入 https://xxxxx.xx 最终会解析成域名邦定的ip后面跟上443端口号,要是输入的是 http 开头的解析后的ip后面会跟上80端口。

  • 第6行跟第是行是你刚才证书文件存放的路径。

  • 第14行表示将这个请求代理到你本地起的一个服务中。

好了,至此你的网站就可以通过https防问了,浏览器地直栏也会带上一把锁:

全部替换

如果你的网站是通过https防问的,但你的页面引用了http开头资源(如http开头的图片,http开头的API),那么这些资源可能会防问不了,会报如下错误:

解决办法是让这些静态资源全部支持https,如果已经支持了那好办,只需要把所有的http替换成https即可,页面太多替换起来太麻烦? 没问题只需在页面的head中加入:

<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

意思是浏览器会自动帮你替换成https的前缀,前提是你的资源服务器要支持https。

完!

上篇文章: 接入https(理论篇)

接入Https(理论篇)

前言

HTTPS (全称:Hyper Text Transfer Protocol over SecureSocket Layer), 相比于HTTP多了个s(SecureSocket Layer),这个s指的是SSL/TSL,因HTTPS 在HTTP 的基础下加入SSL/TSL层,也被称之为HTTP的安全版。

http的缺陷

http传输数据是明文传输的,也就是未加密的,通过简单的抓包手段可以轻易的查看到传输的数据、以及修改传输的数据,甚至可以拦截请求冒充服务器。

https为了解决这点加入了SSL/TSL层:

SSL/TLS

SSL(Secure Sockets Layer 安全套接字协议)、TSL(Transport Layer Security 传输层安全)

TLS是SSL的继任者,两者差别不大,下文统一用SSL来表示。

在讲SSL流程之前先来看看两个加密方式:

对称加密与非对称加密

对称加密

对称加密指的就是加密和解密使用同一把钥匙,用钥匙A加密的只能用钥匙A解开。

非对称加密

对称加密用的是A和B两把钥匙,用钥匙A加密的只能用钥匙B解密,用B加密的只能用钥匙A来解密,
这种方式相对非对称加密非常安全,一把钥匙放在自己手里用来解密,一把钥匙给别人用来加密,即使数据被黑客拦截也没用因为解密的钥匙在自己手里,著名的RSA加密算法用的就是非对称加密。

对比

非对称加密比对称加密要安全,但加解密计算量太大如果https每次请求都使用非对称加密会影响请求速度,所以SSL中的实现是将两种加密方式结合起来。

SSL/TLS运行过程

经过上图4次会话后面所有传输都将使用普通的http协议,只过不所有转输的内容都是经过加密后的,下面分析上图的几个重点:

如何校验证书真伪?

图中第二步服务器将证书发送给客户端,客户端拿到证书如何校验真伪?

  1. 服务器向一个有公信力的认证中心(CA)申请一本证书,证书上有网站的域名等信息

  2. 证书在传输过程中可以放个人信息,将这些信息使用hash生成一个签明,再在对签明使用以CA机构提供的私钥进行非对称加密

  3. 客户端收到证书会先看有没有过期,以及是不是这个域名的证书

  4. 然后会在操作系统找到对应的证书的公钥进行解密,得出hash签名,再将证书中的个人信息进行签明,两个签明一对比一致,说明证书合法。

服务器给的公钥放在哪里?

图中第三步通过公钥加密信息,这个公钥存放在哪里呢?

答: 为了防止公钥被篡改,所以将公钥存放在证书中,客户端验证证书有效,再对比hash签名一至则公钥就没有被篡改过。

为什么要使用会话密钥来加密数据?

答: 会话密钥顾名思义就是每次会话都会生成一个,之所以使用会话密钥加密数据而不使用公钥加密是因为它是对称加密速度快,公钥加密加密大量数据比较慢所以只用来加密会话密钥。

公钥加密用来传输密钥,会话密钥用来传输数据,SSL就是采用这两种方式的组合完成加密的。

完!

下篇文章: 接入https(实践篇)

JWT(实践篇)

前言

上篇文章介绍了jwt的组成包括安全方面的理论知识(JWT(理论篇)),这篇文章以java为例实现jwt令牌的颁发和验证接口。

本文不会从手写jwt生成跟,而是使用现有的内库来实现(现在的web应用很少有纯手写的了,都是跟拼积木一样,一个个插件组装起来的)。

引入jwt内库

在jwt官网有对应各种语言的jwt库安装介绍以及支持的签名算法: https://jwt.io

本文使用的是auth0这个库,在pom文件添加依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.8.1</version>
</dependency>

依赖安装好后接下来编写我们的第一个接口生成token:

颁发token接口

private static String getToken() {
    Algorithm algorithm = Algorithm.HMAC256(secret);  // 定义签名算法
    Map<String,Date> map = JwtToken.calculateExpiredIssues();  // 定义过期时间

    return JWT.create()
           .withClaim("uid", 1) // 存放在payload中的用户id
           .withClaim("nickname", "路过一只大侠") // 存放在payload中的用户昵称
           .withExpiresAt(map.get("expiredTime")) // 设置过期时间
           .withIssuedAt(map.get("now")) // 设置颁发时间
           .sign(algorithm); // 使用上面定义的算法对payload、header签名生成token
}
  1. 第2行定义jwt签名使用的算法这里使用的,参数是你定义的秘钥这个秘钥不能泄漏。

  2. 第6行 withClaim 这个方法用来定义payload中的内容存放业务数据,这个由你自己定义想放什么就放什么。

  3. 第10行 sign 这个方法使用上面定义的算法对payload、header进行签名防止数据被篡改。

  4. 第8行 withExpiresAt 用来定义令牌的过期时间, auth0这个库设置过期时间需要手动去计算一个未来的时间点,什么是未来时间点? 比如颁发令牌的这一刻是 2014-8-13 10:00:00,
    你想要2小时后过期你就需要手动的把两小时加上变成 2014-8-13 12:00:00, 这点相对其它的jwt内库有点笨,其它的库只要传入2小时就会自动帮你计算出过期时间。

    为了计算出过期时间我们在下面定义了 calculateExpiredIssues 方法,就是第二所调用的。

calculateExpiredIssues

// 反回当前时间加上过期时间后的时间
private static Map<String, Date> calculateExpiredIssues() {
    Map<String, Date> map = new HashMap<>();
    Calendar calendar = Calendar.getInstance();
    Date now = calendar.getTime();

    // 当前时间加上120分钟、两小时后过期
    calendar.add(Calendar.MINUTE, 120);
    map.put("now", now);
    map.put("expiredTime", calendar.getTime());
    return map;
}

校验token接口

这个接口返回布尔值,验证过true否则false。

    public static boolean verifyToken(String token) {
        DecodedJWT decodedJWT;
        Algorithm algorithm = Algorithm.HMAC256(secret); // 定义签名算法
        JWTVerifier jwtVerifier = JWT.require(algorithm).build();
        try {
            jwtVerifier.verify(token); // 比对数据签名是否一至
        } catch (Exception e) {
            return false;
        }
        return true;
    }
  1. 第2行 定义了跟颁发token接口一样的签名算法用于对前端传过来的token进行签名,再进行比对。

  2. 第6行 通过auth0库内部的verify方法对签名进行对比,一至说明token没有被改过,这个方法内部还会对过期时间进行判断,只要是签名不一致或是token过期了都会抛出一个异常,我们对这个方法进行try/catch,一旦捕获到异常就反回false表示校验失败,不然就反回true表示校验成功令牌有效。

获取payload中的值

获取payload中的值只需对 verifyToken 方法进行简单修改即可:

    public static Map<String, Claim> getClaims(String token) {
        DecodedJWT decodedJWT;
        Algorithm algorithm = Algorithm.HMAC256(secret);
        JWTVerifier jwtVerifier = JWT.require(algorithm).build();
        try {
            decodedJWT = jwtVerifier.verify(token);
        } catch (JWTVerificationException e) {
            throw new ForbiddenException(10004);
        }
        return decodedJWT.getClaims();
    }

auth0库内部的verify方法如果校验成功会返回一个 DecodedJWT 对象,这个对象下有个 getClaims 方法可以获取到包含payload数据的map对象。

测试

使用的postman进行测试:

getToken

verifyToken

上篇文章:(JWT(理论篇)

完!

JWT(理论篇)

前言

互联网服务离不身份验证,JWT一种跨域身份验证解决方案,全称JSON WEB TOKEN,在web中通过json格式组装数据传输的一种token。

JWT的特点

  • 无状态(token签发后服务器不会保留任何用户登录信息)
  • 可扩展 (token仅存储在客户端完全无状态,因此可以负载均衡传输到任何服务器)
  • 通用性 (jwt是一串加密的字符,只要能转输字符就能使用jwt)

JWT对比SESSION

jwt-png

session的工作方式

  1. 前端输入用户名密码通过http请求发送给服务器
  2. 服务器验证通过,在服务器本地保存一个session(里面写入了业务数据)
  3. 服务器返回一个session_id给前端,前端自动写入cookie
  4. 前端登录成功,下次发请求前端会自动带上有session_id的cookie
  5. 服务器拿到session_id去本地找到对应的session读取里面的数据

jwt的工作方式

  1. 前端输入用户名密码通过http请求发送给服务器
  2. 服务器验证通过,将业务数据(如用户的id,称昵等)通过base64进行编码生成token
  3. 服务器将token写入http请求响应中返回给前端
  4. 前端拿到token存储在本地,下次发请求时将token放在http请求中带给服务器
  5. 服务器收到token通过base64解密拿到业务数据
  6. 服务器进行签名对比,签名一致证明用户合法

 

明显的区别jwt存储在客户端,session存储在服务器所以不利于分布式多台服务器负载均衡(因为用户登录后只在一台服务器存储了session别的服务器上没有),需且还会占用服务器存储空间。

还有个硬伤session是依赖cookie传输session_id的,但有些客户端是不支持cookie的比如说小程序。

jwt长什么样?

如图jwt是一串由三个点分隔开的加密字符串,由 header(头部)、payload(负载)、signature(签名)组成。

header

header由一个json对象组成,里面包函两个字段:

{
  "alg": "HS256", // 表示token的类型是jwt
  "typ": "JWT" // 表示使用的签名算法是HS256
}

将这部分由base64编码组成jwt字符串的第一部分。

payload

payload也是由一个json组成,里面用来存放业务数据通常是用户的基本信息(用户id,用户权限)和一些用来描述令牌的数据(令牌的颁发时间,令牌的过期时间)。

jwt官方推荐了七个字段,但这不是必须的:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

我经常定义这几个字段:

{
  "uid": "HS256", // 用户的id
  "nickname": "bwx", // 用户的昵称
  "exp": "1608703180085" // 令牌的过期时间
}

将这部分由base64编码组成jwt字符串的第二部分。


注意:不要在JWT的payload或header中放置敏感信息,只需存储用户的唯一标识即可,因为payload、header部分只是通过base64将json格式的数据编码成字符串方便传输,客户端拿到这串字符编码回去即可看到里面的内容。

signature

signature是对header和payload进行签名防止数据被篡改。

签名的过程是将base64编码后的header、base64编码后的playload以及密钥secret通过header中指定的算法进行计算得到。

这个secret不能泄漏只能服务器知道。
 
因为如果客户端拿到token进行base64解码,然后修改前两部分的数据(header、payload)再用base64编码回去发送给服务端,服务拿到header、payload两部分加上secret加密得到signature,跟客户端传入的signature做对比发现不一致,因此就能得知数据被篡改了。

上图可看到jwt的组织结构。

token的过期时间设置多久好?

  • 面对极度敏感的信息,如钱或银行数据,那就根本不要在本地存放Token,只存放在内存中。这样,随着App关闭,Token也就没有了。

  • 此外,将Token的时限设置成较短的时间(如1小时)。

  • 对于那些虽然敏感但跟钱没关系,如健身App的进度,这个时间可以设置得长一点,如个月。

  • 对于像游戏或社交类App,时间可以更长些,半年或1年。

token泄漏别人可以冒充我登录吗?

别人拿到你的token通过http请求发送给服务器是可以冒充你的,但我们可以在服务器写一些逻辑预防这些:

  1. 在服务器为用户建立一个常用ip表存储用户常用ip,当你的token被盗取防问服务器的ip不再常用ip表中就让用户重新授权,有点类似于QQ的异地登录。

  2. 颁发令牌时将用用户的ip存储到jwtpayload中,当你的token被盗取防问服务器的ip跟payload中的ip不一至就让用户重新授权。

  3. token过期时间设置的短一些。

参考链接

http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html by 阮一峰

https://www.cnblogs.com/cjsblog/p/9277677.html by 废物大师兄

typescript之联合类型与类型保护

前言

联合类型与类型保护两者通常结合使用,因为在开发时typescript无法确切的知道一个联合类型具体是哪个类型,所以需要使用类型保护方式明确的指明。

联合类型

联合类型语法为: Type1 | Type2 | Type3

将指定的类型使用 | 管道符分割开,表示取值类型只能为这些类型。

    let unionType: string | number

    unionType = 21
    unionType = '字符串'
    unionType = true  // 报错!!! 布尔值不在指定的类型当中

可见设置unionType的变量类型为 string | number, 第5行因给变量赋值为布尔值所以报错了。

也能将联合类型作用于函数参数、函数返回值、数组使用:

    // 作用于函数
    function myFunc(param: string | number) : string | number {
        return 123 
        return '字符串'
        return true // 报错!!! 布尔值不在指定的类型当中
    }

    // 作用于数组
    let myArr: string[] | number[]
    myArr = ['字符串']
    myArr = [123]
    myArr = [true] // 报错!!! 元素只能全是string或者全是number类型。

 

当typescript不知道一个联合类型具体是哪种类型的时候,只能防问联合类型中所有类型的公共属性或方法。

什么意思呢? 看下面的例子:

这个变量是string跟number的联合类型,并没有给它赋值,所以准备调用它的方法时只能调用 string跟number都共有的方法。

 

为什么这里能防问到字符串的方法呢? 因为typescript有个类型推断的机制,当你给这个类型赋值为一个字符串时,聪明的typescript就已知道这个变量是字符串类型了。

联合类型的问题

问题来了〜 前文说到 :

当typescript不知道一个联合类型具体是哪种类型的时候,只能防问联合类型中所有类型的公共属性或方法

当我们将联合类型作为函数的返回参数时,外部并不能知道我们反回的到底是哪个类型,因为一个函数内部根据条件分支可能会有多个 return 语句, 如下例:

    function getValue(isNumber: boolean) : number | string {
        if(isNumber) {
            return 123
        } else {
            return '字符串'
        }
    }

    let value = getValue(true);
    value.toFixed() // !!! 报错

这是一个反回类型为number或string的联合类型函数,第9行调用函数传入 true,我们很清楚的知道会返回一个number类型的123, 但是typescript不知道,因为typescript并不知道函数体里写的是什么,所以第10行调用number类型特有的toFixed方法报错了。

如何让typescript知道或者说如何告诉typescript value变量的具体类型呢?

使用类型保护解决

类型保护是一个抽象的词,具体有多种方法来实现。

1.类型断言

    let value = getValue(true);
    (value as number).toFixed()
    (value as number).toExponential()

使用类型断言告诉typescript,value这个变量就是number类型的。

类型断言:有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。 通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。

2.类型谓词

使用类型类型断言当只能针对一次防问语句,多次防问需要断言多次很麻烦,使用类型谓词可以在之后的某一作用域判断都有效:

    let value = getValue(true);

    function isNumber(value: string | number): value is number {
        return (value as number).toFixed !== undefined
    }

    if(isNumber(value)) {
        value.toFixed()
        value.toExponential()
    } else {
        value.length
        value.charAt(1)
    }

我们需要先定义一个函数 isNumber, 函数内进行断言判断,函数返回值是一个类型谓词如果函数内返回true表示这个value是number类型。

经过判断后第7行中的if作用域中能清楚的知道这是一个number类型的变量,而且第10行else作用域中也能明白这是一个string类型的变量,因为这个复合类型只有两种选择不是number就是string, 如果这个复合对象不止两种类型则不能这样做。

3.typeof 类型保护

使用类型谓词需要定义一个函数也挺麻烦的, typescript中使用typeof能用于类型保护:

    let value = getValue(true);

    if(typeof value === 'number') {
        value.toFixed()
        value.toExponential()
    } else {
        value.length
        value.charAt(1)
    }

使用typeof进行类型保护只能判断基本类型: numberstringbooleansymbol, 操作符只能是 ===!==,也可以不符合这些条件,但typescript不会识别成类型保护。

4.instance 类型保护

对于引用类型可以使用instance来判断,同样typescript也会将它识别成类型保护:

    function getValue(isNumber: boolean) : Number | String {
        if(isNumber) {
            return new Number(123)
        } else {
            return new String('字符串')
        }
    }

    let value = getValue(true);

    if((value instanceof Number)) {
        value.toFixed()
        value.toExponential()
    } else {
        value.length
        value.charAt(1)
    }

注意 getValue 函数中的number跟string都变成了以new的方式生成的引用类型。

javascript之prototype

前言

本文将围绕 prototype, __proto__, constructor 三个属性展来,它们的关系如下图:

可见 prototype 对象 是构造函数 myFunc 的一个属性,它有个默认属性 constructor 指向 myFunc 构造函数,而 myFunc 的每个实例对象都有个 proto 属性它指向 myFunc 的prototype,下文将逐个分析它们。

prototype

prototype 这个属性是函数独有的,当你声明一个函数JS引擎会自动为这个函数加上 prototype 属性:

function myFunc() {
  ...
}

console.log(myFunc.prototype)

在chrome控制台打印出来可以看到:

可见这个 prototype 对象内部有两个属性 constructor 跟 proto ,而且可以看到constructor指向的是它的构造函数 myFunc,proto属性指向的是JS的顶层对象 Object。

proto

这个属性是抽像的,使用中并不会去显示调用它,它由ES标准定义由浏览器厂商自己实现,在chrome中它叫 __proto__, JS中每个对像都有这个属性,它指向了当前对象的构造函数的prototype

实际作用是防问当前对象属性时,若不存在则会去 proto 上去找,而 proto 又指向了当前对象的构造函数的prototype,所以就是当一个实例不存在某个属性时会往构造函数的 prototype 上查找。

function myFunc() {

}

myFunc.prototype.name = 'bwx'

var f1 = new myFunc()

console.log(f1.name) // bwx

实例 f1 并不存在 name 字段但是也输出了字符串 ‘bwx’ , 因为它通过 proto 属性防问到了构造函数 myFunc 的 prototype 上的 name 属性。

优先级

实例上的属性优先级要高于 prototype 上的属性。

function myFunc() {
    this.name = 'mit'
}

myFunc.prototype.name = 'bwx'

var f1 = new myFunc()

console.log(f1.name) // mit

可见实例上已经存在name属性了而不会再去 prototype 上查找了,所以输出了字符串 ‘mit’。

相同引用

同一个构造函数 new 出来的实例引用的 prototype 都是同一个对象, 也就是说一个构造函数的 prototype 只会在内存中创建一次。

function myFunc() {

}

myFunc.prototype.num = 0

var f1 = new myFunc()
var f2 = new myFunc()

f1.__proto__.num ++
f2.__proto__.num ++

console.log(f1.num) // 2
console.log(f2.num) // 2
console.log(f1.__proto__ === f2.__proto__) // true

可见第15行 f1、f2 两个实例的 prototype 完全相等,prototype上的num初始值是 0,我们分别对 f1、f2的 proto .num 进行了一次自增,最后输出它们的 num 值都是 2,因为自增操作的对象是 prototype。

 

这里为什么对实例的 proto 下的num自境,而不是对实例的 num 自增,是因为实例上的属性优先级要高于 prototype 上的属性,如进行 f1.num ++ 操作相当给f1实例添加了一个 num 属性,从而会优先防问实例上的 num 属性, 而不会操作原型上的 num 属性。

function myFunc() {

}

myFunc.prototype.num = 0

var f1 = new myFunc()
var f2 = new myFunc()

f1.num ++
f1.__proto__.num ++
f2.__proto__.num ++

console.log(f1.num) // 1
console.log(f2.num) // 2

可见 f1 的 num 进行了两次 ++ 操作可值还是 1,因为第10行,给实例本身添加了一个 num 属,会优先使用实例本身的 num,而第11、12行操作的是实例共享的 prototype 上的属性,一共对 prototype 上的 num 自增了两次,所以 f2 的 num 值是2。

总结

一个构造函数的 prototype, 被它的所有实例同享,构造函数体中的属性为每个实例独有,因此实际开发中,我们可以将不涉及数据的方法放到 prototype 中, 将存储数据的属性放到构造函数体中。

function myFunc() {
    this.num = 0
}

myFunc.prototype.increment = function() {
    this.num ++
}

var f1 = new myFunc()
var f2 = new myFunc()

f1.increment()
f2.increment()

console.log(f1.num) // 1
console.log(f2.num) // 1

这样避免了实例数据相互干扰,实现了方法复用。

javascript之深浅拷贝

前言

本文先给出值类型跟引用类型的区别列出哪些是值类型哪些是引用类型以及它们在内存中如何存放的,再聊聊计算机为什么需要引用类型,最后对引用类型实现深拷贝。

值类型跟引用类型

javascript中值类型跟引用类型有:

值类型: number, string, boolean, symbol, undefined
引用类型: object, null, array

它们的区别在于应用跑起来时在内存中的存放方式不同,值类型变量的数据直接存放在栈区,引用类型的变量数据存放在堆区(这个堆跟数据结构中的堆不是一个东西), 通过一个地址来实现对数据的引用:

let obj1 = 'liaowei'

let obj2 = {
    name: 'liudehua',
    age: 60
}

let obj3 = obj2
let obj4 = obj1

obj3.age = 20
obj4 = 'guofucheng'

console.log(obj1) // liaowei
console.log(obj2.age) // 20
console.log(obj3.age) // 20
console.log(obj4.age) // guofucheng

代码运行后在内存中的模型图:

可见obj2、obj3两个引用类型指向的都是堆中的一个数据,所以对obj3进行修改后obj2也相应的发生了变化,而obj1、obj4都是值类型直接存储在栈区中因此两者并不存在引用关系。

程序为什么要区分堆区跟区呢?

因为引用类型的数据量比较大,频繁赋值对性能有影响所以将其存放在堆中赋值时使用浅拷贝直接引用,如果用户有需要可以手动进行深拷贝。

深拷贝

1.递归实现深拷贝

function deepCopy(obj) {

    if(typeof obj !== 'object' || obj === null) {
        return obj
    }

    let result
    if(obj instanceof Array) {
        result = []
    } else {
        result = {}
    }

    for(key in obj) {
        result[key] = deepCopy(obj[key])
    }

    return result
}

该函数有两个return语句,一个返回值类型的值,一个返回引用类型的值,如果当前考察的对象是引用类型,对其属性进行遍历调用自身实现递归,如果当前考察对象是一个值类型直接反回其本身结束递归。

2.使用JSON.stringify实现深拷贝

let obj2 = JSON.parse(JSON.stringify(obj1))

使用stringify方法先将对象转换成JSON字符串,再将字符串解析成一个新的JSON对象,这种方法如果对象中存在函数或是undefined,则序列化的结果会把函数或undefined丢失。