Giter Site home page Giter Site logo

blog's Introduction

Typing SVG

🙇‍♀️ About Me

  • 🏃‍♀️ Four and a half years of working experience in Front-End development.
  • 💼 I'm currently working on DTStack.
  • 🎓 Graduated from the School of Computer Science, Zhejiang University of Technology.
  • ✍️ Here is my blog.

🛠️ Tech Stack

  • 🌐   HTML5 CSS JavaScript Node.js React
  • ⚙️   Git GitHub Markdown
  • 🔧   Visual Studio Code

blog's People

Contributors

luckyfbb avatar

Stargazers

 avatar  avatar

blog's Issues

你应该知道的Hooks知识

Hooks

Hook是React16.8的新增特性,能够在不写class的情况下使用state以及其他特性。

动机

  • 在组件之间复用状态逻辑很难
  • 复杂组件变得难以理解
  • 难以理解的 class

hook规则

  • 只有在最顶层使用hook,不要再循环/条件/嵌套函数中使用
  • 只有在React函数中调用Hook

函数组件和类组件的不同

函数组件能够捕获到当前渲染的所用的值。

点击查看示例

对于类组件来说,虽然props是一个不可变的数据,但是this是一个可变的数据,在我们渲染组件的时候this发生了改变,所以this.props发生了改变,因此在this.showMessage中会拿到最新的props值。

对于函数组件来说捕获了渲染所使用的值,当我们使用hooks时,这种特性也同样的试用于state上。

点击查看示例

const showMessage = () => {
  alert("写入:" + message);
};

const handleSendClick = () => {
  setTimeout(showMessage, 3000);
};

const handleMessageChange = (e) => {
  setMessage(e.target.value);
};

如果我们想跳出'函数组件捕获当前渲染的所用值‘这个特性,我们可以采用ref来追踪某些数据。通过ref.current可以获取到最新的值

const showMessage = () => {
  alert("写入:" + ref.current);
};

const handleSendClick = () => {
  setTimeout(showMessage, 3000);
};

const handleMessageChange = (e) => {
  setMessage(e.target.value);
  ref.current = e.target.value;
};

useEffect

useEffect能够在函数组件中执行副作用操作(数据获取/涉及订阅),其实可以把useEffect看作是componentDidMount/componentDidUpdate/componentWillUnMount的组合

第一个参数是一个callback,返回destory。destory作为下一个callback执行前调用,用于清除上一次callback产生的副作用

第二个参数是依赖项,一个数组,可以有多个依赖项。依赖项改变,执行上一个callback返回的destory,和执行新的effect第一个参数callback

对于useEffect的执行,React处理逻辑是采用异步调用的,对于每一个effect的callback会像setTimeout回调函数一样,放到任务队列里面,等到主线程执行完毕才会执行。所以effect的回调函数不会阻塞浏览器绘制视图

  1. 相关的生命周期替换方案

    • componentDidMount替代方案

      React.useEffect(()=>{
        //请求数据,事件监听,操纵DOM
      },[]) //dep=[],只有在初始化执行
      /* 
      因为useEffect会捕获props和state,
      所以即使是在回调函数中我们拿到的还是最初的props和state
      */
    • componentDidUnmount替代方案

      React.useEffect(()=>{
        /* 请求数据 , 事件监听 , 操纵dom , 增加定时器,延时器 */
        return function componentWillUnmount(){
          /* 解除事件监听器 ,清除定时器,延时器 */
        }
      },[])/* 切记 dep = [] */
      
      //useEffect第一个函数的返回值可以作为componentWillUnmount使用
    • componentWillReceiveProps替代方案

      其实两者的执行时机是完全不同的,一个在render阶段,一个在commit阶段

      useEffect会初始化执行一次,但是componentWillReceiveProps只会在props变化时执行更新

      React.useEffect(()=>{
        console.log('props变化:componentWillReceiveProps')
      },[ props ])
    • componentDidUpdate替代方案

      useEffect 和 componentDidUpdate 在执行时期虽然有点差别,useEffect 是异步执行,componentDidUpdate 是同步执行 ,但都是在 commit 阶段

      React.useEffect(()=>{
        console.log('组件更新完成:componentDidUpdate ')     
      }) //没有dep依赖项,没有第二个参数,那么每一次执行函数组件,都会执行该 effect。
  2. 在useEffect中[]需要处理什么

    React官网FAQ这样说

    只有当函数(以及它所调用的函数)不引用 props、state 以及由它们衍生而来的值时,你才能放心地把它们从依赖列表中省略,使用eslint-plugin-react-hooks帮助我们的代码做一个校验

    点击查看详细示例

    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        const id = setInterval(() => {
          setCount(count + 1);
        }, 1000);
        return () => clearInterval(id);
      }, []);
    
      return <h1>{count}</h1>;
    }
    //只会做一次更新,然后定时器不再转动
  3. 是否应该把函数当做effect的依赖

    const loadResourceCatalog = async () => {
      if (!templateType) return
      const reqApi = templateType === TEMPLATE_TYPE.STANDARD ? 'listCatalog' : 'getCodeManageCatalog'
      const res: any = await API[reqApi]()
      if (!res.success) return
      setCatalog(res.data)
    }
    
    useEffect(() => {
      loadResourceCatalog();
    }, [])
    //在函数loadResourceCatalog中使用了templateType这样的一个state
    //在开发的过程中可能会忘记函数loadResourceCatalog依赖templateType值

    第一个简单的解法,对于某些只在useEffect中使用的函数,直接定义在effect中,以至于能够直接依赖某些state

    useEffect(() => {
      const loadResourceCatalog = async () => {
        if (!templateType) return
        const reqApi = templateType === TEMPLATE_TYPE.STANDARD ? 'listCatalog' : 'getCodeManageCatalog'
        const res: any = await API[reqApi]()
        if (!res.success) return
        setCatalog(res.data)
      }
      loadResourceCatalog();
    }, [templateType])

    假如我们需要在很多地方用到我们定义的函数,不能够把定义放到当前的effect中,并且将函数放到了第二个的依赖参数中,那这个代码将就进入死循环。因为函数在每一次渲染中都返回一个新的引用

    const Template = () => {
      const getStandardTemplateList = async () => {
        const res: any = await API.getStandardTemplateList()
        if (!res.success) return;
        const { data } = res;
        setCascaderOptions(data);
        getDefaultOption(data[0])
      }
      useEffect(() => {
        getStandardTemplateList()
      }, [getStandardTemplateList])
    }

    针对这种情况,如果当前函数没有引用任何组件内的任何值,可以将该函数提取到组件外面去定义,这样就不会组件每次render时不会再次改变函数引用。

    const getStandardTemplateList = async () => {
      const res: any = await API.getStandardTemplateList()
      if (!res.success) return;
      const { data } = res;
      setCascaderOptions(data);
      getDefaultOption(data[0])
    }
    
    const Template = () => {
      useEffect(() => {
        getStandardTemplateList()
      }, [])
    }

    如果说当前函数中引用了组件内的一些状态值,可以采用useCallBack对当前函数进行包裹

    const loadResourceCatalog = useCallback(async () => {
      if (!templateType) return
      const reqApi = templateType === TEMPLATE_TYPE.STANDARD ? 'listCatalog' : 'getCodeManageCatalog'
      const res: any = await API[reqApi]()
      if (!res.success) return
      setCatalog(res.data)
    }, [templateType])
    
    useEffect(() => {
      loadResourceCatalog();
    }, [loadResourceCatalog])
    //通过useCallback的包裹,如果templateType保持不变,那么loadResourceCatalog也会保持不变,所以useEffect也不会重新运行
    //如果templateType改变,那么loadResourceCatalog也会改变,所以useEffect也会重新运行

useCallback

React官网定义

返回一个memoized回调函数,该回调函数仅在某个依赖项改变时才会更新

import React, { useCallback, useState } from "react";

const CallBackTest = () => {
  const [count, setCount] = useState(0);
  const [total, setTotal] = useState(0);
  const handleCount = () => setCount(count + 1);
  //const handleCount = useCallback(() => setCount(count + 1), [count]);
  const handleTotal = () => setTotal(total + 1);

  return (
    <div>
      <div>Count is {count}</div>
      <div>Total is {total}</div>
      <br />
      <div>
        <Child onClick={handleCount} label="Increment Count" />
        <Child onClick={handleTotal} label="Increment Total" />
      </div>
    </div>
  );
};

const Child = React.memo(({ onClick, label }) => {
  console.log(`${label} Child Render`);
  return <button onClick={onClick}>{label}</button>;
});

export default CallBackTest;

点击查看详细示例

React.memo是通过记忆组件渲染结果的方式来提高性能,memo是react16.6引入的新属性,通过浅比较(源码通过Object.is方法比较)当前依赖的props和下一个props是否相同来决定是否重新渲染;如果使用过类组件方式,就能知道memo其实就相当于class组件中的React.PureComponent,区别就在于memo用于函数组件。useCallback和React.memo一定要结合使用才能有效果。

使用场景

  • 作为props,传递给子组件,为避免子元素不必要的渲染,需要配合React.Memo使用,否则无意义
  • 作为useEffect的依赖项,需要进行比较的时候才需要加上useCallback

useMemo

React官网定义

返回一个 memoized 值

仅会在某个依赖项改变时才重新计算memoized值,这种优化有助于避免在每次渲染时都进行高开销的计算

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

对于实现上,基本上是和useCallback相似,只是略微有些不同

使用场景

  • 避免在每次渲染时都进行高开销的计算

两个 hooks 内置于 React 都有特别的原因:

  1. 引用相等

    当在React函数组件中定义一个对象时,它跟上次定义的相同对象,引用是不一样的(即使它具有所有相同值和相同属性)

    • 依赖列表
    • React.memo

    大多数时候,你不需要考虑去优化不必要的重新渲染,因为优化总会带来成本。

  2. 昂贵的计算

    计算成本很高的同步计算值的函数

总结

本文介绍了hooks产生动机、函数组件和类组件的区别以及useEffect/useCallback/useMemo等内容。重点介绍了useEffect的生命周期替换方案以及是否把函数作为useEffect的第二参数。

再谈connect设计

阅读本文前,请先阅读前文数据流前篇

回顾以及引入问题

在之前的设计中,我们使用 useReducer 获得了一个强制更新函数(forceComponentUpdateDispatch),然后在store.subcribe回调函数中执行

export const connect = (mapStateToProps, mapDispatchToProps) => (
  WrappedComponent
) => (props) => {
  const { ...wrapperProps } = props;
  const context = useContext(ReactReduxContext);
  const { store } = context; // 解构出store
  const state = store.getState(); // 拿到state
  //使用useReducer得到一个强制更新函数
  const [, forceComponentUpdateDispatch] = useReducer((count) => count + 1, 0);
  // 订阅state的变化,当state变化的时候执行回调
  store.subscribe(() => {
    forceComponentUpdateDispatch();
  });
  // 执行mapStateToProps和mapDispatchToProps
  const stateProps = mapStateToProps?.(state);
  const dispatchProps = mapDispatchToProps?.(store.dispatch);
  // 组装最终的props
  const actualChildProps = Object.assign(
    {},
    stateProps,
    dispatchProps,
    wrapperProps
  );
  return <WrappedComponent {...actualChildProps} />;
};

上述代码已经实现了 store 中数据改变时,对应使用 connect 包裹的组件能够获得对应数据,但是存在一个更新顺序的问题。

connect

在前文中,我们提及到 React 是单向数据流,props 都是父组件传递给子组件的。一旦我们引入了 redux 后,假设父子组件都会引用了同一个变量count,子组件根本不会从父组件拿该参数,而是直接从 redux 中读取,这使得 React 的原本父→子的单向数据流被打破了。

再说到更新问题,在 React 中,如果一个共同变量变化了,那必然是父组件先更新,再把数据传给子组件做更新。但是 redux 里,数据变成 redux→父redux→子,父子组件完全根据 redux 的数据做独立更新,不能完全保证父组件先更新,子组件再更新。react-redux 为了保证更新顺序引入了一个监听者类Subscription

Subscription类

Subscription需要做什么?线上代码

  1. 实现发布订阅,处理所有state的回调
  2. 需要判断当前连接 redux 的组件是否为第一个连接 redux 的组件,如果当前组件就是连接 redux的根组件,它state回调直接注册到 redux store;同时创建一个Subscription实例(subscription)并且通过context传递给子级
  3. 如果当前组件不是根组件,说明已经有组件注册到了 redux store 了,那在子组件中可以拿到通过context传递的subscription(由于是父组件的监听类又称为parentSub),那么当前子组件的回调会注册到parentSub上。并且会新建一个Subscription实例,在context上继续传递,那么当前组件的子组件回调会注册到当前组件的Subscription实例上
  4. state变化了,根组件注册到 redux store 的回调会更新根组件,根组件会手动更新子组件的回调,子组件的回调执行更新子组件,子组件会执行subscription上注册的回调,触发孙子组件更新...这样子就实现了一层一层的组件更新,保证了父→子的更新顺序
export class Subscription {
  constructor(store, parentSub) {
    this.store = store;
    this.parentSub = parentSub;
    this.listeners = [];
    this.handleChangeWrapper = this.handleChangeWrapper.bind(this);
  }
  //当前组件注册
  addNestedSub(listener) {
    this.listeners.push(listener);
  }
  //通知监听者
  notifyNestedSub() {
    this.listeners.forEach((listener) => listener());
  }

  // 回调函数的包装
  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange();
    }
  }

  //注册回调函数
  //如果没有parentSub,说明是根组件注册到store上
  //如果有,就注册到父组件的监听类上
  trySubscribe() {
    this.parentSub
      ? this.parentSub.addNestedSub(this.handleChangeWrapper)
      : this.store.subscribe(this.handleChangeWrapper);
  }
}

Subscription源码

对应改造

Provider

在我们使用 redux 的时候,Provider始终是我们的根组件,所以需要给Provider创建一个Subscription实例再通过context传递下去,线上代码

export const Provider = (props) => {
  const { store, children, context } = props;

  // 传给子组件的context{store,subscription}
  const contextValue = useMemo(() => {
    const subscription = new Subscription(store);
    // 注册回调函数,通知子组件
    subscription.onStateChange = subscription.notifyNestedSubs;
    return { store, subscription };
  }, [store]);

  const previousState = useMemo(() => store.getState(), [store]);

  useEffect(() => {
    const { subscription } = contextValue;
    // 添加监听者
    subscription.trySubscribe();
    // 如果state发生改变,通知监听者
    if (previousState !== store.getState()) {
      subscription.onStateChange();
    }
  }, [contextValue, previousState, store]);

  const Context = context || ReactReduxContext;
  return <Context.Provider value={contextValue}>{children}</Context.Provider>;
};

Provider源码

Connect

在之前的版本中,connect 是直接注册到 store 上,那现在就应该注册在父级的subscription上,在自己更新完成之后,再去通知自己的子级做更新。

还有就是我们需要重写context中的subscription,因为当前组件拿到的subscription是属于它父级的,而当前组件的子级需要的subscription是当前组件创建的,我们需要重写context中的subscription,所以我们的connect返回的组件需要用Context.Provider包裹一下。线上代码

export const connect = (mapStateToProps, mapDispatchToProps) => (
  WrappedComponent
) => (props) => {
  const { ...wrapperProps } = props;
  const context = useContext(ReactReduxContext);
  const { store, subscription: parentSub } = context; // 解构出store
  const subscription = new Subscription(store, parentSub); // 创建当前组件的subscription
  // 保存上一次的值
  const lastChildProps = useRef();
  //使用useReducer得到一个强制更新函数
  const [, forceComponentUpdateDispatch] = useReducer((count) => count + 1, 0);

  // 获取传递给组件的props
  const childPropsSelector = (store, wrapperProps) => {
    const state = store.getState();
    // 执行mapStateToProps和mapDispatchToProps
    const stateProps = mapStateToProps?.(state);
    const dispatchProps = mapDispatchToProps?.(store.dispatch);
    return Object.assign({}, stateProps, dispatchProps, wrapperProps);
  };

  //对比state,处理回调
  const compareStateForUpdate = () => {
    const newChildProps = childPropsSelector(store, wrapperProps);
    if (isEqual(newChildProps, lastChildProps.current)) return;
    lastChildProps.current = newChildProps;
    forceComponentUpdateDispatch();
    subscription.notifyNestedSubs();
  };
  const actualChildProps = childPropsSelector(store, wrapperProps);

  useLayoutEffect(() => {
    lastChildProps.current = actualChildProps;
  }, [actualChildProps]);

  // 使用subscription注册回调
  subscription.onStateChange = compareStateForUpdate;
  subscription.trySubscribe();

  //重写contextValue,把自己的subscription传递下去
  const overWriteContextValue = {
    ...context,
    subscription
  };

  return (
    <ReactReduxContext.Provider value={overWriteContextValue}>
      <WrappedComponent {...actualChildProps} />
    </ReactReduxContext.Provider>
  );
};

connect源码

总结

在本文中,提出了上一篇文章中connect实现的问题,由于 Redux 的引入使得 React 原本的数据流遭遇破坏。通过引入Subscription类实现发布订阅模式,来保证父父→子的一个更新顺序。数据发生改变时,从根组件开始通知自己的子组件,子组件通知其子组件,这样来保证更新顺序。

babel插件和eslint插件对比

前言

babel 和 eslint 都是我们项目中常用的工具,两者都是基于 AST 去扩展的,前者做代码的转换,后者做错误检查和修复。两者都能够做到分析和转换代码。所以两者有啥不同呢?

babel插件

babel 的编译流程分为parse → transform → generate三步,可以指定插件,在遍历 AST 的时候调用visitor,对某些节点做处理

babel插件示例—no-function-assign-plugin

在之前的 babel 插件中,有讲过这个示例

实现思路是: 根据赋值语句去查找作用域,左边的引用是否是一个函数

  • 当我们处理赋值语句AssignmentExpression,判断 left 的引用是否是一个函数
  • 使用path.scope.getBinding,从作用域中查找binding
  • 获取到binding是否为FunctionDeclaration/FunctionExpression
module.exports = function ({ }, options) {
    return {
        pre(file) {
            file.set('errors', []);
        },
        visitor: {
            AssignmentExpression(path, state) {
                const errors = state.file.get("errors")
                const assignTarget = path.get("left").toString()
                const binding = path.scope.getBinding(assignTarget)
                if (binding) {
                    if (binding.path.isFunctionDeclaration() || binding.path.isFunctionExpression()) {
                        const tmp = Error.stackTraceLimit;
                        Error.stackTraceLimit = 0;
                        errors.push(path.buildCodeFrameError('不能重复给函数赋值', Error));
                        Error.stackTraceLimit = tmp;
                    }
                }
            }
        },
        post(file) {
            console.log(file.get('errors'));
        }
    }
}

插件特点

  • 插件返回一个对象,vistor 属性中声明对节点的处理
  • visitor 函数可以通过 path 来对 ast 做增删改查
  • 修改之后的 ast 通过@babel/generator能够生成目标代码

eslint插件

eslint 完全插件化的,每一个规则都是一个插件,在项目中可以配置多个规则。规则列表

eslint 也是 AST 的应用,也需要通过 parser 将源码转为 AST。Eslint 默认使用的是 espree,也可以在配置文件中配置不同的 parser

eslint 和 babel 都会使用 parser 来做转译源码,其实它们的 parser 都是基于 estree 标准实现和扩充的

parser

acorn 的实现是基于插件的,所以 espree / babel parser 也能够通过插件来扩充

eslint插件示例(no-function-assign)

eslint 的 rule 实现包括两部分:

  • meta: 信息/文档/报错信息等
  • create: 返回一个对象,其中包含一些 Eslint 遍历 AST 时,访问节点的方法

创建 eslint 插件

基于 Yeoman generator 快速创建 Eslint Plugin 项目

npm i -g yo
npm i -g generator-eslint
// 创建一个plugin
yo eslint:plugin
// 创建一个规则
yo eslint:rule

rule

lib/rules 中存储的是当前插件的所有 rule,tests/lib/rules 中存储的是对应的测试文件
package.json 中 name 属性就是当前插件的名字全部以 eslint-plugin-xxx 来命名

no-function-assign实现

上述的 babel 插件,是找到赋值语句再去判断需要赋值的引用是否为函数声明或者函数表达式

那其实 eslint 也是可以采用相同的思路去实现的,找到找到赋值语句再去判断需要赋值的引用是为函数,那在这其中我们需要用到context.getScope这个 API 去获取作用域里面的信息

module.exports = {
    create(context) {
        function checkIdentifierIsFunction(scope, leftName) {
            const allVariables = scope.variables
            for (const variable of allVariables) {
                const defs = variable.defs
                for (const def of defs) {
                    if (def.name.name === leftName && def.type === "FunctionName") return true
                    if (def.name.name === leftName) return false
                }
            }
            if (scope.upper) {
                return checkIdentifierIsFunction(scope.upper, leftName)
            }
            return false
        }
        return {
            AssignmentExpression: (node) => {
                const left = node.left
                const scope = context.getScope()
                if (checkIdentifierIsFunction(scope, left.name)) {
                    context.report({
                        node: node,
                        message: `${left} is a function`
                    })
                }
            }
        };
    },
};

但是上述代码随着 ES6 结构语法的出现,就不能够实现我们的效果,[foo] = bar; function foo(){},对于这个代码来说,赋值语句的左边是[foo],也就是一个 ArrayPattern 节点,根本不会存在 name 值,所以checkIdentifierIsFunction的返回将一直是false,所以根本不会报错(上面babel 实现的插件也会有这个问题)。

或许你会问,是不是可以再多加一层的判断对 ArrayPattern 节点做一个特殊处理,比如下面这样

return {
    AssignmentExpression: (node) => {
        const left = node.left
        const scope = context.getScope()
        if (!left.name) {
            if (left.type === "ArrayPattern") {
                const elements = left.elements
                for (const element of elements) {
                    if (checkIdentifierIsFunction(scope, element.name)) {
                        context.report({
                            node: node,
                            message: `${element.name} is a function`
                        })
                    }
                }
                return
            }
        }
        if (checkIdentifierIsFunction(scope, left.name)) {
            context.report({
                node: node,
                message: `${left} is a function`
            })
        }
    }
};

虽然这样能够兼容 ArrayPattern,奈何我们 ES6 还提供了对象的结构,那总不能针对于每一个情况都去做特殊处理吧?
为了兼容所有的情况,Eslint 目前的解决思路是,找到对应的函数声明或者函数表达式,再去获得作用域中当前节点引用是否为一个赋值语句

module.exports = {
    meta: {
        type: null, // `problem`, `suggestion`, or `layout`
        docs: {
            description: "函数不能重新赋值",
            category: "Fill me in",
            recommended: false,
            url: null, // URL to the documentation page for this rule
        },
        fixable: null, // Or `code` or `whitespace`
        schema: [], // Add a schema if the rule has options
        messages: {
            isAFunction: "'{{name}}' 是一个函数,不能够重新赋值"
        }
    },

    create(context) {
        function checkForFunction(node) {
            context.getDeclaredVariables(node).forEach(checkVariable);
        }

        function checkVariable(variable) {
            if (variable.defs[0].type === "FunctionName") {
                checkReference(variable.references);
            }
        }

        function checkReference(references) {
            // 如果是赋值语句这种
            getModifyingReferences(references).forEach(reference => {
                context.report({
                    node: reference.identifier,
                    messageId: "isAFunction",
                    data: {
                        name: reference.identifier.name
                    }
                });
            });
        }

        return {
            FunctionDeclaration: checkForFunction,
            FunctionExpression: checkForFunction
        };
    },
};

通过context.getDeclaredVariables拿到当前node的variable,再去遍历每一个 variable 的引用中是否有函数

项目中引入插件

由于是本地开发,所以采用npm link的方式,使用我们创建的 plugins
link
在我们的项目中引入该插件npm link eslint-plugin-demo,创建对应的软链接
demo

配置如下的 .eslintrc.js 文件,编写对应的代码,执行一下 eslint

module.exports = {
    "plugins": [
        "eslint-plugin-demo"
    ],
    "rules": {  //0或off(关闭) 1或warn 2或error
        "demo/no-function-assign": 2
    }
};

lint

eslint插件示例(block-one-line)

在该示例中,会对大括号的位置进行校验,并且开始自动修复模式,能够自动修复报错。

line
我们需要处理块级语句,也就是BlockStatement,对于该节点来说,希望它和判断条件在一行并且中间保持一个空格。

⚠️ 我们之所以在 Eslint 中可以做格式校验,是因为我们可以通过 context 提供的getSourceCodeAPI获得 AST 以及相关的 tokens
sourceCode
token

token 中包含了每一个单词的位置信息,sourceCode 提供了通过 node 获取对应token

//块级node开始的token,也就是红色部分
const firstToken = sourceCode.getFirstToken(node);
//块级node前一个token,也就是绿色部分
const beforeFirstToken = sourceCode.getTokenBefore(node);

当我们拿到两个对应 token 之后,将其位置(line/range)做一个判断,就能完成我们的校验

实现自动修复功能,只需要在context.report中定义fix函数,而fix函数也是调用 eslint 的fixer,我们采用replaceTextRange,将[beforeFirstToken.range[1], firstToken.range[0]]中间的内容替换为空格即可

module.exports = {
  create(context) {
    const sourceCode = context.getSourceCode();
    return {
      BlockStatement(node) {
        //块级node开始的token
        const firstToken = sourceCode.getFirstToken(node);
        //块级node前一个token
        const beforeFirstToken = sourceCode.getTokenBefore(node);
        if (firstToken.loc.start.line !== beforeFirstToken.loc.start.line) {
          return context.report({
            node,
            loc: firstToken.loc,
            message: '大括号不需要换行',
            fix: fixer => {
              return fixer.replaceTextRange([beforeFirstToken.range[1], firstToken.range[0]], ' ');
            }
          });
        }
        if (firstToken.range[0] - 1 !== beforeFirstToken.range[1]) {
          return context.report({
            node,
            loc: firstToken.loc,
            message: '大括号前需要空格',
            fix: fixer => {
              return fixer.replaceTextRange([beforeFirstToken.range[1], firstToken.range[0]], ' ');
            }
          });
        }
      }
    };
  },
};

eslint 实现原理

原理

  • 读取配置时,可以利用层叠配置。层叠配置能够让检测的文件最近的 eslintrc 文件的优先级最高

    your-project
    ├── .eslintrc
    ├── lib
     └── source.js     // 使用根目录下的eslintrc
    └─┬ tests
      ├── .eslintrc
      └── test.js       // 使用根目录下和tests/eslintrc的组合,且tests/eslintrc中的配置优先级更高

    当项目中 package.json 中有 eslintConfig 配置时,可用整个项目,但是根目录 eslintrc 优先级更高
    关于更多读取配置信息,点击查看

  • 加载配置,对于 extends 来说,支持我们使用插件中的配置,所以会递归扩展配置,并且前面的配置项优先级会高于 extends的
    关于更多加载配置信息, 点击查看

eslint插件的特点

  • 一个 plugin 是由 一个或者多个 rule 组成的
  • 每一个 rule 都是一个对象,其中包含 meta 一些元信息,create 函数返回一个对象,定义对应的访问节点
  • 对于定义的节点可以通过 context 来获取到源码中 tokens 等,来进行格式检查
  • 通过在context.report中定义 fix 函数,使用 fixer 来对某个位置的代码进行字符的增删改,可以通过 eslint 配置开启是否自动修复功能

为什么Eslint能够做格式检查

Eslint 之所以能够做错误检查,它的 AST 记录了源代码所有的 token,token 中有行列号信息,而且 AST 中也保存了 range,也就是当前节点的开始结束位置。并且还提供了 SourceCode 的 api 可以根据 range 去查询 token。这是它能实现格式检查的原因。

而 Babel 其实也支持 range 和 token,但是却没有提供根据 range 查询 token 的 api,这是它不能做格式检查的原因。

总结

Babel 和 Eslint 原理是差不多的,先将源代码 parse,在提供对应的访问节点。但是 Eslint 是被设计来做代码错误和格式检查与修复的,而 Babel 是被设计用来做代码分析和转换的,所以也就提供了不同的 api,支持做不同的事情

对比

参考链接

babel基础介绍

babel的背景

babel的原名叫 6to5,简明扼要就是 es6 转 es5,但是没想到 es 标准推进的过快,短时间就有了 es7/8,所以它改名为 babel

babel的用途

  • 转译 esnext/typescript 等到目标环境支持的js

    用来把代码中的 esnext 的新的语法、typescript 和 flow 的语法转成基于目标环境支持的语法的实现。并且还可以把目标环境不支持的 api 进行 polyfill。babel7 支持了 preset-env,可以指定 targets 来进行按需转换

  • 一些特定用途的代码转换

    babel 是一个转译器,暴露了很多 api,用这些 api 可以完成代码到 AST 的 parse,AST 的转换,以及目标代码的生成

  • 代码的静态分析

    对代码进行 parse 之后,能够进行转换,是因为通过 AST 的结构能够理解代码,也可以用于分析代码的信息,进行一些检查

babel的转译

transform

// 源代码
const sourceCode = `
 const a = 1
`;
// 调用parse,生成ast
const ast = parser.parse(sourceCode, {})

// 调用traverse执行自定义的逻辑,处理ast节点
traverse(ast, {})

// 生成目标代码
const { code } = generate(ast, {});

console.log('result after deal with》〉》〉》', code)

babel的架构

image

  1. 核心@babel/core
    • 加载处理配置/加载插件
    • 调用Parser进行语法解析,生成 AST
    • 调用Traverser遍历AST,并使用访问者模式应用插件对 AST 进行转换
    • 调用Generator生成代码,包括SourceMap转换和源代码生成
  2. 核心周边支撑
    • Parser: @babel/parser
    • Traverser: @babel/traverser
    • Generator: @babel/generator
  3. 插件
    • 语法插件: 该类插件只允许 Babel 解析特定类型的语法
    • 转换插件: 用于对 AST 进行转换,实现转换为ES5代码、压缩、功能增强等目的
  4. 插件开发辅助
    • @babel/template: 可以将字符串转为 AST 节点
    • @babel/types: 对 AST 节点的断言

babel的编译配置

创建一个 babel 项目,在 src/index.js 中,写入如下代码

const fn = () => {
    console.log(111);
}

如果我们什么都不配置,直接执行编译,会发现前后的代码完全一致。因为 babel 是基于插件的,所以当我们什么插件都不配置的时候,babel 什么都不会做。

插件(plugins)

我们想将箭头函数转为ES5函数,只需要提供一个转换箭头函数的插件。

在项目目录下新建.babelrc文件,添加上如下配置

{
    "plugins": [
        "@babel/plugin-transform-arrow-functions"
    ]
}

再一次执行编译,会发现上述代码中的箭头函数已经成功被编译,代码如下:

const fn = function () {
  console.log(111);
};

// ===== 编译后的结果 ===== //

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

如果我们还需要支持解构语法,那么我们需要给它配置"@babel/plugin-transform-destructuring"插件。

{
    "plugins": [
        "@babel/plugin-transform-arrow-functions",
        "@babel/plugin-transform-destructuring"
    ]
}

插件是有一个执行顺序的,插件是从上往下执行的,所以 Babel 在遍历AST时会先调用@babel/plugin-transform-arrow-functions定义的转换方法,然后再调用@babel/plugin-transform-destructuring

🤔 发现问题所在,如果我们需要转换的语法很多,那岂不是需要手动配置很多插件,实在繁琐。

预设(preset)

preset 的出现就是为了解决上述问题。通过添加/创建一个 preset 就可以轻松的使用一组插件。官方也为我们提供了很多的 presets

preset执行顺序

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react",
        "@babel/preset-typescript"
    ]
}

前面提到 plugins 的执行顺序是从上往下,而 preset 的执行顺序恰恰相反,是从下往上执行的。并且 plugins 的执行先于 preset

一些过时的preset

  1. @babel/preset-stage-xxx

    stage-xxx是不同阶段语法提案的转码规则而产生的预设,随着被批准为 ES 新版本的组成部分而进行相应的改变

    • stage-0 - 设想(Strawman): 只是一个想法,可能有 Babel 插件,stage-0 的功能范围最广大,包含 stage-1 , stage-2 以及 stage-3 的所有功能
    • stage-1 - 建议(Proposal): 这是值得跟进的
    • stage-2 - 草案(Draft): 初始规范
    • stage-3 - 候选(Candidate): 完成规范并在浏览器上初步实现
    • stage-4 - 完成(Finished): 将添加到下一个年度版本发布中
  2. @babel/preset-es2015

    ES 的标准一年一个版本,意味着 babel 插件需要去实时跟进,es6 语法采用@babel/preset-es2015,es7 语法就需要引入@babel/preset-es2016,如果是一些还未加入标准的语法就需要用上述讲的 stage0/stage1 等

上述讲的 preset-stage-xxx/preset-es20xx 都是 babel6 的产物,依旧会发现一些问题,preset 难以维护,ES 的标准变化比较快,意味着 stage-xxx 变得也很快。如果目标环境已经支持了 ES6+ 特性,那我们就不用做转换了。

@babel/preset-env

babel7 中,淘汰了上述的preset-es20xx,开始推行 preset/env

preset-env 可以使用es6+语法去写代码,并且只转换需要转换的代码。

默认情况下,preset-env 什么都不需要配置,它会默认转换所有的es6+的代码。提供了 targets 配置项制定运行环境。

修改 .babelrc 文件,修改为如下配置

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": "ie >= 10" // 表明只有在ie10以上版本浏览器不支持的语法才会被转换
            }
        ]
    ]
}

修改 src/index.js

const arr = [1, 2, 3, 4]
const arr1 = [...arr]
arr.includes(1)
const p = new Promise((resolve, reject) => {
    resolve("FBB");
});

// ===== 编译后的结果 ===== //

"use strict";

var arr = [1, 2, 3, 4];
var arr1 = [].concat(arr);
arr.includes(1);
var p = new Promise(function (resolve, reject) {
  resolve("FBB");
});   // includes/Promise竟然没有被转换????

🤔 ES6 增加的内容可以分为语法和 api 两个部分。新语法比如箭头函数/解构/class等,新的api比如Set/Map/Promise/Array原型链上等。

语法转换只是将高版本语法转为低版本的,但是新的内置函数/实例方法等无法转换。所以这时polyfill出现了。

@babel/polyfill

polyfill是垫片的意思,所谓垫片就是抹平不同浏览器或者不同环境下的差异,让新的内置函数、实例方法等在低版本浏览器中也可以使用

为我们的代码添加 @babel/polyfill,直接在 src/index.js 前引入该包

import "@babel/polyfill";

const arr = [1, 2, 3, 4]
const arr1 = [...arr]
arr.includes(1)
const p = new Promise((resolve, reject) => {
    resolve("FBB");
});

// ===== 编译后的结果 ===== //

"use strict";

require("@babel/polyfill");

var arr = [1, 2, 3, 4];
var arr1 = [].concat(arr);
arr.includes(1);
var p = new Promise(function (resolve, reject) {
  resolve("FBB");
});

经过 babel 编译后的内容,其实也是引入了 @babel/polyfill 的包,这个时候采用的是全量引入,不管有无使用的 API 都会被引入

🤔 那其实我们代码只需要 Promise 和 includes 的polyfill,那有没有一种按需加载的功能?当然有,babel不会连这么蠢的问题都不解决。

useBuiltIns

在回到上一节所讲的 @babel/preset-env,我们刚刚提到了 target配置项是用于标识目标环境。useBuiltIns 该配置是用于做 polyfill 的,我们在 .babelrc 中加入该配置项,babel 编译时就会自动进行 polyfill,不需要我们在手动引入

useBuiltIns 的参数:

  • false: 不会对 polyfill 做操作,引入 @babel/polyfill 之后会全量引入

  • usage: 会根据配置的目标环境的兼容性以及代码中使用的 API 来进行 polyfill,实现按需加载

  • entry: 会根据配置的浏览器兼容,引入浏览器不兼容的 polyfill,需要在入口文件手动添加import '@babel/polyfill'。如果指定的"corejs": "3",则需要引入import 'core-js/stable'; import 'regenerator-runtime/runtime'

    💡core-js是JavaScript 的模块化标准库,包含 Promise/Symbol/Iterator 和许多其他的特性,它可以让你仅加载必需的功能。[email protected]的版本已经之冻结,所有的新特性只会添加到3.0的分支中

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": "ie >= 10",
                "useBuiltIns": "usage",
                "corejs": "3" // 声明 corejs 版本
            }
        ]
    ]
}

源代码以及转换之后的代码

const arr = [1, 2, 3, 4]
const arr1 = [...arr]
arr.includes(1)
const p = new Promise((resolve, reject) => {
    resolve("FBB")
})

// ===== 编译后的结果 ===== //

"use strict";

require("core-js/modules/es.array.concat.js");

require("core-js/modules/es.array.includes.js");

require("core-js/modules/es.object.to-string.js");

require("core-js/modules/es.promise.js");

var arr = [1, 2, 3, 4];
var arr1 = [].concat(arr);
arr.includes(1);
var p = new Promise(function (resolve, reject) {
  resolve("FBB");
});

🤔 @babel/preset-env 是如何实现按需加载的呢?

首先我们在 @babel/preset-env 的 target 配置项中,可以设置目标环境。在上面的示例中我们设置的环境是 ie10+,targets 是 browserlist 的查询字符串,能够获得项目中的目标浏览器环境信息

当我们拿到所有的浏览器信息之后,我们还需要知道每个特性在不同版本浏览器是否支持,babel-compat-data 中就存放了该内容。

有了浏览器版本,已经每个特性支持的浏览器版本,那我们就能够知道当前目标浏览器支持和不支持的特性。对于不支持的特性做转换和 polyfill。

@babel/plugin-transform-runtime

上述讲完了按需引入,会有一个新的问题等待我们去解决,看如下代码

class Person {
    constructor() { }
    say(word) {
        console.log(":::", word)
    }
}

// ===== 编译后的结果 ===== //

"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }

var Person = /*#__PURE__*/function () {
  function Person() {
    _classCallCheck(this, Person);
  }

  _createClass(Person, [{
    key: "say",
    value: function say(word) {
      console.log(":::", word);
    }
  }]);

  return Person;
}();

其中有_createClass/_defineProperties/_classCallCheck三个辅助函数,假设我们有10个文件中都使用了 class 语法,那么这三个辅助函数会在注入十次。这会使得我们打包的代码变大,并且我们不需要这样的辅助函数被注入多次

这时候@babel/plugin-transform-runtime就闪亮登场了。使用@babel/plugin-transform-runtime插件,所有帮助程序都将引用模块@babel/runtime,这样就可以避免编译后的代码中出现重复的帮助程序,有效减少包体积

首先安装依赖,@babel/plugin-transform-runtime通常仅在开发时使用,但是运行时最终代码需要依赖@babel/runtime,所以@babel/runtime必须要作为生产依赖被安装

修改 .babelrc 如下

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": "ie >= 10",
                "useBuiltIns": "usage",
                "corejs": 3
            }
        ]
    ],
    "plugins": [
        [
            "@babel/plugin-transform-runtime"
        ]
    ]
}

再次编译我们得到如下的代码,我们发_createClass/_defineProperties/_classCallCheck三个函数都是从 babel/runtime 中引入的了

"use strict";

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));

var Person = /*#__PURE__*/function () {
  function Person() {
    (0, _classCallCheck2.default)(this, Person);
  }

  (0, _createClass2.default)(Person, [{
    key: "say",
    value: function say(word) {
      console.log(":::", word);
    }
  }]);
  return Person;
}();

这样的话就解决了代码冗余的问题,再回到我们刚刚使用 useBuiltIns 实现按需加载的例子中,经过编译我们发现会引入如下几个文件

const arr = [1, 2, 3, 4]
const arr1 = [...arr]
arr.includes(1)
const p = new Promise((resolve, reject) => {
    resolve("FBB")
})

// ===== 编译后的结果 ===== //

"use strict";

require("core-js/modules/es.array.concat.js");

require("core-js/modules/es.array.includes.js");

require("core-js/modules/es.object.to-string.js");

require("core-js/modules/es.promise.js");

var arr = [1, 2, 3, 4];
var arr1 = [].concat(arr);
arr.includes(1);
var p = new Promise(function (resolve, reject) {
  resolve("FBB");
});

Array.prototype上新增了includes方法,并且新增了全局的Promise方法,污染了全局环境。对于一个应用程序来说,这并不会有什么问题。但是如果我们的代码会做为一个库发布并提供给别人使用就会出现问题

我们可以使用@babel/plugin-transform-runtime来帮我们解决这个问题

修改我们 .babelrc 文件

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": "ie >= 10"
            }
        ]
    ],
    "plugins": [
        [
            "@babel/plugin-transform-runtime",
            {
                "corejs": "3.0"
            }
        ]
    ]
}

重新编译之后会得到如下结果,发现最终转换后的文件不会再出现 polyfill 的 require 方法了。可以看出,没有直接去修改Array.prototype,或者是新增Promise方法,而是将方法重写成为_promise/_includes,避免了全局污染

"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");

var _concat = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/concat"));

var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/includes"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/promise"));

var _context;

var arr = [1, 2, 3, 4];
var arr1 = (0, _concat.default)(_context = []).call(_context, arr);
(0, _includes.default)(arr).call(arr, 1);
var p = new _promise.default(function (resolve, reject) {
  resolve("FBB");
});

plugin-transform-runtime 插件借助 babel-runtime 实现了下面两个重要的功能

  • 对辅助函数的复用,解决转译语法层时出现的代码冗余
  • 解决转译api层出现的全局变量污染

总结

在本文中简单介绍了:

  • Babel 的转译过程/基础架构,相关包的核心包的意义
  • 重点放到了 .babelrc 的配置上,从 plugins 的使用,到为什么产生了 presets
  • @babel/preset-env 出现的原因和解决问题,以及通过 browserList 和 babel-compat-data 实现的按需加载
  • 使用 @babel/polyfill 解决 API 不能够被转译的,但是产生了全量引用的问题
  • 为了解决 @babel/polyfill 解决转译语法层时出现的代码冗余 以及全局变量污染问题,@babel/plugin-transform-runtime 出现了

参考链接

React中的数据流管理

前言

💡 为什么数据流管理重要?React的核心**为:UI=render(data),data就是所谓的数据,render是React提供的纯函数,所以UI展示完全由数据层决定。

在本文中,会简单介绍react中的数据流管理,从自身的context到三方库的redux的相关概念,以及redux附属内容丐版实现。

在正文之前,先简单介绍数据状态的概念。React是利用可复用的组件来构建界面,组件本质上有限状态机,能够记住当前组件的状态,根据不同的状态变化做出相关的操作。在React中,把这种状态定义为state。通过管理状态来实现对组件的管理,当state发生改变时,React会自动去执行相应的操作。

而数据,它不仅指server层返回给前端的数据,React中的状态也是一种数据。当数据改变时,我们需要改变状态去引发界面的变更。

React自身的数据流方案

基于Props的单向数据流

React是自上而下的单向数据流,容器组件&展示组件是最常见的React组件设计方案。容器组件负责处理复杂的业务逻辑和数据,展示组件负责处理UI层。通常我们会把展示组件抽出来复用或者组件库的封装,容器组件自身通过state来管理状态,setState更新状态,从而更新UI,通过props将自身的state传递给展示组件实现通信

111

对于简单的通信,基于props串联父子和兄弟组件是很灵活的。

但是对于嵌套深数据流组件,A→B→C→D→E,A的数据需要传递给E使用,那么我们需要在B/C/D的props都加上该数据,导致最为中间组件的B/C/D来说会引入一些不属于自己的属性

使用Context API维护全局状态

Context API是React官方提供的一种组件树全局通信方式

Context基于生产者-消费者模式,对应React中的三个概念: React.createContextProviderConsumer。通过调用createContext创建出一组ProviderProvider作为数据的提供方,可以将数据下发给自身组件树中的任意层级的Consumer,而Consumer不仅能够读取到Provider下发的数据还能读取到这些数据后续的更新值

const defaultValue = {
  count: 0,
  increment: () => {}
};

const ValueContext = React.createContext(defaultValue);

<ValueContext.Provider value={this.state.contextState}>
  <div className="App">
    <div>Count: {count}</div>
    <ButtonContainer />
    <ValueContainer />
  </div>
</ValueContext.Provider>

<ValueContext.Consumer>
  {({ increment }) => (
    <button onClick={increment} className="button">increment</button>
  )}
</ValueContext.Consumer>

16.3之前的用法16.3之后的createContext用法useContext用法

Context工作流的简单图解:

Untitled

在v16.3之前由于各种局限性不被推荐使用

  • 代码不够简单优雅:生产者需要定义childContextTypesgetChildContext,消费者需要定义ChildTypes才能够访问this.context访问到生产者提供的数据
  • 数据无法及时同步:类组件中可以使用shouldComponentUpdate返回false或者是PureComponent,后代组件都不会被更新,这违背了Context模式的设置,导致生产者和消费者之间不能及时同步

在v16.3之后的版本中做了对应的调整,即使组件的shouldComponentUpdate返回false,它仍然可以”穿透”组件继续向后代组件进行传播,更改了声明方式变得更加语义化,使得Context成为了一种可行的通信方案

但是Context的也是通过一个容器组件来管理状态的,但是ConsumerProvider是一一对应的,在项目复杂度高的时候,可能会出现多个ProviderConsumer,甚至一个Consumer需要对应多个Provider的情况

当某个组件的业务逻辑变得非常复杂时,代码会越写越多,因为我们只能够在组件内部去控制数据流,这样导致Model和View都在View层,业务逻辑和UI实现都在一块,难以维护

所以这个时候需要真正的数据流管理工具,从UI层完全抽离出来,只负责管理数据,让React只专注于View层的绘制

Redux

Redux是JS应用的状态容器,提供可预测的状态管理

Redux的三大原则

  • 单一数据源:整个应用的state都存储在一棵树上,并且这棵状态树只存在于唯一的store中
  • state是只读的:对state的修改只有触发action
  • 用纯函数执行修改:reducer根据旧状态和传进来的action来生成一个新的state(类似与reduce的**,接受上一个state和当前项action,计算出来一个新值)

Redux工作流

image.png

不可变性(Immutability)

mutable意为可改变的,immutability意为用不可改变的

在JS的对象(object)和数组(array)默认都是mutable,创建一个对象/数组都是可以改变内容

const obj = { name: 'FBB', age: 20 };
obj.name = 'shuangxu';

const arr = [1,2,3];
arr[1] = 6;
arr.push('change');

改变对象或者数组,内存中的引用地址尚未改变,但是内容已经改变

如果想用不可变的方式来更新,代码必须复制原来的对象/数组,更新它的复制体

const obj = { info: { name: 'FBB', age: 20 }, phone: '177xxx' }
const cloneObj = { ...obj, info: { name: 'shuangxu' } }

//浅拷贝、深拷贝

Redux期望所有的状态都采用不可变的方式。

react-redux

react-redux是Redux提供的react绑定,辅助在react项目中使用redux

它的API简单,包括一个组件Provider和一个高阶函数connect

Provider

❓为什么Provider只传递一个store,被它包裹的组件都能够访问到store的数据呢?

Provider做了些啥?

  • 创建一个contextValue包含redux传入的store和根据store创建出的subscription,发布订阅均为subscription做的
  • 通过context上下文把contextValue传递子组件

Connect

❓connect做了什么事情讷?

使用容器组件通过context提供的store,并将mapStateToPropsmapDispatchToProps返回的statedispatch传递给UI组件

组件依赖redux的state,映射到容器组件的props中,state改变时触发容器组件的props的改变,触发容器组件组件更新视图

const enhancer = connect(mapStateToProps, mapDispatchToProps)
enhancer(Component)

react-redux丐版实现

mini-react-redux

Provider

export const Provider = (props) => {
  const { store, children, context } = props;
  const contextValue = { store };
  const Context = context || ReactReduxContext;
  return <Context.Provider value={contextValue}>{children}</Context.Provider>
};

connect

import { useContext, useReducer } from "react";
import { ReactReduxContext } from "./ReactReduxContext";


export const connect = (mapStateToProps, mapDispatchToProps) => (
  WrappedComponent
) => (props) => {
  const { ...wrapperProps } = props;
  const context = useContext(ReactReduxContext);
  const { store } = context; // 解构出store
  const state = store.getState(); // 拿到state
  //使用useReducer得到一个强制更新函数
  const [, forceComponentUpdateDispatch] = useReducer((count) => count + 1, 0);
  // 订阅state的变化,当state变化的时候执行回调
  store.subscribe(() => {
    forceComponentUpdateDispatch();
  });
  // 执行mapStateToProps和mapDispatchToProps
  const stateProps = mapStateToProps?.(state);
  const dispatchProps = mapDispatchToProps?.(store.dispatch);
  // 组装最终的props
  const actualChildProps = Object.assign(
    {},
    stateProps,
    dispatchProps,
    wrapperProps
  );
  return <WrappedComponent {...actualChildProps} />;
};

redux Middleware

“It provides a third-party extension point between dispatching an action, and the moment it reaches the reducer.” – Dan Abramov

middleware提供分类处理action的机会,在middleware中可以检查每一个action,挑选出特定类型的action做对应操作

image.png

middleware示例

打印日志

store.dispatch = (action) => {
  console.log("this state", store.getState());
  console.log(action);
  next(action);
  console.log("next state", store.getState());
};

监控错误

store.dispatch = (action) => {
  try {
    next(action);
  } catch (err) {
    console.log("catch---", err);
  }
};

二者合二为一

store.dispatch = (action) => {
  try {
    console.log("this state", store.getState());
    console.log(action);
    next(action);
    console.log("next state", store.getState());
  } catch (err) {
    console.log("catch---", err);
  }
};

提取loggerMiddleware/catchMiddleware

const loggerMiddleware = (action) => {
  console.log("this state", store.getState());
  console.log("action", action);
  next(action);
  console.log("next state", store.getState());
};
const catchMiddleware = (action) => {
  try {
    loggerMiddleware(action);
  } catch (err) {
    console.error("错误报告: ", err);
  }
};
store.dispatch = catchMiddleware

catchMiddleware中都写死了调用的loggerMiddleware,loggerMiddleware中写死了next(store.dispatch),需要灵活运用,让middleware接受dispatch参数

const loggerMiddleware = (next) => (action) => {
  console.log("this state", store.getState());
  console.log("action", action);
  next(action);
  console.log("next state", store.getState());
};
const catchMiddleware = (next) => (action) => {
  try {
    /*loggerMiddleware(action);*/
    next(action);
  } catch (err) {
    console.error("错误报告: ", err);
  }
};
/*loggerMiddleware 变成参数传进去*/
store.dispatch = catchMiddleware(loggerMiddleware(next));

middleware中接受一个store,就能够把上面的方法提取到单独的函数文件中

export const catchMiddleware = (store) => (next) => (action) => {
  try {
    next(action);
  } catch (err) {
    console.error("错误报告: ", err);
  }
};

export const loggerMiddleware = (store) => (next) => (action) => {
  console.log("this state", store.getState());
  console.log("action", action);
  next(action);
  console.log("next state", store.getState());
};

const logger = loggerMiddleware(store);
const exception = catchMiddleware(store);
store.dispatch = exception(logger(next));

每个middleware都需要接受store参数,继续优化这个调用函数

export const applyMiddleware = (middlewares) => {
  return (oldCreateStore) => {
    return (reducer, initState) => {
      //获得老的store
      const store = oldCreateStore(reducer, initState);
      //[catch, logger]
      const chain = middlewares.map((middleware) => middleware(store));
      let oldDispatch = store.dispatch;
      chain
        .reverse()
        .forEach((middleware) => (oldDispatch = middleware(oldDispatch)));
      store.dispatch = oldDispatch;
      return store;
    };
  };
};

const newStore = applyMiddleware([catchMiddleware, loggerMiddleware])(
  createStore
)(rootReducer);

Redux提供了applyMiddleware来加载middleware,applyMiddleware接受三个参数,middlewares数组/reduxcreateStore/reducer

export default function applyMiddleware(...middlewares) {
  return createStore => (reducer, ...args) => {
    //由createStore和reducer创建store
    const store = createStore(reducer, ...args) 
    let dispatch = store.dispatch
    var middlewareAPI = {
      getState: store.getState,
      dispatch: (action, ...args) => dispatch(action, ...args)
    }
    //把getState/dispatch传给middleware,
    //map让每个middleware获得了middlewareAPI参数
    //形成一个chain匿名函数数组[f1,f2,f3...fn]
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    //dispatch=f1(f2(f3(store.dispatch))),把所有  的middleware串联起来
    dispatch = compose(...chain)(store.dispatch)
    return {
      ...store,
      dispatch
    }
  }
}

applyMiddleware符合洋葱模型

image

总结

本文意在讲解react的数据流管理。从react本身的提供的数据流方式出发

  1. 基于props的单向数据流,串联父子和兄弟组件非常灵活,但是对于嵌套过深的组件,会使得中间组件都加上不需要的props数据
  2. 使用Context维护全局状态,介绍了v16.3之前/v16.3之后/hooks,不同版本context的使用,以及v16.3之前版本的context的弊端。
  3. 引入redux,第三方的状态容器,以及react-redux API(Provider/connect)分析与丐版实现,最后介绍了redux强大的中间件是如何重写dispatch方法

参考连接

重新认识受控和非受控组件

该文章包含如下内容

  • 受控与非受控组件
    • 非受控组件
    • 受控组件
  • 受控和非受控组件边界
  • 反模式
  • 解决方案

前言

在HTML中,表单元素(<input>/<textarea>/<select>),通常自己会维护state,并根据用户的输入进行更新

<form>
  <label>
    名字:
    <input type="text" name="name" />
  </label>
  <input type="submit" value="提交" />
</form>

在这个HTML中,我们可以在input中随意的输入值,如果我们需要获取到当前input所输入的内容,应该怎么做呢?

受控与非受控组件

非受控组件(uncontrolled component)

使用非受控组件,不是为每个状态更新编写数据处理函数,而是将表单数据交给DOM节点来处理,可以使用Ref来获取数据

在非受控组件中,希望能够赋予表单一个初始值,但是不去控制后续的更新。可以采用defaultValue指定一个默认值

class Form extends Component {
  handleSubmitClick = () => {
    const name = this._name.value;
    // do something with `name`
  }
  render() {
    return (
      <div>
        <input
          type="text"
          defaultValue="Bob"
          ref={input => this._name = input}
        />
        <button onClick={this.handleSubmitClick}>Sign up</button>
      </div>
    );
  }
}

受控组件(controlled component)

在React中,可变状态(mutable state)通常保存在组件的state属性中,并且只能够通过setState 来更新

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: 'shuangxu'};
  }
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          名字:
          <input type="text" value={this.state.value}/>
        </label>
        <input type="submit" value="提交" />
      </form>
    );
  }
}

在上述的代码中,在input设置了value属性值,因此显示的值始终为this.state.value,这使得state成为了唯一的数据源。

const handleChange = (event) => {
  this.setState({ value: event.target.value })
}

<input type="text" value={this.state.value} onChange={this.handleChange}/>

如果我们在上面的示例中写入handleChange 方法,那么每次按键都会执行该方法并且更新React的state,因此表单的值将随着用户的输入而改变

React组件控制着用户输入过程中表单发生的操作并且state还是唯一数据源,被React以这种方式控制取值的表单输入元素叫做受控组件

📌 对于受控组件来说,输入的值始终由Reactstate驱动!!!

受控和非受控组件边界

非受控组件

Input组件只接收一个defaultValue默认值,调用Input组件的时候,只需要通过props传递一个defaultValue 即可

//组件
function Input({defaultValue}){
  return <input defaultValue={defaultValue} />  
}

//调用
function Demo(){
  return <Input defaultValue='shuangxu' />
}

受控组件

数值的展示和变更需要由statesetState,组件内部控制state,并实现自己的onChange方法

//组件
function Input() {
  const [value, setValue] = useState('shuangxu')
  return <input value={value} onChange={e=>setValue(e.target.value)} />;
}

//调用
function Demo() {
  return <Input />;
}

请问这时Input组件是受控还是非受控?如果我们采用之前的写法更改这个组件以及其调用

//组件
function Input({defaultValue}) {
  const [value, setValue] = useState(defaultValue)
  return <input value={value} onChange={e=>setValue(e.target.value)} />;
}

//调用
function Demo() {
  return <Input defaultValue='shuangxu' />;
}

此时的Input组件本身是一个受控组件,它是由唯一的state数据驱动的。但是对于Demo来说,我们并没有Input组件的一个数据变更权利,那么对于Demo组件来说,Input组件就是一个非受控组件。(‼️以非受控组件的方式去调用受控组件是一种反模式)

如何修改当前的Input和Demo组件代码,才能够使得Input组件本身也是一个受控组件,并且对于Demo组件来说它也是受控的讷?

function Input({value, onChange}){
  return <input value={value} onChange={onChange} />
}

function Demo(){
  const [value, setValue] = useState('shuangxu')
  return <Input value={value} onChange={e => setValue(e.target.value)} />
}

📌 受控以及非受控组件的边界划分取决于当前组件对于子组件值的变更是否拥有控制权。如果拥有控制权利子组件对于当前组件来说是受控的;反之则是非受控。

反模式-以非受控组件的方式去调用受控组件

虽然受控和非受控通常用来指向表单的inputs,也能用来描述数据频繁更新的组件。

通过上一节受控与非受控组件的边界划分,我们可以简单的分类为:

  • 如果使用props传入数据,有对应的数据处理方法,组件对于父级来说认为是可控的
  • 数据只是保存在组件内部的state中,组件对于父级来说是非受控的
    ⁉️什么是派生state

简单来说,如果一个组件的state中的某个数据来自外部,就将该数据称之为派生状态。
大部分使用派生state导致的问题,不外乎两个原因:

  • 直接复制props到state
  • 如果props和state不一致就更新state

直接复制prop到state

getDerivedStateFromPropscomponentWillReceiveProps的执行时期
在父级重新渲染时,不管props是否有变化,这两个生命周期都会执行

所以在两个方法里面直接复制props到state是不安全的,会导致state没有正确渲染

class EmailInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      email: this.props.email   //初始值为props中email
    };
  }
  componentWillReceiveProps(nextProps) {
    this.setState({ email: nextProps.email });   //更新时,重新给state赋值
  }
  handleChange = (e) => {
    this.setState({ email: e.target.value });
  };
  render() {
    const { email } = this.state;
    return <input value={email} onChange={this.handleChange} />;
  }
}

点击查看示例

给input设置props传来的初始值,在input输入时它会修改state。但是如果父组件重新渲染时,输入框input的值就会丢失,变成props的默认值

即使我们在重置前比较nextProps.email!==this.state.email仍然会导致更新

针对于目前这个小demo来说,可以使用shouldComponentUpdate来比较props中的email是否修改再来决定是否需要重新渲染。但是对于实际应用来说,这种处理方式并不可行,一个组件会接收多个prop,任何一个prop的改变都会导致重新渲染和不正确的状态重置。加上行内函数和对象 prop,创建一个完全可靠的shouldComponentUpdate会变得越来越难。shouldComponentUpdate这个生命周期更多是用于性能优化,而不是处理派生state。

截止这里,讲清为什么不能直接复制prop到state。思考另一个问题,如果只使用props中的email属性更新组件讷?

在props变化后修改state

接着上述示例,只使用props.email来更新组件,这样可以防止修改state导致的bug

class EmailInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      email: this.props.email   //初始值为props中email
    };
  }
  componentWillReceiveProps(nextProps) {
    if(nextProps.email !== this.props.email){
      this.setState({ email: nextProps.email });   //email改变时,重新给state赋值
    }
  }
  //...
}

通过这个改造,组件只有在props.email改变时才会重新给state赋值,那这样改造会有问题吗?

在下列场景中,对拥有两个相同email的账号进行切换的时,这个输入框不会重置,因为父组件传来的prop值没有任何变化

点击查看示例

这个场景是构建出来的,可能设计奇怪,但是这样子的错误很常见。对于这种反模式来说,有两种方案可以解决这些问题。关键在于,任何数据,都要保证只有一个数据来源,而且避免直接复制它。

解决方案

完全可控的组件

从EmailInput组件中删除state,直接使用props来获取值,将受控组件的控制权交给父组件。

function EmailInput(props){
  return <input onChange={props.onChange} value={props.email}/>
}

如果想要保存临时的值,需要父组件手动执行保存。

有key的非受控组件

让组件存储临时的email state,email的初始值仍然是通过prop来接受的,但是更改之后的值就和prop没有关系了

function EmailInput(props){
  const [email, setEmail] = useState(props.email)
  return <input value={email} onChange={(e) => setEmail(e.target.value)}/>
}

在之前的切换账号的示例中,为了在不同页面切换不同的值,可以使用key这个React特殊属性。当key变化时,React会创建一个新的组件而不是简单的更新存在的组件(获取更多)。我们经常使用在渲染动态列表时使用key值,这里也可以使用。

<EmailInput
  email={account.email}
  key={account.id}
/>

点击查看示例

每次id改变的时候,都会重新创建EmailInput,并将其状态重置为最近email值。

可选方案

  1. 使用key属性来做,会使组件整个组件的state都重置。可以在getDerivedStateFromPropscomponentWillReceiveProps 来观察id的变化,麻烦但是可行

    class EmailInput extends Component {
      state = {
        email: this.props.email,
        prevId: this.props.id
      };
    
      componentWillReceiveProps(nextProps) {
        const { prevId } = this.state;
        if (nextProps.id !== prevId) {
          this.setState({
            email: nextProps.email,
            prevId: nextProps.id
          });
        }
      }
      // ...
    }

    点击查看示例

  2. 使用实例方法重置非受控组件

    刚刚两种方式,均是再有唯一标识值的情况下。如果在没有合适的key值时,也想要重新创建组件。第一种方案就是生成随机值或者递增的值当作key值,另一种就是使用示例方法强制重置内部状态

    class EmailInput extends Component {
      state = {
        email: this.props.email
      };
    
      resetEmailForNewUser(newEmail) {
        this.setState({ email: newEmail });
      }
    
      // ...
    }

    父组件使用ref调用这个方法,点击查看示例

那我们如何选?

在我们的业务开发中,尽量选择受控组件,减少使用派生state,过量的使用componentWillReceiveProps可能导致props判断不够完善,倒是重复渲染死循环问题。

在组件库开发中,例如antd,将受控与非受控的调用方式都开放给用户,让用户自主选择对应的调用方式。比如Form组件,我们常使用getFieldDecoratorinitialValue来定义表单项,但是我们根本不关心中间的输入过程,在最后提交的时候通过getFieldsValue或者validateFields拿到所有的表单值,这就是非受控的调用方式。或者是,我们在只有一个Input的时候,我们可以直接绑定value和onChange事件,这也就是受控的方式调用。

总结

在本文中,首先介绍了非受控组件和受控组件的概念。对于受控组件来说,组件控制用户输入的过程以及state是受控组件唯一的数据来源。

接着介绍了组件的调用问题,对于组件调用方而言,组件提供方是否为受控组件。对于调用方而言,组件受控以及非受控的边界划分取决于当前组件对于子组件值的变更是否拥有控制权。

接着介绍了以非受控组件的方式调用受控组件这种反模式用法,以及相关示例。不要直接复制props到state,而是使用受控组件。对于不受控的组件,当你想在prop变化时重置state的话,可以选择以下几种方式:

  • 建议: 使用key属性,重置内部所有的初始state
  • 选项一:仅更改某些字段,观察特殊属性的变化(具有唯一性的属性)
  • 选项二:使用ref调用实例方法

最后总结了一下,应当如何选择受控组件还是非受控组件。

参考链接

React-Router原理及丐版实现

前端路由

在 Web 前端单页面应用 SPA(Single Page Application)中,路由是描述 URL 和 UI 之间的映射关系,这种映射是单向的,即 URL 的改变会引起 UI 更新,无需刷新页面

如何实现前端路由

实现前端路由,需要解决两个核心问题

  1. 如何改变 URL 却不引起页面刷新?
  2. 如何监测 URL 变化?

在前端路由的实现模式有两种模式,hash 和 history 模式,分别回答上述两个问题

hash 模式

  1. hash 是 url 中 hash(#) 及后面的部分,常用锚点在页面内做导航,改变 url 中的 hash 部分不会引起页面的刷新

  2. 通过 hashchange 事件监听 URL 的改变。改变 URL 的方式只有以下几种:通过浏览器导航栏的前进后退、通过<a>标签、通过window.location,这几种方式都会触发hashchange事件

    image

history 模式

  1. history 提供了 pushStatereplaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新

  2. 通过 popstate 事件监听 URL 的改变。需要注意只在通过浏览器导航栏的前进后退改变 URL 时会触发popstate事件,通过<a>标签和pushState/replaceState不会触发popstate方法。但我们可以拦截<a>标签的点击事件和pushState/replaceState的调用来检测 URL 变化,也是可以达到监听 URL 的变化,相对hashchange显得略微复杂

    image

JS实现前端路由

基于 hash 实现

由于三种改变 hash 的方式都会触发hashchange方法,所以只需要监听hashchange方法。需要在DOMContentLoaded后,处理一下默认的 hash 值

// 页面加载完不会触发 hashchange,这里主动触发一次 hashchange 事件,处理默认hash
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("hashchange", onHashChange);
// 路由变化时,根据路由渲染对应 UI
function onHashChange() {
  switch (location.hash) {
    case "#/home":
      routerView.innerHTML = "This is Home";
      return;
    case "#/about":
      routerView.innerHTML = "This is About";
      return;
    case "#/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

hash实现demo

基于 history 实现

因为 history 模式下,<a>标签和pushState/replaceState不会触发popstate方法,我们需要对<a>的跳转和pushState/replaceState做特殊处理。

  • <a>作点击事件,禁用默认行为,调用pushState方法并手动触发popstate的监听事件
  • pushState/replaceState可以重写 history 的方法并通过派发事件能够监听对应事件
var _wr = function (type) {
  var orig = history[type];
  return function () {
    var e = new Event(type);
    e.arguments = arguments;
    var rv = orig.apply(this, arguments);
    window.dispatchEvent(e); 
    return rv;
  };
};
// 重写pushstate事件
history.pushState = _wr("pushstate");

function onLoad() {
  routerView = document.querySelector("#routeView");
  onPopState();
  // 拦截 <a> 标签点击事件默认行为
  // 点击时使用 pushState 修改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的效果。
  var linkList = document.querySelectorAll("a[href]");
  linkList.forEach((el) =>
    el.addEventListener("click", function (e) {
      e.preventDefault();
      history.pushState(null, "", el.getAttribute("href"));
      onPopState();
    })
  );
}
// 监听pushstate方法
window.addEventListener("pushstate", onPopState());
// 页面加载完不会触发 hashchange,这里主动触发一次 popstate 事件,处理默认pathname
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("popstate", onPopState);
// 路由变化时,根据路由渲染对应 UI
function onPopState() {
  switch (location.pathname) {
    case "/home":
      routerView.innerHTML = "This is Home";
      return;
    case "/about":
      routerView.innerHTML = "This is About";
      return;
    case "/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

history 实现 demo

react-router的理解

image

在 v4 之后,我们在 View 层直接从react-router-dom中引入BrowserRouter/HashRouterBrowserRouter/HashRouter又分别使用了react-router提供的 Router 组件和 history 提供的createBrowserHistory/createHashHistory方法。

react-router v3/v4/v6的应用

v3v4v6

image

history

在上文中说到,BrowserRouter使用history库提供的createBrowserHistory创建的history对象改变路由状态和监听路由变化。

❓那么 history 对象需要提供哪些功能讷?

  • 监听路由变化的listen方法以及对应的清理监听unlisten方法
  • 改变路由的push方法
// 创建和管理listeners的方法 
export const EventEmitter = () => {
  const events = [];
  return {
    subscribe(fn) {
      events.push(fn);
      return function () {
        events = events.filter((handler) => handler !== fn);
      };
    },
    emit(arg) {
      events.forEach((fn) => fn && fn(arg)); 
    }
  }
}

BrowserHistory

const createBrowserHistory = () => {
  const EventBus = EventEmitter();
  // 初始化location
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.pathname
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 定义history.push方法
  const push = (path) => {
    const history = window.history;
    // 为了保持state栈的一致性
    history.pushState(null, "", path);
    // 由于push并不触发popstate,我们需要手动调用回调函数
    location = { pathname: path };
    EventBus.emit(location);
  };

  const listen = (listener) => EventBus.subscribe(listener);

  // 处理浏览器的前进后退
  window.addEventListener("popstate", handlePop);

  // 返回history
  const history = {
    location,
    listen,
    push
  };
  return history;
};

上述代码实现简单版本的 history,只有监听路由变化的listen/unlisten方法以及改变路由的push方法,详细的BrowserHistory源码

HashHistory

const createHashHistory = () => {
  const EventBus = EventEmitter();
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.hash.slice(1)
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 不用手动执行回调,因为hash改变会触发hashchange事件
  const push = (path) => window.location.hash = path
  const listen = (listener: Function) => EventBus.subscribe(listener);
  // 监听hashchange事件
  window.addEventListener("hashchange", handlePop);
  // 返回的history上有个listen方法
  const history = {
    location,
    listen,
    push
  };
  return history;
};

BrowserHistory一样,hashHistory也是极简版,详细的hashHistory源码

react-router丐版

image

Router

Router 接受一个 history 属性,用history.listen创建监听者,使用 context 传递 history 和location 数据

export default class Router extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location // 将history的location挂载到state上
    };
    this.unlisten = props.history.listen((location) => {
      this.setState({ location });
    });
  }
  componentDidMount() {}
  componentWillUnmount() {
    this.unlisten();
  }
  render() {
    const { history, children } = this.props;
    const { location } = this.state;
    return (
      <RouterContext.Provider
        value={{
          history,
          location
        }}
      >
        {children}
      </RouterContext.Provider>
    );
  }
}

Router源码

BrowserRouter/HashRouter

只是给 Router 组件传递 history 属性

BrowserRouter

class BrowserRouter extends React.Component {
  history = createBrowserHistory();
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

HashRouter

class HashRouter extends React.Component {
  history = createHashHistory();
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

BrowserRouter源码/HashRouter源码

Route

Route可以接收component/render/children,但是它们渲染的优先级是不一样的。

v4/v5三个优先级不同

直接使用Route组件时,每个Route组件都会被渲染,会根据路由规则进行判断是否需要把组件渲染出来,目前代码中使用的正则来做匹配

export default class Route extends React.Component<IProps> {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const pathname = context.location.pathname;
          const {
            path,
            component: Component,
            exact = false,
            render,
            children
          } = this.props;
          const props = { ...context };
          const reg = pathToRegExp(path, [], { end: exact });
          // 判断url是否匹配
          if (!reg.test(pathname)) return null;
          if (Component) return <Component {...props} />;
          if (render) return render();
          if (children) return children;
        }}
      </RouterContext.Consumer>
    );
  }
}

Route源码

Link

在 Link 中,我们使用<a>标签来做跳转,但是 a 标签会使页面重新刷新,所以需要阻止 a 标签的默认行为,使用 context 中 history 的 push 方法

export default class Link extends React.Component<IProps> {
  render() {
    const { to, children } = this.props;
    return (
      <RouterContext.Consumer>
        {(context) => {
          return (
            <a
              href={to}
              onClick={(event) => {
                // 阻止a标签的默认行为
                event.preventDefault();
                context.history.push(to);
              }}
            >
              {children}
            </a>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

Link源码

Switch

Route 组件的功能是只要 path 匹配上当前路由就渲染组件,也就意味着如果多个 Route 的 path 都匹配上了当前路由,这几个组件都会渲染,例如/home/1能够匹配上/home/1/home,所以需要一个组件来控制匹配上一个 Route 就返回,所以 Switch 组件诞生了。

它的功能就是即使多个 Route 的 path 都匹配上了当前路由,也只渲染第一个匹配上的组件。

要实现该功能,把 Switch 的 children 拿出来循环,找出第一个匹配的 child,记录下当前的 child ,把其他的 child 全部干掉

export default class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const location = context.location;
          let element, match; // 两个变量记录第一次匹配上的子元素和match属性
          React.Children.forEach(this.props.children, (child) => {
            // 先检测下match是否已经匹配到了, 如果已经匹配过了,直接跳过
            if (!match && React.isValidElement(child)) {
              element = child;
              const { path, exact } = child.props;
              const reg = pathToRegExp(path, [], { end: exact });
              if (reg.test(location.pathname)) match = true;
            }
          });
          // <Switch>组件的返回值只是匹配上元素的拷贝,其他元素被忽略了
          // 如果一个都没匹配上,返回null
          return match ? React.cloneElement(element, { location }) : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

Switch源码

到现在 react-router 的核心组件以及 API 都实现完成,线上demo

总结

在本文中,从前端路由入手,分析了原生的 hash/history 的路由实现,react-router 底层依赖和上层使用,实现了简版的 react-router

需要注意的是,hash 模式下三种改变 url 的方法都会触发 hashchange 事件,而 history 模式下只有浏览器前进后退会触发popstatepushState/replaceState以及<a>标签都不会。<a>标签的默认行为会触发页面刷新,所以在实现路由时需要用e.preventDefault阻止默认行为。

由 Base64 引发的知识探讨

文章首发于由 Base64 展开的知识探讨

什么是编码

编码,是信息从一种形式转变为另一种形式的过程,简要来说就是语言的翻译。

将机器语言(二进制)转变为自然语言。

五花八门的编码

ASCII 码

ASCII 码是一种字符编码标准,用于将数字、字母和其他字符转换为计算机可以理解的二进制数。

它最初是由美国信息交换标准所制定的,它包含了 128 个字符,其中包括了数字、大小写字母、标点符号、控制字符等等。

在计算机中一个字节可以表示256众不同的状态,就对应256字符,从0000000到11111111。ASCII 码一共规定了128字符,所以只需要占用一个字节的后面7位,最前面一位均为0,所以 ASCII 码对应的二进制位 00000000 到 01111111。

Untitled

非 ASCII 码

当其他国家需要使用计算机显示的时候就无法使用 ASCII 码如此少量的映射方法。因此技术革新开始啦。

  • GB2312

    收录了6700+的汉字,使用两个字节作为编码字符集的空间

  • GBK

    GBK 在保证不和 GB2312/ASCII 冲突的情况下,使用两个字节的方式编码了更多的汉字,达到了2w

  • GB18030

全面统一的 Unicode

面对五花八门的编码方式,同一个二进制数会被解释为不同的符号,如果使用错误的编码的方式去读区文件,就会出现乱码的问题。

那能否创建一种编码能够将所有的符号纳入其中,每一个符号都有唯一对应的编码,那么乱码问题就会消失。因此 Unicode 借此机会统一江湖。

Unicode 最常用的就是使用两个字节来表示一个字符(如果是更为偏僻的字符,可能所需字节更多)。现代操作系统都直接支持 Unicode。

Unicode 和 ASCII 的区别

  • ASCII 编码通常是一个字节,Unicode 编码通常是两个字节

    字母 A 用 ASCII 编码十进制为 65,二进制位 01000001;而在 Unicode 编码中,需要在前面全部补0,即为 0000000 01000001

  • 问题产生了,虽然使用 Unicode 解决乱码的问题,但是为纯英文的情况,存储空间会大一倍,传输和存储都不划算。

问题对应的解决方案之UTF-8

本着节约的精神,又出现了把 Unicode 编码转为可变长编码的 UTF-8。可以根据不同字符而变化字节长度,使用1~4字节表示一个符号。UTF-8 是 Unicode 的实现方式之一。

UTF-8 的编码规则

  1. 对于单字节的符号,字节的第一位设置为0,后面七位为该字符的 Unicode 码。因此对于英文字母,UTF-8 编码和 ASCII 编码是相同的。
  2. 对于 n 字节的符号,第一个字节的前 n 位都是1,第 n+1 位为0,后面的字节的前两位均为10。剩下的位所填充的二进制就是这个字符的 Unicode 码

对应的编码表格

Unicode 符号范围                      |     UTF-8 编码方式
0000 0000-0000 007F  (0-127)               0xxxxxxx
0000 0080-0000 07FF  (128-2047)            110xxxxx 10xxxxxx
0000 0800-0000 FFFF  (2048-65535)          1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF  (65536往上)            1111xxxx 10xxxxxx 10xxxxxx 10xxxxxxx

在 Unicode 对应表中查找到“杪”所在的位置,以及其对应的十六进制 676A,对应的十进制为 26474(110011101101010),对应三个字节 1110xxxx 10xxxxxx 10xxxxxx

110011101101010的最后一个二进制依次填充到1110xxxx 10xxxxxx 10xxxxxx从后往前的 x ,多出的位补0即可,中,得到11100110 10011101 10101010 ,转换得到39a76a,即是杪字对应的 UTF-8 的编码

Untitled 1

💡 位运算 Tips
  • >> 向右移动,前面补 0, 如 104 >> 2 即 01101000=> 00011010
  • & 与运算,只有两个操作数相应的比特位都是 1 时,结果才为 1,否则为 0。如 104 & 3即 01101000 & 00000011 => 00000000
  • | 或运算,对于每一个比特位,当两个操作数相应的比特位至少有一个 1 时,结果为 1,否则为 0。如 01101000 | 00000011 => 01101011
function unicodeToByte(input) {
    if (!input) return;
    const byteArray = [];
    for (let i = 0; i < input.length; i++) {
        const code = input.charCodeAt(i); // 获取到当前字符的 Unicode 码
        if (code < 127) {
            byteArray.push(code);
        } else if (code >= 128 && code < 2047) {
            byteArray.push((code >> 6) | 192);
            byteArray.push((code & 63) | 128);
        } else if (code >= 2048 && code < 65535) {
            byteArray.push((code >> 12) | 224);
            byteArray.push(((code >> 6) & 63) | 128);
            byteArray.push((code & 63) | 128);
        }
    }
    return byteArray.map((item) => parseInt(item.toString(2)));
}

问题对应的解决方案之UTF-16

在 Unicode 编码中,最常用的字符是0-65535,UTF-16 将0–65535范围内的字符编码成2个字节,超过这个的用4个字节编码

UTF-16 编码规则

  1. 对于 Unicode 码小于 0x10000 的字符, 使用 2 个字节存储,并且是直接存储 Unicode 码,不用进行编码转换
  2. 对于 Unicode 码在 0x10000 和 0x10FFFF 之间的字符,使用 4 个字节存储,这 4 个字节分成前后两部分,每个部分各两个字节,其中,前面两个字节的前 6 位二进制固定为 110110,后面两个字节的前 6 位二进制固定为 110111,前后部分各剩余 10 位二进制表示符号的 Unicode 码 减去 0x10000 的结果
  3. 大于 0x10FFFF 的 Unicode 码无法用 UTF-16 编码

对应的编码表格

Unicode 符号范围                   |     具体Unicode码                |     UTF-16 编码方式                         |   字节
0000 0000-0000 FFFF  (0-65535)    |     xxxxxxxx xxxxxxxx           |     xxxxxxxx xxxxxxxx                      |   2字节
0001 0000-0010 FFFF  (65536往上)   |     yy yyyyyyyy xx xxxxxxxx     |     110110yy yyyyyyyy 110111xx xxxxxxxx     |   4字节    

“杪”字的 Unicode 码为 676A(26474),小于 65535,所以对应的 UTF-16 编码也为 676A

找一个大于 0x10000 的字符,0x1101F,进行 UTF-16 编码

Untitled 2

字节序

字节序就是字节之间的顺序,当传输或者存储时,如果超过一个字节,需要指定字节间的顺序。

最小编码单元是多字节才会有字节序的问题存在,UTF-8 最小编码单元是一个字节,所以它是没有字节序的问题,UTF-16 最小编码单元是两个个字节,在解析一个 UTF-16 字符之前,需要知道每个编码单元的字节序。

💡 为什么会出现字节序?

计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序。但是,人类还是习惯读写大端字节序。所以,除了计算机的内部处理,其他的场合比如网络传输和文件储存,几乎都是用的大端字节序。正是因为这些原因才有了字节序。

比如:前面提到过,"杪"字的 Unicode 码是 676A,"橧"字的 Unicode 码是 6A67,当我们收到一个 UTF-16 字节流 676A 时,计算机如何识别它表示的是字符 "杪"还是 字符 "橧"呢 ?

对于多字节的编码单元需要有一个标识显式的告诉计算机,按着什么样的顺序解析字符,也就是字节序。

  • 大端字节序(Big-Endian),表示高位字节在前面,低位字节在后面。高位字节保存在内存的低地址端,低位字节保存在在内存的高地址端。
  • 小端字节序(Little-Endian),表示低位字节在前,高位字节在后面。高位字节保存在内存的高地址端,而低位字节保存在内存的低地址端。

Untitled 3

简单聊聊 ArrayBuffer 和 TypedArray、DataView

Untitled 4

  • ArrayBuffer

    ArrayBuffer 是一段存储二进制的内存,是字节数组。

    它不能够被直接读写,需要创建视图来对它进行操作,指定具体格式操作二进制数据。

    可以通过它创建连续的内存区域,参数是内存大小(byte),默认初始值都是 0

  • TypedArray

    ArrayBuffer 的一种操作视图,数据都存储到底层的 ArrayBuffer 中

    const buf = new ArrayBuffer(8);
    const int8Array = new Int8Array(buf);
    int8Array[3] = 44;
    const int16Array = new Int16Array(buf);
    int16Array[0] = 42;
    console.log(int16Array); // [42, 11264, 0, 0]
    console.log(int8Array);  // [42, 0, 0, 44, 0, 0, 0, 0]

    使用 int8 和 int16 两种方式新建的视图是相互影响的,都是直接修改的底层 buffer 的数据

  • DataView

    DataView 是另一种操作视图,并且支持设置字节序

    const buf = new ArrayBuffer(24);
    const dataview = new DataView(buf);
    dataView.setInt16(1, 3000, true);  // 小端序

明确电脑的字节序

上述讲到,在存储多字节的时候,我们会采用不同的字节序来做存储。那对我们的操作系统来说是有一种默认的字节序的。下面就用上述知识来明确 MacOS 的默认字节序。

function isLittleEndian() {
    const buf = new ArrayBuffer(2);
    const view = new Int8Array(buf);
    view[0]=1;
    view[1]=0;
    console.log(view);
    const int16Array = new Int16Array(buf);
    return int16Array[0] === 1;
}
console.log(isLittleEndian());

通过上诉代码我们可以得出此款 MacOS 是小端序列存储

一个🌰

const buffer = new ArrayBuffer(8);
const int8Array = new Int8Array(buffer);
int8Array[0] = 30;
int8Array[1] = 41;

const dataView = new DataView(buffer);
dataView.setInt16(2, 256, true);
const int16Array = new Int16Array(buffer);
console.log(int16Array);  // [10526, 256, 0, 0]
int16Array[0] = 256;
const int8Array1 = new Int8Array(buffer);
console.log(int8Array1);

虽然 TypedArray 无法指定字节序,但是在存储的时候采用操作系统默认的字节序。所以当我们设置 int16Array[0] = 256 时,内存中存储的为 00 01

Untitled 5

Base64 编码解码

什么是 Base64

Base64 是一种基于64个字符来表示二进制数据的方式。

A-Z、a-z、0-9、+、/、= 65个字符组成,值得注意的是 = 用于补位操作

const _base64Str =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';

Base64 原理

除去 = 这个补位符号,64个字符(即2^6),可表示二进制 000000 至111111共6个比特位,一个字节有8个比特位,因此可以推算出3个字节的数据需要用4个 Base64 字符表示

举个🌰,this 的 Base64 编码为 dGhpcw== ,具体编码如下

Untitled 6

Base64 编码解码实现

在我们的项目中,实现 Base64 编码通常使用 btoa 和 atob 实现编码和解码,下面来尝试实现这个函数

💡 前置知识
  • 获取相应字符 ASCII 码方法 String.charCodeAt(index)
  • 取得 Base64 对应的字符方法 String.charAt(index)

编码实现思路

  • 三个字符分别为 char1/char2/char3,对应的 base64 字符为 encode1/encode2/encode3/encode4
  • encode1 是 char1 取前六位,即 char1 右移2位,encode1 = char1 >> 2
  • encode2 是 char1 后两位 + char2 前四位组成,encode2 = ((char1 & 3) << 4) | (char2 >> 4)
  • encode3 是 char2 后四位 + char3 前两位组成,encode3 = ((char2 & 15) << 2) | (char3 >> 6)
  • encode4 是 char3 的后六位,encode4 = char3 & 63
function encodeBase64(input) {
    if (!input) return;
    let base64String = "";
    for (let i = 0; i < input.length; ) {
        const char1 = input.charCodeAt(i++);
        const encode1 = char1 >> 2;
        const char2 = input.charCodeAt(i++);
        const encode2 = ((char1 & 3) << 4) | (char2 >> 4);
        const char3 = input.charCodeAt(i++);
        let encode3 = ((char2 & 15) << 2) | (char3 >> 6);
        let encode4 = char3 & 63;
        if (Number.isNaN(char2)) encode3 = encode4 = 64;
        if (Number.isNaN(char3)) encode4 = 64;
        base64String +=
            _base64Str.charAt(encode1) +
            _base64Str.charAt(encode2) +
            _base64Str.charAt(encode3) +
            _base64Str.charAt(encode4);
    }
    return base64String;
}

解码实现思路

  • base64 字符为 encode1/encode2/encode3/encode4,三个字符分别为 char1/char2/char3
  • char1 是 encode1 + encode2 前两位,char1 = (encode1 << 2) | (encode2 >> 4)
  • char2 是 encode2 后四位 + encode3 前四位,char2 = ((encode2 & 15) << 4) | (encode3 >> 2)
  • char3 是 encode3 后两位 + encode4,char3 = ((encode3 & 3) << 6) | encode4
function decodeBase64(input) {
    if (!input) return;
    let output = "";
    for (let i = 0; i < input.length; ) {
        const encode1 = _base64Str.indexOf(input.charAt(i++));
        const encode2 = _base64Str.indexOf(input.charAt(i++));
        const encode3 = _base64Str.indexOf(input.charAt(i++));
        const encode4 = _base64Str.indexOf(input.charAt(i++));
        const char1 = (encode1 << 2) | (encode2 >> 4);
        const char2 = ((encode2 & 15) << 4) | (encode3 >> 2);
        const char3 = ((encode3 & 3) << 6) | encode4;
        output += String.fromCharCode(char1);
        if (encode3 != 64) {
            output += String.fromCharCode(char2);
        }
        if (encode4 != 64) {
            output += String.fromCharCode(char3);
        }
    }
    return output;
}

一些问题

当我们使用上述代码去编码中文的时候,就能够发现一些问题了。

console.log(encodeBase64("霜序"));                // 8=
console.log(decodeBase64(encodeBase64("霜序")));  // ô

其实是当字符的 Unicode 码大于255时,上述魔法就会失灵。同样的 window 上的 btoa 和 atob 方法也会失效。

霜序 两个字的 Unicode 分别为 38684/24207,那我们可以把这些数字转化为多个255内的数字,也就是用多个字节表示,就可以使用我们上述 Unicode 转 UTF-8 的方法,得到对应的字符,在对齐进行编码

function encodeTransform(input) {
    if (!input) return;
    const byteArray = [];
    for (let i = 0; i < input.length; i++) {
        const code = input.charCodeAt(i); // 获取到当前字符的 Unicode 码
        if (code < 128) {
            byteArray.push(code);
        } else if (code >= 128 && code < 2048) {
            byteArray.push((code >> 6) | 192);
            byteArray.push((code & 63) | 128);
        } else if (code >= 2048 && code < 65535) {
            byteArray.push((code >> 12) | 224);
            byteArray.push(((code >> 6) & 63) | 128);
            byteArray.push((code & 63) | 128);
        }
    }
    return byteArray;  // 返回 UTF-8 编码的数据
}

function encodeBase64(input) {
    if (!input) return;
    let base64String = "";
    const byteArray = encodeTransform(input);
    for (let i = 0; i < byteArray.length; ) {
        const char1 = byteArray[i++];
        const encode1 = char1 >> 2;
        const char2 = byteArray[i++];
        const encode2 = ((char1 & 3) << 4) | (char2 >> 4);
        const char3 = byteArray[i++];
        let encode3 = ((char2 & 15) << 2) | (char3 >> 6);
        let encode4 = char3 & 63;
        if (Number.isNaN(char2)) encode3 = encode4 = 64;
        if (Number.isNaN(char3)) encode4 = 64;
        base64String +=
            _base64Str.charAt(encode1) +
            _base64Str.charAt(encode2) +
            _base64Str.charAt(encode3) +
            _base64Str.charAt(encode4);
    }
    return base64String;
}

console.log(encodeBase64("霜序"));     // 6Zyc5bqP

同样的我们也需要对解码的内容做相应的转换

function decodeTransform(byteArray) {
    let i = 0;
    const output = [];
    while (i < byteArray.length) {
        const code = byteArray[i];
        if (code < 128) {
            output.push(code);
            i++;
        } else if (code > 191 && code < 224) {
            const code1 = byteArray[i + 1];
            output.push(((code & 31) << 6) | (code1 & 63));
            i += 2;
        } else {
            const code1 = byteArray[i + 1];
            const code2 = byteArray[i + 2];
            output.push(
                ((code & 15) << 12) | ((code1 & 63) << 6) | (code2 & 63)
            );
            i += 3;
        }
    }
    return output.map((item) => String.fromCharCode(item)).join("");
}

function decodeBase64(input) {
    if (!input) return;
    const byteArray = [];
    for (let i = 0; i < input.length; ) {
        const encode1 = _base64Str.indexOf(input.charAt(i++));
        const encode2 = _base64Str.indexOf(input.charAt(i++));
        const encode3 = _base64Str.indexOf(input.charAt(i++));
        const encode4 = _base64Str.indexOf(input.charAt(i++));
        const char1 = (encode1 << 2) | (encode2 >> 4);
        const char2 = ((encode2 & 15) << 4) | (encode3 >> 2);
        const char3 = ((encode3 & 3) << 6) | encode4;
        byteArray.push(char1);
        if (encode3 != 64) {
            byteArray.push(char2);
        }
        if (encode4 != 64) {
            byteArray.push(char3);
        }
    }
    return decodeTransform(byteArray);
}

总结

在本文中,重点是要实现 Base64 编码的内容,然后先给大家讲述了相关字符集(ASCII/Unicode)出现的原因。
Unicode 编码相关的缺点,由此引出了 UTF-8/UTF-16 编码。
对于 UTF-16 来说,最小的编码单元为两个字节,由此引出了字节序的内容。
当我们有了上述知识之后,最后开始 Base64 编码的实现。

参考链接

我所知道的npm/Yarn/pnpm包管理

npm

嵌套结构(npm2)

在 npm2 中,npm 的处理依赖的方式很粗暴,直接采用递归的方式,严格的按照 package.json 结构以及子依赖包中的 package.json 结构将依赖安装到各自的 node_modules 中

举个🌰来说,npm-test项目依赖如下三个模块:

{
  "name": "npm-test",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4"
  }
}

buffer模块依赖了base64-jsieee754模块

{
  "name": "buffer",
  "dependencies": {
    "base64-js": "^1.3.1",
    "ieee754": "^1.1.13"
  }
}

执行 npm install 之后,我们会得到如下图的目录结构:

1

将其内部的依赖全部画出来会成为如下图:

2

这种方式的优劣式都非常的明显。优点就是 node_modules 的结构和 package.json 的结构是一一对应的,结构层级很明显,并且能够保证每次 install 的目录都是相同的。

试想一下,依赖的模块非常多,项目的 node_modules 会非常的庞大,嵌套会很深

3

从上图中,我们可以看出来嵌套结构的劣势

  • 在不同层级的依赖中,可能引用了同一个模块,导致大量的模块冗余
  • 在 windows 系统中,文件路径最大长度为260字符,嵌套层级过深可能导致不可预知的问题

💡 在 npm2 中,按照递归的方式,严格将 package.json 中的依赖安装到对应模块下。并不会处理某几个模块中的相同版本依赖,直接无脑生成对应树结构

扁平结构(npm3)

在此之后的 npm3 做了较大的更新,将 npm2 的嵌套结构改成了扁平结构

子依赖模块无关联

安装模块时,不管其是直接依赖模块还是子依赖的依赖模块,都优先安装在 node_modules 根目录下

执行 npm install 之后,会得到如下的目录结构

4

将其内部的依赖全部画出来会成为如下图:

5

💡 如果 package.json 中的依赖的子依赖无相同依赖,那么所有的依赖都会被扁平化到根目录的 node_modules 下

子依赖项依赖相同/兼容版本

修改 npm-test 的 package.json 文件,添加[email protected],websocket-util 也依赖了base64-js^1.3.0

{
  "name": "npm-test",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
    "websocket-util": "1.0.0"
  }
}

执行 npm install,会得到如下的目录结构:

6

将其内部的依赖全部画出来会成为如下图:

7

💡 如果 package.json 中的依赖的子依赖有相同或者兼容版本依赖,那么所有的依赖都会被扁平化到根目录的 node_modules 下

子依赖项的依赖不兼容

  1. 我们在项目 npm-test 中,又依赖了[email protected]版本,修改 package.json:

    {
      "name": "npm-test",
      "dependencies": {
        "buffer": "^5.4.3",
        "ignore": "^5.1.4",
        "base64-js": "1.0.1",
      }
    }

    执行 npm install 之后的目录结构如下:

    8

    将其内部的依赖全部画出来会成为如下图:

    9

    npm-test 直接依赖的[email protected]放在了根目录的 node_modules下,buffer 所依赖的与其不兼容,就放在自身的 node_modules下

  2. 修改 package.json,在其中添加[email protected],它依赖[email protected][email protected]依赖base64-js^1.3.0,buffer依赖base64-js^1.3.1

    {
      "name": "npm-test",
      "dependencies": {
        "bops": "1.0.1",
        "buffer": "^5.4.3",
        "ignore": "^5.1.4",
        "websocket-util": "1.0.0"
      }
    }

    执行 npm install,会得到如下的目录结构:

    17

    将其内部的依赖全部画出来会成为如下图:

    18

    会发现[email protected]被提取到了第一级的 node_modules 上,而 buffer/websocket-util 依赖的依旧挂在它自己的 node_modules 下
    package.json 的安装顺序是按着字母顺序来的,首先 bops 的底层依赖都会被优先提出来,所以 node_modules 下会先有[email protected],到了处理 buffer/websocket-util 的底层依赖时,发现已经存在 base64-js 且不兼容就会放到它自身的 node_modules 下

  3. 我们在往 package.json 中加入[email protected]它依赖base64-js^1.5.1的版本

    {
      "name": "npm-test",
      "dependencies": {
        "ag-psd": "14.3.6",
        "bops": "1.0.1",
        "buffer": "^5.4.3",
        "ignore": "^5.1.4"
      }
    }

    执行 npm install 之后,得到目录结构如下:

    10

    将其内部的依赖全部画出来会成为如下图:

    11

    能够发现,这次是[email protected]被提取到了根目录下的node_modules下,buffer 的base64-js 能够和它兼容,所以 buffer 的 node_modules 下不再存在依赖,然而 bops 依赖的 base64-js 不兼容,所以会挂在自身的 node_modules 下

💡 子依赖项的依赖不兼容的情况下,底层会通过localeCompare的方法对依赖进行一个排序,字典序靠前的 npm 包的底层依赖会优先被提取出来,放到根目录下的 node_modules 中,之后如果发现不兼容的依赖,则继续采用 npm 2 的处理方式,都会放在自身的 node_modules 下

❓通过上面这几个例子,能够发现 npm3 在解决了一些问题的同时,也带来新的问题。

npm3 仿佛解决了 npm2 的冗余问题,但是也没有完全解决。例如上面的例子,[email protected]先被提出来,如果后面的包依赖的 base64-js 和1.0.2版本不兼容,就会导致每个子依赖的 node_modules 都会存在 base64-js 包,又出现了 npm2 的冗余问题

package-lock.json

在之前我们讲了 package.json 文件,在 npm5 中,推出了 package-lock 文件,它就是每个依赖项的列表,当前安装的版本,模块位置,验证模块完整性的哈希,以及对应的包列表/依赖项列表。其实也是和 node_modules 一一对应的,项目目录下存在 package-lock 可以让每次安装生成的依赖目录结构保持相同

{
  "name": "npm-test",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4"
  }
}

上述 package.json 执行 npm install 之后得到的 lock 文件如下:

{
  "name": "npm-test",
  "dependencies": {
    "base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
    },
    "buffer": {
      "version": "5.7.1",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
      "requires": {
        "base64-js": "^1.3.1",
        "ieee754": "^1.1.13"
      }
    },
    "ieee754": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
    },
    "ignore": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
      "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ=="
    }
  }
}

resolved: 依赖包的位置URI

integrity: 验证模块完整性的哈希

当我们的项目中已经存在 package-lock.json 之后,将以该文件为主进行解析安装指定版本依赖包,而不是使用 package.json 来解析和安装模块。因为 package-lock 为每个模块都指定了版本/位置/完整性哈希,所以每次创建的安装都是一样的。和使用的设备无关,每次都能给到相同的结果。

该图为第一次 install 和有了 package-lock 文件之后 install 时间的对比。在依赖少的情况下并不明显,依赖越多时间差会更加明显

13

在 npm5.0.x的版本中,不管 package.json 中依赖是否有更新,都会以 package-lock 文件为第一安装依赖

在 npm5.1.0之后的版本中,如果 package.json 中的依赖项有更新,install 时会无视 package-lock 直接去下载新版本依赖,然后在更新 package-lock 文件

其实上面的两种方案都存在对应的问题,因此在5.4.2的版本之后,更改了规则

  • 根据 package.json 文件,运行 install 会生成对应的 package-lock 文件,package-lock 文件指明了直接依赖版本和间接依赖版本
  • 如果 package 和 lock 文件中依赖版本兼容,即使 package 中有新版本,执行 install 的时候也会根据 lock 文件下载
  • 如果两个文件中的版本不兼容,那么执行 install 的时候会把 lock 文件更新到兼容package.json 的版本

缓存

在我们执行 install/update 时,除了将依赖包安装在 node_modules 目录之外,还会在本地缓存目录缓存一份

执行npm config get cache命令可以查询到缓存目录,Mac默认是在用户主目下.npm/_cacache

12

content-v2 目录用于存储 tar 包的缓存,而 index-v5 目录用于存储 tar 的 hash 值

查找缓存的步骤

  1. 根据 lock 文件中的integrityresolved字段,通过pacote:range-manifest:{url}:{integrity}构建出key

    例如: pacote:version-manifest:https://registry.npmjs.org/base64-js/-/base64-js-1.0.2.tgz:sha1-R0IRyV5s8qVH20YeT2d4tR0I+mU=

  2. 通过 SHA256 算法加密得到一个 Hash 值

    49c780b41697e5f2aa0641d954ec662e78d57096456d0108d1a84ba538306284

  3. 加密之后的 Hash 值的前四位就是 index-v5 目录的下两级

    _cacache/index-v5/49/c7/…

  4. 通过路径找到对应的索引文件,在文件中找到对应的 _shasum 字段

    474211c95e6cf2a547db461e4f6778b51d08fa65

  5. 上述字符串表明的就是缓存包的位置,对应的前四位为目录的下两级,执行tar -xvf file就能解压文件

    _cacache/content-v2/47/42/…

文件完整性

上文提到过几次文件完整性,那具体什么是文件完整性?

在下载依赖包之前,能够拿到 npm 对该依赖包计算的 hash 值。执行 npm info 命令,shasum就是 hash

15

用户下载依赖包到本地之后,要确定在下载的过程中没有出现错误,所以在下载完成之后在本地计算一次文件的 hash 值。如果两个 hash 值相同才能够确保下载的依赖是完整的;如果不同则需要重新下载

npmrc文件

.npmrc 文件可以理解成为 npm running configuration,即 npm 运行时配置文件。

npm 的作用就是帮助开发者安装需要的依赖包,但是要从哪里下载?这是可以在.npmrc中进行配的。

在我们安装包的时候,npm按照如下顺序读取这些配置文件:

  • 项目配置文件:你可以在项目的根目录下创建一个.npmrc文件,只用于管理这个项目的npm安装。
  • 用户配置文件:在你使用一个账号登陆的电脑的时候,可以为当前用户创建一个.npmrc文件,之后用该用户登录电脑,就可以使用该配置文件。可以通过 npm config get userconfig 来获取该文件的位置。
  • 全局配置文件: 一台电脑可能有多个用户,在这些用户之上,你可以设置一个公共的.npmrc文件,供所有用户使用。该文件的路径为: $PREFIX/etc/npmrc,使用 npm config get prefix 获取$PREFIX。如果你不曾配置过全局文件,该文件不存在。
  • npm内嵌配置文件:最后还有npm内置配置文件,基本上用不到,不用过度关注。

整体流程

16

  • 检查 .npmrc 文件,优先级为:项目级 > 用户级 > 全局级 > npm内置
  • 检查有无 lock 文件
  • 无 lock 文件
    • 从 npm 远程仓库获取包的信息
    • 根据 package.json 构建依赖树,构建过程如下
      • 首先确定首层依赖模块dependencies/devDependencies/optionalDependencies,工程本身是整棵依赖树的根节点,每个首层模块都是根节点下的一个子树,此时会开启多进程从每个首层依赖模块开始逐步寻找更深层级的节点
      • 这一步只是确定逻辑上的依赖树,并非真正的安装,之后根据这个依赖结构去下载或拿到缓存中的依赖包
    • 模块扁平化
      在上一个步骤的到的是一个完整的依赖树,包含了大量的重复模块,在npm3后就开启了扁平化操作,会遍历所有的节点(有广度遍历的感觉)将模块逐个放到根节点下,有重复模块时就丢弃;不兼容时则放到当前节点,不做改变。
    • 在缓存中依次查找依赖树中的依赖
      • 不存在缓存
        • 从 npm 远程仓库下载包
        • 校验包的完整性
        • 校验不通过重新下载
        • 校验通过,将下载的包复制到 npm 缓存目录;将下载的包按照依赖结构解压到 node_modules
      • 存在缓存
        • 将缓存按照依赖结构解压到 node_modules
    • 生成 lock 文件
  • 有 lock 文件
    • 检查 package.json 中的依赖版本和 lock 文件是否兼容
    • 如果兼容,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同
    • 如果不兼容,从远程获取包信息,后续过程相同

Yarn

yarn 的发布时间是在2016年,那时候的 npm 还处于v3阶段,还没有 package-lock.json 文件,存在的问题上文也提到过。yarn 就在此时诞生了
yarn 也是采用的 npm-v3 扁平化结构来管理包依赖,安装完成之后会生成一个 yarn.lock 文件
执行与上述一样的 package.json,得到如下的 yarn.lock 文件

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

base64-js@^1.3.1:
  version "1.5.1"
  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==

buffer@^5.4.3:
  version "5.7.1"
  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
  dependencies:
    base64-js "^1.3.1"
    ieee754 "^1.1.13"

ieee754@^1.1.13:
  version "1.2.1"
  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==

ignore@^5.1.4:
  version "5.2.0"
  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==

和 npm 产生的 lock 文件还是比较相似的,也有一些区别

  • package-lock.json 是 json 格式,yarn.lock 使用一种自定义格式
  • 所有依赖,不管是项目声明的依赖,还是依赖的依赖,都是扁平化管理
  • 依赖的版本是由所有依赖的版本声明范围确定的,具备相同版本声明范围的依赖归结为一类,确定一个该范围下的依赖版本。如果同一个依赖多个版本共存,那么会并列归类
  • 相比 npm,Yarn 一个显著区别是 yarn.lock 中子依赖的版本号不是固定版本。 也就是说单独一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合
    yarn.lock 是扁平化的,即使有相同的包也会带有版本号并列在一级;而 package-lock 是和 node_modules 一一对应的嵌套结构
    所以 yarn.lock 即使有确定的版本号也没法得到对应 node_modules 的目录结构所以需要结合 package.json

19

yarn install 的过程,可以在终端看到由 emoji 图标装饰的四个步骤

  1. Resolving packages(解析包): 整合依赖信息
  2. Fetching packages(获取包): 获取依赖包到缓存中
  3. Linking dependencies(连接依赖): 复制依赖到 node_modules
  4. Building fresh packages(构建安装): 执行 install 阶段的 scripts

pnpm

什么是pnpm

其官网这样说:

Fast, disk space efficient package manager 快速的,节省磁盘空间的包管理工具

pnpm 本质上就是一个包管理器,在这一点和 npm/yarn 没有区别,但是其两个优势在于:

  1. 包安装数度极快
    官方 benchmarks 对 npm、pnpm、yarn、yarnPnP 对多个情景下的性能基准测试,涵盖了很多使用场景

  2. 磁盘空间利用非常高效
    使用 yarn 安装依赖时的node_modules

    20

    使用 pnpm 安装依赖时的 node_modules

    21

    pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,该文件系统出色的地方在于

    • 不会重复安装同一个包。在 npm/yarn 中,我们所有的项目(10个)有依赖了 dt-common,dt-common 会被安装10次,dt-common 这个包在磁盘中就会有10个地方写入该代码。但是在 pnpm 中只会被安装一次,磁盘中只有一个地方会写入,后面再次使用都直接使用硬连接hardlink
  3. 支持monorepo

  4. 相对安全

前置知识-软链接和硬链接

在 Linux 中有两种链接方式:

  • 硬连接 hard link
  • 软链接 soft link,又称为符号链接 symbolic link
    不论是硬链接或软链接都不会将原本的源文件复制一份,只会占用非常少量的磁盘空间

inode

每个文件都有唯一一个 inode,它包含了文件元信息,在访问文件时,对应的元信息会被 copy 到内存去实现文件的访问
使用stat xxx能够查到对应的 inode

22

hard link

硬链接可以理解成为一个相互的指针,创建的 hardlink 指向源文件的 inode,系统并不为会其分配新的 inode
硬链接不管有多少个,都会指向用一个 inode 节点,这等同于修改源文件或者链接文件时都会被同步修改(感觉和JS对象引用地址略微相似
每新建一个 hardlink 会把节点连接数增加,只要节点的链接数非零,文件就一直存在,不管你删除的是源文件还是 hradlink。只要有一个存在,文件就存在

soft link

软链接可以理解为单向指针,一个独立的文件且拥有独立的 inode,永远指向源文件
修改源文件内容,软链接内容也会改变。当删除源文件时,访问软链接会报错No such file or directory

23

软硬链接对node寻包的影响

可以使用ln命令对文件和目录在另一个位置建立一个同步的链接
语法: ln [参数][源文件或目录][目标文件或目录]

在某个项目中新建两个文件夹 one/two,目录结构如下:

24

one/index.js 中内容如下:

const base64 = require("base64-js")
console.log(base64)

现在运行当前 one/index.js 一定会报错,因为我们的 one 文件下不存在 base64-js 依赖
分别在 two 文件下创建 one/index.js 的硬链接/软链接文件

27

hard.js 是一个硬链接,soft.js 是一个软链接
我们分别执行 node hard.js/node soft.js

28

发现硬链接是可以正常运行的,但是软链接不行。这是因为硬链接会从链接到的位置开始查找依赖,而软链接会从文件原始位置开始查找依赖。

软链依赖目录

上述的例子讲清楚了 node 处理软硬链接的不同之处,那我们将 two/node_modules 软链接到 one 的目录下,one/index.js 和 two/soft.js 能否正常运行呢🤔

ln -s  ../two/node_modules ./one

25

对应执行one/index.js 和 two/soft.js。哇哦,work well

26

通过上面的示例,我们发现即使当前 node_modules 是一个软链接也能够作为依赖被查找到,说明软链可以将其他地方的目录增加到依赖查找路径中。

依赖管理

{
  "name": "npm-test",
  "dependencies": {
    "buffer": "^5.4.3"
  }
}

在我们的 npm-test 项目中执行pnpm i控制台会有如下输出:

30

能够发现在 Progress 中,能够明确有多少包被复用和重新下载了多少包
并且当我们运行pnpm install进行 node_modules 安装的时候,会使用软链接 & 硬链接的方式来节省磁盘空间 & 提升安装效率

pnpm的node_modules目录结构(软链接的使用)

上述安装得到如图的 node_modules:.pnpm 文件 & package.json描述的其他文件

29

  • .pnpm 目录:存放了所有实际安装的包,它里面全部都是我们项目所需要的依赖,唯一不同的它们是来自 store 目录的硬链接
  • 其他文件:package.json中声明的包,只是生成一个软链接,实际指向.pnpm中安装的包
    因为我们只依赖项中直定义了buffer,所以它是唯一一个你的应用必须拥有访问权限的包(想了解为什么要对依赖项做严格控制,点击查看)
    pnpm 这种依赖管理的方式也很巧妙地规避了非法访问依赖的问题,也就是只要一个包未在 package.json 中声明依赖,那么在项目中是无法访问的
    但是在 yarn/npm 中因为存在依赖提升问题,buffer 依赖 base64-js,因此 node_modules 中存在 base64-js 的包,我们在项目中可以直接引用。这就是为什么 pnpm 的包管理更为安全的原因

那 buffer 目录下都有些啥呢?发现 buffer 下根本没有 node_modules,它的依赖项都放在哪里了?目录结构第一个诀窍,buffer 是一个软链接,在 node 解析依赖的时候,它使用这些依赖的真实位置,因此在 buffer 下不用保留依赖

31

那你又会问 buffer 的真实地址在哪里?🧐
嚯,真实地址在这里: node_modules/.pnpm/[email protected]/node_modules/buffer
所以我们现在知道了.pnpm/文件夹的用途。.pnpm/以平铺的形式储存着所有的包,所以每个包都可以在这种命名模式的文件夹中被找到:node_modules/.pnpm/<name>@<version>/node_modules/<name>

使用这种平铺的结构避免 npm2 嵌套结构引起的长路径问题,和 npm3/yarn 的扁平化结构不同的是它保留了包之间的相互隔离
点开 buffer 的真实地址,再一次发现自己又被骗了,真实地址下面还是没有 node_modules

32

目录结构第二个诀窍来了,包的依赖项与依赖包的实际位置位于同一目录级别,buffer 的依赖不在.pnpm/[email protected]/node_modules/buffer/node_modules,而是在.pnpm/[email protected]/node_modules/。buffer 的所有依赖都被软链接到了node_modules/.pnpm的对应目录
到了这里你可能会问,为什么要把子依赖放在统一层级🤔

假如我们有两个包,foo 和 bar 相互依赖,使用同一层级安装的目录如下,这时 foo 和 bar 的软链接不是一个循环

.pnpm
  [email protected]
    node_modules
      bar
      foo --> ../../[email protected]/node_modules/foo
  [email protected]
    node_modules
      foo
      bar --> ../../[email protected]/node_modules/bar

假设 bar/foo 的依赖放在它的子文件中,如下,这时 foo/bar 的软链接就是一个循环

.pnpm
  [email protected]
    node_modules
      bar
        node_modules
          foo --> ../../[email protected]/node_modules/foo
  [email protected]
    node_modules
      foo
        node_modules
          bar --> ../../[email protected]/node_modules/bar

pnpm下node_modules存在的意义

35

buffer 中有 base64/ieee754 两个包,假设在 base64 的 dependencies 没有声明ieee754,那我们在 base64/index 使用requie('ieee754')时,他就是一个幽灵依赖。会先寻找 base64 上级的node_modules,若找到直接使用;否则直接往上级寻找 node_modules,如果上级也没有的话,当前的使用就会有问题

所以pnpm会将所有的依赖都提升到.pnpm/node_modules下,保证幽灵依赖可以被找到,作为兜底方案discussions

硬链接使用

在使用 pnpm 安装时,pnpm 会将依赖存储在~/.pnpm-store/v3目录下。在同一台电脑下,下一次安装的时候 pnpm 会先检查 store 目录,如果有找到所需的依赖,则会通过硬链接放到我们的项目中去,而不是重新安装依赖
因为 pnpm 安装依赖时会将依赖存储在 store 目录下,该目录能够使不同的项目之间共享依赖。例如 dt-data-api 项目中中安装了 dt-common/dt-react-component 等依赖,当我们在dt-easy-index 项目中也用了该两个依赖时,会重复使用 store 下的依赖。而 yarn/npm 每次都会重新安装依赖

33

🤔那么问题来了,为什么在这里要使用硬链接,而不是直接创建到全局存储的符号链接
通过上文我们知道 node 对软硬链接查询位置的处理不同。假设我们有如下的两个项目:

foo

"react": "16.8.0",
"dt-react-component": "1.0.0"

bar

"react": "17.0.0",
"dt-react-component": "1.0.0"

dt-react-component里面,会使用import React from 'react',假设foo/node_modules/dt-react-componentbar/node_modules/dt-react-component都使用软链接到./pnpm-store/v3,那他们都会使用同一个版本的 react,这不是我们所期望的。所以使用硬链接,在dt-react-component中在使用import React from 'react'时,会根据当前的位置去寻找依赖,这样使得 foo 和 bar 可以依赖不同的react的版本

pnpm的依赖管理图

34

参考链接

认识一下Mobx

前言

在之前的文章中,我们讲述了 React 的数据流管理,从 props → context → Redux,以及 Redux 相关的三方库 React-Redux(#3 #5 )

那其实说到 React 的状态管理器,除了 Redux 之外,Mobx 也是应用较多的管理方案。Mobx 是一个响应式库,在某种程度上可以看作没有模版的 Vue,两者的原理差不多

先看一下 Mobx 的简单使用,线上示例

export class TodoList {
  @observable todos = [];

  @computed get getUndoCount() {
    return this.todos.filter((todo) => !todo.done).length;
  }
  @action add(task) {
    this.todos.push({ task, done: false });
  }
  @action delete(index) {
    this.todos.splice(index, 1);
  }
}

Mobx 借助于装饰器来实现,是的代码更加简洁。使用了可观察对象,Mobx 可以直接修改状态,不用像 Redux 那样写 actions/reducers()。Redux 是遵循 setState 的流程,MobX就是干掉了 setState 的机制

通过响应式编程使得状态管理变得简单和可扩展,任何源自应用状态的东西都应该自动的获得

MobX v5 版本利用 ES6 的proxy来追踪属性,以前的旧版本通过Object.defineProperty实现的。通过隐式订阅,自动追踪被监听的对象变化

Mobx 的执行流程,一张官网结合上述例子的图

1

MobX将应用变为响应式可归纳为下面三个步骤

  1. 定义状态并使其可观察

    使用observable对存储的数据结构成为可观察状态

  2. 创建视图以响应状态的变化

    使用observer来监听视图,如果用到的数据发生改变视图会自动更新

  3. 更改状态

    使用action来定义修改状态的方法

Mobx核心概念

observable

给数据对象添加可观察的功能,支持任何的数据结构

observable之后的数据不是普通的数据形式,所以在内部使用时需要toJS()转化

const todos = observable([{
  task: "Learn Mobx",
  done: false
}])

// 更多的采用装饰器的写法
class Store {
  @observable todos = [{
    task: "Learn Mobx",
    done: false
  }]
}

computed

在 Redux 中,我们需要计算已经 completeTodo 和 unCompleteTodo,我们可以采用:在 mapStateToProps 中,通过 allTodos 过滤出对应的值,线上示例

const mapStateToProps = (state) => {
  const { visibilityFilter } = state;
  const todos = getTodosByVisibilityFilter(state, visibilityFilter);
  return { todos };
};

可以定义相关数据发生变化时自动更新的值,通过@computed调用getter/setter函数进行变更

一旦 todos 的发生改变,getUndoCount 就会自动计算

export class TodoList {
  @observable todos = [];

  @computed get getUndo() {
    return this.todos.filter((todo) => !todo.done)
  }

  @computed get getCompleteTodo() {
    return this.todos.filter((todo) => todo.done)
  }
}

action

动作是任何用来修改状态的东西。MobX 中的 action 不像 redux 中是必需的,把一些修改state 的操作都规范使用 action 做标注。

在 MobX 中可以随意更改todos.push({title:'coding', done: false}),state 也是可以有作用的,但是这样杂乱无章不好定位是哪里触发了 state 的变化,建议在任何更新observable或者有副作用的函数上使用 actions。

在严格模式useStrict(true)下,强制使用action

// 非action使用
<button
  // onClick={() => todoList.add(this.inputRef.value)}
  onClick={() =>
    todoList.todos.push({ task: this.inputRef.value, done: false })
  }
>
  Add New Todo
</button>

// action使用
<button
  onClick={() => todoList.add(this.inputRef.value)}
>
  Add New Todo
</button>

class TodoList {
  @action add(task) {
    this.todos.push({ task, done: false });
  }
}

Reactions

计算值computed是自动响应状态变化的值。反应是自动响应状态变化是的副作用,反应可以确保相关状态变化时指定的副作用执行。

  1. autorun

    autorun负责运行所提供的sideEffect并追踪在sideEffect运行期间访问过的observable的状态

    接受一个函数sideEffect,当这个函数中依赖的可观察属性发生变化的时候,autorun里面的函数就会被触发。除此之外,autorun里面的函数在第一次会立即执行一次。

    autorun(() => {
    	console.log("Current name : " + this.props.myName.name);
    });
    
    // 追踪函数外的间接引用不会生效
    const name = this.props.myName.name;
    autorun(() => {
    	console.log("Current name : " + name);
    });
  2. reaction

    reactionautorun的变种,在如何追踪observable方面给予了更细粒度的控制。 它接收两个函数,第一个是追踪并返回数据,该数据用作第二个函数,也就是副作用的输入。

    autorun 会立即执行一次,但是 reaction 不会

    reaction(
    	() => this.props.todoList.getUndoCount,
    	(data) => {
    		console.log("Current count : ", data);
    	}
    );

observer

使用 Redux 时,我们会引入 React-Redux 的 connect 函数,使得我们的组件能够通过 props 获取到 store 中的数据

在 Mobx 中也是一样的道理,我们需要引入 observer 将组件变为响应式组件

包裹 React 组件的高阶组件,在组件的 render 函数中任何使用的observable发生变化时,组件都会调用 render 重新渲染,更新 UI

⚠️ 不要放在顶层 Page,如果一个 state 改变,整个 Page 都会 render,所以 observer 尽量取包裹小组件,组件越小重新渲染的变化就越小

@observer
export default class TodoListView extends Component {
  render() {
    const { todoList } = this.props;
    return (
      <div className="todoView">
        <div className="todoView__list">
          {todoList.todos.map((todo, index) => (
            <TodoItem
              key={index}
              todo={todo}
              onDelete={() => todoList.delete(index)}
            />
          ))}
        </div>
      </div>
    );
  }
}

Mobx原理实现

前文中提到Mobx 实现响应式数据,采用了Object.defineProperty或者Proxy

上面讲述到使用 autorun 会在第一次执行并且依赖的属性变化时也会执行。

const user = observable({ name: "FBB", age: 24 })
autorun(() => {
	console.log(user.name)
})

当我们使用 observable 创建了一个可观察对象user,autorun 就会去监听user.name是否发生了改变。等于user.name被 autorun 监控了,一旦有任何变化就要去通知它

user.watchers.push(watch)
// 一旦user的数据发生了改变就要去通知观察者
user.watchers.forEach(watch => watch())

2

observable

装饰器一般接受三个参数: 目标对象、属性、属性描述符

通过上面的分析,通过 observable 创建的对象都是可观察的,也就是创建对象的每个属性都需要被观察

每一个被观察对象都需要有自己的订阅方法数组

const counter = observable({ count: 0 })
const user = observable({ name: "FBB", age: 20 })
autorun(function func1() {
    console.log(`${user.name} and ${counter.count}`)
})
autorun(function func2() {
    console.log(user.name)
})

对于上述代码来说,counter.count 的 watchers 只有 func1,user.name 的 watchers则有 func1/func2

实现一下观察者类 Watcher,借助 shortid 来区分不同的观察者实例

class Watcher {
    id: string
    value: any;
    constructor(v: any, property: string) {
        this.id = `ob_${property}_${shortid()}`;
        this.value = v;
    }
		// 调用get时,收集所有观察者
    collect() {
        dependenceManager.collect(this.id);
        return this.value;
    }
		// 调用set时,通知所有观察者
    notify(v: any) {
        this.value = v;
        dependenceManager.notify(this.id);
    }
}

实现一个简单的装饰器,需要拦截我们属性的get/set方法,并且使用Object.defineProperty进行深度拦截

export function observable(target: any, name: any, descriptor: { initializer: () => any; }) {
    const v = descriptor.initializer();
    createDeepWatcher(v)
    const watcher = new Watcher(v, name);
    return {
        enumerable: true,
        configurable: true,
        get: function () {
            return watcher.collect();
        },
        set: function (v: any) {
            return watcher.notify(v);
        }
    };
};

function createDeepWatcher(target: any) {
    if (typeof target === "object") {
        for (let property in target) {
            if (target.hasOwnProperty(property)) {
                const watcher = new Watcher(target[property], property);
                Object.defineProperty(target, property, {
                    get() {
                        return watcher.collect();
                    },
                    set(value) {
                        return watcher.notify(value);
                    }
                });
                createDeepWatcher(target[property])
            }
        }
    }
}

在上面 Watcher 类中的get/set中调用了 dependenceManager 的方法还未写完。在调用属性的get方法时,会将函数收集到当前 id 的 watchers 中,调用属性的set方法则是去通知所有的 watchers,触发对应收集函数

那这这里其实我们还需要借助一个类,也就是依赖收集类DependenceManager,马上就会实现

autorun

前面说到 autorun 会立即执行一次,并且会将函数收集起来,存储到对应的observable.id的watchers中。autorun 实现了收集依赖,执行对应函数。再执行对应函数的时候,会调用到对应observable对象的get方法,来收集依赖

export default function autorun(handler) {
    dependenceManager.beginCollect(handler)
    handler()
    dependenceManager.endCollect()
}

实现DependenceManager类:

  • beginCollect: 标识开始收集依赖,将依赖函数存到一个类全局变量中
  • collect(id): 调用get方法时,将依赖函数放到存入到对应 id 的依赖数组中
  • notify: 当执行set的时候,根据 id 来执行数组中的函数依赖
  • endCollect: 清除刚开始的函数依赖,以便于下一次收集
class DependenceManager {
    _store: any = {}
    static Dep: any;
    beginCollect(handler: () => void) {
        DependenceManager.Dep = handler
    }
    collect(id: string) {
        if (DependenceManager.Dep) {
            this._store[id] = this._store[id] || {}
            this._store[id].watchers = this._store[id].watchers || []
            if (!this._store[id].watchers.includes(DependenceManager.Dep))
                this._store[id].watchers.push(DependenceManager.Dep);
        }
    }
    notify(id: string) {
        const store = this._store[id];
        if (store && store.watchers) {
            store.watchers.forEach((watch: () => void) => {
                watch.call(this);
            })
        }
    }
    endCollect() {
        DependenceManager.Dep = null
    }
}

一个简单的 Mobx 框架都搭建好了~

computed

computed 的三个特点:

  • computed 方法是一个 get 方法
  • computed 会根据依赖的属性重新计算值
  • 依赖 computed 的函数也会被重新执行

发现 computed 的实现大致和 observable 相似,从以上特点可以推断出 computed 需要两次收集依赖,一次是收集 computed 所依赖的属性,一次是依赖 computed 的函数

首先定义一个 computed 方法,是一个装饰器

export function computed(target: any, name: any, descriptor: any) {
    const getter = descriptor.get; // get 函数
    const _computed = new ComputedWatcher(target, getter);

    return {
        enumerable: true,
        configurable: true,
        get: function () {
            _computed.target = this
            return _computed.get();
        }
    };
}

实现 ComputedWatcher 类,和 Watcher 类差不多

class ComputedWatcher {
    id: string;
    target: any;
    getter: any;
    constructor(target: any, getter: any) {
        this.id = `computed_${shortid()}`
        this.target = target
        this.getter = getter
    }
    // 提供给外部调用时收集依赖使用
    get() {
        dependenceManager.collect(this.id);
    }
}

在执行 get 方法的时候,我们和之前一样,去收集一下依赖 computed 的函数,丰富 get 方法

class ComputedWatcher {
    // 标识是否绑定过recomputed依赖,只需要绑定一次
    hasBindAutoReCompute: boolean | undefined;
    value: any;
    // 绑定recompute 和 内部涉及到的观察值的关系
    _bindAutoReCompute() {
        if (!this.hasBindAutoReCompute) {
            this.hasBindAutoReCompute = true;
            dependenceManager.beginCollect(this._reComputed, this);
            this._reComputed();
            dependenceManager.endCollect();
        }
    }
    // 依赖属性变化时调用的函数
    _reComputed() {
        this.value = this.getter.call(this.target);
        dependenceManager.notify(this.id);
    }
    // 提供给外部调用时收集依赖使用
    get() {
        this._bindAutoReCompute()
        dependenceManager.collect(this.id);
        return this.value
    }
}

observer

observer 相对实现会简单一点,其实是利用 React 的 render 函数对依赖进行收集,我们采用在 componnetDidMount 中调用 autorun 方法

export function observer(target: any) {
    const componentDidMount = target.prototype.componentDidMount;
    target.prototype.componentDidMount = function () {
        componentDidMount && componentDidMount.call(this);
        autorun(() => {
            this.render();
            this.forceUpdate();
        });
    };
}

至此一个简单的 Mobx 就实现了,线上代码地址
文章中使用的 Object.defineProperty 实现,Proxy 实现差不多,线上代码地址

Mobx vs Redux

  1. 数据流
    Mobx 和 Redux 都是单向数据流,都通过 action 触发全局 state 更新,再通知视图
    Redux 的数据流
    3

    Mobx 的数据流
    4

  2. 修改数据的方式
    他们修改状态的方式是不同的,Redux 每一次都返回了新的 state;Mobx 每次修改的都是同一个状态对象,基于响应式原理,get时收集依赖,set时通知所有的依赖
    当 state 发生改变时,Redux 会通知所有使用 connect 包裹的组件;Mobx 由于收集了每个属性的依赖,能够精准通知
    当我们使用 Redux 来修改数据时采用的是 reducer 函数,函数式编程**;Mobx 使用的则是面向对象代理的方式

  3. Store 的区别
    Redux 是单一数据源,采用集中管理的模式,并且数据均是普通的 JavaScript 对象。state 数据可读不可写,只有通过 reducer 来改变
    Mobx 是多数据源模式,并且数据是经过observable包裹的 JavaScript 对象。state 既可读又可写,在非严格模式下,action 不是必须的,可以直接赋值

总结

本文从 Mobx 的简单示例开始,讲述了一下 Mobx 的执行流程,引入了对应的核心概念,然后从零开始实现了一个简版的 Mobx,最后将 Mobx 和 Redux 做了一个简单的对比

参考链接

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.