普通视图

发现新文章,点击刷新页面。
昨天以前黄琦雲的博客

TypeScript 之泛型

2021年4月18日 08:00

背景

泛型用于创建可复用的支持多种类型的组件,比如不仅能支持当前的类型,还能支持未来的类型,为大型系统的构建提供一定灵活性,有广泛、多种的意思,即泛型可实现对多种类型的支持;泛型是一种已有的概念,除了 TypeScript,同样也存在于其他多种语言中;

先举一个基本的例子,ts 中实现一个加法运算的函数,可以是这样的:

function addFn(arg1: number, arg2: number): number {
    return arg1 + arg2;
}
addFn(1, 2);

如果后期功能拓展,需要上述函数也具备拼接字符串的功能,即:

function addFn(arg1: string, arg2: string): string {
    return arg1 + arg2;
}
addFn('a', 'b');

但是这样的申明并不与上面已有的申明兼容,即使使用联合类型处理也比较复杂,但是它们的处理逻辑却是一样的,只是类型值换了,重写一个新函数也不符合拓展的初衷,所以需要寻求其他方法;

函数重载

在 ts 中重载是为一个函数提供多个类型定义的操作,使得函数可以根据传参的不同而拥有不同的返回类型;这样,我们就能轻松实现之前例子的拓展需求:

function addFn(arg1: string, arg2: string): string;
function addFn(arg1: number, arg2: number): number;
function addFn(arg1: any, arg2: any): any {
  // 上面一行只是函数的实现签名,为了兼容上面两个重载签名,不能被直接调用,
  // 同时它也并不算作一个重载,真正的重载签名只有最上面的两个
  return arg1 + arg2;
}

// 以下代码都能通过类型检查
addFn(1, 2);
addFn('a', 'b');

// 而没在重载定义中的类型会报错
// addFn(true, true); // Error

这个例子中,该申明函数与正常函数的区别是:

  • 在函数申明的上方又叠加了几个申明表达式,包裹参数类型和返回类型,末尾以分号结束,每一个申明便是一个重载;
  • 然后是在函数区块中写处理逻辑,同时包含进上面的几种参数与返回类型的情况;
  • 最后调用时就可以传入不同重载所对应的传参类型,并且能通过类型检查,而不在重载定义中的类型则不会通过类型检查;
  • 函数在调用时,ts 会在申明的函数重载中自上而下查找第一个匹配的重载签名,最后一个函数签名称为“实现签名”,并不会被调用;

使用重载虽然实现了同时能计算数字和拼接字符串的需求,但是这种写法还是有些复杂,因为参数类型与返回类型具有一定规律性;因此还可以继续寻求更简便的方式;

类型变量

在 ts 中,可以使用泛型来解决上述需求,具体的方式是使用类型变量,顾名思义,ts 包含一个庞大的类型处理系统,有各种使用类型的情景,为了应对一些场景,就需要类型值有像变量一样的变化性,支持赋值与取值操作;

先看一下使用类型变量的具体写法:

function addFn<T>(arg1: T, arg2: T): T {
    return arg1 + arg2;
}
addFn<number>(1, 2);
addFn<string>('1', '2');
addFn('a', 'b'); // 调用时也可以省略类型赋值

这里的写法就比写重载的形式简便了许多;示例中出现了 <T> 这个标识,其中 T 表示类型变量<> 表示对类型变量的申明,即申明时使用 <> 设置变量,调用时再使用 <> 进行赋值,这样所有用到变量 T 的地方都会被替换为传入的类型值;这里可以发现,泛型就像类型系统中一个针对类型的函数,类型参数就是形参

调用时可以省略类型赋值的操作是因为上面的场景中 ts 可以利用类型推断机制自动判断出 T 的实际类型值(number);

由于 T 表示任意类型,所以不能直接访问某些属性或方法:

function fn<T>(arg: T): T {
  // return arg.toString(); // Error,因为并不是所有类型都有该方法
  return arg;
}

如果是复合类型,则可以使用某些固有属性:

function fn<T>(arg: T[]): string {
  return arg.toString(); // 普通数组类型都具有该方法
}

类型变量也可以使用其他字母或者单词(通常使用 T),并且可以同时定义多个变量:

function fn<M, My, other>(arg: M): M {
  let one: My;
  let two: other;

  return arg;
}

// 存在多个类型变量时,需要依次赋值类型
fn<string, number, boolean>('abc');

泛型接口

除了函数,泛型也可以在接口中使用,例如:

interface IGeneric {
  <T>(arg: T): T;
}

let fn: IGeneric = function(arg) {
  return arg;

  // 和接口申明不一致会报错
  // return arg + '';
}

或者是针对整个接口的泛型:

interface IGeneric<T> {
  a: T;
  b: T[];
  c(arg: T): T;
}

const obj: IGeneric<number> = {
  a: 123,
  b: [1, 2, 3],
  c: (arg) => arg + 1
};

泛型类

针对类定义类型变量时,类的所有非静态成员都可以使用该变量:

class CS<T> {
  constructor(public attr: T) {}

  fn(): T {
    return this.attr;
  }

  // 静态成员不能使用泛型类型
  // static a: T = ''; // Error
}

// 实例化时传入类型值
const cs = new CS<number>(123);

cs.fn(); // 123

泛型约束

在一些场景中,可能并不期望类型变量的值太,而是需要具有某些属性或方法,这时就可以使用 extends 关键字对类型加一些约束,这和类中的继承用途也有些类似;例如:

interface IObj {
  length: number;
}

// 将参数约束为具有 length 属性的任意类型
function fn<T extends IObj>(arg: T): number {
  return arg.length;
}

fn('abc'); // 3

// 报错,因为数字没有 length 属性
// fn(123); // Error

结合其他 ts 特性,也能表示一些特殊情形,比如下例表示函数第二个参数,需要是第一个参数对象的属性名之一,keyof 关键字为索引查询操作符,keyof T 表示 T 的所有属性构成的联合类型:

function fn<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

fn({ a: 1 }, 'a');

// 报错,因为第一个参数对象中并没有一个叫 'b' 的属性
// fn({ a: 1 }, 'b'); // Error

Redux 主要知识学习总结

2020年11月29日 08:00

概念

Redux 作为一个状态管理器,可以应用于多种 web 技术或框架中,React 只是其中之一;Redux 的特点在于,多个页面或组件使用同一个状态(store,用于管理应用的 state),可以实现各模块或组件之间的数据共享,应用的任何部分都能进行状态修改,避免了传统的组件间深层次传值问题;

使用

创建状态(store)

Redux.createStore() 方法用于创建一个 store,其接收 reducer 作为第一个参数;

reducer 为一个自定义函数,接收 state 作为第一个参数,同时返回一个值作为新的 state

reduce 有缩减,减少的意思,可以理解为一个缩减器,不断将新得到的状态覆盖原状态,以实现 store 的单一状态更新,其名字也是根据 Array.reduce() 方法而来的;

import Redux from 'redux';

// 为 state 设置默认值
const reducer = (state = 1, action) => {
    return state;
}
const store = Redux.createStore(reducer);

createStore() 方法还接收第二个参数 initialState,作为 state 的初始化值,即下面两种写法效果相同:

import Redux from 'redux';

const store1 = Redux.createStore((state = 1) => {
    return state;
});
const store2 = Redux.createStore((state) => {
    return state;
}, 1);

const state1 = store1.getState();
const state2 = store2.getState();

console.log(state1); // 1
console.log(state2); // 1

读取状态

getState(),是所创建的 store 对象的一个方法,用于获取创建的状态;

const store = Redux.createStore(
    (state = 1) => state;
);
const state = store.getState();

console.log(state); // 1

改变状态(action)

触发更新

state 的更新需要通过触发 action 来实现,actoin 是前面的 reducer 函数接收的第二个参数,一个 action 是一个包含操作信息的对象,同时也可以携带要传递的额外数据;

触发 action 使用 dispatch 实现,dispatchstore 对象的一个方法,其接收参数为 action 对象,是更新状态的唯一途径;

这里之所以多加一层 action,而不直接修改状态,是为了追踪某一状态为何更新,或者调试时进行操作复现等目的,而 action 中的 type 就相当于为了被追踪而留下的痕迹;

const store = Redux.createStore(
    (state=1, action) => {
        if (action.type === 'myAction') {
            return action.myData;
        } else {
            return state;
        }
    }
);

const action = {
    type: 'myAction', // type 属性为必填项
    myData: 'myContent.', // 自定义携带数据
}

store.dispatch(action);
console.log(store.getState()); // "myContent."

在模块较多的复杂应用中,为了辨识操作,方便理解,通常 type 的格式会定义为 模块/操作 的形式,模块一般和对应的 reducer 相关,例如:

const todoReducer = (state, action) => state;
const userReducer = (state, action) => state;

const addTodo = { type: 'todo/add' };
const renameUser = { type: 'user/rename' };

响应更新

更新 stateaction 被触发了,还需要定义一些操作对其进行响应,在 action 触发时执行,即指定如何更新 state

这里更新 state 的逻辑写在之前创建 store 时传入的 reducer 函数中,由于 Redux 中的 state 是只读的(并未强制,但需自行在代码中遵守),所以 reducer 每次返回的 state 都是新的;

const myState = {
    num: 0
}
const myReducer = (state=myState, action) => {
    if (action.type === 'add') {
        return {
            num: state.num + 1
        };
    } else {
        return state; // 非指定状态需要考虑返回原状态
    }
}
const store = Redux.createStore(myReducer);
const myAction = {
    type: 'add'
}

store.dispatch(myAction);

console.log(store.getState().num); // 1

Redux 并未强制 reducer 中的 state 为只读的,其实是可以对其进行修改,例如:

const defaultState = { num: 0 };
const store = Redux.createStore(
    (state=defaultState, action) => {
        if (action.type === 'add') {
            state.num += 1;
            return state;
        } else {
            return state;
        }
    }
);

console.log(store.getState()); // { num: 0 }

store.dispatch({ type: 'add'});

console.log(store.getState()); // { num: 1 }

但官方并不建议这么做,这有可能导致页面数据得不到及时更新的 bug,所以需要开发者考虑自行维护其不可变性(Immutability),这也能实现更好的状态追踪,问题追溯等开发体验,如官网提到的一项叫 time traveling debugging 技术;并且,Redux 官网对该框架的介绍也是 Redux is a predictable state container,即具有预见性的状态管理器;

订阅状态

subscribe() 是 store 对象的方法之一,它接收一个函数作为参数,用于设置监听器以订阅状态的更新,即指定 state 更新时应该做什么;

const store = Redux.createStore((state=0, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
});

store.subscribe(() => {
    // 指定每次更新状态就打印当前值
    console.log('dispatch', store.getState());
})
store.dispatch({ type: 'add' }); // 'dispatch' 1
store.dispatch({ type: 'add' }); // 'dispatch' 2

拓展

状态合成

虽然 Redux 为了管理方便而设置单一的 store 对所有 state 进行统一管理,但是状态量的增长会使得书写变得复杂,所以 Redux 对象提供了一个 combineReducers() 方法,将所有声明的分工不同(不同组件、页面或子应用)的 reducer 合并为一个总的 reducer

该方法接收一个对象作为参数,不同的属性名用于标识不同作用的 reducer,以及状态更新后从 store 中取回状态值,属性值为声明的 reducer 函数;

const calcReducer = (state=1, action) => {
    switch (action.type) {
        case 'add':
            return state + 1;
        case 'minus':
            return state - 1;
        default:
            return state;
    }
}
const countReducer = (state=0, action) => {
    if (action.type === 'add') return state + 1;
    else return state;
}
const rootReducer = Redux.combineReducers({
    calc: calcReducer,
    count: countReducer,
});
const store = Redux.createStore(rootReducer);


console.log(store.getState()); // { calc: 1, count: 0, }

store.dispatch({ type: 'add' });

console.log(store.getState()); // { calc: 2, count: 1 }

combineReducers() 参数对象中指定的属性名用于存储该 reducer 的所有状态值;

Enhancer

Redux.createStore() 方法还可以接收第三个参数 enhancer,用于自定义 store 的功能或强化其能力(例如魔改),比如改变 dispatch(), getState(), subscribe() 等方法的默认行为;

enhancer 参数为一个自定义函数,其接收 Redux.createStore 这个方法作为参数,并返回一个新的 createStore 方法;

下面是一个为 dispatch 添加功能的简单示例:

const myReducer = (state=1, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
}
// enhancer 接收一个参数,即 Redux.createStore 这个方法,
// 用于执行创建 store 的默认行为;
const myEnhancer = (createStore) => {
    // enhancer 需要返回一个函数,其参数与 Redux.createStore 的相同,
    // 可以理解为返回另一个新的 createStore 函数;
    return (reducer, initialState, enhancer) => {
        // 需要执行一次 Redux.createStore 的默认行为,并获取 store
        const store = createStore(reducer, initialState, enhancer);

        // 修改 store 中的默认 dispatch 方法
        store.dispatch = (action) => {
            // 新加的功能
            console.log('dispatched.');
            // 最后仍需执行一次 dispatch 的默认行为
            return store.dispatch(action);
        }
        
        // 修改默认的 getState 方法
        store.getState = () => {
            return store.getState() + 1;
        }

        // 返回的新 createStore 方法还需要返回一个对象,即整个 store 对象;
        return store; 
        }
    }
}

const store = Redux.createStore(myReducer, undefined, myEnhancer);

store.dispatch({ type: 'add' }); // "dispatched."

console.log(store.getState()); // 3

需要同时使用多个 enhancer 时,需要进行合成,可以使用 Redux.compose() 方法:

const enhancers = Redux.compose(enhancer1, enhancer2); // 可以传入多个参数作 enhancer
const store = Redux.createStore(myReducer, undefined, enhancers);

Middleware

大多数时候,我们只希望自定义 dispatcch 方法的逻辑,所以官方专门提供了一个叫 middleware 的特性,翻译过来就是中间件,即在触发 action 和调用 reducer 执行响应之间,给用户提供一个可操作空间,如用于日志记录,问题报告,或者处理异步操作等;

middleware 是一个自定义函数,其接收一个对象作为参数,该参数对象有两个方法,分别是 dispatchgetState,逻辑都与 store 对象中的两个同名方法相同;

middleware 函数还需要返回另一个函数作为包装(自定义)后的 dispatch 方法,由于逻辑层次较多,下面会通过代码说明;

Redux 中内置了一个叫做 applyMiddlewareenhancer 方法,用于添加 middleware,它可以接收多个参数以传入多个 middleware

具体实现通过举例说明:

const myReducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
}

// 自定义的中间件函数
const myMiddleware1 = ({ dispatch, getState }) => {
    // 中间件需要返回一个函数,即新的 dispatch 逻辑,
    // 该函数又接收一个参数 next,用于执行下一个 middleware,
    // 当然如果有下一个中间件就执行,没有了就执行原始的 dispatch,
    // 其实这个参数 next 也就是原始的那个 store.dispatch 方法;
    return (next) => {
        // 该函数也需要返回一个函数,用于处理 action,
        // 接收一个 acction 作参数,即 store 触发的 action;
        return (action) => {
            // 自定义逻辑
            console.log('mid 1', getState());

            // 这个函数还需要返回一个函数,即用之前的 next 方法
            // 将 action 传递给下一个 middleware 继续处理;
            return next(action);
        }
    }
}

// 也可以使用简写方式
const myMiddleware2 = ({ getState }) => next => action => {
    console.log('mid 2', getState());
    const result = next(action);
    console.log('mid 2 new', getState());

    return result;
}

// 使用中间件
const myEnhancer = Redux.applyMiddleware(myMiddleware1, myMiddleware2);

const store = Redux.createStore(myReducer, 1, myEnhancer);

store.dispatch({ type: 'add' });
// "mid 1" 1
// "mid 2" 1
// "mid 2 new" 2

console.log(store.getState());
// 2

总结一下整个执行过程就是:

  • 用户调用了 store.dispatch() 触发 action
  • Redux 按 applyMiddleware() 方法中参数的传入顺序,挨个执行自定义的 middleware 逻辑;
  • 然后再调用原始的 store.dispatch() 方法触发 action
  • 最终执行 reducer 中的逻辑;

整个过程有些类似函数的链式调用:

dispatch -> middleware1 -> middleware2 ... -> dispatch -> reducer

此外,由于 middleware 的执行逻辑,其特性还包括对 action 中数据的修改、中断甚至彻底停止 action,的触发,例如上例中最后不返回 next(action),那么整个过程执行完第一个 middleware 就结束了,state 也不会发生预期的改变;

处理异步逻辑

Redux 内部并不知道如何处理异步逻辑,只会同步的触发 action,然后调用 reducer 更新 state,所以任何异步逻辑需要我们在外部自己实现;而 Redux 的宗旨是 recuder 不要有任何副作用,最好是一个纯函数,即不要有多余的外部联系,如控制台打印,异步请求等;

middleware 就是 Redux 专为副作用逻辑需求而设计的,这里以异步操作为例用代码进行简单实现:

const reducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else if (action.type === 'asyncAdd') {
        return action.data;
    } else {
        return state;
    }
}
const asyncMiddleware = _store => next => action => {
    if (action.type === 'asyncAdd') {
        setTimeout(() => {
            action.data = 'some data.';
            next(action);
        }, 2000);
    } else {
        next(action);
    }
}
const enhancer = Redux.applyMiddleware(asyncMiddleware);
const store = Redux.createStore(reducer, 0, enhancer);


store.dispatch({ type: 'add' });
console.log(store.getState()); // 1

store.dispatch({ type: 'asyncAdd' });
console.log(store.getState()); // 1

setTimeout(() => {
    console.log(store.getState());
}, 2000);
// 2 秒后输出:
// some data.

结果显示异步操作获取的数据,可以成功被 reducer 拿到并实现相应的逻辑,所以把 setTimeout 换成 Ajax 请求也同样可以从服务器获取到数据,然后传递给 Redux 进行下一步处理;

由于上面的异步逻辑的原生写法不太方便,Redux 官方就提供了一款 redux-thunk 工具,封装好了一个 middleware,应用之后就可以将 action 声明为一个函数(以前是一个对象),其接收 dispatchgetState 两个参数;具体用法如下:

import Redux from 'redux';
import ReduxThunk from 'redux-thunk';

const reducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else if (action.type === 'asyncAdd') {
        return action.data;
    } else {
        return state;
    }
}

// 直接应用该工具
const middlewareEnhancer = Redux.applyMiddleware(ReduxThunk.default);

const store = Redux.createStore(reducer, 1, middlewareEnhancer);

// 这里 action 声明为函数,处理异步逻辑
const asyncAction = (dispatch, getState) => {
    console.log('old state:', getState());
    setTimeout(() => {
        dispatch({ type: 'asyncAdd', data: 'some data.' });
    }, 2000);
}

store.dispatch(asyncAction);
// "old state:" 1

setTimeout(() => {
    console.log(store.getState());
}, 2000);
// 2 秒后输出:
// "some data."

需要注意的是,一些教程上(包括 Redux 官网)介绍 Redux Thunk 的用法时,仍然使用的 Redux.applyMiddleware(ReduxThunk) 写法,这是该工具 1.x 版本的写法,现在 2.x 版本需要加上 .default,即 Redux.applyMiddleware(ReduxThunk.default),不然程序会出现问题;

记一次 React 组件无法更新状态值的问题分析与解决

2020年9月10日 08:00

问题

React 组件中通过直接声明的元素变量(jsx 写法),在访问 state 中指定的状态值时,如果状态发生改变,使用状态值的元素内容无法得到相应更新;

下面的例子中,直接在 class 组件中声明元素变量 myDiv,并且需要访问 this.state 中的数据,最终对状态值进行展示,按钮用于改变状态值:

import React from 'react';

class App extends React.Component {
  state = {
    msg: 'hello',
  };
  
  myDiv = <div>{this.state.msg}</div>;
  
  handleClick() {
    this.setState({
      msg: 'world',
    })
  }
  
  render() {
    return (
      <>
        <div>{this.myDiv}</div>
        <button onClick={() => this.handleClick()}>
          change
        </button>
      </>
    );
  }
}

export default App;

按理说点击按钮后,状态发生改变(this.state.msg),页面的值会发生相应更新,但是页面内容并未发生相应改变,这其实是一个微小的细节问题,下面对其进行展开分析;

分析

上例中,在组件中直接声明了值为元素的一个变量 myDiv,并且其内容调用了状态值(this.state.msg),其实该变量在声明时状态内容直接被赋予了 this.state.msg当前值,并非想象中的引用值,然后状态改变(this.setState())时,React 会重新调用组件的 render() 方法,重新渲染组件内容,但是此时该变量中的状态值仍是之前被赋予的字面值,不会再访问一次当前的 state,所以其值最终也就不会发生相应的改变;

并且在一般的写法中,涉及访问状态的逻辑(如 {this.state.msg})一般都写在整个 render() 方法之中,这样每次状态的改变导致 render 方法重新执行,使得重新执行获取状态的逻辑,就能每次都访问到最新的状态值了;但有时又很难避免在复用组件时在 render 方法以外的地方访问 state,并期望数据被动态改变,这里就需要寻求其他解决方案;

解决方案

方法一

我们可以使用 getter 方式来声明变量,getter/setter 方法是声明对象属性的一种方式,可以实现该对象指定属性的属性值的访问控制(getter)以及修改控制(setter),下面是一个简单的使用示例:

var obj = {
    num: 1,
    get a() {
        return this.num;
    }
    set b(n) {
        this.num = n;
    }
}

console.log(obj.a); // 1

obj.b = 2;
console.log(obj.a); // 2

getter 声明的属性的特点是,每次调用对象的该属性,都会执行一次 getter 函数内的逻辑,然后返回 return 的值;所以如果把之前组件中的 myDiv 属性以 get 方式进行声明,这样每一次状态改变后,render() 方法重新执行,然后就会涉及对该变量的访问,导致重新执行 getter 方法中的逻辑,最后就能访问到改变后的状态值(this.state.msg),页面也就相应地更新了;

改造后的组件代码:

import React from 'react';

class App extends React.Component {
  state = {
    msg: 'hello',
  };
  
  get myDiv() {
    return <div>{this.state.msg}</div>;  
  } 
  
  handleClick() {
    this.setState({
      msg: 'world',
    })
  }
  
  render() {
    return (
      <>
        <div>{this.myDiv}</div>
        <button onClick={() => this.handleClick()}>
          change
        </button>
      </>
    );
  }
}

export default App;

方法二

类似使用 getter 的思路,为了让每次状态改变,用到状态的变量也发生相应的更新,另一种方法就是将变量 myDiv 声明为函数类型,同样也能使每次获取变量时都重新执行一次获取状态的逻辑,以获取最新状态值,改造后代码如下:

import React from 'react';

class App extends React.Component {
  state = {
    msg: 'hello',
  };
  
  myDiv = () => <div>{this.state.msg}</div>;
  // 或者是:
  // myDiv() { return <div>{this.state.msg}</div> };
  
  handleClick() {
    this.setState({
      msg: 'world',
    })
  }
  
  render() {
    return (
      <>
        <div>{this.myDiv()}</div>
        <button onClick={() => this.handleClick()}>
          change
        </button>
      </>
    );
  }
}

export default App;

不同之处就是每次调用变量 myDiv 时需要使用函数式调用(后面加一对括号),为了方便多处调用,显然方法一更有优势;

React 组件间传值的几种情形

2020年9月3日 08:00

父级传向子级

这应该是最常见的一种场景,通过在子组件上写 props,将数据从父组件中传递到子组件,子组件再从 this.props 中获取相应的值,这样可以根据传入值的不同返回不同的状态,即实现组件的复用;例如:

import React from 'react';

// 父组件
class Parent extends React.Component {
    message = 'Hello world!';

    render() {
        return (
            <Child myProp={this.message} />
        );
    }
}

// 子组件
class Child extends React.Component {
    message = this.props.myProp;

    render() {
        // Hello world!
        return (
            <div>{this.message}</div>
        );
    }
}

export default Parent;

多层传值

上述方法只用于单层数据传递,即父级传向子级,如果子级又存在子级,甚至向下递推,那么父组件要传值给后代组件,就要逐层向下传递,类似下面的情况:

import React from 'react';

class Parent extends React.Component {
    render() {
        return <Child myProp="hello" />
    }
}

class Child extends React.Component {
    render() {
        return <Grandchild myProp1={this.props.myProp} />
    }
}

class Grandchild extends React.Component {
    render() {
        // hello
        return <div>{this.props.myProp1}</div>
    }
}

export default Parent;

如果层数再多一些就是书写噩梦了,所以 React 提供了 context 机制,解决了深层传值的问题,现在来改造上面的代码:

import Reac from 'react';

// 首先需要创建一个自定义 context
// 该方法接收一个参数作为 context 的默认值
const myContext = React.createContext();
// 获取包裹组件,用于包裹需要应用 context 的组件
const { Provider } = myContext;

class Parent extends React.Component {
    value = {
        message: 'hello',
    };
    
    render() {
        // 通过包裹器的 value 属性向下传递指定值
        return (
            <Provider value={this.value}>
                <Child />
            </Provider>
        );
    }
}

class Child extends React.Component {
    render() {
        return <Grandchild />
    }
}

class Grandchild extends React.Component {
    // 在需要获取祖代传递的 context 值的后代组件中,
    // 声明 contextType 静态属性,值为之前创建的 context;
    static contextType = myContext;
    
    render() {
        // 最后使用 this.context 就能获取到之前
        // 在 Provider 中传入的 value 值;
        return <div>{this.context.message}</div>
        // hello
    }
}

export default Parent;

需要注意的是,由于提供方和调用方需要使用同一个使用 React.createContext() 创建的 context,所以如果父组件和要调用 context 的子组件不在同一个文件中的话,则需要考虑通过 exportimport 来实现引用,但是这样组件间的耦合度又增加了一层,React 官方建议使用 组合 方式取代上述的 继承 方式,下面再次对上述代码进行改造:

import React from 'react';

class Parent extends React.Component {
    value = {
        message: 'hello',
    };

    render() {
        return (
            <Child>
                {this.value.message}
            </Child>
        );
    }
}

class Child extends React.Component {
    render() {
        // this.props.children 会指向父组件在子组件中嵌入的数据或组件
        return (
            <div>{this.props.children}</div>
        );
    }
}

export default Parent;

子级传向父级

React 中似乎没有提供子级向父级直接传值,类似 props 的方法或途径,可以通过一些间接手段实现,开发中常见的处理方式就是子组件调用父组件通过 props 传入的处理函数,对需要传递的值进行处理;例如:

import React from 'react';

class Parent extends React.Component {
    state = {
        message: '',
    };
    
    handleMsg(msg) {
        this.setState({
            message: msg,
        });
    }
    
    render() {
        // Hello
        return (
            <>
                {this.state.message}
                <Child 
                    onMsg={(msg) => this.handleMsg(msg)}
                />
            </>
        );
    }
}

class Child extends React.Component {
    handleClick() {
        this.props.onMsg('Hello');
    }

    render() {
        return (
            <button
                onClick={() => this.handleClick()}
            >Clike me</button>
        );
    }
}

export default Parent;

简单梳理一下流程:

  • 父组件提前声明数据处理逻辑,该方法接收传入的值,然后进行相应处理;
  • 父组件将该方法通过 props 传递给子组件;
  • 子组件触发一些行为,得到了将要传递给父组件的值;
  • 子组件通过 this.props 调用父组件传入的处理函数,并将要传递的值作为该函数的参数;
  • 处理函数开始执行,由于其是在父组件的作用域中声明的,所以也能访问父组件中的一些数据,比如 state,相当于在父组件中处理子组件传入的数据;
  • 处理函数更新 state 状态值,随后其他访问该 state 的地方也会随即更新;

同级间传递

状态提升

这是 React 官网提到的一个概念,即多个组件都在重复使用同一个状态值,可以将这个值 提升 至父组件中保存,相当于数据复用;当然如果想实现一个子组件向另一个子组件传值,也可以通过父组件这层“媒介”;下面举例说明:

import React from 'react';

class Parent extends React.Component {
    state = {
        msg: 'hello',
    }
    
    handleChangeMsg(msg) {
        this.setState({
            msg: msg,
        });
    }
    
    render() {
        return (<>
            <ChildLabel msg={this.state.msg} />
            <ChildButton
                onChangeMsg={(msg) => this.handleChangeMsg(msg)}
            />
        </>);
    }
}

class ChildLabel extends React.Component {
    render() {
        return <span>{this.props.msg}</span>
    }
}

class ChildButton extends React.Component {
    render() {
        return (
            <button
                onClick={() => this.props.onChangeMsg('world')}
            >Change</button>
        )
    }
}

export default Parent;

这样在页面中点击子组件 <ChildButton /> 中的按钮时,子组件 <ChildLabel /> 中的文本便会发生相应变化,成功获取传入的值;这里的操作相当于子组件先向父组件传值,使得父组件状态变化,然后使用该状态的另一子组件就会相应地变化;

Refs

Refs 是 React 提供的另一种机制,React 中典型数据流是通过 props 传递,而 refs 则相当于提供了直接操纵组件或 DOM 元素的一种途径,强制修改元素;因此官网也建议避免过度使用 refs,防止应用变得难以理解或“失控”;下面通过举例简单说明其用法:

import React from 'react';

class Parent extends React.Component {
    // 首先需要声明 ref
    myRef = React.createRef();
    
    handleClick(msg) {
        // 通过 ref 的 current 属性实现对该元素的引用,
        // 然后就能想操作正常 DOM 一样实现控制;
        this.myRef.current.innerText = msg;
    }
    
    render() {
        // 对需要被引用的元素使用 ref 属性,值为之前所创建的 ref
        return (<>
            <span ref={this.myRef}>hello</span>
            <button
                onClick={() => this.handleClick('world')}
            >Change</button>
        </>);
    }
}

export default Parent;

在页面上点击按钮后,前面的文本同样会发生改变,即 DOM 元素的元素属性 innerText 值被成功修改,如需使用其他原生属性或方法同理;

Refs 转发

虽然 Refs 提供了直接访问组件或元素的途径,但是它却访问不了组件中的组件,这是 React 层故意为之,隐藏组件实现细节与渲染结果,防止组件的 DOM 结构被过度依赖;但有一些特殊情况下确实需要访问组件内部的组件的话,React 也提供了另外一种机制,即 Refs 转发(Refs Forwarding);顾名思义,组件 A 不能直接使用 ref 访问组件 B 中的组件 C,但是可以通过组件 B 转发 ref 给组件 C,这里改造一下上面 Refs 中的例子,我们在 <Parent /><span> 之间再加一层组件,再实现对其的操作:

class Parent extends React.Component {
    myRef = React.createRef();
    
    handleClick(msg) {
        this.myRef.current.innerText = msg;
    }
    
    render() {
        // 对子组件正常使用 ref
        return (
            <Child
                ref={this.myRef}
                handleClick={this.handleClick.bind(this)}
            />
        );
    }
}

// 使用 React.forwardRef 方法转发 ref 给下一层组件
const Child = React.forwardRef((props, ref) => {
    return (<>
        <span ref={ref}>hello</span>
        <button
            onClick={() => props.handleClick('world')}
        >Change</button>
    </>);
});

Refs 转发需要使用 React.forwardRef() 方法创造组件,该方法接收一个回调函数做为参数,该回调函数接收两个入参,第一个是传进组件的 props,第二个是传进组件的的 ref,通过内部逻辑决定 ref 再转发给谁,回调函数的返回值是最终生成的组件;页面加载组件后,点击按钮,就能像直接使用 ref 一样改变展示的文本值了;

关于浮点数的剪不断理还乱

2020年3月21日 08:00

二进制小数

谈浮点数前,先了解一些基础知识;对于整数的十进制与二进制转换不难,原理也简单就不再赘述,假如现在要进行转换的十进制数字是带有小数点的,转换方法就会稍微有点不一样了;为了使说明更浅显易懂,先来探究一下十进制小数中的奥秘;

以十进制数 3.125 为例,小数点以左为整数部分,右为小数部分,它也可以被更具体的拆解为以下形式:

3x10^0 + 1x10^-1 + 2x10^-2 + 5x10^-3

这也是十进制数的特点,基数为 10,所以每一位都是与 10 的相应次方的乘积,指数也是有规律的,可以看成以小数点为对称轴,向左就是 10 的 0、1、2、3……次方递增,向右则是 10 的 -1、-2、-3……次方递减,如果换一种形式,那么在运算方面也具有对称性(乘法与除法互为逆运算),就是下面的形式:

3x10^0 + 1/10 + 2/10^2 + 5/10^3

这样,理解二进制的小数部分就容易了,已经知道整数十进制转二进制其实就是连续除以 2 取余数,那么根据上面的规律,小数部分看出逆运算,就可以总结为 连续乘 2 取整数,举例说明:

3.125(十进制)

3     / 2 = 1    --> 1
         余 1    --> 1
0.125 x 2 = 0.25 --> 0
0.25  x 2 = 0.5  --> 0
0.5   x 2 = 1    --> 1

==> 11.001(二进制)

11.001 就是 3.125 的二进制小数形式,同时它也可以做如下拆解:

1x2^1 + 1x2^0 + 0x2^-1 + 0x2^-2 + 0x2^-3

= 2 + 1 + 0 + 0 + 0.125
= 3.125

可以看到也是与十进制形式相对应的,只是基数从 10 换成了 2 而已;

定点数

当然,都知道机器码都是一堆 01 组成的数据,并没有小数点这个专门的符号,要表示数字 3125 还好说,但是 3.125 中间多出的这一点要怎么解决呢……一个很直接的方法就是把小数点应该在的位置焊死 -_-,比如 32 位的系统,规定小数点在 16 位和 17 位之间,那么根据 3.125 的二进制是 11.001,在 32 中的表现就是:

0000 0000 0000 0011 0010 0000 0000 0000

这就是 定点数 的规则干的事情,看着挺好,要是多用几次系统空间可能就 hold 不住了,不好之处明显就是花掉当前一半的数量级取表述小数部分,当然可以少划分几位给小数,但对小数好像不太公平,万一精度要求高呢,反过来呢对整数又不公平了,手心手背都是肉,得想办法解决才行;

浮点数

于是乎,浮点数横空出世;其实上述问题平时肯定都遇到过,比如记个帐,昨日净收入 31200000000 元(@_@),肯定不想写这一堆 0 对不对(如果是真的咱愿意写 100 遍……),一般都会写 312 亿或者 3.12x10^10 元,重点就在后面这种科学计数表示法,人为了省力省纸省笔墨弄了个科学计数法,处理器也自然用上了浮点数,省空间;具体原理与表示方法后面会讲;

以 C语言为代表,其中就有浮点这一数据类型,比如单精度浮点 float(32 位),双精度浮点 double(64 位)等,可能平时也就直接声明和使用较多,没太关注底层实现的算法,其实也不太复杂,套用一种规则而已,下面开始介绍;

标准

目前关于浮点运算与表示,使用广泛的应该就是 IEEE 754(二进制浮点数算术标准)了,其主要内容如下:

v = (-1)^s * 2^e * m

v: 浮点数具体值
s: 符号位,即正负号,0 为正,1 为负
m: 有效数,也叫尾数,可以类比科学计数法前面的有效数字
   另外还有一个小数位 f, m = 1 + f
e: 指数位,即 2 的多少次方

该标准包含了多中位数,以 32 位为例:

(1)   (8)          (23)          : 位数
0 00000011 0010000000000000000000
s e        f

总结就是:

  • 第一位为符号位 s;
  • 后 8 位为指数位 e;
  • 最后 23 位为小数位 f;

64 位的规则是:

  • 第一位是符号位 s;
  • 后 11 位是指数 e;
  • 最后 52 位是有效数字 m;

知道了这些还不能立刻套用上面的公式经行转换,还需要了解接下来的一些规定;

指数偏移值

浮点的二进制表示中指数位 e 的计算值(即转换成十进制后的值),要在实际指数值(十进制)的基础上加上一个 偏移值,标准中规定偏移值为 2^(e-1) - 1,如 32 为中 e 是 8 位,所以偏移值为 127,64 位的就为 1023;所以 32 位指数实际值的范围为 -126 ~ 127

举个例,指数 e 的实际值为 -3,那么 32 位中加上偏移值就是 -3 + 127 = 124,换算成二进制的 8 位指数位就是 01111100;

这种指数位偏移后的指数值,又叫做 阶码,因为科学计数法中的指数是有正负之分的,所以实际指数值加上一个正的适中偏移值,就可以使得浮点表示法中的指数位为无符号的整型(就是变成正整数),利于浮点数的比较大小,就是可以直接从浮点的二进制表示中,由高位向低位逐位进行比较(如果是负数二进制比较大小要复杂一点)。

表示方式

具体的表示方式会根据不同的情况而不同,主要有以下几种情况;

规约形式

即 e 的 8 位数字不是全部为 0 或者 1,此时 m = 1 + f,由于小数部分 f 的值在 0 到 1 之间,所以有效数 m 的值在 1 到 2 之间;

非规约形式

这种形式 e 的 8 位全部为 0,小数位 f 值不为 0,用于表示非常接近 0 的数,此时不再是 m = 1 + f,而是 m = f,即 m 值在 0 到 1 之间;实际上所有非规约的浮点数比规约浮点数更接近于 0;

零值

指数位 e 全为 0 的同时,小数部分(f)为 0,用来表示 ±0(正负取决于 s 位的值);且规定最小指数位编码(e = 0 时)的实际值应该取 -126(本来应该是 0 - 127 = -127);

无穷大

如果 e 全为 1,且 m 全为 0,则表示无穷大(Infinity,正负取决于 s 位的值);

NaN

如果 e 全为 1,且 m 不全为 0,则表示 NaN(Not a Number,非数值类型);

综合举例

后面的例子都以 32 位为例,其它位数根据标准类推;

先来看一个十进制转浮点,规约形式的例子,比如用之前的十进制数 3.125,转换为 32 位浮点二进制格式(0b 开头的表示二进制数据):

3.125
= 0b11.001
= 0b1.1001 x 2^1

s = 0

e = 1
  => 1 + 127
  =  128
  =  0b10000000
  
m = ob1.1001
f = m - 1
  = 0b0.1001
  => 0b10010000000000000000000
  
==>
0 10000000 10010000000000000000000

转换的大致流程总结如下:

十进制小数 –> 二进制小数 –> 浮点表示法 –> 二进制浮点

再举一个二进制浮点转十进制小数例子:

1 01111111 00100000000000000000000

s = 1
  => -1
  
e = 0b01111111
  = 127
  => 127 - 127
  = 0
  
f = 0b0.001
m = 1 + f
  = 0b1.001
  
v = -1 x 0b1.001 x 2^0
  = 0b-1.001
  = -1.125

==> -1.125

精度

以 32 位单精度浮点数为例,由于分配给有效数字的位数是 23 位,而整数部分默认是 1,它的位置就不用留了,所以小数部分就可以独占 23 位,在加上默认的一个整数位就是 24 位了,同理,64 位双精度浮点数的有效数就是 53 位(52+1),再进行一下算术运算:

log(2^24) = 7.22
log(2^53) = 15.95

上面的算式表示二进制下的这么多位数的实际值,对应到十进制中有多少位;结果表明,单精度浮点可以保证 7 位十进制的有效数字,双精度的则可以保证 15 位;

“浮”的原因

取名为浮点,那么到底“浮”在了什么地方,与定点数相比的优势又是什么?总的看来,其实其转换操作很类似于十进制中的科学计数法,而科学计数法的出现,就是为了实现能简短地书写较大的数,比如写作 1.0x10^20,就可以避免在 1 后面写那令人抓狂的 20 个 0 了;

同理,二进制中如果用定点数表示小数,那么 32 位的话就最多到 32 位有效数字,而用这种类似科学技术的浮点表示法的话,指数能表示到 100 以上,也就是 100 多位了,相信现在的 100 位系统也是稀有物种了吧;因此,浮点数有效的扩大了能表示的数据范围,科学计数法减少了书写量,浮点表示则是节省了存储空间;

另外也是由于科学计数本身的特性,以及指数偏移值,也就是 阶码 的应用,小数点也就不再像之前一样固定,具体位置会根据指数的大小最终“漂浮”到不同的位置,甚至到那遥远的地方……

JavaScript之注释规范化(JSDoc)

2020年3月13日 08:00

前言

俗话说,无规矩不成方圆;虽说代码敲出来都是交给编译器解释执行的,只要不存在语法格式错误,排版无论多么反人类都是没有问题的,但是代码除了执行外的另一个广泛用途就是阅读了,翻阅自己过去的代码、理解别人的源码,等等;所以出现了代码风格化,美化外观的同时便于阅读,这就是目前 JSLint 等工具的作用;

当然,除了代码本身外,阅读更多的可能就是代码注释了,注释本身是不会被编译器编译执行的,其作用也是为了留下一些信息,方便更好的理解代码本身;所以,注释的规范化也是一个值得思考的问题;而接下来即将介绍的 JSDoc 就是这样的一款工具;

JSDoc

根据其官网(https://jsdoc.app/index.html)的介绍,JSDoc 是一个针对 JavaScript 的 API 文档生成器,类似于 Java 中的 Javadoc 或者 PHP 中的 phpDocumentor;在源代码中添加指定格式的注释,JSDoc 工具便会自动扫描你的代码并生成一个 API 文档网站(在指定目录下生成相关的网页文件);

生成 API 文档只是一方面,其更主要的贡献在于对代码注释格式进行了规范化,你可能没用过,但多半曾经在某个地方的源码中见过类似于下面的注释格式:

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function sum(a, b) {
    return a + b;
}

使用

工具的使用很简单,首先安装它:

npm install -g jsdoc

其次假设在一个名为 doc.js 的文件中书写以下代码:

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function sum(a, b) {
    return a + b;
}
/**
 * Return the diff fo a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function diff(a, b) {
    return a - b;
}

然后就是在当前目录执行以下命令:

jsdoc doc.js

最后就会在当前目录下生成一个名为 out 的目录(也可以另外指定),当前目录内容就会变成像下面这样:

├── doc.js
└── out
    ├── index.html
    ├── doc.js.html
    ├── global.html
    ├── fonts
    │   ├── OpenSans-BoldItalic-webfont.eot 
    │   ├── OpenSans-BoldItalic-webfont.svg 
    │   ├── OpenSans-BoldItalic-webfont.woff
    │   ├── OpenSans-Bold-webfont.eot       
    │   ├── OpenSans-Bold-webfont.svg       
    │   ├── OpenSans-Bold-webfont.woff      
    │   ├── OpenSans-Italic-webfont.eot     
    │   ├── OpenSans-Italic-webfont.svg     
    │   ├── OpenSans-Italic-webfont.woff    
    │   ├── OpenSans-LightItalic-webfont.eot
    │   ├── OpenSans-LightItalic-webfont.svg
    │   ├── OpenSans-LightItalic-webfont.woff
    │   ├── OpenSans-Light-webfont.eot
    │   ├── OpenSans-Light-webfont.svg
    │   ├── OpenSans-Light-webfont.woff
    │   ├── OpenSans-Regular-webfont.eot
    │   ├── OpenSans-Regular-webfont.svg
    │   └── OpenSans-Regular-webfont.woff
    ├── scripts
    │   ├── linenumber.js
    │   └── prettify
    │       ├── Apache-License-2.0.txt
    │       ├── lang-css.js
    │       └── prettify.js
    └── styles
        ├── jsdoc-default.css
        ├── prettify-jsdoc.css
        └── prettify-tomorrow.css

通过浏览器访问这个 out 目录中的相关网页,就会展示类似于下面的页面内容;

主页:

jsdoc-home.png

指定函数页:

jsdoc-func.png

网页样式模板也可以更换,根据命令行参数修改即可,这里不再探究,下面主要来学习一下它的注释格式;

注释格式

完整的格式介绍请参考官网(https://jsdoc.app/index.html),目前版本是 JSDoc 3,下面只介绍几种常用的标签并配合举例;当然如果嫌手写一堆标签麻烦,现在许多编辑器(比如 VS Code)都提供了相关的插件下载,直接在插件中搜索关键词 jsdoc 就会出现许多,都是带提示或者自动识别当前代码生成的,很方便;

注释符

JSDoc 使用以下格式的注释符来对要添加的标签进行块级包裹:

/**
 * 
 * 
 */

即星号列垂直对其,第一行使用两个星号,每个星号后要添加一个空格再写内容,比如:

/**
 * 前面留一个空格,再写描述
 * 或者多行描述
 * @param {number} 关于该参数的描述
 */

行内包裹:

/** @function */

@description

也可写作 @desc,描述当前注释对象的详细信息;

/**
 * @function
 * @description 关于该函数的介绍内容
 */
function myFn() {}

/**
 * 也能在这里直接写介绍内容
 * @function
 * @description 如果这里又继续使用标签添加内容,则会覆盖第一行的介绍内容
 */
function myFn() {}

@file

注释写在文件开头,用于描述当前文件的相关信息;例如:

/**
 * @file 这是一个用于...的文件,包含了...功能
 */
 
// 然后是代码正文...

@author

描述当前文件或者代码的作者的相关信息;

/**
 * @author Jack <jack@example.com>
 */

@copyright

描述当前文件的版权相关信息

/**
 * @copyright Jack 2020
 */

@license

描述当前文件许可证相关信息;

/**
 * @license MIT
 */

或者是:

/**
 * @license
 * Copyright (c) 2015 Example Corporation Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * ...
 */

@version

描述当前项目的版本号;

/**
 * 这个版本修复了...问题
 * @version 1.2.3
 */

@since

描述某个功能是从哪个版本开始引入的;

/**
 * 提供了...功能
 * @since 1.2.1
 */
function newFn() {}

@see

类似于“另见”、“详见”的意思,引导至其他位置,也可以使用 @link 引导至某一网络地址;

/**
 * @see fn2
 */
function fn1() {}

/**
 * @see {@link http://example.com|some text}
 */
function fn2() {}

@todo

描述接下来准备做的事情;

/**
 * @todo 添加...功能
 * @todo 修复...bug
 */
function myFn() {}

@function

@func, @method 含义相同,描述一个函数;

/** @function */
var myFn = function() {}

@type

描述一个变量的类型;

/**
 * 一个对象类型的变量
 * @type {object}
 */
var val1 = {};

/**
 * 一个字符或者数字类型的变量
 * @type {(string|number)}
 */
var val2;

/**
 * 类型为数字或为空
 * @type {?number}
 */
var val3;

/**
 * 类型为数字或且不能为空
 * @type {!number}
 */
var val4;

/**
 * 一个 MyClass 类的实例数组
 * @type {Array.<MyClass>}
 */
var arr = new MyClass();

/**
 * 一个字符串的数组
 * @type {string[]}
 */
var arr2 = ['a', 'b', 'c'];

/**
 * 一个包含一个字符串和一个数字类型的对象
 * @type {object.<string, number>}
 */
var obj1 = {a: 'one', b: 2}

/**
 * 指定具体键和类型的对象
 * @type {{a: string, b: number}}
 */
var obj2 = {a: 'one', b: 2}

/**
 * 指定具体键和类型的命名对象
 * @type {object} obj3
 * @type {string} obj3.a
 * @type {number} obj3.b
 */
var obj3 = {a: 'one', b: 2}

@param

@arg, @argument 含义相同,描述一个函数的参数信息;

/**
 * 标签后跟参数类型,然后是参数名,最后是参数描述
 * @param {number} a 这里写变量的描述
 * @param {string} b - 或者加上连字符便于阅读
 * @param {string} c - 又或者这个参数有一个很长很长很长
 * 很长很长很长很长很长非常长的描述,可以这样占用多行
 */
function myFn(a, b, c) {}

/**
 * 传入的参数是个对象
 * @param {object} option - 传入的对象参数
 * @param {string} option.name - 对象的 name 属性
 * @param {number} option.age - 对象的 age 属性
 */
function myFn(option) {
    var name = option.name;
    var age = option.age;
}

/**
 * 传入的参数是个字符串组成的数组
 * @param {string[]} arr - 传入的对象参数
 */
function myFn(arr) {
    var name = option.name;
    var age = option.age;
}

/**
 * 表示某个参数是可选的
 * @param {number} a - 这是必填参数
 * @param {number} [b] - 这是可选参数
 * @param {number=} c - 可选参数的另一种表示
 */
function myFn(a, b, c) {}

/**
 * 表示可选参数的默认值
 * @param {number} a
 * @param {number} [b=3] - 默认值为 3
 */
function myFn(a, b) {}

/**
 * 参数类型的各种表示
 * @param {number} a - 类型为数字
 * @param {number|string} b - 类型为数字或字符串
 * @param {?number} c - 类型为数字或者为空(null)
 * @param {!number} d - 类型为数字且不为空
 * @param {*} e - 类型不做限制,即可以为任意类型
 */
function myFn(a, b, c, d, e) {}

/**
 * 表示具有任意多个参数的函数
 * 下面的函数返回所有传入参数的和
 * @param {...number} num - 参数个数任意,但是都是数字类型
 */
function sum(num) {
    var len = arguments.length;
    var result = 0;
    
    for (let i = 0; i < len; i++) {
        result += arguments[i];
    }
    return result;
}

@typedef

用于描述自定义的变量类型;

/**
 * 关于自定义类型的描述
 * @typedef {(string|number)} myType
 */

/**
 * 关于自定义类型的描述
 * @type {myType} val - 使用自定义的类型
 */
function myFn(val) {}

@callback

描述指定函数中作为回调函数的参数信息;

/**
 * 这是关于回调函数的描述
 * @callback myCallback
 * @param {string} aa - 回调函数接受的参数
 * @param {number} [bb] - 回调函数接受的另一个可选参数
 */
 
/**
 * 这是关于函数本身的描述
 * @param {string} a
 * @param {myCallback} callback - 回调函数
 */
function myFn(a, callback) {}

@returns

或者写作 @return,描述函数的返回值的信息;

/**
 * @param {number} a
 * @returns {number} 关于返回值的描述
 */
function myFn(a) {
    return a + 1;
}

/**
 * @param {number} a
 * @returns {(number|string)} 返回值可能是数字或字符类型
 */
function myFn2(a) {
    if (a > 1) {
        return 1;
    } else {
        return 'no.';
    }
}

@example

描述指定代码的使用示例;

/**
 * 添加示例代码(格式会被高亮展示)
 * @param {string} a
 * @param {string} b
 * @returns {string} return a concat b.
 *
 * @example
 * console.log(myFn('hello ', 'world!'));
 * // "hello world!"
 */
function myFn(a, b) {
    return a + b;
}

@class

描述一个 class 类;

/**
 * 关于该类的描述
 * @class
 */
class MyClass {}

/**
 * 或者是一个构造函数
 * @class
 */
function MyClass() {}
var ins = new MyClass();

@namespace

描述一个命名空间;

/**
 * 指定一个对象对命名空间
 * @namespace
 */
var MyNamespace = {
    /**
     * 表示为 MyNamespace.fn
     * @returns {*}
     */
    fn: function() {},
    /**
     * 表示为 MyNamespace.a
     * @type {number}
     */
    a: 1
}

/**
 * 手动指定命名空间
 * @namespace MyNamespace
 */
/**
 * 一个成员函数,MyNamespace.myFn
 * @function
 * @returns {*}
 * @memberof MyNamespace
 */
function myFn() {}

@member

描述当前类的一个成员;

/**
 * @class
 */
function MyClass() {
    /** @member {string} */
    this.name = 'knightyun';
    
    /**
     * 或者一个虚拟的成员
     * @member {number} age
     */
}

@memberof

描述成员所属的类;

/**
 * @class
 */
class MyClass {
    /**
     * @constructor
     * @memberof MyClass
     */
    constructor() {}
    /*
     * @param {string} val
     * @returns {*}
     * @memberof MyClass
     */
    myFn(val) {}
}

JavaScript 变量提升(Hoisting)详解

2019年9月2日 08:00

概念

变量提升是 JavaScript 的一种执行机制,大致就是字面意思,将声明的变量提前,但并不是指在编译时改变语句的顺序,而是将变量提前放入内存中,供后续操作,下面通过实例进行分析;

函数申明

在 JavaScript 中,声明一个函数并执行的话,通常会是以下形式:

function fn() {
    console.log('run');
}

fn();  // run

上面是正常的思维顺序,但是包括其他一些编程语言在内,通常会使用如下形式:

fn();

function fn() {
    console.log('run');
}
// run

这样做在执行上是没用问题的,同时可以在包含大量语句和函数申明的情况下,也可以使用这种特性将普通语句和函数申明分开,提高可读性;

以上情况便是一种常见的提升(Hoisting),即编译时提前将当前执行上下文包含的申明的函数,提前放入内存中,供全文语句执行时调用,为了方便理解而抽象成一种提升行为;

但是如果使用下面的方式申明函数并执行:

fn();

var fn = function() {
    console.log('run');
}
// TypeError: fn is not a function

这里就没有像上面一样的结果了,这属于下面将介绍的变量提升行为;

变量申明

当然函数只是一种类型的变量,还存在其他的变量类型,例如考虑以下语句:

var a = 1;
console.log(a);
// 1

逻辑和执行都是正常的,输出结果也是预期的,但是如果变一下顺序:

console.log(a);
var a = 1;

这种情况,通常可能会认为第一行调用了一个未定义的变量,然后输出 Uncaught ReferenceError: a is not defined 这样的错误,但是呢,并非如此,输出信息如下:

undefined

没错,就只有一个单独的 undefined,这种输出情况就类似于以下代码的执行:

var a;
console.log(a);
// undefined

从这里便可以大致分析出,前面的顺序怪异的代码,相当于在编译时提前将后面出现的变量申明提前,然后执行就输出了一个已申明但未 初始化(赋值) 的值,这便是其他类型的变量的提升行为,即在当前执行上下文中,将后面申明的变量提前放入内存,供前面的语句调用;

注意,前面的代码最后一行的语句是 var a = 1,即对变量进行了申明并赋值,但是最后输出仍然是 undefined 而不是 1,证明变量提升行为只会对变量进行申明操作,并不会对其初始化赋值,不管原语句是否有赋值操作;

然后便能解释之前的代码:

fn();

var fn = function() {
    console.log('run');
}
// TypeError: fn is not a function

这种情况便是将变量 fn 提升,值为 undefined,所以执行 fn() 语句会提示 fn is not a function 而不是 fn is not defined,与使用关键字 function 申明函数情况不一样;

拓展

return 的限制

另外值得一提的是,我们都知道 return 是函数内代码执行结束的标志,其后代码不会执行,但是提升行为却不受此限制,例如:

function fn() {
    console.log(a);
    fnn();
    return ;

    var a = 1;
    function fnn() {
        console.log('exist.')
    }
}

fn();
// undefined
// exist.

提升优先级

上面提到两种提升行为,那么它们的优先级顺序是如何的呢?还是通过代码说明:

function fn1() {
    console.log(a);
    var a = 1;
    function a(){};
}
function fn2() {
    console.log(a);
    function a(){};
    var a = 1;
}

fn1(); // f a() {}
fn2(); // f a() {}

结果证明函数的提升优先级始终高于普通变量的提升;

提升的执行

再来看一种情况:

function fn() {
    fnn();

    var a = 1;
    function fnn() {
        console.log(a);
    }
}

fn(); // undefined

这里按照正常的逻辑,申明函数 fnn() 之前就已经申明了变量 a,所以会感觉函数 fnn 应该可以访问变量 a,但是最后输出的并不是 1,输出 undefined 说明函数 fnn 并没有访问到赋值后的 a,并且所访问的 a 也触发了提升机制,因为输出的不是 RefferenceError,那么就能大致梳理出提升真 正的执行顺序了:

  1. 执行 fn();
  2. 执行 fnn();
    1. 发现前面没有关于函数 fnn() 的申明,于是向后寻找,最后找到了;
    2. 执行 console.log(a);
    3. 发现 fnn 内部没有 a 的定义,向外一层寻找;
  3. a 申明在 fnn() 执行语句以后,所以 a 触发提升,供之前的 console.log 使用;

因此上面的代码相当于是以下面的顺序执行的:

function fn() {
    console.log(a);
    var a = 1;
}

fn();

var, let, const的区别

JavaScript 中申明变量的方式以及对应效果如下:

a = 0;       // 全局变量
var b = 1;   // 局部作用域变量(当前上下文)
let c = 2;   // 块级作用域变量(当前块级上下文)
const d = 3; // 常量

作用域

这里解释一下,变量 a 申明时没有带任何关键字,默认其为全局变量;变量 b 申明带有关键字 var,为当前上下文的局部作用域,如果用在全局则为全局变量;变量 c 使用关键字关键字 let,d 使用关键字 const,二者都是ES6中新增的块级作用域申明,只不过 const 申明的是常量,值不可更改;

通过例子看一下它们的区别;

var a = '全局';
function fn() {
    var aa = '局部'
    console.log(aa);
}
if (true) {
    var b = '全局';
    let bb = '块级';
    const bbb = '块级';
}
for (i = 0; i < 1; i++) {
    var c = '全局'
    let cc = '块级';
    const ccc = '块级';
}

console.log(a);  // “全局”
fn();  // “局部”
console.log(aa); // aa is not defined
console.log(b, c); // “全局” “全局”
console.log(bb, cc); // bb is not defined  cc is not defined
console.log(bbb, ccc); // bbb is not defined  ccc is not defined

可以看出,var 的局部限于全局或者函数内部上下文,而 let 和 const 的块级的意思则是被 块(block) 所包含的上下文,也就是包含在花括号 {} 内部的作用域中,所以也包括函数在内,加上 if, for, while, switch 等情况,且不能被外部作用域访问;

全局变量提升

首先看申明全局变量时的提升行为:

console.log(a);
a = 0;
// ReferenceError: a is not defined

证明不带关键字的申明全局变量,似乎并没有执行变量的提升行为,与以下代码的执行无异:

console.log(a); // a 前面未申明
// ReferenceError: a is not defined

局部变量提升

使用关键字 var 申明的情况:

function fn() {
    console.log(aa);
    var aa = 1;
}
console.log(a);
var a = 1;
// undefined
fn();
// undefined

前面已解释,不再赘述,只是需要注意下面这种情况:

if (false) {
    var a = 1;
}
console.log(a);
// undefined

正常思维可能会理解 if 条件判断为假所以不会执行内部语句,最后会输出 a is not defined,然而并非如此,仍然将申明的变量执行了提升机制;这里可以简单理解为存在即提升,也就是为了避免以上问题的影响,所以出现了块级变量申明 letconst

块级变量提升

使用 letconst 的情况:

if (true) {
    console.log(a);
    let a = 1;
    console.log(aa);
}
// ReferenceError: Cannot access 'a' before initialization
// ReferenceError: aa is not defined

if (true) {
    console.log(b);
    const b = 1;
    console.log(bb);
}
// ReferenceError: Cannot access 'b' before initialization
// ReferenceError: bb is not defined

可以看出,块级变量申明似乎也执行了类似提升的机制,但是处理却与 var 有区别,这里是直接以错误的形式处理输出,提示该变量未进行初始化,而没有变量的申明语句的情况,则是提示未定义的错误,且 letconst 的处理情况一致;

❌
❌