Giter VIP home page Giter VIP logo

blog's Introduction

蝉時雨

Proactive Front-End Developer with 7 years of experience in designing, developing, and maintaining front-end web applications. Proficient in HTML5, CSS3, JavaScript, and TypeScript. Experienced in using web frameworks such as Vue.js, React.js, Node.js and Next.js, along with various JavaScript libraries. Skilled in version control systems and source code management tools like GIT and SVN.

Collaborates closely with designers to ensure accurate implementation of UI components, layouts, and visuals. Develops user-friendly web pages and features to optimize user experience effectively. Seeking to leverage technical expertise and creativity to create engaging user experiences and advance professionally.

Chanshiyu's github stats

Code is Long, Life is Short.

まだ五里霧中です。

blog's People

Contributors

chanshiyucx avatar ichanshiyu avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

blog's Issues

SpringMVC 前后端传参协调

话接上次练手 JavaWeb 实现了第一个后端接口,在 Postman 上测试可正常食用。寻思搭建个后台方便测试,却意外发生而后端无法接收参数的问题,花了点时间找下问题根源所在。

问题复现

前端传参

前端接口请求使用的 HTTP 库是知名的 axios,之前工作中项目一直使用都能顺利工作,故将其从原来的项目中 CV 过来。

import axios from 'axios'

const baseUrl = 'http://localhost:8088'

class HttpRequest {
  constructor(baseUrl) {
    this.baseUrl = baseUrl
    this.queue = {}
  }
  getInsideConfig() {
    const config = {
      baseURL: this.baseUrl,
      headers: {
        'Content-Type': 'application/json'
      }
    }
    return config
  }
  destroy(url) {
    delete this.queue[url]
  }
  interceptors(instance, url) {
    // 请求拦截
    instance.interceptors.request.use(
      config => {
        this.queue[url] = true
        return config
      },
      error => {
        return Promise.reject(error)
      }
    )
    // 响应拦截
    instance.interceptors.response.use(
      res => {
        this.destroy(url)
        return res.data
      },
      error => {
        this.destroy(url)
        return Promise.reject(error)
      }
    )
  }
  request(options) {
    const instance = axios.create()
    options = Object.assign(this.getInsideConfig(), options)
    this.interceptors(instance, options.url)
    return instance(options)
  }
}

const axios = new HttpRequest(baseUrl)
export default axios

在 vuex 中调用登录接口,使用 Post 方法传参:

// 用户登录
async ['user/handleLogin'](context, { username, password }) {
  const res = await axios.request({
    method: 'POST',
    url: '/user/login.do',
    data: {
      username,
      password
    }
  })
  return res
}

后端接口

后端是正常的 SpringMVC 接收参数方式:

// 用户登录
@RequestMapping(value = "login.do", method = RequestMethod.POST)
@ResponseBody
public ServerResponse<User> login(HttpSession session, String username, String password) {
    System.out.println("username: " + username + " password: " + password);
    ServerResponse<User> response = iUserService.login(username, password);
    if (response.isSuccess()) {
        session.setAttribute(Const.CURRENT_USER, response.getData());
    }
    return response;
}

项目成功运行后,访问登录接口却无法登录,查看后端日志发现参数用户名和密码并没有正确接收,值为 null,改用 Get 请求后可以正确调用,看来是前后端对 Post 方法传参和接参没有协调一致。

解决方案

方案一 前端修改

前端主流传参格式使用 json 格式,并设置请求头 'Content-Type': 'application/json',查看 Request Payload 可查参数格式如下:

{ "username": "shiyu", "password": "654321" }

后端无法接收参数原因很简单,因为 axios post 一个对象到后端的时候,是直接把 json 放到请求体中的,提交到后端的,而后端是怎么取参数的,是用的 @RequestParam,这种方式只能从请求的地址中取出参数,也就是只能从 username=shiyu&password=654321 这种字符串中解析出参数,而不能提取出请求体中的参数的。

在后端代码不变的情况下,可以修改前端传参方式,使用 URLSearchParams 传参并修改请求头 'Content-Type': 'application/x-www-form-urlencoded',将 json 对象传参转换为字符串格式。

// 用户登录
async ['user/handleLogin'](context, { username, password }) {
  const param = new URLSearchParams()
  param.append('username', username)
  param.append('password', password)
  const res = await axios.request({
    method: 'POST',
    url: '/user/login.do',
    data: param
  })
  return res
}

方案二 后端修改

如果觉得前端使用 URLSearchParams 传参不方便,毕竟 json 传参还是主流,可以修改后端代码,直接去请求体中取参数。通过 @RequestBody 注解,SpringMVC 可以把 json 中的数据绑定到 Map 中,这样就可以取出参数了。

// 用户登录
@RequestMapping(value = "login.do", method = RequestMethod.POST)
@ResponseBody
public ServerResponse<User> login(HttpSession session, @RequestBody Map map) {
    String username = (String) map.get("username");
    String password = (String) map.get("password");
    System.out.println("username: " + username + " password: " + password);
    ServerResponse<User> response = iUserService.login(username, password);
    if (response.isSuccess()) {
        session.setAttribute(Const.CURRENT_USER, response.getData());
    }
    return response;
}

参考文章:
axios 发送 post 请求,springMVC 接收不到数据问题

最坏の结局

这是第三次提交认证资料了,godaddy 域名如果还是找不回那就 GG 了,在考虑是不是要准备下一步的打算了,chanshiyu.com 这个域名也用了三年,绑定了太多资料,这下好多东西都要重新处理,有点烦~

新主题 Aurora

花了一周多的时间重构了 HeartBeat,这次将 Preact 替换为 Vue,并重写了每一个页面与组件,在原有设计上做了大量改进和细节优化。新主题命名为 Aurora,意为极光,希望这次能有个好的开始。

JavaScript 设计模式(Ⅱ)

本篇是《JavaScript 设计模式与开发实践》第二部分读书笔记,总结前 7 种设计模式:单例模式、策略模式、代理模式、迭代器模式、发布-订阅模式、命令模式、组合模式。

单例模式

单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。单例模式常见如线程池、全局缓存、浏览器中的 window 对象等。

代理单例

通过引入代理类的方式来管理单例逻辑:

var CreateDiv = function(html) {
  this.html = html
  this.init()
}

CreateDiv.prototype.init = function() {
  var div = document.createElement('div')
  div.innerHTML = this.html
  document.body.appendChild(div)
}

var ProxySingletonCreateDiv = (function() {
  var instance
  return function(html) {
    if (!instance) {
      instance = new CreateDiv(html)
    }
    return instance
  }
})()

var a = new ProxySingletonCreateDiv('sven1')
var b = new ProxySingletonCreateDiv('sven2')
alert(a === b) // true

惰性单例

惰性单例指的是在需要的时候才创建对象实例。可以把管理单例的逻辑从原来的代码中抽离出来,封装在 getSingle 函数内部,创建对象的方法 fn 被当成参数动态传入 getSingle 函数。

下面以创建登录悬浮窗为例:

var getSingle = function(fn) {
  var result
  return function() {
    return result || (result = fn.apply(this, arguments))
  }
}

var createLoginLayer = function() {
  var div = document.createElement('div')
  div.innerHTML = '我是登录浮窗'
  div.style.display = 'none'
  document.body.appendChild(div)
  return div
}
var createSingleLoginLayer = getSingle(createLoginLayer)

document.getElementById('loginBtn').onclick = function() {
  var loginLayer = createSingleLoginLayer()
  loginLayer.style.display = 'block'
}

比较上面的代理单例,可以发现只是将立即执行函数表达式提取出单独函数 getSingle,其余毫无二致。

策略模式

策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

一个基于策略模式的程序至少由两部分组成。第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类 Context,用来接受客户的请求,随后把请求委托给某一个策略类。要做到这点,说明 Context 中要维持对某个策略对象的引用。

计算奖金

var strategies = {
  S: function(salary) {
    return salary * 4
  },
  A: function(salary) {
    return salary * 3
  },
  B: function(salary) {
    return salary * 2
  }
}

var calculateBonus = function(level, salary) {
  return strategies[level](salary)
}

calculateBonus('S', 20000)

通过使用策略模式重构代码,消除了程序中大片的条件分支语句。通过替换 Context 中当前保存的策略对象,便能执行不同的算法来得到想要的结果,这也是多态在策略模式中的体现。

缓动动画

var tween = {
  linear: function(t, b, c, d) {
    return (c * t) / d + b
  },
  easeIn: function(t, b, c, d) {
    return c * (t /= d) * t + b
  },
  strongEaseIn: function(t, b, c, d) {
    return c * (t /= d) * t * t * t * t + b
  },
  strongEaseOut: function(t, b, c, d) {
    return c * ((t = t / d - 1) * t * t * t * t + 1) + b
  },
  sineaseIn: function(t, b, c, d) {
    return c * (t /= d) * t * t + b
  },
  sineaseOut: function(t, b, c, d) {
    return c * ((t = t / d - 1) * t * t + 1) + b
  }
}

var Animate = function(dom) {
  this.dom = dom // 进行运动的 dom 节点
  this.startTime = 0 // 动画开始时间
  this.startPos = 0 // 动画开始时,dom 的初始位置
  this.endPos = 0 // 动画结束时,dom 的目标位置
  this.propertyName = null // dom 节点需要被改变的 css 属性名
  this.easing = null // 缓动算法
  this.duration = null // 动画持续时间
}

Animate.prototype.start = function(propertyName, endPos, duration, easing) {
  this.startTime = +new Date() // 动画启动时间
  this.startPos = this.dom.getBoundingClientRect()[propertyName] // dom 节点初始位置
  this.propertyName = propertyName // dom 节点需要被改变的CSS属性名
  this.endPos = endPos // dom 节点目标位置
  this.duration = duration // 动画持续时间
  this.easing = tween[easing] // 缓动算法
  var self = this
  var timeId = setInterval(function() {
    if (self.step() === false) {
      // 启动定时器,开始执行动画
      clearInterval(timeId) // 如果动画已结束,则清除定时器
    }
  }, 19)
}

Animate.prototype.step = function() {
  var t = +new Date() // 取得当前时间
  if (t >= this.startTime + this.duration) {
    this.update(this.endPos)
    return false
  }

  var pos = this.easing(t - this.startTime, this.startPos, this.endPos - this.startPos, this.duration)
  this.update(pos)
}

Animate.prototype.update = function(pos) {
  this.dom.style[this.propertyName] = pos + 'px'
}

var div = document.getElementById('div')
var animate = new Animate(div)
animate.start('left', 500, 1000, 'strongEaseOut')

上面的缓动动画使用策略模式把算法传入动画类中,来达到各种不同的缓动效果,这些算法都可以轻易地被替换为另外一个算法,这是策略模式的经典运用之一。策略模式的实现并不复杂,关键是如何从策略模式的实现背后,找到封装变化、委托和多态性这些**的价值。

表单验证

var strategies = {
  isNonEmpty: function(value, errorMsg) {
    if (value === '') {
      return errorMsg
    }
  },
  minLength: function(value, length, errorMsg) {
    if (value.length < length) {
      return errorMsg
    }
  },
  isMobile: function(value, errorMsg) {
    if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
      return errorMsg
    }
  }
}

var Validator = function() {
  this.cache = [] // 保存校验规则
}

Validator.prototype.add = function(dom, rules) {
  var self = this
  for (var i = 0, rule; (rule = rules[i++]); ) {
    // @蝉時雨:没有必要用立即执行函数表达式,用 forEach 是否更合适
    ;(function(rule) {
      var strategyAry = rule.strategy.split(':')
      var errorMsg = rule.errorMsg
      self.cache.push(function() {
        // 把校验的步骤用空函数包装起来,并且放入 cache
        var strategy = strategyAry.shift()
        strategyAry.unshift(dom.value)
        strategyAry.push(errorMsg)
        return strategies[strategy].apply(dom, strategyAry)
      })
    })(rule)
  }
}

Validator.prototype.start = function() {
  for (var i = 0, validatorFunc; (validatorFunc = this.cache[i++]); ) {
    var msg = validatorFunc() // 开始校验,并取得校验后的返回信息
    if (msg) {
      return msg // 如果有确切的返回值,说明校验没有通过
    }
  }
}

var validataFunc = function() {
  var validator = new Validator() // 创建一个 validator 对象
  /*************** 添加一些校验规则 ****************/
  validator.add(registerForm.userName, [
    { strategy: 'isNonEmpty', errorMsg: '用户名不能为空' },
    { strategy: 'minLength:10', errorMsg: '用户名长度不能小于10位' }
  ])
  validator.add(registerForm.password, [{ strategy: 'minLength:6', errorMsg: '密码长度不能小于6位' }])
  validator.add(registerForm.phoneNumber, [{ strategy: 'isMobile', errorMsg: '手机号码格式不正确' }])
  var errorMsg = validator.start()
  return errorMsg // 返回校验结果
}

var registerForm = document.getElementById('registerForm')
registerForm.onsubmit = function() {
  var errorMsg = validataFunc() // 如果 errorMsg 有确切的返回值,说明未通过校验
  if (errorMsg) {
    return false // 阻止表单提交
  }
}

策略模式优缺点

通过以上三个例子,总结策略模式优点:

  • 策略模式利用组合、委托和多态等技术和**,可以有效地避免多重条件选择语句。
  • 策略模式提供了对开放—封闭原则的完美支持,将算法封装在独立的 strategy 中,使得它们易于切换,易于理解,易于扩展。
  • 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
  • 在策略模式中利用组合和委托来让 Context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案。

当然,策略模式也有一些缺点:

  • 使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在 Context 中要好。
  • 要使用策略模式,必须了解所有的 strategy,必须了解各个 strategy 之间的不同点,这样才能选择一个合适的 strategy。此时 strategy 要向客户暴露它的所有实现,违反最少知识原则。

一等函数对象与策略模式

在以类为中心的传统面向对象语言中,不同的算法或者行为被封装在各个策略类中,Context 将请求委托给这些策略对象,这些策略对象会根据请求返回不同的执行结果,这样便能表现出对象的多态性。

在函数作为一等对象的语言中,策略模式是隐形的。strategy 就是值为函数的变量。 -- Peter Norvig

在 JavaScript 中,除了使用类来封装算法和行为之外,使用函数当然也是一种选择。这些“算法”可以被封装到函数中并且四处传递,也就是常说的“高阶函数”。 实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。在 JavaScript 中,“函数对象的多态性”来得更加简单。

代理模式

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。

常见代理模式有:

  • 虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才去创建。
  • 缓存代理:缓存代理可以为一些开销大的运算结果提供暂时的存储。
  • 保护代理:用于对象应该有不同访问权限的情况,过滤请求。
  • 防火墙代理:控制网络资源的访问,保护主题不让“坏人”接近。
  • 远程代理:为一个对象在不同的地址空间提供局部代表,在 Java 中,远程代理可以是另一个虚拟机中的对象。
  • 智能引用代理:取代了简单的指针,它在访问对象时执行一些附加操作,比如计算一个对象被引用的次数。

在 JavaScript 开发中最常用的是虚拟代理和缓存代理。

虚拟代理

图片预加载

var myImage = (function() {
  var imgNode = document.createElement('img')
  document.body.appendChild(imgNode)
  return {
    setSrc: function(src) {
      imgNode.src = src
    }
  }
})()

var proxyImage = (function() {
  var img = new Image()
  img.onload = function() {
    myImage.setSrc(this.src)
  }

  return {
    setSrc: function(src) {
      myImage.setSrc('loading.gif')
      img.src = src
    }
  }
})()
proxyImage.setSrc('avatar.jpg')

单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。

同时在大多数情况下,若违反其他任何原则,同时将违反开放—封闭原则。

上面的预加载代码中,给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对方。何况就算有一天不再需要预加载,那么只需要改成请求本体而不是请求代理对象即可。其中关键是代理对象和本体都对外提供了 setSrc 方法,在客户看来,代理对象和本体是一致的,代理接手请求的过程对于用户来说是透明的,用户并不清楚代理和本体的区别,这样做有两个好处:

  • 用户可以放心地请求代理,他只关心是否能得到想要的结果。
  • 在任何使用本体的地方都可以替换成使用代理。

合并 HTTP 请求

频繁触发的 HTTP 请求可以缓存起来一并发送,能大大减轻服务器的压力,通过代理模式实现如下:

var synchronousFile = function(id) {
  console.log('开始同步文件,id为: ' + id)
}

var proxySynchronousFile = (function() {
  var cache = [], // 保存一段时间内需要同步的ID
    timer // 定时器
  return function(id) {
    cache.push(id)
    if (timer) {
      // 保证不会覆盖已经启动的定时器
      return
    }
    timer = setTimeout(function() {
      synchronousFile(cache.join(',')) // 2秒后向本体发送需要同步的ID集合
      clearTimeout(timer) // 清空定时器
      timer = null
      cache.length = 0 // 清空ID集合
    }, 2000)
  }
})()

var checkbox = document.getElementsByTagName('input')
for (var i = 0, c; (c = checkbox[i++]); ) {
  c.onclick = function() {
    if (this.checked === true) {
      proxySynchronousFile(this.id)
    }
  }
}

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

var mult = function() {
  var a = 1
  for (var i = 0, l = arguments.length; i < l; i++) {
    a = a * arguments[i]
  }
  return a
}

var plus = function() {
  var a = 0
  for (var i = 0, l = arguments.length; i < l; i++) {
    a = a + arguments[i]
  }
  return a
}

// 创建缓存代理的工厂
var createProxyFactory = function(fn) {
  var cache = {}
  return function() {
    var args = Array.prototype.join.call(arguments, ',')
    if (args in cache) {
      return cache[args]
    }
    return (cache[args] = fn.apply(this, arguments))
  }
}

var proxyMult = createProxyFactory(mult),
  proxyPlus = createProxyFactory(plus)
alert(proxyMult(1, 2, 3, 4)) // 输出:24
alert(proxyPlus(1, 2, 3, 4)) // 输出:10

迭代器模式

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

迭代器可以分为内部迭代器和外部迭代器。

内部迭代器调用方便,外界不用关心迭代器内部的实现,跟迭代器的交互也仅仅是一次初始调用,但这也刚好是内部迭代器的缺点,内部迭代器的迭代规则已经被提前规定。

外部迭代器必须显式地请求迭代下一个元素。外部迭代器增加了一些调用的复杂度,但相对也增强了迭代器的灵活性,可以手工控制迭代的过程或者顺序。

下面这个外部迭代器的实现来自《松本行弘的程序世界》第 4 章,原例用 Ruby 写成,这里翻译成 JavaScript:

var Iterator = function(obj) {
  var current = 0
  var next = function() {
    current += 1
  }

  var isDone = function() {
    return current >= obj.length
  }
  var getCurrItem = function() {
    return obj[current]
  }
  return {
    next: next,
    isDone: isDone,
    getCurrItem: getCurrItem
  }
}

迭代器模式是一种相对简单的模式,简单到很多时候都不认为它是一种设计模式,目前的绝大部分语言都内置了迭代器。

迭代器模式的应用

这里已文件上传为例,在不同的浏览器环境下,选择的上传方式是不一样的:

var getActiveUploadObj = function() {
  try {
    return new ActiveXObject('TXFTNActiveX.FTNUpload') // IE上传控件
  } catch (e) {
    return false
  }
}

var getFlashUploadObj = function() {
  if (supportFlash()) {
    // supportFlash 函数未提供
    var str = '<object type="application/x-shockwave-flash"></object>'
    return $(str).appendTo($('body'))
  }
  return false
}

var getFormUpladObj = function() {
  var str = '<input name="file" type="file" class="ui-file"/>' // 表单上传
  return $(str).appendTo($('body'))
}

var iteratorUploadObj = function() {
  for (var i = 0, fn; (fn = arguments[i++]); ) {
    var uploadObj = fn()
    if (uploadObj !== false) {
      return uploadObj
    }
  }
}

var uploadObj = iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUpladObj)

在 getActiveUploadObj、getFlashUploadObj、getFormUpladObj 这 3 个函数中都有同一个约定:如果该函数里面的 upload 对象是可用的,则让函数返回该对象,反之返回 false,提示迭代器继续往后面进行迭代。

发布-订阅模式

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,一般用事件模型来替代传统的发布—订阅模式。

全局订阅与通信

发布—订阅模式可以用一个全局的 Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来。

利用基于一个全局的 Event 对象,可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。但模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后,无法分辨消息的来源与流向。

var Event = (function() {
  var global = this,
    Event,
    _default = 'default'
  Event = (function() {
    var _listen,
      _trigger,
      _remove,
      _slice = Array.prototype.slice,
      _shift = Array.prototype.shift,
      _unshift = Array.prototype.unshift,
      namespaceCache = {},
      _create,
      find,
      each = function(ary, fn) {
        var ret
        for (var i = 0, l = ary.length; i < l; i++) {
          var n = ary[i]
          ret = fn.call(n, i, n)
        }
        return ret
      }

    _listen = function(key, fn, cache) {
      if (!cache[key]) {
        cache[key] = []
      }
      cache[key].push(fn)
    }

    _remove = function(key, cache, fn) {
      if (cache[key]) {
        if (fn) {
          for (var i = cache[key].length; i >= 0; i--) {
            if (cache[key][i] === fn) {
              cache[key].splice(i, 1)
            }
          }
        } else {
          cache[key] = []
        }
      }
    }

    _trigger = function() {
      var cache = _shift.call(arguments),
        key = _shift.call(arguments),
        args = arguments,
        _self = this,
        ret,
        stack = cache[key]
      if (!stack || !stack.length) {
        return
      }
      return each(stack, function() {
        return this.apply(_self, args)
      })
    }

    _create = function(namespace) {
      var namespace = namespace || _default
      var cache = {},
        offlineStack = [], // 离线事件
        ret = {
          listen: function(key, fn, last) {
            _listen(key, fn, cache)
            if (offlineStack === null) {
              return
            }
            if (last === 'last') {
              offlineStack.length && offlineStack.pop()()
            } else {
              each(offlineStack, function() {
                this()
              })
            }
            offlineStack = null
          },

          one: function(key, fn, last) {
            _remove(key, cache)
            this.listen(key, fn, last)
          },

          remove: function(key, fn) {
            _remove(key, cache, fn)
          },

          trigger: function() {
            var fn,
              args,
              _self = this
            _unshift.call(arguments, cache)
            args = arguments
            fn = function() {
              return _trigger.apply(_self, args)
            }
            if (offlineStack) {
              return offlineStack.push(fn)
            }
            return fn()
          }
        }

      return namespace
        ? namespaceCache[namespace]
          ? namespaceCache[namespace]
          : (namespaceCache[namespace] = ret)
        : ret
    }

    return {
      create: _create,
      one: function(key, fn, last) {
        var event = this.create()
        event.one(key, fn, last)
      },

      remove: function(key, fn) {
        var event = this.create()
        event.remove(key, fn)
      },

      listen: function(key, fn, last) {
        var event = this.create()
        event.listen(key, fn, last)
      },

      trigger: function() {
        var event = this.create()
        event.trigger.apply(this, arguments)
      }
    }
  })()
  return Event
})()

/************** 先发布后订阅 ********************/
Event.trigger('click', 1)
Event.listen('click', function(a) {
  console.log(a) // 输出:1
})
/************** 使用命名空间 ********************/
Event.create('namespace1').listen('click', function(a) {
  console.log(a) // 输出:1
})
Event.create('namespace1').trigger('click', 1)
Event.create('namespace2').listen('click', function(a) {
  console.log(a) // 输出:2
})

JavaScript 中的发布—订阅模式,跟一些别的语言(比如 Java)中的实现还是有区别的。在 Java 中通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需提供一个名为诸如 update 的方法,供发布者对象在适合的时候调用。而在 JavaScript 中,用注册回调函数的形式来代替传统的发布—订阅模式,显得更加优雅和简单。

另外,在 JavaScript 中,无需去选择使用推模型还是拉模型。推模型是指在事件发生时,发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型不同的地方是,发布者仅仅通知订阅者事件已经发生了,此外发布者要提供一些公开的接口供订阅者来主动拉取数据。

拉模型的好处是可以让订阅者“按需获取”,但同时有可能让发布者变成一个“门户大开”的对象,同时增加了代码量和复杂度。刚好在 JavaScript 中,arguments 可以很方便地表示参数列表,所以一般都会选择推模型,使用 Function.prototype.apply 方法把所有参数都推送给订阅者。

发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是 MVC 还是 MVVM,都少不了发布—订阅模式的参与,而且 JavaScript 本身也是一门基于事件驱动的语言

当然,发布—订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 bug 不是件轻松的事情。

命令模式

命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

另外,相对于过程化的请求调用,command 对象拥有更长的生命周期。对象的生命周期是跟初始请求无关的,因为这个请求已经被封装在了 command 对象的方法中,成为了这个对象的行为,可以随时调用。

除了这两点之外,命令模式还支持撤销、排队等操作。

JavaScript 中的命令模式

一个最简单的命令模式代码如下:

var MenuBar = {
  refresh: function() {
    console.log('刷新菜单目录')
  }
}

// ① 面向对象实现
var RefreshMenuBarCommand = function(receiver) {
  this.receiver = receiver
}
RefreshMenuBarCommand.prototype.execute = function() {
  this.receiver.refresh()
}

var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar)
var button = document.getElementById('button')

var setCommand = function(button, command) {
  button.onclick = function() {
    command.execute()
  }
}
setCommand(button, refreshMenuBarCommand)

上面示例代码是模拟传统面向对象语言的命令模式实现。命令模式将过程式的请求调用封装在 command 对象的 execute 方法里,通过封装方法调用,可以把运算块包装成形。command 对象可以被四处传递,所以在调用命令的时候,不需要关心事情是如何进行的。

命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品

JavaScript 作为将函数作为一等对象的语言,跟策略模式一样,命令模式也早已融入到了 JavaScript 语言之中。运算块不一定要封装在 command.execute 方法中,也可以封装在普通函数中。函数作为一等对象,本身就可以被四处传递。即使依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。

在面向对象设计中,命令模式的接收者被当成 command 对象的属性保存起来,同时约定执行命令的操作调用 command.execute 方法。在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。

使用闭包方式重写上面命令封装:

// ② 闭包实现
var RefreshMenuBarCommand = function(receiver) {
  return {
    execute: function() {
      receiver.refresh()
    }
  }
}

宏命令

宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。宏命令是命令模式与组合模式的联用产物。

var openPcCommand = {
  execute: function() {
    console.log('开电脑')
  }
}

var openQQCommand = {
  execute: function() {
    console.log('登录QQ')
  }
}

var MacroCommand = function() {
  return {
    commandsList: [],
    add: function(command) {
      this.commandsList.push(command)
    },

    execute: function() {
      for (var i = 0, command; (command = this.commandsList[i++]); ) {
        command.execute()
      }
    }
  }
}

var macroCommand = MacroCommand()
macroCommand.add(openPcCommand)
macroCommand.add(openQQCommand)
macroCommand.execute()

智能命令和傻瓜命令

回顾上面的命令代码:

var openPcCommand = {
  execute: function() {
    console.log('开电脑')
  }
}

命令中没有接收者 receiver,本身就包揽了执行请求的行为。一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。

但是也可以定义一些更“聪明”的命令对象叫做智能命令,智能命令可以直接实现请求,不再需要接收者的存在。没有接收者的智能命令和策略模式非常相近,从代码结构上已经无法分辨它们,它们只有意图的不同。策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。而智能命令模式指向的问题域更广,command 对象解决的目标更具发散性。

组合模式

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

以宏命令为例,请求从树最顶端的对象往下传递,如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。

组合模式最大的优点在于可以一致地对待组合对象和基本对象。客户不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有 execute 方法,这个命令就可以被添加到树中。这种透明性带来的便利,在静态类型语言中体现得尤为明显。比如在 Java 中,实现组合模式的关键是 Composite 类和 Leaf 类都必须继承自一个 Compenent 抽象类。这个 Compenent 抽象类既代表组合对象,又代表叶对象,它也能够保证组合对象和叶对象拥有同样名字的方法,从而可以对同一消息都做出反馈。组合对象和叶对象的具体类型被隐藏在 Compenent 抽象类身后。

然而在 JavaScript 这种动态类型语言中,对象的多态性是与生俱来的,JavaScript 中实现组合模式的难点在于要保证组合对象和叶对象对象拥有同样的方法,这通常需要用鸭子类型的**对它们进行接口检查。

扫描文件夹

以下以文件复制和文件夹扫描为例:

/******** Folder **********/
var Folder = function(name) {
  this.name = name
  this.files = []
  this.parent = null // 增加 this.parent 属性
}

Folder.prototype.add = function(file) {
  file.parent = this // 设置父对象
  this.files.push(file)
}

Folder.prototype.scan = function() {
  for (var i = 0, file, files = this.files; (file = files[i++]); ) {
    file.scan()
  }
}

Folder.prototype.remove = function() {
  if (!this.parent) {
    // 根节点或者树外的游离节点
    return
  }
  for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
    var file = files[l]
    if (file === this) {
      files.splice(l, 1)
    }
  }
}

/************ File **************/
var File = function(name) {
  this.name = name
  this.parent = null
}

File.prototype.add = function() {
  throw new Error('文件下面不能再添加文件')
}

File.prototype.scan = function() {
  console.log('开始扫描文件: ' + this.name)
}

File.prototype.remove = function() {
  if (!this.parent) {
    // 根节点或者树外的游离节点
    return
  }

  for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
    var file = files[l]
    if (file === this) {
      files.splice(l, 1)
    }
  }
}

var folder = new Folder('学习资料')
var folder1 = new Folder('JavaScript')
var file1 = new File('JavaScript设计模式与开发实践')
var file2 = new File('重构与模式')
folder1.add(file1)
folder.add(folder1)
folder.add(file2)
folder1.remove()
folder.scan()

组合模式适用的两大场景:

  • 表示对象的部分-整体层次结构
  • 客户希望统一对待树中的所有对象

然而组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。

不一样の烟火

anime.js 是一个强大的前端动画库,HeartBeat 主题的背景点击特效就是借用其官网效果。为了学习动画库的使用,这里用 ES6 重构了烟火代码,来一场不一样的烟火。

Anime (/ˈæn.ə.meɪ/) is a lightweight JavaScript animation library. It works with any CSS Properties, individual CSS transforms, SVG or any DOM attributes, and JavaScript Objects.

不一样の烟火

在开始之前,先链上 Source Code在线预览

引入 anime.min.js

首先在引入 anime.min.js,这里使用 BootCDN 外链。然后创建一个 canvas 画布,用来呈现烟火效果。在 body 标签尾部引入 index.js,接下来就是在 index.js 完成最终的烟火代码。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>不一样の烟火</title>
    <script src="https://cdn.bootcss.com/animejs/2.2.0/anime.min.js"></script>
  </head>
  <style>
    body {
      background: #000;
      overflow: hidden;
    }
  </style>
  <body>
    <canvas class="fireworks"></canvas>
    <script src="./index.js"></script>
  </body>
</html>

初始化画布

在 index.js 中,新建一个 Firework 类,并初始化画布大小尺寸。

class Firework {
  constructor() {
    this.canvasEl = null // 画布元素
    this.ctx = null // 画布上下文
  }

  // Let's go
  start() {
    // 初始化画布
    this.setCanvasSize()
  }

  // 设置画布尺寸
  setCanvasSize() {
    // 获取画布元素
    const canvasEl = document.querySelector('.fireworks')
    const ctx = canvasEl.getContext('2d')
    // 窗口尺寸
    const innerWidth = window.innerWidth
    const innerHeight = window.innerHeight
    // 设置画布尺寸
    canvasEl.width = innerWidth * 2
    canvasEl.height = innerHeight * 2
    canvasEl.style.width = innerWidth + 'px'
    canvasEl.style.height = innerHeight + 'px'
    ctx.scale(2, 2)
    // 保存画布
    this.canvasEl = canvasEl
    this.ctx = ctx
  }
}

const margicAnime = new Firework()
margicAnime.start()

绑定事件

接下来监听点击事件以绘制动画,并且监听窗口缩放事件,当窗口大小变化时重置画布尺寸。为了兼容不同浏览器,这里将事件绑定和解绑方法提取出公用方法。

/**
 * @description 绑定事件 on(element, event, handler)
 */
const on = (function() {
  if (document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler)
      }
    }
  }
})()

/**
 * @description 解绑事件 off(element, event, handler)
 */
const off = (function() {
  if (document.removeEventListener) {
    return function(element, event, handler) {
      if (element && event) {
        element.removeEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event) {
        element.detachEvent('on' + event, handler)
      }
    }
  }
})()

然后添加绑定事件,并且添加销毁方法,在销毁時解绑事件:

// 点击事件
const tap = 'ontouchstart' in window || navigator.msMaxTouchPoints ? 'touchstart' : 'mousedown'

class Firework {
  // Let's go
  start() {
    // 初始化画布
    this.setCanvasSize()

    // 监听点击和窗口缩放事件
    on(document, tap, this.render.bind(this))
    on(window, 'resize', this.setCanvasSize.bind(this))
  }

  // 销毁
  destroyed() {
    off(document, tap, this.render.bind(this))
    off(window, 'resize', this.setCanvasSize.bind(this))
    this.tapFunc = this.resizeFunc = this.renderAnime = null
  }

  // 点击事件
  render() {}
}

擦除与绘制

借助 anime.js,可以很方便在每一帧画布更新后擦除画布,通过不断清除画布内容再绘制,形成动画效果。

class Firework {
  // 点击事件
  render(e) {
    const canvasEl = this.canvasEl
    const ctx = this.ctx

    // 绘制前启用擦除动画
    if (!this.renderAnime) {
      this.renderAnime = anime({
        duration: Infinity,
        update() {
          // 擦除画布
          ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
        }
      })
    }
    this.renderAnime.play()

    // 点击坐标
    const pointerX = e.clientX || e.touches[0].clientX
    const pointerY = e.clientY || e.touches[0].clientY
    this.animateParticules(pointerX, pointerY)
  }

  // 绘制烟火
  animateParticules() {}
}

绘制烟火

绘制烟火是最为核心代码,烟火由扩散圈的烟火粒子两部分组成。并在每一个动画帧更新后重新绘制粒子。

class Firework {
  constructor() {
    this.numberOfParticules = 30 // 粒子数量
    this.colors = ['#FF1461', '#18FF92', '#5A87FF', '#FBF38C'] // 粒子颜色
  }

  // 创建扩散圈
  createCircle(x, y) {
    const ctx = this.ctx
    const p = {}
    p.x = x
    p.y = y
    p.color = '#FFF'
    p.radius = 0.1
    p.alpha = 0.5
    p.lineWidth = 6
    p.draw = function() {
      ctx.globalAlpha = p.alpha
      ctx.beginPath()
      // 绘制正圆
      ctx.arc(p.x, p.y, p.radius, 0, 2 * Math.PI, true)
      ctx.lineWidth = p.lineWidth
      ctx.strokeStyle = p.color
      ctx.stroke()
      ctx.globalAlpha = 1
    }
    return p
  }

  // 创建粒子
  createParticule(x, y) {
    const ctx = this.ctx
    const p = {}
    p.x = x
    p.y = y
    p.color = this.colors[anime.random(0, this.colors.length - 1)]
    p.radius = anime.random(16, 32)
    p.endPos = this.setParticuleDirection(p)
    p.draw = function() {
      ctx.beginPath()
      ctx.arc(p.x, p.y, p.radius, 0, 2 * Math.PI, true)
      ctx.fillStyle = p.color
      ctx.fill()
    }
    return p
  }

  // 粒子扩散方向
  setParticuleDirection(p) {
    const angle = (anime.random(0, 360) * Math.PI) / 180
    const value = anime.random(50, 180)
    const radius = [-1, 1][anime.random(0, 1)] * value
    return {
      x: p.x + radius * Math.cos(angle),
      y: p.y + radius * Math.sin(angle)
    }
  }

  // 绘制粒子
  renderParticule(anim) {
    for (let i = 0; i < anim.animatables.length; i++) {
      anim.animatables[i].target.draw()
    }
  }

  // 绘制烟火
  animateParticules(x, y) {
    const circle = this.createCircle(x, y)
    const particules = []
    for (let i = 0; i < this.numberOfParticules; i++) {
      particules.push(this.createParticule(x, y))
    }
    const renderParticule = this.renderParticule
    anime
      .timeline()
      .add({
        targets: particules,
        x(p) {
          return p.endPos.x
        },
        y(p) {
          return p.endPos.y
        },
        radius: 0.1,
        duration: anime.random(1200, 1800),
        easing: 'easeOutExpo',
        // 每一个动画帧更新后重新绘制粒子
        update: renderParticule
      })
      .add({
        targets: circle,
        radius: anime.random(80, 160),
        lineWidth: 0,
        alpha: {
          value: 0,
          easing: 'linear',
          duration: anime.random(600, 800)
        },
        duration: anime.random(1200, 1800),
        easing: 'easeOutExpo',
        update: renderParticule,
        offset: 0
      })
  }
}

大功告成

最终烟火效果代码如下:

/**
 * @description 绑定事件 on(element, event, handler)
 */
const on = (function() {
  if (document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler)
      }
    }
  }
})()

/**
 * @description 解绑事件 off(element, event, handler)
 */
const off = (function() {
  if (document.removeEventListener) {
    return function(element, event, handler) {
      if (element && event) {
        element.removeEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event) {
        element.detachEvent('on' + event, handler)
      }
    }
  }
})()

// 点击事件
const tap = 'ontouchstart' in window || navigator.msMaxTouchPoints ? 'touchstart' : 'mousedown'

class Firework {
  constructor() {
    this.canvasEl = null // 画布元素
    this.ctx = null // 画布上下文
    this.numberOfParticules = 30 // 粒子数量
    this.colors = ['#FF1461', '#18FF92', '#5A87FF', '#FBF38C'] // 粒子颜色
    this.tapFunc = null
    this.resizeFunc = null
    this.renderAnime = null
  }

  // Let's go
  start() {
    // 初始化画布
    this.setCanvasSize()

    // 监听点击和窗口缩放事件
    on(document, tap, this.render.bind(this))
    on(window, 'resize', this.setCanvasSize.bind(this))
  }

  // 销毁
  destroyed() {
    off(document, tap, this.render.bind(this))
    off(window, 'resize', this.setCanvasSize.bind(this))
    this.tapFunc = this.resizeFunc = this.renderAnime = null
  }

  // 设置画布尺寸
  setCanvasSize() {
    // 获取画布元素
    const canvasEl = document.querySelector('.fireworks')
    const ctx = canvasEl.getContext('2d')
    // 窗口尺寸
    const innerWidth = window.innerWidth
    const innerHeight = window.innerHeight
    // 设置画布尺寸
    canvasEl.width = innerWidth * 2
    canvasEl.height = innerHeight * 2
    canvasEl.style.width = innerWidth + 'px'
    canvasEl.style.height = innerHeight + 'px'
    ctx.scale(2, 2)
    // 保存画布
    this.canvasEl = canvasEl
    this.ctx = ctx
  }

  // 创建点击扩散圈
  createCircle(x, y) {
    const ctx = this.ctx
    const p = {}
    p.x = x
    p.y = y
    p.color = '#FFF'
    p.radius = 0.1
    p.alpha = 0.5
    p.lineWidth = 6
    p.draw = function() {
      ctx.globalAlpha = p.alpha
      ctx.beginPath()
      ctx.arc(p.x, p.y, p.radius, 0, 2 * Math.PI, true)
      ctx.lineWidth = p.lineWidth
      ctx.strokeStyle = p.color
      ctx.stroke()
      ctx.globalAlpha = 1
    }
    return p
  }

  // 创建点击粒子
  createParticule(x, y) {
    const ctx = this.ctx
    const p = {}
    p.x = x
    p.y = y
    p.color = this.colors[anime.random(0, this.colors.length - 1)]
    p.radius = anime.random(16, 32)
    p.endPos = this.setParticuleDirection(p)
    p.draw = function() {
      ctx.beginPath()
      ctx.arc(p.x, p.y, p.radius, 0, 2 * Math.PI, true)
      ctx.fillStyle = p.color
      ctx.fill()
    }
    return p
  }

  // 粒子扩散方向
  setParticuleDirection(p) {
    const angle = (anime.random(0, 360) * Math.PI) / 180
    const value = anime.random(50, 180)
    const radius = [-1, 1][anime.random(0, 1)] * value
    return {
      x: p.x + radius * Math.cos(angle),
      y: p.y + radius * Math.sin(angle)
    }
  }

  // 绘制粒子
  renderParticule(anim) {
    for (let i = 0; i < anim.animatables.length; i++) {
      anim.animatables[i].target.draw()
    }
  }

  // 绘制烟火
  animateParticules(x, y) {
    const circle = this.createCircle(x, y)
    const particules = []
    for (let i = 0; i < this.numberOfParticules; i++) {
      particules.push(this.createParticule(x, y))
    }
    const renderParticule = this.renderParticule
    anime
      .timeline()
      .add({
        targets: particules,
        x(p) {
          return p.endPos.x
        },
        y(p) {
          return p.endPos.y
        },
        radius: 0.1,
        duration: anime.random(1200, 1800),
        easing: 'easeOutExpo',
        update: renderParticule
      })
      .add({
        targets: circle,
        radius: anime.random(80, 160),
        lineWidth: 0,
        alpha: {
          value: 0,
          easing: 'linear',
          duration: anime.random(600, 800)
        },
        duration: anime.random(1200, 1800),
        easing: 'easeOutExpo',
        update: renderParticule,
        offset: 0
      })
  }

  // 点击事件
  render(e) {
    const canvasEl = this.canvasEl
    const ctx = this.ctx

    // 绘制前启用擦除画布
    if (!this.renderAnime) {
      this.renderAnime = anime({
        duration: Infinity,
        update() {
          ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
        }
      })
    }
    this.renderAnime.play()

    // 点击坐标
    const pointerX = e.clientX || e.touches[0].clientX
    const pointerY = e.clientY || e.touches[0].clientY
    this.animateParticules(pointerX, pointerY)
  }
}

const margicAnime = new Firework()
margicAnime.start()

Just enjoy it 😃!

ES6标准入门-Iterator 遍历器

JavaScript 有四种表示“集合”和数据结构,分别是 Array、Object 和 ES6 新增的 Set、Map,遍历器(Iterator)就是为各种不同的数据结构提供统一访问机制的接口。

Iterator

Iterator 概念

遍历器对象本质上就是一个指针对象。任何数据结构,只要部署 Iterator 接口,就可以完成遍历操作。

Iterator 的作用有 3 个:

  1. 为各种数据结构提供一个统一的、简便的访问接口;
  2. 使得数据结构的成员能够按某种次序排列;
  3. Iterator 接口主要供 for...of 消费。

Iterator 的遍历过程如下:

  1. 创建一个指针对象,指向当前数据结构的起始位置。
  2. 第一次调用指针对象的 next 方法,可以将指针指向数据结构的第一个成员。
  3. 第二次调用指针对象的 next 方法,指针就指向数据结构的第二个成员。
  4. 不断调用指针对象的 next 方法,直到它指向数据结构的结束位置。

默认 Iterator 接口

数据结构只要部署了 Iterator 接口,就称这种数据结构为“可遍历”(iterable)的。

默认的 Iterator 接口部署在 Symbol.iterator 属性上,调用 Symbol.iterator 方法,会得到当前数据结构默认的遍历器生成函数。

原生具备 Iterator 接口的数据结构有:Array、Map、Set、String、TypedArray、arguments 对象、NodeList 对象。

let arr = ['a', 'b', 'c']
let iter = arr[Symbol.iterator]()
iter.next() // { value: 'a', done: false }

Object 之所以没有默认部署 Iterator 接口,是因为对象属性的遍历先后顺序是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口就等于部署一种线性转换

可以手动给 Object 部署遍历器接口:

class RangeIterator {
  constructor(start, stop) {
    this.value = start
    this.stop = stop
  }
  [Symbol.iterator]() {
    return this
  }
  next() {
    let value = this.value
    if (value < this.stop) {
      this.value++
      return { done: false, value: value }
    }
    return { done: true, value: undefined }
  }
}
function range(start, stop) {
  return new RangeIterator(start, stop)
}
for (let value of range(0, 3)) {
  console.log(value) // 0, 1, 2
}

对于类似数组的对象,部署 Iterator 接口有一个简便方法,即使用 Symbol.iterator 方法直接引用数组的 Iterator 接口:

let iterable = {
  0: 'a',
  1: 'b',
  2: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
}

调用的场合

有一些场合会默认调用 Iterator 接口(即 Symbol.iterator 方法)。

解构赋值

对数组和 Set 结构进行解构赋值时,会默认调用 Symbol.iterator 方法。

let set = new Set()
  .add('a')
  .add('b')
  .add('c')
let [x, y] = set // x='a'; y='b'

扩展运算符

扩展运算符(...)也会调用默认的 Iterator 接口。

let str = 'hello'
;[...str] //  ['h','e','l','l','o']

yield*

yield* 后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。

let generator = function*() {
  yield 1
  yield* [2, 3]
  yield 4
}
let iterator = generator()
iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: undefined, done: true }

其他场合

由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合其实都调用了遍历器接口:

  • for...of
  • Array.from()
  • Map()、Set()、WeakMap() 和 WeakSet()
  • Promise.all()
  • Promise.race()

for of 循环

一个数据结构只要部署了 Symbol.iterator 属性,就被视为具有 iterator 接口,就可以用 for...of 循环遍历它的成员。

for...of 循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如 arguments 对象、DOM NodeList 对象)、后文的 Generator 对象,以及字符串。

数组

JavaScript 原有的 for...in 循环只能获得对象的键名,不能直接获取键值。ES6 提供的 for...of 循环允许遍历获得键值。

let arr = ['a', 'b', 'c', 'd']
arr.foo = 'hello'

for (let a in arr) {
  console.log(a) // 0 1 2 3 foo
}
for (let a of arr) {
  console.log(a) // a b c d
}

上面的代码中,for...of 循环不会返回数组 arr 的 foo 属性。

Set 和 Map 结构

Set 和 Map 结构原生具有 Iterator 接口,可以直接使用 for...of 循环。

var engines = new Set(['Gecko', 'Trident'])
for (var e of engines) {
  console.log(e)
}
// Gecko
// Trident

var es6 = new Map()
es6.set('edition', 6)
es6.set('committee', 'TC39')
for (var [name, value] of es6) {
  console.log(name + ': ' + value)
}
// edition: 6
// committee: TC39

值得注意是:首先,遍历的顺序是按照各个成员被添加进数据结构的顺序;其次,Set 结构遍历时返回的是一个值,而 Map 结构遍历时返回的是一个数组,该数组的两个成员分别为当前 Map 成员的键名和键值。

对象

对于普通的对象,for...in 循环可以遍历键名,for...of 循环会报错。

一种解决方法是,使用 Object.keys 方法将对象的键名生成一个数组,然后遍历这个数组:

for (var key of Object.keys(someObject)) {
  console.log(key + ': ' + someObject[key])
}

另一个方法是使用 Generator 函数将对象重新包装一下:

function* entries(obj) {
  for (let key of Object.keys(obj)) {
    yield [key, obj[key]]
  }
}
for (let [key, value] of entries(obj)) {
  console.log(key, '->', value)
}
// a -> 1
// b -> 2
// c -> 3

JavaScript 提供其它几种循环如 forEach、for...in 方式。对于 forEach,无法中途跳出循环,break 命令或 return 命令都不能奏效;对于 for...in,循环遍历数组得到的键名是数字,且会遍历原型链上的键。然而 for...of 循环没有以上缺点。

JavaScript 设计模式(Ⅰ)

《JavaScript 设计模式与开发实践》是去年在多看阅读上买的电子书,拖延症晚期患者在快一年后终于把这本书粗略读完,顺便做个笔记,加以总结,以便往后重新翻阅温习。

设计模式

设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案

所有设计模式的实现都遵循一条原则,即“找出程序中变化的地方,并将变化封装起来”。

本书内容分为三大部分:

第一部分讲解 JavaScript 面向对象和函数式编程方面的知识,主要包括静态类型语言和动态类型语言的区别及其在实现设计模式时的异同,以及封装、继承、多态在动态类型语言中的体现,此外还介绍了 JavaScript 基于原型继承的面向对象系统的来龙去脉,给学习设计模式做好铺垫。

第二部分是核心部分,通过从普通到更好的代码示例,由浅到深地讲解了 14 种设计模式。

第三部分主要讲解面向对象的设计原则及其在设计模式中的体现,还介绍了一些常见的面向对象编程技巧和日常开发中的代码重构。

本篇是第一部分基础知识的相关总结。

面向对象的 JavaScript

JavaScript 没有提供传统面向对象语言中的类式继承,而是通过原型委托的方式来实现对象与对象之间的继承。

动态类型语言和鸭子类型

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。

动态类型语言无需进行类型检测,可以尝试调用任何对象的任意方法,而无需去考虑它原本是否被设计为拥有该方法。

这一切都建立在鸭子类型(duck typing)的概念上,鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的**,不必借助超类型的帮助,就能轻松实现一个原则:“面向接口编程,而不是面向实现编程”

在静态类型语言中,要实现“面向接口编程”并不容易,往往要通过抽象类或者接口等将对象进行向上转型。当对象的真正类型被隐藏在它的超类型身后,这些对象才能在类型检查系统的“监视”之下互相被替换使用,才能体现出对象多态性的价值。

多态

多态的实际含义:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果

多态背后的**是将“不变的事物”与“可能改变的事物”分离开来。把不变的部分隔离,把可变的部分封装,这给予了我们扩展程序的能力,也符合开放—封闭原则。

继承与多态

对于静态类型的面向对象语言通常被设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。

使用继承来得到多态效果,是让对象表现出多态性的最常用手段。继承通常包括实现继承和接口继承。

public abstract class Animal {
  abstract void makeSound(); // 抽象方法
}

public class Chicken extends Animal {
  public void makeSound() {
    System.out.println( "咯咯咯" );
  }
}

public class Duck extends Animal {
  public void makeSound() {
    System.out.println( "嘎嘎嘎" );
  }
}

public class AnimalSoun{
  public void makeSound(Animal animal) { // 接受Animal类型的参数
    animal.makeSound();
  }
}

public class Test {
  public static void main(String args[]) {
    AnimalSound animalSound= new AnimalSound();
    Animal duck = new Duck();
    Animal chicken = new Chicken();
    animalSound.makeSound(duck);    // 输出:嘎嘎嘎
    animalSound.makeSound(chicken); // 输出:咯咯咯
}

上面 java 例子中先创建一个 Animal 抽象类,再分别让 Duck 和 Chicken 都继承自 Animal 抽象类,然后让 AnimalSound 类的 makeSound 方法接受 Animal 类型的参数,而不是具体的 Duck 类型或者 Chicken 类型。

JavaScript 的多态

要实现多态先要消除类型之间的耦合关系。在 Java 中,可以通过向上转型来实现多态。而 JavaScript 的变量类型在运行期是可变的,这意味着 JavaScript 对象的多态性是与生俱来的,并不需要诸如向上转型之类的技术来取得多态的效果。

Martin Fowler 在《重构:改善既有代码的设计》里写到:

多态的最根本好处在于,你不必再向对象询问“你是什么类型”而后根据得到的答案调用对象的某个行为——你只管调用该行为就是了,其他的一切多态机制都会为你安排妥当。

换句话说,多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。

let googleMap = {
  show: function() {
    console.log('开始渲染谷歌地图')
  }
}

let baiduMap = {
  show: function() {
    console.log('开始渲染百度地图')
  }
}

let renderMap = function(map) {
  if (map.show instanceof Function) {
    map.show()
  }
}

renderMap(googleMap) // 输出:开始渲染谷歌地图
renderMap(baiduMap) // 输出:开始渲染百度地图

在 JavaScript 函数本身也是对象,函数用来封装行为并且能够被四处传递。当对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在 JavaScript 中可以用高阶函数来代替实现的原因。

封装

封装的目的是将信息隐藏,一般而言的封装是封装数据和封装实现,更广义的封装还包括封装类型和封装变化。

封装数据

在许多语言的对象系统中,封装数据是由语法解析来实现的,如 java 提供了 private、public、protected 等关键字来提供不同的访问权限。但 JavaScript 并没有提供对这些关键字的支持,只能依赖变量的作用域来实现封装特性,而且只能模拟出 public 和 private 这两种封装性。

let myObject = (function() {
  let __name = 'sven' // 私有(private)变量
  return {
    getName: function() {
      // 公开(public)方法
      return __name
    }
  }
})()

console.log(myObject.getName()) // 输出:sven
console.log(myObject.__name) // 输出:undefined

封装实现

封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。

从封装实现细节来讲,封装使得对象内部的变化对其他对象而言是不可见的,对象对它自己的行为负责,其他对象都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。当修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。

封装类型

封装类型是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使工厂方法模式、组合模式诞生的原因之一。

在 JavaScript 中并没有对抽象类和接口的支持。JavaScript 本身也是一门类型模糊的语言。在封装类型方面,JavaScript 没有能力,也没有必要做得更多。

封装变化

从设计模式的角度出发,封装在更重要的层面体现为封装变化。《设计模式》一书曾提到如下文字:

考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。

通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,这可以最大程度地保证程序的稳定性和可扩展性。

原型模式

在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程的**中,一个对象是通过克隆另外一个对象所得到的。原型模式不单是一种设计模式,也被称为一种编程泛型。

使用克隆的原型模式

从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另外一种方式,不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。

原型模式的实现关键,是语言本身是否提供了 clone 方法。ECMAScript5 提供了 Object.create 方法,可以用来克隆对象。

在不支持 Object.create 方法的浏览器中,则可以使用以下代码:

Object.create =
  Object.create ||
  function(obj) {
    var F = function() {}
    F.prototype = obj
    return new F()
  }

原型编程泛型

基于原型链的委托机制就是原型继承的本质。原型编程范型至少包括以下基本规则:

  • 所有的数据都是对象。
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
  • 对象会记住它的原型。
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

我们不能说在 JavaScript 中所有的数据都是对象,但可以说绝大部分数据都是对象。JavaScript 中的根对象是 Object.prototype 对象,它是一个空的对象。JavaScript 所有对象实际上都是从这个对象克隆而来的,Object.prototype 对象就是它们的原型。

var obj1 = new Object()
var obj2 = {}

console.log(Object.getPrototypeOf(obj1) === Object.prototype) // 输出:true
console.log(Object.getPrototypeOf(obj2) === Object.prototype) // 输出:true

目前一直在讨论“对象的原型”,就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于“对象把请求委托给它自己的原型”这句话,更好的说法是对象把请求委托给它的构造器的原型。

JavaScript 给对象提供了一个名为 __proto__ 的隐藏属性,某个对象的 __proto__ 属性默认会指向它的构造器的原型对象,即{Constructor}.prototype

var A = function() {}
A.prototype = { name: 'sven' }
var B = function() {}
B.prototype = new A()
var b = new B()
console.log(b.name) // 输出:sven

和把 B.prototype 直接指向一个字面量对象相比,通过 B.prototype = new A() 形成的原型链比之前多了一层。但二者之间没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间。

原型继承的未来

美中不足是在当前的 JavaScript 引擎下,通过 Object.create 来创建对象的效率并不高,通常比通过构造函数创建对象要慢。此外通过 Object.create(null) 可以创建出没有原型的对象。

ECMAScript6 带来了新的 Class 语法。这让 JavaScript 看起来像是一门基于类的语言,但其背后仍是通过原型机制来创建对象。

原型模式是一种设计模式,也是一种编程泛型,它构成了 JavaScript 这门语言的根本。

this、call 和 apply

this

JavaScript 的 this 总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。

this 指向

除去不常用的 with 和 eval 的情况,具体到实际应用中,this 的指向大致可以分为以下 4 种。

  1. 作为对象的方法调用。
  2. 作为普通函数调用
  3. 构造器调用。
  4. Function.prototype.callFunction.prototype.apply 调用。

关于 4 种调用方式的 this 指向,不再累述,可查阅《JavaScript 秘密花园》。

this 丢失

当方法作为普通函数调用时容易出现 this 丢失问题。例如获取根据 id 获取节点:

var getId = function(id) {
  return document.getElementById(id)
}
getId('div1') // ok!

但是如果直接把方法赋值给一个变量:

var getId = document.getElementById
getId('div1') // error!

在浏览器中执行这段代码抛出了一个异常,这是因为许多引擎的 document.getElementById 方法的内部实现中需要用到 this。这个 this 本来被期望指向 document,当 getElementById 方法作为 document 对象的属性被调用时,方法内部的 this 确实是指向 document 的。但当用 getId 来引用调用时就成了普通函数调用,函数内部的 this 指向了 window,而不是原来的 document。

可以尝试利用 apply 把 document 当作 this 传入 getId 函数,帮助“修正” this:

document.getElementById = (function(func) {
  return function() {
    return func.apply(document, arguments)
  }
})(document.getElementById)
var getId = document.getElementById
var div = getId('div1')

call 和 apply

Function.prototype.callFunction.prototype.apply 作用一模一样,区别仅在于传入参数形式的不同。

apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组或类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数。

call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数。

性能上来说 call 的效率比 apply 更高。当使用 call 或者 apply 的时候,如果传入的第一个参数为 null,函数体内的 this 会指向默认的宿主对象,在浏览器中则是 window,但如果是在严格模式下,函数体内的 this 还是为 null(注意不是 undefined)。

有时候使用 call 或者 apply 的目的不在于指定 this 指向,而是另有用途,比如借用其他对象的方法,那么可以传入 null 来代替某个具体的对象:

Math.max.apply(null, [1, 2, 5, 3, 4]) // 输出:5

call 和 apply 的用途主要有 3 种:

  1. 改变 this 指向
  2. Function.prototype.bind
  3. 借用其他对象的方法

改变 this 指向

document.getElementById('div1').onclick = function() {
  var func = function() {
    console.log(this.id) // 输出:div1
  }
  func.call(this)
}

Function.prototype.bind

大部分高级浏览器都实现了内置的 Function.prototype.bind,用来指定函数内部的 this 指向,即使没有实现也可以模拟:

Function.prototype.bind = function(context) {
  var self = this // 保存原函数
  return function() {
    // 返回一个新的函数
    return self.apply(context, arguments) // 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
  }
}

var obj = { name: 'sven' }
var func = function() {
  console.log(this.name) // 输出:sven
}.bind(obj)
func()

这是一个简化版的 Function.prototype.bind 实现,通常还会把它实现得稍微复杂一点,使得可以往函数中预先填入一些参数:

Function.prototype.bind = function() {
  var self = this, // 保存原函数
    context = [].shift.call(arguments), // 需要绑定的 this 上下文
    args = [].slice.call(arguments) // 剩余的参数转成数组
  return function() {
    // 返回一个新的函数
    // 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
    // 并且组合两次分别传入的参数,作为新函数的参数
    return self.apply(context, [].concat.call(args, [].slice.call(arguments)))
  }
}

var obj = { name: 'sven' }
var func = function(a, b, c, d) {
  console.log(this.name) // 输出:sven
  console.log([a, b, c, d]) // 输出:[ 1, 2, 3, 4 ]
}.bind(obj, 1, 2)
func(3, 4)

闭包和高阶函数

在 JavaScript 版本的设计模式中,许多模式都可以用闭包和高阶函数来实现。

闭包

闭包的作用

闭包常见的作用有封装变量和延续局部变量的寿命。举两个栗子:

  • 封装变量:通过闭包加入缓存机制来提高函数的性能
var mult = (function() {
  var cache = {}
  var calculate = function() {
    var a = 1
    for (var i = 0, l = arguments.length; i < l; i++) {
      a = a * arguments[i]
    }
    return a
  }
  return function() {
    var args = Array.prototype.join.call(arguments, ',')
    if (args in cache) {
      return cache[args]
    }
    return (cache[args] = calculate.apply(null, arguments))
  }
})()

console.log(mult(1, 2, 3)) // 输出:6
console.log(mult(1, 2, 3)) // 输出:6
  • 延续局部变量的寿命:img 对象经常用于进行数据上报
var report = function(src) {
  var img = new Image()
  img.src = src
}
report('http://xxx.com/getUserInfo')

但是 report 函数并不是每一次都成功发起了 HTTP 请求,丢失数据的原因是 img 是 report 函数中的局部变量,当 report 函数的调用结束后,img 局部变量随即被销毁,而此时或许还没来得及发出 HTTP 请求,所以此次请求就会丢失掉。

只要把 img 变量用闭包封闭起来,便能解决请求丢失的问题。

var report = (function() {
  var imgs = []
  return function(src) {
    var img = new Image()
    imgs.push(img)
    img.src = src
  }
})()

闭包和面向对象设计

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面向对象**能实现的功能,用闭包也能实现。

下面来看看这段跟闭包相关的代码:

var extent = function() {
  var value = 0
  return {
    call: function() {
      value++
      console.log(value)
    }
  }
}
var extent = extent()
extent.call()

如果换成面向对象的写法:

var extent = {
  value: 0,
  call: function() {
    this.value++
    console.log(this.value)
  }
}
extent.call()

亦或者:

var Extent = function() {
  this.value = 0
}
Extent.prototype.call = function() {
  this.value++
  console.log(this.value)
}
var extent = new Extent()
extent.call()

闭包实现命令模式

通过上节比较,这里分别使用闭包和面向对象来实现命令模式。

  • 面向对象版本
var Tv = {
  open: function() {
    console.log('打开电视机')
  },
  close: function() {
    console.log('关上电视机')
  }
}

var OpenTvCommand = function(receiver) {
  this.receiver = receiver
}
OpenTvCommand.prototype.execute = function() {
  this.receiver.open() // 执行命令,打开电视机
}
OpenTvCommand.prototype.undo = function() {
  this.receiver.close() // 撤销命令,关闭电视机
}

var setCommand = function(command) {
  document.getElementById('execute').onclick = function() {
    command.execute() // 输出:打开电视机
  }
  document.getElementById('undo').onclick = function() {
    command.undo() // 输出:关闭电视机
  }
}

setCommand(new OpenTvCommand(Tv))
  • 闭包版本
var Tv = {
  open: function() {
    console.log('打开电视机')
  },
  close: function() {
    console.log('关上电视机')
  }
}

// 通过闭包返回一个对象
var createCommand = function(receiver) {
  var execute = function() {
    return receiver.open() // 执行命令,打开电视机
  }
  var undo = function() {
    return receiver.close() // 执行命令,关闭电视机
  }
  return {
    execute: execute,
    undo: undo
  }
}

var setCommand = function(command) {
  document.getElementById('execute').onclick = function() {
    command.execute() // 输出:打开电视机
  }
  document.getElementById('undo').onclick = function() {
    command.undo() // 输出:关闭电视机
  }
}

setCommand(createCommand(Tv))

命令模式的意图是把请求封装为对象,从而分离请求的发起者和请求的接收者之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接收者。

但在 JavaScript 中,函数作为一等对象,本身就可以四处传递,用函数对象而不是普通对象来封装请求显得更加简单和自然。如果需要往函数对象中预先植入命令的接收者,那么闭包可以完成这个工作。

在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中。

闭包与内存管理

闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。

跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。

在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。如果要解决循环引用带来的内存泄露问题,只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

高阶函数

高阶函数是指至少满足下列条件之一的函数:

  • 函数可以作为参数被传递
  • 函数可以作为返回值输出

AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用功能模块。

通常在 JavaScript 中实现 AOP,都是指把一个函数“动态织入”到另外一个函数之中,这种使用 AOP 的方式来给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式实现。

Function.prototype.before = function(beforefn) {
  var __self = this // 保存原函数的引用
  return function() {
    // 返回包含了原函数和新函数的"代理"函数
    beforefn.apply(this, arguments) // 执行新函数,修正this
    return __self.apply(this, arguments) // 执行原函数
  }
}

Function.prototype.after = function(afterfn) {
  var __self = this
  return function() {
    var ret = __self.apply(this, arguments)
    afterfn.apply(this, arguments)
    return ret
  }
}

var func = function() {
  console.log(2)
}

func = func
  .before(function() {
    console.log(1)
  })
  .after(function() {
    console.log(3)
  })

func()

高阶函数的应用

高阶函数常见的应用包括:currying、uncurrying、函数节流、分时函数、惰性加载函数等。

  • currying

函数柯里化(function currying)又称部分求值,一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。

一个计算月开销的示例如下,未柯里化之前:

var monthlyCost = 0
var cost = function(money) {
  monthlyCost += money
}
cost(100) // 第1天开销
cost(200) // 第2天开销

这段代码每次都会计算当天为止的总开销,但这并不重要,柯里化之后:

var currying = function(fn) {
  var args = []
  return function() {
    if (arguments.length === 0) {
      return fn.apply(this, args)
    } else {
      ;[].push.apply(args, arguments)
      return arguments.callee
    }
  }
}

var cost = (function() {
  var money = 0
  return function() {
    for (var i = 0, l = arguments.length; i < l; i++) {
      money += arguments[i]
    }
    return money
  }
})()

var cost = currying(cost) // 转化成 currying 函数
cost(100) // 未真正求值
cost(200) // 未真正求值
  • uncurrying

通过 call 和 apply 可以借用其他对象的方法,把任意对象当作 this 传入某个方法,方法中用到 this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。

uncurrying 可以把把泛化 this 的过程提取出来。

// 方式①
Function.prototype.uncurrying = function() {
  var self = this
  return function() {
    var obj = Array.prototype.shift.call(arguments)
    return self.apply(obj, arguments)
  }
}

// 方式②
Function.prototype.uncurrying = function() {
  var self = this
  return function() {
    return Function.prototype.call.apply(self, arguments)
  }
}

使用方式:

var push = Array.prototype.push.uncurrying()(function() {
  push(arguments, 4)
  console.log(arguments) // 输出:[1, 2, 3, 4]
})(1, 2, 3)

通过 uncurrying 的方式,Array.prototype.push.call 变成了一个通用的 push 函数。这样一来,push 函数的作用就跟 Array.prototype.push 一样了,同样不仅仅局限于只能操作 array 对象。而对于使用者而言,调用 push 函数的方式也显得更加简洁和意图明了。

  • 函数节流
var throttle = function(fn, interval) {
  var __self = fn,
    timer,
    firstTime = true

  return function() {
    var __me = this,
      args = arguments

    if (firstTime) {
      __self.apply(__me, args)
      return (firstTime = false)
    }

    if (timer) {
      return false
    }

    timer = setTimeout(function() {
      clearTimeout(timer)
      timer = null
      __self.apply(__me, args)
    }, interval || 500)
  }
}

// 注:除了使用 timer 定时器,还可以使用 Date 比较
  • 分时函数

通过函数节流限制函数被频繁调用,但是在一些情况下,某些函数确实通过用户主动调用,但会严重影响页面性能,如用户主动创建一个大列表,在短时间内往页面中大量添加 DOM 节点显然也会让浏览器吃不消,结果往往就是浏览器的卡顿甚至假死。这个问题的解决方案之一是下面的 timeChunk 分时函数,分时函数让创建节点的工作分批进行,比如把 1 秒钟创建 1000 个节点,改为每隔 200 毫秒创建 8 个节点。

var timeChunk = function(ary, fn, count = 1) {
  var t,
    len = ary.length
  var start = function() {
    for (var i = 0; i < Math.min(count, len); i++) {
      var obj = ary.shift()
      fn(obj)
    }
  }
  return function() {
    t = setInterval(function() {
      if (ary.length === 0) {
        // 如果全部节点都已经被创建好
        return clearInterval(t)
      }
      start()
    }, 200) // 分批执行的时间间隔,也可以用参数的形式传入
  }
}
  • 惰性加载函数

在 Web 开发中,因为浏览器之间的实现差异,一些嗅探工作总是不可避免。比如需要一个在各个浏览器中能够通用的事件绑定函数 addEvent,常见的写法如下:

var addEvent = function(elem, type, handler) {
  if (window.addEventListener) {
    return elem.addEventListener(type, handler, false)
  }
  if (window.attachEvent) {
    return elem.attachEvent('on' + type, handler)
  }
}

这个函数的缺点是,当它每次被调用的时候都会执行里面的 if 条件分支,虽然执行这些 if 分支的开销不算大,但也许有一些方法可以让程序避免这些重复的执行过程。

第二种方案可以把嗅探浏览器的操作提前到代码加载的时候,在代码加载的时候就立刻进行一次判断,以便让 addEvent 返回一个包裹了正确逻辑的函数。

var addEvent = (function() {
  if (window.addEventListener) {
    return function(elem, type, handler) {
      elem.addEventListener(type, handler, false)
    }
  }

  if (window.attachEvent) {
    return function(elem, type, handler) {
      elem.attachEvent('on' + type, handler)
    }
  }
})()

目前的 addEvent 函数依然有个缺点,也许从头到尾都没有使用过 addEvent 函数,这样前一次的浏览器嗅探就是完全多余的操作,而且也会稍稍延长页面 ready 的时间。

第三种方案即是惰性载入函数方案。此时 addEvent 依然被声明为一个普通函数,在函数里依然有一些分支判断。但是在第一次进入条件分支之后,在函数内部会重写这个函数,重写之后的函数就是期望的 addEvent 函数,在下一次进入时函数里不再存在条件分支语句:

var addEvent = function(elem, type, handler) {
  if (window.addEventListener) {
    addEvent = function(elem, type, handler) {
      elem.addEventListener(type, handler, false)
    }
  } else if (window.attachEvent) {
    addEvent = function(elem, type, handler) {
      elem.attachEvent('on' + type, handler)
    }
  }
  addEvent(elem, type, handler)
}

以上便是《JavaScript 常用设计模式》第一部分总结,在 JavaScript 中,闭包和高阶函数的应用极多,很多设计模式都是通过闭包和高阶函数实现的。

ES6标准入门-异步编程 Promise

Promise 是 JS 中异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理且更强大。ES6 将其写进了语言标准,并原生提供了 Promise 对象支持。

Promise 对象

Promise 含义

所谓 Promise,简单来说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。Promise 对象有以下两个特点:

  1. 对象的状态不受外界影响。Promise 对象代表一个异步操作,有 3 种状态:Pending(进行中)、Fulfilled(已成功)和 Rejected(已失败)。只有异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
  2. 一旦状态改变就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变只有两种可能:从 Pending 变为 Fulfilled 和从 Pending 变为 Rejected。只要这两种情况发生,状态就凝固了,这时就称为 Resolved(已定型)。就算改变已经发生,再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同。事件的特点是,如果错过了它,再去监听是得不到结果的。

Promise 也有一些缺点:

  1. 无法取消 Promise,一旦新建它就会立即执行,无法中途取消。
  2. 如果不设置回调函数,Promise 内部抛出的错误不会反应到外部。
  3. 当处于 Pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

基本用法

Promise 对象是一个构造函数,用来生成 Promise 实例。

Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是两个函数 resolve 和 reject,它们由引擎提供,不用自己部署。

resolve 函数将 Promise 对象的状态从 Pending 变为 Resolved,在异步操作成功时调用,并将异步操作的结果作为参数传递出去;reject 函数将 Promise 对象的状态从 Pending 变为 Rejected,在异步操作失败时调用,并将异步操作报出的错误作为参数传递出去。

Promise 实例生成以后,可以用 then 方法分别指定 Resolved 状态和 Rejected 状态的回调函数。

const promise = new Promise(function(resolve, reject) {
  // ... some code
  if (/* 异步操作成功 */) {
    resolve(value)
  } else {
    reject(error)
  }
})

promise.then(
  function(value) {
    // success
  },
  function(error) {
    // failure
  }
)

then 方法可以接受两个回调函数作为参数。第一个回调函数是 Promise 对象的状态变为 Resolved 时调用,第二个可选回调函数是 Promise 对象的状态变为 Rejected 时调用。这两个函数都接受 Promise 对象传出的值作为参数。

Promise 新建后就会立即执行

let promise = new Promise(function(resolve, reject) {
  console.log('Promise')
  resolve()
})
promise.then(function() {
  console.log('Resolved.')
})
console.log('Hi!')
// Promise
// Hi!
// Resolved

上面代码中,Promise 新建后会立即执行,所以首先输出的是 Promise。然后,then 方法指定的回调函数将在当前脚本所有同步任务执行完成后才会执行,所以 Resolved 最后输出。

resolve 函数的参数除了正常的值外,还可能是另一个 Promise 实例:

const p1 = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error('fail')), 3000)
})
const p2 = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(p1), 1000)
})

p2.then(result => console.log(result)).catch(error => console.log(error)) // Error: fail

上面的代码中,p1 和 p2 都是 Promise 的实例,但是 p2 的 resolve 方法将 p1 作为参数,此时 p1 的状态就会传递给 p2。也就是说,p1 的状态决定了 p2 的状态。如果 p1 的状态是 Pending,那么 p2 的回调函数就会等待 p1 的状态改变;如果 p1 的状态已经是 Resolved 或 Rejected,那么 p2 的回调函数将会立刻执行。

需要注意:调用 resolve 或 reject 并不会终结 Promise 的参数函数的执行

new Promise((resolve, reject) => {
  resolve(1)
  console.log(2)
}).then(r => {
  console.log(r)
})
// 2
// 1

上面的代码中,调用 resolve(1) 以后,后面的 console.log(2) 还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。所以,最好在它们前面加上 return 语句,这样就不会产生意外。

new Promise((resolve, reject) => {
  return resolve(1)
  // 后面的语句不会执行
  console.log(2)
})

Promise.prototype.then()

Promise 实例 then 方法是定义在原型对象 Promise.prototype 上。then 方法返回的是一个新的 Promise 实例,因此可以采用链式写法。

Promise.prototype.catch()

Promise.prototype.catch 方法是 .then(null, rejection) 的别名,用于指定发生错误时的回调函数。

异步操作 reject 抛出的错误和 then 方法回调函数在运行中抛出的错误,都会被 catch 方法捕获。

p.then(val => console.log('fulfilled:', val)).catch(err => console.log('rejected', err))

// 等同于
p.then(val => console.log('fulfilled:', val)).then(null, err => console.log('rejected:', err))

如果 Promise 状态已经变成 Resolved,再抛出错误是无效的

const promise = new Promise(function(resolve, reject) {
  resolve('ok')
  throw new Error('test')
})
promise
  .then(function(value) {
    console.log(value)
  })
  .catch(function(error) {
    console.log(error)
  }) // ok

上面的代码中,Promise 在 resolve 语句后面再抛出错误,并不会被捕获,等于没有抛出。因为 Promise 的状态一旦改变,就会永久保持该状态,不会再改变了。

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch 语句捕获。

一般说来,不要在 then 方法中定义 Rejected 状态的回调函数(即 then 的第二个参数),而应总是使用 catch 方法。

跟传统的 try/catch 代码块不同的是,如果没有使用 catch 方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。

const someAsyncThing = function() {
  return new Promise(function(resolve, reject) {
    // 下面一行会报错,因为x没有声明
    resolve(x + 2)
  })
}
someAsyncThing().then(function() {
  console.log('everything is great')
})

上面的代码中,由于没有指定 catch 方法,错误不会被捕获,也不会传递到外层代码。正常情况下,运行后不会有任何输出。如果这个脚本放在服务器中执行,退出码就是 0(即表示执行成功)。

const promise = new Promise(function(resolve, reject) {
  resolve('ok')
  setTimeout(function() {
    throw new Error('test')
  }, 0)
})
promise.then(function(value) {
  console.log(value)
})
// ok
// Uncaught Error: test

上面的代码中,Promise 指定在下一轮“事件循环”再抛出错误。那时,Promise 的运行已经结束,所以这个错误是在 Promise 函数体外抛出的,会冒泡到最外层,成了未捕获的错误。

需要注意:catch 方法返回的还是一个 Promise 对象,因此后面还可以接着调用 then 方法。此时要是后面的 then 方法里面报错,就与前面的 catch 无关了。

Promise.all()

Promise.all 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。方法接受一个数组(或者具有 Iterator 接口结构)作为参数,数组成员都是 Promise 对象的实例;如果不是,就会先调用 Promise.resolve 方法,将参数转为 Promise 实例。

const p = Promise.all([p1, p2, p3])

p 的状态由成员决定,分成两种情况:

  1. 只有所有成员的状态都变成 Fulfilled,p 的状态才会变成 Fulfilled,此时返回值组成一个数组,传递给 p 的回调函数。
  2. 只要成员中有一个被 Rejected,p 的状态就变成 Rejected,此时第一个被 Rejected 的实例的返回值会传递给 p 的回调函数。

需要注意:如果作为参数的 Promise 实例自身定义了 catch 方法,那么它被 rejected 时并不会触发 Promise.all()的 catch 方法

const p1 = new Promise((resolve, reject) => {
  resolve('hello')
})
  .then(result => result)
  .catch(e => e)

const p2 = new Promise((resolve, reject) => {
  throw new Error('报错了')
})
  .then(result => result)
  .catch(e => e)

Promise.all([p1, p2])
  .then(result => console.log(result)) // ["hello", Error: 报错了]
  .catch(e => console.log(e))

上面的代码中,p1 会 resolved,p2 首先会 rejected,但是 p2 有自己的 catch 方法,该方法返回的是一个新的 Promise 实例,p2 实际上指向的是这个实例。该实例执行完 catch 方法后也会变成 resolved,导致 Promise.all() 方法参数里面的两个实例都会 resolved,因此会调用 then 方法指定的回调函数,而不会调用 catch 方法指定的回调函数。如果 p2 没有自己的 catch 方法,就会调用 Promise.all() 的 catch 方法。

Promise.race()

Promise.race 方法同样是将多个 Promise 实例包装成一个新的 Promise 实例。

不同的是只要成员中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值就传递给 p 的回调函数。

const p = Promise.race([p1, p2, p3])

下面是一个例子,如果指定时间内没有获得结果,就将 Promise 的状态变为 Rejected,否则变为 Resolved。

const p = Promise.race([
  fetch('/resource-that-may-take-a-while'),
  new Promise(function(resolve, reject) {
    setTimeout(() => reject(new Error('request timeout')), 5000)
  })
])
p.then(response => console.log(response))
p.catch(error => console.log(error))

Promise.resolve()

Promise.resolve 方法将现有对象转为 Promise 对象。Promise.resolve 等价于下面的写法:

Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))

Promise.resolve 方法的参数分成以下 4 种情况:

  1. 参数是一个 Promise 实例。如果参数是 Promise 实例,那么 Promise.resolve 将不做任何修改,原封不动地返回这个实例。

  2. 参数是一个 thenable 对象。thenable 对象指的是具有 then 方法的对象。

let thenable = {
  then: function(resolve, reject) {
    resolve(42)
  }
}

Promise.resolve 方法会将这个对象转为 Promise 对象,然后立即执行 thenable 对象的 then 方法。

  1. 参数不是具有 then 方法的对象或根本不是对象。如果参数是一个原始值,或者是一个不具有 then 方法的对象,那么 Promise.resolve 方法返回一个新的 Promise 对象,状态为 Resolved。

  2. 不带有任何参数。Promise.resolve 方法允许在调用时不带有参数,而直接返回一个 Resolved 状态的 Promise 对象。

需要注意:立即 resolve 的 Promise 对象是在本轮“事件循环”(event loop)结束时,而不是在下一轮“事件循环”开始时

setTimeout(function() {
  console.log('three')
}, 0)
Promise.resolve().then(function() {
  console.log('two')
})
console.log('one')
// one
// two
// three

上面的代码中,setTimeout(fn, 0) 是在下一轮“事件循环”开始时执行的,Promise.resolve() 在本轮“事件循环”结束时执行,console.log('one') 则是立即执行,因此最先输出。

Promise.reject()

Promise.reject(reason) 方法也会返回一个新的 Promise 实例,状态为 Rejected。

需要注意:Promise.reject() 方法的参数会原封不动地作为 reject 的理由变成后续方法的参数。这一点与 Promise.resolve 方法不一致

const thenable = {
  then(resolve, reject) {
    reject('出错了')
  }
}
Promise.reject(thenable).catch(e => {
  console.log(e === thenable) // true
})

上面的代码中,Promise.reject 方法的参数是一个 thenable 对象,执行以后,后面 catch 方法的参数不是 reject 抛出的“出错了”这个字符串,而是 thenable 对象。

附加方法

ES6 的 Promise API 提供的方法不是很多,可以自己部署一些有用的方法。下面部署两个不在 ES6 中但很有用的方法。

done()

无论 Promise 对象的回调链以 then 方法还是 catch 方法结尾,只要最后一个方法抛出错误,都有可能无法捕捉到(因为 Promise 内部的错误不会冒泡到全局)。为此,可以提供一个 done 方法,它总是处于回调链的尾端,保证抛出任何可能出现的错误。

asyncFunc()
  .then(f1)
  .catch(r1)
  .then(f2)
  .done()

代码实现:

Promise.prototype.done = function(onFulfilled, onRejected) {
  this.then(onFulfilled, onRejected).catch(function(reason) {
    // 抛出一个全局错误
    setTimeout(() => {
      throw reason
    }, 0)
  })
}

done 方法可以像 then 方法那样使用,提供 Fulfilled 和 Rejected 状态的回调函数,也可以不提供任何参数。但不管怎样,done 方法都会捕捉到任何可能出现的错误,并向全局抛出。

finally()

finally 方法用于指定不管 Promise 对象最后状态如何都会执行的操作。它与 done 方法的最大区别在于,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。

下面一个示例,服务器使用 Promise 处理请求,然后使用 finally 方法关掉服务器。

server
  .listen(0)
  .then(function() {
    // run test
  })
  .finally(server.stop)

代码实现:

Promise.prototype.finally = function(callback) {
  let P = this.constructor
  return this.then(
    value => P.resolve(callback()).then(() => value),
    reason =>
      P.resolve(callback()).then(() => {
        throw reason
      })
  )
}

上面的代码中,不管前面的 Promise 是 fulfilled 还是 rejected,都会执行回调函数 callback。

Generator 函数与 Promise 的结合

使用 Generator 函数管理流程,遇到异步操作时通常返回一个 Promise 对象。

function getFoo() {
  return new Promise(function(resolve, reject) {
    resolve('foo')
  })
}
let g = function*() {
  try {
    let foo = yield getFoo()
    console.log(foo)
  } catch (e) {
    console.log(e)
  }
}
function run(generator) {
  let it = generator()
  function go(result) {
    if (result.done) return result.value
    return result.value.then(
      function(value) {
        return go(it.next(value))
      },
      function(error) {
        return go(it.throw(error))
      }
    )
  }
  go(it.next())
}
run(g)

Promise.try()

实际开发中经常遇到一种情况:不知道或者不想区分函数 f 是同步函数还是异步操作,但是想用 Promise 来处理它。因为这样就可以不管 f 是否包含异步操作,都用 then 方法指定下一步流程,用 catch 方法处理 f 抛出的错误。一般的写法如下:

Promise.resolve().then(f)

上面的写法有一个缺点:如果 f 是同步函数,那么它会在本轮事件循环的末尾执行:

const f = () => console.log('now')
Promise.resolve().then(f)
console.log('next')
// next
// now

有两种方法可以让同步函数同步执行,让异步函数异步执行,并且让它们具有统一的 API。

第一种方法是用 async 函数:

const f = () => console.log('now')
;(async () => f())()
console.log('next')
// now
// next

上面的代码中,第二行是一个立即执行的匿名函数,会立即执行里面的 async 函数,因此如果 f 是同步的,就会得到同步的结果;如果 f 是异步的,就可以用 then 指定下一步。同时需要注意:async ()=>f() 会吃掉 f() 抛出的错误。所以,如果想捕获错误,要使用 promise.catch 方法:

;(async () => f())().then(...).catch(...)

第二种方式是使用 new Promise():

const f = () => console.log('now')
;(() => new Promise(resolve => resolve(f())))()
console.log('next')
// now
// next

主题集成友链访问统计

前段时间小站添加了不少友链,好奇这些友链的转换价值,偷偷在主题里埋了个小功能来统计这个无人小站的游客访问来源,目前已稳定运行了近半月,统计了下近期友链访问数据。

document.referrer

这个统计功能主要是使用了document.referrer,它返回跳转或打开到当前页面的那个页面的 URI。如果用户直接打开了这个页面(不是通过页面跳转,而是通过地址栏或者书签等打开的),则该属性为空字符串。目前主流桌面浏览器基本都支持这个 API。

Can I Use

代码实现

接下来先看下半个月所有访问这个无人小站的来源,如果是通过书签或者直接访问的话 document.referrer 为空,访问来源就是本站。好吧,从数据看起来这个站还真无人气,最后感谢下白喵的友链,居然能有辣么多人点进来看~

半月访问统计

最后来一份实现代码:

// 转换访问来源地址
const getLocation = href => {
  const a = document.createElement('a')
  a.href = href
  return a
}

// 统计访客数据
const visitorStatistics = () => {
  const referrer = getLocation(document.referrer)
  const hostname = referrer.hostname

  return new Promise(resolve => {
    const query = new AV.Query('Visitor')
    const Visitor = AV.Object.extend('Visitor')
    query.equalTo('referrer', hostname)
    query
      .first()
      .then(res => {
        // 存在则增加访问次数
        if (res) {
          res
            .increment('time', 1)
            .save(null, { fetchWhenSave: true })
            .then(() => resolve())
            .catch(console.error)
        } else {
          // 不存在则新建
          const newVisitor = new Visitor()
          newVisitor.set('referrer', hostname)
          newVisitor.set('time', 1)
          newVisitor
            .save()
            .then(() => resolve())
            .catch(console.error)
        }
      })
      .catch(console.error)
  }).catch(console.error)
}

ES6标准入门-Async 函数

Async 函数是 ES2017 标准提供的改进版异步编程解决方案,它比 Generator 函数更加优雅方便。Async 函数就是隧道尽头的亮光,很多人认为它是异步操作的终极解决方案。

异步操作是 JavaScript 编程的麻烦事,从最早的回调函数,到 Promise 对象,再到 Generator 函数,每次都有所改进,但又让人觉得不彻底。它们都有额外的复杂性,都需要理解抽象的底层运行机制。

异步编程的最高境界,就是根本不用关心它是不是异步。

async 函数

含义

总体来讲,async 函数是 Generatot 函数的语法糖,但是相比之有 4 点改进:

  1. 内置执行器:Generator 函数执行必须靠执行器,而 async 函数自带执行器。
  2. 更好的语义:async 和 await 相比星号和 yield,语义更清晰。
  3. 更广的适用性:yield 命令后面只能是 Thunk 函数或 Promise 对象,而 await 命令后面可以是 Promise 对象或原始值。
  4. 返回值是 Promise:async 函数返回值是 Promise 对象,而 Generator 函数返回值是 Iterator 对象。

async 函数可以看作由多个异步操作包装成的一个 Promise 对象,而 await 命令就是内部 then 命令的语法糖。

语法

await 命令

async 函数返回一个 Promise 对象。async 函数内部 return 语句返回的值,会成为 then 方法回调函数的参数。

正常情况下,await 命令后面是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。此时加不加 return 效果一样。

async function f() {
  // 加不加 return 效果一样
  await Promise.reject('出错了')
}

f()
  .then(v => console.log(v))
  .catch(e => console.log(e)) // 出错了

需要注意:只要一个 await 语句后面的 Promise 变为 reject,那么整个 async 函数都会中断执行。如果希望异步操作失败也不会中断后面的异步操作,有两种解决办法:

  1. 将 await 放在 try...catch 结构里面。
  2. 在 await 后面的 Promise 对象后面添加一个 catch 方法。
async function f() {
  // 失败也能继续执行后面的异步操作
  await Promise.reject('出错了').catch(e => console.log(e))
  return await Promise.resolve('hello world')
}

错误处理

如果 await 后面的异步操作出错,那么等同于 async 函数返回的 Promise 对象被 reject。如果有多个 await 命令,则可以统一放在 try...catch 结构中。

// 使用 try...catch 实现多次重复尝试
const NUM_RETRIES = 3
async function test() {
  for (let i = 0; i < NUM_RETRIES; ++i) {
    try {
      await fetch('http://google.com/this-throws-an-error')
      // 如果请求成功,则跳出循环,否则继续重试直至三次
      break
    } catch (err) {}
  }
}

注意点

在使用 await 命令时,有几个注意点:

  1. 最好将 await 命令放在 try...catch 代码块中。
  2. 多个 await 命令如果不存在继发关系,最好同时触发。
// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()])

// 写法二
let fooPromise = getFoo()
let barPromise = getBar()
let foo = await fooPromise
let bar = await barPromise

forEach 陷阱

如果使用 forEach 循环执行异步操作,此时多个异步操作是并发执行的

function func() {
  list.forEach(async url => {
    // 并发执行
    await fetch(url)
  })
}

正确的写法是采用 for 循环或 for of 循环:

async function func() {
  for (let url of list) {
    // 相继执行
    await fetch(url)
  }
}

为什么使用 forEach 和 for 循环执行多个异步操作会有不同表现,翻阅一些网上资料,找到 forEach 的 polyfill 实现如下:

Array.prototype.forEach = function(callback) {
  // this represents our array
  for (let index = 0; index < this.length; index++) {
    // We call the callback for each entry
    callback(this[index], index, this)
  }
}

可以看出相当于 for 循环执行了这个异步函数,但是却是并发执行的,因为 callback 函数并没有进行异步执行。我们可以改造一个异步执行的 forEach 函数:

async function asyncForEach(array, callback) {
  for (let index = 0; index < array.length; index++) {
    await callback(array[index], index, array)
  }
}

则可以改造上面的 forEach 异步操作代码:

asyncForEach(list, async url => {
  await fetch(url)
})

实际开发中经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。分别使用 Promise 和 async 实现如下:

// Promise 实现
function logInOrder(urls) {
  // 远程读取所有 URL
  const textPromises = urls.map(url => {
    return fetch(url).then(response => response.text())
  })

  // 按次序输出
  textPromise.reduce((chain, textPrimose) => {
    return chain.then(() => textPromise).then(text => console.log(text))
  }, Promise.resolve())
}

// async 实现
function logInOrder(urls) {
  // 并发读取所有 URL
  const textPromises = urls.map(async url => {
    const response = await fetch(url)
    return response.text()
  })

  // 按次序输出
  for (const textPromise of textPromisces) {
    console.log(await textPromise)
  }
}

关于异步操作的重要概念之一遍历器这里按下不表,等有需要再来重新回顾。

Aurora 食用指南

Aurora 是一款动漫风格博客主题,基于 Vue 开发,使用开源的 Github Api 服务,开发至今一直以为主题无人问津,近来有人问起如何食用,故忙里偷闲摸一篇简单食用文档。

相较于 Wordpress 和 Typecho 等博客框架,Aurora 主题最大的优势就是简单轻量与免费,全部使用现有开源免费服务,相对稳定,也不怕 Github 跑路(笑),文章发布与更新也是相当简单,不需要操作服务器与数据库,对新人来说非常友好。

初始化环境

在食用 Aurora 主题之前,需先安装 Nodejs 和 Git 环境,这两步不必细说。环境安装完毕,由于 Aurora 使用 vue 开发,所以需要安装 vue-cli

npm install -g @vue/cli-service-global

然后将主题 clone 到本地并安装依赖包:

# clone 主题
git clone [email protected]:chanshiyucx/aurora.git

# 进入主题目录
cd aurora

# 安装依赖包
npm install

# 本地预览
npm start

依赖包安装完毕,便可执行 npm start 本地预览效果,访问 http://localhost:8080/, 当然现在看到的是我的博客,接下来需要我们自定义主题。

替换站点标题和图标

首先修改站点标题和图标,替换主题目录 public/assets 下的图标资源,然后修改 public/index.html 的站点标题和描述,同时注意修改 manifest.json 标题。

配置主题

主题配置文件为根目录下 src/config.js,里面每个配置项皆有详细注释,这里对一些基本配置项做简要说明。

配置文章仓库

Aurora 使用 Github api 做后台数据托管。所以需要新建一个仓库来存放文章和一些自定义页面内容。这里新建一个仓库取名为 Blog 为例。

由于 Github api 有访问次数限制,所以需要申请 token 来解除访问限制,申请地址戳这里。将申请的 token 从中间随意拆成两部分填入配置项,拆分的目的避免代码提交的时候 github 对其进行检测,导致 token 失效。

Github Token

// github 用户名
username: 'chanshiyucx',
// 仓库地址
repository: 'blog',
// token 从中间任意位置拆开成两部分,避免 github 代码检测失效
token: ['0ad1a0539c5b96fd18fa', 'aaafba9c7d1362a5746c'],

配置 Leancloud

Aurora 主题的文章阅读次数与 Valine 评论系统都是采用 Leancloud 存储。注册一个 Leancloud 账号并新建一个应用,将应用 key 填入相应配置项。 然后创建三个 Class,Comment 用来存储游客评论、Counter 用来存储文章热度、Visitor 用来统计友链访问次数,注意新建时选择表的访问权限无限制。

Leancloud_应用_Key

/**
 * leancloud 配置 【文章浏览次数】
 */
leancloud: {
  appId: 'b6DWxsCOWuhurfp4YqbD5cDE-gzGzoHsz',
  appKey: 'h564RR5uVuJV5uSeP7oFTBye'
}

LeanCloud **版 2019 年 10 月份后开始停止为不绑定自有域名的应用服务,所以需要将节点切换到国际版。

配置 Gitalk

Gitalk 是一个基于 GitHub Issue 和 Preact 开发的评论插件。其原理的文章存储是一样的,详细介绍见官方文档

首先需要申请 GitHub Application,跳转地址填写你的博客域名,如果你使用 github pages 来托管你的网站,你也可以使用 https://chanshiyucx.github.io 域名。最后将生成的 Client IDClient Secret 填入相应配置项。在开发环境调试时 Gitlak 无法展示是正常现象,发布到线上后会正常显示。

申请 GitHub Application

生成 clientID 和 clientSecret

/**
 * Gittalk 配置【评论功能】
 */
gitalk: {
  clientID: '864b1c2cbc4e4aad9ed8',
  clientSecret: '6ca16373efa03347e11a96ff92e355c5cea189bb',
  repo: 'Comment', // 你的评论仓库
  owner: 'chanshiyucx', // 你的 github 账户
  admin: ['chanshiyucx'], // 你的 github 账户
  distractionFreeMode: false // 是否开始无干扰模式【背景遮罩】
}

到此为止,所有主要的配置项皆已完成,剩下的几个配置项非常简单,相信你自己可以配置完善。

页面模板

为了更好地定制各个页面的展示效果,Aurora 约定一些页面格式以便内容能够正确解析。主要约定 文章、书单、友链、关于 四个页面内容模板。模板参考 蝉時雨の Issues

文章模板

文章模板没有太多的格式约束,只需要在文章正文顶部加上封面配图即可,配图采用的是 markdown 的注释语法,所以并不会在正文里渲染,以后即使你更换博客主题,也不会影响内容的展示。

[pixiv: 41652582]: # 'https://raw.githubusercontent.com/chanshiyucx/yoi/master/bg/3.jpg'

由于博客的文章、友链、书单、关于、心情等内容都放在 issues 里,所以需要对它们进行区分,这里主要使用 issues 状态Labels 进行分类。

首先需要规定的是文章的 issues 状态都是 open 的,友链、书单、关于、心情页面的 issues 内容都是 closed 状态的

新建文章的时候 Labels 表示文章标签,Milestone 代表文章的分类,同时在文章正文顶部使用 markdown 注释来设置文章封面图,如下所示:

文章模板

Tips:通过给正文图片预设尺寸可以获得更流畅的图片加载效果,尺寸设置形如 ?vw=1920&vh=1080,举个栗子:

[预设尺寸](https://raw.githubusercontent.com/chanshiyucx/yoi/bg.png?vw=1920&vh=1080)

心情模板

注意心情 issues 状态是 closed 的,且需要打上 Inspiration 的 Labels,其他不做约束。

书单、友链、关于标签

友链模板

友链页面使用空行做分割,内容示例如下:

## 阁子

link: //newdee.cf/
cover: //i.loli.net/2018/12/15/5c14f329b2c88.png
avatar: //i.loli.net/2018/12/15/5c14f3299c639.jpg

书单模板

书单页面使用空行做分割,内容示例如下:

## ES6 标准入门

author: 阮一峰
published: 2017-09-01
progress: 正在阅读...
rating: 5,
postTitle: ES6 标准入门
postLink: //chanshiyu.com/#/post/12
cover: //chanshiyu.com/yoi/2019/ES6-标准入门.jpg
link: //www.duokan.com/book/169714
description: 柏林已经来了命令,阿尔萨斯和洛林的学校只许教 ES6 了...他转身朝着黑板,拿起一支粉笔,使出全身的力量,写了两个大字:“ES6 **!”(《最后一课》)。

关于模板

关于页面以 ## 段落 拆分,其他不做约束。

添加分类

添加分类

部署

Aurora 2.0 添加一键部署功能,只需要编辑 /deploy.sh,配置自己的仓库和域名,之后命令行执行 /deploy.sh,即可自动打包并上传到指定仓库,将该仓库开启 Github Pages 功能即可在线访问。相关文档参考自动部署

Just enjoy it! 😃

ES6标准入门-变量声明与解构赋值

柏林已经来了命令,阿尔萨斯和洛林的学校只许教 ES6 了...他转身朝着黑板,拿起一支粉笔,使出全身的力量,写了两个大字:“ES6 **!”(《最后一课》)。

阮一峰的《ES6 标准入门》第二版和第三版都有购入,第二版是去年买的实体书,当初大略翻了一遍,今年第三版又出世了,在原来的基础上新增了不少内容,是时候重拾书本学习了。

Any application that can be written in JavaScript will eventually be written in JavaScript. --Jeff Atwood

let 和 const

基本用法

let 和 const 声明变量的三大特性:不存在变量提升、暂时性死区、不允许重复声明

不存在变量提升

let 声明的变量一定要在声明后使用,否则便会报错。

// var的情况
console.log(foo) // 输出 undefined
var foo = 2

// let的情况
console.log(bar) // 报错 ReferenceError
let bar = 2

暂时性死区

ES6 规定,如果区块中存在 let 和 const 命令,则这个区块对这些命令声明的变量从一开始就形成封闭作用域。这在语法上称为“暂时性死区”(temporal dead zone,简称 TDZ)。

var tmp = 123
if (true) {
  // TDZ 开始
  tmp = 'abc' // ReferenceError
  let tmp
  // TDZ 结束
  console.log(tmp) // undefined
}

“暂时性死区”也意味着 typeof 不再是一个百分之百安全的操作,变量用 let 声明的话,那么在声明之前引用会报错。作为比较,如果一个变量根本没有被声明,使用 typeof 反而不会报错。

typeof x // ReferenceError
let x

typeof undeclared_variable // "undefined"

总之,暂时性死区的本质就是,只要进入当前作用域,所要使用的变量就已经存在,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

不允许重复声明

let 和 const 命令不允许在相同作用域内重复声明同一个变量。

function foo(arg) {
  let arg // 报错
}

function bar(arg) {
  {
    let arg // 不报错
  }
}

扩展

扩展 1:const 实际上保证的是变量指向的那个内存地址不得改动。如果真的想将对象冻结,应该使用 Object.freeze 方法。

var constantize = obj => {
  Object.freeze(obj)
  Object.keys(obj).forEach((key, i) => {
    if (typeof obj[key] === 'object') {
      constantize(obj[key])
    }
  })
}

扩展 2:ES5 只有两种声明变量的方法:使用 var 命令和 function 命令。ES6 除了添加了 let 和 const 命令,还有另外两种声明变量的方法:import 命令和 class 命令。所以,ES6 一共有 6 种声明变量的方法

块级作用域

ES5 只有全局作用域和函数作用域,没有块级作用域,容易出现变量覆盖和变量泄露的问题。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定:在块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用

块级作用域的出现,实际上使得获得广泛应用的立即执行匿名函数(IIFE)不再必要了。

但是由于这条规则会对旧代码产生很大影响。为了减轻因此产生的不兼容问题,浏览器可以不遵守这条规则,所以尽量避免在块级作用域内声明函数。

顶层对象的属性

顶层对象在浏览器环境中指的是 window 对象,在 Node 环境中指的是 global 对象。在 ES5 中,顶层对象的属性与全局变量等价。

ES6 为了改变这一点,一方面规定,为了保持兼容性,var 命令和 function 命令声明的全局变量依旧是顶层对象的属性;另一方面规定,let 命令、const 命令、class 命令声明的全局变量不属于顶层对象的属性。

var a = 1
window.a // 1

let b = 1
window.b // undefined

global 对象

ES5 的顶层对象在各种实现中是不统一的。

  • 在浏览器中,顶层对象是 window,但 Node 和 Web Worker 没有 window。
  • 在浏览器和 Web Worker 中,self 也指向顶层对象,但是 Node 没有 self。
  • 在 Node 中,顶层对象是 global,但其他环境都不支持。

同一段代码为了能够在各种环境中都取到顶层对象,目前一般是使用 this 变量,但是也有局限性。

  • 在全局环境中,this 会返回顶层对象。但是在 Node 模块和 ES6 模块中,this 返回的是当前模块。
  • 对于函数中的 this,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this 会指向顶层对象。但是严格模式下,this 会返回 undefined。
  • 不管是严格模式,还是普通模式,new Function('returnthis')() 总会返回全局对象。但是,如果浏览器用了 CSP(Content Security Policy,内容安全政策),那么 eval、new Function 这些方法都可能无法使用。

综上所述,很难找到一种方法可以在所有情况下都取到顶层对象。以下是两种勉强可以使用的方法:

// 方法一
typeof window !== 'undefined'
  ? window
  : typeof process === 'object' && typeof require === 'function' && typeof global === 'object'
  ? global
  : this

// 方法二
const getGlobal = function() {
  if (typeof self !== 'undefined') {
    return self
  }
  if (typeof window !== 'undefined') {
    return window
  }
  if (typeof global !== 'undefined') {
    return global
  }
  throw new Error('unable to locate global object')
}

变量的解构赋值

数组的解构赋值

解构(Destructuring)属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。

let [x, y, ...z] = ['a']
x // "a"
y // undefined
z // []

// 不完全解构
let [a, b] = [1, 2, 3]
a // 1
b // 2

对于 Set 结构,也可以使用数组的解构赋值。

let [x, y, z] = new Set(['a', 'b', 'c'])
x // "a"

事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。

function* fibs() {
  let a = 0
  let b = 1
  while (true) {
    yield a
    ;[a, b] = [b, a + b]
  }
}

let [first, second, third, fourth, fifth, sixth] = fibs()
sixth // 5

解构赋值允许指定默认值。ES6 内部使用严格相等运算符(===)判断一个位置是否有值。所以,如果一个数组成员不严格等于 undefined,默认值是不会生效的

let [x = 1] = [undefined]
x // 1

let [y = 1] = [null]
y // null

如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到时才会求值。

// x 可以取值,函数不会执行
function f() {
  console.log('aaa')
}
let [x = f()] = [1]

默认值可以引用解构赋值的其他变量,但该变量必须已经声明:

let [x = 1, y = x] = [] // x=1; y=1

对象的解构赋值

对象的解构赋值的内部机制是先找到同名属性,然后再赋值给对应的变量。真正被赋值的是后者,而不是前者。

let { foo: baz } = { foo: 'aaa', bar: 'bbb' }
baz // 'aaa'

上面的代码中,foo 是匹配的模式,baz 才是变量。真正被赋值的是变量 baz,而不是模式 foo。

与数组一样,解构也可以用于嵌套结构的对象:

let obj = { p: ['Hello', { y: 'World' }] }
let {
  p: [x, { y }]
} = obj

注意,这时 p 是模式,不是变量,因此不会被赋值。如果 p 也要作为变量赋值,可以写成下面这样。

let {
  p,
  p: [x, { y }]
} = obj

同数组解构一样,对象解构也可以使用默认值,默认值生效的条件是,对象的属性值严格等于 undefined。

注意:如果要将一个已经声明的变量用于解构赋值,必须非常小心。

// 错误的写法
let x
;{ x } = { x: 1 } // SyntaxError: syntax error

上面代码的写法会报错,因为 JavaScript 引擎会将 { x } 理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免将其解释为代码块,才能解决这个问题。

// 正确的写法
let x
;({ x } = { x: 1 })

由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

let arr = [1, 2, 3]
let { 0: first, [arr.length - 1]: last } = arr
first // 1
last // 3

字符串的解构赋值

对字符串解构时,字符串会被转换为类数组对象。

const [a, b, c, d, e] = 'hello'
a // 'h'
b // 'e'

数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。

let { toString: s } = 123
s === Number.prototype.toString // true

let { toString: s } = true
s === Boolean.prototype.toString // true

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于 undefined 和 null 无法转为对象,所以对它们进行解构赋值时都会报错。

let { prop: x } = undefined // TypeError
let { prop: y } = null // TypeError

函数参数的解构赋值

function move({ x, y } = { x: 0, y: 0 }) {
  return [x, y]
}
move({ x: 3, y: 8 }) // [3, 8]
move({ x: 3 }) // [3, undefined]
move({}) // [undefined, undefined]
move() // [0, 0]

圆括号问题

解构赋值时,对于编译器来说,一个式子到底是模式还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

ES6 规定:只要有可能导致解构的歧义,就不得使用圆括号

不能使用圆括号的情况

  1. 变量声明语句
// 全部报错
let [(a)] = [1]
let {x: (c)} = {}
let ({x: c}) = {}
let {(x: c)} = {}
let {(x): c} = {}
let { o: ({ p: p }) } = { o: { p: 2 } }

上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

  1. 函数参数

函数参数也属于变量声明,因此不能使用圆括号。

// 报错
function foo([(z)]) { return z }
  1. 赋值语句的模式
// 全部报错
;({ p: a }) = { p: 42 }
;([a]) = [5]

上面的代码将整个模式放在圆括号之中,导致报错。

// 报错
;[{ p: a }, { x: c }] = [{}, {}]

上面的代码将一部分模式放在圆括号之中,导致报错。

可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分可以使用圆括号。

;[b] = [3] // 正确
;({ p: d } = {}) // 正确
;[parseInt.prop] = [3] // 正确

上面 3 行语句都可以正确执行,因为它们都是赋值语句,而不是声明语句,另外它们的圆括号都不属于模式的一部分:

  • 第 1 行语句中,模式是取数组的第 1 个成员,跟圆括号无关;
  • 第 2 行语句中,模式是 p 而不是 d;
  • 第 3 行语句与第 1 行语句的性质一致。

扩展

任何部署了 Iterator 接口的对象都可以用 for...of 循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值获取键名和键值就非常方便。

// 获取键名
for (let [key] of map) {
  // ...
}
// 获取键值
for (let [, value] of map) {
  // ...
}

惊雷

心事浩茫连广宇,于无声处听惊雷。

Canvas 基础用法

canvas 是一个可以使用脚本来绘制图形的 HTML 元素。它可以用于绘制图表、制作图片构图或者制作简单的(以及不那么简单的)动画。此篇是学习 canvas 基础用法所作摘要。

基础用法

属性介绍

<canvas> 标签只有两个可选的属性 widthheight。当没有设置宽度和高度的时候,canvas 会初始化宽度为 300 像素和高度为 150 像素。宽高属性会自动忽略单位,以像素展示,所以使用 em 或 rem 等单位无效。

在视觉表现上,CSS 的宽高属性权重要高于 <canvas> 标签的宽高权重。可以将 <canvas> 看作 <img> 元素,主要区别是 <canvas> 的等比例特性是强制的,会忽略 HTML 属性的设置,但 <img> 不会。

<img src="1.jpg" width="300" height="150" style="height:100px;" />
<canvas width="300" height="150" style="height:100px;"></canvas>

如上代码所示,此时 <img> 宽度不会随高度缩放,最终以 300x100 尺寸显示,而 <canvas> 宽度会按高度等比例缩放,以 200x100 尺寸显示。

需要注意:在使用 Canvas API 绘制图像时,是以 HTML 的宽高属性为原点,与 CSS 属性无关。

可以在 <canvas> 标签中提供替换内容。不支持的浏览器将会忽略容器并在其中渲染后备内容。

<canvas width="150" height="150">
  你的浏览器不支持 canvas,请升级你的浏览器
</canvas>

渲染上下文

<canvas> 标签创建画布,并公开渲染上下文(The rendering context),用来绘制内容。使用方法 getContext() 可以获取渲染上下文对象,该方法接受一个参数表示上下文格式,一般传入 2d,当然还有 3d 模式,这里不细谈。

const canvas = document.getElementById('yoo')
const ctx = canvas.getContext('2d')

绘制图形

绘制矩形

原生 canvas 只支持一种图形绘制:矩形。所有其他的图形的绘制都至少需要生成一条路径。canvas 提供了三种方法绘制矩形:

  • fillRect(x, y, width, height): 绘制一个填充矩形
  • strokeRect(x, y, width, height): 绘制一个矩形边框
  • clearRect(x, y, width, height): 清除指定矩形区域,使之变透明

三种方式示例如下:

ctx.fillStyle = '#fb618d'
ctx.fillRect(50, 50, 200, 200)
ctx.clearRect(70, 70, 160, 160)
ctx.strokeRect(90, 90, 120, 120)

三种方式绘制矩形

绘制路径

图形的基本元素是路径,使用路径绘制图形的步骤如下:

  1. 创建路径起始点
  2. 画出路径
  3. 将路径封闭
  4. 描边或填充路径区域

整个步骤需要使用一下函数:

  1. beginPath():新建一条新路径
  2. closePath():闭合路径
  3. stroke():通过线条来绘制图形轮廓
  4. fill():通过填充路径的内容区域生成实心图形
  5. moveTo(x, y):移动笔触到指定坐标
  6. lineTo(x, y):绘制一条从当前位置到指定坐标的直线
  7. arc(x, y, radius, startAngle, endAngle, anticlockwise):绘制圆弧,anticlockwise 为 true 时逆时针,默认为顺时针。

当 canvas 初始化或者 beginPath() 调用后,通常会使用 moveTo() 函数设置起点。或者使用该方法绘制不连续的路径。

示例 1:绘制三角形

// 填充三角形
ctx.beginPath()
ctx.moveTo(40, 40)
ctx.lineTo(220, 40)
ctx.lineTo(40, 220)
ctx.fill()

// 描边三角形
ctx.beginPath()
ctx.moveTo(260, 260)
ctx.lineTo(260, 80)
ctx.lineTo(80, 260)
ctx.closePath()
ctx.stroke()

注意到填充三角形和描边三角形有些不同,当路径使用填充 fill() 时会自动闭合,而使用描边 stroke() 时则不会闭合路径,所以需要调用 closePath() 方法。

绘制三角形

示例 2:绘制笑脸

ctx.beginPath()
ctx.moveTo(260, 150)
ctx.arc(150, 150, 110, 0, Math.PI * 2, true) // 脸
ctx.moveTo(220, 150)
ctx.arc(150, 150, 70, 0, Math.PI, false) // 嘴
ctx.moveTo(120, 110)
ctx.arc(110, 110, 10, 0, Math.PI * 2, false) // 左眼
ctx.moveTo(200, 110)
ctx.arc(190, 110, 10, 0, Math.PI * 2, false) // 右眼
ctx.stroke()

笑脸

贝塞尔曲线

canvas 里使用二次贝塞尔曲线和三次贝塞尔曲线可以用来绘制复杂的图形。

canvas API quadraticCurveTo(cp1x, cp1y, x, y),用来绘制二次贝塞尔曲线,cp1x,cp1y 为控制点,x,y 为结束点。

二次贝塞尔曲线

canvas API bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y),用来绘制三次贝塞尔曲线,cp1x,cp1y 为控制点一,cp2x,cp2y 为控制点二,x,y 为结束点。

三次贝塞尔曲线

关于贝塞尔曲线的使用,这里不再细研究~~(看得头痛)~~,下次如有机会再说。

Path2D

之前所介绍的 canvas API 都是使用路径和绘画命令来把对象“画”在画布上,不能复用命令。较新的浏览器支持 Path2D 对象,用来缓存或记录绘画命令,这样可以复用路径,简化代码和优化性能。

Path2D() 会返回一个新初始化的 Path2D 对象,可能将某一个路径作为变量——创建一个它的副本,或者将一个包含 SVG path 数据的字符串作为变量。

new Path2D() // 空的Path对象
new Path2D(path) // 克隆Path对象
new Path2D('M10 10 h 80 v 80 h -80 Z') // 从SVG建立Path对象

之前介绍的所有 canvas API 都可以在生成的 Path2D 对象上使用。

const rectangle = new Path2D()
rectangle.rect(10, 10, 50, 50)

const circle = new Path2D()
circle.moveTo(125, 35)
circle.arc(100, 35, 25, 0, 2 * Math.PI)

ctx.stroke(rectangle)
ctx.fill(circle)

2019-01-01

又是新的一年,2018 太过平淡,2019 且行且珍惜~

Github Style 博客主题

前不久闲逛发现了个 Github Style 的 Hexo 博客主题 小白妹妹写代码,突然感觉这种简约风格主题莫名好看,故摸鱼摸了一周也仿了个 Github Style 的单页面主题 Gitleaf。

About Gitleaf

Gitleaf

Gitleaf 是一个 Github Style 的单页面博客应用程序,同博主当前所使用的 Aurora 主题类似,基于 Vue 开发。主题后台数据源依托于 Github Issues,配合使用 Github REST API v3GraphQL API v4 提供的接口支持,无需数据库与服务器。

博客评论系统使用开源项目 GitalkValine 作为备用评论系统。该主题基于 Github 全家桶,脱离服务器与数据库,关注内容本身,操作简单,免费食用。

技术栈:Github Api + Github Pages + Github Style + Gitalk

食用文档可参考 Aurora 食用指南,在线演示:蝉時雨

Getting Started

Installing

[email protected]:chanshiyucx/gitleaf.git
cd gitleaf
npm install # or yarn

Configuration

修改目录 src/config.js 的配置文件,每个配置项都有详细说明。注意修改 vue.config.js 的 publicPath

页面模板参考: 文章、关于、标签、分类、书单等模板,第一次食用可以直接 Fork 预览效果。

Preview

npm start

浏览器打开 http://localhost:8000 便可访问新的博客!

Deployment

npm run build

打包完毕,将 dist 目录下生成的静态文件发布 Github PagesCoding Pages 即可。

Just enjoy it ฅ●ω●ฅ

主题参考:
小白妹妹写代码

优雅实现 BackTop

BackTop 即滚动到页面顶部,是很多网站都会用到的基础功能,实现方法很多,Github 上也有许多优秀的三方库,如 smooth-scroll,但如何优雅实现也是一门学问。

事件绑定和解绑

滚动到页面顶部的按钮一般位于页面角落,并且只有在需要的时候才显示出来。所以首先需要监听页面滚动事件,直到滚动到一定距离后显示 BackTop 按钮。

监听页面滚动最简单的实现方式是使用 addEventListener 监听 scroll 事件,并在页面卸载时解除监听,代码如下:

window.addEventListener('scroll', handleScroll, false)
window.removeEventListener('scroll', handleScroll, false)

但既然称为最优雅的实现方式,为了兼任各种浏览器,可以将绑定和解绑事件提取出公共方法,并作兼容优化,代码如下:

/**
 * @description 绑定事件 on(element, event, handler)
 */
export const on = (function() {
  if (document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler)
      }
    }
  }
})()

/**
 * @description 解绑事件 off(element, event, handler)
 */
export const off = (function() {
  if (document.removeEventListener) {
    return function(element, event, handler) {
      if (element && event) {
        element.removeEventListener(event, handler, false)
      }
    }
  } else {
    return function(element, event, handler) {
      if (element && event) {
        element.detachEvent('on' + event, handler)
      }
    }
  }
})()

调用方式:

on(window, 'scroll', handleScroll)
off(window, 'scroll', handleScroll)

function handleScroll() {
  console.log(window.pageYOffset)
}

回到顶部动画

window.requestAnimationFrame() 方法请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。回调的次数通常是每秒 60 次。由于兼容问题,在不同浏览器需要带上前缀,并且在浏览器不支持时使用 setTimeout 模拟。

requestAnimationFrame 目的是为了让各种网页动画效果(DOM 动画、Canvas 动画、SVG 动画、WebGL 动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。

使用 requestAnimationFrame 来实现滚动到页面顶部的动画,核心是按帧来滚动小段距离来实现平滑滚动的效果,代码如下:

// scrollTop animation
export function scrollTop(el, from = 0, to, duration = 500, endCallback) {
  if (!window.requestAnimationFrame) {
    window.requestAnimationFrame =
      window.webkitRequestAnimationFrame ||
      window.mozRequestAnimationFrame ||
      window.msRequestAnimationFrame ||
      function(callback) {
        return window.setTimeout(callback, 1000 / 60)
      }
  }
  const difference = Math.abs(from - to)
  const step = Math.ceil((difference / duration) * 50)

  function scroll(start, end, step) {
    if (start === end) {
      endCallback && endCallback()
      return
    }

    let d = start + step > end ? end : start + step
    if (start > end) {
      d = start - step < end ? end : start - step
    }

    if (el === window) {
      window.scrollTo(d, d)
    } else {
      el.scrollTop = d
    }
    window.requestAnimationFrame(() => scroll(d, end, step))
  }
  scroll(from, to, step)
}

调用方式:

function backTop() {
  const sTop = document.documentElement.scrollTop || document.body.scrollTop
  scrollTop(window, sTop, 0, 2000)
}

扩展:该 API 还提供 cancelAnimationFrame 方法用来取消重绘,参数是 requestAnimationFrame 返回的一个代表任务 ID 的整数值,使用如下:

const requestID = window.requestAnimationFrame(() => scroll(d, end, step))
window.cancelAnimationFrame(requestID)

如果不需要考虑浏览器兼容性,在 Chrome、Firefox 浏览器上,window.scrollTo 还支持第二种参数形式,传入参数 options 是一个包含三个属性的对象:

  • top 等同于 y-coord,代表纵轴坐标
  • left 等同于 x-coord,代表横轴坐标
  • behavior 类型 String,表示滚动行为,支持参数 smooth(平滑滚动),instant(瞬间滚动),默认值 auto,效果等同于 instant
window.scrollTo({
  top: 0,
  behavior: 'smooth'
})

此方法简单高效,可惜 Edge、IE、Safari 皆不支持。

JavaScript 设计模式(Ⅳ)

本篇是《JavaScript 设计模式与开发实践》第三部分读书笔记,主要讲解面向对象的设计原则及其在设计模式中的体现,还介绍了一些常见的面向对象编程技巧和日常开发中的代码重构。

设计原则通常指的是单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、合成复用原则和最少知识原则。

单一职责原则

设计模式中的 SRP 原则

单一职责原则(SRP)的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。SRP 原则体现为:一个对象(方法)只做一件事情。

SRP 原则在很多设计模式中都有着广泛的运用,例如代理模式、迭代器模式、单例模式和装饰者模式。

在代理模式中,将添加 img 标签和预加载图片的职责分发到两个对象中。

在迭代器模式中,将 ajax 请求和渲染页面节点的职责分发到两个对象中。

在单例模式中,把管理单例的职责和创建登录浮窗的职责分发到两个对象中。

而对于装饰者模式,通常让类或者对象一开始只具有一些基础的职责,更多的职责在代码运行时被动态装饰到对象上面。装饰者模式可以为对象动态增加职责,从另一个角度来看,这也是分离职责的一种方式。

SRP 原则的优缺点

SRP 原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。

但 SRP 原则也有一些缺点,最明显的是会增加编写代码的复杂度。当按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

最小知识原则

最少知识原则(LKP)说的是一个软件实体应当尽可能少地与其他实体发生相互作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数、变量等。

单一职责原则指导我们把对象划分成较小的粒度,这可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的联系,如果修改了其中一个对象,很可能会影响到跟它相互引用的其他对象。对象和对象耦合在一起,有可能会降低它们的可复用性。

最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。

最少知识原则在设计模式中体现得最多的地方是中介者模式和外观模式。

中介者模式很好地体现了最少知识原则。通过增加一个中介者对象,让所有的相关对象都通过中介者对象来通信,而不是互相引用。所以,当一个对象发生改变时,只需要通知中介者对象即可。

外观模式的作用是对客户屏蔽一组子系统的复杂性。外观模式对客户提供一个简单易用的高层接口,高层接口会把客户的请求转发给子系统来完成具体的功能实现。大多数客户都可以通过请求外观接口来达到访问子系统的目的。

外观模式容易跟普通的封装实现混淆。这两者都封装了一些事物,但外观模式的关键是定义一个高层接口去封装一组“子系统”。

开放-封闭原则

在面向对象的程序设计中,开放-封闭原则(OCP)是最重要的一条原则。它的定义如下:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

开放-封闭原则的**:当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

对象多态性消除条件分支

过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当需要增加一个新的 if 语句时,都要被迫改动原函数。把 if 换成 switch-case 是没有用的,这是一种换汤不换药的做法。利用对象的多态性来让程序遵守开放-封闭原则,是一个常用的技巧。

利用多态的**,把程序中不变的部分隔离出来,然后把可变的部分封装起来,这样一来程序就具有了可扩展性。

隔离变化

除了利用对象的多态性之外,还有其他方式可以帮助我们编写遵守开放-封闭原则的代码。

放置挂钩

放置挂钩(hook)也是分离变化的一种方式。在程序有可能发生变化的地方放置一个挂钩,挂钩的返回结果决定了程序的下一步走向。这样一来,原本的代码执行路径上就出现了一个分叉路口,程序未来的执行方向被预埋下多种可能性。

使用回调函数

回调函数是一种特殊的挂钩。我们可以把一部分易于变化的逻辑封装在回调函数里,然后把回调函数当作参数传入一个稳定和封闭的函数中。当回调函数被执行的时候,程序就可以因为回调函数的内部逻辑不同,而产生不同的结果。

在 JavaScript 版本的设计模式中,策略模式和命令模式等都可以用回调函数轻松实现。

设计模式中的开放-封闭原则

好设计通常都经得起开放-封闭原则的考验。不管是具体的各种设计模式,还是更抽象的面向对象设计原则,比如单一职责原则、最少知识原则、依赖倒置原则等,都是为了让程序遵守开放-封闭原则而出现的。可以这样说,开放-封闭原则是编写一个好程序的目标,其他设计原则都是达到这个目标的过程。

发布-订阅模式

发布-订阅模式用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。当有新的订阅者出现时,发布者的代码不需要进行任何修改;同样当发布者需要改变时,也不会影响到之前的订阅者。

模板方法模式

模板方法模式是一种典型的通过封装变化来提高系统扩展性的设计模式。在一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以把这部分逻辑抽出来放到父类的模板方法里面;而子类的方法具体怎么实现则是可变的,于是把这部分变化的逻辑封装到子类中。通过增加新的子类,便能给系统增加新的功能,并不需要改动抽象父类以及其他的子类,这也是符合开放-封闭原则的。

策略模式

策略模式和模板方法模式是一对竞争者。在大多数情况下,它们可以相互替换使用。模板方法模式基于继承的**,而策略模式则偏重于组合和委托。策略模式将各种算法都封装成单独的策略类,这些策略类可以被交换使用。策略和使用策略的客户代码可以分别独立进行修改而互不影响。我们增加一个新的策略类也非常方便,完全不用修改之前的代码。

代理模式

以图片预加载为例,预加载图片的功能和给图片设置 src 的功能被隔离在两个函数里,它们可以单独改变而互不影响。myImage 不知晓代理的存在,它可以继续专注于自己的职责——给图片设置 src。

接口和面向接口编程

这里谈论的接口即是我们谈论的“面向接口编程”中的接口,接口的含义在这里体现得更为抽象。用《设计模式》中的话说就是:接口是对象能响应的请求的集合

静态类型语言通常设计为可以“向上转型”。当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。通过向上转型,对象的具体类型被隐藏在“超类型”身后。当对象类型之间的耦合关系被解除之后,这些对象才能在类型检查系统的监视下相互替换使用,这样才能看到对象的多态性。

从过程上来看,“面向接口编程”其实是“面向超类型编程”。当对象的具体类型被隐藏在超类型身后时,这些对象就可以相互替换使用,我们的关注点才能从对象的类型上转移到对象的行为上。“面向接口编程”也可以看成面向抽象编程,即针对超类型中的 abstract 方法编程,接口在这里被当成 abstract 方法中约定的契约行为。这些契约行为暴露了一个类或者对象能够做什么,但是不关心具体如何去做。

用鸭子类型进行接口检查

鸭子类型是动态类型语言面向对象设计中的一个重要概念。利用鸭子类型的**,不必借助超类型的帮助,就能在动态类型语言中轻松地实现设计原则:面向接口编程,而不是面向实现编程。

比如,一个对象如果有 push 和 pop 方法,并且提供了正确的实现,它就能被当作栈来使用;一个对象如果有 length 属性,也可以依照下标来存取属性,这个对象就可以被当作数组来使用。如果两个对象拥有相同的方法,则有很大的可能性它们可以被相互替换使用。

代码重构

模式和重构之间有着一种与生俱来的关系。从某种角度来看,设计模式的目的就是为许多重构行为提供目标。

这里提出一些重构的目标和手段:

  • 提炼函数
  • 合并重复的条件片段
  • 把条件分支语句提炼成函数
  • 合理使用循环
  • 提前让函数退出代替嵌套条件分支
  • 传递对象参数代替过长的参数列表
  • 尽量减少参数数量
  • 少用三目运算符
  • 合理使用链式调用
  • 分解大型类
  • 用 return 退出多重循环

Flex 弹性布局

Flex 弹性布局是我项目中使用频率最多的布局方式,基本任何涉及到布局时采用的方案就是 Flex,虽说弹性布局用得多,但是对一些不常用的属性还是知之甚少,故在此温故知新。

Flex(Flexible Box)即为“弹性布局” ,它是一种一维的布局模型。采用 Flex 布局的元素,称为 Flex 容器(flex container)。它的所有子元素自动成为容器成员,称为 Flex 项目(flex item)。

Flex 容器有两根轴线:水平的主轴(main axis)和垂直的交叉轴(cross axis)。主轴的起终点分别为 main startmain end;交叉轴的起终点分别为cross startcross end。项目沿主轴排列,单个项目占据的主轴空间为main size,占据的交叉轴空间为cross size

一张图说明 Flex Box 模型:

Flex Box

当前主流浏览器对 Flex 布局都能良好兼容,可以放心使用:

Can I Use Flex

容器的属性

Flex 容器可设置 6 个属性:

  • flex-direction:决定主轴的方向
  • flex-wrap:子项目是否可换行
  • flex-flow:属于 flex-direction 属性和 flex-wrap 属性的简写形式
  • justify-content:定义了项目在主轴上的对齐方式
  • align-items:定义项目在交叉轴上如何对齐
  • align-content:定义了多根轴线的对齐方式

flex-direction

flex-direction 属性决定主轴的方向,可选值:row | row-reverse | column | column-reverse,各值表现如下:

.box {
  flex-direction: row | row-reverse | column | column-reverse;
}

flex-direction

flex-wrap

flex-wrap 属性定义子项目是否可换行,可选值:nowrap | wrap | wrap-reverse,各值表现如下:

.box {
  flex-wrap: nowrap | wrap | wrap-reverse;
}

flex-wrap

flex-flow

flex-flow 属性属于 flex-direction 属性和 flex-wrap 属性的简写形式,默认值 row nowrap

justify-content

justify-content:属性定义了项目在主轴上的对齐方式,可选值:flex-start | flex-end | center | space-between | space-around,各值表现如下:

.box {
  justify-content: flex-start | flex-end | center | space-between | space-around;
}

justify-content

align-items

align-items 属性定义项目在交叉轴上如何对齐,可选值 flex-start | flex-end | center | baseline | stretch,各值表现如下:

.box {
  align-items: flex-start | flex-end | center | baseline | stretch;
}

align-items

align-content

align-content 属性定义多根轴线的对齐方式,如果项目只有一根轴线,该属性不起作用,可选值flex-start | flex-end | center | space-between | space-around | stretch,各值表现如下:

.box {
  align-content: flex-start | flex-end | center | space-between | space-around | stretch;
}

align-content

项目的属性

Flex 项目可设置 6 个属性:

  • order:定义项目的排列顺序
  • flex-grow:定义项目的放大比例
  • flex-shrink:定义了项目的缩小比例
  • flex-basis:定义了在分配多余空间之前,项目占据的主轴空间
  • flex:属于 flex-grow, flex-shrink 和 flex-basis 的简写形式
  • align-self:允许单个项目有与其他项目不一样的对齐方式,可覆盖 align-items 属性

order

order 属性定义项目的排列顺序,数值越小,排列越靠前,默认为 0,具体表现如下:

.item {
  order: 0;
}

order

flex-grow

flex-grow 属性定义项目的放大比例,默认为 0,即如果存在剩余空间,也不放大,具体表现如下:

.item {
  flex-grow: 0;
}

flex-grow

flex-shrink

flex-shrink 属性定义了项目的缩小比例,默认为 1,即如果空间不足,该项目将缩小,具体表现如下:

.item {
  flex-shrink: 1;
}

flex-shrink

flex-basis

flex-basis 属性定义了在分配多余空间之前,项目占据的主轴空间(main size),默认值为 auto,即项目的本来大小,浏览器根据这个属性计算主轴是否有多余空间。

.item {
  flex-basis: auto;
}

flex

flex 属性是 flex-grow, flex-shrink 和 flex-basis 的简写,默认值为 0 1 auto,后两个值可选,不设置则为默认值。该属性有两个快捷值:auto (1 1 auto)none (0 0 auto)

.item {
  flex: none;
}

align-self

align-self 属性允许单个项目有与其他项目不一样的对齐方式,可覆盖 align-items 属性。默认值为 auto,表示继承父元素的 align-items 属性,如果没有父元素,则等同于 stretch。

align-self

ES6标准入门-Class 类

直至 ES6,JavaScript 终于有了“类”的概念,它简化了之前直接操作原型的语法,也是我最喜欢的新特性之一,但此类非彼类,它不同于 Java 中的类,它本质上只是一颗语法糖。

Class 的基本语法

简介

ES6 的类完全可以看作构造函数的另一种写法,类的所有方法都定义在类的 prototype 属性上,类的数据类型就是函数,类本身就指向构造函数

class Point {
  constructor() {}
  toString() {}
}

typeof Point // function
Point === Point.prototype.constructor // true

// 等同于
Point.prorotype = {
  constructor() {},
  toString() {}
}

在类的实例上调用方法,其实就是调用原型上的方法。使用 Object.assign 方法可以方便向类添加多个方法。类的内部定义的方法都是不可枚举的(non-enumerable),这点与 ES5 表现不一致。

class Point {}
let p = new Point()
p.constructor === Point.prototype.constructor // true

Object.assign(Point.prototype, {
  toString()
})

constructor

constructor 方法默认返回实例对象(即 this),不过完全可以指定返回另外一个对象。实例的属性除非显式定义在其本身(即 this 对象)上,否则都是定义在原型上

class Foo {
  constructor() {
    return Object.create(null)
  }
}
new Foo() instanceof Foo // false

class Point {
  constructor(x) {
    this.x = x
  }
  toString() {}
}
const p = new Point(1)
p.hasOwnProperty('x') // true
p.hasOwnProperty('toString') // false
p.__proto__.hasOwnProperty('toString') // true

proto 是浏览器厂商添加的私有属性,应避免使用,在生产环境中,可以使用 Object.getPrototypeOf 方法来获取实例对象的原型。

Class 表达式

Class 可以使用表达式形式定义:

const MyClass = class Me {
  getClassName() {
    return Me.name
  }
}

const inst = new MyClass()
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined

需要注意:上面定义的类的名字是 MyClass 而不是 Me,Me 只在 Class 的内部代码可用,指代当前类。如果 Class 内部没有用到 Me,则可以省略。同时,也可以写出立即执行 Class。

// 省略 Me
const MyClass = class {}

// 立即执行 Class
const p = new (class {})()

不存在变量提升

类不存在变量提升(hoist),这点与 ES5 完全不同。这与类的继承有关,因为要确保父类在子类之前定义,如果出现变量提升,则会出错。

// 确保父类在子类之前定义
const Foo = class {}
class Bar extends Foo {}

私有方法

利用 Symbol 值的唯一性将私有方法的名字命名为一个 Symbol 值,从而实现私有方法。

const bar = Symbol('bar')

class Point {
  foo() {
    this[bar]()
  }

  [bar]() {}
}

this 指向

类的方法内部如果含有 this,它将默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能会报错。

class Logger {
  printName() {
    this.print()
  }
  print() {
    console.log('Hello')
  }
}
const logger = new Logger()
const { printName } = logger
printName() // TypeError: Cannot read property 'print' of undefined

一种解决办法是在 constructor 里绑定 this。

class Logger {
  constructor {
    this.printName = this.printName.bind(this)
  }
}

更巧妙的方式是使用 Proxy,在获取方法时自动绑定 this。

function selfish(target) {
  const cache = new WeakMap()
  const handler = {
    get(target, key) {
      const value = Reflect.get(target, key)
      if (typeof value !== 'function') return value
      if (!cache.has(value)) cache.set(value, value.bind(target))
      return cache.get(value)
    }
  }
  return new Proxy(target, handler)
}
const logger = selfish(new Logger())

new.target

ES6 为 new 命令引入了 new.target 属性,返回 new 命令所作用的构造函数。

function Person(name) {
  if (new.target === Person) {
    this.name = name
  } else {
    throw new Error('必须使用 new 生成实例')
  }
}

需要注意:子类继承父类时 new.target 会返回子类。利用这个特点,可以写出不能独立独立使用而必须继承后才能使用的类。

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error('本类不能实例化')
    }
  }
}

class Rectangle extends Shape {
  constructor(length, width) {
    super()
  }
}

Class 的继承

简介

Class 可以通过 extends 关键字实现继承,子类必须在 constructor 方法中调用 super 方法,否则新建实例时会报错。这是因为子类没有自己的 this 对象,而是继承父类的 this 对象,然后对其进行加工。如果不调用 super 方法,子类就得不到 this 对象。

class Point {}
class ColorPoint extends Point {
  constructor() {}
}
const cp = new ColorPoint() // ReferenceError

ES5 的继承实质是先创造子类的实例对象 this,然后再将父类的方法添加到 this 上面(Parent.apply(this))。

ES6 的继承机制完全不同,实质是先创造父类的实例对象 this(所以必须先调用 super 方法),然后再用子类的构造函数修改 this。如果子类没有定义 constructor 方法,则会被默认添加。且只有调用 super 之后才能使用 this 关键字

class ColorPoint extends Point {}

// 等同于
class ColorPoint extends Point {
  constructor(...args) {
    super(...args)
  }
}

Object.getPrototypeOf()

Object.getPrototypeOf 方法可以用来从子类上获取父类。因此,可以使用这个方法来判断一个类是否继承了另一个类。

Object.getPrototypeOf(ColorPoint) === Ponit // true

super 关键字

super 这个关键字既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

第一种情况,super 作为函数调用时代表父类的构造函数。需要注意,super 虽然代表了父类的构造函数,但返回的是子类的实例,即 super 内部的 this 指向的是 ColorPoint,此时 super() 相当与 Point.prototype.constructor.call(this)

class A {
  constructor() {
    console.log(new.target.name)
  }
}

class B extends A {
  constructor() {
    super()
  }
}

new A() // A
new B() // B

上面的代码中,new.target 指向当前正在执行的函数,在 super 函数执行时,它指向的是子类的构造函数,即 super() 内部的 this 指向的是 B。

第二种情况,super 作为对象时在普通方法中指向父类的原型对象,在静态方法中指向父类。需要注意,由于普通方法中 super 指向父类的原型对象,所以定义在父类实例上的方法或属性是无法通过 super 调用的

class Parent {
  static myMethod(msg) {
    console.log('static', msg)
  }
  myMethod(msg) {
    console.log('instance', msg)
  }
}

class Child extends Parent {
  static myMethod(msg) {
    super.myMethod(msg)
  }
  myMethod(msg) {
    super.myMethod(msg)
  }
}

Child.myMethod(1) // static 1
const child = new Child()
child.myMethod(2) // instance 2

class A {
  constructor() {
    // 无法获得
    this.p = 2
  }
}
// 可以获得
A.prototype.p = 2

作为普通方法调用时,super 指向 A.prototype,所以 super.func() 相当于 A.prototype.func()。同时 super 会绑定子类的 this,super.func() 相当于 super.func.call(this)

由于绑定子类的 this,因此如果通过 super 对某个属性赋值,这时 super 就是 this,赋值的属性会变成子类实例的属性。

class A {
  constructor() {
    this.x = 1
  }
}
class B extends A {
  constructor() {
    super()
    this.x = 2
    super.x = 3
    console.log(super.x) // undefined
    console.log(this.x) // 3
  }
}

上面的代码中,super.x 被赋值为 3,等同于对 this.x 赋值为 3。当读取 super.x 时,相当于读取的是 A.prototype.x,所以返回 undefined。

prototype 和 __proto__

在 ES5 中,每一个对象都有 __proto__ 属性,指向对应的构造函数的 prototype 属性。Class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__ 属性,因此同时存在两条继承链。

  • 子类的 __proto__ 属性表示构造函数的继承,总是指向父类。
  • 子类的 prototype 属性的 __proto__ 属性表示方法的继承,总是指向父类的 prototype 属性。
class A {}
class B extends A {}
B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

造成这样的结果是因为类的继承是按照下面的模式实现的:

// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype)
// B 的实例继承 A 的静态属性
Object.setPrototypeOf(B, A)

其中 Object.setPrototypeOf 的实现如下:

Object.setPrototypeOf = function(obj, proto) {
  obj.__proto__ = proto
  return obj
}

所以上面的代码等同如下:

Object.setPrototypeOf(B.prototype, A.prototype)
// 等同于
B.prototype.__proto__ = A.prototype

Object.setPrototypeOf(B, A)
// 等同于
B.__proto__ = A

两条原型链理解如下:作为一个对象,子类(B)的原型(__proto__ 属性)是父类(A);作为一个构造函数,子类(B)的原型(prototype 属性)是父类的实例

extends 的继承目标

下面讨论三种特殊的继承情况。

第一种特殊情况,子类继承 Object 类:

class A extends Object {}
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A 其实就是构造函数 Object 的复制,A 的实例就是 Object 的实例。

第二种特殊情况,不存在任何继承:

class A {}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A 作为一个基类(即不存在任何继承)就是一个普通函数,所以直接继承 Function.prototype。但是,A 调用后返回一个空对象(即 Object 实例),所以 A.prototype.__proto__ 指向构造函数(Object)的 prototype 属性。

第三种特殊情况,子类继承 null:

class A extends null {}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === undefined // true

这与第二种情况非常像。A 也是一个普通函数,所以直接继承 Function. prototype。但是,A 调用后返回的对象不继承任何方法,所以它的 __proto__ 指向 Function.prototype。

实例的 __proto__

子类实例的 __proto__ 属性的 __proto__ 属性指向父类实例的 __proto__ 属性。也就是说,子类的原型的原型是父类的原型。

const p1 = new Point(2, 3)
const p2 = new ColorPoint(2, 3, 'red')
p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true

Mixin 模式

Mixin 模式指的是将多个类的接口“混入”(mixin)另一个类,在 ES6 中的实现如下:

function mix(...mixins) {
  class Mix {}
  for (let mixin of mixins) {
    copyProperties(Mix, mixin)
    copyProperties(Mix.prototype, mixin.prototype)
  }
  return Mix
}
function copyProperties(target, source) {
  for (let key of Reflect.ownKeys(source)) {
    if (key !== 'constructor' && key !== 'prototype' && key !== 'name') {
      let desc = Object.getOwnPropertyDescriptor(source, key)
      Object.defineProperty(target, key, desc)
    }
  }
}

上面代码中的 mix 函数可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。

class DistributedEdit extends mix(Loggable, Serializable) {
  // ...
}

ES6标准入门-Generator 函数

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。在此之前,只在 dva(内部封装 redux-saga)里使用过,此次深入了解之。

Generator 函数

简介

基本概念

Generator 函数可以理解成一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,该遍历器对象可以依次遍历 Generator 函数内部的每一个状态。换言之,Generator 函数除了是状态机,还是一个遍历器对象生成函数。

Generator 函数的调用方法与普通函数一样。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象。

每次调用 next 方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一条 yield 语句(或 return 语句)为止。换言之,Generator 函数是分段执行的,yield 语句是暂停执行的标记,而 next 方法可以恢复执行

function* helloWorldGenerator() {
  yield 'hello'
  yield 'world'
  return 'ending'
}
let hw = helloWorldGenerator()

hw.next() // { value: 'hello', done: false }
hw.next() // { value: 'world', done: false }
hw.next() // { value: 'ending', done: true }
hw.next() // { value: undefined, done: true }

yield 表达式

遍历器对象的 next 方法的运行逻辑如下:

  1. 遇到 yield 语句就暂停执行后面的操作,并将紧跟在 yield 后的表达式的值作为返回对象的 value 属性值。
  2. 下一次调用 next 方法时再继续往下执行,直到遇到下一条 yield 语句。
  3. 如果没有再遇到新的 yield 语句,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值作为返回对象的 value 属性值。
  4. 如果该函数没有 return 语句,则返回对象的 value 属性值为 undefined。

只有调用 next 方法且内部指针指向该语句时才会执行 yield 语句后面的表达式,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

下面的代码中,yield 后面的表达式不会立即求值,只会在 next 方法将指针移到这一句时才求值。

function* gen() {
  yield 123 + 456
}

Generator 函数可以不用 yield 语句,这时就变成了一个单纯的暂缓执行函数。下面的代码中,函数 f 如果是普通函数,在为变量 generator 赋值时就会执行。但是函数 f 是一个 Generator 函数,于是就变成只有调用 next 方法时才会执行。

function* f() {
  console.log('执行了!')
}
let generator = f()
setTimeout(function() {
  generator.next()
}, 2000)

yield 表达式如果用在另一个表达式之中,必须放在圆括号里面。

function* demo() {
  console.log('Hello' + yield 123) // SyntaxError
  console.log('Hello' + (yield 123)) // OK
}

与 Iterator 接口的关系

任意一个对象的 Symbol.iterator 方法等于该对象的遍历器对象生成函数,调用该函数会返回该对象的一个遍历器对象。

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的 Symbol.iterator 属性,从而使得该对象具有 Iterator 接口。

let myIterable = {}
myIterable[Symbol.iterator] = function*() {
  yield 1
  yield 2
  yield 3
}
;[...myIterable] // [1, 2, 3]

next 方法

yield 语句本身没有返回值,或者说总是返回 undefined。next 方法可以带有一个参数,该参数会被当作上一条 yield 语句的返回值

function* f() {
  for (let i = 0; true; i++) {
    let reset = yield i
    if (reset) {
      i = -1
    }
  }
}
let g = f()
g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

上面的代码先定义了一个可以无限运行的 Generator 函数 f,如果 next 方法没有参数,每次运行到 yield 语句时,变量 reset 的值总是 undefined。当 next 方法带有一个参数 true 时,当前的变量 reset 就被重置为这个参数(即 true),因而 i 会等于 -1,下一轮循环就从 -1 开始递增。

Generator 函数从暂停状态到恢复运行,其上下文状态(context)是不变的。通过 next 方法的参数就有办法在 Generator 函数开始运行后继续向函数体内部注入值,从而调整函数行为。

function* foo(x) {
  let y = 2 * (yield x + 1)
  let z = yield y / 3
  return x + y + z
}

let a = foo(5)
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

let b = foo(5)
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

需要注意:由于 next 方法的参数表示上一条 yield 语句的返回值,所以第一次使用 next 方法时传递参数是无效的。V8 引擎直接忽略第一次使用 next 方法时的参数,只有从第二次使用 next 方法开始,参数才是有效的。从语义上讲,第一个 next 方法用来启动遍历器对象,所以不用带有参数。

function* dataConsumer() {
  console.log('Started')
  console.log(`1. ${yield}`)
  console.log(`2. ${yield}`)
  return 'result'
}
let genObj = dataConsumer()
genObj.next() // Started
genObj.next('a') // 1. a
genObj.next('b') // 2. b

for...of 循环

for...of 循环可以自动遍历 Generator 函数生成的 Iterator 对象,且此时不再需要调用 next 方法。

function* foo() {
  yield 1
  yield 2
  return 3
}
for (let v of foo()) {
  console.log(v) // 1 2
}

一旦 next 方法的返回对象的 done 属性为 true,for...of 循环就会终止,且不包含该返回对象,所以上面的 return 语句返回的 3 不包括在 for...of 循环中。

下面是一个利用 Generator 函数和 for...of 循环实现斐波那契数列的例子:

function* fibonacci() {
  let [prev, curr] = [0, 1]
  for (;;) {
    ;[prev, curr] = [curr, prev + curr]
    yield curr
  }
}

for (let n of fibonacci()) {
  if (n > 1000) break
  console.log(n)
}

利用 for...of 循环,给对象加上遍历器接口:

function* objectEntries(obj) {
  let propKeys = Reflect.ownKeys(obj)
  for (let propKey of propKeys) {
    yield [propKey, obj[propKey]]
  }
}
let jane = { first: 'Jane', last: 'Doe' }
for (let [key, value] of objectEntries(jane)) {
  console.log(`${key}: ${value}`)
}
// first: Jane
// last: Doe

另一种方法是直接将 Generator 函数添加到 Symbol.iterator 属性上:

function* objectEntries() {
  let propKeys = Object.keys(this)
  for (let propKey of propKeys) {
    yield [propKey, this[propKey]]
  }
}
jane[Symbol.iterator] = objectEntries

除了 for...of 循环,扩展运算符(...)、解构赋值和 Array.from 方法内部调用的都是遍历器接口。可以将 Generator 函数返回的 Iterator 对象作为参数:

function* numbers() {
  yield 1
  yield 2
  return 3
  yield 4
}
// 扩展运算符
;[...numbers()] // [1, 2]

// Array.from方法
Array.from(numbers()) // [1, 2]

// 解构赋值
let [x, y] = numbers()
x // 1
y // 2

Generator.prototype.throw()

Generator 函数返回的遍历器对象都有一个 throw 方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。

var g = function*() {
  try {
    yield
  } catch (e) {
    console.log('内部捕获', e)
  }
}
var i = g()
i.next()
try {
  i.throw('a')
  i.throw('b')
} catch (e) {
  console.log('外部捕获', e)
}
// 内部捕获a
// 外部捕获b

上面的代码中,遍历器对象 i 连续抛出两个错误。第一个错误被 Generator 函数体内的 catch 语句捕获。第二个错误由于 Generator 函数内部的 catch 语句已经执行过了,不会再捕捉到这个错误,所以就被抛出了 Generator 函数体,被函数体外的 catch 语句捕获。

Generator.prototype.return()

Generator 函数返回的遍历器对象有一个 return 方法,可以返回给定的值,并终结 Generator 函数的遍历。

function* gen() {
  yield 1
  yield 2
  yield 3
}
var g = gen()
g.next() // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next() // { value: undefined, done: true }

上面的代码中,遍历器对象 g 调用 return 方法后,返回值的 value 属性就是 return 方法的参数。同时,Generator 函数的遍历终止,返回值的 done 属性为 true。如果 return 方法调用时不提供参数,则返回值的 vaule 属性为 undefined。

如果 Generator 函数内部有 try...finally 代码块,那么 return 方法会推迟到 finally 代码块执行完再执行。

function* numbers() {
  yield 1
  try {
    yield 2
    yield 3
  } finally {
    yield 4
    yield 5
  }
  yield 6
}
var g = numbers()
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }

yield* 表达式

如果在 Generator 函数内部调用另一个 Generator 函数,需要用到 yield* 语句。

function* foo() {
  yield 'a'
  yield 'b'
}
function* bar() {
  yield 'x'
  yield* foo()
  yield 'y'
}

yield* 后面的 Generator 函数(没有 return 语句时)不过是 for...of 的一种简写形式,完全可以用后者替代。反之,在有 return 语句时则需要用 var value = yield* iterator 的形式获取 return 语句的值。

任何数据结构只要有 Iterator 接口,就可以被 yield* 遍历。下面的代码中,yield 命令后面如果不加星号,返回的是整个数组,加了星号就表示返回的是数组的遍历器对象。

function* gen() {
  yield* ['a', 'b', 'c']
}
gen().next() // { value:"a", done:false }

Generator 函数 this

Generator 函数总是返回一个遍历器,这个遍历器是 Generator 函数的实例,它继承了 Generator 函数的 prototype 对象上的方法。

function* g() {}
g.prototype.hello = function() {
  return 'hi!'
}
let obj = g()
obj instanceof g // true
obj.hello() // 'hi!'

但是,如果把 g 当作普通的构造函数,则并不会生效,因为 g 返回的总是遍历器对象,而不是 this 对象。

function* g() {
  this.a = 11
}
let obj = g()
obj.a // undefined

Generator 函数也不能跟 new 命令一起用,否则会报错。

有一个变通方法可以让 Generator 函数返回一个正常的对象实例,既可以用 next 方法,又可以获得正常的 this:

function* gen() {
  this.a = 1
  yield (this.b = 2)
  yield (this.c = 3)
}
function F() {
  return gen.call(gen.prototype)
}
var f = new F()
f.next() // Object {value: 2, done: false}
f.next() // Object {value: 3, done: false}
f.next() // Object {value: undefined, done: true}
f.a // 1
f.b // 2
f.c // 3

上面代码中,使用 call 方法绑定 Generator 函数内部的 this 到原型 prototype 上,调用 next 方法完成 gen 内部所有代码的运行。这时,所有内部属性都绑定在原型对象上了,因此原型对象也就成了 gen 的实例。

函数节流与函数防抖

函数节流和函数防抖怕是老生常谈的两个功能,网上关于两者的功用与代码实现都有大量资料。另外 loadsh.jsunderscore.js 等知名三方库都有带有方法实现。

函数节流(throttle)与函数防抖(debounce)核心**都是通过限制函数调用来实现性能优化,但两者概念却有不同:

  • 函数节流:函数按指定间隔调用,限制函数调用频率
  • 函数防抖:一定时间段连续的函数调用,只让其执行一次

两者的使用场景也有不同:

  • 函数节流:页面滚动事件监听(scroll)、DOM 元素拖拽(mousemove)、键盘事件(keydown)
  • 函数防抖:文本输入验证发送请求、窗口缩放(resize)

函数节流

函数节流可以通过设置两个时间戳来实现,通过比较两个时间戳,让其在一定时间间隔内只执行一次,然后将时间戳重置为这次执行的时间,实现代码如下:

function throttle(func, delay) {
  let lastCall = new Date()
  return function(...args) {
    const now = new Date()
    if (now - lastCall < delay) {
      return
    }
    lastCall = now
    return func.apply(this, args)
  }
}

loadsh.js 里的函数节流功能实现更为复杂,可以实现首调用和尾调用。这里不再细说,详情可以参考源码。

函数防抖

函数防抖可以通过定时器来实现,还可以设置第一次触发时是否立即执行:

function debounce(func, wait, immediate = false) {
  let timeout
  return function(...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(this, args)
    }, wait)
    // 是否立即执行一次任务
    if (immediate) {
      immediate = false
      func.apply(this, args)
    }
  }
}

七天学会 NodeJS

就如《21 天学会 C++》一样,《七天学会 NodeJS》取名好似噱头,此书是阿里内部手册,书中没有太大篇幅的累述 API,精要介绍了 NodeJS 核心运用,颇为受用,故记之。

文件操作

文件拷贝

NodeJS 提供了基本的文件操作 API,却没有提供文件拷贝的高级功能。

小文件拷贝

const fs = require('fs')

function copy(src, dst) {
  fs.writeFileSync(dst, fs.readFileSync(src))
}

function main(argv) {
  copy(argv[0], argv[1])
}

main(process.argv.slice(2))

以上程序使用 fs.readFileSync 从源路径读取文件内容,并使用 fs.writeFileSync 将文件内容写入目标路径。

process 是一个全局变量,可通过 process.argv 获得命令行参数。由于 argv[0] 固定等于 NodeJS 执行程序的绝对路径,argv[1] 固定等于主模块的绝对路径,因此第一个命令行参数从 argv[2] 这个位置开始。

大文件拷贝

对于大文件拷贝,如果一次性把所有文件内容都读取到内存中后再一次性写入磁盘的方式可能会造成内存爆仓。所以对于大文件,只能读一点写一点,直到完成拷贝。

const fs = require('fs')

function copy(src, dst) {
  fs.createReadStream(src).pipe(fs.createWriteStream(dst))
}

function main(argv) {
  copy(argv[0], argv[1])
}

main(process.argv.slice(2))

以上程序使用 fs.createReadStream 创建了一个源文件的只读数据流,并使用 fs.createWriteStream 创建了一个目标文件的只写数据流,并且用 pipe 方法把两个数据流连接了起来。抽象类比的话类似水顺着水管从一个桶流到了另一个桶。

API 简介

NodeJS 提供了一些文件操作有关的 API,这里作简要介绍。

Buffer 数据块

Buffer 类的实例类似于整数数组,但 Buffer 的大小是固定的、且在 V8 堆外分配物理内存。Buffer 的大小在创建时确定,且无法改变。

Buffer 与字符串类似,除了可以用 .length 属性得到字节长度外,还可以用 [index] 方式读取指定位置的字节。

Buffer 与字符串能够互相转化,例如可以使用指定编码将二进制数据转化为字符串,或者将字符串转换为指定编码下的二进制数据:

let bin = new Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f])
console.log('bin.length:', bin.length) // 5
let str = bin.toString('utf-8')
console.log('str:', str) // hello

let bin2 = new Buffer('hello', 'utf-8')
//<Buffer 68 65 6c 6c 6f>

Buffer 与字符串有一个重要区别。字符串是只读的,并且对字符串的任何修改得到的都是一个新字符串,原字符串保持不变。至于 Buffer 可以用[index]方式直接修改某个位置的字节。

而 .slice 方法也不是返回一个新的 Buffer,而更像是返回了指向原 Buffer 中间的某个位置的指针,因此对 .slice 方法返回的 Buffer 的修改会作用于原 Buffer。

let bin = new Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f])
let sub = bin.slice(2)

sub[0] = 0x65
console.log('bin', bin) // <Buffer 68 65 65 6c 6f>

因此拷贝 Buffer 得首先创建一个新的 Buffer,并通过 .copy 方法把原 Buffer 中的数据复制过去。类似于申请一块新的内存,并把已有内存中的数据复制过去。

let bin = new Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f])
let dup = new Buffer.alloc(bin.length)
bin.copy(dup)
dup[0] = 0x48
console.log(bin) // => <Buffer 68 65 6c 6c 6f>
console.log(dup) // => <Buffer 48 65 65 6c 6f>

Stream 数据流

流(stream)是 Node.js 中处理流式数据的抽象接口。stream 模块提供了一些 API,用于构建实现了流接口的对象。Node.js 提供了多种流对象。例如,HTTP 服务器的请求和 process.stdout 都是流的实例。

流可以是可读的、可写的、或者可读可写的。 Stream 基于事件机制工作,所有的流都是 EventEmitter 的实例。

这里已文件拷贝为例,创建一个只读数据流:

let rs = fs.createReadStream(src)

rs.on('data', function(chunk) {
  rs.pause()
  doSomething(chunk, function() {
    rs.resume()
  })
})

rs.on('end', function() {
  cleanUp()
})

代码中 data 事件会源源不断地被触发,为了避免 doSomething 函数无法及时处理,处理数据前暂停数据读取,并在处理数据后通过回调函数继续读取数据。

此外,也可以为数据目标创建一个只写数据流:

let rs = fs.createReadStream(src)
let ws = fs.createWriteStream(dst)

rs.on('data', function(chunk) {
  if (ws.write(chunk) === false) {
    rs.pause()
  }
})

rs.on('end', function() {
  ws.end()
})

ws.on('drain', function() {
  rs.resume()
})

以上代码实现了数据从只读数据流到只写数据流的搬运,并包括了防爆仓控制。因为这种使用场景很多,例如上边的大文件拷贝程序,NodeJS 直接提供了 .pipe 方法来做这件事情,其内部实现方式与上边的代码类似。

File System 文件系统

NodeJS 通过 fs 内置模块提供对文件的操作。fs 模块提供的 API 基本上可以分为以下三类:

  • 文件属性读写:其中常用的有 fs.stat、fs.chmod、fs.chown 等;
  • 文件内容读写:其中常用的有 fs.readFile、fs.readdir、fs.writeFile、fs.mkdir 等;
  • 底层文件操作:其中常用的有 fs.open、fs.read、fs.write、fs.close 等。

所有的文件系统操作都有同步和异步两种形式。异步形式的最后一个参数是完成时的回调函数。传给回调函数的参数取决于具体方法,但第一个参数会保留给异常。如果操作成功完成,则第一个参数会是 null 或 undefined。

Path 路径

path 模块用于处理文件与目录的路径,常用 API 如下:

  • path.normalize:将传入的路径转换为标准路径,能去掉多余的斜杠;
  • path.join:将传入的多个路径拼接为标准路径,能在不同系统下正确使用相应的路径分隔符;
  • path.extname:获取文件扩展名。

标准化之后的路径里的斜杠在 Windows 系统下是 \,而在 Linux 系统下是 /。如果想保证任何系统下都使用 / 作为路径分隔符的话,需要用 .replace(/\\/g, '/') 再替换一下标准路径。

遍历目录

遍历目录时一般使用递归算法,否则就难以编写出简洁的代码。递归算法与数学归纳法类似,通过不断缩小问题的规模来解决问题。

使用递归算法编写的代码虽然简洁,但由于每递归一次就产生一次函数调用,在需要优先考虑性能时,需要把递归算法转换为循环算法,以减少函数调用次数。

目录是一个树状结构,在遍历时一般使用深度优先+先序遍历算法。深度优先,意味着到达一个节点后,首先接着遍历子节点而不是邻居节点。先序遍历,意味着首次到达了某节点就算遍历完成,而不是最后一次返回某节点才算数。

实现同步遍历算法如下:

const fs = require('fs')
const path = require('path')

function travel(dir, callback) {
  fs.readdirSync(dir).forEach(file => {
    let pathname = path.join(dir, file)
    if (fs.statSync(pathname).isDirectory()) {
      travel(pathname, callback)
    } else {
      callback(file)
    }
  })
}

该函数以某个目录作为遍历的起点。遇到一个子目录时,就先接着遍历子目录。遇到一个文件时,就把文件的绝对路径传给回调函数。回调函数拿到文件路径后,就可以做各种判断和处理。

文本编码

NodeJS 操作文本时需要处理文件编码问题,常用的文本编码有 UTF8 和 GBK 两种,并且 UTF8 文件还可能带有 BOM。在读取不同编码的文本文件时,需要将文件内容转换为 JS 使用的 UTF8 编码字符串后才能正常处理。

BOM 移除

BOM 用于标记一个文本文件使用 Unicode 编码,其本身是一个 Unicode 字符 "\uFEFF",位于文本文件头部。在不同的 Unicode 编码下,BOM 字符对应的二进制字节如下:

Bytes Encoding
FE FF UTF16BE
FF FE UTF16LE
EF BB BF UTF8

因此,可以根据文本文件头几个字节来判断文件是否包含 BOM,以及使用哪种 Unicode 编码。但是,BOM 字符虽然起到了标记文件编码的作用,其本身却不属于文件内容的一部分,如果读取文本文件时不去掉 BOM,在某些使用场景下就会有问题。

例如把几个 JS 文件合并成一个文件后,如果文件中间含有 BOM 字符,就会导致浏览器 JS 语法错误。因此,使用 NodeJS 读取文本文件时,一般需要去掉 BOM。例如,以下代码实现了识别和去除 UTF8 BOM 的功能:

function readText(pathname) {
  let bin = fs.readFileSync(pathname)

  if (bin[0] === 0xef && bin[1] === 0xbb && bin[2] === 0xbf) {
    bin = bin.slice(3)
  }

  return bin.toString('utf-8')
}

GBK 转 UTF8

NodeJS 支持在读取文本文件时,或者在 Buffer 转换为字符串时指定文本编码,但 GBK 编码不在 NodeJS 自身支持范围内。因此,一般借助iconv-lite这个三方包来转换编码。使用它可以按下边方式编写一个读取 GBK 文本文件的函数:

const iconv = require('iconv-lite')

function readGBKText(pathname) {
  let bin = fs.readFileSync(pathname)
  return iconv.decode(bin, 'gbk')
}

小结

NodeJS 操作文件小结如下:

  • 如果不是很在意性能,尽量使用同步 API;
  • 需要对文件读写做到字节级别的精细控制时,请使用 fs 模块的文件底层操作 API;
  • 不要使用拼接字符串的方式来处理路径,使用 path 模块。

网络操作

http 模块

NodeJS 内置的 http 模块来处理网络操作。

http 模块提供两种使用方式:

  • 作为服务端使用时,创建一个 HTTP 服务器,监听 HTTP 客户端请求并返回响应。
  • 作为客户端使用时,发起一个 HTTP 客户端请求,获取服务端响应。

HTTP 请求本质上是一个数据流,由请求头(headers)和请求体(body)组成。例如以下是一个完整的 HTTP 请求数据内容。

POST / HTTP/1.1
User-Agent: curl/7.26.0
Host: localhost
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded

Hello World

可以看到,空行之上是请求头,之下是请求体。HTTP 请求在发送给服务器时,可以认为是按照从头到尾的顺序一个字节一个字节地以数据流方式发送的。

而 http 模块创建的 HTTP 服务器在接收到完整的请求头后,就会调用回调函数。在回调函数中,除了可以使用 request 对象访问请求头数据外,还能把 request 对象当作一个只读数据流来访问请求体数据。

下面代码中服务端原样将客户端请求的请求体数据返回给客户端:

const http = require('http')

http
  .createServer(function(request, response) {
    response.writeHead(200, { 'Content-Type': 'text/plain' })

    request.on('data', function(chunk) {
      response.write(chunk)
    })

    request.on('end', function() {
      response.end()
    })
  })
  .listen(8124)

https 模块

https 模块与 http 模块极为类似,区别在于 https 模块需要额外处理 SSL 证书。

在服务端模式下,创建一个 HTTPS 服务器的示例如下:

const http = require('http')

const options = {
  key: fs.readFileSync('./ssl/default.key'),
  cert: fs.readFileSync('./ssl/default.cer')
}

const server = https.createServer(options, function(request, response) {
  // ...
})

可以看到,与创建 HTTP 服务器相比,多了一个 options 对象,通过 key 和 cert 字段指定了 HTTPS 服务器使用的私钥和公钥。

另外,NodeJS 支持 SNI 技术,可以根据 HTTPS 客户端请求使用的域名动态使用不同的证书,因此同一个 HTTPS 服务器可以使用多个域名提供服务。

server.addContext('foo.com', {
  key: fs.readFileSync('./ssl/foo.com.key'),
  cert: fs.readFileSync('./ssl/foo.com.cer')
})

server.addContext('bar.com', {
  key: fs.readFileSync('./ssl/bar.com.key'),
  cert: fs.readFileSync('./ssl/bar.com.cer')
})

但如果目标服务器使用的 SSL 证书是自制的,不是从颁发机构购买的,默认情况下 https 模块会拒绝连接,提示说有证书安全问题。在 options 里加入 rejectUnauthorized: false 字段可以禁用对证书有效性的检查,从而允许 https 模块请求开发环境下使用自制证书的 HTTPS 服务器。

URL

处理 HTTP 请求时会使用 url 模块,该模块允许解析、生成以及拼接 URL。一个完整的 URL 的组成如下:

                            href
 -----------------------------------------------------------------
                            host              path
                      --------------- ----------------------------
 http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
 -----    ---------   --------   ---- -------- ------------- -----
protocol     auth     hostname   port pathname     search     hash
                                                ------------
                                                   query

可以使用 .parse 方法来将一个 URL 字符串转换为 URL 对象,示例如下:

const url = require('url')

const obj = url.parse('http://user:[email protected]:8080/p/a/t/h?query=string#hash')
console.log(obj)

/**
 * Url {
 *   protocol: 'http:',
 *   slashes: true,
 *   auth: 'user:pass',
 *   host: 'host.com:8080',
 *   port: '8080',
 *   hostname: 'host.com',
 *   hash: '#hash',
 *   search: '?query=string',
 *   query: 'query=string',
 *   pathname: '/p/a/t/h',
 *   path: '/p/a/t/h?query=string',
 *   href: 'http://user:[email protected]:8080/p/a/t/h?query=string#hash'
 * }
 **/

传给 .parse 方法的不一定要是一个完整的 URL,例如在 HTTP 服务器回调函数中,request.url 不包含协议头和域名,但同样可以用 .parse 方法解析。

.parse 方法还支持第二个和第三个布尔类型可选参数。第二个参数等于 true 时,该方法返回的 URL 对象中,query 字段不再是一个字符串,而是一个经过 querystring 模块转换后的参数对象。第三个参数等于 true 时,该方法可以正确解析不带协议头的 URL,例如 //www.example.com/foo/bar

反过来,.format 方法允许将一个 URL 对象转换为 URL 字符串。另外,.resolve 方法可以用于拼接 URL。

const url = require('url')

url.resolve('http://www.example.com/foo/bar', '../baz')
// http://www.example.com/baz

Query String

querystring 模块用于实现 URL 参数字符串与参数对象的互相转换,示例如下:

const querystring = require('querystring')

querystring.parse('foo=bar&baz=qux&baz=quux&corge')
// { foo: 'bar', baz: ['qux', 'quux'], corge: '' }

querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' })
// 'foo=bar&baz=qux&baz=quux&corge='

Zlib

zlib 模块提供通过 Gzip 和 Deflate/Inflate 实现压缩和解压功能。

通过判断客户端是否支持 gzip,并在支持的情况下使用 zlib 模块返回 gzip 之后的响应体数据:

const http = require('http')

http
  .createServer(function(request, response) {
    let i = 1024,
      data = ''

    while (i--) {
      data += '.'
    }

    if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
      zlib.gzip(data, function(err, data) {
        response.writeHead(200, {
          'Content-Type': 'text/plain',
          'Content-Encoding': 'gzip'
        })
        response.end(data)
      })
    } else {
      response.writeHead(200, {
        'Content-Type': 'text/plain'
      })
      response.end(data)
    }
  })
  .listen(80)

同时,通过判断服务端响应是否使用 gzip 压缩,并在压缩的情况下使用 zlib 模块解压响应体数据:

const http = require('http')

const options = {
  hostname: 'www.example.com',
  port: 80,
  path: '/',
  method: 'GET',
  headers: {
    'Accept-Encoding': 'gzip, deflate'
  }
}

http
  .request(options, function(response) {
    let body = []

    response.on('data', function(chunk) {
      body.push(chunk)
    })

    response.on('end', function() {
      body = Buffer.concat(body)

      if (response.headers['content-encoding'] === 'gzip') {
        zlib.gunzip(body, function(err, data) {
          console.log(data.toString())
        })
      } else {
        console.log(data.toString())
      }
    })
  })
  .end()

Net

net 模块可用于创建 Socket 服务器或 Socket 客户端。

下面使用 net 模块创建一个 HTTP 服务器:

const net = require('net')

net
  .createServer(function(conn) {
    conn.on('data', function(data) {
      conn.write(
        ['HTTP/1.1 200 OK', 'Content-Type: text/plain', 'Content-Length: 11', '', 'Hello World'].join('\n')
      )
    })
  })
  .listen(80)

再创建一个客户端:

let options = {
  port: 80,
  host: 'www.example.com'
}

let client = net.connect(options, function() {
  client.write(
    ['GET / HTTP/1.1', 'User-Agent: curl/7.26.0', 'Host: www.baidu.com', 'Accept: */*', '', ''].join('\n')
  )
})

client.on('data', function(data) {
  console.log(data.toString())
  client.end()
})

小结

本章介绍了使用 NodeJS 操作网络时需要的 API 以及一些坑回避技巧,总结起来有以下几点:

  • http 和 https 模块支持服务端模式和客户端模式两种使用方式;
  • request 和 response 对象除了用于读写头数据外,都可以当作数据流来操作;
  • url.parse 方法加上 request.url 属性是处理 HTTP 请求时的固定搭配;
  • 使用 zlib 模块可以减少使用 HTTP 协议时的数据传输量;
  • 通过 net 模块的 Socket 服务器与客户端可对 HTTP 协议做底层操作。

进程管理

NodeJS 可以感知和控制自身进程的运行环境和状态,也可以创建子进程并与其协同工作,这使得 NodeJS 可以把多个程序组合在一起共同完成某项工作,并在其中充当胶水和调度器的作用。

调用终端命令

在第一章里实现了文件拷贝的功能,但终端下的 cp 命令比较好用,一条 cp -r source/* target 命令就能搞定目录拷贝:

const child_process = require('child_process')
const util = require('util')

function copy(source, target, callback) {
  child_process.exec(util.format('cp -r %s/* %s', source, target), callback)
}

function main(argv) {
  copy(argv[0], argv[1], function(err) {
    console.log(err)
  })
}

main(process.argv.slice(2))

退出程序

通常一个程序执行完成正常退出,这时程序的退出状态码为 0。或者一个程序运行时发生了异常后就挂了,这时程序的退出状态码不等于 0。如果在代码中捕获了某个异常,但是觉得程序不应该继续运行下去,需要立即退出,并且需要把退出状态码设置为指定数字,就可以按照以下方式:

try {
  // ...
} catch (err) {
  // ...
  process.exit(1)
}

创建子进程

以下是一个创建 NodeJS 子进程的例子:

const child_process = require('child_process')

let child = child_process.spawn('node', ['xxx.js'])

child.stdout.on('data', function(data) {
  console.log('stdout: ' + data)
})

child.stderr.on('data', function(data) {
  console.log('stderr: ' + data)
})

child.on('close', function(code) {
  console.log('child process exited with code ' + code)
})

上例中使用了 .spawn(exec, args, options) 方法,该方法支持三个参数。第一个参数是执行文件路径,可以是执行文件的相对或绝对路径,也可以是根据 PATH 环境变量能找到的执行文件名。第二个参数中,数组中的每个成员都按顺序对应一个命令行参数。第三个参数可选,用于配置子进程的执行环境与行为。

进程间通信

进程间可以互相通信:

const child_process = require('child_process')

/* parent.js */
let child = child_process.spawn('node', ['child.js'])

child.kill('SIGTERM')

/* child.js */
process.on('SIGTERM', function() {
  cleanUp()
  process.exit(0)
})

上面代码中,父进程通过 .kill 方法向子进程发送 SIGTERM 信号,子进程监听 process 对象的 SIGTERM 事件响应信号。.kill 方法本质上是用来给进程发送信号的,进程收到信号后具体要做啥,完全取决于信号的种类和进程自身的代码。

另外,如果父子进程都是 NodeJS 进程,就可以通过 IPC(进程间通讯)双向传递数据。

const child_process = require('child_process')

/* parent.js */
let child = child_process.spawn('node', ['child.js'], {
  stdio: [0, 1, 2, 'ipc']
})

child.on('message', function(msg) {
  console.log(msg)
})

child.send({ hello: 'hello' })

/* child.js */
process.on('message', function(msg) {
  msg.hello = msg.hello.toUpperCase()
  process.send(msg)
})

可以看到,父进程在创建子进程时,在 options.stdio 字段中通过 ipc 开启了一条 IPC 通道,之后就可以监听子进程对象的 message 事件接收来自子进程的消息,并通过 .send 方法给子进程发送消息。在子进程这边,可以在 process 对象上监听 message 事件接收来自父进程的消息,并通过 .send 方法向父进程发送消息。数据在传递过程中,会先在发送端使用 JSON.stringify 方法序列化,再在接收端使用 JSON.parse 方法反序列化。

守护子进程

守护进程一般用于监控工作进程的运行状态,在工作进程不正常退出时重启工作进程,保障工作进程不间断运行:

const child_process = require('child_process')

function spawn(mainModule) {
  let worker = child_process.spawn('node', [mainModule])

  worker.on('exit', function(code) {
    if (code !== 0) {
      spawn(mainModule)
    }
  })
}

spawn('worker.js')

大项目

最后以一个大项目作为总结,项目开发的是一个简单的静态文件合并服务器,该服务器需要支持类似以下格式的 JS 或 CSS 文件合并请求:

http://assets.example.com/foo/??bar.js,baz.js

在以上 URL 中,?? 是一个分隔符,之前是需要合并的多个文件的 URL 的公共部分,之后是使用 , 分隔的差异部分。因此服务器处理这个 URL 时,返回的是以下两个文件按顺序合并后的内容:

/foo/bar.js
/foo/baz.js

此外,服务器也同时支持普通的 JS 或 CSS 文件请求:

http://assets.example.com/foo/bar.js

第一次迭代

设计方案:

           +---------+   +-----------+   +----------+
request -->|  parse  |-->|  combine  |-->|  output  |--> response
           +---------+   +-----------+   +----------+

服务器会首先分析 URL,得到请求的文件的路径和类型(MIME)。然后,服务器会读取请求的文件,并按顺序合并文件内容。最后,服务器返回响应,完成对一次请求的处理。

另外,服务器在读取文件时的根目录和服务器监听的 HTTP 端口可以配置。

设计实现:

const fs = require('fs')
const path = require('path')
const http = require('http')

const MIME = {
  '.css': 'text/css',
  '.js': 'application/javascript'
}

function combineFiles(pathnames, callback) {
  let output = []
  ;(function next(i, len) {
    if (i < len) {
      fs.readFile(pathnames[i], (err, data) => {
        if (err) {
          callback(err)
        } else {
          output.push(data)
          next(i + 1, len)
        }
      })
    } else {
      callback(null, Buffer.concat(output))
    }
  })(0, pathnames.length)
}

function parseURL(root, url) {
  let base, parts, pathnames
  if (!url.includes('??')) {
    url = url.replace('/', '/??')
  }
  parts = url.split('??')
  base = parts[0]
  pathnames = parts[1].split(',').map(val => {
    return path.join(root, base, val)
  })
  return {
    mine: MIME[path.extname(pathnames[0])] || 'text/plain',
    pathnames
  }
}

function main(argv) {
  // 读取配置文件 config.json
  const config = JSON.parse(fs.readFileSync(argv[0], 'utf-8'))
  // 根目录和端口号
  const { root = '.', port = 80 } = config

  http
    .createServer((request, response) => {
      let urlInfo = parseURL(root, request.url)

      combineFiles(urlInfo.pathnames, (err, data) => {
        if (err) {
          response.writeHead(400)
          response.end(err.message)
        } else {
          response.writeHead(200, {
            'Content-Type': urlInfo.mine
          })
          response.end(data)
        }
      })
    })
    .listen(port)
}

main(process.argv.slice(2))

第二次迭代

第一次迭代的请求分析:

发送请求       等待服务端响应         接收响应
---------+----------------------+------------->
         --                                        解析请求
           ------                                  读取a.js
                 ------                            读取b.js
                       ------                      读取c.js
                             --                    合并数据
                               --                  输出响应

可以看到,第一版代码依次把请求的文件读取到内存中之后,再合并数据和输出响应。这会导致以下两个问题:

  1. 当请求的文件比较多比较大时,串行读取文件会比较耗时,从而拉长了服务端响应等待时间。
  2. 由于每次响应输出的数据都需要先完整地缓存在内存里,当服务器请求并发数较大时,会有较大的内存开销。

对于问题一,很容易想到把读取文件的方式从串行改为并行。但是别这样做,**因为对于机械磁盘而言,因为只有一个磁头,尝试并行读取文件只会造成磁头频繁抖动,反而降低 IO 效率。而对于固态硬盘,虽然的确存在多个并行 IO 通道,但是对于服务器并行处理的多个请求而言,硬盘已经在做并行 IO 了,对单个请求采用并行 IO 无异于拆东墙补西墙。**因此,正确的做法不是改用并行 IO,而是一边读取文件一边输出响应,把响应输出时机提前至读取第一个文件的时刻。

这样调整后,整个请求处理过程变成下边这样:

发送请求 等待服务端响应 接收响应
---------+----+------------------------------->
         --                                        解析请求
           --                                      检查文件是否存在
             --                                    输出响应头
               ------                              读取和输出a.js
                     ------                        读取和输出b.js
                           ------                  读取和输出c.js

设计实现:

function outputFiles(pathnames, write) {
  ;(function next(i, len) {
    if (i < len) {
      let reader = fs.createReadStream(pathnames[i])
      reader.pipe(
        write,
        { end: false }
      )
      reader.on('end', function() {
        next(i + 1, len)
      })
    } else {
      write.end()
    }
  })(0, pathnames.length)
}

function validateFiles(pathnames, callback) {
  ;(function next(i, len) {
    if (i < len) {
      fs.stat(pathnames[i], (err, stat) => {
        if (err) {
          callback(err)
        } else if (!stat.isFile()) {
          callback(new Error())
        } else {
          next(i + 1, len)
        }
      })
    } else {
      callback(null, pathnames)
    }
  })(0, pathnames.length)
}

function main(argv) {
  // 读取配置文件 config.json
  const config = JSON.parse(fs.readFileSync(argv[0], 'utf-8'))
  // 根目录和端口号
  const { root = '.', port = 80 } = config

  http
    .createServer((request, response) => {
      let urlInfo = parseURL(root, request.url)

      validateFiles(urlInfo.pathnames, (err, pathnames) => {
        if (err) {
          response.writeHead(400)
          response.end(err.message)
        } else {
          // 在检查完文件后立即输出请求头
          response.writeHead(200, {
            'Content-Type': urlInfo.mine
          })
          outputFiles(pathnames, response)
        }
      })
    })
    .listen(port)
}

可以看到,第二版代码在检查了请求的所有文件是否有效之后,立即就输出了响应头,并接着一边按顺序读取文件一边输出响应内容。并且在读取文件时,使用了只读数据流来简化代码。

第三次迭代

从工程角度上讲,没有绝对可靠的系统。即使代码没有 BUG,也可能因为操作系统,甚至是硬件导致服务器程序在某一天挂掉。因此一般生产环境下的服务器程序都配有一个守护进程,在服务挂掉的时候立即重启服务。一般守护进程的代码会远比服务进程的代码简单,从概率上可以保证守护进程更难挂掉。甚至守护进程自身可以在自己挂掉时重启自己,从而实现双保险。

可以利用 NodeJS 的进程管理机制,将守护进程作为父进程,将服务器程序作为子进程,并让父进程监控子进程的运行状态,在其异常退出时重启子进程。

//daemon.js
const cp = require('child_process')

let worker

function spawn(server, config) {
  worker = cp.spawn('node', [server, config])
  worker.on('exit', code => {
    if (code !== 0) {
      spawn(server, config)
    }
  })
}

function main(argv) {
  spawn('server.js', argv[0])
  process.on('SIGTERM', () => {
    worker.kill()
    process.exit(0)
  })
}

main(process.argv.slice(2))

//server.js
process.on('SIGTERM', () => {
  server.close(() => {
    process.exit(0)
  })
})

可以把守护进程的代码保存为 daemon.js,之后可以通过 node daemon.js config.json 启动服务,而守护进程会进一步启动和监控服务器进程。

此外,为了能够正常终止服务,让守护进程在接收到 SIGTERM 信号时终止服务器进程。而在服务器进程这一端,同样在收到 SIGTERM 信号时先停掉 HTTP 服务再正常退出。

参考文章:
七天学会 NodeJS

Vue 一键导出 PDF

最近上班有点闲,摸鱼码了个在线简历生成器 Tamayura,提供在线编辑和主题设置等功能。一开始使用 chrome 自带 PDF 导出功能,后来寻思不够方便,便研究下如何一键导出 PDF。

生成方案

一键生成导出 PDF 看起来是广大群众的普遍需求,网上一通 Google 便有不少现成方案,前人栽树后人乘凉,既然有成熟的方案那便直接采用了。

生成 PDF 基本思路大多一致,先用 html2canvas 将 DOM 元素转换为 canvas,再利用 canvas 的 toDataURL 方法输出为图片,最后使用 jsPDF 添加图片生成 PDF 实现一键下载。

html2canvas 是一个著名开源库,可将一个元素渲染为 canvas,只需要简单的调用 html2canvas(element[, options]) 即可。该方法会返回一个包含有 canvas 元素的 promise。

jsPDF 是一个基于 HTML5 的客户端解决方案,用于在客户端 JavaScript 中生成 PDF 的库,支持文本、图片等格式。借助 jsPDF,利用之前生成的 canvas 元素,可以直接在前端生成 PDF 文件。

代码实现

根据以上方案,实现一个 vue 插件,提供 PDF 一键导出功能:

import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'

export default {
  install(Vue) {
    Vue.prototype.getPdf = function(title) {
      const element = document.getElementById('pdfDom')
      const opts = {
        scale: 4, // 缩放比例,提高生成图片清晰度
        useCORS: true, // 允许加载跨域的图片
        allowTaint: false, // 允许图片跨域,和 useCORS 二者不可共同使用
        tainttest: true, // 检测每张图片都已经加载完成
        logging: true // 日志开关,发布的时候记得改成 false
      }

      html2Canvas(element, opts).then(function(canvas) {
        let contentWidth = canvas.width
        let contentHeight = canvas.height
        let pageHeight = (contentWidth / 592.28) * 841.89
        let leftHeight = contentHeight
        let position = 0
        let imgWidth = 595.28
        let imgHeight = (592.28 / contentWidth) * contentHeight
        let pageData = canvas.toDataURL('image/jpeg', 1.0)
        let PDF = new JsPDF('', 'pt', 'a4')
        if (leftHeight < pageHeight) {
          PDF.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight)
        } else {
          while (leftHeight > 0) {
            PDF.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
            leftHeight -= pageHeight
            position -= 841.89
            if (leftHeight > 0) {
              PDF.addPage()
            }
          }
        }
        PDF.save(title + '.pdf')
      })
    }
  }
}

食用方式:

import htmlToPdf from './utils/htmlToPdf'

Vue.use(htmlToPdf)

注意点:

  1. 如果引入外链图片,需要配置图片跨域,并给 img 标签设置 crossOrigin='anonymous'
  2. 尽量提高生成图片质量,可以适当放大 canvas 画布,通过设置 scale 缩放画布大小,或者设置 dpi 提高清晰度。

ES6标准入门-Proxy 和 Reflect

ES6 新增 Proxy 和 Reflect,两者相辅相成,功能颇为强大,但工作中基本未被提及,这里略微学习一下,不求甚解,待到工作 coding 时遇到再温故知新。

Proxy

Proxy 概述

Proxy 用于修改某些操作的默认行为,属于一种“元编程”(meta programming),即对编程语言进行编程。

Proxy 可以理解成在目标对象前架设一个“拦截”层,外界对该对象的访问都必须先通过这层拦截,因此提供了一种机制可以对外界的访问进行过滤和改写。

let obj = {}

let proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('get--> target:', target, 'key:', key, 'receiver:', receiver)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log('set--> target:', target, 'key:', key, 'value:', value)
    return Reflect.set(target, key, value, receiver)
  }
})

proxy.count = 1
// set--> target: {} key: count value: 1

++proxy.count
// get--> target: {count: 1} key: count receiver: Proxy {count: 1}
// set--> target: {count: 1} key: count value: 2

obj // {count: 2}

上面的代码说明,Proxy 实际上重载(overload)了点运算符,即用自己的定义覆盖了语言的原始定义。

ES6 原生提供 Proxy 构造函数,用于生成 Proxy 实例:

let proxy = new Proxy(target, handler)

Proxy 对象的所有用法基本一致,不同的只是 handler 参数的写法。其中,new Proxy() 表示生成一个 Proxy 实例,target 参数表示所要拦截的目标对象,handler 参数也是一个对象,用来定制拦截行为。

如果 handler 没有设置任何拦截,那就等同于直接通向原对象。

Proxy 实例也可以作为其他对象的原型对象。

let proxy = new Proxy(
  {},
  {
    get(target, key, receiver) {
      return 42
    }
  }
)

let obj = Object.create(proxy)
obj.a // 42

Proxy 实例方法

下面是 Proxy 支持的所有拦截操作:

  • get(target, propKey, receiver):拦截对象属性的读取,最后一个参数 receiver 是一个可选对象。
  • set(target, propKey, value, receiver):拦截对象属性的设置,返回一个布尔值。
  • has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截 delete proxy[propKey]的操作,返回一个布尔值。
  • ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols (proxy)、Object.keys(proxy),返回一个数组。该方法返回目标对象所有自身属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey):拦截 Object.getOwnPropertyDescriptor(proxy, prop-Key),返回属性的描述对象。
  • defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc)、Object.define Properties(proxy, propDescs),返回一个布尔值。
  • preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。
  • getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。
  • isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。
  • setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args):拦截 Proxy 实例,并将其作为函数调用的操作。
  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如 newproxy(...args)。

具体方法介绍这里不在累述,详见《ES6 标准入门》。

Proxy.revocable()

Proxy.revocable 方法返回一个可取消的 Proxy 实例。

let target = {}
let handler = {}
let { proxy, revoke } = Proxy.revocable(target, handler)
proxy.foo = 123
proxy.foo // 123
revoke()
proxy.foo // TypeError: Revoked

Proxy.revocable 方法返回一个对象,其 proxy 属性是 Proxy 实例,revoke 属性是一个函数,可以取消 Proxy 实例。上面的代码中,当执行 revoke 函数后再访问 Proxy 实例,就会抛出一个错误。

Proxy.revocable 的一个使用场景是,目标对象不允许直接访问,必须通过代理访问,一旦访问结束,就收回代理权,不允许再次访问。

this 问题

虽然 Proxy 可以代理针对目标对象的访问,但它不是目标对象的透明代理,即不做任何拦截的情况下也无法保证与目标对象的行为一致。主要原因就是在 Proxy 代理的情况下,目标对象内部的 this 关键字会指向 Proxy 代理

const target = {
  m: function() {
    console.log(this === proxy)
  }
}
const handler = {}
const proxy = new Proxy(target, handler)
target.m() // false
proxy.m() // true

上面的代码中,一旦 proxy 代理 target.m,后者内部的 this 就指向 proxy,而不是 target。

此外,有些原生对象的内部属性只有通过正确的 this 才能获取,所以 Proxy 也无法代理这些原生对象的属性。

const target = new Date()
const handler = {}
const proxy = new Proxy(target, handler)
proxy.getDate() // TypeError: this is not a Date object.

通过 Proxy 可以拦截网络请求或者实现数据库的 ORM 层。

Reflect

Proxy 概述

Reflect 对象与 Proxy 对象一样,也是 ES6 为了操作对象而提供的新的 API,Reflect 对象的设计目的:

  1. 将 Object 对象的一些明显属于语言内部的方法(比如 Object.defineProperty)放到 Reflect 对象上。现阶段,某些方法同时在 Object 和 Reflect 对象上部署,未来的新方法将只在 Reflect 对象上部署。
  2. 修改某些 Object 方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc) 在无法定义属性时会抛出一个错误,而 Reflect.defineProperty(obj, name,desc) 则会返回 false。
  3. 让 Object 操作都变成函数行为。某些 Object 操作是命令式,比如 name in obj 和 delete obj[name],而 Reflect.has(obj, name) 和 Reflect.deleteProperty (obj, name) 让它们变成了函数行为。
  4. Reflect 对象的方法与 Proxy 对象的方法一一对应,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。这就使 Proxy 对象可以方便地调用对应的 Reflect 方法来完成默认行为,作为修改行为的基础。

每一个 Proxy 对象的拦截操作内部都应调用对应的 Reflect 方法,保证原生行为能够正常执行。

Reflect 对象一共有 13 个静态方法,与 Proxy 一一对应,这里不再累述,详见《ES6 标准入门》。

观察者模式

观察者模式(Observer mode)指的是函数自动观察数据对象的模式,一旦对象有变化,函数就会自动执行。

const quenedObserves = new Set()
const observe = fn => quenedObserves.add(fn)
const observable = obj => new Proxy(obj, { set })

function set(target, key, value, receiver) {
  const result = Reflect.set(target, key, value, receiver)
  quenedObserves.forEach(observe => observe())
  return result
}

const person = observable({
  name: '张三'
})
observe(() => console.log('name:', person.name))
person.name = '李四'

上面的代码先定义了一个 Set 集合,所有观察者函数都放进这个集合中。然后,observable 函数返回原始对象的代理,拦截赋值操作。拦截函数 set 会自动执行所有观察者。

异国生活

不知觉在异国的生活已近一月,异国风情尚未深刻感受。每天两点一线,几分钟的路程,不用在思虑三餐和忍受早晚高峰挤地铁的煎熬,怕是最为惬意的事情,每天有更多的时间来思考充实自己。

在未来半年乃至更长时间,努力去适应和享受这种生活…

JavaScript 秘密花园

《JavaScript 秘密花园》小册由两位 Stack Overflow 用户伊沃·韦特泽尔(写作)和张易江(设计)完成,由三生石上翻译完成,内容短小精炼。这次温故知新,做一番总结。

对象

对象使用和属性

JavaScript 中所有变量都是对象,除了两个例外 nullundefined

JavaScript 解析器错误,试图将点操作符解析为浮点数字值的一部分。

2.toString()    // SyntaxError

2..toString()   // 第二个点号可以正常解析
2 .toString()   // 添加空格
;(2).toString() // 2 先被计算

删除属性

删除属性的唯一方法是使用 delete 操作符,设置属性为 undefined 或者 null 并不能真正的删除属性,而仅仅是移除属性和值的关联。

原型

实现传统的类继承模型很简单,但是实现 JavaScript 中的原型继承则困难的多。

It is for example fairly trivial to build a classic model on top of it, while the other way around is a far more difficult task.

function Foo() {
  this.value = 42
}

Foo.prototype = {
  method: function() {}
}

function Bar() {}

// 设置 Bar.prototype 属性为 Foo 的实例对象
Bar.prototype = new Foo()
Bar.prototype.foo = 'Hello World'

// 修正 Bar.prototype.constructor 为 Bar 本身
Bar.prototype.constructor = Bar

/**
 * test [Bar的实例]
 *     Bar.prototype [Foo的实例]
 *       { foo: 'Hello World' }
 *       Foo.prototype
 *           { method: ... }
 *           Object.prototype
 *               { toString:... }
 **/

继承与原型链

当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象(object)都有一个私有属性(称之为 proto)指向它的原型对象(prototype)。该原型对象也有一个自己的原型对象,层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。

someObject.[[Prototype]] 符号是用于指向 someObject 的原型。从 ECMAScript6 开始,[[Prototype]] 可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 访问器来访问。这个等同于 JavaScript 的非标准但许多浏览器实现的属性 __proto__

但它不应该与构造函数 func 的 prototype 属性相混淆。被构造函数创建的实例对象的 [[prototype]] 指向 func 的 prototype 属性。 Object.prototype 属性表示 object 的原型对象。

hasOwnProperty 函数

hasOwnProperty 是 JavaScript 中处理属性并且不会遍历原型链的方法之一(另一种方法: Object.keys())。当检查对象某个属性是否存在时,hasOwnProperty 是唯一可用的方法。同时在使用 for in loop 遍历对象时,推荐总使用 hasOwnProperty 方法。

函数

函数声明与表达式

函数声明会被提前解析(hoisted),但函数表达式不会。

// 函数声明
foo()
function foo() {}

// 函数表达式
bar // undefined
bar() // TypeError
const bar = function() {}

命名函数的赋值表达式

函数名在函数内总是可见的。

const foo = function bar() {
  bar() // 正常运行
}
bar() // 出错:ReferenceError

上面代码中,bar 函数声明外是不可见的,这是因为已经把函数赋值给了 foo;然而在 bar 内部依然可见。这是由于 JavaScript 的命名处理所致,函数名在函数内总是可见的。

this 的工作原理

JavaScript 有一套完全不同于其它语言的对 this 的处理机制。在 5 种不同的情况下,this 指向的各不相同。

  • 全局范围内:this 指向全局对象。但在严格模式下,不存在全局变量,this 将会是 undefined。
  • 函数调用:this 指向全局对象。
foo() // this 指向全局对象
  • 方法调用:this 指向调用方法的对象。
test.foo() // this 指向 test 对象
  • 调用构造函数:在构造函数内部,this 指向新创建的对象。
new foo() // this 指向返回的新对象
  • 显示设置 this:调用 call 或者 apply 方法时,函数内 this 将会被显式设置为函数调用的第一个参数。
Foo.method = function() {
  function test() {
    // this 将会被设置为全局对象
  }
  test()
}

为了在 test 中获取对 Foo 对象的引用,需要在 method 函数内部创建一个局部变量指向 Foo 对象。因为 JavaScript 中不可以对作用域进行引用或赋值,所以不可以在外部访问私有变量。

Foo.method = function() {
  const that = this
  function test() {
    // 使用 that 来指向 Foo 对象
  }
  test()
}

arguments 对象

arguments 对象为其内部属性以及函数形式参数创建 getter 和 setter 方法。因此,改变形参的值会影响到 arguments 对象的值,反之亦然。

function foo(a, b, c) {
  arguments[0] = 2
  console.log(a) // 2

  b = 4
  console.log(arguments[1]) // 4

  var d = c
  d = 9
  console.log(c) // 3
}
foo(1, 2, 3)

命名空间

只有一个全局作用域导致的常见错误是命名冲突。在 JavaScript 中,这可以通过匿名包装器轻松解决。

;(function() {
  // 小括号内的函数首先被执行, 并且返回函数对象
  // 函数创建一个命名空间
  window.foo = function() {
    // 对外公开的函数,创建了闭包
  }
})() // 立即执行此匿名函数, 也就是函数对象

// 另外两种方式
;+(function() {})()
;(function() {})()

数组

数组遍历与属性

由于 for in 循环会枚举原型链上的所有属性,唯一过滤这些属性的方式是使用 hasOwnProperty 函数, 因此会比普通的 for 循环慢上好多倍。为了达到遍历数组的最佳性能,推荐使用经典的 for 循环。

length 属性

length 属性的 getter 方式会简单的返回数组的长度,而 setter 方式会截断数组。

var foo = [1, 2, 3, 4, 5, 6]
foo.length = 3
foo // [1, 2, 3]

类型

相等与比较

JavaScript 是弱类型语言,这就意味着等于操作符会为了比较两个值而进行强制类型转换。

'' == '0' // false
0 == '' // true
0 == '0' // true
false == 'false' // false
false == '0' // true
false == undefined // false
false == null // false
null == undefined // true
' \t\r\n' == 0 // true

typeof 操作符

typeof 操作符(和 instanceof 一起)或许是 JavaScript 中最大的设计缺陷,因为几乎不可能从它们那里得到想要的结果。

尽管 instanceof 还有一些极少数的应用场景,typeof 只有一个实际的应用,那便是用来检测一个对象是否已经定义或者是否已经赋值,而这个应用却不是用来检查对象的类型。

typeof foo !== 'undefined'

上面代码会检测 foo 是否已经定义,如果没有定义而直接使用会导致 ReferenceError 的异常。 这是 typeof 唯一有用的地方。除非为了检测一个变量是否已经定义,应尽量避免使用 typeof 操作符。

JavaScript 类型表格

Value Class Type
'foo' String string
new String('foo') String object
1.2 Number number
new Number(1.2) Number object
true Boolean boolean
new Boolean(true) Boolean object
new Date() Date object
new Error() Error object
[1,2,3] Array object
new Array(1, 2, 3) Array object
new Function('') Function function
/abc/g RegExp object
new RegExp('meow') RegExp object
{} Object object
new Object() Object object

上面表格中,Type 一列表示 typeof 操作符的运算结果。可以看到,这个值在大多数情况下都返回 object

Class 一列表示对象的内部属性 [[Class]] 的值。

JavaScript 标准文档只给出了一种获取 [[Class]] 值的方法,那就是使用 Object.prototype.toString

function is(type, obj) {
  var clas = Object.prototype.toString.call(obj).slice(8, -1)
  return obj !== undefined && obj !== null && clas === type
}

is('String', 'test') // true
is('String', new String('test')) // true

为了检测一个对象类型,推荐使 Object.prototype.toString 方法,因为这是唯一一个可依赖的方式。

instanceof 操作符

instanceof 操作符用来比较两个操作数的构造函数。只有在比较自定义的对象时才有意义,如果用来比较内置类型,将会和 typeof 操作符一样用处不大。

function Foo() {}
function Bar() {}
Bar.prototype = new Foo()

new Bar() instanceof Bar // true
new Bar() instanceof Foo // true

// 如果仅仅设置 Bar.prototype 为函数 Foo 本身,而不是 Foo 构造函数的一个实例
Bar.prototype = Foo
new Bar() instanceof Foo // false

// instanceof 比较内置类型
new String('foo') instanceof String // true
new String('foo') instanceof Object // true
'foo' instanceof String // false
'foo' instanceof Object // false

需要注意:instanceof 用来比较属于不同 JavaScript 上下文的对象(比如浏览器中不同的文档结构)时将会出错,因为它们的构造函数不会是同一个对象。

instanceof 操作符应该仅仅用来比较来自同一个 JavaScript 上下文的自定义对象。正如 typeof 操作符一样,任何其它的用法都应该是避免的。

ES6标准入门-数据类型与数据结构

ES6 新增了 Synmbol 数据类型和 Set、Map 两种数据据结构,以及衍生的 WeakSet 和 WeakMap。之前工作中基本未用过,惭愧之至,努力学习之。

Symbol 数据类型

Symbol 概述

ES6 引入了一种新的原始数据类型 Symbol,表示独一无二的值。它是 JavaScript 的 第 7 种数据类型,前 6 种分别是:Undefined、Null、布尔值(Boolean)、字符串(String)、数值(Number)和对象(Object)

Symbol 值通过 Symbol 函数生成。注意 Symbol 函数前不能使用 new 命令,否则会报错。这是因为生成的 Symbol 是一个原始类型的值,不是对象,基本上,它是一种类似于字符串的数据类型。

let s = Symbol()
typeof s // 'symbol'

Symbol 函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述,主要是为了在控制台显示,或者转为字符串时比较容易区分。

let s = Symbol('foo')
s.toString() // 'Symbol(foo)'

如果 Symbol 的参数是一个对象,就会调用该对象的 toString 方法,将其转为字符串,然后才生成一个 Symbol 值。

需要注意:每一个 Symbol 值都是不相等的,Symbol 函数的参数只表示对当前 Symbol 值的描述,因此相同参数的 Symbol 函数的返回值是不相等的

Symbol 值不能与其他类型的值进行运算,否则会报错。但 Symbol 值可以显式转为字符串。另外,Symbol 值也可以转为布尔值,但是不能转为数值。

let s = Symbol()
Boolean(s) // true
Number(s) // TypeError

Symbol 属性名遍历

Symbol 作为属性名,该属性不会出现在 for...in、for...of 循环中,也不会被 Object.keys()、Object.getOwnPropertyNames() 返回。但它也不是私有属性,Object.getOwnropertySymbols 方法可以获取指定对象的所有 Symbol 属性名。该方法返回一个数组,成员是当前对象的所有用作属性名的 Symbol 值。

此外,Reflect.ownKeys 方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。

let obj = { [Symbol('my_key')]: 1, enum: 2, nonEnum: 3 }
Object.getOwnPropertySymbols(obj) // [Symbol(my_key)]
Reflect.ownKeys(obj) // ['enum', 'nonEnum', Symbol(my_key)]

Symbol.for()、Symbol.keyFor()

Symbol.for()Symbol() 这两种写法都会生成新的 Symbol。它们的区别是,前者会被登记在全局环境中供搜索,而后者不会。Symbol.for() 不会在每次调用时都返回一个新的 Symbol 类型的值,而是会先检查给定的 key 是否已经存在,如果不存在才会新建一个值。

Symbol.for('bar') === Symbol.for('bar') // true
Symbol('bar') === Symbol('bar') // false

Symbol.keyFor 方法返回一个已登记的 Symbol 类型值的 key。

let s1 = Symbol.for('foo')
Symbol.keyFor(s1) // 'foo'

let s2 = Symbol('foo')
Symbol.keyFor(s2) // undefined

注意:Symbol.for 为 Symbol 值登记的名字是全局环境的,可以在不同的 iframe 或 service worker 中取到同一个值

内置的 Symbol 值

除了定义自己使用的 Symbol 值,ES6 还提供了 11 个内置的 Symbol 值,指向语言内部使用的方法。

Symbol.hasInstance

对象的 Symbol.hasInstance 属性指向一个内部方法,对象使用 instanceof 运算符时会调用这个方法,判断该对象是否为某个构造函数的实例。比如,foo instanceof Foo 在语言内部实际调用的是 Foo[Symbol.hasInstance](foo)

class Even {
  static [Symbol.hasInstance](obj) {
    return Number(obj) % 2 === 0
  }
}

1 instanceof Even // false
2 instanceof Even // true

Symbol.isConcatSpreadable

对象的 Symbol.isConcatSpreadable 属性等于一个布尔值,表示该对象使用 Array.prototype.concat() 时是否可以展开。

该值为 true 或 undefined 时可以展开,为 false 时不可展开。

let arr = ['c', 'd']
arr[Symbol.isConcatSpreadable] = false
;[('a', 'b')].concat(arr, 'e') // ['a', 'b', ['c','d'], 'e']

Symbol.species

对象的 Symbol.species 属性指向当前对象的构造函数。创造实例时默认会调用这个方法,即使用这个属性返回的函数当作构造函数来创造新的实例对象。

class MyArray extends Array {
  // 覆盖父类 Array 的构造函数
  static get [Symbol.species]() {
    return Array
  }
}

上面的代码中,子类 MyArray 继承了父类 Array。创建 MyArray 的实例对象时,本来会调用它自己的构造函数(本例中被省略了),但是由于定义了 Symbol.species 属性,所以会使用这个属性返回的函数来创建 MyArray 的实例。

默认的 Symbol.species 属性等同于下面的写法:

static get [Symbol.species]() {
  return this
}

Symbol.match

对象的 Symbol.match 属性指向一个函数,当执行 str.match(myObject) 时,如果该属性存在,会调用它返回该方法的返回值。

String.prototype.match(regexp) // 等同于
regexp[Symbol.match](this)

示例如下:

class MyMatcher {
  [Symbol.match](string) {
    return 'hello world'.indexOf(string)
  }
}
'e'.match(new MyMatcher()) // 1

Symbol.replace

对象的 Symbol.replace 属性指向一个方法,当对象被 String.prototype.replace 方法调用时会返回该方法的返回值。

String.prototype.replace(searchValue, replaceValue)
// 等同于
searchValue[Symbol.replace](this, replaceValue)

示例如下:

const x = {}
x[Symbol.replace] = (...s) => console.log(s)
'Hello'.replace(x, 'World') // ["Hello", "World"]

上面示例中,x[Symbol.replace] 方法传入两个参数 this 和 replaceValue 分别为 'Hello' 和 'World',经过参数扩展运算符转换为数组后输出 '["Hello", "World"]'。

Symbol.search

对象的 Symbol.search 属性指向一个方法,当对象被 String.prototype.search 方法调用时会返回该方法的返回值。

String.prototype.search(regexp)
// 等同于
regexp[Symbol.search](this)

示例如下:

class MySearch {
  constructor(value) {
    this.value = value
  }
  [Symbol.search](string) {
    return string.indexOf(this.value)
  }
}
'foobar'.search(new MySearch('foo')) // 0

Symbol.split

对象的 Symbol.split 属性指向一个方法,当对象被 String.prototype.split 方法调用时会返回该方法的返回值。

String.prototype.split(separator, limit)
// 等同于
separator[Symbol.split](this, limit)

示例如下:

class MySplitter {
  constructor(value) {
    this.value = value
  }
  [Symbol.split](string) {
    var index = string.indexOf(this.value)
    if (index === -1) {
      return string
    }
    return [string.substr(0, index), string.substr(index + this.value.length)]
  }
}
'foobar'.split(new MySplitter('foo')) // ['', 'bar']

Symbol.iterator

对象的 Symbol.iterator 属性指向该对象的默认遍历器方法。

var myIterable = {}
myIterable[Symbol.iterator] = function*() {
  yield 1
  yield 2
  yield 3
}
;[...myIterable] // [1, 2, 3]

对象进行 for...of 循环时,会调用 Symbol.iterator 方法返回该对象的默认遍历器。

class Collection {
  *[Symbol.iterator]() {
    let i = 0
    while (this[i] !== undefined) {
      yield this[i]
      ++i
    }
  }
}
let myCollection = new Collection()
myCollection[0] = 1
myCollection[1] = 2
for (let value of myCollection) {
  console.log(value)
}
// 1
// 2

Symbol.toPrimitive

对象的 Symbol.toPrimitive 属性指向一个方法,对象被转为原始类型的值时会调用这个方法,返回该对象对应的原始类型值。

Symbol.toPrimitive 被调用时会接受一个字符串参数,表示当前运算的模式。一共有 3 种模式:

  • Number:该场合需要转成数值。
  • String:该场合需要转成字符串。
  • Default:该场合可以转成数值,也可以转成字符串。
let obj = {
  [Symbol.toPrimitive](hint) {
    switch (hint) {
      case 'number':
        return 123
      case 'string':
        return 'str'
      case 'default':
        return 'default'
      default:
        throw new Error()
    }
  }
}
2 * obj // 246
3 + obj // '3default'
obj == 'default' // true
String(obj) // 'str'

Symbol.toStringTag

对象的 Symbol.toStringTag 属性指向一个方法,在对象上调用 Object. prototype.toString 方法时,如果这个属性存在,其返回值会出现在 toString 方法返回的字符串中,表示对象的类型。也就是说,这个属性可用于定制 [object Object] 或 [object Array] 中 object 后面的字符串。

;({ [Symbol.toStringTag]: 'Foo' }.toString())
// "[object Foo]"

ES6 新增了大量内置对象的 Symbol.toStringTag 属性值,这里略。

Symbol.unscopables

对象的 Symbol.unscopables 属性指向一个对象,指定了使用 with 关键字时哪些属性会被 with 环境排除。

Array.prototype[Symbol.unscopables]
/**
 * {
 *   copyWithin: true,
 *   entries: true,
 *   fill: true,
 *   find: true,
 *   findIndex: true,
 *   includes: true,
 *   keys: true
 * }
 */

上面代码说明,数组有 7 个属性会被 with 命令排除。

示例如下:

class MyClass {
  foo() {
    return 1
  }
  get [Symbol.unscopables]() {
    return { foo: true }
  }
}
var foo = function() {
  return 2
}
with (MyClass.prototype) {
  foo() // 2
}

上面的代码通过指定 Symbol.unscopables 属性使 with 语法块不会在当前作用域寻找 foo 属性,即 foo 将指向外层作用域的变量。

Set 数据结构

Set

ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复。

Set 基本用法

Set 本身是一个构造函数,用来生成 Set 数据结构。Set 函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。

const set = new Set([1, 2, 3, 4, 4])

借助 Set 成员的唯一性,可以实现数组去重:

;[...new Set(array)]
Array.from(new Set(array))

Set 内部判断两个值是否相同时使用的算法叫作 “Same-value equality”,它类似于精确相等运算符(===),主要的区别是 NaN 等于自身,而精确相等运算符认为 NaN 不等于自身。在 Set 内部,两个 NaN 是相等的。

Set 属性和方法

Set 结构的实例有以下属性:

  • Set.prototype.constructor:构造函数,默认就是 Set 函数。
  • Set.prototype.size:返回 Set 实例的成员总数。

Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。先介绍 4 个操作方法:

  • add(value):添加某个值,返回 Set 结构本身。
  • delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • has(value):返回一个布尔值,表示参数是否为 Set 的成员。
  • clear():清除所有成员,没有返回值。

add 方法返回 Set 结构本身,所以可以链式调用:

new Set().add(1).add(2)

Set 遍历操作

Set 结构的实例有 4 个遍历方法,可用于遍历成员:

  • keys():返回键名的遍历器。
  • values():返回键值的遍历器。
  • entries():返回键值对的遍历器。
  • forEach():使用回调函数遍历每个成员。

需要特别指出的是:Set 的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。

keys 方法、values 方法、entries 方法返回的都是遍历器对象。由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以 keys 方法和 values 方法的行为完全一致。

let set = new Set(['red', 'green', 'blue'])
for (let x of set.values()) {
  console.log(x)
}

Set 结构的实例默认可遍历,其默认遍历器生成函数就是它的 values 方法。

Set.prototype[Symbol.iterator] === Set.prototype.values // true

这意味着,可以省略 values 方法,直接用 for...of 循环遍历 Set:

let set = new Set(['red', 'green', 'blue'])
for (let x of set) {
  console.log(x)
}

Set 结构实例的 forEach 方法用于对每个成员执行某种操作,没有返回值。该函数的参数依次为键值、键名、集合本身。还可传入第二个参数表示绑定的 this 对象。

let set = new Set([1, 2, 3])
set.forEach((value, key) => console.log(value * 2))

扩展运算符(...)内部使用 for...of 循环,所以也可以用于 Set 结构。

let set = new Set([1, 2, 3])
set = new Set([...set].map(x => x * 2))
// 返回 Set 结构:{2, 4, 6}

let set = new Set([1, 2, 3, 4, 5])
set = new Set([...set].filter(x => x % 2 == 0))
// 返回 Set 结构:{2, 4}

数组的 map 和 filter 方法也可以用于 Set。因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。

let a = new Set([1, 2, 3])
let b = new Set([2, 3, 4])

//并集
let union = new Set([...a, ...b]) // set {1, 2, 3, 4}
//交集
let intersect = new Set([...a].filter(x => b.has(x))) // set {2, 3}
//差集
let difference = new Set([...a].filter(x => !b.has(x))) // set {1}

目前没有直接的方法在遍历操作中同步改变原来的 Set 结构,但有两种变通方法。一种是利用原 Set 结构映射出一个新的结构,然后赋值给原来的 Set 结构;另一种是利用 Array.from 方法

// 方法一
let set = new Set([1, 2, 3])
set = new Set([...set].map(val => val * 2))
// set { 2, 4, 6 }

// 方法二
let set = new Set([1, 2, 3])
set = new Set(Array.from(set, val => val * 2))
// set { 2, 4, 6 }

WeakSet

WeakSet 含义

WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别:

  • WeakSet 的成员只能是对象,而不能是其他类型的值
  • WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象是否还存在于 WeakSet 之中。

垃圾回收机制依赖引用计数,如果一个值的引用次数不为 0,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。

WeakSet 里面的引用都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。

WeakSet 的成员是不适合引用的,因为它会随时消失。另外,WeakSet 内部有多少个成员取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定WeakSet 不可遍历

WeakSet 语法

WeakSet 是一个构造函数,可以使用 new 命令创建 WeakSet 数据结构。

同 Set 一样,WeakSet 函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。需要注意:成为 WeakSet 的成员的是数组的成员,而不是数组本身。这意味着,数组的成员只能是对象。

WeakSet 结构有以下 3 个方法:

  • WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
  • WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员。
  • WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例中。

WeakSet 没有 size 属性,没有办法遍历其成员。

WeakSet 的一个用处是储存 DOM 节点,而不用担心这些节点从文档移除时会引发内存泄漏。举个栗子:

const foos = new WeakSet()
class Foo {
  constructor() {
    foos.add(this)
  }
  method() {
    if (!foos.has(this)) {
      throw new TypeError('Foo.prototype.method 只能在Foo的实例上调用!')
    }
  }
}

上面的代码保证了 Foo 的实例方法只能在 Foo 的实例上调用。同时,数组 foos 对实例的引用不会被计入内存回收机制,所以删除实例的时候不用考虑 foos,也不会出现内存泄漏。

Map 数据结构

Map

Map 基本用法

JavaScript 的对象(Object)本质上是键值对的集合(Hash 结构),但是只能用字符串作为键,这给它的使用带来了很大的限制。

const data = {}
const element = document.getElementById('myDiv')
data[element] = 'metadata'
data['[object HTMLDivElement]'] // "metadata"

上面代码将一个 DOM 节点作为对象 data 的键,由于对象只接受字符串作为键名,所以 element 被自动转为字符串 [Object HTMLDivElement]

ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果需要“键值对”的数据结构,Map 比 Object 更合适。

const m = new Map()
const o = { p: 'Hello World' }
m.set(o, 'content')
m.get(o) // "content"
m.has(o) // true
m.delete(o) // true

作为构造函数,Map 也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。

const map = new Map([['name', '张三'], ['title', 'Author']])
map.size // 2
map.has('name') // true
map.get('name') // "张三"

Map 构造函数接受数组作为参数,实际上执行的是下面的算法:

const items = [['name', '张三'], ['title', 'Author']]
const map = new Map()
items.forEach(([key, value]) => map.set(key, value))

事实上,任何具有 Iterator 接口且每个成员都是一个双元素数组的数据结构都可以当作 Map 构造函数的参数。也就是说,Set 和 Map 都可以用来生成新的 Map。

// set 生成 map
const set = new Set([['foo', 1], ['bar', 2]])
const m1 = new Map(set)

// map 生成 map
const m2 = new Map([['baz', 3]])
const m3 = new Map(m2)

两点注意事项:

  • 如果对同一个键多次赋值,后面的值将覆盖前面的值。
  • 只有对同一个对象的引用,Map 结构才将其视为同一个键。
const map = new Map()
map.set(['a'], 555)
map.get(['a']) // undefined

上面的 set 和 get 方法表面上是针对同一个键,实际上却是两个值,内存地址是不一样的,因此 get 方法无法读取该键,返回 undefined。

Map 的键实际上是和内存地址绑定的,只要内存地址不一样,就视为两个键。如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 就将其视为一个键,包括 0 和 -0。另外,虽然 NaN 不严格等于自身,但 Map 将其视为同一个键。

Map 属性和方法

Map 结构的 size 属性返回 Map 结构的成员总数。Map 结构的操作方法如下:

  • set(key, value):set 方法设置 key 所对应的键值,然后返回整个 Map 结构。如果 key 已经有值,则键值会被更新,否则就新生成该键。可以链式调用。
  • get(key):get 方法读取 key 对应的键值,如果找不到 key,则返回 undefined。
  • has(key):has 方法返回一个布尔值,表示某个键是否在 Map 数据结构中。
  • delete(key):delete 方法删除某个键,返回 true。如果删除失败,则返回 false。
  • clear():clear 方法清除所有成员,没有返回值。

Map 遍历操作

Map 原生提供了 3 个遍历器生成函数和 1 个遍历方法。

  • keys():返回键名的遍历器。
  • values():返回键值的遍历器。
  • entries():返回所有成员的遍历器。
  • forEach():遍历 Map 的所有成员。

Map 结构的默认遍历器接口(Symbol.iterator 属性)就是 entries 方法。

map[Symbol.iterator] === map.entries // true

const map = new Map([[1, 'one'], [2, 'two']])
;[...map] // [[1,'one'], [2, 'two']

WeakMap

WeakMap 含义

WeakMap 结构与 Map 结构类似,也用于生成键值对的集合。WeakMap 与 Map 的区别有以下两点:

  • WeakMap 只接受对象作为键名(null 除外),不接受其他类型的值作为键名。
  • WeakMap 的键名所指向的对象不计入垃圾回收机制。

类似于 WeakSet,WeakMap 的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。

WeakMap 的专用场景就是它的键所对应的对象可能会在将来消失的场景。WeakMap 结构有助于防止内存泄漏。

const wm = new WeakMap()
const element = document.getElementById('example')
wm.set(element, 'some information')
wm.get(element) // "some information"

上面的 DOM 节点对象的引用计数是 1,而不是 2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。WeakMap 保存的这个键值对也会自动消失。

需要注意:WeakMap 弱引用的只是键名而不是键值。键值依然是正常引用的

WeakMap 语法

WeakMap 与 Map 在 API 上的区别主要有两个:

一是没有遍历操作(即没有 key()、values() 和 entries() 方法),也没有 size 属性。因为没有办法列出所有键名,某个键名是否存在完全不可预测,和垃圾回收机制是否运行相关。

二是无法清空,即不支持 clear 方法。因此,WeakMap 只有 4 个方法可用:get()、set()、has()、delete()。

WeakMap 用途

WeakMap 应用的典型场景就是以 DOM 节点作为键名的场景:

let myElement = document.getElementById('logo')
let myWeakmap = new WeakMap()
myWeakmap.set(myElement, { timesClicked: 0 })
myElement.addEventListener(
  'click',
  function() {
    let logoData = myWeakmap.get(myElement)
    logoData.timesClicked++
  },
  false
)

上面的代码中,myElement 是一个 DOM 节点,每当发生 click 事件就更新一下状态。状态作为键值放在 WeakMap 里,对应的键名就是 myElement。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

注册监听事件的 listener 对象很适合用 WeakMap 来实现:

const listener = new WeakMap()
listener.set(element, handler)
element.addEventListener('click', listener.get(element), false)

上面的代码中,监听函数放在 WeakMap 里面。一旦 DOM 对象消失,与它绑定的监听函数也会自动消失。

WeakMap 的另一个用处是部署私有属性:

const _counter = new WeakMap()
const _action = new WeakMap()
class Countdown {
  constructor(counter, action) {
    _counter.set(this, counter)
    _action.set(this, action)
  }
  dec() {
    let counter = _counter.get(this)
    if (counter < 1) return
    counter--
    _counter.set(this, counter)
    if (counter === 0) {
      _action.get(this)()
    }
  }
}

const c = new Countdown(2, () => console.log('DONE'))
c.dec()
c.dec() // 'DONE'

上面的代码中,Countdown 类的两个内部属性 _counter 和 _action 是实例的弱引用,如果删除实例,它们也会随之消失,不会造成内存泄漏。

图片加解密二三事

近来公司新项目管理后台需要做图片上传并加解密功能,加密在服务端进行,加密成功后返回加密后图片地址,后台负责解密在线图片然后预览,折腾一天,此中曲折,闲做记录。

高级加密标准 AES

高级加密标准(Advanced Encryption Standard: AES)是美国国家标准与技术研究院(NIST)在 2001 年建立了电子数据的加密规范。它是一种分组加密标准,每个加密块大小为 128 位,允许的密钥长度为 128、192 和 256 位。

AES 加密有 ECB、CBC、CFB 和 OFB 多种加密模式,各种模式功用各不同。

密码学中,分组(block)密码的工作模式(mode of operation)允许使用同一个分组密码密钥对多于一块的数据进行加密,并保证其安全性。分组密码自身只能加密长度等于密码分组长度的单块数据,若要加密变长数据,则数据必须先被划分为一些单独的密码块。通常而言,最后一块数据也需要使用合适填充方式将数据扩展到匹配密码块大小的长度。一种工作模式描述了加密每一数据块的过程,并常常使用基于一个通常称为初始化向量的附加输入值以进行随机化,以保证安全。

ECB 模式

ECB 模式(电子密码本模式:Electronic codebook)是最简单的块密码加密模式,加密前根据加密块大小(如 AES 为 128 位)分成若干块,之后将每块使用相同的密钥单独加密,解密同理。

ECB 模式最大的缺点是相同的明文块会被加密成相同的密文块,这种方法在某些环境下不能提供严格的数据保密性。

ECB 加密

ECB 解密

CBC 模式

CBC 模式(密码分组链接:Cipher-block chaining)对于每个待加密的密码块在加密前会先与前一个密码块的密文异或然后再用加密器加密。在这种方法中,每个密文块都依赖于它前面的所有明文块。同时,为了保证每条消息的唯一性,第一个明文块与一个叫 初始化向量 的数据块异或。

CBC 加密

CBC 解密

CBC 是最为常用的工作模式。CBC 模式相比 ECB 有更高的保密性,但由于对每个数据块的加密依赖与前一个数据块的加密所以加密无法并行。与 ECB 一样在加密前需要对数据进行填充,不是很适合对流数据进行加密。

加密时,明文中的微小改变会导致其后的全部密文块发生改变,而在解密时,从两个邻接的密文块中即可得到一个明文块。因此,解密过程可以被并行化,而解密时,密文中一位的改变只会导致其对应的明文块完全改变和下一个明文块中对应位发生改变,不会影响到其它明文的内容。

CFB 模式

CFB 模式(密文反馈:Cipher feedback)模式类似于 CBC,可以将块密码变为自同步的流密码;工作过程亦非常相似,CFB 的解密过程几乎就是颠倒的 CBC 的加密过程。

CFB 加密

CFB 解密

注意:CFB、OFB 和 CTR 模式中解密也都是用的加密器而非解密器。 CFB 的加密工作分为两部分:

  1. 将一前段加密得到的密文再加密;
  2. 将第 1 步加密得到的数据与当前段的明文异或。

由于加密流程和解密流程中被块加密器加密的数据是前一段密文,因此即使明文数据的长度不是加密块大小的整数倍也是不需要填充的,这保证了数据长度在加密前后是相同的。

与 CBC 相似,明文的改变会影响接下来所有的密文,因此加密过程不能并行化;而同样的,与 CBC 类似,解密过程是可以并行化的。在解密时,密文中一位数据的改变仅会影响两个明文块:对应明文块中的一位数据与下一块中全部的数据,而之后的数据将恢复正常。

OFB 模式

OFB 模式(输出反馈:Output feedback)是先用块加密器生成密钥流(Keystream),然后再将密钥流与明文流异或得到密文流,解密是先用块加密器生成密钥流,再将密钥流与密文流异或得到明文,由于异或操作的对称性所以加密和解密的流程是完全一样的。

OFB 加密

OFB 解密

每个使用 OFB 的输出块与其前面所有的输出块相关,因此不能并行化处理。然而,由于明文和密文只在最终的异或过程中使用,因此可以事先对 IV 进行加密,最后并行的将明文或密文进行并行的异或处理。

采坑

图片上传后服务端采用的是 AES-256-CBC 加密方式,故此后台也须采用同样的解密方式。通过创建 XMLHttpRequest 请求访问加密图片链接,并设置 responseType 为 arraybuffer 便可得到加密后的图片流,然后将流转换为 base64,采用 crypto-js 解密,将解密后的流重新转为 base64 图片。

总体过程如下:

  1. 创建 XMLHttpRequest 请求图片流;
  2. 将图片流 utf8 解码后再转换为 base64;
  3. 采用 crypto-js 解密;
  4. 将解密后的流转为 base64 图片。

在将图片流 utf8 解码时踩了坑,一开始 buffer 解码时采用如下方法:

let base64String = String.fromCharCode(...new Uint8Array(buffer))

报错 Uncaught RangeError: Maximum call stack size exceeded

搜索到 stackflow 同款问题:Converting arraybuffer to string : Maximum call stack size exceeded

The error is caused by a limitation in the number of function arguments.

如上所述,报错原因是由于函数参数过长导致。在该问题下找到解决方法:Uint8Array to string in Javascript

let base64String = new TextDecoder('utf-8').decode(buffer)

TextEncoder and TextDecoder from the Encoding standard, which is polyfilled by the stringencoding library, converts between strings and ArrayBuffers。

查询 MDN web docs 对 TextDecoder 文档说明:

The TextDecoder interface represents a decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points.

大功告成

完整图片解密代码如下:

const CryptoJS = require('crypto-js')

let key = '95362058623512345678901234567890'
let iv = '0473bd1234567890'

key = CryptoJS.enc.Utf8.parse(key)
iv = CryptoJS.enc.Utf8.parse(iv)

export function decrypt(url) {
  if (!url) return
  return new Promise(resolve => {
    let xhr = new XMLHttpRequest()
    xhr.open('GET', url, true)
    xhr.responseType = 'arraybuffer'
    xhr.setRequestHeader('Access-Control-Allow-Origin', '*')
    xhr.onload = () => {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          let base64Img = process(xhr.response)
          resolve(base64Img)
        }
      }
    }
    xhr.send()
  })
}

function process(buffer) {
  // 将 buffer 转换为base64
  let view = new TextDecoder('utf-8').decode(buffer)
  let base64String = view.toString(CryptoJS.enc.Base64)
  // 解密
  let decryptedData = CryptoJS.AES.decrypt(base64String, key, {
    iv: iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  })
  // 把解密后的对象再转为 base64 编码
  let d64 = decryptedData.toString(CryptoJS.enc.Base64)
  let imgSrc = 'data:image/png;base64,' + d64
  return imgSrc
}

友链

静かな森

link: https://innei.ren
cover: https://chanshiyu.com/yoi/friend/innei1.jpg
avatar: https://chanshiyu.com/yoi/friend/innei2.jpg

白猫

link: https://2heng.xin
cover: https://chanshiyu.com/yoi/friend/2heng1.jpg
avatar: https://chanshiyu.com/yoi/friend/2heng2.jpg

木子

link: https://blog.k8s.li
cover: https://chanshiyu.com/yoi/friend/501li1.jpg
avatar: https://chanshiyu.com/yoi/friend/501li2.jpg

BD4SUR

link: https://bd4sur.com
cover: https://chanshiyu.com/yoi/friend/mikukonai1.jpg
avatar: https://chanshiyu.com/yoi/friend/mikukonai2.jpg

Eltrac

link: https://guhub.cn
cover: https://chanshiyu.com/yoi/friend/Eltrac1.jpg
avatar: https://chanshiyu.com/yoi/friend/Eltrac2.jpg

芝士部落格

link: https://chee5e.space
cover: https://chanshiyu.com/yoi/friend/vensing1.jpg
avatar: https://chanshiyu.com/yoi/friend/vensing2.jpg

HONG

link: https://hongweblog.com
cover: https://chanshiyu.com/yoi/friend/hongweblog1.jpg
avatar: https://chanshiyu.com/yoi/friend/hongweblog2.jpg

Makito

link: https://blog.keep.moe
cover: https://chanshiyu.com/yoi/friend/keep1.png
avatar: https://chanshiyu.com/yoi/friend/keep2.jpg

椎咲良田

link: https://sanshiliuxiao.github.io/
cover: https://chanshiyu.com/yoi/friend/sanshiliuxiao1.jpg
avatar: https://chanshiyu.com/yoi/friend/sanshiliuxiao2.jpg

惶心

link: https://huangxin.dev
cover: https://chanshiyu.com/yoi/friend/justhx1.jpg
avatar: https://chanshiyu.com/yoi/friend/justhx2.jpg

梓喵出没

link: https://www.azimiao.com
cover: https://chanshiyu.com/yoi/friend/azimiao1.png
avatar: https://chanshiyu.com/yoi/friend/azimiao2.jpeg

月子喵

link: https://haozi.moe
cover: https://chanshiyu.com/yoi/friend/haozi1.jpg
avatar: https://chanshiyu.com/yoi/friend/haozi2.jpg

白洛嘉

link: https://phenol-phthalein.info
cover: https://chanshiyu.com/yoi/friend/phenol1.jpg
avatar: https://chanshiyu.com/yoi/friend/phenol2.jpg

维基萌

link: https://www.wikimoe.com
cover: https://chanshiyu.com/yoi/friend/wikimoe1.png
avatar: https://chanshiyu.com/yoi/friend/wikimoe2.jpg

七夏浅笑

link: https://www.julydate.com
cover: https://chanshiyu.com/yoi/friend/julydate1.jpg
avatar: https://chanshiyu.com/yoi/friend/julydate2.jpg

青空之蓝

link: https://ixk.me
cover: https://chanshiyu.com/yoi/friend/ixk1.jpg
avatar: https://chanshiyu.com/yoi/friend/ixk2.jpg

星空未屿

link: https://www.clrxx.com
cover: https://chanshiyu.com/yoi/friend/clrxx1.jpg
avatar: https://chanshiyu.com/yoi/friend/clrxx2.jpg

PRIN

link: https://printempw.github.io
cover: https://chanshiyu.com/yoi/friend/blessing1.png
avatar: https://chanshiyu.com/yoi/friend/blessing2.jpg

羽忆江南

link: https://yyjn.org
cover: https://chanshiyu.com/yoi/friend/yyjner1.jpg
avatar: https://chanshiyu.com/yoi/friend/yyjner2.jpg

明日が来ると

link: https://kiseki.blog
cover: https://chanshiyu.com/yoi/friend/asuhe0.jpg
avatar: https://chanshiyu.com/yoi/friend/asuhe2.jpg

云游君

link: https://www.yunyoujun.cn
cover: https://chanshiyu.com/yoi/friend/yunyoujun1.jpg
avatar: https://chanshiyu.com/yoi/friend/yunyoujun2.jpg

初之音

link: https://www.himiku.com
cover: https://chanshiyu.com/yoi/friend/himiku1.jpg
avatar: https://chanshiyu.com/yoi/friend/himiku2.jpg

轻音部

link: https://www.myeriri.com
cover: https://chanshiyu.com/yoi/friend/myeriri1.jpg
avatar: https://chanshiyu.com/yoi/friend/myeriri2.png

ChrAlpha

link: https://chralpha.com
cover: https://chanshiyu.com/yoi/friend/chralpha1.jpg
avatar: https://chanshiyu.com/yoi/friend/chralpha2.jpg

蚊子

link: https://qwq.moe
cover: https://chanshiyu.com/yoi/friend/qwq1.jpg
avatar: https://chanshiyu.com/yoi/friend/qwq2.jpg

苹果酱

link: https://kirimasharo.com
cover: https://chanshiyu.com/yoi/friend/syaro1.jpg
avatar: https://chanshiyu.com/yoi/friend/syaro2.jpg

东方幻梦

link: https://blog.badapple.pro
cover: https://chanshiyu.com/yoi/friend/badapple1.jpg
avatar: https://chanshiyu.com/yoi/friend/badapple2.jpg

Ukenn

link: https://blog.ukenn.top
cover: https://chanshiyu.com/yoi/friend/Ukenn1.jpg
avatar: https://chanshiyu.com/yoi/friend/Ukenn2.jpg

极束梦想

link: https://www.imsle.com
cover: https://chanshiyu.com/yoi/friend/imsle1.jpg
avatar: https://chanshiyu.com/yoi/friend/imsle2.jpg

Raptazure

link: https://raptazure.github.io
cover: https://chanshiyu.com/yoi/friend/raptazure1.jpg
avatar: https://chanshiyu.com/yoi/friend/raptazure2.jpg

非科学の河童

link: https://bkryofu.xyz
cover: https://chanshiyu.com/yoi/friend/kawashiros1.jpg
avatar: https://chanshiyu.com/yoi/friend/kawashiros2.jpg

夏·祈·楓

link: https://flymc.cc
cover: https://chanshiyu.com/yoi/friend/flymc1.jpg
avatar: https://chanshiyu.com/yoi/friend/flymc2.jpg

翠翠

link: https://idealclover.top
cover: https://chanshiyu.com/yoi/friend/idealclover2.jpg
avatar: https://chanshiyu.com/yoi/friend/idealclover1.jpg

TonyHe

link: https://ouorz.com
cover: https://chanshiyu.com/yoi/friend/ouorz1.jpg
avatar: https://chanshiyu.com/yoi/friend/ouorz2.jpg

稗田千秋

link: https://wind.moe
cover: https://chanshiyu.com/yoi/friend/wind1.png
avatar: https://chanshiyu.com/yoi/friend/wind2.jpg

诺诺

link: https://www.rmolives.com
cover: https://chanshiyu.com/yoi/friend/rmolives1.jpg
avatar: https://chanshiyu.com/yoi/friend/rmolives2.jpg

阡陌

link: https://www.weiaini.xyz
cover: https://chanshiyu.com/yoi/friend/weiaini1.jpg
avatar: https://chanshiyu.com/yoi/friend/weiaini2.jpg

青花鱼

link: https://zankyo.cc
cover: https://chanshiyu.com/yoi/friend/zankyo1.jpg
avatar: https://chanshiyu.com/yoi/friend/zankyo2.jpg

Sakitami

link: https://blog.skihome.xyz
cover: https://chanshiyu.com/yoi/friend/sakitami1.jpg
avatar: https://chanshiyu.com/yoi/friend/sakitami2.jpg

時雨

link: https://dearain.cn
cover: https://chanshiyu.com/yoi/friend/shiyu1.jpg
avatar: https://chanshiyu.com/yoi/friend/shiyu2.jpg

亿林

link: https://minemine.cc
cover: https://chanshiyu.com/yoi/friend/minemine1.jpg
avatar: https://chanshiyu.com/yoi/friend/minemine2.jpeg

Hans362

link: https://blog.hans362.cn
cover: https://chanshiyu.com/yoi/friend/hans3621.jpg
avatar: https://chanshiyu.com/yoi/friend/hans3622.jpg

⑨BIE

link: https://9bie.org
cover: https://chanshiyu.com/yoi/friend/ero1.jpg
avatar: https://chanshiyu.com/yoi/friend/ero2.jpeg

Cat

link: https://flandre-scarlet.moe/blog
cover: https://chanshiyu.com/yoi/friend/scarlet1.jpg
avatar: https://chanshiyu.com/yoi/friend/scarlet2.jpg

指尖小屋

link: https://fasty97.top
cover: https://chanshiyu.com/yoi/friend/fasty971.jpg
avatar: https://chanshiyu.com/yoi/friend/fasty972.jpg

Gitleaf 主题

摸鱼一周摸了个 Github Style 博客主题,本取名渣随便取了个名字 Gitleaf ,有兴趣来个 Star 么~

技术栈:Github Api + Github Pages + Github Style + Gitalk

域名丢失

博客域名三月份就要过期了,上 Godaddy 准备续费,然后发现域名不见了!翻了个天都没找到!然后和客服用英语尬聊了一个多小时,上传证件准备找回,找不回就只能等死吧😫

秘技!JS 正则技巧

常言道不学正则,无成大事。正则表达式一直是我的弱项,工作中需要正则都是棘手万分,能够优雅解决的问题最后都是一笔烂账,对正则虽避之不及却又无法割舍,痛之又痛。

何为正则?一句话总结:正则是匹配模式,要么匹配字符,要么匹配位置

字符匹配

模糊匹配

正则除了精确匹配,还能实现模糊匹配,模糊匹配又分为横向模糊和纵向模糊。

横向模糊匹配

横向模糊指的是,一个正则可匹配的字符串的长度不是固定的。其实现方式是使用量词,譬如 {m, n},表示连续出现最少 m 次,最多 n 次。

const regex = /ab{2,4}c/g
const string = 'abc abbc abbbc abbbbc abbbbbc'
console.log(string.match(regex)) // ["abbc", "abbbc", "abbbbc"]

正则 g 修饰符表示全局匹配,强调“所有”而不是“第一个”。

// 无全局修饰符的情况
const regex = /ab{2,4}c/
const string = 'abc abbc abbbc abbbbc abbbbbc'
console.log(string.match(regex))
// ["abbc", index: 4, input: "abc abbc abbbc abbbbc abbbbbc", groups: undefined]

纵向模糊匹配

纵向模糊指的是,一个正则匹配的字符串,具体到某一位字符时,它可以不是某个确定的字符。其实现方式是使用字符组,譬如 [abc],表示该字符是可以字符 "a"、"b"、"c" 中的任何一个。

const regex = /a[123]b/g
const string = 'a0b a1b a2b a3b a4b'
console.log(string.match(regex)) // ["a1b", "a2b", "a3b"]

字符组

虽然称为字符组,但匹配的其实只是一个字符。譬如字符组 [abc] 只是匹配一个字符。字符组有范围表示法、排除法和简写形式。

范围表示法

字符组 [0-9a-zA-Z] 表示数字、大小写字母中任意一个字符。由于连字符"-"有特殊含义,所以要匹配 "a"、"-"、"c" 中的任何一个字符,可以写成如下形式:[-az]、[az-]、[a\-z],连字符要么开头,要么结尾,要么转义。

排除字符组

排除字符组(反义字符组) 表示是一个除 "a"、"b"、"c"之外的任意一个字 符。字符组的第一位放 ^(脱字符),表示求反。^ 可以配合范围表示法使用,如 。

简写形式

正则简写形式如下:

字符组 含义
\d [0-9],表示数字
\D [^0-9],表示非数字
\w [0-9a-zA-Z_],表示数字、大小写字符和下划线
\W [^0-9a-za-z_],表示非单词字符
\s [ \t\v\n\r\f],表示空白符
\S [^ \t\v\n\r\f],表示非空白符
. 通配符

需要注意:[ \t\v\n\r\f] 分别表示空白符、水平制表符、垂直制表符、换行符、回车符、换页符。

通配符 . 可以表示几乎任意字符。换行符、回车符、行分隔符和段分隔符除外。如果想要匹配任意字符,可以使用组合写法:[\d\D][\w\W][\s\S][^] 中任何的一个。

量词

简写形式

量词 含义
{m, n} 表示出现 m 到 n 次
{m,} 至少出现 m 次
{m} 等价 {m, m},表示出现 m 次
? 等价 {0, 1},表示出现或不出现
+ 等价 {1,},表示至少出现 1 次
* 等价 {0,},表示出现任意次

贪婪匹配与惰性匹配

贪婪匹配会尽可能多的匹配,表现如下:

const regex = /\d{2,5}/g
const string = '123 1234 12345 123456'
console.log(string.match(regex))
// ["123", "1234", "12345", "12345"]

通过在量词后面加 ? 实现惰性匹配,惰性匹配会尽可能少的匹配,表现如下:

const regex = /\d{2,5}?/g
const string = '123 1234 12345 123456'
console.log(string.match(regex))
// ["12", "12", "34", "12", "34", "12", "34", "56"]

多选分支

多选分支可以支持多个子模式任选其一。具体形式如下:(p1|p2|p3),其中 p1、p2 和 p3 是子模式,用 |(管道符)分隔,表示其中任何之一。需要注意:多选分支是从左到右惰性匹配的,前面匹配成功之后后面的模式便不再尝试。可以通过更改子模式的顺序来改变匹配的结果。

const regex = /good|goodbye/g
const string = 'goodbye'
console.log(string.match(regex))
// ["good"]

实例应用

匹配文件路径

文件路径格式如 盘符:\文件夹\文件夹\文件夹\。匹配符盘:[a-zA-Z]:\\。匹配文件名或文件夹名,不能包含一些特殊字符,需要排除字符组 来表示合法字符,且文件名或文件夹名不能为空,至少有一个字符,需要使用量词 +。文件夹可以出现任意次,最后可能是文件而不是文件夹,不需要带 \\

const regex = /^[a-zA-Z]:\\([^\\:*<>|"?\r\n/]+\\)*([^\\:*<>|"?\r\n/]+)?$/
console.log(regex.test('F:\\study\\regular expression.pdf'))

匹配 id

const regex = /id=".*"/
const string = '<div id="container" class="main"></div>'
// id="container" class="main"

量词 . 是通配符,可以匹配双引号,同时是贪婪匹配,所以出错。可以将其改造成惰性匹配:

const regex = /id=".*?"/

但以上正则匹配效率低,因为其匹配原理设计”回溯“ 概念,最优解如下:

const regex = /id="[^"]*"/

位置匹配

位置的概念

位置(锚)是相邻字符之间的位置。可以将位置理解成空字符串。在 ES5 中,一共有六个锚:^$\b\B(?=p)(?!p)

  1. ^ 匹配开头,多行匹配则匹配行开头
  2. & 匹配结尾,多行匹配则匹配行结尾
  3. \b 匹配单词边界,即 \w\W^$ 之间的位置
  4. \B 匹配非单词边界
  5. (?=p) 为正向先行断言(positive lookhead),匹配模式 p 前的位置
  6. (?!p) 为负向先行断言(negative lookhead),匹配非 p 前的位置

实例应用

数字千分位分隔符

千分位分隔符的插入位置为三位一组数字的前面,且不能是开头位置。

const result = '123456789'
const regex = /(?!^)(?=(\d{3})+$)/g
console.log(result.replace(regex, ','))
// 123,456,789

密码验证

密码长度 6-12 位,由数字、大小写字母组成,但必须至少包括 2 种字符。首先考虑匹配 6-12 位的数字、大小写字母:

const regex = /^[0-9A-Za-z]{6-12}$/g

然后需要判断至少包含两种字符,有两种解法。

第一种解法:首先判断是否包含数字,正则可以表示如下:

const regex = /(?=.*[0-9])^[0-9A-Za-z]{6-12}$/

重点需要理解 (?=.*[0-9])^,该正则表示开头前的位置,同时也表示开头,因为位置可以表示为空字符串。该正则表示在任意多个字符后有数字。依次类推,如果需要同时包含数组和大写字母可以表示为:

const regex = /(?=.*[0-9])(?=.*[A-Z])^[0-9A-Za-z]{6-12}$/

最终正则可以表示为:

const regex = /((?=.*[0-9])(?=.*[A-Z])|(?=.*[0-9])(?=.*[a-z])|(?=.*[A-Z])(?=.*[a-z]))^[0-9A-Za-z]{6-12}$/

const str1 = '123456'
const str2 = '123456a'
const str3 = 'abcdefgA'
console.log(str1, regex.test(str1)) // false
console.log(str2, regex.test(str2)) // true
console.log(str3, regex.test(str3)) // true

第二种解法:“至少包含两种字符” 表示不能全为数字、大写字母或小写字母,不能全为数字可以表示如下:

const regex = /(?!^[0-9]{6-12}$)^[0-9A-Za-z]{6-12}$/

所以最终正则可以表示为:

const regex = /(?!^[0-9]{6,12}$)(?!^[A-Z]{6,12}$)(?!^[a-z]{6,12}$)^[0-9A-Za-z]{6,12}$/

括号的作用

分组和分支结构

括号提供了分组,用于引用。引用分两种:在 JavaScript 里引用和在正则里引用。分组和分支结构是括号最直接的功能,强调括号内是一个整体,即提供子表达式。

// 分组的情况,强调 ab 是一个整体
const regex1 = /(ab)+/g

// 分支的情况,强调分支结构是一个整体
const regex = /this is (ab|cd)/g

分组引用

使用括号分组,可以进行数据提取和替换操作。以提取数据为例,提取形如 yyyy-mm-dd 日期年月日:

const regex = /(\d{4})-(\d{2})-(\d{2})/g
const date = '2018-01-01'
const regex = /(\d{4})-(\d{2})-(\d{2})/
const date = '2018-01-01'
console.log(regex.exec(date))
// console.log(date.match(regex))
// ["2018-01-01", "2018", "01", "01", index: 0, input: "2018-01-01", groups: undefined]

console.log(RegExp.$1, RegExp.$2, RegExp.$3)
// 2018 01 01

扩展:在 JavaScript 里,execmatch 方法作用基本一致,主要有两点区别:

  1. exec 是 RegExp 类分方法,而 match 是 String 类的方法
  2. exec 只匹配第一个符合的字符串,而 match 行为跟是否配置 g 修饰符有关,在非全局匹配情况下,两者表现一致

此外,括号分组还可方便进行替换操作,如将 yyyy-mm-dd 替换为 dd-mm-yyyy:

const date = '2018-01-31'
const regex = /^(\d{4})-(\d{2})-(\d{2})$/
const result = date.replace(regex, '$3-$2-$1')
console.log(result) // 31-01-2018

// 等价于
const result2 = data.replace(regex, function() {
  return RegExp.$3 + '-' + RegExp.$2 + '-' + RegExp.$1
})

// 等价于
const result3 = data.replace(regex, function(match, year, month, day) {
  return day + '-' + month + '-' + year
})

反向引用

除了在 JavaScript 里引用分组,还可以在正则里引用,即反向引用。举个栗子,以匹配日期为例:

const date1 = '2018-01-31'
const date2 = '2018-01.31'
const regex = /\d{4}(-|\/|\.)\d{2}\1\d{2}/
console.log(regex.test(date1)) // true
console.log(regex.test(date2)) // false

如果出现括号嵌套,则以首次出现的左括号顺序为分组顺序。反向引用有三个 Tips:

  • Tip1:如果出现类似 \10,则表示第 10 个分组而不是 \1 和 0,如果需要表示后者,需要使用非捕获括号,表示成 (?:\1)0 或 \1(?:0)。
  • Tip2:如果引用不存在分组,则只匹配反向引用的字符本身,如 \2 只匹配 2,反斜杠表示转义。
  • Tip3:如果分组后面有量词,则以最后一次捕获的数据为分组。

非捕获括号

之前的例子,括号里的分组或捕获数据,以便后续引用,称之为捕获型分组和捕获型分支。如果只想使用括号原始功能,可以使用非捕获型括号 (?:p)(?:p1|p2|p3)

回溯法原理

回溯法也称试探法,它的基本**是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。这种不断“前进”、不断“回溯”寻找解的方法,就称作“回溯法”。

”回溯法“本质上是深度优先算法。举个栗子,以正则 /ab{1,3}/c 来匹配字符串 'abbc',其匹配流程如下:

正则回溯

图中第 5 步有红颜色,表示匹配不成功。此时 b{1,3} 已经匹配到了 2 个字符 "b",准备尝试第三个时,结果发现接下来的字符是 "c"。那么就认为 b{1,3} 就已经匹配完毕。然后状态又回到之前的状态,最后再用子表达式 c,去匹配字符 "c"。此时整个表达式匹配成功了。图中第 6 步便称为”回溯“。

以上为贪婪匹配情况下的回溯,在惰性匹配中也存在回溯。再举个惰性匹配的栗子:

const string = '12345'
const regex = /^(\d{1,3}?)(\d{1,3})$/
console.log(string.match(regex))
// => ["1234", "12", "2345", index: 0, input: "12345"]

尽管是惰性匹配,但为了整体匹配成功,第一个分组还是会多分配一个字符,其整体匹配流程如下:

惰性正则回溯

此外,分支结构也可视为一种回溯,在当前分支不满足匹配条件时,会切换到另一条分支。

形象类比一下回溯的几种情况:

  • 贪婪量词“试”的策略是:买衣服砍价。价钱太高了,便宜点,不行,再便宜点。
  • 惰性量词“试”的策略是:卖东西加价。给少了,再多给点行不,还有点少啊,再给点。
  • 分支结构“试”的策略是:货比三家。这家不行,换一家吧,还不行,再换。

正则的拆分

结构和操作符

JavaScript 里正则表达式由字符字面量、字符组、量词、锚、分组、选择分支、反向引用等结构组成。

结构 说明
字符字面量 匹配一个具体字符,包括转义与非转义
字符组 匹配一个多种可能的字符
量词 匹配连续出现的字符
匹配一个位置
分组 匹配一个括号整体
选择分支 匹配多个子表达式之一

其中涉及的操作符有:

操作符描述 操作符 优先级
转义符 \ 1
括号和方括号 (...)、(?:...)、(?=...)、(?!...)、[...] 2
量词限定符 {m}、{m,n}、{m,}、?、*、+ 3
位置和序列 ^、$、\元字符、一般字符 4
管道符 ` ` 5

元字符

JavaScript 正则里用到的元字符有 ^、$、.、*、+、?、|、\、/、(、)、[、]、{、}、=、!、:、-,当匹配到上面字符本身时,可以一律转义。

正则的构建

构建正则的平衡法则:

  • 匹配预期的字符串
  • 不匹配非预期的字符串
  • 可读性和可维护性
  • 效率

这里只谈如何改善匹配效率的几种方式:

  1. 使用具体型字符组来代替通配符,来消除回溯
  2. 使用非捕获分组。因为捕获分组需要占用内存来存储捕获分组和分支里的数据
  3. 独立出确定字符,如 a+ 可以修改为 aa*,后者比前者多确定了字符 a。
  4. 提取分支公共部分,如 this|that 修改为 th(:?is|at)
  5. 减少分支数量,如 red|read 修改为 rea?d

正则编程

在 JavsScript 里,关于正则常用的相关 API 有 6 个,其中字符串实例 4 个,正则实例 2 个:

  • String#search
  • String#split
  • String#match
  • String#replace
  • RegExp#test
  • RegExp#exec

字符串实例的 matchsearch 方法,会把字符串转换为正则:

const str = '2018.01.31'
console.log(str.search('.'))
// 0
//需要修改成下列形式之一
console.log(str.search('\\.'))
console.log(str.search(/\./))
// 4

console.log(str.match('.'))
// ["2", index: 0, input: "2018.01.31"]
//需要修改成下列形式之一
console.log(str.match('\\.'))
console.log(str.match(/\./))
// [".", index: 4, input: "2018.01.31"]

字符串的四个方法,每次匹配时,都是从 0 开始的,即 lastIndex 属性始终不变。而正则实例的两个方法 exec、test,当正则是全局匹配时,每一次匹配完成后,都会修改 lastIndex。

Linux 常见命令

最近开始学习 Java 服务端开发,使用 win10 自带的 Hyper-v 虚拟机管理器安装 CentOS7 虚拟机,学习 Linux 基本使用同时进行代码部署,这里记录一些常用 Linux 命令。

命令基本格式

[chan@localhost ~]$ 命令提示符:

  • chan 表示当前登录用户,root 是超级管理员
  • localhost 表示主机名
  • ~ 表示当前目录(家目录),其中超级管理员家目录为 /root,普通用户家目录为 /home/user1
  • $ 表示普通用户提示符,# 表示超级管理员提示符

Linux 命令格式:命令 [选项] [参数],需要注意:个别命令不遵守此规则,选项可以简化,如一些命令 -a 等同于 --all

文件处理命令

目录 描述
/ 根目录
/bin 命令保存目录
/sbin 超级管理员命令保存目录
/boot 启动目录
/dev 设备文件保存目录
/etc 配置文件保存目录
/home 普通用户的家目录
/lib 系统库保存目录
/mnt 系统挂载目录
/media 挂载目录
/tmp 临时目录
/var 系统相关文档目录

存放文件建议放置在家目录(root、home)或者临时目录(tmp)

查询目录内容 ls

选项 描述
-a 显示包括隐藏文件的所有文件
-l 显示详细信息
-h 人性化显示文件大小【配合 -l 食用】
-d 查看目录属性【需加参数】

ls -l 查看详细信息可以查看文件类型和访问权限,如 -rw-r--r--,其中首位代表文件类型,- 文件,d 目录,| 软连接。剩下九位三个一组,分别表示 u 所有者,g 所属组,o 其他人 的访问权限,权限各表示 r 读,w 写,x 执行

  • mkdir:建立目录,选项 -p 递归创建目录
  • rmdir:删除空目录
  • rm -rf:删除文件或目录,选项 -r 删除目录,-f 强制删除
  • cd:切换目录,参数目录表示 ~ 家目录,- 上次目录,.. 上级目录,在 Linux 下,按两下 Tab 可以进行目录补全
  • pwd:查看当前所在目录
  • cp:复制目录,cp [选项] [源文件目录] [目标目录],选项 -r 复制目录,-p 连带文件属性复制,-d 复制链接属性,-a 相当于 -pdr
  • mv:剪切目录,mv [源文件目录或文件] [目标目录],该命令可以用来文件改名

文件搜索命令

  • locate:在后台数据库搜索文件
  • updatedb:更新后台数据库
  • whereis:搜索系统命令所在位置
  • which:搜索命令所在路径及别名
  • find:搜索文件或文件夹

压缩和解压缩命令

Linux 常用压缩格式:.zip、.gz、bz2、.tar.gz、.tar.bz2

  • .zipzip 压缩文件名 源文件 压缩文件;zip -r 压缩文件名 源目录 压缩目录;unzip 压缩文件 解压缩
  • .gzgzip 源文件 压缩文件,源文件不保留;gzip -c 源文件 > 压缩文件 压缩文件,源文件保留;gzip -r 目录 压缩目录下所有子文件,但不能压缩目录;gzip -d 压缩文件gunzip 压缩文件 都可以解压缩文件
  • .bz2bzip2 源文件 压缩文件,源文件不保留;bzip2 -k 源文件 压缩文件,源文件保留,注意 bzip2 命令不能压缩目录

打包命令 tar 可以将目录打包,然后用上述压缩命令进行压缩,解决目录不能压缩的问题。

  • tar -cvf 打包文件名 源文件 打包命令,选项 -c 打包,-v 显示过程,-f 指定打包后的文件名
  • tar -xvf 打包文件吗 解打包,选项 -x 解打包

.tar.gz 是先打包再压缩,使用 tar -zcvf 压缩包.tar.gz 源文件 一键打包压缩;tar -zxvf 压缩包.tar.gz 一键解压缩。同理 -jcvf-jxvf 分别是压缩和解压缩 .tar.bz2 文件。添加 -C 选项可以选择解压缩位置。

关机与重启

shutdown [选项] 时间:选项 -c 取消前一个关机命令,-h 关机,-r 重启

shutdown -r 05:00 & 表示凌晨五点重启,& 表示后台执行。shutdown -r now 立即重启。需要注意服务器一般不能远程关机,只能重启。

其他常见命令

1、停止、屏蔽、启动防火墙服务

systemctl stop firewalld
systemctl mask firewalld
systemctl start iptables

JavaEE 工具

Tomcat

./startup.sh  # 启动
./shutdown.sh # 关闭

vsftpd

systemctl start vsftpd.service # 启动
systemctl stop vsftpd.service # 关闭
systemctl restart vsftpd.service # 重启
systemctl status vsftpd.service # 查看状态

Nginx

/nginx/sbin/nginx -s reload

Godaddy 域名找回记事

现在博客使用的域名是在国外注册商 Godaddy 上购买,至今陪伴这个小站已有近三年。年前收到邮件通知说是域名即将到期,需尽快续费,然后经过自己一通操作,成功将域名搞丢。

缘起

事情缘起听我道来,年前收到 Godaddy 发来的域名过期通知邮件,便第一时间登上 Godaddy 准备续费。但是,自己在续费之前的一通*操作导致了最后的域名丢失后尴尬的局面。

在准备域名续费之前,顺手查看了域名的注册的联系人信息,发现绑定的邮箱是之前使用的网易 163 邮箱,由于这个邮箱是大学时通过手机号码注册,自毕业工作后手机号便不再使用停机了,邮箱也自此不再使用,便想将绑定邮箱修正为自己的 QQ 邮箱,然后直接编辑修改邮箱,出于安全考虑,最后确认修改时 Godaddy 会提示修改信息后是否锁定域名,如果同意域名在未来三个月内将被锁定,无法转入转出,我一看觉得 Godaddy 还是服务挺细心的嘛,并直接同样锁定了!

邮箱修改完之后打算续费,点击续费、加入购物车一气呵成,然后一看购物车傻眼了,一个 .com 域名续费竟要 127,呃么么么……这不是抢劫嘛。取消续费、清空购物车一气呵成,上淘宝一看,.com 域名续费 55,直接下单,然后 PUSH 域名,嗯?好像有什么不对,怎么域名没 PUSH 过去?猛然想起刚刚修改邮箱之后不是将域名锁定了嘛,突然被自己的机智感动到哭 😭,联系客服、退款一气呵成。

考虑到域名锁定三个月,域名将在过期前半个月解锁,便打算先不续费,等域名解锁后再在找淘宝续费服务,直接下线静待域名解锁。

直至半个月前,琢磨域名应该年后已解锁,便登录 Godaddy 打算续费,一上线发现域名不见了!翻遍邮箱没有发现域名变更邮件,这几个月自己也没有处理域名,看来不是被盗号就是被 Godaddy 误删了,紧急联系客服等待域名找回。

域名找回

联系客服

一开始找到线上客服,排队一个多小时后终于接通,不得不说英语真心重要,我这半桶水英语尴尬到不行,谷歌翻译、百度翻译、有道翻译全开,使用浑身解数和客服尬聊了一个小时,客服尝试后也无力解决,直接给我发个网址让我联系技术部找回域名。

认证信息

进入帮助中心,填表找回域名,需要上传证件证明域名注册人信息是本人,网上说护照即可,便直接传护照照片等待回应。两天后收到邮件说是需要手持照片。

第一封邮件

然后又重新上传手持护照的照片,等待回应,三天后收到邮件回复说是护照不包含地址!但是域名注册时是需要填写居住地址的,所以需要能够提供有效住址的证件!说好的用护照就可以找回域名的呢,心累 😭。

第二封邮件

身份证上包含住址信息,看来只能用身份证找回了,由于老外不懂中文,所以需要翻译证件。需要注意的是翻译文件要盖章认证,必须要找老外认可的正规翻译机构。上淘宝花 50 找了个看起来靠谱的翻译服务,隔天拿到盖章的身份证翻译文件,重新填表找回域名。忐忑等待了三天,本已做好了最坏的打算,如果域名不能找回,只好重新注册个域名,欸~好麻烦的样子。直至第三天收到邮件回应。

第三封邮件

看来我好事没少做,域名找回终于看到了曙光。邮件大意是说我不是域名的所有者,但是是域名的注册人。Godaddy 会给我发一封包含交易 ID 和安全代码的邮件提供域名转移。果不其然,几个小时后收到了邮件,按上面的操作登录 Godaddy 进行域名接受,输入交易 ID 和安全代码成功将域名找回,而此时离 3 月 17 号域名过期仅有一周的时间。

域名续费

域名找回后查看域名状态发现还处于锁定状态,可能处于安全考虑,需要 10 天后方才解锁,看来是不能用淘宝的续费服务,只能自己在线续费了,上 Godaddy 一看续费一年 115,好像比之前便宜了点但还是贵得离谱!难怪不少人纷纷逃离 Godaddy 将域名转移到更便宜的 NameSilo,但现在域名无法转移别无选择,只好硬着续费。等待域名解锁后也需要考虑将域名转移,这续费也太贵了。

Java 服务端分层模型

复杂的软件系统都会采用分层的架构设计,分层之后,每一层职责鲜明,整体上降低了系统的耦合性,提高了健壮性。比如常见的:展示层、业务层等,Java 服务端开发亦是如此。

作为刚入门 Java 服务端开发的萌新,开始练习尝试开发一个简易的商场后端,尚未上手便接触不少技术名词,先摸清一个大概脉络框架,再着手实践。

Java 服务端也是采用分层架构,针对每一层,对应对象的职责是不同的,以及层与层之间也需要通信,故而有着不同的“概念”对象。

Java_数据模型

分层领域模型

分层领域模型规约:

  • DO(Data Object):与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
  • DTO(Data Transfer Object):数据传输对象,Service 或 Manager 向外传输的对象。
  • BO(Business Object):业务对象,由 Service 层输出的封装业务逻辑的对象。
  • AO(Application Object):应用对象,在 Web 层与 Service 层之间抽象的复用对象模型,极为贴近展示层,复用度不高。
  • VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。
  • POJO(Plain Ordinary Java Object):POJO 专指只有 setter/getter/toString 的简单类,包括 DO/DTO/BO/VO 等。

领域模型命名规约:

  • 数据对象:xxxDO,xxx 即为数据表名。
  • 数据传输对象:xxxDTO,xxx 为业务领域相关的名称。
  • 展示对象:xxxVO,xxx 一般为网页名称。
  • POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。

PO 和 VO

理解 PO 和 VO 需要先理解 ORM,ORM(Object Relational Mapping 对象关系映射)就是将对象与关系数据库绑定,用对象来表示关系数据。映射需要 Hibernate 和 MyBatis 等框架执行,操作过程中,根据不同的 ORM Framework 编写不同的映射文件,一般是以 xml 方式进行存储,将表与 Javabean 的值对象一一对应。

以前插入一条记录书写形式为:

  • 建立连接
  • 建立操作对象
  • 执行 sql

现在可以如下书写:

  • 读取映射配置
  • javabean 值对象 set 字段名
  • save 操作即可

这么做最基本的好处就是:关系发生改变直接改动映射配置文件即可,不需要到源文件里面去一条条修改语句(主要是 sql 语句)。

在 O/R 映射的世界里,PO(Persisent Object 持久对象)和 VO(Value Object 值对象)是两个基本的概念。PO 与 VO 均由一组属性和属性的 get 和 set 方法组成,结构上没有不同,但是本质上完全不同。

PO 通常对应数据模型(数据库),本身还有部分业务逻辑的处理。可以看成是与数据库中的表相映射的 java 对象。最简单的 PO 就是对应数据库中某个表中的一条记录,多个记录可以用 PO 的集合。PO 中应该不包含任何对数据库的操作。

VO 通常用于业务层之间的数据传递,和 PO 一样也是仅仅包含数据而已。但应是抽象出的业务对象,可以和表对应,也可以不,这根据业务的需要。

区别点:

  1. VO 是用 new 关键字创建,由 GC 回收。PO 则是向数据库中添加新数据时创建,删除数据库中数据时削除的。并且它只能存活在一个数据库连接中,断开连接即被销毁。PO 对象需要实现序列化接口。
  2. VO 是值对象,精确点讲它是业务对象,是存活在业务层的,是业务逻辑使用的,它存活的目的就是为数据提供一个生存的地方。PO 则是有状态的,每个属性代表其当前的状态。它是物理数据的对象表示。使用它,可以使我们的程序与物理数据解耦,并且可以简化对象数据与物理数据之间的转换。
  3. VO 的属性是根据当前业务的不同而不同的,也就是说,它的每一个属性都一一对应当前业务逻辑所需要的数据的名称。PO 的属性是跟数据库表的字段一一对应的。

如果没有 PO 和 VO 的区别,那么数据库表结构的所有字段就一览无余地展示到了前端,给后台安全带来很大的隐患,并且无法在网络传输中剥离冗余信息提高了用户的带宽成本。

DTO,DAO,BO,POJO

DTO(Data Transfer Object 数据传输对象) 指用于展示层与服务层之间的数据传输对象。主要用于远程调用等需要大量传输对象的地方。比如一张表有 100 个字段,那么对应的 PO 就有 100 个属性。但是界面上只要显示 10 个字段,客户端用 WEB service 来获取数据,没有必要把整个 PO 对象传递到客户端,这时就可以用只有这 10 个属性的 DTO 来传递结果到客户端,这样也不会暴露服务端表结构.到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为 VO。

**DAO(Data Access Object 数据访问对象)**是 sun 的一个标准 j2ee 设计模式,这个模式中有个接口就是 DAO,它负持久层的操作。为业务层提供接口。此对象用于访问数据库。通常和 PO 结合使用,DAO 中包含了各种数据库的操作方法。通过它的方法,结合 PO 对数据库进行相关的操作。夹在业务逻辑与数据库资源中间。配合 VO,提供数据库的 CRUD 操作,应当注意 DAO 中应该只关心数据库的 CRUD 操作,而不应掺杂业务逻辑。

**BO(Business Object 业务对象)**封装业务逻辑的 java 对象,通过调用 DAO 方法,结合 PO,VO 进行业务操作。主要作用是把业务逻辑封装为一个对象。这个对象可以包括一个或多个其它的对象。比如一个简历,有教育经历、工作经历、社会关系等等。可以把教育经历对应一个 PO,工作经历对应一个 PO,社会关系对应一个 PO。建立一个对应简历的 BO 对象处理简历,每个 BO 包含这些 PO。这样处理业务逻辑时,我们就可以针对 BO 去处理。

**POJO(Plain Ordinary Java Object)**简单无规则 java 对象,纯的传统意义的 java 对象。就是说在一些 Object/Relation Mapping 工具中,能够做到维护数据库表记录的 persisent object 完全是一个符合 Java Bean 规范的纯 Java 对象,没有增加别的属性和方法。就是最基本的 Java Bean,只有属性字段及 setter 和 getter 方法。

JavaScript 设计模式(Ⅲ)

本篇是《JavaScript 设计模式与开发实践》第二部分读书笔记,总结后 7 种设计模式:模板方法模式、享元模式、职责链模式、中介者模式、装饰者模式、状态模式、适配器模式。

模板方法模式

模板方法模式是一种只需使用继承就可以实现的非常简单的模式。

模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

Coffee or Tea

以下是一个冲泡咖啡或茶的例子,这里将咖啡与茶抽象为“饮料”,冲泡和浸泡抽象为“泡”,牛奶和柠檬抽象为“调料”。

var Beverage = function() {}
Beverage.prototype.boilWater = function() {
  console.log('把水煮沸')
}
Beverage.prototype.brew = function() {} // 空方法,应该由子类重写
Beverage.prototype.pourInCup = function() {} // 空方法,应该由子类重写
Beverage.prototype.addCondiments = function() {} // 空方法,应该由子类重写
Beverage.prototype.init = function() {
  this.boilWater()
  this.brew()
  this.pourInCup()
  this.addCondiments()
}

var Coffee = function() {}
Coffee.prototype = new Beverage()
Coffee.prototype.brew = function() {
  console.log('用沸水冲泡咖啡')
}
Coffee.prototype.pourInCup = function() {
  console.log('把咖啡倒进杯子')
}
Coffee.prototype.addCondiments = function() {
  console.log('加糖和牛奶')
}
var Coffee = new Coffee()
Coffee.init()

var Tea = function() {}
Tea.prototype = new Beverage()
Tea.prototype.brew = function() {
  console.log('用沸水浸泡茶叶')
}
Tea.prototype.pourInCup = function() {
  console.log('把茶倒进杯子')
}
Tea.prototype.addCondiments = function() {
  console.log('加柠檬')
}
var tea = new Tea()
tea.init()

上面例子中,真正的模板方法是 Beverage.prototype.init,它被称为模板方法的原因是,该方法中封装了子类的算法框架,它作为一个算法的模板,指导子类以何种顺序去执行哪些方法。

抽象类

模板方法模式是一种严重依赖抽象类的设计模式。JavaScript 在语言层面并没有提供对抽象类的支持,也很难模拟抽象类的实现,这里以 Java 中的抽象类讨论。

在 Java 中,类分为两种,一种为具体类,另一种为抽象类。具体类可以被实例化,抽象类不能被实例化。抽象类和接口一样可以用于向上转型,把对象的真正类型隐藏在抽象类或者接口之后,这些对象才可以被互相替换使用,这可以让 Java 程序尽量遵守依赖倒置原则。

除了用于向上转型,抽象类也可以表示一种契约。继承了这个抽象类的所有子类都将拥有跟抽象类一致的接口方法,抽象类的主要作用就是为它的子类定义这些公共接口。

抽象方法被声明在抽象类中,抽象方法并没有具体的实现过程,是一些“哑”方法。除了抽象方法之外,如果每个子类中都有一些同样的具体实现方法,那这些方法也可以选择放在抽象类中,这可以节省代码以达到复用的效果,这些方法叫作具体方法。

JavaScript 并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于 JavaScript 是一门“类型模糊”的语言,所以隐藏对象的类型在 JavaScript 中并不重要。另一方面,当在 JavaScript 中使用原型继承来模拟传统的类式继承时,并没有编译器进行任何形式的检查,没有办法保证子类会重写父类中的“抽象方法”。

下面提供两种变通的解决方案。

  • 第 1 种方案是用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求在业务代码中添加一些跟业务逻辑无关的代码。
  • 第 2 种方案是让抽象方法方法直接抛出一个异常,如果因为粗心忘记编写子类方法,那么至少会在程序运行时得到一个错误,这种方式实现简单,缺点是得到错误信息的时间点太靠后。

钩子方法

通过模板方法模式,在父类中封装了子类的算法框架,但如果父类的算法框架并不适用全部子类,那便需要用到钩子方法,放置钩子是隔离变化的一种常见手段。

在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能。

依旧以上面的 Coffee or Tea 代码为例,改写模板方法:

Beverage.prototype.customerWantsCondiments = function() {
  return true // 默认需要调料
}
Beverage.prototype.init = function() {
  this.boilWater()
  this.brew()
  this.pourInCup()
  if (this.customerWantsCondiments()) {
    this.addCondiments()
  }
}

// 省略其他代码...
Coffee.prototype.customerWantsCondiments = function() {
  return window.confirm('请问需要调料吗?')
}

好莱坞原则

好莱坞原则指的是,允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候、以何种方式去使用这些底层组件,高层组件对待底层组件的方式,跟演艺公司对待新人演员一样,都是“别调用我们,我们会调用你”。

模板方法模式是好莱坞原则的一个典型使用场景,它与好莱坞原则的联系非常明显,当我们用模板方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。

除此之外,好莱坞原则还常常应用于其他模式和场景,例如发布-订阅模式和回调函数。

在发布—订阅模式中,发布者会把消息推送给订阅者,取代了原先不断去 fetch 消息的形式。在回调函数中,如 ajax 请求,当数据返回之后,回调函数才被执行,取代了时刻轮询判断是否返回了数据。

真的需要“继承”吗

模板方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序,子类继承父类之后,父类通知子类执行这些方法,好莱坞原则很好地诠释了这种设计技巧,即高层组件调用底层组件。

模板方法模式是为数不多的基于继承的设计模式,但 JavaScript 语言实际上没有提供真正的类式继承,继承是通过对象与对象之间的委托来实现的。

下面改写上面的 Coffee or Tea 的例子:

var Beverage = function(param) {
  var boilWater = function() {
    console.log('把水煮沸')
  }
  var brew =
    param.brew ||
    function() {
      throw new Error('必须传递 brew 方法')
    }
  var pourInCup =
    param.pourInCup ||
    function() {
      throw new Error('必须传递 pourInCup 方法')
    }
  var addCondiments =
    param.addCondiments ||
    function() {
      throw new Error('必须传递 addCondiments 方法')
    }

  var F = function() {}
  F.prototype.init = function() {
    boilWater()
    brew()
    pourInCup()
    addCondiments()
  }
  return F
}

var Coffee = Beverage({
  brew: function() {
    console.log('用沸水冲泡咖啡')
  },
  pourInCup: function() {
    console.log('把咖啡倒进杯子')
  },
  addCondiments: function() {
    console.log('加糖和牛奶')
  }
})

var coffee = new Coffee()
coffee.init()

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。但在 JavaScript 中,很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。

享元模式

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

内部状态与外部状态

享元模式要求将对象的属性划分为内部状态与外部状态。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引:

  • 内部状态存储于对象内部。
  • 内部状态可以被一些对象共享。
  • 内部状态独立于具体的场景,通常不会改变。
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成一个完整对象需要时间,但可以大大减少系统中对象数量,所以说享元模式是一种时间换空间的优化模式。

通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象。使用享元模式的关键是如何区别内部状态和外部状态。可以被对象共享的属性通常被划分为内部状态,而外部状态取决于具体的场景,并根据场景而变化。

这里以文件上传为例,文件上传方式为内部状态,文件名和文件大小为外部状态:

var Upload = function(uploadType) {
  this.uploadType = uploadType
}
Upload.prototype.delFile = function(id) {
  uploadManager.setExternalState(id, this) // 通过管理器设置外部状态
  if (window.confirm('确定要删除该文件吗? ' + this.fileName)) {
    return this.dom.parentNode.removeChild(this.dom)
  }
}

// 上传对象创建工厂,如果内部状态对象已被创建,则直接返回
var UploadFactory = (function() {
  var createdFlyWeightObjs = {}
  return {
    create: function(uploadType) {
      if (createdFlyWeightObjs[uploadType]) {
        return createdFlyWeightObjs[uploadType]
      }
      return (createdFlyWeightObjs[uploadType] = new Upload(uploadType))
    }
  }
})()

// 管理器负责管理外部状态
var uploadManager = (function() {
  var uploadDatabase = {} // 保存所有 uoload 对象外部状态
  return {
    add: function(id, uploadType, fileName, fileSize) {
      var flyWeightObj = UploadFactory.create(uploadType)
      var dom = document.createElement('div')
      dom.innerHTML =
        '<span>文件名称:' +
        fileName +
        ', 文件大小: ' +
        fileSize +
        '</span>' +
        '<button class="delFile">删除</button>'
      dom.querySelector('.delFile').onclick = function() {
        flyWeightObj.delFile(id)
      }
      document.body.appendChild(dom)
      uploadDatabase[id] = { fileName: fileName, fileSize: fileSize, dom: dom }
      return flyWeightObj
    },
    setExternalState: function(id, flyWeightObj) {
      var uploadData = uploadDatabase[id]
      for (var i in uploadData) {
        flyWeightObj[i] = uploadData[i]
      }
    }
  }
})()

var id = 0
window.startUpload = function(uploadType, files) {
  for (var i = 0, file; (file = files[i++]); ) {
    var uploadObj = uploadManager.add(++id, uploadType, file.fileName, file.fileSize)
  }
}

享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,上面代码的比较可以看到,使用了享元模式之后,需要分别多维护一个 factory 对象和一个 manager 对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。

享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

  • 一个程序中使用了大量的相似对象。
  • 由于使用了大量对象,造成很大的内存开销。
  • 对象的大多数状态都可以变为外部状态。
  • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

职责链模式

职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

职责链模式的最大优点:请求发送者只需要知道链中的第一个节点,从而弱化了发送者和一组接收者之间的强联系。

灵活可拆分的职责链节点

这里以购物预付订金为例,根据不同订金获取不同优惠券,如果某个节点不能处理请求,则返回一个特定的字符串 'nextSuccessor' 来表示该请求需要继续往后面传递,这样一来,各个节点之间不再耦合,节点可以灵活拆分和重组。

var order500 = function(orderType, pay, stock) {
  if (orderType === 1 && pay === true) {
    console.log('500元定金预购,得到100优惠券')
  } else {
    return 'nextSuccessor'
  }
}
var order200 = function(orderType, pay, stock) {
  if (orderType === 2 && pay === true) {
    console.log('200元定金预购,得到50优惠券')
  } else {
    return 'nextSuccessor'
  }
}
var orderNormal = function(orderType, pay, stock) {
  if (stock > 0) {
    console.log('普通购买,无优惠券')
  } else {
    console.log('手机库存不足')
  }
}

var Chain = function(fn) {
  this.fn = fn
  this.successor = null
}
// Chain.prototype.setNextSuccessor  指定在链中的下一个节点
Chain.prototype.setNextSuccessor = function(successor) {
  return (this.successor = successor)
}
// Chain.prototype.passRequest  传递请求给某个节点
Chain.prototype.passRequest = function() {
  var ret = this.fn.apply(this, arguments)
  if (ret === 'nextSuccessor') {
    return this.successor && this.successor.passRequest.apply(this.successor, arguments)
  }
  return ret
}

var chainOrder500 = new Chain(order500)
var chainOrder200 = new Chain(order200)
var chainOrderNormal = new Chain(orderNormal)

chainOrder500.setNextSuccessor(chainOrder200)
chainOrder200.setNextSuccessor(chainOrderNormal)

chainOrder500.passRequest(1, true, 500) // 输出:500元定金预购,得到100优惠券
chainOrder500.passRequest(1, false, 0) // 输出:手机库存不足

异步的职责链

上一节的职责链模式中,每个节点函数同步返回一个特定的值 "nextSuccessor" 来表示是否把请求传递给下一个节点。而在现实开发中,经常会遇到一些异步的问题,比如要在节点函数中发起一个 ajax 异步请求,异步请求返回的结果才能决定是否继续在职责链中 passRequest。

这时候让节点函数同步返回 "nextSuccessor" 已经没有意义了,所以要给 Chain 类再增加一个原型方法 Chain.prototype.next,表示手动传递请求给职责链中的下一个节点:

Chain.prototype.next = function() {
  return this.successor && this.successor.passRequest.apply(this.successor, arguments)
}

来看一个异步职责链的例子:

var fn1 = new Chain(function() {
  console.log(1)
  return 'nextSuccessor'
})
var fn2 = new Chain(function() {
  console.log(2)
  var self = this
  setTimeout(function() {
    self.next()
  }, 1000)
})
var fn3 = new Chain(function() {
  console.log(3)
})
fn1.setNextSuccessor(fn2).setNextSuccessor(fn3)
fn1.passRequest()

现在得到了一个特殊的链条,请求在链中的节点里传递,但节点有权利决定什么时候把请求交给下一个节点。

职责链模式的优缺点

职责链模式的最大优点就是解耦了请求发送者和 N 个接收者之间的复杂关系,由于不知道链中的哪个节点可以处理你发出的请求,所以你只需把请求传递给第一个节点即可。

其次,使用了职责链模式之后,链中的节点对象可以灵活地拆分重组。增加或者删除一个节点,或者改变节点在链中的位置都是轻而易举的事情。

职责链模式还有一个优点,那就是可以手动指定起始节点,请求并不是非得从链中的第一个节点开始传递。

但这种模式也并非没有弊端,首先我们不能保证某个请求一定会被链中的节点处理。

另外,职责链模式使得程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点并没有起到实质性的作用,它们的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗。

AOP 实现职责链

在之前的职责链实现中,利用了一个 Chain 类来把普通函数包装成职责链的节点。其实利用 JavaScript 的函数式特性,有一种更加方便的方法来创建职责链。

实现一个 Function.prototype.after 函数,使得第一个函数返回 'nextSuccessor' 时,将请求继续传递给下一个函数。

Function.prototype.after = function(fn) {
  var self = this
  return function() {
    var ret = self.apply(this, arguments)
    if (ret === 'nextSuccessor') {
      return fn.apply(this, arguments)
    }
    return ret
  }
}

var order = order500yuan.after(order200yuan).after(orderNormal)
order(1, true, 500) // 输出:500元定金预购,得到100优惠券

中介者模式

面向对象设计鼓励将行为分布到各个对象中,把对象划分成更小的粒度,有助于增强对象的可复用性,但由于这些细粒度对象之间的联系激增,又有可能会反过来降低它们的可复用性。

中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。

这里以多人组队游戏为例,设置一个中介者管理状态:

function Player(name, teamColor) {
  this.name = name // 角色名字
  this.teamColor = teamColor // 队伍颜色
  this.state = 'alive' // 玩家生存状态
}
Player.prototype.win = function() {
  console.log(this.name + ' won ')
}
Player.prototype.lose = function() {
  console.log(this.name + ' lost')
}
Player.prototype.die = function() {
  this.state = 'dead'
  playerDirector.ReceiveMessage('playerDead', this) // 给中介者发送消息,玩家死亡
}
Player.prototype.remove = function() {
  playerDirector.ReceiveMessage('removePlayer', this) // 给中介者发送消息,移除一个玩家
}
Player.prototype.changeTeam = function(color) {
  playerDirector.ReceiveMessage('changeTeam', this, color) // 给中介者发送消息,玩家换队
}

// 玩家创建工厂
var playerFactory = function(name, teamColor) {
  var newPlayer = new Player(name, teamColor) // 创造一个新的玩家对象
  playerDirector.ReceiveMessage('addPlayer', newPlayer) // 给中介者发送消息,新增玩家
  return newPlayer
}

// 中介者
var playerDirector = (function() {
  var players = {}, // 保存所有玩家
    operations = {} // 中介者可以执行的操作
  /****** 新增一个玩家 *********/
  operations.addPlayer = function(player) {
    var teamColor = player.teamColor // 玩家的队伍颜色
    players[teamColor] = players[teamColor] || [] // 如果该颜色的玩家还没有成立队伍,则新成立一个队伍
    players[teamColor].push(player) // 添加玩家进队伍
  }
  /****** 移除一个玩家 *********/
  operations.removePlayer = function(player) {
    var teamColor = player.teamColor, // 玩家的队伍颜色
      teamPlayers = players[teamColor] || [] //该队伍所有成员
    for (var i = teamPlayers.length - 1; i >= 0; i--) {
      // 遍历删除
      if (teamPlayers[i] === player) {
        teamPlayers.splice(i, 1)
      }
    }
  }
  /******* 玩家换队 **********/
  operations.changeTeam = function(player, newTeamColor) {
    // 玩家换队
    operations.removePlayer(player) // 从原队伍中删除
    player.teamColor = newTeamColor // 改变队伍颜色
    operations.addPlayer(player) // 增加到新队伍中
  }
  operations.playerDead = function(player) {
    // 玩家死亡
    var teamColor = player.teamColor,
      teamPlayers = players[teamColor] // 玩家所在队伍
    var all_dead = true
    for (var i = 0, player; (player = teamPlayers[i++]); ) {
      if (player.state !== 'dead') {
        all_dead = false
        break
      }
    }
    if (all_dead === true) {
      // 全部死亡
      for (var i = 0, player; (player = teamPlayers[i++]); ) {
        player.lose() // 本队所有玩家lose
      }
      for (var color in players) {
        if (color !== teamColor) {
          var teamPlayers = players[color] // 其他队伍的玩家
          for (var i = 0, player; (player = teamPlayers[i++]); ) {
            player.win() // 其他队伍所有玩家win
          }
        }
      }
    }
  }
  var ReceiveMessage = function() {
    // arguments的第一个参数为消息名称
    var message = Array.prototype.shift.call(arguments)
    operations[message].apply(this, arguments)
  }
  return { ReceiveMessage: ReceiveMessage }
})()

中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。如果对象之间的耦合性太高,一个对象发生改变之后,难免会影响到其他的对象,而在中介者模式里,对象之间几乎不知道彼此的存在,它们只能通过中介者对象来互相影响对方。

因此,中介者模式使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。

不过,中介者模式也存在一些缺点。其中,最大的缺点是系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。

装饰者模式

装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不灵活,还会带来许多问题:一方面会导致超类和子类之间存在强耦合性,当超类改变时,子类也会随之改变;另一方面,继承这种功能复用方式通常被称为“白箱复用”,“白箱”是相对可见性而言的,在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性。

使用继承还会带来另外一个问题,在完成一些功能复用的同时,有可能创建出大量的子类,使子类的数量呈爆炸性增长。

为了解决上述问题,可以给对象动态地增加职责,这种方式称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。

传统面向对象语言的装饰者模式

作为一门解释执行的语言,给 JavaScript 中的对象动态添加或者改变职责是一件再简单不过的事情,虽然这种做法改动了对象自身,跟传统定义中的装饰者模式并不一样,但这无疑更符合 JavaScript 的语言特色。

var Plane = function() {}
Plane.prototype.fire = function() {
  console.log('发射普通子弹')
}

var MissileDecorator = function(plane) {
  this.plane = plane
}
MissileDecorator.prototype.fire = function() {
  this.plane.fire()
  console.log('发射导弹')
}

var plane = new Plane()
plane = new MissileDecorator(plane)
plane.fire() // 分别输出:发射普通子弹、发射导弹

这种给对象动态增加职责的方式,并没有真正地改动对象自身,而是将对象放入另一个对象之中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口(fire 方法),当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。

因为装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。

JavaScript 的装饰者模式

JavaScript 语言动态改变对象相当容易,我们可以直接改写对象或者对象的某个方法,并不需要使用“类”来实现装饰者模式:

var plane = {
  fire: function() {
    console.log('发射普通子弹')
  }
}
var missileDecorator = function() {
  console.log('发射导弹')
}
var fire1 = plane.fire
plane.fire = function() {
  fire1()
  missileDecorator()
}
plane.fire() // 分别输出:发射普通子弹、发射导弹

装饰者也是包装器

在《设计模式》成书之前,GoF 原想把装饰者(decorator)模式称为包装器(wrapper)模式。从功能上而言,decorator 能很好地描述这个模式,但从结构上看,wrapper 的说法更加贴切。装饰者模式将一个对象嵌入另一个对象之中,实际上相当于这个对象被另一个对象包装起来,形成一条包装链。请求随着这条链依次传递到所有的对象,每个对象都有处理这条请求的机会。

装饰函数

在 JavaScript 中,几乎一切都是对象,其中函数又被称为一等对象。在 JavaScript 中可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。

一般可以通过保存原引用的方式就可以改写某个函数:

window.onload = function() {
  alert(1)
}
var _onload = window.onload || function() {}
window.onload = function() {
  _onload()
  alert(2)
}

但是这种方式存在以下两个问题:

  • 必须维护中间变量,如果函数的装饰链较长,或者需要装饰的函数变多,这些中间变量的数量也会越来越多
  • this 容易被劫持的问题,如果把 window.onload 换成 document.getElementById 会报错

通过使用 AOP,可以提供一种完美的方法给函数动态添加功能。

Function.prototype.before = function(beforefn) {
  var __self = this // 保存原函数的引用
  return function() {
    // 返回包含了原函数和新函数的"代理"函数
    beforefn.apply(this, arguments)
    return __self.apply(this, arguments) // 执行原函数并返回原函数的执行结果,并且保证 this 不被劫持
  }
}
Function.prototype.after = function(afterfn) {
  var __self = this
  return function() {
    var ret = __self.apply(this, arguments)
    afterfn.apply(this, arguments)
    return ret
  }
}

Function.prototype.before 接受一个函数当作参数,这个函数即为新添加的函数,它装载了新添加的功能代码。接下来把当前的 this 保存起来,这个 this 指向原函数,然后返回一个“代理”函数,这个“代理”函数只是结构上像代理而已,并不承担代理的职责(比如控制对象的访问等)。它的工作是把请求分别转发给新添加的函数和原函数,且负责保证它们的执行顺序,让新添加的函数在原函数之前执行(前置装饰),这样就实现了动态装饰的效果。通过 Function.prototype.apply 来动态传入正确的 this,保证了函数在被装饰之后,this 不会被劫持。

如此一来便可以给 document.getElementById 添加新功能并不会被劫持 this:

document.getElementById = document.getElementById.before(function() {
  alert(1)
})
var button = document.getElementById('button')

window.onload = function() {
  alert(1)
}
window.onload = (window.onload || function() {})
  .after(function() {
    alert(2)
  })
  .after(function() {
    alert(3)
  })
  .after(function() {
    alert(4)
  })

上面的 AOP 实现是在 Function.prototype 上添加 before 和 after 方法,如果不喜欢这种污染原型的方式,那么可以做一些变通,把原函数和新函数都作为参数传入 be-fore 或者 after 方法:

var before = function(fn, beforefn) {
  return function() {
    beforefn.apply(this, arguments)
    return fn.apply(this, arguments)
  }
}
var a = before(
  function() {
    alert(3)
  },
  function() {
    alert(2)
  }
)
a = before(a, function() {
  alert(1)
})
a()

AOP 应用

用 AOP 装饰函数的技巧在实际开发中非常有用。不论是业务代码的编写,还是在框架层面,都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我们编写一个松耦合和高复用性的系统。

回顾上面 AOP 函数可以发现 beforefn 和原函数_self 共用一组参数列表 arguments,当在 beforefn 的函数体内改变 arguments 的时候,原函数_self 接收的参数列表自然也会变化。

var func = function(param) {
  console.log(param) // 输出:{a: "a", b: "b"}
}
func = func.before(function(param) {
  param.b = 'b'
})
func({ a: 'a' })

借此启发,可以使用此特性为网络请求动态设置 token:

var getToken = function() {
  return 'Token'
}
ajax = ajax.before(function(type, url, param) {
  param.Token = getToken()
})
ajax('get', 'http:// xxx.com/userinfo', { name: 'sven' })

用 AOP 的方式给 ajax 函数动态装饰上 Token 参数,保证了 ajax 函数是一个相对纯净的函数,提高了 ajax 函数的可复用性,它在被迁往其他项目的时候,不需要做任何修改。

同理,也可以使用 AOP 在表单上传前做表单验证:

Function.prototype.before = function(beforefn) {
  var __self = this
  return function() {
    // beforefn 返回 false 的情况直接 return,不再执行后面的原函数
    if (beforefn.apply(this, arguments) === false) {
      return
    }
    return __self.apply(this, arguments)
  }
}
var validata = function() {
  if (username.value === '') {
    alert('用户名不能为空')
    return false
  }
  if (password.value === '') {
    alert('密码不能为空')
    return false
  }
}
var formSubmit = function() {
  var param = {
    username: username.value,
    password: password.value
  }
  ajax('http:// xxx.com/login', param)
}
formSubmit = formSubmit.before(validata)
submitBtn.onclick = function() {
  formSubmit()
}

值得注意的是,因为函数通过 Function.prototype.before 或者 Function.prototype.after 被装饰之后,返回的实际上是一个新的函数,如果在原函数上保存了一些属性,那么这些属性会丢失:

var func = function() {
  alert(1)
}
func.a = 'a'
func = func.after(function() {
  alert(2)
})
alert(func.a) // 输出:undefined

另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。

装饰者模式和代理模式

装饰者模式和代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。

代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。

在虚拟代理实现图片预加载的例子中,本体负责设置 img 节点的 src,代理则提供了预加载的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体一样,最终都是设置 src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,先使用一张占位的 loading 图片反馈给客户。

状态模式

状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

通常谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部。同时还可以把状态的切换规则事先分布在状态类中,这样就有效地消除了原本存在的大量条件分支语句。

以电灯开关为例,在不使用状态模式情况下:

Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '开关'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.buttonWasPressed()
  }
}

Light.prototype.buttonWasPressed = function() {
  if (this.state === 'off') {
    console.log('弱光')
    this.state = 'weakLight'
  } else if (this.state === 'weakLight') {
    console.log('强光')
    this.state = 'strongLight'
  } else if (this.state === 'strongLight') {
    console.log('关灯')
    this.state = 'off'
  }
}

var light = new Light()
light.init()

使用了状态模式之后:

var OffLightState = function(light) {
  this.light = light
}
OffLightState.prototype.buttonWasPressed = function() {
  console.log('弱光') // offLightState 对应的行为
  this.light.setState(this.light.weakLightState) // 切换状态到 weakLightState
}

var WeakLightState = function(light) {
  this.light = light
}
WeakLightState.prototype.buttonWasPressed = function() {
  console.log('强光') // weakLightState 对应的行为
  this.light.setState(this.light.strongLightState) // 切换状态到 strongLightState
}

var StrongLightState = function(light) {
  this.light = light
}
StrongLightState.prototype.buttonWasPressed = function() {
  console.log('关灯') // strongLightState 对应的行为
  this.light.setState(this.light.offLightState) // 切换状态到 offLightState
}

var Light = function() {
  this.offLightState = new OffLightState(this)
  this.weakLightState = new WeakLightState(this)
  this.strongLightState = new StrongLightState(this)
  this.button = null
}
Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  this.button = document.body.appendChild(button)
  this.button.innerHTML = '开关'
  this.currState = this.offLightState
  this.button.onclick = function() {
    self.currState.buttonWasPressed()
  }
}
Light.prototype.setState = function(newState) {
  this.currState = newState
}

var light = new Light()
light.init()

通过状态模式,状态之间的切换都被分布在状态类内部,这使得我们无需编写过多的 if、else 条件分支语言来控制状态之间的转换。

状态模式的定义

状态模式定义:允许一个对象在其内部状态改变时改变它的行为。意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。

状态模式的优缺点

状态模式的优点如下:

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
  • 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。
  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
  • Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

状态模式的缺点是会在系统中定义许多状态类,而且系统中会因此而增加不少对象。另外由于逻辑分散在状态类中,虽然避开了条件分支语句,但也造成了逻辑分散的问题,无法在一个地方就看出整个状态机的逻辑。

状态模式与策略模式

状态模式与策略模式类似,它们的类图看起来几乎一模一样,但在意图上有很大不同,因此它们是两种迥然不同的模式。策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。

它们之间的区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。

Javascript 状态机

前面示例是模拟传统面向对象语言的状态模式实现,为每种状态都定义一个状态子类,然后在 Context 中持有这些状态对象的引用,以便把 currState 设置为当前的状态对象。

状态模式是状态机的实现之一,但在 JavaScript 这种“无类”语言中,没有规定让状态对象一定要从类中创建而来。另外一点,JavaScript 可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。

var Light = function() {
  this.currState = FSM.off // 设置当前状态
  this.button = null
}
Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '已关灯'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.currState.buttonWasPressed.call(self) // 把请求委托给 FSM 状态机
  }
}
var FSM = {
  off: {
    buttonWasPressed: function() {
      console.log('关灯')
      this.button.innerHTML = '下一次按我是开灯'
      this.currState = FSM.on
    }
  },
  on: {
    buttonWasPressed: function() {
      console.log('开灯')
      this.button.innerHTML = '下一次按我是关灯'
      this.currState = FSM.off
    }
  }
}
var light = new Light()
light.init()

也可以尝试另一种方法,即利用下面的 delegate 函数来完成这个状态机编写。这是面向对象设计和闭包互换的一个例子,前者把变量保存为对象的属性,而后者把变量封闭在闭包形成的环境中:

var delegate = function(client, delegation) {
  return {
    buttonWasPressed: function() {
      // 将客户的操作委托给delegation对象
      return delegation.buttonWasPressed.apply(client, arguments)
    }
  }
}
var FSM = {
  off: {
    buttonWasPressed: function() {
      console.log('关灯')
      this.button.innerHTML = '下一次按我是开灯'
      this.currState = this.onState
    }
  },
  on: {
    buttonWasPressed: function() {
      console.log('开灯')
      this.button.innerHTML = '下一次按我是关灯'
      this.currState = this.offState
    }
  }
}
var Light = function() {
  this.offState = delegate(this, FSM.off)
  this.onState = delegate(this, FSM.on)
  this.currState = this.offState // 设置初始状态为关闭状态
  this.button = null
}
Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '已关灯'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.currState.buttonWasPressed()
  }
}
var light = new Light()
light.init()

适配器模式

适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。

var googleMap = {
  show: function() {
    console.log('开始渲染谷歌地图')
  }
}
var baiduMap = {
  display: function() {
    console.log('开始渲染百度地图')
  }
}
var baiduMapAdapter = {
  show: function() {
    return baiduMap.display()
  }
}
renderMap(googleMap) // 输出:开始渲染谷歌地图
renderMap(baiduMapAdapter) // 输出:开始渲染百度地图

适配器模式是一对相对简单的模式。有一些模式跟适配器模式的结构非常相似,比如装饰者模式、代理模式和外观模式。这几种模式都属于“包装模式”,都是由一个对象来包装另一个对象。区别它们的关键仍然是模式的意图。

  • 适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。适配器模式不需要改变已有的接口,就能够使它们协同作用。
  • 装饰者模式和代理模式也不会改变原有对象的接口,但装饰者模式的作用是为了给对象增加功能。装饰者模式常常形成一条长的装饰链,而适配器模式通常只包装一次。代理模式是为了控制对对象的访问,通常也只包装一次。
  • 外观模式的作用倒是和适配器比较相似,有人把外观模式看成一组对象的适配器,但外观模式最显著的特点是定义了一个新的接口。

RN App 外部唤醒踩坑记

公司新企划的 RN 项目需要实现链接分享功能,用户直接通过分享链接唤醒 App 并跳转相应页面,同时该功能要求在 iOS 和 Android 双端兼容,在此记录下拥抱新技术的踩坑历程。

Universal Links

在 iOS 中,唤醒功能是通过 Universal Links 来实现。Universal Links 通用链接是 Apple 在 2015 推出的一个新功能,只有在 iOS9 以上才支持。如果你的 App 支持 Universal Links,那就可以访问 HTTP/HTTPS 链接直接唤起 APP 进入具体页面,不需要其他额外判断;如果未安装 App,访问此通用链接时可以一个自定义网页。

关于如何添加 Universal Links 来唤醒 App,Apple 官方文档 Support Universal Links 中虽然有了说明,但是具体的细节操作却未交代清楚,致使我走了不少弯路。其实到最后发现具体实现其实很简单,大体来说分三步。

添加验证域名

激活 Xcode 工程中的 Associated Domains ,需要填入想要支持的域名,必须以 applinks: 为前缀,Apple 将会在合适的时候,从这个域名请求验证文件。

添加验证域名

上传验证文件

新建一个 json 格式的验证文件命名为 apple-app-site-association ,注意不要加 .json 后缀,然后编辑验证内容如下:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "RFD4R6TMUT.org.reactjs.native.HAD",
        "paths": ["/url"]
      }
    ]
  }
}

上面需要修改的地方只有 appIdpaths,其中 appIDTeamIdBundle Identifier 两部分相加组成,即 appID = TeamId.Bundle Identifier。进入 Apple Developer 网站,找到 Certificates, IDs & Profiles --> App IDs,查阅便可获得:

Apple Developer 获取 ID

如果上传成功后,可以进行先行在线验证

处理 URL 跳转

修改 AppDelegate.m,添加内容如下:

#import <React/RCTLinkingManager.h>

- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity
 restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler
{
  return [RCTLinkingManager application:application
                   continueUserActivity:userActivity
                     restorationHandler:restorationHandler];
}

App Links

在 Android 中,唤醒功能是通过 App Links 来实现。App Links 是谷歌在 Android M 之后推出的一个新功能,在此之前,点击一个链接会产生一个弹出框,询问用户打开哪个应用(包括浏览器应用)。但是谷歌在 Android M 实现了一个自动认证(auto-verify)机制,让开发者可以避开这个弹出框,使用户不必去选择一个列表,直接跳转到他们的 app。

App Links 和 Universal Links 实现大同小异,也是通过上传文件进行验证,在 app 第一次安装或更新后第一次打开时候,会自动下载服务器的文件然后进行验证。

激活 App Links

在使用 App Links 之前,需要先激活,修改 AndroidManifest.xml,增加一个 intent-filter,设置自动验证 android:autoVerify="true",然后填写验证域名和需要唤醒的路径。这样 APP 就会自动在所列的 host 中去验证,如果验证成功,APP 将成为匹配 URI 默认打开方式。

<activity
  android:name=".MainActivity"
  android:label="@string/app_name"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
  android:windowSoftInputMode="adjustResize">

  <!-- 添加 intent-filter -->
  <intent-filter android:autoVerify="true">
    <!-- action 和 category 必须如下填写 -->
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <!-- 填写验证域名和需要唤醒的路径 -->
    <data
      android:scheme="https"
      android:host="airi.me"
      android:pathPrefix="/url" />
  </intent-filter>
</activity>

上传验证文件

新建一个验证文件命名为 assetlinks.json,编辑 assetlinks.json 如下:

;[
  {
    relation: ['delegate_permission/common.handle_all_urls'],
    target: {
      namespace: 'android_app',
      package_name: 'com.had',
      sha256_cert_fingerprints: ['C1:96:B8:EB:AC:BD:6C:B3:03:...:7E:13:CC:0B:EE:50:80:5D:DA:81']
    }
  }
]

其中需要修改的只有包名 package_namesha256_cert_fingerprints,其中包名在 AndroidManifest.xml 里可以找到,sha256_cert_fingerprints 需要在密钥里面获取。

在 Windows 上 keytool 命令放在 JDK 的 bin 目录中(比如 C:\Program Files\Java\jdkx.x.x_x\bin),需要在命令行中先进入那个目录才能执行此命令。

keytool -list -v -keystore  had.jks

验证文件编辑上传完成后,可以先行在线验证

RN Linking 模块

React Native 通过 Linking 模块提供了一个通用的接口来与传入和传出的 App 链接进行交互。如果你的应用被其注册过的外部 url 调起,则可以在任何组件内获取和处理它:

// 组件内监听 Url 跳转
componentDidMount() {
  Linking.getInitialURL()
    .then(url => {
      this.navigate(url)
    })
    .catch(console.error)

  Linking.addEventListener('url', this.appWokeUp)
}

componentWillUnmount() {
  Linking.removeEventListener('url', this.appWokeUp)
}

appWokeUp = event => {
  this.navigate(event.url)
}

navigate = url => {
  // dosomething
}

参考文章:
唤醒 APP 的那些事
Universal Linking For React-Native with Rails API, and Deep Linking Android
Universal Links, URI Schemes, App Links, and Deep Links: What’s the Difference?

网站动态标题的两种方式

不少博客喜欢用动态网站标题来卖萌,为小站增添几分生趣,如友邻梓喵出没,甚至一些商业网站如饿了么也借此效果来提高用户粘性,故此探究下网站的动态标题的几种实现方式。

Window 对象

浏览器 Window 对象有 onbluronfocus 两个方法,分别表示焦点从当前窗口移开和选中,通过这两个方法可以简单设置动态标题。代码如下:

const title = {
  focus: '蝉鸣如雨',
  blur: '花宵道中'
}

window.onblur = () => (document.title = title.blur)
window.onfocus = () => (document.title = title.focus)

通过判断当前窗口的焦点状态来设置标题,多数网站的动态标题都是用的这个方法。

Page Visibility API

Page Visibility API 可以获取一个网页是可见或点击选中的状态,用户使用切换标签的方式来浏览网页时,API 会发送一个关于当前页面的可见信息的事件 visibilitychange,可以通过监听页面可见状态来实现动态标题,目前主流浏览器都支持此 API。

Can I use Page Visibility API

Page Visibility API 有如下两个属性:

  • document.hidden:如果页面处于被认为是对用户隐藏状态时返回 true,否则返回 false。
  • document.visibilityState:一个用来展示文档可见性状态的字符串。可能的值有:
    • visible:页面内容至少部分可见。
    • hidden:页面内容对用户不可见。
    • prerender:页面内容正在被预渲染且对用户是不可见的。
    • unloaded:页面正在从内存中卸载。

所以使用 Page Visibility API 可以简单实现网站动态标题:

const title = {
  focus: '蝉鸣如雨',
  blur: '花宵道中'
}

handleVisibilityChange = () => {
  if (document.hidden) {
    document.title = title.blur
  } else {
    document.title = title.focus
  }
}

document.addEventListener('visibilitychange', handleVisibilityChange, false)

应当注意的是当浏览器最小化时,不会触发 visibilitychange 事件,也不会设置 hidden 为 true。

一个接口的诞生

自学习 Java 以来已半月有余,之前看过教程,这次权当复习,虽然基础内容了解不少,一些重点概念依旧云里雾里。实践出真知,在慕课网上找了个实战教程开始练手,let's go!

数据返回对象

在开始设计接口之前,先设计一个通用的接口返回状态码和接口返回数据对象。

状态码

接口共有 4 种返回状态,分别为成功(SUCCESS)、失败(ERROR)、强制登陆(NEED_LOGIN)和非法参数(ILLEGAL_ARGUMENT),使用枚举实现。

package com.mmall.common;

public enum ResponseCode {

    SUCCESS(0, "SUCCESS"),
    ERROR(1, "ERROR"),
    NEED_LOGIN(10, "NEED_LOGIN"),
    ILLEGAL_ARGUMENT(2, "ILLEGAL_ARGUMENT");

    private final int code;
    private final String desc;

    ResponseCode(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public int getCode() {
        return code;
    }

    public String getDesc() {
        return desc;
    }
}

返回对象

设计接口返回数据类 ServerResponse,添加三个成员属性,分别为状态码(status)、消息(msg)和返回数据(data),并添加接口访问成功和失败的成员方法。

package com.mmall.common;

import org.codehaus.jackson.annotate.JsonIgnore;
import org.codehaus.jackson.map.annotate.JsonSerialize;

import java.io.Serializable;

//保证序列化对象的时候,如果是null的对象,key也会消失
@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL)
public class ServerResponse<T> implements Serializable {

    private int status;
    private String msg;
    private T data;

    private ServerResponse(int status) {
        this.status = status;
    }

    private ServerResponse(int status, T data) {
        this.status = status;
        this.data = data;
    }

    private ServerResponse(int status, String msg, T data) {
        this.status = status;
        this.msg = msg;
        this.data = data;
    }

    private ServerResponse(int status, String msg) {
        this.status = status;
        this.msg = msg;
    }

    // 使之不在json序列化结果当中
    @JsonIgnore
    public boolean isSuccess() {
        return this.status == ResponseCode.SUCCESS.getCode();
    }

    public int getStatus() {
        return status;
    }

    public String getMsg() {
        return msg;
    }

    public T getData() {
        return data;
    }

    public static <T> ServerResponse<T> createBySuccess() {
        return new ServerResponse<T>(ResponseCode.SUCCESS.getCode());
    }

    public static <T> ServerResponse<T> createBySuccessMessage(String msg) {
        return new ServerResponse<T>(ResponseCode.SUCCESS.getCode(), msg);
    }

    public static <T> ServerResponse<T> createBySuccess(T data) {
        return new ServerResponse<T>(ResponseCode.SUCCESS.getCode(), data);
    }

    public static <T> ServerResponse<T> createBySuccess(String msg, T data) {
        return new ServerResponse<T>(ResponseCode.SUCCESS.getCode(), msg, data);
    }


    public static <T> ServerResponse<T> createByError() {
        return new ServerResponse<T>(ResponseCode.ERROR.getCode(), ResponseCode.ERROR.getDesc());
    }

    public static <T> ServerResponse<T> createByErrorMessage(String errorMessage) {
        return new ServerResponse<T>(ResponseCode.ERROR.getCode(), errorMessage);
    }

    public static <T> ServerResponse<T> createByErrorCodeMessage(int errorCode, String errorMessage) {
       return new ServerResponse<T>(errorCode, errorMessage);
    }
}

MD5 加密

MD5 加密方法

数据库不存储用户的明文密码,对于密码都使用 MD5 加密,设计一个通用的加密工具类,在这里文本内容都使用 UTF-8 编码格式。

package com.mmall.util;

import java.security.MessageDigest;

public class MD5Util {

    private static String byteArrayToHexString(byte b[]) {
        StringBuffer resultSb = new StringBuffer();
        for (int i = 0; i < b.length; i++)
            resultSb.append(byteToHexString(b[i]));
        return resultSb.toString();
    }

    private static String byteToHexString(byte b) {
        int n = b;
        if (n < 0)
            n += 256;
        int d1 = n / 16;
        int d2 = n % 16;
        return hexDigits[d1] + hexDigits[d2];
    }

    /**
     * 返回大写MD5
     * @param origin
     * @param charsetname
     * @return
     */
    private static String MD5Encode(String origin, String charsetname) {
        String resultString = null;
        try {
            resultString = new String(origin);
            MessageDigest md = MessageDigest.getInstance("MD5");
            if (charsetname == null || "".equals(charsetname))
                resultString = byteArrayToHexString(md.digest(resultString.getBytes()));
            else
                resultString = byteArrayToHexString(md.digest(resultString.getBytes(charsetname)));
        } catch (Exception exception) {
        }
        return resultString.toUpperCase();
    }

    public static String MD5EncodeUtf8(String origin) {
        origin = origin + PropertiesUtil.getProperty("password.salt", "");
        return MD5Encode(origin, "utf-8");
    }


    private static final String hexDigits[] = {"0", "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};

}

Password salt

为了避免通过撞库实现 MD5 密码破解,在进行密码 MD5 加密前给密码添加一个固定前缀来增加其复杂度,这个前缀称之为 Password salt,将其写入配置文件 mmall.properties,在加密前读取配置文件再进行密码加密,添加工具类 PropertiesUtil 如下:

package com.mmall.util;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Properties;

public class PropertiesUtil {

    private static Logger logger = LoggerFactory.getLogger(PropertiesUtil.class);

    private static Properties props;

    static {
        String fileName = "mmall.properties";
        props = new Properties();
        try {
            props.load(new InputStreamReader(PropertiesUtil.class.getClassLoader().getResourceAsStream(fileName),"UTF-8"));
        } catch (IOException e) {
            logger.error("配置文件读取异常",e);
        }
    }

    public static String getProperty(String key){
        String value = props.getProperty(key.trim());
        if(StringUtils.isBlank(value)){
            return null;
        }
        return value.trim();
    }

    public static String getProperty(String key,String defaultValue){
        String value = props.getProperty(key.trim());
        if(StringUtils.isBlank(value)){
            value = defaultValue;
        }
        return value.trim();
    }
}

接口实现

接口访问流程为访问 Controller->Service->Dao->SQL,接口申明在 Controller 层,在 Service 层设计接口并添加接口的具体实现,在 Dao 层完成数据交互,调用 SQL 语句完成数据库访问。

Controller

package com.mmall.controller.portal;

import com.mmall.common.Const;
import com.mmall.common.ResponseCode;
import com.mmall.common.ServerResponse;
import com.mmall.pojo.User;
import com.mmall.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpSession;

@Controller
@RequestMapping("/user/")
public class UserController {

    @Autowired
    private IUserService iUserService;

    /**
     * 用户登录
     * @param username
     * @param password
     * @param session
     * @return
     */
    @RequestMapping(value = "login.do", method = RequestMethod.POST)
    @ResponseBody
    public ServerResponse<User> login(String username, String password, HttpSession session) {
        ServerResponse<User> response = iUserService.login(username, password);
        if (response.isSuccess()) {
            session.setAttribute(Const.CURRENT_USER, response.getData());
        }
        return response;
    }
}

Service

添加接口:

package com.mmall.service;

import com.mmall.common.ServerResponse;
import com.mmall.pojo.User;

public interface IUserService {
    ServerResponse<User> login(String username, String password);
}

接口实现:

package com.mmall.service.impl;

import com.mmall.common.ServerResponse;
import com.mmall.dao.UserMapper;
import com.mmall.pojo.User;
import com.mmall.service.IUserService;
import com.mmall.util.MD5Util;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Service("iUserService")
public class UserServiceImpl implements IUserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public ServerResponse<User> login(String username, String password) {
        int resultCount = userMapper.checkUsername(username);
        if (resultCount == 0) {
            return ServerResponse.createByErrorMessage("用户不存在");
        }

        String md5Password = MD5Util.MD5EncodeUtf8(password);
        User user = userMapper.selectLogin(username, md5Password);
        if (user == null) {
            return ServerResponse.createByErrorMessage("密码错误");
        }

        // 处理返回值密码
        user.setPassword(StringUtils.EMPTY);
        return ServerResponse.createBySuccess("登录成功", user);
    }
}

DAO

package com.mmall.dao;

import com.mmall.pojo.User;
import org.apache.ibatis.annotations.Param;

public interface UserMapper {
    int deleteByPrimaryKey(Integer id);

    int insert(User record);

    int insertSelective(User record);

    User selectByPrimaryKey(Integer id);

    int updateByPrimaryKeySelective(User record);

    int updateByPrimaryKey(User record);

    // 检查用户名是否存在
    int checkUsername(String username);

    // 用户登录
    User selectLogin(@Param("username")String username,@Param("password")String password);
}

SQL 实现

resources/mappers/UserMapper.xml 新增用户名检查和登录 SQL,当传入多个查询参数时参数类型为 map:

<select id="checkUsername" resultType="int" parameterType="string">
  select count(1) from mmall_user
  where username = #{username}
</select>

<select id="selectLogin" resultMap="BaseResultMap" parameterType="map">
  select
  <include refid="Base_Column_List" />
  from mmall_user
  where username = #{username}
  and password = #{password}
</select>

添加 Valine 评论系统

很多小伙伴抱怨 Gitalk 评论不方便,毕竟不是每个人都会注册 Github 账号,所以给 Aurora 主题添加了 Valine 评论系统,这下就可以匿名评论了,不过发现大家还是更喜欢用 Gitalk 哈~

Vue 项目引入 SVG 图标

最近摸鱼 Github Style 博客主题 Gitlife 的时候需要用到大量 svg 图标,故参考 element-admin 的资源引入方式,尝试在 vue 项目里引入 svg 图标,尽量取代字体图标。

关于 SVG

SVG 是一种可缩放矢量图形(Scalable Vector Graphics,SVG)是基于可扩展标记语言(XML),用于描述二维矢量图形的图形格式。SVG 由 W3C 制定,是一个开放标准。

SVG 在既能满足现有图片的功能的前提下,又是矢量图,在可访问性上面也非常不错,并且有利于 SEO 和无障碍,在性能和维护性方面也比 icon font 要出色许多。

SVG 与 icon font 的区别:

  1. icon font 是字体渲染,而 svg 是图形渲染,icon font 在一倍屏幕下渲染效果不好,细节部分锯齿明显
  2. icon font 因为是字体只能支持单色
  3. icon font 可读性不够好,icon font 主要在页面用 Unicode 符号调用对应的图标,对浏览器和搜索引擎不友好

安装依赖

在 vue 项目中引入 svg,首要工作是安装依赖包 svgosvg-sprite-loader,这两个工具包都是给 webpack 打包 svg 图标资源使用。

  • svgo: Node.js tool for optimizing SVG files.
  • svg-sprite-loader: Webpack loader for creating SVG sprites.
"devDependencies": {
  "svgo": "^1.2.1",
  "svg-sprite-loader": "^4.1.3"
}

然后配置 webpack 对于 svg 文件的 loader,修改 vue.config.js 文件,引入刚刚安装的 svg loader,详情如下:

// vue.config.js
module.exports = {
  chainWebpack: config => {
    // svg rule loader
    const svgRule = config.module.rule('svg') // 找到 svg-loader
    svgRule.uses.clear() // 清除已有 loader
    svgRule.exclude.add(/node_modules/) ) // 排除 node_modules 目录
    svgRule // 添加新的 svg loader
      .test(/\.svg$/)
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
  }
}

引入资源

这里举个栗子,新建 src/assets/icons 文件夹,在此文件夹下新建 svg 子文件夹用于存放 svg 图标文件,并新增 svgo 配置文件 svgo.yml,详见官方文档,添加简要配置如下:

# svgo.yml
plugins:
  - removeAttrs:
      attrs:
        - 'fill'
        - 'fill-rule'

在此文件夹下新增 index.js 引入 svg 资源并全局注册 vue 组件,代码如下:

import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon' // svg组件

// register globally
Vue.component('svg-icon', SvgIcon)

const req = require.context('./svg', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

require.context() 方法来创建自己的(模块)上下文,这个方法有 3 个参数:

  • 要搜索的文件夹目录
  • 是否还应该搜索它的子目录
  • 以及一个匹配文件的正则表达式

创建一个通用的引入图标的 SvgIcon 组件如下:

<template>
  <svg :class="svgClass" aria-hidden="true" v-on="$listeners">
    <use :xlink:href="iconName" />
  </svg>
</template>

<script>
  export default {
    name: 'SvgIcon',
    props: {
      iconClass: {
        type: String,
        required: true
      },
      className: {
        type: String,
        default: ''
      }
    },
    computed: {
      iconName() {
        return `#icon-${this.iconClass}`
      },
      svgClass() {
        if (this.className) {
          return 'svg-icon ' + this.className
        } else {
          return 'svg-icon'
        }
      }
    }
  }
</script>

<style scoped>
  .svg-icon {
    width: 1em;
    height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
  }
</style>

最后不要忘记在 main.js 里引入 icons:

import './assets/icons'

食用方式,添加 svg 文件到 /assets/icons/svg 文件夹下即可,如添加 github.svg 后在 vue 文件里引入:

<svg-icon icon-class="github" />

参考文章:
在 vue 项目中优雅的使用 Svg

书单

ES6 标准入门

author: 阮一峰
published: 2017-09-01
progress: 正在阅读...
rating: 5
postTitle: ES6 标准入门
postLink: https://chanshiyu.com/#/post/12
cover: https://chanshiyu.com/yoi/book/ES6-标准入门.jpg
link: https://www.duokan.com/book/169714
description: 柏林已经来了命令,阿尔萨斯和洛林的学校只许教 ES6 了,他转身朝着黑板,拿起一支粉笔,使出全身的力量,写了两个大字:“ES6 **!”。

JavaScript 设计模式与开发实践

author: 曾探
published: 2015-05-01
progress: 正在阅读...
rating: 5
postTitle: JS 设计模式与开发实践
postLink: https://chanshiyu.com/#/post/8
cover: https://chanshiyu.com/yoi/book/JavaScript-设计模式与开发实践.jpg
link: https://www.duokan.com/book/120447
description: 设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。书中介绍了 JavaScript 常用的 16 种设计模式,受益良多。

你不知道的 JavaScript(上卷)

author: Kyle Simpson
published: 2015-04-01
progress: 正在阅读...
rating: 5
postTitle:
postLink:
cover: https://chanshiyu.com/yoi/book/You_Dont_Know_JavaScript(上卷).jpg
link: https://www.duokan.com/book/102758
description: You-Dont-Know-JS 系列目前在 Github 上已获得近 7.5 万 star,说是 2017 年最火的一个项目也不为过,学而不思则殆,重新温故知新。

JavaScript 秘密花园

author: 伊沃·韦特泽尔
published: 未出版
progress: 温习中
rating: 4
postTitle: JavaScript 秘密花园
postLink: https://chanshiyu.com/#/post/6
cover: https://chanshiyu.com/yoi/book/JavaScript-秘密花园.jpg
link: https://www.jb51.net/onlineread/JavaScript-Garden-CN/#intro
description: JavaScript 秘密花园是一个不断更新,主要关心 JavaScript 一些古怪用法的文档。初学者可以籍此深入了解 JavaScript 的语言特性。

排序算法初探

关于 JavaScript 排序算法,可以说是面试里的常客,虽然对于我等只会写写界面的初级前端萌新(哈~)来说,工作中基本用不到排序算法。这里初探排序算法,不求甚解。

冒泡排序

冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

冒泡排序算法的运作如下:

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

冒泡排序平均时间复杂度 O(n^2),在最好情况下,即一次排完時复杂度为 O(n),在最坏情况下复杂度为
O(n^2)。

经典冒泡算法

经典冒泡算法:通过双层循环实现排序,外层循环表示当前是第几轮排序,内层循环表示当前轮的第几次排序,通过两两比较交换位置完成排序。

function bubbleSort(nums) {
  const len = nums.length
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len - 1 - i; j++) {
      if (nums[j] > nums[j + 1]) {
        ;[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]] // 交换位置
      }
    }
  }
  return nums
}

改进冒泡算法

改进冒泡算法:设置一标志性变量 pos,用于记录每轮排序中最后一次进行交换的位置。由于 pos 位置之后的记录均已交换到位,故在进行下一轮排序时只要扫描到 pos 位置即可。

function bubbleSort(nums) {
  const len = nums.length
  let i = len - 1
  while (i > 0) {
    let pos = 0
    for (let j = 0; j < i; j++) {
      if (nums[j] > nums[j + 1]) {
        pos = j // 记录交换時的位置
        ;[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]] // 交换位置
      }
    }
    i = pos
  }
  return nums
}

终极冒泡算法

终极冒泡算法:传统冒泡排序中每一轮排序操作只能找到一个最大值或最小值,可以在每趟排序中进行正向和反向两遍冒泡方法一次得到两个最终值(最大者和最小者),从而使排序轮数几乎减少了一半。

function bubbleSort(nums) {
  let low = 0
  let high = nums.length - 1
  let i
  while (low < high) {
    // 正向排序,找出最大值
    for (i = low; i < high; ++i) {
      if (nums[i] > nums[i + 1]) {
        ;[nums[i], nums[i + 1]] = [nums[i + 1], nums[i]] // 交换位置
      }
    }
    --high // 前移一位

    // 反向排序,找出最小值
    for (i = high; i > low; --i) {
      if (nums[i] < nums[i - 1]) {
        ;[nums[i], nums[i - 1]] = [nums[i - 1], nums[i]] // 交换位置
      }
    }
    ++low // 后移一位
  }
  return nums
}

选择排序

选择排序是一种简单直观的排序算法。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

选择排序算法的运作如下:

  1. 初始状态:无序区为 R[1..n],有序区为空。
  2. 第 i 轮排序开始时,当前有序区和无序区分别为 R[1..i-1] 和 R[i..n]。然后从当前无序区中选出最小值,将它与无序区的第一个值交换,使得新增一位有序区和减少一位无序区。
  3. n-1 轮排序结束,数组全部变为有序区,从而完成排序。

选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对 n 个元素的表进行排序总共进行至多 n-1 次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。

选择排序平均时间复杂度稳定在 O(n^2)。

经典选择算法

经典选择算法:将数列分为有序区和无序区两部分,在每轮循环中从无序区选择一个最小值并入有序区,新增一位有序区同时减少一位无序区,n - 1 轮排序后全部变为有序区,从而完成排序。

function selectionSort(nums) {
  const len = nums.length
  let min = 0
  for (let i = 0; i < len - 1; i++) {
    min = i
    for (let j = i + 1; j < len; j++) {
      if (nums[min] > nums[j]) {
        min = j // 保存最小值的索引
      }
    }
    ;[nums[i], nums[min]] = [nums[min], nums[i]] // 交换位置
  }
  return nums
}

插入排序

是一种简单的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用 in-place 排序,因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

插入排序算法的运作如下:

  1. 从第一个元素开始,该元素可以认为已经被排序。
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描,如果该元素大于新元素,将该元素移到下一位置。
  3. 重复步骤 2,直到找到已排序的元素小于或者等于新元素的位置,将新元素插入到该位置后。
  4. 重复步骤 2~3,直到完成排序。

插入排序平均时间复杂度 O(n^2),在最好情况下复杂度为 O(n),在最坏情况下复杂度为 O(n^2)。

经典插入算法

经典选择算法:将数列分为有序区和无序区两部分,在每轮循环中从无序区选择一个最小值并入有序区,新增一位有序区同时减少一位无序区,n - 1 轮排序后全部变为有序区,从而完成排序。

function insertionSort(nums) {
  const len = nums.length
  for (let i = 1; i < len; i++) {
    let k = nums[i]
    let j = i - 1
    while (j >= 0 && nums[j].num > k.num) {
      nums[j + 1] = num[j]
      j--
    }
    nums[j + 1] = k
  }
  return nums
}

二分插入算法

二分插入算法:查找插入位置时使用二分查找的方式,在插入值之前,先在有序区中找到待插入值需要比较的左边界,在数据长度较大时,可以有效减少每轮排序中的查找插入位置的次数。

function insertionSort(nums) {
  const len = nums.length
  for (let i = 1; i < len; i++) {
    let k = nums[i]
    let left = 0
    let right = i - 1
    while (left <= right) {
      let middle = ~~((left + right) / 2)
      if (k < nums[middle]) {
        right = middle - 1
      } else {
        left = middle + 1
      }
    }
    for (let j = i - 1; j >= left; j--) {
      nums[j + 1] = nums[j]
    }
    nums[left] = k
  }
  return nums
}

归并排序

归并排序是创建在归并操作上的一种有效的排序算法。归并操作也叫归并算法,指的是将两个已经排序的序列合并成一个序列的操作。归并排序时间复杂度为 O(nlogn)。该算法是采用分治法的一个非常典型的应用,且各层分治递归可以同时进行。

归并排序算法的运作如下:

  1. 将长度为 n 的序列每相邻两个数字进行归并操作,形成 n/2 个序列,排序后每个序列包含二或一个元素。
  2. 若此时序列数不是 1 个则将上述序列再次归并,形成 n/4 个序列,每个序列包含三或四个元素。
  3. 重复步骤 2,直到所有元素排序完毕,即序列数为 1,即完成排序。
  4. 归并排序平均时间复杂度稳定在 O(nlogn)。

经典归并算法

经典归并算法:将数列分为两个子序列,如果子序列长度不为一,再将子序列细分,直至子序列长度为一。递归比较两个子序列并排序后,再相互组合成有序数列,最终完成排序。

function mergeSort(nums) {
  const len = nums.length
  if (len <= 1) return nums
  let middle = ~~(len / 2)
  let left = nums.slice(0, middle)
  let right = nums.slice(middle)
  return merge(mergeSort(left), mergeSort(right))
}
function merge(left, right) {
  const result = []
  while (left.length && right.length) {
    if (left[0] < right[0]) {
      result.push(left.shift())
    } else {
      result.push(right.shift())
    }
  }
  return result.concat(left, right)
}

关于

缘起

蝉時雨,源自日语 せみしぐれ。

夏日众蝉鸣叫此起彼伏好似落雨,蝉儿们似要将仅存的的生命燃烧奏出最后的音符,绚烂与壮美中氤氲着沉寂与无常,是日本夏天最具代表性的季节物语之一。

正如蝉儿一般,生命短暂即逝,却仍一无反顾奏出生命的最强音,而我的青春岁月又何尝不期望如此,在最美的年华绽放最璀璨的人间烟火。

蝉鸣如雨,花宵道中,一如少年。

自述

车万人』大学时入坑,弹幕苦手,不玩正作,喜欢包括且不限于手书、漫画、音乐、同人图等作品。云村自建东方纯音乐歌单 幽霊楽団 欢迎来听。喜欢看东方手书,因曾看 東方幼靈夢 哭成泪人,故成为灵梦单推人,斥巨资购入灵梦 1/4 超大手办,详见 她的眼里有星辰,现渐已退坑中。

米卫兵』原神开服打卡至今不曾间断,因曾连续两次梦见胡桃,染上了相思毒,深入骨髓,药石罔效,因此在设计 ZERO 主题时采用胡桃主题配色,并使用堂主梅花作为站点图标,细节处添加点缀。现今国内各游戏厂商停驻不前,時雨感到寒心,对米哈游寄予厚望。

花丸晴琉 单推人』至今唯一单推的虚拟主播,也是唯一上舰打赏过的主播,大嘴花很可爱,欢迎来 D。

建筑师 INTJ-T』性格内向拘谨,不擅长交友,所以如果有人和時雨打招呼就会感到非常开心,所以现在还等什么,下面留个言或许我们能成为好朋友呢。

中二病、珂学家、白学、社惠主义、团子党、妖精小姐』因为生活与工作,時雨已经两年未追新番了,所以也算是旧时代二次元的遗老,这里可以查看 時雨の追番,旧时代的船票,已经无法登上新时代的客船。

主题

个人建站已有好几年历史了,虽然内容产出甚少 (黑历史都被删干净了),期间也食用过不少博客主题,从最初的 Wordpress,以及后来的 Hexo,都尝试过一段时间。但最终发现这些主题都不让人中意,Wordpress 太过臃肿,Hexo 文章发布不便,每次都要手动构建。而時雨想要 write everywhere everytime,巡视一圈并未发现中意的一款,既然如此,如不自己撸一个!

第一次尝试用 Umijs 设计了第一款 SPA 单页面博客主题 HeartBeat,该主题基于 Preact 开发,后台数据源依托于 Github Issues,使用开源项目 Gitalk 作为博客评论系统。该主题充分利用 Github 开源的免费服务,脱离服务器与数据库,关注内容本身,免费食用。

HeartBeat 作为蝉時雨の博客主题坎坷运行了一年,并在不断的更新优化后趋于稳定。但在运行过程中逐渐发现一些难以忍受的问题,受制于最初设计遗留的缺陷和对动效的盲目追求,HeartBeat 的用户体验让人不甚满意,甚至有点糟糕。作为强迫症晚期患者,是时候考虑回炉重构主题了,于是便有了第二次尝试。

之后又吸取经验设计了 Aurora,意为极光,该主题继承了 HeartBeat 的设计灵感,并在此基础上做了大量颠覆和细节优化。使用 Vue 重构了所有代码,重写了每一个页面与组件。相较于 HeartBeat,体验有了大幅提升,但是很遗憾,在服役了三年时间后,Aurora 的风格设计也显露出不少缺陷。

于是 ZERO 就此诞生,時雨秉承 Less but better 的**,将 ZERO 打造成自己最后一个博客主题,从设计之初就做了大量的调研,阅遍众多中外博客,汲取了不少灵感,将其中的闪光点一一实践,或采用,或魔改,或摒弃,相信 ZERO 也会在漫长的迭代中逐步完善。

Just enjoy it ฅ●ω●ฅ.

时光机

2023-01-07:Zero 添加白天/黑夜模式切换
2022-05-21:Zero! Nya~~~
2019-10-24:Aurora 2.0 版本重构完成!更完美の移动端适配!
2019-01-28:新主题 Aurora 正式上线!
2018-01-14:添加看板娘 Tia 和 Pio,实现看板娘换装
2017-12-31:React 重写 SPA 博客,全站使用 Github Issues
2017-04-20:博客荒废中重生,迁入 Hexo,主题 NexT
2016-03-17:购入域名 chanshiyu.com,小站起步

浅析 Java 反射

反射 (Reflection) 是 Java 的特征之一,它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。反射最重要的用途就是开发各种通用框架。

反射的概念

什么是反射

Reflection enables Java code to discover information about the fields, methods and constructors of loaded classes, and to use reflected fields, methods, and constructors to operate on their underlying counterparts, within security restrictions.

每个类都有一个 Class 对象,包含了与类有关的信息。当编译一个新类时,会产生一个同名的 .class 文件,该文件内容保存着 Class 对象。类加载相当于 Class 对象的加载,类在第一次使用时才动态加载到 JVM 中。

反射可以提供运行时的类信息,并且这个类可以在运行时才加载进来,甚至在编译时期该类的 .class 不存在也可以加载进来。

Class 和 java.lang.reflect 一起对反射提供了支持,java.lang.reflect 类库主要包含了以下三个类:

  • Field :可以使用 get() 和 set() 方法读取和修改 Field 对象关联的字段;
  • Method :可以使用 invoke() 方法调用与 Method 对象关联的方法;
  • Constructor :可以用 Constructor 创建新的对象。

Java 反射主要提供以下功能:

  • 在运行时判断任意一个对象所属的类;
  • 在运行时构造任意一个类的对象;
  • 在运行时判断任意一个类所具有的成员变量和方法(通过反射甚至可以调用 private 方法);
  • 在运行时调用任意一个对象的方法

反射的优点

  • 可扩展性:应用程序可以利用全限定名创建可扩展对象的实例,来使用来自外部的用户自定义类。
  • 类浏览器和可视化开发环境:一个类浏览器需要可以枚举类的成员。可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码。
  • 调试器和测试工具:调试器需要能够检查一个类里的私有成员。测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率。

反射的缺点

  • 性能开销:反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。应该避免在经常被执行的代码或对性能要求很高的程序中使用反射。
  • 安全限制:使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如 Applet,那么这就是个问题了。
  • 内部暴露:由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。

反射的基本作用

获取 Class 对象

首先理解 Java 对象的两个概念:

  1. Java 语言中,万事万物皆对象,但普通数据类型和静态的成员不是对象。
  2. 类是对象,类是 java.lang.Class 类的实例对象。There is a class named Class。

任何一个类都是 Class 的实例对象,这个实例对象有三种表示方式。

  • 直接获取类对象的 class,Foo.class
  • 调用类实例对象的 foo.getClass() 方法
  • 使用 Class 类的 Class.forName() 静态方法
package com.chanshiyu;

public class Hello {
  public static void main(String[] args) {
    // Foo 的实例对象
    Foo foo1 = new Foo();

    // Foo 这个类也是 Class 的实例对象,有三种表示方式
    // 1. 第一种表示方式:任何一个类都有一个隐含的静态成员变量 class
    Class c1 = Foo.class;

    // 2.第二种表示方式:已知该类的实例对象,使用 getClass 方法
    Class c2 = foo1.getClass();

    // 3.第三种表示方式
    Class c3 = null;
    try {
      c3 = Class.forName("com.chanshiyu.Foo");
    } catch (ClassNotFoundException e) {
      e.printStackTrace();
    }

    // c1、c2、c3 代表了 Foo 类的类类型(class type)
    // c1 == c2 == c3,一个类只能 Class 类的一个实例对象
    // 可以通过 c1、c2、c3 来创建实例对象
    try {
      Foo foo2 = (Foo)c1.newInstance();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

class Foo {}

动态加载类

编译时加载类是静态加载类,运行时加载类是动态加载类。Class.forName("类的全称") 不仅代表了类类型(class type),还代表了动态加载类。

new 创建对象是静态加载类,在编译时刻就需要加载所有可能用到的类。通过动态加载类可以实现按需加载类。

通过动态加载类有两种创建实例对象方式:

  • 使用 Class 对象的 newInstance() 方法来创建 Class 对象对应类的实例
  • 先通过 Class 对象获取指定的 Constructor 对象,再调用 Constructor 对象的 newInstance() 方法来创建实例。这种方法可以用指定的构造器构造类的实例。
package com.chanshiyu;

public class Office {
  public static void main(String[] args) {
    try {
      // 动态加载类
      Class c = Class.forName(args[0]);
      // 通过类类型创建对象
      OfficeAble oa = (OfficeAble) c.newInstance();
      oa.start();

      //获取指定参数的构造器
      //Constructor constructor = c.getConstructor(String.class);
      //根据构造器创建实例
      //OfficeAble oa = (OfficeAble) constructor.newInstance("hello");
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

class Word implements OfficeAble {
  @Override
  public void start() {
    System.out.println("wold start");
  }
}

interface OfficeAble {
  public void start();
}

获取类的信息

反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 java 语言的反射机制。

获取类类型,再通过类类型获取类名称:

Class c1 = int.class; // int 的类类型
Class c2 = String.class; // String 的类类型
Class c3 = void.class; // void 的类类型
System.out.println(c1.getName()); // int
System.out.println(c2.getName()); // java.lang.String
System.out.println(c2.getSimpleName()); // String
System.out.println(c3.getName()); // void

工具类获取类的信息:

package com.chanshiyu;

import java.lang.reflect.Method;
import java.lang.reflect.Field;
import java.lang.reflect.Constructor;

public class ClassUtil {
  /**
   * 打印类的信息,包括成员变量与成员函数
   * @param obj 该对象所属类的信息
   */
  public static void printClassMessage(Object obj) {
    // 首先获取类类型
    Class c = obj.getClass();
    // 获取类的名称
    System.out.println("类的名称:" + c.getName());

    /**
     * Method 类,方法对象。一个成员方法就是一个 Method 对象
     * getMethods() 方法获取所有 public 方法,包括父类继承而来的
     * getDeclaredMethods() 方法获取该类自己申明的方法,不问访问权限
     */
    Method[] ms = c.getMethods();
    for (Method m: ms) {
      // 获取方法名
      System.out.print(m.getName());
      // 获取方法返回值类型
      Class returnType = m.getReturnType();
      System.out.print("方法返回值类型:" + returnType.getName());
      // 获取参数类型
      System.out.print("  参数类型:");
      Class[] paramTypes = m.getParameterTypes();
      for (Class c1: paramTypes) {
        System.out.print(c1.getName() + ",");
      }
      System.out.println();
    }

    /**
     * 获取成员变量:java.lang.reflect.Field
     * 成员变量也是对象,Field 类封装了成员变量的操作
     * getFileds() 方法获取所有 public 变量,包括父类继承而来的
     * getDeclaredFileds() 方法获取该类自己声明的变量,不问访问权限
     */
    Field[] fs = c.getDeclaredFields();
    for(Field field: fs) {
      // 获取成员变量的名称
      String fieldName = field.getName();
      // 获取成员变量类型
      Class fieldType = field.getType();
      String typeName = fieldType.getName();
      System.out.println(fieldName + " " + typeName);
    }

    /**
     * 获取构造函数:java.lang.reflect.Constructor
     * 构造函数也是对象
     * getConstructors() 方法获取所有 public 构造函数
     * getDecleardConstructors() 获取该类自己申明的构造函数
     */
    Constructor[] cs = c.getConstructors();
    for (Constructor constructor: cs) {
      // 获取构造函数名称
      System.out.print("构造函数:" + constructor.getName() + "(");
      // 获取构造函数参数列表
      Class[] paramTypes = constructor.getParameterTypes();
      for (Class c1: paramTypes) {
        System.out.print(c1.getName() + ",");
      }
      System.out.println(")");
    }
  }
}

方法的反射

通过反射操作可以获取类的方法然后调用。

package com.chanshiyu;

import java.lang.reflect.Method;

public class Demo1 {
  public static void main(String[] args) {
    A a = new A();
    Class c = a.getClass();

    // 获取方法 通过名称和参数列表决定
    try {
      // 反射操作调用方法
      // Method m1 = c.getMethod("print", int.class, int.class);
      // m1.invoke(a, 10, 20);

      Method m1 = c.getMethod("print",new Class[]{int.class, int.class});
      m1.invoke(a, new Object[]{10, 20});
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

class A {
  public void print(int a, int b) {
    System.out.println(a + b);
  }

  public void print(String a, String b) {
    System.out.println(a.toUpperCase() + "," + b.toUpperCase());
  }
}

泛型的本质

泛型是参数化类型的应用,操作的数据类型不限定于特定类型,可以根据实际需要设置不同的数据类型,以实现代码复用。

Java 源代码里面类型提供实现泛型功能,而编译后 Class 文件类型就变成原生类型(即类型被擦除掉),而在引用处插入强制类型转换以实现 JVM 对泛型的支持。本质是 Java 泛型只是 Java 提供的一个语法糖。Java 中集合的泛型是防止错误输入的,只在编译阶段有效,可以通过方法的反射来绕过编辑阶段检测。

package com.chanshiyu;

import java.lang.reflect.Method;
import java.util.ArrayList;

public class Demo2 {
  public static void main(String[] args) {
    ArrayList list1 = new ArrayList();
    ArrayList<String> list2 = new ArrayList<String>();
    list2.add("hello"); // ok
    // list2.add(20); 错误

    Class c1 = list1.getClass();
    Class c2 = list2.getClass();
    System.out.println(c1 == c2); // true

    /**
     * c1 == c2 返回 true 说明编译之后集合的泛型是去泛型话的
     * Java 中集合的泛型是防止错误输入的,只在编译阶段有效
     * 验证:可以通过方法的反射来绕过编辑阶段检测
     */
    try {
      Method m = c2.getMethod("add", Object.class);
      m.invoke(list2, 20); // 可以添加成功
      System.out.println("list2 length:" + list2.size());
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

Python 之禅

作为一个前端攻城狮,工作中用到最多的是 JavaScript,但我接触的第一门编程语言却是 Python,大学时期只是略作了解,而现在,是时候重启 Python 之路,开启新的篇章。

缘起

在知乎上看到过一个话题:如果每人最多只能学习 5 门编程语言,你会选择哪 5 门,为什么?

之后我便在思考,在未来漫长的时间里,自己的选择是什么。在几经思量之后,谨慎得到了答案:JavaScript、Java 和 Python,并没有选择五种,因为我不确定其他选择是否正确,也不希望到最后自不量力。

对于 JavaScript,是目前的饭碗,工作的支柱,但对于这门语言,却又恨又爱。它生于仓促,兴于风口,多年来饱受争议。虽自 ES6 发布之后,JavaScript 语法大为改善,也添加了许多新特性。可它一些糟粕之处却又让我无法忍受,我所向往的一门优雅的、简而美的编程语言,而它目前并不深入我心。不过 JavaScript 社区生态一直在蓬勃发展,对于它的未来,我一直在翘首展望。

对于 Java,编程界的元老,行业的老大,几十年来经久不衰。网上流传的一句话:每一个程序员,都应该学一门静态语言。而 Java 目前来看是一个不错的选择。其实 Java 的学习计划很久之前便开始了,买了一大摞书,陆陆续续翻阅了不少,只是状态一直比较低迷,教材现在已经积灰,但却从未想过放弃,它的篇章,或许得搁置一段时间。

对于 Python,是我接触的一门编程语言,虽然接触得早,但对它得理解却并不深刻。虽然社区对它得吹捧之声从未断绝,现在都快被捧上神坛,大数据分析、人工智能、区块链等等方向发展得声势浩大。但我对学习这门语言并没有设置过高的追求。目前只是在 LeetCode 上刷题有些帮助。暂且入门,往后自说。

Python 之禅

最后在此敬上 Tim Peters 的 The Zen of python,希冀自己以后恪守规范,写出优雅简洁的代码。

Beautiful is better than ugly.
优美胜于丑陋(Python 以编写优美的代码为目标)

Explicit is better than implicit.
明了胜于晦涩(优美的代码应当是明了的,命名规范,风格相似)

Simple is better than complex.
简洁胜于复杂(优美的代码应当是简洁的,不要有复杂的内部实现)

Complex is better than complicated.
复杂胜于凌乱(如果复杂不可避免,那代码间也不能有难懂的关系,要保持接口简洁)

Flat is better than nested.
扁平胜于嵌套(优美的代码应当是扁平的,不能有太多的嵌套)

Sparse is better than dense.
间隔胜于紧凑(优美的代码有适当的间隔,不要奢望一行代码解决问题)

Readability counts.
可读性很重要(优美的代码是可读的)

Special cases aren't special enough to break the rules.
Although practicality beats purity.
即便假借特例的实用性之名,也不可违背这些规则(这些规则至高无上)

Errors should never pass silently.
Unless explicitly silenced.
不要包容所有错误,除非你确定需要这样做(精准地捕获异常,不写 except:pass 风格的代码)

In the face of ambiguity, refuse the temptation to guess.
当存在多种可能,不要尝试去猜测

There should be one-- and preferably only one --obvious way to do it.
而是尽量找一种,最好是唯一一种明显的解决方案(如果不确定,就用穷举法)

Although that way may not be obvious at first unless you're Dutch.
虽然这并不容易,因为你不是 Python 之父

Now is better than never.
Although never is often better than right now.
做也许好过不做,但不假思索就动手还不如不做(动手之前要细思量)

If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
如果你无法向人描述你的方案,那肯定不是一个好方案;反之亦然(方案测评标准)

Namespaces are one honking great idea -- let's do more of those!
命名空间是一种绝妙的理念,我们应当多加利用(倡导与号召)

参考文章:
Python 之禅

ES6标准入门-语法的扩展

ES6 对语法进行了大量扩展,包括且不限于字符串、正则、数值、函数、数组、对象的扩展等,此篇总结 ES6 新增的一些常用的新语法,一起来学习新姿势。

字符串的扩展

ES6 加强了对 Unicode 的支持,并且扩展了字符串对象。

Unicode 表示法

JavaScript 内部,字符以 UTF-16 的格式储存,每个字符固定为 2 个字节。但只限于码点在 \u0000~\uFFFF 之间的字符。对于 Unicode 码点大于 0xFFFF 的字符,需要 2 个字符,也就是 4 个字节存储。

同时如果在 \u 后面码点大于 0xFFFF,需要加上花括号才能正确显示,如 \u{20BB7}

// 大括号表示法与 UTF-16 等价
'\u{1F680}' === '\uD83D\uDE80'

有了这种表示法之后,JavaScript 共有 6 种方法可以表示一个字符。

'z' === 'z' // true
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
'\u{7A}' === 'z' // true

codePointAt() 和 fromCodePoint()

对于 4 个字节的字符,JavaScript 不能正确处理,字符串长度会被误判为 2,而且 charAt 方法无法读取整个字符,charCodeAt 方法只能分别返回前 2 个字节和后 2 个字节的值。

ES6 提供了 codePointAt 方法,能够正确处理 4 个字节储存的字符,返回一个字符的码点。

codePointAt 方法是测试一个字符是由 2 个字节还是 4 个字节组成的最简单方法。

function is32Bit(c) {
  return c.codePointAt(0) > 0xffff
}

于此同时,ES6 提供了 String.fromCodePoint 方法,作用同 codePointAt 相反,新方法可以识别大于 0xFFFF 的字符,弥补了 String.fromCharCode 方法的不足。

String.fromCodePoint(0x20bb7)
String.fromCodePoint(0x78, 0x1f680, 0x79) === 'x\uD83D\uDE80y' // true

上面的代码中,如果 String.fromCharCode 方法有多个参数,则它们会被合并成一个字符串返回。

注意:fromCodePoint 方法定义在 String 对象上,而 codePointAt 方法定义在字符串的实例对象上。

遍历器接口

ES6 为字符串添加了遍历器接口,使得字符串可以由 for...of 循环遍历。同时,遍历器的最大优点是可以识别大于 0xFFFF 的码点,传统的 for 循环无法识别这样的码点。

var text = String.fromCodePoint(0x20bb7)
for (let i of text) {
  console.log(i) // '𠮷'
}

includes()、startsWith()、endsWidth()

ES6 新增 3 种新方法用来判断一个字符串是否包含在另一个字符串中。

  • includes():返回布尔值,表示是否找到了参数字符串。
  • startsWith():返回布尔值,表示参数字符串是否在源字符串的头部。
  • endsWith():返回布尔值,表示参数字符串是否在源字符串的尾部。

注意:使用第二个参数 n 时,endsWith 针对前 n 个字符,而其他两个方法针对从第 n 个位置到字符串结束位置之间的字符。

repeat()

repeat 方法返回一个新字符串,表示将原字符串重复 n 次。如果参数是字符串,则会先转换成数字。

'na'.repeat('3') // 'nanana'

padStart()、padEnd()

这两个方法用于字符串长度补全。padStart() 用于头部补全,padEnd() 用于尾部补全。如果省略第二个参数,则会用空格来补全。

'x'.padStart(5, 'ab') // 'ababx'
'x'.padEnd(5, 'ab') // 'xabab'

'x'.padStart(4) // '   x'

padStart 的常见用途是为数值补全指定位数和提示字符串格式。

'1'.padStart(10, '0') // '0000000001'
'09-12'.padStart(10, 'YYYY-MM-DD') // 'YYYY-09-12'

标签模板

模板字符串可以紧跟在一个函数名后面,该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能(tagged template)。

标签模板是函数调用的一种特殊形式。整个表达式的返回值就是函数处理模板字符串后的返回值。

var a = 5
var b = 10
tag`Hello ${a + b} world ${a * b}`
// 等同于 tag(['Hello ', ' world ', ''], 15, 50);

标签函数的第一个参数是数组,数组成员是模板字符串中那些没有变量替换的部分,变量替换只发生在数组的成员之间。

正则的扩展

修饰符与属性

ES6 为正则添加了新的修饰符:u 修饰符、y 修饰符、s 修饰符和 sticky 属性、flags 属性。关于这部分内容,等深入学习正则时再做总结。

后行断言

JavaScript 语言的正则表达式只支持先行断言(lookahead)和先行否定断言(negative lookahead),不支持后行断言(lookbehind)和后行否定断言(negative lookbehind)。目前,有一个引入后行断言提案被提出,其中 V8 引擎已经支持。

“先行断言”指的是,x 只有在 y 前面才匹配,必须写成 /x(?=y)/ 的形式。比如,只匹配百分号之前的数字,要写成 /\d+(?=%)/。“先行否定断言”指的是,x 只有不在 y 前面才匹配,必须写成 /x(?!y)/ 的形式。比如,只匹配不在百分号之前的数字,要写成 /\d+(?!%)/

;/\d+(?=%)/.exec('100% of US presidents have been male') // ["100"]
;/\d+(?!%)/.exec('that’s all 44 of them') // ["44"]

“后行断言”正好与“先行断言”相反,x 只有在 y 后面才匹配,必须写成 /(?<=y)x/ 的形式,比如,只匹配美元符号之后的数字,要写成 /(?<=\$)\d+/。“后行否定断言”则与“先行否定断言”相反,x 只有不在 y 后面才匹配,必须写成 /(?<!y)x/ 的形式。比如,只匹配不在美元符号后面的数字,要写成 /(?<!\$)\d+/

;/(?<=\$)\d+/.exec('Benjamin Franklin is on the $100 bill') // ["100"]
;/(?<!\$)\d+/.exec('it’s is worth about €90') // ["90"]

“先行断言”和“后行断言”中括号部分都是不计入返回结果的:

const RE_DOLLAR_PREFIX = /(?<=\$)foo/g
'$foo %foo foo'.replace(RE_DOLLAR_PREFIX, 'bar') // '$bar %foo foo'

“后行断言”的实现需要先匹配 /(?<=y)x/ 的 x,然后再回到左边匹配 y 的部分。这种“先右后左”的执行顺序与所有其他正则操作相反,导致了一些不符合预期的结果。

;/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
;/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]

其次,“后行断言”的反斜杠引用也与通常的顺序相反,必须放在对应的括号之前。

;/(?<=(o)d\1)r/.exec('hodor') // null
;/(?<=\1d(o))r/.exec('hodor') // ["r", "o"]
// 完整输出:["r", "o", index: 4, input: "hodor"]

上面的代码中,后行断言的反斜杠引用(\1)必须放在前面才可以,放在括号的后面就不会得到匹配结果。因为后行断言是先从左到右扫描,发现匹配以后再回过头从右到左完成反斜杠引用。

扩展

exec() 方法用于检索字符串中的正则表达式的匹配。如果 exec() 找到了匹配的文本,则返回一个结果数组。否则,返回 null。此数组的第 0 个元素是与正则表达式相匹配的文本,第 1 个元素是与 RegExpObject 的第 1 个子表达式相匹配的文本(如果有的话),以此类推。

除了数组元素和 length 属性之外,exec() 方法还返回两个属性。index 属性声明的是匹配文本的第一个字符的位置。input 属性则存放的是被检索的字符串 string。

在调用非全局的 RegExp 对象的 exec() 方法时,返回的数组与调用方法 String.match() 返回的数组是相同的。

但是,当 RegExpObject 是一个全局正则表达式时,exec() 的行为就稍微复杂一些。它会在 RegExpObject 的 lastIndex 属性指定的字符处开始检索字符串 string。当 exec() 找到了与表达式相匹配的文本时,在匹配后,它将把 RegExpObject 的 lastIndex 属性设置为匹配文本的最后一个字符的下一个位置。这就是说,可以通过反复调用 exec() 方法来遍历字符串中的所有匹配文本。当 exec() 再也找不到匹配的文本时,它将返回 null,并把 lastIndex 属性重置为 0。

数值的扩展

二进制与八进制表示法

ES6 提供了二进制和八进制数值的新写法,分别用前缀 0b(或 0B)和 0o(或 0O)表示。

如果要将使用 0b 和 0o 前缀的字符串数值转为十进制数值,要使用 Number 方法。

Number('0b111') // 7
Number('0o10') // 8

Number.isFinite()、Number.isNaN()

ES6 在 Number 对象上新提供了 Number.isFinite() 和 Number.isNaN() 两个方法。

Number.isFinite() 用来检查一个数值是否为有限的(finite)。

Number.isNaN() 用来检查一个值是否为 NaN。

这两个新方法与传统的全局方法 isFinite() 和 isNaN() 的区别在于,传统方法先调用 Number() 将非数值转为数值,再进行判断,而新方法只对数值有效,对于非数值一律返回 false。

这两个方法皆可在 ES5 中部署:

;(function(global) {
  var global_isFinite = global.isFinite
  var global_isNaN = global.isNaN

  Object.defineProperty(Number, 'isFinite', {
    value: function isFinite(value) {
      return typeof value === 'number' && global_isFinite(value)
    },
    configurable: true,
    enumerable: false,
    writable: true
  })

  Object.defineProperty(Number, 'isNaN', {
    value: function isNaN(value) {
      return typeof value === 'number' && global_isNaN(value)
    },
    configurable: true,
    enumerable: false,
    writable: true
  })
})(this)

Number.parseInt()、Number.parseFloat()

ES6 将全局方法 parseInt() 和 parseFloat() 移植到了 Number 对象上面,行为完全保持不变。这样做的目的是逐步减少全局性方法,使得语言逐步模块化。

Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true

Number.isInteger()

Number.isInteger() 用来判断一个值是否为整数。需要注意:在 JavaScript 内部,整数和浮点数是同样的储存方法,所以 3 和 3.0 被视为同一个值。

Number.isInteger(3.0) // true

ES5 可以通过下面的代码部署 Number.isInteger():

;(function(global) {
  var floor = Math.floor,
    isFinite = global.isFinite
  Object.defineProperty(Number, 'isInteger', {
    value: function isInteger(value) {
      return typeof value === 'number' && isFinite(value) && floor(value) === value
    },
    configurable: true,
    enumerable: false,
    writable: true
  })
})(this)

Number.EPSILON

ES6 在 Number 对象上面新增一个极小的常量 Number.EPSILON,目的在于为浮点数计算设置一个误差范围。

如果计算误差能够小于 Number.EPSILON,就可以认为得到了正确结果。

function withinErrorMargin(left, right) {
  return Math.abs(left - right) < Number.EPSILON
}

安全整数和 Number.isSafeInteger()

JavaScript 能够准确表示的整数范围在 -2^53 到 2^53 之间(不含两个端点),超过这个范围就无法精确表示。

Math.pow(2, 53) // 输出:9007199254740992
9007199254740993 // 输出:9007199254740992,超出范围不再精确
9007199254740993 === 9007199254740992 // true

ES6 引入了 Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 两个常量,用来表示这个范围的上下限。

Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1 // true
Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER // true

Number.isSafeInteger() 则是用来判断一个整数是否落在这个范围之内。

指数运算符

ES6 新增了一个指数运算符 **。指数运算符可以与等号结合,形成一个新的赋值运算符**=

let a = 2
a **= 3 // 8

Math 对象的扩展

ES6 在 Math 对象上新增了 17 个与数学相关的方法。所有这些方法都是静态方法,只能在 Math 对象上调用。

  • Math.trunc 方法用于去除一个数的小数部分,返回整数部分。
  • Math.sign 方法用来判断一个数到底是正数、负数,还是零。对于非数值,会先将其转换为数值。其返回值有 5 种情况。参数位正数返回 +1;参数为负数返回 -1;参数为 0 返回 0;参数为 -0 返回 -0;参数为其他值返回 NaN。
  • Math.cbrt 方法用于计算一个数的立方根。
  • JavaScript 的整数使用 32 位二进制形式表示,Math.clz32 方法返回一个数的 32 位无符号整数形式有多少个前导 0。
  • Math.imul 方法返回两个数以 32 位带符号整数形式相乘的结果,返回的也是一个 32 位的带符号整数。大多数情况下,Math.imul(a, b) 与 a*b 的结果是相同的,即该方法等同于 (a*b)|0 的效果(超过 32 位的部分溢出)。
  • Math.fround 方法返回一个数的单精度浮点数形式。
  • Math.hypot 方法返回所有参数的平方和的平方根。
  • Math.expm1(x) 返回 e-1,即 Math.exp(x)-1。
  • Math.log1p(x) 方法返回 ln(1+x),即 Math.log(1+x)。如果 x 小于 -1,则返回 NaN。
  • Math.log10(x) 返回以 10 为底的 x 的对数。如果 x 小于 0,则返回 NaN。
  • Math.log2(x) 返回以 2 为底的 x 的对数。如果 x 小于 0,则返回 NaN。
  • Math.sinh(x) 返回 x 的双曲正弦(hyperbolic sine)
  • Math.cosh(x) 返回 x 的双曲余弦(hyperbolic cosine)
  • Math.tanh(x) 返回 x 的双曲正切(hyperbolic tangent)
  • Math.asinh(x) 返回 x 的反双曲正弦(inverse hyperbolic sine)
  • Math.acosh(x) 返回 x 的反双曲余弦(inverse hyperbolic cosine)
  • Math.atanh(x) 返回 x 的反双曲正切(inverse hyperbolic tangent)

函数的扩展

默认参数

函数默认参数用法不再做介绍,不过有三点需要注意:

  1. 参数变量是默认声明的,所以不能用 let 或 const 再次声明。
  2. 参数默认值是惰性求值的。
let x = 99
function foo(p = x + 1) {
  console.log(p)
}
foo() // 100
x = 100
foo() // 101
  1. 触发默认值需要严格等于 undefined(与解构赋值一样)。

函数的 length 属性的含义是该函数预期传入的参数个数。指定了默认值以后,预期传入的参数个数就不包括这个参数了,函数的 length 属性将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length 属性将失真。同理,rest 参数也不会计入 length 属性。

;(function(a) {}.length) // 1
;(function(a = 5) {}.length) // 0
;(function(a, b, c = 5) {}.length) // 2

如果设置了默认值的参数不是尾参数,那么 length 属性也不再计入后面的参数。

;(function(a = 0, b, c) {}.length) // 0

利用参数默认值可以指定某一个参数不得省略,如果省略就抛出一个错误:

function throwIfMissing() {
  throw new Error('Missing parameter')
}
function foo(mustBeProvided = throwIfMissing()) {
  return mustBeProvided
}
foo() // Error: Missing parameter

rest 参数

使用 rest 参数可以取代之前使用的 arguments 对象。

// arguments变量的写法
function foo() {
  return Array.prototype.slice.call(arguments).sort()
}
// rest参数的写法
const bar = (...numbers) => numbers.sort()

严格模式

ES6 规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则就会报错。

这样规定的原因是,函数内部的严格模式同时适用于函数体和函数参数。但是,函数执行时,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方:只有从函数体之中才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。

有两种方法可以规避这种限制。第一种是设定全局性的严格模式,第二种是把函数包在一个无参数的立即执行函数里面。

const doSomething = (function() {
  'use strict'
  return function(value = 42) {
    return value
  }
})()

name 属性

函数的 name 属性返回该函数的函数名。

如果将一个匿名函数赋值给一个变量,ES5 的 name 属性会返回空字符串,而 ES6 的 name 属性会返回实际的函数名。

var f = function() {}
// ES5
f.name // ""
// ES6
f.name // "f"

如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的 name 属性都返回这个具名函数原本的名字。

const bar = function baz() {}
// ES5 and ES6
bar.name // "baz"

Function 构造函数返回的函数实例,name 属性的值为 anonymous。

new Function().name // "anonymous"

bind 返回的函数,name 属性值会加上 bound 前缀。

function foo() {}
foo.bind({}).name // "bound foo"
;(function() {}.bind({}).name) // "bound "

箭头函数

箭头函数有以下几个使用注意事项。

  1. 函数体内的 this 对象就是定义时所在的对象,而不是使用时所在的对象。
  2. 不可以当作构造函数。也就是说,不可以使用 new 命令,否则会抛出一个错误。
  3. 不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
  4. 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。

使用箭头函数实现部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。

const pipeline = (...funcs) => val => funcs.reduce((a, b) => b(a), val)
const plus1 = a => a + 1
const mult2 = a => a * 2
const addThenMult = pipeline(plus1, mult2)
addThenMult(5) // 12

绑定 this

ES7 的一个提案提出了“函数绑定”(function bind)运算符,用来取代 call、apply、bind 调用。

函数绑定运算符是并排的双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象作为上下文环境(即 this 对象)绑定到右边的函数上。

foo::bar
// 等同于
bar.bind(foo)
foo::bar(...arguments)
// 等同于
bar.apply(foo, arguments)

尾调用优化【重点】

尾调用

尾调用(Tail Call)是函数式编程的一个重要概念,指某个函数的最后一步是调用另一个函数。如下所示:

function f(x) {
  return g(x)
}

以下情况都不属于尾调用:

// 调用函数后还有赋值操作
function a(x) {
  let y = g(x)
  return y
}

// 同上
function b(x) {
  return g(x) + 1
}

// 最后一步 return undefined
function c(x) {
  g(x)
}

尾调用之所以与其他调用不同,就在于其特殊的调用位置。

函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数 A 的内部调用函数 B,那么在 A 的调用帧上方还会形成一个 B 的调用帧。等到 B 运行结束,将结果返回到 A,B 的调用帧才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用帧,以此类推。所有的调用帧就形成一个“调用栈”(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,直接用内层函数的调用帧取代外层函数的即可。

function f() {
  let m = 1
  let n = 2
  return g(m + n)
}
f()

// 等同于
function f() {
  return g(3)
}
f()

// 等同于
g(3)

上面的代码中,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于调用 g 之后,函数 f 就结束了,所以执行到最后一步,完全可以删除 f(x) 的调用帧,只保留 g(3) 的调用帧。这就叫作“尾调用优化”(Tail Call Optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。

注意:只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。

function addOne(a) {
  var one = 1
  function inner(b) {
    return b + one
  }
  return inner(a)
}

上面的函数不会进行尾调用优化,因为内层函数 inner 用到了外层函数 addOne 的内部变量 one。

尾递归

函数调用自身称为递归。如果尾调用自身就称为尾递归。

递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。

下面以计算阶乘为例:

function factorial(n) {
  if (n === 1) return 1
  return n * factorial(n - 1)
}
factorial(5) // 120

计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度为 O(n)。

如果改写成尾递归,只保留一个调用记录,则复杂度为 O(1)。

function factorial(n, total) {
  if (n === 1) return total
  return factorial(n - 1, n * total)
}
factorial(5, 1) // 120

再以计算 Fibonacci 数列为例,非尾调用的 Fibonacci 数列实现容易堆栈溢出:

function Fibonacci(n) {
  if (n <= 1) return 1
  return Fibonacci(n - 1) + Fibonacci(n - 2)
}

Fibonacci(100) // 堆栈溢出

而进行尾调用优化的 Fibonacci 数列实现如下:

function Fibonacci(n, ac1 = 1, ac2 = 1) {
  if (n <= 1) return ac2
  return Fibonacci(n - 1, ac2, ac1 + ac2)
}

Fibonacci(100) // 573147844013817200000

由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 第一次明确规定,所有 ECMAScript 的实现都必须部署“尾调用优化”。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。

尾递归的实现往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数,这样的缺点是不太直观。

有两种方法可以解决,方法一是在尾递归函数之外再提供一个正常形式的函数:

function tailFactorial(n, total) {
  if (n === 1) return total
  return tailFactorial(n - 1, n * total)
}
function factorial(n) {
  return tailFactorial(n, 1)
}
factorial(5) // 120

或者使用函数柯里化。函数式编程有一个概念,叫作柯里化(currying),意思是将多参数的函数转换成单参数的形式。柯里化过程中可以预先填入参数。

function currying(fn, n) {
  return function(m) {
    return fn.call(this, m, n)
  }
}
function tailFactorial(n, total) {
  if (n === 1) return total
  return tailFactorial(n - 1, n * total)
}
const factorial = currying(tailFactorial, 1)
factorial(5) // 120

方法二是使用函数默认参数:

function factorial(n, total = 1) {
  if (n === 1) return total
  return factorial(n - 1, n * total)
}
factorial(5) // 120

总结一下,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言(比如 Lua、ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。

严格模式

ES6 的尾调用优化只在严格模式下开启,正常模式下是无效的。这是因为,在正常模式下函数内部有两个变量,可以跟踪函数的调用栈。

  1. func.arguments:返回调用时函数的参数。
  2. func.caller:返回调用当前函数的那个函数。

尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。

尾递归优化的实现

尾递归优化只在严格模式下生效,在正常模式下,可以自己实现尾递归优化。

function sum(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  } else {
    return x
  }
}
sum(1, 100000) // Uncaught RangeError: Maximum call stack size exceeded(…)

上面的递归函数,x 为累加值,y 为递归次数,递归次数过大就会报错。

蹦床函数(trampoline)可以将递归执行转为循环执行,它接受函数作为参数,只要函数执行后返回函数,就继续执行。

然后将原来的递归函数改写为每一步返回另一个函数。

// 蹦床函数
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f()
  }
  return f
}

function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1)
  } else {
    return x
  }
}

trampoline(sum(1, 100000)) // 100001

然而蹦床函数并不是真正的尾递归优化,下面的实现才是:

function tco(f) {
  var value
  var active = false
  var accumulated = []
  return function accumulator() {
    accumulated.push(arguments)
    if (!active) {
      active = true
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift())
      }
      active = false
      return value
    }
  }
}
var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  } else {
    return x
  }
})
sum(1, 100000) // 100001

上面的代码中,tco 函数是尾递归优化的实现,它的奥妙就在于状态变量 active。默认情况下,这个变量是不被激活的。一旦进入尾递归优化的过程,这个变量就被激活了。然后,每一轮递归 sum 返回的都是 undefined,所以就避免了递归执行;而 accumulated 数组存放每一轮 sum 执行的参数,总是有值的,这就保证了 accumulator 函数内部的 while 循环总会执行,很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。

数组的扩展

扩展运算符

扩展运算符(spread)如同 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。

由于扩展运算符可以展开数组,所以不再需要使用 apply 方法将数组转为函数的参数。

// ES5 的写法
Math.max.apply(null, [14, 3, 77])

// ES6 的写法
Math.max(...[14, 3, 77])

扩展运算符还可以很方便地将一个数组添加到另一个数组的尾部:

// ES5的写法
var arr1 = [0, 1, 2]
var arr2 = [3, 4, 5]
Array.prototype.push.apply(arr1, arr2)

// ES6 的写法
var arr1 = [0, 1, 2]
var arr2 = [3, 4, 5]
arr1.push(...arr2)

扩展运算符可以将字符串转为真正的数组,且能够正确识别 32 位的 Unicode 字符:

'\uD83D\uDE80'.length // 2
[...'\uD83D\uDE80'].length // 1

因此,正确返回字符串长度的函数可以像下面这样写:

function length(str) {
  return [...str].length
}

凡是涉及操作 32 位 Unicode 字符的函数都有这个问题。因此,最好都用扩展运算符改写。

let str = 'x\uD83D\uDE80y'
str
  .split('')
  .reverse()
  .join('') // 输出错误:'y\uDE80\uD83Dx'
;[...str].reverse().join('') // 输出正确: 'y\uD83D\uDE80x'

扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,如 Map 结构。

let map = new Map([[1, 'one'], [2, 'two'], [3, 'three']])
let arr = [...map.keys()] // [1, 2, 3]

Generator 函数运行后会返回一个遍历器对象,因此也可以使用扩展运算符。

var go = function*() {
  yield 1
  yield 2
  yield 3
}
;[...go()] // [1, 2, 3]

Array.from()

Array.from 方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)对象(包括 ES6 新增的数据结构 Set 和 Map)。

Array.from 相较于扩展运算符的优势是支持类数组对象,所谓类数组对象,本质特征只有一点,即必须有 length 属性。因此,任何有 length 属性的对象,都可以通过 Array.from 方法转为数组,而这种情况扩展运算符无法转换。

Array.from 还可以接受第二个参数,作用类似于数组的 map 方法,用来对每个元素进行处理,将处理后的值放入返回的数组。

Array.from(arrayLike, x => x * x)
// 等同于
Array.from(arrayLike).map(x => x * x)

如果 map 函数里面用到了 this 关键字,还可以传入 Array.from 第三个参数,用来绑定 this。

同扩展运算符一样,Array.from() 也可以将字符串转换为数组,并且能正确识别码点大于 \uFFFF 的字符。

Array.of()

Array.of 方法用于将一组值转换为数组。这个方法的主要目的是弥补数组构造函数 Array() 的不足。因为参数个数的不同会导致 Array() 的行为有差异。

Array() // []
Array(3) // [, , ,]
Array(3, 11, 8) // [3, 11, 8]

Array.of 方法可以用下面的代码模拟实现:

function ArrayOf() {
  return [].slice.call(arguments)
}

copyWithin()

数组实例的 copyWithin 方法会在当前数组内部将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。

Array.prototype.copyWithin(target, (start = 0), (end = this.length))

它接受 3 个参数:

  • target(必选):从该位置开始替换数据。
  • start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示倒数。
  • end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示倒数。
;[1, 2, 3, 4, 5].copyWithin(0, 3) // [4, 5, 3, 4, 5]

find() 和 findIndex()

数组实例的 find 方法和 findIndex 都是用来查找数组中的匹配项。这两个方法都可以发现 NaN,弥补了数组的 IndexOf 方法的不足。

;[NaN].indexOf(NaN) // -1
;[NaN].findIndex(y => Object.is(NaN, y)) // 0

这两个方法都可以接受第二个参数,用来绑定回调函数的 this 对象。

fill()

fill 方法使用给定值填充一个数组。该方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。

entries()、keys() 和 values()

ES6 提供了 3 个新方法 entries()、keys() 和 values() 用于遍历数组。

它们都返回一个遍历器对象,可用 for...of 循环遍历。keys() 是对键名的遍历,values() 是对键值的遍历,entries() 是对键值对的遍历。

for (let index of ['a', 'b'].keys()) {
  console.log(index)
}
// 0
// 1
for (let elem of ['a', 'b'].values()) {
  console.log(elem)
}
// 'a'
// 'b'
for (let [index, elem] of ['a', 'b'].entries()) {
  console.log(index, elem)
}
// 0 "a"
// 1 "b"

如果不使用 for...of 循环,可以手动调用遍历器对象的 next 方法进行遍历。

let letter = ['a', 'b']
let entries = letter.entries()
console.log(entries.next().value) // [0, 'a']
console.log(entries.next().value) // [1, 'b']

includes()

Array.prototype.includes 方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的 includes 方法类似。

indexOf 其内部使用严格相等运算符(===)进行判断,会导致对 NaN 的误判,而 includes 方法能正确识别 NaN。

;[NaN].indexOf(NaN) // -1
;[NaN].includes(NaN) // true

数组的空位

数组的空位指数组的某一个位置没有任何值。空位不是 undefined,一个位置的值等于 undefined 依然是有值的。空位是没有任何值的,in 运算符可以说明这一点。

0 in [undefined, undefined, undefined] // true
0 in [, , ,] // false

上面的代码说明,第一个数组的 0 号位置是有值的,第二个数组的 0 号位置没有值。

ES5 对空位的处理很不一致,大多数情况下会忽略空位。

  • forEach()、filter()、every() 和 some() 都会跳过空位。
  • map()会跳过空位,但会保留这个值。
  • join() 和 toString() 会将空位视为 undefined,而 undefined 和 null 会被处理成空字符串。

ES5 中空位表现如下:

;[, 'a'].forEach((x, i) => console.log(i)) // 1
;['a', , 'b'].filter(x => true) // ['a','b']
;[, 'a'].every(x => x === 'a') // true
;[, 'a'].some(x => x !== 'a') // false
;[, 'a'].map(x => 1) // [,1]
;[, 'a', undefined, null].join('#') // "#a##"
;[, 'a', undefined, null].toString() // ",a,,"

ES6 明确将空位转为 undefined。具体体现在:

  • Array.from 方法会将数组的空位转为 undefined。
  • 扩展运算符(...)会将空位转为 undefined。
  • copyWithin()会连空位一起复制。
  • for...of 循环会遍历空位。
  • entries()、keys()、values()、find() 和 findIndex() 会将空位处理成 undefined。

ES6 中空位表现如下:

Array.from(['a', , 'b']) // [ "a", undefined, "b" ]
;[...['a', , 'b']] // [ "a", undefined, "b" ]
;[, 'a', 'b', ,].copyWithin(2, 0) // [,"a",,"a"]
new Array(3).fill('a') // ["a","a","a"]
;[...[, 'a'].entries()] // [[0,undefined], [1,"a"]]
;[...[, 'a'].keys()] // [0,1]
;[...[, 'a'].values()] // [undefined,"a"]
;[, 'a'].find(x => true) // undefined
;[, 'a'].findIndex(x => true) // 0

let arr = [, ,]
for (let i of arr) {
  console.log(1)
}
// 输出 3 次 1

由于空位的处理规则非常不统一,所以建议避免出现空位。

对象的扩展

方法的 name 属性

同函数的 name 属性一样,对象方法的 name 属性也返回函数名。

如果对象的方法使用了取值函数(getter)和存值函数(setter),则 name 属性不是在该方法上面,而是在该方法属性的描述对象的 get 和 set 属性上面,返回值是方法名前加上 get 和 set。

const obj = {
  get foo() {},
  set foo(x) {}
}
obj.foo.name // TypeError: Cannot read property 'name' of undefined

const descriptor = Object.getOwnPropertyDescriptor(obj, 'foo')
descriptor.get.name // "get foo"
descriptor.set.name // "set foo"

name 属性有两种特殊情况:bind 方法创造的函数,name 属性返回 “bound” 加上原函数的名字;Function 构造函数创造的函数,name 属性返回 “anonymous”。

new Function().name // "anonymous"

var doSomething = function() {}
doSomething.bind().name // "bound doSomething"

如果对象的方法是一个 Symbol 值,那么 name 属性返回的是这个 Symbol 值的描述。

const key1 = Symbol('description')
const key2 = Symbol()
let obj = { [key1]() {}, [key2]() {} }
obj[key1].name // "[description]"
obj[key2].name // ""

Object.is()

ES5 中严格相等运算符(===)有两个缺点: 一是 NaN 不等于自身,二是 +0 等于 -0。JavaScript 缺乏这样一种运算:在所有环境中,只要两个值是一样的,它们就应该相等。

ES6 提出了 “Same-value equality”(同值相等)算法用来解决这个问题。Object.is 就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格相等运算符(===)的行为基本一致。

不同之处只有两个:一是 +0 不等于 -0,二是 NaN 等于自身

;+0 === -0 //true
NaN === NaN // false

Object.is(+0, -0) // false
Object.is(NaN, NaN) // true

ES5 可以通过下面的代码部署 Object.is:

Object.defineProperty(Object, 'is', {
  value: function(x, y) {
    if (x === y) {
      // 针对+0 不等于 -0的情况
      return x !== 0 || 1 / x === 1 / y
    }
    // 针对NaN的情况
    return x !== x && y !== y
  },
  configurable: true,
  enumerable: false,
  writable: true
})

Object.assign()

Object.assign 方法用于将源对象(source)的所有可枚举属性复制到目标对象(target)。

如果只有一个参数,Object.assign 会直接返回该参数,注意和扩展运算符不同,是相同引用。

非对象参数会先转换成对象,由于 undefined 和 null 无法转成对象,所以如果将它们作为首参数会报错,非首参数则跳过。

其他类型的值(即数值、字符串和布尔值)不在首参数也不会报错。但是,除了字符串会以数组形式复制到目标对象,其他值都不会产生效果。

Object(true) // {[[PrimitiveValue]]: true}
Object(10) // {[[PrimitiveValue]]: 10}
Object('abc') // {0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc"}

上面的代码中,布尔值、数值、字符串分别转成对应的包装对象,可以看到它们的原始值都在包装对象的内部属性 [[Primi-tiveValue]] 上面,这个属性是不会被 Object.assign 复制的。只有字符串的包装对象会产生可枚举的实义属性,那些属性则会被拷贝。

Object.assign 只复制源对象的自身属性,也不复制不可枚举的属性(enumer-able:false)。

注意,Object.assign 可以用来处理数组,但是会把数组视为对象来处理。

Object.assign([1, 2, 3], [4, 5]) // [4, 5, 3]

上面的代码中,Object.assign 把数组视为属性名为 0、1、2 的对象,因此目标数组的 0 号属性 4 覆盖了原数组的 0 号属性 1。

Object.assign 可以用来克隆对象,不过,采用这种方法只能克隆原始对象自身的值,不能克隆它继承的值。如果想要保持继承链,可以采用下面的代码:

function clone(origin) {
  let originProto = Object.getPrototypeOf(origin)
  return Object.assign(Object.create(originProto), origin)
}

属性的可枚举性

对象的每一个属性都具有一个描述对象(Descriptor),用于控制该属性的行为。Object.getOwnPropertyDescriptor 方法可以获取该属性的描述对象。

let obj = { foo: 123 }
Object.getOwnPropertyDescriptor(obj, 'foo')
//  {
//    value: 123,
//    writable: true,
//    enumerable: true,
//    configurable: true
//  }

描述对象的 enumerable 属性称为“可枚举性”,如果该属性为 false,就表示某些操作会忽略当前属性。

ES5 有 3 个操作会忽略 enumerable 为 false 的属性:

  • for...in 循环:只遍历对象自身的和继承的可枚举属性。
  • Object.keys():返回对象自身的所有可枚举属性的键名。
  • JSON.stringify():只串行化对象自身的可枚举属性。

ES6 新增了 1 个操作 Object.assign(),会忽略 enumerable 为 false 的属性,只复制对象自身的可枚举属性。

上面 4 个操作之中,只有 for...in 会返回继承的属性。实际上,引入 enumerable 的最初目的就是让某些属性可以规避掉 for...in 操作。比如,对象原型的 toString 方法以及数组的 length 属性,就通过这种手段而不会被 for...in 遍历到。

Object.getOwnPropertyDescriptor(Object.prototype, 'toString').enumerable // false
Object.getOwnPropertyDescriptor([], 'length').enumerable // false

另外,ES6 规定,所有 Class 的原型的方法都是不可枚举的。

Object.getOwnPropertyDescriptor(
  class {
    foo() {}
  }.prototype,
  'foo'
).enumerable // false

总的来说,操作中引入继承的属性会让问题复杂化,尽量不要用 for...in 循环,而用 Object.keys() 代替。

属性的遍历

ES6 一共有 5 种方法可以遍历对象的属性:

  1. for...in 循环遍历对象自身的和继承的可枚举属性(不含 Symbol 属性)
  2. Object.keys(obj) 返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含 Symbol 属性)
  3. Object.getOwnPropertyNames(obj) 返回一个数组,包含对象自身的所有属性(不含 Symbol 属性,但是包括不可枚举属性)
  4. Object.getOwnPropertySymbols(obj) 返回一个数组,包含对象自身的所有 Symbol 属性
  5. Reflect.ownKeys(obj) 返回一个数组,包含对象自身的所有属性,不管属性名是 Symbol 还是字符串,也不管是否可枚举

以上 5 种方法遍历对象的属性时都遵守同样的属性遍历次序规则:

  • 首先遍历所有属性名为数值的属性,按照数字排序。
  • 其次遍历所有属性名为字符串的属性,按照生成时间排序。
  • 最后遍历所有属性名为 Symbol 值的属性,按照生成时间排序。
Reflect.ownKeys({ [Symbol()]: 0, b: 0, 10: 0, 2: 0, a: 0 })
// ['2', '10', 'b', 'a', Symbol()]

__proto__、Object.setPrototypeOf()、Object.getPrototypeOf()

__proto__ 是一个内部属性,标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用 Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)或 Object.create()(生成操作)代替。

在实现上,__proto** 调用的是 Object.prototype.__proto**。

Object.defineProperty(Object.prototype, '__proto__', {
  get() {
    let _thisObj = Object(this)
    return Object.getPrototypeOf(_thisObj)
  },
  set(proto) {
    if (this === undefined || this === null) {
      throw new TypeError()
    }
    if (!isObject(this)) {
      return undefined
    }
    if (!isObject(proto)) {
      return undefined
    }
    let status = Reflect.setPrototypeOf(this, proto)
    if (!status) {
      throw new TypeError()
    }
  }
})
function isObject(value) {
  return Object(value) === value
}

如果一个对象本身部署了 __proto__ 属性,则该属性的值就是对象的原型。

Object.setPrototypeOf()

Object.setPrototypeOf 方法的作用与 __proto__ 相同,用来设置一个对象的 prototype 对象,返回参数对象本身。它是 ES6 正式推荐的设置原型对象的方法。

// 格式
Object.setPrototypeOf(object, prototype)

// 等同于
function (object, prototype) {
  object.__proto__ = prototype
  return object
}

如果第一个参数不是对象,则会自动转为对象。但是由于返回的还是第一个参数,所以这个操作不会产生任何效果。

由于 undefined 和 null 无法转为对象,所以如果第一个参数是 undefined 或 null,就会报错。

Object.setPrototypeOf(1, {}) === 1 // true
Object.setPrototypeOf('foo', {}) === 'foo' // true
Object.setPrototypeOf(true, {}) === true // true

Object.getPrototypeOf()

该方法与 setPrototypeOf 方法配套,用于读取一个对象的 prototype 对象。同样,如果参数不是对象,则会被自动转为对象。

Object.keys、Object.values()、Object.entries()

Object.keys、Object.values、Object.entries 方法都返回一个数组,成员分别是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键名、键值和键值对数组。

Object.entries 方法的另一个用处是将对象转为真正的 Map 结构。

var obj = { foo: 'bar', baz: 42 }
var map = new Map(Object.entries(obj))
map // Map { foo: "bar", baz: 42 }

对象的扩展运算符

使用扩展运算符可以克隆对象,包括复制对象的原型:

// 写法一
const clone1 = {
  __proto__: Object.getPrototypeOf(obj),
  ...obj
}
// 写法二
const clone2 = Object.assign(Object.create(Object.getPrototypeOf(obj)), obj)

上面的代码中,写法一的 __proto__ 属性在非浏览器的环境不一定部署,因此推荐使用写法二。

如果扩展运算符的参数是 null 或 undefined,则这两个值会被忽略,不会报错。

Object.getOwnPropertyDescriptors()

ES5 的 Object.getOwnPropertyDescriptor 方法用来返回某个对象属性的描述对象(descriptor)。

var obj = { p: 'a' }
Object.getOwnPropertyDescriptor(obj, 'p')
// {
//   value: "a",
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

ES2017 引入 Object.getOwnPropertyDescriptors 方法,返回指定对象所有自身属性(非继承属性)的描述对象。

const obj = {
  foo: 123,
  get bar() {
    return 'abc'
  }
}
Object.getOwnPropertyDescriptors(obj)
// {
//   foo: {
//     value: 123,
//     writable: true,
//     enumerable: true,
//     configurable: true },
//   bar: {
//     get: [Function: bar],
//     set: undefined,
//     enumerable: true,
//     configurable: true }
// }

Null 传导运算符

如果读取对象内部的某个属性,往往需要判断该对象是否存在。一般做法如下:

const firstName = (message && message.body && message.body.user && message.body.user.firstName) || 'default'

现有一个提案引入 “Null 传导运算符(?.)” 可以简化上面的写法:

const firstName = message?.body?.user?.firstName || 'default'

Python 文件操作

趁工作闲暇将之前用 Nodejs 写的微博爬虫用 Python 重构了一遍,可以算是入门 Python 的第一个练手作,虽然磕磕碰碰踩了不少坑,基本功能还算完成,这里记录一些实用方法。

文件操作

爬虫的核心登录模块复用了 Github 开源的第三方库,然后自己添加了 html 解析和文件保存预览的功能,爬虫涉及到的文件操作技巧记一下小本本。

获取或创建文件夹

import os

def get_or_create_folder():
    base_folder = os.path.abspath(os.path.dirname(__file__))
    folder = 'pictures' # 需要获取或打开的文件夹
    full_path = os.path.join(base_folder, folder)

    if not os.path.exists(full_path) or not os.path.isdir(full_path):
        print('Creating new directory at {}'.format(full_path))
        os.mkdir(full_path)

    return full_path

打开文件或文件夹

import platform
import subprocess

def open_folder(folder):
    print('Displaying cats in OS window.')
    os_name = platform.system()
    if os_name == 'Darwin':
        subprocess.call(['open', folder])
    elif os_name == 'Windows':
        subprocess.call(['explorer', folder])
    elif os_name == 'Linux':
        subprocess.call(['xdg-open', folder])
    else:
        print("We don't support your os: " + os_name)

下载图片

import os
import requests
import shutil

def download_img(folder, name, url):
    data = get_data_from_url(url)
    save_image(folder, name, data)


def get_data_from_url(url):
    response = requests.get(url, stream=True)
    return response.raw


def save_image(folder, name, data):
    file_name = os.path.join(folder, name + '.jpg')
    with open(file_name, 'wb') as fout:
        shutil.copyfileobj(data, fout)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.