款冬

款冬 查看完整档案

杭州编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

前端小小弄潮儿

个人动态

款冬 回答了问题 · 11月22日

http 缓存问题

缓存针对的是资源文件,如图片、css文件、js文件、字体文件等,并不是说设置了缓存之后,请求就不发送了。只是服务端接受到了带缓存头的请求后,会根据缓存规则进行对应的处理。

https://www.jianshu.com/p/54cc04190252

关注 2 回答 1

款冬 回答了问题 · 11月22日

解决无效的import 会对打包有印影响吗?

首先明确下这个 “无效的” 的定义,是路径不对,加载不到的资源,还是引进来了资源,但是实际上在代码中没有用到。

如果是前者
可以理解为对打包几乎没有影响,只是在打包的时候会去查找这个资源,但是查不到,因此对最后的打包体积不会有影响,对打包速度会有轻微影响,可以忽略不计(因为多了去查找资源的过程)

如果是后者
会对打包的速度和体积有影响,webpack的打包过程会对从入口文件开始,依次读取依赖,碰到 require 和 import的资源,最终都会打包到最后的产出bundle中。不过这个问题可以通过webpack的配置 tree shaking 来解决,打包时会删除没用到的方法和文件。

关注 2 回答 1

款冬 发布了文章 · 9月13日

egg-core源码解读

前言

egg框架的使用过程中会发现有一些非常方便和优雅的地方,比如对各个环境下配置的合并和加载,对controller,service,middleware的集成和建立关联,对插件扩展等,从源码中可以发现egg是继承于egg-core的,而这些核心逻辑的实现都是在egg-core里完成的,因此可以说egg框架的核心在于egg-core。下面就对egg-core的源码进行一些解读,来体会框架设计的精妙之处。

模块关系

egg-core源码的入口导出了EggCore、EggLoader、BaseContextClass、utils 四个模块,其中EggCore类是基类,做一些初始化工作;EggLoader类是最核心的一个部分,对整个框架的controller,service,middleware等进行初始化和加载集成,并建立相互关联。BaseContextClass是另一个基类, 用来接收ctx对象,挂载在上下文上,egg框架中的 controller 和 service 都继承该类,所以都能通过this.ctx拿到上下文对象。utils则是定义了一些框架中用到的方法。看一张图片会比较清晰:
image

核心模块

egg-core

egg-core继承于 Koa ,在该类中主要完成一些初始化工作,大概可以分为

  1. 对初始化参数的处理,包括对传入的应用的目录,运行的类型的判断等。
constructor(options = {}) {
    options.baseDir = options.baseDir || process.cwd();
    options.type = options.type || 'application';

    assert(typeof options.baseDir === 'string', 'options.baseDir required, and must be a string');
    assert(fs.existsSync(options.baseDir), `Directory ${options.baseDir} not exists`);
    assert(fs.statSync(options.baseDir).isDirectory(), `Directory ${options.baseDir} is not a directory`);
    assert(options.type === 'application' || options.type === 'agent', 'options.type should be application or agent');
  1. 关键属性的初始化和挂载,包括Controller、Service、lifecycle、loader、router等。
this.console = new EggConsoleLogger();
this.BaseContextClass = BaseContextClass;
const Controller = this.BaseContextClass;
this.Controller = Controller;
const Service = this.BaseContextClass;
this.Service = Service;
this.lifecycle = new Lifecycle({
      baseDir: options.baseDir,
      app: this,
      logger: this.console,
    });
 this.loader = new Loader({
      baseDir: options.baseDir,
      app: this,
      plugins: options.plugins,
      logger: this.console,
      serverScope: options.serverScope,
      env: options.env,
    });
  1. 生命周期函数的初始化和监听,中间件use方法的定义。
beforeStart(scope) {
    this.lifecycle.registerBeforeStart(scope);
}
ready(flagOrFunction) {
    return this.lifecycle.ready(flagOrFunction);
}
beforeClose(fn) {
    this.lifecycle.registerBeforeClose(fn);
}

use(fn) {
    assert(is.function(fn), 'app.use() requires a function');
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(utils.middleware(fn));
    return this;
}

egg-loader

整个框架目录结构(controller,service,middleware,extend,router)的加载和初始化工作都在该类中实现的。egg-loader中定义了一系列初始化的全局方法和加载loader的基础方法。将所有分开写在各个文件中的loader方法统一在该类中引入进行加载,会根据目录结构规范将目录结构中的 config,controller,service,middleware,plugin,router 等文件 load 到 app 或者 context 上,最终挂载输出内容。开发人员只要按照这套约定规范,就可以很方便进行开发。

// 加载文件
  loadFile(filepath, ...inject) {
    filepath = filepath && this.resolveModule(filepath);
    if (!filepath) {
      return null;
    }

    // function(arg1, args, ...) {}
    if (inject.length === 0) inject = [ this.app ];

    let ret = this.requireFile(filepath);
    if (is.function(ret) && !is.class(ret)) {
      ret = ret(...inject);
    }
    return ret;
  }
  
requireFile(filepath) {
    const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`;
    this.timing.start(timingKey);
    const ret = utils.loadFile(filepath);
    this.timing.end(timingKey);
    return ret;
  }
// 挂载到App上
  loadToApp(directory, property, opt) {
    const target = this.app[property] = {};
    opt = Object.assign({}, {
      directory,
      target,
      inject: this.app,
    }, opt);

    const timingKey = `Load "${String(property)}" to Application`;
    this.timing.start(timingKey);
    new FileLoader(opt).load();
    this.timing.end(timingKey);
  }
  
// 挂载到上下文上
loadToContext(directory, property, opt) {
    opt = Object.assign({}, {
      directory,
      property,
      inject: this.app,
    }, opt);

    const timingKey = `Load "${String(property)}" to Context`;
    this.timing.start(timingKey);
    new ContextLoader(opt).load();
    this.timing.end(timingKey);
 }

在加载的过程中会用到 file_loader 和 context_loader 两个类,这两个是Load加载流程中的基础类,提供了基础的加载上下文和方法,这个在后面会有细说。

具体loader模块

err-loader中会将具体的一些loader模块require进来并挂载在原型链上,这样在err-loader中就可以访问到具体的loader模块中的方法。这些loader模块包括plugin、config、service、middleware、controller等,分别负责各自的一些逻辑,并且模块间也存在着相关的联系。下面这张图中大致标注出了各个loader的依赖关系,比如加载 middleware 时会用到 config 关于应用中间件的配置,对内部中间件进行 use 的主动加载。具体的关系图如下:
image
下面会挑其中的重点Loader进行具体分析:

plugin_loader

首先来看插件加载模块,该模块是err-core中一个非常重要的模块,egg-core中的插件大致可以分为3类:框架级插件、应用级插件、用户自定义插件。这3种插件如何进行共存和覆盖,如何根据环境变量和开启开关进行加载?这就是plugin-loader中做的控制。该模块整体做了4件事情:

  1. 从以上说的3类插件的目录中读取插件,并按照框架级插件、应用级插件、用户自定义插件的顺序进行插件的加载和覆盖,后面的插件会覆盖前面的插件,得到最后合并后的插件。
  2. 根据当前环境变量和插件的配置对插件是否开启进行处理,因为有一些插件只有在特定的环境下才会开启。
  3. 对所有的框架进行依赖关系的检查和相应的处理,如果有依赖插件的缺失或者循环引用,会抛出错误。如果有依赖关系的插件没有开启,那么也会将改插件开启。
  4. 经过以上3步处理后,将最终符合开启条件的插件对象挂载在 this 对象上,完成插件的处理流程。

file_loader

这是一个基础的loader模块,通过提供一个 load 函数对工程的目录结构和文件内容进行解析,这个函数如下,其核心在于调用了parse方法对文件路径进行解析,对解析后的数组中的对象的属性进行重新分割处理。

load() {
    const items = this.parse();
    //items的形式: [{ properties: [ 'a', 'b', 'c'], exports1,fullpath1}, { properties: [ 'a', 'b', 'c'], exports2,fullpath2}]
    const target = this.options.target;
    for (const item of items) {
      debug('loading item %j', item);
      // item { properties: [ 'a', 'b', 'c'], exports }
      // => target = {a: {b: {c: exports1, d: exports2}}}
      // => target.a.b.c = exports
      item.properties.reduce((target, property, index) => {
        let obj;
        const properties = item.properties.slice(0, index + 1).join('.');
        if (index === item.properties.length - 1) {
          if (property in target) {
            if (!this.options.override) throw new Error(`can't overwrite property '${properties}' from ${target[property][FULLPATH]} by ${item.fullpath}`);
          }
          obj = item.exports;
          if (obj && !is.primitive(obj)) {
            obj[FULLPATH] = item.fullpath;
            obj[EXPORTS] = true;
          }
        } else {
          obj = target[property] || {};
        }
        target[property] = obj;
        debug('loaded %s', properties);
        return obj;
      }, target);
    }
    return target;
  }

对应的parse方法如下,代码中已经进行了关键语句的注释

  parse() {
    //最终生成 [{ properties: [ 'a', 'b', 'c'], exports,fullpath}] 形式, 
    //properties 文件路径名称的数组, exports 是导出对象, fullpath 是文件的绝对路径
    let files = this.options.match;
    if (!files) {
      files = (process.env.EGG_TYPESCRIPT === 'true' && utils.extensions['.ts'])
        ? [ '**/*.(js|ts)', '!**/*.d.ts' ]
        : [ '**/*.js' ];
    } else {
      files = Array.isArray(files) ? files : [ files ];
    }

    let ignore = this.options.ignore;
    if (ignore) {
      ignore = Array.isArray(ignore) ? ignore : [ ignore ];
      ignore = ignore.filter(f => !!f).map(f => '!' + f);
      files = files.concat(ignore);
    }

    //文件目录转换为数组
    let directories = this.options.directory;
    if (!Array.isArray(directories)) {
      directories = [ directories ];
    }

    const filter = is.function(this.options.filter) ? this.options.filter : null;
    const items = [];
    debug('parsing %j', directories);
    for (const directory of directories) {
      //每个文件目录下面可能还会有子文件夹,所以 globby.sync 函数是获取所有文件包括子文件下的文件的路径
      const filepaths = globby.sync(files, { cwd: directory });
      for (const filepath of filepaths) {
        // 拼接成完整文件路径
        // app/service/foo/bar.js
        const fullpath = path.join(directory, filepath);
        // 如果不是文件跳过,进行下一次循环
        if (!fs.statSync(fullpath).isFile()) continue;
        // get properties
        // foo/bar.js => ['foo', 'bar' ]
        const properties = getProperties(filepath, this.options);
       // app/service ['foo', 'bar' ] => service.foo.bar
        const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
        // get exports from the file
        const exports = getExports(fullpath, this.options, pathName);

        // ignore exports when it's null or false returned by filter function
        if (exports == null || (filter && filter(exports) === false)) continue;

        // set properties of class
        if (is.class(exports)) {
          exports.prototype.pathName = pathName;
          exports.prototype.fullPath = fullpath;
        }

        items.push({ fullpath, properties, exports });
        debug('parse %s, properties %j, export %j', fullpath, properties, exports);
      }
    }

    return items;
  }

}

最后会返回一个解析后的 target 对象,对象的层级结构跟目录结构相对应,最内层是文件的导出对象或者方法。大概的格式如下:

target = {
    a: {
        b: {
            c: exports1, 
            d: exports2,
            
        }
        
    }
    
}

导出的这个 target 对象会在 context_loader 里用到。service、controller的loader实现 均借助了该基类。

context_loader

context_loader类是用于处理上下文挂载的基类,继承于file_loader。上文说了 file_loader 的作用是对文件目录的解析生成 target 对象。而 context_loader 类就是在这基础上进一步实现了 target 对象的挂载。用于将 FileLoader 解析出来的 target 挂载在 app.context 上 对应传入的 property 属性上。

service_loader

service_loader处理service文件夹下文件的加载,该模块中直接就导出了一个 loadService 的方法。该方法把service的文件目录('app/service') 和 解析后需要挂载的属性('service')作为参数传入 egg_loader的 loadToContext 方法中,loadToContext方法会创建一个 ContextLoader 类的实例,并调用其load()方法,通过上文说的 file_loader 和 context_loader 中的核心逻辑,实现将 app/service 文件夹下的文件路径和导出解析成target对象,最终挂载在app.context.service下。

middleware_loader

中间件的加载,主要做了3件事:

  1. 将通过 FileLoader 实例加载 app/middleware 目录下的所有文件并导出,然后将 middleware 挂载在 app 上,可以通过app.middleware进行访问
  2. 对中间件函数进行包装,统一处理成async function(ctx, next) 形式
  3. 对在 config 中配置了 appMiddleware 和 coreMiddleware 的中间件直接调用 app.use 使用,其它中间件只是挂载在 app 上。

controller_loader

constroller的加载跟service还是有区别的:

  • constroller挂载在app上,service挂载在app.ctx上,constroller的调用只需要访问到对应的service名称,而service的调用需要具体到导出的函数,因此两者使用egg_loader中方法不同,一个是loadToApp(调用FileLoader实例),一个是LoadToContext(调用ContextLoader实例)
  • controller 中生成的函数最终还是在 router.js 中当作一个中间件使用,所以我们需要将 controller 中内容转换为中间件形式 async function(ctx, next) ,这跟service相比在调用FileLoad类实例的load函数时就要多传一个 initializer 函数,对exports的内容进行处理。

具体controller_loader类中做的事情也是围绕以上两点,解析 app/controller 文件目录生成targe对象,完成在app上对 controller 属性的挂载。同时对 initializer 函数进行了各种情况下的处理。

config_loader

config_loader对整个应用的配置加载做了管理,会根据当前环境的不同,加载不同的配置环境,并和默认的配置合并后得到最终的配置。config_loader对配置维度的加载有2个维度,大的维度来说,先会加载plugin的配置文件,再加载framework的配置文件,最后才是app的配置文件。小的维度来说会先从基本路径下的config/config 目录下加载默认的配置文件,然后根据当前的serverEnv的不同,加载不同环境的配置文件,最后将当前环境下的配置文件和默认的配置文件进行合并得到最终的配置文件。总结来说:先分别按照plugin、framework、app的顺序合并得到默认的配置和当前环境下的配置。然后用合并默认的配置和当前环境下的配置,得到的才是最终的配置。

router_loader

router_loader中其实就做了加载一下 app/router 目录下的文件而已。这是因为具体的router的逻辑,都交给了eggcore中的router属性,而而 router 又是 Router 类的实例,Router 类是基于 koa-router 实现的。所以egg中关于router的原理跟koa大致是相同的,这里就不展开说了。

总结

看完 egg-core 的源码之后,还是有很多收获的,在我看来有以下几点值得借鉴和思考:

  1. 规范和代码风格的重要性,在多人合作中这一点尤其重要。而egg-core则通过定义和实现了关于目录解析和属性挂载的这一套规范,解决了规范一致性的问题,同时通过controller、service的分层设计,让代码的可读性和易维护性也得到了大大增强。
  2. 框架的扩展和继承的设计,err-core本身是基础koa的,而本身也被egg继承。通过这种框架之间的继承可以根据实际需求方便地构建出需要的框架。而具体到里面的各个load类,也是通过先定义了 fileLoader 和 contextLoader 两个基类,被其他loader类频繁地进行依赖和调用。而 egg_loader类 和其他 loader 类也是解耦的,将其他 loader类 加载到egg_loader类的原型链上进行访问,入口统一,内容代码独立,体现了很好的设计思想。
  3. Symbol和Symbol.for的使用,对已有或缓存的内容的判断和加载通过了Symbol来实现,跟用全局Map对象进行维护更优雅和方便。
查看原文

赞 3 收藏 1 评论 0

款冬 发布了文章 · 5月17日

koa源码解读

前言

koa作为广泛运用的node框架,其源代码非常精简,看完之后愈发佩服TJ大神,能够用这么少的代码实现了如此强大易用的框架。下面结合源码具体分析一下其中的核心原理。

整体结构

首先看一下 koa 框架的组成结构,koa 的源码由4个部分组成,分别是 application.js(入口文件),context.js(上下文,即koa的ctx),request.js(请求对象,基于req封装),response.js(响应对象,基于res封装),其中核心代码主要都位于 application.js 中。下面会从4个文件展开具体的分析:

application

application.js 是 koa 的入口文件,也是核心所在。在该文件中引入了其他3个文件,并在构造函数中定义了一些核心属性,主要有

  • middleware:这是注册的中间件的集合
  • context:上下文模块,继承于context.js创建的对象
  • request:请求模块,继承于request.js创建的对象
  • response:响应模块,继承于response.js创建的对象
const context = require('./context');
const request = require('./request');
const response = require('./response');
....

constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
    this.maxIpsCount = options.maxIpsCount || 0;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;
    this.middleware = []; // 中间件数组
    this.context = Object.create(context);// 上下文对象
    this.request = Object.create(request);// 请求对象
    this.response = Object.create(response);// 响应对象
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }

接下来看koa的使用方法 listen ,listen 方法就是对 http.createServer 对了一个简单的封装,抽离出来单独的回调函数,返回 http 服务对传入端口的监听。node 原生的 http.createServer方法中需要传入处理的回调函数,但是在实际的复杂业务逻辑中,代码不可避免不好管理,因此 koa 这里对回调函数作了单独处理。

 listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

下面继续看单独抽离的 callback 方法,该方法中主要做了3件事情:

  1. 对注册的中间件进行了统一整合处理
  2. 监听框架运行错误,并设置了错误处理函数
  3. 返回请求处理函数

下面具体来看这 3 个步骤:

 callback() {
    const fn = compose(this.middleware);

    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

compose

该方法中的第一步就调用了 compose 方法对中间件进行了整合处理, compose方法是单独写的 koa-compose 模块,是koa中间件处理的核心所在,具体看一下处理逻辑,十分有意思:

function compose (middleware) {
  //入参必须是数组,且数组中的元素都必须是函数(每个中间件都是一个个的函数)
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); // 返回的方法中的第二个参数递归调用下一个中间件方法,这就是为什么中间件中执行next()时会调用下一个中间件函数
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

该方法中首先对传入的中间件集合入参做了判断,只能是数组,并且数组中的每个元素都必须是函数,这就要求注册的中间件必须是函数。 接下来就是核心所在,该方法返回一个函数,第一个参数 context 是请求上下文,第二个参数 next 是所有中间件执行完之后最终执行的回调函数。我们重点来看一下该方法中核心的dispatch函数的逻辑:

dispatch

dispatch函数会遍历 middleware 中间件集合,依次取出中间件进行执行,直到所有中间件都执行完成。fn(context, dispatch.bind(null, i + 1)) 这条语句是最关键的一条语句,执行当前中间件函数,将上下文context作为第一个参数传入,下一个要执行的中间件方法作为第二个参数传入。这就是为什么我们在 koa 中间件中执行next()方法(对应这里的第二个参数)时,会执行下一个中间件函数的原因,如果不调用next(),那么后面的中间件函数都会无法执行。

监听error

第二步,通过 this.on('error', this.onerror) 对框架中的 error 事件进行监听,对应的 onerror 处理函数分情况进行了相应的错误处理

 onerror(err) {
    if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));

    if (404 == err.status || err.expose) return;
    if (this.silent) return;

    const msg = err.stack || err.toString();
    console.error();
    console.error(msg.replace(/^/gm, '  '));
    console.error();
  }

返回handleRequest

createContext

先执行了 const ctx = this.createContext(req, res),创建了当前请求的上下文对象,createContext 方法做的事情就是创建了一个 context 对象,并且将当前的this、req、res都挂载到了该对象上,这也是为什么我们在使用 koa 时能在请求的 ctx 上拿到关于 app、req 和 res 上的各种请求相关属性的原因。

createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }
handleRequest

紧接着 return this.handleRequest(ctx, fn) 其中 ctx 就是上一步创建的请求上下文对象,fn 是 compose 返回的闭包函数。handleRequest方法最终 return fnMiddleware(ctx).then(handleResponse).catch(onerror); 即当所有中间件执行之后执行响应处理函数。

 handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror); // 一个监听请求结束的第三方库,发生错误时,执行默认错误回调函数
    return fnMiddleware(ctx).then(handleResponse).catch(onerror); // 所有中间件执行之后执行响应处理函数,若抛出异常,执行默认错误回调函数
  }
respond

接下来重点看一下上一步相应处理函数中用到的 respond 函数,该函数是 koa 中的又一个核心函数。主要就是针对不同的响应主体和状态,进行不同的处理,主要分为以下几种case:

  1. 没有响应主体时的处理
  if (statuses.empty[code]) { // 返回的状态码表示没有相应主体时
    // strip headers
    ctx.body = null;
    return res.end();
  }
  1. HEAD请求方法,响应头已经发送,但是没有内容长度时的处理
  if ('HEAD' === ctx.method) { // HEAD请求方法
    if (!res.headersSent && !ctx.response.has('Content-Length')) { // 响应头已经发送,但是没有内容长度,进行设置
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }
  1. 有相应主体,但是为空时的处理
 if (null == body) { // 有相应主体,但是为空
    if (ctx.response._explicitNullBody) {
      ctx.response.remove('Content-Type');
      ctx.response.remove('Transfer-Encoding');
      return res.end();
    }
    ...
  1. 有相应主体,不同格式的处理
 // responses,对不同的响应主体进行处理
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);

至此整个 koa 入口文件的主流程使用方法已经分析完成,除了这个主流程之外,还有个中间件的使用方法 use() 需要单独看一下。

use

use 方法是 koa 中注册中间件的方法,原理其实很简单,当调用 use 方法注册中间件时,实质上就是讲中间件函数 push 到 this.middleware 这个框架中间件集合中,所以中间件的执行是先进先出。并且函数最后返回了 this, 这么做是保证了中间件的注册可以实现链式调用。具体的代码和注释如下:

 use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // koa1.x版本使用Generator Function的方式写中间件,而Koa2改用ES6 async/await。所以在use()函数中会判断是否为旧风格的中间件写法,并对旧风格写法得中间件进行转换
    if (isGeneratorFunction(fn)) { 
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this; // 返回this,所以可以链式调用
  }

context

接下来看 koa 的 context.js 文件,context 的核心在于:

  1. 封装了 koa 请求的上下文,代理了 request 和 response 这两个对象的属性和方法,用到的 delegate 方法是一个第三方库,用来代理对象的属性和方法。
delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  
  ...
  
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set') 
  
  ...

2.定义了 onerror 错误处理函数,在之前 application.js 里面的 handleRequest 就有用到。该函数主要也是根据不同的情况做不同的处理:

onerror(err) {
    if (null == err) return;
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
    
    ...
    
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    res.end(msg);
    
}

request && response

最后的请求和响应模块,没什么特别需要分析的,就是对请求和响应的相关属性和方法作了封装,用 set 和 get 函数的形式进行属性的读写操作。大致的封装代码如下:

get search() {
    if (!this.querystring) return '';
    return `?${this.querystring}`;
  },

set search(str) {
    this.querystring = str;
  },
  ...

洋葱圈模型

最后着重看下 koa 中间件请求的洋葱圈模型,这是 koa 区别于 express 的一个重大特点。 用一张网上经典的图片和一个简单的小例子说明一下: image

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    console.log(6);
});

app.use(async (ctx, next) => {
    console.log(2);
    await next();
    console.log(5);
});

app.use(async (ctx, next) => {
    console.log(3);
    ctx.body = "last middleware";
    console.log(4);
});

app.listen(3000, () => {~~~~
    console.log('listenning on 3000');
});

//依次输出 1、2、3、4、5、6

为什么能实现这个效果呢,其实在前面介绍 koa-compose 源码中已经可以找到答案了。koa 中间件机制 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 每次都返回一个promise,并在中间件方法中调用 next()时,对应执行下一个中间件。因此当 await next() 时会等待下一个中间件执行完成后,再回到当前中间件中继续执行后续代码。这也就是洋葱圈模型实现的原理。

查看原文

赞 1 收藏 0 评论 0

款冬 发布了文章 · 4月25日

Vue中的组件通信

前言

vue中的组件通信是必不可少的使用场景,回顾了平时使用vue中碰到的一些业务场景和对应采用的组件通信方式,简单地做了一个归类总结,大致有以下一些通信方式:

props/$emit

定义

最常用的父子组件通信方式,父组件通过props向子组件传值,子组件通过$emit事件向父组件触发事件并传值

实例说明
//父组件
<parent :name="xxx" @updateFromChild="xxx"></parent>

//子组件
props: {
    name: {
        type: String,
        default: ''
    }
}
methods: {
    xxx () {
        this.$emit('updateFromChild', xx)
    }
}

$attrs 和 \$listeners

$attrs:

定义

子组件中获取除了在props中定义之外的父组件中传递的属性值(class 和 style 除外)

实例说明
//父组件
<parent name="小明" age=5 sex="男" home="杭州" class="parent"></parent>

//子组件
<script>
props: {
    name: {
        type: String,
        default: ''
    }
}
mounted () {
    console.log(this.$attrs)
    // {age:5, sex:"男", home:"杭州"}
}
</script>
使用场景

当孙组件中需要用到爷组件中的大量属性,但是父组件却不需要用到很多,只是作为一个中转的传递,巧用$attrs可以节省在父组件中写的props值。

//父组件
<parent name="小明" age=5 sex="男" home="杭州" class="parent"></parent>

//子组件
<template>
  <grand-son :passAttr="$attrs"></grand-son>
</template>

props: {
    name: {
        type: String,
        default: ''
    }
}

$listener:

定义

包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。

实例说明
//父组件
<parent @change="onChange" @hide="OnHide"></parent>

//子组件
<script>
mounted () {
    console.log(this.$listener)
    // {change:f, hide:f}
}
</script>
使用场景

子组件中调用父组件的方法


provide 和 inject

provide:

是一个对象或返回一个对象的函数。该对象包含可注入其子孙的属性

inject:

是一个数组或者对象,包含祖先组件中注入的属性

实例说明:

//父组件:
<script>
provide: {
    name: '小明',
    sayName () {
        console.log('我是' + this.name)
    }
}
mounted() {
    this.name = '小花'
}
</script>


//子组件
<script>
inject:['name', 'sayName'],
mounted () {
    console.log(this.name); //小明
    this.sayName() //我是小明
}
</script>

可以看到在子组件中的 mounted 中打印出了 inject 中传递进来的 name 属性,并成功执行了 sayName 方法。而且 name 属性值还是'小明',并没有变成'小花',并不是响应式的。

注意点

  • provide 和 inject 成对出现使用
  • provide 注入的属性,在组件下的所有子孙组件中都能在 inject 中拿到
  • provide 和 inject 绑定并不是可响应的,这是和props的本质区别

$parent 和 \$children

定义:

已创建的实例的父实例和子实例中,子实例可以用 this.\$parent 访问父实例,子实例被推入父实例的 \$children 数组中,父实例可以用 this.$children 访问子实例。

实例说明

//父组件
mounted(){
console.log(this.$children)
//可以操作子组件的data,或者调用其methods中的方法
}

//子组件
mounted(){
console.log(this.$parent) //可以拿到父组件中的属性和方法
}

注意点

  • \$children和$parent不是响应式的
  • Vue官方有说明节制地使用 \$parent 和 $children - 它们的主要目的是作为访问组件的应急方法。

$refs

定义

一个对象,持有注册过 ref attribute 的所有 DOM 元素和组件实例。通过this.$refs.xxx 可以拿到 xxx 子组件中的data和methods。

实例说明
//父组件
<child ref="child"></child>

mounted(){
this.$refs.child.sayHello() // hello
}

//子组件
methods: {
    sayHello () {
        console.log('hello')
    }
}
注意点
  • this.$refs.xxx 必须在 xxx 组件渲染完成之后才能拿到,并不是所有生命周期中都能取到。
  • 通过 this.$refs.xxx 进行对子组件的属性操作并不是响应式的,因此避免在computed中进行使用。这是一个很好的应急手段,常用于以下两种使用场景

    1. 父组件直接调用子组件中的方法,节省不必要的代码重写
    2. 用来取代JQuery或者JS原生方法对dom元素的获取

vuex

不多说,强大的复杂单页面全局数据通信管理中心,供页面中的每个组件进行全局数据的操作,并响应式到各个组件中。如果不知道自行搜索。这里就简单说两个注意点

  • 注意vuex的使用场景,不要滥用,复杂的页面数据管理,如果只是简单的数据通信用props和$emit即可,不要为了用而用,适合的才是最好的
  • vuex中可以根据情况进行分模块管理,代码的可维护性会得到进一步提升

eventBus

定义

对于非父子组件来说,可以采用一个中间介质来作为一个中央事件总线,完成事件的触发和监听,并进行传值。

实例说明

第一步:创建事件总线:建立一个eventBus.js文件

//仅仅是为了创建一个vue实例
import Vue from 'vue'
export default new Vue()

第二步:在传值的源头组件中引进事件总线,进行事件的触发和传值

<template>
 <div class = "source">
   <button @click="emitEvent">
 </div>
</template>

<script>
import eventBus from './eventBus.js'
export default {
  methods: {
      emitEvent: funciton () {
        eventBus.$emit('sendVal',param)
      }
  }
}
</script>

第三步:在传值的目标组件中引进事件总线,进行事件的监听和传值的接收

<template>
 
</template>

<script>
import eventBus from './eventBus.js'
export default {
  data: function () {
      return {
          requireVal: ''
      }
  }
  mounted: function () {
    var that = this
    eventBus.$on('sendVal',function(val){
        that.requireVal = val
    }) 
  }
}
</script>

总结如下:

1.创建一个事件总线,例如demo中的eventBus,用它作为通信桥梁

2.在需要传值的组件中用bus.$emit触发一个自定义事件,并传递参数

3.在需要接收数据的组件中用bus.$on监听自定义事件,并在回调函数中处理传递过来的参数

注意点
  • eventBus 特别适合非跨多个层级组件之间的通信,解决了通过层层props传递的繁琐,也避免了使用vuex的相对臃肿。
  • 缺陷在于如果不加说明和规范管理,容易找不到事件的触发者和监听者。因此建议放在单独的js文件中创建好eventBus的vue实例,并在该文件中注释好事件的触发组件和监听组件。
查看原文

赞 0 收藏 0 评论 0

款冬 回答了问题 · 3月29日

如何处理http站点chrome80.0后samesite的问题?

我们也遇到了这个问题,说一下我这边的解决方式,给你参考一下:
首先线上环境 确实在setCookie的时候 通过设置 samesite: none 和 secure: true,并且运维侧配置https,解决了这个问题

开发环境,因为没有https的支持,所以通过关闭 chrome 的 samesite 默认设置,来实现http下的正常开发,具体的设置如下:
1.打开chrome设置:chrome://flags/#same-site-by-default-cookies
2.将 SameSite by default cookies 这一项设置修改成disabled,重启浏览器,就可以正常 在http下使用cookie了。

最后,我在考虑是否用token或者先存到localstorage里的方式来替代原来cookie的解决方式,不得不说chrome这一突然改动对开发来说太不友好了。

关注 2 回答 1

款冬 赞了文章 · 3月4日

Express 文档(错误处理)

错误处理

错误处理是指Express如何捕获和处理同步和异步发生的错误,Express附带一个默认的错误处理程序,因此你无需编写自己的错误处理程序即可开始使用。

捕捉错误

确保Express捕获运行路由处理程序和中间件时发生的所有错误非常重要。

路由处理程序和中间件内的同步代码中发生的错误不需要额外的工作,如果同步代码抛出错误,则Express将捕获并处理它,例如:

app.get("/", function (req, res) {
  throw new Error("BROKEN"); // Express will catch this on its own.
});

对于由路由处理程序和中间件调用的异步函数返回的错误,必须将它们传递给next()函数,Express将捕获并处理它们,例如:

app.get("/", function (req, res, next) {
  fs.readFile("/file-does-not-exist", function (err, data) {
    if (err) {
      next(err); // Pass errors to Express.
    }
    else {
      res.send(data);
    }
  });
});

如果将任何内容传递给next()函数(字符串'route'除外),则Express将当前请求视为错误,并将跳过任何剩余的非错误处理路由和中间件函数。

如果序列中的回调不提供数据,只提供错误,则可以按如下方式简化此代码:

app.get("/", [
  function (req, res, next) {
    fs.writeFile("/inaccessible-path", "data", next);
  },
  function (req, res) {
    res.send("OK");
  }
]);

在上面的示例中,next作为fs.writeFile的回调提供,调用时有或没有错误,如果没有错误,则执行第二个处理程序,否则Express会捕获并处理错误。

你必须捕获由路由处理程序或中间件调用的异步代码中发生的错误,并将它们传递给Express进行处理,例如:

app.get("/", function (req, res, next) {

  setTimeout(function () {
    try {
      throw new Error("BROKEN");
    }
    catch (err) {
      next(err);
    }
  }, 100);
});

上面的示例使用try...catch块来捕获异步代码中的错误并将它们传递给Express,如果省略try...catch块,Express将不会捕获错误,因为它不是同步处理程序代码的一部分。

使用promises可以避免try...catch块的开销或者使用返回promises的函数,例如:

app.get("/", function (req, res, next) {
  Promise.resolve().then(function () {
    throw new Error("BROKEN");
  }).catch(next); // Errors will be passed to Express.
});

由于promises会自动捕获同步错误和拒绝promises,你可以简单地提供next作为最终的catch处理程序,Express将捕获错误,因为catch处理程序被赋予错误作为第一个参数。

你还可以使用处理程序链来依赖同步错误捕获,通过将异步代码减少为一些简单的代码,例如:

app.get("/", [
  function (req, res, next) {
    fs.readFile("/maybe-valid-file", "utf8", function (err, data) {
        res.locals.data = data;
        next(err);
    });
  },
  function (req, res) {
    res.locals.data = res.locals.data.split(",")[1];
    res.send(res.locals.data);
  }
]);

上面的例子有一些来自readFile调用的简单语句,如果readFile导致错误,那么它将错误传递给Express,否则你将快速返回到链中下一个处理程序中的同步错误处理的世界。然后,上面的示例尝试处理数据,如果失败,则同步错误处理程序将捕获它,如果你在readFile回调中完成了此处理,则应用程序可能会退出,并且Express错误处理程序将无法运行。

无论使用哪种方法,如果要调用Express错误处理程序并使应用程序存活,你必须确保Express收到错误。

默认错误处理程序

Express附带了一个内置的错误处理程序,可以处理应用程序中可能遇到的任何错误,此默认错误处理中间件函数添加在中间件函数堆栈的末尾。

如果你将错误传递给next()并且你没有在自定义错误处理程序中处理它,它将由内置错误处理程序处理,错误将堆栈跟踪写入客户端,堆栈跟踪不包含在生产环境中。

将环境变量NODE_ENV设置为production,以在生产模式下运行应用程序。

如果在开始写入响应后调用next()并出现错误(例如,如果在将响应流式传输到客户端时遇到错误),则Express默认错误处理程序将关闭连接并使请求失败。

因此,当你添加自定义错误处理程序时,必须在headers已发送到客户端时委托给默认的Express错误处理程序:

function errorHandler (err, req, res, next) {
  if (res.headersSent) {
    return next(err)
  }
  res.status(500)
  res.render('error', { error: err })
}

请注意,如果你在你的代码调用next()出现错误多次,则会触发默认错误处理程序,即使自定义错误处理中间件已就绪也是如此。

编写错误处理程序

以与其他中间件函数相同的方式定义错误处理中间件函数,除了错误处理函数有四个参数而不是三个:(err, req, res, next),例如:

app.use(function (err, req, res, next) {
  console.error(err.stack)
  res.status(500).send('Something broke!')
})

你可以在其他app.use()和路由调用之后定义错误处理中间件,例如:

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(function (err, req, res, next) {
  // logic
})

中间件函数内的响应可以是任何格式,例如HTML错误页面、简单消息或JSON字符串。

对于组织(和更高级别的框架)目的,你可以定义多个错误处理中间件函数,就像使用常规中间件函数一样,例如,为使用XHR和不使用XHR的请求定义错误处理程序:

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(logErrors)
app.use(clientErrorHandler)
app.use(errorHandler)

在此示例中,通用logErrors可能会将请求和错误信息写入stderr,例如:

function logErrors (err, req, res, next) {
  console.error(err.stack)
  next(err)
}

同样在此示例中,clientErrorHandler定义如下,在这种情况下,错误会明确传递给下一个错误。

请注意,在错误处理函数中不调用“next”时,你负责编写(和结束)响应,否则这些请求将“挂起”,并且不符合垃圾回收的条件。

function clientErrorHandler (err, req, res, next) {
  if (req.xhr) {
    res.status(500).send({ error: 'Something failed!' })
  } else {
    next(err)
  }
}

实现“catch-all”的errorHandler函数,如下所示(例如):

function errorHandler (err, req, res, next) {
  res.status(500)
  res.render('error', { error: err })
}

如果你有一个具有多个回调函数的路由处理程序,则可以使用route参数跳转到下一个路由处理程序,例如:

app.get('/a_route_behind_paywall',
  function checkIfPaidSubscriber (req, res, next) {
    if (!req.user.hasPaid) {
      // continue handling this request
      next('route')
    }
    else{
      next();
    }
  }, function getPaidContent (req, res, next) {
    PaidContent.find(function (err, doc) {
      if (err) return next(err)
      res.json(doc)
    })
  })

在此示例中,将跳过getPaidContent处理程序,但app中的/a_route_behind_paywall中的任何剩余处理程序将继续执行。

next()next(err)的调用表明当前处理程序已完成并处于什么状态,next(err)将跳过链中的所有剩余处理程序,除了那些设置为处理上述错误的处理程序。

上一篇:使用模板引擎

下一篇:调试

查看原文

赞 1 收藏 0 评论 0

款冬 发布了文章 · 3月3日

vue数据渲染

前言

vue 是如何将编译器中的代码转换为页面真实元素的?这个过程涉及到模板编译成 AST 语法树,AST 语法树构建渲染函数,渲染函数生成虚拟 dom,虚拟 dom 编译成真实 dom 这四个过程。前两个过程在我们 vue 源码解读系列文章的上一期已经介绍过了,所以本文会接着上一篇文章继续往下解读,着重分析后两个过程。

整体流程

解读代码之前,先看一张 vue 编译和渲染的整体流程图:
屏幕快照 2020-01-04 下午9.29.17.png

vue 会把用户写的代码中的 <template></template> 标签中的代码解析成 AST 语法树,再将处理后的 AST 生成相应的render函数,render 函数执行后会得到与模板代码对应的虚拟 dom,最后通过虚拟 dom 中新旧 vnode 节点的对比和更新,渲染得到最终的真实 dom。 有了这个整体的概念我们再来结合源码分析具体的数据渲染过程。

从vm.$mount开始

vue 中是通过 $mount 实例方法去挂载 vm 的,数据渲染的过程就发生在 vm.$mount 阶段。在这个方法中,最终会调用 mountComponent 方法来完成数据的渲染。我们结合源码看一下其中的几行关键代码:

 updateComponent = () => {
      vm._update(vm._render(), hydrating) // 生成虚拟dom,并更新真实dom
    }

这是在 mountComponent 方法的内部,会定义一个 updateComponent 方法,在这个方法中 vue 会通过 vm._render() 函数生成虚拟 dom,并将生成的 vnode 作为第一个参数传入 vm._update() 函数中进而完成虚拟 dom 到真实 dom 的渲染。第二个参数 hydrating 是跟服务端渲染相关的,在浏览器中不需要关心。这个函数最后会作为参数传入到 vue 的 watch 实例中作为 getter 函数,用于在数据更新时触发依赖收集,完成数据响应式的实现。这个过程不在本文的介绍范围内,在这里只要明白,当后续 vue 中的 data 数据变化时,都会触发 updateComponent 方法,完成页面数据的渲染更新。具体的关键代码如下:

  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        // 触发beforeUpdate钩子
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    // 触发mounted钩子
    callHook(vm, 'mounted')
  }
  return vm
}

代码中还有一点需要注意的是,在代码结束处,会做一个判断,当 vm 挂载成功后,会调用 vue 的 mounted 生命周期钩子函数。这也就是为什么我们在 mounted 钩子中执行代码时,vm 已经挂载完成的原因。

vm._render()

接下来具体分析 vue 生成虚拟 dom 的过程。前面说了这一过程是调用vm._render()方法来完成的,该方法的核心逻辑是调用vm.$createElement方法生成vnode,代码如下:

vnode = render.call(vm._renderProxy, vm.$createElement)

其中vm.renderProxy是个代理,代理vm,做一些错误处理,vm.$createElement 是创建vnode的真正方法,该方法的定义如下:

vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

可见最终调用的是createElement方法来实现生成vnode的逻辑。在进一步介绍createElement方法之前,我们先理清楚两个个关键点,1.render的函数来源,2.vnode到底是什么

render方法的来源

在 vue 内部其实定义了两种 render 方法的来源,一种是如果用户手写了 render 方法,那么 vue 会调用这个用户自己写的 render 方法,即下面代码中的 vm.$createElement;另外一种是用户没有手写 render 方法,那么vue内部会把 template 编译成 render 方法,即下面代码中的 vm._c。不过这两个 render 方法最终都会调用createElement方法来生成虚拟dom

// bind the createElement fn to this instance
  // so that we get proper render context inside it.
  // args order: tag, data, children, normalizationType, alwaysNormalize
  // internal version is used by render functions compiled from templates
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

vnode类

vnode 就是用一个原生的 js 对象去描述 dom 节点的类。因为浏览器操作dom的成本是很高的,所以利用 vnode 生成虚拟 dom 比创建一个真实 dom 的代价要小很多。vnode 类的定义如下:

export default class VNode {
  tag: string | void; // 当前节点的标签名
  data: VNodeData | void; // 当前节点对应的对象
  children: ?Array<VNode>; // 当前节点的子节点
  text: string | void; // 当前节点的文本
  elm: Node | void; // 当前虚拟节点对应的真实dom节点
  ....
  
  /*创建一个空VNode节点*/
  export const createEmptyVNode = (text: string = '') => {
    const node = new VNode()
    node.text = text
    node.isComment = true
    return node
  }
  /*创建一个文本节点*/
  export function createTextVNode (val: string | number) {
    return new VNode(undefined, undefined, undefined, String(val))
  }
   ....

可以看到 vnode 类中仿照真实 dom 定义了很多节点属性和一系列生成各类节点的方法。通过对这些属性和方法的操作来达到模仿真实 dom 变化的目的。

createElement

有了前面两点的知识储备,接下来回到 createElement 生成虚拟 dom 的分析。createElement 方法中的代码很多,这里只介绍跟生成虚拟 dom 相关的代码。该方法总体来说就是创建并返回一个 vnode 节点。 在这个过程中可以拆分成三件事情:1.子节点的规范化处理; 2.根据不同的情形创建不同的 vnode 节点类型;3.vnode 创建后的处理。下面开始分析这3个步骤:

子节点的规范化处理

  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

为什么会有这个过程,是因为传入的参数中的子节点是 any 类型,而 vue 最终生成的虚拟 dom 实际上是一个树状结构,每一个 vnode 可能会有若干个子节点,这些子节点应该也是 vnode 类型。所以需要对子节点处理,将子节点统一处理成一个 vnode 类型的数组。同时还需要根据 render 函数的来源不同,对子节点的数据结构进行相应处理。

创建vnode节点

这部分逻辑是对tag标签在不同情况下的处理,梳理一下具体的判断case如下:

  1. 如果传入的 tag 标签是字符串,则进一步进入下列第 2 点和第 3 点判断,如果不是字符串则创建一个组件类型 vnode 节点。
  2. 如果是内置的标签,则创建一个相应的内置标签 vnode 节点。
  3. 如果是一个组件标签,则创建一个组件类型 vnode 节点。
  4. 其他情况下,则创建一个命名空间未定义的 vnode 节点。
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    // 获取tag的名字空间
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)

    // 判断是否是内置的标签,如果是内置的标签则创建一个相应节点
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
      // 如果是组件,则创建一个组件类型节点
      // 从vm实例的option的components中寻找该tag,存在则就是一个组件,创建相应节点,Ctor为组件的构造类
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children

      //其他情况,在运行时检查,因为父组件可能在序列化子组件的时候分配一个名字空间
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    // tag不是字符串的时候则是组件的构造类,创建一个组件节点
    vnode = createComponent(tag, data, context, children)
  }
vnode创建后的处理

这部分同样也是一些 if/else 分情况的处理逻辑:

  1. 如果 vnode 成功创建,且是一个数组类型,则返回创建好的 vnode 节点
  2. 如果 vnode 成功创建,且有命名空间,则递归所有子节点应用该命名空间
  3. 如果 vnode 没有成功创建则创建并返回一个空的 vnode 节点
  if (Array.isArray(vnode)) {
    // 如果vnode成功创建,且是一个数组类型,则返回创建好的vnode节点
    return vnode
  } else if (isDef(vnode)) {
    // 如果vnode成功创建,且名字空间,则递归所有子节点应用该名字空间
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    // 如果vnode没有成功创建则创建空节点
    return createEmptyVNode()
  }

vm._update()

vm._update() 做的事情就是把 vm._render() 生成的虚拟 dom 渲染成真实 dom。_update() 方法内部会调用 vm.__patch__ 方法来完成视图更新,最终调用的是 createPatchFunction 方法,该方法的代码量和逻辑都非常多,它定义在 src/core/vdom/patch.js 文件中。下面介绍下具体的 patch 流程和流程中用到的重点方法:

重点方法

  1. createElm:该方法会根据传入的虚拟 dom 节点创建真实的 dom 并插入到它的父节点中
  2. sameVnode:判断新旧节点是否是同一节点。
  3. patchVnode:当新旧节点是相同节点时,调用该方法直接修改节点,在这个过程中,会利用 diff 算法,循环进行子节点的的比较,进而进行相应的节点复用或者替换。
  4. updateChildren方法:diff 算法的具体实现过程

patch流程

第一步:

判断旧节点是否存在,如果不存在就调用 createElm() 创建一个新的 dom 节点,否则进入第二步判断。

 if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
 }
第二步:

通过 sameVnode() 判断新旧节点是否是同一节点,如果是同一个节点则调用 patchVnode() 直接修改现有的节点,否则进入第三步判断

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // patch existing root node
    /*是同一个节点的时候直接修改现有的节点*/
    patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
第三步:

如果新旧节点不是同一节点,则调用 createElm()创建新的dom,并更新父节点的占位符,同时移除旧节点。

else {
    ....
    createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
    )
     // update parent placeholder node element, recursively
        /*更新父的占位符节点*/
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)  /*调用destroy回调*/
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)  /*调用create回调*/
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }
        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0) /* 删除旧节点 */
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode) /* 调用destroy钩子 */
        }
}
第四步:

返回 vnode.elm,即最后生成的虚拟 dom 对应的真实 dom,将 vm.$el 赋值为这个 dom 节点,完成挂载。

其中重点的过程在第二步和第三步中,特别是 diff 算法对新旧节点的比较和更新很有意思,diff 算法在另外一篇文章来详细介绍 Vue中的diff算法

其他注意点

sameVnode的实际应用

在patch的过程中,如果两个节点被判断为同一节点,会进行复用。这里的判断标准是

1.key相同

2.tag(当前节点的标签名)相同

3.isComment(是否为注释节点)相同

4.data的属性相同

平时写 vue 时会遇到一个组件中用到了 A 和 B 两个相同的子组件,可以来回切换。有时候会出现改变了 A 组件中的值,切到 B 组件中,发现 B 组件的值也被改变成和 A 组件一样了。这就是因为 vue 在 patch 的过程中,判断出了 A 和 B 是 sameVnode,直接进行复用引起的。根据源码的解读,可以很容易地解决这个问题,就是给 A 和 B 组件分别加上不同的 key 值,避免 A 和 B 被判断为同一组件。

虚拟DOM如何映射到真实的DOM节点

vue 为平台做了一层适配层,浏览器平台的代码在 /platforms/web/runtime/node-ops.js。不同平台之间通过适配层对外提供相同的接口,虚拟 dom 映射转换真实 dom 节点的时候,只需要调用这些适配层的接口即可,不需要关心内部的实现。

最后

通过上述的源码和实例的分析,我们完成了 Vue 中 数据渲染 的完整解读。如果想要了解更多的 Vue 源码。欢迎进入我们的github进行查看,里面有Vue源码分析另外几篇文章,另外对 Vue 工程的每一行源码都做了注释,方便大家的理解。~~~~

查看原文

赞 6 收藏 4 评论 1

款冬 发布了文章 · 3月3日

Vue中的diff算法

前言

Vue 数据渲染中最核心的的部分就是 diff算法 的应用,本文从源码入手,结合实例,一步步解析 diff 算法的整个流程。

diff算法简介

diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。diff算法的在很多场景下都有应用,例如在 vue 虚拟 dom 渲染成真实 dom 的新旧 VNode 节点比较更新时,就用到了该算法。diff算法有两个比较显著的特点:

1、比较只会在同层级进行, 不会跨层级比较。
image
2、在diff比较的过程中,循环从两边向中间收拢
image

diff流程

本着对 diff 过程的认识和 vue 源码的学习,我们通过 vue 源码的解读和实例分析来理清楚 diff 算法的整个流程,下面把整个 diff 流程拆成三步来具体分析:

第一步

vue 的虚拟 dom 渲染真实 dom 的过程中首先会对新老 VNode 的开始和结束位置进行标记:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。

let oldStartIdx = 0 // 旧节点开始下标
let newStartIdx = 0 // 新节点开始下标
let oldEndIdx = oldCh.length - 1 // 旧节点结束下标
let oldStartVnode = oldCh[0]  // 旧节点开始vnode
let oldEndVnode = oldCh[oldEndIdx] // 旧节点结束vnode
let newEndIdx = newCh.length - 1 // 新节点结束下标
let newStartVnode = newCh[0] // 新节点开始vnode
let newEndVnode = newCh[newEndIdx] // 新节点结束vnode

经过第一步之后,我们初始的新旧 VNode 节点如下图所示:
image

第二步

标记好节点位置之后,就开始进入到的 while 循环处理中,这里是 diff 算法的核心流程,分情况进行了新老节点的比较并移动对应的 VNode 节点。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    ....//处理逻辑
}

接下来具体介绍 while 循环中的处理逻辑, 循环过程中首先对新老 VNode 节点的头尾进行比较,寻找相同节点,如果有相同节点满足 sameVnode(可以复用的相同节点) 则直接进行 patchVnode (该方法进行节点复用处理),并且根据具体情形,移动新老节点的 VNode 索引,以便进入下一次循环处理,一共有 2 \* 2 = 4 种情形。下面根据代码展开分析:

情形一:当新老 VNode 节点的 start 满足sameVnode 时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加1。
    if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
     }
情形二:当新老 VNode 节点的 end 满足 sameVnode 时,同样直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减1。
else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
情形三:当老 VNode 节点的 start 和新 VNode 节点的 end 满足 sameVnode 时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加1,新 VNode 节点的结束索引减1。
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      }
情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 满足 sameVnode 时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减1,新 VNode 节点的开始索引加1。
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }

如果都不满足以上四种情形,那说明没有相同的节点可以复用,于是则通过查找事先建立好的以旧的 VNode 为 key 值,对应 index 序列为 value 值的哈希表。从这个哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,如果两者满足 sameVnode 的条件,在进行 patchVnode 的同时会将这个真实 dom 移动到 oldStartVnode 对应的真实 dom 的前面;如果没有找到,则说明当前索引下的新的 VNode 节点在旧的 VNode 队列中不存在,无法进行节点的复用,那么就只能调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置。

else {// 没有找到相同的可以复用的节点,则新建节点处理
        /* 生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫) 比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 结果生成{key0: 0, key1: 1, key2: 2} */
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          /*获取同key的老节点*/
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            //因为已经patchVnode进去了,所以将这个老节点赋值undefined
            oldCh[idxInOld] = undefined
            /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }

再来看我们的实例,第一次循环后,找到了旧节点的末尾和新节点的开头(都是D)相同,于是直接复用 D 节点作为 diff 后创建的第一个真实节点。同时旧节点的 endIndex 移动到了 C,新节点的 startIndex 移动到了 C。
image

紧接着开始第二次循环,第二次循环后,同样是旧节点的末尾和新节点的开头(都是C)相同,同理,diff 后创建了 C 的真实节点插入到第一次创建的 B 节点后面。同时旧节点的 endIndex 移动到了 B,新节点的 startIndex 移动到了 E。
image

接下来第三次循环中,发现 patchVnode 的4种情形都不符合,于是在旧节点队列中查找当前的新节点 E,结果发现没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 startIndex 和 endIndex 都保持不动。![]
image

第四次循环中,发现了新旧节点的开头(都是A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了B,新节点的startIndex 移动到了B。
image

第五次循环中,情形同第四次循环一样,因此 diff 后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex 移动到了C,新节点的 startIndex 移动到了F。
image

这时候发现新节点的 startIndex 已经大于 endIndex 了。不再满足循环的条件了。因此结束循环,接下来走后面的逻辑。

第三步

当 while 循环结束后,根据新老节点的数目不同,做相应的节点添加或者删除。若新节点数目大于老节点则需要把多出来的节点创建出来加入到真实 dom 中,反之若老节点数目大于新节点则需要把多出来的老节点从真实 dom 中删除。至此整个 diff 过程就已经全部完成了。

 if (oldStartIdx > oldEndIdx) {
      /*全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多, 所以这时候多出来的新节点需要一个一个创建出来加入到真实Dom中*/
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) //创建 newStartIdx - newEndIdx 之间的所有节点
    } else if (newStartIdx > newEndIdx) {
      /*如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多于新节点,这个时候需要将多余的老节点从真实Dom中移除*/
      removeVnodes(oldCh, oldStartIdx, oldEndIdx) //移除 oldStartIdx - oldEndIdx 之间的所有节点
    }

再回过头看我们的实例,新节点的数目大于旧节点,需要创建 newStartIdx 和 newEndIdx 之间的所有节点。在我们的实例中就是节点 F,因此直接创建 F 节点对应的真实节点放到 B 节点后面即可。
image

最后

通过上述的源码和实例的分析,我们完成了 Vue 中 diff 算法的完整解读。如果想要了解更多的 Vue 源码。欢迎进入我们的github进行查看,里面有Vue源码分析另外几篇文章,另外对 Vue 工程的每一行源码都做了注释,方便大家的理解。~~~~

查看原文

赞 7 收藏 6 评论 2

款冬 收藏了文章 · 1月10日

收藏的关于Vue技巧or实践的文章

刚开始学vue的时候用的是全家桶里的vue-resource,后来发现axios延展性更强。这篇文章简单梳理了axios的API,简洁清晰明了。

了解完axios后下一步就是封装成适应项目的方法,比如拦截请求、状态码相应跳转提示、接口模块化等。个人而言由于项目较复杂,所以用了vuex,接口放在store采用moduls分模块管理。顺便拓展一下:axios请求超时,设置重新请求的完美解决方法

教你如何写一个自定义组件,此处用到了Vue.extend()。没有很惊艳,但是能让你思路眼前一亮or瞬间醍醐灌顶,就像打通任督二脉,都是好文章。

vue-element-admin的作者,vue后台的系列文章有很多值得参考的地方,尤其是统筹和模块管理方面。

五个有用的小技巧,原文在YouTube,评论区有人贴出,可看原视频。

文章繁复,值得参考的地方不多,适合新手看。对于实践过几个项目的人来说,基本api已经熟能生巧。

祖孙、父子、兄弟、隔代信息传递,比较常用的都归纳了。大项目用vuex方便很多,小项目附上一个EventBus的介绍:vue篇之事件总线(EventBus)

3.0用的是TypeScript,当然要跟紧发展的脚步。这篇写的比较基础,对于我这种新手容易理解,学TypeScript建议还是回归到官方文档,能发现自己的宝藏。

关于vuex的用法官网已经清晰,加上实践出真知,所以收藏里没有关于vuex的文章,在掘金找了个简单易懂的。emmm看啥时候写篇自己的总结。


来几篇面试题,最考验基础了


平日没有收藏的习惯(大概知道自己收藏了猴年马月才会看),以上能在收藏找到都是对自身有启发的key。

更多分享内容可以看我的博客

查看原文

认证与成就

  • 获得 112 次点赞
  • 获得 26 枚徽章 获得 2 枚金徽章, 获得 6 枚银徽章, 获得 18 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-02-26
个人主页被 1.2k 人浏览