vuex源码解析

666 阅读4分钟

什么是Vuex?

Vuex 是一个专为 Vue.js 应用程序开发的用于管理页面数据状态、提供统一数据操作的生态系统。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

针对以下问题进行源码解析

  1. vuex中的store实例是如何注入到vue的每个组件上的?
  2. state内部是如何实现支持模块配置和模块嵌套的?
  3. 在执行dispatch触发action(commit同理)的时候,只需传入(type, payload),action执行函数中第一个参数store从哪里获取的?
  4. vuex中的state如何和vue关联起来?
  5. 如何区分state是外部直接修改,还是通过mutation方法修改的?
  6. 调试时的“时空穿梭”功能是如何实现的?

vuex的核心流程

image.png

  1. Vue Components: vue中的组件用户操作的时候执行dispatch触发actions;
  2. dispatch: 操作actions的方法,并且是唯一操作Actions的方法;该方法返回一个promise对象;
  3. Actions: 处理同步或异步的操作,支持多个同名的方法,按照注册顺序触发执行;api的接口请求逻辑就写在此处;该方法支持返回一个promise对象,以便链式调用;
  4. commit: 操作mutations的方法,并且是唯一操作mutations的方法;
  5. mutations: 修改State状态的唯一方法;其他方法修改state在严格模式下会报错;并且方法名是唯一的;
  6. state: 状态对象;

源码解析

目录结构

image.png

  • module.js: 封装了Module类,此类的目的就是统一state数据的格式;
  • module-collection.js: 递归处理state数据,把state中的子模块全部转成统一的格式;
  • plugins: 修改日志等内部的插件;
  • helpers.js:提供action、mutations以及getters的查找API;
  • index.js: 源码的入口,封装了Store类;
  • util.js: 提供了工具方法如find、deepCopy、forEachValue以及assert等方法;

install方法

vuex是通过插件的形式注入到vue中

    Vue.use(Vuex)

Vue.use方法中执行了Vuex的install方法,因此Vuex中有install方法;

let Vue
function install (_Vue) {
    // 已经被注册过就直接返回
    if (Vue && _Vue === Vue) {
      {
        console.error(
          '[vuex] already installed. Vue.use(Vuex) should be called only once.'
        );
      }
      return
    }
    // 保存vue实例
    Vue = _Vue;
    // 执行applyMixin
    applyMixin(Vue);
}

install放中进行了重复判断,并且保存了Vue的实例,执行了applyMixin方法;

function applyMixin (Vue) {
    // 获取到vue的版本号
    var version = Number(Vue.version.split('.')[0]);
    // 如果版本大于等于2就通过mixin方法混入beforeCreate钩子
    if (version >= 2) {
      Vue.mixin({ beforeCreate: vuexInit });
    } else { // 小于2的版本通过重写vue的_init方法
      var _init = Vue.prototype._init;
      Vue.prototype._init = function (options) {
        if ( options === void 0 ) options = {};

        options.init = options.init
          ? [vuexInit].concat(options.init)
          : vuexInit;
        _init.call(this, options);
      };
    }
    function vuexInit () {
      // 获取到vue的options
      var options = this.$options;
      // store injection
      // 如果options存在store表示当前为父组件
      if (options.store) {
        this.$store = typeof options.store === 'function'
          ? options.store()
          : options.store;
      // 否则就是子组件,通过parent获取到父级上的$sore赋值给子实例的$store
      } else if (options.parent && options.parent.$store) {
        this.$store = options.parent.$store;
      }
    }
  }

applyMixin方法先获取到vue的版本号,通过版本号判断vue是否提供了mixin方法,如果大于等于2的版本就使用mixin混入的方式,把beforeCreate混入到各个组件中;如果是小于2的版本,重写vue上的init方法;init和beforeCreate中都执行了vuexInit方法;vuexInit方法主要把store挂载到vue实例上的$store属性上,如果是子组件实例就会从父组件的实例上的$store中获取到store,并且赋值给子组件实例上的$store,这样一层一层的就可以从父组件上获取到$store;

Root组件中有App组件,App组件中有A组件; image.png

Store类

export class Store {
    constructor (options = {}) {
     
      const {
        plugins = [],
        strict = false,
        devtools
      } = options

      // 初始化数据
      // 是否正则修改State数据
      this._committing = false
      // 存储所有的actions中的方法
      this._actions = Object.create(null)
      this._actionSubscribers = []
      // 存储所有的mutaions中的方法
      this._mutations = Object.create(null)
      // 存储所有的getters中的方法
      this._wrappedGetters = Object.create(null)
      // 通过ModulesCollection方法修改数据的格式
      this._modules = new ModuleCollection(options)
      // 存储命名空间的map
      this._modulesNamespaceMap = Object.create(null)
      // 存储subscribers
      this._subscribers = []

      const store = this
      const { dispatch, commit } = this
      // 重写dispatch,把store传递进去
      this.dispatch = function boundDispatch (type, payload) {
        return dispatch.call(store, type, payload)
      }
      // 重写commit,把store传递进去
      this.commit = function boundCommit (type, payload, options) {
        return commit.call(store, type, payload, options)
      }

      // 获取到严格模式的值
      this.strict = strict
      // 获取到根下的state
      const state = this._modules.root.state

      // 安装模块,把所有子模块下的state都放在根模块的State中,
      //所有模块下的mutations,actions,getter都存储到根下的
      //_mutations,_actions,_wrapperGetters
      installModule(this, state, [], this._modules.root)

      // 创建vue实例,把vue和store关联起来
      resetStoreState(this, state)

      // 执行插件
      plugins.forEach(plugin => plugin(this))
    }
}

Sotre类的构造函数中,主要初始化一些属性,重写了dispatch和commit方法,目的就是把store作为第一个参数传递进去;通过ModuleCollection类修改数据的格式方便后面的操作,通过installModule方法把数据中所有的getter,actions,mutations提取出来全部放在根对象下对应的属性上;通过resetStoreState方法创建vue实例把state数据和vue关联起来;最后执行所有的插件;下面进行每个方法的具体分析,看完下面的分析之后,再回到这里进行整体分析就会把整个过程串联起来了;

ModuleCollection类

ModuleCollection类主要是递归处理用户传递进来的State数据,把数据转成统一的格式;

// 用户传递进来的State数据
const store = new Vuex.Store({
      state: {
        age: 100
      },
      getters: {
        myAge(state){
          return state.age + 20
        }
      },
      mutations: {
        add(state, payload) {
          state.age += payload
        }
      },
      actions: {
        add ({ commit }, payload) {
          return new Promise(res => {
            setTimeout(() => {
              commit('add', payload)
              res()
            }, 2000)
          })
          
        }
      },
      modules: { // 使用模块的时候一定要有命名空间,否则会出问题
        a: {
          namespaced: true,
          state: {
            age: 1
          },
          getters: {
            myAge(state){
              return state.age + 20
            }
          },
          mutations: {
            add(state, payload) {
              state.age += payload
            }
          },
          modules: {
            b: {
              namespaced: true,
              state: {
                age: 'bbb'
              },
              mutations: {
                add(state, payload) {
                  state.age += payload
                }
              }
            }
          }
        },
        c: {
          namespaced: true,
          state: {
            age: 22
          },
          mutations: {
            add(state, payload) {
              state.age += payload
            }
          }
        }
      }
    })

根下面有a和c两个子模块,a下有b子模块; image.png

通过ModuleCollection类转换之后的结果如下:

image.png

每个模块的格式都是由context、runtime、state、_children、_rawModule组成;

  • context: 存储Store中的两个方法,和当前模块下的state和getters(包括子模块);
  • state: 当前模块下的state数据;
  • _children: 当前模块下的子模块;
  • _rawModule: 当前模块;
export default class ModuleCollection {
  constructor (rawRootModule) {
    this.register([], rawRootModule, false)
  }
  // 通过模块名的集合获取到对应的父模块
  get (path) {
    return path.reduce((module, key) => {
      return module.getChild(key)
    }, this.root)
  }

  register (path, rawModule, runtime = true) {
    // 通过Module类创建每个模块的实例,此类主要是定义模块的格式
    const newModule = new Module(rawModule, runtime)
    // 如果path的长度为0,表示此时为根模块
    if (path.length === 0) {
      this.root = newModule
    } else { // 否则就是子模块
      // 获取到当前子模块的父级
      const parent = this.get(path.slice(0, -1))
      // 把当前子模块放在父级的_children中
      parent.addChild(path[path.length - 1], newModule)
    }

    // 如果当前模块有子模块就进行遍历,通过递归的方式给当前模块下的子模块进行处理
    if (rawModule.modules) {
      forEachValue(rawModule.modules, (rawChildModule, key) => {
        // 递归处理,把当前模块的名称和之前模块的名称合并起来,这样就可以通过path获取到父级
        this.register(path.concat(key), rawChildModule, runtime)
      })
    }
  }
}

ModuleCollection类中主要通过Module类创建每个模块的实例,给每个模块统一数据格式;通过path的长度判断是根模块还是子模块,如果是子模块就通过遍历path获取到对应的父模块,把当前子模块存储到父模块的_children中,如果当前模块有modules,就进行遍历并且通过递归的形式处理子模块;

Module类

Module类主要是创建带有state,,runtime,_children,_rawModule属性的实例,并且提供操作_children的方法;提供遍历getter,actions,mutations的方法;

export default class Module {
  constructor (rawModule, runtime) {
    this.runtime = runtime
    // 初始_children
    this._children = Object.create(null)
    // 初始_rawModule,直接保存传递进来的rawModule
    this._rawModule = rawModule
   
    const rawState = rawModule.state
     // 初始state
    this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
  }
  // 获取命名空间
  get namespaced () {
    return !!this._rawModule.namespaced
  }
  // 给当前模块下的_children添加子模块
  addChild (key, module) {
    this._children[key] = module
  }
  // 删除当前模块下的_children下指定的子模块  
  removeChild (key) {
    delete this._children[key]
  }
  // 获取当前模块下的_children下指定的子模块  
  getChild (key) {
    return this._children[key]
  }
  // 判断当前模块下的_children下是否有指定的子模块  
  hasChild (key) {
    return key in this._children
  }
  // 遍历当前模块下的_children
  forEachChild (fn) {
    forEachValue(this._children, fn)
  }
  // 遍历当前模块下的Gettes
  forEachGetter (fn) {
    if (this._rawModule.getters) {
      forEachValue(this._rawModule.getters, fn)
    }
  }
  // 遍历当前模块下的actions
  forEachAction (fn) {
    if (this._rawModule.actions) {
      forEachValue(this._rawModule.actions, fn)
    }
  }
  // 遍历当前模块下的mutations
  forEachMutation (fn) {
    if (this._rawModule.mutations) {
      forEachValue(this._rawModule.mutations, fn)
    }
  }
}

以上就分析完ModuleCollection和Module类,这两个类主要是把用户传递进来的数据转成指定的格式,方便后续的操作;数据格式处理完成之后,就需要通过installModule方法对各个模块下的state,actions,getters,mutaion进行处理;

installModule方法

Store构造函数中通过ModuleCollection处理完数据格式之后,就通过installModule方法对格式化的数据进行了处理;

export function installModule (store, rootState, path, module, hot) {
  // 是否是根
  const isRoot = !path.length
  // 获取到对应的命名空间
  const namespace = store._modules.getNamespace(path)

  // 如果当前模块中定义了命名空间就把当前模块存储到_modulesNamespaceMap中
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }

  // 如果不是根 就是子模块
  if (!isRoot && !hot) {
    // 通过path模块名路径获取到对应的父级的State属性
    const parentState = getNestedState(rootState, path.slice(0, -1))
    // 获取到当前的模块名
    const moduleName = path[path.length - 1]
    // 通过_withCommit包裹执行修改State的方法
    store._withCommit(() => {
      // 把当前模块的State存储到父级的State下
      parentState[moduleName] = module.state
    })
  }
  // 给当前模块上添加Context属性
  const local = module.context = makeLocalContext(store, namespace, path)
  // 遍历当前模块下的mutation,通过registerMutation函数把每一个mutation中的方法都存储到根对象下的_mutations属性中
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })
  // 遍历当前模块下的action,通过registerAction函数把每一个action中的方法都存储到根对象下的_actions属性中
  module.forEachAction((action, key) => {
    const type = action.root ? key : namespace + key
    const handler = action.handler || action
    registerAction(store, type, handler, local)
  })
  // 遍历当前模块下的getter,通过registerGetter函数把每一个getter中的方法都存储到根对象下的_wrappedGetters属性中
  module.forEachGetter((getter, key) => {
    const namespacedType = namespace + key
    registerGetter(store, namespacedType, getter, local)
  })
  // 遍历当前模块下的modules,通过递归进行处理子模块
  module.forEachChild((child, key) => {
    installModule(store, rootState, path.concat(key), child, hot)
  })
}

以上是把子模块下的State属性都存放到根对象下的State中,并且通过_withCommit方法进行包裹,目的就是在严格模式下只能通过mutations修改State,其他方法都不能修改State;遍历当前模块下的mutation,action,getter,modules,把对应下面的每个方法都存储到根对象下对应的属性中;

function registerMutation (store, type, handler, local) {
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    handler.call(store, local.state, payload)
  })
}

function registerAction (store, type, handler, local) {
  const entry = store._actions[type] || (store._actions[type] = [])
  entry.push(function wrappedActionHandler (payload) {
    let res = handler.call(store, {
      dispatch: local.dispatch,
      commit: local.commit,
      getters: local.getters,
      state: local.state,
      rootGetters: store.getters,
      rootState: store.state
    }, payload)
    // 如果返回的结果不是promise,那么就通过promise进行包裹返回
    if (!isPromise(res)) {
      res = Promise.resolve(res)
    }
    if (store._devtoolHook) {
      return res.catch(err => {
        store._devtoolHook.emit('vuex:error', err)
        throw err
      })
    } else {
      return res
    }
  })
}

function registerGetter (store, type, rawGetter, local) {
  // 已经存在就直接返回
  if (store._wrappedGetters[type]) {
    return
  }
  store._wrappedGetters[type] = function wrappedGetter (store) {
    return rawGetter(
      local.state, // local state
      local.getters, // local getters
      store.state, // root state
      store.getters // root getters
    )
  }
}

以上就完成了下面格式的处理

image.png

如图可以看到_mutaion和_wrapperGetters下的key都带有/,是因为根据模块进行定义key,a模块下的add就是a/add;a模块下的b模块下的add就是a/b/add,那么这个是怎么做到的?

// 获取到对应的命名空间
 const namespace = store._modules.getNamespace(path)

主要通过ModuleCollection类下的getNamespace实现的,回过来看下此方法是怎么实现的

export default class ModuleCollection {
  ...
  getNamespace (path) {
    // 获取到根模块
    let module = this.root
    return path.reduce((namespace, key) => {
      module = module.getChild(key)
      return namespace + (module.namespaced ? key + '/' : '')
    }, '')
  }
}

遍历path,通过path中的模块名,不断的获取到对应的模块并且进行替换,如果有命名空间就通过/进行拼接,path遍历完就返回一个完整的路径;

以上就完成了store数据的格式修改,并且把对应的数据都添加到了根对象下对应的属性中;下面就是把Store中的数据和vue进行关联起来了;

resetStoreState方法

function resetStoreVM (store, state, hot) {
  // 存储上个vue实例
  const oldVm = store._vm

  // 存储getters
  store.getters = {}
  // 缓存getters
  store._makeLocalGettersCache = Object.create(null)
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  // 遍历getters
  forEachValue(wrappedGetters, (fn, key) => {
    // 存储到computed对象中
    computed[key] = partial(fn, store)
    // 给store.getters上定义Getter,并且是通过_vm[key]进行代理
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true // for local getters
    })
  })

  const silent = Vue.config.silent
  Vue.config.silent = true
  // 创建vue实例
  store._vm = new Vue({
    data: {
      $$state: state // 把state放在Data下的$$state属性上
    },
    computed, // 计算属性
  })
  Vue.config.silent = silent

  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }
  // 如果老的存在就进行销毁
  if (oldVm) {
    if (hot) {
      store._withCommit(() => {
        oldVm._data.$$state = null
      })
    }
    Vue.nextTick(() => oldVm.$destroy())
  }
}

function partial (fn, arg) {
  return function () {
    return fn(arg)
  }
}

resetStoreVM函数中主要创建了vue实例,遍历根上_wrappedGetters属性,把每个Getter作为vue的计算属性,并且把每个getter通过代理的形式又定义在Store下的getters属性上;把state属性作为vue的data下的$$state属性;这样在vue中操作的store下的state都是响应的;

完善Store上的其他方法

访问store上的state属性
    get state () {
      return this._vm._data.$$state
    }

Store.state其实就是访问Store._vm._data.$$state属性;

commit方法
commit (_type, _payload, _options) {
  const {
    type,
    payload,
    options
  } = unifyObjectStyle(_type, _payload, _options)

  const mutation = { type, payload }
  // 获取到对应的mutations
  const entry = this._mutations[type]
  // 如果不存在就返回
  if (!entry) {
    return
  }
  // 通过_withCommit方法进行包裹
  this._withCommit(() => {
    // 遍历执行mutations
    entry.forEach(function commitIterator (handler) {
      handler(payload)
    })
  })
  // 如果存在_subscribers就遍历执行
  this._subscribers
    .slice() 
    .forEach(sub => sub(mutation, this.state))
}

commit方法中获取当对应的mutations,遍历这个mutaions进行执行,并且通过_withCommit方法包裹执行的;

dispatch方法
dispatch (_type, _payload) {
  const {
    type,
    payload
  } = unifyObjectStyle(_type, _payload)

  const action = { type, payload }
  // 获取到对应的actions
  const entry = this._actions[type]
  if (!entry) {
    return
  }
  // 如果Action的长度大于1就通过promise.all进行执行,否则直接执行第一个函数
  const result = entry.length > 1
    ? Promise.all(entry.map(handler => handler(payload)))
    : entry[0](payload)
 // 返回一个promise
  return new Promise((resolve, reject) => {
    result.then(res => {
      resolve(res)
    }, error => {
      reject(error)
    })
  })
}

dispatch方法主要获取到对应的actions,如果actions数组的长度大于1就通过Promise.all进行执行,否则就执行数组的第一个函数,最后返回一个promise;

replaceState方法
replaceState (state) {
  this._withCommit(() => {
    this._vm._data.$$state = state
  })
}

通过_withCommit包裹修改了_vm._data.$$state

_withCommit方法
_withCommit (fn) {
  this._committing = true
  fn()
  this._committing = false
}

进入_withCommit方法的时候把committing属性设置为true,表示此时正在执行fn函数,当fn执行完毕之后就会改为false;此方法主要是防止用户调用mutation之外的方法修改State;不是通过mutation方法修改state,那么_commiting属性是false,在开发环境的严格模式下就会报错;

registerModule方法
registerModule (path, rawModule, options = {}) {
  if (typeof path === 'string') path = [path]

  this._modules.register(path, rawModule)
  installModule(this, this.state, path, this._modules.get(path), options.preserveState)
  // reset store to update getters...
  resetStoreVM(this, this.state)
}

registerModule方法是动态添加模块的,函数内部和Store构造函数内部一样,先通过register进行模块的格式修改,并且根据path会添加到对应的父级的_children中;再通过installModule进行action,state,getters,mutations属性的处理,全部放在根对象的对应属性下;最后通过resetStoreVM把state和vue关联起来;

subscribe方法

subscribe主要是订阅 store 的 mutation;handler 会在每个 mutation 完成后调用,接收 mutation 和经过 mutation 后的状态作为参数:

subscribe (fn, options) {
  return genericSubscribe(fn, this._subscribers, options)
}

function genericSubscribe (fn, subs, options) {
  if (subs.indexOf(fn) < 0) {
    options && options.prepend
      ? subs.unshift(fn)
      : subs.push(fn)
  }
  return () => {
    const i = subs.indexOf(fn)
    if (i > -1) {
      subs.splice(i, 1)
    }
  }
}

subscribe函数主要是把传递进去的回调存储到_subscribers属性中,并且返回一个函数,此函数中会从_subscribers中删除此回调;_subscribers属性是在mutations执行的时候遍历执行的;

devtool.js
const target = typeof window !== 'undefined'
  ? window
  : typeof global !== 'undefined'
    ? global
    : {}
const devtoolHook = target.__VUE_DEVTOOLS_GLOBAL_HOOK__

export default function devtoolPlugin (store) {
  if (!devtoolHook) return

  store._devtoolHook = devtoolHook
  // 触发vuex:init方法,传递store
  devtoolHook.emit('vuex:init', store)
  // 定义时光穿梭机,通过replaceState替换传递进来的state
  devtoolHook.on('vuex:travel-to-state', targetState => {
    store.replaceState(targetState)
  })
  // mutation被执行时,触发hook,并提供被触发的mutation函数和当前的state状态
  store.subscribe((mutation, state) => {
    devtoolHook.emit('vuex:mutation', mutation, state)
  }, { prepend: true })
}
mapState的实现
function mapSate(list){
  const obj = {}
  list.forEach((key, value) => {
    obj[value] = function () {
      return this.$store.state[value]
    }
  })
  return obj
}

mapSate方法其实很简单,就是把传递进来的数组进行遍历,从state中找到对应的值,保存到一个新的对象中,最后返回这个对象;那么在使用的时候就可以通过扩展运算符进行展开,其他的map方法都是类似的实现;

源码分析到这里,Vuex框架的实现原理基本都已经分析完毕。

总结

  1. 通过vue.use插件的形式使用vuex,vuex的install方法中通过mixin混入把beforeCreate钩子混入到每个子组件中;beforeCreate钩子中通过父级获取到$store并且把它赋值给子级的$store,让每个子组件都能获取到$store实例;
  2. vuex的Store类中通过ModuleCollection和Module类把传递进来的数据的格式进行统一处理;
  3. 把处理完成之后的数据通过installModule方法,把每个模块下的actions,getters,state,mutations都存储到根对象的对应属性中,如果开启了命名空间那么mutations和getters的key是通过模块的名称进行拼接;
  4. 数据处理完成之后,就通过resetStoreVM方法创建vue实例,把state放在Vue的data下的$$state中,把geter作为计算属性,此时就把vue和store关联起来了;
  5. commit方法就是执行指定对应的mutations;
  6. dispatch方法执行对应的Action并且返回一个promise对象;
  7. registerModule动态添加模块的方法,内部重新执行了1,2,3步;
  8. replaceState方法直接替换vue实例上data下的$$state属性;

面试题

  1. vuex中的store实例是如何注入到vue的每个组件上的?
    首先通过mixin把beforecreate钩子混入到每个组件中,在钩子中通过把$options$store赋值给当前实例的$store上,如果是子组件,就可以通过parent.$store获取到父组件的store,把此store再赋值给自身的$store属性,这样就实现了注入到每个组件上;使用beforecreate钩子是因为在此阶段已经初始好了$options属性,可以通过$options获取到store对象;

  2. state内部是如何实现支持模块配置和模块嵌套的?
    把子模块放在当前模块的_children中,把所有模块的Actions和muataions,geters,state都放在根对象下的对应属性中;操作某个子模块下的方法或属性,就可以直接从根对象下的对应的属性中获取到;

  3. 在执行dispatch触发action(commit同理)的时候,只需传入(type, payload),action执行函数中第一个参数store从哪里获取的?
    在Store构造函数中重写了这两个方法,把当前的store作为第一个参数传递进去;

  4. vuex中的state如何和vue关联起来?
    通过创建vue的实例,把state放在vue下的data下的$$state属性中,把getters作为vue的计算属性;

  5. 如何区分state是外部直接修改,还是通过mutation方法修改的?
    vuex内部修改State都是通过一个函数进行包裹执行的,此函数在修改之前会把一个变量设置为true,当修改完成之后改为false;如果外部修改的话此变量都是false,watch会监听state的变化,并且此变量为false就会报错;

  6. 调试时的“时空穿梭”功能是如何实现的?
    devtoolPlugin中提供了此功能。因为dev模式下所有的state change都会被记录下来,’时空穿梭’ 功能其实就是将当前的state替换为记录中某个时刻的state状态,利用 store.replaceState(targetState) 方法将执行this._vm.state = state 实现

参考:

tech.meituan.com/2017/04/27/…