Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2018/12/10 - redux 源码分析 #27

Open
Baifann opened this issue Dec 10, 2018 · 0 comments
Open

2018/12/10 - redux 源码分析 #27

Baifann opened this issue Dec 10, 2018 · 0 comments

Comments

@Baifann
Copy link
Contributor

Baifann commented Dec 10, 2018

redux 源码分析

背景

在之前的文章Redux从入门到实践当中对redux的使用进行了说明,这次就来看下它的源码,从而进一步的熟悉它。

构建

相关git地址

git clone https://github.com/reduxjs/redux.git

构建文档是CONTRBUTING.md

package.json

"main": "lib/redux.js",
// ...
"scripts": {
    "clean": "rimraf lib dist es coverage",
    "format": "prettier --write \"{src,test}/**/*.{js,ts}\" index.d.ts \"**/*.md\"",
    "format:check": "prettier --list-different \"{src,test}/**/*.{js,ts}\" index.d.ts \"**/*.md\"",
    "lint": "eslint src test",
    "pretest": "npm run build",
    "test": "jest",
    "test:watch": "npm test -- --watch",
    "test:cov": "npm test -- --coverage",
    "build": "rollup -c",
    "prepare": "npm run clean && npm run format:check && npm run lint && npm test",
    "examples:lint": "eslint examples",
    "examples:test": "cross-env CI=true babel-node examples/testAll.js"
  }

package.json当中可以看到redux的入口文件是lib/redux.js,这个文件是通过打包出来的。那我们看下打包配置文件rollup.config

{
    input: 'src/index.js',
    output: { file: 'lib/redux.js', format: 'cjs', indent: false },
    external: [
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {})
    ],
    plugins: [babel()]
  },
  // ...省略

可以看到入口文件应该是src/index.js

我们来看下src/index.js

import createStore from './createStore'
import combineReducers from './combineReducers'
import bindActionCreators from './bindActionCreators'
import applyMiddleware from './applyMiddleware'
import compose from './compose'
import warning from './utils/warning'
import __DO_NOT_USE__ActionTypes from './utils/actionTypes'

/*
 * This is a dummy function to check if the function name has been altered by minification.
 * If the function has been minified and NODE_ENV !== 'production', warn the user.
 */
// 是否压缩代码,如果运行环境在非生成环境但是代码被压缩了,警告用户
function isCrushed() {}

// 判断环境是否是生成环境,如果是生成环境使用此代码就给出警告提示
if (
  process.env.NODE_ENV !== 'production' &&
  typeof isCrushed.name === 'string' &&
  isCrushed.name !== 'isCrushed'
) {
  warning(
    'You are currently using minified code outside of NODE_ENV === "production". ' +
      'This means that you are running a slower development build of Redux. ' +
      'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' +
      'or setting mode to production in webpack (https://webpack.js.org/concepts/mode/) ' +
      'to ensure you have the correct code for your production build.'
  )
}

export {
  createStore,
  combineReducers,
  bindActionCreators,
  applyMiddleware,
  compose,
  __DO_NOT_USE__ActionTypes
}

src/index.js主要是将方法暴露出来,给使用者使用

  • createStore 用于创建store
  • combineReducers 用于组合成rootReducers,因为在外部初始化store时,只能传入一个reducers
  • bindActionCreators 组装了dispatch方法
  • applyMiddleware 合并多个中间件
  • compose 将中间件(middleware)和增强器(enhancer)合并传入到createStore

combineReducers

src/combineReducers.js

export default function combineReducers(reducers) {
  // 遍历出reducers的对象名称
  const reducerKeys = Object.keys(reducers)
  const finalReducers = {}
  // 遍历reducers名称
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]

    if (process.env.NODE_ENV !== 'production') {
      // 如果reducer对应的值是 undefined 输出警告日志
      if (typeof reducers[key] === 'undefined') {
        warning(`No reducer provided for key "${key}"`)
      }
    }

    // 当这个reducer是函数 则加入到finalReducers对象中
    if (typeof reducers[key] === 'function') {
      finalReducers[key] = reducers[key]
    }
  }
  // 读取出过滤后的reducers
  const finalReducerKeys = Object.keys(finalReducers)

  let unexpectedKeyCache
  // 开发环境将unexpectedKeyCache设置为空对象
  if (process.env.NODE_ENV !== 'production') {
    unexpectedKeyCache = {}
  }

  let shapeAssertionError
  try {
    // 检查各个reducers是否考虑过defualt的情况,不能返回undefined
    assertReducerShape(finalReducers)
  } catch (e) {
    shapeAssertionError = e
  }

  // combineRducers返回的是一个方法,dispatch最后执行的方法
  return function combination(state = {}, action) {
    // 如果reducer检查出有问题就会抛出异常
    if (shapeAssertionError) {
      throw shapeAssertionError
    }
    // 开发者环境下
    if (process.env.NODE_ENV !== 'production') {
      // 对过滤后的reducers和初始化的state进行检查
      const warningMessage = getUnexpectedStateShapeWarningMessage(
        state,
        finalReducers,
        action,
        unexpectedKeyCache
      )
      // 如果有问题就会输出
      if (warningMessage) {
        warning(warningMessage)
      }
    }

    let hasChanged = false
    const nextState = {}
    // 遍历过滤后的reducers
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      // 根据key取出对应reducer
      const reducer = finalReducers[key]
      // 根据key将state对应的值取出
      const previousStateForKey = state[key]
      // 执行我们reducer的方法,nextStateForKey就是根据actionType返回的state
      const nextStateForKey = reducer(previousStateForKey, action)
      // 检查nextStateForKey是否是undefined
      if (typeof nextStateForKey === 'undefined') {
        // 如果undefined就报错
        const errorMessage = getUndefinedStateErrorMessage(key, action)
        throw new Error(errorMessage)
      }
      // 将合并后的state赋值到nextState当中
      nextState[key] = nextStateForKey
      // 如果state的值改变过 则hasChanged置为true
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    // 如果改变过 则返回新的state不然则是原有的state({})
    // 如果state不传值就是 空的对象{}
    return hasChanged ? nextState : state
  }
}

combineReducers方法会先对传入的reducers进行校验,reducer的类型只能是function,最后返回的是个方法,这个方法很关键,因为在disptach时,最后执行的就是这个方法。这个方法有2个参数stateaction,方法内会根据传入的action返回state,最后会比较新旧的state,如果不相等,则会返回新的state,如果相等会返回新的state。

那么如果我们直接对store的state进行操作而不是通过dispatch会发生呢,比如说我们这样

const state = store.getState();
state.name = 'baifann';

我们看一下combineReducers中的getUnexpectedStateShapeWarningMessage这个方法,它会检查store中初始化的state的key有没有在各个子reducer当中,如果没有就会报错。

/**
 * @inputState 初始化的state
 * @reducers 已经过过滤的reducers 
 * @action 随着combinRecuers传入的action
 * @unexpectedKeyCache 开发者环境是一个空的对象,生成环境是undefined
 */
/**
 * 检查合法的reducers是否存在
 * 
 * 
 */
function getUnexpectedStateShapeWarningMessage(
  inputState,
  reducers,
  action,
  unexpectedKeyCache
) {
  // 将过滤的reducers的名取出
  const reducerKeys = Object.keys(reducers)
  const argumentName =
    // 如果这个action的type是预制的ActionTypes.INIT
    // argumentName就是preloadedState argument passed to createStore
    // 不然是previous state received by the reducer
    action && action.type === ActionTypes.INIT
      ? 'preloadedState argument passed to createStore'
      : 'previous state received by the reducer'
  // 如果过滤后的reducer长度为0
  // 则返回字符串告知没有一个合法的reducer(reducer必须是function类型)
  if (reducerKeys.length === 0) {
    return (
      'Store does not have a valid reducer. Make sure the argument passed ' +
      'to combineReducers is an object whose values are reducers.'
    )
  }
  // 判断输入的state是否是obj对象
  // 如果不是 则返回字符串告知inputState不合法
  if (!isPlainObject(inputState)) {
    return (
      `The ${argumentName} has unexpected type of "` +
      {}.toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] +
      `". Expected argument to be an object with the following ` +
      `keys: "${reducerKeys.join('", "')}"`
    )
  }

  // 传入的state进行遍历
  // 如果state的对象名不包含在reducer中 并且不包含在unexpectedKeyCache对象中
  // unexpectedKeyCache在开发者环境是一个空的对象  因此只要state的对象名不包含在reducer中,这个key就会
  // 保存到 unexpectedKeys 当中
  const unexpectedKeys = Object.keys(inputState).filter(
    key => !reducers.hasOwnProperty(key) && !unexpectedKeyCache[key]
  )

  // 将inputState的key全部设置为true
  unexpectedKeys.forEach(key => {
    unexpectedKeyCache[key] = true
  })

  // 如果这个action的type是定义的定义中的ActionTypes.REPLACE 就返回不执行
  if (action && action.type === ActionTypes.REPLACE) return
  // 如果unexpectedKeys中有值,则发出警告
  if (unexpectedKeys.length > 0) {
    return (
      `Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} ` +
      `"${unexpectedKeys.join('", "')}" found in ${argumentName}. ` +
      `Expected to find one of the known reducer keys instead: ` +
      `"${reducerKeys.join('", "')}". Unexpected keys will be ignored.`
    )
  }
}

compose

compose会返回一个方法,这个方法可以将传入的方法依次执行

export default function compose(...funcs) {
  // 如果函数方法为0 则
  if (funcs.length === 0) {
    // 会将参数直接返回
    return arg => arg
  }

  // 如果只传入一个方法则会返回这个方法
  if (funcs.length === 1) {
    return funcs[0]
  }
  // a为上一次回调函数返回的值 b为当前值
  // 效果就是不断执行数组中的方法最后返回时一个函数
  // 这个方法可以将所有数组中的方法执行
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

createStore

我们接下来看下createStore.js这个文件,它只暴露出了createStore的方法,在createStore中,初始化了一些参数,同时返回了一个store,store中包括了dispatchsubscribegetStatereplaceReducer,[$$observable]: observable

import $$observable from 'symbol-observable'

import ActionTypes from './utils/actionTypes'
import isPlainObject from './utils/isPlainObject'


export default function createStore(reducer, preloadedState, enhancer) {
  // 如果初始化的state是一个方法并且enhancer也是方法就会报错
  // 如果enhancer是方法如果第4个参数是方法就会报错
  if (
    (typeof preloadedState === 'function' && typeof enhancer === 'function') ||
    (typeof enhancer === 'function' && typeof arguments[3] === 'function')
  ) {
    throw new Error(
      'It looks like you are passing several store enhancers to ' +
        'createStore(). This is not supported. Instead, compose them ' +
        'together to a single function'
    )
  }

  // 如果初始化的state是方法,enhancer的参数为undefined
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    // enhancer赋值初始话的stae
    enhancer = preloadedState
    // preloadedState赋值为undefined
    preloadedState = undefined
    // 这里是一个兼容2个参数的处理,当参数仅为2个 第二个参数为enhcaner时的处理
  }

  // 如果enhancer 不是undefined
  if (typeof enhancer !== 'undefined') {
    // 如果enhancer不是方法会报错
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }

    // 返回enhancer的方法
    return enhancer(createStore)(reducer, preloadedState)
  }

  // 如果reducer不是方法 则报错
  if (typeof reducer !== 'function') {
    throw new Error('Expected the reducer to be a function.')
  }


  // rootReducer赋值到currentReducer当中 实际是一个函数
  let currentReducer = reducer
  // 当前store中的state 默认是初始化的state
  let currentState = preloadedState
  let currentListeners = []
  let nextListeners = currentListeners
  let isDispatching = false

  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      // 浅拷贝一个数组 虽然是浅拷贝 但是currentListener不会被nextListener改变
      nextListeners = currentListeners.slice()
    }
  }

  function getState() {
      // 省略代码...
  }

  function subscribe(listener) {
      // 省略代码...
  }

  function dispatch(action) {
      // 省略代码...
  }

  function replaceReducer(nextReducer) {
      // 省略代码...
  }

  function observable() {
      // 省略代码...
  }

  // When a store is created, an "INIT" action is dispatched so that every
  // reducer returns their initial state. This effectively populates
  // the initial state tree.
  // 执行dispatch 来初始化store中的state
  dispatch({ type: ActionTypes.INIT })

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}

看完之后,我们可能在这个地方有一点疑惑,就是这里

  // 如果enhancer 不是undefined
  if (typeof enhancer !== 'undefined') {
    // 如果enhancer不是方法会报错
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }

    // 返回enhancer的方法
    return enhancer(createStore)(reducer, preloadedState)
  }

这个返回的是什么呢,我们知道applyMiddleware返回的其实就是enhancer,那我们结合在一起看一下

applyMiddleware

import compose from './compose'

 /**
  * 创建一个store的增强器,使用中间件来包装dispath方法,这对于各种任务来说都很方便
  * 比如以简洁的方式进行异步操作,或记录每个操作有效负载
  * 
  * 查看`redux-thunk`包,这是一个中间件的例子
  * 
  * 因为中间件可能是异步的,所以应该是对个enhancer传参
  * 
  * 每一个中间件都要提供dispatch和getstate两个方法作参数
  * 
  */
export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    // 创建一个store ...args为reducer, preloadedState
    const store = createStore(...args)
    // 默认定义disptach方法,是一个抛出的报错
    let dispatch = () => {
      throw new Error(
        `Dispatching while constructing your middleware is not allowed. ` +
          `Other middleware would not be applied to this dispatch.`
      )
    }

    // 中间件的的参数
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    // 将所有的中间件遍历,将参数传入到中间件函数中,返回一个中间件函数的数组
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    // dispatch做了包装,会在dispatch的同时同时将中间件的方法也返回
    dispatch = compose(...chain)(store.dispatch)
    // 返回store中的属性以及新的dispatch方法
    return {
      ...store,
      dispatch
    }
  }
}

如果直接返回了enhancer那么返回的其实也是store,但是这个store中的dispatch被包装过,当dispatch被执行时,会将所有中间件也依次执行。

接下来分析一下createStore中的方法

  • getState
  • subscribe
  • dispatch
  • replaceReducer
  • observable

getState

很简单,就是返回currentState

/**
   * Reads the state tree managed by the store.
   *
   * @returns {any} The current state tree of your application.
   */
  function getState() {
    if (isDispatching) {
      throw new Error(
        'You may not call store.getState() while the reducer is executing. ' +
          'The reducer has already received the state as an argument. ' +
          'Pass it down from the top reducer instead of reading it from the store.'
      )
    }
    // 返回state
    return currentState
  }

subscribe

这是将一个回调加入到监听数组当中,同时,它会返回一个注销监听的方法。

  function subscribe(listener) {
    // listener必须是一个方法
    if (typeof listener !== 'function') {
      throw new Error('Expected the listener to be a function.')
    }

    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
          'If you would like to be notified after the store has been updated, subscribe from a ' +
          'component and invoke store.getState() in the callback to access the latest state. ' +
          'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
      )
    }

    let isSubscribed = true
  
    ensureCanMutateNextListeners()
    // 把listenr加入到nextListeners的数组当中
    nextListeners.push(listener)

    // 解除观察
    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
            'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
        )
      }

      isSubscribed = false
      // 这里做了个拷贝 做的所有操作不影响currentListener
      ensureCanMutateNextListeners()
      const index = nextListeners.indexOf(listener)
      // 在nextListener将它去除
      nextListeners.splice(index, 1)
    }
  }

dispatch

dispatch首先会检查参数,随后会执行currentReducer(currentState, action),而这个方法实际就是combineReducers

  function dispatch(action) {
    // 如果dispatch的参数不是action
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
          'Use custom middleware for async actions.'
      )
    }

    // action必须得有type属性,如果没有会报错
    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. ' +
          'Have you misspelled a constant?'
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      // 执行reducer 遍历过滤后的reducer,随后依次赋值到state当中
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    // 获取当前的监听器
    const listeners = (currentListeners = nextListeners)
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      // 依次执行监听器回调
      listener()
    }

    // dispatch默认返回action
    return action
  }

replaceReducer

  /**
   * Replaces the reducer currently used by the store to calculate the state.
   *
   * You might need this if your app implements code splitting and you want to
   * load some of the reducers dynamically. You might also need this if you
   * implement a hot reloading mechanism for Redux.
   *
   * @param {Function} nextReducer The reducer for the store to use instead.
   * @returns {void}
   */
  /**
   * 替换reducer
   * 
   * 动态替换原有的reducer
   */
  function replaceReducer(nextReducer) {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }
    // 将reducer赋值
    currentReducer = nextReducer
    // 发送一个dispatch 随后重置store
    dispatch({ type: ActionTypes.REPLACE })
  }

observable

这里不谈太多observable

这里有个使用例子

const state$ = store[Symbol.observable]();
const subscription = state$.subscribe({
  next: function(x) {
     console.log(x);
   }
 });
 subscription.unsubscribe();
 /**
   * Interoperability point for observable/reactive libraries.
   * @returns {observable} A minimal observable of state changes.
   * For more information, see the observable proposal:
   * https://github.com/tc39/proposal-observable
   */
  function observable() {
    const outerSubscribe = subscribe
    return {
      /**
       * The minimal observable subscription method.
       * @param {Object} observer Any object that can be used as an observer.
       * The observer object should have a `next` method.
       * @returns {subscription} An object with an `unsubscribe` method that can
       * be used to unsubscribe the observable from the store, and prevent further
       * emission of values from the observable.
       */
      /**
       * @param {Object} 任何对象都可以当做observer
       * observer应该有一个`next`方法
       * @returns {subscription} 一个对象,它有`unsubscribe`方法能够
       * 用来从store中unsubscribe observable
       */
      subscribe(observer) {
        // observer必须是一个非空的object
        if (typeof observer !== 'object' || observer === null) {
          throw new TypeError('Expected the observer to be an object.')
        }

        function observeState() {
          if (observer.next) {
            observer.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }

bindActionCreators

在讲这个方法前,先看下文档对它的使用说明

Example

TodoActionCreators.js

我们在文件中创建了2个普通的action。

export function addTodo(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}export function removeTodo(id) {
  return {
    type: 'REMOVE_TODO',
    id
  }
}

SomeComponent.js

import { Component } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'import * as TodoActionCreators from './TodoActionCreators'
console.log(TodoActionCreators)
// {
//   addTodo: Function,
//   removeTodo: Function
// }class TodoListContainer extends Component {
  constructor(props) {
    super(props)const { dispatch } = props// Here's a good use case for bindActionCreators:
    // You want a child component to be completely unaware of Redux.
    // We create bound versions of these functions now so we can
    // pass them down to our child later.this.boundActionCreators = bindActionCreators(TodoActionCreators, dispatch)
    console.log(this.boundActionCreators)
    // {
    //   addTodo: Function,
    //   removeTodo: Function
    // }
  }componentDidMount() {
    // Injected by react-redux:
    let { dispatch } = this.props// Note: this won't work:
    // TodoActionCreators.addTodo('Use Redux')// You're just calling a function that creates an action.
    // You must dispatch the action, too!// This will work:
    let action = TodoActionCreators.addTodo('Use Redux')
    dispatch(action)
  }render() {
    // Injected by react-redux:
    let { todos } = this.propsreturn <TodoList todos={todos} {...this.boundActionCreators} />// An alternative to bindActionCreators is to pass
    // just the dispatch function down, but then your child component
    // needs to import action creators and know about them.// return <TodoList todos={todos} dispatch={dispatch} />
  }
}export default connect(state => ({ todos: state.todos }))(TodoListContainer)

bindActionCreators.js

我们接下来来看它的源码

/**
 * 
 * 在看`bindActionCreator`方法之前可以先看`bindActionCreators`方法
 * 
 * @param {Function} actionCreator 实际就是 action
 * 
 * @param {Function}
 * 
 * @returns {Function}
 */
function bindActionCreator(actionCreator, dispatch) {
  return function() {
    // 返回的是dispath
    return dispatch(actionCreator.apply(this, arguments))
  }
}

// actionCreators是一个包含众多actions的对象
export default function bindActionCreators(actionCreators, dispatch) {
  if (typeof actionCreators === 'function') {
    // actionCreators是函数就代表他是单一的action方法
    return bindActionCreator(actionCreators, dispatch)
  }

  // actionCreator如果不是object 或者它是空的则报错
  if (typeof actionCreators !== 'object' || actionCreators === null) {
    throw new Error(
      `bindActionCreators expected an object or a function, instead received ${
        actionCreators === null ? 'null' : typeof actionCreators
      }. ` +
        `Did you write "import ActionCreators from" instead of "import * as ActionCreators from"?`
    )
  }
  // 将action的keys遍历出来
  const keys = Object.keys(actionCreators)
  const boundActionCreators = {}
  for (let i = 0; i < keys.length; i++) {
    // 每个action的key
    const key = keys[i]
    // 将action取出 这是一个方法
    const actionCreator = actionCreators[key]
    if (typeof actionCreator === 'function') {
      // bindActionCreator返回的是dispatch的返回值
      // 实际是action 所以boundActionCreators是一个dispatch function的对象
      // 同时如果key相同会被覆盖
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch)
    }
  }
  return boundActionCreators
}

使用bindActionCreators实际可以创建一个充满dispatch方法的对象。然后可以将这个对象传递子组件来使用。

总结

看完源码后我们大致了解到为什么reducer必须是function,store中的state为什么会创建和reducer相应的对象名的state,为什么只能通过dispatch来对store进行操作。另外redux的一个核心不可变性,redux本身并不能保证。所以我们在自己写的reducer当中必须要保证不能改变store原有的对象,必须得重新创建。

广而告之

本文发布于薄荷前端周刊,欢迎Watch & Star ★,转载请注明出处。

欢迎讨论,点个赞再走吧 。◕‿◕。 ~

@Baifann Baifann changed the title redux 源码分析 2018/12/10 - redux 源码分析 Dec 10, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant