jianglinqi

jianglinqi 查看完整档案

成都编辑成都工业学院  |  计算机科学与技术 编辑成都某不知名公司  |  bug批发商 编辑 blog.slower.top/blog 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

jianglinqi 赞了文章 · 2020-05-16

这才是官方的tapable中文文档

起因

搜索引擎搜索tapable中文文档,你会看见各种翻译,点进去一看,确实是官方的文档翻译过来的,但是webpack的文档确实还有很多需要改进的地方,既然是开源的为什么不去github上的tapable库看呢,一看,确实,比webpack文档上的描述得清楚得多.

tapable 是一个类似于nodejs 的EventEmitter 的库, 主要是控制钩子函数的发布与订阅,控制着webpack的插件系.webpack的本质就是一系列的插件运行.

Tapable

Tapable库 提供了很多的钩子类, 这些类可以为插件创建钩子

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

安装

npm install --save tapable

使用

所有的钩子构造函数,都接受一个可选的参数,(这个参数最好是数组,不是tapable内部也把他变成数组),这是一个参数的字符串名字列表

const hook = new SyncHook(["arg1", "arg2", "arg3"]);

最好的实践就是把所有的钩子暴露在一个类的hooks属性里面:

class Car {
    constructor() {
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            brake: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
    }

    /* ... */
}

其他开发者现在可以这样用这些钩子

const myCar = new Car();

// Use the tap method to add a consument
// 使用tap 方法添加一个消费者,(生产者消费者模式)
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());

这需要你传一个名字去标记这个插件:

你可以接收参数

myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

在同步钩子中, tap 是唯一的绑定方法,异步钩子通常支持异步插件

// promise: 绑定promise钩子的API
myCar.hooks.calculateRoutes.tapPromise("GoogleMapsPlugin", (source, target, routesList) => {
    // return a promise
    return google.maps.findRoute(source, target).then(route => {
        routesList.add(route);
    });
});
// tapAsync:绑定异步钩子的API
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
    bing.findRoute(source, target, (err, route) => {
        if(err) return callback(err);
        routesList.add(route);
        // call the callback
        callback();
    });
});

// You can still use sync plugins
// tap: 绑定同步钩子的API
myCar.hooks.calculateRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {
    const cachedRoute = cache.get(source, target);
    if(cachedRoute)
        routesList.add(cachedRoute);
})

类需要调用被声明的那些钩子

class Car {
    /* ... */

    setSpeed(newSpeed) {    
        // call(xx) 传参调用同步钩子的API
        this.hooks.accelerate.call(newSpeed);
    }

    useNavigationSystemPromise(source, target) {
        const routesList = new List();
        // 调用promise钩子(钩子返回一个promise)的API
        return this.hooks.calculateRoutes.promise(source, target, routesList).then(() => {
            return routesList.getRoutes();
        });
    }

    useNavigationSystemAsync(source, target, callback) {
        const routesList = new List();
        // 调用异步钩子API
        this.hooks.calculateRoutes.callAsync(source, target, routesList, err => {
            if(err) return callback(err);
            callback(null, routesList.getRoutes());
        });
    }
}

tapable会用最有效率的方式去编译(构建)一个运行你的插件的方法,他生成的代码依赖于一下几点:

  • 你注册的插件的个数.
  • 你注册插件的类型.
  • 你使用的调用方法(call, promise, async) // 其实这个类型已经包括了
  • 钩子参数的个数 // 就是你new xxxHook(['ooo']) 传入的参数
  • 是否应用了拦截器(拦截器下面有讲)

这些确定了尽可能快的执行.

钩子类型

每一个钩子都可以tap 一个或者多个函数, 他们如何运行,取决于他们的钩子类型

  • 基本的钩子, (钩子类名没有waterfall, Bail, 或者 Loop 的 ), 这个钩子只会简单的调用每个tap进去的函数
  • Waterfall, 一个waterfall 钩子,也会调用每个tap进去的函数,不同的是,他会从每一个函数传一个返回的值到下一个函数
  • Bail, Bail 钩子允许更早的退出,当任何一个tap进去的函数,返回任何值, bail类会停止执行其他的函数执行.(类似 Promise.race())
  • Loop, TODO(我.... 这里也没描述,应该是写文档得时候 还没想好这个要怎么写,我尝试看他代码去补全,不过可能需要点时间.)

此外,钩子可以是同步的,也可以是异步的,Sync, AsyncSeries 和 AsyncParallel ,从名字就可以看出,哪些是可以绑定异步函数的

  • Sync, 一个同步钩子只能tap同步函数, 不然会报错.
  • AsyncSeries, 一个 async-series 钩子 可以tap 同步钩子, 基于回调的钩子(我估计是类似chunk的东西)和一个基于promise的钩子(使用myHook.tap(), myHook.tapAsync()myHook.tapPromise().).他会按顺序的调用每个方法.
  • AsyncParallel, 一个 async-parallel 钩子跟上面的 async-series 一样 不同的是他会把异步钩子并行执行(并行执行就是把异步钩子全部一起开启,不按顺序执行).

拦截器(interception)

所有钩子都提供额外的拦截器API

// 注册一个拦截器
myCar.hooks.calculateRoutes.intercept({
    call: (source, target, routesList) => {
        console.log("Starting to calculate routes");
    },
    register: (tapInfo) => {
        // tapInfo = { type: "promise", name: "GoogleMapsPlugin", fn: ... }
        console.log(`${tapInfo.name} is doing its job`);
        return tapInfo; // may return a new tapInfo object
    }
})

call:(...args) => void当你的钩子触发之前,(就是call()之前),就会触发这个函数,你可以访问钩子的参数.多个钩子执行一次

tap: (tap: Tap) => void 每个钩子执行之前(多个钩子执行多个),就会触发这个函数

loop:(...args) => void 这个会为你的每一个循环钩子(LoopHook, 就是类型到Loop的)触发,具体什么时候没说

register:(tap: Tap) => Tap | undefined 每添加一个Tap都会触发 你interceptor上的register,你下一个拦截器的register 函数得到的参数 取决于你上一个register返回的值,所以你最好返回一个 tap 钩子.

Context(上下文)

插件和拦截器都可以选择加入一个可选的 context对象, 这个可以被用于传递随意的值到队列中的插件和拦截器.

myCar.hooks.accelerate.intercept({
    context: true,
    tap: (context, tapInfo) => {
        // tapInfo = { type: "sync", name: "NoisePlugin", fn: ... }
        console.log(`${tapInfo.name} is doing it's job`);

        // `context` starts as an empty object if at least one plugin uses `context: true`.
        // 如果最少有一个插件使用 `context` 那么context 一开始是一个空的对象
        // If no plugins use `context: true`, then `context` is undefined
        // 如过tap进去的插件没有使用`context` 的 那么内部的`context` 一开始就是undefined
        if (context) {
            // Arbitrary properties can be added to `context`, which plugins can then access.    
            // 任意属性都可以添加到`context`, 插件可以访问到这些属性
            context.hasMuffler = true;
        }
    }
});

myCar.hooks.accelerate.tap({
    name: "NoisePlugin",
    context: true
}, (context, newSpeed) => {
    if (context && context.hasMuffler) {
        console.log("Silence...");
    } else {
        console.log("Vroom!");
    }
});

HookMap

一个 HookMap是一个Hooks映射的帮助类

const keyedHook = new HookMap(key => new SyncHook(["arg"]))
keyedHook.tap("some-key", "MyPlugin", (arg) => { /* ... */ });
keyedHook.tapAsync("some-key", "MyPlugin", (arg, callback) => { /* ... */ });
keyedHook.tapPromise("some-key", "MyPlugin", (arg) => { /* ... */ });
const hook = keyedHook.get("some-key");
if(hook !== undefined) {
    hook.callAsync("arg", err => { /* ... */ });
}

钩子映射接口(HookMap interface)

Public(权限公开的):

interface Hook {
    tap: (name: string | Tap, fn: (context?, ...args) => Result) => void,
    tapAsync: (name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
    tapPromise: (name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
    intercept: (interceptor: HookInterceptor) => void
}

interface HookInterceptor {
    call: (context?, ...args) => void,
    loop: (context?, ...args) => void,
    tap: (context?, tap: Tap) => void,
    register: (tap: Tap) => Tap,
    context: boolean
}

interface HookMap {
    for: (key: any) => Hook,
    tap: (key: any, name: string | Tap, fn: (context?, ...args) => Result) => void,
    tapAsync: (key: any, name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
    tapPromise: (key: any, name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
    intercept: (interceptor: HookMapInterceptor) => void
}

interface HookMapInterceptor {
    factory: (key: any, hook: Hook) => Hook
}

interface Tap {
    name: string,
    type: string
    fn: Function,
    stage: number,
    context: boolean
}

Protected(保护的权限),只用于类包含的(里面的)钩子

interface Hook {
    isUsed: () => boolean,
    call: (...args) => Result,
    promise: (...args) => Promise<Result>,
    callAsync: (...args, callback: (err, result: Result) => void) => void,
}

interface HookMap {
    get: (key: any) => Hook | undefined,
    for: (key: any) => Hook
}

MultiHook

把其他的Hook 重定向(转化)成为一个 MultiHook

const { MultiHook } = require("tapable");

this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);

OK 所有的内容我都已翻译完成.

其中有很多不是直译,这样写下来感觉就是按照原文的脉络重新写了一遍....,应该能更清楚明白,要不是怕丢脸我就给个原创了,哈哈.

之后, 我还会写一篇完整的原创解析,直击源码,搞定tapable, 完全了解webpack插件系统(webpack本来就是一个插件的事件流), 好久没写原创了. 我自己也很期待.

查看原文

赞 40 收藏 22 评论 3

jianglinqi 赞了文章 · 2020-05-16

源码学习记录: tapable

前言

上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.

先从同步函数开始分析,异步也就是回调而已;

tap

这里有一个例子

let SyncHook = require('./lib/SyncHook.js')

let h1 = new SyncHook(['options']);

h1.tap('A', function (arg) {
  console.log('A',arg);
  return 'b'; // 除非你在拦截器上的 register 上调用这个函数,不然这个返回值你拿不到.
})

h1.tap('B', function () {
  console.log('b')
})
h1.tap('C', function () {
  console.log('c')
})
h1.tap('D', function () {
  console.log('d')
})

h1.intercept({
  call: (...args) => {
    console.log(...args, '-------------intercept call');
  },
  //
  register: (tap) => {
  console.log(tap, '------------------intercept register');

    return tap;
  },
  loop: (...args) => {
    console.log(...args, '-------------intercept loop')
  },
  tap: (tap) => {
    console.log(tap, '-------------------intercept tap')

  }
})
h1.call(6);

new SyncHook(['synchook'])

首先先创建一个同步钩子对象,那这一步会干什么呢?

这一步会先执行超类Hook的初始化工作

// 初始化
constructor(args) {
  // 参数必须是数组
  if (!Array.isArray(args)) args = [];
  // 把数组参数赋值给 _args 内部属性, new 的时候传进来的一系列参数.
  this._args = args;
  // 绑定taps,应该是事件
  this.taps = [];
  // 拦截器数组
  this.interceptors = [];
  // 暴露出去用于调用同步钩子的函数
  this.call = this._call;
  // 暴露出去的用于调用异步promise函数
  this.promise = this._promise;
  // 暴露出去的用于调用异步钩子函数
  this.callAsync = this._callAsync;
  // 用于生成调用函数的时候,保存钩子数组的变量,现在暂时先不管.
  this._x = undefined;
}

第二部 .tap()

现在我们来看看调用了tap() 方法后发生了什么

tap(options, fn) {
  // 下面是一些参数的限制,第一个参数必须是字符串或者是带name属性的对象,
  // 用于标明钩子,并把钩子和名字都整合到 options 对象里面
  if (typeof options === "string") options = { name: options };
  if (typeof options !== "object" || options === null)
    throw new Error(
      "Invalid arguments to tap(options: Object, fn: function)"
    );
  options = Object.assign({ type: "sync", fn: fn }, options);
  if (typeof options.name !== "string" || options.name === "")
    throw new Error("Missing name for tap");
  // 注册拦截器
  options = this._runRegisterInterceptors(options);
  // 插入钩子
  this._insert(options);
}
  • 现在我们来看看如何注册拦截器
_runRegisterInterceptors(options) {
  // 现在这个参数应该是这个样子的{fn: function..., type: sync,name: 'A' }
// 遍历拦截器,有就应用,没有就把配置返还回去
for (const interceptor of this.interceptors) {
  if (interceptor.register) {
    // 把选项传入拦截器注册,从这里可以看出,拦截器的register 可以返回一个新的options选项,并且替换掉原来的options选项,也就是说可以在执行了一次register之后 改变你当初 tap 进去的方法
    const newOptions = interceptor.register(options);
    if (newOptions !== undefined) options = newOptions;
  }
}
return options;
}

注意: 这里执行的register拦截器是有顺序问题的, 这个执行在tap()里面,也就是说,你这个拦截器要在调用tap(),之前就调用 intercept()添加的.

那拦截器是怎么添加进去的呢,来看下intercept()

intercept(interceptor) {
  // 重置所有的 调用 方法,在教程中我们提到了 编译出来的调用方法依赖的其中一点就是 拦截器. 所有每添加一个拦截器都要重置一次调用方法,在下一次编译的时候,重新生成.
  this._resetCompilation();
  // 保存拦截器 而且是复制一份,保留原本的引用
  this.interceptors.push(Object.assign({}, interceptor));
  // 运行所有的拦截器的register函数并且把 taps[i],(tap对象) 传进去.
  // 在intercept 的时候也会遍历执行一次当前所有的taps,把他们作为参数调用拦截器的register,并且把返回的 tap对象(tap对象就是指 tap函数里面把fn和name这些信息整合起来的那个对象) 替换了原来的 tap对象,所以register最好返回一个tap, 在例子中我返回了原来的tap, 但是其实最好返回一个全新的tap
  if (interceptor.register) {
    for (let i = 0; i < this.taps.length; i++)
      this.taps[i] = interceptor.register(this.taps[i]);
  }
}

注意: 也就是在调用tap() 之后再传入的拦截器,会在传入的时候就为每一个tap 调用register方法

  • 现在我们来看看_insert
_insert(item) {
  // 重置资源,因为每一个插件都会有一个新的Compilation
  this._resetCompilation();
  // 顺序标记, 这里联合 __test__ 包里的Hook.js一起使用
  // 看源码不懂,可以看他的测试代码,就知道他写的是什么目的.
  // 从测试代码可以看到,这个 {before}是插件的名字.
  let before;
  // before 可以是单个字符串插件名称,也可以是一个字符串数组插件.
  if (typeof item.before === "string") {
    before = new Set([item.before]);
  }
  else if (Array.isArray(item.before)) {
    before = new Set(item.before);
  }
  // 阶段
  // 从测试代码可以知道这个也是一个控制顺序的属性,值越小,执行得就越在前面
  // 而且优先级低于 before
  let stage = 0;
  if (typeof item.stage === "number") stage = item.stage;
  let i = this.taps.length;
  // 遍历所有`tap`了的函数,然后根据 stage 和 before 进行重新排序.
  // 假设现在tap了 两个钩子  A B  `B` 的配置是  {name: 'B', before: 'A'}
  while (i > 0) {// i = 1, taps = [A]
    i--;// i = 0 首先-- 是因为要从最后一个开始
    const x = this.taps[i];// x = A
    this.taps[i + 1] = x;// i = 0, taps[1] = A  i+1 把当前元素往后移位,把位置让出来
    const xStage = x.stage || 0;// xStage = 0
    if (before) {// 如果有这个属性就会进入这个判断
      if (before.has(x.name)) {// 如果before 有x.name 就会把这个插件名称从before这个列表里删除,代表这个钩子位置已经在当前的钩子之前
        before.delete(x.name);
        continue;// 如果before还有元素,继续循环,执行上面的操作
      }
      if (before.size > 0) {
        continue;// 如果before还有元素,那就一直循环,直到第一位.
      }
    }
    if (xStage > stage) {// 如果stage比当前钩子的stage大,继续往前挪
      continue;
    }
    i++;
    break;
  }
  this.taps[i] = item;// 把挪出来的位置插入传进来的钩子
}

这其实就是一个排序算法, 根据before, stage 的值来排序,也就是说你可以这样tap进来一个插件

h1.tap({
  name: 'B',
  before: 'A'
  }, () => {
    console.log('i am B')
  })

发布订阅模式

发布订阅模式是一个在前后端都盛行的一个模式,前端的promise,事件,等等都基于发布订阅模式,其实tapable 也是一种发布订阅模式,上面的tap 只是订阅了钩子函数,我们还需要发布他,接下来我们谈谈h1.call(),跟紧了,这里面才是重点.

我们可以在初始化中看到this.call = this._call,那我们来看一下 this._call() 是个啥

Object.defineProperties(Hook.prototype, {
  _call: {
    value: createCompileDelegate("call", "sync"),
    configurable: true,
    writable: true
  },
  _promise: {
    value: createCompileDelegate("promise", "promise"),
    configurable: true,
    writable: true
  },
  _callAsync: {
    value: createCompileDelegate("callAsync", "async"),
    configurable: true,
    writable: true
  }
});

结果很明显,这个函数是由createCompileDelegate(),这个函数返回的,依赖于,函数的名字以及钩子的类型.

createCompileDelegate(name, type)

function createCompileDelegate(name, type) {
  return function lazyCompileHook(...args) {
    // 子类调用时,this默认绑定到子类
    // (不明白的可以了解js this指向,一个函数的this指向调用他的对象,没有就是全局,除非使用call apply bind 等改变指向)
    // 在我们的例子中,这个 this 是 SyncHook
    this[name] = this._createCall(type);
    // 用args 去调用Call
    return this[name](...args);
  };
}

在上面的注释上可以加到,他通过闭包保存了nametype的值,在我们这个例子中,这里就是this.call = this._createCall('sync');然后把我们外部调用call(666) 时 传入的参数给到他编译生成的方法中.

注意,在我们这个例子当中我在call的时候并没有传入参数.

这时候这个call方法的重点就在_createCall方法里面了.

_createCall()

_createCall(type) {

  // 传递一个整合了各个依赖条件的对象给子类的compile方法
  return this.compile({
    taps: this.taps,
    interceptors: this.interceptors,
    args: this._args,
    type: type
  });
}

从一开始,我们就在Hook.js上分析,我们来看看Hook上的compile

compile(options) {
  throw new Error("Abstract: should be overriden");
}

清晰明了,这个方法一定要子类复写,不然报错,上面的_createCompileDelegate的注释也写得很清楚,在当前的上下文中,this指向的是,子类,在我们这个例子中就是SyncHook

来看看SyncHook 的compile

compile(options) {
  // 现在options 是由Hook里面 传到这里的
  // options
  // {
  //  taps: this.taps, tap对象数组
  //  interceptors: this.interceptors, 拦截器数组
  //  args: this._args,
  //  type: type
  // }
  // 对应回教程中的编译出来的调用函数依赖于的那几项看看,是不是这些,钩子的个数,new SyncHook(['arg'])的参数个数,拦截器的个数,钩子的类型.
  factory.setup(this, options);

  return factory.create(options);
}

好吧 现在来看看setup, 咦? factory 怎么来的,原来

const factory = new SyncHookCodeFactory();

是new 出来的

现在来看看SyncHookCodeFactory 的父类 HookCodeFactory

constructor(config) {

  // 这个config作用暂定.因为我看了这个文件,没看到有引用的地方,
  // 应该是其他子类有引用到
  this.config = config;
  // 这两个不难懂, 往下看就知道了
  this.options = undefined;
  this._args = undefined;
}

现在可以来看一下setup了

setup(instance, options) {
  // 这里的instance 是syncHook 实例, 其实就是把tap进来的钩子数组给到钩子的_x属性里.
  instance._x = options.taps.map(t => t.fn);
}

OK, 到create了

这个create有点长, 看仔细了,我们现在分析同步的部分.

create(options) {
  // 初始化参数,保存options到本对象this.options,保存new Hook(["options"]) 传入的参数到 this._args
  this.init(options);
  let fn;
  // 动态构建钩子,这里是抽象层,分同步, 异步, promise
  switch (this.options.type) {
    // 先看同步
    case "sync":
      // 动态返回一个钩子函数
      fn = new Function(
        // 生成函数的参数,no before no after 返回参数字符串 xxx,xxx 在
        // 注意这里this.args返回的是一个字符串,
        // 在这个例子中是options
        this.args(),
        '"use strict";\n' +
          this.header() +
          this.content({
            onError: err => `throw ${err};\n`,
            onResult: result => `return ${result};\n`,
            onDone: () => "",
            rethrowIfPossible: true
          })
      );
      break;
    case "async":
      fn = new Function(
        this.args({
          after: "_callback"
        }),
        '"use strict";\n' +
          this.header() +
          // 这个 content 调用的是子类类的 content 函数,
          // 参数由子类传,实际返回的是 this.callTapsSeries() 返回的类容
          this.content({
            onError: err => `_callback(${err});\n`,
            onResult: result => `_callback(null, ${result});\n`,
            onDone: () => "_callback();\n"
          })
      );
      break;
    case "promise":
      let code = "";
      code += '"use strict";\n';
      code += "return new Promise((_resolve, _reject) => {\n";
      code += "var _sync = true;\n";
      code += this.header();
      code += this.content({
        onError: err => {
          let code = "";
          code += "if(_sync)\n";
          code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
          code += "else\n";
          code += `_reject(${err});\n`;
          return code;
        },
        onResult: result => `_resolve(${result});\n`,
        onDone: () => "_resolve();\n"
      });
      code += "_sync = false;\n";
      code += "});\n";
      fn = new Function(this.args(), code);
      break;
  }
  // 把刚才init赋的值初始化为undefined
  // this.options = undefined;
  // this._args = undefined;
  this.deinit();

  return fn;
}

到了这个方法,一切我们都一目了然了(看content的参数), 在我们的例子中他是通过动态的生成一个call方法,根据的条件有,钩子是否有context 属性(这个是根据header的代码才能知道), 钩子的个数, 钩子的类型,钩子的参数,钩子的拦截器个数.

注意,这上面有关于 fn这个变量的函数,返回的都是字符串,不是函数不是方法,是返回可以转化成代码执行的字符串,思维要转变过来.

现在我们来看看header()

header() {
  let code = "";
  // this.needContext() 判断taps[i] 是否 有context 属性, 任意一个tap有 都会返回 true
  if (this.needContext()) {
    // 如果有context 属性, 那_context这个变量就是一个空的对象.
    code += "var _context = {};\n";
  } else {
    // 否则 就是undefined
    code += "var _context;\n";
  }
  // 在setup()中 把所有tap对象的钩子 都给到了 instance ,这里的this 就是setup 中的instance _x 就是钩子对象数组
  code += "var _x = this._x;\n";
  // 如果有拦截器,在我们的例子中,就有一个拦截器
  if (this.options.interceptors.length > 0) {
    // 保存taps 数组到_taps变量, 保存拦截器数组 到变量_interceptors
    code += "var _taps = this.taps;\n";
    code += "var _interceptors = this.interceptors;\n";
  }
  // 如果没有拦截器, 这里也不会执行.一个拦截器只会生成一次call
  // 在我们的例子中,就有一个拦截器,就有call
  for (let i = 0; i < this.options.interceptors.length; i++) {
    const interceptor = this.options.interceptors[i];
    if (interceptor.call) {
      // getInterceptor 返回的 是字符串 是 `_interceptors[i]`
      // 后面的before 因为我们的拦截器没有context 所以返回的是undefined 所以后面没有跟一个空对象
      code += `${this.getInterceptor(i)}.call(${this.args({
        before: interceptor.context ? "_context" : undefined
      })});\n`;
    }
  }
  return code;
  // 注意 header 返回的不是代码,是可以转化成代码的字符串(这个时候并没有执行).
  /**
    * 此时call函数应该为:
    * "use strict";
    * function (options) {
    *   var _context;
    *   var _x = this._x;
    *   var _taps = this.taps;
    *   var _interterceptors = this.interceptors;
    * // 我们只有一个拦截器所以下面的只会生成一个
    *   _interceptors[0].call(options);
    *}
    */
}

现在到我们的this.content()了,仔细一看,this.content()方法并不在HookCodeFactory上,很明显这个content是由子类来实现的,往回看看这个create是由谁调用的?没错,是SuncHookCodeFactory的石料理,我们来看看SyncHook.js上的SyncHookCodeFactory实现的content

在看这个content实现之前,先来回顾一下父类的create()给他传了什么参数.

this.content({
  onError: err => `throw ${err};\n`,
  onResult: result => `return ${result};\n`,
  onDone: () => "",
  rethrowIfPossible: true
})

注意了,这上面不是抛出错误,不是返回值. 这里面的回调执行了以后返回的是一个字符串,不要搞混了代码与可以转化成代码的字符串.

content({ onError, onResult, onDone, rethrowIfPossible }) {
  return this.callTapsSeries({
    // 可以在这改变onError 但是这里的 i 并没有用到,这是什么操作...
    // 注意这里并没有传入onResult
    onError: (i, err) => onError(err),
    onDone,
    // 这个默认为true
    rethrowIfPossible
  });
}

这个函数返回什么取决于this.callTapSeries(), 那接下来我们来看看这个函数(这层层嵌套,其实也是有可斟酌的地方.看源码不仅要看实现,代码的组织也是很重要的编码能力)

刚才函数的头部已经出来了,头部做了初始化的操作,与生成执行拦截器代码.content很明显,要开始生成执行我们的tap对象的代码了(如果不然,我们的tap进来的函数在哪里执行呢? 滑稽:).

callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
  // 如果 taps 钩子处理完毕,执行onDone,或者一个tap都没有 onDone() 返回的是一个字符串.看上面的回顾就知道了.
  if (this.options.taps.length === 0) return onDone();
  // 如果由异步钩子,把第一个异步钩子的下标,如果没有这个返回的是-1
  const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
  // 定义一个函数 接受一个 number 类型的参数, i 应该是taps的index
  // 从这个函数的命名来看,这个函数应该会递归的执行
  // 我们先开最后的return语句,发现第一个传进来的参数是0
  const next = i => {
    // 如果 大于等于钩子函数数组长度, 返回并执行onDone回调,就是tap对象都处理完了
    // 跳出递归的条件
    if (i >= this.options.taps.length) {
      return onDone();
    }
    // 这个方法就是递归的关键,看见没,逐渐往上遍历
    // 注意这里只是定义了方法,并没有执行
    const done = () => next(i + 1);
    // 传入一个值 如果是false 就执行onDone true 返回一个 ""
    // 字面意思,是否跳过done 应该是增加一个跳出递归的条件
    const doneBreak = skipDone => {
      if (skipDone) return "";
      return onDone();
    };
    // 这里就是处理单个taps对象的关键,传入一个下标,和一系列回调.
    return this.callTap(i, {
      // 调用的onError 是 (i, err) => onError(err) , 后面这个onError(err)是 () => `throw ${err}`
      // 目前 i done doneBreak 都没有用到
      onError: error => onError(i, error, done, doneBreak),
      // 这里onResult 同步钩子的情况下在外部是没有传进来的,刚才也提到了
      // 这里onResult是 undefined
      onResult:
        onResult &&
        (result => {
          return onResult(i, result, done, doneBreak);
        }),
      // 没有onResult 一定要有一个onDone 所以这里就是一个默认的完成回调
      // 这里的done 执行的是next(i+1), 也就是迭代的处理完所有的taps
      onDone:
        !onResult &&
        (() => {return done();}),
      // rethrowIfPossible 默认是 true 也就是返回后面的
      // 因为没有异步函数 firstAsync = -1.
      // 所以返回的是 -1 < 0,也就是true, 这个可以判断当前的是否是异步的tap对象
      //  这里挺妙的 如果是 false 那么当前的钩子类型就不是sync,可能是promise或者是async
      // 具体作用要看callTaps()如何使用这个.
      rethrowIfPossible:
        rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
    });
  };
  
  return next(0);
}

参数搞明白了,现在,我们可以进入callTap() 了.

callTap挺长的,因为他也分了3种类型分别处理,像create()一样.

/** tapIndex 下标
  * onError:() => onError(i,err,done,skipdone) ,
  * onReslt: undefined
  * onDone: () => {return: done()} //开启递归的钥匙
  * rethrowIfPossible: false 说明当前的钩子不是sync的.
  */
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
  let code = "";
  // hasTapCached 是否有tap的缓存, 这个要看看他是怎么做的缓存了
  let hasTapCached = false;
  // 这里还是拦截器的用法,如果有就执行拦截器的tap函数
  for (let i = 0; i < this.options.interceptors.length; i++) {
    const interceptor = this.options.interceptors[i];
    if (interceptor.tap) {
      if (!hasTapCached) {
        // 这里getTap返回的是 _taps[0] _taps[1]... 的字符串
        // 这里生成的代码就是 `var _tap0 = _taps[0]`
        // 注意: _taps 变量我们在 header 那里已经生成了
        code += `var _tap${tapIndex} = ${this.getTap(tapIndex)};\n`;
        // 可以看到这个变量的作用就是,如果有多个拦截器.这里也只会执行一次.
        // 注意这句获取_taps 对象的下标用的是tapIndex,在一次循环中,这个tapIndex不会变
        // 就是说如果这里执行多次,就会生成多个重复代码,不稳定,也影响性能.
        // 但是你又要判断拦截器有没有tap才可以执行,或许有更好的写法
        // 如果你能想到,那么你就是webpack的贡献者了.不过这样写,似乎也没什么不好.
        hasTapCached = true;
      }
      // 这里很明显跟上面的getTap 一样 返回的都是字符串
      // 我就直接把这里的code 分析出来了,注意 这里还是在循坏中.
      // code += _interceptor[0].tap(_tap0);
      // 由于我们的拦截器没有context,所以没传_context进来.
      // 可以看到这里是调用拦截器的tap方法然后传入tap0对象的地方
      code += `${this.getInterceptor(i)}.tap(${
        interceptor.context ? "_context, " : ""
      }_tap${tapIndex});\n`;
    }
  }
  // 跑出了循坏
  // 这里的getTapFn 返回的也是字符串 `_x[0]`
  // callTap用到的这些全部在header() 那里生成了,忘记的回头看一下.
  // 这里的code就是: var _fn0 = _x[0]
  code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
  const tap = this.options.taps[tapIndex];
  // 开始处理tap 对象
  switch (tap.type) {
    case "sync":
      // 全是同步的时候, 这里不执行, 如果有异步函数,那么恭喜,有可能会报错.所以他加了个 try...catch
      if (!rethrowIfPossible) {
        code += `var _hasError${tapIndex} = false;\n`;
        code += "try {\n";
      }
      // 前面分析了 同步的时候 onResult 是 undefined
      // 我们也分析一下如果走这里会怎样
      // var _result0 = _fn0(option)
      // 可以看到是调用tap 进来的钩子并且接收参数
      if (onResult) {
        code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({
          before: tap.context ? "_context" : undefined
        })});\n`;
      } else {
        // 所以会走这里
        // _fn0(options) 额... 我日 有就接受一下结果
        code += `_fn${tapIndex}(${this.args({
          before: tap.context ? "_context" : undefined
        })});\n`;
      }
      // 把 catch 补上,在这个例子中没有
      if (!rethrowIfPossible) {
        code += "} catch(_err) {\n";
        code += `_hasError${tapIndex} = true;\n`;
        code += onError("_err");
        code += "}\n";
        code += `if(!_hasError${tapIndex}) {\n`;
      }
      // 有onResult 就把结果给传递出去. 目前没有
      if (onResult) {
        code += onResult(`_result${tapIndex}`);
      }
      // 有onDone() 就调用他开始递归,还记得上面的next(i+1) 吗?
      if (onDone) {
        code += onDone();
      }
      // 这里是不上上面的if的大括号,在这个例子中没有,所以这里也不执行
      if (!rethrowIfPossible) {
        code += "}\n";
      }
      // 同步情况下, 这里最终的代码就是
      // var _tap0 = _taps[0];
      // _interceptors[0].tap(_tap0);
      // var _fn0 = _x[0];
      // _fn0(options);
      // 可以看到,这里会递归下去
      // 因为我们tap了4个钩子
      // 所以这里会从复4次
      // 最终长这样
      // var _tap0 = _taps[0];
      // _interceptors[0].tap(_tap0);
      // var _fn0 = _x[0];
      // _fn0(options);
      // var _tap1 = _taps[1];
      // _interceptors[1].tap(_tap1);
      // var _fn1 = _x[1];
      // _fn1(options);
      // ......
      break;
    case "async":
      let cbCode = "";
      if (onResult) cbCode += `(_err${tapIndex}, _result${tapIndex}) => {\n`;
      else cbCode += `_err${tapIndex} => {\n`;
      cbCode += `if(_err${tapIndex}) {\n`;
      cbCode += onError(`_err${tapIndex}`);
      cbCode += "} else {\n";
      if (onResult) {
        cbCode += onResult(`_result${tapIndex}`);
      }
      if (onDone) {
        cbCode += onDone();
      }
      cbCode += "}\n";
      cbCode += "}";
      code += `_fn${tapIndex}(${this.args({
        before: tap.context ? "_context" : undefined,
        after: cbCode
      })});\n`;
      break;
    case "promise":
      code += `var _hasResult${tapIndex} = false;\n`;
      code += `var _promise${tapIndex} = _fn${tapIndex}(${this.args({
        before: tap.context ? "_context" : undefined
      })});\n`;
      code += `if (!_promise${tapIndex} || !_promise${tapIndex}.then)\n`;
      code += `  throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise${tapIndex} + ')');\n`;
      code += `_promise${tapIndex}.then(_result${tapIndex} => {\n`;
      code += `_hasResult${tapIndex} = true;\n`;
      if (onResult) {
        code += onResult(`_result${tapIndex}`);
      }
      if (onDone) {
        code += onDone();
      }
      code += `}, _err${tapIndex} => {\n`;
      code += `if(_hasResult${tapIndex}) throw _err${tapIndex};\n`;
      code += onError(`_err${tapIndex}`);
      code += "});\n";
      break;
  }
  return code;
}

好了, 到了这里 我们可以把compile 出来的call 方法输出出来了

"use strict";
function (options) {
  var _context;
  var _x = this._x;
  var _taps = this.taps;
  var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
  _interceptors[0].call(options);

  var _tap0 = _taps[0];
  _interceptors[0].tap(_tap0);
  var _fn0 = _x[0];
  _fn0(options);
  var _tap1 = _taps[1];
  _interceptors[1].tap(_tap1);
  var _fn1 = _x[1];
  _fn1(options);
  var _tap2 = _taps[2];
  _interceptors[2].tap(_tap2);
  var _fn2 = _x[2];
  _fn2(options);
  var _tap3 = _taps[3];
  _interceptors[3].tap(_tap3);
  var _fn3 = _x[3];
  _fn3(options);
}

到了这里可以知道,我们的例子中h1.call()其实调用的就是这个方法.到此我们可以说是知道了这个库的百分之80了.

不知道大家有没有发现,这个生成的函数的参数列表是从哪里来的呢?往回翻到create()方法里面调用的this.args()你就会看见,没错就是this._args. 这个东西在哪里初始化呢? 翻一下就知道,这是在Hook.js这个类里面初始化的,也就是说你h1 = new xxxHook(['options']) 的时候传入的数组有几个值,那么你h1.call({name: 'haha'}) 就能传几个值.看教程的时候他说,这里传入的是一个参数名字的字符串列表,那时候我就纳闷,什么鬼,我传入的不是值吗,怎么就变成了参数名称,现在完全掌握....

好了,最简单的SyncHook 已经搞掂,但是一看tapable内部核心使用的钩子却不是他,而是SyncBailHook,在教程中我们已经知道,bail是只要有一个钩子执行完了,并且返回一个值,那么其他的钩子就不执行.我们来看看他是怎么实现的.

从刚才我们弄明白的synchook,我们知道了他的套路,其实生成的函数的header()都是一样的,这次我们直接来看看bailhook实现的content()方法

content({ onError, onResult, onDone, rethrowIfPossible }) {
  return this.callTapsSeries({
    onError: (i, err) => onError(err),
  // 看回callTapsSeries 就知道这里传入的next 是 done
    onResult: (i, result, next) =>
      `if(${result} !== undefined) {\n${onResult(
        result
      )};\n} else {\n${next()}}\n`,
    onDone,
    rethrowIfPossible
  });
}

看出来了哪里不一样吗? 是的bailhookcallTapsSeries传了onResult属性,我们来看看他这个onResult是啥黑科技

父类传的onResult默认是 (result) => 'return ${result}',那么他这里返回的就是:


// 下面返回的是字符串,
if (xxx !== undefined) {
  // 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
  return result;
} else {
  // next(); 这里返回的是一个字符串(因为要生成字符串代码)
  // 我在上面的注释中提到了 next 是 done 就是那个开启递归的门
  // 所以如果tap 一直没返回值, 这里就会一直 if...else.. 的嵌套下去
  
}

回头想想,我们刚刚是不是分析了capTap(),如果我们传了onResult 会怎样? 如果你还记得就知道,如果有传了onResult这个回调,他就会接收这个返回值.并且会调用这个回调把result传出去.

而且还要注意的是,onDonecallTap()的时候是处理过的,我在贴出来一次.

onDone:!onResult && (() => {return done();})

也就是说如果我传了onResult 那么这个onDone就是一个false.

所以递归的门现在从synconDone,变到syncBailonResult

好,现在带着这些变化去看this.capTap(),你就能推出现在这个 call 函数会变成这样.

"use strict";
function (options) {
  var _context;
  var _x = this._x;
  var _taps = this.taps;
  var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
  _interceptors[0].call(options);

  var _tap0 = _taps[0];
  _interceptors[0].tap(_tap0);
  var _fn0 = _x[0];
  var _result0 = _fn0(options);

  if (_result0 !== undefined) {
    // 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
    return _result0
  } else {
    var _tap1 = _taps[1];
    _interceptors[1].tap(_tap1);
    var _fn1 = _x[1];
    var _result1 = _fn1(options);
    if (_result1 !== undefined) {
      return _result1
    } else {
      var _tap2 = _taps[2];
      _interceptors[2].tap(_tap2);
      var _fn2 = _x[2];
      var _result2 = _fn2(options);
      if (_result2 !== undefined) {
        return _result2
      } else {
        var _tap3 = _taps[3];
        _interceptors[3].tap(_tap3);
        var _fn3 = _x[3];
        _fn3(options);
      }
    }
  }

到如今,tapable库 已经删除了 tapable.js文件(可能做了一些整合,更细分了),只留下了钩子文件.但不影响功能,webpack 里的compilecompilation 等一众重要插件,都是基于 tapable库中的这些钩子.

现在我们require('tapable')得到的对象是这样的:

{
    SyncHook: function(...){},
    SyncBailHook: function(...){},
    ...
}

到此,关于tapable的大部分我都解剖了一遍,还有其他类型的hook 如果你们愿意,相信你们去研究一下,也能够游刃有余.

那个,写得有些随性,可能会让你们觉得模糊,但是...我真尽力了,这篇改了几遍,历时一个星期...,不懂就在那个评论区问我.我看到会回复的.共勉.

后记:
本来以为会很难,但是越往下深入的时候发现,大神之所以成为大神,不是他的代码写得牛,是他的思维牛,没有看不懂的代码,只有跟不上的思路,要看懂他如何把call 函数组织出来不难,难的是,他居然能想到这样来生成函数,还可以考虑到,拦截器钩子,和context 属性,以及他的 onResultonDone 回调的判断,架构的设计,等等,一步接一步.先膜拜吧...

路漫漫其修远兮, 吾将上下而求索.

查看原文

赞 15 收藏 6 评论 6

jianglinqi 赞了文章 · 2019-11-25

「nodejs + docker + github pages 」 定制自己的 「今日头条」

WX20191121-170303.png

前言

在闲暇之余,我们经常会逛各种社区,逛掘金看技术软文,逛虎扑看今日赛事,逛头条看热门时事,逛 91……

每个社区都有各种各样的资讯,但有时我们只想看某个社区的某些资讯。那我们能不能将这些社区里我们想要的信息做一下整合 定制成自己的“今日头条”呢?

思路

每天定时抓取 资讯的标题和链接 整合后发布到自己的网站 这样每天只要打开自己的网站就可以看到属于自己的今日头条啦~

  • 抓取资讯 puppeteer
  • 定时任务 node-schedule
  • 部署 docker + github pages

我的今日头条

  • 掘金社区 前端热门文章
  • 今日头条 热门时事
  • 虎扑社区 nba 赛事
  • QQ 音乐 热门音乐

ok,开撸...

项目初始化

npm init -y
today's hot
│   README.md
└───html
│   │   index.html  // 网站入口,用于部署github pages
└───resource
│   │   index.json  // 资讯数据,爬取存放文件
└───tasks           // 任务队列
│   │   index.js
│   │   juejin.js
│   │   top.js
│   │   nba.js
│   │   music.js
│   │   jianshu.js
└───tools          //  工具类
    │   index.js
│   index.js       //  工程入口
│   package.json

抓取资讯

抓取资讯 我使用的是 puppeteer,它是 Google Chrome 团队官方的一个工具,提供了一些 API 来控制 chrome!(一听就很刺激。)

npm i puppeteer --save

我们先写一个简单的 demo 来了解一些 puppeteer 的基本 api.

const puppeteer = require("puppeteer");

const task = async () => {
  // 打开chrome浏览器
  const browser = await puppeteer.launch({
    // 关闭无头模式,方便查看
    headless: false
  });
  // 新建页面
  const page = await browser.newPage();
  // 跳转到掘金
  await page.goto("https://juejin.im");
  // 截屏保存
  await page.screenshot({
    path: "./juejin.png"
  });
};
task();

juejin

ok~我们趁阴明站长不在的时候,来掘金"拿点"东西~

掘金的前端热门文章是我比较关注的模块,我们来"拿"这个模块的资讯.

const puppeteer = require("puppeteer");

const task = async () => {
  // 打开chrome浏览器
  const browser = await puppeteer.launch({
    headless: false
  });
  // 新建页面
  const page = await browser.newPage();
  // 跳转到掘金
  await page.goto("https://juejin.im");
  // 菜单导航对应的类名
  const navSelector = ".view-nav .nav-item";
  // 前端菜单
  const navType = "前端";
  // 等待菜单加载完成...
  await page.waitFor(navSelector);
  // 菜单导航名称
  const navList = await page.$$eval(navSelector, ele =>
    ele.map(el => el.innerText)
  ); // [ '推荐', '后端', '前端', 'Android', 'iOS', '人工智能', '开发工具', '代码人生', '阅读' ]
  // 找出菜单中前端模块对应的索引
  const webNavIndex = navList.findIndex(item => item === navType);
  // 点击前端模块并等待页面跳转完成
  await Promise.all([
    page.waitForNavigation(),
    page.click(`${navSelector}:nth-child(${webNavIndex + 1})`)
  ]);
  // 截屏保存
  await page.screenshot({
    path: "./juejin-web.png"
  });
};
task();

juejin

上图可以看到,我们已经跳转到了前端模块.

接下来,我们只要找出文章列表对应的类名就可以对它进行爬取.

const puppeteer = require("puppeteer");

const task = async () => {
  // 打开chrome浏览器
  const browser = await puppeteer.launch({
    headless: false
  });
  // 新建页面
  const page = await browser.newPage();
  // 跳转到掘金
  await page.goto("https://juejin.im");
  // 菜单导航选择器
  const navSelector = ".view-nav .nav-item";
  // 文章列表选择器
  const listSelector = ".entry-list .item a.title";
  // 菜单类别
  const navType = "前端";
  await page.waitFor(navSelector);
  // 导航列表
  const navList = await page.$$eval(navSelector, ele =>
    ele.map(el => el.innerText)
  );
  // 前端导航索引
  const webNavIndex = navList.findIndex(item => item === navType);
  await Promise.all([
    page.waitForNavigation(),
    page.click(`${navSelector}:nth-child(${webNavIndex + 1})`)
  ]);
  // 等待文章列表选择器加载完成
  await page.waitForSelector(listSelector, {
    timeout: 5000
  });
  // 通过选择器找到对应列表项的标题和链接
  const res = await page.$$eval(listSelector, ele =>
    ele.map(el => ({
      url: el.href,
      text: el.innerText
    }))
  );
  // [ { url: 'https://juejin.im/post/5dd55512f265da47a807cc06',
  //   text: 'if 我是前端Leader,怎么走出小微前端团队的围墙?' },
  // { url: 'https://juejin.im/post/5dd49a45e51d45400206a655',
  //   text: 'Koa还是那个Koa,但是Nodejs已经不再是那个Nodejs' },
  // { url: 'https://juejin.im/post/5dd4b991e51d450818244c30',
  //   text: 'WebSocket 原理浅析与实现简单聊天' },...
};
task();

ok,我们已经成功拿到了掘金前端热门文章的内容,趁站长还没来,赶紧溜~其他网站也是一样的方法,这里就不啰嗦了~

我们拿到了资讯,接下来对它进行保存。

保存资讯

因为只是玩具级别的 demo,这里就不用数据库了,简单的用 json 进行保存。

// resource/index.json
{
  "data": []
}

我们基于 nodejs fs 文件操作模块,简单封装读写方法。

// tools/index.js
const fs = require("fs");
const fileServer = {
  // 写文件
  write(path, text) {
    fs.writeFileSync(path, text);
  },
  // 读文件
  read(path) {
    return fs.readFileSync(path);
  }
};

接下来,我们只要在每次获取完资讯,将内容写进文件就好了

const { fileServer } = require("./tools");
const path = require("path");
const task = () => {
  // 获取资讯任务
  const getMsgTask = Promise.all(tasks());
  getMsgTask.then(res => {
    // 读取json
    const { data } = JSON.parse(
      fileServer.read(path.join(resourcePath, "./index.json")).toString()
    );
    // ... 此处省略对资讯 格式化内容
    const text = msgHandle(res);
    // 写入资讯
    fileServer.write(
      path.join(resourcePath, "./index.json"),
      JSON.stringify({
        data: [
          {
            date: now,
            text
          },
          ...data
        ]
      })
    );
  });
};

保存完资讯,我们只要请求这个文件,将它渲染出来就好了~

// html/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>今日资讯</title>
    <script data-original="https://cdn.bootcss.com/marked/0.7.0/marked.min.js"></script>
    <script data-original="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
  </head>
  <body>
    <div id="content"></div>
  </body>
  <script>
    (function() {
        $.ajax({
          url: "http://localhost:8888/index.json",
          dataType: "json",
          success(data) {
            const content = data.data.reduce((a, b) => a + b.text, "");
            // 资讯我使用的是markdown进行保存,所以用marked进行转换
            $("#content").html(marked(content));
          }
        });
    })();
  </script>
</html>

定时任务

定时任务使用的是node-schedule,非常简单易用的一个 nodejs 库。

// 每日18时定时任务
function crontab() {
  schedule.scheduleJob(`00 00 18 * * *`, mainTask);
}
// 任务
function mainTask(){...}

部署

部署我采用的是 docker + github pages 。

docker 部署这里有两个要注意的地方

  1. 时区问题:docker 时区是 UTC,和北京时间差了 8 小时,会导致我们的定时任务时间失准.
  2. docker 和 puppeteer chorium 源问题 ...
# Dockerfile

FROM node:10-slim
# 创建项目代码的目录
RUN mkdir -p /workspace

# 指定RUN、CMD与ENTRYPOINT命令的工作目录
WORKDIR /workspace

# 复制宿主机当前路径下所有文件到docker的工作目录
COPY . /workspace
# 清除npm缓存文件
RUN npm cache clean --force && npm cache verify
# 如果设置为true,则当运行package scripts时禁止UID/GID互相切换
# RUN npm config set unsafe-perm true

RUN npm config set registry "https://registry.npm.taobao.org"

RUN npm install -g pm2@latest
# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer
# installs, work. 此处有墙...
# https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
  && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
  && apt-get update \
  && apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf \
  --no-install-recommends \
  && rm -rf /var/lib/apt/lists/*

# 只安装package.json dependencies
RUN npm install --production

RUN npm i puppeteer
# 设置时区
RUN rm -rf /etc/localtime && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

EXPOSE 8888

CMD [ "pm2-docker", "start", "pm2.json" ]

构建镜像 shell

# build.sh
docker build -t today-hot .

启动容器 shell

# run.sh
curPath=`cd $(dirname $0);pwd -P`
docker run --name todayHot -d -v $curPath:/workspace -p 8888:8888 today-hot

接下来只要把 html 文件部署到网站上即可,我们这里使用 github-pages ,免费的静态网站托管平台~

npm install gh-pages --save

在 package.json 定义 scripts

  "scripts": {
    "deploy": "gh-pages -d html"
  }

  npm run deploy 将前端资源推送到github上,然后通过 xxx.github.io/xxx  就可以访问了

结语

本文主要讲解的是思路,具体代码如下,爬虫 服务并没有部署到服务器,大家可以 download 代码自行尝试。

完整代码地址

效果

如果觉得有帮助到你,你懂的~

查看原文

赞 38 收藏 24 评论 0

jianglinqi 赞了文章 · 2019-11-13

TypeScript在node项目中的实践

TypeScript可以理解为是JavaScript的一个超集,也就是说涵盖了所有JavaScript的功能,并在之上有着自己独特的语法。
最近的一个新项目开始了TS的踩坑之旅,现分享一些可以借鉴的套路给大家。

为什么选择TS

作为巨硬公司出品的一个静态强类型编译型语言,该语言已经出现了几年的时间了,相信在社区的维护下,已经是一门很稳定的语言。
我们知道,JavaScript是一门动态弱类型解释型脚本语言,动态带来了很多的便利,我们可以在代码运行中随意的修改变量类型以达到预期目的。
但同时,这是一把双刃剑,当一个庞大的项目出现在你的面前,面对无比复杂的逻辑,你很难通过代码看出某个变量是什么类型,这个变量要做什么,很可能一不小心就会踩到坑。

而静态强类型编译能够带来很多的好处,其中最重要的一点就是可以帮助开发人员杜绝一些马虎大意的问题:
javascript-error-graph.png
图为rollbar统计的数千个项目中数量最多的前十个异常

不难看出,因为类型不匹配、变量为空导致的异常比你敢承认的次数要多。
譬如
sample-error-code.1ci4jfvvt2t4ojs.png
而这一点在TS中得到了很好的改善,任何一个变量的引用,都需要指定自己的类型,而你下边在代码中可以用什么,支持什么方法,都需要在上边进行定义:
typescript-example.1ciuh4eh1v1sum.png
这个提示会在开发、编译期来提示给开发者,避免了上线以后发现有问题,再去修改。

另外一个由静态编译类型带来的好处,就是函数签名。
还是就像上边所说的,因为是一个动态的脚本语言,所以很难有编辑器能够在开发期间正确地告诉你所要调用的一个函数需要传递什么参数,函数会返回什么类型的返回值。

js-function-call.1ci4kli9ou37kr.png

而在TS中,对于一个函数,首先你需要定义所有参数的类型,以及返回值的类型。
这样在函数被调用时,我们就可以很清晰的看到这个函数的效果:
ts-function-call.1ciuh6bsp2ujs1q.png

这是最基础的、能够让程序更加稳定的两个特性,当然,还有更多的功能在TS中的:TypeScript | Handbook

TypeScript在node中的应用

在TS的官网中,有着大量的示例,其中就找到了Express版本的例子,针对这个稍作修饰,应用在了一个 koa 项目中。

环境依赖

在使用TS之前,需要先准备这些东西:

  1. VS code,同为巨硬公司出品,本身就是TS开发的,遂该编辑器是目前对TS支持度最高的一个
  2. Node.js 推荐8.11版本以上
  3. npm i -g typescript,全局安装TS,编译所使用的tsc命令在这里
  4. npm i -g nodemon,全局安装nodemon,在tsc编译后自动刷新服务器程序

以及项目中使用的一些核心依赖:

  1. reflect-metadata: 大量装饰器的包都会依赖的一个基础包,用于注入数据
  2. routing-controllers: 使用装饰器的方式来进行koa-router的开发
  3. sequelize: 抽象化的数据库操作
  4. sequelize-typescript: 上述插件的装饰器版本,定义实体时使用

项目结构

首先,放出目前项目的结构:

.
├── README.md
├── copy-static-assets.ts
├── nodemon.json
├── package-lock.json
├── package.json
├── dist
├── src
│   ├── config
│   ├── controllers
│   ├── entity
│   ├── models
│   ├── middleware
│   ├── public
│   ├── app.ts
│   ├── server.ts
│   ├── types
│   └── utils
├── tsconfig.json
└── tslint.json

src为主要开发目录,所有的TS代码都在这里边,在经过编译过后,会生成一个与src同级的dist文件夹,这个文件夹是node引擎实际运行的代码。
src下,主要代码分为了如下结构(依据自己项目的实际情况进行增删):

|folder|desc

1controllers用于处理接口请求,原appsroutes文件夹。
2middleware存放了各种中间件、全局 or 自定义的中间件
3config各种配置项的位置,包括端口、log路径、各种巴拉巴拉的常量定义。
4entity这里存放的是所有的实体定义(使用了sequelize进行数据库操作)。
5models使用来自entity中的实体进行sequelize来完成初始化的操作,并将sequelize对象抛出。
6utils存放的各种日常开发中提炼出来的公共函数
7types存放了各种客制化的复合类型的定义,各种结构、属性、方法返回值的定义(目前包括常用的Promise版redis与qconf)

controllers

controllers只负责处理逻辑,通过操作model对象,而不是数据库来进行数据的增删改查

鉴于公司绝大部分的Node项目版本都已经升级到了Node 8.11,理所应当的,我们会尝试新的语法。
也就是说我们会抛弃Generator,拥抱async/await

使用KoaExpress写过接口的童鞋应该都知道,当一个项目变得庞大,实际上会产生很多重复的非逻辑代码:

router.get('/', ctx => {})
router.get('/page1', ctx => {})
router.get('/page2', ctx => {})
router.get('/page3', ctx => {})
router.get('/pageN', ctx => {})

而在每个路由监听中,又做着大量重复的工作:

router.get('/', ctx => {
  let uid = Number(ctx.cookies.get('uid'))
  let device = ctx.headers['device'] || 'ios'
  let { tel, name } = ctx.query
})

几乎每一个路由的头部都是在做着获取参数的工作,而参数很可能来自headerbody甚至是cookiequery

所以,我们对原来koa的使用方法进行了一个较大的改动,并使用routing-controllers大量的应用装饰器来帮助我们处理大部分的非逻辑代码。

原有router的定义:

module.exports = function (router) {
  router.get('/', function* (next) {
    let uid = Number(this.cookies.get('uid'))
    let device = this.headers['device']
    
    this.body = {
      code: 200
    }
  })
}

使用了TypeScript与装饰器的定义:

@Controller
export default class {
  @Get('/')
  async index (
    @CookieParam('uid') uid: number,
    @HeaderParam('device') device: string
  ) {
    return {
      code: 200
    }
  }
}

为了使接口更易于检索、更清晰,所以我们抛弃了原有的bd-router的功能(依据文件路径作为接口路径、TS中的文件路径仅用于文件分层)。
直接在controllers下的文件中声明对应的接口进行监听。

middleware

如果是全局的中间件,则直接在class上添加@Middleware装饰器,并设置type: 'after|before'即可。
如果是特定的一些中间件,则创建一个普通的class即可,然后在需要使用的controller对象上指定@UseBefore/@UseAfter(可以写在class上,也可以写在method上)。

所有的中间件都需要继承对应的MiddlewareInterface接口,并需要实现use方法

// middleware/xxx.ts
import {ExpressMiddlewareInterface} from "../../src/driver/express/ExpressMiddlewareInterface"

export class CompressionMiddleware implements KoaMiddlewareInterface {
  use(request: any, response: any, next?: Function): any {
    console.log("hello compression ...")
    next()
  }
}

// controllers/xxx.ts
@UseBefore(CompressionMiddleware)
export default class { }

entity

文件只负责定义数据模型,不做任何逻辑操作

同样的使用了sequelize+装饰器的方式,entity只是用来建立与数据库之间通讯的数据模型。

import { Model, Table, Column } from 'sequelize-typescript'

@Table({
  tableName: 'user_info_test'
})
export default class UserInfo extends Model<UserInfo> {
  @Column({
    comment: '自增ID',
    autoIncrement: true,
    primaryKey: true
  })
  uid: number

  @Column({
    comment: '姓名'
  })
  name: string

  @Column({
    comment: '年龄',
    defaultValue: 0
  })
  age: number

  @Column({
    comment: '性别'
  })
  gender: number
}

因为sequelize建立连接也是需要对应的数据库地址、账户、密码、database等信息、所以推荐将同一个数据库的所有实体放在一个目录下,方便sequelize加载对应的模型
同步的推荐在config下创建对应的配置信息,并添加一列用于存放实体的key。
这样在建立数据库链接,加载数据模型时就可以动态的导入该路径下的所有实体:

// config.ts
export const config = {
  // ...
  mysql1: {
    // ... config
+   entity: 'entity1' // 添加一列用来标识是什么实体的key
  },
  mysql2: {
    // ... config
+   entity: 'entity2' // 添加一列用来标识是什么实体的key
  }
  // ...
}

// utils/mysql.ts
new Sequelize({
  // ...
  modelPath: [path.reolve(__dirname, `../entity/${config.mysql1.entity}`)]
  // ...
})

model

model的定位在于根据对应的实体创建抽象化的数据库对象,因为使用了sequelize,所以该目录下的文件会变得非常简洁。
基本就是初始化sequelize对象,并在加载模型后将其抛出。

export default new Sequelize({
  host: '127.0.0.1',
  database: 'database',
  username: 'user',
  password: 'password',
  dialect: 'mysql', // 或者一些其他的数据库
  modelPaths: [path.resolve(__dirname, `../entity/${configs.mysql1.entity}`)], // 加载我们的实体
  pool: { // 连接池的一些相关配置
    max: 5,
    min: 0,
    acquire: 30000,
    idle: 10000
  },
  operatorsAliases: false,
  logging: true // true会在控制台打印每次sequelize操作时对应的SQL命令
})

utils

所有的公共函数,都放在这里。
同时推荐编写对应的索引文件(index.ts),大致的格式如下:

// utils/get-uid.ts
export default function (): number {
  return 123
}

// utils/number-comma.ts
export default function(): string {
  return '1,234'
}

// utils/index.ts
export {default as getUid} from './get-uid'
export {default as numberComma} from './number-comma'

每添加一个新的util,就去index中添加对应的索引,这样带来的好处就是可以通过一行来引入所有想引入的utils

import {getUid, numberComma} from './utils'

configs

configs下边存储的就是各种配置信息了,包括一些第三方接口URL、数据库配置、日志路径。
各种balabala的静态数据。
如果配置文件多的话,建议拆分为多个文件,然后按照utils的方式编写索引文件。

types

这里存放的是所有的自定义的类型定义,一些开源社区没有提供的,但是我们用到的第三方插件,需要在这里进行定义,一般来说常用的都会有,但是一些小众的包可能确实没有TS的支持,例如我们有使用的一个node-qconf

// types/node-qconf.d.ts
export function getConf(path: string): string | null
export function getBatchKeys(path: string): string[] | null
export function getBatchConf(path: string): string | null
export function getAllHost(path: string): string[] | null
export function getHost(path: string): string | null

类型定义的文件规定后缀为 .d.ts
types下边的所有文件可以直接引用,而不用关心相对路径的问题(其他普通的model则需要写相对路径,这是一个很尴尬的问题)。

目前使用TS中的一些问题

issues.1ci8qk2dr12l4sc.png
当前GitHub仓库中,有2600+的开启状态的issues,筛选bug标签后,依然有900+的存在。
所以很难保证在使用的过程中不会踩坑,但是一个项目拥有这么多活跃的issues,也能从侧面说明这个项目的受欢迎程度。

目前遇到的唯一一个比较尴尬的问题就是:
引用文件路径一定要写全。。

import module from '../../../../f**k-module'

小结

初次尝试TypeScript,深深的喜欢上了这个语言,虽说也会有一些小小的问题,但还是能克服的:)。
使用一门静态强类型编译语言,能够将很多bug都消灭在开发期间。

基于上述描述的一个简单示例:代码仓库

希望大家玩得开心,如有任何TS相关的问题,欢迎来骚扰。NPM loves U.

查看原文

赞 27 收藏 15 评论 0

jianglinqi 赞了文章 · 2019-09-02

如何写出一个惊艳面试官的深拷贝?

导读

最近经常看到很多JavaScript手写代码的文章总结,里面提供了很多JavaScript Api的手写实现。

里面的题目实现大多类似,而且说实话很多代码在我看来是非常简陋的,如果我作为面试官,看到这样的代码,在我心里是不会合格的,本篇文章我拿最简单的深拷贝来讲一讲。

看本文之前先问自己三个问题:

  • 你真的理解什么是深拷贝吗?
  • 在面试官眼里,什么样的深拷贝才算合格?
  • 什么样的深拷贝能让面试官感到惊艳?

本文由浅入深,带你一步一步实现一个惊艳面试官的深拷贝。

本文测试代码:https://github.com/ConardLi/C...

例如:代码clone到本地后,执行 node clone1.test.js查看测试结果。

建议结合测试代码一起阅读效果更佳。

深拷贝和浅拷贝的定义

深拷贝已经是一个老生常谈的话题了,也是现在前端面试的高频题目,但是令我吃惊的是有很多同学还没有搞懂深拷贝和浅拷贝的区别和定义。例如前几天给我提issue的同学:

很明显这位同学把拷贝和赋值搞混了,如果你还对赋值、对象在内存中的存储、变量和类型等等有什么疑问,可以看看我这篇文章:https://juejin.im/post/5cec1b...

你只要少搞明白拷贝赋值的区别。

我们来明确一下深拷贝和浅拷贝的定义:

浅拷贝:

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

深拷贝:

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象

话不多说,浅拷贝就不再多说,下面我们直入正题:

乞丐版

在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。

JSON.parse(JSON.stringify());

这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。

显然,面试时你只说出这样的方法是一定不会合格的。

接下来,我们一起来手动实现一个深拷贝方法。

基础版本

如果是浅拷贝的话,我们可以很容易写出下面的代码:

function clone(target) {
    let cloneTarget = {};
    for (const key in target) {
        cloneTarget[key] = target[key];
    }
    return cloneTarget;
};

创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性依次添加到新对象上,返回。

如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题,稍微改写上面的代码:

  • 如果是原始类型,无需继续拷贝,直接返回
  • 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后依次添加到新对象上。

很容易理解,如果有更深层次的对象可以继续递归直到属性为原始类型,这样我们就完成了一个最简单的深拷贝:

function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

我们可以打开测试代码中的clone1.test.js对下面的测试用例进行测试:

const target = {
    field1: 1,
    field2: undefined,
    field3: 'ConardLi',
    field4: {
        child: 'child',
        child2: {
            child2: 'child2'
        }
    }
};

执行结果:

这是一个最基础版本的深拷贝,这段代码可以让你向面试官展示你可以用递归解决问题,但是显然,他还有非常多的缺陷,比如,还没有考虑数组。

考虑数组

在上面的版本中,我们的初始化结果只考虑了普通的object,下面我们只需要把初始化代码稍微一变,就可以兼容数组了:

module.exports = function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

clone2.test.js中执行下面的测试用例:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};

执行结果:

OK,没有问题,你的代码又向合格迈进了一小步。

循环引用

我们执行下面这样一个测试用例:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

可以看到下面的结果:

很明显,因为递归进入死循环导致栈内存溢出了。

原因就是上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况:

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。

这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:

  • 检查map中有无克隆过的对象
  • 有 - 直接返回
  • 没有 - 将当前对象作为key,克隆对象作为value进行存储
  • 继续克隆
function clone(target, map = new Map()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

再来执行上面的测试用例:

可以看到,执行没有报错,且target属性,变为了一个Circular类型,即循环应用的意思。

接下来,我们可以使用,WeakMap提代Map来使代码达到画龙点睛的作用。

function clone(target, map = new WeakMap()) {
    // ...
};

为什么要这样做呢?,先来看看WeakMap的作用:

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

什么是弱引用呢?

在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。

我们默认创建一个对象:const obj = {},就默认创建了一个强引用的对象,我们只有手动将obj = null,它才会被垃圾回收机制进行回收,如果是弱引用对象,垃圾回收机制会自动帮我们回收。

举个例子:

如果我们使用Map的话,那么对象间是存在强引用关系的:

let obj = { name : 'ConardLi'}
const target = new Map();
target.set(obj,'code秘密花园');
obj = null;

虽然我们手动将obj,进行释放,然是target依然对obj存在强引用关系,所以这部分内存依然无法被释放。

再来看WeakMap

let obj = { name : 'ConardLi'}
const target = new WeakMap();
target.set(obj,'code秘密花园');
obj = null;

如果是WeakMap的话,targetobj存在的就是弱引用关系,当下一次垃圾回收机制执行时,这块内存就会被释放掉。

设想一下,如果我们要拷贝的对象非常庞大时,使用Map会对内存造成非常大的额外消耗,而且我们需要手动清除Map的属性才能释放这块内存,而WeakMap会帮我们巧妙化解这个问题。

我也经常在某些代码中看到有人使用WeakMap来解决循环引用问题,但是解释都是模棱两可的,当你不太了解WeakMap的真正作用时。我建议你也不要在面试中写这样的代码,结果只能是给自己挖坑,即使是准备面试,你写的每一行代码也都是需要经过深思熟虑并且非常明白的。

能考虑到循环引用的问题,你已经向面试官展示了你考虑问题的全面性,如果还能用WeakMap解决问题,并很明确的向面试官解释这样做的目的,那么你的代码在面试官眼里应该算是合格了。

性能优化

在上面的代码中,我们遍历数组和对象都使用了for in这种方式,实际上for in在遍历时效率是非常低的,我们来对比下常见的三种循环for、while、for in的执行效率:

可以看到,while的效率是最好的,所以,我们可以想办法把for in遍历改变为while遍历。

我们先使用while来实现一个通用的forEach遍历,iteratee是遍历的回掉函数,他可以接收每次遍历的valueindex两个参数:

function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}

下面对我们的cloen函数进行改写:当遍历数组时,直接使用forEach进行遍历,当遍历对象时,使用Object.keys取出所有的key进行遍历,然后在遍历时把forEach会调函数的value当作key使用:

function clone(target, map = new WeakMap()) {
    if (typeof target === 'object') {
        const isArray = Array.isArray(target);
        let cloneTarget = isArray ? [] : {};

        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);

        const keys = isArray ? undefined : Object.keys(target);
        forEach(keys || target, (value, key) => {
            if (keys) {
                key = value;
            }
            cloneTarget[key] = clone2(target[key], map);
        });

        return cloneTarget;
    } else {
        return target;
    }
}

下面,我们执行clone4.test.js分别对上一个克隆函数和改写后的克隆函数进行测试:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    f: { f: { f: { f: { f: { f: { f: { f: { f: { f: { f: { f: {} } } } } } } } } } } },
};

target.target = target;

console.time();
const result = clone1(target);
console.timeEnd();

console.time();
const result2 = clone2(target);
console.timeEnd();

执行结果:

很明显,我们的性能优化是有效的。

到这里,你已经向面试官展示了,在写代码的时候你会考虑程序的运行效率,并且你具有通用函数的抽象能力。

其他数据类型

在上面的代码中,我们其实只考虑了普通的objectarray两种数据类型,实际上所有的引用类型远远不止这两个,还有很多,下面我们先尝试获取对象准确的类型。

合理的判断引用类型

首先,判断是否为引用类型,我们还需要考虑functionnull两种特殊的数据类型:

function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}
    if (!isObject(target)) {
        return target;
    }
    // ...

获取数据类型

我们可以使用toString来获取准确的引用类型:

每一个引用类型都有toString方法,默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中type是对象的类型。

注意,上面提到了如果此方法在自定义对象中未被覆盖,toString才会达到预想的效果,事实上,大部分引用类型比如Array、Date、RegExp等都重写了toString方法。

我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果。

function getType(target) {
    return Object.prototype.toString.call(target);
}

下面我们抽离出一些常用的数据类型以便后面使用:

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const errorTag = '[object Error]';
const numberTag = '[object Number]';
const regexpTag = '[object RegExp]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';

在上面的集中类型中,我们简单将他们分为两类:

  • 可以继续遍历的类型
  • 不可以继续遍历的类型

我们分别为它们做不同的拷贝。

可继续遍历的类型

上面我们已经考虑的objectarray都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有MapSet等都是可以继续遍历的类型,这里我们只考虑这四种,如果你有兴趣可以继续探索其他类型。

有序这几种类型还需要继续进行递归,我们首先需要获取它们的初始化数据,例如上面的[]{},我们可以通过拿到constructor的方式来通用的获取。

例如:const target = {}就是const target = new Object()的语法糖。另外这种方法还有一个好处:因为我们还使用了原对象的构造方法,所以它可以保留对象原型上的数据,如果直接使用普通的{},那么原型必然是丢失了的。

function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}

下面,我们改写clone函数,对可继续遍历的数据类型进行处理:

function clone(target, map = new WeakMap()) {

    // 克隆原始类型
    if (!isObject(target)) {
        return target;
    }

    // 初始化
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target, type);
    }

    // 防止循环引用
    if (map.get(target)) {
        return map.get(target);
    }
    map.set(target, cloneTarget);

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value,map));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value,map));
        });
        return cloneTarget;
    }

    // 克隆对象和数组
    const keys = type === arrayTag ? undefined : Object.keys(target);
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value;
        }
        cloneTarget[key] = clone(target[key], map);
    });

    return cloneTarget;
}

我们执行clone5.test.js对下面的测试用例进行测试:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    empty: null,
    map,
    set,
};

执行结果:

没有问题,里大功告成又进一步,下面我们继续处理其他类型:

不可继续遍历的类型

其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理:

BoolNumberStringStringDateError这几种类型我们都可以直接用构造函数和原始数据创建一个新对象:

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        default:
            return null;
    }
}

克隆Symbol类型:

function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

克隆正则:

function cloneReg(targe) {
    const reFlags = /\w*$/;
    const result = new targe.constructor(targe.source, reFlags.exec(targe));
    result.lastIndex = targe.lastIndex;
    return result;
}

实际上还有很多数据类型我这里没有写到,有兴趣的话可以继续探索实现一下。

能写到这里,面试官已经看到了你考虑问题的严谨性,你对变量和类型的理解,对JS API的熟练程度,相信面试官已经开始对你刮目相看了。

克隆函数

最后,我把克隆函数单独拎出来了,实际上克隆函数是没有实际应用场景的,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的,我特意看了下lodash对函数的处理:

 const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
 }

可见这里如果发现是函数的话就会直接返回了,没有做特殊的处理,但是我发现不少面试官还是热衷于问这个问题的,而且据我了解能写出来的少之又少。。。

实际上这个方法并没有什么难度,主要就是考察你对基础的掌握扎实不扎实。

首先,我们可以通过prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的。

我们可以直接使用eval和函数字符串来重新生成一个箭头函数,注意这种方法是不适用于普通函数的。

我们可以使用正则来处理普通函数:

分别使用正则取出函数体和函数参数,然后使用new Function ([arg1[, arg2[, ...argN]],] functionBody)构造函数重新构造一个新的函数:

function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        console.log('普通函数');
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            console.log('匹配到函数体:', body[0]);
            if (param) {
                const paramArr = param[0].split(',');
                console.log('匹配到参数:', paramArr);
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}

最后,我们再来执行clone6.test.js对下面的测试用例进行测试:

const map = new Map();
map.set('key', 'value');
map.set('ConardLi', 'code秘密花园');

const set = new Set();
set.add('ConardLi');
set.add('code秘密花园');

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    empty: null,
    map,
    set,
    bool: new Boolean(true),
    num: new Number(2),
    str: new String(2),
    symbol: Object(Symbol(1)),
    date: new Date(),
    reg: /\d+/,
    error: new Error(),
    func1: () => {
        console.log('code秘密花园');
    },
    func2: function (a, b) {
        return a + b;
    }
};

执行结果:

最后

为了更好的阅读,我们用一张图来展示上面所有的代码:

完整代码:https://github.com/ConardLi/C...

可见,一个小小的深拷贝还是隐藏了很多的知识点的。

千万不要以最低的要求来要求自己,如果你只是为了应付面试中的一个题目,那么你可能只会去准备上面最简陋的深拷贝的方法。

但是面试官考察你的目的是全方位的考察你的思维能力,如果你写出上面的代码,可以体现你多方位的能力:

  • 基本实现

    • 递归能力
  • 循环引用

    • 考虑问题的全面性
    • 理解weakmap的真正意义
  • 多种类型

    • 考虑问题的严谨性
    • 创建各种引用类型的方法,JS API的熟练程度
    • 准确的判断数据类型,对数据类型的理解程度
  • 通用遍历:

    • 写代码可以考虑性能优化
    • 了解集中遍历的效率
    • 代码抽象能力
  • 拷贝函数:

    • 箭头函数和普通函数的区别
    • 正则表达式熟练程度

看吧,一个小小的深拷贝能考察你这么多的能力,如果面试官看到这样的代码,怎么能够不惊艳呢?

其实面试官出的所有题目你都可以用这样的思路去考虑。不要为了应付面试而去背一些代码,这样在有经验的面试官面前会都会暴露出来。你写的每一段代码都要经过深思熟虑,为什么要这样用,还能怎么优化...这样才能给面试官展现一个最好的你。

参考

小结

希望看完本篇文章能对你有如下帮助:

  • 理解深浅拷贝的真正意义
  • 能整我深拷贝的各个要点,对问题进行深入分析
  • 可以手写一个比较完整的深拷贝

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

想阅读更多优质文章、可关注我的github博客,你的star✨、点赞和关注是我持续创作的动力!

推荐关注我的微信公众号【code秘密花园】,每天推送高质量文章,我们一起交流成长。

图片描述

查看原文

赞 130 收藏 88 评论 26

jianglinqi 赞了文章 · 2019-09-02

前端20个真正灵魂拷问,吃透这些你就是中级前端工程师 【上篇】

clipboard.png

网上参差不弃的面试题,本文由浅入深,让你在做面试官的时候,能够辨别出面试者是不是真的有点东西,也能让你去面试中级前端工程师更有底气。但是切记把背诵面试题当成了你的唯一求职方向

另外欢迎大家加入我们的前端交流二群~,里面很多小姐姐哦,下篇将是非常硬核的源码,原理,自己编写框架和库等,如果感觉写得不错,可以关注给个star

clipboard.png

越是开放性的题目,更能体现回答者的水平,一场好的面试,不仅能发现面试者的不足,也能找到他的闪光点,还能提升面试官自身的技术

1.CssHtml合并在第一个题目,请简述你让一个元素在窗口中消失以及垂直水平居中的方法,还有Flex布局的理解

标准答案:百度上当然很多,这里不做阐述,好的回答思路是:

  • 元素消失的方案先列出来, display:nonevisibility: hidden;的区别,拓展到vue框架的v-ifv-show的区别,可以搭配回流和重绘来讲解

回流必将引起重绘,重绘不一定会引起回流

回流(Reflow):

Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流
  • 下面内容会导致回流:

    • 页面首次渲染
    • 浏览器窗口大小发生改变
    • 元素尺寸或位置发生改变
    • 元素内容变化(文字数量或图片大小等等)
    • 元素字体大小变化
    • 添加或者删除可见的DOM元素
    • 激活CSS伪类(例如::hover)
    • 查询某些属性或调用某些方法
  • 一些常用且会导致回流的属性和方法:

    • clientWidth、clientHeight、clientTop、clientLeft
    • offsetWidth、offsetHeight、offsetTop、offsetLeft
    • scrollWidth、scrollHeight、scrollTop、scrollLeft
    • scrollIntoView()、scrollIntoViewIfNeeded()
    • getComputedStyle()
    • getBoundingClientRect()
    • scrollTo()

重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

性能影响对比:

clipboard.png

原文出处,感谢作者

  • 列出元素垂直居中的方案,以及各种方案的缺陷

16种居中方案,感谢作者

  • 讲出flex常用的场景,以及flex 1做了什么

阮一峰老师的Flex布局

上面的问题如果答得非常好,在重绘和回流这块要下大功夫。这点是前端性能优化的基础,而性能优化是前端最重要的核心基础技能点,也是面试官最看中的基础之一

2.你对This了解吗,有自己实现过call,apply,bind吗?

50行javaScript代码实现call,apply,bind

这是一个很基础的技能点,考察你对闭包,函数调用的理解程度,我感觉我写得比较简单容易懂

3.如何减少重绘和回流的次数:

clipboard.png

4.你对前端的异步编程有哪些了解呢

这个题目如果回答非常完美,那么可以判断这个人已经脱离了初级前端工程师,前端的核心就是异步编程,这个题目也是体现前端工程师基础是否扎实的最重要依据。

还是老规矩,从易到难吧

传统的定时器,异步编程:

setTimeout(),setInterval()等。

缺点:当同步的代码比较多的时候,不确定异步定时器的任务时候能在指定的时间执行。

例如:

在第100行执行代码 setTimeout(()=>{console.log(1)},1000)//1s后执行里面函数

但是后面可能有10000行代码+很多计算的任务,例如循环遍历,那么1s后就无法输出console.log(1)

可能要到2s甚至更久

setInterval跟上面同理 当同步代码比较多时,不确保每次能在一样的间隔执行代码,

如果是动画,那么可能会掉帧

ES6的异步编程:

promise generator async
 new promise((resolve,reject)=>{ resolve() }).then()....
 缺点: 仍然没有摆脱回掉函数,虽然改善了回掉地狱
 
 generator函数 调用next()执行到下一个yeild的代码内容,如果传入参数则作为上一个

`yield`的
返回值
 缺点:不够自动化

 async await 
 只有async函数内部可以用await,将异步代码变成同步书写,但是由于async函数本身返回一个
promise,也很容易产生async嵌套地狱

requestAnimationFramerequestIdleCallback

传统的javascript 动画是通过定时器 setTimeout 或者 setInterval 实现的。但是定时器动画一直存在两个问题

第一个就是动画的循时间环间隔不好确定,设置长了动画显得不够平滑流畅,设置短了浏览器的重绘频率会达到瓶颈,推荐的最佳循环间隔是17ms(大多数电脑的显示器刷新频率是60Hz,1000ms/60);

第二个问题是定时器第二个时间参数只是指定了多久后将动画任务添加到浏览器的UI线程队列中,如果UI线程处于忙碌状态,那么动画不会立刻执行。为了解决这些问题,H5 中加入了 requestAnimationFrame以及requestIdleCallback

requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率

在隐藏或不可见的元素中,requestAnimationFrame 将不会进行重绘或回流,这当然就意味着更少的 CPU、GPU 和内存使用量

requestAnimationFrame 是由浏览器专门为动画提供的 API,在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了 CPU 开销

性能对比:

clipboard.png

requestAnimationFrame的回调会在每一帧确定执行,属于高优先级任务,而requestIdleCallback的回调则不一定,属于低优先级任务。

我们所看到的网页,都是浏览器一帧一帧绘制出来的,通常认为FPS为60的时候是比较流畅的,而FPS为个位数的时候就属于用户可以感知到的卡顿了,那么在一帧里面浏览器都要做哪些事情呢,如下所示:

clipboard.png

图中一帧包含了用户的交互、js的执行、以及requestAnimationFrame的调用,布局计算以及页面的重绘等工作。

假如某一帧里面要执行的任务不多,在不到16ms(1000/60)的时间内就完成了上述任务的话,那么这一帧就会有一定的空闲时间,这段时间就恰好可以用来执行requestIdleCallback的回调,如下图所示:

clipboard.png

5.简述浏览器的EventloopNode.jsEventloop

浏览器的EventLoop

clipboard.png

不想解释太多,看图

Node.jsEventLoop

clipboard.png

特别提示:网上大部分Node.jsEventLoop的面试题,都会有BUG,代码量和计算量太少,很可能还没有执行到微任务的代码,定时器就到时间被执行了

6.闭包与V8垃圾回收机制:

JS 的垃圾回收机制的基本原理是:

找出那些不再继续使用的变量,然后释放其占用的内存,垃圾收集器会按照固定的时间间隔周期性地执行这一操作。

V8 的垃圾回收策略主要基于分代式垃圾回收机制,在 V8 中,将内存分为新生代和老生代,新生代的对象为存活时间较短的对象,老生代的对象为存活事件较长或常驻内存的对象。

clipboard.png

V8 堆的整体大小等于新生代所用内存空间加上老生代的内存空间,而只能在启动时指定,意味着运行时无法自动扩充,如果超过了极限值,就会引起进程出错。

Scavenge 算法

在分代的基础上,新生代的对象主要通过 Scavenge 算法进行垃圾回收,在 Scavenge 具体实现中,主要采用了一种复制的方式的方法—— Cheney 算法。

Cheney 算法将堆内存一分为二,一个处于使用状态的空间叫 From 空间,一个处于闲置状态的空间称为 To 空间。分配对象时,先是在 From 空间中进行分配。

当开始进行垃圾回收时,会检查 From 空间中的存活对象,将其复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。

clipboard.png
当一个对象经过多次复制后依然存活,他将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用新的算法进行管理。

还有一种情况是,如果复制一个对象到 To 空间时,To 空间占用超过了 25%,则这个对象会被直接晋升到老生代空间中。

标记-清除和标记-整理算法

对于老生代中的对象,主要采用标记-清除和标记-整理算法。标记-清除 和前文提到的标记一样,与 Scavenge 算法相比,标记清除不会将内存空间划为两半,标记清除在标记阶段会标记活着的对象,而在内存回收阶段,它会清除没有被标记的对象。

而标记整理是为了解决标记清除后留下的内存碎片问题。

增量标记(Incremental Marking)算法

前面的三种算法,都需要将正在执行的 JavaScript 应用逻辑暂停下来,待垃圾回收完毕后再恢复。这种行为叫作“全停顿”(stop-the-world)。

在 V8 新生代的分代回收中,只收集新生代,而新生代通常配置较小,且存活对象较少,所以全停顿的影响不大,而老生代就相反了。

为了降低全部老生代全堆垃圾回收带来的停顿时间,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JS应用逻辑交替进行,直到标记阶段完成。

clipboard.png

经过增量标记改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右。

内存泄漏

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

内存泄漏的常见场景:

  • 缓存:存在内存中数据一只没有被清掉
  • 作用域未释放(闭包)
  • 无效的 DOM 引用
  • 没必要的全局变量
  • 定时器未清除(React中的合成事件,还有原生事件的绑定区别)
  • 事件监听为清空
  • 内存泄漏优化

7.你熟悉哪些通信协议,它们的优缺点?

通信协议全解

我的这篇文章非常详细介绍了 http1.0 http1.1 http2.0 https websocket等协议

8.从输入url地址栏,发生了什么?由此来介绍如何性能优化:

性能优化不完全手册

如何优化你的超大型React应用

我的这两篇文章基本上涵盖了前端基础的性能优化,后期我会再出专栏。

9.浏览器的缓存实现,请您介绍:

1.preload,prefetch,dns-prefetch

什么是preload

使用 preload 指令的好处包括:

允许浏览器来设定资源加载的优先级因此可以允许前端开发者来优化指定资源的加载。

赋予浏览器决定资源类型的能力,因此它能分辨这个资源在以后是否可以重复利用。

浏览器可以通过指定 as 属性来决定这个请求是否符合 content security policy。

浏览器可以基于资源的类型(比如 image/webp)来发送适当的 accept 头。

Prefetch

Prefetch 是一个低优先级的资源提示,允许浏览器在后台(空闲时)获取将来可能用得到的资源,并且将他们存储在浏览器的缓存中。一旦一个页面加载完毕就会开始下载其他的资源,然后当用户点击了一个带有 prefetched 的连接,它将可以立刻从缓存中加载内容。

DNS Prefetching

DNS prefetching 允许浏览器在用户浏览页面时在后台运行 DNS 的解析。如此一来,DNS 的解析在用户点击一个链接时已经完成,所以可以减少延迟。可以在一个 link 标签的属性中添加 rel="dns-prefetch' 来对指定的 URL 进行 DNS prefetching,我们建议对 Google fonts,Google Analytics CDN 进行处理。

2.servece-worker,PWA渐进式web应用

PWA文档

clipboard.png

3.localstorage,sessionstorage,cookie,session等。
浏览器的会话存储和持久性存储
4.浏览器缓存的实现机制的实现

clipboard.png

10.同源策略是什么,跨域解决办法,cookie可以跨域吗?

跨域解决的办法

Q:为什么会出现跨域问题?

A:出于浏览器的同源策略限制,浏览器会拒绝跨域请求。

  • 注:严格的说,浏览器并不是拒绝所有的跨域请求,实际上拒绝的是跨域的读操作。浏览器的同源限制策略是这样执行的:

通常浏览器允许进行跨域写操作(Cross-origin writes),如链接,重定向;

通常浏览器允许跨域资源嵌入(Cross-origin embedding),如 img、script 标签;

通常浏览器不允许跨域读操作(Cross-origin reads)。

Q:什么情况才算作跨域?

A:非同源请求,均为跨域。名词解释:同源 —— 如果两个页面拥有相同的协议(protocol),端口(port)和主机(host),那么这两个页面就属于同一个源(origin)。

Q:为什么有跨域需求?

A:场景 —— 工程服务化后,不同职责的服务分散在不同的工程中,往往这些工程的域名是不同的,但一个需求可能需要对应到多个服务,这时便需要调用不同服务的接口,因此会出现跨域。

方法:JSONP,CORS,postmessage,webscoket,反向代理服务器等。

上篇已经结束,欢迎你关注,等待下篇非常硬核的文章出炉~

期待你加入我们哦~

现在一群满了,所以新开了一个二群

clipboard.png

查看原文

赞 305 收藏 251 评论 8

jianglinqi 赞了文章 · 2019-08-31

发布-订阅模式和观察者模式真的不一样?

背景

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

设计模式并不能直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案,它不是一个死的机制,它是一种思想,一种代码的形式。

每种语言对于各种设计模式都要它们自己的实现方式,对于某些设计模式来说,可能在某些语言下并不适用,比如工厂模式就不适用于JavaSctipt。模式应该用在正确的地方,而所谓正确的地方只有我们深刻理解模式的意图后,再结合项目的实际场景才知道。

观察者模式 (Observer Pattern)

观察者模式定义了对象间的一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知,并自动更新。

观察者模式属于行为模式,行为模式关注的是对象之间的通讯,观察者模式就是观察者和被观察者之间的通讯。

观察者模式还有一个别名叫“发布-订阅模式”,又或者“订阅-发布模式”,订阅者和订阅目标是联系在一起的,当订阅目标发生改变时,逐各通知订阅者。
我们用报纸期刊的订阅来举例说明,当你订阅一份报纸,每天都会有一份最新的报纸送到你手上,有多少人订阅报纸,报社就会发多少份报纸,报社和订报纸的客户就是定义里描述的“一对多”的依赖关系。

发布-订阅模式 (Pub-Sub Pattern)

其实24种基本设计模式中,并没有发布-订阅模式,上面也解释了,它只是观察者模式的一个别称。但经过时间的沉淀,它已经强大起来,已经独立于观察者模式,成为一种新的设计模式。

在现在的发布-订阅模式中,发布者的消息不会直接发送给订阅者,这意味着发布者和订阅者都不知道彼此的存在。在发布者和订阅者之间存在第三个组件,称为消息代理或调度中心或中间件,它维持着发布者和订阅者之间的联系,过滤所有发布者传入的消息,并相应的分发给它们的订阅者。
举个例子,你在微博上关注了A,同时其他很多人也关注了A,当A发布动态时,微博就会为你们推送这条动态。A就是发布者,你就是订阅者,微博是调度中心,你和A是没有直接的消息往来的,全是通过微博来协调的。

观察者模式和发布-订阅模式有什么区别?

我们先来看下者两个模式的实现结构:
观察者模式和发布-订阅模式的实现结构

观察者模式: 观察者(Observer)直接订阅(Subscribe)主体(Subject),而当主体被激活时,会触发(Fire Event)观察者里的事件。

发布-订阅模式: 订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)到调度中心(Topic),当发布者(Publisher)发布该事件(Publish topic)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。

案例

观察者模式

// 有一家猎人公会,其中每个猎人都具体发布任务(publish),订阅任务(subscribe)的功能
// 它们都有一个订阅列表记录谁订阅了自己

// 定义一个猎人,包括姓名、级别、订阅列表
function Hunter(name, level) {
  this,name = name
  this.level = level
  this.list = []
}
Hunter.prototype.publish = function (money) {
  console.log(this,level + '猎人: ' + this.name + '寻求帮助')
  this.list.forEach(function (callback) {
    callback && callback(money)
  })
}
Hunter.prototype.subscribe = function (target, callback) {
  console.log(this.level + '猎人: ' + this.name + '订阅了: ' + target.name)
  target.list.push(callback)
}

// 猎人公会走注册了几个猎人
var hunterZhang = new Hunter('张三', '钻石')
var hunterLi = new Hunter('李四', '黄金')
var hunterWang = new Hunter('王五', '白银')
var hunterZhao = new Hunter('赵六', '青铜')

// 赵六等级较低,可能需要帮助,所以张三、李四、王五都订阅了赵六
hunterZhang.subscribe(hunterZhao, function (money) {
  console.log('小明表示: ' + (money > 200 ? '' : '暂时很忙,不能') + '给予帮助')
})
hunterLi.subscribe(hunterZhao, function () {
  console.log('李四表示: 给予帮助')
})
hunterWang.subscribe(hunterZhao, function () {
  console.log('王五表示: 给予帮助')
})

// 赵六遇到困难,悬赏198寻求帮助
hunterZhao.publish(198)

// 猎人们(观察者)关联他们感兴趣的猎人(目标对象),如赵六,当赵六有困难时,会自动通知给他们(观察者)

发布-订阅模式

// 定义了一家猎人公会
// 主要功能包含任务发布大厅(topics)、订阅任务(subscribe)、发布任务(publish)
var HunterUnion = {
  type: 'hunt',
  topics: Object.create(null),
  subscribe: function (topic, callback) {
    if (!this.topics[topic]) {
      this.topics[topic] = []
    }
    this.topics[topic].push(callback)
  },
  publish: function (topic, money) {
    if (!this.topics[topic]) {
      return
    }
    for(var cb of this.topics[topic]) {
      cb(money)
    }
  }
}

// 定义一个猎人类,包括姓名和级别
function Hunter(name, level) {
  this.name = name
  this.level = level
}
// 猎人可以在猎人公会发布、订阅任务
Hunter.prototype.subscribe = function (task, fn) {
  console.log(this.level + '猎人: ' + this.name + '订阅了狩猎: ' + task + '的任务')
  HunterUnion.subscribe(task, fn)
}
Hunter.prototype.publish = function (task, money) {
  console.log(this.level + '猎人: ' + this.name + '发布了狩猎: ' + task + '的任务')
  HunterUnion.publish(task, money)
}

//猎人工会注册了几个猎人
let hunterZhang = new Hunter('张三', '钻石')
let hunterLi = new Hunter('李四', '黄金')
let hunterWang = new Hunter('王五', '白银')
let hunterZhao = new Hunter('赵六', '青铜')

//张三,李四,王五分别订阅了狩猎tiger的任务
hunterZhang.subscribe('tiger', function(money){
  console.log('张三表示:' + (money > 200 ? '' : '不') + '接取任务')
})
hunterLi.subscribe('tiger', function(money){
  console.log('李四表示:接取任务')
})
hunterWang.subscribe('tiger', function(money){
  console.log('王五表示:接取任务')
})
//赵六订阅了狩猎sheep的任务
hunterZhao.subscribe('sheep', function(money){
  console.log('赵六表示:接取任务')
})

//赵六发布了狩猎tiger的任务
hunterZhao.publish('tiger', 198)

//猎人们发布(发布者)或订阅(观察者/订阅者)任务都是通过猎人工会(调度中心)关联起来的,他们没有直接的交流。

观察者模式和发布-订阅模式最大的区别: 发布-订阅模式有事件调度中心

观察者模式由具体目标调度,每个被订阅的目标里面都需要有对观察者的处理,这种处理方式可能会造成代码的冗余。

发布-订阅模式中,统一由调度中进行处理,订阅者和发布者互不干扰,消除了发布者和订阅者之间的依赖。这样一方面实现了解耦,另一方面可以实现更加细粒度的控制。比如发布者发布了很多消息,但不是所有的订阅者都希望接收到,就可以在调度中心做一些处理,类似权限控制之类的。还可以做一些节流操作。

观察者模式是不是发布-订阅模式?

《JavaScript设计模式与开发实践》一书中说到分辨模式的关键是意图而不是结构

如果以结构来分辨模式,发布-订阅模式比观察者模式多了一个调度中心,所以发布-订阅模式不同于观察者模式。

如果以意图来分辨模式,它们都实现了对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知,并自动更新,那么它们就是同一种模式,发布-订阅模式是在观察者模式的基础上做了优化升级。

不过不管它们是不是同一个设计模式,它们的实现方式的确是有区别,我们在使用时应该根据应用场景来判断选择哪个。

查看原文

赞 23 收藏 13 评论 0

jianglinqi 赞了文章 · 2019-08-31

axios如何利用promise无痛刷新token

需求

最近遇到个需求:前端登录后,后端返回tokentoken有效时间,当token过期时要求用旧token去获取新的token,前端需要做到无痛刷新token,即请求刷新token时要做到用户无感知。

需求解析

当用户发起一个请求时,判断token是否已过期,若已过期则先调refreshToken接口,拿到新的token后再继续执行之前的请求。

这个问题的难点在于:当同时发起多个请求,而刷新token的接口还没返回,此时其他请求该如何处理?接下来会循序渐进地分享一下整个过程。

实现思路

由于后端返回了token的有效时间,可以有两种方法:

方法一:

在请求发起前拦截每个请求,判断token的有效时间是否已经过期,若已过期,则将请求挂起,先刷新token后再继续请求。

方法二:

不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过期后,先刷新token,再进行一次重试。

两种方法对比

方法一

  • 优点: 在请求前拦截,能节省请求,省流量。
  • 缺点: 需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。
PS:token有效时间建议是时间段,类似缓存的MaxAge,而不要是绝对时间。当服务器和本地时间不一致时,绝对时间会有问题。

方法二

  • 优点:不需额外的token过期字段,不需判断时间。
  • 缺点: 会消耗多一次请求,耗流量。

综上,方法一和二优缺点是互补的,方法一有校验失败的风险(本地时间被篡改时,当然一般没有用户闲的蛋疼去改本地时间的啦),方法二更简单粗暴,等知道服务器已经过期了再重试一次,只是会耗多一个请求。

在这里博主选择了 方法二

实现

这里会使用axios来实现,方法一是请求前拦截,所以会使用axios.interceptors.request.use()这个方法;

而方法二是请求后拦截,所以会使用axios.interceptors.response.use()方法。

封装axios基本骨架

首先说明一下,项目中的token是存在localStorage中的。request.js基本骨架:

import axios from 'axios'

// 从localStorage中获取token
function getLocalToken () {
    const token = window.localStorage.getItem('token')
    return token
}


// 给实例添加一个setToken方法,用于登录后将最新token动态添加到header,同时将token保存在localStorage中
instance.setToken = (token) => {
  instance.defaults.headers['X-Token'] = token
  window.localStorage.setItem('token', token)
}

// 创建一个axios实例
const instance = axios.create({
  baseURL: '/api',
  timeout: 300000,
  headers: {
    'Content-Type': 'application/json',
    'X-Token': getLocalToken() // headers塞token
  }
})

// 拦截返回的数据
instance.interceptors.response.use(response => {
  // 接下来会在这里进行token过期的逻辑处理
  return response
}, error => {
  return Promise.reject(error)
})

export default instance

这个是项目中一般的axios实例的封装,创建实例时,将本地已有的token放进header,然后export出去供调用。接下来就是如何拦截返回的数据啦。

instance.interceptors.response.use拦截实现

后端接口一般会有一个约定好的数据结构,如:

{code: 1234, message: 'token过期', data: {}}

如我这里,后端约定当code === 1234时表示token过期了,此时就要求刷新token。

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    // 说明token过期了,刷新token
    return refreshToken().then(res => {
      // 刷新token成功,将最新的token更新到header中,同时保存在localStorage中
      const { token } = res.data
      instance.setToken(token)
      // 获取当前失败的请求
      const config = response.config
      // 重置一下配置
      config.headers['X-Token'] = token
      config.baseURL = '' // url已经带上了/api,避免出现/api/api的情况
      // 重试当前请求并返回promise
      return instance(config)
    }).catch(res => {
      console.error('refreshtoken error =>', res)
      //刷新token失败,神仙也救不了了,跳转到首页重新登录吧
      window.location.href = '/'
    })
  }
  return response
}, error => {
  return Promise.reject(error)
})

function refreshToken () {
    // instance是当前request.js中已创建的axios实例
    return instance.post('/refreshtoken').then(res => res.data)
}

这里需要额外注意的是,response.config就是原请求的配置,但这个是已经处理过了的,config.url已经带上了baseUrl,因此重试时需要去掉,同时token也是旧的,需要刷新下。

以上就基本做到了无痛刷新token,当token正常时,正常返回,当token已过期,则axios内部进行一次刷新token和重试。对调用者来说,axios内部的刷新token是一个黑盒,是无感知的,因此需求已经做到了。

问题和优化

上面的代码还是存在一些问题的,没有考虑到多次请求的问题,因此需要进一步优化。

如何防止多次刷新token

如果refreshToken接口还没返回,此时再有一个过期的请求进来,上面的代码就会再一次执行refreshToken,这就会导致多次执行刷新token的接口,因此需要防止这个问题。我们可以在request.js中用一个flag来标记当前是否正在刷新token的状态,如果正在刷新则不再调用刷新token的接口。

// 是否正在刷新的标记
let isRefreshing = false
instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        const config = response.config
        config.headers['X-Token'] = token
        config.baseURL = ''
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})

这样子就可以避免在刷新token时再进入方法了。但是这种做法是相当于把其他失败的接口给舍弃了,假如同时发起两个请求,且几乎同时返回,第一个请求肯定是进入了refreshToken后再重试,而第二个请求则被丢弃了,仍是返回失败,所以接下来还得解决其他接口的重试问题。

同时发起两个或以上的请求时,其他接口如何重试

两个接口几乎同时发起和返回,第一个接口会进入刷新token后重试的流程,而第二个接口需要先存起来,然后等刷新token后再重试。同样,如果同时发起三个请求,此时需要缓存后两个接口,等刷新token后再重试。由于接口都是异步的,处理起来会有点麻烦。

当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。
那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。最终代码:

// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    const config = response.config
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        config.headers['X-Token'] = token
        config.baseURL = ''
        // 已经刷新了token,将所有队列中的请求进行重试
        requests.forEach(cb => cb(token))
        // 重试完了别忘了清空这个队列(掘金评论区同学指点)
        requests = []
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    } else {
      // 正在刷新token,返回一个未执行resolve的promise
      return new Promise((resolve) => {
        // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
        requests.push((token) => {
          config.baseURL = ''
          config.headers['X-Token'] = token
          resolve(instance(config))
        })
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})

这里可能比较难理解的是requests这个队列中保存的是一个函数,这是为了让resolve不执行,先存起来,等刷新token后更方便调用这个函数使得resolve执行。至此,问题应该都解决了。

最后完整代码

import axios from 'axios'

// 从localStorage中获取token
function getLocalToken () {
    const token = window.localStorage.getItem('token')
    return token
}

// 给实例添加一个setToken方法,用于登录后将最新token动态添加到header,同时将token保存在localStorage中
instance.setToken = (token) => {
  instance.defaults.headers['X-Token'] = token
  window.localStorage.setItem('token', token)
}

function refreshToken () {
    // instance是当前request.js中已创建的axios实例
    return instance.post('/refreshtoken').then(res => res.data)
}

// 创建一个axios实例
const instance = axios.create({
  baseURL: '/api',
  timeout: 300000,
  headers: {
    'Content-Type': 'application/json',
    'X-Token': getLocalToken() // headers塞token
  }
})

// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    const config = response.config
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        config.headers['X-Token'] = token
        config.baseURL = ''
        // 已经刷新了token,将所有队列中的请求进行重试
        requests.forEach(cb => cb(token))
        requests = []
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    } else {
      // 正在刷新token,将返回一个未执行resolve的promise
      return new Promise((resolve) => {
        // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
        requests.push((token) => {
          config.baseURL = ''
          config.headers['X-Token'] = token
          resolve(instance(config))
        })
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})

export default instance

希望对大家有帮助。感谢看到最后,感谢点赞^_^。

后续更新

针对方法一的实现,请大家阅读: axios如何利用promise无痛刷新token(二)

查看原文

赞 171 收藏 120 评论 50

jianglinqi 赞了文章 · 2019-08-31

Array.from() 五个超好用的用途

翻译:刘小夕

原文链接:https://dmitripavlutin.com/ja...

因水平有限,文中部分翻译可能不够准确,如果你有更好的想法,欢迎在评论区指出。

更多文章可戳:https://github.com/YvetteLau/...

任何一种编程语言都具有超出基本用法的功能,它得益于成功的设计和试图去解决广泛问题。

JavaScript 中有一个这样的函数: Array.from:允许在 JavaScript 集合(如: 数组、类数组对象、或者是字符串、mapset 等可迭代对象) 上进行有用的转换。

在本文中,我将描述5个有用且有趣的 Array.from() 用例。

1. 介绍

在开始之前,我们先回想一下 Array.from() 的作用。语法:

Array.from(arrayLike[, mapFunction[, thisArg]])
  • arrayLike:必传参数,想要转换成数组的伪数组对象或可迭代对象。
  • mapFunction:可选参数,mapFunction(item,index){...} 是在集合中的每个项目上调用的函数。返回的值将插入到新集合中。
  • thisArg:可选参数,执行回调函数 mapFunction 时 this 对象。这个参数很少使用。

例如,让我们将类数组的每一项乘以2:

const someNumbers = { '0': 10, '1': 15, length: 2 };

Array.from(someNumbers, value => value * 2); // => [20, 30]

2.将类数组转换成数组

Array.from() 第一个用途:将类数组对象转换成数组。

通常,你会碰到的类数组对象有:函数中的 arguments 关键字,或者是一个 DOM 集合。

在下面的示例中,让我们对函数的参数求和:

function sumArguments() {
    return Array.from(arguments).reduce((sum, num) => sum + num);
}

sumArguments(1, 2, 3); // => 6

Array.from(arguments) 将类数组对象 arguments 转换成一个数组,然后使用数组的 reduce 方法求和。

此外,Array.from() 的第一个参数可以是任意一个可迭代对象,我们继续看一些例子:

Array.from('Hey');                   // => ['H', 'e', 'y']
Array.from(new Set(['one', 'two'])); // => ['one', 'two']

const map = new Map();
map.set('one', 1)
map.set('two', 2);
Array.from(map); // => [['one', 1], ['two', 2]]

3.克隆一个数组

JavaScript 中有很多克隆数组的方法。正如你所想,Array.from() 可以很容易的实现数组的浅拷贝。

const numbers = [3, 6, 9];
const numbersCopy = Array.from(numbers);

numbers === numbersCopy; // => false

Array.from(numbers) 创建了对 numbers 数组的浅拷贝,numbers === numbersCopy 的结果是 false,意味着虽然 numbersnumbersCopy 有着相同的项,但是它们是不同的数组对象。

是否可以使用 Array.from() 创建数组的克隆,包括所有嵌套的?挑战一下!

function recursiveClone(val) {
    return Array.isArray(val) ? Array.from(val, recursiveClone) : val;
}

const numbers = [[0, 1, 2], ['one', 'two', 'three']];
const numbersClone = recursiveClone(numbers);

numbersClone; // => [[0, 1, 2], ['one', 'two', 'three']]
numbers[0] === numbersClone[0] // => false

recursiveClone() 能够对数组的深拷贝,通过判断 数组的 item 是否是一个数组,如果是数组,就继续调用 recursiveClone() 来实现了对数组的深拷贝。

你能编写一个比使用 Array.from() 递归拷贝更简短的数组深拷贝吗?如果可以的话,请写在下面的评论区。

4. 使用值填充数组

如果你需要使用相同的值来初始化数组,那么 Array.from() 将是不错的选择。

我们来定义一个函数,创建一个填充相同默认值的数组:

const length = 3;
const init   = 0;
const result = Array.from({ length }, () => init);

result; // => [0, 0, 0]

result 是一个新的数组,它的长度为3,数组的每一项都是0。调用 Array.from() 方法,传入一个类数组对象 { length } 和 返回初始化值的 mapFunction 函数。

但是,有一个替代方法 array.fill() 可以实现同样的功能。

const length = 3;
const init   = 0;
const result = Array(length).fill(init);

fillArray2(0, 3); // => [0, 0, 0]

fill() 使用初始值正确填充数组。

4.1 使用对象填充数组

当初始化数组的每个项都应该是一个新对象时,Array.from() 是一个更好的解决方案:

const length = 3;
const resultA = Array.from({ length }, () => ({}));
const resultB = Array(length).fill({});

resultA; // => [{}, {}, {}]
resultB; // => [{}, {}, {}]

resultA[0] === resultA[1]; // => false
resultB[0] === resultB[1]; // => true

Array.from 返回的 resultA 使用不同空对象实例进行初始化。之所以发生这种情况是因为每次调用时,mapFunction,即此处的 () => ({}) 都会返回一个新的对象。

然后,fill() 方法创建的 resultB 使用相同的空对象实例进行初始化。不会跳过空项。

4.2 使用 array.map 怎么样?

是不是可以使用 array.map() 方法来实现?我们来试一下:

const length = 3;
const init   = 0;
const result = Array(length).map(() => init);

result; // => [undefined, undefined, undefined]

map() 方法似乎不正常,创建出来的数组不是预期的 [0, 0, 0],而是一个有3个空项的数组。

这是因为 Array(length) 创建了一个有3个空项的数组(也称为稀疏数组),但是 map() 方法会跳过空项。

5. 生成数字范围

你可以使用 Array.from() 生成值范围。例如,下面的 range 函数生成一个数组,从0开始到 end - 1

function range(end) {
    return Array.from({ length: end }, (_, index) => index);
}

range(4); // => [0, 1, 2, 3]

range() 函数中,Array.from() 提供了类似数组的 {length:end} ,以及一个简单地返回当前索引的 map 函数 。这样你就可以生成值范围。

6.数组去重

由于 Array.from() 的入参是可迭代对象,因而我们可以利用其与 Set 结合来实现快速从数组中删除重复项。

function unique(array) {
  return Array.from(new Set(array));
}

unique([1, 1, 2, 3, 3]); // => [1, 2, 3]

首先,new Set(array) 创建了一个包含数组的集合,Set 集合会删除重复项。

因为 Set 集合是可迭代的,所以可以使用 Array.from() 将其转换为一个新的数组。

这样,我们就实现了数组去重。

7.结论

Array.from() 方法接受类数组对象以及可迭代对象,它可以接受一个 map 函数,并且,这个 map 函数不会跳过值为 undefined 的数值项。这些特性给 Array.from() 提供了很多可能。

如上所述,你可以轻松的将类数组对象转换为数组,克隆一个数组,使用初始化填充数组,生成一个范围,实现数组去重。

实际上,Array.from() 是非常好的设计,灵活的配置,允许很多集合转换。

你知道 Array.from() 的其他有趣用例吗?可以写在评论区。

写在最后

翻译完又是凌晨一点,果然,没有一个成年人的生活是容易的。

谢谢各位小伙伴愿意花费宝贵的时间阅读本文,如果本文给了您一点帮助或者是启发,请不要吝啬你的赞和Star,你的肯定是我前进的最大动力。https://github.com/YvetteLau/...

推荐关注本人公众号:

clipboard.png

查看原文

赞 59 收藏 44 评论 12

jianglinqi 赞了文章 · 2019-08-31

稍微学一下 Vuex 原理

博客原文

介绍

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式
这种集中管理应用状态的模式相比父子组件通信来说,使数据的通信更方便,状态的更改也更加直观。

Bus

肯定有不少同学在写 Vue 时使用过 new Vue() 创建 bus 进行数据通信。

import Vue from 'vue';
const bus = new Vue();
export default {
  install(Vue) {
    Object.defineProperty(Vue.prototype, '$bus', {
      get () { return bus }
    });
  }
};

组件中使用 this.$bus.$onthis.$bus.$emit 监听和触发 bus 事件进行通信。
bus 的通信是不依赖组件的父子关系的,因此实际上可以理解为最简单的一种状态管理模式。
通过 new Vue() 可以注册响应式的数据,
下面基于此对 bus 进行改造,实现一个最基本的状态管理:

// /src/vuex/bus.js
let Vue
// 导出一个 Store 类,一个 install 方法
class Store {
  constructor (options) {
    // 将 options.state 注册为响应式数据
    this._bus = new Vue({
      data: {
        state: options.state
      }
    })
  }
  // 定义 state 属性
  get state() {
    return this._bus._data.state;
  }
}
function install (_Vue) {
  Vue = _Vue
  // 全局混入 beforeCreate 钩子
  Vue.mixin({
    beforeCreate () {
      // 存在 $options.store 则为根组件
      if (this.$options.store) {
        // $options.store 就是创建根组件时传入的 store 实例,直接挂在 vue 原型对象上
        Vue.prototype.$store = this.$options.store
      }
    }
  })
}
export default {
  Store,
  install
}

创建并导出 store 实例:

// /src/store.js
import Vue from 'vue'
import Vuex from './vuex/bus'
Vue.use(Vuex) // 调用 Vuex.install 方法
export default new Vuex.Store({
  state: {
    count: 0
  }
})

创建根组件并传入 store 实例:

// /src/main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store'
new Vue({
  store,
  render: h => h(App)
}).$mount('#app')

组件中使用示例:

<!-- /src/App.vue -->
<template>
  <div id="app">
    {{ count }}
    <button @click="changeCount">+1</button>
  </div>
</template>
<script>
export default {
  name: 'app',
  computed: {
    count() {
      return this.$store.state.count;
    }
  },
  methods: {
    changeCount() {
      this.$store.state.count++
    }
  }
}
</script>

从零实现一个 Vuex

前一节通过 new Vue() 定义一个响应式属性并通过 minxin 为所有组件混入 beforeCreate 生命周期钩子函数的方法为每个组件内添加 $store 属性指向根组件的 store 实例的方式,实现了最基本的状态管理。
继续这个思路,下面从零一步步实现一个最基本的 Vuex。

以下代码的 git 地址:simple-vuex

整体结构

let Vue;
class Store {}
function install() {}
export default {
  Store,
  install
}

install 函数

// 执行 Vue.use(Vuex) 时调用 并传入 Vue 类
// 作用是为所有 vue 组件内部添加 `$store` 属性
function install(_Vue) {
  // 避免重复安装
  if (Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error('[vuex] already installed. Vue.use(Vuex) should be called only once.');
    }
    return
  }
  Vue = _Vue; // 暂存 Vue 用于其他地方有用到 Vue 上的方法
  Vue.mixin({
    // 全局所有组件混入 beforeCreate 钩子,给每个组件中添加 $store 属性指向 store 实例
    beforeCreate: function vuexInit() {
      const options = this.$options;
      if (options.store) {
        // 接收参数有=中有 store 属性则为根组件
        this.$store = options.store;
      } else if (options.parent && options.parent.$store) {
        // 非根组件通过 parent 父组件获取
        this.$store = options.parent.$store;
      }
    }
  })
}

Store 类

// 执行 new Vuex.Store({}) 时调用
class Store {
  constructor(options = {}) {
    // 初始化 getters mutations actions
    this.getters = {};
    this._mutations = {};
    this._actions = {};
    // 给每个 module 注册 _children 属性指向子 module
    // 用于后面 installModule 中根据 _children 属性查找子 module 进行递归处理
    this._modules = new ModuleCollection(options)
    const { dispatch, commit } = this;
    // 固定 commit dispatch 的 this 指向 Store 实例
    this.commit = (type, payload) => {
      return commit.call(this, type, payload);
    }
    this.dispatch = (type, payload) => {
      return dispatch.call(this, type, payload);
    }
    // 通过 new Vue 定义响应式 state
    const state = options.state;
    this._vm = new Vue({
      data: {
        state: state
      }
    });
    // 注册 getters  mutations actions
    // 并根据 _children 属性对子 module 递归执行 installModule
    installModule(this, state, [], this._modules.root);
  }
  // 定义 state commit dispatch
  get state() {
    return this._vm._data.state;
  }
  set state(v){
    throw new Error('[Vuex] vuex root state is read only.')
  }
  commit(type, payload) {
    return this._mutations[type].forEach(handler => handler(payload));
  }
  dispatch(type, payload) {
    return this._actions[type].forEach(handler => handler(payload));
  }
}

ModuleCollection 类

Store 类的构造函数中初始化 _modules 时是通过调用 ModuleCollection 这个类,内部从根模块开始递归遍历 modules 属性,初始化模块的 _children 属性指向子模块。

class ModuleCollection {
  constructor(rawRootModule) {
    this.register([], rawRootModule)
  }
  // 递归注册,path 是记录 module 的数组 初始为 []
  register(path, rawModule) {
    const newModule = {
      _children: {},
      _rawModule: rawModule,
      state: rawModule.state
    }
    if (path.length === 0) {
      this.root = newModule;
    } else {
      // 非最外层路由通过 reduce 从 this.root 开始遍历找到父级路由
      const parent = path.slice(0, -1).reduce((module, key) => {
        return module._children[key];
      }, this.root);
      // 给父级路由添加 _children 属性指向该路由
      parent._children[path[path.length - 1]] = newModule;
      // 父级路由 state 中也添加该路由的 state
      Vue.set(parent.state, path[path.length - 1], newModule.state);
    }
    // 如果当前 module 还有 module 属性则遍历该属性并拼接 path 进行递归
    if (rawModule.modules) {
      forEachValue(rawModule.modules, (rawChildModule, key) => {
        this.register(path.concat(key), rawChildModule);
      })
    }
  }
}

installModule

Store 类的构造函数中调用 installModule ,通过 _modules 的 _children 属性遍历到每个模块并注册 getters mutations actions

function installModule(store, rootState, path, module) {
  if (path.length > 0) {
    const parentState = rootState;
    const moduleName = path[path.length - 1];
    // 所有子模块都将 state 添加到根模块的 state 上
    Vue.set(parentState, moduleName, module.state)
  }
  const context = {
    dispatch: store.dispatch,
    commit: store.commit,
  }
  // 注册 getters mutations actions
  const local = Object.defineProperties(context, {
    getters: {
      get: () => store.getters
    },
    state: {
      get: () => {
        let state = store.state;
        return path.length ? path.reduce((state, key) => state[key], state) : state
      }
    }
  })
  if (module._rawModule.actions) {
    forEachValue(module._rawModule.actions, (actionFn, actionName) => {
      registerAction(store, actionName, actionFn, local);
    });
  }
  if (module._rawModule.getters) {
    forEachValue(module._rawModule.getters, (getterFn, getterName) => {
      registerGetter(store, getterName, getterFn, local);
    });
  }
  if (module._rawModule.mutations) {
    forEachValue(module._rawModule.mutations, (mutationFn, mutationName) => {
      registerMutation(store, mutationName, mutationFn, local)
    });
  }
  // 根据 _children 拼接 path 并递归遍历
  forEachValue(module._children, (child, key) => {
    installModule(store, rootState, path.concat(key), child)
  })
}

installModule 中用来注册 getters mutations actions 的函数:

// 给 store 实例的 _mutations 属性填充
function registerMutation(store, mutationName, mutationFn, local) {
  const entry = store._mutations[mutationName] || (store._mutations[mutationName] = []);
  entry.push((payload) => {
    mutationFn.call(store, local.state, payload);
  });
}

// 给 store 实例的 _actions 属性填充
function registerAction(store, actionName, actionFn, local) {
  const entry = store._actions[actionName] || (store._actions[actionName] = [])
  entry.push((payload) => {
    return actionFn.call(store, {
      commit: local.commit,
      state: local.state,
    }, payload)
  });
}

// 给 store 实例的 getters 属性填充
function registerGetter(store, getterName, getterFn, local) {
  Object.defineProperty(store.getters, getterName, {
    get: () => {
      return getterFn(
        local.state,
        local.getters,
        store.state
      )
    }
  })
}

// 将对象中的每一个值放入到传入的函数中作为参数执行
function forEachValue(obj, fn) {
  Object.keys(obj).forEach(key => fn(obj[key], key));
}

使用

还有 modules、plugins 等功能还没有实现,而且 getters 的并没有使用 Vue 的 computed 而只是简单的以函数的形式实现,但是已经基本完成了 Vuex 的主要功能,下面是一个使用示例:

// /src/store.js
import Vue from 'vue'
import Vuex from './vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    changeCount(state, payload) {
      console.log('changeCount', payload)
      state.count += payload;
    }
  },
  actions: {
    asyncChangeCount(ctx, payload) {
      console.log('asyncChangeCount', payload)
      setTimeout(() => {
        ctx.commit('changeCount', payload);
      }, 500);
    }
  }
})
<!-- /src/App.vue -->
<template>
  <div id="app">
    {{ count }}
    <button @click="changeCount">+1</button>
    <button @click="asyncChangeCount">async +1</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  computed: {
    count() {
      return this.$store.state.count;
    }
  },
  methods: {
    changeCount() {
      this.$store.commit('changeCount', 1);
    },
    asyncChangeCount() {
      this.$store.dispatch('asyncChangeCount', 1);
    }
  },
  mounted() {
    console.log(this.$store)
  }
}
</script>
阅读源码的过程中写了一些方便理解的注释,希望给大家阅读源码带来帮助,github: vuex 源码

参考

查看原文

赞 15 收藏 13 评论 0

认证与成就

  • 获得 0 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-11-05
个人主页被 447 人浏览