Giter VIP home page Giter VIP logo

blog's Introduction

未来可期 🏂

您好!我是柯森(Cosen),欢迎来到我的博客!

我会定期发布一些大前端相关的前沿技术文章和日常开发过程中的实战总结。主要还是想和大家一起进步、一起成长。:stuck_out_tongue_winking_eye:

2020的核心会放在框架(主要是vue和react)源码、node、数据库、跨平台(flutter/weex)等方面,我会不断地输出高质量的文章给到大家,也欢迎大家的持续关注!:satisfied:

在路上 🚴

Vue相关 🏈

优雅的在vue中使用TypeScript

React相关 🏉

useTypescript-React Hooks和TypeScript完全指南

React源码分析

创建更新

ReactDOM.render

FiberRoot 和 RootFiber

Update & UpdateQueue

expirationTime

setState 和 forceUpdate

Fiber Scheduler

scheduleWork

requestWork

reactScheduler

performWork

renderRoot

前端工程化 🚀

你不知道的npm

webpack5快发布了,你还没用过4吗?

你可能不知道的9条Webpack优化策略

面试官:webpack原理都不会?

一文带你快速上手Rollup

浏览器相关(缓存、工作原理等)🦊

深入理解浏览器的缓存机制

聊一聊前端性能优化 CRP

“根深蒂固”的食粮 🏇

从图片裁剪来聊聊前端二进制

规范相关(git规范、编码规范等)🐳

你可能已经忽略的git commit规范

面试 ✏

「源码级回答」大厂高频Vue面试题(上)

「源码级回答」大厂高频Vue面试题(中)

Node相关 🙈

Koa2+MongoDB+JWT实战--Restful API最佳实践

你真的了解mongoose吗?

关于koa2,你不知道的事

数据结构与算法 🍪

「面试必问」leetcode高频题精选

架构/设计 👨‍🌾

从 Element UI 源码的构建流程来看前端 UI 库设计

Chrome相关🍩

Chrome DevTools中的这些*操作,你都知道吗?

客户端应用 🍓

万物皆可快速上手之Electron(第一弹)

Hybrid(混合开发)🦁

浅谈Hybrid

“高深莫测”的JavaScript 👨‍🚀

JavaScript中的这些*操作,你都知道吗?

好玩儿的css 🎃

滚动视差让你不相信“眼见为实”

好玩儿的css

*操作/技巧 🦐

你可能不知道的15个有用的Github功能

那些前端开发必不可少的生产力工具

互联网趣事 🌰

GitHub迎来重大变更:可以直接用vscode编码了!

那个男人 他带着Vue3来了~

交流 🍻

同时我也是公众号「前端森林」的作者,欢迎关注!

前端森林公众号二维码2

如果你想进群跟诸多一线大厂的大佬交流学习,先进入前端森林公众号👉点击“关于我 > 初心”,加我微信,我拉你入群

blog's People

Contributors

cosen95 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

Vue源码探秘(计算属性computed)

引言

计算属性(computed)是 Vue中比较强大又十分重要的功能 ,它具有 分离逻辑缓存值双向绑定 等作用或功能。

本节我就带大家一起来看下计算属性 computed部分的源码。

computed

官方文档对于计算属性的使用时机有一个很清晰的说明:如果模板内的逻辑过于复杂,那就应该考虑使用计算属性来代替。

这里引用官方的示例来说明计算属性的用法:

// App.vue

<template>
  <div id="app">
    {{ reverseName }}
  </div>
</template>
<script>
  export default {
    data() {
      return {
        name: 'forest'
      }
    },
    computed: {
      reverseName: function() {
        return this.name.split('').reverse().join();
      }
    }
  }
</script>

大致了解了computed的用法后,和之前一样,为了更清晰的了解源码的执行过程,我下面将会结合一个例子来分析源码。

从一个简单示例开始

看下这个例子:

// App.vue

<template>
  <div id="app">
    <p>{{ name }}</p>
    <button @click="handleToggleShow">toggleShow</button>
    <button @click="changeName">change</button>
  </div>
</template>
<script>
  export default {
    data() {
      return {
        firstName: 'jack',
        lastName: 'cool',
        isShow: false
      }
    },
    computed: {
      name() {
        return this.isShow ? `${this.firstName}, ${this.lastName}` : 'please click the toggleShow button';
      }
    },
    methods: {
      handleToggleShow() {
        this.isShow = true;
      },
      changeName() {
        this.lastName = 'rose';
      }
    }
  }
</script>

在这个例子中,计算属性 name 依赖了三个响应式数据 firstNamelastNameisShow

需要注意计算属性依赖的数据必须是响应式的,否则依赖的数据发生变化并不会触发计算属性的变化。

接下来会先介绍这个例子的初始化到渲染的整个过程,然后再介绍点击 toggleShowchange 按钮时对应源码的执行过程。

计算属性的渲染过程

在前面分析组件化的时候,我们知道组件实例化前要先通过 Vue.extend 函数来创建组件构造函数:

// src/core/global-api/extend.js

Vue.extend = function (extendOptions: Object): Function {
  // ...
  if (Sub.options.computed) {
    initComputed(Sub);
  }
  // ...
};

Sub 也就是我们例子中 App 组件的构造函数,这里 Vue.extend 函数判断如果组件 options 中有 computed ,则执行 initComputed 函数,并且将 Sub 传进去:

// src/core/global-api/extend.js

function initComputed(Comp) {
  const computed = Comp.options.computed;
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key]);
  }
}

initComputed 函数拿到 computed 对象然后遍历每一个计算属性调用 defineComputed 方法,将组件原型,计算属性和对应的值传入。来看 defineComputed 函数的定义:

// src/core/instance/state.js
export function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering();
  if (typeof userDef === "function") {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef);
    sharedPropertyDefinition.set = noop;
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  if (
    process.env.NODE_ENV !== "production" &&
    sharedPropertyDefinition.set === noop
  ) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      );
    };
  }
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

首先定义了 shouldCache 表示是否需要缓存值。接着对 userDef 是函数或者对象分别处理。这里有一个 sharedPropertyDefinition ,我们来看它的定义:

// src/core/instance/state.js
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop,
};

sharedPropertyDefinition其实就是一个属性描述符,这个在之前的章节,我们也有分析这块。

回到 defineComputed 函数。如果 userDef 是函数的话,就会定义 getter 为调用 createComputedGetter(key) 的返回值。

因为 shouldCachetrue

userDef 是对象的话,非服务端渲染并且没有指定 cachefalse 的话,getter 也是调用 createComputedGetter(key) 的返回值,setter 则为 userDef.set 或者为空。

所以 defineComputed 函数的作用就是定义 gettersetter ,并且在最后调用 Object.defineProperty 给计算属性添加 getter/setter ,当我们访问计算属性时就会触发这个 getter

对于计算属性的 setter 来说,实际上是很少用到的,除非我们在使用 computed 的时候指定了 set 函数。

无论是userDef是函数还是对象,最终都会调用createComputedGetter函数,我们来看createComputedGetter的定义:

function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate();
      }
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value;
    }
  };
}

可以看到,createComputedGetter 返回了一个 computedGetter 函数,也就是说计算属性的 getter 就是这个 computedGetter 函数。

我们知道访问计算属性时才会触发这个 getter,对应就是computedGetter函数被执行。所以这块逻辑在被调用时再来分析。

在创建了组件构造函数后,就会进行组件实例化。经过前面的学习,我们知道在组件实例化时会调用各种 init 函数做初始化工作,在执行 initState 的时候:

// src/core/instance/state.js

export function initState(vm: Component) {
  // ...
  const opts = vm.$options;
  if (opts.computed) initComputed(vm, opts.computed);
  // ...
}

这里opts.computed是存在的,所以会执行initComputed函数:

function initComputed(vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = (vm._computedWatchers = Object.create(null));
  // computed properties are just getters during SSR
  const isSSR = isServerRendering();

  for (const key in computed) {
    // ...
  }
}

这里 initComputed 函数的 computed 参数就是我们组件中的 computed 对象。函数首先定义了两个常量,watchers 指向 vm._computedWatchers ,是一个空对象,而 isSSR 表示服务端渲染,这里为 false

接着就是遍历 computed 对象,我们分段来分析:

for (const key in computed) {
  const userDef = computed[key];
  const getter = typeof userDef === "function" ? userDef : userDef.get;
  if (process.env.NODE_ENV !== "production" && getter == null) {
    warn(`Getter is missing for computed property "${key}".`, vm);
  }
  // ...
}

这个 getter 是我们自己编写的 computed 中的函数,也就是例子中的 name() 函数。

从源码可以看到,computed 有两种写法,一种是直接写一个函数,一种是一个对象,同时有一个 get 属性作为 getter 。如果拿不到 getter 的话就抛出警告。继续往下看:

for (const key in computed) {
  // ...
  if (!isSSR) {
    // create internal watcher for the computed property.
    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    );
  }
  // ...
}

这里判断如果不是服务端渲染就会给计算属性创建一个 computed Watcher 实例赋值给watchers[key](对应就是vm._computedWatchers[key])。

这也说明了计算属性是通过 Watcher 来实现。

我们来看 computed Watcher 的实例化过程是怎么样的,回顾 Watcher 的定义:

// src/core/observer/watcher.js
/**
 * A watcher parses an expression, collects dependencies,
 * and fires callback when the expression value changes.
 * This is used for both the $watch() api and directives.
 */
export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  this.vm = vm
  if (isRenderWatcher) {
    vm._watcher = this
  }
  vm._watchers.push(this)
  // options
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new Set()
  this.newDepIds = new Set()
  this.expression = process.env.NODE_ENV !== 'production'
    ? expOrFn.toString()
    : ''
  // parse expression for getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) {
      this.getter = noop
      process.env.NODE_ENV !== 'production' && warn(
        `Failed watching path: "${expOrFn}" ` +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      )
    }
  }
  this.value = this.lazy
    ? undefined
    : this.get()
}

前面创建 computed Watcher 传入的四个参数分别是:vmgetternoopcomputedWatcherOptions{ lazy: true })。

因为这里是computed Watcher,所以 if (isRenderWatcher) 不会执行,另外 this.dirty = this.lazy = true 。接着会将 getter 也就是我们编写的 name 函数赋值给 this.getter

最后 this.value = undefined ,也就是说不会像渲染 Watcher 一样实例化时就执行get 函数调用 getter 求值。

回到 initComputed 函数的 for 循环,还有最后一段代码:

// src/core/instance/state.js
for (const key in computed) {
  // ...
  // component-defined computed properties are already defined on the
  // component prototype. We only need to define computed properties defined
  // at instantiation here.
  if (!(key in vm)) {
    defineComputed(vm, key, userDef);
  } else if (process.env.NODE_ENV !== "production") {
    if (key in vm.$data) {
      warn(`The computed property "${key}" is already defined in data.`, vm);
    } else if (vm.$options.props && key in vm.$options.props) {
      warn(`The computed property "${key}" is already defined as a prop.`, vm);
    }
  }
}

最后一段逻辑实际上是不会执行的。if 逻辑判断的是当前计算属性有没有定义在我们的 App 组件实例上,而前面在创建 App 组件构造函数的时候,已经调用 defineComputed 函数把计算属性定义在组件原型上了,所以每个组件实例都能访问到计算属性。

else if 逻辑是判断 key 有没有定义在 dataprops 上,因为计算属性是不能和 dataprops 重名的,如果重名会抛出警告。这样整个 initComputed 函数的逻辑我们就分析完了。

接着 App 组件来到渲染阶段,我们知道渲染阶段会执行 render 函数创建 VNode ,而这个过程会访问到计算属性,这样就触发了计算属性的 getter 也就是前面提到的 computedGetter 函数:

return function computedGetter() {
  const watcher = this._computedWatchers && this._computedWatchers[key];
  if (watcher) {
    if (watcher.dirty) {
      watcher.evaluate();
    }
    if (Dep.target) {
      watcher.depend();
    }
    return watcher.value;
  }
};

computedGetter 函数首先通过 this._computedWatchers[key] 拿到前面实例化组件时创建的 computed Watcher 并赋值给 watcher 。接着有两个 if 判断,首先调用 evaluate 函数:

/**
 * Evaluate the value of the watcher.
 * This only gets called for lazy watchers.
 */
evaluate () {
  this.value = this.get()
  this.dirty = false
}

首先调用 this.get() 将它的返回值赋值给 this.value ,回顾 get 函数:

// src/core/observer/watcher.js
/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get 函数第一步是调用 pushTargetcomputed Watcher 传入:

// src/core/observer/dep.js
export function pushTarget(target: ?Watcher) {
  targetStack.push(target);
  Dep.target = target;
}

可以看到 computed Watcher 被 push 到 targetStack 同时将 Dep.target 置为 computed Watcher 。而 Dep.target 原来的值是渲染 Watcher ,因为正处于渲染阶段。回到 get 函数,接着就调用了 this.getter

中间具体过程之前的章节已经介绍了,这里不在赘述 。最后 get 函数会执行 popTarget()Dep.target 重新恢复为渲染 Watcher ,然后将 value 返回出去。

回到 evaluate 函数:

evaluate () {
  this.value = this.get()
  this.dirty = false
}

执行完get函数,将dirty置为false

回到computedGetter函数,接着往下进入另一个if判断,执行了depend函数:

// src/core/observer/watcher.js
/**
 * Depend on all deps collected by this watcher.
 */
depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

这里的逻辑就是让 Dep.target 也就是渲染 Watcher 订阅了 this.dep 也就是前面实例化 computed Watcher 时候创建的 dep 实例,渲染 Watcher 就被保存到 this.depsubs 中。

在执行完 evaluatedepend 函数后,computedGetter 函数最后将 evaluate 的返回值返回出去,也就是计算属性最终计算出来的值,这样页面就渲染出来了。

计算属性的修改过程

在例子中,当我们点击 toggleShow 按钮时,会修改 data 中的 isShow ,就会触发 isShowsetter

export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep();
  // ...
  const setter = property && property.set;
  // ...
  let childOb = !shallow && observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // ...
    },
    set: function reactiveSetter(newVal) {
      // ...
      dep.notify();
    },
  });
}

setter 的执行流程在 Vue源码探秘(派发更新) 那一节也有介绍过, 这里重点看 setter 的最后一步,也就是执行 dep.notify()派发更新:

// src/core/observer/dep.js
notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort((a, b) => a.id - b.id)
  }
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

notify 函数的主要逻辑就是遍历 subs 中的 Watcher 执行 update 方法。而前面也分析了 computed Watcher 订阅了计算属性依赖的 data 的变化,所以这里的 subs 存放的就是 computed Watcher ,执行了 computed Watcherupdate 方法:

// src/core/observer/watcher.js
/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

渲染 Watcherupdate 方法走的是 else 逻辑执行 queueWatcher 方法。queueWatcher 函数的具体执行流程在Vue源码探秘(派发更新)那一节已经分析过了,这里就不再分析。最终页面会重新渲染。

总结

这一节我们学习了计算属性的初始化渲染过程和依赖改变时重新渲染的过程,了解到了计算属性本质上就是一个 computed watcher

下一节,我将带大家一起探秘侦听器(watch)部分的源码。

万物皆可快速上手之Electron(第一弹)

最近在开发一款桌面端应用,用到了ElectronReact

React作为日常使用比较频繁的框架,这里就不详细说明了,这里主要是想通过几篇文章让大家快速上手Electron以及与React完美融合。

本篇是系列文章的第一篇,主要是给大家分享Electron的一些概念,让大家对Electron有一个初步的认知。

先来了解一下什么是Electron吧,可能很多小伙伴还没有听过Electron,相信很多小伙伴此时的表情是这样的:

看下官网的自我介绍:

Electron 是一个可以使用 Web 技术如 JavaScriptHTMLCSS 来创建跨平台原生桌面应用的框架。借助 Electron,我们可以使用纯 JavaScript 来调用丰富的原生 APIs

Electronweb 页面作为它的 GUI,而不是绑定了 GUI 库的 JavaScript。它结合了 ChromiumNode.js 和用于调用操作系统本地功能的 APIs(如打开文件窗口、通知、图标等)。

上面这张图很好的说明了Electron的强大之处。

正因如此,现在已经有很多由Electron开发的应用,比如AtomVisual Studio Code等。我们可以在Apps Built on Electron看到所有由Electron构建的项目。

快速开始

前面说了那么多废话,下面进入正题,带大家用五分钟(为什么是五分钟?我猜的 🐶 )的时间运行一个ElectronHello World

安装

这一步很简单:

npm install electron -g

第一个 Electron 应用

一个最简单的 Electron 应用目录结构如下:

hello-world/
├── package.json
├── main.js
└── index.html

package.json的格式和 Node 的完全一致,并且那个被 main 字段声明的脚本文件是你的应用的启动脚本,它运行在主进程上。你应用里的 package.json 看起来应该像:

{
  "name": "hello-world",
  "version": "0.1.0",
  "main": "main.js"
}

创建main.js文件并添加如下代码:

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
const path = require("path");
let mainWindow;

app.on("ready", () => {
  mainWindow = new BrowserWindow({
    width: 1024,
    height: 680,
    webPreferences: {
      nodeIntegration: true,
      // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
      enableRemoteModule: true,
    },
  });
  // https://www.electronjs.org/docs/api/browser-window#event-ready-to-show
  // 在加载页面时,渲染进程第一次完成绘制时,如果窗口还没有被显示,渲染进程会发出 ready-to-show 事件 。 在此事件后显示窗口将没有视觉闪烁
  mainWindow.once("ready-to-show", () => {
    mainWindow.show();
  });
  const urlLocation = `file://${__dirname}/index.html`;
  mainWindow.loadURL(urlLocation);
});

然后是index.html文件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Hello World!</title>
    <style media="screen">
      .version {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>Hi! 我是柯森!</h1>
  </body>
</html>

到这里main.jsindex.htmlpackage.json 这几个文件都有了。万事俱备,来运行这个项目。因为前面已经全局安装了electron,所以我们可以使用 electron 命令来运行项目。在 hello-world/ 目录里面运行下面的命令:

$ electron .

你会发现会弹出一个 electron 应用客户端,如图所示:

到这里,我们已经完成了一个最简单的electron 应用。

但你一定会对上面用到的一些api有疑惑,下面我将带大家深入浅出的了解一下electron的常用概念和api

相关概念

Electron 的进程分为主进程和渲染进程。在说这个之前,我觉得有必要先说一下进程和线程的概念。

进程和线程

这里参考的是廖雪峰老师关于进程和线程概念的阐述,我觉得说的清晰明了。

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

主进程和渲染进程

主进程

electron 里面,运行 package.json 里面 main 脚本的进程被称为主进程。主进程控制整个应用的生命周期,在主进程中可以创建 Web 形式的 GUI,而且整个 Node API 是内置其中。

渲染进程

由于 Electron 使用 Chromium 来展示页面,所以 Chromium 的多进程架构也被充分利用。每个 Electron 的页面都在运行着自己的进程,这样的进程我们称之为渲染进程

在一般浏览器中,网页通常会在沙盒环境下运行,并且不允许访问原生资源。然而,Electron 用户拥有与底层操作系统直接交互的能力。

主进程与渲染进程的区别

主进程使用BrowserWindow实例创建页面。每个BrowserWindow实例都在自己的渲染进程里运行页面。当一个BrowserWindow实例被销毁后,相应的渲染进程也会被终止。

主进程管理所有页面和与之对应的渲染进程。每个渲染进程都是相互独立的,并且只关心他们自己的页面。

electron 中,页面不直接调用底层 APIs,而是通过主进程进行调用。所以如果你想在网页里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

electron 中,主进程和渲染进程的通信主要有以下几种方式:

  • ipcMain、ipcRender
  • Remote 模块

进程通信将稍后在下文详细介绍。

BrowserWindow 的创建

BrowserWindow用于创建和控制浏览器窗口。像上面的hello-world中:

mainWindow = new BrowserWindow({
  width: 1024,
  height: 680,
  webPreferences: {
    nodeIntegration: true,
    // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
    enableRemoteModule: true,
  },
});

const urlLocation = `file://${__dirname}/index.html`;
mainWindow.loadURL(urlLocation);

创建了一个1024*680的窗口,并通过loadURL方法来加载了一个本地的html文件。

这里一般会通过区分环境加载对应不同的文件。

进程间的通信

在计算机系统设计中,不同的进程间内存资源都是相互隔离的,因此进程间的数据交换,会使用进程间通讯方式达成。而不同于一般的原生应用开发,Electron 的渲染进程与主进程分别属于独立的进程中,而且进程间会存在频繁的数据交换,这时选择一个合理的进程间通讯方式显得尤为重要。下面是 Electron 中官方提供的进程间通讯方式:

window.postMessage,LocalStorage

在前端开发中,鉴于浏览器对本地数据有严格的访问限制,所以一般通过该两种方式进行窗口间的数据通讯,该方式同样适用于 Electron 开发中。然而因为 API 设计目的仅仅是为了前端窗口间简单的数据传输,大量以及频繁的数据通讯会导致应用结构松散,同时传输效率也值得怀疑。

使用IPC进行通信

Electron 中提供了 ipcRenderipcMain 作为主进程以及渲染进程间通讯的桥梁,该方式属于 Electron 特有传输方式,不适用于其他前端开发场景。Electron 沿用 Chromium 中的 IPC 方式,不同于 sockethttp 等通讯方式,Chromium 使用的是命名管道 IPC ,能够提供更高的效率以及安全性。

主进程收发信息

详细参考ipcMain

  • 主进程接收渲染进程发送的信息
ipcMain.on("message", (e, msg) => {
  console.log(msg);
});
  • 主进程(主窗口)发送信息给渲染进程
mainWindow.webContents.send('message', { name: 'from the main by cosen' });

渲染进程收发信息

通过ipcRenderer发送或接收

  • 渲染进程接收主进程发送的信息
ipcRenderer.on("message", (e, msg) => {
  console.log(msg);
});
  • 渲染进程发送信息给主进程
ipcRenderer.send("message", { name: "Cosen" });

使用remote实现跨进程访问

remote 模块提供了一种在渲染进程(网页)和主进程之间进行进程间通讯(IPC)的简便途径。

Electron中, 与GUI相关的模块(如 dialog, menu 等)只存在于主进程,而不在渲染进程中 。为了能从渲染进程中使用它们,需要用ipc模块来给主进程发送进程间消息。使用 remote 模块,可以调用主进程对象的方法,而无需显式地发送进程间消息。

总结

本小节我们大概的了解了Electron的一些概念以及运行了一个入门的hello-world程序。但这远远还不够,下一节我会讲一下如何将ElectronReact完美融合,毕竟还是要更贴近业务的~

好了,不早了,我要去开启我的网易云时光了 🤖

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

深入理解浏览器的缓存机制

引言

浏览器缓存,一个经久不衰的话题。

先来看一下百度百科对它的定义:

浏览器缓存(Browser Caching)是为了节约网络的资源加速浏览,浏览器在用户磁盘上对最近请求过的文档进行存储,当访问者再次请求这个页面时,浏览器就可以从本地磁盘显示文档,这样就可以加速页面的阅览。

缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。

本篇文章会从缓存位置、缓存过程分析、缓存类型、缓存机制、缓存策略以及用户行为对浏览器缓存的影响几方面带你一步步深入了解浏览器缓存。

缓存位置

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

Memory Cache

Memory Cache 也就是内存中的缓存,主要包含的是当前页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放(一旦我们关闭 Tab 页面,内存中的缓存也就被释放了)。

内存缓存中有一块重要的缓存资源是 preloader 相关指令(例如<link rel="prefetch">)众所周知 preloader 的相关指令已经是页面优化的常见手段之一,它可以一边解析 js/css 文件,一边网络请求下一个资源。

Disk Cache

Disk Cache 也就是存储在硬盘中的缓存,读取速度虽然慢点,但是什么都能存储到磁盘中,与 Memory Cache 相比,优势是容量和存储时效性。

在所有浏览器缓存中,Disk Cache 覆盖面基本上是最大的。它会根据 HTTP Header 中的字段判断哪些资源缓存(不用慌,关于 HTTP 的协议头中的缓存字段,会在下面详细介绍的),哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。

浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?

关于这点,网上说法不一,不过以下两点比较靠得住:

  • 对于大文件来说,大概率是不存储在内存中的
  • 当前系统内存使用率高的话,文件优先存进硬盘

Push Cache

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂。

如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。

为了性能上的考虑,大部分的接口都应该选择好缓存策略,通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。

缓存过程分析

浏览器与服务器通信的方式为应答模式,即: 浏览器发起 HTTP 请求 >> 服务器响应该请求,那么浏览器怎么确定一个资源该不该缓存,如何去缓存呢?浏览器第一次向服务器发起该请求后拿到请求结果后,将请求结果和缓存标识存入浏览器缓存,浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。具体过程如下图:

由上图我们可以知道:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识。
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中。

以上两点是浏览器缓存机制的关键,它确保了每个请求的缓存存入与读取。下面说一下浏览器缓存的使用规则。根据是否需要向服务器重新发起 HTTP 请求将缓存过程分为两个部分,分别是强缓存和协商缓存。

强缓存

强缓存: 不会向服务器发起请求,直接从缓存中读取资源,在 chrome 控制台的 Network 选项中可以看到该请求返回 200 的状态码,并且size显示from disk cachefrom memory cache。强缓存可以通过设置两种 HTTP Header 实现: Expires 和 Cache-Control

1、 Expires

缓存过期时间,用来指定资源到期的时间,是服务端的具体时间点。也就是说,Expires=max-age + 请求时间,需要和 Last-modified 结合使用。Expires 是 Web 服务器响应消息头字段,在响应 http 请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求。

Expires 是 HTTP/1 的产物,受限于本地时间,如果修改了本地时间,可能会造成缓存失效。

2、 Cache-Control

在 HTTP/1.1 中,Cache-Control 是最重要的规则,主要用于控制网页缓存。

Cache-Control 可以在请求头或者响应头中设置,并且可以组合使用多种指令:

  • public: 表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容(例如,该响应没有max-age指令或Expires消息头)。

  • private: 表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容。

  • no-cache: 在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证。

  • no-store: 缓存不应存储有关客户端请求或服务器响应的任何内容。

  • max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。

  • s-maxage: 覆盖max-age或者Expires头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。

  • max-stale: 表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。

  • min-fresh: 表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。

3、 Expires 和 Cache-Control 两者对比

其实这两者差别不大,区别就在于 Expires 是 http1.0 的产物,Cache-Control 是 http1.1 的产物,两者同时存在的话,Cache-Control 优先级高于 Expires;在某些不支持 HTTP1.1 的环境下,Expires 就会发挥用处。所以 Expires 其实是过时的产物,现阶段它的存在只是一种兼容性的写法。

强缓存判断是否缓存的依据来自于是否超出某个时间或者某个时间段,而不关心服务器端文件是否已经更新,这可能会导致加载文件不是服务器端最新的内容,那我们如何获知服务器端内容是否已经发生了更新呢?此时我们需要用到协商缓存策略。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况:

  • 协商缓存生效,返回 304 和 Not Modified

  • 协商缓存成功,返回 200 和请求结果

协商缓存可以通过设置两种 HTTP Header 实现: Last-Modified 和 ETag

缓存机制

强制缓存优先于协商缓存进行,若强制缓存 (Expires 和 Cache-Control) 生效则直接使用缓存,若不生效则进行协商缓存 (Last-Modified / If-Modified-Since 和 Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。具体流程图如下:

实际场景应用缓存策略

  • 频繁变动的资源

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

  • 不常变化的资源

通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名 (或者路径) 中添加 hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。

用户行为对浏览器缓存的影响

所谓用户行为对浏览器缓存的影响,指的就是用户在浏览器如何操作时,会触发怎样的缓存策略。主要有 3 种:

  • 打开网页,地址栏输入地址: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求;

  • 普通刷新 (F5):因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用 (如果匹配的话)。其次才是 disk cache;

  • 强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache), 服务器直接返回 200 和最新内容。

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

GitHub迎来重大变更:可以直接用vscode编码了!

GitHub在本周的Satellite 2020活动中宣布了一些新功能和更新,涵盖了云、协作、安全性等。

与其他技术公司一样,由于COVID-19危机,微软拥有的代码托管平台已选择将其年度开发者活动移至在线,Satellite 2020也是GitHub本年度的首次虚拟会议。

GitHub Codespaces

这次活动最大的亮点是一个名为GitHub Codespaces的发布,该产品旨在使开发人员更轻松地加入项目,启动开发人员环境并以最少的配置开始编码-所有这些都来自浏览器。

“向社区贡献代码可能很困难。每个存储库都有其自己的配置开发环境的方式,在编写任何代码之前,这通常需要数十个步骤,” GitHub 产品高级副总裁Shanku Niyogi写道。“ 更糟糕的是,有时您正在从事的两个项目的环境彼此冲突。GitHub Codespaces为您提供了一个功能全面的云托管开发环境,可在几秒钟内直接在GitHub内启动,因此您可以立即开始为项目做贡献。”

Codespaces从本周开始在“有限的公共 beta”中可用,它是一个具有GitHub所有功能的云托管开发环境,可以将其设置为加载开发人员的代码以及依赖项,扩展名和dotfile,并包含一个内置的调试器。

值得一提的是,微软去年推出了一个名为Visual Studio OnlineVisual Studio在线版本,最近又将其更名为Visual Studio Codespaces。这为新的GitHub Codespace的构建块提供了强烈的暗示-这很有可能是MicrosoftVisual Code的品牌和基于浏览器的功能引入GitHub

当前,GitHub Codespaces处于beta版,可免费使用。该公司上线后尚未为该服务定价,但Niyogi表示,该价格将与GitHub Actions相似。

GitHub Discussions

这次活动宣布的另一个主要新功能是GitHub Discussions,开发人员可以在其中提出问题并就项目存储库中的特定问题或主题进行交流。在此之前,这样的讨论只能通过issuespull requests来进行。

通过GitHub DiscussionsGitHub现在正在寻求在主要代码库之外建立社区知识库,事实上,似乎正在着手实现类似于Stack Overflow的目标。讨论围绕主题进行,可以将问题标记为“已回答”以供将来参考。

GitHub Discussions已经在几个开源代码社区中以有限的私人Beta版本提供了一段时间,该公司表示,它将在今年夏天向所有开源代码社区开放。

代码扫描和秘密扫描

在安全方面,GitHub 还宣布了两个新功能:代码扫描和秘密扫描。代码扫描会检查您的代码中是否存在潜在的安全漏洞。它由CodeQL支持,对于开源项目免费。

秘密扫描(以前称为令牌扫描),它可以帮助公司识别代码中的加密秘密,以便在不良行为者将其拦截之前将其撤销。自 2018 年以来,秘密扫描已可用于公共存储库,现在也可用于私有存储库。

这两个功能都是GitHub Advanced Security的一部分。
前端森林公众号二维码

requestWork

requestWork

首先说明以下requestWork的核心功能:

  • root节点加入到root调度队列中
  • 判断是否是批量更新
  • 最后根据expirationTime的类型判断调度的类型

下面,我们来详细分析下requestWork的流程。首先来看requestWork

// requestWork is called by the scheduler whenever a root receives an update.
// It's up to the renderer to call renderRoot at some point in the future.
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
  // 把当前 root设置为最高优先级
  addRootToSchedule(root, expirationTime);
  if (isRendering) {
    // 在render过程当中, 此时直接return
    // Prevent reentrancy. Remaining work will be scheduled at the end of
    // the currently rendering batch.
    return;
  }

  // 批量处理相关
  // 调用 setState 时在 enqueueUpdates 前 batchedUpdates 会把 isBatchingUpdates 设置成 true
  if (isBatchingUpdates) {
    // Flush work at the end of the batch.
    if (isUnbatchingUpdates) {
      // ...unless we're inside unbatchedUpdates, in which case we should
      // flush it now.
      nextFlushedRoot = root;
      nextFlushedExpirationTime = Sync;
      performWorkOnRoot(root, Sync, false);
    }
    return;
  }

  // TODO: Get rid of Sync and use current time?
  if (expirationTime === Sync) {
    // 同步的调用 js 代码
    performSyncWork();
  } else {
    // 异步调度 独立的 react 模块包,利用浏览器有空闲的时候进行执行,设置 deadline 在此之前执行
    scheduleCallbackWithExpirationTime(root, expirationTime);
  }
}

首先调用了addRootToSchedule

/**
 * 将 root 加入到调度队列
 *
 * @param {FiberRoot} root
 * @param {ExpirationTime} expirationTime
 */
function addRootToSchedule(root: FiberRoot, expirationTime: ExpirationTime) {
  // Add the root to the schedule.
  // Check if this root is already part of the schedule.

  // root.nextScheduledRoot 用来判断是否有异步任务正在调度, 为 null 时会增加 nextScheduledRoot
  // 这个 root 还没有进入过调度
  if (root.nextScheduledRoot === null) {
    // This root is not already scheduled. Add it.

    root.expirationTime = expirationTime;
    if (lastScheduledRoot === null) {
      // lastScheduledRoot firstScheduledRoot 是单向链表结构,表示多个 root 更新
      // 这里只有一个 root 只会在这里执行
      firstScheduledRoot = lastScheduledRoot = root;
      root.nextScheduledRoot = root;
    } else {
      // 有个多个root 时进行单向链表的插入操作
      lastScheduledRoot.nextScheduledRoot = root;
      lastScheduledRoot = root;
      lastScheduledRoot.nextScheduledRoot = firstScheduledRoot;
    }
  } else {
    // This root is already scheduled, but its priority may have increased.
    // 传入的 root 已经进入过调度, 把 root 的优先级设置最高
    const remainingExpirationTime = root.expirationTime;
    // 如果 root 的 expirationTime 是同步或者优先级低,增加为计算出的最高优先级
    if (expirationTime > remainingExpirationTime) {
      // Update the priority.
      // 把当前 root 的优先级设置为当前优先级最高的
      root.expirationTime = expirationTime;
    }
  }
}

作用比较清晰:

  • 判断当前root是否调度过, 单个或多个root构建成单向链表结构
  • 如果调度过,设置当前任务优先级最高

回到requestWork,分成了三个分支:

  • isRendering
  • isBatchingUpdates
  • expirationTime === Sync

我们来分别解释一下:

  • isRendering

isRendering 顾名思义在render过程当中, 此时直接返回return

batchedUpdate批处理的时候,将isRendering置成true,这样每次在执行setState时,都会走isRendering的分支直接返回,阻止更新,相当于暂停住,当第二个setState时依旧直接返回,避免出现频繁式的更新,也就是批处理。

  • isBatchingUpdates

isBatchingUpdates中又有一个isUnbatchingUpdates分支。isUnbatchingUpdates会在执行unbatchedUpdates()时设置为true。 也就是在首次渲染的时候不需要进行批处理,所以就会进行立即更新。 这里的两个变量名可能会造成误解,isBatchingUpdatesisUnbatchingUpdates这两个变量并不是互斥的,而是在两个方法中设置的哨兵变量。

如果是批处理的过程中的话,也会直接return

  • expirationTime === Sync

当前面的expirationTime被设置成了同步任务的时候,就会立即执行。 剩余的则会进入scheduleCallbackWithExpirationTime 通过ExpirationTime 进行调度。

Vue源码探秘(实例挂载$mount)

引言

本篇文章,我们来分析一下vm.$mount内部具体发生了什么。

实例挂载($mount)

我们知道$mount被定义在src/platforms/web/runtime/index.js中:

// public mount method
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined;
  return mountComponent(this, el, hydrating);
};

其实不仅在这里有定义$mount方法,在src/platform/web/entry-runtime-with-compiler.jssrc/platform/weex/runtime/index.js都有定义。因为$mount方法的实现是和平台、构建方式都相关的。

在运行时(Runtime Only)版本的Vue中,调用的就是上面的这个$mount函数。而在完整版(Runtime + Compiler)的Vue中,$mount函数在src/platform/web/entry-runtime-with-compiler.js中被重写,这部分代码是我们这里要着重分析的。先看一下整体结构:

// src/platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el);

  // ...

  return mount.call(this, el, hydrating);
};

这里先拿到了Runtime Only版本的$mount方法,然后进行重写,最后又调用了Runtime Only版本的$mount方法。参数的类型检查表明el可以是字符串或DOM节点。接下来又调用了query方法:

// src/platforms/web/util/index.js
/**
 * Query an element selector if it's not an element already.
 */
export function query(el: string | Element): Element {
  if (typeof el === "string") {
    const selected = document.querySelector(el);
    if (!selected) {
      process.env.NODE_ENV !== "production" &&
        warn("Cannot find element: " + el);
      return document.createElement("div");
    }
    return selected;
  } else {
    return el;
  }
}

query函数的逻辑比较简单: 如果el是一个字符串,就调用querySelector获取节点并返回;如果节点不存在就抛出警告并创建一个div节点。如果el是一个节点就直接返回。

我们接着往下分析,先来看第一小段:

/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
  process.env.NODE_ENV !== "production" &&
    warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    );
  return this;
}

这里去检查el是不是根节点(html)、(body),如果是就抛出警告并停止挂载。

Vue是不能挂载在bodyhtml这样的根节点上的,因为挂载实际上就是把el节点替换为组件的模版。

继续往下看:

const options = this.$options;
// resolve template/el and convert to render function
if (!options.render) {
  let template = options.template;
  if (template) {
    // [1] ...
  } else if (el) {
    template = getOuterHTML(el);
  }
  // [2] ...
}

首先判断render函数是否存在,如果未定义则需做进一步处理。

Vue2.0 开始,所有组件的渲染都需要用到render函数,无论是我们上一节的例子还是使用.vue文件编写。

进入if判断后先拿到options.template,如果template存在就执行[1]处的代码:

if (typeof template === "string") {
  if (template.charAt(0) === "#") {
    template = idToTemplate(template);
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== "production" && !template) {
      warn(`Template element not found or is empty: ${options.template}`, this);
    }
  }
} else if (template.nodeType) {
  template = template.innerHTML;
} else {
  if (process.env.NODE_ENV !== "production") {
    warn("invalid template option:" + template, this);
  }
  return this;
}

先判断template是否是字符串,如果是字符串而且是id选择器,通过idToTemplate方法拿到相应节点,如果拿不到会抛出警告。如果是字符串但不是选择器,不作处理。

如果template是一个节点,那么获取它的innerHTML

如果template既不是字符串也不是一个节点,那么抛出警告并结束挂载。

如果template不存在,接着判断el是否存在,存在则执行template = getOuterHTML(el)。来看下getOuterHTML函数:

// src/platforms/web/entry-runtime-with-compiler.js
/**
 * Get outerHTML of elements, taking care
 * of SVG elements in IE as well.
 */
function getOuterHTML(el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML;
  } else {
    const container = document.createElement("div");
    container.appendChild(el.cloneNode(true));
    return container.innerHTML;
  }
}

这里判断el.outerHTML是否存在,有就返回outerHTML

IE9-11SVG 标签元素是没有 innerHTMLouterHTML 这两个属性的。

else中就是对以上情况的兼容处理: 在el的外面包装了一层div,然后获取该divinnerHTML

这样无论是 template 还是 el ,都被转为了字符串模板,然后执行[2]处的代码:

if (template) {
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    mark("compile");
  }

  const { render, staticRenderFns } = compileToFunctions(
    template,
    {
      outputSourceRange: process.env.NODE_ENV !== "production",
      shouldDecodeNewlines,
      shouldDecodeNewlinesForHref,
      delimiters: options.delimiters,
      comments: options.comments
    },
    this
  );
  options.render = render;
  options.staticRenderFns = staticRenderFns;

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    mark("compile end");
    measure(`vue ${this._name} compile`, "compile", "compile end");
  }
}

这里先判断template是否存在(template可能为空字符串)。可以清楚的看到进入if语句内,里面有两个相同的if语句,这与我们之前介绍_init函数时遇到的一样,都是用于性能追踪。

中间这段代码调用了compileToFunctions函数,返回的render函数将其挂载到options.render上。
最后执行了:

return mount.call(this, el, hydrating);

这里是调用之前缓存的在src/platforms/web/runtime/index.js中定义的$mount函数:

// src/platforms/web/runtime/index.js

// public mount method
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined;
  return mountComponent(this, el, hydrating);
};

这里的 $mount 又把 el 从字符串转换成了节点然后传给了 mountComponent 函数。和上面一样,这里把 mountComponent 函数的代码分成几部分,先来看第一部分:

// src/core/instance/lifecycle.js

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el;
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode;
    if (process.env.NODE_ENV !== "production") {
      /* istanbul ignore if */
      if (
        (vm.$options.template && vm.$options.template.charAt(0) !== "#") ||
        vm.$options.el ||
        el
      ) {
        warn(
          "You are using the runtime-only build of Vue where the template " +
            "compiler is not available. Either pre-compile the templates into " +
            "render functions, or use the compiler-included build.",
          vm
        );
      } else {
        warn(
          "Failed to mount component: template or render function not defined.",
          vm
        );
      }
    }
  }
  callHook(vm, "beforeMount");

  // ...
}

先将el保存到vm.$el上,然后判断前面的template是否被正确的转换成了render函数。如果转换失败,将createEmptyVNode作为render函数。createEmptyVNode函数会创建一个空的VNode对象。

在非生产环境下(一般是开发版本下),如果编写了template或者el的同时又使用了Runtime Only版本的Vue,导致在$mount中不能编译成render函数,则会抛出警告;另外如果既没有template也没有render函数也会抛出警告。

接下来调用的callHook函数是生命周期相关。继续往下看:

let updateComponent;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
  updateComponent = () => {
    const name = vm._name;
    const id = vm._uid;
    const startTag = `vue-perf-start:${id}`;
    const endTag = `vue-perf-end:${id}`;

    mark(startTag);
    const vnode = vm._render();
    mark(endTag);
    measure(`vue ${name} render`, startTag, endTag);

    mark(startTag);
    vm._update(vnode, hydrating);
    mark(endTag);
    measure(`vue ${name} patch`, startTag, endTag);
  };
} else {
  updateComponent = () => {
    vm._update(vm._render(), hydrating);
  };
}

if语句里面是再熟悉不过的性能追踪,我们直接跳过,看else部分。

这里面定义了一个updateComponent函数,涉及到两个函数:

  • _render: 调用 vm.$options.render 函数并返回生成的虚拟节点(VNode
  • _update: 将 VNode 渲染成真实DOM

这里我只是大概说明一下它的作用,具体会在后面章节中展开。

接着往下看:

// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(
  vm,
  updateComponent,
  noop,
  {
    before() {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, "beforeUpdate");
      }
    }
  },
  true /* isRenderWatcher */
);
hydrating = false;

这里创建了一个 Watcher 实例,显然是与响应式数据相关的。这里的 Watcher 也仅做了解,在后面的章节会具体分析。

Watcher 会解析表达式,收集依赖关系,并且在表达式的值发生改变时触发回调。Watcher 在这里主要有两个作用: 一个是初始化的时候会执行回调函数;另一个是当 vm 实例中监测的数据发生变化的时候执行回调函数,而回调函数就是传入的 updateComponent 函数。

回到 mountComponent 函数,还剩最后一段代码:

// 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;
  callHook(vm, "mounted");
}
return vm;

函数最后判断为根节点的时候设置 vm._isMountedtrue, 表示这个实例已经挂载了,同时执行 mounted 钩子函数。

这里注意 vm.$vnode 表示 Vue 实例的父虚拟 Node,所以它为 Null 则表示当前是根 Vue 的实例。

总结

这一节我们分析了 $mount 函数的大体执行流程,下一篇文章我将介绍 $mount 函数中_render 函数的实现。

你可能已经忽略的git commit规范

引言

在日常的开发工作中,我们通常使用 git 来管理代码,当我们对代码进行某项改动后,都可以通过 git commit 来对代码进行提交。

git 规定提交时必须要写提交信息,作为改动说明,保存在 commit 历史中,方便回溯。规范的 log 不仅有助于他人 review, 还可以有效的输出 CHANGELOG,甚至对于项目的研发质量都有很大的提升。

但是在日常工作中,大多数同学对于 log 信息都是简单写写,没有很好的重视,这对于项目的管理和维护来说,无疑是不友好的。本篇文章主要是结合我自己的使用经验来和大家分享一下 git commit 的一些规范,让你的 log 不仅“好看”还“实用”。

为什么要规范 git commit

一直在说要规范 commit 格式,那为什么要这样做呢?

让我们先来看一个不太规范的 commit 记录:

看完什么感觉,写的是啥啊(内心 OS),这种 commit 信息对于想要从中获取有效信息的人来说无疑是一种致命的打击。

那我们来看一个社区里面比较流行的Angular规范的 commit 记录:

看完是不是一目了然呢?

上图中这种规范的 commit 信息首先提供了更多的历史信息,方便快速浏览。其次,可以过滤某些 commit(比如文档改动),便于快速查找信息。

既然说到了 Angular 团队的规范是目前社区比较流行的 commit 规范,那它具体是什么呢?下面让我们来具体深入了解下吧。

Angular 团队的 commit 规范

它的 message 格式如下:

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

分别对应 Commit message 的三个部分:HeaderBodyFooter

Header

Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。

  • type: 用于说明 commit 的类型。一般有以下几种:

    feat: 新增feature
    fix: 修复bug
    docs: 仅仅修改了文档,如readme.md
    style: 仅仅是对格式进行修改,如逗号、缩进、空格等。不改变代码逻辑。
    refactor: 代码重构,没有新增功能或修复bug
    perf: 优化相关,如提升性能、用户体验等。
    test: 测试用例,包括单元测试、集成测试。
    chore: 改变构建流程、或者增加依赖库、工具等。
    revert: 版本回滚
    
  • scope: 用于说明 commit 影响的范围,比如: views, component, utils, test...

  • subject: commit 目的的简短描述

Body

对本次 commit 修改内容的具体描述, 可以分为多行。如下所示:

# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
# initial commit

Footer

一些备注, 通常是 BREAKING CHANGE(当前代码与上一个版本不兼容) 或修复的 bug(关闭 Issue) 的链接。

简单介绍完上面的规范,我们下面来说一下commit.template,也就是 git 提交信息模板。

git 提交信息模板

如果你的团队对提交信息有格式要求,可以在系统上创建一个文件,并配置 git 把它作为默认的模板,这样可以更加容易地使提交信息遵循格式。

通过以下命令来配置提交信息模板:

git config commit.template   [模板文件名]    //这个命令只能设置当前分支的提交模板
git config  — —global commit.template   [模板文件名]    //这个命令能设置全局的提交模板,注意global前面是两杠

新建 .gitmessage.txt(模板文件) 内容可以如下:

# headr: <type>(<scope>): <subject>
# - type: feat, fix, docs, style, refactor, test, chore
# - scope: can be empty
# - subject: start with verb (such as 'change'), 50-character line
#
# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
#
# footer:
# - Include a link to the issue.
# - BREAKING CHANGE
#

看完上面这些,你会不会像我一样感觉配置下来挺麻烦的,配置一个适合自己和团队使用的近乎完美的 commit 规范看来也不是一件容易的事情。不过社区也为我们提供了一些辅助工具来帮助进行提交,下面来简单介绍一下这些工具。

commitizen(cz-cli)

commitizen是一款可以交互式建立提交信息的工具。它帮助我们从 type 开始一步步建立提交信息,具体效果如图所示:

  • 首先通过上下键控制指向你想要的 type 类型,分别对应有上面提到的featfixdocsperf等:

  • 然后会让你选择本次提交影响到的文件:

  • 后面会让你分别写一个简短的和详细的提交描述:

  • 最后会让你去判断本次提交是否是BREAKING CHANGE或者有关联已开启的issue:

看完上面的 commitizen 的整个流程,下面让我们来看下如何来安装。

  • 全局环境下安装:

    commitizen 根据不同的adapter配置 commit message。例如,要使用 Angular 的 commit message 格式,可以安装cz-conventional-changelog

    # 需要同时安装commitizen和cz-conventional-changelog,后者是adapter
    $ npm install -g commitizen cz-conventional-changelog
    # 配置安装的adapter
    $ echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
    # 使用
    $ git cz
    
    
  • 本地项目安装:

    # 安装commitizen
    $ npm install --save-dev commitizen
    # 接下来安装适配器
    # for npm >= 5.2
    $ npx commitizen init cz-conventional-changelog --save-dev --save-exact
    # for npm < 5.2
    $ ./node_modules/.bin/commitizen init cz-conventional-changelog --save-dev --save-exact
    
    // package.json script字段中添加commit命令
    "scripts": {
       "commit": "git-cz"
    }
    // use
    $ npm run commit

commitlint

commitlint是一个提交验证工具。原理是可以在实际的 git commit 提交到远程仓库之前使用 git 钩子来验证信息。提交不符合规则的信息将会被阻止提交到远程仓库。

先来看一下演示:

对于 Conventional Commits 规范,社区已经整理好了 @commitlint/config-conventional 包,我们只需要安装并启用它就可以了。

首先安装 commitlint 以及 conventional 规范:

npm install --save-dev @commitlint/cli @commitlint/config-conventional

接着在 package.json 中配置 commitlint 脚本:

"commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },

当然如果你想单独对 commitlint 进行配置的话,需要建立校验文件 commitlint.config.js,不然会校验失败

为了可以在每次 commit 时执行 commitlint 来 检查我们输入的 message,我们还需要用到一个工具 —— husky

husky 是一个增强的 git hook 工具。可以在 git hook 的各个阶段执行我们在 package.json 中配置好的 npm script。

首先安装 husky:

npm install --save-dev husky

接着在 package.json 中配置 commitmsg 脚本:

"husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
 },

到这里,commitlint就配置完成了~

gitmoji-cli

平时与朋友聊天时,我们一定会用到表情包,比如。表情包的出现让我们与朋友之间的沟通变得更加有趣。如果能在 git 提交 commit 时用到表情包,岂不是使每次的 commit 能够更加直观,维护起来也更加方便。

gitmoji就是可以实现这种功能的插件,先让我们来感受一下

有没有感觉很 cool~~

其实gitmoji的使用是很简单的:

# 安装
npm i -g gitmoji-cli
# 使用
git commit -m ':bug: 问题fix'

我们来看一下官方的示例吧:

是不是跃跃欲试了呢?

gitmoji项目地址

gitmoji使用示例

看完本文,是不是感觉对于git commit message又有了新的认识呢?去在你的项目中运用这些吧,让你的commit更加规范的同时,也不要忘了给你的log加上emoji哦!

最后附上一个之前项目针对git commit配置的package.json,作为参考:

{
  "name": "ts-axios",
  "version": "0.0.0",
  "description": "",
  "keywords": [],
  "main": "dist/ts-axios.umd.js",
  "module": "dist/ts-axios.es5.js",
  "typings": "dist/types/ts-axios.d.ts",
  "files": [
    "dist"
  ],
  "author": "fengshuan <[email protected]>",
  "repository": {
    "type": "git",
    "url": ""
  },
  "license": "MIT",
  "engines": {
    "node": ">=6.0.0"
  },
  "scripts": {
    "dev": "node examples/server.js",
    "lint": "tslint  --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
    "prebuild": "rimraf dist",
    "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src",
    "start": "rollup -c rollup.config.ts -w",
    "test": "jest --coverage",
    "test:watch": "jest --coverage --watch",
    "test:prod": "npm run lint && npm run test -- --no-cache",
    "deploy-docs": "ts-node tools/gh-pages-publish",
    "report-coverage": "cat ./coverage/lcov.info | coveralls",
    "commit": "git-cz",
    "semantic-release": "semantic-release",
    "semantic-release-prepare": "ts-node tools/semantic-release-prepare",
    "precommit": "lint-staged",
    "travis-deploy-once": "travis-deploy-once"
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "{src,test}/**/*.ts": [
      "prettier --write",
      "git add"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-conventional-changelog"
    }
  },
  "jest": {
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "testEnvironment": "node",
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ],
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 90,
        "functions": 95,
        "lines": 95,
        "statements": 95
      }
    },
    "collectCoverageFrom": [
      "src/*.{js,ts}"
    ]
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  },
  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
  "devDependencies": {
    "@commitlint/cli": "^7.1.2",
    "@commitlint/config-conventional": "^7.1.2",
    "@types/jest": "^23.3.2",
    "@types/node": "^10.11.0",
    "body-parser": "^1.19.0",
    "colors": "^1.3.2",
    "commitizen": "^3.0.0",
    "coveralls": "^3.0.2",
    "cross-env": "^5.2.0",
    "cz-conventional-changelog": "^2.1.0",
    "express": "^4.17.1",
    "husky": "^1.0.1",
    "jest": "^23.6.0",
    "jest-config": "^23.6.0",
    "lint-staged": "^8.0.0",
    "lodash.camelcase": "^4.3.0",
    "prettier": "^1.14.3",
    "prompt": "^1.0.0",
    "replace-in-file": "^3.4.2",
    "rimraf": "^2.6.2",
    "rollup": "^0.67.0",
    "rollup-plugin-commonjs": "^9.1.8",
    "rollup-plugin-json": "^3.1.0",
    "rollup-plugin-node-resolve": "^3.4.0",
    "rollup-plugin-sourcemaps": "^0.4.2",
    "rollup-plugin-typescript2": "^0.18.0",
    "semantic-release": "^15.9.16",
    "shelljs": "^0.8.3",
    "travis-deploy-once": "^5.0.9",
    "ts-jest": "^23.10.2",
    "ts-loader": "^6.1.1",
    "ts-node": "^7.0.1",
    "tslint": "^5.11.0",
    "tslint-config-prettier": "^1.15.0",
    "tslint-config-standard": "^8.0.1",
    "tslint-loader": "^3.5.4",
    "typedoc": "^0.12.0",
    "typescript": "^3.0.3",
    "webpack": "^4.40.2",
    "webpack-dev-middleware": "^3.7.1",
    "webpack-hot-middleware": "^2.25.0"
  }
}

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

面试官:webpack原理都不会?

引言

前一段时间我把webpack源码大概读了一遍,webpack4.x版本后,其源码已经比较庞大,对各种开发场景进行了高度抽象,阅读成本也愈发昂贵。

过度分析源码对于大家并没有太大的帮助。本文主要是想通过分析webpack的构建流程以及实现一个简单的webpack来让大家对webpack的内部原理有一个大概的了解。(保证能看懂,不懂你打我 🙈)
表情01

webpack 构建流程分析

首先,无须多言,上图~
webpack流程图

webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:首先会从配置文件和 Shell 语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compilerrun来真正启动webpack编译构建过程,webpack的构建流程包括compilemakebuildsealemit阶段,执行完这些阶段就完成了构建过程。

初始化

entry-options 启动

从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。

run 实例化

compiler:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译

编译构建

entry 确定入口

根据配置中的 entry 找出所有的入口文件

make 编译模块

从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理

build module 完成模块编译

经过上面一步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系

seal 输出资源

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

分析完构建流程,下面让我们自己动手实现一个简易的webpack吧~
表情02

实现一个简易的 webpack

准备工作

目录结构

我们先来初始化一个项目,结构如下:

|-- forestpack
    |-- dist
    |   |-- bundle.js
    |   |-- index.html
    |-- lib
    |   |-- compiler.js
    |   |-- index.js
    |   |-- parser.js
    |   |-- test.js
    |-- src
    |   |-- greeting.js
    |   |-- index.js
    |-- forstpack.config.js
    |-- package.json

这里我先解释下每个文件/文件夹对应的含义:

  • dist:打包目录
  • lib:核心文件,主要包括compilerparser
    • compiler.js:编译相关。Compiler为一个类, 并且有run方法去开启编译,还有构建modulebuildModule)和输出文件(emitFiles
    • parser.js:解析相关。包含解析ASTgetAST)、收集依赖(getDependencies)、转换(es6转es5
    • index.js:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入
    • test.js:测试文件,用于测试方法函数打console使用
  • src:源代码。也就对应我们的业务代码
  • forstpack.config.js: 配置文件。类似webpack.config.js
  • package.json:这个就不用我多说了~~~(什么,你不知道??)

先完成“造轮子”前 30%的代码

项目搞起来了,但似乎还少点东西~~
表情03

对了!基础的文件我们需要先完善下:forstpack.config.jssrc

首先是forstpack.config.js

const path = require("path");

module.exports = {
  entry: path.join(__dirname, "./src/index.js"),
  output: {
    path: path.join(__dirname, "./dist"),
    filename: "bundle.js",
  },
};

内容很简单,定义一下入口、出口(你这也太简单了吧!!别急,慢慢来嘛)
表情04

其次是src,这里在src目录下定义了两个文件:

  • greeting.js
// greeting.js
export function greeting(name) {
  return "你好" + name;
}
  • index.js
import { greeting } from "./greeting.js";

document.write(greeting("森林"));

ok,到这里我们已经把需要准备的工作都完成了。(问:为什么这么基础?答:当然要基础了,我们的核心是“造轮子”!!)
表情05

梳理下逻辑

短暂的停留一下,我们梳理下逻辑:

Q: 我们要做什么?

A: 做一个比webpack更强的super webpack(不好意思,失态了,一不小心说出了我的心声)。还是低调点(防止一会被疯狂打脸)
表情06

Q: 怎么去做?

A: 看下文(23333)

Q: 整个的流程是什么?

A: 哎嘿,大概流程就是:

  • 读取入口文件
  • 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树。
  • 根据AST语法树,生成浏览器能够运行的代码

正式开工

compile.js 编写

const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  // 接收通过lib/index.js new Compiler(options).run()传入的参数,对应`forestpack.config.js`的配置
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {}
  // 构建模块相关
  buildModule(filename, isEntry) {
    // filename: 文件名称
    // isEntry: 是否是入口文件
  }
  // 输出文件
  emitFiles() {}
};

compile.js主要做了几个事情:

  • 接收forestpack.config.js配置参数,并初始化entryoutput
  • 开启编译run方法。处理构建模块、收集依赖、输出文件等。
  • buildModule方法。主要用于构建模块(被run方法调用)
  • emitFiles方法。输出文件(同样被run方法调用)

到这里,compiler.js的大致结构已经出来了,但是得到模块的源码后, 需要去解析,替换源码和获取模块的依赖项, 也就对应我们下面需要完善的parser.js

parser.js 编写

const fs = require("fs");
// const babylon = require("babylon");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("babel-core");
module.exports = {
  // 解析我们的代码生成AST抽象语法树
  getAST: (path) => {
    const source = fs.readFileSync(path, "utf-8");

    return parser.parse(source, {
      sourceType: "module", //表示我们要解析的是ES模块
    });
  },
  // 对AST节点进行递归遍历
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      },
    });
    return dependencies;
  },
  // 将获得的ES6的AST转化成ES5
  transform: (ast) => {
    const { code } = transformFromAst(ast, null, {
      presets: ["env"],
    });
    return code;
  },
};

看完这代码是不是有点懵(说好的保证让看懂的 😤)

别着急,你听我辩解!!😷
表情07

这里要先着重说下用到的几个babel包:

  • @babel/parser:用于将源码生成AST
  • @babel/traverse:对AST节点进行递归遍历
  • babel-core/@babel/preset-env:将获得的ES6AST转化成ES5

parser.js中主要就三个方法:

  • getAST: 将获取到的模块内容 解析成AST语法树
  • getDependencies:遍历AST,将用到的依赖收集起来
  • transform:把获得的ES6AST转化成ES5

完善 compiler.js

在上面我们已经将compiler.js中会用到的函数占好位置,下面我们需要完善一下compiler.js,当然会用到parser.js中的一些方法(废话,不然我上面干嘛要先把parser.js写完~~)
表情08

直接上代码:

const { getAST, getDependencies, transform } = require("./parser");
const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {
    const entryModule = this.buildModule(this.entry, true);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
    // console.log(this.modules);
    this.emitFiles();
  }
  // 构建模块相关
  buildModule(filename, isEntry) {
    let ast;
    if (isEntry) {
      ast = getAST(filename);
    } else {
      const absolutePath = path.join(process.cwd(), "./src", filename);
      ast = getAST(absolutePath);
    }

    return {
      filename, // 文件名称
      dependencies: getDependencies(ast), // 依赖列表
      transformCode: transform(ast), // 转化后的代码
    };
  }
  // 输出文件
  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }
};

关于compiler.js的内部函数,上面我说过一遍,这里主要来看下emitFiles

emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }

这里的bundle一大坨,什么鬼?
表情09

我们先来了解下webpack的文件 📦 机制。下面一段代码是经过webpack打包精简过后的代码:

// dist/index.xxxx.js
(function(modules) {
  // 已经加载过的模块
  var installedModules = {};

  // 模块加载函数
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  __webpack_require__(0);
})([
/* 0 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* 1 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* n module */
(function(module, exports, __webpack_require__) {
  ...
})]);

简单分析下:

  • webpack 将所有模块(可以简单理解成文件)包裹于一个函数中,并传入默认参数,将所有模块放入一个数组中,取名为 modules,并通过数组的下标来作为 moduleId
  • modules 传入一个自执行函数中,自执行函数中包含一个 installedModules 已经加载过的模块和一个模块加载函数,最后加载入口模块并返回。
  • __webpack_require__ 模块加载,先判断 installedModules 是否已加载,加载过了就直接返回 exports 数据,没有加载过该模块就通过 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 执行模块并且将 module.exports 给返回。

(你上面说的这一坨又是什么鬼?我听不懂啊啊啊啊!!!)
表情10

那我换个说法吧:

  • 经过webpack打包出来的是一个匿名闭包函数(IIFE
  • modules是一个数组,每一项是一个模块初始化函数
  • __webpack_require__用来加载模块,返回module.exports
  • 通过WEBPACK_REQUIRE_METHOD(0)启动程序

(小声 bb:怎么样,这样听懂了吧)
表情11

lib/index.js 入口文件编写

到这里,就剩最后一步了(似乎见到了胜利的曙光)。在lib目录创建index.js

const Compiler = require("./compiler");
const options = require("../forestpack.config");

new Compiler(options).run();

这里逻辑就比较简单了:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入。

运行node lib/index.js就会在dist目录下生成bundle.js文件。

(function (modules) {
  function require(fileName) {
    const fn = modules[fileName];
    const module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }
  require("/Users/fengshuan/Desktop/workspace/forestpack/src/index.js");
})({
  "/Users/fengshuan/Desktop/workspace/forestpack/src/index.js": function (
    require,
    module,
    exports
  ) {
    "use strict";

    var _greeting = require("./greeting.js");

    document.write((0, _greeting.greeting)("森林"));
  },
  "./greeting.js": function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
    exports.greeting = greeting;

    function greeting(name) {
      return "你好" + name;
    }
  },
});

和上面用webpack打包生成的js文件作下对比,是不是很相似呢?
表情12

来吧!展示

我们在dist目录下创建index.html文件,引入打包生成的bundle.js文件:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./bundle.js"></script>
  </body>
</html>

此时打开浏览器:
forestpack演示

如你所愿,得到了我们预期的结果~
表情13

总结

通过对webpack构建流程的分析以及实现了一个简易的forestpack,相信你对webpack的构建原理已经有了一个清晰的认知!(当然,这里的forestpackwebpack相比还很弱很弱,,,,)
表情14

参考

本文是看过极客时间程柳锋老师的「玩转 webpack」课程后整理的。这里也十分推荐大家去学习这门课程~
表情15

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
前端森林公众号二维码2

renderRoot

上一篇解析了React在进入work流程之前会把产生了更新的FiberRoot通过addRootToSchedule添加到一个调度队列中去,这个调度队列是一个环形的链表。之后在performWork方法中,递归的调用findHighestPriorityRoot方法找到队列中expirationTime最大的那个fiberRoot,并将expirationtimenoWorkfiberRoot从队列中剔除,并将这个FiberRoot设置为全局变量nextFlushedRoot,将优先级最高的expirationTime设置为全局变量nextFlushedExpirationTime,之后调用performWorkOnRoot方法进入renderRoot流程。

/**
 * 渲染FiberRoot节点
 *
 * @param {FiberRoot} root 需要被渲染的FiberFRoot
 * @param {boolean} isYieldy 任务是否可以被中断
 * @returns {void}
 */
function renderRoot(root: FiberRoot, isYieldy: boolean): void {
  invariant(
    !isWorking,
    "renderRoot was called recursively. This error is likely caused " +
      "by a bug in React. Please file an issue."
  );

  flushPassiveEffects();
  // 标记当前正在进行render工作
  isWorking = true;
  // reactHooks相关
  if (enableHooks) {
    ReactCurrentOwner.currentDispatcher = Dispatcher;
  } else {
    ReactCurrentOwner.currentDispatcher = DispatcherWithoutHooks;
  }
  // 获取root的计算出来优先级最高的expirationTime,
  // 这个时间是在scheduleWork的过程中通过findNextExpirationTimeToWorkOn比较出来的
  // 因此这个时间既可能是本次更新的expirationTime,
  // 也可能是之前在提交中被中断的任务的expirationTime(或者其他情况)
  const expirationTime = root.nextExpirationTimeToWorkOn;

  // Check if we're starting from a fresh stack, or if we're resuming from
  // previously yielded work.
  // 判断此次更新是一个全新的任务栈,还是恢复之前被中断的任务
  // 如果是一个全新的任务,进入创建workInProgress流程
  if (
    expirationTime !== nextRenderExpirationTime ||
    root !== nextRoot ||
    nextUnitOfWork === null
  ) {
    // Reset the stack and start working from the root.
    // 重置更新栈,从当前root重新开始
    resetStack();
    nextRoot = root;
    nextRenderExpirationTime = expirationTime;
    // fiber节点的的alternate就是nextUnitOfWork
    // 这里就是为fiber节点创建alternate
    nextUnitOfWork = createWorkInProgress(
      nextRoot.current,
      null,
      nextRenderExpirationTime
    );
    root.pendingCommitExpirationTime = NoWork;

    if (enableSchedulerTracing) {
      // Determine which interactions this batch of work currently includes,
      // So that we can accurately attribute time spent working on it,
      // And so that cascading work triggered during the render phase will be associated with it.
      const interactions: Set<Interaction> = new Set();
      root.pendingInteractionMap.forEach(
        (scheduledInteractions, scheduledExpirationTime) => {
          if (scheduledExpirationTime >= expirationTime) {
            scheduledInteractions.forEach((interaction) =>
              interactions.add(interaction)
            );
          }
        }
      );

      // Store the current set of interactions on the FiberRoot for a few reasons:
      // We can re-use it in hot functions like renderRoot() without having to recalculate it.
      // We will also use it in commitWork() to pass to any Profiler onRender() hooks.
      // This also provides DevTools with a way to access it when the onCommitRoot() hook is called.
      root.memoizedInteractions = interactions;

      if (interactions.size > 0) {
        const subscriber = __subscriberRef.current;
        if (subscriber !== null) {
          const threadID = computeThreadID(
            expirationTime,
            root.interactionThreadID
          );
          try {
            subscriber.onWorkStarted(interactions, threadID);
          } catch (error) {
            // Work thrown by an interaction tracing subscriber should be rethrown,
            // But only once it's safe (to avoid leaveing the scheduler in an invalid state).
            // Store the error for now and we'll re-throw in finishRendering().
            if (!hasUnhandledError) {
              hasUnhandledError = true;
              unhandledError = error;
            }
          }
        }
      }
    }
  }

  let prevInteractions: Set<Interaction> = (null: any);
  if (enableSchedulerTracing) {
    // We're about to start new traced work.
    // Restore pending interactions so cascading work triggered during the render phase will be accounted for.
    prevInteractions = __interactionsRef.current;
    __interactionsRef.current = root.memoizedInteractions;
  }

  let didFatal = false;

  startWorkLoopTimer(nextUnitOfWork);

  do {
    try {
      workLoop(isYieldy);
    } catch (thrownValue) {
      resetContextDependences();
      resetHooks();

      // Reset in case completion throws.
      // This is only used in DEV and when replaying is on.
      let mayReplay;
      if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
        mayReplay = mayReplayFailedUnitOfWork;
        mayReplayFailedUnitOfWork = true;
      }

      if (nextUnitOfWork === null) {
        // This is a fatal error.
        didFatal = true;
        onUncaughtError(thrownValue);
      } else {
        if (enableProfilerTimer && nextUnitOfWork.mode & ProfileMode) {
          // Record the time spent rendering before an error was thrown.
          // This avoids inaccurate Profiler durations in the case of a suspended render.
          stopProfilerTimerIfRunningAndRecordDelta(nextUnitOfWork, true);
        }

        if (__DEV__) {
          // Reset global debug state
          // We assume this is defined in DEV
          (resetCurrentlyProcessingQueue: any)();
        }

        if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
          if (mayReplay) {
            const failedUnitOfWork: Fiber = nextUnitOfWork;
            replayUnitOfWork(failedUnitOfWork, thrownValue, isYieldy);
          }
        }

        // TODO: we already know this isn't true in some cases.
        // At least this shows a nicer error message until we figure out the cause.
        // https://github.com/facebook/react/issues/12449#issuecomment-386727431
        invariant(
          nextUnitOfWork !== null,
          "Failed to replay rendering after an error. This " +
            "is likely caused by a bug in React. Please file an issue " +
            "with a reproducing case to help us find it."
        );

        const sourceFiber: Fiber = nextUnitOfWork;
        let returnFiber = sourceFiber.return;
        if (returnFiber === null) {
          // This is the root. The root could capture its own errors. However,
          // we don't know if it errors before or after we pushed the host
          // context. This information is needed to avoid a stack mismatch.
          // Because we're not sure, treat this as a fatal error. We could track
          // which phase it fails in, but doesn't seem worth it. At least
          // for now.
          didFatal = true;
          onUncaughtError(thrownValue);
        } else {
          throwException(
            root,
            returnFiber,
            sourceFiber,
            thrownValue,
            nextRenderExpirationTime
          );
          nextUnitOfWork = completeUnitOfWork(sourceFiber);
          continue;
        }
      }
    }
    break;
  } while (true);

  if (enableSchedulerTracing) {
    // Traced work is done for now; restore the previous interactions.
    __interactionsRef.current = prevInteractions;
  }

  // We're done performing work. Time to clean up.
  isWorking = false;
  ReactCurrentOwner.currentDispatcher = null;
  resetContextDependences();
  resetHooks();

  // Yield back to main thread.
  if (didFatal) {
    const didCompleteRoot = false;
    stopWorkLoopTimer(interruptedBy, didCompleteRoot);
    interruptedBy = null;
    // There was a fatal error.
    if (__DEV__) {
      resetStackAfterFatalErrorInDev();
    }
    // `nextRoot` points to the in-progress root. A non-null value indicates
    // that we're in the middle of an async render. Set it to null to indicate
    // there's no more work to be done in the current batch.
    nextRoot = null;
    onFatal(root);
    return;
  }

  if (nextUnitOfWork !== null) {
    // There's still remaining async work in this tree, but we ran out of time
    // in the current frame. Yield back to the renderer. Unless we're
    // interrupted by a higher priority update, we'll continue later from where
    // we left off.
    const didCompleteRoot = false;
    stopWorkLoopTimer(interruptedBy, didCompleteRoot);
    interruptedBy = null;
    onYield(root);
    return;
  }

  // We completed the whole tree.
  const didCompleteRoot = true;
  stopWorkLoopTimer(interruptedBy, didCompleteRoot);
  const rootWorkInProgress = root.current.alternate;
  invariant(
    rootWorkInProgress !== null,
    "Finished root should have a work-in-progress. This error is likely " +
      "caused by a bug in React. Please file an issue."
  );

  // `nextRoot` points to the in-progress root. A non-null value indicates
  // that we're in the middle of an async render. Set it to null to indicate
  // there's no more work to be done in the current batch.
  nextRoot = null;
  interruptedBy = null;

  if (nextRenderDidError) {
    // There was an error
    if (hasLowerPriorityWork(root, expirationTime)) {
      // There's lower priority work. If so, it may have the effect of fixing
      // the exception that was just thrown. Exit without committing. This is
      // similar to a suspend, but without a timeout because we're not waiting
      // for a promise to resolve. React will restart at the lower
      // priority level.
      markSuspendedPriorityLevel(root, expirationTime);
      const suspendedExpirationTime = expirationTime;
      const rootExpirationTime = root.expirationTime;
      onSuspend(
        root,
        rootWorkInProgress,
        suspendedExpirationTime,
        rootExpirationTime,
        -1 // Indicates no timeout
      );
      return;
    } else if (
      // There's no lower priority work, but we're rendering asynchronously.
      // Synchronsouly attempt to render the same level one more time. This is
      // similar to a suspend, but without a timeout because we're not waiting
      // for a promise to resolve.
      !root.didError &&
      isYieldy
    ) {
      root.didError = true;
      const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
      const rootExpirationTime = (root.expirationTime = Sync);
      onSuspend(
        root,
        rootWorkInProgress,
        suspendedExpirationTime,
        rootExpirationTime,
        -1 // Indicates no timeout
      );
      return;
    }
  }

  if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) {
    // The tree was suspended.
    const suspendedExpirationTime = expirationTime;
    markSuspendedPriorityLevel(root, suspendedExpirationTime);

    // Find the earliest uncommitted expiration time in the tree, including
    // work that is suspended. The timeout threshold cannot be longer than
    // the overall expiration.
    const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
      root,
      expirationTime
    );
    const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
    if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) {
      nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs;
    }

    // Subtract the current time from the absolute timeout to get the number
    // of milliseconds until the timeout. In other words, convert an absolute
    // timestamp to a relative time. This is the value that is passed
    // to `setTimeout`.
    const currentTimeMs = expirationTimeToMs(requestCurrentTime());
    let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs;
    msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;

    // TODO: Account for the Just Noticeable Difference

    const rootExpirationTime = root.expirationTime;
    onSuspend(
      root,
      rootWorkInProgress,
      suspendedExpirationTime,
      rootExpirationTime,
      msUntilTimeout
    );
    return;
  }

  // Ready to commit.
  onComplete(root, rootWorkInProgress, expirationTime);
}

renderRoot首先会标记isWorkingtrue,之后读取root节点上的nextExpirationTimeToWorkOn作为本次渲染的expirationTimenextExpirationTimeToWorkOn并不一定就是root上此次更新的expirationTime,也有可能是之前被中断的更新任务。然后为fiberRootfiber节点创建alternate对象,并赋值给全局变量nextUnitOfWorkfiberRoot的更新变动将体现在alternate对象上。

准备工作完成,进入workLoopworkLoop流程中会递归的完成整颗Fiber树的构建,performUnitOfWork将返回每一个fiber节点,并且这个fiber节点将作为下一次performUnitOfWork的入参,这里仅分析第一次HostRoot的处理。

/**
 *
 *
 * @param {*} isYieldy 任务是否能够被中断
 */
function workLoop(isYieldy) {
  if (!isYieldy) {
    // Flush work without yielding
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  } else {
    // Flush asynchronous work until there's a higher priority event
    while (nextUnitOfWork !== null && !shouldYieldToRenderer()) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  }
}

调用performUnitOfWork,并将前面设置的全局变量nextUnitOfWork也就是fiberRoot对象作为参数传入。

/**
 * 主要是性能检测,最终的工作是调用beginWork
 *
 * @param {Fiber} workInProgress 当前处于工作流程中的fiber节点
 * @returns {(Fiber | null)}
 */
function performUnitOfWork(workInProgress: Fiber): Fiber | null {
  // The current, flushed, state of this fiber is the alternate.
  // Ideally nothing should rely on this, but relying on it here
  // means that we don't need an additional field on the work in
  // progress.
  // 获取alternate对应的fiber节点
  const current = workInProgress.alternate;

  // See if beginning this work spawns more work.
  startWorkTimer(workInProgress);
  if (__DEV__) {
    setCurrentFiber(workInProgress);
  }

  if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
    stashedWorkInProgressProperties = assignFiberPropertiesInDEV(
      stashedWorkInProgressProperties,
      workInProgress
    );
  }

  let next;
  if (enableProfilerTimer) {
    if (workInProgress.mode & ProfileMode) {
      startProfilerTimer(workInProgress);
    }
    // 开始render流程
    next = beginWork(current, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;

    if (workInProgress.mode & ProfileMode) {
      // Record the render duration assuming we didn't bailout (or error).
      stopProfilerTimerIfRunningAndRecordDelta(workInProgress, true);
    }
  } else {
    next = beginWork(current, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;
  }

  if (__DEV__) {
    resetCurrentFiber();
    if (isReplayingFailedUnitOfWork) {
      // Currently replaying a failed unit of work. This should be unreachable,
      // because the render phase is meant to be idempotent, and it should
      // have thrown again. Since it didn't, rethrow the original error, so
      // React's internal stack is not misaligned.
      rethrowOriginalError();
    }
  }
  if (__DEV__ && ReactFiberInstrumentation.debugTool) {
    ReactFiberInstrumentation.debugTool.onBeginWork(workInProgress);
  }

  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    next = completeUnitOfWork(workInProgress);
  }

  ReactCurrentOwner.current = null;
  // next是一次work后产生的fiber节点,被返回后作为下一次performUnitOfWork的入参
  return next;
}

你可能不知道的15个有用的Github功能

引言 🏂

我们平时的工作中,github是必不可少的代码托管平台,但是大多数同学也只是把它做为了托管代码的地方,并没有合理的去运用。

其实github里面有非常多好玩或者有趣的地方的。当然,这些技巧也能对你的工作效率有很大的提升。

我整理了一些自己平时用的比较多的一些功能/技巧,也希望能给你的工作带来一些帮助!

Gist 🍓

可能很多人并没有听过Gist。它在github首页的子目录下:

这是github提供的一个非常有用的功能。Gist作为一个粘贴数据的工具,就像 Pastie 网站一样,可以很容易地将数据粘贴在Gist网站中,并在其他网页中引用Gist中粘贴的数据。

作为GitHub的一个子网站,很自然地,Gist使用Git版本库对粘贴数据进行维护,这是非常方便的。

进入Gist网站的首页,就会看到一个大大的数据粘贴对话框. 只要提供一行简单的描述、文件名,并粘贴文件内容,即可创建一个新的粘贴。

每一个新的粘贴称为一个Gist,并拥有一个单独的URL

当一个粘贴创建完毕后,会显示新建立的Gist页面, 点击其中的embed(嵌入)按钮,就会显示一段用于嵌入其他网页的JavaScript代码,将上面的JavaScript代码嵌入到网页中,即可在相应的网页中嵌入来自Gist的数据,并保持语法高亮等功能。

通过 web 界面创建文件 🍋

在有些时候,我们可能不太想用本地创建文件,然后通过git推送到远程这种方式去创建文件,那么有没有简单高效的一种做法呢?

很简单,通过github提供的 web 界面创建方式(create new file)去创建就可以了:
38

文件查找 🛵

有时,我们想在一个庞大的 git 仓库中去查找某一个文件,如果一个一个的去看,可能需要一段时间(我之前时常感觉在github仓库中去查找一个文件真的好麻烦)。

其实 github 提供了一个快捷的查找方式:按键盘'T'键激活文件查找器,按 ⬆️ 和 ⬇️ 上下选择文件,当然也可以输入你要查找的文件名,快速查找。

github cli(命令行) 🖥

当我们将本地代码提交到 GitHub 后,就可以在 GitHub 网站上查看到各种的交互信息了,例如其它开发者提的 Issue,或者提交的代码合并请求等。但是,如果我们能在命令行上直接查看、处理这些信息,那么这一定非常酷。

下面让我带你从 0 到 1 上手GitHub CLI吧!

安装

要安装 GitHub CLI 非常简单。

macOS下面可以使用Homebrew工具进行安装:

$ brew install github/gh/gh
# 如果需要更新执行下面的命令即可
$ brew update && brew upgrade gh

Windows下可以使用如下命令行进行安装:

scoop bucket add github-gh https://github.com/cli/scoop-gh.git
scoop install gh

安装完成后直接在命令行中执行gh命令,看到如下图所示的信息就说明已经安装成功了:

其他平台的安装参考官方文档即可: https://cli.github.com/manual/installation

使用

使用的时候需要我们进行一次授权:

在命令行中输入回车键就会在浏览器中打开授权页面,点击授权即可:

授权成功回到命令行,我们发现通过gh issue list指令已经拿到了issue列表:

我这边列举几个常用的操作。

创建 issue

我们通过 CLI 先交互式地提交了一条issueissueBody 需要通过nano编辑。

筛选 issue

issue列表往往存在有太多的条目,通过指定条件筛选issue是一个很常见的需求:

如上图所示,它将筛选出label动态规划的所有issue

快速浏览

找到一个你关注的issue过后,要想查看该issue的具体信息,可以使用如下命令在浏览器中快速将issue的详细信息页面打开:

接下来可以选择打开网页,预览并提交。当然,我们也可以选择直接在命令行进行提交。

这里我只是简单介绍了issue相关的几个常用命令,更多的使用方式可以查看官方文档了解更多:https://cli.github.com/manual/examples

GitHub Actions 🚀


GitHub ActionsGitHub 的持续集成服务。

通常持续集成是由很多操作组成的,比如抓取代码、执行脚本、登录远程服务器、发布到第三方服务等。GitHub将这些操作称作actions

如果你需要某个 action,不必自己写复杂的脚本,直接引用他人写好的 action 即可,整个持续集成过程,就变成了一个 actions 的组合。

GitHub 做了一个官方市场,可以搜索到他人提交的 actions

下面分别从基本概念和发布流程详细说明一下GitHub Actions

基本概念

  • workflow (流程):持续集成一次运行的过程,就是一个 workflow。
  • job (任务):一个 workflow 由一个或多个 jobs 构成,含义是一次持续集成的运行,可以完成多个任务。
  • step(步骤):每个 job 由多个 step 构成,一步步完成。
  • action (动作):每个 step 可以依次执行一个或多个命令(action)。

实例:React 项目发布到 GitHub Pages

这里通过 GitHub Actions 构建一个 React 项目,并发布到 GitHub Pages。最终代码都在这个仓库里面,发布后的网址为https://jack-cool.github.io/github-actions-demo/

生成密钥

由于示例需要将构建成果发到GitHub仓库,因此需要 GitHub 密钥。按照官方文档,生成一个密钥。然后,将这个密钥储存到当前仓库的Settings/Secrets里面。

我这里环境变量的名字用的是ACCESS_TOKEN

创建 React 项目

使用create-react-app初始化一个 React 应用:

$ npx create-react-app github-actions-demo
$ cd github-actions-demo

在项目的package.json中,添加一个homepage字段(表示该应用发布后的根目录)

"homepage": "https://jack-cool.github.io/github-actions-demo"

创建 workflow 文件

在项目的.github/workflows目录,创建一个workflow文件,这里用的是ci.yml

上面已经提到GitHub有一个官方的市场,这里我们直接采用的是JamesIves/github-pages-deploy-action

name: GitHub Actions Build and Deploy Demo
on:
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      # 拉取代码
      - name: Checkout
        uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
        with:
          persist-credentials: false
      # 安装依赖、打包
      - name: Install and Build
        run: |
          npm install
          npm run-script build

      # 部署到 GitHub Pages
      - name: Deploy
        uses: JamesIves/github-pages-deploy-action@releases/v3
        with:
          ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
          BRANCH: gh-pages
          FOLDER: build

这里梳理下配置文件都做了些什么:

1、 拉取代码。这里用的是 GitHub 官方的 action: actions/checkout@v2

2、安装依赖、打包

3、部署到GitHub Pages。使用了第三方作者的 action:JamesIves/github-pages-deploy-action@releases/v3。我这里详细介绍下这个 action

使用 with 参数向环境中传入了三个环境变量:

  • ACCESS_TOKEN:读取 GitHub 仓库 secretsACCESS_TOKEN 变量,也就是我们前面设置的
  • BRANCH:部署分支 gh-pagesGitHub Pages 读取的分支)
  • FOLDER:需要部署的文件在仓库中的路径,也就是我们使用 npm run build 生成的打包目录

这里有一点需要注意:我使用的是 v3 版本,需要使用 with 参数传入环境变量,且需要自行构建;网上常见的教程使用的是 v2 版本,使用 env 参数传入环境变量,不需要自行构建,可使用 BUILD_SCRIPT 环境变量传入构建脚本。

到这里,配置工作就完成了。

以后,你每次有代码 pushmaster 分支时,GitHub 都会开始自动构建。

分享具体代码 🎯

平时我们可能有一行非常好的代码,想分享给其他同事看,那么可以在url后面加上#L 行号,比如:
https://github.com/Jack-cool/rest_node_api/blob/master/app/models/users.js#L17,效果如下图:

如果是想分享某几行的,可以在url后面添加如#L 开始行号-L 结束行号,像https://github.com/Jack-cool/rest_node_api/blob/master/app/models/users.js#L17-L31,效果如下图:

通过提交的msg自动关闭 issue 🏉

我们先来看一下关闭issues的关键字:

  • close
  • closes
  • closed
  • fix
  • fixes
  • fixed
  • resolve
  • resolves
  • resolved

关闭同一个仓库中的 issue

如果是在同一个仓库中去关闭issue的话,可以使用上面列表中的关键字并在其后加上issue编号的引用。

例如一个提交信息中含有fixes #26,那么一旦这次提交被合并到默认分支,仓库中的 26 号issue就会自动关闭。

如果这次提交不是在默认分支,那么这个issue将不会关闭,但是在它下面会有一个提示信息。这个提示信息会提示你某人添加了一个提交提到了这个issue,如果你将它合并到默认分支就会关闭该issue

关闭不同仓库中的 issue

如果想关闭另一个仓库中的issue,可以使用username/repository/#issue_number这样的语法。

例如,提交信息中包含closes Jack-cool/fe_interview/issues/113,将会关闭fe_interview中的113issue

关闭其他仓库issue的前提是你将代码push到了对应的仓库

查看项目的访问数据 🎃

在自己的项目下,点击 Insights,然后再点击 Traffic,里面有 Referring sitesPopular content 的详细数据和排名。

其中 Referring sites 表示大家都是从什么网站来到你的项目的,Popular content 则表示大家经常看你项目的哪些文件。

任务清单 📝

有时候我们需要标记一些任务清单去记录我们接下来要做的事情。

创建任务列表

issuespull requests 里可以添加复选框,语法如下(注意空白符):

- [ ] 步骤一
- [ ] 步骤二
  - [ ] 步骤2.2
  - [ ] 步骤2.3
- [ ] 步骤三

效果如下:

普通的markdown文件中可创建只读的任务列表,比如在README.md中添加 TODO list:

### 接下来要做的事 🦀
- [x] 数据结构与算法
- [ ] react源码
- [ ] docker

效果如下:

对任务排序

你可以单击任务左边的复选框并拖放至新位置,对单一评论中的任务列表重新排序。

issues 模版和 pull request 模版 🍪

这里以issue模版举例,pr模板类似

这个issue模版我是在给element uiissue时发现的:

GitHub中,代码库维护者如果提供有定制的 issues 模版和pull request 模版,可以让人们有针对性的提供某类问题的准确信息,从而在后续维护中能够进行有效地对话和改进,而不是杂乱无章的留言。

创建issues模版

  • 在代码库根目录新建.github目录
  • .github 目录下添加 ISSUE_TEMPLATE.md 文件作为 issues 默认模版。当创建 issue 时,系统会自动引用该模版。

如我在项目根目录下新建了.github/ISSUE_TEMPLATE.md

## 概述

bug 概述

## 重现步骤

1. aaa
2. bbb
3. ccc

## Bug 行为

Bug 的表现行为

## 期望行为

软件的正确行为

## 附件

附上图片或日志,日志请用格式:

> ```
> 日志内容
> ```

在该仓库新建issue时就会出现上面预设的issue模版:

GitHub Wiki 📜

大家平时的项目,一般都使用 Markdown 来编写项目文档和 README.md 等。Markdown 一般情况下能够满足我们的文档编写需求,如果使用得当的话,效果也非常棒。不过当项目文档比较长的时候,阅读体验可能就不是那么理想了,这种情况我想大家应该都曾经遇到过。

GitHub 每一个项目都有一个单独完整的 Wiki 页面,我们可以用它来实现项目信息管理,为项目提供更加完善的文档。我们可以把 Wiki 作为项目文档的一个重要组成部分,将冗长、具体的文档整理成 Wiki,将精简的、概述性的内容,放到项目中或是 README.md 里。

关于Wiki的使用,这里就不展开说明了,具体可以参考官方文档

查看提交记录热度图 👨‍🚀

查看文件时,可以按b查看提交记录和显示每一行的最近修改情况的热度图。它会告诉你每行代码的提交人,并且提供一个可以点击的链接去查看完整提交。
37

中间有一个橙色的竖条。颜色越鲜艳,更改的时间就越近。

Git Submodules vs Git Subtrees 👨‍🌾

为什么使用 Submodules or Subtrees?

团队中一般都会有公共的代码库,submodulesubtrees可以让我们在不同项目中使用这些公共的代码,避免因拷贝产生重复代码,甚至导致相同代码出现不同修改产生多个版本。

区别

subtreesubmodule 的目的都是用于 git 子仓库管理,二者的主要区别在于,subtree 属于拷贝子仓库,而 submodule 属于引用子仓库。

使用

关于实践,官方文档写的已经非常清楚了,我这里直接放上链接:

  • submodule: https://git-scm.com/book/en/v2/Git-Tools-Submodules
  • subtree: https://einverne.github.io/post/2020/04/git-subtree-usage.html

GitHub 插件推荐 🦐

GitHub的插件有很多很多,这里就推荐一下我常用的三个插件。

Octotree

我们有时经常需要在github上查找文件,但如果文件结构嵌套很深,查找起来是非常麻烦的,这时使用Octotree可以在仓库左侧显示当前项目的目录结构,让你可以在github上像使用Web IDE一样方便。

isometric-contributions


这个是可以更酷炫的 3D 立体式渲染github贡献。

Enhanced GitHub


这个插件支持在仓库中显示仓库大小、每个文件的大小以及每个文件的下载链接。

GitHub 吉祥物 Octocat 🦊

哈哈 这个就比较有意思了 我也是刚知道原来github也有自己的吉祥物。

这里贴下网站,顺便选了几个感觉很萌的,大家也可以去上面选几个作为自己的头像什么的。

参考

  • 阮一峰老师 《GitHub Actions 入门教程》

爱心三连击

1、如果感觉内容对你有帮助,也欢迎你分享给更多的朋友。

2、关注公众号前端森林,定期为你推送新鲜干货好文。

3、添加微信fs1263215592,拉你进技术交流群一起学习 🍻

Vue源码探秘(Virtual DOM)

引言

Virtual DOM(后文简称vdom)的概念大规模的推广得益于react的出现,vdom也是react框架比较重要的特性之一。相比较频繁的手动去操作dom而带来性能问题,vdom很好的将dom做了一层映射关系,进而将在我们本需要直接进行dom的一系列操作,映射到了操作vdom

Vue.js 2.0引入vdom,比Vue.js 1.0的初始渲染速度提升了 2-4 倍,并大大降低了内存消耗。那么,什么是vdom呢?

让我们进入今天的文章。

VNode

VNodeVirtual DOMVue.js 中的数据结构定义,它被定义在 src/core/vdom/vnode.js 中:

// src/core/vdom/vnode.js
export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // for SSR caching
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support

  constructor(
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag;
    this.data = data;
    this.children = children;
    this.text = text;
    this.elm = elm;
    this.ns = undefined;
    this.context = context;
    this.fnContext = undefined;
    this.fnOptions = undefined;
    this.fnScopeId = undefined;
    this.key = data && data.key;
    this.componentOptions = componentOptions;
    this.componentInstance = undefined;
    this.parent = undefined;
    this.raw = false;
    this.isStatic = false;
    this.isRootInsert = true;
    this.isComment = false;
    this.isCloned = false;
    this.isOnce = false;
    this.asyncFactory = asyncFactory;
    this.asyncMeta = undefined;
    this.isAsyncPlaceholder = false;
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child(): Component | void {
    return this.componentInstance;
  }
}

可以看到 VNode 是一个类,有很多属性。每一个vnode都映射到一个真实的dom节点上。我们这里先了解几个重要的属性:

  • tag: 对应真实节点的标签名
  • data: 当前节点的相关数据(节点上的class,attribute,style以及绑定的事件),是 VNodeData 类型。该类型声明在 flow/vnode.js 中:
// flow/vnode.js
declare interface VNodeData {
  key?: string | number;
  slot?: string;
  ref?: string;
  is?: string;
  pre?: boolean;
  tag?: string;
  staticClass?: string;
  class?: any;
  staticStyle?: { [key: string]: any };
  style?: string | Array<Object> | Object;
  normalizedStyle?: Object;
  props?: { [key: string]: any };
  attrs?: { [key: string]: string };
  domProps?: { [key: string]: any };
  hook?: { [key: string]: Function };
  on?: ?{ [key: string]: Function | Array<Function> };
  nativeOn?: { [key: string]: Function | Array<Function> };
  transition?: Object;
  show?: boolean; // marker for v-show
  inlineTemplate?: {
    render: Function,
    staticRenderFns: Array<Function>
  };
  directives?: Array<VNodeDirective>;
  keepAlive?: boolean;
  scopedSlots?: { [key: string]: Function };
  model?: {
    value: any,
    callback: Function
  };
}
  • children: vnode的子节点
  • text:当前节点的文本
  • elm: 当前虚拟节点对应的真实节点
  • parent: 当前节点的父节点

看完VNodeVue.js中的数据结构定义,我想你已经大概知道vdom是什么了吧。

Virtual DOM 是什么?

本质上来说,vdom只是一个简单的js对象,并且最少包含tagpropschildren三个属性。不同的框架对这三个属性的命名会有点差别,但表达的意思是一致的。它们分别是标签名(tag)属性(props)子元素对象(children)。下面是举一个经典的vdom例子:

<div>
  Hello jack-cool
  <ul>
    <li id="1" class="li-1">
      我是森林
    </li>
  </ul>
</div>

vdomdom对象有着一一对应的关系,上面的html对应生成的vdom如下:

{
    tag: "div",
    props: {},
    children: [
        "Hello jack-cool",
        {
            tag: "ul",
            props: {},
            children: [{
                tag: "li",
                props: {
                    id: 1,
                    class: "li-1"
                },
                children: ["我是", "森林"]
            }]
        }
    ]
}

Virtual DOM 有什么作用?

vdom的最终目标是将vnode渲染到视图上。但是如果直接使用新节点覆盖旧节点的话,会有很多不必要的DOM操作。

我们先来看下引入vdom前后,实现视图更新的不同流程:

引入 vdom 之前

  • 数据 + 模板生成真实 DOM
  • 数据发生改变
  • 新的数据 + 模板生成新的 DOM
  • 新的 DOM 替换掉原来的 DOM

这么做的缺点在于:即使模板中只有一个元素发生了变化,也会把整个模板替换掉。例如,一个ul标签下很多个li标签,其中只有一个li有变化,这种情况下如果使用新的ul去替代旧的ul,会有很多不必要的DOM操作而造成性能上的损失。

为了避免不必要的DOM操作,vdomvnode映射到视图的过程中,将vnode与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作其他无需改动的DOM

引入 vdom 之后

  • 数据 + 模板生成虚拟 DOM
  • 虚拟 DOM 生成真实 DOM
  • 数据发生改变
  • 新的数据 + 模板生成新的虚拟 DOM 而不是真实 DOM
  • 用新的虚拟 DOM 和原来的虚拟 DOM 作对比(diff 算法,后面会详细介绍)【性能 up↑
  • 找出发生改变的元素
  • 直接修改原来的真实 DOM【性能 up↑

总结

这一节我带大家大概了解了Virtual DOM的概念。Vue.jsVNode其实是借鉴了 snabbdom 的实现。

VNode 到真实 DOM 需要经过 creatediffpatch 等几个过程。本小节呢,我们只是大概了解一下vdom是什么以及它有什么作用。关于vdom的一些详细概念、流程和内部实现,我会在后面的章节中和大家分享(其实关于Virtual DOM单独出一个系列文章也不为过)。

performWork

在开始之前,我们先回顾一下ReactrequestWork之前的工作流程。React会先根据window.performance.now()方法获取一个从地址栏输入地址回车后到执行这个方法中间间隔的毫秒数,然后通过一个常量减去这个毫秒数/10 计算出一个expirationTime,然后fiber节点会根据实际情况判断是否记录此expirationTime作为自己最终的expirationTime

然后进入updateContainer流程,updateContainer流程中会创建一个update对象,这个对象会记录这次updateexpirationTimeeffecttagnext等信息,并最终组成一个叫做updateQueue的链表存储到fiber节点中。

接下来进入scheduleWork流程,react递归的更新FiberRoot上的与expirationTime相关的属性,找到下一个工作的expirationTime

接下来就进入requestWork方法。

// requestWork is called by the scheduler whenever a root receives an update.
// It's up to the renderer to call renderRoot at some point in the future.
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
  // 把当前 root设置为最高优先级
  addRootToSchedule(root, expirationTime);
  if (isRendering) {
    // 在render过程当中, 此时直接return
    // Prevent reentrancy. Remaining work will be scheduled at the end of
    // the currently rendering batch.
    return;
  }

  // 批量处理相关
  // 调用 setState 时在 enqueueUpdates 前 batchedUpdates 会把 isBatchingUpdates 设置成 true
  if (isBatchingUpdates) {
    // Flush work at the end of the batch.
    if (isUnbatchingUpdates) {
      // ...unless we're inside unbatchedUpdates, in which case we should
      // flush it now.
      nextFlushedRoot = root;
      nextFlushedExpirationTime = Sync;
      performWorkOnRoot(root, Sync, false);
    }
    return;
  }

  // TODO: Get rid of Sync and use current time?
  if (expirationTime === Sync) {
    // 同步的调用 js 代码
    performSyncWork();
  } else {
    // 异步调度 独立的 react 模块包,利用浏览器有空闲的时候进行执行,设置 deadline 在此之前执行
    scheduleCallbackWithExpirationTime(root, expirationTime);
  }
}

我们先看requestWork中调用的第一个方法addRootToSchedule,将root节点添加到调度队列中。

/**
 * 将 root 加入到调度队列
 *
 * @param {FiberRoot} root
 * @param {ExpirationTime} expirationTime
 */
function addRootToSchedule(root: FiberRoot, expirationTime: ExpirationTime) {
  // Add the root to the schedule.
  // Check if this root is already part of the schedule.

  // root.nextScheduledRoot 用来判断是否有异步任务正在调度, 为 null 时会增加 nextScheduledRoot
  // 这个 root 还没有进入过调度
  if (root.nextScheduledRoot === null) {
    // This root is not already scheduled. Add it.

    root.expirationTime = expirationTime;
    if (lastScheduledRoot === null) {
      // lastScheduledRoot firstScheduledRoot 是单向链表结构,表示多个 root 更新
      // 这里只有一个 root 只会在这里执行
      firstScheduledRoot = lastScheduledRoot = root;
      root.nextScheduledRoot = root;
    } else {
      // 有个多个root 时进行单向链表的插入操作
      lastScheduledRoot.nextScheduledRoot = root;
      lastScheduledRoot = root;
      lastScheduledRoot.nextScheduledRoot = firstScheduledRoot;
    }
  } else {
    // This root is already scheduled, but its priority may have increased.
    // 传入的 root 已经进入过调度, 把 root 的优先级设置最高
    const remainingExpirationTime = root.expirationTime;
    // 如果 root 的 expirationTime 是同步或者优先级低,增加为计算出的最高优先级
    if (expirationTime > remainingExpirationTime) {
      // Update the priority.
      // 把当前 root 的优先级设置为当前优先级最高的
      root.expirationTime = expirationTime;
    }
  }
}

还记得前面我们说过,首次渲染更新的时候expirationTime就是Sync,因此在将FiberRoot添加到调度队列中后,会进入到performSyncWork()方法。

function performSyncWork() {
  performWork(Sync, false);
}

performWork方法有两种调用形式,一种performSyncWork,一种performAsyncWork

/**
 * performWork 只发现了两种调用方式,performSyncWork和performAsyncWork
 * performSyncWork的minExpirationTime就是Sync,isYieldy是false
 * performAsyncWork的minExpirationTime是NoWork,isYieldy是true
 * @param {ExpirationTime} minExpirationTime
 * @param {boolean} isYieldy 任务是否可以中断
 */
function performWork(minExpirationTime: ExpirationTime, isYieldy: boolean) {
  // Keep working on roots until there's no more work, or until there's a higher
  // priority event.
  // 查找优先级最高的root
  findHighestPriorityRoot();

  if (isYieldy) {
    recomputeCurrentRendererTime();
    currentSchedulerTime = currentRendererTime;

    if (enableUserTimingAPI) {
      const didExpire = nextFlushedExpirationTime > currentRendererTime;
      const timeout = expirationTimeToMs(nextFlushedExpirationTime);
      stopRequestCallbackTimer(didExpire, timeout);
    }

    // nextFlushedRoot 下一个将要执行的 FiberRoot
    // nextFlushedExpirationTime !== NoWork 下一个FiberRoot中还有未执行的任务

    while (
      nextFlushedRoot !== null &&
      nextFlushedExpirationTime !== NoWork &&
      minExpirationTime <= nextFlushedExpirationTime &&
      !(didYield && currentRendererTime > nextFlushedExpirationTime)
    ) {
      performWorkOnRoot(
        nextFlushedRoot,
        nextFlushedExpirationTime,
        currentRendererTime > nextFlushedExpirationTime
      );
      findHighestPriorityRoot();
      recomputeCurrentRendererTime();
      currentSchedulerTime = currentRendererTime;
    }
  } else {
    while (
      nextFlushedRoot !== null &&
      nextFlushedExpirationTime !== NoWork &&
      minExpirationTime <= nextFlushedExpirationTime
    ) {
      performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false);
      findHighestPriorityRoot();
    }
  }

  // We're done flushing work. Either we ran out of time in this callback,
  // or there's no more work left with sufficient priority.

  // If we're inside a callback, set this to false since we just completed it.
  if (isYieldy) {
    callbackExpirationTime = NoWork;
    callbackID = null;
  }
  // If there's work left over, schedule a new callback.
  if (nextFlushedExpirationTime !== NoWork) {
    scheduleCallbackWithExpirationTime(
      ((nextFlushedRoot: any): FiberRoot),
      nextFlushedExpirationTime
    );
  }

  // Clean-up.
  finishRendering();
}

findHighestPriorityRoot在前面就遇到过,主要工作是查找优先级最高的Root

/**
 * 查找拥有最高渲染优先级的Root,通过设置nextFlushedRoot和nextFlushedExpirationTime进行控制
 *
 */
function findHighestPriorityRoot() {
  // 最高优先级的Work
  let highestPriorityWork = NoWork;
  // 最高优先级的Root
  let highestPriorityRoot = null;
  // 判断上一个被调度的Root是否为空,如果是,就证明是首次渲染,跳出判断。
  if (lastScheduledRoot !== null) {
    // 上一个被渲染的root节点
    let previousScheduledRoot = lastScheduledRoot;
    let root = firstScheduledRoot;
    // 从调度队列中的起始节点开始递归的判断调度队列中的root节点
    while (root !== null) {
      const remainingExpirationTime = root.expirationTime;
      if (remainingExpirationTime === NoWork) {
        // This root no longer has work. Remove it from the scheduler.

        // TODO: This check is redudant, but Flow is confused by the branch
        // below where we set lastScheduledRoot to null, even though we break
        // from the loop right after.
        // 如果root的expirationTime === NoWork, 则说明root没有任务需要完成,则将root从调度队列中移除
        // 调度队列是一个环形链表
        invariant(
          previousScheduledRoot !== null && lastScheduledRoot !== null,
          "Should have a previous and last root. This error is likely " +
            "caused by a bug in React. Please file an issue."
        );
        if (root === root.nextScheduledRoot) {
          // This is the only root in the list.
          // 只有一个Root的情况, 则直接清空整个调度链表
          root.nextScheduledRoot = null;
          firstScheduledRoot = lastScheduledRoot = null;
          break;
        } else if (root === firstScheduledRoot) {
          // This is the first root in the list.
          // 如果root是起始节点,起始节点后移
          const next = root.nextScheduledRoot;
          firstScheduledRoot = next;
          lastScheduledRoot.nextScheduledRoot = next;
          root.nextScheduledRoot = null;
        } else if (root === lastScheduledRoot) {
          // This is the last root in the list.
          // 如果root是结束节点,结束节点前移
          lastScheduledRoot = previousScheduledRoot;
          lastScheduledRoot.nextScheduledRoot = firstScheduledRoot;
          root.nextScheduledRoot = null;
          break;
        } else {
          // 如果root是中间节点,则删除
          previousScheduledRoot.nextScheduledRoot = root.nextScheduledRoot;
          root.nextScheduledRoot = null;
        }
        // 继续递归
        root = previousScheduledRoot.nextScheduledRoot;
      } else {
        // 如果root节点有更新任务
        if (remainingExpirationTime > highestPriorityWork) {
          // Update the priority, if it's higher
          // 找到优先级最高的expirationTime
          highestPriorityWork = remainingExpirationTime;
          highestPriorityRoot = root;
        }
        // 如果root是链表尾节点,跳出while循环
        if (root === lastScheduledRoot) {
          break;
        }
        // 碰到同步任务,跳出while循环
        if (highestPriorityWork === Sync) {
          // Sync is highest priority by definition so
          // we can stop searching.
          break;
        }
        previousScheduledRoot = root;
        // 继续递归
        root = root.nextScheduledRoot;
      }
    }
  }

  nextFlushedRoot = highestPriorityRoot;
  nextFlushedExpirationTime = highestPriorityWork;
}

在找到优先级最高的Root之后,由于我们这里是通过performSyncWork方法调用进入的performWork流程,因此isYieldyfalse,会递归的调用performWorkOnRoot()方法,然后通过findHighestPriorityRoot()查找下一个Root继续递归。performWorkOnRoot代码如下:

/**
 *
 *
 * @param {FiberRoot} root 进入更新流程的FiberRoot
 * @param {ExpirationTime} expirationTime 更新流程中expirationTime
 * @param {boolean} isYieldy 是否可以被中断
 */
function performWorkOnRoot(
  root: FiberRoot,
  expirationTime: ExpirationTime,
  isYieldy: boolean
) {
  invariant(
    !isRendering,
    "performWorkOnRoot was called recursively. This error is likely caused " +
      "by a bug in React. Please file an issue."
  );
  // 标记当前进入render流程
  isRendering = true;

  // Check if this is async work or sync/expired work.
  // 判断当前是同步更新还是异步更新
  if (!isYieldy) {
    // Flush work without yielding.
    // TODO: Non-yieldy work does not necessarily imply expired work. A renderer
    // may want to perform some work without yielding, but also without
    // requiring the root to complete (by triggering placeholders).

    // root在完成更新工作之后的fiber对象
    let finishedWork = root.finishedWork;
    // 如果FiberRoot已经完成了更新
    if (finishedWork !== null) {
      // This root is already complete. We can commit it.
      // 直接完成,进入commit流程
      completeRoot(root, finishedWork, expirationTime);
    } else {
      // 还原FiberRoot
      root.finishedWork = null;
      // If this root previously suspended, clear its existing timeout, since
      // we're about to try rendering again.
      const timeoutHandle = root.timeoutHandle;
      if (timeoutHandle !== noTimeout) {
        root.timeoutHandle = noTimeout;
        // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
        cancelTimeout(timeoutHandle);
      }
      // 进入渲染流程
      renderRoot(root, isYieldy);
      finishedWork = root.finishedWork;
      if (finishedWork !== null) {
        // We've completed the root. Commit it.
        completeRoot(root, finishedWork, expirationTime);
      }
    }
  } else {
    // Flush async work.
    let finishedWork = root.finishedWork;
    if (finishedWork !== null) {
      // This root is already complete. We can commit it.
      completeRoot(root, finishedWork, expirationTime);
    } else {
      root.finishedWork = null;
      // If this root previously suspended, clear its existing timeout, since
      // we're about to try rendering again.
      const timeoutHandle = root.timeoutHandle;
      if (timeoutHandle !== noTimeout) {
        root.timeoutHandle = noTimeout;
        // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
        cancelTimeout(timeoutHandle);
      }
      renderRoot(root, isYieldy);
      finishedWork = root.finishedWork;
      if (finishedWork !== null) {
        // We've completed the root. Check the if we should yield one more time
        // before committing.
        if (!shouldYieldToRenderer()) {
          // Still time left. Commit the root.
          completeRoot(root, finishedWork, expirationTime);
        } else {
          // There's no time left. Mark this root as complete. We'll come
          // back and commit it later.
          root.finishedWork = finishedWork;
        }
      }
    }
  }

  isRendering = false;
}

在首次渲染时我们进入的是performSyncWork的同步调用,因此isYieldyfalse,代码会执行到renderRoot方法,进入渲染Root的流程。

JavaScript中的这些*操作,你都知道吗?

引言 🏂

写这篇文章的缘由是上周在公司前端团队的code review时,看了一个实习小哥哥的代码后,感觉一些刚入行不久的同学,对于真实项目中的一些js处理不是很熟练,缺乏一些技巧。

因此整理了自己开发中常用的一些js技巧,灵活的运用,会增强你解决问题的能力,也会对你的代码简洁性有很大的改观。

数组去重 🐻

正常我们实现数组去重大多都是通过双层遍历或者indexOf的方式。

双层for循环去重

function unique(arr) {
  for (var i = 0; i < arr.length; i++) {
    for (var j = i + 1; j < arr.length; j++) {
      if (arr[i] == arr[j]) {
        arr.splice(j, 1);
        j--;
      }
    }
  }
  return arr;
}

利用indexOf去重

function unique(arr) {
  if (!Array.isArray(arr)) {
    console.log("type error!");
    return;
  }
  var array = [];
  for (var i = 0; i < arr.length; i++) {
    if (array.indexOf(arr[i]) === -1) {
      array.push(arr[i]);
    }
  }
  return array;
}

但其实有一种更简单的方式:利用Array.fromset去重

function unique(arr) {
  if (!Array.isArray(arr)) {
    console.log("type error!");
    return;
  }
  return Array.from(new Set(arr));
}

这种代码的实现是不是很简洁 😉

数组转化为对象(Array to Object)🦑

数组转化为对象,大多数同学首先想到的就是这种方法:

var obj = {};
var arr = ["1","2","3"];
for (var key in arr) {
    obj[key] = arr[key];
}
console.log(obj)

Output:
{0: 1, 1: 2, 2: 3}

但是有一种比较简单快速的方法:

const arr = [1,2,3]
const obj = {...arr}
console.log(obj)

Output:
{0: 1, 1: 2, 2: 3}

一行代码就能搞定的事情为什么还要用遍历呢?😛

合理利用三元表达式 👩‍👦‍👦

有些场景我们需要针对不同的条件,给变量赋予不同的值,我们往往会采用下面这种方式:

const isGood = true;
let feeling;
if (isGood) {
  feeling = 'good'
} else {
  feeling = 'bad'
}
console.log(`I feel ${feeling}`)

Output:
I feel good

但是为什么不采用三元表达式呢?

const isGood = true;
const feeling = isGood ? 'good' : 'bad'
console.log(`I feel ${feeling}`)

Output:
I feel good

这种也就是所谓的Single line(单行)**,其实就是代码趋向于简洁性

转换为数字类型(Convert to Number)🔢

这种是很常见的,大家用的比较多的可能是parseInt()Number()这种:

const age = "69";
const ageConvert = parseInt(age);
console.log(typeof ageConvert);

Output: number;

其实也可以通过+来实现转换:

const age = "69";
const ageConvert = +age;
console.log(typeof ageConvert);

Output: number;

转换为字符串类型(Convert to String)🔡

转换为字符串一般会用toString()String()实现:

let a = 123;

a.toString(); // '123'

但也可以通过value + ""这种来实现:

let a = 123;

a + ""; // '123'

性能追踪 🥇

如果你想测试一段js代码的执行耗时,那么你可以尝试下performance

let start = performance.now();
let sum = 0;
for (let i = 0; i < 100000; i++) {
  sum += 1;
}
let end = performance.now();
console.log(start);
console.log(end);

合并对象(Combining Objects)🌊

两个对象合并大家用的比较多的可能就是Object.assign了:

const obj1 = { a: 1 }
const obj2 = { b: 2 }
console.log(Object.assign(obj1, obj2))

Output:
{ a: 1, b: 2 }

其实有一种更简洁的方式:

const obj1 = { a: 1 }
const obj2 = { b: 2 }
const combinObj = { ...obj1, ...obj2 }
console.log(combinObj)

Output:
{ a: 1, b: 2 }

也就是通过展开操作符(spread operator)来实现。

短路运算(Short-circuit evaluation) 🥅

我们可以通过&&||来简化我们的代码,比如:

if (isOnline) {
  postMessage();
}
// 使用&&
isOnline && postMessage();

// 使用||
let name = null || "森林";

数组扁平化(Flattening an array)🍓

数组的扁平化,我们一般会用递归reduce去实现

递归

var arr = [1, [2, [3, 4]]];

function flatten(arr) {
  var result = [];
  for (var i = 0, len = arr.length; i < len; i++) {
    if (Array.isArray(arr[i])) {
      result = result.concat(flatten(arr[i]));
    } else {
      result.push(arr[i]);
    }
  }
  return result;
}

console.log(flatten(arr));

reduce

var arr = [1, [2, [3, 4]]];

function flatten(arr) {
  return arr.reduce(function (prev, next) {
    return prev.concat(Array.isArray(next) ? flatten(next) : next);
  }, []);
}

console.log(flatten(arr));

但是es6提供了一个新方法 flat(depth),参数depth,代表展开嵌套数组的深度,默认是1

let arr = [1, [2, 3, [4, [5]]]];
arr.flat(3); // [1,2,3,4,5]

求幂运算 🍜

平时我们实现指数运算,用的比较多的应该是Math.pow(),比如求2^10

console.log(Math.pow(2, 10));

ES7中引入了指数运算符****具有与Math.pow()一样的计算结果。

console.log(2 ** 10); // 输出1024

浮点数转为整数(Float to Integer)🦊

我们一般将浮点数转化为整数会用到Math.floor()Math.ceil()Math.round()。但其实有一个更快的方式:

console.log(~~6.95); // 6
console.log(6.95 >> 0); // 6
console.log(6.95 << 0); // 6
console.log(6.95 | 0); // 6
// >>>不可对负数取整
console.log(6.95 >>> 0); // 6

也就是使用~, >>, <<, >>>, |这些位运算符来实现取整

截断数组

如果你有修改数组长度为某固定值的需求,那么你可以试试这个

let array = [0, 1, 2, 3, 4, 5];
array.length = 3;
console.log(array);

Output: [0, 1, 2];

获取数组中的最后一项 🦁

通常,获取数组最后一项,我们用的比较多的是:

let arr = [0, 1, 2, 3, 4, 5];
const last = arr[arr.length - 1];
console.log(last);

Output: 5;

但我们也可以通过slice操作来实现:

let arr = [0, 1, 2, 3, 4, 5];
const last = arr.slice(-1)[0];
console.log(last);

Output: 5;

美化你的JSON 💄

日常开发中,我们会经常用到JSON.stringify,但大家可能并不大清楚他具体有哪些参数。

他有三个参数:

  • json: 必须,可以是数组或Object
  • replacer: 可选值,可以是数组,也可以是方法
  • space: 用什么来进行分隔

而我们恰恰可以指定第三个参数space的值去美化我们的JSON

Object.create(null) 🐶

VueVuex的源码中,作者都使用了Object.create(null)来初始化一个新对象。为什么不用更简洁的{}呢?
我们来看下Object.create()的定义:

Object.create(proto,[propertiesObject])
  • proto:新创建对象的原型对象
  • propertiesObject:可选。要添加到新对象的可枚举(新添加的属性是其自身的属性,而不是其原型链上的属性)的属性。

我们对比分别通过Object.create(null){}创建对象的不同:

从上图可以看到,通过{}创建的对象继承了Object自身的方法,如hasOwnPropertytoString等,在新对象上可以直接使用。

而使用Object.create(null)创建的对象,除了自身属性a之外,原型链上没有任何属性。

也就是我们可以通过Object.create(null)这种方式创建一个纯净的对象,我们可以自己定义hasOwnPropertytoString等方法,完全不必担心会将原型链上的同名方法覆盖掉。

拷贝数组 🐿

日常开发中,数组的拷贝是一个会经常遇到的场景。其实实现数组的拷贝有很多*技巧。

Array.slice

const arr = [1, 2, 3, 4, 5];
const copyArr = arr.slice();

展开操作符

const arr = [1, 2, 3, 4, 5];
const copyArr = [...arr]

使用 Array 构造函数和展开操作符

const arr = [1, 2, 3, 4, 5];
const copyArr = new Array(...arr)

Array.concat

const arr = [1, 2, 3, 4, 5];
const copyArr = arr.concat();

避免多条件并列 🦀

开发中有时会遇到多个条件,执行相同的语句,也就是多个||这种:

if (status === 'process' || status === 'wait' || status === 'fail') {
  doSomething()
}

这种写法语义性、可读性都不太好。可以通过switch caseincludes这种进行改造。

switch case

switch(status) {
  case 'process':
  case 'wait':
  case 'fail':
    doSomething()
}

includes

const enum = ['process', 'wait', 'fail']
if (enum.includes(status)) {
  doSomething()
}

Object.freeze() 🃏

Vue 的文档中介绍数据绑定和响应时,特意标注了对于经过 Object.freeze() 方法的对象无法进行更新响应。
Object.freeze() 方法用于冻结对象,禁止对于该对象的属性进行修改。

正是由于这种特性,所以在实际项目中,他有很多的适用场景。

像一些纯展示类的页面,可能存在巨大的数组或对象,如果这些数据不会发生更改,那么你就可以使用Object.freeze()将他们冻结,这样Vue就不会对这些对象做settergetter的转换,可以大大的提升性能。

一文带你快速上手Rollup

前言

项目中一直用的都是webpack,前一段需要开发几个类库供其他平台使用,本来打算继续用webpack的,但感觉webpack用来开发js库,不仅繁琐而且打包后的文件体积也比较大。正好之前看vue源码,知道vue也是通过rollup打包的。这次又是开发类库的,于是就快速上手了rollup

本篇文章是我有了一定的项目实践后,回过来给大家分享一下如何从零快速上手rollup

什么是rollup

系统的了解rollup之前,我们先来简单了解下What is rollup?

关于rollup的介绍,官方文档已经写的很清楚了:

Rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。

Webpack偏向于应用打包的定位不同,rollup.js更专注于Javascript类库打包。

我们熟知的VueReact等诸多知名框架或类库都是通过rollup.js进行打包的。

为什么是rollup

webpack我相信做前端的同学大家都用过,那么为什么有些场景还要使用rollup呢?这里我简单对webpackrollup做一个比较:

总体来说webpackrollup在不同场景下,都能发挥自身优势作用。webpack对于代码分割和静态资源导入有着“先天优势”,并且支持热模块替换(HMR),而rollup并不支持。

所以当开发应用时可以优先选择webpack,但是rollup对于代码的Tree-shakingES6模块有着算法优势上的支持,若你项目只需要打包出一个简单的bundle包,并是基于ES6模块开发的,可以考虑使用rollup

其实webpack2.0开始就已经支持Tree-shaking,并在使用babel-loader的情况下还可以支持es6 module的打包。实际上,rollup已经在渐渐地失去了当初的优势了。但是它并没有被抛弃,反而因其简单的API、使用方式被许多库开发者青睐,如ReactVue等,都是使用rollup作为构建工具的。

快速上手

我们先花大概十分钟左右的时间来了解下rollup的基本使用以及完成一个hello world

安装

首先全局安装rollup

npm i rollup -g

目录准备(hello world)

接着,我们初始化一个如下所示的项目目录

├── dist # 编译结果
├── example # HTML引用例子
   └── index.html
├── package.json
└── src # 源码
    └── index.js

首先我们在src/index.js中写入如下代码:

console.log("柯森");

然后在命令行执行以下命令:

rollup src/index.js -f umd -o dist/bundle.js

执行命令,我们即可在dist目录下生成bundle.js文件:

(function (factory) {
	typeof define === 'function' && define.amd ? define(factory) :
	factory();
}((function () { 'use strict';

	console.log("柯森");

})));

这时,我们再在example/index.html中引入上面打包生成的bundle.js文件,打开浏览器:

如我们所预料的,控制台输出了柯森

到这里,我们就用rollup打包了一个最最简单的demo

可能很多同学看到这里对于上面命令行中的参数不是很明白,我依次说明下:

  • -f-f参数是--format的缩写,它表示生成代码的格式,amd表示采用AMD标准,cjsCommonJS标准,esm(或 es)为ES模块标准。-f的值可以为amdcjssystemesm('es’也可以)、iifeumd中的任何一个。
  • -o-o指定了输出的路径,这里我们将打包后的文件输出到dist目录下的bundle.js

其实除了这两个,还有很多其他常用的命令(这里我暂且列举剩下两个也比较常用的,完整的rollup 命令行参数):

  • -c。指定rollup的配置文件。
  • -w。监听源文件是否有改动,如果有改动,重新打包。

使用配置文件(rollup.config.js)

使用命令行的方式,如果选项少没什么问题,但是如果添加更多的选项,这种命令行的方式就显得麻烦了。

为此,我们可以创建配置文件来囊括所需的选项

在项目中创建一个名为rollup.config.js的文件,增加如下代码:

export default {
  input: ["./src/index.js"],
  output: {
    file: "./dist/bundle.js",
    format: "umd",
    name: "experience",
  },
};

然后命令行执行:

rollup -c

打开dist/bundle.js文件,我们会发现和上面采用命令行的方式打包出来的结果是一样的。

这里,我对配置文件的选项做下简单的说明:

  • input表示入口文件的路径(老版本为 entry,已经废弃)
  • output表示输出文件的内容,它允许传入一个对象或一个数组,当为数组时,依次输出多个文件,它包含以下内容:
    • output.file:输出文件的路径(老版本为 dest,已经废弃)
    • output.format:输出文件的格式
    • output.banner:文件头部添加的内容
    • output.footer:文件末尾添加的内容

到这里,相信你已经差不多上手rollup了。

进阶

但是,这对于真实的业务场景是远远不够的。

下面,我将介绍rollup中的几种常用的插件以及external属性、tree-shaking机制。

resolve插件

为什么要使用resolve插件

在上面的入门案例中,我们打包的对象是本地的js代码和库,但实际开发中,不太可能所有的库都位于本地,我们大多会通过npm下载远程的库。

webpackbrowserify这样的其他捆绑包不同,rollup不知道如何打破常规去处理这些依赖。因此我们需要添加一些配置。

resolve插件使用

首先在我们的项目中添加一个依赖the-answer,然后修改src/index.js文件:

import answer from "the-answer";

export default function () {
  console.log("the answer is " + answer);
}

执行npm run build

这里为了方便,我将原本的rollup -c -w添加到了package.jsonscripts中:"build": "rollup -c -w"

会得到以下报错:

打包后的bundle.js仍然会在Node.js中工作,但是the-answer不包含在包中。为了解决这个问题,将我们编写的源码与依赖的第三方库进行合并,rollup.js为我们提供了resolve插件。

首先,安装resolve插件:

npm i -D @rollup/plugin-node-resolve

修改配置文件rollup.config.js

import resolve from "@rollup/plugin-node-resolve";

export default {
  input: ["./src/index.js"],
  output: {
    file: "./dist/bundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve()],
};

这时再次执行npm run build,可以发现报错已经没有了:

打开dist/bundle.js文件:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

  var index = 42;

  function index$1 () {
    console.log("the answer is " + index);
  }

  return index$1;

})));

打包文件bundle.js中已经包含了引用的模块。

有些场景下,虽然我们使用了resolve插件,但可能我们仍然想要某些库保持外部引用状态,这时我们就需要使用external属性,来告诉rollup.js哪些是外部的类库。

external 属性

修改rollup.js的配置文件:

import resolve from "@rollup/plugin-node-resolve";

export default {
  input: ["./src/index.js"],
  output: {
    file: "./dist/bundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve()],
  external: ["the-answer"],
};

重新打包,打开dist/bundle.js文件:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('the-answer')) :
  typeof define === 'function' && define.amd ? define(['the-answer'], factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory(global.answer));
}(this, (function (answer) { 'use strict';

  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }

  var answer__default = /*#__PURE__*/_interopDefaultLegacy(answer);

  function index () {
    console.log("the answer is " + answer__default['default']);
  }

  return index;

})));

这时我们看到the-answer已经是做为外部库被引入了。

commonjs插件

为什么需要commonjs插件

rollup.js编译源码中的模块引用默认只支持 ES6+的模块方式import/export。然而大量的npm模块是基于CommonJS模块方式,这就导致了大量 npm模块不能直接编译使用。

因此使得rollup.js编译支持npm模块和CommonJS模块方式的插件就应运而生:@rollup/plugin-commonjs

commonjs插件使用

首先,安装该模块:

npm i -D @rollup/plugin-commonjs

然后修改rollup.config.js文件:

import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
export default {
  input: ["./src/index.js"],
  output: {
    file: "./dist/bundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve(), commonjs()],
  external: ["the-answer"],
};

babel插件

为什么需要babel插件?

我们在src目录下添加es6.js文件(⚠️ 这里我们使用了 es6 中的箭头函数):

const a = 1;
const b = 2;
console.log(a, b);
export default () => {
  return a + b;
};

然后修改rollup.config.js配置文件:

import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
export default {
  input: ["./src/es6.js"],
  output: {
    file: "./dist/esBundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve(), commonjs()],
  external: ["the-answer"],
};

执行打包,可以看到dist/esBundle.js文件内容如下:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

  const a = 1;
  const b = 2;
  console.log(a, b);
  var es6 = () => {
    return a + b;
  };

  return es6;

})));

可以看到箭头函数被保留下来,这样的代码在不支持ES6的环境下将无法运行。我们期望在rollup.js打包的过程中就能使用babel完成代码转换,因此我们需要babel插件。

babel插件的使用

首先,安装:

npm i -D @rollup/plugin-babel

同样修改配置文件rollup.config.js

import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import babel from "@rollup/plugin-babel";

export default {
  input: ["./src/es6.js"],
  output: {
    file: "./dist/esBundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve(), commonjs(), babel()],
  external: ["the-answer"],
};

然后打包,发现会出现报错:

提示我们缺少@babel/core,因为@babel/corebabel的核心。我们来进行安装:

npm i @babel/core

再次执行打包,发现这次没有报错了,但是我们尝试打开dist/esBundle.js

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

  const a = 1;
  const b = 2;
  console.log(a, b);
  var es6 = (() => {
    return a + b;
  });

  return es6;

})));

可以发现箭头函数仍然存在,显然这是不正确的,说明我们的babel插件没有起到作用。这是为什么呢?

原因是由于我们缺少.babelrc文件,添加该文件:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false,
        // "useBuiltIns": "usage"
      }
    ]
  ]
}

我们看.babelrc配置了preset env,所以先安装这个插件:

npm i @babel/preset-env

这次再次执行打包,我们打开dist/esBundle.js文件:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

  var a = 1;
  var b = 2;
  console.log(a, b);
  var es6 = (function () {
    return a + b;
  });

  return es6;

})));

可以看到箭头函数被转换为了function,说明babel插件正常工作。

json插件

为什么要使用json插件?

src目录下创建json.js文件:

import json from "../package.json";
console.log(json.author);

内容很简单,就是引入package.json,然后去打印author字段。

修改rollup.config.js配置文件:

import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import babel from "@rollup/plugin-babel";

export default {
  input: ["./src/json.js"],
  output: {
    file: "./dist/jsonBundle.js",
    format: "umd",
    name: "experience",
  },
  plugins: [resolve(), commonjs(), babel()],
  external: ["the-answer"],
};

执行打包,发现会发生如下报错:

提示我们缺少@rollup/plugin-json插件来支持json文件。

json插件的使用

来安装该插件:

npm i -D @rollup/plugin-json

同样修改下配置文件,将插件加入plugins数组即可。

然后再次打包,发现打包成功了,我们打开生成的dist/jsonBundle目录:

(function (factory) {
  typeof define === 'function' && define.amd ? define(factory) :
  factory();
}((function () { 'use strict';

  var name = "rollup-experience";
  var version = "1.0.0";
  var description = "";
  var main = "index.js";
  var directories = {
  	example: "example"
  };
  var scripts = {
  	build: "rollup -c -w",
  	test: "echo \"Error: no test specified\" && exit 1"
  };
  var author = "Cosen";
  var license = "ISC";
  var dependencies = {
  	"@babel/core": "^7.11.6",
  	"@babel/preset-env": "^7.11.5",
  	"the-answer": "^1.0.0"
  };
  var devDependencies = {
  	"@rollup/plugin-babel": "^5.2.0",
  	"@rollup/plugin-commonjs": "^15.0.0",
  	"@rollup/plugin-json": "^4.1.0",
  	"@rollup/plugin-node-resolve": "^9.0.0"
  };
  var json = {
  	name: name,
  	version: version,
  	description: description,
  	main: main,
  	directories: directories,
  	scripts: scripts,
  	author: author,
  	license: license,
  	dependencies: dependencies,
  	devDependencies: devDependencies
  };

  console.log(json.author);

})));

完美!!

tree-shaking机制

这里我们以最开始的src/index.js为例进行说明:

import answer from "the-answer";

export default function () {
  console.log("the answer is " + answer);
}

修改上述文件:

const a = 1;
const b = 2;
export default function () {
  console.log(a + b);
}

执行打包。打开dist/bundle.js文件:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

  var a = 1;
  var b = 2;
  function index () {
    console.log(a + b);
  }

  return index;

})));

再次修改src/index.js文件:

const a = 1;
const b = 2;
export default function () {
  console.log(a);
}

再次执行打包,打开打包文件:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.experience = factory());
}(this, (function () { 'use strict';

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

  return index;

})));

发现了什么?

我们发现关于变量b的定义没有了,因为源码中并没有用到这个变量。这就是ES模块著名的tree-shaking机制,它动态地清除没有被使用过的代码,使得代码更加精简,从而可以使得我们的类库获得更快的加载速度。

总结

本文大致向大家介绍了什么是rollup以及如何快速上手rollup。文中提到的这些其实只是冰山一角,rollup能玩的东西还有很多,关于更多可以去rollup 官网查询

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

FiberRoot和RootFiber

FiberRootRootFiber

接下来上面的继续分析legacyCreateRootFromDOMContainer方法中的剩余内容,在函数体的结尾返回了调用createLegacyRoot方法返回的一个ReactSyncRoot实例。来看createLegacyRoot方法的定义(packages/react-dom/src/client/ReactDOMRoot.js):

// packages/react-dom/src/client/ReactDOMRoot.js
export function createLegacyRoot(
  container: Container,
  options?: RootOptions
): RootType {
  return new ReactDOMBlockingRoot(container, LegacyRoot, options);
}

内部又实例化了ReactDOMBlockingRoot

// packages/react-dom/src/client/ReactDOMRoot.js
/**
 * ReactSyncRoot构造函数
 *
 * @param {Container} container DOM容器
 * @param {RootTag} tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @param {(void | RootOptions)} options 配置信息,只有在hydrate时才有值,否则为undefined
 */
function ReactDOMBlockingRoot(
  container: Container,
  tag: RootTag,
  options: void | RootOptions
) {
  this._internalRoot = createRootImpl(container, tag, options);
}

内部调用了createRootImpl方法,并将返回结果赋值给_internalRoot,来看createRootImpl方法的定义:

/**
 * 创建并返回一个fiberRoot
 *
 * @param {Container} container DOM容器
 * @param {RootTag} tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @param {(void | RootOptions)} options 配置信息,只有在hydrate时才有值,否则为undefined
 * @returns
 */
function createRootImpl(
  container: Container,
  tag: RootTag,
  options: void | RootOptions
) {
  // Tag is either LegacyRoot or Concurrent Root
  // 判断是否为hydrate模式
  const hydrate = options != null && options.hydrate === true;
  const hydrationCallbacks =
    (options != null && options.hydrationOptions) || null;
  const mutableSources =
    (options != null &&
      options.hydrationOptions != null &&
      options.hydrationOptions.mutableSources) ||
    null;
  // 创建一个fiberRoot
  const root = createContainer(container, tag, hydrate, hydrationCallbacks);
  // 给container附加一个内部属性用于指向fiberRoot的current属性对应的rootFiber节点
  markContainerAsRoot(root.current, container);
  const containerNodeType = container.nodeType;

  if (enableEagerRootListeners) {
    const rootContainerElement =
      container.nodeType === COMMENT_NODE ? container.parentNode : container;
    listenToAllSupportedEvents(rootContainerElement);
  } else {
    if (hydrate && tag !== LegacyRoot) {
      const doc =
        containerNodeType === DOCUMENT_NODE
          ? container
          : container.ownerDocument;
      // We need to cast this because Flow doesn't work
      // with the hoisted containerNodeType. If we inline
      // it, then Flow doesn't complain. We intentionally
      // hoist it to reduce code-size.
      eagerlyTrapReplayableEvents(container, ((doc: any): Document));
    } else if (
      containerNodeType !== DOCUMENT_FRAGMENT_NODE &&
      containerNodeType !== DOCUMENT_NODE
    ) {
      ensureListeningTo(container, "onMouseEnter", null);
    }
  }

  if (mutableSources) {
    for (let i = 0; i < mutableSources.length; i++) {
      const mutableSource = mutableSources[i];
      registerMutableSourceForHydration(root, mutableSource);
    }
  }

  return root;
}

从上述源码中,我们可以看到createRootImpl方法通过调用createContainer方法来创建一个fiberRoot实例,并将该实例返回并赋值到ReactSyncRoot构造函数的内部成员_internalRoot属性上。我们继续深入createContainer方法去探究一下fiberRoot完整的创建过程,该方法被抽取到与react-dom包同级的另一个相关的依赖包react-reconciler包中,然后定位到packages/react-reconciler/src/ReactFiberReconciler.new.js

/**
 * 内部调用createFiberRoot方法返回一个fiberRoot实例
 *
 * @export
 * @param {Container} containerInfo DOM容器
 * @param {RootTag} tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @param {boolean} hydrate 判断是否是hydrate模式
 * @param {(null | SuspenseHydrationCallbacks)} hydrationCallbacks 只有在hydrate模式时才可能有值,该对象包含两个可选的方法:onHydrated和onDeleted
 * @returns {OpaqueRoot}
 */
export function createContainer(
  containerInfo: Container,
  tag: RootTag,
  hydrate: boolean,
  hydrationCallbacks: null | SuspenseHydrationCallbacks
): OpaqueRoot {
  return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks);
}

内部调用了createFiberRoot方法,定义在packages/react-reconciler/src/ReactFiberRoot.new.js:

/**
 * 创建fiberRoot和rootFiber并相互引用
 *
 * @export
 * @param {*} containerInfo DOM容器
 * @param {RootTag} tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @param {boolean} hydrate 判断是否是hydrate模式
 * @param {(null | SuspenseHydrationCallbacks)} hydrationCallbacks 只有在hydrate模式时才可能有值,该对象包含两个可选的方法:onHydrated和onDeleted
 * @returns {FiberRoot}
 */
export function createFiberRoot(
  containerInfo: any,
  tag: RootTag,
  hydrate: boolean,
  hydrationCallbacks: null | SuspenseHydrationCallbacks
): FiberRoot {
  // 通过FiberRootNode构造函数创建一个fiberRoot实例
  const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
  if (enableSuspenseCallback) {
    root.hydrationCallbacks = hydrationCallbacks;
  }

  // Cyclic construction. This cheats the type system right now because
  // stateNode is any.
  // 通过createHostRootFiber方法创建fiber tree的根结点,即rootFiber
  // fiber节点也会像DOM树结构一样形成一个fiber tree单链表树结构
  // 每个DOM节点或者组件都会生成一个与之对应的fiber节点,在后续的调和(reconciliation)阶段起着至关重要的作用
  const uninitializedFiber = createHostRootFiber(tag);
  // 创建完rootFiber之后,会将fiberRoot的实例的current属性指向刚创建的rootFiber
  root.current = uninitializedFiber;
  // 同时rootFiber的stateNode属性会指向fiberRoot实例,形成相互引用
  uninitializedFiber.stateNode = root;

  initializeUpdateQueue(uninitializedFiber);
  // 最后将创建的fiberRoot实例返回
  return root;
}

一个完整的FiberRootNode实例包含了很多有用的属性,这些属性在任务调度阶段都发挥着各自的作用,可以看到完整的FiberRootNode构造函数的实现(这里只列举部分属性):

// packages/react-reconciler/src/ReactFiberRoot.new.js
/**
 *  FiberRootNode构造函数
 * @param containerInfo DOM容器
 * @param tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @param hydrate 判断是否是hydrate模式
 * @constructor
 */
function FiberRootNode(containerInfo, tag, hydrate) {
  // 用于标记fiberRoot的类型
  this.tag = tag;
  // 和fiberRoot关联的DOM容器的相关信息
  this.containerInfo = containerInfo;
  // 指向当前激活的与之对应的rootFiber节点
  this.current = null;

  ...
  // 当前的fiberRoot是否处于hydrate模式
  this.hydrate = hydrate;
  ...
  // 每个fiberRoot实例上都只会维护一个任务,该任务保存在callbackNode属性中
  this.callbackNode = null;
  // 当前任务的优先级
  this.callbackPriority = NoPriority;
  ...
}

在了解完了fiberRoot的属性结构之后,接下来继续探究createFiberRoot方法的后半部分内容:

// packages/react-reconciler/src/ReactFiberRoot.new.js > createFiberRoot
// Cyclic construction. This cheats the type system right now because
// stateNode is any.
// 通过createHostRootFiber方法创建fiber tree的根结点,即rootFiber
// fiber节点也会像DOM树结构一样形成一个fiber tree单链表树结构
// 每个DOM节点或者组件都会生成一个与之对应的fiber节点,在后续的调和(reconciliation)阶段起着至关重要的作用
const uninitializedFiber = createHostRootFiber(tag);
// 创建完rootFiber之后,会将fiberRoot的实例的current属性指向刚创建的rootFiber
root.current = uninitializedFiber;
// 同时rootFiber的stateNode属性会指向fiberRoot实例,形成相互引用
uninitializedFiber.stateNode = root;

initializeUpdateQueue(uninitializedFiber);
// 最后将创建的fiberRoot实例返回
return root;

看下createHostRootFiber,定义在packages/react-reconciler/src/ReactFiber.new.js

/**
 * 内部调用createFiber方法创建一个FiberNode实例
 *
 * @export
 * @param {RootTag} tag fiberRoot节点的标记(LegacyRoot、BatchedRoot、ConcurrentRoot)
 * @returns {Fiber}
 */
export function createHostRootFiber(tag: RootTag): Fiber {
  // 根据fiberRoot的标记类型来动态设置rootFiber的mode属性
  let mode;
  // mode定义在packages/react-reconciler/src/ReactTypeOfMode.js
  // export const NoMode = 0b00000; => 0
  // export const StrictMode = 0b00001; => 1
  // export const BlockingMode = 0b00010; => 2
  // export const ConcurrentMode = 0b00100; => 4
  // export const ProfileMode = 0b01000; => 8
  // export const DebugTracingMode = 0b10000;
  if (tag === ConcurrentRoot) {
    mode = ConcurrentMode | BlockingMode | StrictMode;
  } else if (tag === BlockingRoot) {
    mode = BlockingMode | StrictMode;
  } else {
    mode = NoMode;
  }

  if (enableProfilerTimer && isDevToolsPresent) {
    // Always collect profile timings when DevTools are present.
    // This enables DevTools to start capturing timing at any point–
    // Without some nodes in the tree having empty base times.
    mode |= ProfileMode;
  }
  // 调用createFiber方法创建并返回一个FiberNode实例,HostRoot表示fiber tree的根节点
  return createFiber(HostRoot, null, null, mode);
}

内部调用了createFiber,定义在packages/react-reconciler/src/ReactFiber.new.js

/**
 * 创建并返回一个FiberNode实例
 * @param {*} tag 用于标记fiber节点的类型
 * @param {*} pendingProps 表示待处理的props数据
 * @param {*} key 用于唯一标识一个fiber节点(特别在一些列表数据结构中,一般会要求为每个DOM节点或组件加上额外的key属性,在后续的调和阶段会派上用场)
 * @param {*} mode 表示fiber节点的模式
 */
const createFiber = function (
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode
): Fiber {
  // $FlowFixMe: the shapes are exact here but Flow doesn't like constructors
  // FiberNode构造函数用于创建一个FiberNode实例,即一个fiber节点
  return new FiberNode(tag, pendingProps, key, mode);
};

至此我们就成功地创建了一个fiber节点,上文中我们提到过,和DOM树结构类似,fiber节点也会形成一个与DOM树结构对应的fiber tree,并且是基于单链表的树结构,我们在上面刚创建的fiber节点可作为整个fiber tree的根节点,即RootFiber节点。

在目前阶段,我们暂时不用关心一个fiber节点所包含的所有属性,但可以稍微留意一下以下相关属性:

/**
 * FiberNode构造函数
 * @param tag 用于标记fiber节点的类型
 * @param pendingProps 表示待处理的props数据
 * @param key 用于唯一标识一个fiber节点(特别在一些列表数据结构中,一般会要求为每个DOM节点或组件加上额外的key属性,在后续的调和阶段会派上用场)
 * @param mode 表示fiber节点的模式
 * @constructor
 */
function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  // 用于标记fiber节点的类型
  this.tag = tag;
  // 用于唯一标识一个fiber节点
  this.key = key;
  ...
  // 对于rootFiber节点而言,stateNode属性指向对应的fiberRoot节点
  // 对于child fiber节点而言,stateNode属性指向对应的组件实例
  this.stateNode = null;

  // Fiber
  // 以下属性创建单链表树结构
  // return属性始终指向父节点
  // child属性始终指向第一个子节点
  // sibling属性始终指向第一个兄弟节点
  this.return = null;
  this.child = null;
  this.sibling = null;
  // index属性表示当前fiber节点的索引
  this.index = 0;
  ...

  // 表示待处理的props数据
  this.pendingProps = pendingProps;
  // 表示之前已经存储的props数据
  this.memoizedProps = null;
  // 表示更新队列
  // 例如在常见的setState操作中
  // 其实会先将需要更新的数据存放到这里的updateQueue队列中用于后续调度
  this.updateQueue = null;
  // 表示之前已经存储的state数据
  this.memoizedState = null;
  ...

  // 表示fiber节点的模式
  this.mode = mode;

  // 用于指向另一个fiber节点
  // 这两个fiber节点使用alternate属性相互引用,形成双缓冲
  // alternate属性指向的fiber节点在任务调度中又称为workInProgress节点
  this.alternate = null;
  ...
}

在本小节中我们主要是为了理解FiberRootRootFiber这两个容易混淆的概念以及两者之间的联系。同时在这里我们需要特别注意的是,多个fiber节点可形成基于单链表的树形结构,通过自身的returnchildsibling属性可以在多个fiber节点之间建立联系。

「源码级回答」大厂高频Vue面试题(中)

写在前面

本篇是「源码级回答」大厂高频Vue面试题系列的第二篇,本篇也是选择了面试中经常会问到的一些经典面试题,从源码角度去分析。

想从第一篇开始看的,地址这里

话不多说,干就完了!

简述 Vue 中 diff 算法原理

diff 简介

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

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

updateChildren

我们知道,在对 model 进行操作时,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch 的过程,比对得出「差异」,最终将这些「差异」更新到视图上。

diff 算法又是patch 的核心内容,我们用 diff 算法可以比对出两颗树的「差异」,假设我们现在有如下两颗树,它们分别是新老 VNode 节点,这时候到了 patch 的过程,我们需要将他们进行比对:

diff算法01

diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。

diff算法02

图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。

patch 的过程比较复杂,我们这里主要说一下「oldChch 都存在且不相同时,使用 updateChildren 函数来更新子节点」这种情况。

来看下updateChildren函数

为了方便理解,我在对应代码中添加了注释

function updateChildren(
  parentElm,
  oldCh,
  newCh,
  insertedVnodeQueue,
  removeOnly
) {
  let oldStartIdx = 0; // oldVnode开始下标
  let newStartIdx = 0; // newVnode开始下标
  let oldEndIdx = oldCh.length - 1; // oldVnode结束下标
  let newEndIdx = newCh.length - 1; // newVnode结束下标
  let oldStartVnode = oldCh[0]; // oldVnode开始节点
  let newStartVnode = newCh[0]; // newVnode开始节点
  let oldEndVnode = oldCh[oldEndIdx]; // oldVnode结束节点
  let newEndVnode = newCh[newEndIdx]; // newVnode结束节点

  let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

  // ...
}

首先定义了 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的开始/结束的下标,同时 oldStartVnodenewStartVnodeoldEndVnode 以及 newEndVnode 分别指向这几个索引对应的 VNode 节点。

diff算法03

接下来是一个 while 循环,在这过程中,oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}

diff算法04

首先当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdxoldEndIdx 继续向中间靠拢,并更新对应的 oldStartVnodeoldEndVnode 的指向。

if (isUndef(oldStartVnode)) {
  oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEndVnode)) {
  oldEndVnode = oldCh[--oldEndIdx];
}

接下来这一块,是将 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 两两比对的过程,一共会出现 2*2=4 种情况。

首先是 oldStartVnodenewStartVnode 符合 sameVnode 时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdxnewStartIdx 向后移动一位。

if (sameVnode(oldStartVnode, newStartVnode)) {
  // 首先是 oldStartVnode 与 newStartVnode 符合 sameVnode 时,
  // 说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位
  patchVnode(
    oldStartVnode,
    newStartVnode,
    insertedVnodeQueue,
    newCh,
    newStartIdx
  );
  oldStartVnode = oldCh[++oldStartIdx];
  newStartVnode = newCh[++newStartIdx];
}

diff算法05

其次是 oldEndVnodenewEndVnode 符合 sameVnode,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作并将 oldEndVnodenewEndVnode 向前移动一位。

if (sameVnode(oldEndVnode, newEndVnode)) {
  // 其次是 oldEndVnode 与 newEndVnode 符合 sameVnode,
  // 也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作并将 oldEndVnode 与 newEndVnode 向前移动一位。
  patchVnode(
    oldEndVnode,
    newEndVnode,
    insertedVnodeQueue,
    newCh,
    newEndIdx
  );
  oldEndVnode = oldCh[--oldEndIdx];
  newEndVnode = newCh[--newEndIdx];
}

diff算法06

接下来是oldStartVnodenewEndVnode 符合 sameVnode 的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

if (sameVnode(oldStartVnode, newEndVnode)) {
  // oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,
  // 也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,
  // 将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。
  patchVnode(
    oldStartVnode,
    newEndVnode,
    insertedVnodeQueue,
    newCh,
    newEndIdx
  );
  canMove &&
    nodeOps.insertBefore(
      parentElm,
      oldStartVnode.elm,
      nodeOps.nextSibling(oldEndVnode.elm)
    );
  oldStartVnode = oldCh[++oldStartIdx];
  newEndVnode = newCh[--newEndIdx];
}

diff算法07

最后是oldEndVnodenewStartVnode 符合 sameVnode 时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

if (sameVnode(oldEndVnode, newStartVnode)) {
  // oldEndVnode 与 newStartVnode 符合 sameVnode 时,
  // 也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,
  // 将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。
  patchVnode(
    oldEndVnode,
    newStartVnode,
    insertedVnodeQueue,
    newCh,
    newStartIdx
  );
  canMove &&
    nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
  oldEndVnode = oldCh[--oldEndIdx];
  newStartVnode = newCh[++newStartIdx];
}

diff算法08

如果都不满足以上四种情形,那说明没有相同的节点可以复用。于是则通过查找事先建立好的以旧的 VNodekey 值,对应 indexvalue 值的哈希表。

从这个哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,如果两者满足 sameVnode 的条件,在进行 patchVnode 的同时会将这个真实 dom 移动到 oldStartVnode 对应的真实 dom 的前面;如果没有找到,则说明当前索引下的新的 VNode 节点在旧的 VNode 队列中不存在,无法进行节点的复用,那么就只能调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置。

最后还有一段代码:

// while 循环结束
if (oldStartIdx > oldEndIdx) {
  // 如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
  addVnodes(
    parentElm,
    refElm,
    newCh,
    newStartIdx,
    newEndIdx,
    insertedVnodeQueue
  );
} else if (newStartIdx > newEndIdx) {
  // 如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。
  removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}

while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。

如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。

Vue 组件中的 data 为什么是个函数?

其实这个问题还有下半句:而 new Vue 实例里,data 可以直接是一个对象?

先来看下平时在组件和new Vue时使用data的场景:

// 组件
data() {
  return {
   msg: "hello 森林",
  }
}

// new Vue
new Vue({
  data: {
    msg: 'hello jack-cool'
  },
  el: '#app',
  router,
  template: '<App/>',
  components: {
    App
  }
})

我们知道,Vue组件其实就是一个Vue实例。

JS中的实例是通过构造函数来创建的,每个构造函数可以new出很多个实例,那么每个实例都会继承原型上的方法或属性。

Vuedata数据其实是Vue原型上的属性,数据存在于内存当中

Vue为了保证每个实例上的data数据的独立性,规定了必须使用函数,而不是对象。

因为使用对象的话,每个实例(组件)上使用的data数据是相互影响的,这当然就不是我们想要的了。对象是对于内存地址的引用,直接定义个对象的话组件之间都会使用这个对象,这样会造成组件之间数据相互影响。

我们来看个示例:

// 创建一个简单的构建函数
var MyComponent = function() {
    // ...
}
// 原型链对象上设置data数据,data设为Object
MyComponent.prototype.data = {
  name: '森林',
  age: 20,
}
// 创建两个实例:春娇,志明
var chunjiao = new MyComponent()
var zhiming = new MyComponent()
// 默认状态下春娇和志明的年龄一样
console.log(chunjiao.data.age === zhiming.data.age) // true
// 改变春娇的年龄
chunjiao.data.age = 25;
// 打印志明的年龄,发现因为改变了春娇的年龄,结果造成志明的年龄也变了
console.log(chunjiao.data.age)// 25
console.log(zhiming.data.age) // 25

使用函数后,使用的是data()函数,data()函数中的this指向的是当前实例本身,就不会相互影响了。

总结一下,就是:

组件中的data是一个函数的原因在于:同一个组件被复用多次,会创建多个实例。这些实例用的是同一个构造函数,如果 data 是一个对象的话。那么所有组件都共享了同一个对象。为了保证组件的数据独立性要求每个组件必须通过 data 函数返回一个对象作为组件的状态。

new Vue 的实例,是不会被复用的,因此不存在引用对象的问题。

谈谈你对 Vue 生命周期的理解?

回答这个问题,我们先要概括的回答一下Vue生命周期是什么:

Vue 实例有一个完整的生命周期,也就是从开始创建、初始化数据、编译模版、挂载 Dom -> 渲染、更新 -> 渲染、卸载等一系列过程,我们称这是 Vue 的生命周期。

这里放上官网的生命周期流程图:

vue-lifecycle

我这里用一张图梳理了源码中关于周期的全流程(长图预警):

谈谈对Vue生命周期的理解

  • Vue本质上是一个构造函数,定义在src/core/instance/index.js中:
// src/core/instance/index.js
function Vue(options) {
  if (process.env.NODE_ENV !== "production" && !(this instanceof Vue)) {
    warn("Vue is a constructor and should be called with the `new` keyword");
  }
  this._init(options);
}
  • 构造函数的核心是调用了_init方法,_init定义在src/core/instance/init.js中:
// src/core/instance/init.js
Vue.prototype._init = function(options?: Object) {
  const vm: Component = this;
  // a uid
  vm._uid = uid++;
  [1];
  let startTag, endTag;
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    startTag = `vue-perf-start:${vm._uid}`;
    endTag = `vue-perf-end:${vm._uid}`;
    mark(startTag);
  }

  // a flag to avoid this being observed
  vm._isVue = true;
  // merge options
  if (options && options._isComponent) {
    // optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options);
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    );
  }
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== "production") {
    initProxy(vm);
  } else {
    vm._renderProxy = vm;
  }
  // expose real self
  vm._self = vm;
  initLifecycle(vm);
  initEvents(vm);
  initRender(vm);
  callHook(vm, "beforeCreate");
  initInjections(vm); // resolve injections before data/props
  initState(vm);
  initProvide(vm); // resolve provide after data/props
  callHook(vm, "created")[2];
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    vm._name = formatComponentName(vm, false);
    mark(endTag);
    measure(`vue ${vm._name} init`, startTag, endTag);
  }

  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
};

_init内调用了很多初始化函数,从函数名称可以看出分别是执行初始化生命周期(initLifecycle)、初始化事件中心(initEvents)、初始化渲染(initRender)、执行beforeCreate钩子(callHook(vm, 'beforeCreate'))、解析 inject(initInjections)、初始化状态(initState)、解析 provide(initProvide)、执行created钩子(callHook(vm, 'created'))。

  • _init函数的最后有判断如果有el就执行$mount方法。定义在src/platforms/web/entry-runtime-with-compiler.js中:
// src/platforms/web/entry-runtime-with-compiler.js

// ...

const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el);

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== "production" &&
      warn(
        `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
      );
    return this;
  }

  const options = this.$options;
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template;
    if (template) {
      if (typeof template === "string") {
        // ...
      } else if (template.nodeType) {
        template = template.innerHTML;
      } else {
        // ...
        return this;
      }
    } else if (el) {
      template = getOuterHTML(el);
    }
    if (template) {
       // ...
    }
  }
  return mount.call(this, el, hydrating);
};
// ...

export default Vue;

这里面主要做了两件事:

1、 重写了Vue函数的原型上的$mount函数

2、 判断是否有模板,并且将模板转化成render函数

最后调用了runtimemount方法,用来挂载组件,也就是mountComponent方法。

  • mountComponent内首先调用了beforeMount方法,然后在初次渲染和更新后会执行vm._update(vm._render(), hydrating)方法。最后渲染完成后调用mounted钩子。
  • beforeUpdateupdated钩子是在页面发生变化,触发更新后,被调用的,对应是在src/core/observer/scheduler.jsflushSchedulerQueue函数中。
  • beforeDestroy 和 destroyed 都在执行 $destroy 函数时被调用。$destroy 函数是定义在 Vue.prototype 上的一个方法,对应在 src/core/instance/lifecycle.js 文件中:
// src/core/instance/lifecycle.js

Vue.prototype.$destroy = function() {
  const vm: Component = this;
  if (vm._isBeingDestroyed) {
    return;
  }
  callHook(vm, "beforeDestroy");
  vm._isBeingDestroyed = true;
  // remove self from parent
  const parent = vm.$parent;
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm);
  }
  // teardown watchers
  if (vm._watcher) {
    vm._watcher.teardown();
  }
  let i = vm._watchers.length;
  while (i--) {
    vm._watchers[i].teardown();
  }
  // remove reference from data ob
  // frozen object may not have observer.
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--;
  }
  // call the last hook...
  vm._isDestroyed = true;
  // invoke destroy hooks on current rendered tree
  vm.__patch__(vm._vnode, null);
  // fire destroyed hook
  callHook(vm, "destroyed");
  // turn off all instance listeners.
  vm.$off();
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null;
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null;
  }
};

Vue 中常见的性能优化方式

编码优化

  • 尽量不要将所有的数据都放在data中,data中的数据都会增加gettersetter,会收集对应的 watcher
  • vuev-for 时给每项元素绑定事件尽量用事件代理
  • 拆分组件( 提高复用性、增加代码的可维护性,减少不必要的渲染 )
  • v-if 当值为false时内部指令不会执行,具有阻断功能,很多情况下使用v-if替代v-show
  • 合理使用路由懒加载、异步组件
  • Object.freeze 冻结数据

用户体验

  • app-skeleton 骨架屏
  • pwa serviceworker

加载性能优化

  • 第三方模块按需导入 ( babel-plugin-component )
  • 滚动到可视区域动态加载 ( https://tangbc.github.io/vue-virtual-scroll-list )
  • 图片懒加载 (https://github.com/hilongjw/vue-lazyload.git)

SEO 优化

  • 预渲染插件 prerender-spa-plugin
  • 服务端渲染 ssr

打包优化

  • 使用 cdn 的方式加载第三方模块
  • 多线程打包 happypackparallel-webpack
  • 控制包文件大小(tree shaking / splitChunksPlugin
  • 使用DllPlugin提高打包速度

缓存/压缩

  • 客户端缓存/服务端缓存
  • 服务端gzip压缩

你不知道的npm

引言

作为 node 自带的包管理器工具,在 nodejs 社区和 web 前端工程化领域发展日益庞大的背景下,npm已经成为每位前端开发同学必备的工具。

每天,无数的开发人员使用npm来构建项目,npm initnpm install等方式几乎成为了构建项目的首选方式,但是大多数同学对于 npm 的使用却只停留在了npm install这里。(相信删除 node_modules 文件夹,重新执行 npm install 这种事情你应该做过吧)

本篇文章主要是结合我以往的经验,带大家更深层次的了解一下 npm

npm 中的依赖包

依赖包类型

npm 目前支持一下几种类型的依赖包管理

  • dependencies
  • devDependencies
  • peerDependencies
  • optionalDependencies
  • bundledDependencies / bundleDependencies
  "dependencies": {
    "koa": "^2.7.0",
    "koa-bodyparser": "^4.2.1",
    "koa-redis": "^4.0.0",
  },
  "devDependencies": {
    "babel-eslint": "^10.0.3",
    "cross-env": "^6.0.3",
    "lint-staged": "^9.5.0",
    "mysql2": "^2.1.0",
    "nodemon": "^1.19.1",
    "precommit": "^1.2.2",
    "redis": "^2.8.0",
    "sequelize": "^5.21.3",
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": []

dependencies

应用依赖,或者叫做业务依赖,是我们最常用的一种。这种依赖是应用发布后上线所需要的,也就是说其中的依赖项属于线上代码的一部分。比如框架react,第三方的组件库ant-design等。可通过下面的命令来安装:

npm i ${packageName} -S

devDependencies

开发环境依赖。这种依赖只在项目开发时所需要,比如构建工具webpackgulp,单元测试工具jestmocha等。可通过下面的命令来安装:

npm i ${packageName} -D

peerDependencies

同行依赖。这种依赖的作用是提示宿主环境去安装插件在peerDependencies中所指定依赖的包,用于解决插件与所依赖包不一致的问题。

听起来可能没有那么好理解,举个例子来说明下。[email protected]只是提供了一套基于reactui组件库,但它要求宿主环境需要安装指定的react版本,所以你可以看到 node_modules 中 antd 的package.json中有这么一项配置:

"peerDependencies": {
    "react": ">=16.0.0",
    "react-dom": ">=16.0.0"
  },

它要求宿主环境安装大于等于16.0.0版本的react,也就是antd的运行依赖宿主环境提供的该范围的react安装包。

在安装插件的时候,peerDependencies 在npm 2.xnpm 3.x中表现不一样。npm2.x 会自动安装同等依赖,npm3.x 不再自动安装,会产生警告!手动在package.json文件中添加依赖项可以解决。

optionalDependencies

可选依赖。这种依赖中的依赖包即使安装失败了,也不影响整个安装的过程。需要注意的是,optionalDependencies会覆盖dependencies中的同名依赖包,所以不要在两个地方都写。

在实际项目中,如果某个包已经失效,我们通常会寻找它的替代方案。不确定的依赖会增加代码判断和测试难度,所以这个依赖项还是尽量不要使用。

bundledDependencies / bundleDependencies

打包依赖。如果在打包发布时希望一些依赖包也出现在最终的包里,那么可以将包的名字放在bundledDependencies中,bundledDependencies 的值是一个字符串数组,如:

{
  "name": "sequelize-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mysql2": "^2.1.0"
  },
  "devDependencies": {
    "sequelize": "^5.21.3"
  },
   "bundledDependencies": [
    "mysql2",
    "sequelize"
  ]
}

执行打包命令npm pack,在生成的sequelize-test-1.0.0.tgz包中,将包含mysql2sequelize

需要注意的是,在bundledDependencies中指定的依赖包,必须先在 dependencies 和 devDependencies 声明过,否则打包会报错。

语义化版本控制

为了在软件版本号中包含更多意义,反映代码所做的修改,产生了语义化版本,软件的使用者能从版本号中推测软件做的修改。npm 包使用语义化版控制,我们可安装一定版本范围的 npm 包,npm 会选择和你指定的版本相匹配 的 (latest)最新版本安装。

npm 采用了semver规范作为依赖版本管理方案。版本号由三部分组成:主版本号次版本号补丁版本号。变更不同的版本号,代表不同的意义:

  • 主版本号(major):软件做了不兼容的变更(breaking change 重大变更)
  • 次版本号(minor):添加功能或者废弃功能,向下兼容
  • 补丁版本号(patch):bug 修复,向下兼容

下面让我们来看下常用的几个版本格式:

  • "compression": "1.7.4"

表示精确版本号。任何其他版本号都不匹配。在一些比较重要的线上项目中,建议使用这种方式锁定版本。

  • "typescript": "^3.4.3"

表示兼容补丁和小版本更新的版本号。官方的定义是能够兼容除了最左侧的非 0 版本号之外的其他变化。这句话不是很好理解,举几个例子大家就明白了:

"^3.4.3" 等价于 `">= 3.4.3 < 4.0.0"。即只要最左侧的 "3" 不变,其他都可以改变。所以 "3.4.5", "3.6.2" 都可以兼容。

"^0.4.3" 等价于 ">= 0.4.3 < 0.5.0"。因为最左侧的是 "0",那么只要第二位 "4" 不变,其他的都兼容,比如 "0.4.5" 和 "0.4.99"。

"^0.0.3" 等价于 ">= 0.0.3 < 0.0.4"。大版本号和小版本号都为 "0" ,所以也就等价于精确的 "0.0.3"。
  • "mime-types": "~2.1.24"

表示只兼容补丁更新的版本号。关于 ~ 的定义分为两部分:如果列出了小版本号(第二位),则只兼容补丁(第三位)的修改;如果没有列出小版本号,则兼容第二和第三位的修改。我们分两种情况理解一下这个定义:

"~2.1.24" 列出了小版本号 "1",因此只兼容第三位的修改,等价于 ">= 2.1.24 < 2.2.0"。

"~2.1" 也列出了小版本号 "2",因此和上面一样兼容第三位的修改,等价于 ">= 2.1.0 < 2.2.0"。

"~2" 没有列出小版本号,可以兼容第二第三位的修改,因此等价于 ">= 2.0.0 < 3.0.0"
  • "underscore-plus": "1.x" "uglify-js": "3.4.x"

除了上面的xX还有*和(),这些都表示使用通配符的版本号,可以匹配任何内容。具体来说:

"*" 、"x" 或者 (空) 表示可以匹配任何版本。
"1.x", "1.*" 和 "1" 表示匹配主版本号为 "1" 的所有版本,因此等价于 ">= 1.0.0 < 2.0.0"。

"1.2.x", "1.2.*" 和 "1.2" 表示匹配版本号以 "1.2" 开头的所有版本,因此等价于 ">= 1.2.0 < 1.3.0"。
  • "css-tree": "1.0.0-alpha.33" "@vue/test-utils": "1.0.0-beta.29"

有时候为了表达更加确切的版本,还会在版本号后面添加标签或者扩展,来说明是预发布版本或者测试版本等。常见的标签有:

标签 含义 补充
demo demo 版本 可能用于验证问题的版本
dev 开发版 开发阶段用的,bug 多,体积较大等特点,功能不完善
alpha α 版本 预览版,或者叫内部测试版;一般不向外发布,会有很多 bug;一般只有测试人员使用。
beta 测试版(β 版本) 测试版,或者叫公开测试版;这个阶段的版本会一直加入新的功能;在 alpha 版之后推出。
gamma (γ)伽马版本 较 α 和 β 版本有很大的改进,与稳定版相差无几,用户可使用
trial 试用版本 本软件通常都有时间限制,过期之后用户如果希望继续使用,一般得交纳一定的费用进行注册或购买。有些试用版软件还在功能上做了一定的限制。
csp 内容安全版本 js 库常用
rc 最终测试版本 可能成为最终产品的候选版本,如果未出现问题则可发布成为正式版本
latest 最新版本 不指定版本和标签,npm 默认安最新版
stable 稳定版

npm install 原理分析

我们都知道,执行npm install后,依赖包被安装到了node_modules中。虽然在实际开发中我们无需十分关注里面具体的细节,但了解node_modules中的内容可以帮助我们更好的理解npm安装依赖包的具体机制。

嵌套结构

在 npm 的早期版本中,npm 处理依赖的方式简单粗暴,以递归的方式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。

举个例子,我们的项目ts-axios现在依赖了两个模块: axiosbody-parser:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
  }
}

axios依赖了follow-redirectsis-buffer模块:

{
  "name": "axios",
  "dependencies": {
      "follow-redirects": "1.5.10",
      "is-buffer": "^2.0.2"
    },
}

body-parser依赖了bytescontent-type等模块:

{
  "name": "body-parser",
  "dependencies": {
    "bytes": "3.1.0",
    "content-type": "~1.0.4",
     ...
  }
}

那么,执行 npm install 后,得到的 node_modules 中模块目录结构就是下面这样的:

这样的方式优点很明显, node_modules 的结构和 package.json 结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。

但是,试想一下,如果你依赖的模块非常之多,你的 node_modules 将非常庞大,嵌套层级非常之深:

从上图这种情况,我们不难得出嵌套结构拥有以下缺点:

  • 在不同层级的依赖中,可能引用了同一个模块,导致大量冗余
  • 嵌套层级过深可能导致不可预知的问题

扁平结构

为了解决以上问题,npm 在 3.x 版本做了一次较大更新。其将早期的嵌套结构改为扁平结构。

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

还是上面的依赖结构,我们在执行 npm install 后将得到下面的目录结构:

此时我们若在模块中又依赖了 [email protected] 版本:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
    "is-buffer": "^2.0.1"
  }
}

当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。

此时,我们在执行 npm install 后将得到下面的目录结构:

对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:

  • 在当前模块路径下搜索
  • 在当前模块 node_modules 路径下搜索
  • 在上级模块的 node_modules 路径下搜索
  • ...
  • 直到搜索到全局路径中的 node_modules

假设我们又依赖了一个包 axios2@^0.19.0,而它依赖了包 is-buffer@^2.0.3,则此时的安装结构是下面这样的:

所以 npm 3.x 版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。

我们在 package.json 通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。

package-lock.json

为了解决 npm install 的不确定性问题,在 npm 5.x 版本新增了 package-lock.json 文件,而安装方式还沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。

例如,我们有如下的依赖结构:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
  }
}

在执行 npm install 后生成的 package-lock.json 如下:

{
  "name": "ts-axios",
  "version": "0.1.0",
  "dependencies": {
      "axios": {
        "version": "0.19.0",
        "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
        "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
        "requires": {
          "follow-redirects": "1.5.10",
          "is-buffer": "^2.0.2"
        },
        "dependencies": {
          "debug": {
            "version": "3.1.0",
            "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
            "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
            "requires": {
              "ms": "2.0.0"
            }
          },
          "follow-redirects": {
            "version": "1.5.10",
            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
            "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
            "requires": {
              "debug": "=3.1.0"
            }
          },
          "is-buffer": {
            "version": "2.0.3",
            "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
            "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
          },
          "ms": {
            "version": "2.0.0",
            "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
            "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
          }
      }
    },
  }
}

最外面的两个属性 nameversionpackage.json 中的 nameversion ,用于描述当前包名称和版本。

dependencies 是一个对象,对象和 node_modules 中的包结构一一对应,对象的 key 为包名称,值为包的一些描述信息:

  • version: 包唯一的版本号
  • resolved: 安装来源
  • integrity: 表明包完整性的 hash 值(验证包是否已失效)
  • requires: 依赖包所需要的所有依赖项,与子依赖的 package.jsondependencies的依赖项相同。
  • dependencies: 依赖包node_modules中依赖的包,与顶层的dependencies一样的结构

这里注意,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。

通过以上几个步骤,说明package-lock.json文件和node_modules目录结构是一一对应的,即项目目录下存在package-lock.json可以让每次安装生成的依赖目录结构保持相同。

在开发一个应用时,建议把package-lock.json文件提交到代码版本仓库,从而让你的团队成员、运维部署人员或CI系统可以在执行npm install时安装的依赖版本都是一致的。

npm scripts 脚本

脚本功能是 npm 最强大、最常用的功能之一。

npm 允许在package.json文件中使用scripts字段来定义脚本命令。以vue-cli3为例:

"scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "test:unit": "vue-cli-service test:unit"
  },

这样就可以通过npm run serve脚本替代vue-cli-service serve脚本来启动项目,而无需每次都敲一遍冗长的脚本。

原理

这里我们参考一下阮老师的文章:

npm 脚本的原理非常简单。每当执行 npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。
比较特别的是,npm run 新建的这个 Shell,会将当前目录的node_modules/.bin子目录加入 PATH 变量,执行结束后,再将 PATH 变量恢复原样。

传入参数

在原有脚本后面加上 -- 分隔符, 后面再加上参数,就可以将参数传递给 script 命令了,比如 eslint 内置了代码风格自动修复模式,只需给它传入 -–fix 参数即可,我们可以这样写:

"scripts": {
    "lint": "vue-cli-service lint --fix",
  },

除了第一个可执行的命令,以空格分割的任何字符串(除了一些 shell 的语法)都是参数,并且都能通过process.argv属性访问。

process.argv 属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。 第一个元素是 process.execPath,表示启动 node 进程的可执行文件的绝对路径名。第二个元素为当前执行的 JavaScript 文件路径。剩余的元素为其他命令行参数。

执行顺序

如果 npm 脚本里面需要执行多个任务,那么需要明确它们的执行顺序。

如果是串行执行,即要求前一个任务执行成功之后才能执行下一个任务。使用&&符号连接。

npm run script1 && npm run script2

串行命令执行过程中,只要一个命令执行失败,则整个脚本将立刻终止。

如果是并行执行,即多个任务可以同时执行。使用&符号来连接。

npm run script1 & npm run script2

钩子

这里的钩子和vuereact里面的生命周期有点相似。

npm 脚本有prepost两个钩子。在执行 npm scripts 命令(无论是自定义还是内置)时,都经历了 pre 和 post 两个钩子,在这两个钩子中可以定义某个命令执行前后的命令。

比如,在用户执行npm run build的时候,会自动按照下面的顺序执行。

npm run prebuild && npm run build && npm run postbuild

当然,如果没有指定prebuildpostbuild,会默默的跳过。如果想要指定钩子,必须严格按照 pre 和 post 前缀来添加。

环境变量

npm 脚本有一个非常强大的功能,就是可以使用 npm 的内部变量。

在执行npm run脚本时,npm 会设置一些特殊的env环境变量。其中 package.json 中的所有字段,都会被设置为以npm_package_ 开头的环境变量。比如 package.json 中有如下字段内容:

{
  "name": "sequelize-test",
  "version": "1.0.0",
  "description": "sequelize测试",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "mysql2": "^2.1.0",
    "sequelize": "^5.21.3"
  }
}

那么,变量npm_package_name返回sequelize-test,变量npm_package_description返回sequelize测试。也就是:

console.log(process.env.npm_package_name)  // sequelize-test

console.log(process.env.npm_package_description)  // sequelize测试

npm 配置

优先级

npm 从以下来源获取配置信息(优先级由高到低):

命令行

npm run dev --foo=bar

执行上述命令,会将配置项foo的值设为bar,通过process.env.npm_config_foo可以访问其配置值。这个时候的 foo 配置值将覆盖所有其他来源存在的 foo 配置值。

环境变量

如果 env 环境变量中存在以npm_config_为前缀的环境变量,则会被识别为 npm 的配置属性。比如,环境变量中的npm_config_foo=bar 将会设置配置参数 foo 的值为 "bar"

如果只指定了参数名却没有指定任何值的配置参数,其值将会被设置为 true

npmrc文件

通过修改 npmrc 文件可以直接修改配置。系统中存在多个 npmrc 文件,这些 npmrc 文件被访问的优先级从高到低的顺序为:

  • 项目配置文件

只作用在本项目下。在其他项目中,这些配置不生效。通过创建这个.npmrc 文件可以统一团队的 npm 配置规范。路径为/path/to/my/project/.npmrc

  • 用户配置文件

默认为~/.npmrc/,可通过npm config get userconfig查看存放的路径。

  • 全局配置文件

    通过npm config get globalconfig可以查看具体存放的路径。

  • npm 内置的配置文件

这是一个不可更改的内置配置文件,为了维护者以标准和一致的方式覆盖默认配置。mac下的路径为/path/to/npm/npmrc

默认配置

通过npm config ls -l查看 npm 内部的默认配置参数。如果命令行、环境变量、所有配置文件都没有配置参数,则使用默认参数值。

npm config 指令

set

npm config set <key> <value> [-g|--global]
npm config set registry <url>  # 指定下载 npm 包的来源,默认为 https://registry.npmjs.org/ ,可以指定私有源

设置配置参数 key 的值为 value,如果省略 value,key 会被设置为 true

get

npm config get <key>

查看配置参数 key 的值。

delete

npm config delete <key>

删除配置参数 key。

list

npm config list [-l] [--json]

查看所有设置过的配置参数。使用 -l 查看所有设置过的以及默认的配置参数。使用 --json 以 json 格式查看。

edit

npm config edit

在编辑器中打开 npmrc 文件,使用 --global 参数打开全局 npmrc 文件。

总结

以上就是我关于 npm 的一些深度挖掘,当然有很多方面没有总结到位,后续我会在实战的过程中,不断总结,随时更新。也欢迎大佬随时来吐槽

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

setState和forceUpdate

setState

setState的定义在packages/react/src/ReactBaseClasses.js

/**
 * @param {object|function} partialState Next partial state or function to
 *        produce next partial state to be merged with current state.
 * @param {?function} callback Called after state is updated.
 * @final
 * @protected
 */
Component.prototype.setState = function (partialState, callback) {
  invariant(
    typeof partialState === "object" ||
      typeof partialState === "function" ||
      partialState == null,
    "setState(...): takes an object of state variables to update or a " +
      "function which returns an object of state variables."
  );
  this.updater.enqueueSetState(this, partialState, callback, "setState");
};

可以看到内部调用了enqueueSetState

// packages/react-reconciler/src/ReactFiberClassComponent.js
// classComponent初始化的时候拿到的update对象
const classComponentUpdater = {
  isMounted,
  enqueueSetState(inst, payload, callback) {
    // inst即调用this.setState时传进来的this,也就是classComponent实例

    // 通过this获取fiber对象
    // this._reactInternalFiber
    // this本身有存储 fiber对象 的属性,叫 _reactInternalFiber

    const fiber = getInstance(inst);
    // 计算当前时间
    const currentTime = requestCurrentTime();
    // 计算fiber对象的过期时间
    const expirationTime = computeExpirationForFiber(currentTime, fiber);
    // 创建update对象
    const update = createUpdate(expirationTime);
    // setState传进来的要更新的对象
    update.payload = payload;
    // callback就是setState({},()=>{})的回调函数
    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback, "setState");
      }
      update.callback = callback;
    }
    flushPassiveEffects();
    // update入队
    enqueueUpdate(fiber, update);
    // 任务调度
    scheduleWork(fiber, expirationTime);
  },
  // ...
};

enqueueSetState函数的作用是:给React节点的fiber对象创建update,并将该更新对象入队

传入的inst即调用this.setState时传进来的this,也就是classComponent实例。

通过getInstance方法来获取目标对象的_reactInternalFiber属性。

下面依次拿到currentTimeexpirationTime,然后通过createUpdate来创建update对象。

最后update入队、进入任务调度。

接着来看下forceUpdate

forceUpdate

定义同样在packages/react/src/ReactBaseClasses.js

/**
 * Forces an update. This should only be invoked when it is known with
 * certainty that we are **not** in a DOM transaction.
 *
 * You may want to call this when you know that some deeper aspect of the
 * component's state has changed but `setState` was not called.
 *
 * This will not invoke `shouldComponentUpdate`, but it will invoke
 * `componentWillUpdate` and `componentDidUpdate`.
 *
 * @param {?function} callback Called after update is complete.
 * @final
 * @protected
 */
Component.prototype.forceUpdate = function (callback) {
  this.updater.enqueueForceUpdate(this, callback, "forceUpdate");
};

内部调用了enqueueForceUpdate方法:

 enqueueForceUpdate(inst, callback) {
    const fiber = getInstance(inst);
    const currentTime = requestCurrentTime();
    const expirationTime = computeExpirationForFiber(currentTime, fiber);

    const update = createUpdate(expirationTime);
    update.tag = ForceUpdate;

    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback, 'forceUpdate');
      }
      update.callback = callback;
    }

    flushPassiveEffects();
    enqueueUpdate(fiber, update);
    scheduleWork(fiber, expirationTime);
  },

enqueueSetState()方法的流程类似,唯一不同的是多了个手动修改属性tag的值:

update.tag = ForceUpdate;

可以看到createUpdate()方法中,初始化的tag值是UpdateState

export const UpdateState = 0; // 更新
export const ReplaceState = 1; // 替换
export const ForceUpdate = 2; // 强制更新
export const CaptureUpdate = 3; // 捕获性的更新

export function createUpdate(expirationTime: ExpirationTime): Update<*> {
  return {
    expirationTime: expirationTime,
    // 重点提下CaptureUpdate,在React16后有一个ErrorBoundaries功能
    // 即在渲染过程中报错了,可以选择新的渲染状态(提示有错误的状态),来更新页面
    // 默认是0即更新
    tag: UpdateState,
    payload: null,
    callback: null,

    next: null,
    nextEffect: null,
  };
}

因此要改成ForceUpdate,以便React进行Update优先级排序。

Vue源码探秘(watch)

引言

上一节我们分析了计算属性computed,这一节我们一起来看下侦听器watch

大家平时在项目开发中,有时会对这两种特性感到模棱两可,不知道该选择那个。相信看完本篇文章,结合上一篇,你会有一个答案的。

让我们开始吧!

watch

首先组件实例化过程中,会调用initState函数:

// src/core/instance/state.js

export function initState(vm: Component) {
  // ...
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}

这里面调用了initWatch方法,来看它的定义:

function initWatch(vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key];
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i]);
      }
    } else {
      createWatcher(vm, key, handler);
    }
  }
}

initWatch 函数会遍历 watch 的属性然后调用 createWatcher 函数。这里判断了 watch[key] 是数组的情况。

Vue 是支持 watch 的同一个 key 对应多个 handler,也就是handler是一个数组

如果是数组需要遍历数组每一项再调用 createWatcher 函数。来看 createWatcher 函数的定义:

function createWatcher(
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
  }
  if (typeof handler === "string") {
    handler = vm[handler];
  }
  return vm.$watch(expOrFn, handler, options);
}

createWatcherhandler分别是对象和字符串的情况,进行了处理,统一将handler转成函数并调用 vm.$watch 函数。$watch 方法定义在 Vue 原型上:

// src/core/instance/state.js
Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this;
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options);
  }
  options = options || {};
  options.user = true;
  const watcher = new Watcher(vm, expOrFn, cb, options);
  // ...
};

这里$watch先判断传入的cb是不是对象,如果是对象则继续调用createWatcher进行参数规范化处理。

这里是因为$watch 是个对外暴露的接口,我们平时开发也可以直接使用 $watch 函数来取代 watch 属性。

这里传入的 options 参数是 undefined ,所以会给 options 创建一个空对象并且 options.user = true

接着将 options 传入 Watcher 创建一个 user Watcher 实例。我们来看 user Watcher 的实例化过程:

export default class Watcher {
  // ...

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  this.vm = vm
  if (isRenderWatcher) {
    vm._watcher = this
  }
  vm._watchers.push(this)
  // options
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new Set()
  this.newDepIds = new Set()
  this.expression = process.env.NODE_ENV !== 'production'
    ? expOrFn.toString()
    : ''
  // parse expression for getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) {
      this.getter = noop
      process.env.NODE_ENV !== 'production' && warn(
        `Failed watching path: "${expOrFn}" ` +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      )
    }
  }
  this.value = this.lazy
    ? undefined
    : this.get()
}

这里因为不是渲染 Watcher ,所以 if (isRenderWatcher) 不会执行,接着在 if (options) 逻辑中初始化属性值,其中 this.user = true 表明了这是一个 user Watcher ,而其他的像 deepsync 对应watch中的配置项。

接着会判断传入的 expOrFn 是不是函数,如果是则赋值给 this.getter 。不是的话走 else 逻辑调用 parsePath 函数。

我们来看 parsePath 函数的定义:

// src/core/util/lang.js
/**
 * Parse simple path.
 */
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`);
export function parsePath(path: string): any {
  if (bailRE.test(path)) {
    return;
  }
  const segments = path.split(".");
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return;
      obj = obj[segments[i]];
    }
    return obj;
  };
}

parsePath 函数首先会通过正则解析这个 path 看是否合法。如果不合法,直接return。如果合法会将 path 拆成数组。最后 parsePath 函数会返回一个函数赋值给 user Watchergetter 属性。

返回的函数的逻辑也很简单,就是根据解析出来的路径去访问传入的 obj 参数对应的属性值并返回出去。

回到 user Watcher 的实例化过程,最后这里会执行 this.get 方法:

/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get 方法首先执行 pushTarget() 。我们知道 pushTarget 会修改 Dep.target 为传入的 this 也就是 user Watcher

接着会执行 this.getter.call(vm, vm)getter 就是前面 parsePath 函数返回的函数:

return function (obj) {
  for (let i = 0; i < segments.length; i++) {
    if (!obj) return;
    obj = obj[segments[i]];
  }
  return obj;
};

之后会有一个判断 this.deep 的逻辑,如果有配置deeptrue,这里就会执行 traverse(value)traverse 函数定义如下:

// src/core/observer/traverse.js
const seenObjects = new Set();

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse(val: any) {
  _traverse(val, seenObjects);
  seenObjects.clear();
}

function _traverse(val: any, seen: SimpleSet) {
  let i, keys;
  const isA = Array.isArray(val);
  if (
    (!isA && !isObject(val)) ||
    Object.isFrozen(val) ||
    val instanceof VNode
  ) {
    return;
  }
  if (val.__ob__) {
    const depId = val.__ob__.dep.id;
    if (seen.has(depId)) {
      return;
    }
    seen.add(depId);
  }
  if (isA) {
    i = val.length;
    while (i--) _traverse(val[i], seen);
  } else {
    keys = Object.keys(val);
    i = keys.length;
    while (i--) _traverse(val[keys[i]], seen);
  }
}

traverse 这里调用了_traverse 函数。_traverse 函数会对传入的 val 参数进行校验,即 val 满足下面三个条件的话直接return

  • val 不是一个数组或者不是一个对象
  • val是被冻结的
  • valVNode实例

接着有 if (val.__ob__)if (isA) else 两大段逻辑,我们先分析 if (isA) else 。这段逻辑就是针对 val 是数组或者是对象分别做遍历,遍历它们的元素或者属性递归调用 _traverse 函数。

我们再回过头来看 if (val.__ob__) 这段逻辑,它的作用是把 val__ob__.dep.id 保存到 seen 也就是在全局定义的 seenObjects 中,并且还通过 if (seen.has(depId)) 逻辑防止重复保存。那什么情况会重复保存呢,那就是循环引用,比如下面这个例子:

const obj1 = {};
const obj2 = {};
obj1.a = obj2;
obj2.a = obj1;

如果没有 if (seen.has(depId)) 的逻辑,那么上述例子会在 if (isA) elseelse 的逻辑中一直不停地执行。

这样 _traverse 函数的逻辑就分析完了。其实实现深度观测很简单,就是深度遍历数组或者对象,相当于深度访问了数组(对象)的所有元素(属性)。这样就会触发它们的 getter 去收集依赖,把 user Watcher 收集进来。

回到 traverse 函数,在执行完 _traverse 函数后就将 seenObjects 清空。这样 user Watcher 的实例化过程就分析完了。回到 $watch 函数:

// src/core/instance/state.js
Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  // ...
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value);
    } catch (error) {
      handleError(
        error,
        vm,
        `callback for immediate watcher "${watcher.expression}"`
      );
    }
  }
  return function unwatchFn() {
    watcher.teardown();
  };
};

在创建完 user Watcher 之后,$watch 会判断 options.immediate 是否为 true。如果为 true ,则会立即执行 cb 也就是我们编写的回调函数。

最后 $watch 函数返回一个函数,这个函数会执行 watcher.teardown 来解除当前观察者对属性的观察。

以上就是 watch 的初始化渲染过程,其原理就是访问被 watch 的数据触发其 getter ,使得 user Watcher 被收集,在被 watch 的数据改变时就能触发 setter 通知 user Watcher 执行回调。

当被 watch 的数据发生变化时会触发 setter 派发更新,我们知道派发更新就是通知订阅者 user Watcher 去执行 update 方法:

// src/core/observer/watcher.js
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

这里如果synctrue,那么就会执行run函数:

// src/core/observer/watcher.js
/**
 * Scheduler job interface.
 * Will be called by the scheduler.
 */
run () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

可以看到 run 函数会对比被 watch 的数据有没有发生变化,如果有就立即执行回调函数。

如果没有配置 synctrue ,那么在 update 函数中就会走 else 逻辑执行 queueWatcher 函数。

queueWatcher 函数的具体执行过程以及其中的 nextTick 函数我们在 Vue源码探秘(派发更新)Vue源码探秘(nextTick) 已经分析过了,简单来讲,会异步执行 watch 的回调函数。

总结

结合上一节的computed,我们这里简单对比下watchcomputed

  • 计算属性的本质是 computed Watcher
  • 侦听器的本质是 user Watcherwatch 可以是一个配置对象,可以配置 deepsyncimmediate

关于koa2,你不知道的事

引言

什么是 koa

koa 是一个基于 node 实现的一个新的 web 框架,它是由 express 框架的原班人马打造。特点是优雅、简洁、表达力强、自由度高。和 express 相比,它是一个更轻量的 node 框架,因为它所有的功能都通过插件来实现,这种插拔式的架构设计模式,很符合 unix 哲学。

本文从零开始,循序渐进的展示和详解上手 koa2 框架的几个最重要的概念,最后会串联讲解一下 koa2 的处理流程以及源码结构。看完本文以后,相信无论对于上手 koa2 还是深入了解 koa2 都会有不小的帮助。

快速开始

安装并启动(hello world)

按照正常逻辑,安装使用这种一般都会去官网看一下类似guide的入门指引,殊不知 koa 官网和 koa 本身一样简洁(手动狗头)。

如果一步步搭建环境的话可能会比较麻烦,还好有项目生成器koa-generator(出自狼叔-桑世龙)。

// 安装koa项目生成器koa-generator
$ npm i koa-generator -g

// 使用koa-generator生成koa2项目
$ koa2 hello_koa2

// 切到指定项目目录,并安装依赖
$ cd hello_koa2
$ npm install

// 启动项目
$ npm start

项目启动后,默认端口号是3000,在浏览器中运行可以得到下图的效果说明运行成功。

koa2 简析结构

项目已经启动起来了,下面让我们来简单看一下源码文件目录结构吧:

这个就是 koa2 源码的源文件结构,核心代码就是 lib 目录下的四个文件:

application.js

application.js是 koa 的入口文件,它向外导出了创建 class 实例的构造函数,继承自 node 自带的events,这样就会赋予框架事件监听和事件触发的能力。application 还暴露了一些常用的 api,比如listenuse等等。

listen的实现原理其实就是对http.createServer进行了一个封装,这个函数中传入的callback是核心,它里面包含了中间件的合并,上下文的处理,对 res 的特殊处理。

use 的作用主要是收集中间件,将多个中间件放入一个缓存队列中,然后通过koa-compose这个插件进行递归组合调用这一系列的中间件。

context.js

这部分就是 koa 的应用上下文 ctx,其实就一个简单的对象暴露,里面的重点在 delegate,这个就是代理,这个就是为了开发者方便而设计的,比如我们要访问 ctx.repsponse.status 但是我们通过 delegate,可以直接访问 ctx.status 访问到它。

request.js、response.js

这两部分就是对原生的resreq的一些操作了,大量使用 es6 的getset的一些语法,去取headers或者设置headers、还有设置body等等

路由(URL 处理)

原生路由实现

koa 是个极简的 web 框架,简单到连路由模块都没有配备,我们先来可以根据ctx.request.url或者ctx.request.path获取用户请求的路径,来实现简单的路由。

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

app.use( async ( ctx ) => {
  let url = ctx.request.url
  ctx.body = url
})
app.listen(3000)

访问 http://localhost:3000/hello/forest 页面会输出 /hello/forest,也就是说上下文的请求request对象中url就是当前访问的路径名称,可以根据ctx.request.url 通过一定的判断或者正则匹配就可以定制出所需要的路由。

koa-router 中间件

如果依靠ctx.request.url去手动处理路由,将会写很多处理代码,这时候就需要对应的路由的中间件对路由进行控制,这里介绍一个比较好用的路由中间件koa-router

安装 koa-router 中间件

// koa2 对应的版本是 7.x
$ npm install --save koa-router@7

使用

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', async (ctx) => {
  let html = `
      <ul>
        <li><a href="/hello">helloworld</a></li>
        <li><a href="/about">about</a></li>
      </ul>
    `
  ctx.body = html
}).get('/hello', async (ctx) => {
  ctx.body = 'hello forest'
}).get('/about', async (ctx) => {
  ctx.body = '前端森林'
})

app.use(router.routes(), router.allowedMethods())

app.listen(3000);

中间件

在上面说到路由时,我们用到了中间件(koa-router)。那中间件究竟是什么呢?

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。

Koa 中使用app.use()来加载中间件,基本上 Koa 所有的功能都是通过中间件实现的。

每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next函数。只要调用 next 函数,就可以把执行权转交给下一个中间件。

下图为经典的 Koa 洋葱模型:

我们来看一下 koa 官网的这个例子:

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

// logger

app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

上面的执行顺序就是:请求 -> logger 中间件 -> x-response-time 中间件 -> 响应中间件 -> x-response-time 中间件 -> logger 中间件 -> 响应。

通过这个顺序我们可以发现这是个栈结构以"先进后出"(first-in-last-out)的顺序执行。

Koa 已经有了很多好用的中间件(https://github.com/koajs/koa/wiki#middleware)你需要的常用功能基本上上面都有。

请求数据获取

get

获取方法

在 koa 中,获取GET请求数据源使用 koa 中 request 对象中的query方法或querystring方法。

query返回是格式化好的参数对象,querystring返回的是请求字符串,由于 ctx 对 request 的 API 有直接引用的方式,所以获取 GET 请求数据有两个途径。

  • 1、从上下文中直接获取

    • 请求对象ctx.query,返回如 { name:'森林', age:23 }
    • 请求字符串 ctx.querystring,返回如 name=森林&age=23
  • 2、从上下文的 request 对象中获取

    • 请求对象ctx.request.query,返回如 { a:1, b:2 }
    • 请求字符串 ctx.request.querystring,返回如 a=1&b=2

示例

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

app.use( async ( ctx ) => {
  let url = ctx.url
  // 从上下文的request对象中获取
  let request = ctx.request
  let req_query = request.query
  let req_querystring = request.querystring

  // 从上下文中直接获取
  let ctx_query = ctx.query
  let ctx_querystring = ctx.querystring

  ctx.body = {
    url,
    req_query,
    req_querystring,
    ctx_query,
    ctx_querystring
  }
})

app.listen(3000, () => {
  console.log('[demo] request get is starting at port 3000')
})

post

对于POST请求的处理,koa2 没有封装获取参数的方法,需要通过自己解析上下文 context 中的原生 node.js 请求对象req,将 POST 表单数据解析成 querystring(例如:a=1&b=2&c=3),再将 querystring 解析成 JSON 格式(例如:{"a":"1", "b":"2", "c":"3"})。

我们来直接使用koa-bodyparser 中间件从 POST 请求的数据体里面提取键值对。

对于POST请求的处理,koa-bodyparser中间件可以把 koa2 上下文的formData数据解析到ctx.request.body中。

示例

首先安装koa-bodyparser

$ npm install --save koa-bodyparser@3

看一个简单的示例:

const Koa = require('koa')
const app = new Koa()
const bodyParser = require('koa-bodyparser')

// 使用koa-bodyparser中间件
app.use(bodyParser())

app.use(async (ctx) => {

  if (ctx.url === '/' && ctx.method === 'GET') {
    // 当GET请求时候返回表单页面
    let html = `
      <h1>koa2 request post demo</h1>
      <form method="POST" action="/">
        用户名:<input name="name" /><br/>
        年龄:<input name="age" /><br/>
        邮箱: <input name="email" /><br/>
        <button type="submit">submit</button>
      </form>
    `
    ctx.body = html
  } else if (ctx.url === '/' && ctx.method === 'POST') {
    // 当POST请求的时候,中间件koa-bodyparser解析POST表单里的数据,并展示到页面
    ctx.body = ctx.request.body
  } else {
    // 404
    ctx.body = '<h1>404 Not Found</h1>'
  }
})

app.listen(3000, () => {
  console.log('[demo] request post is starting at port 3000')
})

模版引擎

在实际项目开发中,返回给用户的网页往往都会被写成模板文件。 Koa 先读取模板文件,然后将这个模板返回给用户,这里我们就需要使用模板引擎了。

关于 Koa 的模版引擎,我们只需要安装 koa 模板使用中间件koa-views, 然后再下载你喜欢的模板引擎(支持列表)便可以愉快的使用了。

这里以使用ejs模版为例展开说明。

安装模版

// 安装koa模板使用中间件
$ npm install --save koa-views

// 安装ejs模板引擎
$ npm install --save ejs

使用模版引擎

文件目录

├── package.json
├── index.js
└── view
    └── index.ejs

./index.js 文件

const Koa = require('koa')
const views = require('koa-views')
const path = require('path')
const app = new Koa()

// 加载模板引擎
app.use(views(path.join(__dirname, './view'), {
  extension: 'ejs'
}))

app.use( async ( ctx ) => {
  let title = '森林带你学koa2'
  await ctx.render('index', {
    title,
  })
})

app.listen(3000)

./view/index.ejs 模板

<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p>EJS Welcome to <%= title %></p>
</body>
</html>

静态资源服务器

网站一般都提供静态资源(图片、字体、样式表、脚本……),我们可以自己实现一个静态资源服务器,但这没必要,koa-static模块封装了这部分功能。

安装

$ npm i --save koa-static

示例

const Koa = require('koa')
const path = require('path')
const static = require('koa-static')

const app = new Koa()

// 静态资源目录对于相对入口文件index.js的路径
const staticPath = './static'

app.use(static(
  path.join( __dirname,  staticPath)
))


app.use( async ( ctx ) => {
  ctx.body = 'hello world'
})

app.listen(3000, () => {
  console.log('[demo] static-use-middleware is starting at port 3000')
})

cookie/session

koa2 中使用 cookie

使用方法

koa 提供了从上下文直接读取、写入 cookie 的方法:

  • ctx.cookies.get(name, [options]) 读取上下文请求中的 cookie
  • ctx.cookies.set(name, value, [options]) 在上下文中写入 cookie
    koa2 中操作的 cookies 是使用了 npm 的cookies模块,源码在这里,所以在读写 cookie 时的使用参数与该模块的使用一致。

示例

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

app.use( async ( ctx ) => {

  if ( ctx.url === '/index' ) {
    ctx.cookies.set(
      'cid',
      'hello world',
      {
        domain: 'localhost',  // 写cookie所在的域名
        path: '/index',       // 写cookie所在的路径
        maxAge: 10 * 60 * 1000, // cookie有效时长
        expires: new Date('2017-02-15'),  // cookie失效时间
        httpOnly: false,  // 是否只用于http请求中获取
        overwrite: false  // 是否允许重写
      }
    )
    ctx.body = 'cookie is ok'
  } else {
    ctx.body = 'hello world'
  }

})

app.listen(3000, () => {
  console.log('[demo] cookie is starting at port 3000')
})

koa2 中实现 session

koa2 原生功能只提供了 cookie 的操作,但是没有提供session操作。session 就只能自己实现或者通过第三方中间件实现。

我这里给大家演示一下通过中间件koa-generic-session来在 koa2 中实现 session。

const Koa = require("koa");
const redisStore = require("koa-redis");
const session = require("koa-generic-session");

app.use(
  session({
    key: "forum.sid", // cookie name
    prefix: "forum:sess:", // redis key的前缀
    cookie: {
      path: "/",
      httpOnly: true,
      maxAge: 24 * 60 * 60 * 1000 // ms
    },
    ttl: 24 * 60 * 60 * 1000, // ms
    store: redisStore({
      all: `${REDIS_CONF.host}:${REDIS_CONF.port}`
    })
  })
);

koa2 处理流程

上面提到了很多 koa2 涉及到的一些概念,下面让我们梳理一下 koa2 完整的处理流程吧!

完整大致可以分为以下四部分:

  • 初始化应用
  • 请求到来-创建上下文
  • 请求到来-中间件执行
  • 返回 res-特殊处理

这里参考大佬的一张关于 koa2 的完整流程图,

初始化应用

在我们的app.js中,初始化的时候创建了 koa 实例(new koa()),然后是很多的use,最后是app.listen(3000)

use主要是把所有的函数(使用的中间件)收集到一个middleware数组中。

listen主要是对http.createServer进行了一个封装,这个函数中传入的callback是核心,它里面包含了中间件的合并,上下文的处理等。也就是http.createServer(app.callback()).listen(...)

创建上下文

一个请求过来时,可以拿到对应的 req、res,koa 拿到后就通过createContext来创建应用上下文,并进行属性代理delegate

中间件执行

请求过来时,通过use操作已经将多个中间件放入一个缓存队列中。使用koa-compose将传入的middleware组合起来,然后返回了一个 promise。

http.createServer((req, res) => {
 // ... 通过req,res创建上下文
 // fn是`koa-compose`返回的promise
 return fn(ctx).then(handleResponse).catch(onerror);
})

res 返回并进行特殊处理

在上面一部分,我们看到有一个handleResponse,它是什么呢?(其实到这里我们还没有res.end())。

const handleResponse = () => respond(ctx);

respond 到底做了什么呢,其实它就是判断你之前中间件写的 body 的类型,做一些处理,然后才使用res.end(body)

到这里就结束了,返回了页面。

参考

福利

到这里关于 koa2 的一些相关概念就分享结束了,不知道你有没有收获呢?

我这里有两个关于koa2的完整(何为完整,数据库、日志、模型等等等等,你想要的都有)的项目,可以供大家参考,当然感觉不错的话可以给个 star 支持一下!!

  • https://github.com/Jack-cool/rest_node_api
  • https://github.com/Jack-cool/forum_code

最后

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

浅谈Hybrid

引言

随着 Web 技术和移动设备的飞速发展,各种 APP 层出不穷,极速的业务扩展提高了团队对开发效率的要求,这个时候使用 IOS/Andriod 开发一个 APP 似乎成本有点过高了,而 H5 的低成本、高效率、跨平台等特性马上被利用起来形成了一种新的开发模式:Hybrid APP

Hybrid 技术已经成为一种最主流最常见的方案。一套好的 Hybrid 架构解决方案能让 App 既能拥有极致的体验和性能,同时也能拥有 Web 技术 灵活的开发模式、跨平台能力以及热更新机制。本文主要是结合我最近开发的一个 Hybrid 项目(https://github.com/Jack-cool/hybrid_jd),带大家全面了解一下 Hybrid。

现有混合方案

深入了解 Hybrid 前,让我们先来看一下目前市面上比较成熟的混合解决方案。

基于 WebView UI 的基础方案

这种是市面上大多数 app 采取的方案,也是混合开发最基础的方案。在 webview 的基础上,与原生客户端建立js bridge桥接,以达到 js 调用Native API和 Native 执行js方法的目的。

目前国内绝大部分的大厂都有一套自己的基于 webview ui 的 hybrid 解决方案,例如微信的JS-SDK,支付宝的JSAPI等,通过JSBridge完成 h5 与 Native 的双向通讯,从而赋予 H5 一定程度的原生能力。

基于 Native UI 的方案

可以简单理解为“跨平台”,现在比较通用的有React NativeWeexFlutter等。在赋予 H5 原生 API 能力的基础上,进一步通过 JSBridge 将 JS 解析成的虚拟节点数(Virtual DOM)传递到 Native 并使用原生渲染。我们这里来看下上面提到的这三种:

React Native

“Learn once, write anywhere”,React Native采用了 React 的设计模式,但 UI 渲染、动画效果、网络请求等均由原生端实现(由于 JS 是单线程,不大可能处理太多耗时的操作)。开发者编写的 JS 代码,通过 React Native 的中间层转化为原生控件和操作,极大的提高了用户体验。

React Native所有的标签都不是真实控件,JS 代码中所写控件的作用,类似 Map 中的 key 值。JS 端通过这个 key 组合的 Dom ,最后 Native 端会解析这个 Dom ,得到对应的 Native 控件渲染,如 Android 中 标签对应 ViewGroup 控件。

总结下来,就是:React Native 是利用 JS 来调用 Native 端的组件,从而实现相应的功能。

Weex

“Write once, run everywhere”,基于 Vue 设计模式,支持 web、android、ios 三端,原生端同样通过中间层转化,将控件和操作转化为原生逻辑来提升用户体验。

在 weex 中,主要包括三大部分:JS BridgeRenderDom,JS Bridge 主要用来和 JS 端实现进行双向通信,比如把 JS 端的 dom 结构传递给 Dom 线程。Dom 主要是用于负责 dom 的解析、映射、添加等等的操作,最后通知 UI 线程更新。而 Render 负责在 UI 线程中对 dom 实现渲染。

和 react native 一样,weex 所有的标签也都不是真实控件,JS 代码中所生成的 dom,最终都是由 Native 端解析,再得到对应的 Native 控件渲染,如 Android 中 标签对应 WXTextView 控件。

Flutter

Flutter 是谷歌 2018 年发布的跨平台移动 UI 框架。与 react native 和 weex 的通过 Javascript 开发不同,Flutter 的编程语言是Dart,所以执行时并不需要 Javascript 引擎,但实际效果最终也通过原生渲染。

看完这三种方案的简介,下面让我们简单来做个对比吧:

React Native Weex Flutter
平台实现 JavaScript JavaScript 原生编码
引擎 JS V8 JSCore Flutter engine
核心语言 React Vue Dart
框架程度 较重 较轻
特点 适合开发整体 App 适合单页面 适合开发整体 App
支持 Android、IOS Android、IOS、Web Android、IOS(可能还不止)
Apk 大小(Release) 7.6M 10.6M 8.1M

小程序

小程序开发本质上还是前端 HTML + CSS + JS 那一套逻辑,它基于 WebView 和微信(当然支付宝、百度、字节等现在都有自己的小程序,这里只是拿微信小程序做个说明)自己定义的一套 JS/WXML/WXSS/JSON 来开发和渲染页面。微信官方文档里提到,小程序运行在三端:iOS、Android 和用于调试的开发者工具,三端的脚本执行环境以及用于渲染非原生组件的环境是各不相同的。

通过更加定制化的 JSBridge,并使用双 WebView 双线程的模式隔离了 JS 逻辑与 UI 渲染,形成了特殊的开发模式,加强了 H5 与 Native 混合程度,提高了页面性能及开发体验。

PWA

Progressive Web App, 简称 PWA,是提升 Web App 体验的一种新方法,能给用户带来原生应用的体验。

PWA 能做到原生应用的体验不是靠某一项特定的技术,而是经过应用一系列新技术进行改进,在安全、性能和体验三个方面都有了很大的提升,PWA 本质上还是 Web App,并兼具了 Native App 的一些特性和优点,主要包括下面三点:

  • 可靠 - 即使在不稳定的网络环境下,也能快速加载并展现
  • 体验 - 快速响应,并且有平滑的动画响应用户的操作
  • 粘性 - 设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面

Android 和主流的浏览器都早已支持了 PWA 标准,在 iOS 11.3 和 macOS 10.13.4 上,苹果的 Safari 上也支持了 PWA。相信在不久的将来势必会迎来 PWA 的大爆发...

看完目前主流的混合解决方案,我们回归本篇主题,讲解一下成熟解决方案背后的 Hybrid底层基础,要知道决定上层建筑的永远都是底层基础,新的技术层出不穷,只有原理是不变的~~

Hybrid 是什么,为什么要用 Hybrid?

Hybrid,字面意思“混合”。可以简单理解为是前端和客户端的混合开发。

让我们先来看一下目前主流的移动应用开发方式:

Native APP

Native App 是一种基于智能手机本地操作系统如 iOS、Android、WP 并使用原生程式编写运行的第三方应用程序,也叫本地 app。一般使用的开发语言为 Java、C++、Objective-C。。分别来看一下 Native 开发的优缺点:

  • 优点

    • 用户体验近乎完美
    • 性能稳定
    • 访问本地资源(通讯录、相册)
    • 操作流畅
    • 设计出色的动效、转场
    • 系统级的贴心通知或提醒
    • 用户留存率高
  • 缺点

    • 门槛高,原生开发人才稀缺,至少比前端和后端少,开发环境昂贵
    • 发布成本高,需要通过 store 或 market 的审核,导致更新缓慢
    • 维持多个版本、多个系统的成本比较高,而且必须做兼容
    • 无法跨平台,开发的成本比较大,各个系统独立开发

Web APP

Web App,顾名思义是指基于 Web 的应用,基本采用 Html5 语言写出,不需要下载安装。类似于现在所说的轻应用。基于浏览器运行的应用,基本上可以说是触屏版的网页应用。分别来看一下 Web 开发的优缺点:

  • 优点

    • 开发成本低
    • 临时入口,可以随意嵌入
    • 无需安装,不会占用手机内存,而且更新速度最快
    • 能够跨多个平台和终端
    • 不存在多版本问题,维护成本低
  • 缺点

    • 无法获取系统级别的通知,提醒,动效等等
    • 设计受限制较多
    • 体验较差
    • 受限于手机和浏览器性能,用户体验相较于其他模式最差
    • 用户留存率低

究其原因就是性能要求的问题。Web app 之所以能够占领开发市场,主要是因为它的开发速度快,使用简单,应用范围广,但是在性能方面因为无法调用全部硬件底层功能,就现在讲,还是比不过原生 App 的性能。

Hybrid APP

混合开发,也就是半原生半 Web 的开发模式,由原生提供统一的 API 给 JS 调用,实际的主要逻辑有 Html 和 JS 来完成,最终是放在 webview 中显示的,所以只需要写一套代码即可达到跨平台效果。

Hybrid App 兼具了 Native APP 用户体验佳、系统功能强大和 Web APP 跨平台、更新速度快的优势。本质其实是在原生的 App 中,使用 WebView 作为容器直接承载 Web 页面。因此,最核心的点就是 Native 端 与 H5 端 之间的双向通讯层,也就是我们常说的 JSBridge

下面让我们来看下 JS 与 Native(客户端)通信的方式吧。

JS 与客户端通信

JS 通知客户端(Native)

JS上下文注入

原理其实就是 Native 获取 JavaScript 环境上下文,并直接在上面挂载对象或者方法,使 JS 可以直接调用。

Android 与 IOS 分别拥有对应的挂载方式。分别对应是:苹果UIWebview JavaScriptCore注入安卓addJavascriptInterface注入苹果WKWebView scriptMessageHandler注入

上面这三种方式都可以被称为是JS上下文注入,他们都有一个共同的特点就是,不通过任何拦截的办法,而是直接将一个 native 对象(or 函数)注入到 JS 里面,可以由 Web 的 JS 代码直接调用,直接操作。

弹窗拦截

这种方式主要是通过修改浏览器 Window 对象的某些方法,然后拦截固定规则的参数,之后分发给客户端对应的处理方法,从而实现通信。

常用的四个方法:

  • alert: 可以被 webview 的 onJsAlert 监听
  • confirm: 可以被 webview 的 onJsConfirm 监听
  • prompt: 可以被 webview 的 onJsPrompt 监听

简单拿 prompt 来举例说明,Web 页面通过调用 prompt()方法,安卓客户端通过监听onJsPrompt事件,拦截传入的参数,如果参数符合一定协议规范,那么就解析参数,扔给后续的 Java 去处理。这种协议规范,最好是跟 iOS 的协议规范一样,这样跨端调起协议是一致的,但具体实现不一样而已。比如:jack://utils/${action}?a=a 这样的协议,而其他格式的 prompt 参数,是不会监听的,即除了 jack://utils/${action}?a=a 这样的规范协议,prompt 还是原来的 prompt。

但这几种方法在实际的使用中有利有弊,但由于prompt是几个里面唯一可以自定义返回值,可以做同步交互的,所以在目前的使用中,prompt是使用的最多的。

URL Schema

schema 是 URI 的一种格式,上文提到的jack://utils/${action}?a=a 就是一个 scheme 协议,这里说的 scheme(或者 schema)泛指安卓和 iOS 的 schema 协议,因为它比较通用。

安卓和 iOS 都可以通过拦截跳转页 URL 请求,然后解析这个 scheme 协议,符合约定规则的就给到对应的 Native 方法去处理。

安卓和 iOS 分别用于拦截 URL 请求的方法是:

  • android:shouldOverrideUrlLoading方法
  • iOS:UIWebView 的delegate函数

这里简单看一个之前项目中对于 schema 封装:

// 调用
window.fsInvoke.share({title: 'xxx', content: 'xxx'}, result => {
    if (result.errno === 0) {
        alert('分享成功')
    } else {
        // 分享失败
        alert(result.message)
    }
)

---------------------------下方为对fsInvoke的封装

(function(window, undefined) {
    // 分享
    invokeShare = (data, callback) => {
        _invoke('share', data, callback)
    }

    // 登录
    invokeLogin = (data, callback) => {
        _invoke('login', data, callback)
    }

    // 打开扫一扫
    invokeScan = (data, callback) => {
        _invoke('scan', data, callback)
    }

    _invoke = (action, data, callback) => {
        // 拼接schema协议
        let schema = `jack://utils/${action}?a=a`;
        Object.keys(data).forEach(key => {
            schema += `&${key}=${data[key]}`
        })

        // 处理callback
        let callbackName = '';
        if(typeof callback === 'string) {
            callbackName = callback
        } else {
            callbackName = action + Date.now();
            window[callbackName] = callback;
        }

        schema += `&callback=${callbackName}`

        // 触发
        let iframe = document.createElement('iframe');
        iframe.style.display = 'none';
        iframe.src = schema;
        let body = document.body;
        body.appendChild(iframe);
        setTimeout(function() {
            body.removeChild(iframe);
            iframe = null;
        })
    }

    // 暴露给全局
    window.fsInvoke = {
        share: invokeShare,
        login: invokeLogin,
        scan: invokeScan
    }
})(window)

说完了 JS 主动通知客户端(Native)的方式,下面让我们来看下客户端(Native)主动通知调用 JS。

客户端(Native)通知 JS

loadUrl

在安卓 4.4 以前是没有 evaluatingJavaScript API 的,只能通过 loadUrl 来调用 JS 方法,只能让某个 JS 方法执行,但是无法获取该方法的返回值。这时我们需要使用前面提到的 prompt 方法进行兼容,让 H5 端 通过 prompt 进行数据的发送,客户端进行拦截并获取数据。

// mWebView = new WebView(this); //即当前webview对象
mWebView.loadUrl("javascript: 方法名('参数,需要转为字符串')");

//ui线程中运行
 runOnUiThread(new Runnable() {
        @Override
        public void run() {
            mWebView.loadUrl("javascript: 方法名('参数,需要转为字符串')");
            Toast.makeText(Activity名.this, "调用方法...", Toast.LENGTH_SHORT).show();
        }
});

evaluatingJavaScript

在安卓 4.4 之后,evaluatingJavaScript 是一个非常普遍的调用方式。通过 evaluateJavascript 异步调用 JS 方法,并且能在 onReceiveValue 中拿到返回值。

//异步执行JS代码,并获取返回值
mWebView.evaluateJavascript("javascript: 方法名('参数,需要转为字符串')", new ValueCallback() {
        @Override
        public void onReceiveValue(String value) {
    		//这里的value即为对应JS方法的返回值
        }
});

stringByEvaluatingJavaScriptFromString

在 iOS 中 Native 通过stringByEvaluatingJavaScriptFromString调用 Html 绑定在 window 上的函数。

// Swift
webview.stringByEvaluatingJavaScriptFromString("方法名('参数')")
// oc
[webView stringByEvaluatingJavaScriptFromString:@"方法名(参数);"];

总结

看完本篇文章,相信你对 Hybrid 有了一个初步的了解。虽然本篇比较基础,但是只有了解了最本质的底层原理后,才能对现有的解决方案有一个很好的理解,你也可以去打造适合你和团队的Hybrid方案。当然了,后面会有对于 Hybrid 更深入的探讨,敬请期待哦!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

Vue源码探秘(生命周期)

引言

在创建一个 Vue 实例的时候需要经过一系列的初始化过程,比如设置数据监听编译模板挂载实例到 DOM、在数据变化时更新 DOM 等。

同时在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

下面引用官网的一张图,这张图展示了Vue 实例的生命周期以及在它生命周期的各个阶段分别调用的钩子函数:
vue-lifecycle

除了上图中展示的之外,还有activateddeactivated ,这两个是和 keep-alive 相关的函数。

callHook

回顾 _init 函数有这么一段代码:

// src/core/instance/init.js

Vue.prototype._init = function(options?: Object) {
  // ...

  initLifecycle(vm);
  initEvents(vm);
  initRender(vm);
  callHook(vm, "beforeCreate");
  initInjections(vm); // resolve injections before data/props
  initState(vm);
  initProvide(vm); // resolve provide after data/props
  callHook(vm, "created");

  // ...
};

这里调用了两次 callHook 函数,分别执行了生命周期钩子函数 beforeCreatecreated 。来看 callHook 函数的定义:

// src/core/instance/lifecycle.js

export function callHook(vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget();
  const handlers = vm.$options[hook];
  const info = `${hook} hook`;
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, null, vm, info);
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit("hook:" + hook);
  }
  popTarget();
}

callHook 函数接收两个参数,一个是 vm 实例,一个是要执行的钩子函数名。这里通过 vm.$options[hook] 拿到对应的函数数组,然后遍历这个数组调用 invokeWithErrorHandling 函数。 invokeWithErrorHandling 函数定义如下:

export function invokeWithErrorHandling(
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res;
  try {
    res = args ? handler.apply(context, args) : handler.call(context);
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`));
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true;
    }
  } catch (e) {
    handleError(e, vm, info);
  }
  return res;
}

invokeWithErrorHandling 函数主要逻辑就是执行传入的 handler 函数。在调用 invokeWithErrorHandling 函数的时候传入 vm 作为 context 参数,也就是说生命周期函数的 this 会指向当前实例 vm 。另外这里设置一个标识符 _handled 保证函数只被调用一次,避免递归调用。

了解了生命周期的执行方式后,接下来我们会具体介绍每一个生命周期函数它的调用时机。

beforeCreate & created

beforeCreatecreated 这两个钩子函数的调用时机前面也提到过了,在执行 _init 函数时被调用:

initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, "beforeCreate");
initInjections(vm); // resolve injections before data/props
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, "created");

可以看到,在完成初始化生命周期事件render 后调用了 beforeCreate 。在调用 beforeCreate 之后才调用 initState 。也就是说在 beforeCreate 函数中是访问不到 dataprops 等属性的,因为这个时候还没有初始化。

created 是在初始化 dataprops 后才被调用,因此在 created 中可以访问这些属性。

beforeMount & mounted

beforeMountmounted 这两个的调用时机是什么时候呢?

顾名思义,beforeMount 钩子函数发生在 mount,也就是 DOM 挂载之前,它的调用时机是在 mountComponent 函数中,定义在 src/core/instance/lifecycle.js 中:

// src/core/instance/lifecycle.js

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...
  callHook(vm, "beforeMount");

  let updateComponent;
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name;
      const id = vm._uid;
      const startTag = `vue-perf-start:${id}`;
      const endTag = `vue-perf-end:${id}`;

      mark(startTag);
      const vnode = vm._render();
      mark(endTag);
      measure(`vue ${name} render`, startTag, endTag);

      mark(startTag);
      vm._update(vnode, hydrating);
      mark(endTag);
      measure(`vue ${name} patch`, startTag, endTag);
    };
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating);
    };
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          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;
    callHook(vm, "mounted");
  }
  return vm;
}

可以看到,在组件挂载前就会调用 beforeMount 函数,然后在执行了一系列挂载操作后,在最后的 if 语句判断这个 vm 是外部 new Vue 的实例还是内部的组件实例

组件实例会有一个 $vnode 属性,指向组件的占位符 VNode

如果是外部实例则执行 mounted 函数。

因此组件实例的 mounted 函数调用时机不在 mountComponent 函数中,那是在什么地方呢?

回顾 patch 函数:

function patch(oldVnode, vnode, hydrating, removeOnly) {
  // ...

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
  return vnode.elm;
}

组件的 VNode patch 到 DOM 后,会执行 invokeInsertHook 函数,把 insertedVnodeQueue 里保存的钩子函数依次执行一遍,它的定义在 src/core/vdom/patch.js 中:

// src/core/vdom/patch.js

function invokeInsertHook(vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue;
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i]);
    }
  }
}

该函数会执行 insert 这个钩子函数,对于组件而言,insert 钩子函数的定义在 src/core/vdom/create-component.js 中的 componentVNodeHooks 中:

// src/core/vdom/create-component.js

const componentVNodeHooks = {
  insert(vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode;
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true;
      callHook(componentInstance, "mounted");
    }
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance);
      } else {
        activateChildComponent(componentInstance, true /* direct */);
      }
    }
  }
};

可以看到,组件的 mounted 就是在这里通过 callHook 调用的。

beforeUpdate & updated

beforeUpdateupdated 是和数据更新相关的,数据更新这一部分会在下一章详细讲解。

beforeUpdate 的调用时机在 mountComponent 创建 Watcher 实例时:

// src/core/instance/lifecycle.js

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...

  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, "beforeUpdate");
        }
      }
    },
    true /* isRenderWatcher */
  );
  hydrating = false;

  // ...
}

Watcher 的参数中有一个对象,对象中有一个 before 函数,这个函数判断如果组件已经 mounted 并且还没有 destroyed ,就调用 callHook 执行 beforeUpdate

before 函数的执行时机是在 flushSchedulerQueue 函数调用的时候,它被定义在 src/core/observer/scheduler.js 中:

// src/core/observer/scheduler.js

function flushSchedulerQueue() {
  // ...

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    if (watcher.before) {
      watcher.before();
    }
    id = watcher.id;
    has[id] = null;
    watcher.run();
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== "production" && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1;
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          "You may have an infinite update loop " +
            (watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`),
          watcher.vm
        );
        break;
      }
    }
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice();
  const updatedQueue = queue.slice();

  resetSchedulerState();

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue);
  callUpdatedHooks(updatedQueue);

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit("flush");
  }
}

现在我们只需要知道这里的 queue 是一个个 WatcherflushSchedulerQueue 函数会遍历 queue 然后执行每一个 Watcherbefore 方法。

flushSchedulerQueue 函数中还调用了 callUpdatedHooks 函数:

function callUpdatedHooks(queue) {
  let i = queue.length;
  while (i--) {
    const watcher = queue[i];
    const vm = watcher.vm;
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, "updated");
    }
  }
}

可以看到 updated 是在这里被调用的。

beforeDestroy & destroyed

beforeDestroydestroyed 都在执行 $destroy 函数时被调用。$destroy 函数是定义在 Vue.prototype 上的一个方法,在 src/core/instance/lifecycle.js 文件中:

// src/core/instance/lifecycle.js

Vue.prototype.$destroy = function() {
  const vm: Component = this;
  if (vm._isBeingDestroyed) {
    return;
  }
  callHook(vm, "beforeDestroy");
  vm._isBeingDestroyed = true;
  // remove self from parent
  const parent = vm.$parent;
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm);
  }
  // teardown watchers
  if (vm._watcher) {
    vm._watcher.teardown();
  }
  let i = vm._watchers.length;
  while (i--) {
    vm._watchers[i].teardown();
  }
  // remove reference from data ob
  // frozen object may not have observer.
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--;
  }
  // call the last hook...
  vm._isDestroyed = true;
  // invoke destroy hooks on current rendered tree
  vm.__patch__(vm._vnode, null);
  // fire destroyed hook
  callHook(vm, "destroyed");
  // turn off all instance listeners.
  vm.$off();
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null;
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null;
  }
};

可以看到在 $destroy 函数一开始就调用了 beforeDestroy ,然后执行一系列销毁操作后再调用 destroyed ,这些销毁操作会在后面章节再来具体分析。

这里调用了我们之前介绍过的__pacth__ 函数,实际上调用__pacth__函数后会触发子组件的 $destroy 函数,然后又执行__pacth__ 函数。

也就是说会通过递归调用先父后子的顺序把组件一层一层地销毁掉。因此 beforeDestroy 的调用顺序是先父后子,因为它会随着递归被调用;而 destroyed 是递归结束后执行,因此执行顺序是先子后父

总结

这一小节我们学习了生命周期函数的调用时机以及执行顺序。大概整理一下就是:

  • created 钩子函数中可以访问到dataprops 等属性
  • mounted 钩子函数中可以访问到 DOM
  • destroyed 函数中可以执行定时器销毁工作
  • beforeMount / beforeDestroy 的执行顺序是先父后子
  • mounted / destroyed 的执行顺序是先子后父

Koa2+MongoDB+JWT实战--Restful API最佳实践

引言

Web API 已经在最近几年变成重要的话题,一个干净的 API 设计对于后端系统是非常重要的。

通常我们为 Web API 使用 RESTful 设计,REST 概念分离了 API 结构逻辑资源,通过 Http 方法GET, DELETE, POSTPUT等 来操作资源。

本篇文章是结合我最近的一个项目,基于koa+mongodb+jwt来给大家讲述一下 RESTful API 的最佳实践。

RESTful API 是什么?

具体了解RESTful API前,让我们先来看一下什么是REST

REST的全称是Representational state transfer。具体如下:

  • Representational: 数据的表现形式(JSON、XML...)
  • state: 当前状态或者数据
  • transfer: 数据传输

它描述了一个系统如何与另一个交流。比如一个产品的状态(名字,详情)表现为 XML,JSON 或者普通文本。

REST 有六个约束:

  • 客户-服务器(Client-Server)

    关注点分离。服务端专注数据存储,提升了简单性,前端专注用户界面,提升了可移植性。

  • 无状态(Stateless)

    所有用户会话信息都保存在客户端。每次请求必须包括所有信息,不能依赖上下文信息。服务端不用保存会话信息,提升了简单性、可靠性、可见性。

  • 缓存(Cache)

    所有服务端响应都要被标为可缓存或不可缓存,减少前后端交互,提升了性能。

  • 统一接口(Uniform Interface)

    接口设计尽可能统一通用,提升了简单性、可见性。接口与实现解耦,使前后端可以独立开发迭代。

  • 分层系统(Layered System)

  • 按需代码(Code-On-Demand)

看完了 REST 的六个约束,下面让我们来看一下行业内对于RESTful API设计最佳实践的总结。

最佳实践

请求设计规范

  • URI 使用名词,尽量使用复数,如/users
  • URI 使用嵌套表示关联关系,如/users/123/repos/234
  • 使用正确的 HTTP 方法,如 GET/POST/PUT/DELETE

响应设计规范

  • 查询
  • 分页
  • 字段过滤

如果记录数量很多,服务器不可能都将它们返回给用户。API 应该提供参数,过滤返回结果。下面是一些常见的参数(包括上面的查询、分页以及字段过滤):

?limit=10:指定返回记录的数量
?offset=10:指定返回记录的开始位置。
?page=2&per_page=100:指定第几页,以及每页的记录数。
?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
?animal_type_id=1:指定筛选条件
  • 状态码
  • 错误处理

就像 HTML 的出错页面向访问者展示了有用的错误消息一样,API 也应该用之前清晰易读的格式来提供有用的错误消息。

比如对于常见的提交表单,当遇到如下错误信息时:

{
    "error": "Invalid payoad.",
    "detail": {
        "surname": "This field is required."
    }
}

接口调用者很快就能定位到错误原因。

安全

  • HTTPS
  • 鉴权

RESTful API 应该是无状态。这意味着对请求的认证不应该基于cookie或者session。相反,每个请求应该带有一些认证凭证。

  • 限流

为了避免请求泛滥,给 API 设置速度限制很重要。为此 RFC 6585 引入了 HTTP 状态码429(too many requests)。加入速度设置之后,应该给予用户提示。

上面说了这么多,下面让我们看一下如何在 Koa 中践行RESTful API最佳实践吧。

Koa 中实现 RESTful API

先来看一下完成后的项目目录结构:

|-- rest_node_api
    |-- .gitignore
    |-- README.md
    |-- package-lock.json
    |-- package.json      # 项目依赖
    |-- app
        |-- config.js     # 数据库(mongodb)配置信息
        |-- index.js      # 入口
        |-- controllers   # 控制器:用于解析用户输入,处理后返回相应的结果
        |-- models        # 模型(schema): 用于定义数据模型
        |-- public        # 静态资源
        |-- routes        # 路由

项目的目录呈现了清晰的分层、分模块结构,也便于后期的维护和扩展。下面我们会对项目中需要注意的几点一一说明。

Controller(控制器)

什么是控制器?

  • 拿到路由分配的任务并执行
  • 在 koa 中是一个中间件

为什么要用控制器

  • 获取 HTTP 请求参数

    • Query String,如?q=keyword
    • Router Params,如/users/:id
    • Body,如{name: 'jack'}
    • Header,如 Accept、Cookie
  • 处理业务逻辑

  • 发送 HTTP 响应

    • 发送 Status,如 200/400
    • 发送 Body,如{name: 'jack'}
    • 发送 Header,如 Allow、Content-Type

编写控制器的最佳实践

  • 每个资源的控制器放在不同的文件里
  • 尽量使用类+类方法的形式编写控制器
  • 严谨的错误处理

示例

app/controllers/users.js

const User = require("../models/users");
class UserController {
  async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
}

module.exports = new UserController();

错误处理机制

koa自带错误处理

要执行自定义错误处理逻辑,如集中式日志记录,您可以添加一个 “error” 事件侦听器:

app.on('error', err => {
  log.error('server error', err)
});

中间件

本项目中采用koa-json-error来处理错误,关于该中间件的详细介绍会在下文展开。

用户认证与授权

目前常用的用于用户信息认证与授权的有两种方式-JWTSession。下面我们分别对比一下两种鉴权方式的优劣点。

Session

  • 相关的概念介绍

    • session::主要存放在服务器,相对安全
    • cookie:主要存放在客户端,并且不是很安全
    • sessionStorage:仅在当前会话下有效,关闭页面或浏览器后被清除
    • localstorage:除非被清除,否则永久保存
  • 工作原理

    • 客户端带着用户名和密码去访问/login 接口,服务器端收到后校验用户名和密码,校验正确就会在服务器端存储一个 sessionId 和 session 的映射关系。
    • 服务器端返回 response,并且将 sessionId 以 set-cookie 的方式种在客户端,这样,sessionId 就存在了客户端。
    • 客户端发起非登录请求时,假如服务器给了 set-cookie,浏览器会自动在请求头中添加 cookie。
    • 服务器接收请求,分解 cookie,验证信息,核对成功后返回 response 给客户端。
  • 优势

    • 相比 JWT,最大的优势就在于可以主动清楚 session 了
    • session 保存在服务器端,相对较为安全
    • 结合 cookie 使用,较为灵活,兼容性较好(客户端服务端都可以清除,也可以加密)
  • 劣势

    • cookie+session 在跨域场景表现并不好(不可跨域,domain 变量,需要复杂处理跨域)
    • 如果是分布式部署,需要做多机共享 Session 机制(成本增加)
    • 基于 cookie 的机制很容易被 CSRF
    • 查询 Session 信息可能会有数据库查询操作

JWT

  • 相关的概念介绍

    由于详细的介绍 JWT 会占用大量文章篇幅,也不是本文的重点。所以这里只是简单介绍一下。主要是和 Session 方式做一个对比。关于 JWT 详细的介绍可以参考https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样:

{
  "姓名": "森林",
  "角色": "搬砖工",
  "到期时间": "2020年1月198日16点32分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认证用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的格式大致如下:

它是一个很长的字符串,中间用点(.)分隔成三个部分。

JWT 的三个部分依次如下:

Header(头部)
Payload(负载)
Signature(签名)
  • JWT相比Session
    • 安全性(两者均有缺陷)
    • RESTful API,JWT 优胜,因为 RESTful API 提倡无状态,JWT 符合要求
    • 性能(各有利弊,因为 JWT 信息较强,所以体积也较大。不过 Session 每次都需要服务器查找,JWT 信息都保存好了,不需要再去查询数据库)
    • 时效性,Session 能直接从服务端销毁,JWT 只能等到时效性到了才会销毁(修改密码也无法阻止篡夺者的使用)

jsonwebtoken

由于 RESTful API 提倡无状态,而 JWT 又恰巧符合这一要求,因此我们采用JWT来实现用户信息的授权与认证。

项目中采用的是比较流行的jsonwebtoken。具体使用方式可以参考https://www.npmjs.com/package/jsonwebtoken

实战

初始化项目

mkdir rest_node_api  # 创建文件目录
cd rest_node_api  # 定位到当前文件目录
npm init  # 初始化,得到`package.json`文件
npm i koa -S  # 安装koa
npm i koa-router -S  # 安装koa-router

基础依赖安装好后可以先搞一个hello-world

app/index.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

router.get("/", async function (ctx) {
    ctx.body = {message: "Hello World!"}
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000);

相关中间件和插件依赖

koa-body

之前使用 koa2 的时候,处理 post 请求使用的是 koa-bodyparser,同时如果是图片上传使用的是 koa-multer。这两者的组合没什么问题,不过 koa-multer 和 koa-route(注意不是 koa-router) 存在不兼容的问题。

koa-body结合了二者,所以 koa-body 可以对其进行代替。

依赖安装

npm i koa-body -S

app/index.js

const koaBody = require('koa-body');
const app = new koa();
app.use(koaBody({
  multipart:true, // 支持文件上传
  encoding:'gzip',
  formidable:{
    uploadDir:path.join(__dirname,'public/uploads'), // 设置文件上传目录
    keepExtensions: true,    // 保持文件的后缀
    maxFieldsSize:2 * 1024 * 1024, // 文件上传大小
    onFileBegin:(name,file) => { // 文件上传前的设置
      // console.log(`name: ${name}`);
      // console.log(file);
    },
  }
}));

参数配置:

  • 基本参数

    参数名 描述 类型 默认值
    patchNode 将请求体打到原生 node.js 的ctx.req Boolean false
    patchKoa 将请求体打到 koa 的 ctx.request Boolean true
    jsonLimit JSON 数据体的大小限制 String / Integer 1mb
    formLimit 限制表单请求体的大小 String / Integer 24kb
    textLimit 限制 text body 的大小 String / Integer 23kb
    encoding 表单的默认编码 String utf-8
    multipart 是否支持 multipart-formdate 的表单 Boolean false
    urlencoded 是否支持 urlencoded 的表单 Boolean true
    formidable 配置更多的关于 multipart 的选项 Object {}
    onError 错误处理 Function function(){}
    stict 严格模式,启用后不会解析 GET, HEAD, DELETE 请求 Boolean true
  • formidable 的相关配置参数

    参数名 描述 类型 默认值
    maxFields 限制字段的数量 Integer 500
    maxFieldsSize 限制字段的最大大小 Integer 1 * 1024 * 1024
    uploadDir 文件上传的文件夹 String os.tmpDir()
    keepExtensions 保留原来的文件后缀 Boolean false
    hash 如果要计算文件的 hash,则可以选择 md5/sha1 String false
    multipart 是否支持多文件上传 Boolean true
    onFileBegin 文件上传前的一些设置操作 Function function(name,file){}

koa-json-error

在写接口时,返回json格式且易读的错误提示是有必要的,koa-json-error中间件帮我们做到了这一点。

依赖安装

npm i koa-json-error -S

app/index.js

const error = require("koa-json-error");
const app = new Koa();
app.use(
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);

错误会默认抛出堆栈信息stack,在生产环境中,没必要返回给用户,在开发环境显示即可。

koa-parameter

采用koa-parameter用于参数校验,它是基于参数验证框架parameter, 给 koa 框架做的适配。

依赖安装

npm i koa-parameter -S

使用

// app/index.js
const parameter = require("koa-parameter");
app.use(parameter(app));

// app/controllers/users.js
 async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    ...
  }

因为koa-parameter是基于parameter的,只是做了一层封装而已,底层逻辑还是按照 parameter 来的,自定义规则完全可以参照 parameter 官方说明和示例来编写。

let TYPE_MAP = Parameter.TYPE_MAP = {
  number: checkNumber,
  int: checkInt,
  integer: checkInt,
  string: checkString,
  id: checkId,
  date: checkDate,
  dateTime: checkDateTime,
  datetime: checkDateTime,
  boolean: checkBoolean,
  bool: checkBoolean,
  array: checkArray,
  object: checkObject,
  enum: checkEnum,
  email: checkEmail,
  password: checkPassword,
  url: checkUrl,
};

koa-static

如果网站提供静态资源(图片、字体、样式、脚本......),为它们一个个写路由就很麻烦,也没必要。koa-static模块封装了这部分的请求。

app/index.js

const Koa = require("koa");
const koaStatic = require("koa-static");
const app = new Koa();
app.use(koaStatic(path.join(__dirname, "public")));

连接数据库

数据库我们采用的是mongodb,连接数据库前,我们要先来看一下mongoose

mongoosenodeJS提供连接 mongodb的一个库,类似于jqueryjs的关系,对mongodb一些原生方法进行了封装以及优化。简单的说,Mongoose就是对node环境中MongoDB数据库操作的封装,一个对象模型(ODM)工具,将数据库中的数据转换为JavaScript对象以供我们在应用中使用。

安装 mongoose

npm install mongoose -S

连接及配置

const mongoose = require("mongoose");
mongoose.connect(
  connectionStr,  // 数据库地址
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

用户的 CRUD

项目中的模块是比较多的,我不会一一去演示,因为各个模块实质性的内容是大同小异的。在这里主要是以用户模块的crud为例来展示下如何在 koa 中践行RESTful API最佳实践

app/index.js(koa 入口)

入口文件主要用于创建 koa 服务、装载 middleware(中间件)、路由注册(交由 routes 模块处理)、连接数据库等。

const Koa = require("koa");
const path = require("path");
const koaBody = require("koa-body");
const koaStatic = require("koa-static");
const parameter = require("koa-parameter");
const error = require("koa-json-error");
const mongoose = require("mongoose");
const routing = require("./routes");
const app = new Koa();
const { connectionStr } = require("./config");
mongoose.connect(  // 连接mongodb
  connectionStr,
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

app.use(koaStatic(path.join(__dirname, "public")));  // 静态资源
app.use(  // 错误处理
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);
app.use(  // 处理post请求和图片上传
  koaBody({
    multipart: true,
    formidable: {
      uploadDir: path.join(__dirname, "/public/uploads"),
      keepExtensions: true
    }
  })
);
app.use(parameter(app));  // 参数校验
routing(app);  // 路由处理

app.listen(3000, () => console.log("程序启动在3000端口了"));

app/routes/index.js

由于项目模块较多,对应的路由也很多。如果一个个的去注册,有点太麻烦了。这里用 node 的 fs 模块去遍历读取 routes 下的所有路由文件,统一注册。

const fs = require("fs");

module.exports = app => {
  fs.readdirSync(__dirname).forEach(file => {
    if (file === "index.js") {
      return;
    }
    const route = require(`./${file}`);
    app.use(route.routes()).use(route.allowedMethods());
  });
};

app/routes/users.js

用户模块路由,里面主要涉及到了用户的登录以及增删改查。

const jsonwebtoken = require("jsonwebtoken");
const jwt = require("koa-jwt");
const { secret } = require("../config");
const Router = require("koa-router");
const router = new Router({ prefix: "/users" });  // 路由前缀
const {
  find,
  findById,
  create,
  checkOwner,
  update,
  delete: del,
  login,
} = require("../controllers/users");  // 控制器方法

const auth = jwt({ secret });  // jwt鉴权

router.get("/", find);  // 获取用户列表

router.post("/", auth, create);  // 创建用户(需要jwt认证)

router.get("/:id", findById);  // 获取特定用户

router.patch("/:id", auth, checkOwner, update);  // 更新用户信息(需要jwt认证和验证操作用户身份)

router.delete("/:id", auth, checkOwner, del);  // 删除用户(需要jwt认证和验证操作用户身份)

router.post("/login", login);  // 用户登录

module.exports = router;

app/models/users.js

用户数据模型(schema)

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const userSchema = new Schema(
  {
    __v: { type: Number, select: false },
    name: { type: String, required: true },  // 用户名
    password: { type: String, required: true, select: false },  // 密码
    avatar_url: { type: String },  // 头像
    gender: {  //   性别
      type: String,
      enum: ["male", "female"],
      default: "male",
      required: true
    },
    headline: { type: String },  // 座右铭
    locations: {  // 居住地
      type: [{ type: Schema.Types.ObjectId, ref: "Topic" }],
      select: false
    },
    business: { type: Schema.Types.ObjectId, ref: "Topic", select: false },  // 职业
  },
  { timestamps: true }
);

module.exports = model("User", userSchema);

app/controllers/users.js

用户模块控制器,用于处理业务逻辑

const User = require("../models/users");
const jsonwebtoken = require("jsonwebtoken");
const { secret } = require("../config");
class UserController {
  async find(ctx) {  // 查询用户列表(分页)
    const { per_page = 10 } = ctx.query;
    const page = Math.max(ctx.query.page * 1, 1) - 1;
    const perPage = Math.max(per_page * 1, 1);
    ctx.body = await User.find({ name: new RegExp(ctx.query.q) })
      .limit(perPage)
      .skip(page * perPage);
  }
  async findById(ctx) {  // 根据id查询特定用户
    const { fields } = ctx.query;
    const selectFields =  // 查询条件
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => " +" + f)
        .join("");
    const populateStr =  // 展示字段
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => {
          if (f === "employments") {
            return "employments.company employments.job";
          }
          if (f === "educations") {
            return "educations.school educations.major";
          }
          return f;
        })
        .join(" ");
    const user = await User.findById(ctx.params.id)
      .select(selectFields)
      .populate(populateStr);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async create(ctx) {  // 创建用户
    ctx.verifyParams({  // 入参格式校验
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {  // 校验用户名是否已存在
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
  async checkOwner(ctx, next) {  // 判断用户身份合法性
    if (ctx.params.id !== ctx.state.user._id) {
      ctx.throw(403, "没有权限");
    }
    await next();
  }
  async update(ctx) {  // 更新用户信息
    ctx.verifyParams({
      name: { type: "string", required: false },
      password: { type: "string", required: false },
      avatar_url: { type: "string", required: false },
      gender: { type: "string", required: false },
      headline: { type: "string", required: false },
      locations: { type: "array", itemType: "string", required: false },
      business: { type: "string", required: false },
    });
    const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async delete(ctx) {  // 删除用户
    const user = await User.findByIdAndRemove(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.status = 204;
  }
  async login(ctx) {  // 登录
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const user = await User.findOne(ctx.request.body);
    if (!user) {
      ctx.throw(401, "用户名或密码不正确");
    }
    const { _id, name } = user;
    const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: "1d" });  // 登录成功返回jwt加密后的token信息
    ctx.body = { token };
  }
  async checkUserExist(ctx, next) {  // 查询用户是否存在
    const user = await User.findById(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    await next();
  }

}

module.exports = new UserController();

postman演示

登录

获取用户列表

获取特定用户

创建用户

更新用户信息

删除用户

最后

到这里本篇文章内容也就结束了,这里主要是结合用户模块来给大家讲述一下RESTful API最佳实践在 koa 项目中的运用。项目的源码已经开源,地址是https://github.com/Jack-cool/rest_node_api。需要的自取,感觉不错的话麻烦给个 star!!

「面试必问」leetcode高频题精选

引言(文末有福利)🏂

算法一直是大厂前端面试常问的一块,而大家往往准备这方面的面试都是通过leetcode刷题。

我特地整理了几道leetcode中「很有意思」而且非常「高频」的算法题目,分别给出了思路分析(带图解)和代码实现。

认真仔细的阅读完本文,相信对于你在算法方面的面试一定会有不小的帮助!🤠

两数之和 🦊

题目难度easy,涉及到的算法知识有数组、哈希表

题目描述

给定一个整数数组 nums  和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。

示例:

给定 nums = [2, 7, 11, 15], target = 9

因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

思路分析

大多数同学看到这道题目,心中肯定会想:这道题目太简单了,不就两层遍历嘛:两层循环来遍历同一个数组;第一层循环遍历的值记为a,第二层循环时遍历的值记为b;若a+b = 目标值,那么ab对应的数组下标就是我们想要的答案。

这种解法没毛病,但有没有优化的方案呢?🤔

要知道两层循环很多情况下都意味着O(n^2) 的复杂度,这个复杂度非常容易导致你的算法超时。即便没有超时,在明明有一层遍历解法的情况下,你写了两层遍历,面试官也会对你的印象分大打折扣。🤒

其实我们可以在遍历数组的过程中,增加一个Map结构来存储已经遍历过的数字及其对应的索引值。然后每遍历到一个新数字的时候,都回到Map里去查询targetNum与该数的差值是否已经在前面的数字中出现过了。若出现过,那么答案已然显现,我们就不必再往下走了。

我们就以本题中的例子结合图片来说明一下上面提到的这种思路:

  • 这里用对象diffs来模拟map结构:

    首先遍历数组第一个元素,此时key为 2,value为索引 0

  • 往下遍历,遇到了 7:

    计算targetNum和 7 的差值为 2,去diffs中检索 2 这个key,发现是之前出现过的值。那么本题的答案就出来了!

代码实现

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
const twoSum = function (nums, target) {
  const diffs = {};
  // 缓存数组长度
  const len = nums.length;
  // 遍历数组
  for (let i = 0; i < len; i++) {
    // 判断当前值对应的 target 差值是否存在
    if (diffs[target - nums[i]] !== undefined) {
      // 若有对应差值,那么得到答案
      return [diffs[target - nums[i]], i];
    }
    // 若没有对应差值,则记录当前值
    diffs[nums[i]] = i;
  }
};

三数之和 🦁

题目难度medium,涉及到的算法知识有数组、双指针

题目描述

给你一个包含n个整数的数组nums,判断nums中是否存在三个元素abc ,使得a + b + c = 0。请你找出所有满足条件且不重复的三元组。

注意:答案中不可以包含重复的三元组。

示例:

给定数组 nums = [-1, 0, 1, 2, -1, -4]

满足要求的三元组集合为:
[
  [-1, 0, 1],
  [-1, -1, 2]
]

思路分析

和上面的两数之和一样,如果不认真思考,最快的方式可能就是多层遍历了。但有了前车之鉴,我们同样可以把求和问题变为求差问题:固定其中一个数,在剩下的数中寻找是否有两个数的和这个固定数相加是等于 0 的。

这里我们采用双指针法来解决问题,相比三层循环,效率会大大提升。

双指针法的适用范围比较广,一般像求和、比大小的都可以用它来解决。但是有一个前提:数组必须有序

因此我们的第一步就是先将数组进行排序:

// 给 nums 排序
nums = nums.sort((a, b) => {
  return a - b;
});

然后对数组进行遍历,每遍历到哪个数字,就固定当前的数字。同时左指针指向该数字后面的紧邻的那个数字,右指针指向数组末尾。然后左右指针分别向中间靠拢:

每次指针移动一次位置,就计算一下两个指针指向数字之和加上固定的那个数之后,是否等于 0。如果是,那么我们就得到了一个目标组合;否则,分两种情况来看:

  • 相加之和大于 0,说明右侧的数偏大了,右指针左移
  • 相加之和小于 0,说明左侧的数偏小了,左指针右移

代码实现

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
const threeSum = function (nums) {
  // 用于存放结果数组
  let res = [];
  // 目标值为0
  let sum = 0;
  // 给 nums 排序
  nums = nums.sort((a, b) => {
    return a - b;
  });
  // 缓存数组长度
  const len = nums.length;
  for (let i = 0; i < len - 2; i++) {
    // 左指针 j
    let j = i + 1;
    // 右指针k
    let k = len - 1;
    // 如果遇到重复的数字,则跳过
    if (i > 0 && nums[i] === nums[i - 1]) {
      continue;
    }
    while (j < k) {
      // 三数之和小于0,左指针前进
      if (nums[i] + nums[j] + nums[k] < 0) {
        j++;
        // 处理左指针元素重复的情况
        while (j < k && nums[j] === nums[j - 1]) {
          j++;
        }
      } else if (nums[i] + nums[j] + nums[k] > 0) {
        // 三数之和大于0,右指针后退
        k--;

        // 处理右指针元素重复的情况
        while (j < k && nums[k] === nums[k + 1]) {
          k--;
        }
      } else {
        // 得到目标数字组合,推入结果数组
        res.push([nums[i], nums[j], nums[k]]);

        // 左右指针一起前进
        j++;
        k--;

        // 若左指针元素重复,跳过
        while (j < k && nums[j] === nums[j - 1]) {
          j++;
        }

        // 若右指针元素重复,跳过
        while (j < k && nums[k] === nums[k + 1]) {
          k--;
        }
      }
    }
  }

  // 返回结果数组
  return res;
};

盛最多水的容器 🥃

题目难度medium,涉及到的算法知识有数组、双指针

题目描述

给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点  (i, ai) 。在坐标内画 n 条垂直线,垂直线 i  的两个端点分别为  (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与  x  轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器,且  n  的值至少为 2。

图中垂直线代表输入数组[1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例:

输入:[1,8,6,2,5,4,8,3,7]
输出:49

思路分析

首先,我们能快速想到的一种方法:两两进行求解,计算可以承载的水量。 然后不断更新最大值,最后返回最大值即可。

这种解法,需要两层循环,时间复杂度是O(n^2)。这种相对来说比较暴力,对应就是暴力法

暴力法

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;
  for (let i = 0; i < height.length - 1; i++) {
    for (let j = i + 1; j < height.length; j++) {
      let area = (j - i) * Math.min(height[i], height[j]);
      max = Math.max(max, area);
    }
  }

  return max;
};

那么有没有更好的办法呢?答案是肯定有。

其实有点类似双指针的概念,左指针指向下标 0,右指针指向length-1。然后分别从左右两侧向中间移动,每次取小的那个值(因为水的高度肯定是以小的那个为准)。

如果左侧小于右侧,则i++,否则j--(这一步其实就是取所有高度中比较高的,我们知道面积等于长*宽)。对应就是双指针 动态滑窗

双指针 动态滑窗

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;
  let i = 0;
  let j = height.length - 1;
  while (i < j) {
    let minHeight = Math.min(height[i], height[j]);
    let area = (j - i) * minHeight;
    max = Math.max(max, area);
    if (height[i] < height[j]) {
      i++;
    } else {
      j--;
    }
  }
  return max;
};

爬楼梯 🎢

题目难度easy,涉及到的算法知识有斐波那契数列、动态规划。

题目描述

假设你正在爬楼梯。需要 n  阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1.  1  + 1 
2.  2 

示例 2:

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1  + 1  + 1 
2.  1  + 2 
3.  2  + 1 

思路分析

这道题目是一道非常高频的面试题目,也是一道非常经典的斐波那契数列类型的题目。

解决本道题目我们会用到动态规划的算法**-可以分成多个子问题,爬第 n 阶楼梯的方法数量,等于 2 部分之和:

  • 爬上n−1阶楼梯的方法数量。因为再爬 1 阶就能到第 n 阶
  • 爬上n−2阶楼梯的方法数量,因为再爬 2 阶就能到第 n 阶

可以得到公式:

climbs[n] = climbs[n - 1] + climbs[n - 2];

同时需要做如下初始化:

climbs[0] = 1;
climbs[1] = 1;

代码实现

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  let climbs = [];
  climbs[0] = 1;
  climbs[1] = 1;
  for (let i = 2; i <= n; i++) {
    climbs[i] = climbs[i - 1] + climbs[i - 2];
  }
  return climbs[n];
};

环形链表 🍩

题目难度easy,涉及到的算法知识有链表、快慢指针。

题目描述

给定一个链表,判断链表中是否有环。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。

示例 1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

思路分析

链表成环问题也是非常经典的算法问题,在面试中也经常会遇到。

解决这种问题一般有常见的两种方法:标志法快慢指针法

标志法

给每个已遍历过的节点加标志位,遍历链表,当出现下一个节点已被标志时,则证明单链表有环。

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  while (head) {
    if (head.flag) return true;
    head.flag = true;
    head = head.next;
  }
  return false;
};

快慢指针(双指针法)

设置快慢两个指针,遍历单链表,快指针一次走两步,慢指针一次走一步,如果单链表中存在环,则快慢指针终会指向同一个节点,否则直到快指针指向null时,快慢指针都不可能相遇。

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  if (!head || !head.next) {
    return false;
  }
  let slow = head,
    fast = head.next;
  while (slow !== fast) {
    if (!fast || !fast.next) return false;
    fast = fast.next.next;
    slow = slow.next;
  }
  return true;
};

有效的括号 🍉

题目难度easy,涉及到的算法知识有栈、哈希表。

题目描述

给定一个只包括'('')''{''}''['']'  的字符串,判断字符串是否有效。

有效字符串需满足:

1、左括号必须用相同类型的右括号闭合。
2、左括号必须以正确的顺序闭合。

注意空字符串可被认为是有效字符串。

示例 1:

输入: "()";
输出: true;

示例  2:

输入: "()[]{}";
输出: true;

示例  3:

输入: "(]";
输出: false;

示例  4:

输入: "([)]";
输出: false;

示例  5:

输入: "{[]}";
输出: true;

思路分析

这道题可以利用结构。

思路大概是:遇到左括号,一律推入栈中,遇到右括号,将栈顶部元素拿出,如果不匹配则返回 false,如果匹配则继续循环。

第一种解法是利用switch case

switch case

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function (s) {
  let arr = [];
  let len = s.length;
  if (len % 2 !== 0) return false;
  for (let i = 0; i < len; i++) {
    let letter = s[i];
    switch (letter) {
      case "(": {
        arr.push(letter);
        break;
      }
      case "{": {
        arr.push(letter);
        break;
      }
      case "[": {
        arr.push(letter);
        break;
      }
      case ")": {
        if (arr.pop() !== "(") return false;
        break;
      }
      case "}": {
        if (arr.pop() !== "{") return false;
        break;
      }
      case "]": {
        if (arr.pop() !== "[") return false;
        break;
      }
    }
  }
  return !arr.length;
};

第二种是维护一个map对象:

哈希表map

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function (s) {
  let map = {
    "(": ")",
    "{": "}",
    "[": "]",
  };
  let stack = [];
  let len = s.length;
  if (len % 2 !== 0) return false;
  for (let i of s) {
    if (i in map) {
      stack.push(i);
    } else {
      if (i !== map[stack.pop()]) return false;
    }
  }
  return !stack.length;
};

滑动窗口最大值 ⛵

题目难度hard,涉及到的算法知识有双端队列。

题目描述

给定一个数组 nums,有一个大小为  k  的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k  个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

进阶:你能在线性时间复杂度内解决此题吗?

示例:

输入: nums = [1,3,-1,-3,5,3,6,7],  k = 3
输出: [3,3,5,5,6,7]
解释:

  滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

提示:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums.length

思路分析

暴力求解

第一种方法,比较简单。也是大多数同学很快就能想到的方法。

  • 遍历数组
  • 依次遍历每个区间内的最大值,放入数组中
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  let len = nums.length;
  if (len === 0) return [];
  if (k === 1) return nums;
  let resArr = [];
  for (let i = 0; i <= len - k; i++) {
    let max = Number.MIN_SAFE_INTEGER;
    for (let j = i; j < i + k; j++) {
      max = Math.max(max, nums[j]);
    }
    resArr.push(max);
  }
  return resArr;
};

双端队列

这道题还可以用双端队列去解决,核心在于在窗口发生移动时,只根据发生变化的元素对最大值进行更新。

结合上面动图(图片来源)我们梳理下思路:

  • 检查队尾元素,看是不是都满足大于等于当前元素的条件。如果是的话,直接将当前元素入队。否则,将队尾元素逐个出队、直到队尾元素大于等于当前元素为止。(这一步是为了维持队列的递减性:确保队头元素是当前滑动窗口的最大值。这样我们每次取最大值时,直接取队头元素即可。)
  • 将当前元素入队
  • 检查队头元素,看队头元素是否已经被排除在滑动窗口的范围之外了。如果是,则将队头元素出队。(这一步是维持队列的有效性:确保队列里所有的元素都在滑动窗口圈定的范围以内。)
  • 排除掉滑动窗口还没有初始化完成、第一个最大值还没有出现的特殊情况。
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  // 缓存数组的长度
  const len = nums.length;
  const res = [];
  const deque = [];
  for (let i = 0; i < len; i++) {
    // 队尾元素小于当前元素
    while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
      deque.pop();
    }
    deque.push(i);

    // 当队头元素的索引已经被排除在滑动窗口之外时
    while (deque.length && deque[0] <= i - k) {
      // 队头元素出对
      deque.shift();
    }
    if (i >= k - 1) {
      res.push(nums[deque[0]]);
    }
  }
  return res;
};

每日温度 🌡

题目难度medium,涉及到的算法知识有栈。

题目描述

根据每日气温列表,请重新生成一个列表,对应位置的输出是需要再等待多久温度才会升高超过该日的天数。如果之后都不会升高,请在该位置用  0 来代替。

例如,给定一个列表  temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是  [1, 1, 4, 2, 1, 1, 0, 0]。

提示:气温列表长度的范围是  [1, 30000]。每个气温的值的均为华氏度,都是在  [30, 100]  范围内的整数。

思路分析

看到这道题,大家很容易就会想到暴力遍历法:直接两层遍历,第一层定位一个温度,第二层定位离这个温度最近的一次升温是哪天,然后求出两个温度对应索引的差值即可。

然而这种解法需要两层遍历,时间复杂度是O(n^2),显然不是最优解法。

本道题目可以采用栈去做一个优化。

大概思路就是:维护一个递减栈。当遍历过的温度,维持的是一个单调递减的态势时,我们就对这些温度的索引下标执行入栈操作;只要出现了一个数字,它打破了这种单调递减的趋势,也就是说它比前一个温度值高,这时我们就对前后两个温度的索引下标求差,得出前一个温度距离第一次升温的目标差值。

代码实现

/**
 * @param {number[]} T
 * @return {number[]}
 */
var dailyTemperatures = function (T) {
  const len = T.length;
  const stack = [];
  const res = new Array(len).fill(0);
  for (let i = 0; i < len; i++) {
    while (stack.length && T[i] > T[stack[stack.length - 1]]) {
      const top = stack.pop();
      res[top] = i - top;
    }
    stack.push(i);
  }
  return res;
};

括号生成 🎯

题目难度medium,涉及到的算法知识有递归、回溯。

题目描述

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例:

输入:n = 3
输出:[
       "((()))",
       "(()())",
       "(())()",
       "()(())",
       "()()()"
     ]

思路分析

这道题目通过递归去实现。

因为左右括号需要匹配、闭合。所以对应“(”和“)”的数量都是n,当满足这个条件时,一次递归就结束,将对应值放入结果数组中。

这里有一个潜在的限制条件:有效的括号组合。对应逻辑就是在往每个位置去放入“(”或“)”前:

  • 需要判断“(”的数量是否小于 n
  • “)”的数量是否小于“(”

代码实现

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function (n) {
  let res = [];
  const generate = (cur, left, right) => {
    if (left === n && right === n) {
      res.push(cur);
      return;
    }
    if (left < n) {
      generate(cur + "(", left + 1, right);
    }
    if (right < left) {
      generate(cur + ")", left, right + 1);
    }
  };
  generate("", 0, 0);
  return res;
};

电话号码的字母组合 🎨

题目难度medium,涉及到的算法知识有递归、回溯。

题目描述

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].

思路分析

首先用一个对象map存储数字与字母的映射关系,接下来遍历对应的字符串,第一次将字符串存在结果数组result中,第二次及以后的就双层遍历生成新的字符串数组。

代码实现

哈希映射 逐层遍历

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
  let res = [];
  if (digits.length === 0) return [];
  let map = {
    2: "abc",
    3: "def",
    4: "ghi",
    5: "jkl",
    6: "mno",
    7: "pqrs",
    8: "tuv",
    9: "wxyz",
  };
  for (let num of digits) {
    let chars = map[num];
    if (res.length > 0) {
      let temp = [];
      for (let char of chars) {
        for (let oldStr of res) {
          temp.push(oldStr + char);
        }
      }
      res = temp;
    } else {
      res.push(...chars);
    }
  }
  return res;
};

递归

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
  let res = [];
  if (!digits) return [];
  let map = {
    2: "abc",
    3: "def",
    4: "ghi",
    5: "jkl",
    6: "mno",
    7: "pqrs",
    8: "tuv",
    9: "wxyz",
  };
  function generate(i, str) {
    let len = digits.length;
    if (i === len) {
      res.push(str);
      return;
    }
    let chars = map[digits[i]];
    for (let j = 0; j < chars.length; j++) {
      generate(i + 1, str + chars[j]);
    }
  }
  generate(0, "");
  return res;
};

岛屿数量 🏝

题目难度medium,涉及到的算法知识有 DFS(深度优先搜索)。

题目描述

给你一个由  '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入: 11110;
11010;
11000;
00000;
输出: 1;

示例  2:

输入:
11000
11000
00100
00011
输出: 3
解释: 每座岛屿只能由水平和/或竖直方向上相邻的陆地连接而成。

思路分析

如上图,我们需要计算的就是图中相连(只能是水平和/或竖直方向上相邻)的绿色岛屿的数量。

这道题目一个经典的做法是沉岛,大致思路是:采用DFS(深度优先搜索),遇到 1 的就将当前的 1 变为 0,并将当前坐标的上下左右都执行 dfs,并计数。

终止条件是:超出二维数组的边界或者是遇到 0 ,直接返回。

代码实现

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function (grid) {
  const rows = grid.length;
  if (rows === 0) return 0;
  const cols = grid[0].length;
  let res = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (grid[i][j] === "1") {
        helper(grid, i, j, rows, cols);
        res++;
      }
    }
  }
  return res;
};
function helper(grid, i, j, rows, cols) {
  if (i < 0 || j < 0 || i > rows - 1 || j > cols - 1 || grid[i][j] === "0")
    return;

  grid[i][j] = "0";

  helper(grid, i + 1, j, rows, cols);
  helper(grid, i, j + 1, rows, cols);
  helper(grid, i - 1, j, rows, cols);
  helper(grid, i, j - 1, rows, cols);
}

分发饼干 🍪

题目难度easy,涉及到的算法知识有贪心算法。

题目描述

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值  gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。

注意:

你可以假设胃口值为正。
一个小朋友最多只能拥有一块饼干。

示例  1:

输入: [1,2,3], [1,1]

输出: 1

解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。

示例  2:

输入: [1,2], [1,2,3]

输出: 2

解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.

思路分析

这道题目是一道典型的贪心算法类。解题思路大概如下:

  • 优先满足胃口小的小朋友的需求
  • 设最大可满足的孩子数量为maxNum = 0
  • 胃口小的拿小的,胃口大的拿大的
  • 两边升序,然后一一对比
    • 饼干j >= 胃口i 时,i++j++maxNum++
    • 饼干j < 胃口i时,说明饼干不够吃,换更大的,j++
  • 到边界后停止

代码实现

/**
 * @param {number[]} g
 * @param {number[]} s
 * @return {number}
 */
var findContentChildren = function (g, s) {
  g = g.sort((a, b) => a - b);
  s = s.sort((a, b) => a - b);
  let gLen = g.length,
    sLen = s.length,
    i = 0,
    j = 0,
    maxNum = 0;
  while (i < gLen && j < sLen) {
    if (s[j] >= g[i]) {
      i++;
      maxNum++;
    }
    j++;
  }
  return maxNum;
};

买卖股票的最佳时机 II 🚁

题目难度easy,涉及到的算法知识有动态规划、贪心算法。

题目描述

给定一个数组,它的第  i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 

示例 2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5  (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例  3:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0

提示:

  • 1 <= prices.length <= 3 * 10 ^ 4
  • 0 <= prices[i] <= 10 ^ 4

思路分析

其实这道题目思路也比较简单:

  • 维护一个变量profit用来存储利润
  • 因为可以多次买卖,那么就要后面的价格比前面的大,那么就可以进行买卖
  • 因此,只要prices[i+1] > prices[i],那么就去叠加profit
  • 遍历完成得到的profit就是获取的最大利润

代码实现

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function (prices) {
  let profit = 0;
  for (let i = 0; i < prices.length - 1; i++) {
    if (prices[i + 1] > prices[i]) profit += prices[i + 1] - prices[i];
  }
  return profit;
};

不同路径 🛣

题目难度medium,涉及到的算法知识有动态规划。

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

例如,上图是一个 7 x 3 的网格。有多少可能的路径?

示例  1:

输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例  2:

输入: (m = 7), (n = 3);
输出: 28;

思路分析

由题可知:机器人只能向右或向下移动一步,那么从左上角到右下角的走法 = 从右边开始走的路径总数+从下边开始走的路径总数。

所以可推出动态方程为:dp[i][j] = dp[i-1][j]+dp[i][j-1]

代码实现

这里采用Array(m).fill(Array(n).fill(1))进行了初始化,因为每一格至少有一种走法。

/**
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var uniquePaths = function (m, n) {
  let dp = Array(m).fill(Array(n).fill(1));
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }
  return dp[m - 1][n - 1];
};

零钱兑换 💰

题目难度medium,涉及到的算法知识有动态规划。

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回  -1。

示例  1:

输入: (coins = [1, 2, 5]), (amount = 11);
输出: 3;
解释: 11 = 5 + 5 + 1;

示例 2:

输入: (coins = [2]), (amount = 3);
输出: -1;

说明:
你可以认为每种硬币的数量是无限的。

思路分析

这道题目我们同样采用动态规划来解决。

假设给出的不同面额的硬币是[1, 2, 5],目标是 60,问最少需要的硬币个数?

我们需要先分解子问题,分层级找最优子结构。

dp[i]: 表示总金额为 i 的时候最优解法的硬币数

我们想一下:求总金额 60 有几种方法?一共有 3 种方式,因为我们有 3 种不同面值的硬币。

  • 拿一枚面值为 1 的硬币 + 总金额为 59 的最优解法的硬币数量。即:dp[59] + 1
  • 拿一枚面值为 2 的硬币 + 总金额为 58 的最优解法的硬币数。即:dp[58] + 1
  • 拿一枚面值为 5 的硬币 + 总金额为 55 的最优解法的硬币数。即:dp[55] + 1

所以,总金额为 60 的最优解法就是上面这三种解法中最优的一种,也就是硬币数最少的一种,我们下面用代码来表示一下:

dp[60] = Math.min(dp[59] + 1, dp[58] + 1, dp[55] + 1);

推导出状态转移方程

dp[i] = Math.min(dp[i - coin] + 1, dp[i - coin] + 1, ...)

其中 coin 有多少种可能,我们就需要比较多少次,遍历 coins 数组,分别去对比即可

代码实现

/**
 * @param {number[]} coins
 * @param {number} amount
 * @return {number}
 */
var coinChange = function (coins, amount) {
  let dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0;
  for (let i = 0; i <= amount; i++) {
    for (let coin of coins) {
      if (i - coin >= 0) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
};

福利

大多数前端同学对于算法的系统学习,其实是比较茫然的,这里我整理了一张思维导图,算是比较全面的概括了前端算法体系。

另外我还维护了一个github仓库:https://github.com/Cosen95/js_algorithm,里面包含了大量的leetcode题解,并且还在不断更新中,感觉不错的给个star哈!🤗

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

Vue源码探秘(依赖收集)

引言

在上一小节,我们学习了initPropsinitData会通过defineReactive把普通对象转换为响应式对象,其核心是js原生的Object.defineProperty函数。

同时,我们在最后提到了gettersetter的作用分别是依赖收集派发更新,这一节我们先来一起看下getter的执行流程。

准备工作

来看getter的具体流程前,我先来唠叨几句(有利于理解下文的展开分析)。

我们知道,Vue采用了发布订阅的设计模式来实现响应式数据。

了解观察者模式发布订阅号模式,对于解读依赖收集和派发更新将有很大的帮助。

先来看一个Vue响应式的例子:

const app = new Vue({
  data() {
    return {
      info: '淡黄的长裙 蓬松的头发'
    }
  }
});
app.$watch('info', () => {
  console.log('info 被修改了!');
});

$watch函数可以监听到data中数据的变化,如果让我们自己来实现$watch函数,你会怎么做呢?

我们可以给data中的数据设置gettersetter,这样setter就能监听到数据的变化了。

结合发布订阅模式的设计**,这里$watch的第二个参数fn其实就是一个观察者,我们需要保存观察者(也就是收集依赖),并在setter中遍历执行所有观察者。

另外,我们可以定义一个 Dep 来管理依赖(相当于发布订阅模式中的事件通道)。为方便起见,这里 Dep 我只定义了一个 add 方法,代码如下:

const data = { // 模拟 data
  info: '淡黄的长裙 蓬松的头发'
};

function Dep() { // 使用 Dep 来保存依赖
  this.subs = [];
}
Dep.prototype.addSub = function(sub) {
  this.subs.push(sub);
};

Object.defineProperty(data, 'info', {
  get() {
    dep.addSub(sub);
  },
  set() {
    const subs = dep.subs;
    for (let i = 0; i < subs.length; i++) {
      subs[i]();
    }
  }
});

现在有一个问题,getter 收集依赖和 $watch 函数要怎么联系起来呢?

$watch 函数可以拿到属性名而 getter 需要访问来触发,因此我们可以在 $watch 函数中手动访问属性来触发 getter ,另外我们需要一个全局变量 target 来保存当前要被收集的观察者,这样就把getter$watch连接起来了 。代码如下:

Dep.target = null;
Object.defineProperty(data, 'info', {
  get() {
      dep.addSub(Dep.target);
    },
    set() {
      const subs = dep.subs;
      for (let i = 0; i < subs.length; i++) {
        subs[i]();
      }
    }
});
function $watch(key, cb) {
  Dep.target = cb;
  data[key]; // 触发 getter 来收集依赖
}

看下测试的结果:

let dep = new Dep();
$watch('info', () => {
  console.log('info 被修改了!');
});
data.info = '牵着我的手看最新展出的油画'; // 'info 被修改了!'
data.info = '无人的街道 在空荡的家里'; // 'info 被修改了!'
data.info = '就当是一场梦 我还是很感动'; // 'info 被修改了!'

这样我们就使用发布订阅模式结合 getter/setter 实现了一个最简单的响应式数据。当然这个例子只是作为解读源码的铺垫,当中还有很多地方需要完善。

getter

我们先来回顾下 defineReactive 函数以及 getter 函数的大致逻辑:

// src/core/observer/index.js
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
     // ...
  })
}

可以看到每次调用 defineReactive 函数都会先创建一个 Dep 实例,也就是说每一个 data 或者 props 都有自己的 Dep 实例来收集依赖。

getter 中一开始会检查之前是否已经定义了 getter ,如果有就要先调用原来的 getter 拿到返回值并最终返回出去,保证原有的读取操作正常执行,返回正确的属性值。

依赖收集的核心就在接下来的if语句中,这块与Dep有关,我们来看下Dep的定义。

Dep

Dep定义在src/core/observer/dep.js中:

// src/core/observer/dep.js
let uid = 0

/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // stabilize the subscriber list first
    // ...
  }
}

结合我们在准备工作中自己实现的例子。分析下Dep的属性:

  • targetDep的全局静态属性,用来表示当前的目标观察者Watcher
  • id作为每个Dep实例的id,通过全局变量uid自增来赋值
  • subs是一个数组,用于保存Watcher

其中的几个方法,在下面被调用时具体分析。

执行过程

为了更清楚的讲解 getter 的工作原理,我们按照代码的执行顺序来分析。我们从组件实例化开始分析,回顾 Vue源码探秘(实例挂载 $mount) ,我们在分析 mountComponent 函数的时候,有这么几行代码:

// src/core/instance/lifecycle.js
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

这里创建了一个 Watcher 实例,传入的第二个参数是 updateComponent 函数,这个函数之前也介绍过了:

// src/core/instance/lifecycle.js
updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

我们来看 Watcher 类的定义:

// src/core/observer/watcher.js
let uid = 0
/**
 * A watcher parses an expression, collects dependencies,
 * and fires callback when the expression value changes.
 * This is used for both the $watch() api and directives.
 */
export default class Watcher {
  // ...
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = noop
        process.env.NODE_ENV !== 'production' && warn(
          `Failed watching path: "${expOrFn}" ` +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
          vm
        )
      }
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }

  get () {...}
  addDep (dep: Dep) {...}
  cleanupDeps () {...}
  // ...
}

注释的大概意思是:一个Watcher会解析表达式,收集依赖关系,并且在表达式值改变时触发回调。

在构造函数constructor中可以看到,传入的 updateComponent 会作为 Watcher 实例的 getter 属性,并且在最后调用了 this.get() 方法:

/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get 函数一开始会调用 pushTarget 函数,pushTarget 函数定义在 src/core/observer/dep.js 文件中:

// src/core/observer/dep.js
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

这里把传入的Watcherpush 到targetStack数组中,同时存入Dep.target

这里的targetStack数组是用来做什么的呢?我们先卖个关子。

回到get函数,下面是调用了我们传入的updateComponent函数:

value = this.getter.call(vm, vm)  // 上文有说明,这里的`getter`对应的就是我们传入的`updateComponent`函数

我们知道在执行 updateComponent 函数的时候会执行 _render 函数,_render 函数作用是创建 VNode ,这个过程会访问组件实例中的 data ,这样就会触发 defineReactive 函数中的 getter 。回顾 getter

// src/core/observer/index.js
get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
},

此时 Dep.target 已经保存了当前的渲染Watcher,所以会走if逻辑,然后执行dep.depend()

这里还有一段 childOb 的判断逻辑,会放在后面介绍。
dep.depend()走的是下面这小段代码:

// src/core/observer/dep.js
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

也就是调用 Watcher.addDep 方法:

// src/core/observer/watcher.js
/**
 * Add a dependency to this directive.
 */
addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

这里会把iddep实例分别保存到newDepIdsnewDeps中。后面又调用 dep.addSub 收集依赖。这里两个if的作用都是避免重复收集。

getter 执行完后,回到 get 函数的 finally 语句:

// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
  traverse(value)
}
popTarget()
this.cleanupDeps()

这里的 deep 表示要递归去访问 value,触发它所有子项的 getter,这个会放在之后详细讲解。接下来执行 popTarget 函数:

// src/core/observer/dep.js
export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

经过前面的分析,如果组件嵌套组件,会先完成子组件实例化,当子组件实例的数据依赖收集完成,需要回到父级实例的执行栈。targetStack 数组的作用就体现在这里,Dep.target 需要借助 targetStack 数组恢复到上一次的状态。最后会调用 cleanupDeps 函数:

// src/core/observer/watcher.js
/**
 * Clean up for dependency collection.
 */
cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}

第一次执行 cleanupDeps 函数时,deps 显然是空的,不会执行循环。

之后的操作实际上就是将 newDepIds / newDeps 的内容保存到 depIds / deps ,然后将 newDepIdsnewDeps 清空。之后每次执行 cleanupDeps 函数时,前面会先有移除依赖的操作,再执行后面的操作。

cleanupDeps 函数的存在意义是什么呢,这里举一个例子来帮助大家理解:

// App.vue

<template>
  <div id="app">
    <span v-if="isShow">{{ info1 }}</span>
    <span v-else>{{ info2 }}</span>
    <button @click="toggleShow">toggleShow</button>
    <button @click="changeInfo1">changeInfo1</button>
    <button @click="changeInfo2">changeInfo2</button>
  </div>
</template>
<script>
  export default {
    name: 'App',
    data() {
      return {
        info1: '奥利给',
        info2: '樱木花道',
        isShow: true
      }
    },
    methods: {
      toggleShow() {
        this.isShow = !this.isShow;
      },
      changeInfo1() {
        this.info1 += '!';
      },
      changeInfo2() {
        this.info2 += '!';
      }
    }
  }
</script>

在这个例子中,我们每次点击 toggleShow 按钮时都会触发 render 函数重新渲染页面,但是点击 changeInfo1changeInfo2 按钮则不一定。

实际上正是 cleanupDeps 函数避免了不必要的 render 函数的调用。

在创建 Watcher 实例时会初始化 newDepsdepsnewDeps 通过 addSub 函数保存新添加的 Dep 实例,而 deps 通过 cleanupDeps 函数保存上一次添加的 Dep 实例。

cleanupDeps 函数的一开始,它会用 depsnewDeps 作比较,比较出当前 Watcher 两次订阅的区别,找出在上一次订阅而这一次没有订阅的 Dep 实例 dep

比如上述例子中切换 isShow = false 时会找出 info1。然后调用 dep.removeSubsubs 删除掉当前 Watcher ,也就是把 info1 订阅者删掉。这样 info1 改变时没有订阅者可以通知,也就不会再触发 render 重新渲染。

总结

通过这一节的分析,我们对 Vue 数据的依赖收集过程已经有了初步的认识,并且对这其中的一些细节也做了分析。

所谓依赖收集其实就是收集那些订阅了数据变化的 Watcher依赖收集的目的是为了当数据发生变化触发 setter 的时候,会遍历所有订阅者,通知它去做相应处理。这个过程称之为 派发更新 ,也是我们下一节要学习分析的内容。

reactScheduler

自从react 16出来以后,react fiber相关的文章层出不穷,但大多都是讲解fiber的数据结构,以及组件树的diff是如何由递归改为循环遍历的。对于time slicing的描述一般都说利用了requestIdleCallback这个api来做调度,但对于任务如何调度却很难找到详细的描述。

因此,本篇文章就是来干这个事情的,从源码角度来一步步阐述React Scheduler是怎么实现任务调度的。

scheduler

React16.5 之后把scheduler单独发一个包了,就叫scheduler,对应源码在packages/scheduler/src/Scheduler.js

scheduleCallbackWithExpirationTime

在上一节requestWork的最后:

// TODO: Get rid of Sync and use current time?
if (expirationTime === Sync) {
  // 同步的调用 js 代码
  performSyncWork();
} else {
  // 异步调度 独立的 react 模块包,利用浏览器有空闲的时候进行执行,设置 deadline 在此之前执行
  scheduleCallbackWithExpirationTime(root, expirationTime);
}

异步调度调用的是scheduleCallbackWithExpirationTime方法:

function scheduleCallbackWithExpirationTime(
  root: FiberRoot,
  expirationTime: ExpirationTime
) {
  if (callbackExpirationTime !== NoWork) {
    // A callback is already scheduled. Check its expiration time (timeout).
    if (expirationTime < callbackExpirationTime) {
      // Existing callback has sufficient timeout. Exit.
      return;
    } else {
      if (callbackID !== null) {
        // Existing callback has insufficient timeout. Cancel and schedule a
        // new one.
        cancelDeferredCallback(callbackID);
      }
    }
    // The request callback timer is already running. Don't start a new one.
  } else {
    startRequestCallbackTimer();
  }

  callbackExpirationTime = expirationTime;
  const currentMs = now() - originalStartTimeMs;
  const expirationTimeMs = expirationTimeToMs(expirationTime);
  const timeout = expirationTimeMs - currentMs;
  callbackID = scheduleDeferredCallback(performAsyncWork, { timeout });
}

这里最主要的就是调用了schedulerscheduleDeferredCallback方法(在scheduler包中是scheduleWork

传入的的是回调函数performAsyncWork,以及一个包含timeout超时事件的对象。

scheduleDeferredCallback定义在packages/react-dom/src/client/ReactDOMHostConfig.js

export {
  unstable_now as now,
  unstable_scheduleCallback as scheduleDeferredCallback,
  unstable_shouldYield as shouldYield,
  unstable_cancelCallback as cancelDeferredCallback,
} from "scheduler";

这里用的是scheduler包中的unstable_scheduleCallback方法。

分析这个方法前,先来看下几个定义:

任务优先级

react内对任务定义的优先级分为 5 种,数字越小优先级越高:

// 最高优先级
var ImmediatePriority = 1;
// 用户阻塞型优先级
var UserBlockingPriority = 2;
// 普通优先级
var NormalPriority = 3;
// 低优先级
var LowPriority = 4;
// 空闲优先级
var IdlePriority = 5;

这 5 种优先级依次对应 5 个过期时间:

// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out

var USER_BLOCKING_PRIORITY = 250;

var NORMAL_PRIORITY_TIMEOUT = 5000;

var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out

var IDLE_PRIORITY = maxSigned31BitInt;

每个任务在添加到链表里的时候,都会通过performance.now() + timeout来得出这个任务的过期时间,随着时间的推移,当前时间会越来越接近这个过期时间,所以过期时间越小的代表优先级越高。如果过期时间已经比当前时间小了,说明这个任务已经过期了还没执行,需要立马去执行。

上面的maxSigned31BitInt,通过注释可以知道这是 32 位系统 V8 引擎里最大的整数。react用它来做IdlePriority的过期时间。
据粗略计算这个时间大概是 12.427 天。也就是说极端情况下你的网页tab如果能一直开着到 12 天半,任务才有可能过期。

getCurrentTime

获取当前时间,如果不支持performance,利用localDate.now()fallback

var getCurrentTime;

if (hasNativePerformanceNow) {
  var Performance = performance;
  getCurrentTime = function () {
    return Performance.now();
  };
} else {
  getCurrentTime = function () {
    return localDate.now();
  };
}

unstable_scheduleCallback

回到上面的unstable_scheduleCallback方法:

function unstable_scheduleCallback(callback, deprecated_options) {
  var startTime =
    currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();

  var expirationTime;
  if (
    typeof deprecated_options === "object" &&
    deprecated_options !== null &&
    typeof deprecated_options.timeout === "number"
  ) {
    // FIXME: Remove this branch once we lift expiration times out of React.
    // 如果传了options, 就用入参的过期时间
    expirationTime = startTime + deprecated_options.timeout;
  } else {
    // 判断当前的优先级
    switch (currentPriorityLevel) {
      case ImmediatePriority:
        expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
        break;
      case UserBlockingPriority:
        expirationTime = startTime + USER_BLOCKING_PRIORITY;
        break;
      case IdlePriority:
        expirationTime = startTime + IDLE_PRIORITY;
        break;
      case LowPriority:
        expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
        break;
      case NormalPriority:
      default:
        expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
    }
  }
  // 上面确定了当前任务的截止时间,下面创建一个任务节点
  var newNode = {
    callback, // 任务的具体内容
    priorityLevel: currentPriorityLevel, // 任务优先级
    expirationTime, // 任务的过期时间
    next: null, // 下一个节点
    previous: null, // 上一个节点
  };

  // Insert the new callback into the list, ordered first by expiration, then
  // by insertion. So the new callback is inserted any other callback with
  // equal expiration.
  // 下面是按照 expirationTime 把 newNode 加入到任务队列里
  if (firstCallbackNode === null) {
    // This is the first callback in the list.
    firstCallbackNode = newNode.next = newNode.previous = newNode;
    ensureHostCallbackIsScheduled();
  } else {
    var next = null;
    var node = firstCallbackNode;
    do {
      if (node.expirationTime > expirationTime) {
        // The new callback expires before this one.
        next = node;
        break;
      }
      node = node.next;
    } while (node !== firstCallbackNode);

    if (next === null) {
      // No callback with a later expiration was found, which means the new
      // callback has the latest expiration in the list.
      next = firstCallbackNode;
    } else if (next === firstCallbackNode) {
      // The new callback has the earliest expiration in the entire list.
      firstCallbackNode = newNode;
      ensureHostCallbackIsScheduled();
    }

    var previous = next.previous;
    previous.next = next.previous = newNode;
    newNode.next = next;
    newNode.previous = previous;
  }

  return newNode;
}

这个方法的作用就是把任务以过期时间作为优先级进行排序,过程类似双向循环链表的操作过程。

这个方法有两个入参,第一个是要执行的callback,暂时可以理解为一个任务。第二个参数是可选的,可以传入一个超时时间来标识这个任务过多久超时。如果不传的话就会根据上述的任务优先级确定过期时间。

同时会生成一个真正的任务节点。接下来就要把这个节点按照expirationTime排序插入到任务的链表里边去。

到这里一个新进来的任务如何确定过期时间以及如何插入现有的任务队列就讲完了。

此时不禁产生一个疑问,我们把任务按照过期时间排好顺序了,那么何时去执行任务呢?

答案是有两种情况:
1、当添加第一个任务节点的时候开始启动任务执行
2、当新添加的任务取代之前的节点成为新的第一个节点的时候

因为 1 意味着任务从无到有,应该立刻启动,2 意味着来了新的优先级最高的任务,应该停止掉之前要执行的任务,重新从新的任务开始执行。

上面两种情况就对应ensureHostCallbackIsScheduled方法执行的两个分支。所以我们现在应该知道,ensureHostCallbackIsScheduled是用来在合适的时机去启动任务执行的。

到底什么是合适的时机?可以这么描述,在每一帧绘制完成之后的空闲时间。这样就能保证浏览器绘制每一帧的频率能跟上系统的刷新频率,不会掉帧。

ensureHostCallbackIsScheduled

然后来看ensureHostCallbackIsScheduled这个函数,这个也很简单,首先判断是否任务已经开始循环安排了,如果是,则退出,如果没有,则重置条件,重新开始去请求循环安排任务。

function ensureHostCallbackIsScheduled() {
  // 有一个callback正在进行
  if (isExecutingCallback) {
    // Don't schedule work yet; wait until the next time we yield.
    return;
  }
  // firstCallbackNode的过期时间是最早的
  // Schedule the host callback using the earliest expiration in the list.
  var expirationTime = firstCallbackNode.expirationTime;
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true;
  } else {
    // Cancel the existing host callback.
    // 取消其它存在的host callback
    cancelHostCallback();
  }
  // 开始安排任务队列
  requestHostCallback(flushWork, expirationTime);
}

可以看到这里最后走到了requestHostCallback(flushWork, expirationTime); 这里的flushworkschedule的一个刷新任务队列函数,等会再看。先看下requestHostCallback

requestHostCallback

这里requestHostCallback根据传入的callback和过期时间确定下一步执行那些操作,如果当前正在执行任务,或者是过期时间小于 0,则通过port.postMessage发送信息,来立即执行任务更新。

这里的port.postMessage是:

var channel = new MessageChannel();
var port = channel.port2;

这里可以理解为一个通道,就是当在scheduler中如果想要立即执行任务链表的更新,就可以通过port.postMessage来发送一个信息,通过channel.port1.onmessage来接收信息,并且立即开始执行任务链表的更新,类似一个发布订阅,当想更新链表的时候,只需要发送个信息就可以了。

scheduler里边就是通过MessageChannel来完成通知和执行任务链表更新操作的。

requestHostCallback 里边如果没有到到期时间且还还没有开始通过isAnimationFrameScheduled来订阅浏览器的空闲时间,则通过requestAnimationFrameWithTimeout(animationTick)去订阅。

requestHostCallback = function (callback, absoluteTimeout) {
  // callback就是flushWork
  // absoluteTimeout是传入的过期时间
  scheduledHostCallback = callback;
  // timeoutTime就是callback链表的头部的expirationTime
  timeoutTime = absoluteTimeout;
  // isFlushingHostCallback这个判断是一个Eagerly操作,如果有新的任务进来,
  // 尽量让其直接执行,防止浏览器在下一帧才执行这个callback
  // 这个判断其实不是很好理解,建议熟悉模块之后再回来看,并不影响scheduler核心逻辑
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    // absoluteTimeout < 0说明任务超时了,立刻执行,不要等下一帧
    // Don't wait for the next frame. Continue working ASAP, in a new event.
    port.postMessage(undefined);
  } else if (!isAnimationFrameScheduled) {
    // If rAF didn't already schedule one, we need to schedule a frame.
    // TODO: If this rAF doesn't materialize because the browser throttles, we
    // might want to still have setTimeout trigger rIC as a backup to ensure
    // that we keep performing work.
    isAnimationFrameScheduled = true;
    requestAnimationFrameWithTimeout(animationTick);
  }
};

requestAnimationFrameWithTimeout

这里主要是使用requestAnimationFrame,但是会有requestAnimationFrame不起作用的情况下,使用setTimeout

var requestAnimationFrameWithTimeout = function (callback) {
  // callback就是animationTick方法
  // schedule rAF and also a setTimeout
  // localRequestAnimationFrame相当于window.requestAnimationFrame
  // 1. 调用requestAnimationFrame
  rAFID = localRequestAnimationFrame(function (timestamp) {
    // cancel the setTimeout
    localClearTimeout(rAFTimeoutID);
    callback(timestamp);
  });
  // 2. 调用setTimeout,时间为ANIMATION_FRAME_TIMEOUT(100),超时则取消rAF,改为直接调用
  rAFTimeoutID = localSetTimeout(function () {
    // cancel the requestAnimationFrame
    localCancelAnimationFrame(rAFID);
    callback(getCurrentTime());
  }, ANIMATION_FRAME_TIMEOUT);
};

代码也很简单,这里传入的callbackanimationTick,去看下animationTick的代码。

animationTick

var animationTick = function (rafTime) {
  // scheduledHostCallback也就是callback
  if (scheduledHostCallback !== null) {
    // Eagerly schedule the next animation callback at the beginning of the
    // frame. If the scheduler queue is not empty at the end of the frame, it
    // will continue flushing inside that callback. If the queue *is* empty,
    // then it will exit immediately. Posting the callback at the start of the
    // frame ensures it's fired within the earliest possible frame. If we
    // waited until the end of the frame to post the callback, we risk the
    // browser skipping a frame and not firing the callback until the frame
    // after that.
    // 这里是连续递归调用,直到scheduledHostCallback === null
    // scheduledHostCallback会在messageChannel的port1的回调中设为null
    // 因为requestAnimationFrameWithTimeout会加入event loop,所以这里不是普通递归,而是每一帧执行一次
    // 注意当下一帧执行了animationTick时,之前的animationTick已经计算出了nextFrameTime

    requestAnimationFrameWithTimeout(animationTick);
  } else {
    // No pending work. Exit.
    isAnimationFrameScheduled = false;
    return;
  }
  // 保持浏览器能保持每秒30帧,那么每帧就是33毫秒
  // activeFrameTime在模块顶部定义,初始值为33
  // previousFrameTime的初始值也是33
  // nextFrameTime就是此方法到下一帧之前可以执行多少时间
  // 如果第一次执行,nextFrameTime肯定是很大的,因为frameDeadline为0
  // rafTime是当前时间戳

  var nextFrameTime = rafTime - frameDeadline + activeFrameTime;
  if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
    if (nextFrameTime < 8) {
      // Defensive coding. We don't support higher frame rates than 120hz.
      // If the calculated frame time gets lower than 8, it is probably a bug.
      nextFrameTime = 8;
    }
    // If one frame goes long, then the next one can be short to catch up.
    // If two frames are short in a row, then that's an indication that we
    // actually have a higher frame rate than what we're currently optimizing.
    // We adjust our heuristic dynamically accordingly. For example, if we're
    // running on 120hz display or 90hz VR display.
    // Take the max of the two in case one of them was an anomaly due to
    // missed frame deadlines.

    // 这里试探性的设置了activeFrame,因为在某些平台下,每秒的帧数可能更大,例如vr游戏这种情况
    // 设置activeFrameTime为previousFrameTime和nextFrameTime中的较大者
    activeFrameTime =
      nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
  } else {
    previousFrameTime = nextFrameTime;
  }
  frameDeadline = rafTime + activeFrameTime;
  // isMessageEventScheduled的值也是在port1的回调中设置为false
  // isMessageEventScheduled的意义就是每一帧的animationTick是否被执行完
  // animationTick -> port.postMessage(设置isMessageEventScheduled为false) -> animationTick
  // 防止port.postMessage被重复调用(应该是在requestAnimationFrameWithTimeout超时的时候会出现的情况
  // 因为postMessage也是依赖event loop,可能会有竞争关系

  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true;
    // port就是port1
    // postMessage是event loop下一个tick使用,所以就是frameDeadline中,其实留了空闲时间给浏览器执行动画渲染
    // 举个例子: 假设当前浏览器为30帧,则每帧33ms,frameDeadline为currentTime + 33,当调用了port.postMessage,当前tick的js线程就变为空了
    // 这时候就会留给浏览器部分时间做动画渲染,所以实现了requestIdleCallback的功能
    // port.postMessage是留给空出js线程的关键
    port.postMessage(undefined);
  }
};

中间部分nextFrameTime的判断是React检查帧数的计算,我们先忽略,关注整体。

animationTick一开始直接判断scheduledHostCallback是否为null,否则就继续通过requestAnimationFrameWithTimeout调用animationTick自身,这是一个逐帧执行的递归。意思就是这个递归在浏览器渲染下一帧的时候,才会再次调用animationTick

也就是在animationTick调用requestAnimationFrameWithTimeout(animationTick)之后,后面的代码依然有时间可以执行。因为递归会在下一帧由浏览器调用。而在animationTick最后的代码调用了port.postMessage,这是一个浏览器提供的APIMessageChannel,主要用于注册的两端port之间相互通讯,有兴趣的读者可以自己查查。

MessageChannel的通讯每次调用都是异步的,类似于EventListener``,也就是,当调用port.postMessage时告诉浏览器当前EventLoop的任务执行完了,浏览器可以检查一下现在有没有别的任务进来(例如动画或者用户操作),然后插入到下一个EventLoop中。(当然在EventLoop的任务队列中,animationTick剩余的代码优先级会比动画及用户操作更高,因为排序排在前面。但是其实后面的代码也会有根据帧时间是否足够,执行让出线程的操作)

递归的流程如下图:

接下来判断了isMessageEventScheduled的布尔值,这是防止port.postMessage被重复调用。

channel.port1.onmessage(idleTick)

animationTick中调用port.postMessage(undefined)之后,我们实际上进入了channel.port1的回调函数:

// We use the postMessage trick to defer idle work until after the repaint.
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = function (event) {
  // 设置为false,防止animationTick的竞争关系
  isMessageEventScheduled = false;

  var prevScheduledCallback = scheduledHostCallback;
  var prevTimeoutTime = timeoutTime;
  scheduledHostCallback = null;
  timeoutTime = -1;

  var currentTime = getCurrentTime();

  var didTimeout = false;
  // 说明超过了activeFrameTime的时间(默认值33
  // 说明这一帧没有空闲时间,然后检查任务是否过期,过期的话就设置didTimeout,用于后面强制执行
  if (frameDeadline - currentTime <= 0) {
    // 查看任务是否过期,过期则强行更新
    // There's no time left in this idle period. Check if the callback has
    // a timeout and whether it's been exceeded.
    if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
      // Exceeded the timeout. Invoke the callback even though there's no
      // time left.
      // 这种过期的情况有可能已经掉帧了
      didTimeout = true;
    } else {
      // 没有超时则等待下一帧再执行
      // No timeout.
      // isAnimationFrameScheduled这个变量就是判断是否在逐帧执行animationTick
      // 开始设置animationTick时设置为true,animationTick结束时设置为false
      if (!isAnimationFrameScheduled) {
        // Schedule another animation callback so we retry later.
        isAnimationFrameScheduled = true;
        requestAnimationFrameWithTimeout(animationTick);
      }
      // Exit without invoking the callback.
      // 因为上一个任务没有执行完,设置回原来的值,等animationTick继续处理scheduledHostCallback
      scheduledHostCallback = prevScheduledCallback;
      timeoutTime = prevTimeoutTime;
      return;
    }
  }

  if (prevScheduledCallback !== null) {
    isFlushingHostCallback = true;
    try {
      prevScheduledCallback(didTimeout);
    } finally {
      isFlushingHostCallback = false;
    }
  }
};

此处代码用了React中常用的命名方式prevXXXX,一般是在某个流程之中,先保留之前的值,在执行完某个操作之后,再还原某个值,提供给别的代码告诉自己正在处理的阶段。例如:

var prevScheduledCallback = scheduledHostCallback;
scheduledHostCallback = null;
// ...
// ...
// 还原
scheduledHostCallback = prevScheduledCallback;

整个回调函数其实比较简单,只有几个分支:

flushWork

下面就是flushWork了:

function flushWork(didTimeout) {
  // Exit right away if we're currently paused
  // didTimeout是指任务是否超时
  if (enableSchedulerDebugging && isSchedulerPaused) {
    return;
  }

  isExecutingCallback = true;
  const previousDidTimeout = currentDidTimeout;
  currentDidTimeout = didTimeout;
  try {
    if (didTimeout) {
      // Flush all the expired callbacks without yielding.
      while (
        firstCallbackNode !== null &&
        !(enableSchedulerDebugging && isSchedulerPaused)
      ) {
        // TODO Wrap i nfeature flag
        // Read the current time. Flush all the callbacks that expire at or
        // earlier than that time. Then read the current time again and repeat.
        // This optimizes for as few performance.now calls as possible.
        var currentTime = getCurrentTime();
        if (firstCallbackNode.expirationTime <= currentTime) {
          // 这个循环的意思是,遍历callbackNode链表,直到第一个没有过期的callback
          // 所以主要意义就是将所有过期的callback立刻执行完
          do {
            // 这个函数有将callbackNode剥离链表并执行的功能, firstCallbackNode在调用之后会修改成为新值
            // 这里遍历直到第一个没有过期的callback
            flushFirstCallback();
          } while (
            firstCallbackNode !== null &&
            firstCallbackNode.expirationTime <= currentTime &&
            !(enableSchedulerDebugging && isSchedulerPaused)
          );
          continue;
        }
        break;
      }
    } else {
      // Keep flushing callbacks until we run out of time in the frame.
      if (firstCallbackNode !== null) {
        do {
          if (enableSchedulerDebugging && isSchedulerPaused) {
            break;
          }
          flushFirstCallback();
          // shouldYieldToHost就是比较frameDeadline和currentTime,就是当前帧还有时间的话,就一直执行
        } while (firstCallbackNode !== null && !shouldYieldToHost());
      }
    }
  } finally {
    isExecutingCallback = false;
    currentDidTimeout = previousDidTimeout;
    if (firstCallbackNode !== null) {
      // There's still work remaining. Request another callback.
      // callback链表还没全部执行完,继续
      // ensureHostCallbackIsScheduled也是会启动下一帧,所以不是连续调用
      // 同时,isHostCallbackScheduled决定了ensureHostCallbackIsScheduled的行为,
      // 在此分支中isHostCallbackScheduled === true, 所以ensureHostCallbackIsScheduled会执行一个cancelHostCallback函数
      // cancelHostCallback设置scheduledHostCallback为null,可以令上一个animationTick停止
      ensureHostCallbackIsScheduled();
    } else {
      // isHostCallbackScheduled这个变量只会在ensureHostCallbackIsScheduled中被设置为true
      // 这个变量的意义可能是代表,是否所有任务都被flush了?,因为只有firstCallbackNode === null的情况下才会设为false

      isHostCallbackScheduled = false;
    }
    // Before exiting, flush all the immediate work that was scheduled.
    flushImmediateWork();
  }
}

Vue源码探秘(_update)

引言

_update函数是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新时。这一节我们主要是看下首次渲染这种情况,数据更新的情况会在后面介绍响应式原理部分着重分析。

_update

_update 方法的作用是把 VNode 渲染成真实的 DOM,它的定义在 src/core/instance/lifecycle.js文件的lifecycleMixin函数中:

// src/core/instance/lifecycle.js
 Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

_update 的核心就是调用 vm.__patch__ 方法。__patch__ 函数在不同平台会有不同的定义,web 端的定义在 src/platforms/web/runtime/index.js 文件中:

// src/platforms/web/runtime/index.js

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

inBrowser 表示是否处于浏览器端,其实就是区分浏览器端渲染服务端渲染。因为在服务端没有真实的 DOM,它不需要把 VNode 转换成真实的 DOM,因此直接返回 noop(空函数)。而在浏览器端则使用 patch 函数,它被定义在 src/platforms/web/runtime/patch.js 文件中:

// src/platforms/web/runtime/patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })

patch 函数是调用 createPatchFunction 函数的返回值,这里传给 createPatchFunction 函数一个对象,对象的 nodeOps 属性定义在 src/platforms/web/runtime/node-ops.js 中:

// src/platforms/web/runtime/node-ops.js


import { namespaceMap } from 'web/util/index'

export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}

export function createElementNS (namespace: string, tagName: string): Element {
  return document.createElementNS(namespaceMap[namespace], tagName)
}

export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}

// ...

可以看到,这里面封装了各种各样的 DOM 操作。我们再来看另一个参数 modulesmodules 是这样定义的:

// src/platforms/web/runtime/patch.js

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

modules 是由 platformModulesbaseModules 合并而来,里面定义了一些模块的钩子函数的实现,我们先来看一下定义 platformModules 的目录结构(在src/platforms/web/runtime/modules):

|-- modules
    |-- attrs.js
    |-- class.js
    |-- dom-props.js
    |-- events.js
    |-- index.js
    |-- style.js
    |-- transition.js

这里面定义了各种钩子函数,用于在虚拟 DOM 转换为真实 DOM 之后给真实 DOM 添加 attrclassstyleDOM 属性,我们这里先不详细介绍,来看一下 createPatchFunction 的实现。函数定义在 src/core/vdom/patch.js 文件中:

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
  let i, j
  const cbs = {}

  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }

  // ...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

这里定义了 hooks ,也就是生命周期函数,在 patch 的不同时期会调用不同的钩子函数。

createPatchFunction 函数一开始做了遍历,将 hooks 作为 cbs 属性,然后将对应的 modules 的子项 pushcbs.hooks 中。

中间定义了一些辅助函数,并在最后 return 一个 patch 函数,这个函数就是vm._update 函数里调用的 vm.__patch__

在介绍 patch 的方法实现之前,我们可以思考一下为何 Vue.js 源码绕了这么一大圈,把相关代码分散到各个目录呢?这里其实涉及到了函数柯里化的概念。

我们知道函数柯里化最大的作用是提高函数的复用性。在 Vue.js 中,不同平台(webweex)将虚拟 DOM 转换成真实 DOM 的代码实现是不一样的,给真实 DOM 添加各种属性的方法也是不同的。

具体到 createPatchFunction 函数,这里传给它的两个参数 nodeOpsmodules 在不同平台有不同的实现方法(所以定义在 src/platforms 下)。比如 modules 其中一部分是 platformModules ,这个显然就是不同平台有各自的实现。而除了参数的不同,patch 的大部分逻辑是相同的(所以定义在 src/core 下)。

通过 createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOpsmodules 了,这种编程技巧是非常值得学习的。

回到 patch 方法本身,我们来逐段分析:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

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

  // ...
}

这里 patch 函数接收了四个参数:oldVnodevnodehydratingremoveOnly,回顾 _update 中的代码:

if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

可以看到第一次执行 patch 函数时传递的 oldVnode 参数是 vm.$el ,也就是要被替换的 DOM 节点;之后更新调用 patch 函数时传递的 oldVnode 参数是 prevVnode ,所以是一个虚拟 DOM

回顾Vue 源码探秘(\_render 函数)的例子:

<script>
render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
},
data() {
  return {
    message: '森林小哥哥'
  }
}
</script>

我们围绕这个例子来分析 patch 函数。

第一个 if 语句判断 vnode 是否存在,对于我们这个例子而言,显然是存在的。来到第二个 if else 语句,显然会走 else 语句。我们分段来分析 else 语句的代码:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...

  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      if (isRealElement) {
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          } else if (process.env.NODE_ENV !== 'production') {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
              'server-rendered content. This is likely caused by incorrect ' +
              'HTML markup, for example nesting block-level elements inside ' +
              '<p>, or missing <tbody>. Bailing hydration and performing ' +
              'full client-side render.'
            )
          }
        }
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode)
      }

      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)
      // ...
    }
  }
  // ...
}

第一个 if 意思是如果 oldVnode 是一个 VNode(非第一次执行 patch)并且 oldVnodevnode 是同一个 VNode, 则给现有根节点打补丁。显然现在这里不会执行,走 else 逻辑。

进到 if (isRealElement) 逻辑,第一个 if 判断的是服务端渲染,不会执行。下面的 if 由于传入的 hydratingfalse ,因此也不执行。之后调用了 emptyNodeAt 函数,返回值赋给了 oldVnodeemptyNodeAt 函数如下:

// src/core/vdom/patch.js
function emptyNodeAt (elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

其实就是将真实 DOM 转换为 VNode ,另外这里把 oldVnode 作为第六个参数 elm 传进去,因此在下面的语句:

// src/core/vdom/patch.js
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

通过 oldVnode.elm 拿到原来的 oldVnode 赋给 oldElm , 再通过 parentNode 拿到 oldElm 的父节点,对应到我们的例子就是 body 标签。

接着上面的else语句往下看:

// src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...

  if (isUndef(oldVnode)) {
    // ...
  } else {
    // ...
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // ...
    } else {
      // ...

      // create new node
      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)
      )

      // ...
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

这里调用了createElm函数:

// src/core/vdom/patch.js
function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // This vnode was used in a previous render!
    // now it's used as a new node, overwriting its elm would cause
    // potential patch errors down the road when it's used as an insertion
    // reference node. Instead, we clone the node on-demand before creating
    // associated DOM element for it.
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // ...
}

由于代码比较长,这里我们同样分段来分析。

第一个 if 语句显然不会执行,第二个 if 语句调用了 createComponent 函数,这个函数的功能是创建子组件,这个函数会在下一章详细介绍,现在只需要知道这里会返回 false 。接着往下看:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    if (process.env.NODE_ENV !== 'production') {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        warn(
          'Unknown custom element: <' + tag + '> - did you ' +
          'register the component correctly? For recursive components, ' +
          'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)

    // ...
  } // ...
}

这里 if 语句的主要逻辑是在 tag 存在的情况下判断 tag 标签是否合法,如果是未知标签抛出警告。这个警告,在平时的开发中还是可能会经常遇到的:

之后就是创建真实 DOM 节点了,这里的 createElementNScreateElement 前面已经提及了,都是调用原生 js 方法来创建节点。继续往下:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...

  if (isDef(tag)) {
    // ...
    setScope(vnode)

    /* istanbul ignore if */
    if (__WEEX__) {
      // weex相关
    } else {
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
  } // ...
}

前面的if 语句是 weex 相关的,我们略过,直接看 else 语句。else 语句调用了 createChildren 来创建子节点:

// src/core/vdom/patch.js

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

这里判断如果 children 是个数组则遍历数组并递归调用 createElm 把所有虚拟子节点转换为真实 DOM 节点然后插入到父节点 vnode.elm 中;如果是一个文本 VNode 则直接 appendChild 插到 vnode.elm 里面。

回到 createElm 函数,调用完 createChildren 之后又调用了 invokeCreateHooks 函数:

// src/core/vdom/patch.js

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

invokeCreateHooks 函数其实就是遍历 cbs.create 中的所有函数,然后把 vnode push 到 insertedVnodeQueue 中。

下面接着调用了 insert 函数,把 vnode.elm 插入到父节点 parentElm 中:

// src/core/vdom/patch.js

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

可以看到 insert 函数也是调用 nodeOps 中的操作 DOM 的方法来实现的。有参考节点 ref 就调用 insertBefore 插入到参考节点 ref 前,没有就插到父节点 parent 中。

回到 createElm 函数,我们看最后一段代码:

// src/core/vdom/patch.js

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...
  if (isDef(tag)) {
    // ...
  } else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

if 针对的是 tag 的情况,我们已经分析完了。而 else if 针对的是创建注释节点的情况,就直接创建注释节点并插入;else 针对的是文本节点,逻辑也一样。

createElm 函数就分析完了,其实基本流程就是先创建当前节点 vnode.elm,然后把 vnode.children 插到 vnode.elm 中,再把 vnode.elm 插到父节点 parentElm 中。回到 patch 方法:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...
  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // ...
    } else {
      //...

      // 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)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #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(parentElm, [oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

接下来的if 判断的是 vnode.parent ,这是父占位节点

父占位节点,是和组件相关的,这里不会执行,也就不展开细讲。

然后又判断之前定义的 parentElm 是否存在,有则删除掉 vm.$el 对应的节点。在执行这一步前,浏览器的 DOM 结构是这样的:

<body>
<div id="app"></div>
<div id="app">森林小哥哥</div>
</body>

之后删除<div id="app"></div>完成新旧节点替换工作。

最后将vnode.elm(也就是真实DOM)返回。

好玩儿的css

引言

其实很早就想写一篇关于 css 的文章了。(拖延症,一直没写。。。)

css 发展到今天已经越来越强大了。其语法的日新月异,让很多以前完成不了的事情,现在可以非常轻松的做到。

今天带大家看几个用css(部分会用到canvasjs)实现的好玩儿的效果(不好好琢磨下,还真写不出来)

本篇文章有参考一些css大佬的杰作,具体参考链接在文末有提及

超能陆战队-大白

超能陆战队中的大白,相信你一定不陌生吧。影片中的大白又萌又可爱,十分惹人喜欢。

下面让我们打造属于自己的大白吧!

效果

思路

大白主要是由大大小小的圆和椭圆组成,主要会用到border-radius属性。

整体bigwhite头部head(包含 eye、eye2 和 mouth)、躯干torso(heart)、躯干连接处belly(cover)、左臂left-arm(包含 l-bigfinger、l-smallfinger)、右臂right-arm(包含 r-bigfinger、r-smallfinger)、左腿left-leg右腿right-leg组成。

相对还是比较简单的,具体实现如下:

代码实现

html

<body>
  <div id="bigwhite">
    <!--头部-->
    <div id="head">
      <div id="eye"></div>
      <div id="eye2"></div>
      <div id="mouth"></div>
    </div>

    <!--躯干-->
    <div id="torso">
      <div id="heart"></div>
    </div>
    <div id="belly">
      <div id="cover"></div>
      <!--和躯干连接处-->
    </div>

    <!--左臂-->
    <div id="left-arm">
      <div id="l-bigfinger"></div>
      <div id="l-smallfinger"></div>
    </div>

    <!--右臂-->
    <div id="right-arm">
      <div id="r-bigfinger"></div>
      <div id="r-smallfinger"></div>
    </div>

    <!--左腿-->
    <div id="left-leg"></div>

    <!--右腿-->
    <div id="right-leg"></div>
</body>

css

body {
  background: #ff3300;
}
#bigwhite {
  margin: 0 auto;
  height: 600px;
  /*隐藏溢出*/
  overflow: hidden;
}
#head {
  height: 64px;
  width: 100px;
  /*画圆*/
  border-radius: 50%;
  background: #fff;
  margin: 0 auto;
  margin-bottom: -20px;

  border-bottom: 5px solid #e0e0e0;

  /*元素的堆叠顺序*/
  z-index: 100;

  position: relative;
}

#eye,
#eye2 {
  width: 11px;
  height: 13px;
  background: #282828;
  border-radius: 50%;
  position: relative;
  top: 30px;
  left: 27px;

  /*旋转元素*/
  transform: rotate(8deg);
}
#eye2 {
  /*对称旋转*/
  transform: rotate(-8deg);
  left: 69px;
  top: 17px;
}
#mouth {
  width: 38px;
  height: 1.7px;
  background: #282828;
  position: relative;
  top: 10px;
  left: 34px;
}

#torso,
#belly {
  margin: 0 auto;
  height: 200px;
  width: 180px;
  background: #fff;
  border-radius: 47%;

  border: 5px solid #e0e0e0;
  border-top: none;
  z-index: 1;
}
#belly {
  height: 300px;
  width: 245px;
  margin-top: -140px;
  z-index: 5;
}
#heart {
  width: 25px;
  height: 25px;
  border-radius: 50px;
  position: relative;
  /*添加阴影*/
  box-shadow: 2px 5px 2px #ccc inset;

  right: -115px;
  top: 40px;
  z-index: 111;
  border: 1px solid #ccc;
}

#left-arm,
#right-arm {
  height: 270px;
  width: 120px;
  border-radius: 50%;
  background: #fff;
  margin: 0 auto;
  position: relative;
  top: -350px;
  left: -100px;
  transform: rotate(200deg);
  z-index: -1;
}
#right-arm {
  transform: rotate(-200deg);
  left: 100px;
  top: -620px;
}

#l-bigfinger,
#r-bigfinger {
  height: 50px;
  width: 20px;
  border-radius: 50%;
  background: #fff;
  position: relative;
  top: -35px;
  left: 39px;
  transform: rotate(-50deg);
}
#r-bigfinger {
  left: 63px;
  transform: rotate(50deg);
}
#l-smallfinger,
#r-smallfinger {
  height: 35px;
  width: 15px;
  border-radius: 50%;
  background: #fff;
  position: relative;
  top: -70px;
  left: 25px;
  transform: rotate(-40deg);
}
#r-smallfinger {
  background: #fff;
  transform: rotate(40deg);
  top: -70px;
  left: 80px;
}

#left-leg,
#right-leg {
  height: 170px;
  width: 90px;
  border-radius: 40% 30% 10px 45%;
  background: #fff;
  position: relative;
  top: -640px;
  left: -45px;
  transform: rotate(-1deg);
  margin: 0 auto;
  z-index: -2;
}
#right-leg {
  border-radius: 40% 30% 45% 10px;
  position: relative;
  margin: 0 auto;
  top: -810px;
  left: 50px;
  transform: rotate(1deg);
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/vYOYoPp

飘逸灵动的彩带(借鉴尤雨溪博客首页)

很早之前见过这种效果(当时还不知道这是尤大大的作品)。

第一次看到这个主页的时候,就觉得很惊艳。主页图案的组成元素只有一种:富有魅力的三角网格。整个页面简单却不单调,华丽而不喧闹。(简单来说就是有逼格)。

效果

思路

这里最关键的两个点,绘制三角形的算法和颜色的取值算法。具体参考https://zhuanlan.zhihu.com/p/28257724,这里面介绍的比较详细。

下面看一下代码实现(有注释):

代码

html

<body>
  <div id="wrapper">
    <h1>之晨</h1>
    <h2>公众号-「前端森林」</h2>
    <p>
      <a href="https://github.com/Jack-cool" target="_blank">Github</a>
    </p>
    <p>
      <a href="https://juejin.im/user/5a767928f265da4e78327344/activities" target="_blank">掘金</a>
    </p>
    <p>
  </div>
  <canvas width="1920" height="917"></canvas>
</body>

css

html,
body {
  overflow: hidden;
  margin: 0;
}

body {
  font-family: "Open Sans", "Helvetica Neue", "Hiragino Sans GB", "LiHei Pro",
    Arial, sans-serif;
  color: #333;
}

#wrapper {
  position: absolute;
  left: 0;
  width: 320px;
  text-align: center;
  top: 50%;
  left: 50%;
  margin-left: -160px;
  margin-top: -160px;
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}

h1 {
  font-family: "Montserrat", "Helvetica Neue", Arial, sans-serif;
  font-weight: 700;
  font-size: 30px;
  letter-spacing: 9px;
  text-transform: uppercase;
  margin: 12px 0;
  left: 4px;
}

h2 {
  color: #999;
  font-weight: normal;
  font-size: 15px;
  letter-spacing: 0.12em;
  margin-bottom: 30px;
  left: 3px;
}

h1,
h2 {
  position: relative;
}

p {
  font-size: 14px;
  line-height: 2em;
  margin: 0;
  letter-spacing: 2px;
}

canvas {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

a {
  color: #999;
  text-decoration: none;
  transition: color 0.2s ease;
}

a:hover {
  color: #f33;
}

js

document.addEventListener("touchmove", function(e) {
  e.preventDefault();
});
var canvasRibbon = document.getElementsByTagName("canvas")[0],
  ctx = canvasRibbon.getContext("2d"), // 获取canvas 2d上下文
  dpr = window.devicePixelRatio || 1, // the size of one CSS pixel to the size of one physical pixel.
  width = window.innerWidth, // 返回窗口的文档显示区的宽高
  height = window.innerHeight,
  RIBBON_WIDE = 90,
  path,
  math = Math,
  r = 0,
  PI_2 = math.PI * 2, // 圆周率*2
  cos = math.cos, // cos函数返回一个数值的余弦值(-1~1)
  random = math.random; // 返回0-1随机数
canvasRibbon.width = width * dpr; // 返回实际宽高
canvasRibbon.height = height * dpr;
ctx.scale(dpr, dpr); // 水平、竖直方向缩放
ctx.globalAlpha = 0.6; // 图形透明度
function init() {
  ctx.clearRect(0, 0, width, height); // 擦除之前绘制内容
  path = [
    { x: 0, y: height * 0.7 + RIBBON_WIDE },
    { x: 0, y: height * 0.7 - RIBBON_WIDE }
  ];
  // 路径没有填满屏幕宽度时,绘制路径
  while (path[1].x < width + RIBBON_WIDE) {
    draw(path[0], path[1]); // 调用绘制方法
  }
}
// 绘制彩带每一段路径
function draw(start, end) {
  ctx.beginPath(); // 创建一个新的路径
  ctx.moveTo(start.x, start.y); // path起点
  ctx.lineTo(end.x, end.y); // path终点
  var nextX = end.x + (random() * 2 - 0.25) * RIBBON_WIDE,
    nextY = geneY(end.y);
  ctx.lineTo(nextX, nextY);
  ctx.closePath();
  r -= PI_2 / -50;
  // 随机生成并设置canvas路径16进制颜色
  ctx.fillStyle =
    "#" +
    (
      ((cos(r) * 127 + 128) << 16) |
      ((cos(r + PI_2 / 3) * 127 + 128) << 8) |
      (cos(r + (PI_2 / 3) * 2) * 127 + 128)
    ).toString(16);
  ctx.fill(); // 根据当前样式填充路径
  path[0] = path[1]; // 起点更新为当前终点
  path[1] = { x: nextX, y: nextY }; // 更新终点
}
// 获取下一路径终点的y坐标值
function geneY(y) {
  var temp = y + (random() * 2 - 1.1) * RIBBON_WIDE;
  return temp > height || temp < 0 ? geneY(y) : temp;
}
document.onclick = init;
document.ontouchstart = init;
init();

具体可查看https://codepen.io/jack-cool-the-lessful/pen/rNVaBVL

知乎(老版本)首页动态粒子效果背景

效果

思路

涉及到的知识点主要是:canvasES6requestAnimationFrame

大致思路就是:

  • 定义一个类,创建圆和线的实例
  • 设置单个粒子的随机 x,y 坐标和圆圈的半径。使用window.innerWidthwindow.innerHeight获取屏幕宽高,圆的大小设置在一定范围内随机
  • 使用 canvas 的 api 进行绘制粒子(圆圈)和粒子之间连线,设置一个范围,在此范围内的粒子圆心到圆心通过直线连接
  • 让粒子在屏幕范围内移动
  • 置鼠标的交互事件,相当于以鼠标位置的 x,y 坐标为圆心,固定或随机值为半径重新创建了一个粒子,并且也在一定范围内也设置和其他粒子的连线(同第二步)
  • 定义一个变量用来存储生成的圆,遍历它,创建实例;
  • 使用requestAnimationFrame让所有圆动起来

代码实现

html

<canvas id="canvas"></canvas>

css

html {
  height: 100%;
}
body {
  margin: 0;
  height: 100%;
  background: #fff;
}
canvas {
  display: block;
  width: 100%;
  height: 100%;
}

js

class Circle {
  //创建对象
  //以一个圆为对象
  //设置随机的 x,y坐标,r半径,_mx,_my移动的距离
  //this.r是创建圆的半径,参数越大半径越大
  //this._mx,this._my是移动的距离,参数越大移动
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.r = Math.random() * 10;
    this._mx = Math.random();
    this._my = Math.random();
  }

  //canvas 画圆和画直线
  //画圆就是正常的用canvas画一个圆
  //画直线是两个圆连线,为了避免直线过多,给圆圈距离设置了一个值,距离很远的圆圈,就不做连线处理
  drawCircle(ctx) {
    ctx.beginPath();
    //arc() 方法使用一个中心点和半径,为一个画布的当前子路径添加一条弧。
    ctx.arc(this.x, this.y, this.r, 0, 360);
    ctx.closePath();
    ctx.fillStyle = "rgba(204, 204, 204, 0.3)";
    ctx.fill();
  }

  drawLine(ctx, _circle) {
    let dx = this.x - _circle.x;
    let dy = this.y - _circle.y;
    let d = Math.sqrt(dx * dx + dy * dy);
    if (d < 150) {
      ctx.beginPath();
      //开始一条路径,移动到位置 this.x,this.y。创建到达位置 _circle.x,_circle.y 的一条线:
      ctx.moveTo(this.x, this.y); //起始点
      ctx.lineTo(_circle.x, _circle.y); //终点
      ctx.closePath();
      ctx.strokeStyle = "rgba(204, 204, 204, 0.3)";
      ctx.stroke();
    }
  }

  // 圆圈移动
  // 圆圈移动的距离必须在屏幕范围内
  move(w, h) {
    this._mx = this.x < w && this.x > 0 ? this._mx : -this._mx;
    this._my = this.y < h && this.y > 0 ? this._my : -this._my;
    this.x += this._mx / 2;
    this.y += this._my / 2;
  }
}
//鼠标点画圆闪烁变动
class currentCirle extends Circle {
  constructor(x, y) {
    super(x, y);
  }

  drawCircle(ctx) {
    ctx.beginPath();
    //注释内容为鼠标焦点的地方圆圈半径变化
    //this.r = (this.r < 14 && this.r > 1) ? this.r + (Math.random() * 2 - 1) : 2;
    this.r = 8;
    ctx.arc(this.x, this.y, this.r, 0, 360);
    ctx.closePath();
    //ctx.fillStyle = 'rgba(0,0,0,' + (parseInt(Math.random() * 100) / 100) + ')'
    ctx.fillStyle = "rgba(255, 77, 54, 0.6)";
    ctx.fill();
  }
}
//更新页面用requestAnimationFrame替代setTimeout
window.requestAnimationFrame =
  window.requestAnimationFrame ||
  window.mozRequestAnimationFrame ||
  window.webkitRequestAnimationFrame ||
  window.msRequestAnimationFrame;

let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
let w = (canvas.width = canvas.offsetWidth);
let h = (canvas.height = canvas.offsetHeight);
let circles = [];
let current_circle = new currentCirle(0, 0);

let draw = function() {
  ctx.clearRect(0, 0, w, h);
  for (let i = 0; i < circles.length; i++) {
    circles[i].move(w, h);
    circles[i].drawCircle(ctx);
    for (j = i + 1; j < circles.length; j++) {
      circles[i].drawLine(ctx, circles[j]);
    }
  }
  if (current_circle.x) {
    current_circle.drawCircle(ctx);
    for (var k = 1; k < circles.length; k++) {
      current_circle.drawLine(ctx, circles[k]);
    }
  }
  requestAnimationFrame(draw);
};

let init = function(num) {
  for (var i = 0; i < num; i++) {
    circles.push(new Circle(Math.random() * w, Math.random() * h));
  }
  draw();
};
window.addEventListener("load", init(60));
window.onmousemove = function(e) {
  e = e || window.event;
  current_circle.x = e.clientX;
  current_circle.y = e.clientY;
};
window.onmouseout = function() {
  current_circle.x = null;
  current_circle.y = null;
};

具体可查看https://codepen.io/jack-cool-the-lessful/pen/YzXPzRy

canvas 生成验证码

我们在做一些后台系统登录功能的时候,一般都会用到验证码,现在用的比较多的一种是前端直接使用canvas生成验证码。

效果

由于该功能相对比较简单,这里就不过多做解释了。(代码中有对应相关注解)

代码实现

html

<canvas width="120" height="40" id="c1"></canvas>

css

body {
  text-align: center;
}
canvas {
  border: 1px solid skyBlue;
}

js

// 随机数
function rn(min, max) {
  return parseInt(Math.random() * (max - min) + min);
}
// 随机颜色
function rc(min, max) {
  var r = rn(min, max);
  var g = rn(min, max);
  var b = rn(min, max);
  return `rgb(${r},${g},${b})`;
}
// 背景颜色,颜色要浅一点
var w = 120;
var h = 40;
var ctx = c1.getContext("2d");
ctx.fillStyle = rc(180, 230);
ctx.fillRect(0, 0, w, h);
// 随机字符串
var pool = "ABCDEFGHIJKLIMNOPQRSTUVWSYZ1234567890";
for (var i = 0; i < 4; i++) {
  var c = pool[rn(0, pool.length)]; //随机的字
  var fs = rn(18, 40); //字体的大小
  var deg = rn(-30, 30); //字体的旋转角度
  ctx.font = fs + "px Simhei";
  ctx.textBaseline = "top";
  ctx.fillStyle = rc(80, 150);
  ctx.save();
  ctx.translate(30 * i + 15, 15);
  ctx.rotate((deg * Math.PI) / 180);
  ctx.fillText(c, -15 + 5, -15);
  ctx.restore();
}
// 随机5条干扰线,干扰线的颜色要浅一点
for (var i = 0; i < 5; i++) {
  ctx.beginPath();
  ctx.moveTo(rn(0, w), rn(0, h));
  ctx.lineTo(rn(0, w), rn(0, h));
  ctx.strokeStyle = rc(180, 230);
  ctx.closePath();
  ctx.stroke();
}
// 随机产生40个干扰的小点
for (var i = 0; i < 40; i++) {
  ctx.beginPath();
  ctx.arc(rn(0, w), rn(0, h), 1, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fillStyle = rc(150, 200);
  ctx.fill();
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/VwLYYbP

抖音 LOGO

抖音我们每天都在刷,抖音的 logo 大家也再熟悉不过。

效果

思路

抖音 logo 是两个音符 ♪ 叠加、混合而成的。这个音符可以拆分为三个部分:


我们可以看到,它由三部分组成:

1、中间的竖线(矩形)

2、右上角的四分之一圆环(利用 border-radiustransform 旋转来实现)

3、左下角的四分之三圆环(利用 border-radiustransform 旋转来实现)

从上面的 logo,我们可以清晰的看到两个音符 ♪ 之间是有重叠部分的。这一块是通过mix-blend-mode属性实现的。

CSS3 新增了一个很有意思的属性 -- mix-blend-mode ,其中 mix 和 blend 的中文意译均为混合,那么这个属性的作用直译过来就是混合混合模式,当然,我们我们通常称之为混合模式。

由此可以知道实现该 logo 的关键点在于:

  • 主要借助伪元素实现了整体 J 结构,借助了 mix-blend-mode 实现融合效果
  • 利用 mix-blend-mode: lighten 混合模式实现两个 J 形结构重叠部分为白色

代码实现

html

<div class="g-container">
  <div class="j"></div>
  <div class="j"></div>
</div>

css(scss)

body {
  background: #000;
  overflow: hidden;
}

.g-container {
  position: relative;
  width: 200px;
  margin: 100px auto;
  filter: contrast(150%) brightness(110%);
}

.j {
  position: absolute;
  top: 0;
  left: 0;
  width: 47px;
  height: 218px;
  z-index: 1;
  background: #24f6f0;

  &::before {
    content: "";
    position: absolute;
    width: 100px;
    height: 100px;
    border: 47px solid #24f6f0;
    border-top: 47px solid transparent;
    border-radius: 50%;
    top: 121px;
    left: -147px;
    transform: rotate(45deg);
  }

  &::after {
    content: "";
    position: absolute;
    width: 140px;
    height: 140px;
    border: 40px solid #24f6f0;
    border-right: 40px solid transparent;
    border-top: 40px solid transparent;
    border-left: 40px solid transparent;
    top: -110px;
    right: -183px;
    border-radius: 100%;
    transform: rotate(45deg);
    z-index: -10;
  }
}

.j:last-child {
  left: 10px;
  top: 10px;
  background: #fe2d52;
  z-index: 100;
  mix-blend-mode: lighten;
  animation: moveLeft 10s infinite;

  &::before {
    border: 47px solid #fe2d52;
    border-top: 47px solid transparent;
  }
  &::after {
    border: 40px solid #fe2d52;
    border-right: 40px solid transparent;
    border-top: 40px solid transparent;
    border-left: 40px solid transparent;
  }
}

@keyframes moveLeft {
  0% {
    transform: translate(200px);
  }
  50% {
    transform: translate(0px);
  }
  100% {
    transform: translate(0px);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/poJvvVB

掘金登录特效

效果

思路

这里用到了平时不大可能会用到的:focus-within

:focus-within 伪类选择器,它表示一个元素获得焦点,或,该元素的后代元素获得焦点。

这也就意味着,它或它的后代获得焦点,都可以触发 :focus-within

深入了解可查看https://github.com/chokcoco/iCSS/issues/36

代码实现

html

<div class="g-container">
  <h2>登录</h2>
  <div class="g-username">
    <input name="loginPhoneOrEmail" maxlength="64" placeholder="请输入手机号或邮箱" class="input">
    <img src="https://b-gold-cdn.xitu.io/v3/static/img/greeting.1415c1c.png" class="g-username">
  </div>

  <div class="g-password">
    <input name="loginPassword" type="password" maxlength="64" placeholder="请输入密码" class="input">
    <img src="https://b-gold-cdn.xitu.io/v3/static/img/blindfold.58ce423.png" class="g-password">
  </div>

  <img src="https://b-gold-cdn.xitu.io/v3/static/img/normal.0447fe9.png" class="g-normal">
</div>

css(scss)

$bg-normal: "https://b-gold-cdn.xitu.io/v3/static/img/normal.0447fe9.png";
$bg-username: "https://b-gold-cdn.xitu.io/v3/static/img/greeting.1415c1c.png";
$bg-password: "https://b-gold-cdn.xitu.io/v3/static/img/blindfold.58ce423.png";

.g-container {
  position: relative;
  width: 318px;
  margin: 100px auto;
  height: 370px;
  padding: 20px;
  box-sizing: border-box;
  background: #fff;
  z-index: 10;

  h2 {
    font-size: 20px;
    font-weight: bold;
    margin-bottom: 30px;
  }

  input {
    outline: none;
    padding: 10px;
    width: 100%;
    border: 1px solid #e9e9e9;
    border-radius: 2px;
    outline: none;
    box-sizing: border-box;
    font-size: 16px;
  }
}

img {
  position: absolute;
  top: -20%;
  left: 50%;
  width: 120px;
  height: 95px;
  transform: translate(-50%, 0);
}

.g-username {
  margin-bottom: 10px;

  img {
    display: none;
    width: 120px;
    height: 113px;
  }
}

.g-username:focus-within ~ img {
  display: none;
}

.g-username:focus-within {
  input {
    border-color: #007fff;
  }
  img {
    display: block;
  }
}

.g-password {
  margin-bottom: 10px;

  img {
    display: none;
    width: 103px;
    height: 84px;
    top: -15%;
  }
}

.g-password:focus-within ~ img {
  display: none;
}

.g-password:focus-within {
  input {
    border-color: #007fff;
  }
  img {
    display: block;
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/VwLYYqz

波浪百分比

第一次见到这种效果好像还是在手机营业厅里面,展示剩余流量时。

不得不感叹,css 有时真的很强大。当然感觉这也属于 css 的奇技淫巧了。

效果

思路

这里我简单说明一下关键点:

  • 利用 border-radius 生成椭圆
  • 让椭圆旋转起来
  • 并不是利用旋转的椭圆本身生成波浪效果,而是利用它去切割背景,产生波浪的效果。

具体可参考https://zhuanlan.zhihu.com/p/28508128,里面分别提到了用svgcanvas纯css来实现波浪效果。

我们这里是用纯css来实现的。

代码实现

html

<div class="container">
  <div class="wave"></div>
</div>

css(scss)

.container {
  position: absolute;
  width: 200px;
  height: 200px;
  padding: 5px;
  border: 5px solid rgb(0, 102, 204);
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  overflow: hidden;
}
.wave {
  position: relative;
  width: 200px;
  height: 200px;
  background-color: rgb(51, 102, 204);
  border-radius: 50%;

  &::before,
  &::after {
    content: "";
    position: absolute;
    width: 400px;
    height: 400px;
    top: 0;
    left: 50%;
    background-color: rgba(255, 255, 255, 0.4);
    border-radius: 45%;
    transform: translate(-50%, -70%) rotate(0);
    animation: rotate 6s linear infinite;
    z-index: 10;
  }

  &::after {
    border-radius: 47%;
    background-color: rgba(255, 255, 255, 0.9);
    transform: translate(-50%, -70%) rotate(0);
    animation: rotate 10s linear -5s infinite;
    z-index: 20;
  }
}

@keyframes rotate {
  50% {
    transform: translate(-50%, -73%) rotate(180deg);
  }
  100% {
    transform: translate(-50%, -70%) rotate(360deg);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/XWbJJLd

酷炫的充电动画

看完上面的波浪百分比动画,在此基础上我们来实现一个更为复杂的动画效果,也就是常见的充电动画

效果

思路

  • 画一个电池
  • 增加阴影及颜色的变化(使用 filter: hue-rotate() 对渐变色彩进行色彩过渡变换动画)
  • 添加波浪,这里用一张动图说明(结合上个波浪百分比,相信你很快就明白了)

代码实现

html

<div class="container">
  <div class="header"></div>
  <div class="battery">
  </div>
  <div class="battery-copy">
    <div class="g-wave"></div>
    <div class="g-wave"></div>
    <div class="g-wave"></div>
  </div>
</div>

css(scss)

html,
body {
    width: 100%;
    height: 100%;
    display: flex;
    background: #e4e4e4;
}
.container {
  position: relative;
  width: 140px;
  margin: auto;
}

.header {
  position: absolute;
  width: 26px;
  height: 10px;
  left: 50%;
  top: 0;
  transform: translate(-50%, -10px);
  border-radius: 5px 5px 0 0;
  background: rgba(255, 255, 255, 0.88);
}

.battery-copy {
  position: absolute;
  top: 0;
  left: 0;
  height: 220px;
  width: 140px;
  border-radius: 15px 15px 5px 5px;
  overflow: hidden;
}

.battery {
  position: relative;
  height: 220px;
  box-sizing: border-box;
  border-radius: 15px 15px 5px 5px;
  box-shadow: 0 0 5px 2px rgba(255, 255, 255, 0.22);
  background: #fff;
  z-index: 1;

  &::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    top: 80%;
    background: linear-gradient(
      to bottom,
      #7abcff 0%,
      #00bcd4 44%,
      #2196f3 100%
    );
    border-radius: 0px 0px 5px 5px;
    box-shadow: 0 14px 28px rgba(33, 150, 243, 0),
      0 10px 10px rgba(9, 188, 215, 0.08);
    animation: charging 10s linear infinite;
    filter: hue-rotate(90deg);
  }
}

.g-wave {
  position: absolute;
  width: 300px;
  height: 300px;
  background: rgba(255, 255, 255, 0.8);
  border-radius: 45% 47% 44% 42%;
  bottom: 25px;
  left: 50%;
  transform: translate(-50%, 0);
  z-index: 1;
  animation: move 10s linear infinite;
}

.g-wave:nth-child(2) {
  border-radius: 38% 46% 43% 47%;
  transform: translate(-50%, 0) rotate(-135deg);
}

.g-wave:nth-child(3) {
  border-radius: 42% 46% 37% 40%;
  transform: translate(-50%, 0) rotate(135deg);
}

@keyframes charging {
  50% {
    box-shadow: 0 14px 28px rgba(0, 150, 136, 0.83),
      0px 4px 10px rgba(9, 188, 215, 0.4);
  }

  95% {
    top: 5%;
    filter: hue-rotate(0deg);
    border-radius: 0 0 5px 5px;
    box-shadow: 0 14px 28px rgba(4, 188, 213, 0.2),
      0 10px 10px rgba(9, 188, 215, 0.08);
  }
  100% {
    top: 0%;
    filter: hue-rotate(0deg);
    border-radius: 15px 15px 5px 5px;
    box-shadow: 0 14px 28px rgba(4, 188, 213, 0),
      0 10px 10px rgba(9, 188, 215, 0.4);
  }
}

@keyframes move {
  100% {
    transform: translate(-50%, -160px) rotate(720deg);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/gOpbpaB

参考链接:

  • https://github.com/chokcoco/iCSS
  • https://github.com/chokcoco/CSS-Inspiration

最后

到这里本篇文章也就结束了,这里主要是结合我平时的所见所得对好玩儿的css做的简单的总结。希望能给你带来帮助!

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

那些前端开发必不可少的生产力工具

引言

一些开源的生产力工具能极大的提升我们的开发效率(我一直是这么认为的 🤠)。

今天推荐一些我一直在用的、比较香的工具给大家。其中包括一些文档、可视化工具、分析工具、代码片段、调试工具等。

Collect UI 🦑

Collect UI画廊是一个免费的在线资源,用于每日UI设计灵感。 目前,它有 6500 多个条目,并且持续保持更新最新内容。
CollectUI

在边栏中,有做分类。包括 404 页面、登陆/登出、购物车、日历、视频播放器等。如果你想在某方便需求灵感,然后用于你的公司项目或者个人项目,我想是会有很大的帮助的。

Taskade 📝

在平时生活中总会有很多的事要做,比如工作时有很多待办事项,但是很容易就会忘记一些事情,这时我们就需要一款具有带有待办事项的chrome插件--taskade

Taskade简单,整洁并且设计精美,有着令人放松的主题和背景。使用Taskade来整理您的思路,这样您可以集中精力做事情。

Colordot 🌈

有时候我们想寻求一个自己喜欢的颜色(有点像起一个自己满意的昵称),却没有灵感,这时候我们就可以来这里

网页区域内随意滑动鼠标,可以产生不同的色彩。确定一个色彩,再随意滑动产生下一个色彩,直到找到自己满意的配色。

FontSpark 🎯

FontSpark是一个帮助有字体选择困难症的用户打造的选择字体的网站,用户只需要输入所需要展示的文字即可获得网站推荐的字体,包括字体类型和大小。

对于推荐的字体不是很满意的话,点击Generate按钮刷新即可。

The Noun Project 🎃

The Noun Project 网站专门提供高品质、可辨识性强的icon,这些icon没有很炫酷的设计,通常只用单色来呈现,使用者却能很容易地辨别出它要传达的意思。

目前 NounProject 提供超过 200 万的icon供使用者免费下载,且持续在更新中,如果你需要某种icon,却一直没有找到合适的,不妨到这个网站来走走。

csseffects 🚀

CSSeffectsSnippets收录了大约 20 多种CSS动画,无论是加载读取中,或是将光标移动过去产生的动画,都能在网站上即时预览。

而这还不是它最大的亮点,最值得推荐的是所有效果都能在点击后快速复制相关代码,直接让开发者运用到自己的网站或博客,当然可能还是需要经过微调,不过不用从头开始,也不需在网路上寻找这些动画代码,非常方便而且省时。

unDraw 🍉

unDraw 是由希腊设计师 Katerina Limpitsouni 开发的一套开源矢量插图库,在这个网站上有超过 1000 个扁平矢量插画供你下载使用。

如果你在做个人网站,但对于插画没有灵感,或许你可以来看看。

DevDocs 🐨

这个网页应用汇聚了各种项目的文档,还支持离线使用。

不管是新手程序员还是老程序员都需要有一个可以在线查询各种编程手册的文档,而DevDocs汇集了最全的编程开发文档,又拥有极佳的阅读模式,让你可以快速的查询想要的命令,同时还支持浏览器扩展,可谓方便之极。

CSS Tricks 🦊

CSS Tricks是一个国外的优秀前端开发博客,主要分享使用CSS样式的技巧、经验和教程等。

该网站不断的在更新一些优秀的教程和技巧,为前端社区做出了具大的贡献。我也一直在这上面学习,让我在CSS方面视野拓宽了很多。

cssreference 🎾

如果需要更新 CSS 知识或者查询不熟悉、不常用的属性,可以访问这个站点。上面对每个 CSS 属性的讲解很深入,给出的示例也很清楚,便于你理解这些属性并应用于自己的项目。

Can I Use

前端开发的时候时常需要检查浏览器的兼容性,在这里推荐(Can I Use)这个是一个针对前端开发人员定制的一个查询CSSJs在各种流行浏览器中的特性和兼容性的网站,可以很好的保证网页的浏览器兼容性。有了这个工具可以快速的了解到代码在各个浏览器中的效果。

Lighthouse 🌊

Lighthouse是一个Google开源的自动化工具,主要用于改进网络应用(移动端)的质量。目前测试项包括页面性能、PWA、可访问性(无障碍)、最佳实践、SEO

Lighthouse会对各个测试项的结果打分,并给出优化建议,这些打分标准和优化建议可以视为Google的网页最佳实践。

Majestic

Majestic是一款好用的Jest运行测试GUI工具。

利用可视化的方式,使用它可以让我们查看测试用例输出日志更加简单。

Wappalyzer 🔭

Wappalyzer是一款能够分析目标网站所采用的平台架构、网站环境、服务器配置环境、javascript框架、编程语言等参数的chrome网站技术分析插件。

iHateRegex 🌡

对于开发人员来说,正则表达式是会被经常用到的,很多类型复杂的字符串都可以用它匹配出来,但唯一但缺点是编写起来很困难,不仅需要熟练掌握规则,还需要花时间编写、调试。

iHateRegex就是这样一个帮你解决书写正则表达式烦恼的神器。

iHateRegex是一个在线开源工具,可快速检索并匹配到合适的正则表达式,帮你完成如用户名、邮箱、日期、手机号码、密码等常见规则的验证。

当然你也可以看到它内部的匹配过程,这有助于加深你的理解。

参考

https://dev.to/joserfelix/40-high-quality-free-resources-for-web-development-10o3

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻

useTypescript-React Hooks和TypeScript完全指南

引言

React v16.8 引入了 Hooks,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。这些功能可以在应用程序中的各个组件之间使用,从而易于共享逻辑。Hook 令人兴奋并迅速被采用,React 团队甚至想象它们最终将替换类组件。

以前在 React 中,共享逻辑的方法是通过高阶组件和 props 渲染。Hooks 提供了一种更简单方便的方法来重用代码并使组件可塑形更强。

本文将展示 TypeScript 与 React 集成后的一些变化,以及如何将类型添加到 Hooks 以及你的自定义 Hooks 上。

引入 Typescript 后的变化

有状态组件(ClassComponent)

API 对应为:

React.Component<P, S>

class MyComponent extends React.Component<Props, State> { ...

以下是官网的一个例子,创建 Props 和 State 接口,Props 接口接受 name 和 enthusiasmLevel 参数,State 接口接受 currentEnthusiasm 参数:

import * as React from "react";

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

interface State {
  currentEnthusiasm: number;
}

class Hello extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { currentEnthusiasm: props.enthusiasmLevel || 1 };
  }

  onIncrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm + 1);
  onDecrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm - 1);

  render() {
    const { name } = this.props;

    if (this.state.currentEnthusiasm <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(this.state.currentEnthusiasm)}
        </div>
        <button onClick={this.onDecrement}>-</button>
        <button onClick={this.onIncrement}>+</button>
      </div>
    );
  }

  updateEnthusiasm(currentEnthusiasm: number) {
    this.setState({ currentEnthusiasm });
  }
}

export default Hello;

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

TypeScript 可以对 JSX 进行解析,充分利用其本身的静态检查功能,使用泛型进行 Props、 State 的类型定义。定义后在使用 this.state 和 this.props 时可以在编辑器中获得更好的智能提示,并且会对类型进行检查。

react 规定不能通过 this.props.xxx 和 this.state.xxx 直接进行修改,所以可以通过 readonly 将 State 和 Props 标记为不可变数据:

interface Props {
  readonly number: number;
}

interface State {
  readonly color: string;
}

export class Hello extends React.Component<Props, State> {
  someMethod() {
    this.props.number = 123; // Error: props 是不可变的
    this.state.color = 'red'; // Error: 你应该使用 this.setState()
  }
}

无状态组件(StatelessComponent)

API 对应为:

// SFC: stateless function components
const List: React.SFC<IProps> = props => null
// v16.8起,由于hooks的加入,函数式组件也可以使用state,所以这个命名不准确。新的react声明文件里,也定义了React.FC类型^_^
React.FunctionComponent<P> or React.FC<P>

const MyComponent: React.FC<Props> = ...

无状态组件也称为傻瓜组件,如果一个组件内部没有自身的 state,那么组件就可以称为无状态组件。在@types/react已经定义了一个类型type SFC<P = {}> = StatelessComponent

先看一下之前无状态组件的写法:

import React from 'react'

const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)

如果采用 ts 来编写出来的无状态组件是这样的:

import React, { MouseEvent, SFC } from 'react';

type Props = { onClick(e: MouseEvent<HTMLElement>): void };

const Button: SFC<Props> = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
);

事件处理

我们在进行事件注册时经常会在事件处理函数中使用 event 事件对象,例如当使用鼠标事件时我们会通过 clientX、clientY 去获取指针的坐标。

大家可以想到直接把 event 设置为 any 类型,但是这样就失去了我们对代码进行静态检查的意义。

function handleMouseChange (event: any) {
  console.log(event.clientY)
}

试想下当我们注册一个 Touch 事件,然后错误的通过事件处理函数中的 event 对象去获取其 clientY 属性的值,在这里我们已经将 event 设置为 any 类型,导致 TypeScript 在编译时并不会提示我们错误, 当我们通过 event.clientY 访问时就有问题了,因为 Touch 事件的 event 对象并没有 clientY 这个属性。

通过 interface 对 event 对象进行类型声明编写的话又十分浪费时间,幸运的是 React 的声明文件提供了 Event 对象的类型声明。

  • 通用的 React Event Handler

API 对应为:

React.ReactEventHandler<HTMLElement>

简单的示例:

const handleChange: React.ReactEventHandler<HTMLInputElement> = (ev) => { ... }

<input onChange={handleChange} ... />
  • 特殊的 React Event Handler

常用 Event 事件对象类型:

ClipboardEvent<T = Element> 剪贴板事件对象


DragEvent<T = Element> 拖拽事件对象


ChangeEvent<T = Element>  Change 事件对象


KeyboardEvent<T = Element> 键盘事件对象


MouseEvent<T = Element> 鼠标事件对象


TouchEvent<T = Element>  触摸事件对象


WheelEvent<T = Element> 滚轮事件对象


AnimationEvent<T = Element> 动画事件对象


TransitionEvent<T = Element> 过渡事件对象

简单的示例:

const handleChange = (ev: React.MouseEvent<HTMLDivElement>) => { ... }

<div onMouseMove={handleChange} ... />

React 元素

API 对应为:

React.ReactElement<P> or JSX.Element

简单的示例:

// 表示React元素概念的类型: DOM元素组件或用户定义的复合组件
const elementOnly: React.ReactElement = <div /> || <MyComponent />;

React Node

API 对应为:

React.ReactNode

表示任何类型的 React 节点(基本上是 ReactElement + 原始 JS 类型的合集)

简单的示例:

const elementOrComponent: React.ReactNode = 'string' || 0 || false || null || undefined || <div /> || <MyComponent />;

React CSS 属性

API 对应为:

React.CSSProperties

用于标识 jsx 文件中的 style 对象(通常用于 css-in-js

简单的示例:

const styles: React.CSSProperties = { display: 'flex', ...
const element = <div style={styles} ...

Hooks 登场

首先,什么是 Hooks 呢?

React 一直都提倡使用函数组件,但是有时候需要使用 state 或者其他一些功能时,只能使用类组件,因为函数组件没有实例,没有生命周期函数,只有类组件才有。

Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

默认情况下,React 包含 10 个钩子。其中 3 个挂钩被视为是最常使用的“基本”或核心挂钩。还有 7 个额外的“高级”挂钩,这些挂钩最常用于边缘情况。10 个钩子如下:

  • 基础
    • useState
    • useEffect
    • useContext
  • 高级
    • useReducer
    • useCallback
    • useMemo
    • useRef
    • useImperativeHandle
    • useLayoutEffect
    • useDebugValue

useState with TypeScript

API 对应为:

// 传入唯一的参数: initialState,可以是数字,字符串等,也可以是对象或者数组。
// 返回的是包含两个元素的数组:第一个元素,state 变量,setState 修改 state值的方法。
const [state, setState] = useState(initialState);

useState是一个允许我们替换类组件中的 this.state 的挂钩。我们执行该挂钩,该挂钩返回一个包含当前状态值和一个用于更新状态的函数的数组。状态更新时,它会导致组件的重新 render。下面的代码显示了一个简单的 useState 钩子:

import * as React from 'react';

const MyComponent: React.FC = () => {
  const [count, setCount] = React.useState(0);
  return (
    <div onClick={() => setCount(count + 1)}>
      {count}
    </div>
  );
};

useEffect with TypeScript

API 对应为:

// 两个参数
// 第一个是一个函数,是在第一次渲染(componentDidMount)以及之后更新渲染之后会进行的副作用。这个函数可能会有返回值,倘若有返回值,返回值也必须是一个函数,会在组件被销毁(componentWillUnmount)时执行。
// 第二个参数是可选的,是一个数组,数组中存放的是第一个函数中使用的某些副作用属性。用来优化 useEffect
useEffect(() => { // 需要在componentDidMount执行的内容 return function cleanup() { // 需要在componentWillUnmount执行的内容 } }, [])

useEffect是用于我们管理副作用(例如 API 调用)并在组件中使用 React 生命周期的。useEffect 将回调函数作为其参数,并且回调函数可以返回一个清除函数(cleanup)。回调将在第一次渲染(componentDidMount) 和组件更新时(componentDidUpate)内执行,清理函数将组件被销毁(componentWillUnmount)内执行。

useEffect(() => {
  // 给 window 绑定点击事件
  window.addEventListener('click', handleClick);

  return () => {
      // 给 window 移除点击事件
      window.addEventListener('click', handleClick);
  }
});

默认情况下,useEffect 将在每个渲染时被调用,但是你还可以传递一个可选的第二个参数,该参数仅允许您在 useEffect 依赖的值更改时或仅在初始渲染时执行。第二个可选参数是一个数组,仅当其中一个值更改时才会 reRender(重新渲染)。如果数组为空,useEffect 将仅在 initial render(初始渲染)时调用。

useEffect(() => {
  // 使用浏览器API更新文档标题
  document.title = `You clicked ${count} times`;
}, [count]);	// 只有当数组中 count 值发生变化时,才会执行这个useEffect。

useContext with TypeScript

useContext允许您利用React context这样一种管理应用程序状态的全局方法,可以在任何组件内部进行访问而无需将值传递为 props。

useContext 函数接受一个 Context 对象并返回当前上下文值。当提供程序更新时,此挂钩将触发使用最新上下文值的重新渲染。

import { createContext, useContext } from 'react';

props ITheme {
  backgroundColor: string;
  color: string;
}

const ThemeContext = createContext<ITheme>({
  backgroundColor: 'black',
  color: 'white',
})

const themeContext = useContext<ITheme>(ThemeContext);

useReducer with TypeScript

对于更复杂的状态,您可以选择将该 useReducer 函数用作的替代 useState。

const [state,dispatch] =  useReducer(reducer,initialState,init);

如果您以前使用过Redux,则应该很熟悉。useReducer接受 3 个参数(reducer,initialState,init)并返回当前的 state 以及与其配套的 dispatch 方法。reducer 是如下形式的函数(state, action) => newState;initialState 是一个 JavaScript 对象;而 init 参数是一个惰性初始化函数,可以让你延迟加载初始状态。

这听起来可能有点抽象,让我们看一个实际的例子:

const initialState = 0;
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {number: state.number + 1};
    case 'decrement':
      return {number: state.number - 1};
    default:
      throw new Error();
  }
}
function init(initialState){
    return {number:initialState};
}
function Counter(){
    const [state, dispatch] = useReducer(reducer, initialState,init);
    return (
        <>
          Count: {state.number}
          <button onClick={() => dispatch({type: 'increment'})}>+</button>
          <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    )
}

看完例子再结合上面 useReducer 的 api 是不是立马就明白了呢?

useCallback with TypeScript

useCallback 钩子返回一个 memoized 回调。这个钩子函数有两个参数:第一个参数是一个内联回调函数,第二个参数是一个数组。数组将在回调函数中引用,并按它们在数组中的存在顺序进行访问。

const memoizedCallback =  useCallback(()=> {
    doSomething(a,b);
  }[ a,b ],);

useCallback 将返回一个记忆化的回调版本,它仅会在某个依赖项改变时才重新计算 memoized 值。当您将回调函数传递给子组件时,将使用此钩子。这将防止不必要的渲染,因为仅在值更改时才执行回调,从而可以优化组件。可以将这个挂钩视为与shouldComponentUpdate生命周期方法类似的概念。

useMemo with TypeScript

useMemo返回一个 memoized 值。 传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

const memoizedValue =  useMemo(() =>  computeExpensiveValue( a, b),[ a, b ];

useMemo 在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

看到这,你可能会觉得,useMemouseCallback的作用有点像啊,那它们之间有什么区别呢?

  • useCallback 和 useMemo 都可缓存函数的引用或值。
  • 从更细的使用角度来说 useCallback 缓存函数的引用,useMemo 缓存计算数据的值。

useRef with TypeScript

useRef挂钩允许你创建一个 ref 并且允许你访问基础 DOM 节点的属性。当你需要从元素中提取值或获取与 DOM 相关的元素信息(例如其滚动位置)时,可以使用此方法。

const refContainer  =  useRef(initialValue);

useRef 返回一个可变的 ref 对象,其.current属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

function TextInputWithFocusButton() {
  const inputEl = useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    inputEl.current.focus();
  };

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useImperativeHandle with TypeScript

useImperativeHandle可以让你在使用 ref 时,自定义暴露给父组件的实例值。

useImperativeHandle(ref, createHandle, [inputs])

useImperativeHandle 钩子函数接受 3 个参数: 一个 React ref、一个 createHandle 函数和一个用于暴露给父组件参数的可选数组。

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = React.forwardRef(FancyInput);

const fancyInputRef = React.createRef();
<FancyInput ref={fancyInputRef}>Click me!</FancyInput>;

useLayoutEffect with TypeScript

与 useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

useLayoutEffect(() => { doSomething });

进行副作用操作时尽量优先选择 useEffect,以免阻止视图更新。与 DOM 无关的副作用操作请使用 useEffect。

import React, { useRef, useState, useLayoutEffect } from 'react';

export default () => {

    const divRef = useRef(null);

    const [height, setHeight] = useState(50);

    useLayoutEffect(() => {
        // DOM 更新完成后打印出 div 的高度
        console.log('useLayoutEffect: ', divRef.current.clientHeight);
    })

    return <>
        <div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
        <button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
    </>

}

useDebugValue with TypeScript

useDebugValue是用于调试自定义挂钩(自定义挂钩请参考https://reactjs.org/docs/hooks-custom.html)的工具。它允许您在 React Dev Tools 中显示自定义钩子函数的标签。

示例

我之前基于 umi+react+typescript+ant-design 构建了一个简单的中后台通用模板。

涵盖的功能如下:

- 组件
  - 基础表格
  - ECharts 图表
  - 表单
    - 基础表单
    - 分步表单
  - 编辑器

- 控制台
- 错误页面
  - 404

里面对于在 react 中结合Hooks使用 typescript 的各种场景都有很好的实践,大家感兴趣的可以参考一下,https://github.com/easy-wheel/Umi-hooks/tree/feature_hook,当然不要吝惜你的 star!!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!

「源码级回答」大厂高频Vue面试题(上)

​最近一直在读Vue源码,也写了一系列的源码探秘文章。

但,收到很多朋友的反馈都是:源码晦涩难懂,时常看着看着就不知道我在看什么了,感觉缺乏一点动力,如果你可以出点面试中会问到的源码相关的面试题,通过面试题去看源码,那就很棒棒。

看到大家的反馈,我丝毫没有犹豫:安排!!

我通过三篇文章整理了大厂面试中会经常问到的一些Vue面试题,通过源码角度去回答,抛弃纯概念型回答,相信一定会让面试官对你刮目相看。

请说一下响应式数据的原理?

Vue实现响应式数据的核心APIObject.defineProperty

其实默认Vue在初始化数据时,会给data中的属性使用Object.defineProperty重新定义所有属性,当页面取到对应属性时。会进行依赖收集(收集当前组件的watcher) 如果属性发生变化会通知相关依赖进行更新操作。

这里,我用一张图来说明Vue实现响应式数据的流程:

Vue响应式原理流程图

  • 首先,第一步是初始化用户传入的data数据。这一步对应源码src/core/instance/state.js的 112 行
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    // ...
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
   // ...
  }
  // observe data
  observe(data, true /* asRootData */)
}
  • 第二步是将数据进行观测,也就是在第一步的initData的最后调用的observe函数。对应在源码的src/core/observer/index.js的 110 行
/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

这里会通过new Observer(value)创建一个Observer实例,实现对数据的观测。

  • 第三步是实现对对象的处理。对应源码src/core/observer/index.js的 55 行。
/**
 * Observer class that is attached to each observed
 * object. Once attached, the observer converts the target
 * object's property keys into getter/setters that
 * collect dependencies and dispatch updates.
 */
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

   /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // ...
}
  • 第四步就是循环对象属性定义响应式变化了。对应源码src/core/observer/index.js的 135 行。
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()  // 收集依赖
        // ...
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // ...
      dep.notify()  // 通知相关依赖进行更新
    }
  })
}
  • 第五步其实就是使用defineReactive方法中的Object.defineProperty重新定义数据。在get中通过dep.depend()收集依赖。当数据改变时,拦截属性的更新操作,通过set中的dep.notify()通知相关依赖进行更新。

Vue 中是如何检测数组变化?

Vue中检测数组变化核心有两点:

  • 首先,使用函数劫持的方式,重写了数组的方法
  • Vuedata 中的数组,进行了原型链重写。指向了自己定义的数组原型方法,这样当调用数组 api 时,就可以通知依赖更新。如果数组中包含着引用类型,会对数组中的引用类型再次进行观测。

这里用一张流程图来说明:

Vue中如何检测数组变化?

这里第一步和第二步和上题请说一下响应式数据的原理?是相同的,就不展开说明了。

  • 第一步同样是初始化用户传入的 data 数据。对应源码src/core/instance/state.js的 112 行的initData函数。
  • 第二步是对数据进行观测。对应源码src/core/observer/index.js的 124 行。
  • 第三步是将数组的原型方法指向重写的原型。对应源码src/core/observer/index.js的 49 行。
if (hasProto) {
  protoAugment(value, arrayMethods)
} else {
  // ...
}

也就是protoAugment方法:

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}
  • 第四步进行了两步操作。首先是对数组的原型方法进行重写,对应源码src/core/observer/array.js
/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [  // 这里列举的数组的方法是调用后能改变原数组的
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {  // 重写原型方法
  // cache original method
  const original = arrayProto[method]  // 调用原数组方法
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)  // 进行深度监控
    // notify change
    ob.dep.notify()  // 调用数组方法后,手动通知视图更新
    return result
  })
})

为什么Vue采用异步渲染?

我们先来想一个问题:如果Vue不采用异步更新,那么每次数据更新时是不是都会对当前组件进行重写渲染呢?

答案是肯定的,为了性能考虑,会在本轮数据更新后,再去异步更新视图。

通过一张图来说明Vue异步更新的流程:

为什么Vue采用异步渲染?

  • 第一步调用dep.notify()通知watcher进行更新操作。对应源码src/core/observer/dep.js中的 37 行。
notify () {  // 通知依赖更新
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort((a, b) => a.id - b.id)
  }
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()  // 依赖中的update方法
  }
}
  • 第二步其实就是在第一步的notify方法中,遍历subs,执行subs[i].update()方法,也就是依次调用watcherupdate方法。对应源码src/core/observer/watcher.js的 164 行
/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {  // 计算属性
    this.dirty = true
  } else if (this.sync) {  // 同步watcher
    this.run()
  } else {
    queueWatcher(this)  // 当数据发生变化时会将watcher放到一个队列中批量更新
  }
}
  • 第三步是执行update函数中的queueWatcher方法。对应源码src/core/observer/scheduler.js的 164 行。
/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id  // 过滤watcher,多个属性可能会依赖同一个watcher
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)  // 将watcher放到队列中
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)  // 调用nextTick方法,在下一个tick中刷新watcher队列
    }
  }
}
  • 第四步就是执行nextTick(flushSchedulerQueue)方法,在下一个tick中刷新watcher队列

谈一下nextTick的实现原理?

Vue.js在默认情况下,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,在下一个 tick 的时候将这个队列 queue 全部拿出来 runWatcher 对象的一个方法,用来触发 patch 操作) 一遍。

因为目前浏览器平台并没有实现 nextTick 方法,所以 Vue.js 源码中分别用 PromisesetTimeoutsetImmediate 等方式在 microtask(或是task)中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。

nextTick方法主要是使用了宏任务和微任务,定义了一个异步方法.多次调用nextTick 会将方法存入队列中,通过这个异步方法清空当前队列。

所以这个 nextTick 方法是异步方法。

通过一张图来看下nextTick的实现:

nextTick实现原理

  • 首先会调用nextTick并传入cb。对应源码src/core/util/next-tick.js的 87 行。
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
  • 接下来会定义一个callbacks 数组用来存储 nextTick,在下一个 tick 处理这些回调函数之前,所有的 cb 都会被存在这个 callbacks 数组中。
  • 下一步会调用timerFunc函数。对应源码src/core/util/next-tick.js的 33 行。
let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  timerFunc = () => {
    // ...
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {

  timerFunc = () => {
    // ...
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

来看下timerFunc的取值逻辑:

1、 我们知道异步任务有两种,其中 microtask 要优于 macrotask ,所以优先选择 Promise 。因此这里先判断浏览器是否支持 Promise

2、 如果不支持再考虑 macrotask 。对于 macrotask 会先后判断浏览器是否支持 MutationObserversetImmediate

3、 如果都不支持就只能使用 setTimeout 。这也从侧面展示出了 macrotasksetTimeout 的性能是最差的。

nextTickif (!pending) 语句中 pending 作用显然是让 if 语句的逻辑只执行一次,而它其实就代表 callbacks 中是否有事件在等待执行。

这里的flushCallbacks函数的主要逻辑就是将 pending 置为 false 以及清空 callbacks 数组,然后遍历 callbacks 数组,执行里面的每一个函数。

  • nextTick的最后一步对应:
if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

这里 if 对应的情况是我们调用 nextTick 函数时没有传入回调函数并且浏览器支持 Promise ,那么就会返回一个 Promise 实例,并且将 resolve 赋值给 _resolve。回到nextTick开头的一段代码:

let _resolve
callbacks.push(() => {
  if (cb) {
    try {
      cb.call(ctx)
    } catch (e) {
      handleError(e, ctx, 'nextTick')
    }
  } else if (_resolve) {
    _resolve(ctx)
  }
})

当我们执行 callbacks 的函数时,发现没有 cb 而有 _resolve 时就会执行之前返回的 Promise 对象的 resolve 函数。

你知道Vuecomputed是怎么实现的吗?

这里先给一个结论:计算属性computed的本质是 computed Watcher,其具有缓存。

一张图了解下computed的实现:

Vue中computed实现原理

  • 首先是在组件实例化时会执行initComputed方法。对应源码src/core/instance/state.js的 169 行。
const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}

initComputed 函数拿到 computed 对象然后遍历每一个计算属性。判断如果不是服务端渲染就会给计算属性创建一个 computed Watcher 实例赋值给watchers[key](对应就是vm._computedWatchers[key])。然后遍历每一个计算属性调用 defineComputed 方法,将组件原型,计算属性和对应的值传入。

  • defineComputed定义在源码src/core/instance/state.js210 行。
// src/core/instance/state.js
export function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering();
  if (typeof userDef === "function") {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef);
    sharedPropertyDefinition.set = noop;
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  if (
    process.env.NODE_ENV !== "production" &&
    sharedPropertyDefinition.set === noop
  ) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      );
    };
  }
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

首先定义了 shouldCache 表示是否需要缓存值。接着对 userDef 是函数或者对象分别处理。这里有一个 sharedPropertyDefinition ,我们来看它的定义:

// src/core/instance/state.js
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop,
};

sharedPropertyDefinition其实就是一个属性描述符。

回到 defineComputed 函数。如果 userDef 是函数的话,就会定义 getter 为调用 createComputedGetter(key) 的返回值。

因为 shouldCachetrue

userDef 是对象的话,非服务端渲染并且没有指定 cachefalse 的话,getter 也是调用 createComputedGetter(key) 的返回值,setter 则为 userDef.set 或者为空。

所以 defineComputed 函数的作用就是定义 gettersetter ,并且在最后调用 Object.defineProperty 给计算属性添加 getter/setter ,当我们访问计算属性时就会触发这个 getter

对于计算属性的 setter 来说,实际上是很少用到的,除非我们在使用 computed 的时候指定了 set 函数。

  • 无论是userDef是函数还是对象,最终都会调用createComputedGetter函数,我们来看createComputedGetter的定义:
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate();
      }
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value;
    }
  };
}

我们知道访问计算属性时才会触发这个 getter,对应就是computedGetter函数被执行。

computedGetter 函数首先通过 this._computedWatchers[key] 拿到前面实例化组件时创建的 computed Watcher 并赋值给 watcher

new Watcher时传入的第四个参数computedWatcherOptionslazytrue,对应就是watcher的构造函数中的dirtytrue。在computedGetter中,如果dirtytrue(即依赖的值没有发生变化),就不会重新求值。相当于computed被缓存了。

接着有两个 if 判断,首先调用 evaluate 函数:

/**
 * Evaluate the value of the watcher.
 * This only gets called for lazy watchers.
 */
evaluate () {
  this.value = this.get()
  this.dirty = false
}

首先调用 this.get() 将它的返回值赋值给 this.value ,来看 get 函数:

// src/core/observer/watcher.js
/**
 * Evaluate the getter, and re-collect dependencies.
 */
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get 函数第一步是调用 pushTargetcomputed Watcher 传入:

// src/core/observer/dep.js
export function pushTarget(target: ?Watcher) {
  targetStack.push(target);
  Dep.target = target;
}

可以看到 computed Watcher 被 push 到 targetStack 同时将 Dep.target 置为 computed Watcher 。而 Dep.target 原来的值是渲染 Watcher ,因为正处于渲染阶段。回到 get 函数,接着就调用了 this.getter

回到 evaluate 函数:

evaluate () {
  this.value = this.get()
  this.dirty = false
}

执行完get函数,将dirty置为false

回到computedGetter函数,接着往下进入另一个if判断,执行了depend函数:

// src/core/observer/watcher.js
/**
 * Depend on all deps collected by this watcher.
 */
depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

这里的逻辑就是让 Dep.target 也就是渲染 Watcher 订阅了 this.dep 也就是前面实例化 computed Watcher 时候创建的 dep 实例,渲染 Watcher 就被保存到 this.depsubs 中。

在执行完 evaluatedepend 函数后,computedGetter 函数最后将 evaluate 的返回值返回出去,也就是计算属性最终计算出来的值,这样页面就渲染出来了。

Update & UpdateQueue

Update & UpdateQueue

创建了RootFiber对象和FiberRoot对象之后,接下来就是处理更新。对应updateContainer:

// packages/react-reconciler/src/ReactFiberReconciler.new.js
export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function
): Lane {
  if (__DEV__) {
    onScheduleRoot(container, element);
  }
  const current = container.current;
  const eventTime = requestEventTime();
  if (__DEV__) {
    // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests
    if ("undefined" !== typeof jest) {
      warnIfUnmockedScheduler(current);
      warnIfNotScopedWithMatchingAct(current);
    }
  }
  const lane = requestUpdateLane(current);

  if (enableSchedulingProfiler) {
    markRenderScheduled(lane);
  }

  const context = getContextForSubtree(parentComponent);
  if (container.context === null) {
    container.context = context;
  } else {
    container.pendingContext = context;
  }

  if (__DEV__) {
    if (
      ReactCurrentFiberIsRendering &&
      ReactCurrentFiberCurrent !== null &&
      !didWarnAboutNestedUpdates
    ) {
      didWarnAboutNestedUpdates = true;
      console.error(
        "Render methods should be a pure function of props and state; " +
          "triggering nested component updates from render is not allowed. " +
          "If necessary, trigger nested updates in componentDidUpdate.\n\n" +
          "Check the render method of %s.",
        getComponentName(ReactCurrentFiberCurrent.type) || "Unknown"
      );
    }
  }

  const update = createUpdate(eventTime, lane);
  // Caution: React DevTools currently depends on this property
  // being called "element".
  update.payload = { element };

  callback = callback === undefined ? null : callback;
  if (callback !== null) {
    if (__DEV__) {
      if (typeof callback !== "function") {
        console.error(
          "render(...): Expected the last optional `callback` argument to be a " +
            "function. Instead received: %s.",
          callback
        );
      }
    }
    update.callback = callback;
  }

  enqueueUpdate(current, update);
  scheduleUpdateOnFiber(current, lane, eventTime);

  return lane;
}

可以看到通过createUpdate()函数创建出一个update对象。

首先需要明白Update对象到底是什么?它主要用于记录组件状态的改变,存放在UpdateQueue中,通过计算得到一个最终的组件更新状态。同时多个Update状态是可以同时存在的。

下面是createUpdate函数所做的事情,其实就是包装了一层转换成了其他数据结构返回。

export function createUpdate(eventTime: number, lane: Lane): Update<*> {
  const update: Update<*> = {
    eventTime,
    lane,

    tag: UpdateState,
    payload: null,
    callback: null,

    next: null,
  };
  return update;
}

其中tag属性对应四种情况,根据不同的情况执行不同的动作:

// packages/react-reconciler/src/ReactUpdateQueue.new.js
export const UpdateState = 0;
export const ReplaceState = 1;
export const ForceUpdate = 2;
export const CaptureUpdate = 3; // 捕获渲染错误时会生成一个Update

这里顺便来看下Update对象:

export type Update<State> = {|
  // TODO: Temporary field. Will remove this by storing a map of
  // transition -> event time on the root.
  // 更新的过期时间
  eventTime: number,
  lane: Lane,
  // export const UpdateState = 0;
  // export const ReplaceState = 1;
  // export const ForceUpdate = 2;
  // export const CaptureUpdate = 3;
  // 指定更新的类型,值为以上几种
  tag: 0 | 1 | 2 | 3,
  // 更新内容,比如`setState`接收的第一个参数
  payload: any,
  // 对应的回调,`setState`,`render`都有
  callback: (() => mixed) | null,
  // 指向下一个更新
  next: Update<State> | null,
|};

创建了Update对象后,将Update对象传入enqueueUpdate(),下面是enqueueUpdate()所做的事情:

export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
  // 存储执行中的更新任务 Update 队列,尾节点存储形式
  const updateQueue = fiber.updateQueue;
  if (updateQueue === null) {
    // Only occurs if the fiber has been unmounted.
    return;
  }
  //以 pending 属性存储待执行的更新任务 Update 队列,尾节点存储形式
  const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
  const pending = sharedQueue.pending;
  if (pending === null) {
    // This is the first update. Create a circular list.
    // 第一次更新,创建一个循环列表
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  sharedQueue.pending = update;

  if (__DEV__) {
    if (
      currentlyProcessingQueue === sharedQueue &&
      !didWarnUpdateInsideUpdate
    ) {
      console.error(
        "An update (setState, replaceState, or forceUpdate) was scheduled " +
          "from inside an update function. Update functions should be pure, " +
          "with zero side-effects. Consider using componentDidUpdate or a " +
          "callback."
      );
      didWarnUpdateInsideUpdate = true;
    }
  }
}

由上可以看出enqueueUpdate()函数主要是进行创建和更新UpdateQueue的操作。

Vue源码探秘(派发更新)

引言

在上一节,我们分析了响应式数据中依赖收集的过程,而收集依赖的目的就是在数据更新时会遍历订阅者并派发更新。这一节,让我们一起来看下派发更新的过程。

setter

当数据被修改时会触发setter,回顾setter函数:

// src/core/observer/index.js
/**
 * Define a reactive property on an Object.
 */
export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep();

  const property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return;
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get;
  const setter = property && property.set;
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key];
  }

  let childOb = !shallow && observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // ...
    },
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return;
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== "production" && customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return;
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    },
  });
}

这里setter做了两件事情:修改属性值和派发更新。

首先,setter会通过getter拿到修改前的值value和修改后的值newVal做比较,如果两者相同直接return(表示值没有发生变化)。

这里的newVal !== newVal && value !== value 表示的是 valuenewVal 都是 NaN ,因为 NaN 不等于自身。

接着调用了customSetter函数。

再接下来的这部分就是关键逻辑了:首先修改属性值,如果之前定义了setter则直接调用setter,如果没有则做一个赋值操作。

接下来的这句:

childOb = !shallow && observe(newVal);

其实在调用defineReactive函数时,他已经执行一遍了,这里再次执行是考虑到数据可能原来没有被观察,而现在被修改为数组或者纯对象,那就需要调用 observe 将它转换成为一个响应式对象。

最后的dep.notify()就是派发更新了,也正是本节要分析的重点。

派发更新

先来看 notify 函数的定义:

// src/core/observer/dep.js

export default class Dep {
  //...
  notify() {
    // stabilize the subscriber list first
    const subs = this.subs.slice();
    if (process.env.NODE_ENV !== "production" && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id);
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  }
}

这里首先拿到了subs的副本。

这里采用了Array.slice实现了数组的拷贝,其实还有很多方法可以实现。具体参考这里

之后的if判断中的config.async是一个全局配置,默认值为true,用来表示观察者是异步执行还是同步执行。

如果是同步执行,则需要在这里给 subs 中的 Watcherid 从小到大的顺序进行排序。这里为什么要有一个排序操作呢?先留个疑问,我们接着往下看。

接下来就是遍历 subs 中的 Watcher 并执行它们的 update 方法。我们来看 update 方法的定义:

// /src/core/observer/watcher.js

export default class Watcher {
  //...
  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update() {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true;
    } else if (this.sync) {
      this.run();
    } else {
      queueWatcher(this);
    }
  }
  // ...
}

这里的三条条件语句分别对应Watcher的不同状态。前两种分别对应计算属性同步Watcher,会在后面详细介绍。当前会走else逻辑执行queueWatcher函数。queueWatcher函数定义在src/core/observer/scheduler.js中:

// src/core/observer/scheduler.js

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
const queue: Array<Watcher> = [];
let has: { [key: number]: ?true } = {};
let waiting = false;
let flushing = false;
export function queueWatcher(watcher: Watcher) {
  const id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // queue the flush
    if (!waiting) {
      waiting = true;

      if (process.env.NODE_ENV !== "production" && !config.async) {
        flushSchedulerQueue();
        return;
      }
      nextTick(flushSchedulerQueue);
    }
  }
}

queueWatcher 函数会把传入的 Watcher 推入到一个异步的观察者队列 queue 中。

为什么要引入队列呢,实际上这是一个优化手段。它并不会每次数据更新时,就直接遍历 subs 执行 Watcher 的回调,而是将这些 Watcher 放入一个异步队列,调用 nextTick 来异步执行 flushSchedulerQueue

函数首先定义了一个对象 has 用于记录传入 Watcherid ,它的作用是避免同一个 Watcher 被重复推入队列。

这种情况什么时候会出现呢,比如我们同时修改了被同一个 Watcher 订阅的多个数据,那么会触发多个 setter ,也就会调用 queueWatcher 函数多次。

这里定义 flushfalse ,所以会走 if 逻辑将 Watcher push 到 queue 中。flush 的作用以及 else 的具体逻辑现在还不清楚,需要我们往下分析才知道。

最后的 if 语句中的 waiting 和上面的类似,也是为了保证 if 里面的逻辑只执行一次。这里再次遇到之前的同步异步的判断,如果是同步则直接执行 flushSchedulerQueue 函数,否则执行 nextTick(flushSchedulerQueue)

nextTick 方法的具体实现会在下一节详细介绍。接下来我们就来看 flushSchedulerQueue 函数的定义:

// src/core/observer/scheduler.js

function flushSchedulerQueue() {
  currentFlushTimestamp = getNow();
  flushing = true;
  let watcher, id;

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort((a, b) => a.id - b.id);

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    // ...
  }

  // ...
}

flushSchedulerQueue 函数一开始会将 flushing 置为 true ,然后对 queue 做一次按 id 从小到大的排列(也就是按从父到子的顺序排序)。注释说明了做这个排序的原因:

1、组件的更新顺序是从父到子,因为组件的创建顺序是从父到子。

2、组件的 user Watcher 会优先于渲染 Watcher 执行。(user Watcher 什么情况下出现呢,比如我们在组件对象中使用 watch 属性或者调用 $watch 方法时就会创建一个 user Watcher)。

3、如果一个组件在它的父组件的 Watcher 执行的时候被销毁,那这个组件对应的 Watcher 的执行可以被跳过。

回顾前面的 dep.notify() ,我们在那里提出了一个问题:为什么同步执行的 Watcher 要在 dep.notify() 就先排好顺序?

现在可以解开疑惑了:由于异步 WatcherflushSchedulerQueue 函数会在所有 Watcher 入列后才执行,所以可以正确排序,而同步 WatcherflushSchedulerQueue 函数是同步执行的,不会等所有 Watcher 入列后才执行,所以无法保证 queue 能正确排序,所以干脆在 dep 中就先把 subs 排好。

queue 排好序之后就要遍历 queue 中的 Watcher 。注意在 for 循环前的一段注释:

// do not cache length because more watchers might be pushed
// as we run existing watchers

注释的意思是不要缓存length(也就是不要在for循环前先获取queue.length)。这么做的原因是什么呢?

因为在执行 Watcher.run() 时可能有新的 Watcher 加入进来导致 queue 长度发生变化。而新加入的 Watcher 就又会执行到 queueWatcher 函数:

export function queueWatcher(watcher: Watcher) {
  const id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // ...
  }
}

这个时候 flushing 已经被修改为 true 了,所以会执行 else 逻辑。

其实在上面的分析中,我们只看了if逻辑:也就是一开始Watcher入队时。

当所有 Watcher 入队后会遍历执行回调。而在执行 Watcher 过程中可能会有新 Watcher 入队,这个时机就对应 else 逻辑。

else 中不能简单地将 Watcher push 到 queue 中,因为 queue 在遍历执行前已经排好了顺序,这里必须保证新插入的 Watcher 不会打乱顺序,所以需要计算出正确的插入位置。while 的判断逻辑是这样的:

i > index && queue[i].id > watcher.id;

i取值queue.length - 1,也就是queue的最后一位。所以 i > index 的意思是在 [index, i] 的区间从后往前找,然后一直找到 queue[i].id 小于(不可能等于) watcher.id 的位置,找到后将 Watcher 插入。

看完这块,我们回到flushSchedulerQueue函数,接着往下看下for循环的代码:

// src/core/observer/scheduler.js

// do not cache length because more watchers might be pushed
// as we run existing watchers
for (index = 0; index < queue.length; index++) {
  watcher = queue[index];
  if (watcher.before) {
    watcher.before();
  }
  id = watcher.id;
  has[id] = null;
  watcher.run();
  // in dev build, check and stop circular updates.
  if (process.env.NODE_ENV !== "production" && has[id] != null) {
    // ...
  }
}

这里的逻辑是如果 Watcher 定义了 before 就先执行 before ,接着将这个 Watcherhas 中对应的值置为null,然后调用 watcher.run 。我们来看 watcher.run 的定义:

// src/core/observer/watcher.js

export default class Watcher {
  // ...
  run() {
    if (this.active) {
      const value = this.get();
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        const oldValue = this.value;
        this.value = value;
        if (this.user) {
          try {
            this.cb.call(this.vm, value, oldValue);
          } catch (e) {
            handleError(
              e,
              this.vm,
              `callback for watcher "${this.expression}"`
            );
          }
        } else {
          this.cb.call(this.vm, value, oldValue);
        }
      }
    }
  }
  // ...
}

这里调用 this.get() 方法拿到 value ,在上一节我们有介绍过 get 函数,它会手动调用数据的 getter 拿到属性值返回。接着如果满足以下任一条件,就执行回调 this.cb ,同时把新旧值都传入:

  • value(新值)和this.value(旧值)不相等
  • value(新值)是一个对象
  • Watcher 是一个 deep Watcher

回到刚刚的for循环,watcher.run() 执行完后,还有一段 if 判断:

watcher.run();
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== "production" && has[id] != null) {
  circular[id] = (circular[id] || 0) + 1;
  if (circular[id] > MAX_UPDATE_COUNT) {
    warn(
      "You may have an infinite update loop " +
        (watcher.user
          ? `in watcher with expression "${watcher.expression}"`
          : `in a component render function.`),
      watcher.vm
    );
    break;
  }
}

这段逻辑是Vue针对死循环抛出的警告,并终止运行。

我们通过一个例子来说明这种情况:

// App.vue

<template>
  <div id="app">
    {{ msg }}
    <button @click="change">change</button>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        msg: 1
      }
    },
    methods: {
      change() {
        this.msg++;
      }
    },
    watch: {
      msg() {
        this.msg++;
      }
    }
  }
</script>

在这个例子中,我通过 watch 属性来观察 msg ,并且在触发的回调又修改了 msg ,这样就造成了死循环,此时 Vue 会抛出一个警告:

这段警告也正是 if 逻辑中的那一段警告。这样 for 循环所有逻辑都分析完了,回到 flushSchedulerQueue 函数,在 for 循环执行完后,还剩下这一段:

// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice();
const updatedQueue = queue.slice();

resetSchedulerState();

// call component updated and activated hooks
callActivatedHooks(activatedQueue);
callUpdatedHooks(updatedQueue);

// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
  devtools.emit("flush");
}

这里的 activatedChildrencallActivatedHooks 函数是和 keep-alive 相关的,所以会放在后面 keep-alive 的章节再来介绍。updatedQueue 则是拿到 queue 的一个副本,然后执行 callUpdatedHooks(updatedQueue) 。我们来看 callUpdatedHooks 的定义:

// src/core/observer/scheduler.js
function callUpdatedHooks(queue) {
  let i = queue.length;
  while (i--) {
    const watcher = queue[i];
    const vm = watcher.vm;
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, "updated");
    }
  }
}

可以看到,callUpdatedHooks 会遍历 queue 中的 Watcher ,如果是一个渲染 Watcher 并且实例已经 mounted 且未 destroyed ,则调用生命周期钩子函数 updated

回到 flushSchedulerQueue 函数,这里还调用了 resetSchedulerState 函数:

// src/core/observer/scheduler.js
/**
 * Reset the scheduler's state.
 */
function resetSchedulerState() {
  index = queue.length = activatedChildren.length = 0;
  has = {};
  if (process.env.NODE_ENV !== "production") {
    circular = {};
  }
  waiting = flushing = false;
}

resetSchedulerState 函数其实就是把相关变量恢复到初始值,同时将队列清空。

这样派发更新的整个流程就基本分析完了。

总结

到这里,我们已经把数据变化是怎么引起页面重新渲染的的流程分析完了,总结一下大概就是:

  • 派发更新,实际上就是当数据发生变化的时候,会触发 setter ,然后遍历在收集依赖过程收集到的观察者 Watcher,然后触发它们的 update方法
  • Vue 并不是简单的每次触发 setter 就遍历 Watcher ,而是会先把 Watcher 推入到队列中,然后在下一个 tick 执行 flush 方法。

下一节我们来看下nextTick的实现,这部分也是面试常会被问到的。

优雅的在vue中使用TypeScript

引言

近几年前端对 TypeScript的呼声越来越高,Typescript也成为了前端必备的技能。TypeScript 是 JS类型的超集,并支持了泛型、类型、命名空间、枚举等特性,弥补了 JS 在大型应用开发中的不足。

在单独学习 TypeScript时,你会感觉很多概念还是比较好理解的,但是和一些框架结合使用的话坑还是比较多的,例如使用 React、Vue 这些框架的时候与 TypeScript 的结合会成为一大障碍,需要去查看框架提供的.d.ts的声明文件中一些复杂类型的定义、组件的书写方式等都要做出不小的调整。

本篇文章主要是结合我的经验和大家聊一下如何在Vue中平滑的从js过渡到ts,阅读本文建议对 TypeScript 有一定了解,因为文中对于一些 TypeScript 的基础的知识不会有太过于详细的讲解。(具体可以参考官方文档https://www.w3cschool.cn/typescript/typescript-tutorial.html,官方文档就是最好的入门手册)

构建

通过官方脚手架构建安装

# 1. 如果没有安装 Vue CLI 就先安装
npm install --global @vue/cli

最新的Vue CLI工具允许开发者 使用 TypeScript 集成环境 创建新项目。

只需运行vue create my-app

然后,命令行会要求选择预设。使用箭头键选择 Manually select features。

接下来,只需确保选择了 TypeScript 和 Babel 选项,如下图:

然后配置其余设置,如下图:


设置完成 vue cli 就会开始安装依赖并设置项目。

目录解析

安装完成打开项目,你会发现集成 ts 后的项目目录结构是这样子的:

|-- ts-vue
    |-- .browserslistrc     # browserslistrc 配置文件 (用于支持 Autoprefixer)
    |-- .eslintrc.js        # eslint 配置
    |-- .gitignore
    |-- babel.config.js     # babel-loader 配置
    |-- package-lock.json
    |-- package.json        # package.json 依赖
    |-- postcss.config.js   # postcss 配置
    |-- README.md
    |-- tsconfig.json       # typescript 配置
    |-- vue.config.js       # vue-cli 配置
    |-- public              # 静态资源 (会被直接复制)
    |   |-- favicon.ico     # favicon图标
    |   |-- index.html      # html模板
    |-- src
    |   |-- App.vue         # 入口页面
    |   |-- main.ts         # 入口文件 加载组件 初始化等
    |   |-- shims-tsx.d.ts
    |   |-- shims-vue.d.ts
    |   |-- assets          # 主题 字体等静态资源 (由 webpack 处理加载)
    |   |-- components      # 全局组件
    |   |-- router          # 路由
    |   |-- store           # 全局 vuex store
    |   |-- styles          # 全局样式
    |   |-- views           # 所有页面
    |-- tests               # 测试

其实大致看下来,与之前用js构建的项目目录没有什么太大的不同,区别主要是之前 js 后缀的现在改为了ts后缀,还多了tsconfig.jsonshims-tsx.d.tsshims-vue.d.ts这几个文件,那这几个文件是干嘛的呢:

  • tsconfig.json: typescript配置文件,主要用于指定待编译的文件和定义编译选项
  • shims-tsx.d.ts: 允许.tsx 结尾的文件,在 Vue 项目中编写 jsx 代码
  • shims-vue.d.ts: 主要用于 TypeScript 识别.vue 文件,Ts 默认并不支持导入 vue 文件

使用

开始前我们先来了解一下在 vue 中使用 typescript 非常好用的几个库

  • vue-class-component: vue-class-component是一个 Class Decorator,也就是类的装饰器
  • vue-property-decorator: vue-property-decorator是基于 vue 组织里 vue-class-component 所做的拓展
    import { Vue, Component, Inject, Provide, Prop, Model, Watch, Emit, Mixins } from 'vue-property-decorator'
    
  • vuex-module-decorators: 用 typescript 写 vuex 很好用的一个库
    import { Module, VuexModule, Mutation, Action, MutationAction, getModule } from 'vuex-module-decorators'
    

组件声明

创建组件的方式变成如下

import { Component, Prop, Vue, Watch } from 'vue-property-decorator';

@Component
export default class Test extends Vue {

}

data 对象

import { Component, Prop, Vue, Watch } from 'vue-property-decorator';

@Component
export default class Test extends Vue {
  private name: string;
}

Prop 声明

@Prop({ default: false }) private isCollapse!: boolean;
@Prop({ default: true }) private isFirstLevel!: boolean;
@Prop({ default: "" }) private basePath!: string;
  • !: 表示一定存在,?: 表示可能不存在。这两种在语法上叫赋值断言
  • @prop(options: (PropOptions | Constructor[] | Constructor) = {})
    • PropOptions,可以使用以下选项:type,default,required,validator
    • Constructor[],指定 prop 的可选类型
    • Constructor,例如 String,Number,Boolean 等,指定 prop 的类型

method

js 下是需要在 method 对象中声明方法,现变成如下

public clickFunc(): void {
  console.log(this.name)
  console.log(this.msg)
}

Watch 监听属性

@Watch("$route", { immediate: true })
private onRouteChange(route: Route) {
  const query = route.query as Dictionary<string>;
  if (query) {
  this.redirect = query.redirect;
  this.otherQuery = this.getOtherQuery(query);
  }
}
  • @watch(path: string, options: WatchOptions = {})
    • options 包含两个属性 immediate?:boolean 侦听开始之后是否立即调用该回调函数 / deep?:boolean 被侦听的对象的属性被改变时,是否调用该回调函数
  • @watch('arr', { immediate: true, deep: true })
    onArrChanged(newValue: number[], oldValue: number[]) {}

computed 计算属性

public get allname() {
  return 'computed ' + this.name;
}

allname 是计算后的值,name 是被监听的值

生命周期函数

public created(): void {
  console.log('created');
}

public mounted():void{
  console.log('mounted')
}

emit 事件

import { Vue, Component, Emit } from 'vue-property-decorator'
@Component
export default class MyComponent extends Vue {
  count = 0
  @Emit()
  addToCount(n: number) {
      this.count += n
  }
  @Emit('reset')
  resetCount() {
      this.count = 0
  }
  @Emit()
  returnValue() {
      return 10
  }
  @Emit()
  onInputChange(e) {
      return e.target.value
  }
  @Emit()
  promise() {
      return new Promise(resolve => {
      setTimeout(() => {
          resolve(20)
      }, 0)
      })
  }
}

使用 js 写法

export default {
  data() {
      return {
      count: 0
      }
  },
  methods: {
      addToCount(n) {
      this.count += n
      this.$emit('add-to-count', n)
      },
      resetCount() {
      this.count = 0
      this.$emit('reset')
      },
      returnValue() {
      this.$emit('return-value', 10)
      },
      onInputChange(e) {
      this.$emit('on-input-change', e.target.value, e)
      },
      promise() {
      const promise = new Promise(resolve => {
          setTimeout(() => {
          resolve(20)
          }, 0)
      })
      promise.then(value => {
          this.$emit('promise', value)
      })
      }
  }
 }
  • @emit(event?: string)
  • @emit 装饰器接收一个可选参数,该参数是$Emit 的第一个参数,充当事件名。如果没有提供这个参数,$Emit 会将回调函数名的 camelCase 转为 kebab-case,并将其作为事件名
  • @emit 会将回调函数的返回值作为第二个参数,如果返回值是一个 Promise 对象,$emit 会在 Promise 对象被标记为 resolved 之后触发
  • @emit 的回调函数的参数,会放在其返回值之后,一起被$emit 当做参数使用

vuex

在使用 store 装饰器之前,先过一下传统的 store 用法吧

export default  {
    namespaced:true,
    state:{
        foo:""
    },
    getters:{
        getFoo(state){ return state.foo}
    },
    mutations:{
        setFooSync(state,payload){
            state.foo = payload
        }
    },
    actions:{
        setFoo({commit},payload){
            commot("getFoo",payload)
        }
    }
}

然后开始使用vuex-module-decorators

import {
  VuexModule,
  Mutation,
  Action,
  getModule,
  Module
} from "vuex-module-decorators";
  • VuexModule 用于基本属性

    export default class TestModule extends VuexModule { }
    

    VuexModule 提供了一些基本属性,包括 namespaced,state,getters,modules,mutations,actions,context

  • @Module 标记当前为 module

    @Module({ dynamic: true, store, name: "settings" })
    class Settings extends VuexModule implements ISettingsState {
    
    }
    

    module 本身有几种可以配置的属性:

    • namespaced:boolean 启/停用 分模块
    • stateFactory:boolean 状态工厂
    • dynamic:boolean 在 store 创建之后,再添加到 store 中。开启 dynamic 之后必须提供下面的属性
    • name:string 指定模块名称
    • store:Vuex.Store 实体 提供初始的 store
  • @Mutation 标注为 mutation

    @Mutation
    private SET_NAME(name: string) {
    // 设置用户名
    this.name = name;
    }
    
  • @Action 标注为 action

    @Action
    public async Login(userInfo: { username: string; password: string }) {
      // 登录接口,拿到token
      let { username, password } = userInfo;
      username = username.trim();
      const { data } = await login({ username, password });
      setToken(data.accessToken);
      this.SET_TOKEN(data.accessToken);
    }
    
  • getModule 得到一个类型安全的 store,module 必须提供 name 属性

    export const UserModule = getModule(User);
    

示例

我之前基于 ts+vue+element 构建了一个简单的中后台通用模板。


涵盖的功能如下:

- 登录 / 注销

- 权限验证
  - 页面权限
  - 权限配置

- 多环境发布
  - Dev / Stage / Prod

- 全局功能
  - 动态换肤
  - 动态侧边栏(支持多级路由嵌套)
  - Svg 图标
  - 全屏
  - 设置
  - Mock 数据 / Mock 服务器

- 组件
  - ECharts 图表

- 表格
  - 复杂表格

- 控制台
- 引导页
- 错误页面
  - 404

里面对于在 vue 中使用 typescript 的各种场景都有很好的实践,大家感兴趣的可以参考一下,https://github.com/easy-wheel/ts-vue,当然不要吝惜你的 star!!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!

你可能不知道的9条Webpack优化策略

引言

webpack的打包优化一直是个老生常谈的话题,常规的无非就分块、拆包、压缩等。

本文以我自己的经验向大家分享如何通过一些分析工具、插件以及webpack新版本中的一些新特性来显著提升webpack的打包速度和改善包体积,学会分析打包的瓶颈以及问题所在。

本文演示代码,仓库地址

速度分析 🏂

webpack 有时候打包很慢,而我们在项目中可能用了很多的 pluginloader,想知道到底是哪个环节慢,下面这个插件可以计算 pluginloader 的耗时。

yarn add -D speed-measure-webpack-plugin

配置也很简单,把 webpack 配置对象包裹起来即可:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
});

来看下在项目中引入speed-measure-webpack-plugin后的打包情况:

从上图可以看出这个插件主要做了两件事情:

  • 计算整个打包总耗时
  • 分析每个插件和 loader 的耗时情况
    知道了具体loaderplugin的耗时情况,我们就可以“对症下药”了

体积分析 🎃

打包后的体积优化是一个可以着重优化的点,比如引入的一些第三方组件库过大,这时就要考虑是否需要寻找替代品了。

这里采用的是webpack-bundle-analyzer,也是我平时工作中用的最多的一款插件了。

它可以用交互式可缩放树形图显示webpack输出文件的大小。用起来非常的方便。

首先安装插件:

yarn add -D webpack-bundle-analyzer

安装完在webpack.config.js中简单的配置一下:

const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
    //  可以是`server`,`static`或`disabled`。
    //  在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。
    //  在“静态”模式下,会生成带有报告的单个HTML文件。
    //  在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。
    analyzerMode: "server",
    //  将在“服务器”模式下使用的主机启动HTTP服务器。
    analyzerHost: "127.0.0.1",
    //  将在“服务器”模式下使用的端口启动HTTP服务器。
    analyzerPort: 8866,
    //  路径捆绑,将在`static`模式下生成的报告文件。
    //  相对于捆绑输出目录。
    reportFilename: "report.html",
    //  模块大小默认显示在报告中。
    //  应该是`stat`,`parsed`或者`gzip`中的一个。
    //  有关更多信息,请参见“定义”一节。
    defaultSizes: "parsed",
    //  在默认浏览器中自动打开报告
    openAnalyzer: true,
    //  如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成
    generateStatsFile: false,
    //  如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。
    //  相对于捆绑输出目录。
    statsFilename: "stats.json",
    //  stats.toJson()方法的选项。
    //  例如,您可以使用`source:false`选项排除统计文件中模块的来源。
    //  在这里查看更多选项:https:  //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
    statsOptions: null,
    logLevel: "info"
  )
  ]
}

然后在命令行工具中输入npm run dev,它默认会起一个端口号为 8888 的本地服务器:

图中的每一块清晰的展示了组件、第三方库的代码体积。

有了它,我们就可以针对体积偏大的模块进行相关优化了。

多进程/多实例构建 🏈

大家都知道 webpack 是运行在 node 环境中,而 node 是单线程的。webpack 的打包过程是 io 密集和计算密集型的操作,如果能同时 fork 多个进程并行处理各个任务,将会有效的缩短构建时间。

平时用的比较多的两个是thread-loaderHappyPack

先来看下thread-loader吧,这个也是webpack4官方所推荐的。

thread-loader

安装

yarn add -D thread-loader

thread-loader 会将你的 loader 放置在一个 worker 池里面运行,以达到多线程构建。

把这个 loader 放置在其他 loader 之前(如下面示例的位置), 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行。

示例

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // your expensive loader (e.g babel-loader)
        ]
      }
    ]
  }
}

HappyPack

安装

yarn add -D happypack

HappyPack 可以让 Webpack 同一时间处理多个任务,发挥多核 CPU 的能力,将任务分解给多个子进程去并发的执行,子进程处理完后,再把结果发送给主进程。通过多进程模型,来加速代码构建。

示例

// webpack.config.js
const HappyPack = require('happypack');

exports.module = {
  rules: [
    {
      test: /.js$/,
      // 1) replace your original list of loaders with "happypack/loader":
      // loaders: [ 'babel-loader?presets[]=es2015' ],
      use: 'happypack/loader',
      include: [ /* ... */ ],
      exclude: [ /* ... */ ]
    }
  ]
};

exports.plugins = [
  // 2) create the plugin:
  new HappyPack({
    // 3) re-add the loaders you replaced above in #1:
    loaders: [ 'babel-loader?presets[]=es2015' ]
  })
];

这里有一点需要说明的是,HappyPack的作者表示已不再维护此项目,这个可以在github仓库看到:

作者也是推荐使用webpack官方提供的thread-loader

thread-loaderhappypack 对于小型项目来说打包速度几乎没有影响,甚至可能会增加开销,所以建议尽量在大项目中采用。

多进程并行压缩代码 🛵

通常我们在开发环境,代码构建时间比较快,而构建用于发布到线上的代码时会添加压缩代码这一流程,则会导致计算量大耗时多。

webpack默认提供了UglifyJS插件来压缩JS代码,但是它使用的是单线程压缩代码,也就是说多个js文件需要被压缩,它需要一个个文件进行压缩。所以说在正式环境打包压缩代码速度非常慢(因为压缩JS代码需要先把代码解析成用Object抽象表示的AST语法树,再应用各种规则分析和处理AST,导致这个过程耗时非常大)。

所以我们要对压缩代码这一步骤进行优化,常用的做法就是多进程并行压缩。

目前有三种主流的压缩方案:

  • parallel-uglify-plugin
  • uglifyjs-webpack-plugin
  • terser-webpack-plugin

parallel-uglify-plugin

上面介绍的HappyPack的**是使用多个子进程去解析和编译JS,CSS等,这样就可以并行处理多个子任务,多个子任务完成后,再将结果发到主进程中,有了这个**后,ParallelUglifyPlugin 插件就产生了。

webpack有多个JS文件需要输出和压缩时,原来会使用UglifyJS去一个个压缩并且输出,而ParallelUglifyPlugin插件则会开启多个子进程,把对多个文件压缩的工作分给多个子进程去完成,但是每个子进程还是通过UglifyJS去压缩代码。并行压缩可以显著的提升效率。

安装

yarn add -D webpack-parallel-uglify-plugin

示例

import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';

module.exports = {
  plugins: [
    new ParallelUglifyPlugin({
      // Optional regex, or array of regex to match file against. Only matching files get minified.
      // Defaults to /.js$/, any file ending in .js.
      test,
      include, // Optional regex, or array of regex to include in minification. Only matching files get minified.
      exclude, // Optional regex, or array of regex to exclude from minification. Matching files are not minified.
      cacheDir, // Optional absolute path to use as a cache. If not provided, caching will not be used.
      workerCount, // Optional int. Number of workers to run uglify. Defaults to num of cpus - 1 or asset count (whichever is smaller)
      sourceMap, // Optional Boolean. This slows down the compilation. Defaults to false.
      uglifyJS: {
        // These pass straight through to uglify-js@3.
        // Cannot be used with uglifyES.
        // Defaults to {} if not neither uglifyJS or uglifyES are provided.
        // You should use this option if you need to ensure es5 support. uglify-js will produce an error message
        // if it comes across any es6 code that it can't parse.
      },
      uglifyES: {
        // These pass straight through to uglify-es.
        // Cannot be used with uglifyJS.
        // uglify-es is a version of uglify that understands newer es6 syntax. You should use this option if the
        // files that you're minifying do not need to run in older browsers/versions of node.
      }
    }),
  ],
};

webpack-parallel-uglify-plugin已不再维护,这里不推荐使用

uglifyjs-webpack-plugin

安装

yarn add -D uglifyjs-webpack-plugin

示例

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  plugins: [
    new UglifyJsPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        ie8: false
      },
      parallel: true
    })
  ]
};

其实它和上面的parallel-uglify-plugin类似,也可通过设置parallel: true开启多进程压缩。

terser-webpack-plugin

不知道你有没有发现:webpack4 已经默认支持 ES6语法的压缩。

而这离不开terser-webpack-plugin

安装

yarn add -D terser-webpack-plugin

示例

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: 4,
      }),
    ],
  },
};

预编译资源模块 🚀

什么是预编译资源模块?

在使用webpack进行打包时候,对于依赖的第三方库,比如vuevuex等这些不会修改的依赖,我们可以让它和我们自己编写的代码分开打包,这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。

那么第三方库在第一次打包的时候只打包一次,以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样的可以快速的提高打包的速度。其实也就是预编译资源模块

webpack中,我们可以结合DllPluginDllReferencePlugin插件来实现。

DllPlugin是什么?

它能把第三方库代码分离开,并且每次文件更改的时候,它只会打包该项目自身的代码。所以打包速度会更快。

DLLPlugin 插件是在一个额外独立的webpack设置中创建一个只有dllbundle,也就是说我们在项目根目录下除了有webpack.config.js,还会新建一个webpack.dll.js文件。

webpack.dll.js的作用是把所有的第三方库依赖打包到一个bundledll文件里面,还会生成一个名为 manifest.json文件。该manifest.json的作用是用来让 DllReferencePlugin 映射到相关的依赖上去的。

DllReferencePlugin又是什么?

这个插件是在webpack.config.js中使用的,该插件的作用是把刚刚在webpack.dll.js中打包生成的dll文件引用到需要的预编译的依赖上来。

什么意思呢?就是说在webpack.dll.js中打包后比如会生成 vendor.dll.js文件和vendor-manifest.json文件,vendor.dll.js文件包含了所有的第三方库文件,vendor-manifest.json文件会包含所有库代码的一个索引,当在使用webpack.config.js文件打包DllReferencePlugin插件的时候,会使用该DllReferencePlugin插件读取vendor-manifest.json文件,看看是否有该第三方库。

vendor-manifest.json文件就是一个第三方库的映射而已。

怎么在项目中使用?

上面说了这么多,主要是为了方便大家对于预编译资源模块DllPlugin 和、DllReferencePlugin插件作用的理解(我第一次使用看了好久才明白~~)

先来看下完成的项目目录结构:

主要在两块配置,分别是webpack.dll.jswebpack.config.js(对应这里我是webpack.base.js

webpack.dll.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    vendors: ['lodash', 'jquery'],
    react: ['react', 'react-dom']
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, './dll'),
    library: '[name]'
  },
  plugins: [
    new webpack.DllPlugin({
      name: '[name]',
      path: path.resolve(__dirname, './dll/[name].manifest.json')
    })
  ]
}

这里我拆了两部分:vendors(存放了lodashjquery等)和react(存放了 react 相关的库,reactreact-dom等)

webpack.config.js(对应我这里就是webpack.base.js)

const path = require("path");
const fs = require('fs');
// ...
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');
const webpack = require('webpack');

const plugins = [
  // ...
];

const files = fs.readdirSync(path.resolve(__dirname, './dll'));
files.forEach(file => {
  if(/.*\.dll.js/.test(file)) {
    plugins.push(new AddAssetHtmlWebpackPlugin({
      filepath: path.resolve(__dirname, './dll', file)
    }))
  }
  if(/.*\.manifest.json/.test(file)) {
    plugins.push(new webpack.DllReferencePlugin({
      manifest: path.resolve(__dirname, './dll', file)
    }))
  }
})

module.exports = {
  entry: {
    main: "./src/index.js"
  },
  module: {
    rules: []
  },
  plugins,

  output: {
    // publicPath: "./",
    path: path.resolve(__dirname, "dist")
  }
}

这里为了演示省略了很多代码,项目完整代码在这里

由于上面我把第三方库做了一个拆分,所以对应生成也就会是多个文件,这里读取了一下文件,做了一层遍历。

最后在package.json里面再添加一条脚本就可以了:

"scripts": {
    "build:dll": "webpack --config ./webpack.dll.js",
  },

运行yarn build:dll就会生成本小节开头贴的那张项目结构图了~

利用缓存提升二次构建速度 🍪

一般来说,对于静态资源,我们都希望浏览器能够进行缓存,那样以后进入页面就可以直接使用缓存资源,页面打开速度会显著加快,既提高了用户的体验也节省了宽带资源。

当然浏览器缓存方法有很多种,这里只简单讨论下在webpack中如何利用缓存来提升二次构建速度。

webpack中利用缓存一般有以下几种思路:

  • babel-loader开启缓存
  • 使用cache-loader
  • 使用hard-source-webpack-plugin

babel-loader

babel-loader在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积冗余,同时也会减慢编译效率。

可以加上cacheDirectory参数开启缓存:

 {
    test: /\.js$/,
    exclude: /node_modules/,
    use: [{
      loader: "babel-loader",
      options: {
        cacheDirectory: true
      }
    }],
  },

cache-loader

在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里。

安装

yarn add -D cache-loader

使用

cache-loader 的配置很简单,放在其他 loader 之前即可。修改Webpack 的配置如下:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: [
          'cache-loader',
          ...loaders
        ],
        include: path.resolve('src')
      }
    ]
  }
}

请注意,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader

hard-source-webpack-plugin

HardSourceWebpackPlugin 为模块提供了中间缓存,缓存默认的存放路径是: node_modules/.cache/hard-source

配置 hard-source-webpack-plugin后,首次构建时间并不会有太大的变化,但是从第二次开始,构建时间大约可以减少 80%左右。

安装

yarn add -D hard-source-webpack-plugin

使用

// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

webpack5中会内置hard-source-webpack-plugin

缩小构建目标/减少文件搜索范围 🍋

有时候我们的项目中会用到很多模块,但有些模块其实是不需要被解析的。这时我们就可以通过缩小构建目标或者减少文件搜索范围的方式来对构建做适当的优化。

缩小构建目标

主要是excludeinclude的使用:

  • exclude: 不需要被解析的模块
  • include: 需要被解析的模块
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        // include: path.resolve('src'),
        use: ['babel-loader']
      }
    ]
  }

这里babel-loader就会排除对node_modules下对应 js 的解析,提升构建速度。

减少文件搜索范围

这个主要是resolve相关的配置,用来设置模块如何被解析。通过resolve的配置,可以帮助Webpack快速查找依赖,也可以替换对应的依赖。

  • resolve.modules:告诉 webpack 解析模块时应该搜索的目录
  • resolve.mainFields:当从 npm 包中导入模块时(例如,import * as React from 'react'),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同
  • resolve.mainFiles:解析目录时要使用的文件名,默认是index
  • resolve.extensions:文件扩展名
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js')
    }, //直接指定react搜索模块,不设置默认会一层层的搜寻
    modules: [path.resolve(__dirname, 'node_modules')], //限定模块路径
    extensions: ['.js'], //限定文件扩展名
    mainFields: ['main'] //限定模块入口文件名

动态 Polyfill 服务 🦑

介绍动态Polyfill前,我们先来看下什么是babel-polyfill

什么是 babel-polyfill?

babel只负责语法转换,比如将ES6的语法转换成ES5。但如果有些对象、方法,浏览器本身不支持,比如:

  • 全局对象:PromiseWeakMap 等。
  • 全局静态函数:Array.fromObject.assign 等。
  • 实例方法:比如 Array.prototype.includes 等。

此时,需要引入babel-polyfill来模拟实现这些对象、方法。

这种一般也称为垫片

怎么使用babel-polyfill

使用也非常简单,在webpack.config.js文件作如下配置就可以了:

module.exports = {
  entry: ["@babel/polyfill", "./app/js"],
};

为什么还要用动态Polyfill

babel-polyfill由于是一次性全部导入整个polyfill,所以用起来很方便,但与此同时也带来了一个大问题:文件很大,所以后续的方案都是针对这个问题做的优化。

来看下打包后babel-polyfill的占比:

占比 29.6%,有点太大了!

介于上述原因,动态Polyfill服务诞生了。
通过一张图来了解下Polyfill Service的原理:

每次打开页面,浏览器都会向Polyfill Service发送请求,Polyfill Service识别 User Agent,下发不同的 Polyfill,做到按需加载Polyfill的效果。

怎么使用动态Polyfill服务?

采用官方提供的服务地址即可:

//访问url,根据User Agent 直接返回浏览器所需的 polyfills
https://polyfill.io/v3/polyfill.min.js

Scope Hoisting 🦁

什么是Scope Hoisting

Scope hoisting 直译过来就是「作用域提升」。熟悉 JavaScript 都应该知道「函数提升」和「变量提升」,JavaScript 会把函数和变量声明提升到当前作用域的顶部。「作用域提升」也类似于此,webpack 会把引入的 js 文件“提升到”它的引入者顶部。

Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快。

启用Scope Hoisting

要在 Webpack 中使用 Scope Hoisting 非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:

// webpack.config.js
const webpack = require('webpack')

module.exports = mode => {
  if (mode === 'production') {
    return {}
  }

  return {
    devtool: 'source-map',
    plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
  }
}

启用Scope Hoisting后的对比

让我们先来看看在没有 Scope Hoisting 之前 Webpack 的打包方式。

假如现在有两个文件分别是

  • constant.js:
export default 'Hello,Jack-cool';
  • 入口文件 main.js:
import str from './constant.js';
console.log(str);

以上源码用 Webpack 打包后的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__constant_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__constant_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Jack-cool');
  })
]

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var constant = ('Hello,Jack-cool');
    console.log(constant);
  })
]

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了一个,constant.js 中定义的内容被直接注入到了 main.js 对应的模块中。 这样做的好处是:

  • 代码体积更小,因为函数申明语句会产生大量代码;
  • 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小。

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。

参考

极客时间 【玩转 webpack】

❤️ 爱心三连击

1.如果觉得这篇文章还不错,就帮忙点赞、分享一下吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻

从图片裁剪来聊聊前端二进制

写在最前面(不看也不会少一个亿)

最开始的一个小需求

前两天项目中有个小需求:前端下载后台小哥返回的二进制流文件。

起初接到这个需求时,我感觉这很简单啊(虽然我不会,但可以百度啊,,,,)
表情18

然后就写出了如下的代码:

let blob = new Blob([res.data]);
let fileName = `Cosen.csv`;
if (window.navigator.msSaveOrOpenBlob) {
  navigator.msSaveBlob(blob, fileName);
} else {
  let link = document.createElement("a");
  let evt = document.createEvent("HTMLEvents");
  evt.initEvent("click", false, false);
  link.href = URL.createObjectURL(blob);
  link.download = fileName;
  link.style.display = "none";
  document.body.appendChild(link);
  link.click();
  window.URL.revokeObjectURL(link.href);
}

这一段代码,我大概强行解释一下:

首先判断window.navigator.msSaveOrOpenBlob是为了兼容IE(谁要兼容这 xxIE!!)

然后非IE的通过URL.createObjectURL()Blob(Blob是啥?不知道?没关系,我下面会具体装逼讲解的)构建为一个object URL对象、指定文件名&文件类型、创建a链接模拟点击实现下载,最后通过URL.revokeObjectURL释放创建的对象。

功能虽然实现了,但其实我是似懂非懂的~
表情19

紧接着 一个不那么简单的需求

没过几天,产品又给我提了一个需求:图片裁剪上传及预览。

虽然听过类似的需求,但自己手写还真的没写过,然后我就开始了网上冲浪时光(各种搜索,,,)。但这次,没有想象中那么简单了~~

网上看到的都是诸如FileReadercanvasArrayBufferFormDataBlob这些名词。我彻底懵了,这些平时都只是听过啊,用的也不多啊。经过了一番学习,我发现这些都属于前端二进制的知识范畴,所以在搞业务前,我准备先把涉及到的前端二进制梳理一遍,正所谓:底层基础决定上层建筑嘛 🙈
表情20

FileReader

HTML5定义了FileReader作为文件API的重要成员用于读取文件,根据W3C的定义,FileReader接口提供了读取文件的方法和包含读取结果的事件模型。

创建实例

var reader = new FileReader();

方法

方法名 描述
abort 中止读取操作
readAsArrayBuffer 异步按字节读取文件内容,结果用 ArrayBuffer 对象表示
readAsBinaryString 异步按字节读取文件内容,结果为文件的二进制串
readAsDataURL 异步读取文件内容,结果用 data:url 的字符串形式表示
readAsText 异步按字符读取文件内容,结果用字符串形式表示

事件

事件名 描述
onabort 中断时触发
onerror 出错时触发
onload 文件读取成功完成时触发
onloadend 读取完成触发(无论成功或失败)
onloadstart 读取开始时触发
onprogress 读取中

示例

下面我们尝试把一个文件的内容通过字符串的方式读取出来:

<input type="file" id='upload' />


document.getElementById('upload').addEventListener('change', function (e) {
    var file = this.files[0];
    const reader = new FileReader();
    reader.onload = function () {
        const result = reader.result;
        console.log(result);
    }
    reader.readAsText(file);
}, false);

ArrayBuffer/TypedArray/DataView 对象

ArrayBuffer

先来看下ArrayBuffer的功能:
ArrayBuffer功能

先来介绍ArrayBuffer ,是因为 FileReader 有个 readAsArrayBuffer()的方法,如果被读的文件是二进制数据,那用这个方法去读应该是最合适的,读出来的数据,就是一个 Arraybuffer 对象,来看下定义:

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区.ArrayBuffer 不能直接操作,而是要通过类型数组对象或 DataView 对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容.

ArrayBuffer也是一个构造函数,可以分配一段可以存放数据的连续内存区域。

const buffer = new ArrayBuffer(8);
// ArrayBuffer 对象有实例属性 byteLength ,表示当前实例占用的内存字节长度(单位字节)
console.log(buffer.byteLength);

由于无法对 Arraybuffer 直接进行操作,所以我们需要借助其他对象来操作. 所有就有了 TypedArray(类型数组对象)和 DataView对象。

DataView 对象

上面代码生成了一段 8 字节的内存区域,每个字节的值默认都是 0。

为了读写这段内容,需要为它指定视图。DataView视图的创建,需要提供ArrayBuffer对象实例作为参数。

DataView视图是一个可以从二进制ArrayBuffer对象中读写多种数值类型的底层接口。

  • setint8()DataView起始位置以byte为计数的指定偏移量(byteOffset)处存储一个8-bit数(一个字节)
  • getint8()DataView起始位置以byte为计数的指定偏移量(byteOffset)处获取一个8-bit数(一个字节)

调用

new DataView(buffer, [, byteOffset [, byteLength]])

示例

let buffer = new ArrayBuffer(2);
console.log(buffer.byteLength); // 2
let dataView = new DataView(buffer);
dataView.setInt(0, 1);
dataView.setInt(1, 2);
console.log(dataView.getInt8(0)); // 1
console.log(dataView.getInt8(1)); // 2
console.log(dataView.getInt16(0)); // 258

表情22

TypedArray

另一种TypedArray视图,与DataView视图的一个区别是,它不是一个构造函数,而是一组构造函数,代表不同的数据格式。

TypedArray对象描述了一个底层的二进制数据缓存区(binary data buffer)的一个类数组视图(view)。

但它本身不可以被实例化,甚至无法访问,你可以把它理解为接口,它有很多的实现。

实现方法

类型 单个元素值的范围 大小(bytes) 描述
Int8Array -128 to 127 1 8 位二进制有符号整数
Uint8Array 0 to 255 1 8 位无符号整数
Int16Array -32768 to 32767 2 16 位二进制有符号整数
Uint16Array 0 to 65535 2 16 位无符号整数

示例

const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength); // 8
const int8Array = new Int8Array(buffer);
console.log(int8Array.length); // 8
const int16Array = new Int16Array(buffer);
console.log(int16Array.length); // 4

Blob

Blob是用来支持文件操作的。简单的说:在JS中,有两个构造函数 FileBlob, 而File继承了所有Blob的属性。

所以在我们看来,File对象可以看作一种特殊的Blob对象。

上面说了,File对象是一种特殊的Blob对象,那么它自然就可以直接调用Blob对象的方法。让我们看一看Blob具体有哪些方法,以及能够用它们实现哪些功能:

Blob

是的,我们这里更加倾向于实战中的应用~

表情23

关于Blob的更具体介绍可以参考Blob

atobbtoa

base64 相信大家都不会陌生吧(不知道的看这里),最常用的操作可能就是图片转 base64 了吧?

在之前要在字符串跟base64之间互转,我们可能需要去网上拷一个别人的方法,而且大部分情况下,你没有时间去验证这个方法是不是真的可靠,有没有bug

IE10+浏览器开始,所有浏览器就原生提供了Base64编码解码方法。

Base64 解码

var decodedData = window.atob(encodedData);

Base64 编码

var encodedData = window.btoa(stringToEncode);

Canvas中的ImageData对象

关于Canvas,这里我就不做过多介绍了,具体可参考canvas 文档

今天主要说一下Canvas中的ImageData对象(也是为下面的那个图片裁剪的项目做一些基础知识的铺垫~)
表情16

ImageData对象中存储着canvas对象真实的像素数据,它包含以下几个只读属性:

  • width:图片宽度,单位是像素
  • height:图片高度,单位是像素
  • dataUint8ClampedArray类型的一维数组,包含着RGBA格式的整型数据,范围在 0 至 255 之间(包括 255)。

创建一个ImageData对象

使用createImageData() 方法去创建一个新的,空白的ImageData对象。

var myImageData = ctx.createImageData(width, height);

上面代码创建了一个新的具体特定尺寸的ImageData对象。所有像素被预设为透明黑。

得到场景像素数据

为了获得一个包含画布场景像素数据的ImageData对象,你可以用getImageData()方法:

var myImageData = ctx.getImageData(left, top, width, height);

在场景中写入像素数据

你可以用putImageData()方法去对场景进行像素数据的写入。

ctx.putImageData(myImageData, dx, dy);

toDataURLcanvas转为 data URI格式

有如下<canvas>元素:

<canvas id="canvas" width="5" height="5"></canvas>

可以用下面的方式获取一个data-URL

var canvas = document.getElementById("canvas");
var dataURL = canvas.toDataURL();
console.log(dataURL);
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"

到这里,二进制相关的基础知识我已经铺垫完了。下面让我们回到文章开头提到的那个产品的“没那么简单”的新需求:图片裁剪上传及预览。

其实,像图片裁剪上传这种社区已经有非常成熟的解决方案了,如vue-cropper。这里,我选择手写一个简易的图片裁剪的目的是因为这其中用到了上文提及的大量的二进制知识,可以很好的将理论与实践结合。

话不多说,开 Giao!!
表情17

需求开发 Giao Giao!

先来看下最终的效果:
图片裁剪上传

这里贴下完成后的代码地址

另外,我用一张图梳理了以上提到的前端二进制模块的关系,这对于下面需求的开发会有很大的帮助:
前端二进制体系

整个需求分以下四步:

1、获取文件并读取文件。

2、获取裁剪坐标。

3、裁剪图片。

4、读取裁剪后的图片预览并上传。

获取文件并读取文件

首先来看下上面第一步提到的获取文件。对应就是给input绑定的handleChange事件:

handleChange = (event) => {
  let file = event.target.files[0];
  let fileReader = new FileReader();
  fileReader.onload = (event) => {
    this.setState({
      file,
      dataURL: event.target.result,
    });
    this.imageRef.current.onload = () => this.drawImage();
  };
  fileReader.readAsDataURL(file);
};

HTML5 支持从 input[type=file] 元素中直接获取文件信息,也可以读取文件内容。

这里就需要用到了 FileReader ,这个类是专门用来读取本地文件的。纯文本或者二进制都可以读取,但是本地文件必须是经过用户允许才能读取,也就是说用户要在input[type=file]中选择了这个文件,你才能读取到它。

通过 FileReader 我们可以将图片文件转化成 DataURL,就是以 data:image/png;base64开头的一种URL,然后可以直接放在 image.src 里,这样本地图片就显示出来了。

获取裁剪坐标

这里主要是mousedownmousemovemouseup事件的结合使用。

mousedown

鼠标按下事件。这里要记录下鼠标按下时的开始坐标,即startXstartY,同时要将标志位startDrag设为true,标识鼠标开始移动。

handleMouseDown = (event) => {
  this.setState({
    startX: event.clientX,
    startY: event.clientY,
    startDrag: true,
  });
};

mousemove

鼠标移动事件。判断startDragtrue(即鼠标开始移动),然后记录对应移动的距离。

handleMouseMove = (event) => {
  if (this.state.startDrag) {
    this.drawImage(
      event.clientX - this.state.startX + this.state.lastX,
      event.clientY - this.state.startY + this.state.lastY
    );
  }
};

mouseup

鼠标弹起事件。这里要记录下最终鼠标的落点坐标,对应就是lastXlastY

handleMouseUp = (event) => {
  this.setState({
    lastX: event.clientX - this.state.startX + this.state.lastX,
    lastY: event.clientY - this.state.startY + this.state.lastY,
    startDrag: false,
  });
};

裁剪图片

这个时候我们就需要用到canvas了,canvas和图片一样,所以新建canvas时就要确定其高宽。

将图片放置入canvas时需要调用drawImage

drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

具体API使用参考MDN上的drawImage

drawImage = (left = this.state.lastX, top = this.state.lastY) => {
  let image = this.imageRef.current;
  let canvas = this.canvasRef.current;
  let ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  let imageWidth = image.width;
  let imageHeight = image.height;
  if (imageWidth > imageHeight) {
    let scale = canvas.width / canvas.height;
    imageWidth = canvas.width * this.state.times;
    imageHeight = imageHeight * scale * this.state.times;
  } else {
    let scale = canvas.height / canvas.width;
    imageHeight = canvas.height * this.state.times;
    imageWidth = imageWidth * scale * this.state.times;
  }
  ctx.drawImage(
    image,
    (canvas.width - imageWidth) / 2 + left,
    (canvas.height - imageHeight) / 2 + top,
    imageWidth,
    imageHeight
  );
};

其中这里面我们还加入了scale,这个变量是用来实现图片放大缩小效果的。

而且会判断图片的宽、高的大小关系,从而实现图片在canvas中对应的适配。

读取裁剪后的图片并上传

这时我们要获取canvas中图片的信息,用toDataURL就可以转换成上面用到的DataURL

confirm = () => {
  let canvas = this.canvasRef.current;
  let ctx = canvas.getContext("2d");
  const imageData = ctx.getImageData(100, 100, 100, 100);
  let avatarCanvas = document.createElement("canvas");
  avatarCanvas.width = 100;
  avatarCanvas.height = 100;
  let avatarCtx = avatarCanvas.getContext("2d");
  avatarCtx.putImageData(imageData, 0, 0);
  let avatarDataUrl = avatarCanvas.toDataURL();
  this.setState({ avatarDataUrl });
  this.avatarRef.current.src = avatarDataUrl;
};

然后取出其中base64信息,再用window.atob转换成由二进制字符串。但window.atob转换后的结果仍然是字符串,直接给Blob还是会出错。所以又要用Uint8Array转换一下。

这时候裁剪后的文件就储存在blob里了,我们可以把它当作是普通文件一样,加入到FormData里,并上传至服务器了。

upload = (event) => {
  // console.log("文件url", this.state.avatarDataUrl);
  let bytes = atob(this.state.avatarDataUrl.split(",")[1]);
  console.log("bytes", bytes);
  let arrayBuffer = new ArrayBuffer(bytes.length);
  let uInt8Array = new Uint8Array();
  for (let i = 0; i < bytes.length; i++) {
    uInt8Array[i] = bytes.charCodeAt[i];
  }
  let blob = new Blob([arrayBuffer], { type: "image/png" });
  let xhr = new XMLHttpRequest();
  let formData = new FormData();
  formData.append("avatar", blob);
  xhr.open("POST", "/upload", true);
  xhr.send(formData);
};

表情24

参考

  • https://es6.ruanyifeng.com/#docs/arraybuffer
  • https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
前端森林公众号二维码2

ReactDOM.render

react 源码 16.13.1 版本

ReactDOM.render

首先来到packages/react-dom/src/client/ReactDOM.js 文件:

import {
  findDOMNode,
  render,
  hydrate,
  unstable_renderSubtreeIntoContainer,
  unmountComponentAtNode,
} from "./ReactDOMLegacy";

可以看到renderhydrate方法定义在packages/react-dom/src/client/ReactDOMLegacy.js文件中。

首先来看render方法:

/**
 * 客户端渲染
 * @param element 表示一个ReactElement对象
 * @param container 需要将组件挂载到页面中的DOM容器
 * @param callback 渲染完成后需要执行的回调函数
 */
export function render(
  element: React$Element<any>,
  container: Container,
  callback: ?Function
) {
  invariant(
    isValidContainer(container),
    "Target container is not a DOM element."
  );
  if (__DEV__) {
    const isModernRoot =
      isContainerMarkedAsRoot(container) &&
      container._reactRootContainer === undefined;
    if (isModernRoot) {
      console.error(
        "You are calling ReactDOM.render() on a container that was previously " +
          "passed to ReactDOM.createRoot(). This is not supported. " +
          "Did you mean to call root.render(element)?"
      );
    }
  }
  return legacyRenderSubtreeIntoContainer(
    null,
    element,
    container,
    false,
    callback
  );
}

然后是hydrate方法:

/**
 * 服务端渲染
 * @param element 表示一个ReactNode,可以是一个ReactElement对象
 * @param container 需要将组件挂载到页面中的DOM容器
 * @param callback 渲染完成后需要执行的回调函数
 */
export function hydrate(
  element: React$Node,
  container: Container,
  callback: ?Function
) {
  invariant(
    isValidContainer(container),
    "Target container is not a DOM element."
  );
  if (__DEV__) {
    const isModernRoot =
      isContainerMarkedAsRoot(container) &&
      container._reactRootContainer === undefined;
    if (isModernRoot) {
      console.error(
        "You are calling ReactDOM.hydrate() on a container that was previously " +
          "passed to ReactDOM.createRoot(). This is not supported. " +
          "Did you mean to call createRoot(container, {hydrate: true}).render(element)?"
      );
    }
  }
  // TODO: throw or warn if we couldn't hydrate?
  return legacyRenderSubtreeIntoContainer(
    null,
    element,
    container,
    true,
    callback
  );
}

可以看到:render方法和hydrate方法在执行legacyRenderSubtreeIntoContainer时,第一个参数的值均为null,第四个参数的值恰好相反。

我们来看legacyRenderSubtreeIntoContainer方法:

//  packages/react-dom/src/client/ReactDOMLegacy.js
/**
 * 开始构建FiberRoot和RootFiber,之后开始执行更新任务
 *
 * @param {?React$Component<any, any>} parentComponent 父组件,可以把它当成null值来处理
 * @param {ReactNodeList} children ReactDOM.render()或者ReactDOM.hydrate()中的第一个参数,可以理解为根组件
 * @param {Container} container ReactDOM.render()或者ReactDOM.hydrate()中的第二个参数,组件需要挂载的DOM容器
 * @param {boolean} forceHydrate 表示是否融合,用于区分客户端渲染和服务端渲染,render方法传false,hydrate方法传true
 * @param {?Function} callback ReactDOM.render()或者ReactDOM.hydrate()中的第三个参数,组件渲染完成后需要执行的回调函数
 * @returns
 */
function legacyRenderSubtreeIntoContainer(
  parentComponent: ?React$Component<any, any>,
  children: ReactNodeList,
  container: Container,
  forceHydrate: boolean,
  callback: ?Function
) {
  if (__DEV__) {
    topLevelUpdateWarnings(container);
    warnOnInvalidCallback(callback === undefined ? null : callback, "render");
  }

  // 在第一次执行的时候,container上是肯定没有_reactRootContainer属性的
  // 所以第一次执行时,root肯定为undefined

  // TODO: Without `any` type, Flow says "Property cannot be accessed on any
  // member of intersection type."
  let root: RootType = (container._reactRootContainer: any);
  let fiberRoot;
  if (!root) {
    // Initial mount
    // 首次挂载,进入当前流程控制中,container._reactRootContainer指向一个ReactSyncRoot实例
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
      container,
      forceHydrate
    );
    // root表示一个ReactSyncRoot实例,实例中有一个_internalRoot方法指向一个fiberRoot实例
    fiberRoot = root._internalRoot;
    // callback表示ReactDOM.render()或者ReactDOM.hydrate()中的第三个参数
    // 重写callback,通过fiberRoot去找到其对应的rootFiber,然后将rootFiber的第一个child的stateNode作为callback中的this指向
    if (typeof callback === "function") {
      const originalCallback = callback;
      callback = function () {
        const instance = getPublicRootInstance(fiberRoot);
        originalCallback.call(instance);
      };
    }
    // Initial mount should not be batched.
    // 对于首次挂载来说,更新操作不应该是批量的,所以会先执行unbatchedUpdates方法
    // 该方法中会将executionContext(执行上下文)切换成LegacyUnbatchedContext(非批量上下文)
    // 切换上下文之后再调用updateContainer执行更新操作
    // 执行完updateContainer之后再将executionContext恢复到之前的状态
    unbatchedUpdates(() => {
      updateContainer(children, fiberRoot, parentComponent, callback);
    });
  } else {
    // 不是首次挂载,即container._reactRootContainer上已经存在一个ReactSyncRoot实例
    fiberRoot = root._internalRoot;
    if (typeof callback === "function") {
      const originalCallback = callback;
      callback = function () {
        const instance = getPublicRootInstance(fiberRoot);
        originalCallback.call(instance);
      };
    }
    // Update
    // 对于非首次挂载来说,是不需要再调用unbatchedUpdates方法的
    // 即不再需要将executionContext(执行上下文)切换成LegacyUnbatchedContext(非批量上下文)
    // 而是直接调用updateContainer执行更新操作
    updateContainer(children, fiberRoot, parentComponent, callback);
  }
  return getPublicRootInstance(fiberRoot);
}

这部分代码内容稍微有点多,相对也没那么好理解,我们暂时先不去看整个函数的完整内容。试想当我们第一次启动运行项目的时候,也就是第一次执行ReactDOM.render方法的时候,这时去获取container._reactRootContainer肯定是没有值的,所以我们先关心第一个if语句中的内容:

if (!root) {
    // Initial mount
    // 首次挂载,进入当前流程控制中,container._reactRootContainer指向一个ReactSyncRoot实例
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
      container,
      forceHydrate,
    );
    ...
}

这里通过调用legacyCreateRootFromDOMContainer方法将其返回值赋值给container._reactRootContainer,我们来看legacyCreateRootFromDOMContainer的具体实现:

/**
 * 创建并返回一个ReactSyncRoot实例
 *
 * @param {Container} container ReactDOM.render()或者ReactDOM.hydrate()中的第二个参数,组件需要挂载的DOM容器
 * @param {boolean} forceHydrate 是否需要强制融合,render方法传false,hydrate方法传true
 * @returns {RootType}
 */
function legacyCreateRootFromDOMContainer(
  container: Container,
  forceHydrate: boolean
): RootType {
  // 判断是否需要融合
  const shouldHydrate =
    forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
  // First clear any existing content.
  // 针对客户端渲染的情况,需要将container容器中的所有元素移除
  if (!shouldHydrate) {
    let warned = false;
    let rootSibling;
    // 循环遍历每个子节点进行删除
    while ((rootSibling = container.lastChild)) {
      if (__DEV__) {
        if (
          !warned &&
          rootSibling.nodeType === ELEMENT_NODE &&
          (rootSibling: any).hasAttribute(ROOT_ATTRIBUTE_NAME)
        ) {
          warned = true;
          console.error(
            "render(): Target node has markup rendered by React, but there " +
              "are unrelated nodes as well. This is most commonly caused by " +
              "white-space inserted around server-rendered markup."
          );
        }
      }
      container.removeChild(rootSibling);
    }
  }
  if (__DEV__) {
    if (shouldHydrate && !forceHydrate && !warnedAboutHydrateAPI) {
      warnedAboutHydrateAPI = true;
      console.warn(
        "render(): Calling ReactDOM.render() to hydrate server-rendered markup " +
          "will stop working in React v18. Replace the ReactDOM.render() call " +
          "with ReactDOM.hydrate() if you want React to attach to the server HTML."
      );
    }
  }
  // 返回一个ReactSyncRoot实例
  // 该实例具有一个_internalRoot属性指向fiberRoot
  return createLegacyRoot(
    container,
    shouldHydrate
      ? {
          hydrate: true,
        }
      : undefined
  );
}

/**
 * 根据nodeType和attribute判断是否需要融合
 *
 * @param {*} container DOM容器
 * @returns
 */
function shouldHydrateDueToLegacyHeuristic(container) {
  const rootElement = getReactRootElementInContainer(container);
  return !!(
    rootElement &&
    rootElement.nodeType === ELEMENT_NODE &&
    rootElement.hasAttribute(ROOT_ATTRIBUTE_NAME)
  );
}

/**
 * 根据container来获取DON容器中的第一个子节点
 *
 * @param {*} container DOM容器
 * @returns
 */
function getReactRootElementInContainer(container: any) {
  if (!container) {
    return null;
  }

  if (container.nodeType === DOCUMENT_NODE) {
    return container.documentElement;
  } else {
    return container.firstChild;
  }
}

其中在shouldHydrateDueToLegacyHeuristic方法中,首先根据container来获取DOM容器中的第一个子节点,获取该子节点的目的在于通过节点的nodeType和是否具有ROOT_ATTRIBUTE_NAME属性来区分是客户端渲染还是服务端渲染。

ROOT_ATTRIBUTE_NAME位于packages/react-dom/src/shared/DOMProperty.js文件中,表示data-reactroot属性。我们知道,在服务端渲染中有别于客户端渲染的是,node服务会在后台先根据匹配到的路由生成完整的HTML字符串,然后再将HTML字符串发送到浏览器端,最终生成的HTML结构简化后如下:

<body>
  <div id="root">
    <div data-reactroot=""></div>
  </div>
</body>

在客户端渲染中是没有data-reactroot属性的,因此就可以区分出客户端渲染和服务端渲染。

React中的nodeType主要包含了五种,其对应的值和W3C中的nodeType标准是保持一致的,位于与DOMProperty.js同级的HTMLNodeType.js文件中:

// packages/react-dom/src/shared/HTMLNodeType.js

// 代表元素节点
export const ELEMENT_NODE = 1;
// 代表文本节点
export const TEXT_NODE = 3;
// 代表注释节点
export const COMMENT_NODE = 8;
// 代表整个文档,即document
export const DOCUMENT_NODE = 9;
// 代表文档片段节点
export const DOCUMENT_FRAGMENT_NODE = 11;

到这里,相信你一定可以很容易的区分出客户端渲染和服务端渲染。接下来我们将尝试去理解两个很容易混淆的概念:FiberRootRootFiber。这两个概念在React的整个任务调度过程中起着关键性的作用,如果不理解这两个概念,后续的任务调度过程就是空谈,所以这里也是我们必须要去理解的部分。

hello

这时在有没不一样匮乏前

聊一聊前端性能优化 CRP

聊一聊CRP

什么是 CRP?

CRP又称关键渲染路径,引用MDN对它的解释:

关键渲染路径是指浏览器通过把 HTML、CSS 和 JavaScript 转化成屏幕上的像素的步骤顺序。优化关键渲染路径可以提高渲染性能。关键渲染路径包含了 Document Object Model (DOM),CSS Object Model (CSSOM),渲染树和布局。

优化关键渲染路径可以提升首屏渲染时间。理解和优化关键渲染路径对于确保回流和重绘可以每秒 60 帧、确保高性能的用户交互和避免无意义渲染至关重要。

如何结合CRP进行性能优化?

我想对于性能优化,大家都不陌生,无论是平时的工作还是面试,是一个老生常谈的话题。

如果单纯针对一些点去泛泛而谈,我想是不太严谨的。

今天我们结合一道非常经典的面试题:从输入URL到页面展示,这中间发生了什么?来从其中的某些环节,来深入谈谈前端性能优化 CRP

从输入 URL 到页面展示,这中间发生了什么?

这道题的经典程度想必不用我多说,这里我用一张图梳理了它的大致流程:

这个过程可以大致描述为如下:

1、URI 解析

2、DNS 解析(DNS 服务器)

3、TCP 三次握手(建立客户端和服务器端的连接通道)

4、发送 HTTP 请求

5、服务器处理和响应

6、TCP 四次挥手(关闭客户端和服务器端的连接)

7、浏览器解析和渲染

8、页面加载完成

本文我会从浏览器渲染过程、缓存、DNS 优化几方面进行性能优化的说明。

浏览器渲染过程

构建 DOM 树

构建DOM树的大致流程梳理为下图:

我们以下面这段代码为例进行分析:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>构建DOM树</title>
  </head>
  <body>
    <p>森林</p>
    <div>之晨</div>
  </body>
</html>

首先浏览器从磁盘或网络中读取 HTML 原始字节,并根据文件的指定编码将它们转成字符。

然后通过分词器将字节流转换为 Token,在Token(也就是令牌)生成的同时,另一个流程会同时消耗这些令牌并转换成 HTML head 这些节点对象,起始和结束令牌表明了节点之间的关系。

当所有的令牌消耗完以后就转换成了DOM(文档对象模型)。

最终构建出的DOM结构如下:

构建 CSSOM 树

DOM树构建完成,接下来就是CSSOM树的构建了。

HTML的转换类似,浏览器会去识别CSS正确的令牌,然后将这些令牌转化成CSS节点。

子节点会继承父节点的样式规则,这里对应的就是层叠规则和层叠样式表。

构建DOM树的大致流程可梳理为下图:

我们这里采用上面的HTML为例,假设它有如下 css:

body {
  font-size: 16px;
}
p {
  font-weight: bold;
}
div {
  color: orange;
}

那么最终构建出的CSSOM树如下:

有了 DOMCSSOM,接下来就可以合成布局树(Render Tree)了。

构建渲染树

DOMCSSOM 都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制 DOM 树的结构,不同之处在于 DOM 树中那些不需要显示的元素会被过滤掉,如 display:none 属性的元素、head 标签、script 标签等。

复制好基本的布局树结构之后,渲染引擎会为对应的 DOM 元素选择对应的样式信息,这个过程就是样式计算。

样式计算

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

把 CSS 转换为浏览器能够理解的结构

HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets

转换样式表中的属性值,使其标准化

现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。

什么是属性值标准化?我们来看这样的一段CSS

body {
  font-size: 2em;
}
div {
  font-weight: bold;
}
div {
  color: red;
}

可以看到上面的 CSS 文本中有很多属性值,如 2em、bold、red,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?


从图中可以看到,2em 被解析成了 32pxbold 被解析成了 700red 被解析成了 rgb(255,0,0)……

计算出 DOM 树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?

这其中涉及到两点:CSS 的继承规则层叠规则

这里由于不是本文的重点,我简单做下说明:

  • CSS 继承就是每个 DOM 节点都包含有父节点的样式
  • 层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。

样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。

计算布局

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局

绘制

通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。

到这里,浏览器的渲染过程就基本结束了,通过下面的一张图来梳理下:

到这里我们已经把浏览器解析和渲染的完整流程梳理完成了,那么这其中有那些地方可以去做性能优化呢?

从浏览器的渲染过程中可以做的优化点

通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

  • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
  • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
  • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

这里我们需要重点关注加载阶段交互阶段,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。

加载阶段

我们先来分析如何系统优化加载阶段中的页面,来看一个典型的渲染流水线,如下图所示:

通过上面对浏览器渲染过程的分析我们知道JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTMLJavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。

这些能阻塞网页首次渲染的资源称为关键资源。而基于关键资源,我们可以继续细化出三个影响页面首次渲染的核心因素:

  • 关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。
  • 关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。
  • 请求关键资源需要多少个RTT(Round Trip Time)RTT 是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。

了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数降低关键资源大小降低关键资源的 RTT 次数

  • 如何减少关键资源的个数?一种方式是可以将 JavaScriptCSS 改成内联的形式,比如上图的 JavaScriptCSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 sync 或者 defer 属性
  • 如何减少关键资源的大小?可以压缩 CSSJavaScript 资源,移除 HTMLCSSJavaScript 文件中一些注释内容
  • 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

交互阶段

接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。

先来看看交互阶段的渲染流水线:

其实这块大致有以下几点可以优化:

  • 避免DOM的回流。也就是尽量避免重排重绘操作。

  • 减少 JavaScript 脚本执行时间。有时JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:

    • 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
    • 另一种是采用 Web Workers
  • DOM操作相关的优化。浏览器有渲染引擎JS引擎,所以当用JS操作DOM时,这两个引擎要通过接口互相“交流”,因此每一次操作DOM(包括只是访问DOM的属性),都要进行引擎之间解析的开销,所以常说要减少 DOM 操作。总结下来有以下几点:

    • 缓存一些计算属性,如let left = el.offsetLeft
    • 通过DOMclass来集中改变样式,而不是通过style一条条的去修改。
    • 分离读写操作。现代的浏览器都有渲染队列的机制。
    • 放弃传统操作DOM的时代,基于vue/react等采用virtual dom的框架
  • 合理利用 CSS 合成动画。合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。

  • CSS选择器优化。我们知道CSS引擎查找是从右向左匹配的。所以基于此有以下几条优化方案:

    • 尽量不要使用通配符
    • 少用标签选择器
    • 尽量利用属性继承特性
  • CSS属性优化。浏览器绘制图像时,CSS的计算也是耗费性能的,一些属性需浏览器进行大量的计算,属于昂贵的属性(box-shadowsborder-radiustransformsfiltersopcity:nth-child等),这些属性在日常开发中经常用到,所以并不是说不要用这些属性,而是在开发中,如果有其它简单可行的方案,那可以优先选择没有昂贵属性的方案。

  • 避免频繁的垃圾回收。我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。

缓存

缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。下图是浏览器缓存的查找流程图:

浏览器缓存相关的知识点还是很多的,这里我有整理一张图:

关于浏览器缓存的详细介绍说明,可以参考我之前的这篇文章,这里就不赘述了。

DNS 相关优化

DNS全称Domain Name System。它是互联网的“通讯录”,它记录了域名与实际ip地址的映射关系。每次我们访问一个网站,都要通过各级的DNS服务器查询到该网站的服务器ip,然后才能访问到该服务器。

DNS相关的优化一般涉及到两点:浏览器DNS缓存和DNS预解析。

DNS缓存

一图胜千言:

  • 浏览器会先检查浏览器缓存(浏览器缓存有大小和时间限制),时间过长可能导致IP地址变化,无法解析正确IP地址,过短就会让浏览器重复解析域名,一般为几分钟。
  • 如果浏览器缓存没有对应域名,则会去操作系统缓存中查找。
  • 如果还没有找到,域名就会发送到本地区的域名服务器(一般由互联网供应商提供,电信、联通之类),一般在本地区的域名服务器上都能找到了。
  • 当然也可能本地域名服务器也没找到,那本地域名服务器就开始递归查找。

一般而言,浏览器解析DNS需要20-120ms,因此DNS解析可优化之处几乎没有。但存在这样一个场景,网站有很多图片在不同域名下,那如果在登录页就提前解析了之后可能会用到的域名,使解析结果缓存过,这样缩短了DNS解析时间,提高网站整体上的访问速度了,这就是DNS预解析

DNS预解析

来看下 MDN 对于DNS预解析的定义吧:

X-DNS-Prefetch-Control 头控制着浏览器的 DNS 预读取功能。 DNS 预读取是一项使浏览器主动去执行域名解析的功能,其范围包括文档的所有链接,无论是图片的,CSS 的,还是 JavaScript 等其他用户能够点击的 URL

因为预读取会在后台执行,所以 DNS 很可能在链接对应的东西出现之前就已经解析完毕。这能够减少用户点击链接时的延迟。

我们这里就简单看一下如何去做DNS预解析

  • 在页面头部加入,这样浏览器对整个页面进行预解析
<meta http-equiv="x-dns-prefetch-control" content="on">
  • 通过 link 标签手动添加要解析的域名,比如:
<link rel="dns-prefetch" href="//img10.360buyimg.com"/>

参考

李兵 「浏览器工作原理与实践」

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞两连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻

scheduleWork

scheduleWork

ReactDOM.rendersetStateforceUpdate最终都调用了scheduleWork,而它做了什么工作呢?

function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
  const root = scheduleWorkToRoot(fiber, expirationTime);
  if (root === null) {
    // 找不到 rootFiber 直接 return
    if (__DEV__) {
      switch (fiber.tag) {
        case ClassComponent:
          warnAboutUpdateOnUnmounted(fiber, true);
          break;
        case FunctionComponent:
        case ForwardRef:
        case MemoComponent:
        case SimpleMemoComponent:
          warnAboutUpdateOnUnmounted(fiber, false);
          break;
      }
    }
    return;
  }
  // isWorking: 正在执行渲染,有任务正在进行当中 在后续任务正在执行 可能被中断的情况。。!isWorking 代表没任何任务正在进行
  // nextRenderExpirationTime !== NoWork:任务可能是异步任务,并且执行到一半没有执行完,现在要把执行权交给浏览器去执行更高优先级的任务,
  // expirationTime > nextRenderExpirationTim:新的任务的 expirationTime 高于目前任务的 expirationTime
  // 新优先级任务打断低优先级任务的操作
  if (
    !isWorking &&
    nextRenderExpirationTime !== NoWork &&
    expirationTime > nextRenderExpirationTime
  ) {
    // This is an interruption. (Used for performance tracking.)
    // 给开发工具用的,用来展示被哪个节点打断了异步任务
    interruptedBy = fiber;
    // 新优先级任务打断低优先级任务的操作。
    resetStack();
  }
  markPendingPriorityLevel(root, expirationTime);
  if (
    // If we're in the render phase, we don't need to schedule this root
    // for an update, because we'll do it before we exit...
    !isWorking ||
    isCommitting ||
    // ...unless this is a different root than the one we're rendering.
    nextRoot !== root
  ) {
    const rootExpirationTime = root.expirationTime;
    requestWork(root, rootExpirationTime);
  }
  if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
    // Reset this back to zero so subsequent updates don't throw.
    nestedUpdateCount = 0;
    invariant(
      false,
      "Maximum update depth exceeded. This can happen when a " +
        "component repeatedly calls setState inside " +
        "componentWillUpdate or componentDidUpdate. React limits " +
        "the number of nested updates to prevent infinite loops."
    );
  }
}

总结下,scheduleWork做的工作就是:

  • 找到更新对应的FiberRoot节点。
    • 在使用ReactDOM.render的时候我们传给scheduleWork就是FiberRoot
    • 但是调用setStateforceUpdate 一般都是传递的是某个组件的对应的fiber节点,这时候就需要找到FiberRoot节点
  • 找到符合条件重置 stack 。
  • 如果符合条件就请求工作调度

scheduleWorkToRoot

scheduleWork最开始先调用了scheduleWorkToRoot,这一步非常重要:

function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
  recordScheduleUpdate();

  if (__DEV__) {
    if (fiber.tag === ClassComponent) {
      const instance = fiber.stateNode;
      warnAboutInvalidUpdates(instance);
    }
  }

  // Update the source fiber's expiration time
  // 如果 fiber 之前产生的 expirationTime 小于现在产生的优先级时 即当前的 fiber 优先级高 设置为当前节点的 expirationTime
  if (fiber.expirationTime < expirationTime) {
    fiber.expirationTime = expirationTime;
  }
  let alternate = fiber.alternate;
  if (alternate !== null && alternate.expirationTime < expirationTime) {
    // 更新 fiber.alternate 的 expirationTime
    alternate.expirationTime = expirationTime;
  }
  // Walk the parent path to the root and update the child expiration time.
  let node = fiber.return;
  let root = null;
  if (node === null && fiber.tag === HostRoot) {
    // node === null 代表 rootFiber 因为 rootFiber.return === null
    // HostRoot 同样代表 rootFiber
    root = fiber.stateNode;
  } else {
    // node 即父节点不为空,即当前不是 rootFiber 需要循环查找,当中可能会更新 childExpirationTime
    while (node !== null) {
      alternate = node.alternate;
      if (node.childExpirationTime < expirationTime) {
        node.childExpirationTime = expirationTime;
        if (
          alternate !== null &&
          alternate.childExpirationTime < expirationTime
        ) {
          alternate.childExpirationTime = expirationTime;
        }
      } else if (
        alternate !== null &&
        alternate.childExpirationTime < expirationTime
      ) {
        alternate.childExpirationTime = expirationTime;
      }
      // 找到 rootFiber 并赋值 stateNode
      if (node.return === null && node.tag === HostRoot) {
        root = node.stateNode;
        break;
      }
      node = node.return;
    }
  }

  if (enableSchedulerTracing) {
    if (root !== null) {
      const interactions = __interactionsRef.current;
      if (interactions.size > 0) {
        const pendingInteractionMap = root.pendingInteractionMap;
        const pendingInteractions = pendingInteractionMap.get(expirationTime);
        if (pendingInteractions != null) {
          interactions.forEach((interaction) => {
            if (!pendingInteractions.has(interaction)) {
              // Update the pending async work count for previously unscheduled interaction.
              interaction.__count++;
            }

            pendingInteractions.add(interaction);
          });
        } else {
          pendingInteractionMap.set(expirationTime, new Set(interactions));

          // Update the pending async work count for the current interactions.
          interactions.forEach((interaction) => {
            interaction.__count++;
          });
        }

        const subscriber = __subscriberRef.current;
        if (subscriber !== null) {
          const threadID = computeThreadID(
            expirationTime,
            root.interactionThreadID
          );
          subscriber.onWorkScheduled(interactions, threadID);
        }
      }
    }
  }
  return root;
}

它主要做了以下几件事:
1、根据当前Fiber节点向上寻找对应的的root节点
2、给更新节点的父节点链上的每个节点的expirationTime设置为这个updateexpirationTime,除非他本身时间要小于expirationTime
3、给更新节点的父节点链上的每个节点的childExpirationTime设置为这个updateexpirationTime,除非他本身时间要小于expirationTime
4、最终返回root节点的Fiber对象

resetStack

scheduleWork调用了scheduleWorkToRoot最终返回了rootFiber节点后呢,我们执行了一个判断,其中调用了一个很重要的方法resetStack

if (
  !isWorking &&
  nextRenderExpirationTime !== NoWork &&
  expirationTime > nextRenderExpirationTime
) {
  // This is an interruption. (Used for performance tracking.)
  // 给开发工具用的,用来展示被哪个节点打断了异步任务
  interruptedBy = fiber;
  // 新优先级任务打断低优先级任务的操作。
  resetStack();
}

这里的意思就是目前没有任何任务在执行,并且之前有执行过任务,同时当前的任务比之前执行的任务过期时间要早(也就是优先级要高)。

那么这种情况会出现在什么时候呢?答案就是:上一个任务是异步任务(优先级很低,超时时间是 502ms),并且在上一个时间片(初始是 33ms)任务没有执行完,而且等待下一次requestIdleCallback的时候新的任务进来了,并且超时时间很短(52ms 或者 22ms 甚至是 Sync),那么优先级就变成了先执行当前任务,也就意味着上一个任务被打断了(interrupted

被打断的任务会从当前节点开始往上推出context,因为在React只有一个stack,而下一个任务会从头开始的,所以在开始之前需要清空之前任务的的stack

function resetStack() {
  if (nextUnitOfWork !== null) {
    let interruptedWork = nextUnitOfWork.return;
    while (interruptedWork !== null) {
      unwindInterruptedWork(interruptedWork);
      interruptedWork = interruptedWork.return;
    }
  }

  if (__DEV__) {
    ReactStrictModeWarnings.discardPendingWarnings();
    checkThatStackIsEmpty();
  }
  // 重置所有的公共变量
  nextRoot = null;
  nextRenderExpirationTime = NoWork;
  s;
  nextLatestAbsoluteTimeoutMs = -1;
  nextRenderDidError = false;
  nextUnitOfWork = null;
}

这里的nextUnitOfWork用于记录render阶段Fiber树遍历过程中下一个需要执行的节点。在resetStack中分别被重置,他只会指向workInProgress

markPendingPriorityLevel(后面补充)

调用 requestWork

if (
  // If we're in the render phase, we don't need to schedule this root
  // for an update, because we'll do it before we exit...
  !isWorking ||
  isCommitting ||
  // ...unless this is a different root than the one we're rendering.
  nextRoot !== root
) {
  const rootExpirationTime = root.expirationTime;
  requestWork(root, rootExpirationTime);
}

这个判断条件就比较简单了,!isWorking || isCommitting简单来说就是要么处于没有work的状态,要么只能在render阶段,不能处于commit阶段。

在符合条件之后就去执行requestWork了。

Vue源码探秘(new Vue)

引言

Vue的一个核心**是数据驱动,相信大家一定对这个不陌生。所谓数据驱动,就是视图由数据驱动生成。相比传统的使用jQuery等前端库直接操作DOM,大大提高了开发效率,代码简洁易维护。

先来看一个简单的例子:

<div id="app">
  {{ message }}
</div>
<script>
  var app = new Vue({
    el: "#app",
    data() {
      return {
        message: "hello Vue!"
      };
    }
  });
</script>

最终页面展示效果是:

如上图所示,页面上显示hello Vue!,也就是说Vuejs里的数据渲染到DOM上,也就是数据驱动视图,这个渲染过程就是最近几篇文章我们要着重分析的。

本篇文章,我们先来一起看下new Vue发生了什么。

_init()

Vue构造函数是定义在src/core/instance/index.js中的:

// src/core/instance/index.js
function Vue(options) {
  if (process.env.NODE_ENV !== "production" && !(this instanceof Vue)) {
    warn("Vue is a constructor and should be called with the `new` keyword");
  }
  this._init(options);
}

构造函数的核心是调用了_init方法,_init定义在src/core/instance/init.js中:

// src/core/instance/init.js
Vue.prototype._init = function(options?: Object) {
  const vm: Component = this;
  // a uid
  vm._uid = uid++;
  [1];
  let startTag, endTag;
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    startTag = `vue-perf-start:${vm._uid}`;
    endTag = `vue-perf-end:${vm._uid}`;
    mark(startTag);
  }

  // a flag to avoid this being observed
  vm._isVue = true;
  // merge options
  if (options && options._isComponent) {
    // optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options);
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    );
  }
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== "production") {
    initProxy(vm);
  } else {
    vm._renderProxy = vm;
  }
  // expose real self
  vm._self = vm;
  initLifecycle(vm);
  initEvents(vm);
  initRender(vm);
  callHook(vm, "beforeCreate");
  initInjections(vm); // resolve injections before data/props
  initState(vm);
  initProvide(vm); // resolve provide after data/props
  callHook(vm, "created")[2];
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== "production" && config.performance && mark) {
    vm._name = formatComponentName(vm, false);
    mark(endTag);
    measure(`vue ${vm._name} init`, startTag, endTag);
  }

  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
};

梳理下这里面的执行流程:

[1][2]之间的代码主要是进行了性能追踪,参考官网:

接下来是一个合并options的操作。

接下来调用了很多初始化函数,从函数名称可以看出分别是执行初始化生命周期、初始化事件中心、初始化渲染等操作。文章开头的 demo 中有定义data选项,我们先来分析下initState是如何处理data的:

// src/core/instance/state.js

export function initState(vm: Component) {
  vm._watchers = [];
  const opts = vm.$options;
  if (opts.props) initProps(vm, opts.props);
  if (opts.methods) initMethods(vm, opts.methods);
  if (opts.data) {
    initData(vm);
  } else {
    observe((vm._data = {}), true /* asRootData */);
  }
  if (opts.computed) initComputed(vm, opts.computed);
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}

可以看到,initState函数是对propsmethodsdatacomputedwatch这几项的处理。我们现在只关注initDatadata的初始化:

// src/core/instance/state.js
function initData(vm: Component) {
  let data = vm.$options.data;
  data = vm._data = typeof data === "function" ? getData(data, vm) : data || {};
  if (!isPlainObject(data)) {
    data = {};
    process.env.NODE_ENV !== "production" &&
      warn(
        "data functions should return an object:\n" +
          "https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function",
        vm
      );
  }
  // proxy data on instance
  const keys = Object.keys(data);
  const props = vm.$options.props;
  const methods = vm.$options.methods;
  let i = keys.length;
  while (i--) {
    const key = keys[i];
    if (process.env.NODE_ENV !== "production") {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        );
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== "production" &&
        warn(
          `The data property "${key}" is already declared as a prop. ` +
            `Use prop default value instead.`,
          vm
        );
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key);
    }
  }
  // observe data
  observe(data, true /* asRootData */);
}

这里给实例添加了属性_data并把data赋值给它,然后判断data如果是一个函数的话会检查函数返回值是不是纯对象,如果不是会抛出警告。

我们知道,在Vue中,data属性名和methods中的函数名、props的属性名都不能重名,这就是接下来的代码所做的工作了。这里遍历了data的所有属性,去和methodsprops中的进行对比,如果有重名则会抛出警告。如果没有重名并且isReserved返回 false 的话,就接着执行proxy方法。下面是proxy部分的代码:

// src/core/instance/state.js
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
};

export function proxy(target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter() {
    return this[sourceKey][key];
  };
  sharedPropertyDefinition.set = function proxySetter(val) {
    this[sourceKey][key] = val;
  };
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

这里其实是做了一层代理,先是定义一个属性描述符sharedPropertyDefinition,然后调用Object.defineProperty,意思就是当访问/修改target.key时会触发get/set,代理到this[sourceKey][key](即vm[_data][key])上。

先来看一下,平时我们是怎么写Vue的:

<script >
export default {
  data() {
    return {
      name: '森林'
    }
  },
  methods: {
    print() {
      console.log(this.name);
    }
  }
}
</script>

methods中通过this.name就能直接访问到data里面的值(实际上是访问了this._data.name)。到这里,我们就清楚了为什么在initData一开始的代码,开头有一行是将data保存到vm._data中。

回到initData中,接着上面的继续往下走,最后就是调用observe对数据做了响应式处理。响应式处理部分比较重要,我会在后面的章节中详细分析。

看完initData,我们回到_init函数。经过一系列的初始化操作后,最后一步是判断vm.$options.el是否存在,也即传入的对象是否有el属性,有的话就调用vm.$mount进行挂载,挂载的目标就是把模板渲染成最终的DOM

总结

通过本篇文章,我们知道了执行new Vue后其实是执行_init进行了一系列初始化的操作。本文呢,我们是把_init函数大致的执行流程梳理了一遍。上文也提到函数的最后调用了vm.$mount进行挂载,这一步操作是数据渲染到DOM上的关键,在下一篇中,我会带大家一起看下$mount内部具体进行了什么操作。

你真的了解mongoose吗?

引言

继上篇文章「Koa2+MongoDB+JWT实战--Restful API最佳实践」后,收到许多小伙伴的反馈,表示自己对于mongoose不怎么了解,上手感觉有些难度,看官方文档又基本都是英文(宝宝心里苦,但宝宝不说)。

为了让各位小伙伴快速上手,加深对于 mongoose 的了解,我特地结合之前的项目整理了一下关于 mongoose 的一些基础知识,这些对于实战都是很有用的。相信看了这篇文章,一定会对你快速上手,了解使用 mongoose 有不小的帮助。

mongoose 涉及到的概念和模块还是很多的,大体有下面这些:

本篇文章并不会逐个去展开详细讲解,主要是讲述在实战中比较重要的几个模块:模式(schemas)模式类型(SchemaTypes)连接(Connections)模型(Models)联表(Populate)

模式(schemas)

定义你的 schema

Mongoose的一切都始于一个Schema。每个 schema 映射到 MongoDB 的集合(collection)和定义该集合(collection)中的文档的形式。

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const userSchema = new Schema(
  {
    __v: { type: Number, select: false },
    name: { type: String, required: true },
    password: { type: String, required: true, select: false },
    avatar_url: { type: String },
    gender: {
      type: String,
      enum: ["male", "female"],
      default: "male",
      required: true
    },
    headline: { type: String },
  },
  { timestamps: true }
);

module.exports = model("User", userSchema);

这里的__vversionKey。该 versionKey 是每个文档首次创建时,由 mongoose 创建的一个属性。包含了文档的内部修订版。此文档属性是可配置的。默认值为__v。如果不需要该版本号,在 schema 中添加{ versionKey: false}即可。

创建模型

使用我们的 schema 定义,我们需要将我们的userSchema转成我们可以用的模型。也就是mongoose.model(modelName, schema) 。也就是上面代码中的:

module.exports = model("User", userSchema);

选项(options)

Schemas 有几个可配置的选项,可以直接传递给构造函数或设置:

new Schema({..}, options);

// or

var schema = new Schema({..});
schema.set(option, value);

可用选项:

  • autoIndex
  • bufferCommands
  • capped
  • collection
  • id
  • _id
  • minimize
  • read
  • shardKey
  • strict
  • toJSON
  • toObject
  • typeKey
  • validateBeforeSave
  • versionKey
  • skipVersioning
  • timestamps

这里我只是列举了常用的配置项,完整的配置项可查看官方文档https://mongoosejs.com/docs/guide.html#options

这里我主要说一下versionKeytimestamps:

  • versionKey(上文有提到) 是 Mongoose 在文件创建时自动设定的。 这个值包含文件的内部修订号。 versionKey 是一个字符串,代表版本号的属性名, 默认值为 __v
  • 如果设置了 timestamps 选项, mongoose 会在你的 schema 自动添加 createdAtupdatedAt 字段, 其类型为 Date

到这里,已经基本介绍完了Schema,接下来看一下SchemaTypes

模式类型(SchemaTypes)

SchemaTypes为查询和其他处理路径默认值,验证,getter,setter,字段选择默认值,以及字符串和数字的特殊字符。 在 mongoose 中有效的 SchemaTypes 有:

  • String
  • Number
  • Date
  • Buffer
  • Boolean
  • Mixed
  • ObjectId
  • Array
  • Decimal128
  • Map

看一个简单的示例:

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
    answerer: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
      select: false
    },
    questionId: { type: String, required: true },
    voteCount: { type: Number, required: true, default: 0 }
  },
  { timestamps: true }
);

所有的 Schema 类型

  • required: 布尔值或函数,如果为 true,则为此属性添加必须的验证。
  • default: 任意类型或函数,为路径设置一个默认的值。如果值是一个函数,则函数的返回值用作默认值。
  • select: 布尔值 指定 query 的默认 projections
  • validate: 函数,对属性添加验证函数。
  • get: 函数,使用 Object.defineProperty() 定义自定义 getter
  • set: 函数,使用 Object.defineProperty() 定义自定义 setter
  • alias: 字符串,只对mongoose>=4.10.0有效。定义一个具有给定名称的虚拟属性,该名称可以获取/设置这个路径

索引

你可以用 schema 类型选项声明 MongoDB 的索引。

  • index: 布尔值,是否在属性中定义一个索引。
  • unique: 布尔值,是否在属性中定义一个唯一索引。
  • sparse: 布尔值,是否在属性中定义一个稀疏索引。
var schema2 = new Schema({
  test: {
    type: String,
    index: true,
    unique: true // 如果指定`unique`为true,则为唯一索引
  }
});

字符串

  • lowercase: 布尔值,是否在保存前对此值调用toLowerCase()
  • uppercase: 布尔值,是否在保存前对此值调用toUpperCase()
  • trim: 布尔值,是否在保存前对此值调用trim()
  • match: 正则,创建一个验证器,验证值是否匹配给定的正则表达式
  • enum: 数组,创建一个验证器,验证值是否是给定数组中的元素

数字

  • min: 数字,创建一个验证器,验证值是否大于等于给定的最小值
  • max: 数字,创建一个验证器,验证值是否小于等于给定的最大的值

日期

  • min: Date
  • max: Date

现在已经介绍完Schematype,接下来让我们看一下Connections

连接(Connections)

我们可以通过利用mongoose.connect()方法连接 MongoDB 。

mongoose.connect('mongodb://localhost:27017/myapp');

这是连接运行在本地myapp数据库最小的值(27017)。如果连接失败,尝试用127.0.0.1代替localhost

当然,你可在 uri 中指定更多的参数:

mongoose.connect('mongodb://username:password@host:port/database?options...');

操作缓存

意思就是我们不必等待连接建立成功就可以使用 models,mongoose 会先缓存 model 操作

let TestModel = mongoose.model('Test', new Schema({ name: String }));
// 连接成功前操作会被挂起
TestModel.findOne(function(error, result) { /* ... */ });

setTimeout(function() {
  mongoose.connect('mongodb://localhost/myapp');
}, 60000);

如果要禁用缓存,可修改bufferCommands配置,也可以全局禁用 bufferCommands

mongoose.set('bufferCommands', false);

选项

connect 方法也接收一个 options 对象:

mongoose.connect(uri, options);

这里我列举几个在日常使用中比较重要的选项,完整的连接选项看这里

  • bufferCommands:这是 mongoose 中一个特殊的选项(不传递给 MongoDB 驱动),它可以禁用 mongoose 的缓冲机制
  • user/pass:身份验证的用户名和密码。这是 mongoose 中特殊的选项,它们可以等同于 MongoDB 驱动中的auth.userauth.password选项。
  • dbName:指定连接哪个数据库,并覆盖连接字符串中任意的数据库。
  • useNewUrlParser:底层 MongoDB 已经废弃当前连接字符串解析器。因为这是一个重大的改变,添加了 useNewUrlParser 标记如果在用户遇到 bug 时,允许用户在新的解析器中返回旧的解析器。
  • poolSize:MongoDB 驱动将为这个连接保持的最大 socket 数量。默认情况下,poolSize 是 5。
  • useUnifiedTopology:默认情况下为false。设置为 true 表示选择使用 MongoDB 驱动程序的新连接管理引擎。您应该将此选项设置为 true,除非极少数情况会阻止您保持稳定的连接。

示例:

const options = {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  autoIndex: false, // 不创建索引
  reconnectTries: Number.MAX_VALUE, // 总是尝试重新连接
  reconnectInterval: 500, // 每500ms重新连接一次
  poolSize: 10, // 维护最多10个socket连接
  // 如果没有连接立即返回错误,而不是等待重新连接
  bufferMaxEntries: 0,
  connectTimeoutMS: 10000, // 10s后放弃重新连接
  socketTimeoutMS: 45000, // 在45s不活跃后关闭sockets
  family: 4 // 用IPv4, 跳过IPv6
};
mongoose.connect(uri, options);

回调

connect()函数也接收一个回调参数,其返回一个 promise。

mongoose.connect(uri, options, function(error) {
  // 检查错误,初始化连接。回调没有第二个参数。
});

// 或者用promise
mongoose.connect(uri, options).then(
  () => { /** ready to use. The `mongoose.connect()` promise resolves to undefined. */ },
  err => { /** handle initial connection error */ }
);

说完Connections,下面让我们来看一个重点Models

模型(Models)

Models 是从 Schema 编译来的构造函数。 它们的实例就代表着可以从数据库保存和读取的 documents。 从数据库创建和读取 document 的所有操作都是通过 model 进行的。

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
  },
  { timestamps: true }
);

module.exports = model("Answer", answerSchema);

定义好 model 之后,就可以进行一些增删改查操作了

创建

如果是Entity,使用save方法;如果是Model,使用create方法或insertMany方法。

// save([options], [options.safe], [options.validateBeforeSave], [fn])
let Person = mongoose.model("User", userSchema);
let person1 = new Person({ name: '森林' });
person1.save()

// 使用save()方法,需要先实例化为文档,再使用save()方法保存文档。而create()方法,则直接在模型Model上操作,并且可以同时新增多个文档
// Model.create(doc(s), [callback])
Person.create({ name: '森林' }, callback)

// Model.insertMany(doc(s), [options], [callback])
Person.insertMany([{ name: '森林' }, { name: '之晨' }], function(err, docs) {

})

说到这里,我们先要补充说明一下 mongoose 里面的三个概念:schemamodelentity:

  • schema: 一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力
  • model: 由 schema 发布生成的模型,具有抽象属性和行为的数据库操作对
  • entity: 由 Model 创建的实体,他的操作也会影响数据库

Schema、Model、Entity 的关系请牢记: Schema生成Model,Model创造Entity,Model 和 Entity 都可对数据库操作造成影响,但 Model 比 Entity 更具操作性。

查询

对于 Mongoosecha 的查找文档很容易,它支持丰富的查询 MongoDB 语法。包括findfindByIdfindOne等。

find()

第一个参数表示查询条件,第二个参数用于控制返回的字段,第三个参数用于配置查询参数,第四个参数是回调函数,回调函数的形式为function(err,docs){}

Model.find(conditions, [projection], [options], [callback])

下面让我们依次看下 find()的各个参数在实际场景中的应用:

  • conditions

    • 查找全部
    Model.find({})
    • 精确查找
    Model.find({name:'森林'})
    • 使用操作符

    对比相关操作符

    符号 描述
    $eq 与指定的值相等
    $ne 与指定的值不相等
    $gt 大于指定的值
    $gte 大于等于指定的值
    $lt 小于指定的值
    $lte 小于等于指定的值
    $in 与查询数组中指定的值中的任何一个匹配
    $nin 与查询数组中指定的值中的任何一个都不匹配
    Model.find({ age: { $in: [18, 24]} })

    返回 age 字段等于 18 或者 24 的所有 document。

    逻辑相关操作符

    符号 描述
    $and 满足数组中指定的所有条件
    $nor 不满足数组中指定的所有条件
    $or 满足数组中指定的条件的其中一个
    $not 反转查询,返回不满足指定条件的文档
    // 返回 age 字段大于 24 或者 age 字段不存在的文档
    Model.find( { age: { $not: { $lte: 24 }}})

    字段相关操作符

    符号 描述
    $exists 匹配存在指定字段的文档
    $type 返回字段属于指定类型的文档

    数组字段的查找

    符号 描述
    $all 匹配包含查询数组中指定的所有条件的数组字段
    $elemMatch 匹配数组字段中的某个值满足 $elemMatch 中指定的所有条件
    $size 匹配数组字段的 length 与指定的大小一样的 document
    // 使用 $all 查找同时存在 18 和 20 的 document
    Model.find({ age: { $all: [ 18, 20 ] } });
  • projection

    指定要包含或排除哪些 document 字段(也称为查询“投影”),必须同时指定包含或同时指定排除,不能混合指定,_id除外。

    在 mongoose 中有两种指定方式,字符串指定对象形式指定

    字符串指定时在排除的字段前加 - 号,只写字段名的是包含。

    Model.find({},'age');
    Model.find({},'-name');

    对象形式指定时,1 是包含,0 是排除。

    Model.find({}, { age: 1 });
    Model.find({}, { name: 0 });
  • options

    // 三种方式实现
    Model.find(filter,null,options)
    Model.find(filter).setOptions(options)
    Model.find(filter).<option>(xxx)

    options 选项见官方文档 Query.prototype.setOptions()

    这里我们只列举常用的:

    • sort: 按照排序规则根据所给的字段进行排序,值可以是 asc, desc, ascending, descending, 1, 和 -1。
    • limit: 指定返回结果的最大数量
    • skip: 指定要跳过的文档数量
    • lean: 返回普通的 js 对象,而不是 Mongoose Documents。建议不需要 mongoose 特殊处理就返给前端的数据都最好使用该方法转成普通 js 对象。
    // sort 两种方式指定排序
    Model.find().sort('age -name'); // 字符串有 - 代表 descending 降序
    Model.find().sort({age:'asc', name:-1});

    sortlimit 同时使用时,调用的顺序并不重要,返回的数据都是先排序后限制数量。

    // 效果一样
    Model.find().limit(2).sort('age');
    Model.find().sort('age').limit(2);
  • callback

    Mongoose 中所有传入 callback 的查询,其格式都是 callback(error, result) 这种形式。如果出错,则 error 是出错信息,result 是 null;如果查询成功,则 error 是 null, result 是查询结果,查询结果的结构形式是根据查询方法的不同而有不同形式的。

    find() 方法的查询结果是数组,即使没查询到内容,也会返回 [] 空数组。

findById

Model.findById(id,[projection],[options],[callback])

Model.findById(id) 相当于 Model.findOne({ _id: id })

看一下官方对于findOnefindById的对比:

不同之处在于处理 id 为 undefined 时的情况。findOne({ _id: undefined }) 相当于 findOne({}),返回任意一条数据。而 findById(undefined) 相当于 findOne({ _id: null }),返回 null

查询结果:

  • 返回数据的格式是 {} 对象形式。
  • id 为 undefinednull,result 返回 null
  • 没符合查询条件的数据,result 返回 null

findOne

该方法返回查找到的所有实例的第一个

Model.findOne(conditions, [projection], [options], [callback])

如果查询条件是 _id,建议使用 findById()

查询结果:

  • 返回数据的格式是 {} 对象形式。
  • 有多个数据满足查询条件的,只返回第一条。
  • 查询条件 conditions 为 {}、 null 或 undefined,将任意返回一条数据。
  • 没有符合查询条件的数据,result 返回 null。

更新

每个模型都有自己的更新方法,用于修改数据库中的文档,不将它们返回到您的应用程序。常用的有findOneAndUpdate()findByIdAndUpdate()update()updateMany()等。

findOneAndUpdate()

Model.findOneAndUpdate(filter, update, [options], [callback])
  • filter

    查询语句,和find()一样。

    filter 为{},则只更新第一条数据。

  • update

    {operator: { field: value, ... }, ... }

    必须使用 update 操作符。如果没有操作符或操作符不是 update 操作符,统一被视为 $set 操作(mongoose 特有)

    字段相关操作符

    符号 描述
    $set 设置字段值
    $currentDate 设置字段值为当前时间,可以是 Date 或时间戳格式。
    $min 只有当指定值小于当前字段值时更新
    $max 只有当指定值大于当前字段值时更新
    $inc 将字段值增加指定数量指定数量可以是负数,代表减少。
    $mul 将字段值乘以指定数量
    $unset 删除指定字段,数组中的值删后改为 null。

    数组字段相关操作符

    符号 描述
    $ 充当占位符,用来表示匹配查询条件的数组字段中的第一个元素 {operator:{ "arrayField.$" : value }}
    $addToSet 向数组字段中添加之前不存在的元素 { $addToSet: {arrayField: value, ... }},value 是数组时可与 $each 组合使用。
    $push 向数组字段的末尾添加元素 { $push: { arrayField: value, ... } },value 是数组时可与 $each 等修饰符组合使用
    $pop 移除数组字段中的第一个或最后一个元素 { $pop: {arrayField: -1(first) / 1(last), ... } }
    $pull 移除数组字段中与查询条件匹配的所有元素 { $pull: {arrayField: value / condition, ... } }
    $pullAll 从数组中删除所有匹配的值 { $pullAll: { arrayField: [value1, value2 ... ], ... } }

    修饰符

    符号 描述
    $each 修饰 $push$addToSet 操作符,以便为数组字段添加多个元素。
    $position 修饰 $push 操作符以指定要添加的元素在数组中的位置。
    $slice 修饰 $push 操作符以限制更新后的数组的大小。
    $sort 修饰 $push 操作符来重新排序数组字段中的元素。

    修饰符执行的顺序(与定义的顺序无关):

    • 在指定的位置添加元素以更新数组字段
    • 按照指定的规则排序
    • 限制数组大小
    • 存储数组
  • options

    • lean: true 返回普通的 js 对象,而不是 Mongoose Documents
    • new: 布尔值,true 返回更新后的数据,false (默认)返回更新前的数据。
    • fields/select:指定返回的字段。
    • sort:如果查询条件找到多个文档,则设置排序顺序以选择要更新哪个文档。
    • maxTimeMS:为查询设置时间限制。
    • upsert:布尔值,如果对象不存在,则创建它。默认值为 false
    • omitUndefined:布尔值,如果为 true,则在更新之前删除值为 undefined 的属性。
    • rawResult:如果为 true,则返回来自 MongoDB 的原生结果。
  • callback

    • 没找到数据返回 null
    • 更新成功返回更新前的该条数据( {} 形式)
    • options{new:true},更新成功返回更新后的该条数据( {} 形式)
    • 没有查询条件,即 filter 为空,则更新第一条数据

findByIdAndUpdate()

Model.findByIdAndUpdate(id, update, options, callback)

Model.findByIdAndUpdate(id, update) 相当于 Model.findOneAndUpdate({ _id: id }, update)

result 查询结果:

  • 返回数据的格式是 {} 对象形式。
  • id 为 undefinednull,result 返回 null
  • 没符合查询条件的数据,result 返回 null

update()

Model.update(filter, update, options, callback)
  • options

    • multi: 默认 false,只更新第一条数据;为 true 时,符合查询条件的多条文档都会更新。
    • overwrite:默认为 false,即 update 参数如果没有操作符或操作符不是 update 操作符,将会默认添加 $set;如果为 true,则不添加 $set,视为覆盖原有文档。

updateMany()

Model.updateMany(filter, update, options, callback)

更新符合查询条件的所有文档,相当于 Model.update(filter, update, { multi: true }, callback)

删除

删除常用的有findOneAndDelete()findByIdAndDelete()deleteMany()findByIdAndRemove()等。

findOneAndDelete()

Model.findOneAndDelete(filter, options, callback)
  • filter
    查询语句和 find() 一样

  • options

    • sort:如果查询条件找到多个文档,则设置排序顺序以选择要删除哪个文档。
    • select/projection:指定返回的字段。
    • rawResult:如果为 true,则返回来自 MongoDB 的原生结果。
  • callback

    • 没有符合 filter 的数据时,返回 null
    • filter 为空或 {} 时,删除第一条数据。
    • 删除成功返回 {} 形式的原数据。

findByIdAndDelete()

Model.findByIdAndDelete(id, options, callback)

Model.findByIdAndDelete(id) 相当于 Model.findOneAndDelete({ _id: id })

  • callback
    • 没有符合 id 的数据时,返回 null
    • id 为空或 undefined 时,返回 null
    • 删除成功返回 {} 形式的原数据。

deleteMany()

Model.deleteMany(filter, options, callback)
  • filter
    删除所有符合 filter 条件的文档。

deleteOne()

Model.deleteOne(filter, options, callback)
  • filter
    删除符合 filter 条件的第一条文档。

findOneAndRemove()

Model.findOneAndRemove(filter, options, callback)

用法与 findOneAndDelete() 一样,一个小小的区别是 findOneAndRemove() 会调用 MongoDB 原生的 findAndModify() 命令,而不是 findOneAndDelete() 命令。

建议使用 findOneAndDelete() 方法。

findByIdAndRemove()

Model.findByIdAndRemove(id, options, callback)

Model.findByIdAndRemove(id) 相当于 Model.findOneAndRemove({ _id: id })

remove()

Model.remove(filter, options, callback)

从集合中删除所有匹配 filter 条件的文档。要删除第一个匹配条件的文档,可将 single 选项设置为 true

看完Models,最后让我们来看下在实战中比较有用的Populate

联表(Populate)

Mongoose 的 populate() 可以连表查询,即在另外的集合中引用其文档。

Populate() 可以自动替换 document 中的指定字段,替换内容从其他 collection 中获取。

refs

创建 Model 的时候,可给该 Model 中关联存储其它集合 _id 的字段设置 ref 选项。ref 选项告诉 Mongoose 在使用 populate() 填充的时候使用哪个 Model

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
    answerer: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
      select: false
    },
    questionId: { type: String, required: true },
    voteCount: { type: Number, required: true, default: 0 }
  },
  { timestamps: true }
);

module.exports = model("Answer", answerSchema);

上例中 Answer model 的 answerer 字段设为 ObjectId 数组。 ref 选项告诉 Mongoose 在填充的时候使用 User model。所有储存在 answerer 中的 _id 都必须是 User model 中 document_id

ObjectIdNumberString 以及 Buffer 都可以作为 refs 使用。 但是最好还是使用 ObjectId

在创建文档时,保存 refs 字段与保存普通属性一样,把 _id 的值赋给它就好了。

const Answer = require("../models/answers");

async create(ctx) {
  ctx.verifyParams({
    content: { type: "string", required: true }
  });
  const answerer = ctx.state.user._id;
  const { questionId } = ctx.params;
  const answer = await new Answer({
    ...ctx.request.body,
    answerer,
    questionId
  }).save();
  ctx.body = answer;
}

populate(path,select)

填充document

const Answer = require("../models/answers");

const answer = await Answer.findById(ctx.params.id)
      .select(selectFields)
      .populate("answerer");

被填充的 answerer 字段已经不是原来的 _id,而是被指定的 document 代替。这个 document 由另一条 query 从数据库返回。

返回字段选择

如果只需要填充 document 中一部分字段,可给 populate() 传入第二个参数,参数形式即 返回字段字符串,同 Query.prototype.select()

const answer = await Answer.findById(ctx.params.id)
      .select(selectFields)
      .populate("answerer", "name -_id");

populate 多个字段

const populateStr =
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => {
          if (f === "employments") {
            return "employments.company employments.job";
          }
          if (f === "educations") {
            return "educations.school educations.major";
          }
          return f;
        })
        .join(" ");
const user = await User.findById(ctx.params.id)
      .select(selectFields)
      .populate(populateStr);

最后

到这里本篇文章也就结束了,这里主要是结合我平时的项目(https://github.com/Jack-cool/rest_node_api)中对于mongoose的使用做的简单的总结。希望能给你带来帮助!

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

Chrome DevTools中的这些*操作,你都知道吗?

引言 🏂

作为开发人员,平时用的最多的就是Chrome devtools了,但是可能很多同学都像我一样平时用的最多也就只是ConsoleElements面板了。

我整理了一些我平时用的比较多的一些调试小技巧,相信对提高你的工作效率能起到不小的帮助!

命令(Command) 菜单 🏈

“命令”菜单是最最常用的,本文也会多次用到,所以这里先说一下打开方式:

Cmd + Shift + P(如果使用Windows,则按Ctrl + Shift + P)打开“命令”菜单。
chrome-调试-命令

截图DOM元素 🏉

当你只想对一个特别的 DOM 节点进行截图时,你可能需要使用其他工具弄半天,但现在你直接选中那个节点,打开 命令(Command) 菜单并且使用 节点截图 就可以了。
chrome-调试-命令行

截取特定节点对应上图命令是Screenshot Capture node screenshot

截取特定DOM元素示例:

不只是这样,你同样可以用这种方式 实现全屏截图 :通过 Screenshot Capture full size screenshot 命令。

请注意,这里说的是全屏,并不只是页面可视区域,而是包含滚动条在内的所有页面内容。

对应截取全屏示例:

在控制台中使用上次操作的值 🎃

我是最近才发现这个技巧。使用$_可以引用在控制台执行的前一步操作的返回值。如果您正在控制台调试一些JavaScript代码,并且需要引用先前的返回值,那么这可能非常方便。

重新发起xhr请求 🚀

在平时和后端联调时,我们用的最多的可能就是Network面板了。但是每次想重新查看一个请求,我们往往都是通过刷新页面、点击按钮等方式去触发xhr请求,这种方式有时显得会比较麻烦,我们可以通过google提供的Replay XHR的方式去发起一条新的请求,这样对于我们开发效率的提升是有所帮助的。
chrome-调试-重新发起xhr请求

编辑页面上的任何文本 ✍

在控制台输入document.body.contentEditable="true"或者document.designMode = 'on'就可以实现对网页的编辑了。
chrome-调试-网页编辑

其实这个还是比较实用的,比如你要测试一个DOM节点文字太长时,样式是否会混乱,或者要去直接修改页面元素去满足一些业务需求时。(我之前是在Elements面板一个一个去修改的,,,)

网络面板(Network)的幻灯片模式 🌇

启动Network 面板下的Capture screenshots就可以在页面加载时捕捉屏幕截图。有点幻灯片的感觉。
chrome-调试-启动Network幻灯片

单击每一帧截图,显示的就是对应时刻发生的网络请求。这种可视化的展现形式会让你更加清楚每一时刻发生的网络请求情况。

动画检查 🎏

DevTools 中有一个动画面板,默认情况下它是关闭的,很多人可能不太清楚这个功能。它可以让你控制和操纵 CSS 动画,并且可视化这些动画是如何工作的。

要打开该面板,可以在 DevTools 右上角菜单 → More tools 中打开 Animations
chrome-调试-打开动画面板

默认情况下,DevTools 会“监听”动画。一旦触发,它们将被添加到列表中。你能看到这些动画块如何显示。在动画本身上,DevTools 会向我们展示哪些属性正在更改,例如 background-colortransform

然后,我们可以通过使用鼠标拖动或调整时间轴来修改该动画。

递增/递减 CSS 属性值 🃏

作为前端开发,平时少不了通过Elements面板去查找元素以及它的css样式。有时调整像素px会比较麻烦一点,这时就可以使用快捷键去帮你完成:

* 增量0.1
  * Mac: Option +向上和Option +向下
  * Windows: Alt +向上和Alt +向下
* 增量1
  * Mac:向上+向下
  * Windows:向上+向下
* 增量10
  * Mac:⇧+向上和⇧+向下
  * Windows:⇧+向上和⇧+向下
* 递增100
  * Mac: ⌘+向上和⌘+向下
  * Windows: Ctrl +向上和Ctrl +向下

在低端设备和弱网情况下进行测试 📱

我们平时开发一般都是在办公室(wifi 网速加快),而且设备一般都是市面上较新的。但是产品的研发和推广,一定要考虑低设备人群和弱网的情况。

Chrome DevTools中可以轻松调节CPU功能和网络速度。这样,我们就可以测试 Web 应用程序性能并进行相应优化。

具体打开方式是:在Chrome DevTools中通过CMD/Ctrl + Shift + p打开命令菜单。然后输入Show Performance打开性能面板。

copying & saving 📜

在调试的过程中,我们总会有对 Dev Tools 里面的数据进行 复制 或者 保存 的操作,其实他们也是有一些小技巧的!

copy()

可以通过全局的方法 copy()consolecopy 任何你能拿到的资源
chrome-调试-copy

Store as global variable

如果在console中打印了一堆数据,想对这堆数据做额外的操作,可以将它存储为一个全局变量。只需要右击它,并选择 “Store as global variable”选项。

第一次使用的话,它会创建一个名为 temp1 的变量,第二次创建 temp2,第三次 ... 。通过使用这些变量来操作对应的数据,不用再担心影响到他们原来的值。

自定义 devtools 🌈

chrome-调试-主题

大家平时用的最多的Chrome 主题可能就是白色/黑色这两种了,但用的久了,难免想尝试像IDE一样切换主题。

打开方式

  • 首先需要启用实验模式中的Allow custom UI themes
    • 地址栏输入如下url
    chrome://flags/#enable-devtools-experiments # 启用实验功能
    • 启用实验功能,并重启浏览器

chrome-调试-主题03

  • 控制台中使用快捷键F1打开设置,切换到Experiments 选项
  • 启用Allow custom UI themes

chrome-调试-主题02

  • Chrome商店安装Material DevTools Theme Collection扩展程序

chrome-调试-主题04

  • 选择你喜欢的主题即可

chrome-调试-主题05

CSS/JS 覆盖率 ✅

Chrome DevTools 中的Coverage功能可以帮助我们查看代码的覆盖率。

打开方式

  • 打开调试面板,用快捷键 shift+command+P (mac)输入 Show Coverage调出相应面板

chrome-调试-覆盖率

  • 点击reload 按钮开始检测

chrome-调试-覆盖率02

  • 点击相应文件即可查看具体的覆盖情况(绿色的为用到的代码,红色表示没有用到的代码)

chrome-调试-覆盖率03

自定义代码片段 Snippets 🌰

在平常开发过程中,我们经常有些 JavaScript 的代码想在 Chrome Devtools中调试,直接在 console 下 写比较麻烦,或者我们经常有些代码片段(防抖、节流、获取地址栏参数等)想保存起来,每次打开 Devtools 都能获取到这些代码片段,而不用再去google,正好Chrome Devtool 就提供了这种功能。

如图所示,在 Sources 这个tab栏下,有个 Snippets 标签,在里面可以添加一些常用的代码片段。
chrome-调试-代码片段

将图片复制为数据 URI 🦊

打开方式

  • 选择Network面板
  • 在资源面板中选择Img
  • 右键单击将其复制为数据URI(已编码为base 64

媒体查询 🔭

媒体查询是自适应网页设计的基本部分。在Chrome Devtools中的设备模式下,在三圆点菜单中点击 Show Media queries即可启用:
chrome-调试-媒体查询02

Devtools会在样式表中检测媒体查询,并在顶端标尺中将它们显示为彩色条形:
chrome-调试-媒体查询03

那怎么使用呢?其实也很简单:

  • 点击媒体查询条形,调整视口大小和预览适合目标屏幕大小的样式
  • 右键点击某个条形,查看媒体查询在 CSS 中何处定义并跳到源代码中的定义

keys/values 🎯

这个是Devtools提供的快速查看一个对象的keyvaluesAPI。用起来也很简单:
chrome-调试-keys:values

你可能会说Object.keys()Object.values()也可以实现啊,但这个不是更简单点吗 🤠

table 🦐

Devtools提供的用于将对象数组记录为表格的API:
chrome-调试-table

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
前端森林公众号二维码2

Vue源码探秘(_render 函数)

引言

在上一篇文章的结尾,我们提到了在$mount函数的最后调用了mountComponent函数,而mountComponent函数内又定义了updateComponent函数:

// src/core/instance/lifecycle.js
updateComponent = () => {
  vm._update(vm._render(), hydrating);
};

这里面涉及到_update_render两个函数。本篇文章我们先来分析一下_render函数。

_render

Vue_render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。定义在 src/core/instance/render.js 文件中:

Vue.prototype._render = function(): VNode {
  const vm: Component = this;
  const { render, _parentVnode } = vm.$options;

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    );
  }

  // set parent vnode. this allows render functions to have access
  // to the data on the placeholder node.
  vm.$vnode = _parentVnode;
  // render self
  let vnode;
  try {
    // There's no need to maintain a stack because all render fns are called
    // separately from one another. Nested component's render fns are called
    // when parent component is patched.
    currentRenderingInstance = vm;
    vnode = render.call(vm._renderProxy, vm.$createElement);
  } catch (e) {
    handleError(e, vm, `render`);
    // return error render result,
    // or previous vnode to prevent render error causing blank component
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== "production" && vm.$options.renderError) {
      try {
        vnode = vm.$options.renderError.call(
          vm._renderProxy,
          vm.$createElement,
          e
        );
      } catch (e) {
        handleError(e, vm, `renderError`);
        vnode = vm._vnode;
      }
    } else {
      vnode = vm._vnode;
    }
  } finally {
    currentRenderingInstance = null;
  }
  // if the returned array contains only a single node, allow it
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0];
  }
  // return empty vnode in case the render function errored out
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== "production" && Array.isArray(vnode)) {
      warn(
        "Multiple root nodes returned from render function. Render function " +
          "should return a single root node.",
        vm
      );
    }
    vnode = createEmptyVNode();
  }
  // set parent
  vnode.parent = _parentVnode;
  return vnode;
};

这段代码最关键的是render方法的调用。我们先来看一下这段代码:

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

这里的vm._renderProxy是什么呢?

vm._renderProxy

在之前的文章中,我有介绍_init函数,其中有这么一段代码:

// src/core/instance/init.js

Vue.prototype._init = function(options?: Object) {
  //...

  /* istanbul ignore else */
  if (process.env.NODE_ENV !== "production") {
    initProxy(vm);
  } else {
    vm._renderProxy = vm;
  }

  // ...
};

表示在生产环境下,vm._renderProxy就是vm本身;在开发环境下则调用initProxy方法,将vm作为参数传入,来看下initProxy函数:

// src/core/instance/proxy.js
let initProxy;

initProxy = function initProxy(vm) {
  if (hasProxy) {
    // determine which proxy handler to use
    const options = vm.$options;
    const handlers =
      options.render && options.render._withStripped ? getHandler : hasHandler;
    vm._renderProxy = new Proxy(vm, handlers);
  } else {
    vm._renderProxy = vm;
  }
};

hasProxy是什么呢?看下对它的定义:

// src/core/instance/proxy.js
const hasProxy = typeof Proxy !== "undefined" && isNative(Proxy);

很简单,就是判断一下浏览器是否支持Proxy

如果支持就创建一个Proxy对象赋给vm._renderProxy;不支持就和生产环境一样直接使用vm._renderProxy

如果是在开发环境下并且浏览器支持Proxy的情况下,会创建一个Proxy对象,这里的第二个参数handlers,它的定义是:

// src/core/instance/proxy.js
const handlers =
  options.render && options.render._withStripped ? getHandler : hasHandler;

handlers,是负责定义代理行为的对象。options.render._withStripped 的取值一般情况下都是 false ,所以 handlers 的取值为 hasHandler

我们来看下hasHandler:

// src/core/instance/proxy.js
const hasHandler = {
  has(target, key) {
    const has = key in target;
    const isAllowed =
      allowedGlobals(key) ||
      (typeof key === "string" &&
        key.charAt(0) === "_" &&
        !(key in target.$data));
    if (!has && !isAllowed) {
      if (key in target.$data) warnReservedPrefix(target, key);
      else warnNonPresent(target, key);
    }
    return has || !isAllowed;
  }
};

hasHandler对象里面定义了一个has函数。has 函数的执行逻辑是求出属性查询的结果然后存入 has ,下面的 isAllowed 涉及到一个函数 allowedGlobals ,来看看这个函数:

// src/core/instance/proxy.js
const allowedGlobals = makeMap(
  "Infinity,undefined,NaN,isFinite,isNaN," +
    "parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent," +
    "Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl," +
    "require" // for Webpack/Browserify
);

这里传入了各种js的全局属性、函数作为makeMap的参数,其实很容易看出来,allowedGlobals就是检查key是不是这些全局的属性、函数其中的任意一个。

所以isAllowedtrue的条件就是keyjs全局关键字或者非vm.$data下的以_开头的字符串。

如果!has(访问的keyvm不存在)和!isAllowed同时成立的话,进入if语句。这里面有两种情况,分别对应两个不同的警告,先来看第一个:

// src/core/instance/proxy.js
const warnReservedPrefix = (target, key) => {
  warn(
    `Property "${key}" must be accessed with "$data.${key}" because ` +
      'properties starting with "$" or "_" are not proxied in the Vue instance to ' +
      "prevent conflicts with Vue internals. " +
      "See: https://vuejs.org/v2/api/#data",
    target
  );
};

警告信息的大致意思是: 在Vue中,以$_开头的属性不会被代理,因为有可能与内置属性产生冲突。如果你设置的属性以$_开头,那么不能直接通过vm.key这种形式访问,而是需要通过vm.$data.key来访问。

第二个警告是针对我们的key没有在data中定义:

// src/core/instance/proxy.js
const warnNonPresent = (target, key) => {
  warn(
    `Property or method "${key}" is not defined on the instance but ` +
    'referenced during render. Make sure that this property is reactive, ' +
    'either in the data option, or for class-based components, by ' +
    'initializing the property. ' +
    'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',
    target
  )
}

这个报错信息,我想你一定不陌生。就是这种:

到这里,我们就大致把vm._renderProxy分析完成了,回到上文中这一行代码:

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

我们再来看下vm.$createElement

vm.$createElement

vm.$createElement的定义是在initRender函数中:

function initRender(vm: Component) {
  // ...

  // 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);

  // ...
}

这里我们先省略其他部分代码,只关注中间这两行。这两行是分别给实例vm加上_c$createElement方法。这两个方法都调用了createElement方法,只是最后一个参数值不同。

从注释可以很清晰的看出两者的不同,vm._c是内部函数,它是被模板编译成的 render 函数使用;而 vm.$createElement是提供给用户编写的 render 函数使用。

为了更好的理解这两个函数,下面看两个例子:

如果我们手动编写render函数,通常是这样写的:

<div id="app"></div>
<script>
render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
},
data() {
  return {
    message: '森林小哥哥'
  }
}
</script>

这里我们编写的 render 函数的参数 createElement 其实就是 vm.$createElement,所以我也可以这么写:

render: function () {
  return this.$createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
},
data() {
  return {
    message: '森林小哥哥'
  }
}

如果我们使用字符串模版,那么是这样写的:

<div id="app">{{ message }}</div>
<script>
  var app = new Vue({
    el: "#app",
    data() {
      return {
        message: "森林小哥哥"
      };
    }
  });
</script>

这种使用字符串模板的情况,使用的就是vm._c了。

使用字符串模板的话,在相关代码执行完前,会先在页面显示 {{ message }} ,然后再展示 森林小哥哥;而我们手动编写 render 函数的话,根据上一节的分析,内部就不用执行把字符串模板转换成 render 函数这个操作,并且是空白页面之后立即就显示 森林小哥哥 ,用户体验会更好。

我们重新回顾下_render函数:

// src/core/instance/render.js
Vue.prototype._render = function(): VNode {
  const vm: Component = this;
  const { render, _parentVnode } = vm.$options;

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    );
  }

  // set parent vnode. this allows render functions to have access
  // to the data on the placeholder node.
  vm.$vnode = _parentVnode;
  // render self
  let vnode;
  try {
    // There's no need to maintain a stack because all render fns are called
    // separately from one another. Nested component's render fns are called
    // when parent component is patched.
    currentRenderingInstance = vm;
    vnode = render.call(vm._renderProxy, vm.$createElement);
  } catch (e) {
    handleError(e, vm, `render`);
    // return error render result,
    // or previous vnode to prevent render error causing blank component
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== "production" && vm.$options.renderError) {
      try {
        vnode = vm.$options.renderError.call(
          vm._renderProxy,
          vm.$createElement,
          e
        );
      } catch (e) {
        handleError(e, vm, `renderError`);
        vnode = vm._vnode;
      }
    } else {
      vnode = vm._vnode;
    }
  } finally {
    currentRenderingInstance = null;
  }
  // if the returned array contains only a single node, allow it
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0];
  }
  // return empty vnode in case the render function errored out
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== "production" && Array.isArray(vnode)) {
      warn(
        "Multiple root nodes returned from render function. Render function " +
          "should return a single root node.",
        vm
      );
    }
    vnode = createEmptyVNode();
  }
  // set parent
  vnode.parent = _parentVnode;
  return vnode;
};

这里vm.$createElement被作为参数给了render函数,最后会返回一个VNode,我们直接跳过catchfinally,来到最后。

判断vnode是数组并且长度为 1 的情况下,直接取第一项。

如果vnode不是VNode类型(一般是由于用户编写不规范导致渲染函数出错),就去判断vnode是不是数组,如果是的话抛出警告(说明用户的template包含了多个根节点)。并创建一个空的VNode给到vnode。最后返回vnode

总结

到这里,_render函数的大致流程就分析完成了。vm._render 最终是通过执行 createElement 方法并返回的是 vnode,它是一个虚拟 NodeVue 2.0 相比 Vue 1.0 最大的升级就是利用了 Virtual DOM

最后呢,我先抛出一个问题给到大家:为什么 Vue 要限制 template 只能有一个根节点呢?

其实这个问题是与上文最后提到的VNodeVirtual DOM相关的。下一篇文章中呢,我将带大家一块来看下Virtual DOM相关部分的源码。

Vue源码探秘(nextTick)

引言

前两节我带大家分别分析了依赖收集派发更新。在上一节的最后,提到了nextTick,这一节我们一起来看下nextTick 的源码实现。

为什么要异步更新

我们先思考一个问题:Vue为什么要引入异步更新队列这一概念?

其实这块,在上一节有提到。如果渲染 Watcher 的回调是同步执行的,那执行流程是这样子的:在修改一个属性值的时候,会触发它的 setter ,然后就马上去触发渲染 Watcher 的回调,引起页面的重新渲染,乍看好像没有什么问题。

但是我们在实际开发中经常会在一个函数内修改多个属性,也就是说这些属性是同时或者说几乎同时被修改了,那实际上渲染 Watcher 完全可以等同一时间点的所有属性都修改完了再去执行回调重新渲染。

而这对于同步 Watcher 是无法实现的,有几个属性修改它就会被触发渲染页面几次,显然这会造成严重的性能问题。

这也就是引入异步更新队列的意义所在,在数据修改时,不会直接触发 Watcher 的回调,而是先把它放入一个异步队列中,并且我们可以通过相关逻辑控制当有多个属性同时修改时同个 Watcher 不会被重复添加到队列中。

因为有 nextTick 的存在,Watcher 的回调是异步执行的,所以它会一直等待同一时刻被修改的属性的 setter 都触发完,相关 Watcher 都添加入队列中后(也就是同步代码执行完后),才会触发渲染 Watcher 重新渲染,这样页面只需要重新渲染一次,性能也得到了很大的提升。

js 运行机制

由于 nextTick 涉及到了 macrotaskmicrotask 的概念。而这又与js的运行机制有关,所以这里我们有必要先大概了解一下js的运行机制,也有助于我们理解 nextTick

单线程

js 是一门单线程语言,或者说它只有一个主线程。

为什么 js 不能是多线程呢,这是因为 js 是一种与浏览器交互的语言。假设 js 是多线程,然后同时有多个线程操作一个 DOM 节点,那浏览器要以哪个线程为准呢,这显然会造成错乱。

js 引擎

一个典型的浏览器会有图形引擎和一个 js 引擎。js 引擎是一个专门处理 js 脚本的虚拟机,比如 Chromejs 引擎 V8

执行上下文和执行栈

js 在执行一个函数的时候,会创建这个函数的执行上下文,并将执行上下文压入执行栈。当前执行上下文在执行栈的栈顶,当执行完后会弹出栈。

事件循环和任务队列

浏览器的Event loopNodeEvent loop是两个概念。具体可以参考https://segmentfault.com/a/1190000013861128

这里我主要说下在浏览器的Event loop中的microtaskmacrotask

实际上异步任务有两种,分别是微任务 microtask 和宏任务 macrotask 。像是 PromisesMutationObserver 还有 node.js 中的 process.nextTick 都属于微任务;而 setIntervalsetTimeoutsetImmediaterequestAnimationFrameDOM 事件回调则属于宏任务。因此相对的任务队列也分为微任务队列宏任务队列

microtaskmacrotask 的区别在哪里呢?当主线程处于闲置状态时,会先去微任务队列看是否有事件需要执行,有的话会执行完微任务队列的所有事件直到微任务队列为空。然后再去宏任务队列看,有的话则取出第一位的事件执行。

也就是说在每一次事件循环中,只会提取一个 macrotask 出来执行,而 microtask 会一直提取,直到微任务队列清空,并且 microtask 先于 macrotask 执行。

同样举一个例子来帮助理解:

setTimeout(() => {
  console.log(1);
}, 0);
new Promise((resolve) => {
  console.log(2);
  resolve(3);
}).then((res) => {
  console.log(res);
});
console.log(4);
setTimeout(() => {
  console.log(5);
}, 0);

上述例子的输出结果是 2、4、3、1、5 。因为 Promise 属于 microtasksetTimeout 属于 macrotask ,会先输出 3 再输出 1、5 。

nextTick

nextTick 除了有提供给内部使用的接口,也有暴露给外部使用的接口。从官方文档我们可以了解到,外部接口有 vm.$nextTickVue.nextTick 。其中 vm.$nextTick 定义在 src/core/instance/render.js 文件中:

// src/core/instance/render.js

export function renderMixin(Vue: Class<Component>) {
  // ...
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this);
  };
  // ...
}

Vue.nextTick 定义在 src/core/global-api/index.js 文件中:

// src/core/global-api/index.js

export function initGlobalAPI(Vue: GlobalAPI) {
  // ...
  Vue.nextTick = nextTick;
  // ...
}

这两个外部接口实际上都是调用了内部的 nextTick 接口,和 nextTick 相关的代码都定义在 src/core/util/next-tick.js 文件中:

// src/core/util/next-tick.js
/* @flow */
/* globals MutationObserver */

import { noop } from "shared/util";
import { handleError } from "./error";
import { isIE, isIOS, isNative } from "./env";

export let isUsingMicroTask = false;

const callbacks = [];
let pending = false;

function flushCallbacks() {
  // ...
}

/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
    if (isIOS) setTimeout(noop);
  };
  isUsingMicroTask = true;
} else if (
  !isIE &&
  typeof MutationObserver !== "undefined" &&
  (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
  isUsingMicroTask = true;
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb?: Function, ctx?: Object) {
  // ...
}

代码刚开始定义的几个变量和flushCallbacks函数的具体作用,我会在后面介绍。

接着定义了 timerFunc ,它的作用就是异步执行 flushCallbacks 函数。这里 timerFunc 通过一系列的判断来确定最适合的取值。

来看下timerFunc的取值逻辑:

  • 我们知道异步任务有两种,其中 microtask 要优于 macrotask ,所以优先选择 Promise 。因此这里先判断浏览器是否支持 Promise
  • 如果不支持再考虑 macrotask 。对于 macrotask 会先后判断浏览器是否支持 MutationObserversetImmediate
  • 如果都不支持就只能使用 setTimeout 。这也从侧面展示出了 macrotasksetTimeout 的性能是最差的。

我们再来看 nextTick 函数的具体逻辑:

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve;
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, "nextTick");
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}

nextTick 函数会创建一个匿名函数 push 到 callbacks 中。

接着 if (!pending) 语句中 pending 作用显然是让 if 语句的逻辑只执行一次,而它其实就代表 callbacks 中是否有事件在等待执行。

if 语句的逻辑也很简单,就是执行 timerFunc 函数,也就是异步执行 flushCallbacks 函数。现在我们回过头来看 flushCallbacks 函数的定义:

function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

flushCallbacks函数的主要逻辑就是将 pending 置为 false 以及清空 callbacks 数组,然后遍历 callbacks 数组,执行里面的每一个函数。

我们回到 nextTick 函数,来看最后一段代码:

if (!cb && typeof Promise !== "undefined") {
  return new Promise((resolve) => {
    _resolve = resolve;
  });
}

这里 if 对应的情况是我们调用 nextTick 函数时没有传入回调函数并且浏览器支持 Promise ,那么就会返回一个 Promise 实例,并且将 resolve 赋值给 _resolve 。我们再来看前面的代码:

let _resolve;
callbacks.push(() => {
  if (cb) {
    // ...
  } else if (_resolve) {
    _resolve(ctx);
  }
});

当我们执行 callbacks 的函数时,发现没有 cb 而有 _resolve 时就会执行之前返回的 Promise 对象的 resolve 函数。

总结

这一节我们学习了 nextTick 的实现原理,nextTick 会把要执行的回调保存到一个队列 callbacks 中,然后把遍历 callbacks 执行回调作为一个异步任务,所以会在执行栈为空时也就是同步代码执行完后才会执行这些回调。

同时结合上一节派发更新的内容,我们就明白了数据发生改变后,页面重新渲染是一个异步的过程。

滚动视差让你不相信“眼见为实”

引言

视差滚动(Parallax Scrolling)是指让多层背景以不同的速度移动,形成立体的运动效果。

其实,这项技术早在 2013 年就已经开始在一些国外的网站中得到了大量的应用。由于它给网站带来了非常出色的视觉体验,现在已经有数不胜数的网站应用了这项技术。

我是在最近的项目中用到了这块,觉得有必要整理一下。本文主要是简单的介绍一下什么是视差滚动,实现方式以及如何在现有框架(vue/react)中使用视差滚动。

什么是视差滚动?

视差效果, 最初是一个天文术语。当我们看着繁星点点的天空时,较远的恒星运动较慢,而较近的恒星运动较快。当我们坐在车里看着窗外时,我们会有相同的感觉。远处的山脉似乎没有动,附近的稻田很快过去了。许多游戏使用视差效果来增加场景的三维度。说的简单点就是,滚动屏幕时,网页中元素的位置会发生变化。但是不同的元素位置变化的速度不同,导致网页中产生分层元素的错觉。

看完上面这段,相信你对视差滚动的概念已经有了一个初步的了解。下面让我们先来看一下如何用 css 来实现视差滚动。

css 实现

css 中主要有两种实现方式:分别是通过background-attachment: fixedtransform: translate3d来实现,下面让我们看一下具体的实现方式:

background-attachment: fixed

平时业务开发中可能不太会用到background-attachment,让我们先来认识一下它。

background-attachment CSS 属性决定背景图像的位置是在视口内固定,还是随着包含它的区块滚动。

它一共有三个属性:

  • fixed: 键字表示背景相对于视口固定。即使一个元素拥有滚动机制,背景也不会随着元素的内容滚动。
  • local: 此关键字表示背景相对于元素的内容固定。如果一个元素拥有滚动机制,背景将会随着元素的内容滚动。
  • scroll: 此关键字表示背景相对于元素本身固定, 而不是随着它的内容滚动。
    我们使用 background-attachment: fixed 来实现视差滚动,看一下示例:
// html
<div class="a-text">1</div>
<div class="a-img1">2</div>
<div class="a-text">3</div>
<div class="a-img2">4</div>
<div class="a-text">5</div>
<div class="a-img3">6</div>
<div class="a-text">7</div>
// css
$img1: 'https://images.pexels.com/photos/1097491/pexels-photo-1097491.jpeg';

$img2: 'https://images.pexels.com/photos/2437299/pexels-photo-2437299.jpeg';

$img3: 'https://images.pexels.com/photos/1005417/pexels-photo-1005417.jpeg';

div {
    height: 100vh;
    background: rgba(0, 0, 0, .7);
    color: #fff;
    line-height: 100vh;
    text-align: center;
    font-size: 20vh;
}

.a-img1 {
    background-image: url($img1);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

.a-img2 {
    background-image: url($img2);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

.a-img3 {
    background-image: url($img3);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

效果如下:

当然,你可以直接去这里查看:https://codepen.io/jack-cool/pen/MWYogYQ

transform: translate3d

同样,让我们先来看一下两个概念transformperspective

  • transform: css3 属性,可以对元素进行变换(2d/3d),包括平移 translate,旋转 rotate,缩放 scale,等等
  • perspective: css3 属性,当元素涉及 3d 变换时,perspective 可以定义我们眼睛看到的 3d 立体效果,即空间感。

先来看一下示例:

// html
<div id="app">
   <div class="one">one</div>
   <div class="two">two</div>
   <div class="three">three</div>
 </div>
// css
html {
   overflow: hidden;
   height: 100%
}

 body {
   perspective: 1px;
   transform-style: preserve-3d;
   height: 100%;
   overflow-y: scroll;
   overflow-x: hidden;
 }
 #app{
   width: 100vw;
   height:200vh;
   background:skyblue;
   padding-top:100px;
 }
.one{
  width:500px;
  height:200px;
  background:#409eff;
  transform: translateZ(0px);
  margin-bottom: 50px;
}
.two{
  width:500px;
  height:200px;
  background:#67c23a;
  transform: translateZ(-1px);
  margin-bottom: 150px;
}
.three{
  width:500px;
  height:200px;
  background:#e6a23c;
  transform: translateZ(-2px);
  margin-bottom: 150px;
}

效果如下:

当然,你可以直接去这里查看:https://codepen.io/jack-cool/pen/zYxzOpb

这里解释下使用transform: translate3d来实现视差滚动的原理:

1、给容器设置上transform-style: preserve-3dperspective: xpx,那么处于这个容器下的子元素就会处于 3D 空间中;

2、给子元素分别设置不同的transform: translateZ(),这时不同子元素在 3D Z 轴方向距离屏幕的距离也就不一样;

3、滚动滚动条,由于子元素设置了不同的transform: translateZ(),那么他们滚动的上下距离translateY相对屏幕(我们的眼睛),也是不一样的,这就达到了滚动视差的效果。

总结下来就是: 父容器设置transform-style: preserve-3dperspective: xpx,子元素设置不同的transform: translateZ()

看完了用 css 实现滚动视差的两种方式,下面让我们看下如何在现有框架(vue/react)中来应用滚动视差。

vue 或 react 中使用

react 中使用

在 react 中使用可以采用react-parallax,代码示例:

import React from "react";
import { render } from "react-dom";
import { Parallax } from "react-parallax";
import Introduction from "./Introduction";

const styles = {
  fontFamily: "sans-serif",
  textAlign: "center"
};
const insideStyles = {
  background: "white",
  padding: 20,
  position: "absolute",
  top: "50%",
  left: "50%",
  transform: "translate(-50%,-50%)"
};
const image1 =
  "https://images.pexels.com/photos/830891/pexels-photo-830891.jpeg";
const image2 =
  "https://images.pexels.com/photos/1236701/pexels-photo-1236701.jpeg";
const image3 =
  "https://images.pexels.com/photos/3210189/pexels-photo-3210189.jpeg";
const image4 =
  "https://images.pexels.com/photos/2437299/pexels-photo-2437299.jpeg";

const App = () => (
  <div style={styles}>
    <Introduction name="React Parallax" />
    <Parallax bgImage={image1} strength={500}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>HTML inside the parallax</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax bgImage={image3} blur={{ min: -1, max: 3 }}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>Dynamic Blur</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax bgImage={image2} strength={-100}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>Reverse direction</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax
      bgImage={image4}
      strength={200}
      renderLayer={percentage => (
        <div>
          <div
            style={{
              position: "absolute",
              background: `rgba(255, 125, 0, ${percentage * 1})`,
              left: "50%",
              top: "50%",
              borderRadius: "50%",
              transform: "translate(-50%,-50%)",
              width: percentage * 500,
              height: percentage * 500
            }}
          />
        </div>
      )}
    >
      <div style={{ height: 500 }}>
        <div style={insideStyles}>renderProp</div>
      </div>
    </Parallax>
    <div style={{ height: 500 }} />
    <h2>{"\u2728"}</h2>
  </div>
);

render(<App />, document.getElementById("root"));

效果如下:

当然,更多细节可以查看:https://codesandbox.io/s/react-parallax-zw5go

vue 中使用

在 vue 中使用可以采用vue-parallaxy,代码示例:

<template>
  <div id="app">
    <div style="background-color: #fff; height: 100vh;">
      <h1 style="margin-top: 0; padding-top: 20px;">Scroll down ⬇</h1>
    </div>
    <div style="position: relative; z-index: 9999; background-color: #fff;">
      <h1 style="margin:0;">Parallax Effect</h1>
      <parallax>
        <img src="https://images.pexels.com/photos/830891/pexels-photo-830891.jpeg">
      </parallax>
    </div>
    <div style="background-color: #fff; height: 100vh;"></div>
    <h1>Parallax fixed position</h1>

    <div style="position: relative;">
      <parallax :fixed="true">
        <img src="https://images.pexels.com/photos/3210189/pexels-photo-3210189.jpeg">
      </parallax>
    </div>

    <div style="background-color: #fff; height: 100vh;"></div>
  </div>
</template>

<script>
import Parallax from "vue-parallaxy";

export default {
  name: "App",
  components: {
    Parallax
  }
};
</script>

<style>
body {
  margin: 0;
}
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
  position: relative;
}
</style>

效果如下:

当然,更多细节可以查看: https://codesandbox.io/s/vue-parallaxjs-ljh9g

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

expirationTime

expirationTime

ReactDOM.render过程中的updateContainer函数里面有个计算到期时间的函数(computeExpirationForFiber):

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function
): ExpirationTime {
  const current = container.current;
  const currentTime = requestCurrentTime();
  // 这里传入了currentTime和当前的Fiber对象调用了这个计算expirationTime的函数
  const expirationTime = computeExpirationForFiber(currentTime, current);
  return updateContainerAtExpirationTime(
    element,
    container,
    parentComponent,
    expirationTime,
    callback
  );
}

这节就来分析一下expirationTime是如何计算的。

在调用computeExpirationForFiber前,通过requestCurrentTime计算出了currentTime,先来看下requestCurrentTime的定义:

function requestCurrentTime() {
  if (isRendering) {
    // We're already rendering. Return the most recently read time.
    return currentSchedulerTime;
  }
  // Check if there's pending work.
  findHighestPriorityRoot();
  if (
    nextFlushedExpirationTime === NoWork ||
    nextFlushedExpirationTime === Never
  ) {
    // If there's no pending work, or if the pending work is offscreen, we can
    // read the current time without risk of tearing.
    recomputeCurrentRendererTime();
    currentSchedulerTime = currentRendererTime;
    return currentSchedulerTime;
  }
  // There's already pending work. We might be in the middle of a browser
  // event. If we were to read the current time, it could cause multiple updates
  // within the same event to receive different expiration times, leading to
  // tearing. Return the last read time. During the next idle callback, the
  // time will be updated.
  return currentSchedulerTime;
}

React 中我们计算expirationTime要基于当前的时钟时间,一般来说我们只需要获取Date.now或者performance.now就可以了,但是每次获取一下比较消耗性能,所以 React 设置了currentRendererTime来记录这个值,用于一些不需要重新计算得场景。

requestCurrentTime中有如下两行带代码:

recomputeCurrentRendererTime();
currentSchedulerTime = currentRendererTime;

先获取到当前时间赋值给currentRendererTime,然后currentRendererTime赋值给currentSchedulerTime

在上述requestCurrentTime函数中,首先看第一个判断:

if (isRendering) {
  // We're already rendering. Return the most recently read time.
  return currentSchedulerTime;
}

isRendering会在performWorkOnRoot的开始设置为true,在结束设置为false,都是同步的。

在一个事件回调函数中调用多次setState的时候,isRendering总是false,如果是在生命周期钩子函数componentDidMount中调用setState的时候,isRenderingtrue,因为该钩子触发的时机就是在performWorkOnRoot中。

再来看findHighestPriorityRoot()

function findHighestPriorityRoot() {
  let highestPriorityWork = NoWork;
  let highestPriorityRoot = null;
  if (lastScheduledRoot !== null) {
    let previousScheduledRoot = lastScheduledRoot;
    let root = firstScheduledRoot;
    while (root !== null) {
      const remainingExpirationTime = root.expirationTime;
      if (remainingExpirationTime === NoWork) {
        // This root no longer has work. Remove it from the scheduler.

        // TODO: This check is redudant, but Flow is confused by the branch
        // below where we set lastScheduledRoot to null, even though we break
        // from the loop right after.
        invariant(
          previousScheduledRoot !== null && lastScheduledRoot !== null,
          "Should have a previous and last root. This error is likely " +
            "caused by a bug in React. Please file an issue."
        );
        if (root === root.nextScheduledRoot) {
          // This is the only root in the list.
          root.nextScheduledRoot = null;
          firstScheduledRoot = lastScheduledRoot = null;
          break;
        } else if (root === firstScheduledRoot) {
          // This is the first root in the list.
          const next = root.nextScheduledRoot;
          firstScheduledRoot = next;
          lastScheduledRoot.nextScheduledRoot = next;
          root.nextScheduledRoot = null;
        } else if (root === lastScheduledRoot) {
          // This is the last root in the list.
          lastScheduledRoot = previousScheduledRoot;
          lastScheduledRoot.nextScheduledRoot = firstScheduledRoot;
          root.nextScheduledRoot = null;
          break;
        } else {
          previousScheduledRoot.nextScheduledRoot = root.nextScheduledRoot;
          root.nextScheduledRoot = null;
        }
        root = previousScheduledRoot.nextScheduledRoot;
      } else {
        if (remainingExpirationTime > highestPriorityWork) {
          // Update the priority, if it's higher
          highestPriorityWork = remainingExpirationTime;
          highestPriorityRoot = root;
        }
        if (root === lastScheduledRoot) {
          break;
        }
        if (highestPriorityWork === Sync) {
          // Sync is highest priority by definition so
          // we can stop searching.
          break;
        }
        previousScheduledRoot = root;
        root = root.nextScheduledRoot;
      }
    }
  }

  nextFlushedRoot = highestPriorityRoot;
  nextFlushedExpirationTime = highestPriorityWork;
}

findHighestPriorityRoot会找到root双向链表(React.render会创建一个root并添加到这个双向链表中)中有任务需要执行并且到期时间最大即优先级最高的任务,然后将这个需要更新的root以及最大到期时间赋值给nextFlushedRoot以及nextFlushedExpirationTime。当没有任务的时候nextFlushedExpirationTimeNoWork

接着来看第二个判断:

if (
  nextFlushedExpirationTime === NoWork ||
  nextFlushedExpirationTime === Never
) {
  // If there's no pending work, or if the pending work is offscreen, we can
  // read the current time without risk of tearing.
  recomputeCurrentRendererTime();
  currentSchedulerTime = currentRendererTime;
  return currentSchedulerTime;
}

如果没有任务需要执行,那么重新计算当前时间,并返回,在事件处理函数中第一个 setState 会重新计算当前时间,但是第二个 setState 的时候,由于已经有更新任务在队列中了,所以这里直接跳过判断,最后返回上一次 setState 时的记录的当前时间。

注意:这里调用的recomputeCurrentRendererTime是通过调用performance.now()或者Date.now()获取的时间。

看完currentTime,我们来看这节的重点:expirationTime

为什么需要ExpirationTime

React16带来的最振奋人心的改动就是Fiber架构,改变了之前react的组件渲染机制,新的架构使原来同步渲染的组件现在可以异步化,可中途中断渲染,执行更高优先级的任务。释放浏览器主线程。

所以每一个任务都会有一个优先级,不然岂不是会乱套了..... ExpirationTime就是优先级,它是一个过期时间。

在计算ExpirationTime之前调用了requestCurrentTime得到了一个currentTime。这个函数里面牵扯了一些复杂的关于后面知识的逻辑,我们先不深究,大家就先理解为一个当前时间类似的概念。

这里先来看一下计算expirationTime的方法computeExpirationForFiber

computeExpirationForFiber

function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
  let expirationTime;
  // ....
  // No explicit expiration context was set, and we're not currently
  // performing work. Calculate a new expiration time.
  if (fiber.mode & ConcurrentMode) {
    if (isBatchingInteractiveUpdates) {
      // This is an interactive update
      // 交互引起的更新
      expirationTime = computeInteractiveExpiration(currentTime);
    } else {
      // This is an async update
      // 普通异步更新
      expirationTime = computeAsyncExpiration(currentTime);
    }

    // ...
  }
  // ...
  return expirationTime;
}

在异步更新中,这里我们看到有两种计算更新的方式。computeInteractiveExpirationcomputeAsyncExpiration。分别来看下对应方法。

computeInteractiveExpirationcomputeAsyncExpiration

computeInteractiveExpiration

export const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150;
export const HIGH_PRIORITY_BATCH_SIZE = 100;
export function computeInteractiveExpiration(currentTime: ExpirationTime) {
  return computeExpirationBucket(
    currentTime,
    HIGH_PRIORITY_EXPIRATION,
    HIGH_PRIORITY_BATCH_SIZE
  );
}

computeAsyncExpiration

export const LOW_PRIORITY_EXPIRATION = 5000;
export const LOW_PRIORITY_BATCH_SIZE = 250;

export function computeAsyncExpiration(
  currentTime: ExpirationTime
): ExpirationTime {
  return computeExpirationBucket(
    currentTime,
    LOW_PRIORITY_EXPIRATION,
    LOW_PRIORITY_BATCH_SIZE
  );
}

查看上面两种方法,我们发现其实他们调用的是同一个方法:computeExpirationBucket,只是传入的参数不一样,而且传入的是常量。computeInteractiveExpiration传入的是 150、100,computeAsyncExpiration传入的是 5000、250。说明前者的优先级更高。那么我把前者称为高优先级更新,后者称为低优先级更新。

下面来看computeExpirationBucket方法的具体内容:

const UNIT_SIZE = 10;

// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
// export default 1073741823;(MAGIC_NUMBER_OFFSET)
const MAGIC_NUMBER_OFFSET = MAX_SIGNED_31_BIT_INT - 1;

function computeExpirationBucket(
  currentTime,
  expirationInMs,
  bucketSizeMs
): ExpirationTime {
  return (
    MAGIC_NUMBER_OFFSET -
    ceiling(
      MAGIC_NUMBER_OFFSET - currentTime + expirationInMs / UNIT_SIZE,
      bucketSizeMs / UNIT_SIZE
    )
  );
}

最终得到的公式是(以低优先级为例):MAGIC_NUMBER_OFFSET - (((((MAGIC_NUMBER_OFFSET - currentTime + 500) / 25) | 0) + 1) * 25)

这里用到了ceiling函数:

function ceiling(num: number, precision: number): number {
  return (((num / precision) | 0) + 1) * precision;
}

方法的作用是向上取整,|0表示向下取整,再加 1,即向上取整。间隔在precision内的两个num最终得到的相同的值。 如果precision为 25,则num为 50 和 70 转换后的到期时间都是 75。这样相差25ms内的当前时间经过计算被统一为同样的过期时间,让非常相近的两次更新得到相同的expirationTime,然后在一次更新中完成,相当于一个自动的batchedUpdates,减少渲染次数。

分析完这里,我们回到computeExpirationForFiber

function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) {
  let expirationTime;
  // 如果context有更新任务需要执行
  if (expirationContext !== NoWork) {
    // An explicit expiration context was set;
    // expirationTime设置为context上的到期时间
    expirationTime = expirationContext;
  } else if (isWorking) {
    // 如果处于renderRoot渲染阶段或者commitRoot提交阶段
    if (isCommitting) {
      // 如果处于commitRoot
      // Updates that occur during the commit phase should have sync priority
      // by default.
      // expirationTime设置为同步Sync
      expirationTime = Sync;
    } else {
      // 处于renderRoot
      // Updates during the render phase should expire at the same time as
      // the work that is being rendered.
      // expirationTime设置为当前的到期时间nextRenderExpirationTime
      expirationTime = nextRenderExpirationTime;
    }
  } else {
    // No explicit expiration context was set, and we're not currently
    // performing work. Calculate a new expiration time.
    if (fiber.mode & ConcurrentMode) {
      if (isBatchingInteractiveUpdates) {
        // 如果正在批处理交互式更新
        // This is an interactive update
        // 交互引起的更新
        expirationTime = computeInteractiveExpiration(currentTime);
      } else {
        // This is an async update
        // 普通异步更新
        expirationTime = computeAsyncExpiration(currentTime);
      }
      // If we're in the middle of rendering a tree, do not update at the same
      // expiration time that is already rendering.
      if (nextRoot !== null && expirationTime === nextRenderExpirationTime) {
        // 如果有下一root树需要更新,并且到期时间与该树到期时间相等
        expirationTime -= 1; // expirationTime减一,表示让下一个root先更新
      }
    } else {
      // This is a sync update
      expirationTime = Sync;
    }
  }
  if (isBatchingInteractiveUpdates) {
    // 如果正在批处理交互式更新
    // This is an interactive update. Keep track of the lowest pending
    // interactive expiration time. This allows us to synchronously flush
    // all interactive updates when needed.
    // 如果最低优先级的交互式更新优先级大于到期时间expirationTime或者没有交互式更新任务
    if (
      lowestPriorityPendingInteractiveExpirationTime === NoWork ||
      expirationTime < lowestPriorityPendingInteractiveExpirationTime
    ) {
      // 将最低优先级的交互式更新任务到期时间设置为到期时间expirationTime
      lowestPriorityPendingInteractiveExpirationTime = expirationTime;
    }
  }
  return expirationTime;
}

webpack5快发布了,你还没用过4吗?

引言

webpack5 预计会在 2020 年年初发布,之前从 alpha 版本就有关注,本次重点更新在长期缓存,tree shakking 和 es6 打包这块。具体变更可以参考https://github.com/webpack/changelog-v5/blob/master/README.md。

webpack 是现代前端开发中最火的模块打包工具,只需要通过简单的配置,便可以完成模块的加载和打包。那它是怎么做到通过对一些插件的配置,便可以轻松实现对代码的构建呢?

本篇文章不会去探讨 webpack5 中所要更新的内容,我相信大多数前端同学对于 webpack 只是会简单的配置,而且现在像 vue-cli、umi 等对于 webpack 都有很好的封装,但其实这样对于我们自己是不太好的。尤其是想针对业务场景去做一些个性化的定制时。只有对 webpack 中的细节足够了解,我们才能游刃有余,本文将从 webpack 现有的大版本 webpack4,带你一步步打造极致的前端开发环境。

安装 webpack 的几种方式

  • global(全局):通过 webpack index.js 运行
  • local(项目维度安装):通过 npx webpack index.js 运行

避免全局安装 webpack(针对多个项目采用不同的 webpack 版本进行打包的场景),可采用npx

entry(入口)

单一入口

// webpack.config.js

const config = {
  entry: {
    main: "./src/index.js"
  }
};

### 多入口

```js
// webpack.config.js

const config = {
  entry: {
    main: "./src/index.js",
    sub: "./src/sub.js"
  }
};

output(输出)

默认配置

// webpack.config.js
const path = require('path');
...

const config = {
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
};

module.exports = config;

多个入口起点

如果配置创建了多个单独的 "chunk"(例如,使用多个入口起点或使用像 CommonsChunkPlugin 这样的插件),则应该使用占位符(substitutions)来确保每个文件具有唯一的名称。

// webpack.config.js
const path = require('path');
{
  entry: {
    main: './src/index.js',
    sub: './src/sub.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  }
}

// 写入到硬盘:./dist/main.js, ./dist/sub.js

高级进阶

使用 cdn

// webpack.config.js
const path = require('path');
{
  entry: {
    main: './src/index.js',
    sub: './src/sub.js'
  },
  output: {
    publicPath: 'http://cdn.example.com'
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  }
}

// 写入到http://cdn.example.com/main.js, http://cdn.example.com/sub.js

loaders

webpack 可以使用 loader 来预处理文件。这允许你打包除 JavaScript 之外的任何静态资源。

file-loader

  • file-loader 可以解析项目中的 url 引入(不仅限于 css),根据我们的配置,将图片拷贝到相应的路径,再根据我们的配置,修改打包后文件引用路径,使之指向正确的文件。
  • 默认情况下,生成的文件的文件名就是文件内容的 MD5 哈希值并会保留所引用资源的原始扩展名。
rules: [
  {
    test: /\.(jpg|png|gif)$/,
    use: {
      loader: "file-loader",
      options: {
        name: "[name]_[hash].[ext]",
        outputPath: "images/"
      }
    }
  }
];

url-loader

  • url-loader 功能类似于 file-loader,但是在文件大小(单位 byte)低于指定的限制时,可以返回一个 DataURL。
  • url-loader 把资源文件转换为 URL,file-loader 也是一样的功能。不同之处在于 url-loader 更加灵活,它可以把小文件转换为 base64 格式的 URL,从而减少网络请求次数。url-loader 依赖 file-loader。
rules: [
  {
    test: /\.(jpg|png|gif)$/,
    use: {
      loader: "url-loader",
      options: {
        name: "[name]_[hash].[ext]",
        outputPath: "images/",
        limit: 204800
      }
    }
  }
];

css-loader

  • 只负责加载 css 模块,不会将加载的 css 样式应用到 html
  • importLoaders 用于指定在 css-loader 前应用的 loader 的数量
  • 查询参数 modules 会启用 CSS 模块规范
module: {
  rules: [
    {
      test: /\.css$/,
      use: ["style-loader", "css-loader"]
    }
  ];
}

style-loader

  • 负责将 css-loader 加载到的 css 样式动态的添加到 html-head-style 标签中
  • 一般建议将 style-loader 与 css-loader 结合使用

sass-loader

安装

yarn add sass-loader node-sass webpack --dev

  • node-sass 和 webpack 是 sass-loader 的 peerDependency,因此能够精确控制它们的版本。
  • loader 执行顺序:从下至上,从右至左
  • 通过将 style-loader 和 css-loader 与 sass-loader 链式调用,可以立刻将样式作用在 DOM 元素。
// webpack.config.js
module.exports = {
...
module: {
  rules: [{
    test: /\.scss$/,
    use: [{
        loader: "style-loader" // 将 JS 字符串生成为 style 节点
    }, {
        loader: "css-loader" // �将 CSS 转化成 CommonJS 模块
    }, {
        loader: "sass-loader" // 将 Sass 编译成 CSS
    }]
  }]
}
};

postcss-loader

  • webpack4 中使用 postcss-loader 代替 autoprefixer,给 css3 样式加浏览器前缀。具体可参考https://blog.csdn.net/u014628388/article/details/82593185
// webpack.config.js
 {
  test: /\.scss$/,
  use: [
    'style-loader',
      'css-loader',
      'sass-loader',
      'postcss-loader'
    ],
}

//postcss.config.js
module.exports = {
    plugins: [
        require('autoprefixer')({ browsers: ['last 2 versions'] }),
    ],
};

plugins

plugin 可以在 webpack 运行到某个时刻的时候,帮你做一些事情

HtmlWebpackPlugin

  • HtmlWebpackPlugin 会在打包结束后,自动生成一个 html 文件,并把打包生成的 js 自动引入到这个 html 文件中
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
...
plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
  ],
};

clean-webpack-plugin

  • clean-webpack-plugin 插件用来清除残留打包文件,特别是文件末尾添加了 hash 之后,会导致改变文件内容后重新打包时,文件名不同而内容越来越多。
  • 新版本中的 clean-webpack-plugin 仅接受一个对象,默认不需要传任何参数。具体可参考https://blog.csdn.net/qq_23521659/article/details/88353708
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
...
plugins: [
    new CleanWebpackPlugin()
  ],
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }

SplitChunksPlugin

  • 具体概念可参考https://juejin.im/post/5af15e895188256715479a9a
splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

MiniCssExtractPlugin

将 CSS 提取为独立的文件的插件,对每个包含 css 的 js 文件都会创建一个 CSS 文件,支持按需加载 css 和 sourceMap

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      // Options similar to the same options in webpackOptions.output
      // both options are optional
      filename: "[name].css",
      chunkFilename: "[id].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          {
            loader: "css-loader",
            options: {
              importLoaders: 2 // 用于指定在 css-loader 前应用的 loader 的数量
              // modules: true   // 查询参数 modules 会启用 CSS 模块规范
            }
          },
          "sass-loader",
          "postcss-loader"
        ]
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          "css-loader",
          "postcss-loader"
        ]
      }
    ]
  }
};

OptimizeCSSAssetsPlugin

webpack5 可能会内置 CSS 压缩器,webpack4 需要自己使用压缩器,可以使用 optimize-css-assets-webpack-plugin 插件。 设置 optimization.minimizer 覆盖 webpack 默认提供的,确保也指定一个 JS 压缩器

const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");

module.exports = {
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourcMap: true
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
      chunkFilename: "[id].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"]
      }
    ]
  }
};

devtool

source map

source map 就是对打包生成的代码与源代码的一种映射,主要是为了方便定位问题和排查问题。devtool 关键有 eval、cheap、module、inline 和 source-map 这几块,具体可参考文档:https://www.webpackjs.com/configuration/devtool/

  • development 环境参考配置: 'cheap-module-eval-source-map'
  • production 环境参考配置: 'cheap-module-source-map'

webpack-dev-server

webpack-dev-server 提供了一个简单的 web 服务器,并且能够实时重新加载(live reloading)。具体可参考https://www.webpackjs.com/guides/development/#%E4%BD%BF%E7%94%A8-webpack-dev-server

接口代理(请求转发)

如果你有单独的后端开发服务器 API,并且希望在同域名下发送 API 请求 ,那么代理某些 URL 会很有用。dev-server 使用了非常强大的 http-proxy-middleware 包。常用于接口请求转发。具体参考https://www.webpackjs.com/configuration/dev-server/#devserver-proxy

devServer: {
    contentBase: "./dist",
    open: true,
    hot: true,
    hotOnly: true,
    proxy: {
      "/api": {
        target: "https://other-server.example.com",
        pathRewrite: {"^/api" : ""},
        secure: false,
        bypass: function(req, res, proxyOptions) {
          if (req.headers.accept.indexOf("html") !== -1) {
            console.log("Skipping proxy for browser request.");
            return "/index.html";
          }
        }
      }
    }
  },

解决单页面路由问题

当使用 HTML5 History API 时,任意的 404 响应都可能需要被替代为 index.html
通过传入以下启用:

historyApiFallback: true;

通过传入一个对象,比如使用 rewrites 这个选项,此行为可进一步地控制:

historyApiFallback: {
  rewrites: [
    { from: /^\/$/, to: "/views/landing.html" },
    { from: /^\/subpage/, to: "/views/subpage.html" },
    { from: /./, to: "/views/404.html" }
  ];
}

webpack-dev-middleware

webpack-dev-middleware 是一个容器(wrapper),它可以把 webpack 处理后的文件传递给一个服务器(server)。 webpack-dev-server 在内部使用了它,同时,它也可以作为一个单独的包来使用,以便进行更多自定义设置来实现更多的需求

// server.js
// 使用webpack-dev-middleware
// https://www.webpackjs.com/guides/development/#%E4%BD%BF%E7%94%A8-webpack-dev-middleware
const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const config = require("./webpack.config.js");
const complier = webpack(config);

const app = express();

app.use(
  webpackDevMiddleware(complier, {
    publicPath: config.output.publicPath
  })
);

app.listen(3000, () => {
  console.log("server is running");
});

Hot Module Replacement

模块热替换(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新各种模块,而无需进行完全刷新。

// webpack.config.js
...
const webpack = require('webpack');
...
devServer: {
  contentBase: './dist',
  open: true,
  hot: true,
  hotOnly: true
},
plugins: [
  ...
  new webpack.HotModuleReplacementPlugin()
],

如果已经通过 HotModuleReplacementPlugin 启用了模块热替换(Hot Module Replacement),则它的接口将被暴露在 module.hot 属性下面。通常,用户先要检查这个接口是否可访问,然后再开始使用它。

// index.js
if (module.hot) {
  module.hot.accept("./library.js", function() {
    // 使用更新过的 library 模块执行某些操作...
  });
}

bundle 分析

借助一些官方推荐的可视化分析工具,可对打包后的模块进行分析以及优化

  • webpack-chart: webpack 数据交互饼图
  • webpack-visualizer: 可视化并分析你的 bundle,检查哪些模块占用空间,哪些可能是重复使用的
  • webpack-bundle-analyzer: 一款分析 bundle 内容的插件及 CLI 工具,以便捷的、交互式、可缩放的树状图形式展现给用户

Preloading、Prefetching

prefetch:会等待核心代码加载完成后,页面带宽空闲后再去加载 prefectch 对应的文件;preload:和主文件一起去加载

  • 可以使用谷歌浏览器 Coverage 工具查看代码覆盖率(ctrl+shift+p > show coverage)
  • 使用异步引入 js 的方式可以提高 js 的使用率,所以 webpack 建议我们多使用异步引入的方式,这也是 splitChunks.chunks 的默认值是"async"的原因
  • 使用魔法注释 /_ webpackPrefetch: true _/ ,这样在主要 js 加载完,带宽有空闲时,会自动下载需要引入的 js
  • 使用魔法注释 /_ webpackPreload: true _/,区别是 webpackPrefetch 会等到主业务文件加载完,带宽有空闲时再去下载 js,而 preload 是和主业务文件一起加载的

babel

babel 编译 es6、jsx 等

  • @babel/core babel 核心模块
  • @babel-preset-env 编译 es6 等
  • @babel/preset-react 转换 jsx
  • @babel/plugin-transform-runtime 避免 polyfill 污染全局变量,减少打包体积
  • @babel/polyfill es6 内置方法和函数转化垫片
  • @babel/runtime
module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      use: {
        loader: "babel-loader"
      }
    }
  ];
}

新建.babelrc 文件

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": ["@babel/plugin-transform-runtime"]
}

按需引入 polyfill

在 src 下的 index.js 中全局引入@babel/polyfill 并写入 es6 语法,但是这样有一个缺点:
全局引入@babel/polyfill 的这种方式可能会导入代码中不需要的 polyfill,从而使打包体积更大,修改.babelrc 配置

`yarn add core-js@2 @babel/runtime-corejs2 --dev`

{
  "presets": [
    [
      "@babel/preset-env", {
      "useBuiltIns": "usage"
      }
    ],
    "@babel/preset-react"
  ],
  "plugins": ["@babel/plugin-transform-runtime"]
}

这就配置好了按需引入。配置了按需引入 polyfill 后,用到 es6 以上的函数,babel 会自动导入相关的 polyfill,这样能大大减少打包编译后的体积。

babel-runtime 和 babel-polyfill 的区别

参考https://www.jianshu.com/p/73ba084795ce

  • babel-polyfill 会”加载整个 polyfill 库”,针对编译的代码中新的 API 进行处理,并且在代码中插入一些帮助函数
  • babel-polyfill 解决了 Babel 不转换新 API 的问题,但是直接在代码中插入帮助函数,会导致污染了全局环境,并且不同的代码文件中包含重复的代码,导致编译后的代码体积变大。 Babel 为了解决这个问题,提供了单独的包 babel-runtime 用以提供编译模块的工具函数, 启用插件 babel-plugin-transform-runtime 后,Babel 就会使用 babel-runtime 下的工具函数
  • babel-runtime 适合在组件,类库项目中使用,而 babel-polyfill 适合在业务项目中使用。

高级概念

tree shaking(js)

tree shaking 可清除代码中无用的 js 代码,只支持 import 方式引入,不支持 commonjs 的方式引入
mode 是 production 的无需配置,下面的配置是针对 development 的

// webpack.config.js
optimization: {
  usedExports: true
}


// package.json
"sideEffects": false,

Code Spliting

代码分割,和 webpack 无关

  • 同步代码(需在 webpack.config.js 中配置 optimization)
// index.js
import _ from 'lodash';

console.log(_.join(['a','b','c'], '****'))

// 在webpack.base.js里做相关配置
optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },
  • 异步代码(无需任何配置,但需安装@babel/plugin-syntax-dynamic-import包)
// index.js
function getComponent() {
  return import("lodash").then(({ default: _ }) => {
    const element = document.createElement("div");
    element.innerHTML = _.join(["Jack", "Cool"], "-");
    return element;
  });
}

getComponent().then(el => {
  document.body.appendChild(el);
});

Caching(缓存)

通过使用 output.filename 进行文件名替换,可以确保浏览器获取到修改后的文件。[hash] 替换可以用于在文件名中包含一个构建相关(build-specific)的 hash,但是更好的方式是使用 [contenthash] 替换,当文件内容发生变化时,[contenthash]也会发生变化

output: {
  filename: "[name].[contenthash].js",
  chunkFilename: '[name].[contenthash].chunk.js'
}

Shimming

webpack 编译器(compiler)能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些第三方的库(library)可能会引用一些全局依赖(例如 jQuery 中的 $)。这些库也可能创建一些需要被导出的全局变量。这些“不符合规范的模块”就是 shimming 发挥作用的地方

  • shimming 全局变量(第三方库)(ProvidePlugin 相当于一个垫片)
 const path = require('path');
+ const webpack = require('webpack');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist')
-   }
+   },
+   plugins: [
+     new webpack.ProvidePlugin({
+       _: 'lodash'
+     })
+   ]
  };
  • 细粒度 shimming(this 指向 window)(需要安装 imports-loader 依赖)
 const path = require('path');
  const webpack = require('webpack');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist')
    },
+   module: {
+     rules: [
+       {
+         test: require.resolve('index.js'),
+         use: 'imports-loader?this=>window'
+       }
+     ]
+   },
    plugins: [
      new webpack.ProvidePlugin({
        join: ['lodash', 'join']
      })
    ]
  };

环境变量

webpack 命令行环境选项 --env 允许您传入任意数量的环境变量。您的环境变量将可访问 webpack.config.js。例如,--env.production 或--env.NODE_ENV=local

webpack --env.NODE_ENV=local --env.production --progress

使用环境变量必须对 webpack 配置进行一项更改。通常,module.exports 指向配置对象。要使用该 env 变量,必须转换 module.exports 为函数:

// webpack.config.js
const path = require("path");

module.exports = env => {
  // Use env.<YOUR VARIABLE> here:
  console.log("NODE_ENV: ", env.NODE_ENV); // 'local'
  console.log("Production: ", env.production); // true

  return {
    entry: "./src/index.js",
    output: {
      filename: "bundle.js",
      path: path.resolve(__dirname, "dist")
    }
  };
};

library 打包配置

除了打包应用程序代码,webpack 还可以用于打包 JavaScript library
用户应该能够通过以下方式访问 library:

  • ES2015 模块。例如 import library from 'library'
  • CommonJS 模块。例如 require('library')
  • 全局变量,当通过 script 脚本引入时

我们打包的 library 中可能会用到一些第三方库,诸如 lodash。现在,如果执行 webpack,你会发现创建了一个非常巨大的文件。如果你查看这个文件,会看到 lodash 也被打包到代码中。在这种场景中,我们更倾向于把 lodash 当作 peerDependency。也就是说,用户应该已经将 lodash 安装好。因此,你可以放弃对外部 library 的控制,而是将控制权让给使用 library 的用户。这可以使用 externals 配置来完成:

  // webpack.config.js
  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'webpack-numbers.js'
-   }
+   },
+   externals: {
+     lodash: {
+       commonjs: 'lodash',
+       commonjs2: 'lodash',
+       amd: 'lodash',
+       root: '_'
+     }
+   }
  };

对于用途广泛的 library,我们希望它能够兼容不同的环境,例如 CommonJS,AMD,Node.js 或者作为一个全局变量。为了让你的 library 能够在各种用户环境(consumption)中可用,需要在 output 中添加 library 属性:

  // webpack.config.js
  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
-     filename: 'library.js'
+     filename: 'library.js',
+     library: 'library'
    },
    externals: {
      lodash: {
        commonjs: 'lodash',
        commonjs2: 'lodash',
        amd: 'lodash',
        root: '_'
      }
    }
  };

当你在 import 引入模块时,这可以将你的 library bundle 暴露为名为 webpackNumbers 的全局变量。为了让 library 和其他环境兼容,还需要在配置文件中添加 libraryTarget 属性。这是可以控制 library 如何以不同方式暴露的选项。

  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'library.js',
+     library: 'library',
+     libraryTarget: 'umd'
    },
    externals: {
      lodash: {
        commonjs: 'lodash',
        commonjs2: 'lodash',
        amd: 'lodash',
        root: '_'
      }
    }
  };

我们还需要通过设置 package.json 中的 main 字段,添加生成 bundle 的文件路径。

// package.json
{
  ...
  "main": "dist/library.js",
  ...
}

PWA 打包配置

渐进式网络应用程序(Progressive Web Application - PWA),是一种可以提供类似于原生应用程序(native app)体验的网络应用程序(web app)。PWA 可以用来做很多事。其中最重要的是,在离线(offline)时应用程序能够继续运行功能。这是通过使用名为 Service Workers 的网络技术来实现的
添加 workbox-webpack-plugin 插件,并调整 webpack.config.js 文件:

npm install workbox-webpack-plugin --save-dev

webpack.config.js

 const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
  const CleanWebpackPlugin = require('clean-webpack-plugin');
+ const WorkboxPlugin = require('workbox-webpack-plugin');

  module.exports = {
    entry: {
      app: './src/index.js',
      print: './src/print.js'
    },
  plugins: [
    new CleanWebpackPlugin(['dist']),
    new HtmlWebpackPlugin({
-     title: 'Output Management'
+     title: 'Progressive Web Application'
-   })
+   }),
+   new WorkboxPlugin.GenerateSW({
+     // 这些选项帮助 ServiceWorkers 快速启用
+     // 不允许遗留任何“旧的” ServiceWorkers
+     clientsClaim: true,
+     skipWaiting: true
+   })
  ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist')
    }
  };

注册 Service Worker

  import _ from 'lodash';
  import printMe from './print.js';

+ if ('serviceWorker' in navigator) {
+   window.addEventListener('load', () => {
+     navigator.serviceWorker.register('/sw.js').then(registration => {
+       console.log('SW registered: ', registration);
+     }).catch(registrationError => {
+       console.log('SW registration failed: ', registrationError);
+     });
+   });
+ }

现在来进行测试。停止服务器并刷新页面。如果浏览器能够支持 Service Worker,你应该可以看到你的应用程序还在正常运行。然而,服务器已经停止了服务,此刻是 Service Worker 在提供服务。

TypeScript 打包配置

可参考https://www.webpackjs.com/guides/typescript/https://webpack.js.org/guides/typescript/

  • 安装 ts 依赖npm install --save-dev typescript ts-loader
  • 增加 tsconfig.json 配置文件
{
  "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "allowJs": true
  }
}
  • webpack.config.js 添加对 ts/tsx 语法支持(ts-loader)
const path = require("path");

module.exports = {
  entry: "./src/index.ts",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"]
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist")
  }
};
  • 当从 npm 安装第三方库时,一定要牢记同时安装这个库的类型声明文件。可以从 TypeSearch 中找到并安装这些第三方库的类型声明文件。如npm install --save-dev @types/lodash

webpack 性能优化

  • 及时更新 node、yarn、webpack 等的版本
  • 在尽可能少的模块上应用 loader
  • plugin 尽可能精简并确保可靠(选用社区已验证的插件)
  • resolve 参数合理配置(具体参考https://www.webpackjs.com/configuration/resolve/)
  • 使用 DllPlugin 提高打包速度
  • 控制包文件大小(tree shaking / splitChunksPlugin)
  • thread-loader,parallel-webpack,happypack 多进程打包
  • 合理利用 sourceMap
  • 结合stats.json分析打包结果(bundle analyze)
  • 开发环境内存编译
  • 开发环境无用插件剔除

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

前端森林

那个男人 他带着Vue3来了~

Vue 官方团队于 2020 年 9 月 18 日晚 11 点半左右发布了Vue3.0版本 🎉。代号为One Piece

其实Vue3.0版本发布的消息,我是昨天晚上刷朋友圈看到的(已经差不多凌晨 1 点了),然后我就立刻起来,打开电脑,看了一下github,把官方发布文档过了一遍。其实我感觉这次版本更新,最主要的还是Composition API以及对于TypeScript的支持,而且早在 4 月 21 日,尤大在 B 站关于Vue.js 3.0 Beta最新进展的分享上就已经提到了这些改动。

此次版本更新提供了改进的性能,更小的捆绑包大小,更好的TypeScript集成,以及用于处理大规模用例的新API,为框架的长期未来迭代奠定了坚实的基础。

这里我就参考官方releases给大家大概说一下主要更新(更详细的直接查看releases docs):

进一步推进“渐进框架”概念

Vue一开始就秉承这样的原则:成为任何人都能快速学习且平易近人的框架。(时至今日,作为Vue的资深用户,我觉得他做到这一点了)。当然Vue3.0将这种灵活性进一步提升。

分层内部模块

Vue 3.0内核仍然可以通过一个简单的<script>标签使用,但其内部结构已被彻底重写为一组解耦的模块。新的体系结构提供了更好的可维护性,并允许最终用户通过tree-shaking来减少运行时体积大小的一半。

解决规模问题的新 API

Vue 3.0引入了Composition API一套全新的API,旨在解决大型应用程序中Vue使用的难点。Composition API建立在响应式API之上,与2.x基于对象的API方式相比,可实现类似于React Hook的逻辑组成和复用,拥有更灵活的代码组织模式以及更可靠的类型推断能力。

性能改进

Vue 2相比,Vue 3bundle包大小方面通过tree-shaking减轻了多达41%的体积),初始渲染速度加快了55%,更新速度提升了133%,内存使用率方面表现出了显著的性能改进最多可减少54%

改进与TypeScript的兼容

Vue 3.0的代码库是用TypeScript编写的,具有自动生成、测试并构建类型声明。同时,Vue 3已全面支持TSX

未来计划

紧接着,Vue官方团队计划要做的事情如下:

  • 迁移版本
  • IE11支持
  • devtools中的RouterVuex集成
  • Vetur中模板类型推断的进一步改进

快速上手

如果你已经迫不及待的想去学习Vue3了呢,这里放上对应的链接:

参考

Vue 官方发布文档

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

从 Element UI 源码的构建流程来看前端 UI 库设计

引言

由于业务需要,近期团队要搞一套自己的UI组件库,框架方面还是Vue。而业界已经有比较成熟的一些UI库了,比如ElementUIAntDesignVant等。

结合框架Vue,我们选择在ElementUI基础上进行改造。但造轮子绝非易事,首先需要先去了解它整个但构建流程、目录设计等。

本文通过分析ElementUI完整的构建流程,最后给出搭建一个完备的组件库需要做的一些工作,希望对于想了解ElementUI源码或者也有搭建UI组件库需求的你,可以提供一些帮助!

我们先来看下ElementUI的源码的目录结构。

目录结构解析

  • github:存放了Element UI贡献指南、issuePR模板

  • build:存放了打包相关的配置文件

  • examples:组件相关示例 demo
  • packages:组件源码
  • src:存放入口文件和一些工具辅助函数
  • test:单元测试相关文件,这也是一个优秀的开源项目必备的
  • types:类型声明文件

说完文件目录,剩下还有几个文件(常见的.babelrc.eslintc这里就不展开说明了),在业务代码中是不常见的:

  • .travis.yml:持续集成(CI)的配置文件
  • CHANGELOG:更新日志,这里Element UI提供了四种不同语言的,也是很贴心了
  • components.json:标明了组件的文件路径,方便 webpack 打包时获取组件的文件路径。
  • FAQ.md:ElementUI 开发者对常见问题的解答。
  • LICENSE:开源许可证,Element UI使用的是MIT协议
  • Makefile:Makefile 是一个适用于 C/C++ 的工具,在拥有 make 环境的目录下, 如果存在一个 Makefile 文件。 那么输入 make 命令将会执行 Makefile 文件中的某个目标命令。

深入了解构建流程前,我们先来看下ElementUI 源码的几个比较主要的文件目录,这对于后面研究ElementUI的完整流程是有帮助的。

package.json

通常我们去看一个大型项目都是从package.json文件开始看起的,这里面包含了项目的版本、入口、脚本、依赖等关键信息。

我这里拿出了几个关键字段,一一的去分析、解释他的含义。

main

项目的入口文件

import Element from 'element-ui' 时候引入的就是main中的文件

lib/element-ui.common.jscommonjs规范,而lib/index.jsumd规范,这个我在后面的打包模块会详细说明。

files

指定npm publish发包时需要包含的文件/目录。

typings

TypeScript入口文件。

home

项目的线上地址

unpkg

当你把一个包发布到npm上时,它同时应该也可以在unpkg上获取到。也就是说,你的代码既可能在NodeJs环境也可能在浏览器环境执行。为此你需要用umd格式打包,lib/index.jsumd规范,由webpack.conf.js生成。

style

声明样式入口文件,这里是lib/theme-chalk/index.css,后面也会详细说明。

scripts

开发、测试、生产构建,打包、部署,测试用例等相关脚本。scripts算是package.json中最重要的部分了,下面我会一一对其中的重要指令进行说明。

bootstrap

"bootstrap": "yarn || npm i"

安装依赖, 官方推荐优先选用yarn(吐槽一句:我刚开始没看明白,想着bootstrap不是之前用过的那个 ui 库吗 🤔,后来看了下,原来bootstrap翻译过来是引导程序的意思,这样看看也就大概理解了 🤣)

build:file

该指令主要用来自动化生成一些文件。

"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js"

这条指令较长,我们拆开来看:

build/bin/iconInit.js

解析icon.scss,把所有的icon的名字放在icon.json里面 最后挂在Vue原型上的$icon上。

最后通过遍历icon.json,得到了官网的这种效果:

build/bin/build-entry.js

根据components.json文件,生成src/index.js文件,核心就是json-templater/string插件的使用。

我们先来看下src/index.js文件,他对应的是项目的入口文件,最上面有这样一句:

/* Automatically generated by './build/bin/build-entry.js' */

也就是src/index.js文件是由build/bin/build-entry.js脚本自动构建的。我们来看下源码:

// 根据components.json生成src/index.js文件

// 引入所有组件的依赖关系
var Components = require('../../components.json');
var fs = require('fs');
// https://www.npmjs.com/package/json-templater 可以让string与变量结合 输出一些内容
var render = require('json-templater/string');
// https://github.com/SamVerschueren/uppercamelcase  转化为驼峰 foo-bar >> FooBar
var uppercamelcase = require('uppercamelcase');
var path = require('path');
// os.EOL属性是一个常量,返回当前操作系统的换行符(Windows系统是\r\n,其他系统是\n)
var endOfLine = require('os').EOL;

// 生成文件的名字和路径
var OUTPUT_PATH = path.join(__dirname, '../../src/index.js');
var IMPORT_TEMPLATE = 'import {{name}} from \'../packages/{{package}}/index.js\';';
var INSTALL_COMPONENT_TEMPLATE = '  {{name}}';
// var MAIN_TEMPLATE = `/* Automatically generated by './build/bin/build-entry.js' */

// ...


// 获取所有组件的名字,存放在数组中
var ComponentNames = Object.keys(Components);

var includeComponentTemplate = [];
var installTemplate = [];
var listTemplate = [];

ComponentNames.forEach(name => {
  var componentName = uppercamelcase(name);

  includeComponentTemplate.push(render(IMPORT_TEMPLATE, {
    name: componentName,
    package: name
  }));

  if (['Loading', 'MessageBox', 'Notification', 'Message', 'InfiniteScroll'].indexOf(componentName) === -1) {
    installTemplate.push(render(INSTALL_COMPONENT_TEMPLATE, {
      name: componentName,
      component: name
    }));
  }

  if (componentName !== 'Loading') listTemplate.push(`  ${componentName}`);
});

var template = render(MAIN_TEMPLATE, {
  include: includeComponentTemplate.join(endOfLine),
  install: installTemplate.join(',' + endOfLine),
  version: process.env.VERSION || require('../../package.json').version,
  list: listTemplate.join(',' + endOfLine)
});

// 结果输出到src/index.js中
fs.writeFileSync(OUTPUT_PATH, template);
console.log('[build entry] DONE:', OUTPUT_PATH);

其实就是上面说的,根据components.json,生成src/index.js文件。

build/bin/i18n.js

根据 examples/i18n/page.json 和模版,生成不同语言的 demo,也就是官网 demo 展示国际化的处理。

ElementUI官网的国际化依据的模版是examples/pages/template,根据不同的语言,分别生成不同的文件:

这里面都是.tpl文件,每个文件对应一个模版,而且每个tpl文件又都是符合SFC规范的Vue文件。

我们随便打开一个文件:

export default {
    data() {
      return {
        lang: this.$route.meta.lang,
        navsData: [
          {
            path: '/design',
            name: '<%= 1 >'
          },
          {
            path: '/nav',
            name: '<%= 2 >'
          }
        ]
      };
    }
};

里面都有数字标示了需要国际化处理的地方。

首页所有国际化相关的字段对应关系存储在examples/i18n/page.json中:

最终官网展示出来的就是经过上面国际化处理后的页面:

支持切换不同语言。

绕了一圈,回到主题:build/bin/i18n.js帮我们做了什么呢?

我们思考一个问题:首页的展示是如何做到根据不同语言,生成不同的vue文件呢?

这就是build/bin/i18n.js帮我们做的事情。

来看下对应的源码:

'use strict';

var fs = require('fs');
var path = require('path');
var langConfig = require('../../examples/i18n/page.json');

langConfig.forEach(lang => {
  try {
    fs.statSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
  } catch (e) {
    fs.mkdirSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
  }

  Object.keys(lang.pages).forEach(page => {
    var templatePath = path.resolve(__dirname, `../../examples/pages/template/${ page }.tpl`);
    var outputPath = path.resolve(__dirname, `../../examples/pages/${ lang.lang }/${ page }.vue`);
    var content = fs.readFileSync(templatePath, 'utf8');
    var pairs = lang.pages[page];

    Object.keys(pairs).forEach(key => {
      content = content.replace(new RegExp(`<%=\\s*${ key }\\s*>`, 'g'), pairs[key]);
    });

    fs.writeFileSync(outputPath, content);
  });
});

处理流程也很简单:遍历examples/i18n/page.json,根据不同的数据结构把tpl文件的标志位,通过正则匹配出来,并替换成自己预先设定好的字段。

这样官网首页的国际化就完成了。

build/bin/version.js

根据package.json中的version,生成examples/versions.json,对应就是完整的版本列表

build:theme

处理样式相关。

"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",

同样这一条也关联了多个操作,我们拆开来看。

build/bin/gen-cssfile

这一步是根据components.json,生成package/theme-chalk/index.scss文件,把所有组件的样式都导入到index.scss

其实是做了一个自动化导入操作,后面每次新增组件,就不用手动去引入新增组件的样式了。

gulp build --gulpfile packages/theme-chalk/gulpfile.js

我们都知道ElementUI在使用时有两种引入方式:

  • 全局引入
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';

Vue.use(ElementUI);

new Vue({
  el: '#app',
  render: h => h(App)
});
  • 按需引入
import Vue from 'vue';
import { Pagination, Dropdown } from 'element-ui';

import App from './App.vue';

Vue.use(Pagination)
Vue.use(Dropdown)

new Vue({
  el: '#app',
  render: h => h(App)
});

对应两种引入方式,Element在打包时对应的也有两种方案。

具体如下:将packages/theme-chalk下的所有scss文件编译为css,当你需要全局引入时,就去引入index.scss文件;当你按需引入时,引入对应的组件scss文件即可。

这其中有一点,我们需要思考下:如何把packages/theme-chalk下的所有scss文件编译为css

在平时的开发中,我们打包、压缩之类的工作往往都会交给webpack去处理,但是,针对上面这个问题,我们如果采用gulp基于工作流去处理会更加方便。

gulp相关的处理就在packages/theme-chalk/gulpfile.js中:

'use strict';

const { series, src, dest } = require('gulp');
const sass = require('gulp-sass');  // 编译gulp工具
const autoprefixer = require('gulp-autoprefixer');  // 添加厂商前缀
const cssmin = require('gulp-cssmin');  // 压缩css

function compile() {
  return src('./src/*.scss')  // src下的所有scss文件
    .pipe(sass.sync())  // 把scss文件编译成css
    .pipe(autoprefixer({  // 基于目标浏览器版本,添加厂商前缀
      browsers: ['ie > 9', 'last 2 versions'],
      cascade: false
    }))
    .pipe(cssmin())  // 压缩css
    .pipe(dest('./lib')); // 输出到lib下
}

function copyfont() {
  return src('./src/fonts/**')  // 读取src/fonts下的所有文件
    .pipe(cssmin())
    .pipe(dest('./lib/fonts')); // 输出到lib/fonts下
}

exports.build = series(compile, copyfont);

经过处理,最终就会打包出对应的样式文件

cp-cli packages/theme-chalk/lib lib/theme-chalk

cp-cli 是一个跨平台的copy工具,和CopyWebpackPlugin类似

这里就是复制文件到lib/theme-chalk下。

上面提到过多次components.json,下面就来了解下。

components.json

这个文件其实就是记录了组件的路径,在自动化生成文件以及入口时会用到:

{
  "pagination": "./packages/pagination/index.js",
  "dialog": "./packages/dialog/index.js",
  "autocomplete": "./packages/autocomplete/index.js",
  // ...
  "avatar": "./packages/avatar/index.js",
  "drawer": "./packages/drawer/index.js",
  "popconfirm": "./packages/popconfirm/index.js"
}

packages

存放着组件库的源码和组件样式文件。

这里以Alert组件为例做下说明:

Alert 文件夹


这里main.vue对应就是组件源码,而index.js就是入口文件:

import Alert from './src/main';

/* istanbul ignore next */
Alert.install = function(Vue) {
  Vue.component(Alert.name, Alert);
};

export default Alert;

引入组件,然后为组件提供install方法,让Vue可以通过Vue.use(Alert)去使用。

关于install可以看官方文档

packages/theme-chalk

这里面存放的就是所有组件相关的样式,上面也已经做过说明了,里面有index.scss(用于全局引入时导出所有组件样式)和其他每个组件对应的scss文件(用于按需引入时导出对应的组件样式)

src

说了半天,终于绕到了src文件夹。

上面的packages文件夹是分开去处理每个组件,而src的作用就是把所有的组件做一个统一处理,同时包含自定义指令、项目整体入口、组件国际化、组件 mixins、动画的封装和公共方法。

我们主要来看下入口文件,也就是src/index.js

/* Automatically generated by './build/bin/build-entry.js' */
// 导入了packages下的所有组件
import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
import Autocomplete from '../packages/autocomplete/index.js';
// ...

const components = [
  Pagination,
  Dialog,
  Autocomplete,
  // ...
];

// 提供了install方法,帮我们挂载了一些组件与变量
const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);
  // 把所有的组件注册到Vue上面
  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}
// 导出版本号、install方法(插件)、以及一些功能比如国际化功能
export default {
  version: '2.13.2',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  Pagination,
  Dialog,
  Autocomplete,
  // ...
};

文件开头的:

/* Automatically generated by './build/bin/build-entry.js' */

其实在上面的scriptsbuild/bin/build-entry.js中我们已经提到过:src/index.js是由build-entry脚本自动生成的。

这个文件主要做下以下事情:

  • 导入了 packages 下的所有组件
  • 对外暴露了install方法,把所有的组件注册到Vue上面,并在Vue原型上挂载了一些全局变量和方法
  • 最终将install方法、变量、方法导出

examples

存放了 ElementUI的组件示例。

其实从目录结构,我们不难看出这是一个完整独立的Vue项目。主要用于官方文档的展示:

这里我们主要关注下docs文件夹:

Element官网支持 4 种语言,docs一共有 4 个文件夹,每个文件夹里面的内容基本是一样的。

我们可以看到里面全部都是md文档,而每一个md文档,分别对应着官网组件的展示页面。

其实现在各大主流组件库文档都是用采用md编写。

我们上面大致了解了源码的几个主要文件目录,但是都比较分散。下面我们从构建指令到新建组件、打包流程、发布组件完整的看一下构建流程。

构建流程梳理

构建指令(Makefile)

平时我们都习惯将项目常用的脚本放在package.json中的scripts中。但ElementUI还使用了Makefile文件(由于文件内容较多,这里就选取了几个做下说明):

.PHONY: dist test
default: help

# build all theme
build-theme:
	npm run build:theme

install:
	npm install

install-cn:
	npm install --registry=http://registry.npm.taobao.org

dev:
	npm run dev

play:
	npm run dev:play

new:
	node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))

dist: install
	npm run dist

deploy:
	@npm run deploy

pub:
	npm run pub

test:
	npm run test:watch

// Tip:
// make new <component-name> [中文]
// 1、将新建组件添加到components.json
// 2、添加到index.scss
// 3、添加到element-ui.d.ts
// 4、创建package
// 5、添加到nav.config.json

我是第一次见,所以就去Google下,网上对Makefile对定义大概是这样:

Makefile 是一个适用于 C/C++ 的工具,较早作为工程化工具出现在 UNIX 系统中, 通过 make 命令来执行一系列的编译和连接操作。在拥有 make 环境的目录下, 如果存在一个 Makefile 文件。 那么输入 make 命令将会执行 Makefile 文件中的某个目标命令。

这里我以make install为例简要说明下执行流程:

  • 执行 make 命令, 在该目录下找到 Makefile 文件。
  • 找到 Makefile 文件中对应命令行参数的 install 目标。这里的目标就是 npm install

构建入口文件

我们看下scripts中的dev指令:

"dev":
"npm run bootstrap &&
npm run build:file &&
cross-env NODE_ENV=development
webpack-dev-server --config build/webpack.demo.js &
node build/bin/template.js",

首先npm run bootstrap是用来安装依赖的。

npm run build:file在前面也有提到,主要用来自动化生成一些文件。主要是node build/bin/build-entry.js,用于生成Element的入口js:先是读取根目录的components.json,这个json文件维护着Element所有的组件路径映射关系,键为组件名,值为组件源码的入口文件;然后遍历键值,将所有组件进行import,对外暴露install方法,把所有import的组件通过Vue.component(name, component)方式注册为全局组件,并且把一些弹窗类的组件挂载到Vue的原型链上(这个在上面介绍scripts相关脚本时有详细说明)。

在生成了入口文件的src/index.js之后就会运行webpack-dev-server

webpack-dev-server --config build/webpack.demo.js

这个前面也提过,用于跑Element官网的基础配置。

新建组件

上面我们提到了,Element中还用了makefile为我们编写了一些额外的脚本。

这里重点说一下 make new <component-name> [中文] 这个命令。

当运行这个命令的时候,其实运行的是 node build/bin/new.js

build/bin/new.js比较简单,备注也很清晰,它帮我们做了下面几件事:

1、新建的组件添加到components.json

2、在packages/theme-chalk/src下新建对应到组件scss文件,并添加到packages/theme-chalk/src/index.scss

3、添加到 element-ui.d.ts,也就是对应的类型声明文件

4、创建package(我们上面有提到组件相关的源码都在package目录下存放)

5、添加到nav.config.json(也就是官网组件左侧的菜单)

打包流程分析

ElementUI打包执行的脚本是:

"dist":
  "npm run clean &&
   npm run build:file &&
   npm run lint &&
   webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js &&
   npm run build:utils &&
   npm run build:umd &&
   npm run build:theme",

下面我们一一来进行分析:

npm run clean(清理文件)

"clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage",

删除之前打包生成文件。

npm run build:file(生成入口文件)

根据components.json生成入口文件src/index.js,以及i18n相关文件。这个在上面已经做过分析,这里就不再展开进行说明。

npm run lint(代码检查)

"lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet",

项目eslint检测,这也是现在项目必备的。

文件打包相关

webpack --config build/webpack.conf.js &&
webpack --config build/webpack.common.js &&
webpack --config build/webpack.component.js
build/webpack.conf.js

生成umd格式的js文件(index.js)

build/webpack.common.js

生成commonjs格式的js文件(element-ui.common.js),require时默认加载的是这个文件。

build/webpack.component.js

components.json为入口,将每一个组件打包生成一个文件,用于按需加载。

npm run build:utils(转译工具方法)

"build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",

src目录下的除了index.js入口文件外的其他文件通过babel转译,然后移动到lib文件夹下。

npm run build:umd(语言包)

"build:umd": "node build/bin/build-locale.js",

生成umd模块的语言包。

npm run build:theme(生成样式文件)

"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",

根据components.json,生成package/theme-chalk/index.scss。用gulp构建工具,编译scss、压缩、输出csslib目录。

最后用一张图来描述上述整个打包流程:

发布流程

打包完成,紧跟着就是代码的发布了。Element中发布主要是用shell脚本实现的。

Element发布一共涉及三个部分:

1、git 发布

2、npm 发布

3、官网发布

发布对应的脚本是:

"pub":
  "npm run bootstrap &&
   sh build/git-release.sh &&
   sh build/release.sh &&
   node build/bin/gen-indices.js &&
   sh build/deploy-faas.sh",

sh build/git-release.sh(代码冲突检测)

运行 git-release.sh 进行git冲突的检测,这里主要是检测dev分支是否冲突,因为Element是在dev分支进行开发的。

#!/usr/bin/env sh
# 切换至dev分支
git checkout dev
# 检测本地和暂存区是否还有未提交的文件
if test -n "$(git status --porcelain)"; then
  echo 'Unclean working tree. Commit or stash changes first.' >&2;
  exit 128;
fi
# 检测本地分支是否有误
if ! git fetch --quiet 2>/dev/null; then
  echo 'There was a problem fetching your branch. Run `git fetch` to see more...' >&2;
  exit 128;
fi
# 检测本地分支是否落后远程分支
if test "0" != "$(git rev-list --count --left-only @'{u}'...HEAD)"; then
  echo 'Remote history differ. Please pull changes.' >&2;
  exit 128;
fi
# 通过以上检查,表示代码无冲突
echo 'No conflicts.' >&2;

发布 npm && 官网更新

dev分支代码检测没有冲突,接下来就会执行release.sh脚本,合并dev分支到master、更新版本号、推送代码到远程仓库并发布到npm(npm publish)。

官网更新大致就是:将静态资源生成到examples/element-ui目录下,然后放到gh-pages分支,这样就能通过github pages的方式访问。

到这里ElementUI的完整构建流程就分析完了。

ui 组件库搭建指北

通过对ElementUI源码文件和构建流程的分析,下面我们可以总结一下搭建一个完备的 ui 组件库都需要做什么工作。

目录结构

目录结构对于大型项目是尤其重要的,合理清晰的结构对于后期的开发和扩展都是很有意义的。ui组件库的目录结构,我感觉ElementUI的就很不错:

|-- Element
    |-- .babelrc                           // babel相关配置
    |-- .eslintignore
    |-- .eslintrc                          // eslint相关配置
    |-- .gitattributes
    |-- .gitignore
    |-- .travis.yml                        // ci配置
    |-- CHANGELOG.en-US.md
    |-- CHANGELOG.es.md
    |-- CHANGELOG.fr-FR.md
    |-- CHANGELOG.zh-CN.md                 // 版本改动说明
    |-- FAQ.md                             // 常见问题QA
    |-- LICENSE                            // 版权协议相关
    |-- Makefile                           // 脚本集合(工程化编译)
    |-- README.md                          // 项目说明文档
    |-- components.json                    // 组件配置文件
    |-- element_logo.svg
    |-- package.json
    |-- yarn.lock
    |-- .github                            // 贡献者、issue、PR模版
    |   |-- CONTRIBUTING.en-US.md
    |   |-- CONTRIBUTING.es.md
    |   |-- CONTRIBUTING.fr-FR.md
    |   |-- CONTRIBUTING.zh-CN.md
    |   |-- ISSUE_TEMPLATE.md
    |   |-- PULL_REQUEST_TEMPLATE.md
    |   |-- stale.yml
    |-- build                              // 打包
    |-- examples                           // 示例代码
    |-- packages                           // 组件源码
    |-- src                                // 入口文件以及各种辅助文件
    |-- test                               // 单元测试文件
    |-- types                              // 类型声明

组件开发

参考大多数 UI 组件库的做法,可以将 examples 下的示例代码组织起来并暴露一个入口,使用 webpack 配置一个 dev-server,后续对组件的调试、运行都在此 dev-server 下进行。

单元测试

UI 组件作为高度抽象的基础公共组件,编写单元测试是很有必要的。合格的单元测试也是一个成熟的开源项目必备的。

打包

对于打包后的文件,统一放在 lib 目录下,同时记得要在 .gitignore 中加上 lib 目录,避免将打包结果提交到代码库中。

同时针对引入方式的不同,要提供全局引入(UMD)和按需加载两种形式的包。

文档

组件库的文档一般都是对外可访问的,因此需要部署到服务器上,同时也需具备本地预览的功能。

发布

组件库的某个版本完成开发工作后,需要将包发布到 npm 上。发布流程:

  • 执行测试用例
  • 打包构建
  • 更新版本号
  • npm 包发布
  • 打 tag
  • 自动化部署

维护

发布后需要日常维护之前老版本,一般需要注意一下几点:

  • issue(bug 修复)
  • pull request(代码 pr)
  • CHANGELOG.md(版本改动记录)
  • CONTRIBUTING.md(项目贡献者及规范)

参考

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.